0. 들어가기 전에
TaskScheduler
를 활용해 자동적으로 리뷰 완료 상태로 변경하는 기능을 만들었습니다.
1. 필요한 이유
리뷰 랭킹을 구현하고 나니, 리뷰했지만 리뷰 완료 버튼을 누르지 않은 리뷰어들이 많았다. 글 상태가 리뷰 진행중이라고 되어있고, 리뷰 개수가 증가하지 않아 랭킹에 못 들어간 리뷰어들이 많았다! 또한, 리뷰어 평가도 이루어지지 않았다. 이 문제를 해결하기 위해 회의를 한 결과 리뷰 진행 중이면서 마감 기한이 지나면 3일 뒤에 자동으로 리뷰 완료 상태로 변경되도록 정책을 변경하기로 했다.
2. 구현 아이디어
그럼 어떻게 구현해야 할까? 가장 먼저 생각나는 건 아래처럼 스케줄러를 사용해서, 1분마다 조건에 맞는 게시글을 찾아 상태를 바꿔주는 것이다.
@Scheduled(cron = "0 */1 * * * *")
public void doSomething() {
// Business Logic
}
하지만 이렇게 되면 매 분 쿼리가 나가기 때문에 별로 마음에 들지 않았다.
요구 사항에 리뷰가 진행중이면서 마감 기한이 3일 지나면 자동으로 리뷰 완료 상태로 변경된다.
라는 날짜 조건이 명확하기 때문에 그 날짜에 한 번만 실행되면 된다. 이런 동작을 가능하게 하는 기능이 있을거라 조사하던 중에 TaskScheduler
를 찾아냈다.
위 설명에 따르면 다양한 종류의 트리거를 등록할 수 있다고 한다.
/**
* Schedule the given {@link Runnable}, invoking it at the specified execution time
* and subsequently with the given period. * <p>Execution will end once the scheduler shuts down or the returned * {@link ScheduledFuture} gets cancelled.
* @param task the Runnable to execute whenever the trigger fires
* @param startTime the desired first execution time for the task
*
* ...
*
*/
ScheduledFuture<?> schedule(Runnable task, Instant startTime);
우리 팀은 서포터가 선택되면 이벤트로 날려 알림을 보내고 있기 때문에, 이 이벤트를 받는 리스너를 만들어 미리 스케줄이 수행될 날짜를 정해두면 된다.
3. 예상되는 문제
3.1 동시성
서버가 늘어난다면 어떤 문제가 발생할까? 동시성 문제가 발생한다. 늘어난 서버 모두에서 동시에 RunnerPost의 상태를 조작하는 로직이 동작한다. 사실 RunnerPost의 상태가 Done -> Done
으로 변경되는 건 큰 문제가 아니다. 진짜 문제는 서포터의 완료된 리뷰 횟수이다. finishReview()
메서드가 호출되면 Supporter의 ReviewCount도 같이 증가한다. 만약 서버가 3개가 있다면 서포터의 reviewCount
가 3이 증가하게 된다.
이 문제를 해결하려면 어떻게 해야 할까? Lock
을 사용하면 된다. 고려해 본 Lock
은 세 가지이다.
먼저 Optimistic Lock
이다. Optimistic Lock
은 낙관적 락으로 Race Condition을 해결하는 방법 중 하나이다. 개발자가 락을 획득하지 못했을 때 재시도 로직을 직접 구현해 줘야 하기도하고, Entity 안에 @Version
이 들어가야 하므로 맘에 들지 않았다.
다음은 Named Lock
이다. 여러 WAS 중에서 Named Lock
을 획득한 WAS는 로직을 수행할 것이고, 획득하지 못한 WAS 들은 Lock을 점유하러 대기할 것이다. 그 후 다른 WAS가 수정하려 든다면, 방어 로직을 짜 놓아 상태가 변했기 때문에 로직을 실행하지 않는다. 하지만 이 방법은 채택하지 않았는데, 그 이유는 DataSource를 분리해야 한다는 추가적인 공수가 들어가기 때문이다. 왜냐하면 propagation 설정을 requires_new로 설정해서 메인 로직과 트랜잭션을 분리해야, 락 해제 전에 commit이 되는 것을 보장되기 때문이다. requires new는 새로운 커넥션을 사용하고, Lock을 얻기위해 대기하는 Connection 때문에 Connection pool이 고갈되는 위험이 있어 DataSource를 분리해야 한다.
내가 선택한 것은 Pessimistic Lock
이다. 위의 Lock들의의 단점들을 모두 해결할 수 있기 때문에 선택했다.
@Transactional
public void finishReview(final Long runnerPostId) {
final RunnerPost findRunnerPost = runnerPostDeadlineQueryRepository.joinSupporterByRunnerPostIdWithLock(runnerPostId)
.orElseThrow(() -> new ScheduleBusinessException(String.format("RunnerPost를 찾을 수 없습니다. runnerPostId=%s",runnerPostId)));
if (findRunnerPost.isReviewStatusDone()) {
return;
}
findRunnerPost.finishReview();
}
public interface RunnerPostDeadlineQueryRepository extends JpaRepository<RunnerPost, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select rp
from rp
fetch join rp.supporter
where rp.id = :id
""")
Optional<RunnerPost> joinSupporterByRunnerPostIdWithLock(@Param("id") Long runnerPostId);
}
3.2 한 작업이 길어질 때
한 작업이 길어지면 어떻게 될까? 만약 수만 건의 스케줄이 작업 중이고 하나의 작업에서 1분이 넘게 걸린다면? 나머지 작업을처리하는 데 오래 걸릴 것이다.
이 문제를 해결하기 위해 비동기로 처리하는 것을 고려했다. 비동기로 스케줄링 목록들을 실행하도록 하면, 오래 걸리는 로직도 문제없이 처리될 수 있다.
@Async
@Transactional
public void finishReview(final Long runnerPostId) {
final RunnerPost findRunnerPost = runnerPostDeadlineQueryRepository.joinSupporterByRunnerPostIdWithLock(runnerPostId)
.orElseThrow(() -> new ScheduleBusinessException(String.format("RunnerPost를 찾을 수 없습니다. runnerPostId=%s",runnerPostId)));
if (findRunnerPost.isReviewStatusDone()) {
return;
}
findRunnerPost.finishReview();
}
하지만 이 부분은 실제로 우리 서비스에서 문제가 되지 않을거 같아 제외했다.
3.3 서버가 다운 될 때
서버가 다운되는 경우가 얼마나 있을까? 생각보다 별로 없을 것 같지만, 배포할 때마다 서버가 다운되고 재실행된다. 이런 경우에 TaskSchduler
는 각 작업을 스레드에 보관하고 있기 때문에 데이터가 유실된다.
이 문제 해결의 아이디어는 트랜잭션 아웃박스 패턴
에서 찾을 수 있다. 트랜잭션 아웃 박스 패턴은 이벤트를 발행하기 전에 Transaction을 걸어 이벤트의 저장을 보장하는 패턴이다. 이벤트를 소비하는 쪽에서 아웃박스를 제거하면 되고, 만약 아웃박스에 데이터가 있으면 일정 시간마다 실행시켜 주는 것이다.
이 구현 방식과 비슷하게 schedule()
메서드를 실행하기 전에 저장해주고, 스케줄이 끝나면 지워주면 된다. 그러면 어떤 주기마다 데이터베이스를 확인하면 될까? 작업이 사라지는 때를 생각해 보면, 서버가 다운될 때 작업이 사라진다. 그렇기 때문에 서버가 시작할 때 즉, 애플리케이션이 시작할 때 등록해 주면 된다.
@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
public class DeadlineOutbox extends BaseEntity {
@GeneratedValue(strategy = IDENTITY)
@Id
private Long id;
private Long runnerPostId;
private Instant instantToRun;
// ...
}
@EventListener(ApplicationStartedEvent.class)
public void setUpSchedules() {
deadlineOutboxCommandRepository.findAll().forEach(deadlineOutbox ->
taskScheduler.schedule(() -> runnerPostDeadlineCheckScheduler.finishReview(
deadlineOutbox.getRunnerPostId()), deadlineOutbox.getInstantToRun()
));
}
4. 추가로 고려해야할 사항
Pessimistic Lock
으로 구현했는데, 성능이 얼마나 차이나는지 확인해보지 않았다. 성능 테스트를 진행하면서 다른 Lock 방식을 고려해봐야한다.
※ 참고
'개발 > [우테코]' 카테고리의 다른 글
락을 사용한 동시성 테스트하기 (1) | 2024.02.27 |
---|---|
Spring에서 Redis Test 하기 (0) | 2024.02.12 |
Redis를 활용해서 RefreshToken 최적화하기 (0) | 2024.02.08 |
바톤의 DB Replication (0) | 2023.10.14 |
build 할 때 Rest Docs 파일이 생성이 안되는 문제 트러블 슈팅 (0) | 2023.08.20 |