| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- 성능 최적화
- Kotlin
- kafka
- 데이터베이스
- Kubernetes
- grafana
- redis
- helm
- spring boot
- 백엔드
- selector
- 트랜잭션
- prometheus
- docker
- mysql
- NIO
- 백엔드개발
- DevOps
- JPA
- 성능최적화
- webflux
- netty
- CloudNative
- GitOps
- jvm
- Java
- monitoring
- RDBMS
- 동시성제어
- SpringBoot
- Today
- Total
유성
JPA인 줄 알았는데 MyBatis였다? R2DBC 도입 시 겪게 될 당혹감 본문
R2DBC와 Mono를 사용하면서 기본적인 CRUD를 만들어보며 경험했던 것들 중 경험한 것들에 대해서 설명한다.
아마 새롭게 Webflux를 도입하면 비슷한 문제들과 새로운 경험을 할 수 있을것이다.
1. WebFlux, 왜 쓰는 걸까?
전통적인 Spring MVC는 Thread-per-request 모델로, 요청이 오면 스레드 하나를 할당하고, DB응답이 올 때까지 그 스레드는 아무것도 못 하고 기다린다.
반면 WebFlux는 적은 수의 스레드로 수많은 요청을 처리하는 이벤트 루프 방식이다. 기다리는 시간 동안 스레드가 다른 일을 할 수 있게 해주는 것이 핵심이다. 이번 글에서는 가장 기초적인 R2DBC를 활용한 비동기 CRUD를 살펴보자.
2. 의존성 설정 (R2DBC)
WebFlux를 쓰면서 가장 복잡한 부분으로 볼 수 있다.
Blocking을 쓰면 안 되기 때문에 Blocking 관련 의존성을 모두 제거하고, 스레드에 종속된 것들(MDC 등)도 제거를 해야 한다.
Blocking이라고 하면 보통 I/O 관련된 것들이 거의 대부분이기 때문에 이를 모두 제거하고 리엑티브 표준으로 만들어진 의존성들을 활용해야 한다.
그렇기에 JDBC, JPA 대신 R2DBC 를 활용한다.
(RDBMS I/O는 결국 TCP/IP 통신이다. R2DBC는 이를 JDBC처럼 스레드를 점유해 기다리는 방식이 아니라, OS 커널의 NIO(Non-blocking I/O) 모델을 활용해 비동기로 처리한다)
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")
runtimeOnly("com.github.jasync-sql:jasync-mysql-r2dbc:2.2.4") // MySQL 비동기 드라이버
runtimeOnly("io.r2dbc:r2dbc-h2") // H2 비동기 통신
runtimeOnly("com.h2database:h2") // H2 DB
}
3. 도메인 및 리포지토리
R2DBC는 ReactiveCrudRepository를 상속받는 것만으로도 비동기 스트림을 반환할 준비가 끝난다.
@Table("products")
data class Product(
@Id val id: Long? = null,
val name: String,
val price: Double
)
interface ProductRepository : ReactiveCrudRepository<Product, Long>
4. 비동기 CRUD 구현 (Service & Controller)
WebFlux의 핵심은 결과값을 Product가 아닌, 0개 또는 1개의 결과를 약속하는 Mono<Product>로 감싸서 반환하는 것이다.
Service Layer
@Service
class ProductService(private val productRepository: ProductRepository) {
// 저장: Mono를 반환하여 나중에 완료될 것임을 약속함
fun create(product: Product): Mono<Product> = productRepository.save(product)
// 조회: 비동기적으로 조회된 결과를 Mono에 담아 전달
fun findById(id: Long): Mono<Product> = productRepository.findById(id)
}
Controller Layer
@RestController
@RequestMapping("/products")
class ProductController(private val productService: ProductService) {
@PostMapping
fun create(@RequestBody product: Product): Mono<Product> =
productService.create(product)
@GetMapping("/{id}")
fun getProduct(@PathVariable id: Long): Mono<Product> =
productService.findById(id)
}
Controller에서 return이 발생해도 내부에 값이 없다. (subscribe를 만나지 않았으므로)
5. R2DBC 사용 시 복잡하거나 궁금한 부분
R2DBC 사용 간에 복잡한 부분이 존재하는데, 이러한 것들을 JPA와 비교해 보자.
ID 필드에 임의값을 넣고 save() 를 호출한 경우 (DB에 값이 없는 경우)
- JPA 동작: SELECT 쿼리가 먼저 나가고, 값을 찾을 수 없는 경우 INSERT 쿼리가 나간다.
- R2DBC 동작: ID가 필드에 있는 경우 SELECT 쿼리가 나가지 않고, 이미 저장된 데이터라고 판단해 UPDATE 쿼리가 나간다.
R2DBC 내에서는 isNew 값이 flase로 처리되면서 INSERT 쿼리 대신 UPDATE 쿼리가 나가게 된다.
findById 메서드를 사용한 경우
- JPA 동작: 모든 데이터를 가져와 1개의 데이터만 존재하는지 검증한다.
- R2DBC 동작: Limit 2를 쿼리에 추가해 1개의 데이터면 return을, 2개의 데이터면 throw 한다.
R2DBC R2dbcEntityTemplate class 내부 코드
@Override
public <T> Mono<T> selectOne(Query query, Class<T> entityClass) throws DataAccessException {
return doSelect(query.isLimited() ? query : query.limit(2), entityClass, getTableName(entityClass), entityClass,
RowsFetchSpec::one, null);
}
보면서 상당히 신기했던 내용인데, 우선 목적은 쿼리의 결과 row가 2개 이상이면 에러를 발생시키기 위해 작성된 코드이다.
여기에 성능을 보완하기 위해 limit 2 를 달아놓게 되었다는데.. 여기에는 몇 가지 의문이 생긴다.
- RDBMS에서는 이미 PK에 대한 Unique 검증이 포함되어있는데, 한번 더 검증하는게 맞는지? (물론 PK 설정을 하지 않을 수 있지만)
- 클러스터링 인덱스가 있어, 동일한 ID(PK)가 많지 않은이상 성능차이가 크지 않을텐데 왜 Limit를 붙였는지?
결국 이 limit 2는 데이터가 정상일 때는 성능 차이가 없지만, 데이터가 오염된(중복된) 최악의 상황에서 DB가 테이블 전체를 뒤지기 전에 빠르게 멈추게하여 서버 전체의 자원을 보호하려는 설계자의 배려로 이해했다.
연관관계 매핑을 제공하지 않는다
R2DBC는 ORM이 아니고 Data Mapper에 해당하므로 연관관계 매핑을 제공하지 않는다.
비유를 하자면 마치 JPA를 사용할 때 Interface Projection(View) 느낌이 크게 들었다. 마치 좀 더 쉬운 Mybatis처럼 느껴진다.
만약 Join 쿼리가 필요한 경우 쿼리를 직접 작성해야 한다.
마치며
이번에 R2DBC와 Mono를 활용한 CRUD를 구현하며 느낀 점은, WebFlux의 잠재력을 온전히 끌어내는 것은 생각보다 까다로운 설계적 선택이 필요하다는 것이었다.
가장 중요한 기준은 "Controller부터 DB까지 끊기지 않는 파이프라인(Streaming)을 형성할 수 있는가"에 있다. Flux 사용 시 중간에 데이터를 취합하는 순간, 우리가 기대했던 논블로킹의 메모리 효율은 급격히 떨어진다.
만약 비즈니스 로직상 데이터 취합이 불가피하다면, 다음의 세 가지 선택지를 냉정하게 고민해야 한다.
- 데이터의 크기: 이벤트 루프가 감당할 수 있는 수준의 가벼운 작업인가?
- 로직의 위치: 복잡한 가공 로직을 SQL(DB 레이어)로 밀어 넣어 애플리케이션의 부담을 줄일 수 있는가?
- 스레드 격리: 이벤트 루프를 보호하기 위해 별도의 워커 스레드(boundedElastic)를 개입시켜야 하는가?
결론적으로 WebFlux와 R2DBC 조합은 복잡한 도메인 로직이 얽힌 Command 사이드보다는, CQRS 아키텍처의 Query(조회) 역할에서 압도적인 성능과 효율을 발휘할 것으로 보인다.
또한, 현업에서 왜 boundedElastic 위에서 JPA를 혼용하는 '기형적이지만 현실적인' 방식을 택하는지도 명확히 이해할 수 있는 경험이었다.
'Spring' 카테고리의 다른 글
| 서버의 생존 전략: Circuit Breaker로 장애 전파 방지하기 (0) | 2026.01.20 |
|---|---|
| 왜 Request Body는 컨트롤러에 도달하지 못했나? (feat. Sitemesh & Multipart) (0) | 2025.12.19 |
| WebFlux의 기본 개념 (Event Loop, Lazy Evaluation, publish-subscribe) (3) | 2025.07.01 |
| [중요!] @Transactional(AOP)가 동작하지 않는 이유 this.update(); (0) | 2024.10.31 |
| 스프링 의존성 주입(DI) (2) | 2024.10.30 |