2026년 7월 18일 · 46분 읽기 · OPENSEARCHKAFKASYSTEM DESIGN

중고 거래 서비스에서 키워드 알림을 어떻게 설계할까?

당근 같은 중고 거래 앱에는 키워드 알림이라는 기능이 있다. 에어팟프로 같은 키워드를 저장해두면 같은 조건의 매물이 올라왔을 때 알림이 온다. 그런데 어느 날 문득 궁금해졌다. 이 기능을 어떻게 만들었을까?

재미 삼아 설계 연습을 해봤다. 처음엔 어떤 검색 기술을 쓸지 고르면 끝일 줄 알았는데 따져볼수록 결정할 것이 줄줄이 딸려 나왔다. 이 글은 그 선택지들을 설계 순서대로 검토하며 나라면 어느 쪽을 고를지 근거와 함께 적어본 기록이다. 이 기능을 실무에서 만들어본 적은 없으니 어디까지나 연습이다. 다만 각 결정의 근거만큼은 최대한 구체적으로 남기려 했다.

미리 요약하면 이 문제는 네 개의 축으로 쪼개진다.

  1. 언제 - 매칭을 상품 등록 시점에 할 것인가 조회 시점에 할 것인가.
  2. 무엇으로 - DB 조회, 검색엔진, 인메모리 인덱스 중 무엇으로 매칭할 것인가.
  3. 얼마나 정확히 - 키워드와 지역이 결합된 매칭 품질을 어떻게 지킬 것인가.
  4. 얼마나 많이 - 인기 키워드가 만드는 fan-out과 알림 피로를 어떻게 다룰 것인가.

1. 문제를 어떻게 규정할 것인가

설계의 첫 단계는 기술 선택이 아니라 문제의 모양을 정확히 그리는 일이다.

일반적인 검색은 문서(상품)를 쌓아두고 쿼리로 찾는다. 키워드 알림은 반대다. 쿼리(구독)를 쌓아두고 새 문서가 올 때마다 매칭되는 쿼리를 찾는다. 말하자면 검색의 역전이다.

여기서 잠깐 검색이 원래 어떻게 빠른지를 짚고 가자. DB에서 흔히 쓰는 B-Tree 인덱스는 값을 정렬해둔 구조다. 사전처럼 가나다순으로 정렬되어 있어서 이 값과 정확히 같은 행을 빠르게 찾는다. 그런데 제목에 에어팟이 포함된 상품 찾기 같은 질문에는 무력하다. 정렬 기준이 제목 전체라서 제목 안의 단어까지는 찾아 들어가지 못하기 때문이다. 그래서 검색엔진은 역인덱스1라는 다른 구조를 쓴다. 책 맨 뒤의 찾아보기 페이지처럼 문서를 단어 단위로 쪼갠 뒤 단어마다 그 단어가 등장하는 문서 목록을 붙여둔다. 에어팟이라는 단어에서 출발해 문서를 찾아가니 문서가 수백만 건이어도 빠르다. B-Tree가 값에서 행을 찾는 인덱스라면 역인덱스는 단어에서 문서를 찾는 인덱스다. 애초에 답하도록 설계된 질문이 다르다.

키워드 알림은 이 역인덱스를 그대로 쓰되 인덱싱 대상을 문서에서 쿼리로 뒤집는 문제다. 발행-구독(Pub-Sub) 구조에 매칭 엔진이 결합된 문제라고 볼 수도 있다.

정방향 검색은 들어온 검색어 하나를 쌓아둔 상품 제목들과 대조하고 역방향 검색은 들어온 매물 하나를 쌓아둔 키워드들과 대조한다. 쌓아두는 쪽과 들어오는 쪽이 서로 뒤집혀 있다.

숫자를 넣어보면 이 역전이 왜 중요한지 드러난다. 당근의 월간 활성 사용자는 2300만 명이 넘고 2021년 한 해 중고거래 연결만 1억 5000만 건이었다. 키워드 알림 채택률은 공개된 적이 없지만 10명 중 1명이 3개씩 저장한다고 어림잡으면 구독은 700만 건이다. 매물은 연간 거래를 하루로 나누면 40만 건 수준이라 평균 초당 수 건이고 피크로 잡아도 초당 수십 건이다. 구독 수백만 대 초당 수십 건. 워크로드가 심하게 비대칭이다. 상품이 등록될 때마다 구독 테이블 전체를 순회하며 제목에 키워드가 포함되는지 비교하는 가장 순진한 구현은 매물 하나당 700만 번의 문자열 비교를 뜻한다. 피크 초당 40건이면 3억 번 가까이 된다. 계산 한 번으로 탈락이다.

정리하면 이 문제의 핵심 성능 질문은 하나로 좁혀진다. 상품 1건이 들어올 때 수백만 구독 중 매칭되는 것을 얼마나 빨리 찾는가. 등록과 구독 사이의 이 비대칭이 이후 모든 선택의 근거가 된다.

2. 첫 번째 선택지: 매칭을 언제 할 것인가

가장 큰 아키텍처 결정부터 시작한다. 매칭 시점은 둘 중 하나다.

등록 시점 매칭(fan-out on write)2은 상품이 등록되는 순간 매칭하고 알림을 만들어 퍼뜨린다. 실시간성이 좋다. 대신 인기 키워드 하나가 순간적으로 대량의 알림 작업을 일으킨다.

조회 시점 매칭(fan-out on read)은 주기적인 배치나 사용자가 앱을 여는 시점에 신규 상품과 저장된 키워드를 대조한다. 단순하고 저렴하지만 그만큼 지연이 생긴다.

결정의 근거는 도메인에 있다. 중고 거래는 선착순 채팅이 거래 성패를 가르는 도메인이다. 좋은 매물은 올라온 지 몇 분 안에 채팅이 붙는다. 알림이 한 시간 늦으면 기능의 존재 이유가 사라진다. 알림 지연이 곧 기능 가치의 훼손인 셈이다. 실제로 키워드 알림이 오자마자 들어가봤더니 20초쯤 전에 올라온 매물이었던 적이 있다. 적어도 내가 쓰는 서비스는 등록 시점 매칭이거나 그에 준하는 짧은 주기로 돌고 있다고 추측할 수 있는 대목이다. 그래서 나라면 등록 시점 매칭을 고르겠다. 조회 시점 매칭은 가격 하락 알림처럼 지연에 관대한 기능에서 다시 꺼낼 수 있는 카드로 남겨둔다.

3. 선택지를 고르기 전에: 역방향 매칭을 손으로 만들어보기

매칭을 등록 시점에 하기로 했으니 다음은 무엇으로 매칭하느냐다. 그런데 엔진 비교표부터 꺼내는 대신 가장 단순한 도구인 RDB로 직접 만들어보려 한다. 손으로 만들어보면 역방향 매칭에 무엇이 필요한지가 드러나고 그래야 엔진들이 무엇을 대신해주는지 판단할 수 있다.

구독 한 건을 행 하나로 저장하고 상품 제목에서 뽑은 후보 키워드 목록으로 구독 테이블을 조회한다.

CREATE TABLE keyword_subscriptions (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id     BIGINT NOT NULL,
    raw_keyword VARCHAR(100) NOT NULL,  -- 입력 원문. 재정규화 마이그레이션용
    keyword     VARCHAR(100) NOT NULL,  -- 정규화 결과. 단일 값 하나
    KEY idx_keyword (keyword)
);

-- 제목 '에어팟 프로 2세대 팝니다'에서 뽑은 후보 키워드로 조회
SELECT user_id, keyword
FROM keyword_subscriptions
WHERE keyword IN ('에어팟', '프로', '2세대', '팝니다', '에어팟프로', '프로2세대');

keyword 컬럼에는 정규화된 문자열 하나만 담는다. 여러 토큰을 한 컬럼에 이어 붙이면 안 된다. B-Tree는 컬럼 값 전체를 하나의 키로 정렬하니 이어 붙인 문자열 안의 원소는 인덱스로 찾을 수 없고 1장에서 탈락시킨 부분 문자열 문제로 되돌아간다. 제목 쪽 토큰들도 어디에 저장되는 게 아니라 조회 시점의 IN 목록으로만 잠깐 존재한다.

여기서 뭐가 진짜 키워드인지는 문서 쪽이 판단하지 않는다. 제목에서 나온 토큰을 전부 던지면 구독 인덱스가 알아서 판정한다. 팝니다처럼 아무도 구독하지 않을 법한 토큰은 조회에서 빈손으로 돌아올 뿐이고 그 미스 비용은 인덱스 조회 한 번이라 사실상 공짜다.

아무도 구독하지 않을 법한 토큰이라고 썼지만 구독하지 말라는 법도 없다. 궁금해서 실제 서비스에 팝니다를 키워드로 등록해봤다.

팝니다를 구독하면 벌어지는 일

동네 매물 알림이 쉴 새 없이 오기 시작했다. 거의 모든 제목에 들어가는 토큰이니 사실상 동네 전체 매물을 구독한 셈이다. 뭐가 키워드인지는 시스템이 아니라 구독자가 정의한다는 걸 직접 몸으로 확인했다. 뒤에서 다룰 인기 키워드 문제의 극단적인 미리보기이기도 하다.

구현으로 돌아오자. 여기까지는 쉽다. keyword 컬럼에 B-Tree 인덱스만 있으면 되고 별도 인프라도 없다. 그런데 B-Tree 인덱스는 완전일치 조회를 해줄 뿐 텍스트를 쪼개는 능력이 없다. 제목을 토큰 목록으로 바꾸는 일은 통째로 애플리케이션 책임이다. 단순함은 저장·조회 계층 얘기일 뿐이고 텍스트 처리의 복잡도는 사라지는 게 아니라 애플리케이션으로 이동해 있다.

그래서 이 구현의 다음 블록은 토크나이징이다. 어느 수준까지 할지 선택지가 있다.

  • 정규화 + 공백 분리. 소문자화와 특수문자 제거 후 공백으로 쪼갠다. 가장 단순하지만 한국어에서 금방 깨진다. 프로콘을 급처합니다를 공백으로 쪼개면 프로콘을이 나오는데 저장된 키워드는 프로콘이라 매칭이 어긋난다.
  • 형태소 분석기 내장. mecab-ko, Komoran, kiwi 같은 분석기를 매칭 워커에 라이브러리로 탑재한다. 조사 분리와 복합어 처리가 되고 별도 서버도 필요 없다. 대신 사전 관리가 애플리케이션 몫이 된다.
  • n그램 분해. 제목을 2~3글자 단위로 기계적으로 쪼개 매칭한다. 사전 없이도 띄어쓰기 변형과 부분 문자열 매칭까지 커버된다. 대신 토큰 수가 늘고 프로콘서트프로콘에 걸리는 오탐이 생긴다. MySQL의 n-gram 풀텍스트 파서와 PostgreSQL의 pg_trgm이 이 계열이다.

토크나이징을 별도 서비스로 뗄 수도 있지만 검색·알림·추천처럼 여러 시스템이 같은 토크나이징 결과를 공유해야 할 때만 의미가 있다.

어떤 수준을 골라도 지켜야 할 원칙은 결국 하나다. 대칭성이다. 매칭되는 두 쪽인 키워드와 제목은 반드시 같은 토크나이저와 같은 정규화를 거쳐야 한다. 그런데 이 대칭성은 두 방향에서 깨질 수 있다.

먼저 구현이 갈라지며 깨진다. 키워드를 등록하는 경로와 제목을 처리하는 경로가 다른 토크나이저를 쓰거나 한쪽만 형태소 분석을 하면 매칭이 어긋난다. 두 경로가 같은 구현 방식이어야 한다.

다음은 시간이 흐르며 깨지는 경우다. 저장된 키워드는 사용자가 입력한 원문이 아니라 그 시점의 토크나이저를 통과한 가공 결과물이다. 제목 쪽 가공은 매물마다 실시간으로 실행되지만 키워드 쪽 가공은 등록 때 한 번 실행되어 결과가 박제된다. 그래서 토크나이저나 사전을 바꾸면 제목 쪽만 새 규칙을 타면서 대칭성이 소리 없이 깨진다. 사전에 없던 버즈프로[버즈, 프로]로 쪼개져 저장된 뒤 사전에 버즈프로를 한 단어로 추가하면 그날부터 이 키워드의 알림이 조용히 끊기는 식이다. 따라서 원문 키워드를 따로 보관해두고 규칙이 바뀔 때마다 구독 테이블 전체를 새 기준으로 다시 정규화하는 마이그레이션을 돌린다.

정리하면 토크나이징 계층은 저장·조회와 별개로 설계해야 하는 독립된 블록이고 한국어 서비스라면 형태소 분석기나 n그램 중 하나는 사실상 필요하다.

토크나이징만 해결되면 이 구조는 1장의 프레임과 정확히 만난다. 구독 테이블 자체가 손으로 만든 역인덱스다. keyword 컬럼이 단어 사전이고 같은 키워드를 저장한 행들이 그 단어의 구독자 목록이다. B-Tree는 텍스트를 쪼개지 못할 뿐이지 쪼개진 토큰에서 구독자를 찾아가는 역인덱스 역할은 충분히 해낸다. 참고로 tsvector 기반의 DB 풀텍스트 검색은 문서를 인덱싱하는 정방향 전용이라 여기에 쓸 수 없다. 저장된 쿼리들을 인덱싱해두고 새 문서로 찾는 기능은 RDB에 없어서 역방향은 이렇게 직접 만들게 된다.

여러 단어로 된 키워드는 한 단계가 더 필요하다. 아이폰 프로처럼 토큰이 두 개인 키워드는 IN 한 번으로 판정되지 않는다. 대신 키워드를 토큰 단위로 풀어둔 자식 테이블 (keyword_id, token, position)을 둔다. 여기서도 토큰마다 행 하나다. 이 테이블에서 토큰이 하나라도 겹치는 키워드를 후보로 뽑고 후보마다 모든 토큰이 제목에 있는지를 애플리케이션에서 검증한다. 구문 매칭이 필요하면 position으로 순서까지 본다. PostgreSQL이라면 자식 테이블 대신 토큰 배열 컬럼에 GIN 인덱스를 걸어도 된다. GIN이 배열 원소 단위로 인덱스를 만들어줘서 물리적 표현만 다를 뿐 본질은 같은 역인덱스다. 요컨대 방금 우리는 검색엔진의 역방향 검색 기능을 SQL과 애플리케이션 코드로 밑바닥부터 재구현했다. 역방향 매칭이라면 무엇을 쓰든 토크나이징, 대칭성 유지, 후보 선별과 검증의 2단계를 누군가는 해내야 한다. 이제 그 누군가를 고를 차례다.

4. 두 번째 선택지: 매칭 엔진을 무엇으로 돌릴 것인가

후보는 세 가지다. 방금 만든 구조를 그대로 쓰거나 검색엔진에 맡기거나 프로세스 메모리에 올리는 것이다.

첫 번째는 방금 만든 DB 역방향 조회를 그대로 쓰는 것이다. 별도 인프라가 없고 단일 토큰 키워드 위주의 초기 규모에는 충분하다. 대신 3장에서 본 토크나이징 계층(분석기 선택, 사전 관리, 재정규화 마이그레이션)을 애플리케이션이 계속 짊어진다.

두 번째는 검색엔진의 역방향 검색이다. OpenSearch의 Percolator3는 쿼리를 문서처럼 인덱싱해두고 새 문서를 던지면 매칭되는 쿼리 목록을 돌려준다. 사실상 이 문제를 위해 존재하는 기능이다. 사용자가 키워드를 저장하면 내부적으로는 검색 조건 하나가 인덱싱된다.

POST /keyword_subscriptions/_doc
{
  "user_id": "u_1029",
  "query": { "match_phrase": { "title": "에어팟프로" } }
}

새 매물이 등록되면 매물 문서 하나를 percolate 요청으로 던져서 매칭되는 구독 목록을 한 번에 받아온다. 3장에서 손으로 만들던 것들을 여기서는 검색엔진이 통째로 짊어진다. 분석기, 사전 관리, 재인덱싱이 전부 엔진 쪽 일이 되고 키워드 저장 시점과 매물 매칭 시점에 같은 분석기가 적용되니 대칭성도 저절로 지켜진다. 후보 선별과 검증의 2단계도 엔진 내부에 구현되어 있다. 복합 필터(지역·가격·카테고리)를 검색엔진 수준으로 처리할 수 있다는 것도 강점이다. 대신 클러스터 운영 부담을 떠안는다.

세 번째는 인메모리 역인덱스다. 미리 짚어두면 Kafka 같은 스트림에서 상품 이벤트를 소비하는 매칭 워커 자체는 어느 선택지를 골라도 등장한다(7장에서 다룬다). 앞의 두 선택지에서 워커는 매칭을 밖에 요청한다. DB에 SQL을 던지거나 검색 클러스터에 percolate를 던지는 원격 호출이다. 세 번째 선택지는 그 원격 호출을 없앤다. 토큰에서 구독자 목록으로 가는 역인덱스를 워커 자신의 메모리에 올려두고 이벤트를 받은 그 자리에서 매칭을 끝낸다. 키워드가 순수 문자열이라면 Aho-Corasick4 같은 다중 패턴 매칭 알고리즘으로 제목을 한 번 훑는 시간에 모든 키워드를 대조할 수 있다. 구문·불리언 쿼리까지 필요하면 Percolator와 같은 원리를 프로세스 안에 내장한 Lucene Monitor(구 Luwak) 같은 라이브러리를 쓴다. 네트워크 왕복이 없으니 처리량은 최고다. 하지만 인덱스를 워커마다 복제해 올리고 구독 변경을 각 워커에 동기화하는 일까지 전부 직접 만들어야 한다. 매칭은 라이브러리로 가져와도 시스템은 직접 지어야 하는 선택지라 대규모에서만 정당화된다.

셋 중 정답이 하나라기보다는 규모에 따른 진화 경로로 보는 게 맞는 것 같다. DB 역조회에서 시작해 매칭 품질 요구가 커지면 Percolator로 넘어가고 처리량이 그마저 넘어서면 인메모리 역인덱스로 간다. 그리고 방금 봤듯이 매칭 엔진 선택은 토크나이징을 누가 책임지는가의 선택이기도 하다. DB 역조회는 애플리케이션이, Percolator는 검색엔진이, 인메모리 역인덱스는 다시 애플리케이션이 짊어진다.

다만 세 번째 단계까지 실제로 가게 되는지는 따져볼 필요가 있다. Percolator는 저장할 때 쿼리에서 단어를 추출해 함께 인덱싱해두고 매칭할 때는 문서와 단어가 겹치는 쿼리만 후보로 골라 실행한다. 그래서 문서 하나의 비용을 결정하는 건 전체 쿼리 수가 아니라 그 문서와 겹치는 후보의 수다. 전체 건수는 후보 선별용 인덱스 조회와 샤드 수에만 영향을 준다. 따라서 인기 토큰이 든 매물일수록 후보가 커지고 wildcard처럼 단어 추출이 안 되는 쿼리는 모든 문서에서 후보가 되니 피해야 한다. Elastic이 공개한 튜닝 사례에서는 쿼리 100만 건이 문서 하나당 4.2초씩 걸리다가 카테고리 메타데이터 필터를 더하자 100ms 안팎으로 42배 줄었다. 단순 키워드 쿼리 수백만 건에 초당 수십 건의 매물이라면 필터를 갖춘 구성으로 여유가 크다는 뜻이다. 이 메타데이터 필터는 6장에서 지역 조건으로 다시 만난다. 이 여유를 실제로 넘어선 쪽은 뉴스와 소셜 스트림에 복잡한 불리언 쿼리 수만수백만 건을 상시 대조하는 미디어 모니터링 업계다. Bloomberg의 뉴스 알림에 쓰인 Luwak이 그 압력에서 나왔고 개발사 Flax의 벤치마크에서는 쿼리 유형에 따라 Percolator보다 640배 빨랐다. 뒤집어 말하면 중고 거래의 매물 등록 속도로는 Percolator를 넘어설 일이 잘 없을 것 같다. 매물 수정마다 재매칭을 걸거나 제목을 넘어 본문까지 매칭 대상을 넓힐 때가 현실적인 압력 지점일 것이다. 한 가지 주의할 점은 위 수치가 Elasticsearch 기준이라는 거다. OpenSearch에는 Percolator가 눈에 띄게 느리고 후보 선별이 제대로 동작하지 않는다는 이슈가 보고되어 있다. 같은 이름의 기능이라도 포크 이후의 구현 차이가 있을 수 있으니 도입 전에 실제 워크로드로 벤치마크부터 해봐야 한다.

나라면 이 글의 가정(구독 수백만 건)에서는 Percolator를 고르겠다. DB 역조회는 토크나이징 계층을 직접 설계하고 유지하는 비용이 계속 따라붙고 인메모리 인덱스는 이 규모에서 복잡도가 정당화되지 않는다.

5. 매칭 품질: 기능의 체감을 결정하는 부분

엔진을 골랐다고 끝이 아니다. 아키텍처보다 사용자가 먼저 체감하는 것은 매칭 품질이다.

정규화부터 정해야 한다. 프로콘프로 콘procon을 같은 키워드로 볼 것인가. 소문자화, 공백·특수문자 처리, 유사어 사전 같은 정규화 단계를 어디까지 둘지가 첫 결정이다.

한국어라서 생기는 문제가 따로 있다. 무선이어폰무선 이어폰 같은 띄어쓰기 변형이 흔하고 조사가 붙은 형태도 처리해야 한다. 이걸 감당하는 게 3장에서 토크나이징 선택지로 스치듯 등장한 형태소 분석5이다. OpenSearch에서는 한국어 분석기 nori가 이 역할을 한다. 상품명 아이폰 15 프로 미개봉은 인덱싱 시점에 [아이폰, 15, 프로, 미개봉]으로 토큰화되어 저장된다.

'아이폰' 키워드는 토큰이 정확히 일치해 매칭되지만 '아이폰케이스' 키워드는 '케이스' 토큰이 상품명에 없어 매칭되지 않는다.

오탐과 미탐의 균형이 곧 정책이다. 완전일치만 고집하면 놓치는 매물이 늘어난다(미탐). 부분일치를 넓히면 쓸데없는 알림이 쏟아진다(오탐). 예를 들어 아이폰 프로라는 2단어 키워드를 토큰이 하나만 겹쳐도 매칭되는 match 쿼리로 저장하면 아이폰 케이스 (프로맥스용) 매물에도 알림이 간다. 토큰의 순서와 인접까지 요구하는 match_phrase로 저장하면 이런 오탐은 걸러지지만 그만큼 매칭 범위는 좁아진다. 어느 쪽으로 기울일지는 기술이 아니라 정책 결정이다. 나라면 match_phrase를 기본값으로 잡겠다. 검색 결과의 오탐은 사용자가 지나치면 그만이지만 알림의 오탐은 사용자를 직접 찌르기 때문이다.

키워드 단독 매칭이 아니라는 점도 여기서 정해진다. 실제 매칭 조건은 키워드에 지역·가격대·카테고리가 AND로 결합된 형태다. 이 결합 요구는 매칭 엔진 선택에 다시 영향을 준다. 그중 지역은 비중이 커서 절을 따로 뺀다.

6. 지역 조건: 키워드와 결합되는 두 번째 축

중고 거래는 직거래가 기본이라 매칭 조건이 사실상 키워드 AND 지역이다. 지역은 부가 필터가 아니라 매칭의 절반이다.

지역을 어떻게 모델링할 것인가. 당근처럼 동네 인증을 쓰는 서비스라면 행정동 코드가 자연스러운 단위다. 우리 동네에 인접 동네까지 더한 범위 확장을 지원하려면 두 가지 방법이 있다. 구독을 저장할 때 확장된 동 코드 목록을 미리 펼쳐서 저장하거나(쓰기 시 비정규화) 매칭 시점에 인접 동을 계산하는(읽기 시 계산) 방법이다. 나라면 전자를 고르겠다. 쓰기는 구독 등록 때 한 번이고 매칭은 매물마다 일어나니 자주 실행되는 쪽을 단순하게 만드는 게 이득이다. 좌표 기반 반경 검색이 필요해지면 geohash나 S2 셀6 같은 공간 인덱스로 확장할 수 있다.

쓰기 시 비정규화를 반영하면 Percolator에 저장되는 쿼리는 이렇게 생긴다. 키워드 조건은 must에, 지역 조건은 filter에 얹은 bool 쿼리다.

POST /keyword_subscriptions/_doc
{
  "user_id": "u_1029",
  "query": {
    "bool": {
      "must": [{ "match_phrase": { "title": "에어팟프로" } }],
      "filter": [{ "terms": { "region_code": ["11680", "11650", "11710"] } }]
    }
  }
}

region_code에는 인증 동네와 인접 동네의 코드 목록이 통째로 들어간다. 매물 쪽은 등록 시점의 동네 코드 하나만 문서에 담아 던지면 된다. 참고로 인접을 위경도 반경으로 계산하면 강 건너 동네처럼 직선 거리는 가깝지만 거래하러 가기엔 먼 동네가 섞인다. 행정동 경계 기반 인접 그래프를 미리 계산해두는 편이 낫지 않을까 싶다.

엔진별로 지역을 태우는 방법이 다르다. DB 역조회라면 (keyword, region_code) 복합 인덱스로 조회 조건에 지역을 함께 태운다. Percolator라면 방금 본 bool 쿼리로 자연스럽게 표현된다. 인메모리 역인덱스라면 인덱스 키를 (token, region)으로 잡거나 지역별로 워커를 샤딩할 수 있다. 지역이 오히려 자연스러운 샤딩 키가 되어 확장에 유리해진다.

지역이 fan-out을 줄여준다는 장점도 있다. 아이폰 구독자가 전국 30만 명이어도 매물이 올라온 동네 기준으로 자르면 수백 명 수준으로 줄어든다. 지역 조건은 제약이면서 동시에 성능 최적화 수단이다. Percolator에서는 이 반전을 엔진 비용까지 끌고 갈 수 있다. 지역 코드를 저장 쿼리 안에만 두지 않고 구독 문서의 일반 필드로도 저장한 뒤 percolate 요청을 지역 필터와 결합하는 방법이다. 그러면 매칭을 시도할 후보 집합 자체가 동네 단위로 잘린다. Elastic이 권장하는 첫 번째 최적화도 이 메타데이터 필터다.

동네 변경 전파는 필수 요구사항이다. 실제로 이사한 뒤 동네 인증을 옮겼더니 키워드 알림이 새 동네 매물 기준으로 바뀌어 오는 걸 경험했다. 구독의 지역 범위가 사용자 프로필의 현재 동네에서 파생된 값이라는 뜻이다. 그렇다면 사용자가 동네를 옮길 때 구독 전체가 새 동네 기준으로 따라 갱신되어야 한다. 동네 변경을 이벤트로 발행하고 구독 저장소의 비정규화된 동 코드 목록을 다시 계산해 Percolator 쿼리를 재인덱싱하는 전파 파이프라인이 필요하다. 갱신이 비동기라면 변경 직후 잠깐 이전 동네 기준으로 알림이 갈 수 있는 정합성 창이 생긴다. 이걸 허용할지 발송 직전에 현재 동네로 재검증할지도 결정해야 한다. 뒤에서 보겠지만 발송 직전 재검증은 상품 상태 때문에도 필요하다. 같은 지점에서 함께 검증하면 된다.

정책으로 남는 엣지도 있다. 택배 거래처럼 지역 무관 매물을 지역 필터에서 우회시킬지와 구독 쪽에 지역 무관 옵션을 줄지는 기술이 아니라 제품 정책의 영역이다.

7. Fan-out 폭발: 인기 키워드 문제

아이폰 구독자가 30만 명이라면 상품 1건 등록이 30만 건의 알림 작업을 만든다. 지역 필터가 줄여준다 해도 인구 밀집 지역이라면 여전히 수천 건 단위다. 이 지점 때문에 매칭과 발송을 반드시 분리해야 한다.

상품 등록 API는 이벤트만 발행하고 끝낸다. 매칭 워커가 큐 뒤에서 이벤트를 소비해 percolate를 호출하고 발송 워커가 그 결과를 받아 알림을 보낸다. 등록 API의 응답 시간을 fan-out 규모로부터 격리하는 것이 포인트다. 판매자가 등록 버튼을 누르는 순간의 경험이 구독자 수에 영향받을 이유가 없고 OpenSearch 장애가 매물 등록을 막아서도 안 된다.

상품 이벤트 1건이 매칭을 거쳐 수천 건의 알림 작업으로 퍼진다. 등록 API는 이벤트 발행에서 끝난다 (반복 재생)

8. 발송 파이프라인: 매칭 이후가 절반이다

어떤 매칭 엔진을 골랐든 발송 계층의 고민은 공통이다.

중복 제거. 제목과 본문에 키워드가 두 번 등장해도 워커가 재시도해도 알림은 사용자와 매물 조합당 1건이어야 한다. 같은 작업을 여러 번 수행해도 결과가 같아야 한다는 멱등성7이 발송 계층의 기본 요건이다. 발송 전에 (user_id, listing_id) 조합을 키로 이미 보낸 알림인지 확인하는 장치를 둔다.

알림 피로 관리. 인기 키워드는 하루에도 수십 건씩 매칭된다. 사용자별 발송 빈도 제한, 여러 건을 묶어 보내는 다이제스트, 야간 발송 억제 같은 정책이 없으면 사용자는 키워드를 삭제하거나 푸시를 꺼버린다. 기능이 스스로를 죽이는 경로다.

채널 분리. 푸시(FCM과 APNs)8와 인앱 알림함 저장은 별개 경로다. 푸시는 유실될 수 있으니 인앱 알림함을 원본(source of truth)으로 삼는다.

실패 처리. 재시도 정책과 DLQ9를 두고 발송 성공률을 측정할 수 있어야 한다.

9. 정합성과 엣지 케이스

설계가 그럴듯해 보여도 신뢰는 엣지에서 무너진다. 미리 짚어둘 것들이 있다.

구독 변경 반영 지연. 키워드를 삭제했는데 알림이 오는 경우와 동네를 옮겼는데 이전 동네 매물 알림이 오는 경우는 같은 문제의 변형이다. 구독의 생성·삭제·지역 갱신을 하나의 변경 이벤트 스트림으로 통일해 인덱스에 반영하는 구조가 깔끔하다. 6장의 동네 변경 파이프라인이 사실 이 스트림의 한 갈래다.

상품 상태. 검수 전·숨김·삭제·판매완료 상품이 매칭되면 안 된다. 매칭 시점과 발송 시점 사이에 상태가 바뀔 수도 있으니 발송 직전에 상품 상태와 사용자의 현재 동네를 함께 재검증한다.

스팸과 어뷰징. 판매자가 인기 키워드를 제목에 도배해 알림을 태우는 어뷰징이 가능하다. 매칭 전에 스팸 필터를 거치는 단계가 필요해진다. 반대로 구독 쪽을 막는 정책도 실제로 있다. 3장의 팝니다 실험을 하다가 이번엔 나눔을 등록해봤는데 이 단어는 추가할 수 없다는 안내와 함께 거절당했다.

나눔은 키워드로 등록할 수 없다

나눔 매물은 먼저 채팅을 거는 사람이 가져가는 구조라 알림으로 선점하는 경쟁이 과열되기 쉽다. 아무 단어나 구독할 수 있는 시스템 위에 특정 단어만 막는 정책 계층이 한 겹 더 얹혀 있는 셈이다. 매칭 엔진이 아니라 키워드 등록 API에서 거르면 되니 구현은 간단하지만 무엇을 막을지는 역시 제품 정책의 영역이다.

10. 운영: 무엇을 측정할 것인가

기능이 잘 동작하는지의 기준을 미리 정의해야 한다. 시스템 지표로는 상품 등록부터 알림 도달까지의 지연(p50과 p99), 매칭 처리량, 발송 성공률을 본다. 제품 지표로는 알림 클릭률과 키워드 삭제율을 본다. 특히 클릭률이 떨어지고 삭제율이 오르면 오탐이나 알림 피로 정책이 잘못됐다는 조기 신호다. 시스템은 멀쩡한데 기능이 죽어가는 상황을 잡아내는 건 제품 지표 쪽이다.

11. 여기서 더 나아간다면

임베딩 기반 시맨틱 매칭이 자연스러운 다음 단계다. 닌텐도 컨트롤러 매물이 프로콘 구독에 걸리게 하는 식이다. 사용자 행동 기반 개인화 랭킹이나 가격 하락 알림으로도 확장할 수 있다. 다만 시맨틱 확장은 오탐 관리가 훨씬 어려워서 5장의 정밀도-재현율 트레이드오프가 그대로 되돌아온다.

마치며

설계 연습으로 시작했는데 결정 하나를 내릴 때마다 다음 결정이 딸려 나왔다. 정리하면 단순해 보이는 알림 기능도 매칭을 언제 할지, 무엇으로 할지, 키워드와 지역이 결합된 매칭 품질을 어떻게 지킬지, fan-out과 알림 피로를 어떻게 다룰지의 네 축으로 쪼개면 규모에 따른 진화 경로가 보인다.

실제 서비스가 이 글과 같은 결정을 내렸는지는 알 수 없다. 규모와 조직과 기술 스택에 따라 선택지마다 다른 쪽을 골랐을 수도 있다. 다만 선택지 자체는 누구에게나 같을 것이다. 다음에 또 당연하게 쓰던 기능이 궁금해지면 이번처럼 선택지부터 그려볼 생각이다.


Footnotes

  1. inverted index를 옮긴 말로 역색인이라고도 부른다. 찾는 방향이 문서에서 단어가 아니라 단어에서 문서로 뒤집혀 있다는 뜻에서 inverted라는 이름이 붙었다.

  2. fan-out은 하나의 입력이 여러 개의 출력 작업으로 퍼져 나가는 것을 뜻한다. fan-out on write는 쓰기 시점에 미리 퍼뜨려 두는 방식이고 fan-out on read는 읽기 시점에 모아서 계산하는 방식이다. SNS 타임라인 설계에서 자주 등장하는 용어다.

  3. Percolator는 원래 커피를 내릴 때 물이 필터를 통과(percolate)하며 걸러지는 것을 가리키는 단어다. Elasticsearch가 문서가 저장된 쿼리들을 통과하며 걸러진다는 의미로 이 이름을 처음 붙였고 Elasticsearch를 포크해서 만들어진 OpenSearch도 같은 이름과 동작을 그대로 물려받았다.

  4. Aho-Corasick은 여러 개의 문자열 패턴을 하나의 트라이(trie) 구조로 미리 결합해두고 텍스트를 단 한 번만 훑어도 그 안에 포함된 모든 패턴을 한꺼번에 찾아내는 다중 문자열 매칭 알고리즘이다.

  5. 형태소(morpheme)는 의미를 가지는 가장 작은 언어 단위를 뜻하는 언어학 용어다. 형태소 분석은 문장을 이 단위로 쪼개는 작업이다.

  6. geohash와 S2는 지구 표면을 격자 셀로 쪼개 문자열이나 정수 ID로 표현하는 공간 인덱싱 방식이다. 좌표 기반 반경 질의를 셀 ID 목록 조회로 바꿔준다.

  7. 멱등성(idempotency)은 같은 연산을 여러 번 수행해도 결과가 한 번 수행했을 때와 같은 성질이다. 재시도가 있는 시스템에서 중복 부작용을 막는 기본 요건이 된다.

  8. FCM(Firebase Cloud Messaging)은 구글이 제공하는 푸시 알림 발송 서비스이고 APNs(Apple Push Notification service)는 애플이 제공하는 푸시 알림 발송 서비스다.

  9. DLQ(dead letter queue)는 계속 실패하는 메시지를 본 큐에서 빼내 따로 모아두는 큐다. 운영자가 원인을 확인한 뒤 재처리할 수 있게 한다.