데이터 중심 애플리케이션 설계 | 07장. 트랜잭션

발표자 : 박현도, 이승익

매모호한 트랜잭션의 개념

ACID

ACID는 데이터베이스 트랜잭션이 안전하게 수행된다는 것을 보장하기 위한 성질을 가리키는 약어이다.

원자성(Atomicity)

데이터베이스는 전부 반영되거나 아무것도 반영되지 않는 것을 보장하는 것으로써 원자성을 통해 부분 갱신으로 더 큰 문제가 야기되는 것을 방지할 수 있다.

ex) 항공 티켓 티켓은 반드시 지불과 예약이 동시에 되거나 아니면 모두 되지 않아야 한다. 성공적으로 지불은 되었으나 좌석 예약은 되지 않은 경우는 허용되지 않는다.
하나의 트랜잭션은 항공 티켓 예약뿐 아니라 호텔, 운송, 현재 환율로 정확히 환전되는 데에도 적용된다.
출처: wikipedia

일관성(Consistency)

트랜잭션이 실행을 성공적으로 완료하면 언제나 일관성 있는 데이터베이스 상태로 유지하는 것을 말한다.
출처: wikipedia

ex) 회계시스템에서 모든 계좌에 걸친 대변과 차변은 항상 맞아 떨어져야 한다.

일관성(C)은 실제로는 ACID에 속하지 않고 애플리케이션의 속성으로 본다.
데이터베이스 자체만으로 불변식을 위반하는 잘못된 데이터를 쓰지 못하도록 막을 수 없기 때문이다.
이러한 것은 애플리케이션의 책임으로 보고 일관성을 달성하기 위해 데이터베이스의 원자성과 격리성 속성에 기댈 수 있다.

격리성(Isolation)

동시에 실행되는 트랜잭션은 서로 격리되어 방해할 수 없다.
한 트랜잭션이 여러 번 쓴다면 다른 트랜잭션은 그 내용을 전부 볼 수 있든지 아무것도 볼수 없든지 둘 중 하나여야 하고 일부분만 볼 수 있어서는 안된다.

지속성(Durability)

데이터베이스 시스템의 목적은 데이터를 잃어버릴 염려가 없는 안전한 저장소를 제공하는 것이다.
지속성은 트랜잭션이 성공적으로 커밋됐다면 하드웨어 결함이 발생하거나 데이터베이스가 죽더라도 트랜잭션에서 기록한 모든 데이터는 손실되지 않는다는 보장이다.

단일 객체 연산과 다중 객체 연산

단일객체 쓰기

원자성과 격리성은 단일 객체를 변경하는 경우에도 적용된다.

그렇기에 저장소 엔진들은 거의 보편적으로 한 노드에 존재하는 단일 객체 수준에서 원자성과 격리성을 제공하는 것을 목표로 한다.

ex) 20KB의 JSON 문서를 데이터베이스에 쓰는 경우

첫 10KB를 보낸 후 네트워크 연결이 끊기는 경우 데이터베이스가 디스크에 기존 값을 덮어쓰는 도중에 전원이 나가는 경우 문서를 쓰고 있을 때 다른 클라이언트에서 그 문서를 읽는 경우에 부분적으로 갱신된 값을 읽는지

다중 객체 트랜잭션의 필요성

다중 객체 트랜잭션 : 데이터의 여러 조각이 동기화된 상태로 유지돼야 할 때 필요

트랜잭션이 없더라도 복잡한 데이터의 쓰기와 읽기를 수행하는 애플리케이션을 구현할 수 있다.

그러나 원자성이 없으면 오류 처리가 훨씬 더 복잡해지고 격리성이 없으면 동시성 문제가 생길 수 있다.

  • 다중 객체 트랜잭션은 참조가 유효한 상태로 유지되도록 보장해준다.(외래키 등 참조)
  • 비정규화된 데이터가 동기화가 꺠지는 것을 방지(한번에 여러 문서 갱신 시)
  • 트랜잭션 격리성이 없으면 어떤 색인에서는 레코드가 보이지만 다른 색인은 아직 갱신되지 않아서 레코드가 보이지 않을 수 있다.

완화된 격리 수준

커밋 후 읽기 (READ COMMITTED)

가장 기본적인 수준의 트랜잭션 격리로 이 수준에서는 두 가지를 보장해 준다.

  • 데이터베이스에서 읽을 때 커밋된 데이터만 보게 된다(더티 읽기가 없음)
  • 데이터베이스에 쓸 때 커밋된 데이터만 덮어쓰게 된다(더티 쓰기가 없음)

더티 읽기 방지

더티 읽기(dirty read) : 어떤 트랜잭션에서 처리한 작업이 완료되지 않았는데도 다른 트랜잭션에서 볼 수 있는 현상

그림 7-2
그림 7-2. 격릭성 위반: 트랜잭션이 다른 트랜잭션에서 썼지만 커밋되지 않는 데이터를 읽음"(더티 읽기(dirty read))"

더티 읽기를 막는게 유용한 이유

  • 더티 읽기가 생기면 다른 트랜잭션이 일부는 갱신된 값을, 일부는 갱신되지 않은 값을 볼 수 있다.
  • 트랜잭션이 어보트되면 모두 롤백되어야 하나, 더티 읽기를 허용하면 트랜잭션이 나중에 롤백될 데이터를 볼 수 있다.

더티 쓰기 방지

더티 쓰기(dirty write) : 두 트랜잭션이 동일한 객체를 동시에 갱신하려고 할 때, 먼저 쓴 내용이 아직 커밋되지 않은 트랜잭션에서 쓴 것이고 나중에 실행된 쓰기 작업이 커밋되지 않은 값을 덮는 경우

그림 7-5
그림 7-5. 다른 트랜잭션에서 충돌하는 쓰기를 실행할 때 더티 쓰기가 있으면 내용이 섞일 수 있다.

다른 트랜잭션에서 충돌하는 쓰기를 실행할 때 더티 쓰기가 있으면 내용이 섞일 수 있으며 더티 쓰기를 막음으로써 몇 가지 동시성 문제를 회피할 수 있다.

커밋 후 읽기 구현

커밋 후 읽기는 Oracle 11g, PostgreSQL, SQL Server 2012, MemSQL 등에서 기본 설정으로 쓰고 있는 격리 수준이다.

  • 더티쓰기 방지 : 트랜잭션이 커밋되거나 어보트될 때까지 잠금을 보유한다. 이런 잠금은 커밋 후 읽기 모드에서 데이터베이스에 의해 자동으로 실행된다.
  • 더티 읽기 방지: 과거의 커밋된 값/ 현재 쓰고 있는 새로운 값을 모두 기억하게 하여 해당 트랜잭션이 실행 중인 동안 과거의 값을 읽게하여 더티 읽기를 방지 할 수 있다.

그림 7-4
그림 7-4. 더티 읽기 방지: 사용자 2는 사용자 1의 트랜잭션이 커밋된 후에야 x의 새 값을 보게 된다.

더티 읽기 방지 : 사용자 2는 사용자 1의 트랜잭션이 커밋된 후에야 x의 새 값을 보게 된다.

스냅숏 격리와 반복 읽기(Snapshot Isolation)

그림 7-6
그림 7-6. 읽기 스큐: 앨리스는 일관성이 깨진 상태인 데이터베이스를 본다.

커밋 후 읽기 격리 수준에서도 동시성 버그가 생길 수 있으며 이런 현상을 비반복 읽기(nonrepeatable read)나 읽기 스큐(read skew)라고 한다.

위와 같은 경우 몇 초 후 새로고침하면 일관성 있는 계좌를 볼 수 있으나 어떤 상황에서는 이런 비일관성을 감내할 수 없는 경우도 있다.

ex) 백업 (원본과 복사본의 데이터 차이), 분석 질의와 무결성 확인(큰 부분을 스캔하는 질의시 다른 시점의 데이터베이스 일부를 보게되면 잘못된 결과 반환)

스냅숏 격리 : 각 트랜잭션은 데이터베이스의 일관된 스냅숏으로부터 읽는 구현

즉 트랜잭션은 시작할 때 데이터베이스에 커밋된 상태였던 모든 데이터를 본다. 데이터가 나중에 다른 트랜잭션에 의해 바뀌더라도 각 트랜잭션은 특정한 시점의 과거 데이터를 볼 뿐이다.

스냅숏 격리 구현

다중 버전 동시성 제어(multi-version concurrency control, MVCC) : 데이터베이스가 객체의 여러 버전을 함께 유지하는 기법

Q) 커밋 후 읽기도 더티 읽기를 방지하기 위해 버전을 두고 사용하는데 무슨 차이가 있는지?

A) 커밋 후 읽기는 질의마다 독립된 스냅숏을 사용하고 스냅숏 격리는 전체 트랜잭션에 대해 동일한 스냅숏을 사용하는 차이 즉 백업된 레코드의 여러 버전 가운데 몇 번째 이전 버전까지 찾아 들어가느냐에 따라 다르다.

일관된 스냅숏을 보는 가시성 규칙

트랜잭션은 데이터베이스에서 객체를 읽을 때 트랜잭션 ID를 사용해 어떤 것을 볼 수 있고 어떤 것을 볼 수 없는지 결정한다.

Real MySQL 8.0
출처: Real MySQL 8.0

동작 방식

  • 트랜잭션 ID가 더 큰(즉 현재 트랜잭션이 시작한 후에 시작한) 트랜잭션이 쓴 데이터는 그 트랜잭션의 커밋 여부에 관계 없이 모두 무시된다.

색인과 스냅숏 격리

다중 버전 데이터베이스에서 색인의 동작
하나의 선택지는 색인이 객체의 모든 버전을 가리키게 하고 색인 질의가 현재 트랜잭션에서 볼 수 없는 버전을 걸러내고, 가비지 컬렉션이 어떤 트랜잭션에게도 더 이상 보이지 않는 오래된 객체 버전을 삭제 할때 대응되는 색인 항목도 삭제

PostgreSQL

  • 동일한 객체의 다른 버전들이 같은 페이지(page)에 저장될 수 있다면 색인 갱신을 회피하는 최적화 실행

카우치DB, 데이토믹, LMDB

  1. 쓸 때 복사되는 (append-only/copy-on-write) 변종을 사용
    트리의 페이지가 갱신될 때 덮어쓰는 대신 각 변경된 페이지의 새로운 복사본을 생성한다.
    트리의 루트에 이르기까지 존재하는 부모 페이지들은 복사되고 그것들의 자식 페이지들의 새 버전을 가리키도록 갱신된다.
    쓰기에 영향을 받지 않는 페이지들은 복사될 필요가 없고 변함 없는 상태로 남는다.
  2. 추가 전용 B트리 사용
    쓰기를 실행하는 모든 트랜잭션은 새로운 B 트리 루트를 생성하며 특정 루트는 그것이 생성된 시점에 해당하는 데이터베이스의 일관된 스냅숏이 된다.
    나중에 실행되는 쓰기는 새로운 트리 루트만 생성할 수 있고 존재하는 B 트리를 변경할 수 없으므로 트랜잭션 ID를 기반으로 객체를 걸러낼 필요가 없다.
    그러나 이 방법도 컴팩션(compaction)과 가비지 컬랙션을 실행하는 백그라운드 프로세스가 필요하다.

반복 읽기와 혼란스러운 이름

스냅숏 격리는 읽기 전용 트랜잭션에서 유용하며, SQL 표준에 스냅숏 격리의 개념이 없기 때문에 여러 데이터베이스에서 다른 이름으로 불린다.

  • Oracle: 직렬성(Serializable)
  • PostgreSQL, MySQL: 반복 읽기(Repeatable Read)

갱신 손실 방지

만약 두 트랜잭션이 작업을 동시에 하면 두번째 쓰기 작업이 첫 번째 변경을 포함하지 않으므로 변경 중 하나는 손실될 수 있음

해결책

  • 원자적 쓰기 연산
  • 명시적인 잠금
  • 갱신 손실 자동 감지
  • Compare-and-set
  • 충돌 해소와 복제

원자적 쓰기 연산

  • 쓰기 연산에 원사성 (Atomicity) 성질을 부여함으로서 동시성 안전 획득
  • exclusive lock 을 획득하여 구현 → 갱신이 적용될 때까지 다른 트랜잭션에서 그 객체를 읽지 못함
  • or 모든 원자적 연산을 단일 스레드에서 실행되도록 강제하는 방법

명시적인 잠금

  • 애플리케이션에서 갱신할 객체를 명시적으로 잠그는 것
  • 다른 트랜잭션이 동시에 같은 객체를 읽으려고 하면 첫 번째 read-modify-write 주기가 완료될 때까지 기다리도록 강제됨

리스팅개발팀 > 07. 트랜잭션 > 스크린샷 2022-04-01 오후 1.30.42.png

예제 7-1. 로우를 명시적으로 잠금으로써 갱신 손실 막기

BEGIN TRANSACTION;

SELECT * FROM figures
  WHERE name = 'robot' AND game_id = 222
  FOR UPDATE; (1)

-- 이동이 유효한지 확인한 후
-- 이전의 SELECT에서 반환된 것의 위치를 갱신한다.
UPDATE figures SET position = '4' WHERE id = 1234;

COMMIT;

갱신 손실 자동 감지

  • 여러 트랜잭션의 병렬 실행을 허용하고 트랜잭션 관리자가 갱신 손실을 발견하면 트랜잭션을 abort 시키고, 재시도하도록 강제하는 방법

Compare-and-set

  • 값을 마지막으로 읽은 후로 변경되지 않았을 때만 갱신을 허용함으로써 갱신 손실을 회피하는 것
-- 데이터베이스 구현에 따라 안전할 수도 안전하지 않을 수도 있다
UPDATE wiki_pages SET content = 'new content'
  WHERE id = 1234 AND content = 'old content';

충돌 해소와 복제

  • 잠금과 compare-and-set 연산은 데이터의 최신 복사본이 하나만 있다고 가정함
  • 다중 리더 또는 리더 없는 복제를 사용하는 데이터베이스는 일반적으로 여러 쓰기가 동시에 실행되고 비동기식으로 복제되는 것을 허용함
  • 따라서 데이터의 최신 복사본이 하나만 있으리라고 보장할 수 없음
  • 복제가 적용된 데이터베이스에서 흔히 쓰는 방법은 쓰기가 동시에 실행될 때 한 값에 대해 여러 개의 충돌된 버전을 생성하는 것을 허용하고, 추후에 충돌을 해소하고 이 버전들을 병합함

쓰기 스큐와 팬텀

  • 거의 동시에 두 트랜잭션이 시작되었다고 가정

그림 7-8
그림 7-8. 애플리케이션 버그를 유발하는 쓰기 스큐의 예

  • 데이터베이스에서 스냅숏 격리를 사용하므로 둘 다 2를 반환해서 두 트랜잭션 모두 다음 단계로 진행함
  • 최소 한 명의 의사가 호출 대기해야 한다는 요구사항 위반
  • 이러한 현상을 쓰기 스큐 (wirte skew) 라고 함

쓰기 스큐를 특정 짓기

  • 쓰기 스큐는 두 트랜잭션이 같은 객체들을 읽어서 그 중 일부를 갱신할 때 나타날 수 있음

쓰기 스큐를 유발하는 팬텀

쓰기 스큐를 유발하는 팬텀8

  • 어떤 트랜잭션에서 실행한 쓰기가 다른 트랜잭션의 검색 질의 결과를 바꾸는 것을 팬텀(Phantom) 이라고 함

충돌 구체화

  • 충돌 구체화? → 최초의 select 시 잠글 수 있는 객체가 없기 때문이었음 → 인위적으로 데이터베이스에 잠금 객체를 추가하자
  • 대상 row 를 미리 만들고 lock 을 건다 → 트랜잭션 대상이 되는 특정 범위의 모든 조합에 대해 미리 row 를 만들어 둠 (ex, 회의실 예약의 경우 다음 6개월 동안에 해당되는 양)
  • 예약을 하는 트랜잭션은 테이블에서 원하는 대상 row 를 잠글 수 있음 (위에서 미리 생성했기 때문에)
  • 여기서 생성된 row 는 단지 동시에 변경되는 것을 막기 위한 잠금의 모음일 뿐임 (실제 사용되는 데이터가 아님)
  • 단점? → 동시성 제어 메커니즘이 애플리케이션 데이터모델로 새어 나오는 것은 보기 좋지 않음, 다른 대안이 불가능할 때 최후의 수단으로 고려

직렬성

DB의 동시성을 관리하는 방식의 문제점

  • 격리 수준은 이해하기 어렵고 데이터베이스마다 그 구현에 일관성이 없음
  • 애플리케이션 코드를 보고 특정한 격리 수준에서 해당 코드를 실행하는게 안전한지 알기 어려움, 특히 동시에 일어나는 모든 일을 알지 못할 수도 있는 거대한 애플리케이션이라면 더욱.
  • 동시성 문제는 보통 비결정적(간헐적) 이라서 테스트하기 어려움. 운이 나쁠 때만 문제가 발생하게 됨
  • 대안은 직렬성 격리 사용
  • 직렬성 격리는 보통 가장 강력한 경리 수준이라고 여겨짐
  • 여러 트랜잭션이 병렬로 실행되더라도, 최종 결과는 동시성 없이 한 번에 하나씩 직렬로 실행될 때와 같도록 보장

직렬성을 제공하는 3가지 기법

  • 말 그대로 트랜잭션을 순차적으로 실행하기
  • 2단계 잠금
  • 직렬성 스냅숏 격리 같은 낙관적 동시성 제어 기법

실제적인 직렬 실행

  • 동시성 문제를 피하는 가장 간단한 방법은 동시성을 완전히 제거하는 것
  • 한 번에 트랜잭션 하나씩만 직렬로 단일 스레드에서 실행하면 됨
  • 단점? → 성능

트랜잭션을 스토어드 프로시저 안에 캡슐화 하기

  • 데이터베이스 초창기에는 트랜잭션이 사용자의 활동 전체 흐름을 포함할 수 있게 하려는 의도가 있었음
  • 항공권 예약의 여러 과정 (경로 선택, 요금, 가용 좌석 탐색, 여행 일정표 정하기, …) 을 하나의 트랜잭션으로 표현하고 원자적으로 커밋하는 것임
  • 이 방법을 구현하기 위해 데이터베이스 트랜잭션이 사용자의 입력을 기다려야 한다면, 매우 느릴 것으로 예상됨
  • 대신에 트랜잭션 코드 전체를 스토어드 프로시저 형태로 데이터베이스에 미리 제출함
  • 트랜잭션에 필요한 데이터는 모두 메모리에 있고, 스토어드 프로시저는 네트워크나 디스크 I/O 없이 매우 빨리 실행된다고 가정함

그림 7-9
그림 7-9. 상호작용식 트랜잭션과 스토어드 프로시저의 차이점(그림 7-8의 예제 트랜잭션을 사용함)

파티셔닝
각 트랜잭션이 단일 파티션 내에서만 데이터를 읽고 쓰도록 파티셔닝 할 수 있다면, 각 파티션은 다른 파티션과 독립적으로 실행되는 자신만의 트랜잭션 처리 스레드를 가질 수 있음 이 경우 각 CPU 코어에 각자의 파티션을 할당해서 트랜잭션 처리량을 CPU 코어 개수에 맞춰 선형적으로 확장할 수 있음 그러나 여러 파티션에 접근해야 하는 트랜잭션이 있다면, 코디네이션 오버헤드가 있으므로 단일 파티션 트랜잭션보다 엄청 느림

직렬 실행 요약
트랜잭션 직렬 실행은 몇 가지 제약 사항 안에서 직렬성 격리를 획득하는 시용적인 방법이 됐음

  • 모든 트랜잭션은 작고 빨라야 한다. 느린 트랜잭션 하나가 전체 처리를 지연시킬 수 있기 때문.
  • 활성화된 데이터셋이 메모리에 적재될 수 있는 경우로 사용이 제한됨, 단일 스레드 트랜잭션에서 디스크에 접근한다면 시스템이 매우 느려짐
  • 쓰기 처리량이 단일 CPU 코어에서 처리할 수 있을 정도로 충분히 낮아야 함
  • 여러 파티션에 걸친 트랜잭션도 쓸 수 있지만, 이것을 사용할 수 있는 정도에는 엄격한 제한이 있음

2단계 잠금(2PL)

  • 트랜잭션 A가 객체 하나를 읽고 트랜잭션 B가 그 객체에 쓰기를 원한다면 B는 진행하기 전에 A가 커밋되거나 어보트될 때까지 기다려야 한다(이렇게 하면 B가 A 몰래 갑자기 객체를 변경하지 못하도록 보장된다).

  • 트랜잭션 A가 객체에 썼고 트랜잭션 B가 그 객체를 읽기 원한다면 B는 진행하기 전에 A가 커밋되거나 어보트될 때까지 기다려야 한다(그림 7-4에 나왔듯이 2PL을 쓸 때는 객체의 과거 버전을 읽는 게 허용되지 않는다).

  • vs 스냅숏 격리(읽는 쪽은 ㅅ결코 쓰는 쪽을 막지 않으며, 쓰는 쪽도 결코 읽는 쪽을 막지 않음)

2단계 잠금 구현

  • MySQL, SQL Server 에서 직렬성 격리 수준을 구현하는데 사용됨
  • 잠금은 공유 모드 (shared mode) 나 독점 모드 (exclusive mode) 로 사용될 수 있음
  • 잠금이 아주 많이 사용되므로 교착 상태(두 개의 트랜잭션이 서로 기다리는 것)가 매우 쉽게 발생할 수 있음

2단계 잠금의 성능

  • 가장 큰 약점이 성능
  • 잠금을 획득하고 해제하는 오버헤드 때문에 느린 것
  • 더 중요한 원인은 동시성이 줄어들기 때문 (동시성과 성능은 반비례)

서술 잠금

조건에 부합하는 모든 객체에 잠금을 획득하는 것

SELECT * FROM bookings
   WHERE room_id = 123 AND
      end_time > '2018-01-01 12:00' AND
      start_time < '2018-01-01 13:00'
  • 서술 잠금은 오래 걸림 (조건에 부합하는 잠금을 확인하는 데 시간이 오래 걸림)
  • 이 때문에 2PL 을 지원하는 대부분의 데이터베이스는 실제로는 색인 범위 잠금, 다음 키 잠금을 구현하여 사용함

색인 범위 잠금

  • 예를 들어, 정오와 오후 1시 사이에 123번 방을 예약하는 것에 대한 서술 잠금을 → 모든 시간 범위에 123번 방을 예약하는 것으로 근사시켜 잠금 실행
  • 위의 그림 예시에서 room_id 또는 시간 값에 색인이 걸려있을 것이기에 해당 색인 범위에 lock 을 거는 것임
  • 색인 범위 잠금은 서술 잠금 보다 정밀하지 않지만(직렬성을 유지하기 위해 반드시 필요한 것보다 더 큰 범위를 잠글 수도 있음) 오버헤드가 낮기 때문에 좋은 타협안이 됨
  • 범위 잠금을 잡을 수 있는 적합한 색인이 없다면 테이블 전체에 공유 잠금을 잡는 것으로 대체하기도 함

직렬성 스냅숏 격리 (Serializable Snapshot Isolation, SSI)

  • 직렬성 격리와 좋은 성능은 공존할 수 있을까?
  • 현재 최고로 유망한 것이 직렬성 스냅숏 격리임
  • 스냅숏 격리에 비해 약간의 성능 손해만 있을 뿐임

비관적 동시성 제어 vs 낙관적 동시성 제어

  • 2단계 잠금은 비관적 동시성 제어 메커니즘임
    • 뭔가 잘못될 가능성이 있으면 뭔가를 하기 전에 상황이 다시 안전해질 때 까지 기다리는게 낫다는 원칙
  • 직렬성 스냅숏 격리는 낙관적 동시성 제어 메커니즘임
    • 뭔가 위험한 상황이 발생할 가능성이 있을 때 트랜잭션을 막는 대신 모든 것이 괜찮아질 거라는 희망을 갖고 계속 진행한다는 뜻
    • 트랜잭션이 커밋되기를 원할 때 데이터베이스는 나쁜 상황이 발생했는지 확인함
    • 발생했다면 abort 되고 재시도함
    • 경쟁이 심하면 abort 비율이 높아지므로 성능 떨어짐
    • 예비 용량이 충분하고 트랜잭션 사이의 경쟁이 너무 심하지 않으면, 낙관적 동시성 제어 기법이 성능이 좋은 경향이 있음
    • SSI = 스냅숏 격리 + 직렬성 충돌 감지 및 abort 시킬 트랜잭션 결정하는 알고리즘

오래된 읽기 감지하기
그림 7-10
그림 7-10. 트랜잭션이 MVCC 스냅숏에서 뒤처지 값을 읽었는지 감지하기

과거의 읽기에 영향을 미치는 쓰기 감지하기
그림 7-11
그림 7-11. 직렬성 스냅숏 격리에서 트랜잭션이 다른 읽은 데이터를 변경하는 경우를 감지하기




최종 수정 : 2022-04-01