목록으로 돌아가기

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. 기능 비교

항목JPAR2DBC
I/O 모델블로킹논블로킹
반환 타입동기 (직접 반환)비동기 (Mono/Flux)
스레드 모델요청당 스레드이벤트 루프
ORM 기능풍부 (자동 매핑, 관계)최소 (수동 매핑)
쿼리 생성자동 (메서드명)수동 (@Query)
관계 매핑@OneToMany, @ManyToOne 등없음 (수동 JOIN)
Lazy Loading지원없음
자동 스키마 생성ddl-auto 지원없음 (수동)
트랜잭션@TransactionalTransactionalOperator
동시성 처리비관적 락, 낙관적 락낙관적 락
학습 곡선낮음높음 (리액티브)
성능 (높은 동시성)낮음높음
성능 (낮은 동시성)높음비슷

7. 성능 비교

시나리오: 10,000 동시 요청

항목JPAR2DBC
필요 스레드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)

댓글 수정 시 필요합니다. 최소 4자 이상 입력해주세요.

아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!