또 라인업지다. 혹시 라인업지가 무엇인지 궁금하신 분들을 위해 간단히 설명하자면, 2024년 가천대학교 가을 축제에 사용된 원격 웨이팅 서비스이다. 그리고 이 서비스를 구현하기 위해 경험했던 기술적 도전들을 차례차례 풀어나가고 있으며, 이번 글은 AuroraDB와 @Transactional 어노테이션을 통한 간단한 CQRS를 구현하는 내용이 될 것이다!
CQRS
CQRS(Command and Query Responsibility Segregation)는, 데이터 저장소(DB)로부터 발생하는 읽기 작업과 쓰기 작업을 분리하는 것을 뜻한다. 간단히 말해, 조회(R)와 명령(CUD) 작업을 분리한다는 것이다. 대부분의 서비스는 쓰기 작업보다 조회 작업이 압도적으로 많이 발생하기 때문에, 저장소를 분리하여 조금이라도 부하를 줄이기 위해 쓰인다고 한다. 그렇다면 어떤 경우에 CQRS를 도입하는 게 가장 좋을까?
Microsoft에서는 다음과 같은 시나리오일 경우, CQRS 패턴을 도입하는 것을 권장하고 있다.
CQRS 패턴 - Azure Architecture Center
CQRS(명령과 쿼리의 역할 분리) 패턴을 사용하여 데이터를 업데이트하는 작업에서 데이터를 읽는 작업을 분리하는 방법을 알아봅니다.
learn.microsoft.com
- 많은 사용자가 동일한 데이터에 동시에 액세스하는 도메인이 존재할 경우
- 읽기 작업 수가 쓰기 수보다 훨씬 큰 경우
더욱 많은 권장 시나리오가 있지만, 서비스 '라인업지'의 경우 위의 두 가지 조건을 충족한다고 생각하여, 지체 없이 CQRS를 도입하기로 판단했다.
그런데 CQRS 패턴은 말 그대로, 데이터 저장소를 두개 이상으로 분리해야 하기에, 쓰기 전용인 주 서버(master)와 읽기 전용 서버(slave) 간 데이터 정합성이 동기화 작업을 통해 제대로 이루어져야 한다는 단점을 지닌다. 이를 MySQL의 CDC(Change Data Capture)을 통해 Replication을 직접 구현해 주는 방식도 있지만, 시간 관계상 AuroraDB를 통해 이를 해결해 주었다.
AuroraDB
AuroraDB는 AWS에 최적화된 관계형 데이터베이스로, 해당 글에서는 MySQL을 활용할 예정이다. 왜 CQRS를 통해 AuroraDB를 사용했냐면, 다음과 같은 특징을 가지기 때문이다.
- 자동으로 확장되는 DB 인스턴스
- 다중 AZ를 활용한 데이터 내구성 보장 및 자동 장애 복구
- 99.99%의 가용성 제공
- Page 기반의 MySQL Replication 지원
이틀 동안 수천 명의 사용자를 감당해야 하는 우리로서는 AuroraDB를 사용하지 않을 이유가 없었다. 무엇보다, 자동 장애 복구 조치 기능을 통해 고가용성을 유지하고, Replication 기능을 통해 CQRS를 편리하게 구현해 줄 수 있다는 점은 더없이 매력적으로 느껴졌다.
그렇다면, 제공되는 Replication 기능을 CQRS에 어떻게 적용해 줄 수 있을까? 이를 우리는 @Transactional 어노테이션을 통해 구현해 줄 수 있다.
@Transactional 어노테이션
@Transactional Spring 프레임워크에서 제공해 주는 어노테이션으로, 손쉽게 서비스 단에 트랜잭션을 처리해 줄 수 있다. 단순 데이터 조회용 트랜잭션에는 (readOnly = true) 옵션을 적용해 주는데, 이를 통해 성능적 이점을 얻을 수 있다고 한다. 이유가 무엇일까?
이는 JPA의 Dirty Checking(변경 감지)과 관련이 있는데, 일반적으로 JPA의 영속성 컨택스트는 엔티티를 가져올 때, 최초 조회 상태의 엔티티의 변경 감지 여부를 판단하기 위해 스냅샷을 저장한다. 이때 (readOnly = true) 옵션을 적용해 주면 JPA는 변경 감지를 위한 스냅샷을 저장하지 않아 메모리 절약은 물론, 성능적 이점을 기대할 수 있다.
그러면 메서드에 @Transactional(readOnly = true) 어노테이션만 달아주면 자동으로 CQRS가 적용되는 것일까? 슬프게도 그건 아니다.. 그렇다면 이를 어떻게 적용해 줄 수 있었을까? 이제부터 구현 단계를 통해 알아가 보도록 하자.
구현
HikariPool 설정
우선, 어노테이션의 옵션을 통해 연결할 데이터 저장소의 url을 할당해 주기 위해, hikari를 통해 application.yml에서 두 개의 데이터 저장소에 대한 인증 정보와 연결 설정을 명시해 준다.
- maximum-pool-size: 동시에 사용할 수 있는 최대 연결 수이다. 서비스 이용자 수와, 데이터 저장소 성능을 고려하여 적절히 조절한다.
- connection-timeout: 연결 반환되기를 기다리는 최대 시간이다.
- connection-init-sql: 커넥션 풀에서 새로운 커넥션을 생성할 때 저장소와의 연결이 올바른지 확인하는 데 사용된다.
- idle-timeout: 유휴 상태의 연결이 커넥션 풀에서 제거되기 전까지 유지되는 시간이다.
- max-lifetime: 연결이 지속될 수 있는 최대 시간이다.
- minimum-idle: 항상 유지되어야 하는 최소 유휴 연결 수이다.
Maximum Pool Size
커넥션 풀의 크기를 잘못 설정하면 자칫 데드락을 마주할 수도 있다. 이에 대한 자세한 내용은 테코블에서 자세히 설명해 두었으니, 이를 참고하면 더욱 좋을 것 같다!
HikariCP Dead lock에서 벗어나기 (이론편) | 우아한형제들 기술블로그
안녕하세요! 공통시스템개발팀에서 메세지 플랫폼 개발을 하고 있는 이재훈입니다. 메세지 플랫폼 운영 장애를 바탕으로 HikariCP에서 Dead lock이 발생할 수 있는 case와 Dead lock을 회피할 수 있는 max
techblog.woowahan.com
해당 블로그의 내용을 토대로, Hikari에서 제시하는 최적의 풀 사이즈 설정 공식을 적용해 주었다.
poolSize = Tn x (Cm - 1) + 1
// Tn : Thread의 최대 수
// Cm: 하나의 트랜잭션에서 필요한 Connection 갯수
그리고 나머지 변수 값들을 설정해 주는 것은 GPT의 도움을 많이 받았다. 모든 값들은 사용자 수와 DB 인스턴스의 성능을 고려하여 측정되어야 했기 때문에, GPT에 해당 정보들을 제공해 준 후 적절한 값을 받아 이를 실제 운영 환경에 적용해 주었다.(실제로 매우 정상적으로 잘 동작해주었다!🥹)
CustomRoutingDataSource
TransactionSynchronizationManager.isCurrentTransactionReadOnly() 메서드를 통해 현재 진행중인 트랜잭션이 읽기 전용인지, 아닌지 판단합니다. 해당 트랜잭션이 읽기 전용이라면 'slave' 데이터 저장소로, 아니라면 'master' 데이터 저장소로 AbstractRoutingDataSource.determineCurrentLookupKey() 메서드를 통해 라우팅해줍니다. 해당 메서드는 내부의 상태에 따라 라우팅 하게 될 데이터 저장소의 키를 반환하는 역할을 지닙니다.
그리고 위 기능이 구현된 CustomRoutingDataSource 클래스를 통해 최종적으로 트랜잭션에 따른 데이터 저장소 라우팅을 구현하는 설정 클래스를 구현해줍니다.
TransactionConfig
차근차근 진행해보겠습니다.
masterDatasource & slaveDatasource
두 메서드는 모두 환경 변수에서 설정한 'master', 'slave' 데이터 저장소에 대한 정보를 각각 전달 받아 HikariDataSource를 구성한 후 반환해줍니다. 이 때 'slave' 데이터 저장소의 경우, 다음과 같은 메서드를 통해 해당 저장소가 읽기 전용임을 명시해줍니다. 그리고 반환한 HikariDataSource는 routingDataSource() 메서드에서 올바른 데이터 저장소의 키와 매핑됩니다.
routingDataSource
setTargetDataSources() 메서드를 통해 'master' 키에 masterDataSource()의 빈을, 'slave' 키에 slaveDataSource()의 빈을 매핑합니다. 그리고 setDefaultTargetDataSource() 메서드를 통해 기본적으로는 'master' 데이터 저장소를 사용하도록 설정해주었습니다. 최종적으로 해당 설정값이 반영 된 CustomRoutingDataSource를 반환해주고, 해당 값을 인자로 받아 애플리케이션에서 궁극적으로 사용될 Datasource가 설정되게 됩니다.
dataSource
인자로 전달받은 CustomRoutingDataSource를 LazyConnectionDataSourceProxy() 메서드로 감싸, 실제로 요청이 들어왔을 때 동적으로 커넥션을 요청 및 획득하도록 설정해주어, 전체적인 성능을 향상해 주었습니다.
@Primary 어노테이션을 통해 다른 메서드의 빈을 주입받지 않고, 해당 메서드에서 반환하는 빈을 애플리케이션의 기본 DataSource 빈으로 인식하도록 설정해주었습니다.
마무리
일련의 과정을 통해 AuroraDB와 Hikari를 활용한 간단한 CQRS 구현을 시도해보았습니다. 서비스 운영 전에는 데이터 정합성에 대한 우려가 상당했지만, 실제로는 AuroraDB가 동기화를 매우 빠르게 수행해 주어 주 서버와 읽기 전용 서버 간의 데이터 차이가 거의 발생하지 않는다는 점을 확인할 수 있었습니다.
물론 이번 예시에서는 AuroraDB를 통한 간단한 수준의 CQRS 적용에 그쳤지만, 내년 실제 서비스 운영 시에는 NoSQL 도입과 이벤트 처리 등을 함께 고려하여 한 단계 더 고도화된 CQRS 아키텍처를 구축해볼 계획입니다. 이를 위해 앞으로도 꾸준히 학습하여 대비하고자 합니다.
참고자료
HikariCP Dead lock에서 벗어나기 (이론편) | 우아한형제들 기술블로그
안녕하세요! 공통시스템개발팀에서 메세지 플랫폼 개발을 하고 있는 이재훈입니다. 메세지 플랫폼 운영 장애를 바탕으로 HikariCP에서 Dead lock이 발생할 수 있는 case와 Dead lock을 회피할 수 있는 max
techblog.woowahan.com
CQRS 패턴 - Azure Architecture Center
CQRS(명령과 쿼리의 역할 분리) 패턴을 사용하여 데이터를 업데이트하는 작업에서 데이터를 읽는 작업을 분리하는 방법을 알아봅니다.
learn.microsoft.com
HikariCP Maximum Pool Size 설정 시, 고려해야할 부분 | Carrey`s 기술블로그
이 글의 예상 독자 아래와 같은 에러의 원인을 찾아헤멘 개발자 o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 0, SQLState: null o.h.engine.jdbc.spi.SqlExceptionHelper : hikari-pool-1 - Connection is not available, request timed o
jaehun2841.github.io
DB 트래픽 분산시키기(feat. Routing Datasource) | Incheol's TECH BLOG
DB 트래픽을 왜 분산시켜야 하지? 그럼 트래픽을 분산시키는 거 말고 다른 방법은 없나? 그래도 부하가 발생한다면 샤딩으로 데이터를 분산처리할 수 있을 것이다 그럼 다음에 시도해볼 수 있는
incheol-jung.gitbook.io
'스프링 부트 > Java' 카테고리의 다른 글
ElastiCache과 Redisson을 통해 스프링 부트에서 무형 객체 캐싱하기 (2) | 2024.12.10 |
---|---|
@Valid 검증 예외 처리를 통한 공통 응답 보내기 (2) | 2024.12.03 |
AOP 로깅을 통한 효율적인 로깅 로직 구현하기 (1) | 2024.11.27 |
[JPA] 양방향 매핑을 지양해야 하는 이유 (1) | 2024.11.19 |
스프링 이벤트와 비동기 처리를 통한 알림톡 발송 성능 개선하기 (0) | 2024.11.13 |