1. 프로젝트 개요
핵심: 왜 이 프로젝트를 했는지(100만 건, 성능 최적화)를 숫자로 강조
- Slide 1: 표지
- 프로젝트명, 팀명, 팀원 이름
- Slide 2: 기획 배경 및 목표 (Key Metrics)
- 배경: 대규모 트래픽 환경(100만 유저)에서의 배치 처리 및 외부 API 지연(Latency) 상황 시뮬레이션.
- 목표:
- 데이터: 100만 건 이상 처리 (OOM 방지).
- 성능: 목표 TPS 500+ 달성.
- 안정성: 발송 실패 시 100% 복구 (Retry/Fallback).
- Slide 3: 구성원 및 역할 & WBS
- R&R (Role & Responsibility) 간단 명시.
- WBS (간략한 일정 흐름).
2. 프로젝트 설계
핵심: 데이터가 어떻게 흐르는지 큰 그림(Architecture)과 구조(ERD) 보여주기
- Slide 4: 시스템 아키텍처 & 수행 절차
- [이미지] 전체 아키텍처 도 (Batch ➔ DB ➔ Redis ➔ Worker ➔ External API).
- 하이브리드 적재 전략:
- 즉시 발송(Fast Track) vs 예약 발송(Slow Track/DB) 흐름도 시각화. @승혁 조 구현예정
- Slide 5: DB 설계 (ERD)
- [이미지] 핵심 테이블 관계도 (MonthlyInvoice ↔ DeliveryStatus).
- 설계 포인트:
- DeliveryStatus의 Unique Key (중복 발송 방지).
- Scheduled_at 및 인덱스 설계 (스케줄러 성능 최적화). @승혁 조 구현예정
3-0. [Step : 적재] 대용량 데이터 생성 (Dummy)
핵심: 유저 데이터 100만, 청구데이터 500만을 어떻게 만들고 적재했나?
- Slide 5.5: 더미데이터 생성기
- [다이어그램] u+데이터 자료조사, 페르소나 생성, 데이터 생성 과정
- 구현 내용:
- 데이터 자료조사 방법
- 페르소나 기준
- 데이터 생성 로직
- 데이터 적재 방법, 위치
3-1. [Step 1: 정산] 대용량 데이터 처리 (Batch)
핵심: 100만 건을 터트리지 않고 어떻게 DB에 넣었나?
- Slide 6: 정산 프로세스 (Settlement)
- [다이어그램] Reader(1,000건 Chunk) → Processor(계산) → Writer(Bulk Insert) 흐름도.
- 구현 내용:
- Chunk Processing: 메모리 효율을 위해 1,000건 단위 페이징 처리.
- Bulk Operation: JDBC Template을 활용한 일괄 Insert로 I/O 병목 해소.
- 데이터 정합성: BatchAttempt 테이블을 이용한 중복 정산 방지(Locking).
3-2. [Step 2: 적재] Redis 파이프라인과 트랜잭션 (Loading)
핵심: DB에서 꺼내서 Redis로 옮기는 과정 (가장 기술적인 부분)
- Slide 7: 로더 전략 & 하이브리드 적재 (Hybrid Loading)
- [다이어그램] 데이터가 **'즉시 발송(Redis행)'**과 **'예약 발송(DB행)'**으로 갈라지는 분기 처리 그림.
- 구현 내용:
- Bulk Select: findAllUsersByIds (IN절 + JOIN)로 N+1 문제 해결.
- 예약 발송 구현: 미래 시간인 경우 DB(SCHEDULED)에만 저장하고 Redis 적재 건너뜀 (부하 분산).
- 스케줄러: 1분마다 SCHEDULED 데이터를 찾아 Redis로 이관 (ScheduledWorker).
- Slide 8: 트랜잭션 분리 및 성능 최적화 (Troubleshooting)
- [다이어그램] (Before) 긴 트랜잭션으로 인한 DB Lock Timeout → (After) 트랜잭션 분리 후 구조.
- 구현 내용:
- Redis Pipelining(60초 소요) 동안 DB Lock을 잡고 있던 문제 해결.
- DB 저장(Transactonal)과 Redis 적재(Non-Transactional) 분리 적용.
3-3. [Step 3: 발송] 비동기 워커와 지연 극복 (Sending)
핵심: Redis에서 꺼내서 처리하는 과정 (성능 방어)
- Slide 9: 비동기 병렬 처리 (Async Worker)
- [다이어그램] Redis Stream → Worker 스레드 풀 → 병렬 처리(CompletableFuture).
- 구현 내용:
- 지연 시뮬레이션: 외부 응답 1초 지연 상황 가정.
- 병렬성: 동기 방식(Thread.sleep 단일) 대비 높은 처리량(TPS) 확보.
- 안정성: IdempotencyGuard(Redis SETNX)로 중복 발송 원천 차단. @수정 구현예정
3-4. [Step 4: 플랫폼] 도달 보장 및 장애 대응 (Message Platform)
핵심: 최종적으로 잘 도착했는지, 실패했다면 어떻게 했는지
- Slide 10: 재시도 및 채널 전환 (Reliability)
- [다이어그램] [실패] → [Retry 스케줄러] → [SMS Fallback] 흐름도.
- 구현 내용:
- Retry: 실패 건 자동 감지 및 재적재 (retry_count + 1).
- Fallback: 이메일 2회 실패 시 SMS로 자동 채널 전환.
- Mocking: 외부 메시지 플랫폼 연동 로직 모킹(Mocking) 처리.
3-5. 시연 (Demo)
- Slide 11: 구현 영상
- 정산(DB 적재) → 적재(Redis 대기열) → 발송(로그 확인) → 플랫폼(결과 확인) 순서로 영상 편집.
4. 개발 후기 및 느낀 점
핵심: 기술적 성장에 대한 회고
- Slide 12: 향후 개선 사항 및 기대 효과
- 개선: 실시간(Real-time) 발송 API 추가 개발, 모니터링(Grafana) 도입.
- 효과: 마케팅 이벤트 등 대량 트래픽 상황에서도 서비스 안정성 확보.
- Slide 13: 프로젝트 진행 소감 (Lessons Learned)
- 대용량 트래픽에서의 DB Lock 및 트랜잭션 관리의 중요성 체감.
- Redis Stream과 DB 간의 데이터 정합성 유지 전략 습득.
💡 발표 자료 작성 팁
- 로컬에서도 이 정도 시간이 나온다는 멘트는 **Slide 9 (비동기 처리)**나 Slide 11 (구현 영상) 파트에서 성능 지표와 함께 언급하면 설득력이 높습니다.
발표 자료 필수 포함해야 되는 내용
- 배치 진행중 fail에 대한 대처 어떻게 되어있다 포함해야 함
- 데일리스케쥴, 요청에 따른 스케쥴도 가능한지 (배치 진행중 실패건에 대한 처리를 자동으로만 처리하게 했는지, 자동으로 처리도 계속 실패한 건에 대하여 원인을 확인할 수 있는지, 실패 이력에 대해 확인이 가능한지, 실패했던 것들에 대해 관리자 페이지에서 따로 실패건들에 대하여 정산을 요청할 수 있는지)
- 각자 파트별 우리는 어떻게 했을때 어떤 식으로 오류가 났는지 실패를 했던 것들에 대해 어떤걸 사용해서 해결했는지 기술
- 어떤 기술을 사용해서 구현을 하였는데 그 기술을 왜 사용했는지, 수치가 있으면 첨부해서 보여주기
이런식으로 발표 자료 서식을 먼저 준비한 이후, 발표자료 준비 및 프로젝트를 완성시켰습니다.
발표자료
아키텍쳐 다이어그램
















이런식으로 프로젝트 내의 정산 배치 시작부터 완료돼어 발송까지 완료하는 과정입니다.
트러블 슈팅 및 성능 개선 사례
적재 (Loader)
방식으로 전환하고, 조회 결과를 Map에 담아 메모리 내에서 매칭하도록 개선.
배치(Batch)
발송 (Delivery)
프로젝트 진행 소감
팀 협업을 위한 초기 환경 설정과
컨벤션의 중요성을 깊이 체감했습니다. 각 기술의 특징을 바탕으로 아키텍처를 분석하고 선정하는 과정을 경험했으며, 특히 대용량 데이터 처리 시 자원 임계치를 고려한 최적화와 시스템 안정성 확보의 필요성을 확인하는 계기가 되었습니다.
QNA 관련 대비
🎙️ 멀티 모듈 구조에 대한 모범 답변 가이드
1. 관심사의 분리 (Separation of Concerns) 및 의존성 최적화
- 답변: "로직의 성격에 따라 필요한 라이브러리와 의존성이 완전히 다르기 때문입니다."
- 상세 근거:
- Loader 모듈: DB 대량 읽기(JDBC)와 배치 처리에 집중하므로 관련 라이브러리만 포함합니다.
- Worker 모듈: 외부 발송 API(Email/SMS SDK) 호출과 비동기 처리에 집중합니다.
- 효과: 모듈별로 꼭 필요한 라이브러리만 가져가기 때문에 어플리케이션이 가벼워지고, 한쪽 모듈의 라이브러리 업데이트가 다른 쪽에 영향을 주지 않는 의존성 격리가 가능해집니다.
2. 독립적인 확장성 (Scalability)
- 답변: "부하가 발생하는 지점에 따라 특정 모듈만 독립적으로 확장하기 위해서입니다."
- 상세 근거:
- 청구서 생성(Loader)은 하루에 한 번만 돌면 되지만, 실제 발송(Worker)은 처리 속도에 따라 수십 대의 서버가 필요할 수 있습니다.
- 효과: 멀티 모듈로 구성하면 **로더는 1대만 띄우고, 워커는 부하에 따라 10대, 20대로 늘리는 식의 유연한 스케일 아웃(Scale-out)**이 가능합니다. 하나의 통짜(Monolith) 프로젝트였다면 불필요하게 로더 로직까지 포함된 무거운 서버를 여러 대 띄워야 했을 것입니다.
3. 장애 격리 및 빌드 속도 (Maintenance)
- 답변: "시스템의 일부에 문제가 생겨도 전체 서비스로 전이되는 것을 막고, 개발 생산성을 높이기 위함입니다."
- 상세 근거:
- 워커 모듈의 발송 로직을 수정하더라도 로더 모듈은 다시 빌드하거나 테스트할 필요가 없습니다.
- 효과: 코드 수정 범위가 명확해져 유지보수가 쉬워지고, 빌드 및 배포 시간이 단축됩니다.
1. 스케일 아웃(Scale-out) & 스케일 인(Scale-in)
이것은 한마디로 **"일꾼(서버)의 수를 조절하는 것"**입니다.
- 스케일 아웃 (Scale-out): 청구서 발송량이 갑자기 10만 건에서 1,000만 건으로 늘어났을 때, 서버 1대로는 부족하겠죠? 이때 서버를 2대, 5대, 10대로 옆으로 늘리는 것을 말합니다.
- 우리 프로젝트와의 연결: 사용자님이 Redis Stream의 Consumer Group을 사용했기 때문에 가능합니다. 서버를 여러 대 띄워도 레디스가 메시지를 골고루 나눠주니까요.
- 스케일 인 (Scale-in): 발송이 다 끝나서 한가해지면 굳이 서버 10대를 켜둘 필요가 없죠? 돈 아까우니까 다시 1대로 줄이는 것을 말합니다.
2. 레디스를 카프카랑 같이 사용한다는 게 무슨 소리?
보통은 "레디스 쓸래? 카프카 쓸래?"라고 하나만 고르라고 하지만, 거대 기업(넷플릭스, 카카오 등)은 둘을 같이 쓰기도 합니다. 각자의 전공이 다르기 때문입니다.
| 구분 | Redis (우리 시스템) | Kafka (추가 도입 시) |
| 특징 | 속도가 압도적으로 빠름 (In-memory) | 안정성과 보관이 압도적 (Disk) |
| 역할 | 실시간 발송 큐: 지금 당장 빨리 보내야 하는 청구서 처리 | 로그 저장소: 발송된 모든 기록을 몇 달간 안전하게 보관하고 분석 |
🎙️ "왜 같이 쓰나요?"에 대한 답변 예시
"레디스는 실시간성이 중요하고 빠르게 소비되어야 하는 '발송 대기열'에 최적화되어 있습니다. 반면 카프카는 데이터를 영구적으로 보관하고 복잡한 분석을 하기에 유리합니다.
저희 시스템에서 레디스가 발송을 처리하고, 발송이 완료된 결과(로그)를 카프카로 넘겨준다면, 나중에 대량의 발송 데이터를 분석하거나 통계 시스템을 구축할 때 훨씬 안정적인 구조가 됩니다."
3. 질문자가 왜 이런 질문을 했을까? (의도 파악)
질문자가 이 질문을 던진 의도는 **"너의 시스템이 진짜 커져도 버틸 수 있어?"**를 확인하고 싶은 겁니다.
- "스케일 아웃이 가능한가?"
- 대답: "네, Redis Stream의 Consumer Group 기능을 사용했기 때문에 서버만 추가하면 즉시 병렬 처리가 가능한 구조입니다."
- "카프카를 섞어 쓸 생각은 없나?"
- 대답: "현재 규모에서는 레디스만으로도 충분히 빠르고 안정적이지만, 향후 발송 이력 빅데이터 분석이나 타 시스템과의 연동이 필요해진다면 데이터 보존력이 뛰어난 카프카를 뒤단에 배치하여 하이브리드 구조로 발전시킬 수 있습니다."
🚀 [Troubleshooting] Redis 직렬화 함정과 데이터 정합성 사수하기
대용량 발송 시스템을 구축하며 가장 머리 아픈 지점은 **'데이터가 중간에 깨지는 것'**이었습니다. 로더(Loader)가 보낸 데이터를 워커(Worker)가 읽지 못해 발송이 시작조차 안 되는 상황, 그 고군분투의 기록을 정리합니다.
1. RedisTemplate ➔ StringRedisTemplate: "따옴표 하나가 부른 참극"
개발 편의성을 위해 RedisTemplate<String, Object>를 사용했지만, 이것이 오히려 독이 되어 돌아왔습니다.
🔍 문제 상황
- 자바 객체를 저장할 때 Jackson2JsonRedisSerializer 등이 자동으로 클래스 정보나 불필요한 메타데이터를 붙였습니다.
- 특히 숫자가 문자열("")로 감싸지거나 Base64 인코딩 과정에서 중복 따옴표가 발생하여, 워커가 데이터를 꺼낼 때 NumberFormatException을 일으키며 뻗어버리는 문제가 발생했습니다.
✅ 해결책: String으로 대통합
| 비교 항목 | RedisTemplate<String, Object> | StringRedisTemplate |
| 직렬화 방식 | JdkSerialization / Jackson | StringRedisSerializer |
| 저장 형태 | \xac\xed... 또는 "123" (바이러니/객체) | 123 (순수 평문 문자열) |
| 안정성 | 직렬화 설정에 따라 데이터 변질 가능성 높음 | 가장 직관적이고 표준적임 |
💡 Lesson Learned: > "편리함" 뒤에 숨겨진 직렬화 함정을 경계해야 합니다. 데이터 전송 시에는 수동으로 ObjectMapper를 써서 직접 String으로 변환해 넣는 것이 가장 안전하고 확실합니다.
2. ObjectRecord ➔ MapRecord: "택배 상자의 구조를 바꾸다"
Redis Stream이라는 파이프라인에 데이터를 담는 방식에 대한 고민입니다.
🔍 문제 상황
- ObjectRecord는 DTO를 통째로 넣을 수 있어 코드가 깔끔해 보이지만, 실제 Redis 표준(XADD)과는 거리가 멀었습니다.
- 데이터가 payload라는 하나의 거대한 필드에 뭉쳐 들어가다 보니, 필드 단위의 빠른 접근이 어렵고 파싱 에러 시 전체 데이터를 잃게 되는 리스크가 있었습니다.
✅ 해결책: 표준 Key-Value 방식인 MapRecord 채택
- ObjectRecord: payload: {"invoice_id":1, "channel":"EMAIL"} (통짜 구조)
- MapRecord: invoice_id: 1, delivery_channel: EMAIL (칸막이 구조)
왜 MapRecord인가?
- 호환성: 워커가 Java가 아닌 다른 언어(Node.js, Python 등)로 작성되어도 데이터를 즉시 읽을 수 있습니다.
- 명확성: Map.put("key", "value") 방식을 통해 어떤 정보가 들어가는지 코드 레벨에서 명확히 통제할 수 있습니다.
3. saveAll() ➔ try-catch 예외 처리: "폭발 방지 장치 설치"
네트워크 재시도나 중복 요청은 분산 환경에서 피할 수 없는 숙명입니다.
🔍 문제 상황
- 1,000건의 데이터를 saveAll()로 밀어 넣을 때, 단 1건의 중복 데이터(Unique Key 위반)만 섞여 있어도 전체 배치가 Exception과 함께 롤백되는 현상이 발생했습니다.
- 고작 1건 때문에 나머지 999건의 발송이 멈추는 것은 시스템적으로 큰 손실이었습니다.
✅ 해결책: 결함 포용성(Fault Tolerance) 확보
- DB 저장 로직을 try-catch로 감싸서 특정 배치의 실패가 전체 시스템의 중단으로 이어지지 않도록 방어 로직을 구축했습니다.
try {
deliveryStatusRepository.saveAllIgnore(statusList);
} catch (Exception e) {
log.error("🚨 데이터 적재 중 오류 발생 (중복 데이터 의심): {}", e.getMessage());
// 에러를 무시하거나, 로그를 남기고 다음 프로세스로 진행
}
💡 Key Point: > 분산 시스템에서는 '멱등성(Idempotency)' 확보가 필수입니다. "이미 처리된 데이터니 무시하자"는 여유로운 태도가 시스템의 **견고함(Robustness)**을 만듭니다.