아카이빙 서비스
한국어 검색 품질·권한 정합성·메타데이터 동기화를 한 묶음의 설계로 잡은 사내 통합 아카이빙 서비스
기술 스택
개요
사내 곳곳에 흩어져 있던 프로젝트 자료·파일·고객 정보를 한 곳에서 관리하고 검색할 수 있도록 만든 사내 통합 아카이빙 서비스의 백엔드입니다. Django + DRF가 도메인을, PostgreSQL이 메타데이터를, Elasticsearch가 검색 인덱스를, Naver Object Storage가 원본 파일을 담당하며, 트리 구조의 파일함과 프로젝트·세일즈 단위 워크스페이스 위에 통합 검색이 얹혀 있습니다.
검색이 단순 키워드 매칭으로 끝나지 않는 환경 — 한국어 형태소 분해와 부분 매칭이 같은 인덱스에서 동시에 통해야 하고, 사용자·그룹별 권한이 검색 결과에 일관되게 강제되어야 하며, 상위 객체(프로젝트·세일즈) 메타데이터가 바뀌면 하위 문서들의 인덱스도 따라가야 하는 — 인 점이 설계의 출발점이었습니다.
기술 스택
- Backend: Python, Django, Django REST Framework, Celery, Elasticsearch (Nori), PostgreSQL, Redis, NGINX
- Infra: Docker, Naver Cloud Platform (NCP), Naver Object Storage
역할
PM 1·FE 1·BE 3 팀에서 백엔드 3인 중 한 명. 파일함·검색·CRM·인증/권한 영역을 직접 담당했고, 인덱스 매핑과 analyzer 설계, ResourceShare 기반 권한 모델, 신호 기반 인덱싱 파이프라인, 검색 쿼리 튜닝까지 검색·권한 영역의 설계 결정을 책임졌습니다.
주요 기여
-
한국어 형태소 분석과 부분 매칭을 한 인덱스에서 동시에 만족시키는 dual analyzer.
- 본문은 영문·숫자용 분석기와 한국어 형태소 분석기(Nori) 두 가지로 동시에 색인하고, 제목·태그·담당자 같은 짧은 식별 필드는 1~10자 n-gram으로 색인했습니다. 한국어 본문에 n-gram을 그대로 적용하면 인덱스 크기가 비대해지고 토큰 빈도 통계가 망가져 정확도가 떨어지므로 — 본문은 형태소, 식별자는 n-gram으로 역할을 명시적으로 가른 결정입니다.
-
긴 본문이 짧은 제목을 점수로 압도하지 않도록 score를 상한 처리.
- 일반적인 BM25 점수 계산은 길이가 긴 문서에 유리하게 기울어 1만 자짜리 본문이 짧은 제목 매치를 눌러버립니다. 본문 점수에 로그 함수로 상한을 씌워 길이로 인한 점수 인플레이션을 누르고, 제목·태그·담당자·본문에 가중치 차등(10·8·5·1)을 두어 가장 잘 맞는 필드가 결과를 주도하고 보조 필드는 30%만 기여하도록 정렬했습니다. 점수 편향을 수학적으로 통제하는 방향으로 relevance를 다듬은 것이 핵심입니다.
-
PostgreSQL은 source of truth, Elasticsearch는 검색용 비정규화 인덱스 — 쓰기/읽기 분리.
- 권한 같이 자주 바뀌는 도메인 상태는 PostgreSQL이 갖고, ES는 검색 속도용 별도 인덱스로 두는 분리 모델을 택했습니다. 사용자가 접근 가능한 문서 ID 목록을 PostgreSQL에서 계산해 ES에는 그 화이트리스트만 넘겨 결과를 자르는 구조 — 권한을 ES 매핑 안에 박는 대안 대신 이 모델을 택한 건 그룹 위임이 자주 바뀌어 매핑에 박으면 멤버십 변경마다 재인덱스가 필요하고, ID 목록 필터는 ES 내부에서 매우 저렴하게 캐시되기 때문입니다. 검색 엔진은 색인·랭킹, 권한은 앱이 책임지도록 두 역할을 명시적으로 분리했습니다.
-
상위 객체 변경에 따라 하위 인덱스가 자동으로 따라오는 동기화 파이프라인.
- 파일의 인덱스 문서에 부모 이름을 포함한 경로가 비정규화돼 있어 상위 Sales 이름이 바뀌면 하위 모든 File의 인덱스도 갱신해야 합니다. 변경된 필드가 인덱스에 영향을 주는 경우(이름)에만 cascade 재색인을 트리거하고, 버전 충돌을 피하기 위해 단순 갱신 대신 삭제 후 재색인 두 단계로 진행합니다. 대량 재색인은 메모리 한도 안에서 점진적으로 처리하면서 일부 문서가 실패해도 나머지는 이어서 처리하도록 구성했습니다.
트러블슈팅
-
검색 결과와 상세 API가 권한 정의에 대해 합의가 안 되던 read/write 경계 일관성 문제.
- 문제: 검색 인덱스 측 권한 필터는
can_read=True만 통과시키고 있었지만, ResourceShare 권한 모델은can_read=True또는can_edit=True둘 다 읽기 허용 권한으로 보고 있었습니다. 그 결과 검색 결과에는 안 떠도/api/log/{id}같은 상세 endpoint로 직접 들어가면 접근이 되는 침묵 inconsistency가 발생 — read replica(ES)와 write source(PG)가 같은 도메인 규칙을 다르게 해석하고 있는 전형적인 CQRS 경계 동기화 실패였습니다. - 해결: 권한 결정 함수(
check_user_permission)를 양쪽이 모두 호출하는 단일 진입점으로 모으고, 그 안에서Q(can_read=True) | Q(can_edit=True) | Q(is_public=True)OR 합성으로 한 번에 표현하도록 정리했습니다. ES는 결과 ID를 받기만 할 뿐 권한 의미 자체는 항상 앱이 결정하도록 묶어, “검색 보임 ↔ 상세 접근 가능”이 같은 함수 호출에서 파생되는 invariant로 굳혔습니다.
- 문제: 검색 인덱스 측 권한 필터는
-
BM25 길이 편향으로 짧은 정확 매치가 본문 매치에 밀리는 문제.
- 문제: “프로젝트 이름으로 검색했는데 본문에 같은 단어가 여러 번 나오는 무관한 문서가 더 위에 뜬다”는 패턴이 자주 보였습니다. 점수 분포를 직접 들여다보니 BM25 그대로는 길이가 긴 문서가 term frequency 누적만으로 짧은 정확 매치를 항상 누른다는 점이 확인됐고, 단순 boost 조정만으로는 한계가 분명했습니다.
- 해결:
function_score의script_score로 본문 점수를Math.min(score, log(score + 1) * 3)으로 상한 처리해 길이로 인한 점수 인플레이션을 수학적으로 누르고,dis_max로 제목·태그·담당자·본문을 경쟁시키되tie_breaker = 0.3으로 가장 잘 맞는 필드가 결과를 주도하도록 정렬해 의도된 ranking으로 정착시켰습니다.
의의
검색 품질·권한 정합성·메타데이터 동기화 세 축이 서로 흔들리지 않도록 인덱스 매핑·쿼리 구조·권한 경계·신호 파이프라인을 한 묶음의 설계로 잡은 사례입니다. 인턴 기간에 진행한 작업이지만, “검색 엔진의 책임”과 “앱의 책임”을 명확히 가르고 그 경계 위에 한국어 검색 품질을 얹는 결정을 내려본 경험이 가장 가치 있었습니다.