← 프로젝트 회고 · Backend
RETROSPECTIVE · 회고

댓글은 항상 저장, 포인트는 첫 참여만: 트랜잭션 경계 나누기

Backend

시즌 이벤트 페이지에 댓글을 달면 포인트를 주는 API를 만들었다. 포인트 지급은 다른 시스템의 API를 HTTP로 호출해야 했고, 요구사항은 두 가지였다.

  1. 포인트 지급이 실패해도 댓글은 남아야 한다.
  2. 포인트는 회원당 첫 참여에만 한 번 지급한다.

하나의 @Transactional로 묶었을 때의 문제

댓글 저장 → 외부 API 호출을 한 트랜잭션에서 하면 두 방향 모두 꼬인다.

  1. 외부 API가 실패해 예외가 나면 댓글까지 롤백된다.
  2. 외부 API는 성공했는데 그 뒤 커밋이 실패하면 포인트만 지급되고 댓글은 없다. 외부 호출은 롤백되지 않는다.
  3. 외부 호출이 느리면 그동안 DB 커넥션과 락을 잡고 있게 된다.

원칙: 외부 호출은 트랜잭션 밖에서, 되돌릴 수 없는 일은 마지막에

댓글 저장을 먼저 커밋하고, 커밋이 끝난 뒤에 포인트를 지급하도록 나눴다.

@Transactional
public void writeComment(CommentRequest req) {
commentRepository.save(req.toEntity());
boolean first = participationRepository.insertIfAbsent(req.eventId(), req.memberId());
if (first) {
events.publishEvent(new FirstParticipationEvent(req.eventId(), req.memberId()));
}
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onFirstParticipation(FirstParticipationEvent e) {
pointClient.give(e.memberId(), POINT); // 실패해도 댓글은 이미 커밋됨
}

같은 클래스에서 REQUIRES_NEW가 안 먹는 이유

처음에는 포인트 지급 메서드에 @Transactional(propagation = REQUIRES_NEW)를 붙이고 같은 클래스에서 호출했는데 전혀 분리되지 않았다. 스프링 트랜잭션은 프록시로 동작해서 자기 자신의 메서드를 직접 호출하면(self-invocation) 프록시를 거치지 않는다. 분리가 필요하면 별도 빈으로 빼야 한다.

중복 지급 막기

  1. 참여 이력에 (event_id, member_id) 유니크 제약을 걸어 "첫 참여" 판단을 DB가 보장하게 한다. 동시에 두 번 눌러도 한 건만 들어간다.
  2. 지급 상태(대기 / 완료 / 실패)를 기록해두면 외부 API가 실패한 건만 골라 재시도할 수 있다.
Gunmo Lee
댓글은 항상 저장, 포인트는 첫 참여만: 트랜잭션 경계 나누기 | Gunmo's Dev Life