mooner / Resume KO/EN /

mooner:~/resume $ cat projects/amoa/README.md

AMOA

하이브리드 검색 백엔드

짧은 강의 클립 위에 플레이리스트, 후기, 레시피 공유, 추천, 통합 검색을 얹은 학습 서비스의 백엔드입니다. 백엔드를 혼자 맡아 API와 데이터 모델, 검색과 임베딩 색인 파이프라인을 만들고 있습니다.

mooner:~/resume/projects/amoa $ tree -L 1

Structure01

구조

06 parts

Spring Boot로 시작해 Ktor와 Exposed로 다시 썼고, 지금은 기능 단위 수직 슬라이스 구조입니다. 기능마다 도메인이 포트를 갖고 인프라가 구현합니다.

app/
조립만 합니다. 기능 목록을 등록하고 아키텍처 규칙 테스트를 둡니다.
platform/
DB, Redis, 아웃박스, 에러 응답, 임베딩과 미디어 같은 외부 서비스 어댑터.
common/
모든 기능이 기대는 계약. 에러 형식, 도메인 이벤트, 트랜잭션, 벡터·검색 SPI.
feature/
계정, 카탈로그, 플레이리스트, 레시피, 추천, 검색 등 11개. 안쪽은 route → application → domain ← infrastructure 방향만 허용하고 테스트로 막습니다.
docugen/
라우트 정의에서 OpenAPI 3.1 문서를 만드는 DSL.
search SPI
검색 기능은 다른 기능을 모릅니다. 각 기능이 등록한 검색·자동완성 제공자를 모아서 씁니다.

mooner:~/resume/projects/amoa $ cat docs/architecture.md

Architecture02

아키텍처

Data flow

QUERY/searchUNIFIED SEARCHsynonyms · query cacheeach provider · lecture · clip · recipeVECTOR ARM512 → 768 halfvecKEYWORD ARMpg_trgmRRFk = 60RESULTSRRF across typesCONTENT WRITEsame transactionEMBEDDING_JOBoutbox queueWORKERSKIP LOCKED · leaseEMBED SERVER768 dimsPGVECTORHNSW · halfvecread by the vector arm// no embedding server? keyword arm only,// the response still comes back

검색은 벡터 검색과 키워드 검색을 한 SQL 안에서 돌린 뒤 순위를 합칩니다(RRF). 임베딩은 요청 경로 밖에서, 아웃박스 큐를 도는 워커가 채웁니다.

  • 벡터 쪽은 768차원 임베딩의 앞 512차원(halfvec)으로 HNSW 후보를 뽑고, 후보만 768차원 원본으로 다시 정렬합니다.
  • 키워드 쪽은 pg_trgm 유사도로 순위를 매깁니다. 두 결과를 FULL OUTER JOIN으로 합쳐 RRF(k = 60)로 정렬합니다.
  • 통합 검색은 강의, 클립, 레시피 제공자를 병렬로 부르고, 제공자 사이 순위를 RRF로 한 번 더 합칩니다.
  • 임베딩 서버가 없거나 실패하면 키워드 검색만으로 내려가 응답은 그대로 돌려줍니다.
  • 색인 작업은 콘텐츠를 바꾸는 같은 트랜잭션에서 큐에 넣고, 워커가 FOR UPDATE SKIP LOCKED로 선점합니다. 실패하면 간격을 늘려 다시 시도하고 5번째에 멈춥니다.
  • 자동완성은 한글을 자모 단위로 비교해, 아직 조합 중인 글자와 초성 입력도 찾아 줍니다.

mooner:~/resume/projects/amoa $ git log --grep=fix

Problem solving03

문제 해결

05 cases

  1. 01

    배포할 때마다 색인 작업이 조용히 멈췄다

    Problem
    색인 작업을 선점한 뒤 저장하기 전에 프로세스가 재시작되면, 선점 질의도 누락분을 채우는 스윕도 그 클립을 다시 집지 않았습니다. 오류 없이 색인만 빠진 채 남았고, 로컬 데이터에서는 53건 중 10건이 그랬습니다. 개발 서버는 배포할 때마다 재시작하니 매번 생길 수 있는 문제였습니다.
    Approach
    next_attempt_at을 "이 시각 전에는 보이지 않는다"는 한 가지 뜻으로 통일했습니다. 대기 중인 작업에는 재시도 시각, 선점한 작업에는 10분짜리 리스 만료 시각입니다. 조건 하나로 두 상태를 모두 선점하게 되고, 리스로 되살아난 작업도 시도 횟수를 씁니다. 이미 갇힌 행은 마이그레이션에서 되살렸습니다.
    Result
    리스 관련 통합 테스트 7건을 더하고, 선점 조건을 되돌리면 새 테스트가 실패하는 것까지 확인했습니다. 로컬 데이터 53건이 모두 색인됐습니다.
  2. 02

    추천 API가 limit 12부터 500을 냈다

    Problem
    2단 벡터 검색을 넣으면서 HNSW 탐색 폭(ef_search)을 후보 수에서 계산했는데, pgvector의 상한 1000을 확인하지 않았습니다. 기본 요청(limit=20)이 ef_search = 1800이 되어 500이 났고, limit이 12 이상이면 모두 실패했습니다.
    Approach
    상한을 한곳에 두고, ef_search가 아니라 후보 수 자체를 잘랐습니다. ef_search만 낮추면 반복 스캔이 늘어 오히려 느려진다는 측정(40일 때 p50 3.92ms, 200일 때 1.52ms)을 따른 결정입니다. 요청 크기 1–600과 오버페치 배수의 모든 조합에서 상한을 넘지 않는 것을 확인했습니다.
    Result
    실제 PostgreSQL에서만 재현되는 문제라 통합 테스트로 고정했고, 수정 전 코드에서는 그 테스트가 실패하는 것을 확인했습니다.
  3. 03

    벡터 인덱스를 1/3로 줄이고, 그 대가를 재다

    Problem
    768차원 벡터의 HNSW 인덱스가 행당 4,096B라, 버퍼 128MB 기준 약 3만 3천 행이면 인덱스가 메모리를 넘칠 상황이었습니다. 자막 단위 색인이 들어오면 넘을 규모였습니다.
    Approach
    임베딩을 halfvec으로 바꾸고(코사인 거리 최대 오차 7e-5), 앞 512차원으로만 인덱스를 만든 뒤 후보를 768차원으로 다시 정렬하는 2단 검색을 넣었습니다. 합성 데이터 2만 행에서 256차원은 recall@10 0.862로 기준(0.95)에 못 미쳤고, 512차원은 0.984였습니다.
    Result
    인덱스가 행당 1,366B로 줄어, 메모리를 넘는 시점이 약 9만 8천 행으로 늦춰졌습니다. 대신 2만 행에서는 2단 검색이 더 느렸습니다(p50 7.17ms 대 1.52ms). 이 측정에 따라 데이터가 적은 레시피 검색은 단일 768차원 인덱스로 두었습니다.
  4. 04

    검색 입구가 다섯 개라 클립이 빠졌다

    Problem
    검색처럼 보이는 API가 다섯 개였고, 프론트엔드 검색 화면이 강의 목록 API를 쓰고 있어 클립이 결과에서 빠졌습니다. 통합 검색을 하나 더 만드는 것만으로는 잘못 고를 여지가 사라지지 않았습니다.
    Approach
    통합 검색, 레시피 하이브리드 검색과 통합 자동완성, 클립 벡터 검색을 차례로 붙인 뒤, 검색 경로를 /search와 /search/suggestions 둘로 정리하고 응답을 type으로 구분하는 형태로 바꿨습니다. 호환이 깨지는 변경 4건은 이때 한 번에 정리했습니다.
    Result
    검색 제공자 매핑을 빠짐없는 when으로 두어, 새 종류를 빠뜨리면 컴파일이 막히고 등록을 빠뜨리면 스모크 테스트가 잡습니다.
  5. 05

    Spring Boot에서 Ktor로, 바깥 동작은 그대로

    Problem
    Spring Boot와 Modulith로 시작한 백엔드를 Ktor와 Exposed로 다시 쓰면서, API 응답과 DB 스키마, 세션처럼 바깥에서 보이는 동작은 바꾸지 않는 것을 목표로 했습니다.
    Approach
    기존 앱의 실제 응답 56건과 골든 JSON 24건으로 특성화 테스트를 만들었습니다. DB 마이그레이션은 바이트 단위로 같게 두고 스키마 덤프로 차이를 감시했으며, Redis 키와 TTL, 이벤트 아웃박스 형식, 환경 변수 이름까지 맞춰 전환과 롤백 어느 쪽에서도 로그인 세션이 유지되게 했습니다.
    Result
    테스트 167개를 통과한 채 전환했습니다. 전환 직후 fat jar에서 Flyway 서비스 파일이 덮어써져 마이그레이션 두 개가 조용히 빠진 문제도 찾아 빌드 설정으로 고쳤습니다.

Accepting requestsAnsan --:-- KST

mooner:~/resume $ ./contact.sh

Contact→

Name
문민기 / Minki Moon
Handle
@mooner
Base
Ansan, KR

느긋하게 커피 한 잔 어떠세요?

궁금한 점이 있다면 편하게 연락 주세요.

// ping me anytime — i read all.

© 2026 Minki Mooncrafted with monospace affection · rev 2026.10.18