안녕하세요. 아이들나라 플랫폼 팀의 Backend 개발자 김준우입니다.
신입 개발자의 달리는 마차에 바퀴 갈아끼기 라는 주제로 포스팅을 수행했던 적이 있으며, 그 이후로도 지속해서 회원, 구독 서비스를 운영 개선하고 신규 서비스인 상품, 배송, 주문 시스템을 개발하고 있습니다.
해당 글에서는 팀원분들이 작성해주신 아이들나라 통합 회원 시스템을 소개합니다. (강력한 의존관계의 시스템에서 조금씩 느슨해지기) , 회원 시스템 마이그레이션(-ing) 구축 (부제 : 이제 AWS DMS와 kafka를 곁들인) 에 이어 아이들나라의 통합 회원 시스템을 구현하면서 데이터 정합성을 지키기 위해 고민했던 내용들을 공유해 보고자 합니다.
데이터 무결성과 정합성
우선 간단한 예시를 통해 데이터 무결성과 정합성에 대해 알아보겠습니다.
데이터 무결성이 지켜지지 않은 예시
데이터 정합성이 지켜지지 않은 예시
데이터 무결성이란 송금을 수행하고 나서 잔고가 0원 이하(현재는 -5000원) 일 수 없는 것을 의미합니다. 반면 데이터 정합성이란 5000원 송금을 수행했으나 내 잔고에 송금한 돈(5000원)이 빠져나가지 않고 상대 잔고에만 돈이 송금되는 것을 의미합니다. 이런 경우가 발생하면 상황에 따라 예상하기 어려운 치명적인 파급 효과를 가져올 수 있습니다.
데이터 무결성과 정합성에 대해 조금 더 설명하기 위해 아이들나라 서비스를 예로 들어보겠습니다.
예를들어 하나의 사용자에 대하여 A 시스템에는 구독중으로 나오며 B 시스템에는 미구독으로 나온다면 어떤 시스템을 신뢰해야 할까요? 데이터의 정합성이 지켜지지 않은 예시이며 사용자가 실제로 구독했지만 구독중이지 않다고 시스템이 표기하면 사용자는 돈을 내고도 서비스를 사용하지 못할 가능성이 생기게 되고 이런일은 벌어지면 안됩니다.
예를들어 배송준비, 배송중, 배송완료라는 배송상태를 가지는 데이터에 배송대기라는 데이터가 들어가게 되면 데이터의 무결성이 지켜지지 않게됩니다. 이런 경우 보통 Enum을 활용하여 이런 상태들을 관리하는데 만약 예상하지 못했던 배송대기라는 데이터가 들어오게 되면 서비스에 의도하지 않은 예외가 발생하게 됩니다.
위와 같은 상황이 발생하는 경우 사용자입장에서 큰 불편을 야기할 수 있으며, 서비스의 신뢰도가 떨어지게 됩니다.
우리는 서비스 로직을 작성하며 위와 같은 상항을 방지하기 위해 데이터 무결성과 정합성에 대해 다음과 같은 고민을 합니다.
•
데이터 무결성을 지키기 위해 어떻게 해야할까? (불변성보장, 검증로직추가)
•
Race Condition이 발생할 때 어떻게 처리할까? (트랜잭션 격리 수준, 잠금, 원자성 연산 등)
•
프로그램의 예외 발생 시 어떻게 처리할까? (비즈니스, 네트워크)
해당 글에서는 데이터 정합성을 지키기 위해 Spring에서 자주 사용하는 @Transactional에 대해 알아보고자 합니다.
그중에서도 외부 호출과 Rollback에 대해서 자세하게 알아보고자 합니다.
Spring의 @Transactional 이란?
트랜잭션이란 데이터베이스에서 주로 사용하는 개념으로 여러 연산을 하나로 묶어 한 번에 묶여서 같이 일어나거나, 전부 일어나지 말아야 할 경우에 사용합니다. 이를 통해 의도적으로 부분 업데이트(Partial Update)를 방지합니다.
Spring에서는 AOP(Aspect-Oriented Programming)를 활용하여 @Transactional 어노테이션으로 선언적 트랜잭션을 제공합니다. 이를 통해 간편하게 메서드 혹은 클래스위에 @Transactional을 사용하여 예외가 발생하였을 때 Rollback이 수행되고 데이터 정합성을 지켜줄 수 있습니다.
트랜잭션과 Rollback
Rollback이 수행된다는 것은 무엇을 뜻할까요?
Rollback이 수행된다는 것은 Database Connection과 관련되어 하나의 Connection이 하는 연산이 Database에 모두 반영되지 않는다는 것을 의미합니다.
위의 글만 봐서는 잘 이해가 잘되지 않는데 어떻게 이런 일이 일어나는지 내부 코드를 통해 살펴보겠습니다.
@Transactional
fun `의도적으로 예외를 발생시키기`() {
testRepository.save(TestEntity(name = "firstSave"))
throw RuntimeException("의도적으로 runtime Exception을 던지면 rollback이 떻게 발생할까?")
}
Plain Text
복사
데이터를 저장하는 요청을 수행한 뒤 의도적으로 RuntimeException을 발생시켜 본 후 디버깅을 수행해 보았습니다.
여러 클래스들이 있지만 핵심이 되는 클래스 위주로 알아보겠습니다.
TransationAspectSupport 클래스
TransationAspectSupport의 completeTransactionAfterThrowing메서드 호출
RuntimeException이 발생했기 때문에 catch에서 예외를 잡아 completeTransactionAfterThrowing 메서드가 호출됩니다.
completeTransactionAfterThrowing 메서드 내부 구현
completeTransactionAfterThrowing 메서드의 내부 구현 중 if 문을 살펴보면transactionAttribute.rollbackOn(ex) 메서드를 호출하여 Rollback이 수행될지 말지가 결정됩니다.
만약 조건을 충족하지 않아 else로 빠진다면 Rollback이 아니라 Commit이 호출됩니다.
DefaultTransactionAttribute 클래스의 rollbackOn메서드
DefaultTransactionAttribute클래스의 rollbackOn 메서드
DefaultTransactionAttribute 클래스는 트랜잭션 동작을 정의하는 데 사용되며 여러 세부적인 설정(전파 속성, 고립 레벨, 타임아웃, readOnly, 이름)을 기본값으로 구성합니다.
기본 정책으로 Throwable 객체의 타입이 RuntimeException 또는 Error인 경우에 Rollback이 수행되는 것을 확인할 수 있습니다.
JdbcConnection 클래스
JdbcConnection 클래스의 rollback 메서드
이후에 AbstractPlatformTransactionManager, JpaTransactionManager, TransactionImpl, JdbcResourceLocalTransactionCoordinatorImpl, AbstractLogicalConnectionImplementor, ProxyConnection 클래스들을 거쳐서 마지막에 JdbcConnection 클래스에서 실제 Rollback이 발생합니다.
JdbcConnection은 Connection 인터페이스를 구현하는 클래스로 실제 데이터베이스에 대한 Connection을 담당합니다.
이로써 트랜잭션의 Rollback은 Database Connection과 연관 있음을 알 수 있습니다.
PoolEntry 클래스
또한 PoolEntry 클래스를 살펴보면 하나의 트랜잭션은 하나의 Database Connection을 사용하는 것을 알 수 있습니다.
PoolEntry 클래스
커넥션 풀로 HikariPool을 사용하고 계신다면 PoolEntry클래스가 Connection 객체를 필드를 가지고 있는데, Propagation.Require_New를 활용하여 새로운 트랜잭션을 만들어 내고 디버깅을 수행 해보면 2개의 트랜잭션이 서로 다른 Connection을 사용하는 것을 알 수 있습니다.
위의 디버깅 과정을 통해 트랜잭션의 Rollback과 Database Connection의 연관관계를 이해할 수 있으며 서로 다른 트랜잭션 당 하나의 Database Connection을 사용함을 이해할 수 있습니다.
외부 API 호출과 Rollback
이제 우리는 데이터 무결성으로 인한 예외가 발생했을 때 또는 비즈니스 예외 등으로 RuntimeException이 발생하였을 때 Rollback이 수행되어 데이터베이스에 연산들이 반영되지 않으며 데이터 무결성과 정합성이 보장될 것이라 기대할 수 있습니다.
하지만 여기에 HTTP 요청, 메시지 브로커 등의 트랜잭션의 범위를 벗어난 외부 호출이 섞이게 되면 어떻게 될까요?
중간에 RuntimeException이 발생하여 Rollback이 발생했다면 데이터는 반영되지 않았지만, 외부 호출도 Rollback 될까요?
해당 글에서 정의하는 외부 호출이란 Database Connection과 관련 없는 네트워크 호출을 뜻합니다.
외부 호출은 Database Connection과 관련이 없기 때문에 자연스럽게 트랜잭션의 범위에서 벗어나며 Rollback이 수행되지 않습니다.
내가 개발하는 서비스 관점에서는 데이터 정합성이 지켜지겠지만, 서비스의 전체적인 그림으로 보았을 때는 데이터 정합성이 어긋날 가능성이 있습니다.
전체적인 그림에서 데이터 정합성이 어긋난다는 게 어떤 의미일까요? 해당 내용은 아래에서 다이어그램을 통해 자세하게 알아보겠습니다.
서비스 코드에 외부 호출이 포함되는 순간 데이터 정합성을 지키기 위해 고민해야 할 부분들이 점점 많아지기 시작합니다.
고통의 시작
상황에 따라 다음과 같은 방법을 고민해 보았습니다.
•
외부 호출이 실패하면 N 회 재처리 재시도 후 실패 발생
•
외부 호출이 실패하더라도 성공으로 처리하고 내부적으로 재처리로 성공을 보장
•
외부 호출의 실패가 그대로 전파되어 서비스 로직도 실패가 발생
여기서 외부 호출이 내가 작성한 서비스 코드라면 상황에 따라 유연하게 대처할 수 있겠지만 대게 외부 호출은 제어할 수 없을 가능성이 높습니다.
데이터 정합성을 지키기 위해 고민했던 상황들을 나열해 보겠습니다.
외부 호출이 Query 요청인 경우
외부 호출이 Query인 경우에는 외부 호출 Server의 상태를 변경하지 않기 때문에 여러 번 요청하더라도 상관없습니다.
이런 경우에는 실패 시 Rollback이 발생하더라도 여러 번 요청해도 상관없습니다.
외부 호출이 Command 요청인 경우
데이터의 Rollback을 수행되었지만, Post 요청으로 외부 호출 Server의 내부 데이터는 변경되었습니다.
Command 요청인 경우 정합성이 어긋날 수 있는 가능성이 발생합니다.
Command 요청에 대한 Create, Delete 등이 가능하다면 이를 활용해 볼 수 있습니다.
예를 들어 통합회원에서는 회원가입 요청을 시도하다가 외부 호출에서 실패가 발생했을 때는 "이미 가입된 회원입니다" 라는 메시지가 노출된다면 Delete를 수행하고 다시 가입시키는 방법을 도입해 보았습니다.
외부 호출 API가 멱등성을 제공한다면 위의 구현 없이 해결할 수도 있을 것 같습니다.
RFC 7231: Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content에 따르면 멱등성이란 여러 번 동일한 요청을 제공했을 때, 서버에 미치는 의도된 영향이 동일한 경우를 뜻합니다.
하지만 비행기 + 숙박을 세트로 예약하는 경우라면 비행기 예매에 실패하면 숙박 예매도 실패해야 하므로 이런 경우에는 멱등성이 제공된다고 하더라도 데이터 정합성이 어긋날 수 있습니다.
외부 호출 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()
}
}
Plain Text
복사
예를 들어 외부 API를 호출할 때 5초가 걸린다고 가정하겠습니다.
이렇게 되면 Database와 관련된 save 요청이 빠르게 처리된다고 하더라도 외부 호출을 수행하는 5초 동안 Connection을 점유하고 있게 됩니다.
만약 Connection Pool의 개수가 10개이고, 사용자 10명이 해당 Serivce를 의존하는 Endpoint를 호출한다면 11번째 사용자는 커넥션을 획득하지 못하여 대기하다가 Timeout Error를 만날 수 있습니다.
즉, 트랜잭션에 불필요한 외부 호출이 존재하면 Persistence Layer에서 병목현상이 발생할 가능성이 있으며 목표했던 성능을 뽑아내지 못할 수 있습니다.
통합회원에서는 최대 9초 정도 걸리는 외부 호출 API가 존재하였고 이를 트랜잭션 내부에 포함하여 성능 테스트를 수행했을 때 기존 GET 요청이 800TPS가 나왔다면 외부 호출이 포함된 요청은 1/10인 80TPS 정도가 나왔으며, 트랜잭션과 외부 호출을 분리하여 10배 정도의 성능 개선을 수행할 수 있었습니다.
해결하기 위한 방법으로는 외부 호출인 Client Layer와 Persistence Layer를 분리하고 Persistence Layer에만 트랜잭션을 걸어주어야 합니다.
트랜잭션을 외부 호출과 분리
@Service
class PersistenceLayerService(
private val testRepository: TestRepository,
) {
@Transactional
fun `트랜잭션에 데이터 저장로직만 들어있는 메서드`(){
testRepository.save(TestEntity(name = "firstSave"))
}
}
Plain Text
복사
PersistenceLayerSerivce에만 @Transactional 어노테이션을 명시합니다.
@Service
class OuterService(
private val persistenceLayerService: PersistenceLayerService,
private val outerApiCallClient: OuterApiCallClient,
) {
fun `외부 호출과 데이터를 저장하는 트랜잭션이 분리된 메서드`() {
persistenceLayerService.save()
outerApiCallClient.testCall()
}
}
Plain Text
복사
외부 호출인 경우에는 더 이상 트랜잭션으로 묶이지 않습니다.
외부 API 호출을 트랜잭션과 분리했을 때 Rollback 고민 사항
@Service
class OuterService(
private val persistenceLayerService: PersistenceLayerService,
private val outerApiCallClient: OuterApiCallClient,
) {
fun `외부 호출과 데이터를 저장하는 트랜잭션이 분리된 메서드`() {
outerApiCallClient.testCall()
persistenceLayerService.save()
}
}
Plain Text
복사
위의 코드처럼 외부 호출이 먼저 수행되고 데이터를 적재하려고 할 수 있습니다.
이런 경우라면 외부 호출이 실패하였을 때 데이터가 적재되지 않아 데이터 정합성이 지켜질 수 있습니다.
@Service
class OuterService(
private val persistenceLayerService: PersistenceLayerService,
private val outerApiCallClient: OuterApiCallClient,
) {
fun `외부 호출과 데이터를 저장하는 트랜잭션이 분리된 메서드`() {
persistenceLayerService.save()
outerApiCallClient.testCall()
}
}
Plain Text
복사
위의 코드처럼 데이터를 먼저 적재하고 외부 호출을 수행하는 경우라면 외부 호출이 실패하였을 때 데이터는 이미 적재되어 데이터 정합성이 지켜지지 않을 수 있습니다.
이렇게 되면 상황에 따라 저장된 데이터도 다시 지워주는 작업을 수행해야 할 수도 있으며, 이미 저장된 경우에는 upsert를 수행하거나, 성능을 희생하고 외부 호출과 데이터저장 로직을 트랜잭션으로 묶을 수 있습니다.
Event Driven Architecture
보상트랜잭션과 이벤트에 대해 고민해 보다 보면 "Read 이외에, Create, Update, Delete가 발생할 때 외부 호출이 필요할까?" 라는 생각이 듭니다.
대신 CUD에 대한 이벤트를 발행하여 관심 있는 서비스들이 이를 구독하여 자신 서비스의 입맛에 맞게 적용할 수 있습니다.
이를 통해 다른 도메인, 모듈들과 결합도를 느슨하게 가져갈 수 있습니다.
이때 고려해야 할 부분은 Eventually Consistency라는 개념으로 최종적 일관성을 보장해 주어야 합니다. (즉, 실시간성이 보장되어야 한다면 도입 전 미리 검토해 보아야 합니다.)
또한 현재 메시징 인프라로 SNS-SQS를 활용하고 있는데 Application 과 SNS 가 HTTP 통신을 사용하기 때문에 이벤트를 발행하는 과정에 문제가 발생할 수 있습니다. (SNS에 장애가 발생한다면?)
이때 메시징 시스템의 장애가 시스템의 장애로 이어져야 할까요?
비즈니스 상황에 따라 다르게 적용하겠지만, 이벤트 발행을 기록하는 것이 도메인의 중요한 행위로 본다면 Transation Outbox Pattern을 활용하여 메시징 시스템의 장애를 시스템의 장애와 격리할 수 있습니다.
이벤트를 발행하기 전에 READY 상태로 이벤트를 기록해 놓고, 실제로 발행이 되면 COMPLETE 상태 등으로 변환합니다.
마치며
간단한 예시를 통하여 데이터의 무결성과 정합성이 어떤 것인지 왜 중요한지에 대해 알아보았습니다.
Spring의 @Transactional 어노테이션을 활용하여 Rollback과 Database Connection과 관계를 이해하며 데이터 정합성이 어떻게 지켜지는지 알아보았습니다.
트랜잭션과 외부 API 호출 그리고 Rollback이 발생할 때 고려해야 할 점에 대해서 알아보았고 더 나아가서 이벤트 기반 아키텍처까지 살펴보았습니다.
고민했던 부분을 최대한 다양한 사례로 소개해 보고자 했지만 독자분들의 비즈니스 상황에 따라 달라질 수 있으며, 서비스의 아키텍처가 어떤 구조인가에 따라 다를 수 있습니다.
여러 가지 개념을 기반으로 데이터 정합성을 맞추기 위해 다양한 시각에서 바라보며 자신의 상황에 맞게 Best Practice를 고려해 보는 것이 가장 중요할 것 같습니다.
다음 글에서는 트랜잭션 내 여러 동작을 동기적, 비동기적 구현을 할 때 동시성과 병렬성에 대해 고민했던 경험으로 찾아뵙도록 하겠습니다.
도움이 되셨기를 바라며 소프트웨어에서 유명한 한문장으로 끝내보고자 합니다.
There is No Silver Bullet
긴 글 읽어주셔서 감사드립니다.
참고문헌
RealMySQL 8.0