Spring

[Spring] @Retryable, Event 적용기

Tommy__Kim 2023. 12. 4. 07:31

들어가기 앞서

커플 기반 다이어리 프로젝트에서 커플의 활동 지표를 나타내는 사랑의 온도라는 기능이 존재합니다. 

사랑의 온도는 해당 서비스를 사용할 때마다 특정 조건에 대해 온도를 높여주는 기능입니다.

사용자들의 서비스 참여도를 높이기 위해 해당 기능을 추가했었습니다.

리팩토링을 진행하며 @Retryable 그리고 ApplicationEventPublisher를 적용했던 일화에 대해 기록하고자 합니다.

 

@Retryable 적용기

기존 코드

온도 증가 기능 

사랑의 온도 기능의 경우 다이어리 작성, 오늘의 질문에 대한 답변 작성 시마다 온도가 1도씩 증가하도록 설계했습니다.

앱 사용량이 많은 커플의 경우 동시성 문제가 발생 할 수 있다고 판단을 했었고 이에 따라 optimistic lock을 적용해 온도 증가 기능을 구현했습니다.

@Component
@RequiredArgsConstructor
public class IncreaseTemperatureFacade {

    private final CoupleService coupleService;

    public void increaseTemperature(Long coupleId) throws InterruptedException {
        while (true) {
            try {
                coupleService.increaseTemperature(coupleId);
                break;
            } catch (Exception e) {
                Thread.sleep(50);
            }
        }
    }
}

--- 
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CoupleService {

	// 로직 ... 
    
    @Transactional
    public void increaseTemperature(Long coupleId) {
        Couple couple = coupleRepository.findByIdWithOptimisticLock(coupleId)
            .orElseThrow(() -> new EntityNotFoundException("존재하지 않는 커플 id 입니다.")); 
        couple.increaseTemperature();
    }
    
    // 로직 ... 
}

public class Couple extends BaseTimeEntity {
	
    // ... 
    @Column(name = "temperature")
	private Float temperature
	
    // ...
    public void increaseTemperature() {
        if (this.temperature >= 100f) {
            this.temperature = 100f;
        } else {
            this.temperature += 1f;
        }
    }
    
    // ... 
}

 

사실 기존에 짰던 코드 중에서 치명적인 실수를 범한 부분이 있었습니다.

@Component
@RequiredArgsConstructor
public class IncreaseTemperatureFacade {

    private final CoupleService coupleService;

    public void increaseTemperature(Long coupleId) throws InterruptedException {
        while (true) {
            try {
                coupleService.increaseTemperature(coupleId);
                break;
            } catch (Exception e) {
                Thread.sleep(50);
            }
        }
    }
}

현재 로직의 경우  온도를 증가시키고자 할 때 예외가 발생한다면 50ms를 쉰 다음 다시 재시도를 하는 로직을 가지고 있습니다. 

Optimistic Lock을 사용할 때 동시에 데이터를 업데이트하고자 할 때 발생하는 예외는 OptimisticLockingFailureException입니다. 

그런데 현재의 경우에는 모든 예외에 대해서 처리를 하고 있습니다. 그렇기 때문에 동시성관련 예외가 아닌 예외가 터지게 되면 무한 loop에 걸리게 되는 단점을 가지고 있습니다. 실제로 개발 초기에 커플이 맺어지지 않아도 서비스 이용이 가능하도록 접근 제한을 풀었는데 , 커플 아이디가 없어 EntityNotFoundException 이 발생해 무한으로 해결하려고 하는 현상을 발견했었습니다. 

보다 정교한 재시도 설정을 하기 위해 spring에서 제공하는 retry를 적용했습니다.

@Retryable(retryFor = ObjectOptimisticLockingFailureException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000))
@Transactional
public void increaseTemperature(Long coupleId) {
    Couple couple = coupleRepository.findByIdWithOptimisticLock(coupleId)
        .orElseThrow(() -> new EntityNotFoundException("존재하지 않는 커플 id 입니다.")); 
    couple.increaseTemperature();
}

재시도를 하고자 하는 method위에 @Retry 어노테이션을 붙여주면 됩니다.

해당 어노테이션을 통해 Facade 패턴을 쓰지 않고 재시도 옵션을 추가할 수 있었으며, 보다 정교하게 재시도 옵션을 설정할 수 있었습니다.

 

관련 테스트 코드 

@DisplayName("retryable은 3번까지 가능하다.")
@Test
void increaseTemperature() {
    // given
    Long coupleId = 1L;
    Couple mockCouple = Couple.builder()
        .boyId(1L)
        .girlId(2L)
        .temperature(1.0f)
        .build();

    when(coupleRepository.findByIdWithOptimisticLock(coupleId))
        .thenThrow(new ObjectOptimisticLockingFailureException(Couple.class.toString(), coupleId))
        .thenThrow(new ObjectOptimisticLockingFailureException(Couple.class.toString(), coupleId))
        .thenReturn(Optional.of(mockCouple));


    // when
    Throwable thrown = catchThrowable(() -> coupleService.increaseTemperature(coupleId));

    // then
    verify(coupleRepository, times(3)).findByIdWithOptimisticLock(coupleId);
    assertNull(thrown);
}

 

정말 재시도를 3번 하는지 검증을 하기 위해 테스트 코드를 작성했습니다. 

coupleRepository를 Mock처리해두고 예외가 두 번 터지게끔 설정을 했었습니다. 

테스트를 돌려본 결과 제가 원한대로 retry를 하고 있음을 확인했습니다. 


Event 적용기

사랑의 온도를 증가시키는 로직이 다음과 같이 Service Layer에 속해있었습니다. 

// 다이어리 작성 로직 
@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class DiaryService {

	// ... 
    
    @Transactional
    public Long createDiary(List<MultipartFile> multipartFileList, DiaryCreateRequest diaryCreateRequest, Long memberId) {
        Member member = validateMemberId(memberId);
        checkCountOfImage(multipartFileList);
        List<String> uploadedImageUrls = uploadImages(multipartFileList);
        Diary diary = diaryCreateRequest.toEntity(member);
        diary.addPhoto(Photos.create(uploadedImageUrls));
        Diary savedDiary = diaryRepositoryAdapter.save(diary);
        increaseTemperature(savedDiary);

        return savedDiary.getId();
    }

    private void increaseTemperature(Diary savedDiary) {
        try {
            facade.increaseTemperature(savedDiary.getCoupleId());
        } catch (InterruptedException e) {
            log.warn("[System Error] Something went wrong during increasing temperature", e);
            throw new IllegalStateException("System Error Occurred",e);
        }
    }
    // ... 
}
--- 

// 질문 답변 로직
@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class QuestionService {

	// ... 
    
    @Retryable(retryFor = ObjectOptimisticLockingFailureException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000))
    @Transactional
    public void updateQuestionAnswer(Long id, String sex, int answer) {
        Question question = questionRepository.findById(id)
                .orElseThrow(() -> new NoSuchElementException(notFoundEntityMessage("question", id)));
        question.updateAnswer(answer, Sex.valueOf(sex));
        increaseTemperature(question);
    }

    private void increaseTemperature(Question question) {
        try {
            facade.increaseTemperature(question.getCoupleId());
        } catch (InterruptedException e) { 
            log.warn("[System Error] Something went wrong during increasing temperature", e);
            throw new IllegalStateException("System Error Occurred",e);
        }
    }
    
    // ... 
}

기존의 로직들은 다이어리 및 질문 Service Layer에 사랑의 온도 증가 기능이 속해있기 때문에 다음과 같은 문제점이 존재합니다.

  • 온도증가 기능에서 rollback 발생 시 다이어리 및 질문 관련 기능도 rollback 처리
  • 사랑의 온도 관련 시간이 포함된 요청시간

온도증가 기능에서 rollback 발생 시 다이어리 및 질문 관련 기능도 rollback 처리

기존의 온도증가로직의 경우 @Transactional의 기본 옵션인 required를 사용했습니다. 

required의 경우 기존 트랜잭션이 존재하면 트랜잭션에 참여합니다. 사랑의 온도 증가 기능 관련해 Unchecked 예외가 터지면 Rollback 처리가 되고, 같이 참여하고 있던 Diary 및 Question 관련 트랜잭션도 Rollback 처리가 됩니다. 

정상적으로 요청한 사용자의 요청에 대해 시스템에서 장애가 발생한 부분 때문에 사용자의 요청이 무산되는 것은 고객에게 좋은 경험을 남겨줄 수 없다고 판단했습니다. 

 

사랑의 온도 관련 시간이 포함된 요청시간

현재 사랑의 온도 증가 로직 관련해 OptimisticLockingFailureException이 발생할 경우 재시도하는 로직이 존재합니다. 

그렇기 때문에 다이어리 작성 및 질문 답변을 제외한 추가적인 시간이 포함되어 응답시간이 길어지게 됩니다. 

 

Spring의 ApplicationEventPublisher를 사용해 앞선 문제들을 해결 할 수 있었습니다. 

 

@Configuration
public class EventsConfiguration {

    private final ApplicationContext applicationContext;


    public EventsConfiguration(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }

    @Bean
    public InitializingBean eventsInitializer() {
        return () -> Events.setPublisher(applicationContext);
    }

}

---

public class Events {
    private static ApplicationEventPublisher publisher;

    private Events() {
    }

    static void setPublisher(ApplicationEventPublisher publisher) {
        Events.publisher = publisher;
    }

    public static void raise(Object event) {
        if (publisher != null) {
            publisher.publishEvent(event);
        }
    }
}

 

우선 ApplicationEventPublisher를 사용하기 위해서는 해당 빈을 등록해 주어야 합니다. 

빈을 등록할 때 ApplicationContext를 주입받아 빈을 등록하도록 Configuration 파일을 설정해 주었습니다. 

 

@Transactional
public Long createDiary(List<MultipartFile> multipartFileList, DiaryCreateRequest diaryCreateRequest, Long memberId, Long coupleId) {
	
    // ... 

    Events.raise(new DiaryCreatedEvent(coupleId, String.valueOf(coupleId), List.of(CacheConstants.DIARY_LIST, CacheConstants.DIARY_MARKER, CacheConstants.DIARY_GRID)));
    return savedDiary.getId();
}

---

@Component
@Transactional
@RequiredArgsConstructor
@Slf4j
public class TemperatureIncreaseHandler {

    private final CoupleService coupleService;

    // ... 

    @Async
    @TransactionalEventListener(
        classes = DiaryCreatedEvent.class,
        phase = TransactionPhase.AFTER_COMMIT
    )
    public void handle(DiaryCreatedEvent event) {
        coupleService.increaseTemperature(event.getCoupleId());
    }
}

 

다이어리 생성 관련 코드를 보면 기존의 코드와 다르게 DiaryService에서 커플의 온도를 증가시키지 않고 Event를 발행하기만 합니다. 

TemperatureIncreaseHandler에서 커플의 온도를 증가하는 로직을 담당합니다. 

TemperatureIncreaseHandler의 주된 로직은 다음과 같습니다. 

  • @TransactionalEventListener 
    • 해당 어노테이션을 사용하면 어느 시점에 해당 로직을 실행할 지 결정을 할 수 있습니다. 온도 증가 로직과 관련해서 다이어리가 정상적으로 생성이 되었을 때 사랑의 온도를 증가시키고 싶었기에 COMMIT이 된 후에 해당 로직을 실행하도록 설정해주었습니다. 
  • @Async
    • 온도 증가 기능의 경우 Client에게 즉각적으로 반영 될 필요는 없는 부분입니다. 그렇기 때문에 비동기적으로 작업을 수행할 수 있도록 해 주었습니다. 

마무리

@Retryable을 적용하게 됨으로 인해 동시성 관련 이슈가 터졌을 때만 최대 3번까지 재요청을 할 수 있도록 보다 정밀하게 재시도 요청을 컨트롤 할 수 있었습니다. 

또한 ApplicationEventPublisher를 사용함으로 인해 다이어리와 질문 관련 Service Layer에서는 각자의 책임만 수행하고, 온도를 증가시키는 책임은 Handler에서 지도록 분리를 할 수 있었습니다. @TransactionalEventListner를 사용해 정상적으로 커밋이 되었을 때 만 수행할 수 있도록 했고, 비동기적으로 작용하도록 코드를 변경함으로 인해 사용자의 요청에 대한 응답 시간을 보다 줄일 수 있었습니다. 

 

'Spring' 카테고리의 다른 글

[Spring] 테스트 환경 통합  (0) 2024.03.09
Spring, 그리고 Test  (4) 2023.10.11
Spring MVC - 요청과 관련된 사용법 정리  (0) 2023.07.06
Junit에서 TestInstance의 생명주기  (0) 2023.07.03
NamedParameterJdbcTemplate  (0) 2023.07.01