저장 직전에 바뀐 상태를 다시 검증하기
실무 · 화면 진입과 저장 사이의 상태 전이를 서버에서 확인
기업용 플랫폼의 운영 문제를 상태와 데이터 흐름에서 추적하고 서버 로직을 개선했습니다.
회사에서 해결한 문제를 먼저 소개합니다. 개인 프로젝트는 아래에서 코드와 테스트를 함께 볼 수 있습니다.
실무 · 화면 진입과 저장 사이의 상태 전이를 서버에서 확인
실무 · 실행 주체 제어와 고객사·배치별 예정 목록 대조
개인 프로젝트 · DB·Redis·RabbitMQ의 공개 구현과 동시 요청 테스트
다른 실무 사례 · 구매 트랜잭션 · RabbitMQ 요청·결과 분리 · 복합 식별자
근거 안내 · 실무 사례는 당시 코드·테스트 정의·저장된 실행 기록을 기준으로 작성했습니다. 회사 테스트를 현재 환경에서 다시 실행한 결과는 아닙니다. 운영 개선 수치와 실행 결과가 없는 항목은 각 사례에 표시했습니다.
이엠캐스트 · B2B 리테일 교육 플랫폼 · 백엔드 개발·운영
B2B 리테일 교육 플랫폼의 백엔드 개발·운영 중 발생한 문제와 수정 내용을 정리했습니다. 회사 코드와 고객 정보는 공개하지 않습니다.
설정 화면을 연 뒤 관련 작업이 시작되면, 이전 상태를 기준으로 저장하면서 기존 데이터와 변경된 처리 기준이 달라질 수 있었습니다.
별도 개인 프로젝트 · Member Event Consistency의 상태 보호 구현과 테스트.
동시 구매 요청에서 주문 저장 중 DB 데드락이 발생했습니다. 결제 처리를 별도 트랜잭션으로 옮긴 뒤에는 앞 단계의 미커밋 상태를 읽어 결제가 실패하는 문제도 드러났습니다.
StockRush는 개인 프로젝트에서 구현한 주문 상태·실패 복구의 보조 자료입니다. 회사 결제 시스템의 실행 증거와 구분합니다.
Spring 스케줄러가 서버마다 실행되면서 동일 배치가 두 번 처리됐습니다. 업무 기록에는 수정 전 API 서버를 한 대로 제한한 임시 대응이 남아 있습니다.
분산락 도입과 모니터링은 별도 시기에 개선했습니다. 정상 결과도 요약에 포함합니다.
요청 발행 누락과 발행 후 결과 미수신을 구분했습니다. Slack 요약에는 정상 처리 결과도 포함했습니다.
같은 사용자에게 여러 그룹 데이터가 있는데 사용자 ID만으로 구분해, 선택한 삭제 항목 일부가 누락됐습니다. 조회 제외 조건과 페이지 합계도 실제 행 수와 어긋났습니다.
Member Event Consistency에서는 개인 프로젝트의 조건부 갱신·동시 요청 결과를 별도로 확인할 수 있습니다.
대량 발송이 동기 RPC의 응답 제한을 넘으면서 실제 수신과 관리자 화면의 실패 이력이 어긋났습니다. 발송 요청 접수와 최종 결과를 구분해야 했습니다.
Member Event Consistency의 재처리·DLQ는 별도 개인 프로젝트 구현 근거입니다.
직접 설계·구현한 프로젝트입니다. 상세에서 기술 선택, 코드와 테스트 조건을 확인할 수 있습니다.
주문 상태 전이와 Transactional Outbox, 실패 복구
중복 보상·쿠폰 정원·포인트 잔액의 동시성 제어
검색 전 권한 필터와 근거 기반 응답 제어
LLM 요청 정책, 캐시와 외부 모델 장애 처리
CDC 이벤트 식별과 재처리·데이터 계보
이벤트 처리와 추천 배치 스냅샷
한정 재고 주문에서 결제 지연·이벤트 중복·Kafka 중단에 대응하도록 Saga, Outbox와 중복 처리를 막는 소비자를 구현했습니다.
처리 완료 이벤트 키로 중복 차단
취소 주문 상태 유지
실패 상태와 재처리 경로 보존
동시 주문, 결제 실패와 지연, Kafka 중단, 이벤트 재처리 상황에서는 주문 상태와 재고 수량, 결제 결과, 조회 모델이 서로 다른 속도로 바뀝니다. 서비스별 상태 변경과 실패 처리 로직을 구현하고 주문의 최종 상태를 테스트했습니다.
재고·결제·쿠폰·출고 상태 전이를 주문 서비스에서 조율
DB 변경과 Outbox 행을 함께 저장해 발행 실패를 재처리
PersistentCreateOrderService.create
파일services/order-service/src/main/java/com/stockrush/order/application/PersistentCreateOrderService.java
boolean saved = orderRepository.saveIfAbsent(result.order(), command.idempotencyKey());
if (!saved) {
return CreateOrderResult.replayed(replayOrder(command.idempotencyKey(), command.memberId()));
}
outboxEventRepository.save(result.outboxEvent());
처리 조건주문 저장과 Outbox 기록을 같은 트랜잭션에 두고 같은 요청이 다시 들어오면 기존 주문을 조회해 반환합니다.
코드와 통합 테스트로 중복 이벤트 처리, 취소 상태 유지와 발행 재시도를 확인했습니다. 실제 결제 연동, 다중 리전, 운영 규모 부하와 발행기의 고가용성은 테스트하지 않았습니다.
첫 로그인 보상 중복 지급, 쿠폰 초과 발급과 포인트 음수 잔액을 PostgreSQL 제약·잠금·조건부 갱신으로 막았습니다. Redis 락과 RabbitMQ 단일 소비자 처리 방식은 동시 요청 테스트로 비교했습니다.
7건 거절 · 중복 0건
1건 성공 · 음수 잔액 0건
3건 발급 · 5건 거절
모든 회원 이벤트를 하나의 사용자 ID 잠금으로 묶으면 특정 키에 요청이 몰리고, 아무 제어 없이 처리하면 중복 보상, 쿠폰 초과 발급, 포인트 음수 잔액과 후속 이벤트 누락이 발생합니다.
유니크·CHECK 제약, 조건부 갱신과 행 잠금 적용
회원·캠페인별 Redis 잠금을 적용하고 DB 보호 조건 유지
SqlCouponCampaignRepository.issueWithCapacityGuard
파일backend/src/main/java/com/example/consistency/coupon/SqlCouponCampaignRepository.java
where id = ? and status = 'ACTIVE' and issued_count < capacity for update
처리 조건캠페인 행 잠금과 용량 조건을 한 SQL 흐름에서 판정해 동시 요청의 초과 발급을 차단합니다.
PostgreSQL·Redis·RabbitMQ Testcontainers를 사용했습니다. RabbitMQ는 Spring 인스턴스 1개·소비자 1개로 테스트했습니다. Redis 락 장애, 장시간 Redis·RabbitMQ 장애 복구, 운영 규모 부하·SLO와 방식별 성능 비교는 포함하지 않았습니다.
권한 조건을 적용한 검색 결과로 모델 입력과 출처를 구성했습니다. 허용된 근거가 없으면 모델을 호출하지 않고 거절 응답을 반환합니다.
워크스페이스·소유자·부서 권한 선필터
빈 인용과 거절 사유 반환
권한 확인을 거친 검색 결과로 구성
기업 내부 정책 RAG는 검색 품질만으로 부족합니다. 사용자가 볼 수 없는 문서가 검색 후보에 섞이지 않아야 하고, 답변에는 근거가 남아야 하며, 근거가 부족한 경우 거절 응답을 반환해야 합니다.
답변 생성 전에 접근 가능한 문단만 검색 후보로 제한
인용 근거가 없으면 답변과 인용을 비운 거절 응답을 반환
RetrievalService.retrieve
파일app/retrieval.py
access_reason = _access_reason(chunk, query)
if access_reason is None:
continue
처리 조건저장소 필터 뒤에도 워크스페이스·소유자·부서 가시성을 다시 판정해 비인가 결과를 제거합니다.
기본 테스트는 메모리 저장소와 테스트 모델을 사용합니다. PostgreSQL 권한 검색은 별도 통합 테스트로 구성했습니다. 공개 데모는 고정 데이터 화면입니다. 사내 인증·운영 OIDC, 관리형 PostgreSQL·pgvector, 실제 OpenAI 호출, 배포 확인과 답변 사실성 자동 검증은 포함하지 않았습니다.
서비스마다 흩어진 LLM 인증·예산·캐시·라우팅·복구 정책을 OpenAI 호환 WebFlux 게이트웨이의 한 요청 처리 순서로 통합했습니다.
사내 문서 검색, 상담 챗봇, 요약 기능처럼 여러 애플리케이션에서 LLM을 호출하는 사용 시나리오를 가정할 수 있으며, 이는 실제 운영 사례가 아닙니다.
요청 흐름: Auth → Quota → Guardrail → Cache → Router → Dispatch → Fallback → Guardrail → Record
인증→할당량→검사→캐시→라우팅→복구→기록
응답 방식과 대상 선택 정책을 분리
일괄·스트리밍·도구 호출
LLM 기능을 여러 애플리케이션에 붙이면 외부 모델 SDK, 장애 대응, 토큰·비용 예산, 캐시, 처리 조건과 요청 기록이 서비스마다 따로 구현됩니다. 게이트웨이가 공통 정책과 요청 기록을 처리하도록 구성했습니다.
OpenAI 호환 /v1/chat/completions로 클라이언트 변경 최소화
인증·할당량·입출력 검사·캐시·라우팅·복구·기록 순서 고정
GatewayPipeline.execute
파일backend/src/main/java/com/example/gateway/api/GatewayPipeline.java
QuotaOutcome quota = quotaGuard.evaluate(request, estimatedTokens);
if (quota != QuotaOutcome.ALLOWED) {
return rejected(request, mode, quota, elapsed(start));
}
처리 조건예상 토큰으로 사용자 조직의 할당량을 먼저 검사해 초과 요청을 외부 모델 호출 전에 종료합니다.
고정 응답 모델과 메모리 저장소로 테스트했습니다. 실제 OpenAI·Anthropic 호출, 운영 Redis·PostgreSQL, 클라우드 배포와 부하 테스트는 포함하지 않았습니다.
CDC 실행 환경, 제어 API와 레이크하우스를 각각 구현했습니다. 같은 DB 변경 이벤트의 중복 저장을 막고, 실패 원인과 재처리 상태를 원본 LSN·오프셋과 함께 기록했습니다.
PostgreSQL 처리 이력 테스트
로컬 수집과 변환 단계별 확인
복구 상태와 원천 정보 추적
CDC 이벤트는 커넥터 재시작과 재전달로 중복될 수 있고 적재 실패 뒤 복구 이력이 사라질 수 있습니다. 수집·제어·적재 구간이 자동 연결되지 않아 종단 전달과 재처리를 보장할 수 없는 범위도 명시했습니다.
수집기·스키마·테이블·기본 키·LSN·변경 종류로 ID 생성, 오프셋 별도 보존
원본·표준 이벤트·재시도·DLQ·재처리 상태를 분리해 원인과 복구를 추적
CanonicalIngestService.ingestRawEnvelope
파일backend/src/main/java/com/example/cdcplatform/event/CanonicalIngestService.java
DebeziumEnvelope envelope = DebeziumEnvelope.parse(rawEnvelopeJson); CanonicalCdcEvent event = CanonicalCdcEvent.fromEnvelope(envelope); String sourceOffsetJson = sourceOffsetJson(envelope); boolean created = canonicalEventPublisher.recordCanonicalEvent(event, sourceOffsetJson);
처리 조건Debezium 원본을 표준 이벤트로 정규화하면서 원천 오프셋을 보존하고 처리 장부 결과로 중복을 판정합니다.
CDC 실행 환경, 제어 API와 레이크하우스를 각각 테스트했습니다. 세 구성요소를 연결한 자동 수집·적재는 구현하지 않았습니다. Kafka 토픽 상시 소비, 장기간 Kafka Connect 실행, AWS S3·Athena·Trino, 운영 규모 부하와 다중 리전은 테스트하지 않았습니다.
중복·실패 행동 이벤트가 사용자 프로필을 오염시키지 않도록 멱등 처리와 DLQ를 적용하고, 추천 점수와 스냅샷 최신성을 함께 기록했습니다.
새 멱등 키로 재처리
브랜드·사이즈·가격·재고 점수
활성 사용자 최신성 확인
패션 커머스 추천은 브랜드, 카테고리, 태그, 사이즈, 가격대와 재고 같은 상품 정보에 사용자 행동 이벤트를 함께 반영해야 합니다. 중복 이벤트나 처리 실패가 프로필을 오염시키지 않도록 이벤트 처리, 배치 스냅샷과 관리자 리포트를 분리했습니다.
외부 설치 없이 도메인·서비스 흐름을 검증
멱등 키·재시도 예산·DLQ·재처리로 오염 차단
apply_event_to_profile
파일src/fashion_personalization/recommendation.py
selected_size = event.payload.get("size")
if isinstance(selected_size, str) and selected_size in product.sizes:
_add_weight(profile.size_preferences, selected_size, max(weight, 0.1))
처리 조건선택한 값이 실제 상품 옵션에 있을 때만 사이즈 선호에 반영해 잘못된 프로필 신호를 막습니다.
단일 프로세스의 메모리 저장소를 사용했습니다. HTTP 이벤트 워커, 외부 브로커, Outbox와 자동 배치 스케줄러는 구현하지 않았습니다. PostgreSQL·SQS·EventBridge·S3 연동, AWS 배포와 실제 ML·A/B 성과는 테스트 범위에 포함하지 않았습니다.