목록으로 돌아가기
JPA vs R2DBC: 블로킹 vs 논블로킹 데이터베이스 접근 비교
2025년 12월 20일
3개 태그
kotlin
r2dbc
rdbms
spring boot에서 데이터베이스 접근 방식은 크게 두가지로 나뉜다
JPA(블로킹) 와 R2DBC(논블로킹) 으로 나뉜다.
1. I/O모델비교
JPA- 블로킹 I/O
// JPA - 블로킹 방식 @Service class CreditService( private val repository: CreditJpaRepository ) { fun getBalance(userId: String): CreditBalance { // 스레드가 여기서 블로킹됨 val entity = repository.findByUserId(userId) // DB 응답 대기 return entity.toDomain() // 스레드는 DB 응답을 기다리는 동안 아무것도 못함 } }
- 동작 흐름
Thread 1: [요청] → [DB 쿼리 실행] → [대기...] → [응답] → [처리] └─────────── 블로킹 ───────────┘ 이 시간 동안 스레드는 아무것도 못함
R2DBC - 논블로킹 I/O
R2DBC는 논블로킹 I/O모델을 사용한다. 데이터베이스 쿼리를 실행해도 스레드가 블로킹되지 않고, 다른 작업을 계속 처리할 수 있다.
// R2DBC - 논블로킹 방식 @Service class CreditService( private val repository: CreditR2dbcRepository ) { fun getBalance(userId: String): Mono<CreditBalance> { // 스레드는 블로킹되지 않음 return repository.findByUserId(userId) // Mono 반환 .map { it.toDomain() } // 스레드는 다른 작업을 계속 처리할 수 있음 } }
- 동작 흐름
Event Loop: [요청1] → [DB 쿼리 요청] → [요청2 처리] → [요청3 처리] → [응답1 처리] └─ 논블로킹 ─┘ └─ 다른 작업 처리 ─┘ DB 응답 대기 중에도 다른 요청 처리 가능
2. 스레드 모델 비교
JPA thread model
jpa는 요청당 스레드 모델을 사용한다. 각 요청마다 별도의 스레드가 할당되고, DB응답을 기다리는 동안 해당 스레드가 블로킹 된다.
요청 1 → 스레드 1 (블로킹) 요청 2 → 스레드 2 (블로킹) 요청 3 → 스레드 3 (블로킹) 요청 4 → 스레드 4 (블로킹) 요청 5 → 스레드 5 (블로킹) ↓ Database
특징
- 높은 동시성 요청 시 많은 스레드가 필요하다(1000요청 = 1000 스레드)
- 스레드 당 약 1MB 메모리 사용
- 높은 컨텍스트 스위칭 오버헤드 발생
R2DBC 스레드 모델
R2DBC는 이벤트 루프 모델을 사용한다. 적은 수의 스레드(보통 4~8개)로 많은 요청을 처리한다.
요청 1 ─┐ 요청 2 ─┤ 요청 3 ─┼→ Event Loop (스레드 1-4) 요청 4 ─┤ 요청 5 ─┘ ↓ Database
특징
- 적은 수의 스레드로 많은 요청 처리(1000= 4~8 스레드)
- 낮은 메모리 사용
- 낮은 컨텍스트 스위칭 오버헤드
3. 코드 스타일 비교
JPA
// Entity @Entity @Table(name = "credit_balances") class CreditBalanceEntity( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null, @Column(nullable = false) val userId: String, @Column(nullable = false) var balance: Int, @Version // 낙관적 락 @Column(nullable = false) var version: Long = 0 ) // Repository interface CreditBalanceJpaRepository : JpaRepository<CreditBalanceEntity, Long> { fun findByUserId(userId: String): CreditBalanceEntity? } // Service @Service @Transactional // 선언적 트랜잭션 class CreditService( private val repository: CreditBalanceJpaRepository ) { fun save(balance: CreditBalance): CreditBalance { val entity = CreditBalanceEntity.from(balance) val saved = repository.save(entity) // 동기 호출 return saved.toDomain() } }
R2DBC 코드
// Entity @Table("credit_balances") data class CreditBalanceEntity( @Id @Column("id") val id: String = UlidGenerator.generate(), @Column("user_id") val userId: String, @Column("balance") var balance: Int, @Version // 낙관적 락 @Column("version") var version: Long = 0 ) // Repository interface CreditBalanceR2dbcRepository : ReactiveCrudRepository<CreditBalanceEntity, String> { @Query("SELECT * FROM credit_balances WHERE user_id = :userId") fun findByUserId(userId: String): Mono<CreditBalanceEntity> } // Service @Service class CreditService( private val repository: CreditBalanceR2dbcRepository, private val transactionalOperator: TransactionalOperator ) { fun save(balance: CreditBalance): Mono<CreditBalance> { val entity = CreditBalanceEntity.from(balance) return repository.save(entity) // Mono 반환 .map { it.toDomain() } } }
4. 트랜잭션 관리 비교
JPA 트랜잭션
JPA는 @Transactional 어노테이션을 사용한 선언적 트랜잭션 관리를 지원합니다.
@Service @Transactional // 선언적 트랜잭션 class CreditService { @Transactional fun processTransaction(balance: CreditBalance) { repository.save(balance) // 자동으로 트랜잭션에 포함 transactionRepository.save(transaction) // 같은 트랜잭션 // 예외 발생 시 자동 롤백 } }
R2DBC 트랜잭션
R2DBC는 TransactionalOperator를 사용한 프로그래밍 방식 트랜잭션 관리를 사용합니다.
@Service class CreditService( private val transactionalOperator: TransactionalOperator ) { fun processTransaction(balance: CreditBalance): Mono<CreditBalance> { val transactionMono = repository.save(balance) .flatMap { saved -> transactionRepository.save(transaction) .then(Mono.just(saved)) } // 명시적으로 트랜잭션으로 묶음 return transactionalOperator.transactional(transactionMono) } }
ex) 실제 프로젝트 사용
// withTransaction 확장 함수 사용 private suspend fun processTransaction(...): CreditBalance { return withTransaction(transactionalOperator) { val savedBalance = creditRepository.save(balance) val transaction = CreditTransaction(...) creditRepository.saveTransaction(transaction) savedBalance } }
5. 쿼리 작성 비교
JPA query
JPA는 메서드명으로 자동 쿼리 생성, JPQL, 네이티브 쿼리를 지원합니다.
// 자동 쿼리 생성 interface CreditBalanceJpaRepository : JpaRepository<CreditBalanceEntity, Long> { fun findByUserIdAndBalanceGreaterThan(userId: String, balance: Int): List<CreditBalanceEntity> // SELECT * FROM credit_balances // WHERE user_id = ? AND balance > ? } // JPQL @Query("SELECT c FROM CreditBalanceEntity c WHERE c.userId = :userId") fun findByUserId(@Param("userId") userId: String): List<CreditBalanceEntity> // 네이티브 쿼리 @Query(value = "SELECT * FROM credit_balances WHERE user_id = ?1", nativeQuery = true) fun findByUserIdNative(userId: String): List<CreditBalanceEntity>
R2DBC 쿼리
R2DBC는 @Query 어노테이션으로 수동 쿼리 작성이 필요합니다.
interface CreditTransactionR2dbcRepository : ReactiveCrudRepository<CreditTransactionEntity, String> { @Query("SELECT * FROM credit_transactions WHERE user_id = :userId ORDER BY created_at DESC LIMIT :limit OFFSET :offset") fun findByUserIdOrderByCreatedAtDesc(userId: String, limit: Int, offset: Long): Flux<CreditTransactionEntity> @Query("SELECT * FROM credit_transactions WHERE reference_id = :referenceId") fun findByReferenceId(referenceId: String): Flux<CreditTransactionEntity> }
6. 기능 비교
| 항목 | JPA | R2DBC |
|---|---|---|
| I/O 모델 | 블로킹 | 논블로킹 |
| 반환 타입 | 동기 (직접 반환) | 비동기 (Mono/Flux) |
| 스레드 모델 | 요청당 스레드 | 이벤트 루프 |
| ORM 기능 | 풍부 (자동 매핑, 관계) | 최소 (수동 매핑) |
| 쿼리 생성 | 자동 (메서드명) | 수동 (@Query) |
| 관계 매핑 | @OneToMany, @ManyToOne 등 | 없음 (수동 JOIN) |
| Lazy Loading | 지원 | 없음 |
| 자동 스키마 생성 | ddl-auto 지원 | 없음 (수동) |
| 트랜잭션 | @Transactional | TransactionalOperator |
| 동시성 처리 | 비관적 락, 낙관적 락 | 낙관적 락 |
| 학습 곡선 | 낮음 | 높음 (리액티브) |
| 성능 (높은 동시성) | 낮음 | 높음 |
| 성능 (낮은 동시성) | 높음 | 비슷 |
7. 성능 비교
시나리오: 10,000 동시 요청
| 항목 | JPA | R2DBC |
|---|---|---|
| 필요 스레드 | 10,000개 | 8개 |
| 메모리 사용 | 약 10GB (스레드당 1MB) | 약 100MB |
| 처리 시간 | 5초 | 2초 |
| 컨텍스트 스위칭 | 높음 | 낮음 |
성능 특성
JPA
- 높은 동시성 시 많은 스레드 필요
- 높은 메미로 ㅣ사용
- 높은 컨텍스트 스위칭
- 처리량 제한
R2DBC
- 적은 수의 스레드로 처리
- 낮은 메모리 사용
- 낮은 컨텍스트 스위칭
- 높은 처리량
8. 동시성 처리 비교
JPA - 블로킹 방식
Thread 1: save(balance) → UPDATE (락 획득) → [대기...] → 완료 → 성공 Thread 2: save(balance) → UPDATE (락 대기) → [대기...] → 완료 → 성공
각 스레드가 DB응답을 기다리는 동안 계속 블로킹 된다
R2DBC - 논블로킹 방식
Event Loop: save(balance1) → UPDATE 요청 → [다른 작업 처리] → balance1 완료 → Mono 완료 save(balance2) → UPDATE 요청 → [다른 작업 처리] → balance2 완료 → Mono 완료
DB 응답 대기 중에도 다른 작업을 계속 처리할 수 있습니다.
9. 사용 사나리오별 권장사항
JPA를 선택하는 경우
- 일반적인 CRUD 애플리케이션
- 복잡한 관계 매핑이 필요한 경우
- 팀이 jpa에 익숙한 경우
- 낮은 동시성 요구사항 (수백~수천 요청/초)
- 빠른 개발 속도가 필요한 경우
- 자동 쿼리 생성이 필요한 경우
R2DBC를 선택하는 경우
- 높은 동시성 요구사항(수만~수십만 요청/초 처리 필요)
- 마이크로서비스 아키텍처
- 리액티브 스택 (WebFlux) 사용
- I/O 집약적 작업
- 리소스 효율성이 중요한 경우
댓글 (0)
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!