이번 포스팅은 분석 서비스를 개발하다 만난 데드락을 해결하는 과정을 담았습니다.
감정 분석 서비스 미리 보기
기존 서비스에 이용자를 모으기 위해 고도화하는 프로젝트 중 하나가 `감정 분석 서비스`입니다.
핵심 기능
사용자가 작성한 편지를 `Clova API`를 이용해 `감정 분석`을 수행하고, 하루에 작성한 편지들을 바탕으로 `데일리 리포트`를 발행하는 서비스입니다.
제약 사항: 비용이 발생하는 외부 API인 `Clova API` 를 최소한으로 사용한다.
핵심 기능은 비용이 발생하는 외부 API를 사용하기 때문에 최소한으로 사용해야 하는 제약 사항이 있습니다.
- 편지마다 감정 분석은 `1회만 수행`해야 한다.
- 데일리 리포트는 `하루에 한 번만 생성`돼야 한다.
이런 제약 사항을 해결하기 위해 한 번의 `Clova API` 요청에 여러 편지에 대한 `감정 분석`과 `데일리 리포트`를 한 번에 생성합니다.
그러고 나서 parser를 통해 분리해서 저장합니다.
아래 요청 흐름도를 보면 이해하기 쉽습니다.
요청 흐름도
동시성 테스트
실제 서비스에서 사용하는 예시를 통해 테스트를 해보겠습니다.
테스트 시나리오:
한 명의 사용자로부터 당일 데일리 리포트 생성 요청이 여러 번 왔더라도 외부 API는 한 번만 호출돼야 하며, 데일리 리포트는 1개, 감정 분석은 편지마다 한 번씩만 저장돼야 합니다.
동시성 테스트를 할 땐 비용이 발생하므로 외부 API를 사용하는 대신 더미 응답을 만드는 더미 Clova를 먼저 작성합니다.
더미 Clova 응답을 만들기 위해 Spring DI를 활용합니다.
외부 API 대체하는 더미 응답 만들기
더미 Clova 응답 서비스
/**
* Clova API 를 사용하지 않고, 더미 데이터를 응답하는 더미 서비스입니다.
* 테스트 목적으로 생성되었으며, 운영 환경에서 사용할 수 없습니다.
*/
@Profile("test")
@Service
public class DummyReportClavaService extends ClovaService {
public DummyReportClavaService(ClovaFeignClient client) {
super(client);
}
/**
* message 를 구분자({@code ,})를 이용해 분리한 뒤, 편지 개수에 맞는 응답을 생성합니다. (최대 3개)
* @param message 편지의 내용을 구분자({@code ,})를 통해 하나로 만든 문자열
* @return 더미 Clova 응답
*/
@Override
public ClovaResponseDto sendDailyReportRequest(String message) {
int lettersCount = message.split(",").length;
return DummyDailyReportClovaResponseDto.createDummy(lettersCount);
}
}
- 기존 ClovaService를 상속하며, `@Profile("test")` 애너테이션을 붙여 테스트 환경에서만 Bean을 주입받습니다.
더미 Clova 응답 DTO
/**
* 클로바 응답을 대체하는 더미 응답 DTO. 편지 개수에 따른 더미 응답을 동적으로 생성합니다.
*/
@RequiredArgsConstructor
public class DummyDailyReportClovaResponseDto extends ClovaResponseDto {
private static final String RESPONSE_BY_ONE_LETTER = """
{
생략..
}
""";
private static final String RESPONSE_BY_TWO_LETTER = """
{
생략..
}
""";
private static final String RESPONSE_BY_THREE_LETTER = """
{
생략..
}
""";
private final int letterCount;
@Override
public String getResultMessage() {
if (letterCount == 1) {
return RESPONSE_BY_ONE_LETTER;
}
if (letterCount == 2) {
return RESPONSE_BY_TWO_LETTER;
}
return RESPONSE_BY_THREE_LETTER;
}
public static ClovaResponseDto createDummy(int letterCount) {
return new DummyDailyReportClovaResponseDto(letterCount);
}
}
- Clova가 응답하는 형식을 활용하여 편지 개수에 따라 가짜 응답을 동적으로 생성하는 `ClovaResponseDto`를 작성합니다. ( JSON 응답)
데일리 리포트 생성 요청 코드
요청 흐름도대로 동작을 수행합니다.
public DailyReportResponseDto createDailyReport(DailyReportDto.CreateRequest dailyReportDto) {
UUID userId = UUID.fromString(dailyReportDto.getUserId());
LocalDate targetDate = dailyReportDto.getDate();
/* 검증 코드: 실패 시 409 Conflict 응답 */
if (dailyReportRepository.existsByUserAndTargetDate(userId, targetDate)) {
throw new DuplicateDailyReportException("Duplicate daily report exists.");
}
/* 성공 로직 */
// 요청일에 작성된 최근 편지 3개 조회
List<Letter> letters = findRecentLetters(userId, targetDate);
/* 외부 API 호출 */
ClovaResponseDto clovaResponse = requestClovaAnalysis(letters);
/* 응답 Parsing */
ClovaDailyAnalysisResult clovaDailyAnalysisResult = DailyReportExtractor.extract(clovaResponse);
/* 데일리 리포트 저장 */
DailyReport dailyReport = buildDailyReport(targetDate, clovaDailyAnalysisResult);
dailyReportRepository.save(dailyReport);
/* 감정 분석들 저장 */
List<LetterAnalysis> letterAnalyses = buildLetterAnalyses(letters, clovaDailyAnalysisResult);
letterAnalyses.forEach(analysis -> analysis.getLetter().setDailyReport(dailyReport));
letterAnalysisRepository.saveAll(letterAnalyses);
return DailyReportResponseDto.of(dailyReport, letterAnalyses);
}
이 코드는 멀티 스레드 환경에서 데일리 리포트가 이미 존재하는지 검증하는 부분이 정상 작동하지 못할 것입니다.
결과적으로 수많은 데일리 리포트를 생성하기 위해 `외부 API`가 호출되는 문제가 발생하며, 여러 테이블에서 읽기와 쓰기 작업을 수행하기 때문에 `데드락`이 발생할 수 있습니다.
동시성 테스트 코드
@DisplayName("데일리 리포트 생성 요청이 동시에 여러 번 오더라도 한 번만 생성된다.")
@Test
void testConcurrentDailyReportCreation() throws InterruptedException, NoSuchFieldException, IllegalAccessException {
// given
User user = createTestUser();
LocalDate localDate = LocalDate.of(2024, 10, 1);
Letter letter = createLetterByLocalDate(user, localDate);
CreateRequest dto = CreateRequest.builder()
.userId(user.getId().toString())
.date(localDate)
.build();
int threadCount = 100;
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(threadCount);
List<Throwable> exceptions = Collections.synchronizedList(new ArrayList<>());
Runnable task = () -> {
try {
startLatch.await();
dailyReportService.createDailyReport(dto); // 데일리 리포트 생성 요청
} catch (Throwable t) {
exceptions.add(t);
} finally {
endLatch.countDown();
}
};
// when
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(task);
}
startLatch.countDown();
endLatch.await(60, TimeUnit.SECONDS);
// then
executorService.shutdown();
long deadlockCount = exceptions.stream()
.filter(exception -> exception.getMessage().contains("Deadlock"))
.count();
List<DailyReport> dailyReports = dailyReportRepository.findByTargetDateIn(List.of(localDate));
assertAll(
"데일리 리포트는 하나만 생성된다.",
() -> assertThat(exceptions).hasSize(threadCount - 1),
() -> assertThat(deadlockCount).isEqualTo(0L),
() -> assertThat(dailyReports).hasSize(1)
);
}
- given
- 유저, 편지 Entity를 생성합니다. 이를 바탕으로 데일리 리포트 생성에 필요한 DTO를 준비합니다.
- `countDownLatch`를 활용하여 동시에 100개의 요청을 준비합니다. 각 스레드에서 발생한 예외는 `exceptions`에 담습니다.
- when
- `Executors`를 활용해 고정된 개수의 스레드 풀에 래치들을 담습니다. (스레드 풀 사이즈=동시 요청 수)
- `startLatch.countDown()`을 호출해 동시 요청을 수행하는 `endLatch`를 작동시킵니다.
- then
- 의도대로 작동한다면 100개의 스레드 중 한 개의 스레드에서만 데일리 리포트가 생성되야 합니다.
- 따라서, 1개의 스레드를 제외한 99개의 스레드에서 예외가 발생합니다.
- 어느 스레드에서도 데드락이 발생하지 말아야 합니다.
동시성 테스트 결과 (데드락 발생)
테스트는 실패했습니다.
전체 예외 개수는 99개, 생성된 데일리 리포트는 1개가 맞지만 `데드락`이 발생했음을 알 수 있습니다.
예상했던 대로, 검증하는 부분이 정상 작동하지 못했고 결과적으로 수많은 레코드 생성이 발생했습니다.
심지어 데이터베이스에는 데드락이 발생한 것을 확인할 수 있었습니다.
현재 Hikari maximum-pool-size 값은 20인데, 19개의 커넥션에서 `데드락`이 발생한 것을 확인할 수 있었습니다.
운영 환경에서 커넥션 풀의 개수가 늘어날 수록 데드락이 그만큼 발생할 가능성이 높고, 결국 커넥션 수가 모자라 다른 서비스까지 장애가 전파될 수 있습니다.
다음 포스팅에서 데드락 발생 원인과 해결 방법에 대해 알아보겠습니다.
다음 포스팅: 데드락 발생 원인과 해결 방법들
2024.11.26 - [Project] - [포텐데이 409-1pick] 분석 서비스 개발 중 만난 데드락 해결기 (2)
긴 글 읽어주셔서 감사합니다.
'Project' 카테고리의 다른 글
[포텐데이 409-1pick] 분석 서비스 개발 중 만난 데드락과 동시성 문제 해결기 (3) (2) | 2024.11.26 |
---|---|
[포텐데이 409-1pick] 분석 서비스 개발 중 만난 데드락과 동시성 문제 해결기 (2) (0) | 2024.11.26 |
[포텐데이 409-1pick] 올려올려 라디오 서비스 개발기 - 2 (2) | 2024.10.08 |
[포텐데이 409-1pick] 올려올려 라디오 서비스 개발기 - 1 (4) | 2024.10.07 |
[예약 대기 시스템] 4. 컨테이너 환경에서 테스트하기 (Testcontainers) (4) | 2024.09.16 |