[Spring] 트랜잭션 성능과 안정성을 동시에 잡는 최적화 하기🚀

궁금하면 500원·2024년 12월 10일
0

MSA&아키텍처

목록 보기
21/42

들어가며

최근 많은 Spring 기반 프로젝트들이 JPA를 사용하면서, @Transactional 어노테이션의 올바른 사용이 더욱 중요해졌습니다.

이 포스팅에서는 @Transactional이 성능에 미치는 영향과 헥사고날 아키텍처 관점에서의 최적화 방안을 살펴보겠습니다.

1. 문제 상황 발견

1.1 DB 모니터링 지표

- Peak Total QPS: 24K
- Select/Commit 쿼리: ~5K
- Update/Insert 쿼리: < 3K
- Set_option 쿼리: ~14K (!!)

특히 주목할 점은 set_option 쿼리가 전체 QPS의 58%를 차지한다는 것입니다.
이는 심각한 성능 저하를 암시하는 지표였습니다.

쿼리 유형별 상세 분석

1. Set_option 쿼리 (14K, 58.3%)

  • 정의: MySQL 세션 변수를 설정하는 쿼리
  • 주요설정
SET autocommit=0/1
SET transaction_isolation='READ-COMMITTED'
SET sql_mode='...'
SET character_set_results=...

발생 원인

  • @Transactional 어노테이션 사용 시 매번 새로운 트랜잭션 컨텍스트 생성
  • 각 트랜잭션 시작 시 isolation level 설정
  • 세션별 문자셋 설정

2. Select/Commit 쿼리 (5K, 20.8%)

  • Select 쿼리

    • 데이터 조회 작업
    • 읽기 전용 트랜잭션의 조회 작업
  • Commit 쿼리

    • 트랜잭션 종료 시 발생
    • @Transactional 메서드 종료 시 자동 발생

3. Update/Insert 쿼리 (3K, 12.5%)

  • Update 쿼리

    • 기존 데이터 수정
    • 영속성 컨텍스트의 더티 체킹으로 인한 업데이트
  • Insert 쿼리

    • 새로운 데이터 삽입
    • 배치 작업의 일부

4. 기타 쿼리 (2K, 8.4%)

  • Delete 작업
  • 인덱스 조회
  • 메타데이터 조회
  • 통계 정보 업데이트

문제점 분석

1. 높은 Set_option 비중

2. 리소스 낭비

  • 전체 QPS의 58.3%가 실제 비즈니스 로직과 무관한 설정 쿼리
  • 각 트랜잭션마다 반복되는 설정으로 인한 오버헤드
  • DB 커넥션 풀의 불필요한 사용

3. 성능 영향

  • 실제 처리해야 할 쿼리보다 설정 쿼리가 더 많은 비정상적 상황
  • DB 서버의 불필요한 부하 발생
  • 전체 시스템 응답 시간 저하

최적화 방향

1. 트랜잭션 범위 최소화

  • 읽기 전용 작업의 트랜잭션 제거
  • 단일 쿼리 작업의 트랜잭션 최적화

2. 트랜잭션 전파 설정 최적화

@Transactional(propagation = Propagation.SUPPORTS)

3. 배치 처리 도입

  • 여러 건의 데이터 처리를 하나의 트랜잭션으로 묶기
  • 불필요한 트랜잭션 컨텍스트 생성 감소

1.2 아키텍처 관점에서의 분석

헥사고날 아키텍처에서 Repository는 도메인과 인프라스트럭처 계층 사이의 포트 역할을 합니다. @Transactional의 과도한 사용은 이 경계에서 불필요한 오버헤드를 발생시키고 있었습니다.

2. 성능 테스트와 분석

2.1 테스트 시나리오

@RestController
@RequestMapping("/test/read")
class TestController(
    private val orderRepository: OrderRepository
) {
    @GetMapping("/transaction/{transactionId}")
    @Transactional(readOnly = true)
    fun transactionTest(@PathVariable transactionId: String): String {
        return orderRepository.findByTransactionId(transactionId)?.id?.toString() ?: "nohit"
    }

    @GetMapping("/{transactionId}")
    fun nonTransactionTest(@PathVariable transactionId: String): String {
        return orderRepository.findByTransactionId(transactionId)?.id?.toString() ?: "nohit"
    }
}

2.2 성능 테스트 결과

3. 개선 전략과 구현

3.1 헥사고날 아키텍처 기반 개선

  1. Repository Layer 최적화
  • Repository 인터페이스에서 불필요한 트랜잭션 제거
  • Custom Query Method 활용으로 자동 생성 메서드 대체
  1. Domain Layer 트랜잭션 관리
@Service
class OrderService(private val orderRepository: OrderRepository) {
    @ReadOnlyTransactional
    fun findOrder(id: Long): Order? {
        return orderRepository.findByIdWithoutTransaction(id)
    }
}

3.2Custom Annotation 구현

@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
annotation class ReadOnlyTransactional

4. 성능 개선 결과

4.1 주요 개선 지표

  • QPS: 24K → 35K (46% 향상)
  • Set_option 쿼리: 14K → 3K (79% 감소)
  • 평균 응답 시간: 150ms → 50ms (67% 감소)

4.2 리소스 사용량 변화

  • DB Connection Pool 사용률: 85% → 40%
  • CPU 사용률: 75% → 45%

5.추가 최적화 팁

1. N+1 문제 해결

@EntityGraph(attributePaths = ["items"])
@Query("SELECT o FROM Order o WHERE o.id = :id")
fun findByIdWithItems(@Param("id") id: Long): Order?

2. Batch Size 최적화

@BatchSize(size = 100)
@OneToMany(mappedBy = "order")
val items: List<OrderItem> = mutableListOf()

💡 느낌점

이번 정리를 통해 트랜잭션을 단순히 데이터 변경을 위한 도구로 사용하는 것이 아니라, DB 부하를 줄이고 성능을 최적화할 수 있는 전략적인 방법이 많다는 것을 다시 한번 확인할 수 있었습니다.

특히 이벤트 기반 트랜잭션 분리를 활용하면 트랜잭션 지속 시간을 최소화하면서 비즈니스 로직의 확장성을 높일 수 있다는 점이 인상적이었습니다.

앞으로 대규모 시스템 설계 시 이런 패턴들을 적극적으로 활용해야겠다고 생각했습니다. 🚀

profile
꾸준히, 의미있는 사이드 프로젝트 경험과 문제해결 과정을 기록하기 위한 공간입니다.

0개의 댓글