작성일: 2026-04-16 | 면접일: 2026-04-21 | 포지션: 커머스플랫폼유닛 Back-End 개발
NHN에서 4년 동안 소셜 카지노 게임 백엔드와 사내 AI 서비스 파이프라인을 담당했다. 내가 일해온 방식을 한 문장으로 요약하면 "기능이 동작하는 것에서 그치지 않고, 팀 전체가 오래 유지보수할 수 있는 구조를 만드는 것" 이다.
테마 1: 다중 서버 환경에서의 정합성 문제
정적 설정 데이터를 애플리케이션 메모리에 캐싱해 DB 부하를 줄였다. 어드민에서 데이터가 변경·커밋되면 Hibernate PostCommitUpdateEventListener가 발동해 RabbitMQ Fanout Exchange로 변경된 게임 ID를 브로드캐스트한다. 각 서버 인스턴스는 자신의 큐에서 메시지를 수신해 해당 데이터만 선택적으로 갱신한다. 갱신 도중 다른 스레드의 조회 요청이 들어오면 일시적 NPE가 발생했는데, StampedLock의 writeLock으로 갱신 구간을 보호하고 tryReadLock에 2.5초 타임아웃을 걸어 해결했다.
테마 2: 비동기 이벤트 드리븐 설계
스핀 API의 응답 흐름을 동기/비동기로 분리했다. 금액 처리·레벨 변화처럼 즉시 응답이 필요한 로직은 DB 트랜잭션 안에서 처리하고, 미션 진행·통계·알림은 Kafka 비동기로 넘겼다. @TransactionalEventListener(AFTER_COMMIT)으로 커밋 이후에만 메시지가 발행되도록 보장했고, Kafka 전송 실패 시 Outbox Pattern으로 메시지 유실을 막았다.
테마 3: 대용량 데이터 처리 파이프라인
AI 서비스 팀으로 이동해 사내 문서를 OpenSearch에 벡터 색인하는 Spring Batch 파이프라인을 처음부터 설계했다. 11개 Step 분리, AsyncItemProcessor를 활용한 I/O 병렬화, CompositeItemProcessor 4단계 체이닝, 커서 기반 재시작 구조까지 전 과정을 직접 구현했다.
올리브영과의 연결 포인트
올리브영 기술 블로그에서 Cache-Aside + Kafka Event-Driven 하이브리드 설계, Resilience4j 기반 장애 격리, Feature Flag 기반 무중단 배포를 다룬다는 걸 확인했다. 내가 해온 캐시 정합성 설계, Kafka Outbox Pattern, 대용량 배치 파이프라인은 1,600만 고객 규모의 커머스 트래픽 환경에 직접 맞닿는 경험이다.
"여러 서버 인스턴스가 뜬 환경에서 캐시 데이터를 어떻게 동기화했나요? 갱신 중 동시성 문제는 어떻게 처리했나요?"
| 항목 | 구체적 내용 |
|---|---|
| 캐싱 목적 | 정적 설정 데이터(릴 테이블, 심볼 설정)를 JVM 힙에 올려 DB 부하 제거 |
| 전파 방식 | Hibernate PostCommitUpdateEventListener → RabbitMQ Fanout Exchange → 각 인스턴스 큐 → 해당 game ID만 선택적 갱신 |
| 문제 상황 | refreshAll() 진행 중 스레드2 조회 → 기존 데이터 clear 후 새 데이터 로드 전 공백 구간에서 NPE |
| 해결 방식 | StampedLock: 갱신 시 writeLock으로 읽기 차단, 조회 시 tryReadLock(2.5초 타임아웃) |
| 설계 포인트 | StaticDataManager 인터페이스로 데이터 타입별 init/refresh/clear 책임 분리 → 신규 캐시 타입 추가 시 기존 코드 변경 없음 |
S(상황): 다중 서버 환경에서 슬롯 게임의 정적 설정 데이터를 JVM 인메모리에 캐싱해 DB 부하를 최소화하는 구조였습니다.
T(문제): 어드민에서 설정이 변경됐을 때 모든 서버 인스턴스의 캐시를 정확히 동기화해야 했고, 갱신 도중 다른 스레드의 조회 요청이 들어오면 NPE가 발생하는 동시성 문제가 있었습니다.
A(해결): Hibernate PostCommitUpdateEventListener가 커밋 이후에만 발동해 RabbitMQ Fanout Exchange로 변경된 게임 ID를 브로드캐스트하도록 설계했습니다. 동시성 문제는 StampedLock을 도입해 갱신 시 writeLock으로 읽기를 차단하고, 조회 시 tryReadLock에 2.5초 타임아웃을 걸어 갱신이 완료될 때까지 대기하도록 해결했습니다. StaticDataManager 인터페이스로 데이터 타입별 책임을 분리해 확장 구조도 갖췄습니다.
R(결과): 갱신 중 일시적 정합성 오류가 해소됐고, 신규 캐시 타입 추가 시 기존 코드를 건드리지 않는 구조를 만들었습니다.
Q1. StampedLock을 ReentrantReadWriteLock 대신 선택한 이유는 무엇인가요?
StampedLock은 낙관적 읽기(tryOptimisticRead)를 지원해서, 쓰기 락 없이도 읽기를 시도하고 이후 검증하는 방식이 가능합니다. 저의 케이스에서는 갱신 빈도가 낮고 읽기가 압도적으로 많은 구조라서 tryReadLock을 사용했습니다. ReentrantReadWriteLock은 쓰기 락이 걸리면 읽기 락을 아예 막는 반면, StampedLock은 좀 더 세밀한 제어가 가능합니다. 단, StampedLock은 재진입(reentrant)을 지원하지 않아서 같은 스레드에서 중첩 락 획득 시 주의가 필요합니다. 이 점을 감안해 락 범위를 최소한으로 유지했습니다.
Q2. RabbitMQ Fanout Exchange를 선택한 이유와 Redis Pub/Sub과의 차이는?
이미 RabbitMQ 인프라가 팀에 있었고, Fanout Exchange는 Exchange에 바인딩된 모든 큐에 메시지를 자동으로 복제 전달하는 기능이 기본 제공됩니다. 서버 인스턴스가 늘어나도 각 인스턴스가 자신의 큐를 갖기 때문에 코드 변경 없이 확장됩니다. Redis Pub/Sub도 비슷하게 쓸 수 있지만, Pub/Sub은 연결이 끊긴 구독자는 메시지를 유실합니다. RabbitMQ는 큐에 메시지를 보존하기 때문에 인스턴스가 잠시 다운됐다가 재연결해도 발행된 메시지를 소비할 수 있습니다.
Q3. tryReadLock의 2.5초 타임아웃 값은 어떻게 결정했나요?
실제 갱신 작업의 소요 시간을 측정했습니다. clearAllStaticData()와 DB에서 데이터를 다시 로드하는 데 걸리는 시간을 측정하고, 여기에 충분한 버퍼를 더한 값입니다. 너무 짧으면 갱신이 완료되기 전에 락 획득에 실패해 요청이 예외를 받고, 너무 길면 실제 장애 상황에서 요청이 타임아웃까지 대기하게 됩니다. 갱신 빈도가 낮고 갱신 소요 시간이 예측 가능한 정적 데이터였기 때문에 이 방식이 적합했습니다.
Q4. 캐시 갱신 이벤트 수신에 실패하면 어떻게 되나요?
RabbitMQ는 큐에 메시지를 보존하기 때문에 인스턴스가 재연결하면 밀린 메시지를 소비합니다. 인스턴스가 완전히 재시작되는 경우에는 startup 시 DB에서 전체 데이터를 다시 초기화하는 로직이 있어서, 이전 갱신 이벤트를 놓쳐도 최신 상태로 복원됩니다. 다만, 인스턴스 재시작 없이 장시간 메시지 소비가 지연되면 일시적으로 오래된 캐시 데이터를 서빙할 수 있습니다. 이 케이스는 정적 설정 데이터의 특성상 비즈니스 허용 범위 안이었습니다.
Q5. 이 구조를 커머스 환경의 상품 재고나 가격 캐시에 적용한다면 달라지는 점이 있나요?
달라지는 핵심은 변경 빈도와 일관성 요구 수준입니다. 슬롯 설정 데이터는 하루에 수 회 정도 바뀌는 반면, 커머스의 재고나 가격은 훨씬 자주 변경됩니다. 변경 빈도가 높아지면 Fanout Exchange 전파 지연 동안 오래된 캐시가 서빙되는 구간이 길어져서 문제가 됩니다. 올리브영 기술 블로그에서처럼 Cache-Aside 방식으로 TTL을 짧게 유지하거나, 재고처럼 정확성이 중요한 데이터는 캐시에서 조회 후 DB 확인을 병행하는 방식을 고려해야 합니다.
"Kafka를 사용한 비동기 처리에서 메시지 유실 없이 처리를 보장한 경험이 있나요? 트랜잭션과 메시지 발행 시점을 어떻게 관리했나요?"
| 항목 | 구체적 내용 |
|---|---|
| 응답 흐름 분리 기준 | 즉시 응답 필요: 금액 처리·레벨 변화 → DB 트랜잭션 안에서 동기 처리 |
| 최종 일관성으로 충분: 미션 진행·통계·알림 → Kafka 비동기 | |
| AFTER_COMMIT 이유 | BEFORE_COMMIT으로 발행 시 DB 롤백이 발생해도 Kafka 메시지는 나간 상태가 됨 |
| Outbox 설계 | Kafka 전송 실패 → REQUIRES_NEW 별도 트랜잭션으로 실패 메시지 DB 저장 → 스케줄러 재전송 |
| traceId | 실패 메시지에 traceId 함께 저장해 원인 추적 가능 |
S(상황): 슬롯 스핀 API 하나에 금액 처리, 미션 업데이트, 통계, 알림이 모두 포함되어 트랜잭션이 너무 크고 응답 시간도 느렸습니다.
T(문제): 응답 흐름을 분리하면서 Kafka 메시지 발행 시점과 DB 커밋의 원자성을 보장해야 했습니다. DB 커밋 전에 메시지가 나가거나, 커밋 후 Kafka 전송이 실패하면 데이터 불일치가 생깁니다.
A(해결): @TransactionalEventListener(AFTER_COMMIT)으로 커밋 이후에만 메시지가 발행되도록 했습니다. Kafka 전송 실패 시에는 Propagation.REQUIRES_NEW 별도 트랜잭션으로 실패 메시지를 DB에 저장하고, 스케줄러가 주기적으로 재전송하는 Outbox Pattern을 구현했습니다. traceId도 함께 저장해 실패 원인을 추적할 수 있도록 했습니다.
R(결과): 동기/비동기 흐름을 명확히 분리해 응답 시간을 줄이고, 메시지 유실을 막는 구조를 갖췄습니다.
Q1. BEFORE_COMMIT과 AFTER_COMMIT의 차이가 실제로 어떤 문제를 만드나요?
BEFORE_COMMIT으로 Kafka 메시지를 발행하면, 발행 직후 예외가 발생해 DB 트랜잭션이 롤백되어도 Kafka 메시지는 이미 브로커에 전달된 상태가 됩니다. Consumer는 DB에 반영되지 않은 이벤트를 처리하게 됩니다. AFTER_COMMIT을 쓰면 DB 커밋이 완료된 시점에 메시지가 발행되므로 이 문제를 방지할 수 있습니다. 단, AFTER_COMMIT 시점에 Kafka 발행이 실패하면 DB는 커밋됐는데 메시지는 없는 상황이 됩니다. 바로 이 케이스를 Outbox Pattern으로 처리했습니다.
Q2. Outbox 테이블에서 재전송 스케줄러의 주기와 재시도 횟수는 어떻게 관리했나요?
재시도 횟수 컬럼을 Outbox 레코드에 관리해서, 일정 횟수를 초과하면 더 이상 재시도하지 않고 별도로 집계해 모니터링할 수 있도록 했습니다. 스케줄러 주기는 비즈니스 SLA에 따라 설정하고, traceId를 통해 어떤 요청에서 발생한 실패인지 추적할 수 있게 했습니다. 알림처럼 실시간성이 중요한 이벤트는 짧은 주기로, 통계처럼 지연이 허용되는 이벤트는 긴 주기로 분리하는 방향도 고려했습니다.
Q3. Consumer에서 중복 메시지가 들어올 때 어떻게 처리했나요?
at-least-once 특성상 Consumer는 멱등하게 설계했습니다. 미션 진행 이벤트는 상태 머신 기반으로 이미 처리된 미션 ID에 대한 재처리를 방지합니다. 통계의 경우 idempotency key(예: spinId) 기반의 upsert 패턴으로 같은 이벤트가 중복 처리되어도 결과가 동일하도록 설계했습니다.
Q4. 동기 처리(금액/레벨)와 비동기 처리(미션/통계)를 나누는 기준이 무엇이었나요?
두 가지 기준이 있었습니다. 첫 번째는 즉각적인 응답 필요 여부입니다. 금액과 레벨은 스핀 응답에 포함되어 클라이언트가 즉시 보여줘야 하지만, 통계와 알림은 약간 지연되어도 사용자 경험에 영향이 없습니다. 두 번째는 장애 시 롤백 영향 범위입니다. 동기 흐름 안에 너무 많은 로직이 있으면 하나가 실패할 때 전체가 롤백됩니다. 비즈니스 핵심인 금액 처리만 동기 트랜잭션 안에 두고, 부가 처리는 분리해 장애 전파를 최소화했습니다.
Q5. Kafka 브로커가 완전히 다운된 상황에서 Outbox Pattern이 어떻게 동작했나요?
Kafka 브로커가 다운되면 전송 시도가 실패하고 예외가 발생합니다. AFTER_COMMIT 이후 발행 실패이므로 DB 트랜잭션은 이미 커밋된 상태입니다. 이때 REQUIRES_NEW 트랜잭션으로 실패 메시지를 Outbox 테이블에 저장합니다. 스케줄러는 Kafka 가용성과 관계없이 주기적으로 Outbox 테이블을 조회해 재전송을 시도합니다. 브로커가 복구되면 쌓인 메시지들이 순차적으로 재전송됩니다.
"대용량 데이터를 처리하는 배치 작업을 설계한 경험이 있나요? 실패 격리, 재시작, 성능 최적화를 어떻게 처리했나요?"
| 항목 | 구체적 내용 |
|---|---|
| 파이프라인 규모 | 수천 개 Confluence 문서 → OpenSearch 벡터 색인, 11개 Step 분리 |
| 병목 분석 | 임베딩 API 호출은 네트워크 I/O, 동기 처리 시 청크(10개) 처리에 최소 2초 소요 |
| 해결 방식 | AsyncItemProcessor + AsyncItemWriter로 청크 내 문서를 스레드풀에서 병렬 처리 |
| 비용 절감 | ChangeFilterProcessor: Confluence version 필드 비교 → 미변경 문서 임베딩 API 호출 스킵 |
| Step 간 데이터 공유 | @JobScope 빈 사용 (JobExecutionContext는 청크 커밋마다 DB 직렬화 → 수천 개 ID 저장 부적합) |
| 재시작 | ItemStream 구현으로 커서 위치를 ExecutionContext에 저장, 실패 지점부터 재시작 |
S(상황): 사내 AI 서비스의 RAG 기능을 위해 수천 개 Confluence 문서를 OpenSearch에 벡터 색인해야 했습니다. 임베딩 API 호출이 네트워크 I/O라 동기 처리 시 매우 느렸습니다.
T(문제): 처리 속도 개선, 중간 실패 시 재시작 가능한 구조, 미변경 문서의 불필요한 임베딩 API 호출 방지가 필요했습니다.
A(해결): 수집·변환·임베딩·색인·삭제 동기화를 11개 Step으로 분리해 실패 격리를 구현했습니다. 임베딩 API 병목은 AsyncItemProcessor로 청크 내 문서를 스레드풀에서 병렬 처리해 해결했습니다. ChangeFilterProcessor에서 버전 비교로 미변경 문서를 스킵하고, ItemStream으로 커서 위치를 저장해 실패 지점부터 재시작할 수 있도록 했습니다.
R(결과): I/O 병렬화로 처리 속도를 단축했고, Step 단위 실패 격리로 부분 실패 시에도 이전 결과를 보존할 수 있는 구조를 갖췄습니다.
Q1. AsyncItemProcessor는 어떻게 동작하는지 구체적으로 설명해주세요.
AsyncItemProcessor는 각 아이템을 TaskExecutor(스레드풀)에 제출하고 즉시 Future를 반환합니다. 청크 내 모든 아이템이 Future로 감싸져서, 실제 I/O 작업은 스레드풀에서 병렬로 실행됩니다. Writer 단계에서는 AsyncItemWriter가 각 Future에 대해 get()을 호출해 결과를 기다린 뒤, 모은 결과를 실제 Writer(OpenSearch 벌크 색인)에 위임합니다. 스레드풀 크기는 I/O 특성에 맞게 CPU 코어 수보다 크게 설정했는데, I/O 대기 시간이 길수록 더 많은 스레드가 동시에 I/O를 기다릴 수 있기 때문입니다.
Q2. @JobScope 빈에 Step 간 공유 데이터를 저장한 이유가 무엇인가요?
처음에는 JobExecutionContext에 페이지 ID 목록을 저장했습니다. 그런데 JobExecutionContext는 청크 커밋마다 BATCH_JOB_EXECUTION_CONTEXT 테이블에 직렬화됩니다. 수천 개의 페이지 ID를 청크마다 DB에 읽고 쓰는 건 불필요한 I/O 부하입니다. @JobScope 빈은 Job 실행 단위로 생성되는 메모리 내 싱글톤이라 DB 직렬화 없이 데이터를 공유할 수 있습니다. JobExecutionContext는 재시작을 위한 커서 위치처럼 꼭 영속해야 하는 경량 상태에만 씁니다.
Q3. 재시작 시 @JobScope 빈이 비어있는 문제는 어떻게 해결했나요?
Job 실패 후 재시작하면 새 JobExecution이 생성되고 @JobScope 빈도 새 인스턴스로 초기화됩니다. 그런데 상태를 채우는 Step들이 이전 실행에서 COMPLETED 처리되어 스킵되면, 빈이 빈 상태로 남아서 NPE가 발생합니다. 해결 방법으로 상태 로더 Step에 allowStartIfComplete(true)를 설정해서, 재시작 시에도 해당 Step이 반드시 재실행되게 했습니다. 이 Step들은 실행 시간이 짧아서 재실행 비용이 크지 않습니다.
Q4. CompositeItemProcessor의 4단계 중 ChangeFilter를 가장 앞에 둔 이유는?
이후 단계(데이터 보강, ADF→Markdown 변환, 임베딩 API 호출)가 모두 외부 API 호출을 포함하는 비용이 큰 작업입니다. 버전 비교로 미변경 문서를 가장 먼저 걸러내면, 나머지 단계를 아예 건너뛸 수 있습니다. 실제로 대부분의 배치 실행에서 전체 문서 중 일부만 변경되기 때문에, ChangeFilter를 앞에 두는 것만으로도 임베딩 API 호출 횟수를 크게 줄일 수 있었습니다.
Q5. 스페이스마다 메타데이터 포맷이 다를 때 기존 코드를 어떻게 건드리지 않고 처리했나요?
ConfluenceDocumentMetadataProvider 인터페이스로 메타데이터 빌드 로직을 추상화했습니다. 기본 포맷을 위한 DefaultConfluenceDocumentMetadataProvider와 신규 스페이스 포맷을 위한 별도 구현체를 만들고, 각 배치 잡 Config에서 @Qualifier로 원하는 구현체를 주입합니다. EmbeddingProcessor는 인터페이스에만 의존하기 때문에 신규 스페이스 추가 시 EmbeddingProcessor 코드를 건드릴 필요가 없습니다. 전략 패턴을 처음부터 도입했기 때문에 나중에 스페이스가 추가됐을 때 수월하게 대응할 수 있었습니다.
"복잡하게 얽힌 비즈니스 로직을 어떻게 정리하고 구조화했나요? JPA나 Hibernate를 활용하면서 도메인 설계에서 어떤 선택을 했나요?"
| 항목 | 구체적 내용 |
|---|---|
| 리팩터링 전 문제 | 슬롯 5종 이상 쌓이면서 각 슬롯이 공통 흐름을 직접 구현, SlotConfigFactory가 모든 슬롯 케이스 보유 |
| Template Method | AbstractPlayService로 스핀 공통 흐름 통합, SpinOperationHandler 인터페이스로 타입별 동작 위임 |
| OCP 적용 | ExtraConfig 생성 책임을 각 슬롯에 위임 → 신규 슬롯 추가 시 SlotConfigFactory 수정 불필요 |
| Hibernate 활용 | PostCommitUpdateEventListener로 커밋 이후 자동으로 캐시 갱신 트리거 |
| 테스트 인프라 | 447개 테스트 파일, AbstractSlotTest 제네릭 추상 클래스로 게임 타입별 초기화 자동화 |
| DI 전환 | static 의존 → DI로 전환해 테스트에서 모킹 가능하게 |
S(상황): 슬롯이 5종 이상 쌓이면서 각 슬롯이 공통 처리 흐름(베팅 검증, 스핀 결과 계산, 후처리)을 직접 구현하고 있었고, 모든 슬롯의 설정 생성 책임이 SlotConfigFactory 하나에 몰려 있었습니다.
T(문제): 새 슬롯을 추가할 때마다 다른 슬롯의 코드를 건드려야 하고, 테스트가 없어서 리팩터링이 두려운 상황이었습니다.
A(해결): AbstractPlayService 단일 템플릿으로 공통 흐름을 통합하고, 타입별로 달라지는 동작은 SpinOperationHandler 인터페이스로 위임했습니다. ExtraConfig 생성 책임을 각 슬롯에 위임해 OCP를 적용했고, static 의존을 DI로 전환해 테스트 가능성을 높였습니다. AbstractSlotTest 제네릭 추상 클래스로 테스트 인프라를 만들어 총 447개 테스트 파일에서 핵심 로직을 커버했습니다.
R(결과): 이후 슬롯 3종을 추가할 때 기존 코드를 건드리지 않았고, 테스트 안전망이 있어 리팩터링과 신규 기능 추가를 빠르게 진행할 수 있었습니다.
Q1. interface default 메서드 대신 추상 클래스를 선택한 구체적인 이유는 무엇인가요?
SlotService 인터페이스에 default 구현이 늘어나면서 두 가지 문제가 생겼습니다. 첫째, interface default 메서드는 인스턴스 필드를 가질 수 없어서 공통 로직에 필요한 스프링 빈 의존성을 주입받을 방법이 없었습니다. 둘째, default 메서드는 상태를 가질 수 없어서 여러 메서드 사이에 공유해야 하는 중간 계산 결과를 보관할 곳이 없었습니다. BaseSlotService 추상 클래스로 옮기면 생성자 주입과 필드를 쓸 수 있어서 이 문제가 해결됩니다.
Q2. PostCommitUpdateEventListener를 쓴 이유는 무엇인가요? 다른 이벤트 타입은 왜 안 됐나요?
PreUpdate나 PostUpdate 이벤트는 트랜잭션이 커밋되기 전에 발동합니다. 이 시점에 RabbitMQ로 캐시 갱신 메시지를 보내면, 이후 트랜잭션이 롤백되어도 다른 서버 인스턴스들은 이미 갱신 메시지를 받아서 캐시를 갱신합니다. DB에는 변경이 반영되지 않았는데 캐시만 갱신된 상태가 되어 정합성이 깨집니다. PostCommit은 트랜잭션이 실제로 커밋된 이후에만 발동하므로 이 문제를 방지합니다.
Q3. Decorator 패턴을 도메인 로직에 어떻게 적용했나요?
여러 슬롯 타입에 흩어진 계산 로직(베팅 금액 계산 등)을 도메인 레이어로 이동할 때 사용했습니다. 기존에는 static 유틸 메서드로 여러 곳에서 직접 호출하는 방식이었는데, 이렇게 되면 테스트에서 static 메서드를 모킹할 수 없어서 계산 로직이 포함된 서비스를 격리 테스트하기 어렵습니다. Decorator를 스프링 빈으로 만들고 DI로 주입받으면 테스트에서 모킹이 가능해지고, 계산 로직의 책임 범위도 명확해집니다.
Q4. 447개 테스트 파일을 유지하는 데 드는 관리 비용은 어떻게 처리했나요?
AbstractSlotTest 제네릭 추상 클래스가 관리 비용을 크게 줄였습니다. 각 슬롯 테스트가 공통으로 필요한 초기화(릴 테이블 로드, 심볼 설정, Alias 테이블 생성)를 추상 클래스에서 자동 처리하기 때문에, 새 슬롯 테스트를 추가할 때 초기화 코드를 반복 작성하지 않아도 됩니다. static 메서드를 DI로 전환한 것도 테스트 유지에 큰 도움이 됐습니다. 이전에는 static 의존 때문에 테스트가 외부 자원에 의존하는 경우가 있었는데, DI 전환 후 완전한 격리 테스트가 가능해졌습니다.
Q5. 올리브영의 상품·전시·주문 도메인에서 비슷한 구조 문제가 생길 수 있다고 생각하나요?
커머스 도메인도 유사한 패턴이 반복됩니다. 주문 타입(일반 주문, 묶음 주문, 정기구독 주문)이나 상품 타입(일반 상품, 디지털 상품, 한정판)마다 공통 처리 흐름이 있으면서 타입별로 달라지는 동작이 있습니다. AbstractPlayService + SpinOperationHandler 패턴처럼 공통 흐름을 추상 템플릿으로 통합하고, 타입별 동작은 전략 패턴으로 분리하면 새 주문 타입이나 상품 타입이 추가될 때 기존 코드 변경을 최소화할 수 있습니다. 이 구조를 만들어본 경험이 복잡한 커머스 도메인에 바로 적용될 수 있다고 생각합니다.
"복잡한 상태를 관리하는 시스템을 설계한 경험이 있나요? 비동기 처리와 DB 인덱스 최적화를 실제 문제에서 다뤄본 적 있나요?"
| 항목 | 구체적 내용 |
|---|---|
| 시스템 목적 | RTP 편차 문제 → 백그라운드에서 "좋은 결과"를 미리 생성해 DB에 캐시 |
| 비동기 설계 | @Async로 캐시 생성과 스핀 응답 흐름 분리, 유저 경험에 영향 없음 |
| 인덱스 문제 | 캐시 개수 판단 쿼리(COUNT)가 풀스캔, 복합 인덱스(game_id, bet_index, cache_type, used) 추가로 해결 |
| 동시성 제어 | 낙관적 락 검토 → DB 유니크 키 + 예외 처리 조합 선택 (충돌 빈도 낮음, 충돌 시 재시도 불필요) |
| 추상화 | RccSpinResultAnalyzer 인터페이스로 슬롯별 캐시 조건 분리, OCP 적용 |
| 모니터링 | RccCacheStatisticsService, log_slot_play에 RCC 컬럼 추가 |
S(상황): 순수 확률 기반 슬롯은 짧은 세션에서 RTP 편차가 큽니다. 운이 나쁜 유저는 오랫동안 보상을 받지 못해 이탈할 수 있었습니다.
T(문제): 서비스 응답 흐름에 영향 없이 RTP를 보장하는 캐시 시스템이 필요했고, 여러 서버 인스턴스의 동시 캐시 생성과 성능 문제도 해결해야 했습니다.
A(해결): @Async로 백그라운드 캐시 생성과 스핀 응답 흐름을 분리했습니다. 동시성은 낙관적 락 대신 DB 유니크 키 + 예외 처리로 단순하게 해결했습니다. 캐시 개수 판단 쿼리가 풀스캔하고 있다는 것을 발견하고 복합 인덱스를 추가해 성능 문제를 해결했습니다.
R(결과): 스핀 응답 시간에 영향 없이 RTP 보장 구조를 갖췄고, 백그라운드 쿼리 성능을 개선해 불필요한 CPU 사용을 줄였습니다.
Q1. 낙관적 락 대신 DB 유니크 키 + 예외 처리를 선택한 이유는 무엇인가요?
낙관적 락은 충돌 감지 후 재시도 로직이 필요합니다. 그런데 캐시 생성의 경우, 충돌이 발생했다는 것은 다른 인스턴스가 이미 같은 캐시를 생성 중이라는 의미입니다. 충돌 시 재시도 자체가 의미가 없습니다. DB 유니크 키로 중복 생성을 막고, 위반 예외를 "이미 생성됨"으로 처리하는 방식이 더 단순하고 충분했습니다. 충돌 빈도가 낮은 케이스에서는 낙관적 락의 재시도 복잡도가 오히려 과도한 설계가 됩니다.
Q2. 캐시 개수 판단 쿼리의 인덱스 누락을 어떻게 발견했나요?
캐시 생성 로직이 백그라운드에서 주기적으로 실행되는데, 서버 CPU 사용률이 예상보다 높은 게 이상했습니다. Slow Query Log를 확인하니 COUNT(*) 쿼리가 잡혔습니다. EXPLAIN으로 실행 계획을 보니 game_id, bet_index, cache_type, used 복합 조건이지만 인덱스를 타지 않고 풀스캔이었습니다. 복합 인덱스를 추가하고 나서 실행 계획에 index 타입이 잡히는 것을 확인했습니다. 캐시 생성은 백그라운드에서 계속 돌기 때문에 이 쿼리가 느리면 지속적으로 CPU를 잡아먹습니다.
Q3. 시뮬레이터에서 RCC 캐시가 쌓이는 문제를 어떻게 발견하고 해결했나요?
시뮬레이터로 수만 번 스핀을 돌린 후 DB를 확인했더니 대량의 RCC 캐시 레코드가 생성되어 있었습니다. 시뮬레이터는 실제 유저 요청과 동일한 서비스 레이어를 태우기 때문에 RccHandler도 실행된 것입니다. 시뮬레이터 실행 여부를 나타내는 컨텍스트 플래그를 추가하고, RccHandler 진입 시 이 플래그를 체크해 시뮬레이터 실행 중에는 캐시 생성 로직을 스킵하도록 했습니다.
Q4. RccSpinResultAnalyzer 인터페이스 추상화가 실제로 어떻게 개발 속도를 높였나요?
슬롯마다 "어떤 결과를 좋은 결과로 볼 것인가"의 기준이 달랐습니다. 처음부터 인터페이스로 추상화했기 때문에 새 슬롯에 RCC를 붙일 때 RccCacheGenerator 코드를 건드리지 않고 Analyzer 구현체만 만들었습니다. 총 6종 슬롯에 RCC를 붙였는데, 공통 코드 변경 없이 슬롯별 구현체만 추가하는 방식으로 진행됐습니다. 처음에 추상화하지 않았다면 슬롯마다 조건 분기가 쌓여서 나중에 수정하기 어려운 코드가 됐을 것입니다.
Q5. 이 구조를 올리브영의 커머스 컨텍스트에 비유한다면 어떤 케이스에 적용할 수 있을까요?
비슷한 패턴이 적용될 수 있는 케이스로 미리 생성해두는 추천 결과나 개인화 콘텐츠가 있습니다. 올영세일 같은 피크 트래픽 시간에는 실시간으로 추천 결과를 계산하기보다 미리 생성해둔 결과를 캐시에서 꺼내 제공하면 응답 속도를 보장할 수 있습니다. 백그라운드 생성(@Async), 타입별 생성 조건 추상화(Strategy Pattern), 생성 여부 모니터링이라는 구조 자체는 그대로 적용 가능합니다. 올리브영이 캐시 소진 시 DB에서 직접 서빙으로 graceful degradation 하는 패턴도 RCC와 동일한 구조입니다.
답변하기 전에 이 체크리스트를 머릿속으로 확인한다.
면접관이 어떤 방향으로도 파고들 수 있는 꼬리 질문들을 미리 준비한다.
| 주제 | 예상 날카로운 질문 | 핵심 답변 방향 |
|---|---|---|
| 캐시 | "캐시 히트율은 얼마였나요?" | 정적 데이터라 히트율 매우 높음, 갱신 빈도가 낮아 TTL 없이 이벤트 기반 갱신 |
| Kafka | "Consumer lag이 쌓이면 어떻게 되나요?" | 후처리 로직이므로 약간의 지연은 허용, 모니터링으로 임계치 감지, Dead Letter Topic |
| Spring Batch | "배치 중 OOM이 나면 어떻게 하나요?" | 청크 단위 처리로 전체를 메모리에 올리지 않음, 청크 사이즈 조정, 첨부파일은 바이트 배열 대신 스트림 |
| JPA | "N+1 문제를 경험한 적 있나요?" | 슬롯 설정 조회 시 게임별 N+1 발생 → IN절로 일괄 조회로 해결 (Alias 테이블 조회 최적화) |
| 동시성 | "StampedLock이 데드락 날 수 있지 않나요?" | StampedLock은 재진입 미지원이라 중첩 락 주의, 락 범위 최소화 원칙 준수 |
| 설계 | "추상화를 너무 일찍 한 것 아닌가요?" | 슬롯 5종 이상 쌓인 후에 공통점이 보여서 추상화, 조기 추상화 함정 피하는 원칙으로 답변 |