[프로젝트] Spring Event 기반 업적 달성 로직 구현하기

nemo·2025년 9월 13일
post-thumbnail

▶️ 인사말

안녕하세요 nemo입니다!! 🐢

날이 점점 쌀쌀해지고 있습니다
선풍기도 슬슬 집어 넣어야 하나 고민입니다

이번 포스팅도 프로젝트 진행입니다
원래 계획에는 진작 끝났어야 할 친구가 어쩌다 보니 지금까지 함께하고 있네요 하하하

▶️ 개요

최근 프로젝트에서 업적(achievement) 시스템을 구현하면서 이벤트 기반으로 처리하는 구조를 적용했습니다

단순히 +1/-1로 끝나는 로직이 아니라
여러 테이블을 조회하고 count 값을 기반으로 업적 달성 여부를 판단해야 하다 보니 데이터 정합성과 트랜잭션 처리에 꽤 신경을 써야 했습니다

이번 글에서는 제가 겪었던 시행착오와 최종적으로 적용한 해결 방법을 정리해보겠습니다

▶️ 코드 구현

업적 로직은 특정 행동이 발생했을 때 동작합니다

ex)
게시글을 생성하며 이미지를 등록
->
지금까지 올린 이미지 개수를 세서 일정 기준을 넘으면 업적 달성

이 과정을 직접 서비스 로직 안에 넣어버리면 코드가 비대해지고 유지 보수가 안좋아질 것 아
Spring Event를 활용했습니다

🟢 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와 일치하기 때문에 실행됩니다.

🟢 Publisher

@Component
@RequiredArgsConstructor
public class AchievementEventPublisher {

    private final ApplicationEventPublisher publisher;

    public void publishPostImageAchievement(PostImageAchievementEvent event) {
        publisher.publishEvent(event);
    }
}

이벤트를 발행하는 쪽은 단순합니다
ApplicationEventPublisher를 주입받아 publishEvent()만 호출하면 됩니다

🟢 Listener

@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

업적 달성 로직은 이벤트를 비동기(Async)로 실행하여 본 로직에 영향을 주지 않도록 합니다
업적 달성 여부는 즉시 사용자에게 필요한 결과가 아니므로 비동기 처리하여
이후에 SSE(Server Side Event)로 클라이언트에 업적 달성여부를 알려줄 것 입니다

(SSE 구현은 이번 글에선 없습니다 하하😢)

🔴 @TransactionalEventListener

처음에는 단순히 @EventListener로 이벤트를 받아 업적 로직을 실행했는데, 문제는 데이터 정합성이었습니다

업적 판단 로직은 countByAssociateId로 DB를 조회해 지금까지 업로드한 이미지를 세는데, 트랜잭션이 커밋되기 전에 실행되면 최신 데이터가 반영되지 않습니다

이를 해결하기 위해 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)을 사용했습니다

이렇게 하면 이벤트는 현재 트랜잭션이 커밋된 이후에 실행되므로, DB의 최신 상태를 기반으로 count 계산이 가능합니다

⚙️ important

그리고!! 반드시!! @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();
	}
(언제나 보기 좋은 초록색)

▶️ 마무리

이번 포스트에서는 업적 로직 구현과 더불어 트랜잭션 관리에 대한 고민을 많이 담아 봤습니다

업적 시스템은 본 비즈니스 로직과는 살짝 떨어져 있는 “부가 로직”이지만
비동기 처리, 트랜잭션 분리, 테스트 방식까지 신중하게 설계해 봤습니다

긴 글 읽어주셔서 갑사합니다!!🐢

▶️ 참고

https://yoonseon.tistory.com/157

profile
거북이 개발자

0개의 댓글