티스토리 뷰

반응형

개인 프로젝트를 진행하면서 버그를 겪고 해결하면서 공부한 내용을 포스팅 할 예정입니다. 자세한 코드 과정과 도출 과정이 궁금하시다면

Github 를 참고해주세요

Transactional 의 동작방식

먼저 이 문제를 파악하기 전에 동작 방식을 이해해야합니다. Transactional annotation은 AOP 기반으로 동작하고 있습니다. 관리할 프록시가 생성되고 이 객체는 타깃을 감싸 해당 객체의 메소드 호출을 중간에 가로챌 수 있게 합니다. 그 후 메소드 호출 전 후 시작 커밋 롤백 등의 작업을 수행할 수 있게 코드를 주입해주고 이러한 원리 때문에 저희는 쉽게 하나의 요청단위로 묶어 비즈니스 오염 없이 트랜잭션 특징인 ACID를 위반하지 않게할 수 있습니다.

문제 상황

@Aspect
@Component
class FinAccountAspect(
    private val bankAccountQueryService: BankAccountQueryService,
    private val bankAccountApiClient: BankAccountApiClient,
    private val bankAccountRepository: BankAccountRepository
) {

    private val log = LoggerFactory.getLogger(FinAccountAspect::class.java)

    @Around("@annotation(me.golf.kotlin.domain.bank.RequireFinAccount)")
    @Transactional
    fun validateAndGet(joinPoint: ProceedingJoinPoint): Any? {
        val method = (joinPoint.signature as MethodSignature).method
        val lookupType = method.getAnnotation(RequireFinAccount::class.java).type

        val args = joinPoint.args

        return when (lookupType) {
            LookupType.ONE -> validateAndGetOne(args, joinPoint)
            LookupType.SEVERAL -> validateAndGetSeveral(args, joinPoint)
        }
    }

    private fun validateAndGetOne(args: Array<Any>, joinPoint: ProceedingJoinPoint): Any? {
        if (args.size != 2 || args[0] !is CustomUserDetails || args[1] !is Long) {
            return null
        }

        val userDetails = args[0] as CustomUserDetails
        val bankId = args[1] as Long
        val bankAccount = bankAccountQueryService.getBankAccount(bankId, userDetails.memberId)
        var registerNumber = bankAccount.registerNumber

        if (registerNumber == DEFAULT_NH_VALUE) {
            log.info("등록 번호 발급 진행 ID 정보 : {}", bankAccount.id)

            registerNumber = bankAccountApiClient.publishRegisterNumberConnection(
                PublishRegisterNumberRequestDto.of(true, bankAccount.bankName.code, bankAccount.number))
        }

        val finAccount = getFinAccount(bankAccount, registerNumber)

        bankAccount.updateFinAccountAndRegisterNumber(finAccount = finAccount, registerNumber = registerNumber, bankId)

        return joinPoint.proceed()
    }

AspectJ 를 사용하여 외부 API 데이터를 받아오고 받아온 데이터를 DB에 저장하여 재사용 될 수 있는게 목적인 코드입니다. 하지만 여기서 문제는 Transactional을 이용해서 영속화를 유지 시켜 주다가 더티체킹이 일어나 updateFinAccountAndRegisterNumber() 에서 DB가 업데이트가 됐어야 했지만 실제로는 update가 되지 않았습니다.

그래서 Transactional이 제대로 동작하지 않는 것이 의심 되어 Transactional 제거 후 QueryDSL로 직접적으로 update query를 날려주게 바꿨더니 그제서야 update Query가 날라가는 것을 확인할 수 있었습니다.

원인

AOP에는 우선순위가 매겨져 있습니다. 그리고 이 설정이 충돌되면 동작하지 않는 경우가 발생하는데 공식문서에서 Transactional의 우선순위는 LOWEST_PRECEDENCE 였습니다. 

LOWEST_PRECEDENCE는 Ordered 클래스를 살펴보면 값을 알 수 있는데

Integer.MAX_VALUE로 선언되어있고 이 숫자가 낮을 수록 높은 우선순위를 갖기 때문에 Transactional이 우선순위에 밀려 동작하지 않았던 겁니다. 

 

그렇다면 Order를 동일하게 세팅하고 실행하면 해결 될까요? 그것은 틀렸습니다. 이거에 대한 답은 블로그에서 찾을 수 있었는데 참고한 블로그에 따르면

동일한 우선순위를 부여받는다고 하더라도 동일한 관점에서의 관점에서 정의된 두 개의 Advice들은 모두 동일한 JoinPoint에서 실행되야한다면 reflection을 통해 선언 순서를 검색할 방법이 없기 때문에 실행 순서는 정의되지 않습니다. 

 

그렇기 때문에 스프링 진영에서는 이러한 경우 별도의 관점 클래스로 분리하여 개발하는 방법을 권장하고 있습니다. 

 

해결

현재는 개인적인 사정 때문에 시간이 없어 동작은 할 수 있게 Transactional을 제거하고 QueryDSL로 수정하여 동작하게 만들어둔 상태입니다. 하지만 이후 프로젝트 종료 후 리팩토링 과정에서 아예 AOP를 제거하고 별도의 관점 클래스로 분리하여 Controller에서 필요한 endpoint들은 사전에 먼저 실행할 수 있게 수정할 계획을 하고 있습니다.

 

정리

동작 원리를 이해하고 기술을 사용하는 것이 중요하다는것을 깨닫게 된 이번 트러블 슈팅이었습니다. 이러한 문제가 생기더라도 Transactional이 정확히 어떤 동작을하고 어떤 역할을 알 수 있다면 또 그 기반이 되는 AOP의 동작 방식을 이해한다면 금방 해결할 수 있는 문제였습니다. 

 

Ref.

https://kim-jong-hyun.tistory.com/143

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
글 보관함