ReplacingMergeTree: ClickHouse에서 중복을 고통 없이 제거하는 방법
1. ReplacingMergeTree가 필요한 이유 — 실제 중복 문제
온라인 카지노를 개발 중이라고 상상해 보세요. 플레이어가 "베팅하기" 버튼을 클릭합니다 — 블랙에 1000루블. 그 순간 요청을 처리 중이던 서버가 갑자기 다운됩니다(과열, 네트워크 장애, 이유는 모름). 클라이언트는 응답을 받지 못하고 "베팅이 실패했구나"라고 생각합니다. 플레이어가 다시 클릭합니다. 서버가 복구되어 두 요청을 모두 처리합니다. 데이터베이스에는 두 개의 동일한 베팅이 저장됩니다. 플레이어는 분노합니다: 1000루블 대신 2000루블이 차감되었습니다.
이것은 고전적인 멱등성(idempotency) 문제입니다(라틴어 idem — 같음, potens — 가능). 연산을 반복해도 한 번 수행한 것과 같은 결과가 나오면 멱등적입니다. 데이터베이스 세계에서는 "이미 본 베팅이다, 두 번째 버전은 무시하겠다"라고 판단하는 메커니즘이 필요합니다.
ClickHouse에는 이를 위한 ReplacingMergeTree가 있습니다. 데이터 파트의 병합(merge) 중에 자동으로 중복을 제거하는 테이블 엔진입니다. 하지만 미리 말씀드리자면, 마법이 아닙니다 — 우리가 논의할 특이점이 있습니다.
실생활 비유: ReplacingMergeTree는 회의록을 관리하는 비서와 같습니다. 사람들이 요청을 가지고 옵니다. 때로는 같은 고객이 두 개의 동일한 신청서를 가져옵니다(예: 기차를 놓쳐 환불을 요청하고, 다시 전화해서 같은 요청을 함). 비서는 문 앞에서 중복을 버리지 않고 모든 서류를 폴더에 넣습니다. 하루에 한 번 폴더를 정리하며 각 고객의 가장 최근 신청서만 남깁니다. 정리 전에 "Ivanov의 신청서가 몇 개인가요?"라고 묻는다면 두 개가 보입니다. 정리 후에는 하나만 보입니다.
2. ReplacingMergeTree 작동 방식 — 하나씩 살펴보기
중복은 신뢰할 수 없는 전달에서 발생
ClickHouse는 원래 대규모 분석을 위해 설계되었으며, 가끔 누락이나 중복은 중요하지 않았습니다. 하지만 사람들이 중요한 데이터에 사용하기 시작하면서 문제가 생겼습니다. ReplacingMergeTree는 그 고통에 대한 해답입니다.
왜 중복이 발생할까요?
- 클라이언트가 데이터를 보냈지만 확인 응답을 받지 못하고(타임아웃) 다시 보냄.
- 큐 시스템(Kafka, RabbitMQ)이
at-least-once를 보장 — 최소 한 번 전달, 반복 가능. - ETL 프로세스(Extract, Transform, Load)의 버그 — 파이프라인이 두 번 실행됨.
메커니즘: ORDER BY 키로 병합
ReplacingMergeTree로 테이블을 생성할 때 정렬 키(sorting key) — ORDER BY (column1, column2)를 지정해야 합니다. 이것은 고전적인 의미의 기본 키(PostgreSQL처럼)가 아니라 디스크에 데이터를 물리적으로 정렬하는 방법입니다. ClickHouse는 데이터를 파트(part) — 이 키로 정렬된 청크 — 에 저장합니다.
두 파트가 하나로 병합될 때(백그라운드 프로세스인 병합), ReplacingMergeTree는 동일한 ORDER BY 키 값을 가진 행을 스캔하고 하나만 유지합니다. 어느 것을? 기본적으로는 삽입 시간 기준으로 마지막 것. 하지만 숫자 version 열을 지정할 수 있으며, 그러면 버전 값이 가장 큰 행이 남습니다.
Git 비유: 병합 중 ReplacingMergeTree는 충돌을 해결할 때 Git처럼 동작합니다: 같은 파일에 대한 두 변경 중 최신 것을 유지합니다(명시적으로 전략을 지정하지 않은 경우). 여기서 파일은 테이블의 행이고 키는 ORDER BY입니다.
버전 관리: ReplacingMergeTree(version)가 규칙을 바꾸는 방법
구문: ReplacingMergeTree(version_column). version_column이 정수(UInt* 또는 DateTime)이면 가장 큰 값을 가진 행이 남습니다. 이렇게 하면 수동 제어가 가능합니다: 어떤 버전이 "승리"할지 명시적으로 지정할 수 있습니다.
예: updated_at = now()로 베팅을 보냅니다. 재전송 시 updated_at이 약간 더 큽니다. 병합 시 더 최신 것을 유지합니다. version을 지정하지 않으면 ClickHouse는 도착한 마지막 것을 선택합니다 — 비즈니스 로직상 가장 최근이 아닐 수 있으며, 단순히 마지막 물리적 삽입일 뿐입니다. 차이가 중요합니다.
3. ReplacingMergeTree로 CREATE TABLE — 자세히 살펴보기
-- 중복 제거를 위한 베팅 테이블 생성
CREATE TABLE bets_dedup
(
user_id UInt64, -- 플레이어 ID (베팅 소유자)
bet_id String, -- 고유 베팅 ID (클라이언트에서 생성)
amount Decimal(10,2), -- 루블 단위 금액
created_at DateTime, -- 베팅 생성 시간
updated_at DateTime -- 마지막 업데이트 시간 (버전용)
)
ENGINE = ReplacingMergeTree(updated_at) -- updated_at을 버전으로 사용하는 엔진
ORDER BY (user_id, bet_id) -- 중복 제거 키: (user_id, bet_id)
라인별 설명:
ENGINE = ReplacingMergeTree(updated_at)— 이것이 ReplacingMergeTree이며updated_at열이 버전으로 사용됨을 지정합니다. 병합 시 동일한ORDER BY를 가진 두 행 중updated_at이 더 큰(최신) 행이 남습니다.updated_at이 같으면 마지막으로 물리적으로 삽입된 행이 남습니다(하지만 이에 의존하지 않는 것이 좋습니다).ORDER BY (user_id, bet_id)— 가장 중요한 매개변수! 이 열 집합이 중복으로 간주되는 기준을 정의합니다. 두 행은ORDER BY의 모든 열 값이 같을 때 중복으로 간주됩니다. 여기서는 user_id의 bet_id가 고유합니다.user_id=123, bet_id='abc-456'인 두 행이 도착하면 하나로 병합됩니다.
왜 PRIMARY KEY가 아니라 ORDER BY인가? ClickHouse에서 PRIMARY KEY는 고유할 필요가 없습니다. 인덱스에 대한 힌트이며, ORDER BY는 디스크의 물리적 순서입니다. ReplacingMergeTree는 PRIMARY KEY가 더 짧더라도 ORDER BY에 의존합니다. PRIMARY KEY를 지정하지 않으면 ORDER BY와 일치합니다.
ORDER BY가 너무 넓으면 어떻게 되나요? 예를 들어 amount를 포함합니다. 그러면 금액이 다른 두 베팅(user_id, bet_id가 같더라도)은 중복으로 간주되지 않아 둘 다 남습니다. 중복 제거가 작동하지 않습니다. 함정 #1 (마지막에 다시 다루겠습니다).
4. 병합 전 SELECT가 중복을 반환할 수 있는 이유 — 그리고 대처 방법
주요 특이점: ReplacingMergeTree는 데이터 파트의 병합 중에만 중복을 제거합니다. 이는 즉시 발생하지 않는 백그라운드 프로세스입니다. 중복 삽입과 물리적 제거 사이에는 몇 초에서 몇 시간까지 걸릴 수 있습니다(설정과 부하에 따라 다름).
실제로 무엇을 의미할까요?
두 개의 중복을 삽입해 보겠습니다:
-- 첫 번째 삽입
INSERT INTO bets_dedup VALUES (123, 'bet-001', 1000, now(), now());
-- 5초 후 — 두 번째 (서버가 확인을 받지 못하고 다시 보냄)
INSERT INTO bets_dedup VALUES (123, 'bet-001', 1000, now(), now() + interval 5 second);
이제 일반 SELECT * FROM bets_dedup WHERE user_id = 123을 실행합니다. 무엇이 보일까요? 두 개의 행. 병합이 아직 발생하지 않았기 때문입니다. 데이터는 다른 파트에 있습니다. 각 파트는 내부적으로 ORDER BY로 정렬되어 있지만 중복은 다른 파트에 있을 수 있습니다.
하나의 행을 보장하려면? FINAL을 사용합니다:
SELECT * FROM bets_dedup FINAL WHERE user_id = 123;
FINAL은 ClickHouse가 이 쿼리에 대해 즉시 모든 파트를 병합하여 ReplacingMergeTree 로직을 적용하도록 강제합니다. 하나의 행을 얻습니다 — 가장 큰 updated_at을 가진 행(또는 버전이 없으면 삽입 시간 기준 마지막 행).
왜 FINAL이 느린가? ClickHouse는 테이블의 모든 파트를 읽고, 메모리에서 ORDER BY 키로 정렬하고, 중복을 제거한 후에야 결과를 반환합니다. 대규모 테이블(수십억 행)에서는 몇 초에서 몇 분이 걸릴 수 있습니다. 옵티마이저가 인덱스를 효율적으로 사용할 수 없습니다 — 많은 데이터를 스캔해야 합니다.
조언: 대규모 테이블에서 실시간으로 FINAL을 사용하지 마세요. 다음과 같은 경우에 사용하세요:
- 단일
user_id에 대한 포인트 쿼리(인덱스가 여전히 도움이 됨). - 시간이 중요하지 않은 백그라운드 작업(야간 보고서).
- 소규모 테이블(최대 수백만 행).
프로덕션 부하의 경우 FINAL 없이 구체화된 뷰를 사용하는 더 나은 패턴이 있습니다.
5. FINAL의 성능 — 언제 허용되고 언제 아닌가
FINAL이 괜찮은 경우:
- 테이블이 작은 경우(서버당 최대 1천만~2천만 행).
- 인덱스로 단일 사용자를 쿼리하는 경우(WHERE user_id = 특정 값).
- 한 시간에 한 번 백그라운드 집계를 수행하고 10초 대기가 괜찮은 경우.
- 하루에 한 번 보고서용 데이터를 내보내는 경우.
FINAL이 치명적인 경우:
- 테이블이 1억 행 이상인 경우.
- 필터링 없는 쿼리(SELECT * FROM table FINAL) — ClickHouse가 모든 것을 읽습니다.
- 고부하 OLTP 유사 시나리오(초당 수십 개의 FINAL 쿼리).
- 동일한 키에 대한 빈번한 업데이트 — 많은 파트가 누적되고 FINAL이 모두 읽습니다.
비유: SELECT ... FINAL은 특별한 "현재 버전 로그"를 보는 대신 아카이브의 모든 서류를 수동으로 정리하여 문서의 최신 버전을 찾는 것과 같습니다. 작동하지만 모든 클라이언트 요청에 적합하지는 않습니다.
쿼리가 FINAL을 사용하는지 확인하는 방법?
ClickHouse에는 EXPLAIN 명령이 있습니다:
EXPLAIN SELECT * FROM bets_dedup FINAL WHERE user_id = 123;
final 플래그가 있는 ReadFromMergeTree를 찾으세요. 보이면 쿼리가 정직하게 파트를 통과하는 것입니다.
6. 패턴: 구체화된 뷰를 통한 FINAL 없는 백그라운드 집계
이것은 FINAL을 우회하는 제가 가장 좋아하는 방법입니다. 아이디어: ReplacingMergeTree가 제 역할을 하게 두고 중복이 백그라운드에서 점차 사라지게 합니다. 읽기를 위해 주기적으로 재구축되는 구체화된 뷰(materialized view) 를 생성하여 이미 중복이 없는 "깨끗한" 데이터를 포함합니다.
어떻게 보이는가:
-- 1. 기본 테이블 — 더티, 중복 있음
CREATE TABLE bets_raw
(
user_id UInt64,
bet_id String,
amount Decimal(10,2),
created_at DateTime,
updated_at DateTime
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY (user_id, bet_id);
-- 2. 대상 테이블 — 깨끗함, 중복 없음
CREATE TABLE bets_clean
(
user_id UInt64,
bet_id String,
amount Decimal(10,2),
created_at DateTime,
updated_at DateTime
)
ENGINE = MergeTree() -- 중복 제거 없는 일반 MergeTree
ORDER BY (user_id, bet_id);
-- 3. 구체화된 뷰 — 삽입 시 데이터 전송
CREATE MATERIALIZED VIEW bets_mv TO bets_clean AS
SELECT
user_id,
argMax(amount, updated_at) AS amount, -- updated_at이 가장 큰 행의 amount 가져오기
argMax(created_at, updated_at) AS created_at,
max(updated_at) AS updated_at
FROM bets_raw
GROUP BY user_id, bet_id; -- 중복 제거 키로 그룹화
주요 포인트 설명:
argMax(amount, updated_at)— 가장 큰updated_at을 가진 행에서amount값을 반환하는 집계 함수입니다.updated_at이 다른 중복(그리고amount가 다른 경우, 예: 베팅 금액이 변경됨)이 있으면 가장 최신 금액이 유지됩니다. 이는 수동 버전 관리와 유사합니다.GROUP BY user_id, bet_id— 여기서 명시적으로 "사용자+베팅 ID 조합을 중복으로 간주"라고 말합니다. 이제 병합을 기다릴 필요가 없습니다.bets_raw에INSERT할 때마다bets_mv를 통해bets_clean에서 즉시(거의) 재계산이 트리거됩니다.중요한 제한 사항: ClickHouse의 구체화된 뷰는 데이터를 배치(batch) 로 처리합니다 — 각 삽입을 개별적으로 처리합니다. 단일 삽입에 두 개의 중복
(user_id, bet_id)이 포함되면 배치 내에서 축소됩니다. 중복이 다른 삽입으로 들어오면bets_raw가 병합될 때까지bets_clean에 임시 중복이 있을 수 있습니다. 완벽한 정리를 위해서는bets_raw를 읽을 때FINAL을 사용하거나 주기적으로OPTIMIZE TABLE bets_raw(강제 병합)를 실행해야 합니다.
비유: 모든 수정 사항을 넣는 초안(bets_raw)과 5분마다 오류 없이 깨끗한 사본(bets_clean)을 다시 타이핑하는 비서가 있는 것과 같습니다. 독자는 깨끗한 사본만 봅니다 — 빠르고 중복이 없습니다.
7. 단조 증가 버전을 사용하는 ReplacingMergeTree(version) — 업데이트 의미론
일반 ReplacingMergeTree는 단순히 "마지막으로 도착한" 행을 유지합니다. 이는 네트워크 지연 등으로 인해 새 데이터보다 오래된 데이터가 늦게 도착할 수 있는 경우 좋지 않습니다. 해결책: 단조 증가하는 version 열(예: 타임스탬프 또는 시퀀스 ID)을 사용합니다.
예: 입금 내역이 있는 플레이어 잔액 테이블
CREATE TABLE player_balance
(
user_id UInt64,
transaction_id String, -- 고유 트랜잭션 ID (UUID)
amount Int64, -- 잔액 변경 (음수 가능)
balance_after Int64, -- 트랜잭션 후 잔액
event_time DateTime, -- 클라이언트의 이벤트 시간
ingestion_time DateTime -- ClickHouse에 삽입된 시간 (버전)
)
ENGINE = ReplacingMergeTree(ingestion_time) -- 버전 = 삽입 시간
ORDER BY (user_id, transaction_id);
이제 트랜잭션 tx-001이 두 번 도착하더라도 ingestion_time이 다르면 나중에 삽입된 것(더 큰 ingestion_time)이 유지됩니다. 이는 "지연 중복" — 첫 번째 삽입이 12:00에, 두 번째가 12:05에(반복) 발생했지만 네트워크 결함으로 인해 두 번째가 첫 번째보다 먼저 서버에 도착한 경우 — 으로부터 보호합니다. 버전이 없으면 삽입 시간 기준으로 더 이른 것이 유지될 수 있으며, 이는 잘못된 것일 수 있습니다.
"단조 증가"란 무엇인가? 새 삽입마다 ingestion_time 값이 이전 값보다 크거나 같아야 합니다. now()(ClickHouse 서버의 현재 시간) 또는 원자적 카운터(예: ZooKeeper에서)를 사용하세요. 클라이언트 시간에 의존하지 마세요 — 시계가 튈 수 있습니다.
8. 전체 예제: transaction_id로 잔액 충전 중복 제거
모든 것을 종합해 보겠습니다. 결제 시스템에서 잔액 충전을 수락하는 마이크로서비스가 있습니다. 결제 시스템은 웹훅(HTTP 호출)을 보냅니다 — 때로는 중복됩니다.
-- 1단계: 원시 이벤트 테이블 생성
CREATE TABLE balance_events
(
user_id UInt64,
transaction_id String, -- 결제 시스템의 고유 ID
amount Int64, -- +1000 루블
event_time DateTime, -- 사용자로부터 돈이 차감된 시간
inserted_at DateTime DEFAULT now() -- 삽입 시 자동 설정
)
ENGINE = ReplacingMergeTree(inserted_at)
ORDER BY (user_id, transaction_id); -- (사용자, 트랜잭션) 쌍으로 중복 제거
-- 2단계: 데이터 삽입 (중복이 도착한다고 가정)
INSERT INTO balance_events (user_id, transaction_id, amount, event_time)
VALUES (1, 'pay_001', 1000, '2025-06-01 10:00:00');
-- 1분 후 중복 도착 (inserted_at은 자동으로 now() + 60초로 설정됨)
INSERT INTO balance_events (user_id, transaction_id, amount, event_time)
VALUES (1, 'pay_001', 1000, '2025-06-01 10:00:00');
-- 3단계: FINAL 없이 읽기 — 2개의 행이 보임 (아직 병합되지 않은 경우)
SELECT * FROM balance_events WHERE user_id = 1;
-- 결과: user_id, transaction_id, amount가 같은 두 행
-- 4단계: FINAL로 읽기 — 하나의 행이 보임 (가장 큰 inserted_at을 가진)
SELECT * FROM balance_events FINAL WHERE user_id = 1;
-- 결과: 하나의 행
왜 ORDER BY에 transaction_id만으로 충분하지 않은가? 두 명의 다른 사용자가 동일한 transaction_id를 가질 수 있기 때문입니다(예: 각 결제 시스템이 자체 카운터를 가짐). user_id를 추가하면 사용자 내에서 고유성이 보장됩니다. 시스템이 전역 UUID(550e8400-e29b-41d4-a716-446655440000)를 생성하는 경우 ORDER BY transaction_id만 사용해도 됩니다. 하나의 UUID로 충분합니다.
9. CollapsingMergeTree와의 비교
CollapsingMergeTree는 변경 사항을 처리하는 또 다른 엔진입니다. "플러스"와 "마이너스" 쌍을 저장하고 병합 중에 축소합니다.
주요 차이점:
| 특성 | ReplacingMergeTree | CollapsingMergeTree |
|---|---|---|
| 메커니즘 | 중복에서 하나의 행 유지 | 쌍(+1 및 -1) 축소 |
| 목적 | 삽입 중복 제거 | 집계 업데이트(예: 장바구니) |
| 버전 필요 | 선택 사항(버전 열) | 필수 Sign 플래그(+1/-1) |
| 기록 저장 가능 | 예, 병합 전까지 모든 버전 | 아니요, 쌍이 파괴됨 |
| 읽기용 FINAL | 예, 없으면 중복이 보임 | 예, 없으면 축소되지 않은 쌍이 보임 |
ReplacingMergeTree를 선택해야 하는 경우:
- 중복 행을 제거하기만 하면 됩니다.
- 중복 제거를 위한 자연 키(트랜잭션 ID)가 있습니다.
- 데이터가 거의 변경되지 않습니다(주로 삽입).
CollapsingMergeTree를 선택해야 하는 경우:
- 집계된 메트릭(예: "장바구니의 항목 수")을 자주 업데이트합니다.
- 변경 내역이 아닌 결과만 저장하면 됩니다.
CollapsingMergeTree 예:
CREATE TABLE cart_items
(
user_id UInt64,
product_id UInt64,
quantity Int16,
sign Int8 -- +1 (추가), -1 (제거)
) ENGINE = CollapsingMergeTree(sign)
ORDER BY (user_id, product_id);
ReplacingMergeTree를 사용하면 새 quantity 버전으로 행을 덮어쓰기만 하면 됩니다 — 하지만 변경 내역이 손실됩니다. CollapsingMergeTree를 사용하면 FINAL 없이도 총계(SUM(quantity * sign))를 계산할 수 있습니다.
10. 일반적인 함정 — 그리고 피하는 방법
함정 #1: ORDER BY가 모든 고유 필드를 포함하지 않음
-- 나쁨: user_id만 사용
CREATE TABLE bets_bad ENGINE = ReplacingMergeTree ORDER BY user_id;
-- 같은 사용자에 대해 다른 bet_id로 두 개의 베팅 삽입
INSERT INTO bets_bad VALUES (1, 'bet_001', 100);
INSERT INTO bets_bad VALUES (1, 'bet_002', 200);
-- 병합 중에 하나의 행으로 병합됨 — ORDER BY (user_id)가 같기 때문!
-- bet_002가 손실됨.
올바른 방법: ORDER BY에 행을 고유하게 만드는 모든 열을 포함합니다 — 일반적으로 대리 ID(transaction_id) 또는 조합(user_id, bet_id).
함정 #2: 즉각적인 중복 제거에 대한 순진한 기대
초보자는 중복으로 INSERT를 하고 즉시 FINAL 없이 SELECT를 수행하여 중복을 봅니다. ClickHouse에 실망합니다. 기억하세요: 중복 제거는 비동기적입니다. 즉각적인 일관성이 필요하면 FINAL 또는 구체화된 뷰 패턴을 사용하세요.
함정 #3: 단조 증가하지 않는 버전 사용
-- 나쁨: 버전이 클라이언트 시간
CREATE TABLE events ENGINE = ReplacingMergeTree(client_time) ORDER BY (id);
-- 클라이언트 시계가 느려서 새 버전 후에 이전 버전을 보냄
-- 병합 중에 잘못된(이전) 행이 남음
해결책: ClickHouse 측에서 now()를 사용하거나 하드웨어 카운터를 사용하세요.
함정 #4: 대규모 데이터에 대한 FINAL의 낙관론
한 개발자가 20억 행 테이블의 모든 보고서에서 FINAL을 활성화한 사례가 있었습니다. 쿼리가 300초 후에 타임아웃되기 시작했습니다. GROUP BY 및 argMax를 사용한 집계로 다시 작성해야 했습니다.
황금률: FINAL을 통해 테이블의 10% 이상을 읽고 있다면 뭔가 잘못하고 있는 것입니다. 구체화된 뷰를 사용하거나 아키텍처를 재고하세요.
함정 #5: ORDER BY 없는 ReplacingMergeTree
ClickHouse는 ORDER BY 없이 테이블을 생성하는 것을 허용하지 않습니다. 하지만 ORDER BY tuple()(빈 튜플)을 지정할 수 있습니다. 그러면 테이블의 모든 행이 중복으로 간주되어 첫 번째 병합 후 하나의 행만 남습니다. 거의 필요하지 않습니다.
다음 단계 — 관련 기사 링크
이제 ReplacingMergeTree를 마스터했으니 다음 주제를 탐색해 보세요:
병합 최적화 방법 —
merge_with_ttl_timeout,number_of_free_entries_in_pool_to_lower_max_size_of_merge와 같은 설정(무섭게 들리지만 유용함).INSERT 수준의 중복 제거 — ZooKeeper를 사용한
ReplicatedReplacingMergeTree엔진. 이것은 또 다른 수준입니다: 중복이 삽입 시 즉시 차단되지만 지연과 복잡성이라는 대가가 따릅니다.대안:
VersionedCollapsingMergeTree— 버전 관리와 축소를 동시에 지원하는 하이브리드.구체화된 뷰 상세 —
FINAL을 완전히 피하기 위해 다단계 집계를 구축하는 방법.
마지막으로: ReplacingMergeTree는 강력한 도구이지만 "중복을 즉시 삭제"하는 것이 아닙니다. "데이터는 결국 깨끗해질 것이며, 그동안 작업한다"는 것입니다. 엄격한 고유성(PostgreSQL의 PRIMARY KEY처럼)이 필요하다면 ClickHouse는 최선의 선택이 아닙니다. 하지만 반복 삽입이 있는 분석 작업의 99%에서는 생명의 은인입니다.
← 이전 글: ClickHouse 설정: 운영 환경을 구성하면서 실수하지 않는 방법
→ 다음 글: SummingMergeTree와 AggregatingMergeTree: 고통 없는 증분 집계
— Editorial Team
아직 댓글이 없습니다.