Skip to content

[REFACTOR] 추천 플로우 상태 기반 관리 + 트랜잭션 분리 #148

@ryuwldnjs

Description

@ryuwldnjs

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. 검증 방법

  1. ./gradlew build 통과
  2. 기존 테스트 통과
  3. 로컬 FakeMailSender(20초 지연) + FakeSolvedAcRestClient(10초 지연)로:
    • 수동 추천 → SolvedAc 지연 동안 DB 커넥션 미점유 확인
    • 메일 발송 중 다른 API 정상 동작 확인
    • Recommendation: CREATED → READY 전이 확인 (phpMyAdmin)
    • EmailSendStatus: PENDING → SENT 전이 확인 (phpMyAdmin)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions