I/O와 트랜잭션 분리하기

팀원에게 공유 목적으로 작성한 글이라 이 부분에서는 존댓말로 작성되어있습니다.


책을 검색하는 서비스 레이어를 살펴보면 다음과 같아요.

@Service
@RequiredArgsConstructor
public class SearchBookUseCase {

    private final KakaoApiClient kakaoApiClient;

    public SearchBookResponse search(String query, String sort, int size, int page, String target) {
        return SearchBookResponse.from(kakaoApiClient.searchBooks(query, sort, page, size, target));
    }
}

현재는 단순히 요청을 받고, 외부 API를 호출한 후 그 데이터를 가공해서 응답하는 형태로만 구현이 되어있기 때문에 별도의 트랜잭션을 관리할 필요가 없어보입니다.

하지만, 요구사항이 변경되어 검색어 순위를 보고싶다면, 어떻게 구현을 해야할지 생각을 해보겠습니다. (MySQL 기준)

@Service
@RequiredArgsConstructor
public class SearchBookUseCase {

    private final KakaoApiClient kakaoApiClient;
    private final BookCountRepository bookCountRepository;

    @Transactional
    public SearchBookResponse search(String query, String sort, int size, int page, String target) {
        
        bookCountRepository.findByQuery(query)
                           .ifPresentOrElse(
                                   bookCount -> bookCountRepository.updateCount(bookCount.getId(), bookCount.getCount() + 1),
                                   () -> bookCountRepository.save(new BookCount(query, 1))
                           );
        
        return SearchBookResponse.from(kakaoApiClient.searchBooks(query, sort, page, size, target));
    }
}

위와 같이 구현할 경우, 불필요하게 트랜잭션을 길게 잡아버릴 수 있는 문제점이 존재하기 때문에 분리하는 것을 고려해보면 좋다고 합니다.

퍼사드 패턴을 활용한 분리

퍼사드 패턴을 통해 외부 결합도를 낮춰주는 방식으로 적용할 수 있습니다.

SearchCountService.java

@Service
@RequiredArgsConstructor
public class SearchCountService {
    private final BookCountRepository bookCountRepository;
    
    @Transactional
    public void incrementSearchCount(String query) {
        bookCountRepository.findByQuery(query)
                .ifPresentOrElse(
                        bookCount -> bookCountRepository.updateCount(bookCount.getId(), bookCount.getCount() + 1),
                        () -> bookCountRepository.save(new BookCount(query, 1))
                );
    }
}

SearchBookFacade.java

@Service
@RequiredArgsConstructor
public class SearchBookFacade {
    private final KakaoApiClient kakaoApiClient;
    private final SearchCountService searchCountService;
    
    public SearchBookResponse search(String query, String sort, int size, int page, String target) {
        // 외부 API 호출 먼저 수행
        SearchBookResponse response = SearchBookResponse.from(
            kakaoApiClient.searchBooks(query, sort, page, size, target)
        );
        
        // DB 작업은 별도로 수행
        searchCountService.incrementSearchCount(query);
        
        return response;
    }
}

장점

  • 구현이 단순하고 직관적

  • 실행 순서가 명확함

  • 동기적 처리가 필요한 경우에 적합

단점:

  • 여전히 같은 요청 내에서 처리되므로 전체 응답 시간은 동일

이벤트를 활용한 분리

트랜잭션 완료 후 비동기적으로 처리해도 되는 작업인 경우, 이벤트를 고려해볼 수 있습니다.

SearchCountEventListener.java

@Component
@RequiredArgsConstructor
public class SearchCountEventListener {
    private final SearchCountService searchCountService;
    
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleSearchEvent(SearchPerformedEvent event) {
        searchCountService.incrementSearchCount(event.getQuery());
    }
}

SearchBookService.java

@Service
@RequiredArgsConstructor
public class SearchBookService {
    private final KakaoApiClient kakaoApiClient;
    private final ApplicationEventPublisher eventPublisher;
    
    public SearchBookResponse search(String query, String sort, int size, int page, String target) {
        SearchBookResponse response = SearchBookResponse.from(
            kakaoApiClient.searchBooks(query, sort, page, size, target)
        );
        
        // 이벤트 발행
        eventPublisher.publishEvent(new SearchPerformedEvent(query));
        
        return response;
    }
}

장점

  • 비동기 처리로 응답 시간 개선

  • 시스템 결합도 감소

  • 트랜잭션 완료 후 처리 보장

단점

  • 디버깅이 상대적으로 어려움

  • 시스템 복잡도 증가

Last updated

Was this helpful?