티스토리 뷰

개요

  • 비지니스 로직을 아무리 명확하게 짜고 유닛 테스트를 촘촘하게 작성해도 실제 프로덕션 레벨에서는 유입량에 따라 예상하지 못한 수많은 데드락을 경험할 수 있다. 이번 글에서는 데드락을 최소화하는데 도움이 되는 READ-COMMITTED 트랜잭션 격리 레벨과 @Retryable 사용법을 설명하고자 한다.

앞서 읽어보면 좋은 글

데드락 최소화 전략

  • 트랜잭션 범위를 최대한 작게 조정한다. (공유 잠금 시간 최소화) 특히, @Controller 레벨에 명시된 @Transactional은 데드락을 유발할 가능성이 매우 크므로 제거한다.
  • 모든 쿼리의 트랜잭션 격리 레벨을 READ-COMMITTED로 실행한다. (공유 잠금 시간 최소화)
  • 데드락이 발생할 경우 @Retryable을 이용하여 지정된 시간 범위 내에서 랜덤한 시간 후에 재시도하여 실행한다. (불가피하게 일시적으로 발생하는 데드락에 대응)

build.gradle.kts

  • 프로젝트의 루트의 build.gradle.kts에 아래 내용을 추가한다.
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-aop:2.6.3")
    implementation("org.springframework.retry:spring-retry:1.3.1")
}

@EnableRetry

  • @Configuration 빈을 작성하고 클래스 레벨에 @EnableRetry을 명시해야 @Retryable 작동이 활성화된다.
  • 아래 예제는 @EnableRetry을 명시하지 않고 직접 커스텀 빈을 작성했는데 이유는 @Transactional@Retryable이 동시에 명시된 메써드에서의 서로 간의 작동 순서에 대한 고려 때문이다. 어떤 경우에도 @Retryable이 먼저 작동할 수 있도록 아래와 같이 우선순위를 최우선으로 명시했다.
import org.springframework.context.annotation.Configuration
import org.springframework.core.Ordered
import org.springframework.retry.annotation.RetryConfiguration

@Configuration
class RetryConfig : RetryConfiguration() {

    override fun getOrder(): Int {

        return Ordered.HIGHEST_PRECEDENCE
    }
}

@Retryable

  • @Retryable은 스프링 빈의 모든 클래스, 메써드 레벨에 명시될 수 있다. @Retryable의 쓰임이 가능 많은 부분이 바로 데드락에 대한 자동 재시도 처리일 것이다. 데드락에 대비하여 @Repository에서는 아래와 같이 작성할 수 있다. (꼭 @Repository에만 적용할 필요는 없다. @Repository을 호출하는 상위 클래스에서 필요에 따라 작성하면 된다.)
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.retry.annotation.Backoff
import org.springframework.retry.annotation.Retryable
import org.springframework.stereotype.Repository
import org.springframework.transaction.annotation.Isolation
import org.springframework.transaction.annotation.Transactional

@Repository
// 트랜잭션 격리 레벨을 READ-COMMITTED로 완화하여 공유 잠금을 최소화
@Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED)
// 예외 발생시 1~5초 사이의 랜덤한 간격으로 최대 5회까지 자동 재시도 활성화
@Retryable(
    maxAttempts = 5,
    backoff = Backoff(random = true, delay = 1000, maxDelay = 5000),
    // 데이터 무결성 위반으로 인한 예외는 재시도 대상에서 제외
    exclude = [DataIntegrityViolationException::class]
)
interface FooRepository : JpaRepository<Foo, Long>, QuerydslPredicateExecutor {

    // write에는 기본값인 readOnly = false를 설정
    @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRES_NEW)
    fun deleteById(id: Long) { ... }
}

@Repository와 @Transactional

  • Spring Data JPA의 모든 @Repository는 기본적으로 클래스 레벨에 @Transactional(readOnly = true)가 명시되어 있고, 모든 delete, save, flush 메써드에는 @Transactional(readOnly = false)가 명시되어 있다. [관련 링크] 즉, 호출하는 상위 레벨에서 어떠한 @Transactional도 명시하지 않았더라도 각 쿼리의 최소 단위로서의 트랜잭션이 실행되는 것이다.
  • 앞서 소개한 코드에서는 이런 특징을 이해하고, 클래스 레벨에는 @Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED), @Transactional(isolation = Isolation.READ_COMMITTED)을 명시하여 어떠한 상황에서도 READ_COMMITTED 트랜잭션 격리 레벨이 작동하도록 명시했다.
  • 각 메써드에 @Transactional이 선언되면 상위 레벨에 선언된 트랜잭션을 무시하고 개별 트랜잭션이 생성되는 것 아닌가하는 우려를 할 수 있는데 어노테이션 내부를 살펴보면 기본값으로 Propagation.REQUIRED 전파 레벨을 가지므로, 자연스럽게 메써드를 호출한 측의 상위 트랜잭션을 이어 받아 하나의 트랜잭션으로 작동하게 된다.

참고 글

댓글
댓글쓰기 폼