grep

Engineering

트랜잭션 스크립트에서 숙소 메타 + 가격 계산 모듈로 — 전시 아키텍처 개선기 (2/3)

Ung여기어때

2026년 6월 18일

원문에서 보기 ↗

글. 진영준(Ung) / 전시개발팀

안녕하세요, 여기어때 전시개발팀 백엔드 개발자 엉입니다.

상세 화면 API 호출 한 번에 컬렉션 접근 19회. $lookup으로 조회 시점에 모든 것을 조립하던 구조를, MongoDB 문서 개편과 함께 "조회 모델"과 "가격 계산 모듈"로 분리한 과정을 공유합니다.

들어가며

숙박 플랫폼 BE에는 화면 단위 API가 여러 개 있습니다. 목록(PLP), 상세(PDP), 객실 상세(RDP), 객실 목록(ILP) — 화면은 다르지만 하는 일은 비슷합니다. 숙소 정보를 조회하고, 가격을 계산해서 내려준다.

V2 시절 이 API들은 전형적인 트랜잭션 스크립트(Transaction Script) 구조였습니다. 각 API가 자기만의 MongoDB aggregation으로 데이터를 조회 시점에 조립하고, 자기만의 가격 계산 코드를 들고 있었죠. 기능은 문제없이 동작했지만, 트래픽이 가장 많은 조회 경로에서 DB I/O가 구조적으로 낭비되고 있었습니다.

이 글에서는 상세(PDP) API를 해부해 그 낭비가 어디서 오는지 살펴보고, 이를 읽기 최적화 문서 + 숙소 메타 모듈 + 가격 계산 모듈로 개편한 과정을 다룹니다. 마지막으로 네 개의 API를 같은 절차로 옮기기 위해 Claude Code skills로 마이그레이션 워크플로우를 만들고, 쉐도잉 기반 동일성 검증으로 안전망을 마련한 경험을 공유합니다.

Before: 상세 API 한 번이 만들어내는 I/O

쿼리는 3번처럼 보이지만

V2의 PDP 요청 하나는 코드상 세 번의 DB 호출로 보입니다.

  1. 카테고리 판단을 위한 숙소 메타 단건 조회
  2. 노출 정보(이미지·이벤트·엠블럼·광고·쿠폰) 조립 aggregation
  3. 가격/정책(판매가·특가 정책·쿠폰) 조립 aggregation

문제는 2번과 3번이 각각 $lookup을 6~10개씩 포함하고 있다는 점입니다. 가격 aggregation의 흐름을 단계만 남겨 정리하면 이렇습니다.

모텔 단박 기준으로 합산하면 요청 한 번에 컬렉션 접근이 약 19회입니다. 그런데 이 19회가 모니터링에서는 “쿼리 3번”으로 보입니다. aggregation이 I/O를 가리고 있는 셈입니다.

이건 join이 아니라, 직렬화된 쿼리 묶음이다

위 파이프라인에서 눈여겨볼 부분은 두 번째 stage입니다. $match로 숙소 문서를 찾자마자 $project로 _id와 숙소 ID만 남기고 전부 버립니다. 베이스 문서의 내용은 쓰지 않습니다. $lookup을 걸기 위한 앵커일 뿐입니다.

실제로 각 $lookup의 서브 파이프라인을 보면 join 조건이 베이스 문서의 필드가 아니라 전부 외부에서 바인딩된 파라미터 (숙소 ID, 객실 그룹 ID, 날짜)입니다. 즉 이것은 관계형 DB에서 말하는 join이 아니라, 서로 독립적인 쿼리 10개를 aggregation 문법으로 묶어 MongoDB 안에서 순차 실행시킨 것입니다.

이 구조는 다음과 같은 성능 문제로 이어집니다.

1) 병렬화 가능한 I/O의 직렬화. 독립적인 쿼리 10개라면 애플리케이션에서 병렬로 실행할 수 있습니다. 하지만 한 파이프라인에 묶이는 순간 stage 순서대로 실행됩니다. 전체 응답 시간은 lookup들의 합이 되고, 그중 하나만 느려져도(인덱스 미스, 컬렉션 비대화) 전체 응답이 그만큼 지연됩니다.

2) 중복 I/O. 쿠폰 컬렉션은 사용일 조건만 다를 뿐인데 한 파이프라인에서 두 번 조회됩니다. 한 번 읽어서 애플리케이션에서 나누면 될 일이 I/O 두 번으로 늘어납니다.

3) 거대 단일 문서 조립. lookup 결과가 전부 한 결과 문서의 배열 필드로 쌓입니다. 연박 조회라면 판매가 데이터가 객실 수 × 박수만큼 늘어납니다. MongoDB는 이 덩어리를 메모리에서 조립해 네트워크로 한 번에 전송합니다. BSON 문서 크기 제한(16MB)을 의식해야 할 만큼 커질 수 있는데, 정작 필요한 데이터는 그중 일부입니다.

4) 데이터별로 캐시를 걸 수 없다. 파이프라인 안에는 변동 주기가 전혀 다른 데이터가 묶여 있습니다.

한 쿼리로 묶여 있으니 캐시도 전체 단위로만 적용할 수 있습니다. 가격 때문에 캐시를 쓰지 못하면, 거의 변하지 않는 이미지까지 매 요청마다 디스크에서 다시 읽어야 합니다. 가장 자주 바뀌는 데이터를 기준으로 전체 I/O가 늘어나는 전형적인 read amplification(읽기 증폭)입니다.

이 무거운 조회를 매 요청마다 반복할 수는 없었기에, V2는 계산이 끝난 가격을 Redis에 캐싱 해 두고 내려줄 수밖에 없었습니다. 부하는 줄었지만 대신 다른 문제가 생겼습니다. 가격이 실제로 바뀌어도 캐시가 갱신되기 전까지는 이전 가격이 그대로 노출되는 것이죠. 빠른 응답과 정확한 가격, 둘 중 하나를 택해야 하는 상황이었습니다.

5) 분기 수만큼 복제되는 파이프라인. 카테고리(모텔/호텔/게하/펜션) × 숙박 형태(단박/연박) × 화면 변형마다 거의 같은 파이프라인이 메서드로 복제됐습니다. PDP 하나에 이런 aggregation 메서드가 10개 , 자바 파일 속 JSON 문자열로 약 1,600줄입니다. $lookup 하나에 인덱스가 하나씩 필요하니 인덱스 관리 대상도 파이프라인 수만큼 늘어나고, 새로운 노출 요소가 추가되면 10개 파이프라인에 $lookup을 하나씩 더해야 합니다.

여기에 가격 계산 로직의 문제가 더해집니다. 대부분의 화면 API는 가격 계산 로직을 공유하고 있었지만, 트랜잭션 스크립트 구조에는 그 공유를 강제할 장치가 없었습니다. 공유는 개발자의 규율로 유지될 뿐, 구조로 보장되지는 않았죠. 그래서 일부 API는 같은 수백 줄짜리 계산 로직을 별도로 들고 있었고, 한쪽을 고쳐도 다른 쪽에 자동으로 반영되지 않으니 두번 수정하게 되는 구조였습니다. 조회는 DB에서 비싼 비용을 치르고, 계산은 구조가 아니라 규율에 기대고 있던 것이 V2의 현실이었습니다.

After: 조회는 단순하게, 계산은 한 곳에

개편 방향은 다음 한 문장으로 요약할 수 있습니다. 조회 시점에 DB에서 조립하던 것을 쓰기 시점에 미리 조립해 두고, 화면마다 흩어져 있던 계산은 한 모듈로 모은다.

1) 문서 개편 — $lookup이 사라지는 자리

aggregation이 길어진 근본 원인은 저장하는 형태와 읽는 형태가 달랐기 때문입니다. 원천 데이터는 컬렉션별로 나뉘어 있는데 화면은 조립된 형태를 원하니, 그 간극을 매 요청마다 쿼리가 메우고 있었던 셈입니다.

V3에서는 화면에서 필요한 형태에 맞춰 문서를 다시 설계했습니다. 숙소 기본 정보(properties), 객실·요금제(rate plan), 상품(product) 단위로 읽기 최적화 문서를 미리 구성해 두고, 조회는 ID 기반 단건/IN 조회로 단순해졌습니다. $lookup이 조회 시점에 하던 조립은 문서를 만드는 적재 파이프라인이 담당하게 됐습니다.

2) 숙소 메타 모듈 — 변동 주기별 캐시 전략

조회가 단순해지면서 비로소 부분 캐싱이 가능해졌습니다. 데이터 접근을 전담하는 숙소 메타 모듈을 두고, 변동 주기에 맞는 캐시 전략을 데이터 단위로 분리했습니다.

V3에서는 각 데이터가 변동 주기에 맞춰 조회되고 캐싱됩니다. 그리고 꼭 필요한 다건 조회는 aggregation 안에서 순차 실행되는 대신, 애플리케이션에서 배치로 묶어 병렬로 가져옵니다.

여기서 가장 크게 달라진 건 가격 입니다. 앞서 V2는 무거운 조회 때문에 계산된 가격을 Redis에 캐싱했고, 그만큼 정확한 가격을 보여주기 어려웠다고 했습니다. V3에서는 조회가 가벼워지고 계산이 별도 모듈로 빠지면서 가격을 미리 캐싱해 둘 이유 자체가 사라졌습니다. 요청이 올 때마다 최신 데이터로 가격을 계산해 내려주므로, 캐시는 거의 변하지 않는 메타에만 걸고 정작 사용자에게 중요한 가격은 실시간으로 계산해 내려줍니다. 빠른 응답과 정확한 가격 중 하나를 골라야 했던 V2의 고민이, V3에서는 둘 다 잡는 구조로 바뀐 셈입니다.

3) 가격 계산 모듈 — DB에서 꺼내 코드로

흩어져 있던 가격 계산은 goodsprice 단일 모듈로 모았습니다.

goodsprice/
├── roomprice/    # 객실 정상가
├── policy/       # 특가 정책 (어떤 할인이 적용되는가)
├── coupon/       # 쿠폰 판정
└── commission/   # 수수료

“전체 기간 합산 기준 최대 할인 정책을 뽑는 규칙”은 이제 이 모듈 안에 한 번만 존재합니다. PLP가 호출하든 PDP가 호출하든 같은 코드가 실행되므로 화면 간 가격 불일치는 구조적으로 발생하기 어려워졌고, JSON 문자열 속에 있던 노출 조건·우선순위 규칙이 자바 코드로 옮겨지면서 단위 테스트도 가능해졌습니다.

4) 화면 API — 조합만 담당하는 Composer

화면 API에 남는 것은 “무엇을 어떤 순서로 보여줄 것인가”뿐입니다. 카테고리 × 숙박 형태 조합마다 Composer를 두고, 각 Composer는 메타 모듈과 가격 모듈을 호출해 응답을 조립합니다. V2에서 파이프라인 10벌로 복제되던 분기가 V3에서는 Composer 선택 한 곳으로 모입니다.

I/O 관점의 Before/After를 요약하면 이렇습니다.

어떻게 옮겼나 — 반복 작업을 skill로 만들기

설계가 정해져도 문제가 남습니다. PLP, PDP, RDP, ILP — 같은 절차를 네 번 반복해야 합니다. V2 로직 분석, V3 구조로 재설계, 구현, 테스트, 검증. 한 API당 수십 개 클래스가 만들어지는 작업입니다.

저희는 이 절차를 Claude Code의 skill(에이전트가 따르는 작업 절차 정의)로 문서화했습니다. 사람마다, API마다 달라질 수 있는 마이그레이션 과정을 고정된 워크플로우로 만든 것입니다.

  1. V2 분석 — 대상 API의 데이터 흐름, 분기 조건, 가격 로직을 추출해 문서화합니다. 트랜잭션 스크립트와 aggregation 파이프라인에 암묵적으로 묻혀 있던 규칙이 이 단계에서 명시적인 스펙으로 정리됩니다.
  2. 마이그레이션 Plan — V3 구조(어떤 Composer, 어떤 모듈 조합)로 옮길지 계획을 세우고, 사람이 리뷰합니다.
  3. V3 구현 — 계획대로 코드를 생성합니다.
  4. 테스트 — 테스트 계획을 먼저 세우고 테스트 코드를 작성합니다.
  5. 쉐도잉 — 운영 트래픽을 복제해 V3로 흘려보냅니다. 사용자 응답은 여전히 V2가 책임집니다.
  6. 동일성 검증 — 같은 요청에 대한 V2/V3 응답을 비교합니다. 불일치가 나오면 구현 단계로 돌아갑니다.

AI가 짜도 안전했던 이유

핵심은 “AI가 코드를 잘 작성해서”가 아니라 6번 단계가 있었기 때문입니다.

가격 로직 마이그레이션이 위험한 이유는 반올림 시점, 경계값, 우선순위가 같을 때의 처리 같은 미묘한 차이가 소리 없이 틀어지기 때문입니다. 사람이 직접 옮겨도 마찬가지입니다. 쉐도잉과 동일성 검증은 “옮긴 코드가 기존과 같은 답을 내는가”를 운영 트래픽 전체로 확인해 줍니다. 검증이 자동화되어 있고 전수에 가깝다면, 코드를 누가(혹은 무엇이) 작성했는지는 덜 중요한 문제가 됩니다.

순서가 중요했다고 생각합니다. AI를 도구로 활용할 수 있었던 이유는 검증 가능한 구조를 먼저 만들어 두었기 때문입니다. 그 반대가 아닙니다.

트레이드오프 — 무엇을 내주고 무엇을 얻었나

조회를 단순하게 만들었다고 해서 복잡도가 사라진 것은 아닙니다. 자리를 옮겼을 뿐입니다. V3가 새로 떠안은 비용은 세 가지입니다.

1) 쓰기·동기화 복잡도

V2에서는 원천 데이터만 바꾸면 끝이었습니다. 조회 시점에 항상 최신 데이터를 조립하기 때문입니다. V3에서는 읽기 최적화 문서를 미리 만들어 두므로, 최신 상태로 유지할 책임이 생깁니다. 원천 데이터 변경을 이벤트로 받아 문서를 갱신하는 컨슈머 파이프라인이 추가됐고, 다음 질문들에 답해야 했습니다.

2) 구조 복잡도 — 계층이 늘어난다

트랜잭션 스크립트의 장점은 직관적이라는 점입니다. 한 파일을 열면 조회부터 응답까지 전부 보입니다. V3는 그 대가로 계층이 늘었습니다. 응답 하나를 추적하려면 Dispatcher → Composer → 메타 모듈 → 캐시 → 문서를 넘나들어야 하고, 구성 요소 수 자체도 많아졌습니다. 모듈·캐시 계층·동기화 컨슈머가 각각 설정과 모니터링을 필요로 하니, 관리해야 할 운영 포인트가 늘어난 것입니다.

분리가 잘못되면 이 비용만 남습니다. 그래도 감당할 수 있었던 것은 계층마다 책임이 명확했기 때문입니다. “데이터를 가져오는 곳”, “가격을 계산하는 곳”, “조합하는 곳”이 어디인지 명확하게 설명할 수 있다면, 늘어난 계층은 추적을 방해하는 장애물이 아니라 이정표가 됩니다.

3) 인지 비용 — 팀이 새 지도를 익혀야 한다

구조가 바뀌면 코드만 바뀌는 게 아닙니다. “가격 버그가 나면 어디를 봐야 하지?”에 대한 팀 전체의 감각을 다시 만들어야 합니다. V2에서 쌓은 지식(어느 API의 어느 스크립트를 보는지)은 쓸모가 없어지고, 새 구조의 규칙(어느 모듈, 어느 Composer를 봐야 하는지)에 익숙해지기까지 온보딩 비용이 듭니다. 마이그레이션 기간에는 V2와 V3 두 구조를 모두 알고 있어야 했습니다.

정리하면 이렇게 교환한 셈입니다.

저희 서비스는 읽기가 압도적으로 많고 화면 요구사항 변화가 잦아 이득이 더 큰 선택이었습니다. 하지만 쓰기가 잦거나 정합성 요구가 매우 높은 도메인, 혹은 화면이 두세 개뿐인 서비스라면 같은 선택이 정답이 아닐 수 있습니다. 트레이드오프의 답은 아키텍처 유행이 아니라 도메인의 특성에서 찾아야 한다고 생각합니다.

마치며

돌아보면 이번 개편은 세 가지 이야기가 맞물려 있었습니다.

첫째, MongoDB를 문서 DB답게 쓰게 됐습니다. 트랜잭션 스크립트 시절의 코드는 사실 MongoDB를 관계형 DB처럼 다루고 있었습니다. 조회 때마다 $lookup으로 컬렉션을 이어 붙이는 건 RDB의 JOIN을 흉내 낸 것에 가까웠죠. V3에서는 화면이 읽는 모양에 맞춰 문서를 미리 만들어 두는, 문서 모델 본연의 방식으로 옮겼습니다. 무거운 조립은 적재 시점으로 빠지고 조회는 단순해졌습니다. “조회는 단순하게, 계산은 한 곳에”라는 말은, 결국 MongoDB를 그 강점대로 쓰자는 이야기이기도 합니다.

둘째, 가격 모듈이 다음 작업을 미리 그려줍니다. 가격 계산을 goodsprice 한 모듈로 모은 효과는 지난 문제를 정리한 데서 끝나지 않습니다. 앞으로 예정된 실시간 가격 API 연동이 설계적으로 이미 그려집니다. 예전이라면 외부 가격 소스를 붙이는 일은 화면마다 복제된 계산 코드를 전부 찾아 고치는 작업이었고, 어디를 건드려야 하는지부터 불확실했습니다. 지금은 실시간 소스를 꽂을 자리가 한 곳으로 정해져 있고, 그 입력만 바꾸면 PLP·PDP·RDP·ILP에 일괄 반영됩니다. 게다가 가격을 캐시에 굳혀두지 않고 요청마다 계산하는 구조로 이미 옮겨왔으니, 연동에 필요한 토대의 절반은 깔려 있는 셈입니다. 해야 할 일이 막막한 게 아니라 목록으로 이미 그려진다는 것 — 모듈화가 남긴 가장 실용적인 자산이라고 생각합니다.

셋째, 큰 전환을 skill과 검증 위에서 굴렸습니다. 네 개의 API를 같은 손으로 옮길 수 있었던 건 절차를 Claude Code skill로 고정하고, 쉐도잉·동일성 검증이라는 안전망을 먼저 깔아둔 덕분입니다. 핵심은 AI가 코드를 잘 짜서가 아니라, 옮긴 결과를 운영 트래픽 전체로 증명할 수 있었기 때문입니다. 큰 마이그레이션을 앞두고 있다면, 코드를 옮기는 도구보다 옮긴 결과를 증명하는 장치를 먼저 만들기를 권합니다. 그 장치가 있으면 사람이든 AI든, 도구 선택의 폭이 넓어집니다.

세 이야기의 공통 토대는 결국 읽기 최적화 문서였습니다. 본문에서는 “문서를 다시 설계했다”고만 짚고 지나갔지만, 그 문서를 실제로 어떻게 설계했는지가 사실 이야기의 절반입니다. 컬렉션마다 흩어져 있던 데이터를 조회 편의가 아니라 도메인 기준으로 다시 묶는 과정은, 다음 글에서 따로 다룹니다.

다음 글 — 데이터 통합: 파편화된 MongoDB Document를 도메인 단위로 다시 설계하기

조회 시점에 $lookup으로 메우던 간극을, 어떤 도메인 경계로 문서를 다시 그어 없앴는지 — 그 설계 결정과 시행착오를 이어서 풀어보겠습니다.

긴 글 읽어주셔서 감사합니다.