ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring 트랜잭션 외부 API 호출 rollback
    Spring Framework 2023. 8. 28. 00:01

    개요

    Spring Framework를 활용하다 보면 데이터 무결성을 지키기 위해 @Transaction 어노테이션을 활용하곤 합니다.

    하지만 외부 API 호출이 포함될 경우에는 어떤 문제가 발생할 수 있을까요?

     

    Github Code

    https://github.com/Junuu/spring-study/tree/main/transactional-outer-call

     

    Rollback

    만약 중간에 Exception이 발생하여 rollback이 되었다고 외부 호출은 rollback 될까요?

    @RestController
    class TransactionalAndConnectionTestController(
        private val testService: OuterService,
        private val testRepository: TestRepository,
    ) {
        @PostMapping("/transactional-outer-call")
        fun test(): String{
            testService.test()
            return "ok"
        }
    
        //의미론상으론 POST지만 편의성을 위해 GET으로 선언
        @GetMapping("/api-call-data-save")
        fun apiCallDataSave(): String{
            testRepository.save(TestEntity(name = "api-call-data-save"))
            return "api-call-data-save"
        }
    
        @GetMapping("/api-call")
        fun apiCall(): String{
            sleep(5000)
            return "api-call"
        }
    }

    외부의 api를 호출하여 데이터를 저장하는 /api-call-data-save를 만들어둡니다.

    이후 해당 api는 /transactional-outer-call에서 호출될 예정입니다.

     

    @Service
    class OuterService(
        private val persistenceLayerService: PersistenceLayerService,
        private val outerApiCallClient: OuterApiCallClient,
    ) {
    
        @Transactional
        fun test() {
            persistenceLayerService.save()
            outerApiCallClient.testCall()
            throw RuntimeException()
        }
    }
    
    @Service
    class OuterApiCallClient(
        private val restTemplate: RestTemplate,
    ) {
        fun testCall() {
            val apiUrl = "http://localhost:8080/api-call-data-save"
            val response: String = restTemplate.getForObject(apiUrl, String::class.java) ?: "default Value"
            println("Response: $response")
        }
    }
    
    @Configuration
    class RestTemplateRegister{
        @Bean
        fun restTemplate(): RestTemplate {
            return RestTemplate()
        }
    }

     

    이렇게 트랜잭션이 묶여있는 경우에 외부호출 후 RuntimeException에 의해 트랜잭션이 실패하면 api-call-data-save의 경우도 롤백될까요?

     

    외부호출의 경우에는 rollback 되지 않는다는 사실을 이미 알고 있었지만 위의 경우에는 같은 Database를 공유하기 때문에 살짝 기대해 보았습니다.

     

    하지만 insert query가 나가고 h2 db에 저장된 모습을 볼 수 있습니다.

    즉, 외부호출은 rollback 되지 않습니다.

     

    외부호출은 rollback되지 않는데 어떻게 대처해야 할까?

    다음과 같은 방법들을 떠올려 보았습니다.

    • 외부호출을 가장 마지막에 수행한다.
      • 외부호출을 하는 도중 네트워크 지연이나, 예외가 발생하였을 때 해당 예외가 전파되어 앞서 db에 저장된 데이터도 같이 롤백됩니다.
      • 하지만 아래에서 소개하는 DB Connection 고갈로 트랜잭션의 단위를 분리해야 한하면 db에 저장된 데이터도 같이 롤백될까요? 
        • 이렇게 되면 외부호출이 실패하였을때 이미 트랜잭션은 종료되었기 때문에 db의 데이터는 롤백되지 않습니다.
        • 성능과의 trade off를 통해 외부호출까지 트랜잭션으로 묶어서 해결할 수도 있을 것 같습니다.

     

    • 외부호출을 가장 먼저 수행한다.
      • 외부호출은 정상적으로 수행되었으나, DB의 데이터를 적재하다 실패하면 어떻게 될까?
      • 외부호출의 특성에 따라 괜찮은 방법일 수 있습니다. 만약 외부호출이 실패 시에도 동일하게 호출해도 된다면 문제는 발생하지 않습니다. (다만 B라는 서비스에서 외부호출을 수행하고, B라는 서비스에서 호출이 되었으니 데이터가 적재되었을 거라 믿고 호출하게 된다면..?  이 순간에는 데이터정합성이 맞지 않습니다.)

     

    • 실패에 대한 보상트랜잭션을 만들까?
      • 흔히 말하는 분산 트랜잭션을 제어하기 위한 Saga Pattern을 활용하는 방법입니다.
      • 실패에 대한 이벤트를 발생시키고 외부호출을 하는 쪽이 해당 이벤트를 consume 하여 해당 호출에 대한 롤백을 수행합니다.
      • 보상트랜잭션 + 외부호출 데이터 저장 전에 수행하면 데이터의 무결성이 잘 보장될 것 같습니다.
      • 하지만 데이터를 먼저 저장해야 하고 나중에 외부호출이 필요한 경우에는? 데이터에 대한 보상트랜잭션도 고민해봐야 합니다.
      • 또한 이벤트 발행에 대한 트랜잭션 아웃박스 패턴을 수행해야 하고, 네트워크 지연이 발생하여 외부호출을 하는 쪽이 해당 이벤트 consume이 늦어지게 되면 간헐적으로 데이터 정합성이 틀어져서 문제가 발생할 가능성이 있습니다. (near-real-time)

     

    • 외부호출을 하는 쪽에서 멱등성을 제공하면 어떨까?
      • 클라이언트가 호출하는 api가 멱등성을 제공하는 경우에는 몇 번을 호출하던 동일한 결과를 발생시킵니다.
      • 예를 들어 api-call-data-save가 이미 저장된 경우에는 또다시 저장하지 않도록 구현을 해두는 것입니다.
      • 하지만 대게 외부호출은 제어할 수 없을 가능성이 높습니다.
      • 또한 비행기 + 숙박을 세트로 같이 예약하는 경우라면 하나가 실패하면 같이 실패해야 합니다. 이런 경우에는 멱등성을 제공하더라도 rollback이 필요합니다.

     

    • 외부호출에 대한 Create, Delete가 가능하다면 이를 활용해 보는 것도 방법?
      • 예를 들어 Create를 수행하고 트랜잭션이 실패가 되었지만 Create 외부호출은 롤백되지 않았습니다.
      • 다음에 해당 api를 수행했을 때는 "이미 가입된 회원입니다"라는 메시지가 출력됩니다.
      • 이때 try-catch 등이나 api의 response message를 받아 이미 가입된 회원이라면 Delete를 통해 회원을 삭제하고 다시 가입시킨다.
      • 또는 이미 회원은 Create 되었으니 해당절차는 Skip 하고 진행한다.

     

    여러 가지 다양한 생각을 해보았지만.. 은탄환은 없다는 것을 다시 한번 깨닫습니다.

    각자의 상황은 미세하게 다를것이고 그에 적합한방법을 적용해야 합니다.

     

    첫번째로 내가 제어하지 않는 Command요청 (타 서비스 데이터 변경을 위한 요청) 자체가 위험해 보입니다.

    이를 최대한 지양하고 오히려 이벤트를 발행하여 타 서비스 데이터 변경요청은 타 서비스에게 위임할 수 있을 것 같습니다.

     

    이외에도 다른 다양한 방법들에 대해서도 댓글로 남겨주시면 감사하겠습니다.

     

    외부 호출이 오래 걸리는 경우 DB Connection 고갈

    @Service
    class OuterService(
        private val testRepository: TestRepository,
        private val outerApiCallClient: OuterApiCallClient,
    ) {
    
        @Transactional
        fun test() {
            testRepository.save(TestEntity(name = "firstSave"))
            outerApiCallClient.testCall()
        }
    }

    repository를 save 하고 외부 api를 호출합니다.

     

    @RestController
    class TransactionalAndConnectionTestController(
        private val testService: OuterService,
    ) {
        @PostMapping("/transactional-outer-call")
        fun test(): String{
            testService.test()
            return "ok"
        }
    
        @GetMapping("/api-call")
        fun apiCall(): String{
            sleep(5000)
            return "api-call"
        }
    }
    
    @Service
    class OuterApiCallClient(
        private val restTemplate: RestTemplate,
    ) {
        fun testCall() {
            val apiUrl = "http://localhost:8080/api-call"
            val response: String = restTemplate.getForObject(apiUrl, String::class.java) ?: "default Value"
            println("Response: $response")
        }
    }
    
    @Configuration
    class RestTemplateRegister{
        @Bean
        fun restTemplate(): RestTemplate {
            return RestTemplate()
        }
    }

    외부 api는 5초가 걸린다고 가정하겠습니다.

    외부 api 호출은 데이터베이스와 관련된 처리가 아니므로 데이터베이스를 활용한 트랜잭션이라 보기 어렵습니다.

    또한 외부 호출은 우리가 제어할 수 없는 영역일 가능성이 높습니다.

    즉, 불필요한 시간 동안 Connection을 물고 있는 대기시간이 증가하게 됩니다.

     

    데이터베이스 커넥션의 한정된 개수가 10개이고, 유저 10명이 해당 service를 호출하는 api를 호출했다면 11번째 유저는 커넥션을 획득하지 못하여 대기하다가 timeout error를 만날 수도 있습니다.

     

    즉, 불필요하게 트랜잭션을 잡아놓아 병목현상이 발생할 수 있습니다.

    이런 경우는 Persistence Layer와 Service Layer를 분리하여 해결할 수 있습니다.

     

    Persistence Layer와 Service Layer 분리

    @Service
    class PersistenceLayerService(
        private val testRepository: TestRepository,
    ) {
        @Transactional
        fun save(){
            testRepository.save(TestEntity(name = "firstSave"))    
        }
    }

    persistence layer에서 데이터베이스 트랜잭션과 관련된 로직을 수행하고 @Transactional을 붙여줍니다.

     

    @Service
    class OuterService(
        private val persistenceLayerService: PersistenceLayerService,
        private val outerApiCallClient: OuterApiCallClient,
    ) {
    
        fun test() {
            persistenceLayerService.save()
            outerApiCallClient.testCall()
        }
    }

    반면 Service layer에서는 더 이상 @Transactional을 사용하지 않습니다.

    이제 데이터베이스 커넥션 유지시간이 실질적으로 데이터베이스와 통신하는 기간으로만 잡혀 트랜잭션이 활성화된 범위를 최소화할 수 있습니다.

     

     

     

     

    참고자료

    https://tecoble.techcourse.co.kr/post/2022-09-20-external-in-transaction/

    https://waspro.tistory.com/734

    https://tech.kakaopay.com/post/msa-transaction/

     

    댓글

Designed by Tistory.