
안녕하세요 nemo입니다!! 🐢
날이 점점 쌀쌀해지고 있습니다
선풍기도 슬슬 집어 넣어야 하나 고민입니다
이번 포스팅도 프로젝트 진행입니다
원래 계획에는 진작 끝났어야 할 친구가 어쩌다 보니 지금까지 함께하고 있네요 하하하
최근 프로젝트에서 업적(achievement) 시스템을 구현하면서 이벤트 기반으로 처리하는 구조를 적용했습니다
단순히 +1/-1로 끝나는 로직이 아니라
여러 테이블을 조회하고 count 값을 기반으로 업적 달성 여부를 판단해야 하다 보니 데이터 정합성과 트랜잭션 처리에 꽤 신경을 써야 했습니다
이번 글에서는 제가 겪었던 시행착오와 최종적으로 적용한 해결 방법을 정리해보겠습니다
업적 로직은 특정 행동이 발생했을 때 동작합니다
ex)
게시글을 생성하며 이미지를 등록
->
지금까지 올린 이미지 개수를 세서 일정 기준을 넘으면 업적 달성
이 과정을 직접 서비스 로직 안에 넣어버리면 코드가 비대해지고 유지 보수가 안좋아질 것 아
Spring Event를 활용했습니다
@Builder
public record PostImageAchievementEvent(
Long associateId
) {
public static PostImageAchievementEvent from(Long associateId){
return PostImageAchievementEvent.builder()
.associateId(associateId)
.build();
}
}
Event Dto입니다
postImage관련 로직은 굉장히 단순해서 assocaiateId(참여자)만 받아오고 있습니다
이 Dto는 EventPublisher와 EventListener를 자동으로 매칭해주는 중요한 역할도 담당합니다
즉, Publisher가
publisher.publishEvent(PostImageAchievementEvent.from(1L));
를 발행하면,
Spring은 내부적으로 이벤트 디스패처를 통해 리스너 메서드의 파라미터 타입을 확인합니다.
@EventListener
public void handle(PostImageAchievementEvent event) { ... }
→ 메서드 인자 타입이 PostImageAchievementEvent와 일치하기 때문에 실행됩니다.
@Component
@RequiredArgsConstructor
public class AchievementEventPublisher {
private final ApplicationEventPublisher publisher;
public void publishPostImageAchievement(PostImageAchievementEvent event) {
publisher.publishEvent(event);
}
}
이벤트를 발행하는 쪽은 단순합니다
ApplicationEventPublisher를 주입받아 publishEvent()만 호출하면 됩니다
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handlePostImageAchievement(PostImageAchievementEvent event) {
// 업적 현황 테이블
AssociateStats stats = associateStatsRepository.findByAssociateId(event.associateId())
.orElseThrow(() -> new MementoException(ErrorCodes.STATS_NOT_FOUND));
// 지금까지 업로드한 profileImage 카운트 및 업데이트
long count = stats.updateUploadedPostImageCount(
postImageRepository.countByAssociateId(event.associateId())
);
// 업적 달성 여부 확인
if(!achievementAssociateRepository.existsByAchievementIdAndAssociateId(10L, event.associateId())
&& count >= 10){
getAchievement(event.associateId(), 10L);
}
}
업적의 실질적인 로직이 돌아가는 리스너 입니다
맨 위부터 천천히 살펴보겠습니다
업적 달성 로직은 이벤트를 비동기(Async)로 실행하여 본 로직에 영향을 주지 않도록 합니다
업적 달성 여부는 즉시 사용자에게 필요한 결과가 아니므로 비동기 처리하여
이후에 SSE(Server Side Event)로 클라이언트에 업적 달성여부를 알려줄 것 입니다
(SSE 구현은 이번 글에선 없습니다 하하😢)
처음에는 단순히 @EventListener로 이벤트를 받아 업적 로직을 실행했는데, 문제는 데이터 정합성이었습니다
업적 판단 로직은 countByAssociateId로 DB를 조회해 지금까지 업로드한 이미지를 세는데, 트랜잭션이 커밋되기 전에 실행되면 최신 데이터가 반영되지 않습니다
이를 해결하기 위해 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)을 사용했습니다
이렇게 하면 이벤트는 현재 트랜잭션이 커밋된 이후에 실행되므로, DB의 최신 상태를 기반으로 count 계산이 가능합니다
그리고!! 반드시!! @Transactional(propagation = Propagation.REQUIRES_NEW)과 같이 써줘야 합니다
@Transactional(propagation = Propagation.REQUIRES_NEW)이 없을 때는 코드를 아무리 실행해도 아무런 일이 일어나지 않았습니다

이유는.......
단순히 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)로 실행을 하는 경우

Transactional의 기본 설정은 REQUIRED로 서비스의 메인 트랜잭션이 COMMIT된 후
EventListener는 메인 트랜잭션 내에서 실행됩니다
이미 트랜잭션에서 COMMIT이 일어났기 때문에 다시 COMMIT이 일어나는 것은 불가능 합니다
따라서 EventListener 안에서 일어난 일들은 UPDATE되지 못하고 기억의 저편으로 날아가 버리는 거시었다....
(이것도 모르고 뭣 모르고 신기술을 사용하려 했던 수많은 나의 시간을 대가로 치뤘습니다)
@Transactional(propagation = Propagation.REQUIRES_NEW)의 역할은 다음과 같습니다
이벤트 리스너가 새로운 트랜잭션을 열고 실행되도록 강제합니다
@Transactional(propagation = Propagation.REQUIRES_NEW)을 써주게 된다면

서비스의 메인 트랜잭션에서 COMMIT이 일어난 후 REQUIRES_NEW로 인해 새로운 트랜잭션이 열리고 그 안에서 EventListenr가 동작하게 됩니다!!
코드를 짰으니 잘 돌아가는 지 확인도 해야 겠죠??
@Test
public void postImageTest() {
//given
... associate, Post 설정...
for(int i = 0; i < 10; i++) {
postImageRepository.save(PostImage.builder()
.hash(Hash.builder()
.hash("test" + i)
.build())
.url("test")
.post(post)
.build());
}
//when
achievementEventPublisher.
publishPostImageAchievement(PostImageAchievementEvent.from(associate.getId());
//then
assertThat(achievementAssociateRepository
.existsByAchievementIdAndAssociateId(10L, associate.getId()))
.isTrue();
}
결과는 실패였습니다...
이 코드에서 문제는
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
이걸 고려해 주지 않은 것이 문제였습니다
@TransactionalEventListener는 트랜잭션이 커밋된 후 실행됩니다
그런데 테스트 코드에서 같은 트랜잭션 안에서 이벤트를 발행하면
커밋이 발생하지 않으니 핸들러가 실행되지 않는 겁니다
해결방법은 의외로 간단했습니다
Service를 직접 호출해서 Commit 발생하게 하는 것
@Test
public void postImageTest() {
//given
... associate, Post 설정...
for(int i = 0; i < 9; i++) {
postImageRepository.save(PostImage.builder()
.hash(Hash.builder()
.hash("test" + i)
.build())
.url("test")
.post(post)
.build());
}
MultipartFile file =
new MockMultipartFile("image", "test.png", "image/png", "test".getBytes());
String url = "https://example.com/test.png";
given(minioService.createFile(file, MinioProperties.FileType.POST))
.willReturn(url);
String content = "test";
//when
postService
.create(community.getId(), memory.getId(), associate.getId(), content, List.of(file));
//then
assertThat(achievementAssociateRepository
.existsByAchievementIdAndAssociateId(10L, associate.getId()))
.isTrue();
}
(언제나 보기 좋은 초록색)
이번 포스트에서는 업적 로직 구현과 더불어 트랜잭션 관리에 대한 고민을 많이 담아 봤습니다
업적 시스템은 본 비즈니스 로직과는 살짝 떨어져 있는 “부가 로직”이지만
비동기 처리, 트랜잭션 분리, 테스트 방식까지 신중하게 설계해 봤습니다
긴 글 읽어주셔서 갑사합니다!!🐢