새로운 기능 추가에 대한 이야기
스모디 서비스는 사람들에게 동기부여를 해주는 서비스로, 챌린지에 도전하여 인증을 하는 것이 주 콘텐츠이다. 단순한 인증을 넘어, 조금 더 사용자들에게 동기부여를 해주기 위해 랭킹에 관한 기능을 추가하기로 했다.
랭킹 로직을 이벤트로 처리
회원이 챌린지를 인증하면 주간 랭킹 점수에 반영 된다.라는 기능을 추가하려고 한다.
챌린지 인증 → CycleService.increaseProgress()
랭킹 관련 로직 → RankingService
랭킹 로직과 챌린지 로직이 강한 결합을 갖는 것을 막고 랭킹에 장애가 발생해도 챌린지 인증은 정상 동작시키기 위하여 이벤트(@TransactionalEventListener) + 비동기 처리(@Async)를 하기로 선택했다.
( CycleService.increaseProgress() 메서드는 이미 이벤트를 발행하고 있기 때문에 CycleService의 코드 수정 없이 랭킹 기능을 추가할 수 있는 구조이다.)
@Transactional
public ProgressResponse increaseProgress(TokenPayload tokenPayload, ProgressRequest progressRequest) {
// ...
// 챌린지 인증 관련 로직
// ...
applicationEventPublisher.publishEvent(new CycleProgressEvent(cycle));
return new ProgressResponse(cycle.getProgress());
}
왜 비동기 처리를 하였는가
이벤트를 사용하기로 했더라도 동기적으로 실행할 수 있다. 하지만 동기적으로 실행하게 되면 위에서 언급한 랭킹에 장애가 발생해도 챌린지 인증은 정상 동작 시키기가 할 수 없게 된다.
랭킹에 장애가 발생하면 우리 서비스의 핵심 콘텐츠인 챌린지 인증을 할 수 없는 것인데 이렇게 할 수는 없다고 판단했다. 랭킹을 당장에 못 보더라도 핵심 기능은 정상 동작시키고 싶었다.
결과적으로 챌린지 인증 이벤트가 발생했을 때 랭킹 로직을 아래와 같이 처리하도록 했다.
@TransactionalEventListener // 이벤트를 발행하는 로직이 커밋된 뒤에 실행하도록
@Async("asyncExecutor") // 비동기 처리 ("asyncExecutor" 스레드 풀에서 스레드를 받아옴)
@Transactional // DB를 다루기에 트랜잭션 처리
public void handle(CycleProgressEvent event) {
// event에서 가져온 정보를 토대로 특정 회원의 랭킹 점수에 관한 로직을 실행
}
CycleService.increaseProgress()는 CycleProgressEvent를 발행하고 위 메서드는 이 이벤트를 구독했다. CycleProgressEvent가 발행되면 위 로직이 실행되면서 랭킹 점수에 관한 로직을 실행할 것이다.
랭킹 엔티티 구조
- 랭킹 기간: RankingPeriod
- 회원들의 랭킹 점수들이 기록되는 특정 기간 테이블
- 랭킹 활동: RankingActivity
- 회원들의 점수를 기록하는 테이블. 특정 기간에 매핑된다.
랭킹 점수를 올리기 위해서는 RankingActivity(랭킹 활동)을 생성해야 하는데 RankingActivity는 인증 기간(RankingPeriod)과 회원(Member) 엔티티가 있어야 한다.
인증 기간이 항상 존재해야 하는 로직의 구현 방법
챌린지 인증에 따라 랭킹 점수를 집계하기 위해서는 항상 RankingPeriod는 존재해야 하며 현 정책상 RankingPeriod는 7일 간격으로 매주 월~일의 기간이다.
RankingPeriod를 생성하는 api는 존재하지 않기 때문에 항상 존재해야한다는 정합성을 보장하기 위해 아래의 두 가지 방법을 생각했다.
- @Scheduled를 사용해 매주 일요일 밤 11시 55분에 다음 주 RankingPeriod를 생성한다.
- 첫 사용자의 챌린지 인증이 발생했을 때 함께 생성하고, 그 뒤부터 인증하는 사용자들은 있는 RankingPeriod를 조회해서 사용한다. (사용자가 인증하지 않았으면 보여줄 랭킹이 없다고 판단)
스케줄러를 사용하는 방법은 일요일 밤 등의 특정 시간에 장애가 발생하면 정합성이 안 맞는 문제가 발생하고 일요일 후에 배포를 하면 RakingPeriod를 직접 넣어줘야 하는 등 관리하기가 까다롭다고 판단했다.
그래서 첫 인증 발생 요청을 판단하여 RankingPeriod를 생성하기로 결정했다.
로직 흐름
- 사용자가 인증을 한다.
- 현재 시간에 해당하는RankingPeriod를 조회한다.
- 있으면 사용해서 RankingActivity에 점수를 기록한다.
- 없으면 RankingPeriod부터 만들고 만든 RankingPeriod로 RankingActivity를 만들어 점수를 기록한다.
동시에 첫 인증이 발생했을 때 동시성 문제
RankingPeriod는 ‘시작 날짜’와 ‘기간’의 두 속성으로 판별된다. 같은 날에 시작해 같은 기간 동안 지속되는 2개의 RankingPeriod는 필요 없고 있어서도 안 된다.
때문에 동시에 두 사용자가 그 주의 첫 인증을 할 경우 같은 기간 동안 지속되는 RankingPeriod가 생기게 되는 동시성 문제가 발생한다.
실제로 랭킹 기간이 없으면 만들어서 랭킹 점수를 기록하는 비동기 메서드 2개를 한 번에 실행해서 테스트를 해보니 1개만 생성되어야 할 RankingPeriod가 2개 생성되는 것을 확인했다.
해결 첫 번째 단계 - Unique 제약
시작 날짜와 기간의 복합 컬럼으로 유니크 제약을 설정했다.
이렇게 되면 한 트랜잭션에서 이 조합으로 이미 insert 쿼리를 날리면 (설령 커밋 전이라고 해도) 다른 트랜잭션에서는 이 조합으로 insert를 날리지 못하고 대기하게 된다.
먼저 insert를 한 트랜잭션이 커밋되면 두 번째 트랜잭션은 유니크 제약으로 인해 예외를 발생시키고, 롤백되면 정상 커밋된다.
로그를 확인해 보자.
1. 두 스레드에서 동시에 RankingPeriod에 insert 쿼리 실행. 두 쿼리의 duration과 start_date는 같다.
2. [async-pool1] 스레드가 빨랐는지 먼저 RankingActivity를 만들어서 점수를 update 한다.
3. [async-pool1]가 커밋될 때까지 기다리던 [async-pool2]에서 이제야 유니크 제약으로 인한 예외가 발생한 것을 확인할 수 있다. 만약 [async-pool1]에서 롤백이 발생했다면 [async-pool2]은 insert에 성공했을 것이다.
결과적으로 Unique 제약으로 lock을 걸지 않고도 한 주에 한 RankingPeriod를 생성할 수 있게 되었다.
챌린지 인증과 랭킹 점수 간의 정합성 문제 발생
하지만 테스트는 여전히 실패하는데 내용은 아래와 같다.
첫 번째 트랜잭션에서 먼저 RankingPeriod를 insert 했기 때문에 두 번째 트랜잭션이 예외를 발생하는 상황은 RankingPeriod를 하나만 생성하지만 두 번째 트랜잭션은 예외를 맞고 롤백하게 된다.
그럼 두 번째 트랜잭션은 챌린지 인증은 했지만 점수는 사라지게 된다. 정책적으로 이는 말이 안 된다.
해결 두 번째 단계 - try/catch로 잃어버린 점수 찾기
유니크 제약으로 인해 예외를 맞더라도 catch문 안에서 다른 스레드가 저장해준 RankingPeriod를 사용해 점수를 올릴 수 있도록 다음과 같이 코드를 수정했다.
@Async("asyncExecutor")
@TransactionalEventListener
public void handle(CycleProgressEvent event) {
Cycle cycle = event.getCycle();
Long memberId = cycle.getMember().getId();
try {
applyRankingPoint(cycle);
} catch (DataIntegrityViolationException e) {
/**
* 여러 사람이 동시에 랭킹 점수에 관한 이벤트를 발생했을 경우
* unique 제약조건으로 예외가 발생한 이벤트를 다시 한번 처리한다.
*/
applyRankingPoint(cycle);
}
}
private void applyRankingPoint(Cycle cycle) {
List<RankingActivity> activities = findTargetActivities(cycle);
updateActivities(cycle.getLatestCycleDetail(), activities);
}
// ...
해결 세 번째 단계 - JPA session 초기화
유니크 예외가 터지고 catch에서 랭킹 점수 로직을 다시 잘 실행해주길 원했으나 다른 예외가 또 발생했다.
org.hibernate.AssertionFailure: null id in com.woowacourse.smody.ranking.domain.RankingPeriod entry (don't flush the Session after an exception occurs)
찾아보니 이는 세션에 남아 있는 엔티티 때문이다.
- try 안에서 RankingPeriod를 insert 하려다가 DataIntegrityViolationException 발생
- hibernate는 예외가 발생하더라도 세션을 초기화(clear) 해주지 않음
- 때문에 catch 안에서 jpa flush를 호출할 때 try 안에서 지워지지 않은 entity가 남아 있어 오류를 일으킴
참고: https://juneyr.dev/hibernate-exception-does-not-flush
세션을 강제로 초기화시켜주니 AssertionFailure 예외는 사라졌다.
UnexpectedRollbackException 발생
세션까지 강제 초기화시켜주자 예상한 대로 흘러가는 듯했다. 로그를 보니
- [async-pool1] 스레드는 RankingPeriod를 insert 하고 자신의 점수를 기록하고 커밋
- [async-pool2]에서 unique 예외가 발생
- catch 문으로 들어가 점수 로직 실행
- UnexpectedRollbackException 발생
보면 [async-pool1]가 넣어준 RankingPeriod를 사용해 RankingActivity를 insert 하는 쿼리까지는 실행했다. 그런데 커밋을 하려고 하다가 UnexpectedRollbackException이 발생한 듯하다.
(원래 update 쿼리까지 나가야 하는데 커밋 직전에 flush 하려다가 rollback이 발생한 듯)
@Transactional 어노테이션은 메서드 내에서 예외가 발생하면 롤백 처리를 하지만 try/catch로 예외를 잡아서 롤백하지 않을 줄 알았는데 아니었다.
자세한 건 응? 이게 왜 롤백되는 거지?를 읽어 보자.
요약하면 참여 중인 트랜잭션이 실패하면 그 트랜잭션은 전역적으로 rollback-only로 마킹되어 해당 트랜잭션을 재사용할 수 없다는 것이다.
설령 예외를 잡았다고 해도 로직을 실행하다 커밋하는 시점에 어? 롤백 마크 처리가 되어있네 하고 롤백해버린다는 것이다.
해결 마지막 단계 - TransactionTemplate을 이용한 예외 처리
예외가 발생한 트랜잭션을 재사용할 수 없다면 트랜잭션을 분리하면 된다. 하지만 @Transactional 어노테이션을 사용하면서 트랜잭션을 분리하기 위해선 전파 속성을 조절할 필요가 있다.
applyRankingPoint() 메서드의 전파 속성을 REQUIRES_NEW로 바꾸면 해결될 수도 있지만 AOP로 구현되어 있는 @Transactional의 특성상 클래스가 다른 클래스의 메서드를 호출하는 상황에서만 트랜잭션이 적용되기 때문에 사용할 수 없다.
그래서 @Transactional이 아닌 TransactionTemplate을 사용하기로 했다.
아래와 같이 어노테이션을 지우고 TransactionTemplate을 빈으로 주입받아 try와 catch 안에서 각각 사용해 catch 안의 트랜잭션을 try 안의 트랜잭션이 끝나고 실행되도록 하였다.
@Async("asyncExecutor")
@TransactionalEventListener
public void handle(CycleProgressEvent event) {
Cycle cycle = event.getCycle();
Long memberId = cycle.getMember().getId();
try {
applyRankingPointInTransaction(cycle);
} catch (DataIntegrityViolationException e) {
/**
* 여러 사람이 동시에 랭킹 점수에 관한 이벤트를 발생했을 경우
* unique 제약조건으로 예외가 발생한 이벤트를 새로운 트랜잭션으로 다시 한번 처리한다.
*/
applyRankingPointInTransaction(cycle);
}
}
private void applyRankingPointInTransaction(Cycle cycle) {
transactionTemplate.executeWithoutResult(
status -> {
List<RankingActivity> activities = findTargetActivities(cycle);
updateActivities(cycle.getLatestCycleDetail(), activities);
}
);
}
- try 문 안에서 RankingPeriod가 없으면 생성하는 로직과 점수를 올리는 로직을 실행하게 된다.
- 만약 동시성 문제 때문에 예외가 발생하여 catch 문으로 오게 되면 첫 번째 트랜잭션이 이미 RankingPeriod를 생성해 주었을 것이다.
- 여기서 아무 로직도 실행하지 않으면 점수가 사라지겠지만 한 번 더 점수를 올리는 로직을 실행하여 다른 트랜잭션이 먼저 만들어준 RankingPeriod를 조회해 점수를 올린다.
catch 문에서는 이미 커밋된 RankingPeriod를 읽을 수 있게 되면서 정상적으로 다시 점수를 올릴 수 있게 된다.
테스트 통과!!
특정 비즈니스 정책을 만족시키기 위해 비동기, 동시성, 트랜잭션 등등의 복합적인 상황들을 맞이하고 해결해 본 뜻깊은 경험이었으나 정말 머리를 많이 써서 힘들었다.. 사실 이 해결 방법이 최선의 해결 방법이 아닐 수도 있고 더 간단한 방법이 있을 수도 있다.
하지만 일단 해결했다는 것에 의의를 둔다…
'우아한테크코스 > 프로젝트-SMODY' 카테고리의 다른 글
운영 서버에서 예외 발생 시 자동 깃헙 이슈 생성 (0) | 2022.10.12 |
---|---|
[Spring] 알림 기능 비동기 처리하기 (0) | 2022.09.17 |
이벤트 트랜잭션 분리와 테스트에서의 @Transactional 문제 (0) | 2022.08.30 |
결합은 낮추고 전략은 다양하게, 알림 기능 적용기 (0) | 2022.08.15 |
[JPA] 조회 쿼리 N+1 문제 해결 (0) | 2022.07.10 |