Spring 데이터 접근 계층 비교 - JPA, jOOQ, R2DBC, WebFlux, Coroutine 조합 가이드
Spring 애플리케이션에서 DB에 접근하는 방식은 여러 가지다. JPA, jOOQ, R2DBC는 각각 다른 문제를 풀고 있으며, WebFlux와의 조합에 따라 아키텍처가 완전히 달라진다.
전체 아키텍처
클라이언트 요청
│
▼
┌──────────────────────┐
│ Web Layer │ Spring MVC (blocking) 또는 WebFlux (non-blocking)
└────────┬─────────────┘
│
▼
┌──────────────────────┐
│ Service Layer │ 비즈니스 로직
└────────┬─────────────┘
│
▼
┌──────────────────────┐
│ Data Access Layer │ JPA / jOOQ / R2DBC / MyBatis
└────────┬─────────────┘
│
▼
┌──────────────────────┐
│ DB Driver │ JDBC (blocking) 또는 R2DBC (non-blocking)
└────────┬─────────────┘
│
▼
Database
Web Layer와 Data Access Layer의 조합이 핵심이다. blocking끼리, non-blocking끼리 맞춰야 효과가 극대화된다.
각 기술의 역할
JPA / Hibernate
ORM(Object-Relational Mapping). 객체와 테이블을 매핑한다.
@Entity
@Table(name = "fax")
public class Fax {
@Id
@GeneratedValue
private Long id;
private String title;
private String status;
@ManyToOne(fetch = FetchType.LAZY)
private User sender;
}
// Spring Data JPA - 메서드 이름으로 쿼리 생성
public interface FaxRepository extends JpaRepository<Fax, Long> {
List<Fax> findByStatus(String status);
List<Fax> findBySenderIdAndStatusOrderByCreatedAtDesc(Long senderId, String status);
}
| 장점 | 단점 |
|---|---|
| 객체 중심 개발, SQL 작성 최소화 | 복잡한 쿼리에 한계 (JPQL, QueryDSL 필요) |
| 1차 캐시, 변경 감지, 지연 로딩 | N+1 문제, 예상치 못한 쿼리 발생 |
| Spring Data JPA로 생산성 극대화 | 실제 실행 SQL을 예측하기 어려움 |
| 테이블 자동 생성 (DDL auto) | 성능 튜닝 시 SQL을 직접 다뤄야 결국 함 |
적합한 경우: CRUD 중심, 도메인 모델이 중요한 서비스
jOOQ
타입 안전 SQL 빌더. SQL을 Java/Kotlin 코드로 작성한다.
// jOOQ - SQL과 1:1 대응되는 코드
Result<Record> result = dsl
.select(FAX.ID, FAX.TITLE, USER.NAME)
.from(FAX)
.join(USER).on(FAX.SENDER_ID.eq(USER.ID))
.where(FAX.STATUS.eq("SENT"))
.and(FAX.CREATED_AT.gt(LocalDateTime.now().minusDays(7)))
.orderBy(FAX.CREATED_AT.desc())
.limit(20)
.fetch();
DB 스키마를 코드로 생성(Code Generation)하여 컴파일 타임에 검증한다:
DB 스키마 (fax 테이블)
│
▼ jOOQ Code Generator
│
FAX.java (자동 생성)
- FAX.ID : TableField<FaxRecord, Long>
- FAX.TITLE : TableField<FaxRecord, String>
- FAX.STATUS : TableField<FaxRecord, String>
| 장점 | 단점 |
|---|---|
| 컴파일 타임 SQL 검증 (컬럼명 오타 → 컴파일 에러) | Code Generation 설정 필요 |
| SQL과 1:1 대응, 실행 쿼리 예측 가능 | ORM이 아니므로 변경 감지, 지연 로딩 없음 |
| 복잡한 쿼리 (서브쿼리, 윈도우 함수) 자유자재 | 초기 학습 비용 |
| DB 벤더별 SQL 방언 자동 처리 | 상용 DB(Oracle 등)는 유료 라이선스 |
적합한 경우: 복잡한 쿼리가 많은 서비스, SQL 통제가 중요한 경우
jOOQ는 실행 계층이 아니다:
jOOQ = SQL "생성"만 담당
생성된 SQL을 누가 실행하느냐에 따라:
→ JDBC로 실행 → blocking
→ R2DBC로 실행 → non-blocking
R2DBC (Reactive Relational Database Connectivity)
Non-blocking DB 드라이버. JDBC의 리액티브 버전이다.
JDBC: Connection → Statement → ResultSet (전부 blocking)
R2DBC: ConnectionFactory → Statement → Publisher<Result> (전부 non-blocking)
// Spring Data R2DBC
public interface FaxRepository extends ReactiveCrudRepository<Fax, Long> {
Flux<Fax> findByStatus(String status); // Flux = 0~N개 비동기 스트림
Mono<Fax> findById(Long id); // Mono = 0~1개 비동기 값
}
| 장점 | 단점 |
|---|---|
| DB I/O가 non-blocking | JPA처럼 풍부한 ORM 기능 없음 (연관 매핑, 지연 로딩 없음) |
| WebFlux와 조합하면 전 구간 non-blocking | 복잡한 쿼리 작성이 불편 |
| 적은 스레드로 높은 동시 처리 | 디버깅 어려움 (스택 트레이스가 리액티브 체인) |
| 배압(backpressure) 지원 | JDBC 기반 라이브러리 사용 불가 |
적합한 경우: 초고성능 non-blocking 서비스, WebFlux 기반 프로젝트
Spring WebFlux
Non-blocking Web 프레임워크. Spring MVC의 리액티브 대안.
// Spring MVC (blocking)
@GetMapping("/faxes")
public List<Fax> getFaxes() {
return faxRepository.findAll(); // 스레드 블로킹
}
// Spring WebFlux (non-blocking)
@GetMapping("/faxes")
public Flux<Fax> getFaxes() {
return faxRepository.findAll(); // non-blocking, 스레드 반환
}
| Spring MVC | Spring WebFlux |
|---|---|
| Tomcat (스레드 풀) | Netty (이벤트 루프) |
| 1요청 = 1스레드 | 소수 스레드가 다수 요청 처리 |
List<T>, T |
Flux<T>, Mono<T> |
| blocking I/O | non-blocking I/O |
| 직관적 코드 | 리액티브 체인 학습 필요 |
조합 패턴
패턴 1: Spring MVC + JPA + JDBC (가장 보편적)
Spring MVC → JPA/Hibernate → JDBC → DB
(blocking) (blocking) (blocking)
@RestController
@RequiredArgsConstructor
public class FaxController {
private final FaxRepository faxRepository;
@GetMapping("/faxes")
public List<Fax> getFaxes() {
return faxRepository.findByStatus("SENT");
}
}
- 전 구간 blocking이지만 JDK 21+ Virtual Thread로 커버
- 코드가 단순하고 디버깅 쉬움
- Spring Boot 생태계의 대부분 라이브러리와 호환
- B2B 서비스, 관리자 시스템에 적합
패턴 2: Spring MVC + jOOQ + JDBC (복잡한 쿼리)
Spring MVC → jOOQ (SQL 생성) → JDBC (실행) → DB
(blocking) (빌더) (blocking)
@RestController
@RequiredArgsConstructor
public class FaxController {
private final DSLContext dsl;
@GetMapping("/faxes")
public List<FaxDto> searchFaxes(
@RequestParam String status,
@RequestParam(required = false) LocalDate from,
@RequestParam(required = false) LocalDate to) {
var query = dsl.select(FAX.ID, FAX.TITLE, FAX.STATUS, USER.NAME.as("senderName"))
.from(FAX)
.join(USER).on(FAX.SENDER_ID.eq(USER.ID))
.where(FAX.STATUS.eq(status));
// 동적 조건 - 타입 안전하게 추가
if (from != null) {
query = query.and(FAX.CREATED_AT.ge(from.atStartOfDay()));
}
if (to != null) {
query = query.and(FAX.CREATED_AT.lt(to.plusDays(1).atStartOfDay()));
}
return query.orderBy(FAX.CREATED_AT.desc())
.limit(100)
.fetchInto(FaxDto.class);
}
}
- SQL을 완전히 통제, 복잡한 쿼리도 타입 안전
- JPA와 혼용 가능 (단순 CRUD는 JPA, 복잡한 조회는 jOOQ)
- Virtual Thread와 조합하면 성능 충분
패턴 3: WebFlux + R2DBC (Full Reactive)
WebFlux → R2DBC → DB
(non-blocking) (non-blocking)
@RestController
@RequiredArgsConstructor
public class FaxController {
private final FaxRepository faxRepository;
@GetMapping("/faxes")
public Flux<Fax> getFaxes(@RequestParam String status) {
return faxRepository.findByStatus(status);
}
@GetMapping("/fax/{id}")
public Mono<FaxDto> getFax(@PathVariable Long id) {
return faxRepository.findById(id)
.map(fax -> new FaxDto(fax.getId(), fax.getTitle()))
.switchIfEmpty(Mono.error(new NotFoundException("Fax not found")));
}
@PostMapping("/fax")
public Mono<Fax> createFax(@RequestBody Mono<FaxCreateRequest> request) {
return request
.map(req -> new Fax(req.getTitle(), req.getContent()))
.flatMap(faxRepository::save);
}
}
- 전 구간 non-blocking, 최소 스레드로 최대 처리량
- 코드 복잡도가 높음:
Mono,Flux,flatMap,switchIfEmpty체인 - 디버깅 어려움, 스택 트레이스가 리액티브 스케줄러 내부
- JDBC 기반 라이브러리(JPA, MyBatis) 사용 불가
패턴 4: WebFlux + jOOQ + R2DBC (최대 유연성)
WebFlux → jOOQ (SQL 생성) → R2DBC (실행) → DB
(non-blocking) (빌더) (non-blocking)
@RestController
@RequiredArgsConstructor
public class FaxController {
private final DSLContext dsl;
@GetMapping("/faxes")
public Flux<FaxDto> searchFaxes(@RequestParam String status) {
// jOOQ 3.17+에서 R2DBC Publisher 직접 지원
return Flux.from(
dsl.select(FAX.ID, FAX.TITLE, USER.NAME)
.from(FAX)
.join(USER).on(FAX.SENDER_ID.eq(USER.ID))
.where(FAX.STATUS.eq(status))
.orderBy(FAX.CREATED_AT.desc())
).map(record -> new FaxDto(
record.get(FAX.ID),
record.get(FAX.TITLE),
record.get(USER.NAME)
));
}
}
- jOOQ가 복잡한 SQL을 타입 안전하게 생성
- R2DBC가 non-blocking으로 실행
- jOOQ 3.17+에서
Publisher반환을 직접 지원하여Flux.from()으로 연결 - 복잡한 쿼리 + non-blocking이 모두 필요한 경우
패턴 5: WebFlux + Kotlin Coroutine + R2DBC
WebFlux → Coroutine (suspend) → R2DBC → DB
(non-blocking) (sequential style) (non-blocking)
@RestController
class FaxController(private val faxRepository: FaxRepository) {
@GetMapping("/faxes")
suspend fun getFaxes(@RequestParam status: String): List<Fax> =
faxRepository.findByStatus(status) // suspend 함수, non-blocking
@GetMapping("/fax/{id}")
suspend fun getFax(@PathVariable id: Long): FaxDto {
val fax = faxRepository.findById(id)
?: throw NotFoundException("Fax not found")
return FaxDto(fax.id, fax.title)
}
}
Mono/Flux없이 동기 코드처럼 작성- 내부적으로는 non-blocking (Coroutine이 컴파일러 수준에서 변환)
- Kotlin 프로젝트에서 WebFlux를 쓴다면 가장 깔끔한 방식
Blocking vs Non-Blocking 조합 주의
❌ 잘못된 조합: WebFlux + JPA(JDBC)
WebFlux (non-blocking, 이벤트 루프 스레드 소수)
→ JPA → JDBC (blocking)
→ 이벤트 루프 스레드가 DB 응답 기다리며 멈춤
→ 전체 서비스 멈춤
| Web Layer | Data Access | 결과 |
|---|---|---|
| MVC + JDBC | JPA, MyBatis, jOOQ+JDBC | OK - 전부 blocking, Virtual Thread로 커버 |
| WebFlux + R2DBC | R2DBC, jOOQ+R2DBC | OK - 전부 non-blocking |
| WebFlux + JDBC | JPA, MyBatis | 위험 - non-blocking 루프에서 blocking 발생 |
| MVC + R2DBC | R2DBC | 가능하지만 의미 없음 - MVC가 blocking이므로 R2DBC 이점 상실 |
원칙: blocking끼리, non-blocking끼리 맞춘다.
선택 가이드
복잡한 쿼리가 많은가?
├── 아니오 → CRUD 중심
│ ├── 높은 동시성 필요?
│ │ ├── 아니오 → Spring MVC + JPA + Virtual Thread ✅ (가장 보편적)
│ │ └── 예 → WebFlux + R2DBC (또는 + Coroutine)
│ └──
└── 예 → SQL 통제 필요
├── 높은 동시성 필요?
│ ├── 아니오 → Spring MVC + jOOQ + JDBC + Virtual Thread ✅
│ └── 예 → WebFlux + jOOQ + R2DBC
└──
현실적 판단 기준
| 기준 | Spring MVC + JPA | Spring MVC + jOOQ | WebFlux + R2DBC |
|---|---|---|---|
| 학습 비용 | 낮음 | 중간 | 높음 |
| 코드 복잡도 | 낮음 | 중간 | 높음 |
| 디버깅 | 쉬움 | 쉬움 | 어려움 |
| CRUD 생산성 | 최고 | 보통 | 보통 |
| 복잡한 쿼리 | 약함 | 최고 | 보통 |
| 동시 처리량 | Virtual Thread로 충분 | Virtual Thread로 충분 | 최고 |
| 생태계 호환성 | 최고 | 좋음 | 제한적 |
| 팀 도입 난이도 | 낮음 | 중간 | 높음 |
B2B 서비스 (팩스, 메일, 캘린더 등)
Spring MVC + JPA + Virtual Thread (현재)
이미 충분하다. 동시 사용자 수백~수천명 규모에서 WebFlux가 필요한 상황은 거의 없다. JPA로 부족한 복잡한 쿼리가 생기면 jOOQ를 부분 도입하는 것이 현실적이다.
고성능 B2C 서비스 (수만 TPS)
WebFlux + R2DBC (Java) 또는 WebFlux + Coroutine + R2DBC (Kotlin)
코드 복잡도를 감수할 만한 트래픽이 있을 때만 고려한다.
JPA vs jOOQ 동일 쿼리 비교
복잡한 검색 쿼리를 각 방식으로 구현한 비교:
요구사항
팩스 목록 조회: 상태별 필터, 발신자 이름 포함, 최근 7일, 페이징
JPA + QueryDSL
public Page<FaxDto> searchFaxes(String status, Pageable pageable) {
QFax fax = QFax.fax;
QUser user = QUser.user;
BooleanBuilder builder = new BooleanBuilder();
builder.and(fax.status.eq(status));
builder.and(fax.createdAt.after(LocalDateTime.now().minusDays(7)));
List<FaxDto> content = queryFactory
.select(Projections.constructor(FaxDto.class,
fax.id, fax.title, fax.status, user.name))
.from(fax)
.join(user).on(fax.senderId.eq(user.id))
.where(builder)
.orderBy(fax.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
long total = queryFactory
.select(fax.count())
.from(fax)
.where(builder)
.fetchOne();
return new PageImpl<>(content, pageable, total);
}
jOOQ
public Page<FaxDto> searchFaxes(String status, Pageable pageable) {
var condition = FAX.STATUS.eq(status)
.and(FAX.CREATED_AT.gt(LocalDateTime.now().minusDays(7)));
List<FaxDto> content = dsl
.select(FAX.ID, FAX.TITLE, FAX.STATUS, USER.NAME)
.from(FAX)
.join(USER).on(FAX.SENDER_ID.eq(USER.ID))
.where(condition)
.orderBy(FAX.CREATED_AT.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetchInto(FaxDto.class);
int total = dsl.fetchCount(dsl.selectFrom(FAX).where(condition));
return new PageImpl<>(content, pageable, total);
}
두 코드의 차이:
- JPA:
QFax,Projections.constructor,BooleanBuilder등 JPA 전용 API - jOOQ: SQL 구조와 거의 동일,
FAX.STATUS.eq()등 DB 스키마에서 생성된 타입 사용 - jOOQ 코드를 읽으면 실행될 SQL이 그대로 보인다
Spring Boot 의존성 설정
JPA (기본)
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
runtimeOnly("org.postgresql:postgresql")
}
jOOQ
plugins {
id("org.jooq.jooq-codegen-gradle") version "3.19.+"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-jooq")
runtimeOnly("org.postgresql:postgresql")
jooqCodegen("org.postgresql:postgresql")
}
// DB 스키마에서 코드 자동 생성
jooq {
configuration {
jdbc {
driver = "org.postgresql.Driver"
url = "jdbc:postgresql://localhost:5432/mydb"
}
generator {
database { inputSchema = "public" }
target { packageName = "com.example.jooq" }
}
}
}
R2DBC
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")
implementation("org.springframework.boot:spring-boot-starter-webflux")
runtimeOnly("org.postgresql:r2dbc-postgresql")
}
jOOQ + R2DBC
dependencies {
implementation("org.springframework.boot:spring-boot-starter-jooq") {
exclude(group = "org.springframework", module = "spring-jdbc") // JDBC 제외
}
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("io.r2dbc:r2dbc-postgresql")
}
Kotlin Coroutine으로 WebFlux 코드 단순화
WebFlux의 Mono/Flux 체이닝은 강력하지만, 비즈니스 로직이 복잡해지면 가독성이 급격히 떨어진다.
Kotlin Coroutine을 사용하면 동기 코드처럼 작성하면서 non-blocking 실행을 얻을 수 있다.
문제: Mono/Flux 체이닝의 복잡도
팩스 조회 → 발신자 조회 → 권한 확인 → DTO 변환을 Mono로 구현하면:
// Java + Mono/Flux - flatMap 중첩
public Mono<FaxDetailDto> getFaxDetail(Long faxId, Long userId) {
return faxRepository.findById(faxId)
.switchIfEmpty(Mono.error(new NotFoundException("Fax not found")))
.flatMap(fax -> userRepository.findById(fax.getSenderId())
.flatMap(sender -> permissionRepository.check(userId, faxId)
.flatMap(hasPermission -> {
if (!hasPermission) {
return Mono.error(new ForbiddenException());
}
return Mono.just(new FaxDetailDto(fax, sender));
})
)
);
}
flatMap 안에 flatMap 안에 flatMap… 콜백 지옥과 비슷한 구조가 된다.
해결: Coroutine의 suspend 함수
같은 로직을 Coroutine으로 작성하면:
// Kotlin + Coroutine - 동기 코드처럼 읽힌다
suspend fun getFaxDetail(faxId: Long, userId: Long): FaxDetailDto {
val fax = faxRepository.findById(faxId)
?: throw NotFoundException("Fax not found")
val sender = userRepository.findById(fax.senderId)
val hasPermission = permissionRepository.check(userId, faxId)
if (!hasPermission) {
throw ForbiddenException()
}
return FaxDetailDto(fax, sender)
}
둘 다 non-blocking이다. 실행 결과는 동일하지만, Coroutine 버전은 위에서 아래로 읽힌다.
병렬 처리 비교
여러 건을 동시에 처리하는 경우 차이가 더 크다:
// Mono/Flux - 팩스 3건 병렬 발송
public Flux<SendResult> sendFaxes(List<Long> faxIds) {
return Flux.fromIterable(faxIds)
.flatMap(id -> faxRepository.findById(id)
.flatMap(fax -> externalApi.send(fax)
.map(result -> new SendResult(id, true))
.onErrorResume(e -> Mono.just(new SendResult(id, false)))
), 3 // 동시 3건
);
}
// Coroutine - 같은 로직
suspend fun sendFaxes(faxIds: List<Long>): List<SendResult> = coroutineScope {
faxIds.map { id ->
async { // 병렬 실행
try {
val fax = faxRepository.findById(id)
externalApi.send(fax)
SendResult(id, true)
} catch (e: Exception) {
SendResult(id, false)
}
}
}.awaitAll()
}
async/awaitAll로 병렬 처리, try-catch로 에러 처리. 기존에 알던 코드 패턴 그대로다.
Mono/Flux vs Coroutine 패턴 비교
| 패턴 | Mono/Flux (Java) | Coroutine (Kotlin) |
|---|---|---|
| 순차 실행 | .flatMap(a -> ...) |
그냥 다음 줄 |
| 에러 처리 | .onErrorResume(), .switchIfEmpty() |
try-catch, ?: throw |
| 조건 분기 | flatMap 안에서 분기 |
if-else |
| 반복문 | Flux.fromIterable().flatMap() |
for 루프 |
| 병렬 실행 | Flux.merge(), Mono.zip() |
async { } + awaitAll() |
| 타임아웃 | .timeout(Duration.ofSeconds(5)) |
withTimeout(5000) |
| 디버깅 | 리액티브 스케줄러 스택 트레이스 | 일반 스택 트레이스 |
| 학습 비용 | 높음 (리액티브 연산자 수십 개) | 낮음 (동기 코드와 동일) |
원리
개발자가 작성 컴파일러가 변환 실행
───────────── ────────────────── ──────────
suspend fun ──→ ContinuationPassing ──→ non-blocking
(동기 스타일) (상태 머신으로 변환) (Mono/Flux와 동일)
Kotlin 컴파일러가 suspend 함수를 상태 머신(State Machine)으로 변환한다.
개발자는 동기 코드를 작성하고, 컴파일러가 리액티브 체인과 동등한 non-blocking 코드로 만들어준다.
Spring WebFlux + Coroutine 설정
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") // Mono/Flux ↔ Coroutine 변환
}
@RestController
class FaxController(private val faxRepository: FaxRepository) {
// Mono 대신 suspend, Flux 대신 Flow
@GetMapping("/fax/{id}")
suspend fun getFax(@PathVariable id: Long): FaxDto {
val fax = faxRepository.findById(id)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return FaxDto(fax.id, fax.title)
}
@GetMapping("/faxes")
fun getFaxes(@RequestParam status: String): Flow<FaxDto> = // Flow = Flux의 Coroutine 대응
faxRepository.findByStatus(status)
.map { FaxDto(it.id, it.title) }
}
| Reactive 타입 | Coroutine 대응 |
|---|---|
Mono<T> |
suspend fun: T |
Mono<Void> |
suspend fun: Unit |
Flux<T> |
Flow<T> |
정리
| JPA | jOOQ | R2DBC | |
|---|---|---|---|
| 패러다임 | ORM (객체 ↔ 테이블) | SQL 빌더 (타입 안전 SQL) | Reactive DB 드라이버 |
| 실행 방식 | JDBC (blocking) | JDBC 또는 R2DBC | R2DBC (non-blocking) |
| 강점 | CRUD 생산성 | 복잡한 쿼리, SQL 통제 | non-blocking I/O |
| 약점 | 복잡한 쿼리 | ORM 기능 없음 | 생태계 제한적 |
| 같이 쓸 수 있는가 | JPA + jOOQ 혼용 가능 | JPA 또는 R2DBC와 조합 | WebFlux와 세트 |
실무 결론: 대부분의 서비스에서 Spring MVC + JPA + Virtual Thread로 충분하다. jOOQ는 복잡한 쿼리가 필요할 때 부분 도입하고, WebFlux + R2DBC는 수만 TPS가 필요한 서비스에서만 검토한다.
댓글