10주간 점진적으로 구축한 이커머스 플랫폼입니다. 회원/상품/주문 도메인부터 결제, 쿠폰, 대기열, 실시간 랭킹까지 확장하며 클린 아키텍처, 이벤트 기반 설계, 배치 파이프라인을 실전 적용했습니다.
| 구분 | 기술 | 버전 |
|---|---|---|
| Language | Java | 21 |
| Framework | Spring Boot | 3.4.4 |
| Cloud | Spring Cloud | 2024.0.1 |
| Database | MySQL | 8.0 |
| Cache | Redis (Master-Replica) | 7.0 |
| Message Queue | Apache Kafka (KRaft) | 3.5.1 |
| Batch | Spring Batch | 5.x |
| Build | Gradle (Kotlin DSL) | 8.x |
| Test | JUnit 5, Testcontainers, MockK | - |
| Monitoring | Prometheus + Grafana | - |
Root
├── apps ( spring-applications )
│ ├── 📦 commerce-api ← REST API 서버 (port 8080)
│ ├── 📦 commerce-batch ← Spring Batch 배치 서버
│ └── 📦 commerce-streamer ← Kafka Consumer 스트리머
├── modules ( reusable-configurations )
│ ├── 📦 jpa ← JPA + QueryDSL + MySQL 설정
│ ├── 📦 redis ← Lettuce Master-Replica 설정
│ └── 📦 kafka ← Kafka Producer/Consumer 설정
└── supports ( add-ons )
├── 📦 jackson ← 직렬화 설정
├── 📦 monitoring ← Prometheus/Grafana 연동
└── 📦 logging ← 구조화 로깅
설계 원칙:
apps는 실행 가능한 SpringBootApplication으로, BootJar만 생성modules는 도메인에 의존하지 않는 reusable configurationsupports는 logging, monitoring 등 부가 기능 add-on
interfaces/ ← Controller, Kafka Listener, Batch Job 설정
↓
application/ ← Facade, Use Case 조합 계층
↓
domain/ ← Entity, Value Object, Repository 인터페이스, 비즈니스 규칙
↓
infrastructure/ ← JPA Repository 구현, Redis 연동, 외부 API 클라이언트
┌─────────────┐ ┌─────────────┐ ┌──────────────────┐
│ commerce-api│────▶│ MySQL 8.0 │◀────│ commerce-batch │
│ (port 8080)│ │ (port 3306)│ │ (Spring Batch) │
└──────┬──────┘ └─────────────┘ └────────┬─────────┘
│ │
│ ┌─────────────┐ │
├───────────▶│ Redis Master│◀─────────────┤
│ │ (port 6379)│ │
│ └──────┬──────┘ │
│ ┌──────▼──────┐ │
│ │Redis Replica│ │
│ │ (port 6380)│ │
│ └─────────────┘ │
│ │
│ ┌─────────────┐ ┌────────▼─────────┐
└───────────▶│ Kafka(KRaft)│◀────│commerce-streamer │
│ (port 9092)│ │ (Kafka Consumer) │
└─────────────┘ └──────────────────┘
Week 1 - 회원 도메인 & E2E TDD 기반 구축 PR #37
구현 내용:
- Member 도메인 모델 및 Value Object (
Email,Password,Nickname) 구현 - 회원가입 API (
POST /api/v1/members) - 이메일 중복 검증, BCrypt 비밀번호 암호화 - 내 정보 조회 API (
GET /api/v1/members/me) - JWT 인증 기반 - 비밀번호 변경 API (
PATCH /api/v1/members/me/password) - 기존 비밀번호 확인 후 변경 - Testcontainers (MySQL) 기반 E2E 통합 테스트 환경 구성
고민했던 부분:
- TDD 사이클을 어떻게 적용할 것인가 → E2E 테스트를 먼저 작성하고 컴파일 에러를 따라가며 구현하는 Outside-In TDD 채택. Controller 테스트가 빨간불 → Service → Domain 순으로 내려가며 구현
- 멀티 모듈 환경에서 테스트 인프라 격리 → 각 app 모듈별
application-test.yml분리, Testcontainers로 DB 자동 프로비저닝하여 로컬 환경 의존성 제거
Week 2 - 설계 문서화 & 도메인 모델링 PR #76
구현 내용:
- 요구사항 정의서 작성 - 회원/상품/주문/쿠폰 도메인별 비즈니스 규칙 정의
- 시퀀스 다이어그램 - 주문 생성, 결제, 쿠폰 적용 흐름 설계
- 클래스 다이어그램 - 도메인 간 연관 관계 및 책임 분리 설계
- ERD 설계 - 테이블 관계, 인덱스 전략, soft delete(
deleted_at) 컬럼 설계
고민했던 부분:
- 주문-상품-쿠폰 간의 의존 방향 설정 → 주문이 상품/쿠폰을 직접 참조하면 양방향 의존 발생 → 도메인 이벤트 기반 느슨한 결합 방향으로 설계
- 설계 단계에서 확장 가능성 vs 현실적 구현 범위의 균형 → YAGNI 원칙에 따라 현재 요구사항에 충실하되, 이벤트 기반 확장 포인트만 열어둠
Week 3 - 상품/브랜드/좋아요/주문 도메인 구현 PR #172
구현 내용:
- Product 도메인: 재고 차감(
decreaseStock) / 증가(increaseStock) 로직, 재고 부족 시 예외 처리, TDD로 검증 - Brand 도메인:
BrandNameValue Object 검증 (길이, 특수문자 제한), BrandService 등록 로직 - Like 도메인: 좋아요 등록/취소/카운트 기능, 유저당 1회 제한 (unique constraint)
- Order 도메인: 주문 생성 유스케이스 - 상품 재고 차감 → 주문 항목 생성 → 총액 계산
- Like API (
POST /api/v1/likes,DELETE /api/v1/likes) 구현 - 트랜잭션 경계 정리 - Facade 계층에서
@Transactional관리, Domain은 순수 비즈니스 로직만 담당 - Brand 인프라 구현 (JPA Converter, Repository)
고민했던 부분:
- 재고 차감 동시성 문제 → 이 시점에서는 도메인 로직의 정합성 검증에 집중, 동시성 제어는 Week 4에서 별도 처리하기로 결정
- 트랜잭션 경계를 어디에 둘 것인가 → Domain 계층에
@Transactional을 두면 인프라 의존성 침투 → Application(Facade) 계층에서 트랜잭션 경계를 관리하고, Domain은 순수 POJO로 유지
Week 4 - 쿠폰 적용 주문 & 동시성 제어 PR #183
구현 내용:
- 주문 시 쿠폰 적용 로직 - 쿠폰 유효성 검증 (만료일, 사용 여부, 최소 주문금액) → 할인 금액 계산 → 주문 총액에 반영
- 주문 정합성 처리 - 재고 차감 + 쿠폰 사용 + 주문 생성이 하나의 트랜잭션에서 원자적 처리
- 좋아요 동시성 제어 - 낙관적 락(
@Version) + retry 메커니즘 적용 - 동시성 통합 테스트 -
ExecutorService+CountDownLatch로 다수 스레드 동시 요청 검증
고민했던 부분:
- 좋아요 동시성 제어 방식 선택 → 비관적 락: 처리량 저하 + DB 커넥션 점유 시간 증가 / 낙관적 락: retry 로직 복잡도 증가 → 좋아요는 충돌 빈도가 낮아 낙관적 락 + retry 채택
- 쿠폰 적용과 재고 차감의 트랜잭션 범위 → 쿠폰만 사용되고 재고 차감 실패 시 데이터 불일치 발생 → 하나의 트랜잭션으로 묶어 all-or-nothing 보장
Week 5 - 상품 조회 성능 최적화 & Redis 캐시 PR #216
구현 내용:
- 상품 목록 조회 API - 좋아요 수 반영 정렬 구조, 커서 기반 페이지네이션
- 인덱스 최적화: 상품 조회 쿼리에 복합 인덱스 설계, 대용량(10만건+) 데이터 성능 검증
- Redis 캐시 적용: 상품 상세 조회에 Look-Aside 캐시 패턴 적용
- 캐시-DB 정합성 검증 테스트 - 상품 수정 후 캐시 무효화 확인
고민했던 부분:
- 좋아요 수를 조회 시 어떻게 효율적으로 반영할 것인가 → 매번 JOIN은 N+1 문제 발생 → 비정규화 카운터 + Redis 캐시로 읽기 부하 분산
- 캐시 무효화 전략 → Write-Through vs TTL 기반 → 상품 상세는 실시간 정합성보다 조회 성능이 중요하므로 TTL 기반 eventual consistency 허용
- 커버링 인덱스 vs 복합 인덱스 → 조회 컬럼이 많아 커버링 인덱스는 비효율적 → WHERE + ORDER BY 절에 맞춘 복합 인덱스로 결정
Week 6 - 결제 시스템 & Resilience 패턴 PR #237
구현 내용:
- 주문/결제 상태 전이 모델: State Machine 패턴으로
PENDING → PAID → CANCELLED상태 흐름 관리, 잘못된 전이 시 예외 - PG 연동 인터페이스:
PgClient인터페이스 + PG Simulator 클라이언트 구현 (외부 의존성 추상화) - 결제 오케스트레이션 (Saga 패턴): 결제 요청 → PG 승인 → 주문 상태 변경, 실패 시 보상 트랜잭션 (결제 취소 → 재고 복구)
- PG Callback 처리: 비동기 PG 응답 수신 → 결제 상태 반영
- 결제 복구 스케줄러: PENDING 상태 타임아웃(5분) 감지 → PG 상태 조회 → 자동 보상 처리
- Resilience4j 적용: PG 호출에
Bulkhead(maxConcurrentCalls) 설정으로 동시 호출 제한 - 결제 동시성 통합 테스트 - PG callback과 사용자 요청 동시 도달 시나리오 검증
고민했던 부분:
- 결제 실패 보상 처리 방식 → Choreography Saga: 이벤트 기반으로 각 서비스가 독립적 보상 / Orchestration Saga: 중앙 조율자가 보상 흐름 관리 → 결제 도메인이 전체 흐름의 중심이므로 Orchestration Saga 채택
- PG callback과 사용자 취소 요청의 동시 도달 → 결제 상태 전이에 비관적 락(
SELECT FOR UPDATE) 적용으로 race condition 방지 - Resilience4j 설정값 결정 → PG 평균 응답시간과 서버 스레드풀 크기를 고려하여 Bulkhead
maxConcurrentCalls와maxWaitDuration설정
Week 7 - 이벤트 기반 아키텍처 & 선착순 쿠폰 PR #293
구현 내용:
- 도메인 이벤트 발행:
ApplicationEventPublisher+@TransactionalEventListener(AFTER_COMMIT)+@Async비동기 핸들러 - Transactional Outbox 패턴:
OutboxEvent엔티티 (eventType enum, payload JSON, status, partitionKey)- Scheduler가 미발행 이벤트를 polling → Kafka로 relay
- Consumer 측
IdempotencyKey테이블로 중복 소비 방어
- Kafka 설정 강화: Producer
acks=all+enable.idempotence=true, Consumermanual ack+ offset commit 보완 - 선착순 쿠폰 시스템:
CouponIssueRequest도메인 모델 (PENDING → ISSUED / FAILED 상태)- Redis
DECR로 쿠폰 재고 원자적 차감 - Kafka Consumer에서 비동기 발급 처리
- 발급 요청 API (
POST /api/v1/coupons/issue) + polling 조회 API
- OutboxEvent partitionKey: orderId 기반 Kafka partition 전략으로 동일 주문 이벤트 순서 보장
- 쿠폰 발급 동시성 테스트 및 Kafka 관련 테스트 보강
고민했던 부분:
- 이벤트 발행과 DB 트랜잭션의 원자성 →
@TransactionalEventListener(AFTER_COMMIT)만으로는 커밋 후 이벤트 발행 실패 시 유실 → Outbox 패턴으로 at-least-once delivery 보장, Consumer에서 멱등 처리로 exactly-once semantics 근사 - Kafka partition 전략 → 같은 주문의 이벤트(생성/결제/취소)는 순서 보장 필요 →
orderId를 partitionKey로 사용하여 동일 파티션 라우팅 - 선착순 쿠폰 재고의 원자적 차감 → DB 락은 병목 → Redis DECR로 원자적 차감 후, 실제 쿠폰 발급은 Kafka 비동기 처리로 분리
Week 8 - Redis 기반 주문 대기열 시스템 PR #335
구현 내용:
- Redis Sorted Set 대기열: score를 timestamp로 사용하여 FIFO 순서 보장
- 대기열 진입 API (
POST /api/v1/queue/enter) - 중복 진입 방지 (NX 옵션) - 대기 상태 조회 API - 현재 대기 순번 + 예상 대기시간 계산
- 토큰 검증 미들웨어 - 주문 API 호출 시 유효한 입장 토큰 보유 여부 확인
- Lua Script 원자 연산:
ZPOPMIN(대기열 dequeue) +SET NX EX(토큰 발급)를 하나의 스크립트로 묶어 원자 실행 - 주문 성공 시 입장 토큰 명시적 삭제 + TTL fallback 이중 전략
- 활성 토큰 보유 유저의 대기열 재진입 차단
- 동시성 테스트: 2000명 동시 진입, 처리량 초과 검증, 토큰 TTL 만료 시나리오
고민했던 부분:
- dequeue + 토큰 발급의 원자성 → 두 Redis 명령을 순차 실행하면 중간 장애 시 토큰 없이 대기열에서 빠져나가는 유저 발생 → Lua Script로
ZPOPMIN + SET NX EX를 단일 원자 연산으로 묶어 해결 - 토큰 TTL 설정 → 너무 짧으면(1분) 정상 사용자도 주문 중 만료, 너무 길면(1시간) 좀비 토큰 누적 → 주문 완료 시 명시적 DEL + TTL(5분) fallback 이중 전략으로 리소스 누수 방지
- 예상 대기시간 계산 정확도 → 단순
순번 × 평균처리시간은 부정확 → 최근 N건의 처리 속도를 기반으로 동적 계산하도록 보완
Week 9 - 실시간 상품 랭킹 파이프라인 PR #383
구현 내용:
- Ranking Score Policy: 이벤트 유형별 가중치 기반 점수 산출 (주문 완료, 좋아요, 조회 등)
- Kafka → Redis 실시간 파이프라인: Kafka 배치 Consumer (
max.poll.records=3000)가 이벤트 소비 → Score Policy 적용 → Redis ZSET(ranking:all:{yyyyMMdd})ZINCRBY로 실시간 적재 - ZUNIONSTORE 기반 carry-over: 자정에 전일 랭킹 데이터를 가중치(0.5)를 낮춰 새 날짜 키로 복사, 콜드 스타트 완화
- Carry-over Scheduler - 매일 자정 자동 실행, 테스트로 동작 검증
- 상품 랭킹 조회 API (
GET /api/v1/rankings) - Redis ZREVRANGE로 TOP N 조회 - 상품 상세 조회에 dailyRank 필드 추가
고민했던 부분:
- 콜드 스타트 문제 → 자정에 새로운 날짜 키(
ranking:all:20250615)가 생성되면 데이터가 비어있음 →ZUNIONSTORE로 전일 데이터를 가중치(0.5)로 carry-over, 당일 이벤트가 쌓이면 자연스럽게 전일 영향 감소 - Redis 키 네이밍 전략 →
ranking:{category}:{yyyyMMdd}형태로 일자별 분리, TTL(3일)로 오래된 키 자동 정리 - Kafka 배치 Consumer 설정 →
max.poll.records=3000,session.timeout.ms=60000, manual ack으로 처리량 확보와 유실 방지 균형
Week 10 - Spring Batch 랭킹 집계 & API 확장 PR #420
구현 내용:
- Daily Metrics Snapshot Batch (
dailyMetricsSnapshotJob):- Redis ZSET 전체 스코어 조회 →
product_metrics테이블에 일별 스냅샷 저장 EntityManager.flush()/clear()1000건마다 호출하여 영속성 컨텍스트 메모리 제어- Empty result guard - Redis에 데이터가 없으면 기존 DB 데이터 보호 (삭제 스킵)
- 잘못된 productId(null, 음수) 필터링
- Redis ZSET 전체 스코어 조회 →
- Weekly/Monthly Rank Aggregation Batch (
rankAggregationJob):JdbcCursorItemReader로product_metrics7일/30일 구간 집계 (SUM + GROUP BY + ORDER BY)- TOP 100 제한으로 불필요한 연산 방지
- Chunk 기반 처리 (chunk size: 100)
WeeklyRankWriter/MonthlyRankWriter- 첫 번째 chunk에서AtomicBoolean으로 기존 데이터 1회 삭제 후 insertmv_product_rank_weekly/mv_product_rank_monthly테이블에 적재
- Ranking API 확장 (
GET /api/v1/rankings?period=DAILY|WEEKLY|MONTHLY):RankPeriodenum으로 기간별 분기- DAILY:
product_metrics직접 조회 (score DESC) - WEEKLY/MONTHLY: materialized view 테이블 조회 (ranking ASC)
- 페이지 크기 제한 (max 100)
- Batch 모니터링:
JobListener,StepMonitorListener,ChunkListener로 실행 메트릭 로깅 - 모든 배치 작업 멱등성 보장 - 동일
requestDate파라미터로 재실행 시 동일 결과 (deleteByDate→saveAll) - E2E 테스트: 집계 정확성, 날짜 범위 필터링, TOP 100 제한, 멱등성, 엣지 케이스
고민했던 부분:
- Redis 스냅샷을 왜 MySQL에 저장하는가 → Redis는 휘발성이고 장기 데이터 보관에 부적합 → MySQL에 일별 스냅샷을 남겨 주간/월간 집계의 안정적인 원천 데이터 확보
- 대량 insert 시 메모리 관리 → JPA
persist()가 1차 캐시에 엔티티를 누적해 OOM 위험 → 1000건마다flush() + clear()호출로 메모리 사용량 일정하게 유지 - Empty result guard → Redis 장애나 데이터 부재 시 배치가 기존 DB 데이터를 삭제하면 안됨 → Redis 결과가 비어있으면 기존 데이터 보호 후 스킵
- 주간/월간 랭킹 테이블 전략 → DB View는 조회마다 집계 연산 발생으로 느림 → Materialized View 전략 (물리 테이블
mv_product_rank_weekly/monthly에 배치로 미리 적재)으로 조회 성능 확보 - Writer의 delete 타이밍 → 모든 chunk마다 delete하면 데이터 유실 →
AtomicBoolean으로 첫 번째 chunk에서만 1회 삭제, 이후 chunk는 insert만 수행
[사용자 주문/이벤트]
│
▼
┌──────────────┐ Kafka Event ┌──────────────────┐
│ commerce-api │ ──────────────────▶ │ commerce-streamer│
│ (Outbox) │ │ (Kafka Consumer) │
└──────────────┘ └────────┬─────────┘
│
Score Policy 적용
│
▼
┌────────────────┐
│ Redis ZSET │
│ (일별 실시간 │
│ 랭킹 스코어) │
└────────┬───────┘
│
Daily Snapshot Batch
│
▼
┌────────────────┐
│ product_metrics│
│ (MySQL 일별) │
└────────┬───────┘
│
Rank Aggregation Batch (7일/30일)
│
┌────────▼───────┐
│ mv_product_rank│
│ _weekly/monthly│
└────────┬───────┘
│
Ranking API 조회
│
▼
┌────────────────┐
│ 클라이언트 │
└────────────────┘
| 주제 | 결정 | 이유 |
|---|---|---|
| 아키텍처 | Clean Architecture + 멀티 모듈 | 도메인 로직 보호, 인프라 교체 용이성 |
| 트랜잭션 경계 | Application(Facade) 계층 | Domain 계층의 인프라 의존성 제거 |
| 동시성 제어 | 낙관적 락 + retry (좋아요), 비관적 락 (결제) | 유스케이스별 충돌 빈도에 따라 전략 분리 |
| 이벤트 발행 | Transactional Outbox 패턴 | DB 트랜잭션과 메시지 발행의 원자성 보장 |
| 대기열 원자성 | Redis Lua Script | dequeue + 토큰 발급을 단일 원자 연산으로 |
| 랭킹 콜드 스타트 | ZUNIONSTORE carry-over | 자정 랭킹 초기화 문제 해결 |
| 장기 랭킹 집계 | Materialized View 전략 | Redis 휘발성 극복, 주간/월간 안정적 집계 |
| 배치 메모리 | flush/clear per 1000건 | 대량 insert 시 영속성 컨텍스트 OOM 방지 |
| 결제 보상 | Orchestration Saga | 결제 중심의 명확한 보상 흐름 |
| 캐시 전략 | TTL 기반 Look-Aside | 상품 상세 읽기 부하 분산 |
| Kafka 신뢰성 | acks=all + manual ack + 멱등 | 메시지 유실 방지 + 중복 소비 방어 |
| Redis 가용성 | Master-Replica + ReadFrom 분리 | 읽기 부하 분산, replica fallback |
local 프로필로 동작할 수 있도록, 필요 인프라를 docker-compose로 제공합니다.
docker-compose -f ./docker/infra-compose.yml uplocal 환경에서 모니터링을 할 수 있도록, prometheus와 grafana를 제공합니다.
애플리케이션 실행 이후, http://localhost:3000 로 접속해, admin/admin 계정으로 로그인하여 확인하실 수 있습니다.
docker-compose -f ./docker/monitoring-compose.yml up애플리케이션 실행 후 http://localhost:8080/swagger-ui.html 에서 API 문서를 확인할 수 있습니다.