← Back to list

더 빠르게, 그리고 무너지지 않게 — 전시 아키텍처 개선기 (1/3)

글. 박성진(Philip) / 전시개발팀

Philip Park in 여기어때 기술블로그 · 2026-06-19 08:51 · 62 claps · 11.7 min read
Open on Medium ↗

더 빠르게, 그리고 무너지지 않게 — 전시 아키텍처 개선기 (1/3)

글. 박성진(Philip) / 전시개발팀

이 글은 3부작입니다.

  • (1/3) 더 빠르게, 그리고 무너지지 않게 — 문제 정의와 아키텍처 의사결정, 그리고 내고장성 설계 (현재 글)
  • (2/3) 가격 계산 모듈 분리 + skills 로 마이그레이션 — 트랜잭션 스크립트에서 숙소 메타 + 가격 계산 모듈로 (by @엉Ung(진영준) / 전시개발팀 )
  • (3/3) 데이터 통합 — MongoDB 원칙으로 document를 통합하고 동기화를 재설계 (by @조이Joy(우재호) / 전시개발팀 )

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

전시 영역에는 숙소 목록(PLP), 상세(PDP), 객실 상세(RDP)처럼 사용자가 가장 많이 마주하는 화면들이 있습니다. 이 화면에 상품을 내려주는 상품 API를 한동안 운영하면서, 저희는 트래픽이 몰릴 때마다 같은 곳에서 발목을 잡혔습니다 — 바로 캐시였습니다.

이번 글에서는 그 캐시를 V2에서 V3로 다시 설계하며 풀어간 이야기를 해보려 합니다. 빠르게 만드는 것도 목표였지만, 그보다 “캐시가 무너져도 서비스는 멈추지 않게” 만드는 데 더 많은 고민을 쏟았는데요. 어떤 문제를 만났고, 여러 갈래의 선택지를 어떻게 저울질했으며, 결국 어떤 구조에 도달했는지를 차례대로 풀어보겠습니다. 비슷한 고민을 하고 계신 분들께 작은 참고가 되면 좋겠습니다.

결과부터

전시 상품을 내려주는 표준 상품 API(Builder-API)를 V2에서 V3로 다시 설계했습니다. 핵심 지표부터 말씀드리면,

  • 처리량(TPS) 3배 향상 — 211 → 666 (채택 구조 기준, 스테이지 vuser 6000)
  • P95 응답 시간 대폭 단축 — 응답 캐시에 의존하던 4.13초 구간을 캐시 없이도 1초대로
  • MongoDB CPU 부하 20~30% → 10% 이하 — 컬렉션 통합으로 조회 비용 자체를 줄임

하지만 이번 작업에서 더 공들인 건 숫자가 아니었습니다. 원인이 무엇이든 캐시 계층이 무너져도 서비스는 멈추지 않는 구조, 그걸 만드는 데 가장 많은 고민을 했습니다. 왜 그랬는지부터 이야기하겠습니다.

어쩌다 캐시가 발목을 잡았나

기존 구조에서 우리는 캐시에 깊게 의존하고 있었습니다. 제휴점 × 체크인/체크아웃 날짜 조합마다 최종 응답 전체를 통째로 Redis에 캐싱했습니다. 빠르긴 했지만, 같은 메타 정보가 날짜 조합마다 중복 저장되면서 캐시는 점점 무거워졌습니다.

문제는 트래픽이 몰릴 때 드러났습니다. 대용량 응답(한 요청에 메타·상품을 합쳐 수십 MB에 달하는 경우도 있었습니다)이 높은 TPS로 동시에 나가면, Redis 노드의 아웃바운드 네트워크가 인스턴스 baseline을 넘어섭니다. baseline을 넘는 순간 패킷이 큐잉·드랍되고, 응답 지연이 누적되다 결국 클라이언트의 command timeout으로 터집니다. 한 노드에서 시작된 timeout은 곧 전체 pod로 번졌습니다.

Slow query 로그는 깨끗했습니다. Redis가 명령을 느리게 처리한 게 아니라, 네트워크 대역폭 한계에 걸려 응답을 내보내지 못한 것이기 때문입니다. 원인이 애플리케이션 로직이 아니라 인프라 한계선이었다는 점이 중요합니다.

더 뼈아팠던 건 복구 수단마저 캐시 구조에 묶여 있었다는 점입니다. 장애 중에 pod를 늘려보려 했지만, 새로 뜬 pod 역시 같은 Redis를 바라보니 부하만 더 얹는 꼴이었습니다. 스케일아웃이 해법이 되지 못했습니다.

여기서 두 가지 구조적 한계가 분명해졌습니다.

  1. 캐시가 단일 장애점이다. Redis 하나가 흔들리면 전부 멈춘다.
  2. Redis가 병목이라 스케일아웃이 막혀 있다. 애플리케이션 pod를 늘려도, 병목이 Redis에 있으니 처리량이 따라 늘지 않는다.

우리가 마주한 구조적 문제는 사실 세 갈래였습니다.

  • RDB를 그대로 옮긴 파편화된 Document 구조 — PLP 한 번 조회에 15개 이상 컬렉션을 건드리고, 다중 조인성 쿼리가 slow query를 유발했습니다. → 컬렉션 통합으로 해결, 2편에서 다룹니다.
  • 서비스 코드에 트랜잭션 스크립트 스타일로 엉겨 있던 가격 계산 — 숙소 메타 조회와 가격 계산이 한 덩어리라 모듈화가 어려웠습니다. → 숙소 메타 + 가격 계산 모듈로 분리, 3편에서 다룹니다.
  • 캐시 단일 의존 — 이 글에서 끝까지 파고들 주제입니다.

어떤 메모리 아키텍처로 서빙할 것인가

개선은 두 단계로 접근했습니다.

Phase 1 — 연산과 구조부터 풀다

가장 먼저 손본 건 조회 비용 그 자체였습니다. 기존 구조에는 두 가지 병목이 있었습니다.

첫째, 파편화된 Document를 향한 다중 조인. RDB 테이블을 그대로 MongoDB 컬렉션으로 복사해둔 탓에, PLP 한 번 조회에 15개 이상의 컬렉션을 건드려야 했습니다. 관련 데이터가 여기저기 흩어져 있으니 10여 개 Document를 조인하는 무거운 쿼리가 만들어졌고, 이게 slow query와 불필요한 네트워크 대역폭 낭비로 이어졌습니다. 그래서 Document DB의 특성에 맞게 — 정규화보다 조회 효율을 우선해 — 관련 데이터를 하나의 Document로 통합했습니다. 10여 개를 조인하던 쿼리가 3~4개의 단순 쿼리로 줄었습니다.

둘째, 제휴점별 순차 처리. 여러 제휴점을 한 번에 조회할 때, 전체 응답 시간이 각 제휴점 처리 시간의 합이 되는 구조였습니다. 제휴점이 늘수록 응답이 선형으로 느려졌고, 1.2초를 훌쩍 넘기는 경우가 잦았습니다.

// 기존: 순차 처리로 인한 느림
for (Place place : places) {
    PdpResponse pdp = pdpService.getPdp(place.getId()); // IO 대기
    Price lowestPrice = calculateLowestPrice(pdp);      // CPU 작업
}

이걸 풀기 위해 Virtual Thread를 도입했습니다. Properties·RoomRatePlans·Products를 먼저 배치로 적재한 뒤, 제휴점별 모델 생성을 Virtual Thread로 병렬 처리해 한 요청에서 수십 건의 IO를 동시에 흘렸습니다. 플랫폼 스레드 풀을 쓸 때 겪던 스레드 고갈과 컨텍스트 스위칭 오버헤드 없이, 경량 스레드로 대규모 병렬 처리가 가능해졌습니다. 개별 제휴점 실패가 전체 응답을 무너뜨리지 않도록 에러도 격리했습니다.

이 두 가지를 풀고 나니, MongoDB 직접 조회의 비용 자체가 크게 떨어졌습니다. 나중에 “캐시 없이도 버틴다”는 전제를 가능하게 한 토대가 바로 이 단계입니다.

그래서, 어떤 메모리 아키텍처로 서빙할 것인가

연산·구조를 풀고 나니 진짜 설계 질문이 남았습니다. 이 데이터를 어떤 메모리 아키텍처로 들고 서빙할 것인가.

그전에, 장애 직후의 응급조치(스펙업, Replica 증설·payload 줄이기)는 egress를 줄여주긴 했지만 본질적으로 시간 벌기였습니다. 트래픽이 더 늘면 baseline은 다시 넘습니다. 그래서 튜닝이 아니라 구조를 다시 골랐습니다. 후보는 셋이었습니다.

채택 — CDC로 항상 최신인 메타 캐시 + 실시간 가격

우리가 택한 건 TTL로 만료시키는 캐시가 아니라, 변경이 생길 때만 이벤트로 갱신하는 캐시였습니다. 메타(제휴점 정보 properties, 객실 상품 정보 room_rate_plans)는 자주 바뀌지 않으니, MongoDB에 변경이 생기는 순간 그 변경을 캐시에 반영하면 됩니다. 이를 Kafka 기반 CDC(Change Data Capture)로 구현했습니다.

흐름은 이렇습니다. 숙소 정보, 가격 데이터 변경을 감지하면 전시 워커가 CDC 메시지를 만들고, 변경된 id들을 chunk 단위로 묶어 Kafka로 발행합니다. 전시 가격 어플리케이션의 Consumer는 파티션별로 메시지를 받아 Virtual Thread로 병렬 처리하며, MongoDB에서 최신 데이터를 읽어 Redis에 반영합니다.

이 구조가 우리가 가진 문제를 한꺼번에 풀었습니다.

  • 캐시는 항상 최신이다. TTL 만료를 기다리지 않고 변경 즉시 반영되므로, “캐시가 오래돼 틀린 값”이라는 부류의 문제가 사라집니다.
  • 캐시에는 가볍고 변동 적은 메타만 남으니 중복·payload 비대 문제가 사라지고, Redis egress에 여유가 생깁니다.
  • 가격은 실시간 조회라 캐시·실제 가격 불일치가 원천 차단됩니다(사용자 신뢰도와 직결).
  • Phase 1에서 Mongo 조회 비용을 낮춰뒀기에, 가격 경로는 평시에도 이미 Redis를 거치지 않습니다. 즉 Redis가 흔들려도 가격 경로는 멀쩡합니다.
  • 운영 충돌을 막기 위해 v2/v3 Redis를 완전히 분리(별도 RedisTemplate)했고, 실패한 id만 Slack으로 알리되 업서트는 멱등이라 메시지는 ACK 처리해 중복 캐싱을 막았습니다.

그 결과 Redis 부하가 내려가 master CPU를 10% 아래로 유지할 수 있게 됐고, MongoDB CPU 부하도 20~30%에서 10% 이하로 떨어졌습니다. 무엇보다 Redis가 더는 병목이 아니게 되면서, 이제 애플리케이션 pod를 늘리면 그대로 스케일아웃이 됩니다.

(필요하면 상품 캐시나 pod별 로컬 캐시로 처리량을 더 끌어올릴 카드도 확인했지만, 로컬 캐시는 각 pod가 CDC를 받아야 해서 이번 phase에선 의식적으로 미뤘습니다.)

캐시가 무너져도 멈추지 않게

여기까지가 “더 빠르게”였습니다. 이제 “무너지지 않게”입니다.

우리가 한 번 겪은 일은 분명했습니다 — 원인이 무엇이든(심지어 우리가 잘못 쓴 탓이든) 캐시 계층은 무너질 수 있다. 그렇다면 막으려 애쓰는 것만으로는 부족하고, redis가 죽어도 버티는 구조가 필요했습니다. 그래서 조회 경로를 3 tier로 설계했습니다.

이 방어선은 가격 경로만이 아니라 메타 조회·가격 조회 모두를 커버합니다. 어느 한 계층이 무너져도 다음 계층이 받아내고, 최후에는 MongoDB 직접 조회로 떨어지더라도 서비스는 살아 있습니다.

Phase 1에서 Mongo 조회 비용을 낮춰둔 게 여기서 빛을 발합니다 — Level 3가 “겨우 죽지만 않는” 수준이 아니라 실제로 버틸 만한 성능이거든요.

조회 코드는 “Redis → Local → Mongo, 안 되면 다음”이라는 의도만 드러나게 했습니다.

방어선의 복잡함은 설정(서킷 브레이커)으로 빼두었기 때문에, 메타든 가격이든 같은 패턴으로 감싸 일관되게 보호할 수 있습니다.

// 조회는 항상 위에서부터 시도하고, 서킷이 열려 있으면 다음 단계로 떨어진다. 
// 메타 조회·가격 조회 모두 같은 패턴으로 감싼다. 
public PropertyData getProperty(String partnerId) {
    // Level 1 — Redis (Primary): 평시 경로
    return Try.ofSupplier(
            redisCircuitBreaker.decorateSupplier(
                () -> redisCache.getProperty(partnerId)))
        // Level 2 — Local Cache (Secondary): Redis 서킷 OPEN 시
        .recover(throwable ->
            localCircuitBreaker.decorateSupplier(
                () -> localCache.getProperty(partnerId)).get())
        // Level 3 — MongoDB Direct (Fallback): 최후의 보루
        .recover(throwable ->
            mongoRepository.findProperty(partnerId))
        .get();
}

전환은 서킷 브레이커(resilience4j)로 자동화하되, 티어마다 성격이 다르므로 민감도를 다르게 뒀습니다.

# Redis 티어: 빨리 끊고, 오래 쉬게 한다
redis-tier:
  failure-rate-threshold: 50
  slow-call-duration-threshold: 5s
  wait-duration-in-open-state: 10m
  record-exceptions:
    - RedisConnectionFailureException
    - QueryTimeoutException
# Local 티어: 더 관대하게 — 마지막 보루에 가까우므로
local-tier:
  wait-duration-in-open-state: 5m
  record-exceptions:
    - OutOfMemoryError
    - IllegalStateException

설계 의도는 이렇습니다. Redis는 한번 흔들리면 빠르게 차단하고 충분히 쉬게 합니다(다시 붙였다가 또 무너지는 걸 막기 위해, OPEN을 길게). 반대로 Local 캐시는 마지막에 가까운 방어선이라 쉽게 끊지 않습니다. 그리고 각 티어가 “무엇을 장애로 볼지”도 분리했습니다 — Redis는 연결 실패·타임아웃을, Local은 메모리 고갈류를 기준으로.

그리고 실전에서 검증됐습니다

설계를 마친 뒤, 서킷을 강제로 열어 로컬로 매끄럽게 넘어가는지 사전 확인했습니다.

그런데 얼마 지나지 않아 예기치 않게 Redis 연결이 끊기는 상황이 실제로 발생했습니다.

결과는 설계한 그대로였습니다. 서킷 브레이커가 열리며 트래픽이 곧바로 로컬 캐시로 흘렀고, 사용자는 아무것도 느끼지 못했습니다. 과거 같은 상황이라면 전 pod가 timeout으로 무너졌을 텐데, 이번엔 모니터링 그래프 위의 작은 점 하나로 끝났습니다.

정리하며

성능 향상이 1차 목표였던 건 맞습니다. 하지만 정말 공들인 건 “이 캐시가 또 무너지면 어쩌지”라는 불안을 구조로 없애는 일이었습니다. 여러 메모리 아키텍처를 저울질하고, 빠른 길 대신 버티는 길을 고르고, 티어마다 차단 정책을 다르게 다듬은 이유가 거기에 있습니다.

다음 편에서는 이 모든 걸 가능하게 한 토대 — 파편화된 MongoDB Document를 어떻게 도메인 단위로 다시 설계했는지(조이), 그리고 가격 계산을 어떻게 독립 모듈로 떼어냈고 skill을 활용해 페이지 타입별 마이그레이션을 어떻게 처리했는지 더 깊이 다룹니다.

그리고 한 가지 더. 이 글에는 기록의 의미도 있습니다. 지금 전시팀이 수행 하는 가격제공이 변경 예정 입니다.

즉 이 시리즈에서 이야기하는 코드의 일부는 머지않아 우리 손을 떠납니다. 그 전에, 우리가 어떤 문제를 만났고 어떤 고민 끝에 지금의 구조에 도달했는지를 남겨두고 싶었습니다. 떠나보내는 코드에 대한 작은 회고이기도 합니다.

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


메타데이터
post_id
e14159d375b4
slug
더-빠르게-그리고-무너지지-않게-전시-아키텍처-개선기-1-3-e14159d375b4
url
https://techblog.gccompany.co.kr/%EB%8D%94-%EB%B9%A0%EB%A5%B4%EA%B2%8C-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%AC%B4%EB%84%88%EC%A7%80%EC%A7%80-%EC%95%8A%EA%B2%8C-%EC%A0%84%EC%8B%9C-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EA%B0%9C%EC%84%A0%EA%B8%B0-1-3-e14159d375b4
canonical_url
https://techblog.gccompany.co.kr/%EB%8D%94-%EB%B9%A0%EB%A5%B4%EA%B2%8C-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%AC%B4%EB%84%88%EC%A7%80%EC%A7%80-%EC%95%8A%EA%B2%8C-%EC%A0%84%EC%8B%9C-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EA%B0%9C%EC%84%A0%EA%B8%B0-1-3-e14159d375b4
author_url
https://medium.com/@philip_park
status
ok
fetched_at
2026-07-15 09:29:16