개인사업자 중심으로 파편화된 공동구매를 하나의 플랫폼에서 실시간으로 참여할 수 있는 서비스
6개 서비스 · 3종 DB · Kafka 이벤트 기반 · SAGA 보상 트랜잭션 · Redis 분산 락 · k6 부하 테스트 정량 검증
| 항목 | 내용 |
|---|---|
| 원본 개발 | 2022.03 ~ 2022.06 (4명 中 백엔드) |
| 원본 깃허브 | https://github.com/GongGuHaSong/GongGuHaSong |
| 리팩토링 | 2025.10 ~ 2026.03 (1인, 아키텍처/결제/검색/인프라 전체) |
| 수상 | 숙명여대 SOLUX 27기 상반기 우수상 |
| 리팩토링 방식 | 아키텍처 설계, 기술 선택, 결과 검증은 직접 수행. 코드 작성은 AI 도구 활용. |
docker compose up -d
# → http://localhost:3002GongGuHaSong/
├── member-service/ # 회원 관리 (MongoDB)
│ └── domain: Member, Note
├── product-service/ # 상품 관리 (MongoDB + Redis + Kafka)
│ └── domain: Sell, Survey, Like
├── order-service/ # 주문 처리 (MongoDB + Kafka)
│ └── domain: Registration
├── payment-service/ # 결제 — SAGA Orchestrator (MySQL)
│ └── domain: Payment, Card, CompensationOutbox
├── point-service/ # 포인트 — SELECT FOR UPDATE + Redis 분산 락 (MySQL + Redis)
│ └── domain: Point, PointHistory
├── search-service/ # 검색 + 실시간 랭킹 (ES + MongoDB)
│ └── domain: SearchDocument(ES), SearchLog, OrderRecord
├── load-test/ # k6 부하 테스트 스크립트
├── docker-compose.yml # 6개 서비스 + 인프라 통합 배포
└── src/frontend/ # React
물리 인스턴스는 MySQL 1대, MongoDB 1대를 공유하되, 서비스별로 논리 스키마를 분리하고 전용 계정으로 접근 권한을 격리했다. (
mysql-init.sql,mongo-init.js에서 서비스별 DB + 유저 생성, 자기 DB에만 읽기/쓰기 권한 부여)서비스 간 FK 없이 userId, orderId로 논리적 참조. 통신은 Feign/Kafka만 사용.
──→같은 DB 내 참조 (JPA) ····→서비스 간 논리적 참조 (FK 없음)
1건 주문이 3개 서비스에 동시 전달되는 이벤트 드리븐 구조
| 단계 | 흐름 | 방식 | 이유 |
|---|---|---|---|
| 재고 차감 | Order → Product | Feign 동기 | 재고 부족 시 즉시 거절 |
| 후속 처리 | Order → Kafka → Point, Search, Product | 비동기 팬아웃 | 적립·랭킹·캐시 = 도메인 다름 |
| 결제 | 클라이언트 → Payment (SAGA) | REST 동기 | 포인트 차감·카드 결제·보상까지 오케스트레이터가 순서 제어 |
| 성공 흐름 | 실패 시 보상 | |
|---|---|---|
| STEP 1 | HMAC 위변조 검증 | — |
| STEP 2 | 포인트 차감 (동기) + 카드 결제 | ③ 포인트 복구, ② 카드 환불 |
| STEP 3 | 결제 기록 MySQL 저장 | ④ 재고 복구 |
| 주문 적립 | Kafka로 수량 × 100P 자동 적립 | ① 적립 포인트 회수 |
| 보상 실패 시 | — | CompensationOutbox 저장 → 30초 폴링 재시도 (최대 5회) |
ES + Nori 한국어 형태소 분석 · 이벤트 드리븐 즉시 갱신 + 60초 폴링 폴백
| 항목 | 내용 |
|---|---|
| 점수 산정 | 검색횟수 x 0.4 + 주문량 x 0.6 (최근 1시간) |
| 갱신 방식 | 주문 시 Kafka → 즉시 재계산, 검색 시 직접 재계산 |
| 전달 | Redis 캐시 + WebSocket push |
| 자동완성 | /suggest — ES 조회만, 로그 미저장 (랭킹 오염 방지) |
- 깃허브 링크 : https://github.com/GongGuHaSong/GongGuHaSong
- 교내 공구 참여 사이트. 참여 요청, 쪽지, 찜, 회원가입 기능
- MongoDB 단일 DB, 모놀리식 구조, 결제/재고/검색 없음
| 영역 | 문제 |
|---|---|
| 결제 | 없음. "참여 요청"만 존재하고 실제 금액 처리 없음 |
| 재고 | 없음. 수량 제한 없이 무한 참여 가능 |
| 동시성 | 고려 안 됨. 동시 요청 시 데이터 정합성 미보장 |
| DB | MongoDB 단일 DB에 모든 데이터. 금전 데이터도 트랜잭션 없이 처리 |
| 검색 | 없음 |
| 인프라 | 로컬 실행만 가능, 컨테이너화 없음 |
결제/재고/포인트를 새로 만드는 건 당연하고, 진짜 문제는 여러 명이 동시에 주문할 때 금액과 재고가 꼬이지 않게 하는 것이었다.
모놀리식 → 6개 마이크로서비스로 분리. 결제(SAGA), 재고(원자적 차감), 검색(ES + 실시간 랭킹)을 새로 설계했다.
| Before (2022, 팀) | After (리팩토링, 1인) | |
|---|---|---|
| 컨셉 | 교내 공구 참여 + 쪽지 커뮤니티 | 쇼핑몰형 공구 플랫폼 |
| 구매 | 참여 요청만 (결제 없음) | 포인트/카드 실시간 결제 |
| 재고 | 없음 | 자동 차감, 최소수량 달성률 표시 |
| 검색 | 없음 | ES + Nori 한국어 검색, 실시간 랭킹 |
기능을 만든 것만으로는 부족했다. k6로 실제 부하를 걸어보니 단건에서는 보이지 않던 문제들이 드러났다.
| 발견한 문제 | 원인 | 해결 | 검증 결과 |
|---|---|---|---|
| 재고 초과 판매 (300명 → 300건 성공) | 조회-체크-차감 분리 → race condition | MongoDB findAndModify 원자적 차감 |
10,000명 동시 주문에서 정확히 100건만 성공 |
| 포인트 차감-이력 간 누락 | MongoDB에서 별도 연산 → 중간 실패 시 불일치 | MySQL 전환 + @Transactional |
차감과 이력이 하나의 트랜잭션 |
| 동시 적립/차감 시 마이너스 잔액 | MongoDB findAndModify로는 다중 문서 원자성 미보장 |
MySQL SELECT FOR UPDATE 비관적 락 |
21만 건 동시 요청에서 마이너스 잔액 0건 |
| SAGA 보상이 오히려 유실 악화 (90.4% → 97.1%) | 보상 호출이 커넥션 풀에 추가 부하 → 보상 자체가 실패 | CompensationOutbox (로컬 DB 저장 + 30초 폴링 재시도) | 최종 유실 0건 |
2단계에서 적용한 DB 비관적 락(SELECT FOR UPDATE)은 단일 인스턴스에서는 잘 동작했다. 그러나 300 VU 부하에서 p95 469ms, max 2.26s로 지연이 쌓이기 시작했고, timeout이 DB 락 API에서 집중 발생했다.
타임아웃만으로 원인을 단정할 수는 없었지만, 인터넷에 서치하며 여러 문서를 보다 보니
DB 비관적 락을 사용하면 @Transactional 에 진입과 동시에 커넥션을 점유하는 구조 때문에 락을 못 잡아도 커넥션은 잡고 있게 된다는 사실을 알게 됐다.
반면에 Redis 분산 락은 @Transactional 바깥에서 락을 얻고 대기 후 락을 얻었을 때 트랜잭션을 진입하기 때문에 커넥션 점유가 없다.
실제로 point-service 3대 + nginx 로드밸런서 환경에서 비교 실험을 진행한 결과, Redis 분산 락이 처리량과 지연 모두에서 우위를 보였다.
| 지표 (3대, 300 VU, 2분) | DB 비관적 락 | Redis 분산 락 |
|---|---|---|
| 처리량 | 30,145건 (251 TPS) | 40,580건 (338 TPS) |
| p95 지연 | 1.09s | 704ms |
| 성공률 | 100% | 100% |
| 마이너스 잔액 | 0명 | 0명 |
| 영역 | Before | After |
|---|---|---|
| Backend | ||
| Database | ||
| Infra | — | |
| Frontend | ||
| Test | — |
랭킹 점수 = 검색횟수 × 0.4 + 주문량 × 0.6 (최근 1시간 기준)
| 가중치 | 지표 | 이유 |
|---|---|---|
| 0.4 | 검색횟수 (최근 1시간) | 관심도 반영 |
| 0.6 | 주문량 (최근 1시간) | 실제 구매 전환이 더 강한 신호 |
- 주문 전/후 비교 (가중치 0.6)
전 (단순 검색만 진행했을 때)
후 (주문까지 진행했을 때, 가중치가 높아 최상위로 랭킹)
- 결제 전 금액 상태 (포인트 10만p, 카드 잔여 한도 1,010,000원)
- 결제 전 재고 상태 (진행률 38%, 재고 370개)
- 주문 -> 결제 후 금액 상태 (포인트 2000p, 카드 잔여 한도 210,000원)
=> 포인트 -10만p, 카드 -80만 (총 90만) + 포인트 적립 2000p (20개 * 개당 100p 적립)
- 주문 -> 결제 후 재고 상태 (진행률 63%, 재고 350개)
100개의 재고를 가진 물품을 최대 10,000명의 유저가 동시 구매하는 시나리오를 가정.
- before (동시성 보장 전)
=> 각자 요청 시 재고를 수시로 덮어씌워서 (예: VU-1~50이 동시에 stock=100 읽음 → 각자 99로 저장 → 50건 차감인데 1건만 반영)
-200개가 아닌 33개 남은 것이 된 것으로 추정
=> 결과: 300건 중 300건 모두 결제 완료 (결제 건수 목표 대비 200% 초과), 재고 33개 남음
- After (findAndModify로 조회+차감 원자성 보장 + 재고 정합성 보장)
=> 결과: 300명의 유저의 동시 요청 중 100건만 결제 완료, 재고 0개 남음 (정확도 100%)
=> 결과: 3,000명의 유저의 동시 요청 중 100건만 결제 완료, 재고 0개 남음 (정확도 100%)
=> 결과: 10,000명의 유저의 동시 요청 중 100건만 결제 완료, 재고 0개 남음 (정확도 100%)
k6 테스트 스크립트에서 매 요청마다 10P씩 차감하도록 설계했다.
=> 결과: 300 VU × 5분, 총 213,185건(709 TPS)의 동시 적립/차감 요청 중 마이너스 잔액 0건 (SELECT FOR UPDATE 정합성 검증 완료)
300 VU × 5분, 총 213,185건 (709 TPS)
마이너스 잔액: 0건
성공률: 50.43% (잔액 부족으로 정상 거절된 차감 요청 포함 — 적립/차감 반반 설계)
p95 지연: 469ms
max 지연: 2.26s
총 HTTP 요청: 426,520건 (1,418 req/s)
=> 정합성은 완벽. 성공률 50%는 적립/차감 반반 설계에서 잔액 부족 거절 포함된 정상 수치.
=> 다만 p95 469ms, max 2.26s — 300 VU에서 request timeout 발생 시작.
point-service 3대 + nginx 로드밸런서, 300 VU, 2분간 총 4만 건 부하
DB 비관적 락 vs Redis 분산 락 정량 비교
| 지표 | DB 비관적 락 | Redis 분산 락 |
|---|---|---|
| 처리량 | 30,145건 (251 TPS) | 40,580건 (338 TPS) |
| p95 지연 | 1.09s | 704ms |
| 성공률 | 100% | 100% |
| 마이너스 잔액 | 0명 | 0명 |
=> 결과: Redis 분산 락이 처리량 34% 높고, p95 지연은 35% 낮음
50→300 VU 단계별 부하 + 장애 주입 10% (포인트 차감 후 결제 저장 전 강제 예외)
1인 1결제, 검증: 결제 실패 시 포인트가 복구되는가?
- before (보상 없음)
=> 성공률 71.78% (기대는 90%이지만 외부 요인이 더해져 성공률이 낮아짐), 실시간 누락 건수 656건
=> 결과: 실제 요청 7263건 중 10%의 결제 실패 유도로 약 726건의 결제 실패가 있었으며, 그 중 656건의 포인트 유실이 발생 (90.4% 유실)
- after (SAGA 보상 트랜잭션 + Outbox)
=> 성공률 73.74%, 실시간 누락 건수 854건 — 그러나 k6 test 후 DB 조회 최종 유실 0건
=> 결과: 실제 요청 7,408건 중 10%의 결제 실패 유도로 약 740건의 결제 실패가 있었으며, 실시간 측정에서는 854건의 유실이 감지되었으나 (부하로 인한 타임아웃 이슈 예상)
SAGA 보상 + Outbox 재시도 완료 후 최종 유실 0건 (100% 복구)
- MongoDB
findAndModify로 포인트 차감 → 차감-이력 간 누락 발견 → MySQL@Transactional로 전환 - 낙관적 락/비관적 락 충돌 발견 → 금전 도메인은 비관적 락으로 통일
- SAGA 보상 적용 → 오히려 악화(90.4% → 97.1% 유실) → 원인: 보상 호출이 커넥션 풀에 추가 부하 → CompensationOutbox로 해결 (최종 유실 0건)
- DB 비관적 락의 구조적 한계 (
@Transactional안에서 락 대기 = 커넥션 점유) → Redis 분산 락으로 락 대기를 트랜잭션 바깥으로 분리.RedisLockPointFacade(락) +RedisLockPointInnerService(@Transactional) 별도 Bean 분리
- 위 문제들은 전부 부하 테스트에서 발견했다. 단건에서는 보이지 않는 문제가 300명 동시 요청에서 드러남
- 재고 동시성: 300명 → 3,000명 → 10,000명 스파이크에서 초과 판매 0건
- SAGA 보상 3단계 비교: Before(90.4% 유실) → SAGA only(97.1%, 오히려 악화) → SAGA+Outbox(0% 유실)
- DB 락 vs Redis 락: 단일 인스턴스에서 p95 469ms 한계 확인 → 3대 비교 실험으로 커넥션 점유 병목 검증 → Redis 분산 락이 처리량 34%↑, p95 35%↓
- Elasticsearch + Nori 한국어 형태소 분석, 역인덱스 기반 검색
- 이벤트 드리븐(즉시) + 60초 폴링(폴백) 하이브리드
- 자동완성/실제검색 API 분리 (랭킹 오염 방지)
- 띄어쓰기 검색 에러, 랭킹 오염, 폴링→이벤트 드리븐 전환 트러블슈팅
- 대용량 트래픽 대응을 위해 K8s를 도입했으나, 단일 노드 환경에서는 K8s의 핵심 가치(self-healing, HPA, 롤링 업데이트)가 성립하지 않음을 인지
- 피상적 도입보다 확실한 필요에 의한 선택이라는 판단으로 제거. 과정에서 Eureka 제거, 인프라에 따른 코드 동작 차이 등을 학습
- 단일 MongoDB → Database per Service → MongoDB + MySQL + ES
- NoSQL이 적합한 곳 vs RDBMS가 필수인 곳 판단 근거
- 6개 서비스 + 인프라 통합 배포 과정의 이슈 해결 (Nginx DNS, WebSocket 프록시, ES 기동 순서, Kafka 세션 충돌 등)

