ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 데이터 정합성을 지키기 위한 다양한 노력들
    카테고리 없음 2023. 10. 31. 00:01
    728x90

    개요

    사내 기술블로그에 글을 작성하기 전 초안을 미리 개인블로그에 작성해보고자 합니다.

     

    • 자기소개
    • 독자에게 글의 주제와 내용을 간략하게 소개하여 흥미유발
    • 본문
    • 결론

    자기소개

    아이들나라 플랫폼팀 Backend팀의 김준우입니다.

    "신입 개발자의 달리는 마차에 바뀌 갈아끼기" 라는 주제로 포스팅을 수행했던 적이 있으며, 그 이후로도 지속적으로 회원, 구독 서비스를 운영 개선하고 신규 서비스인 상품, 배송, 주문을 개발하고 있습니다.

     

    해당글에서는 팀원분들이 작성해 주신 "아이들나라 통합 회원 시스템 소개", "실시간 회원 시스템 마이그레이션 구축"에 이어 서비스를 구현할 때 데이터 정합성을 지키기 위해 고려했던 내용들을 공유해보고자 합니다.

     

    데이터 정합성과 무결성

    우선 간단한 예시를 통해 데이터 무결성과 정합성에 대해 알아보겠습니다.

     

    데이터 무결성

    데이터 무결성이란 송금을 수행하고 나서 잔고가 0원 이하(현재는 -5000원) 일 수 없는 것입니다.

     

     

    데이터 정합성

    데이터 정합성이란 5000원 송금을 수행했으나 내 잔고에 송금한 돈(5000원)이 빠져나가지 않고 상대잔고에만 돈이 송금되는 것을 의미합니다.

     

    두 가지는 모두 상황에 따라 치명적인 파급효과를 가져올 수 있습니다.

     

    우리는 서비스 로직을 작성하며 흔히 데이터 정합성과 무결성에 대해 다음과 같은 고민을 하곤 합니다.

    • Race Condition이 발생할 때 어떻게 처리할까? (트랜잭션 격리 수준, Lock, 원자성 연산)
    • 프로그램의 예외발생 시 어떻게 처리할까? (비즈니스 또는 네트워크)

     

    해당글에서는 Spring에서 자주 사용하는 @Transactional과 관련된 정합성에 대해 알아보고자 합니다.

    그중에서도 외부호출과 롤백에 대해서 자세하게 알아보고자 합니다.

     

    Spring의 @Transactional 이란?

    많은 분들이 잘 알고 있는 내용이겠지만 모르는 분들을 위해 소개해보겠습니다.

     

    트랜잭션이란 데이터베이스에서 주로 사용하는 개념으로 여러 연산을 하나로 묶어 한 번에 묶여서 같이 일어나거나, 전부 일어나지 말아야 할 경우에 사용합니다.

     

    Spring에서는 AOP를 활용하여 @Transactional 어노테이션으로 선언적 트랜잭션을 제공합니다.

    DefaultTransactionAttribute클래스의 rollbackOn 메서드

    기본적으로 RuntimeException이나 Error가 발생할 경우 rollback-only 마킹을 수행하고 커밋 전 rollback을 수행합니다.

     

    트랜잭션과 rollback

    rollback이 일어난다는 것은 어떤 것일까요?

    하나의 트랜잭션은 하나의 Database Connection을 사용합니다.

     

    PoolEntry 클래스

    커넥션 풀으로 HikariPool을 사용하시고 계시다면 PoolEntry클래스가 connection을 가지고 있는데, Propagation.Require_New를 활용하여 새로운 트랜잭션을 만들어내고 디버깅을 수행해 보면 다른 Connection을 사용하는 것을 알 수 있습니다.

     

    즉, rollback이 일어난다는 것은 데이터베이스 Connection과 관련되어 하나의 Connection이 하는 요청이 전부 반영되거나, 반영되지 않는 것을 의미합니다.

     

    외부 API 호출과 롤백

    여기까지 왔다면 RuntimeException 등의 비즈니스예외가 발생했을 때 rollback이 발생하여 데이터 정합성이 보장될 것이라 이해할 수 있습니다.

     

    하지만 여기에 HTTP 요청, 메시지 브로커 등의 외부호출이 섞이게 되면 어떻게 될까요?

    중간에 RuntimeException이 발생하여 rollback이 발생했다면 데이터는 반영되지 않았지만 외부 호출도 롤백될까요?

     

    아쉽게도 외부호출은 네트워크를 타고 호출하는 것이며 Database connection과 관련 없기 때문에 롤백되지 않습니다.

    서비스 코드에 외부호출이 포함되는 순간 고민해야 할 부분들이 점점 많아지기 시작합니다.

     

     

    상황에 따라 다음과 같은 방법을 고민해 볼 수 있습니다.

    • 외부 호출이 실패하면 N회 재처리 재시도 후 실패 발생
    • 외부 호출이 실패하더라도 성공으로 처리하고 내부적으로 재처리로 성공을 보장
    • 외부 호출의 실패가 그대로 전파되어 서비스 로직도 실패가 발생

     

    게다가 외부 호출도 내가 작성한 서비스 코드라면 상황에 따라 유연하게 대처할 수 있겠지만 대게 외부 호출은 제어할 수 없을 가능성이 높습니다.

     

    데이터 정합성을 지키기 위해 구현하면서 발생할 수 있는 상황들을 나열해 보겠습니다.

     

    외부 호출이 Query 요청인 경우

     

    외부 호출이 Query인 경우에는 외부 호출 Server의 상태를 변경하지 않기 때문에 여러 번 요청하더라도 상관없습니다.

    이런 경우에는 실패 시 Rollback이 발생하더라도 여러 번 요청해도 상관없습니다.

     

    외부 호출이 Command 요청인 경우

     

    데이터의 롤백을 수행되었지만, Post 요청으로 외부 호출 Server의 내부 데이터는 변경되었습니다.

    Command 요청인 경우 정합성이 어긋날 수 있는 가능성이 발생합니다.

     

    Command 요청에 대한 Create, Delete 등이 가능하다면 이를 활용해 볼 수 있습니다.

    예를 들어 회원가입 요청을 시도하다가 외부 호출에서 실패가 발생했을 때는 "이미 가입된 회원입니다"라는 메시지가 노출된다면 Delete를 수행하고 다시 가입시키는 방법을 고려해 볼 수도 있습니다.

     

    외부 호출 API가 멱등성을 제공한다면 위의 구현 없이 해결할 수도 있을 것 같습니다.

    https://www.rfc-editor.org/rfc/rfc7231#section-4.2.2  따르면 멱등성이란 여러 번 동일한 요청을 제공했을 때, 서버에 미치는 의도된 영향이 동일한 경우를 뜻합니다.

    하지만 비행기 + 숙박을 세트로 예약하는 경우라면 비행기 예매에 실패하면 숙박 예매도 실패해야 하므로 이런 경우에는 멱등성이 제공된다고 하더라도 데이터 정합성이 어긋날 수 있습니다.

     

    외부 호출 API의 실패에 대한 보상 트랜잭션을 만드는 방법도 존재합니다.

    흔히 말하는 분산 트랜잭션을 제어하기 위한 Saga Pattern이며 Kafka, SNS, SQS 등을 활용하여 실패에 대한 이벤트를 발행하고 외부 호출을 하는 쪽에서 해당 Event를 Consume 하여 호출에 대한 rollback을 수행할 수 있습니다.

    하지만 단점으로는 비동기 이벤트 발생으로 실패에 대한 보상 작업이 발생하기 때문에 Real Time이 아니라 Near Real Time으로 이루어집니다.

     

    성능을 위해 외부 호출과 트랜잭션 분리

    위의 상황들은 외부 호출과 트랜잭션이 같이 묶여있는 구조여서 외부 호출 실패를 인지하고 Rollback을 수행할 수 있습니다.

    하지만 성능을 위해 외부 호출과 트랜잭션을 분리해야 한다면 어떻게 될까요?

     

    트랜잭션에 외부 호출이 포함되어 있는 경우

     

    @Service
    class OuterService(
        private val testRepository: TestRepository,
        private val outerApiCallClient: OuterApiCallClient,
    ) {
    
        @Transactional
        fun `트랜잭션에 데이터 저장과 외부 호출이 같이 묶여있는 메서드`() {
            testRepository.save(TestEntity(name = "firstSave"))
            outerApiCallClient.testCall()
        }
    }

    예를 들어 외부 API를 호출할 때 5초가 걸린다고 가정하겠습니다.

    이렇게 되면 Database와 관련된 save 요청이 빠르게 처리된다고 하더라도 외부 호출을 수행하는 5초 동안 Connection을 점유하고 있게 됩니다.

     

    만약 Connection Pool의 개수가 10개이고, 사용자 10명이 해당 Serivce를 의존하는 Endpoint를 호출한다면 11번째 사용자는 커넥션을 획득하지 못하여 대기하다가 timeout error를 만날 수 있습니다.

     

    즉, 트랜잭션에 불필요한 외부 호출이 존재하면 Persistence Layer에서 병목현상이 발생할 가능성이 있으며 목표했던 성능을 뽑아내지 못할 수 있습니다.

     

    이를 해결하기 위해서는 외부 호출인 Client Layer와 Persistence Layer를 분리하고 Persistence Layer에만 트랜잭션을 걸어주어야 합니다.

     

    트랜잭션을 외부 호출과 분리

    @Service
    class PersistenceLayerService(
        private val testRepository: TestRepository,
    ) {
        @Transactional
        fun `트랜잭션에 데이터 저장로직만 들어있는 메서드`(){
            testRepository.save(TestEntity(name = "firstSave"))    
        }
    }

    PersistenceLayerSerivce에만 @Transactional 어노테이션 명시

     

    @Service
    class OuterService(
        private val persistenceLayerService: PersistenceLayerService,
        private val outerApiCallClient: OuterApiCallClient,
    ) {
    
        fun `외부호출과 데이터를 저장하는 트랜잭션이 분리된 메서드`() {
            persistenceLayerService.save()
            outerApiCallClient.testCall()
        }
    }

    외부 호출인 경우에는 더 이상 트랜잭션으로 묶이지 않습니다.

     

    외부 API 호출을 트랜잭션과 분리했을 때 Rollback

    @Service
    class OuterService(
        private val persistenceLayerService: PersistenceLayerService,
        private val outerApiCallClient: OuterApiCallClient,
    ) {
    
        fun `외부 호출과 데이터를 저장하는 트랜잭션이 분리된 메서드`() {
            outerApiCallClient.testCall()
            persistenceLayerService.save()
        }
    }

    위의 코드처럼 외부 호출이 먼저 수행되고 데이터를 적재하려고 할 수 있습니다.

    이런 경우라면 외부 호출이 실패하였을 때 데이터가 적재되지 않아 데이터 정합성이 지켜질 수 있습니다.

     

    @Service
    class OuterService(
        private val persistenceLayerService: PersistenceLayerService,
        private val outerApiCallClient: OuterApiCallClient,
    ) {
    
        fun `외부 호출과 데이터를 저장하는 트랜잭션이 분리된 메서드`() {
            persistenceLayerService.save()
            outerApiCallClient.testCall()
        }
    }

    위의 코드처럼 데이터를 먼저 적재하고 외부 호출을 수행하는 경우라면 외부 호출이 실패하였을 때 데이터는 이미 적재되어 데이터 정합성이 지켜지지 않을 수 있습니다.

     

    이렇게 되면 상황에 따라 저장된 데이터도 다시 지워주는 작업을 수행해야 할 수도 있으며, 이미 저장된 경우에는 upsert를 수행하거나, 성능을 희생하고 외부 호출과 데이터저장 로직을 트랜잭션으로 묶을 수 있습니다.

     

     

    Event Driven Architecture

    보상트랜잭션과 이벤트에 대해 고민해 보다 보면 "Read 이외에, Create, Update, Delete가 발생할 때 외부 호출이 필요할까?"라는 생각이 듭니다.

    대신 CUD에 대한 이벤트를 발행하여 관심 있는 서비스들이 이를 구독하여 자신 서비스의 입맛에 맞게 적용할 수 있습니다.

     

    이를 통해 다른 도메인, 모듈들과 결합도를 느슨하게 가져갈 수 있습니다.

    이때 고려해야 할 부분은 Eventually Consistency라는 개념으로 최종적 일관성을 보장해 주어야 합니다. (즉, 실시간성이 보장되어야 한다면 도입 전 미리 검토해 보아야 합니다.)

     

    또한 현재 메시징 인프라로 SNS-SQS를 활용하고 있는데 Application과 SNS 가 HTTP 통신을 사용하기 때문에 이벤트를 발행하는 과정에 문제가 발생할 수 있습니다. (SNS에 장애가 발생한다면?)

     

    이때 메시징 시스템의 장애가 시스템의 장애로 이어져야 할까요?

     

    비즈니스 상황에 따라 다르게 적용하겠지만, 이벤트 발행을 기록하는 것이 도메인의 중요한 행위로 본다면 Transation Outbox Pattern을 활용하여 메시징 시스템의 장애를 시스템의 장애와 격리할 수 있습니다.

     

    이벤트를 발행하기 전에 READY 상태로 이벤트를 기록해 놓고, 실제로 발행이 되면 COMPLETE 상태 등으로 변환합니다.

    이 부분은 위에서 소개했던 "실시간 회원 시스템 마이그레이션 구축"에서 더 자세하게 소개됩니다.

     

    마치며

    트랜잭션과 외부 API 호출 그리고 Rollback이 발생할 때 고려해야 할 점에 대해서 알아보았고 더 나아가서 이벤트 기반 아키텍처까지 살펴보았습니다.

    다양한 사례들을 소개해 보고자 했지만 독자분들의 비즈니스 상황에 따라 달라질 수 있으며, 서비스의 아키텍처가 어떤 구조인가에 따라 다를 수 있습니다.

    여러 가지 개념을 기반으로 데이터 정합성을 맞추기 위해 다양한 시각에서 바라보며 자신의 상황에 맞게 Best Practice를 고려해 보는 것이 가장 중요할 것 같습니다.

     

    도움이 되셨기를 바라며 소프트웨어에서 유명한 한 문장으로 끝내 보고자 합니다.

    There is No Silver Bullet

     

     

     

    같이 보면 좋을 글들

    https://techblog.woowahan.com/2606/

    https://dev.gmarket.com/44

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

     

    댓글

Designed by Tistory.