-
Notifications
You must be signed in to change notification settings - Fork 2
Description
Context
외부 API(SolvedAc, 메일)를 @Transactional 안에서 동기 호출하여 DB 커넥션 풀이 고갈되는 문제(외부API-커넥션풀-고갈)의 해결 방안.
외부 API 호출을 트랜잭션 밖으로 분리하면서, 상태 머신으로 각 단계를 안전하게 관리한다.
트랜잭션 밖 분리 시 부작용
단순히 외부 호출을 트랜잭션 밖으로 빼면 "메일 발송 성공 → DB 저장 실패" 시 다음 배치에서 중복 발송이 발생한다.
반대로 "DB 저장 성공 → 메일 발송 실패" 면 사용자는 메일을 못 받는데 DB는 SENT 상태가 된다.
→ Recommendation에 상태를 두어 SolvedAc 호출 단계를 추적하고, 메일은 "발송 먼저 → 성공 시 SENT 저장" 순서로 해결한다.
1. 상태 머신 설계
Recommendation.status (SolvedAc 호출 추적) — 신규 필드
CREATED ──→ READY
│
└──→ FAILED
| 상태 | 의미 |
|---|---|
CREATED |
레코드 생성됨, SolvedAc 호출 전/호출 중 |
READY |
SolvedAc 성공, 문제 저장 완료, 이메일 발송 가능 |
FAILED |
SolvedAc 호출 실패 |
MemberRecommendation.emailSendStatus — 기존 유지
PENDING ──→ SENT
│
└──→ FAILED
SENDING 중간 상태는 두지 않는다.
- 현재 단일 인스턴스, 단일 스케줄러 스레드라 동시 pick-up 문제 없음
- 추천 메일이 중복 발송(크래시 시)되어도 치명적이지 않음
- 스케일아웃 시 SENDING 추가 검토
메일 발송 순서: 발송 먼저 → 성공 시 SENT 저장. 최악의 경우(발송 성공 직후 크래시) PENDING으로 남아 다음 배치에서 중복 발송 1회. 수용 가능.
2. 아키텍처: Orchestrator 패턴
현재 구조 (문제)
RecommendationService (@Transactional 클래스 레벨)
└── 외부 API 호출 + DB 작업이 하나의 큰 트랜잭션 안에 혼재
→ 외부 API 지연 동안 DB 커넥션 점유
변경 구조
RecommendationService (오케스트레이터, @Transactional 없음)
├── RecommendationTxService (짧은 트랜잭션 전담, 신규)
├── ProblemService (SolvedAc 호출, 트랜잭션 불필요)
├── ProblemSyncService (자체 @Transactional)
└── MailSender (메일 발송, 트랜잭션 불필요)
- RecommendationService: 클래스 레벨
@Transactional제거. 외부 API 호출과 트랜잭션 메서드를 조합하는 오케스트레이터 - RecommendationTxService (신규): 짧은 트랜잭션만 담당 (생성, 상태 전이, 조회)
대안 비교
| 방식 | 장점 | 단점 | 판정 |
|---|---|---|---|
| Orchestrator + TxService | 역할 명확, 테스트 용이 | 클래스 1개 추가 | 채택 |
| Self-injection | 변경 최소 | 안티패턴, 가독성 저하 | X |
| REQUIRES_NEW 중첩 트랜잭션 | 기존 구조 유지 | 커넥션 2개 점유, 근본 해결 안 됨 | X |
3. 플로우별 변경
3-1. 배치 추천 (6am) — prepareDailyRecommendations()
팀별 루프:
[TX1] Recommendation(CREATED) 생성 + 커밋 ← 짧은 TX
[---] SolvedAc API 호출 ← DB 커넥션 미사용
[TX2] problemSyncService.syncProblems() ← 자체 TX
[TX3] RecommendationProblem + MemberRecommendation 저장
+ Recommendation → READY ← 짧은 TX
실패 시:
[TX'] Recommendation → FAILED ← 짧은 TX
3-2. 배치 이메일 (9am) — sendPendingRecommendationEmails()
[TX1] PENDING 조회 + 메일 내용(MailMessage) TX 안에서 미리 빌드 ← 짧은 TX
건별 루프 (트랜잭션 밖):
[---] mailSender.send() ← DB 커넥션 미사용
[TX2] SENT 또는 FAILED + 커밋 ← 짧은 TX
MailMessage 미리 빌드: RecommendationMailBuilder.build()는 엔티티의 Lazy 필드에 접근하므로 TX 안에서 호출해야 한다. 빌드 결과를 DTO에 담아 TX 밖에서 발송한다.
3-3. 수동 추천 — createManualRecommendation()
[TX1] 검증 + Recommendation(CREATED) 생성 + 커밋
[---] SolvedAc API 호출 ← DB 커넥션 미사용
[TX2] problemSyncService.syncProblems()
[TX3] 문제 + MemberRecommendation 저장 + READY 전이 + 커밋
→ 사용자에게 응답 반환 (SolvedAc + DB는 동기)
[비동기] @EventListener + @Async로 메일 발송
메일 발송 → SENT/FAILED
수동 추천은 SolvedAc + DB 저장이 동기여야 한다 (결과를 즉시 보여줘야 하므로). 메일만 비동기.
4. Detached Entity 해결
트랜잭션이 분리되면 첫 번째 TX에서 로드한 엔티티가 다음 TX에서 detached 상태가 된다.
ID + DTO를 전달하여 해결한다.
| 구간 | 전달 데이터 | 이유 |
|---|---|---|
| TX1 → SolvedAc 호출 | recommendationId (Long) + team 필드값 |
Lazy 접근 없음 |
| SolvedAc → TX2/3 | List<ProblemInfo> DTO + recommendationId |
TX에서 ID로 다시 조회 |
| TX(조회) → 메일 발송 | MemberRecommendationEmailInfo record |
TX 안에서 MailMessage 미리 빌드 |
// 트랜잭션 밖에서 사용하는 DTO
record MemberRecommendationEmailInfo(
Long id, // MemberRecommendation ID
Long memberId, // 로깅/에러 추적용
String email,
MailMessage mailMessage // TX 안에서 빌드 완료
) {}5. 비동기 이메일 인프라
수동 추천의 메일 발송을 비동기로 처리하기 위해 @EnableAsync + 이벤트 리스너를 사용한다.
// config/AsyncConfig.java
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor recommendationEmailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("email-async-");
executor.initialize();
return executor;
}
}// recommendation/event/ManualRecommendationEmailListener.java
@Async("recommendationEmailExecutor")
@EventListener
public void onManualRecommendationCreated(ManualRecommendationCreatedEvent event) {
List<MemberRecommendationEmailInfo> targets =
txService.findPendingEmailsByRecommendationId(event.recommendationId());
for (MemberRecommendationEmailInfo target : targets) {
try {
mailSender.send(target.mailMessage());
txService.markEmailAsSent(target.id());
} catch (Exception e) {
txService.markEmailAsFailed(target.id());
}
}
}6. 파일 변경 목록
수정
| 파일 | 변경 |
|---|---|
Recommendation.java |
status 필드 추가, markAsReady(), markAsFailed(), 팩토리 메서드에 초기 상태 |
RecommendationService.java |
클래스 레벨 @Transactional 제거, 오케스트레이터로 리팩터링 |
신규
| 파일 | 역할 |
|---|---|
RecommendationStatus.java |
enum: CREATED, READY, FAILED |
RecommendationTxService.java |
트랜잭션 단위 DB 작업 전담 |
MemberRecommendationEmailInfo.java |
메일 발송용 DTO |
ManualRecommendationCreatedEvent.java |
수동 추천 이벤트 |
ManualRecommendationEmailListener.java |
비동기 메일 발송 리스너 |
AsyncConfig.java |
@EnableAsync + 스레드풀 |
7. 구현 순서
Phase 1: 도메인 모델
1-1. RecommendationStatus enum 생성
1-2. Recommendation에 status 필드 + 상태 전이 메서드
Phase 2: 서비스 분리
2-1. MemberRecommendationEmailInfo DTO
2-2. RecommendationTxService 생성
2-3. RecommendationService 리팩터링
Phase 3: 비동기 이메일
3-1. AsyncConfig
3-2. ManualRecommendationCreatedEvent + Listener
Phase 4: 테스트
4-1. 빌드 + 기존 테스트 통과
4-2. 로컬 Fake 빈으로 플로우 동작 확인
8. 검증 방법
./gradlew build통과- 기존 테스트 통과
- 로컬 FakeMailSender(20초 지연) + FakeSolvedAcRestClient(10초 지연)로:
- 수동 추천 → SolvedAc 지연 동안 DB 커넥션 미점유 확인
- 메일 발송 중 다른 API 정상 동작 확인
- Recommendation: CREATED → READY 전이 확인 (phpMyAdmin)
- EmailSendStatus: PENDING → SENT 전이 확인 (phpMyAdmin)