지원은 실패, 알림은 성공? 트랜잭션 이벤트 정합성 해결

희운·2025년 12월 5일

미팀 프로젝트에서는 프로젝트 지원 후 지원자에게 SSE를 통한 알림을 전송해야 한다.
처음에 단순히, 아래와 같이 기능을 개발을 위해서 자연스럽게 다음과 같은 형태의 코드를 떠올렸다.


@Service
@RequiredArgsConstructor
public class ProjectApplicationService {

    private final ProjectRepository projectRepository;
    private final MemberRepository memberRepository;
    private final JobPositionRepository jobPositionRepository;
    private final ApplicationRepository applicationRepository;
    // 이벤트를 발행하는 대신, 알림 서비스를 직접 의존합니다.
    private final NotificationService notificationService; 

    @Transactional
    public ApplicationResponse apply(Long projectId, Long memberId, ApplicationRequest request) {
        // 1단계: 엔티티 조회
        Project project = projectRepository.findActiveById(projectId)
                .orElseThrow(() -> new CustomException(ErrorCode.PROJECT_NOT_FOUND));
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND));
        JobPosition jobPosition = jobPositionRepository.findByCode(request.jobPositionCode())
                .orElseThrow(() -> new CustomException(ErrorCode.JOB_POSITION_NOT_FOUND));

        // 2단계: 권한 및 비즈니스 검증
        validateNotSelfApplication(project, memberId);
        validateApplyPrecondition(project, member, projectId, memberId);
        validateRecruitmentPosition(projectId, jobPosition);

        // 3단계: 지원서 생성 및 저장
        ProjectApplication application = ProjectApplication.builder()
                .project(project)
                .member(member)
                .jobPosition(jobPosition)
                .motivation(request.motivation())
                .build();
        ProjectApplication savedApplication = applicationRepository.save(application);

        // 4단계: 알림 서비스 직접 호출 (동기 방식의 핵심)
        // 알림 전송이 완료될 때까지 아래 로그와 리턴문은 실행되지 않고 대기합니다.
        notificationService.sendApplyNotification(project.getOwner(), member, savedApplication);

        log.info("프로젝트 지원 완료 - projectId: {}, applicantId: {}, jobPositionCode: {}",
                projectId, memberId, request.jobPositionCode());

        return ApplicationResponse.from(savedApplication);
    }
}

이 코드는 전형적인 동기 방식을 따른다. 동기 방식은 프로그램의 흐름을 직관적으로 이해할 수 있다.
하지만 동기 방식에서, 알림 전송과 같은 부가적인 기능을 만나면 고려할 부분이 있다.
먼저, 위의 요구사항에서 사용자가 지원을 완료하고 알림 전송을 받을때, 알림 전송을 하는 과정에서 Exception 이 발생하면 프로젝트 지원절차 역시 실패한다.
이게 맞는걸까? 생각을 해보면, 알림 전송을 실패했다고 지원까지 실패하는건 사용자가 원하지 않은 결과일것이다. 또한, 알림 전송을 완료하고 사용자에게 프로젝트 지원에 대한 응답을 보낼경우 응답시간 또한 지연될 것이다.
위와 같은 이유로 반드시 알림이 전송되고 지원이 완료되어야 하는게 아니라면, 동기 방식 대신 비동기 방식으로 연동하는 것을 고민해 볼 필요가 있다. 현재 구조에서 비동기 방식으로 구현할 경우, 알림전송에 대한 로직이 끝날때까지 기다리지 않고, 바로 프로젝트 지원 성공을 사용자에게 응답할 수 있다.

알림 발송 로직을 비동기로 호출


@Service
@RequiredArgsConstructor
public class ProjectApplicationService {

    private final ProjectRepository projectRepository;
    private final MemberRepository memberRepository;
    private final JobPositionRepository jobPositionRepository;
    private final ApplicationRepository applicationRepository;
    
    // 알림 서비스를 직접 의존 (결합 발생)
    private final NotificationService notificationService; 

    @Transactional
    public ApplicationResponse apply(Long projectId, Long memberId, ApplicationRequest request) {
        // 1단계: 엔티티 조회
        Project project = projectRepository.findActiveById(projectId)
                .orElseThrow(() -> new CustomException(ErrorCode.PROJECT_NOT_FOUND));
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND));
        JobPosition jobPosition = jobPositionRepository.findByCode(request.jobPositionCode())
                .orElseThrow(() -> new CustomException(ErrorCode.JOB_POSITION_NOT_FOUND));

        // 2단계: 권한 및 비즈니스 검증
        validateNotSelfApplication(project, memberId);
        validateApplyPrecondition(project, member, projectId, memberId);
        validateRecruitmentPosition(projectId, jobPosition);

        // 3단계: 지원서 생성 및 저장
        ProjectApplication application = ProjectApplication.builder()
                .project(project)
                .member(member)
                .jobPosition(jobPosition)
                .motivation(request.motivation())
                .build();
        ProjectApplication savedApplication = applicationRepository.save(application);

        // 4단계: 단순 비동기 호출 (Direct Async Call)
        // @Async가 붙은 메서드를 직접 호출합니다. 
        // 이 시점에 별도의 스레드에서 알림 발송 로직이 즉시 시작됩니다.
        notificationService.sendApplyNotification(project.getOwner(), member, savedApplication);

        // 5단계: 의도적인 예외 발생 (블로그 예시용)
        // 만약 여기서 알 수 없는 에러가 발생하여 트랜잭션이 롤백된다면?
        if (someErrorCondition) {
            throw new RuntimeException("DB 커밋 직전 에러 발생!");
        }

        log.info("프로젝트 지원 완료 - projectId: {}, applicantId: {}", projectId, memberId);
        return ApplicationResponse.from(savedApplication);
    }
}

@Async 를 이용하여 non-blocking 비동기로 알림 발송 로직을 실행했다.
하지만 이 과정에서는 전의 상황보다 더 심각한 상황이 발생할것이다.
비동기 처리를 하기 위해 스레드풀에서 스레드를 할당받고 알림을 보낸다.
하지만, 핵심 로직인 프로젝트 지원 스레드가 예외가 터지면, 롤백이 발생할것이고 결국 지원은 실패한다.
결과적으로 사용자는 "지원이 실패했는데 지원 완료 알림을 받는" 당황스러운 경험을 하게 된다.
이 문제를 어떻게 해야할까? 쉽게 생각하면 프로젝트 지원이 완료되고 알림 발송 로직이 실행되면 된다.
프로젝트 지원후 commit이 완료 되고 나서, 알림전송을 하기 위해서 다음과 같은 방식으로 구현을 하였다.

Facade 클래스로 commit 이후에 비동기 호출하기

// 상위 Facade 클래스
public void applyAndNotify(...) {
    applicationService.apply(...); // 여기서 트랜잭션 커밋 완료
    notificationService.sendAsync(...); // 커밋 완료 후 비동기 호출
}

Fascade 상위 클래스에서는 apply 이후에 commit 이 완료되고 나서 비동기로 비동기 호출을 하면 된다. 위에서 발생할수 있는 문제를 해결할수 있다.
하지만 나는 미팀에서는 Facade 클래스를 이용하지 않고, Spring Event 를 이용하여 비동기를 호출하였다. 현재 요구사항에서는 지원이 완료시에 알림전송만 하면된다. 하지만 추후에 지원후 외부 시스템을 이용하여 알림톡 전송을 하는등, 추가적인 요구사항이 생길경우 Facade 클래스가 의존하는 클래스가 많아질 것이다.
또한, 다른 개발자가 Facade 를 거치지 않고 applicationService.apply()를 직접 호출하면? 알림이 영영 가지 않을수 있다.
현재 상황에서는 위의 방식이 전혀 문제는 없다. 하지만 Spring Event에서의 TransactionEventListener는 다양한 케이스들에서 동작할수 있도록 제어가 가능하다.
After commit만 있는게 아니라 beforeCommit, beforeCompletion, AflterCompletion도 가능하고, 나는 commit 이후에 알림 발송을 하면 되었기에 아래와 같이 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)와 @Async 를 이용했다.


@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(NotificationEvent e) {
  	// 엔티티 조회를 SSENotificationService로 위임
      notificationService.notify(e);
}

여기서 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
를 사용할때 조심해야한다.
AFTER_COMMIT 을 보고 COMMIT 이 완료되었으니까, 새로운 @Transaction을 열면 된다고 생각하는 순간 commit 이 되지 않는다.
위의 코드는 현재 비동기로 처리되기 때문에 기존 트랜잭션과 다른 스레드를 사용하기 때문에 문제가 발생하지 않지만, 만약 동기적으로 실행할 경우에는 하나의 스레드를 계속해서 사용하기 때문에 commit이 안된다.
commit 이 왜 발생하지 않을까 ?
AFTER_COMMIT 이면 COMMIT 이후에 실행되기 때문에, 추가적으로 물리 트랜잭션을 새로 연다고 해도 COMMIT은 되어야 한다고 생각했다.
하지만 여기서 AOP를 통해서 connection.commit() 을 호출하여 물리적인 DB commit 을 하더라도
아직 DataSource 는 살아있고, 아직 connection을 release() 한 상태는 아니다.
즉, 자원정리는 아직 하지 않은 상태이고, DB에 commit을 보낸상태로 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 를 호출하는것이다. 이 어노테이션 이후에 상황은 다음과 같다

  • 기존 커넥션은 살아있음
  • DB에 commit 호출

그럼 알림서비스에서 @Transaction을 열고 write 연산을 진행했다고 가정하자. 그럼 이 상황에서 기존 커넥션을 이용할것이다. 메서드 종료 시점에 commit 을 하겠지만, 여기서 장애가 발생하고 commit을 하지 못한다.
그 이유는 이미 기존에 commit을 호출하였기 때문에 write 연산을 해도 의미가 없기 때문이다.
반면 기존 커넥션을 살아있으므로 read 연산을 해도 에러가 발생하지 않는다.
즉, @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 는 동기적으로 실행될 경우, DB에 commit 호출 이후, 그리고 커넥션을 커넥션풀에 반납하기 전에 실행된다

물론 미팀에서 @Async 를 통해 비동기로 실행되기 때문에 별도의 스레드에서 실행되므로 문제가 발생하지 않지만, 해당 어노테이션이 일어나는 시점을 명확하게 인지할 필요가 있다.

profile
기록하는 공간

0개의 댓글