Skip to main content
테이블 하나만 읽고 싶었는데, 갑자기 JDBC, JPA, Repository, R2DBC가 한꺼번에 튀어나와요.
지난 글에서는 우리 Spring Boot 앱이 다른 서버를 호출할 때 RestClient, WebClient, HTTP Interface를 어떻게 고를지 봤어요. 오늘은 방향을 다시 안쪽으로 돌려볼게요. 이번에는 우리 앱이 데이터베이스를 어떻게 읽고 쓸지예요. 처음 데이터베이스를 붙이려고 하면 이런 질문을 자주 만나죠.
“JPA가 제일 많이 쓰이니까 그냥 JPA로 가면 되나요?"
"JDBC는 옛날 방식 아닌가요?"
"Spring Data JDBC랑 JPA는 이름이 비슷한데 뭐가 달라요?"
"Repository interface를 만들면 SQL을 몰라도 되나요?"
"WebFlux를 쓰면 R2DBC도 같이 써야 하나요?"
"성능이 중요하면 JPA를 피해야 하나요?”
오늘은 기능 목록을 외우기보다 지도를 만들 거예요. 데이터 접근 기술은 최신순으로 고르는 게 아니라, 내가 다루는 데이터의 모양, SQL을 통제해야 하는 정도, transaction 경계, 애플리케이션의 실행 모델로 고르는 도구예요.
이 글은 Spring Boot 4.1.0 공식 문서의 SQL databases, Spring Framework 7.0.x의 JDBC/JdbcClient 문서, Spring Data JDBC/R2DBC와 Spring Data JPA 문서를 기준으로 작성했어요. 개념 선택 기준은 Spring Boot 3.x 프로젝트에서도 비슷하지만, starter 이름, Hibernate 버전, R2DBC driver 지원, 설정 속성은 사용 중인 버전 문서를 함께 확인하세요.

데이터 접근은 “DB 연결” 하나로 끝나지 않아요

Todo API에 데이터베이스를 붙인다고 해볼게요. 처음에는 이렇게 생각하기 쉬워요.
todos 테이블 만들고, 저장하고, 조회하면 끝 아닌가요?”
하지만 실제 코드에서는 바로 선택지가 갈라져요. 즉 데이터 접근 선택은 “어떤 라이브러리가 좋아요?”가 아니라 “우리 앱이 DB와 어떤 관계를 맺나요?”에 가까워요. 이 그림에서 중요한 건 service가 먼저 나온다는 점이에요. Controller가 바로 DB 도구를 잡는 게 아니라, 업무 흐름과 transaction 경계를 잡은 뒤 그 안에서 어떤 데이터 접근 방식을 쓸지 결정해요.

가장 낮은 공통분모는 SQL과 connection이에요

어떤 도구를 쓰든 결국 관계형 데이터베이스 앞에서는 SQL이 실행돼요. JPA를 쓰든, Spring Data repository를 쓰든, R2DBC를 쓰든 데이터베이스는 Java 객체가 아니라 SQL과 transaction, connection, lock으로 움직여요. 그래서 먼저 이 층을 잡아야 해요. 이 그림은 “JPA를 쓰면 SQL을 몰라도 된다”는 오해를 깨는 데 중요해요. 위쪽 abstraction은 코드를 편하게 만들 수 있지만, 아래쪽의 SQL, connection, transaction이 사라지지는 않아요. 실무에서 데이터 문제가 생기면 보통 아래 질문으로 내려가야 해요.
  • 실제로 어떤 SQL이 나갔나요?
  • 한 요청에서 쿼리가 몇 번 실행됐나요?
  • transaction은 어디서 열리고 어디서 끝났나요?
  • connection pool이 고갈되지는 않았나요?
  • 느린 쿼리는 index 문제인가요, N+1 문제인가요, lock 대기인가요?
도구를 고를 때도 이 질문을 피하면 안 돼요. “코드가 짧아진다”와 “운영에서 원인을 찾기 쉽다”는 다른 기준이거든요.

JDBC와 JdbcClient는 SQL을 코드의 중심에 둬요

JDBC는 Java가 관계형 데이터베이스와 이야기하는 가장 기본적인 API예요. 다만 순수 JDBC만 직접 쓰면 connection 열기, statement 만들기, result set 반복, 예외 처리, 자원 닫기 같은 반복 코드가 많아져요. Spring Framework는 오래전부터 JdbcTemplate으로 그 반복을 줄여줬고, 최근 Spring Framework에서는 JdbcClient로 더 읽기 쉬운 fluent API를 제공해요.
이 코드의 장점은 분명해요. SQL이 그대로 보여요. 어떤 table을 읽는지, 어떤 column을 가져오는지, 정렬이 무엇인지 코드에서 바로 보이죠. JDBC와 JdbcClient는 이런 경우에 잘 맞아요. 반대로 모든 CRUD를 전부 손으로 쓰면 반복이 많아질 수 있어요. 단순 저장과 조회가 대부분인데 매번 insert, update, select를 직접 쓰면 repository 코드가 빠르게 길어져요.
JDBC 계열은 SQL을 명시적으로 통제하고 싶을 때 여전히 강한 선택지예요. “새 프로젝트는 무조건 JPA”가 아니라, 쿼리 중심 문제인지 객체 관계 중심 문제인지 먼저 봐야 해요.

Spring Data JDBC는 repository를 주지만 JPA처럼 행동하지 않아요

Spring Data JDBC는 이름 때문에 “JPA의 가벼운 버전”처럼 보일 수 있어요. 하지만 그렇게 보면 오해가 생겨요. Spring Data JDBC는 Spring Data repository 모델을 JDBC 위에 올려줘요. interface를 만들면 기본적인 CRUD 구현을 Spring이 만들어줄 수 있어요.
이제 save, findById, findAll, deleteById 같은 기본 동작을 직접 구현하지 않아도 돼요. 하지만 중요한 차이가 있어요. Spring Data JDBC는 JPA의 persistence context, lazy loading, dirty checking을 제공하려고 만든 도구가 아니에요. Entity 객체를 오래 붙잡고 변경 감지를 기대하는 모델이 아니라, aggregate를 명시적으로 저장하고 다시 읽는 단순한 모델에 가까워요. 그래서 Spring Data JDBC는 이런 문제에 잘 맞아요.
  • aggregate가 단순해요.
  • SQL 데이터베이스를 쓰지만 JPA의 객체 그래프 모델은 부담스러워요.
  • 기본 CRUD repository는 필요해요.
  • DB 구조와 객체 구조를 너무 멀리 떨어뜨리고 싶지 않아요.
둘 다 repository interface를 만들 수 있어서 비슷해 보이지만, runtime model이 달라요. lazy loading, persistence context, dirty checking을 기대한다면 JPA를 봐야 하고, 그런 기능이 오히려 부담이라면 Spring Data JDBC가 더 단순할 수 있어요.

JPA는 객체 관계와 persistence context를 중심에 둬요

JPA(Java Persistence API)는 관계형 데이터베이스를 객체 모델로 다루기 위한 표준이에요. Spring Boot 프로젝트에서는 보통 Hibernate가 JPA provider로 함께 쓰이고, Spring Data JPA가 repository 구현을 줄여줘요. 가장 익숙한 코드는 이런 모양일 거예요.
이제 service에서는 객체를 조회하고, 객체의 method를 호출하고, transaction이 끝날 때 변경이 DB에 반영되는 모델을 쓸 수 있어요.
여기서 orderRepository.save(order)가 없는데도 변경이 반영될 수 있어요. 이게 JPA를 처음 만날 때 신기한 지점이에요. transaction 안에서 JPA가 관리하는 entity가 바뀌면, persistence context가 변경을 추적하고 flush 시점에 SQL을 만들 수 있어요. 이 그림의 핵심은 JPA가 단순히 repository method를 만들어주는 도구가 아니라는 점이에요. transaction 안에서 entity 상태를 관리하고, 변경을 SQL로 바꾸는 runtime model이 있어요. JPA는 이런 문제에 잘 맞아요. 하지만 JPA는 공짜가 아니에요. 추상화가 강한 만큼 경계도 알아야 해요.
JPA는 객체 모델을 중심으로 코드를 쓰게 해주지만, 운영에서 성능과 장애를 판단할 때는 결국 생성된 SQL을 봐야 해요. JPA를 잘 쓰는 사람은 Entity와 SQL을 둘 다 읽어요.

Spring Data repository는 기술 이름이 아니라 공통 패턴이에요

여기서 헷갈리는 말이 하나 있어요. “Spring Data repository”예요. 처음에는 이렇게 생각하기 쉬워요.
“Repository interface를 만들면 그게 JPA 아닌가요?”
아니에요. Spring Data는 여러 저장소 기술 위에 비슷한 repository 프로그래밍 모델을 제공하는 큰 프로젝트예요. JPA에도 repository가 있고, JDBC에도 repository가 있고, R2DBC에도 repository가 있어요. 그래서 repository interface 모양만 보고 runtime behavior를 판단하면 위험해요.
이 모양은 비슷해도, 뒤에서 JPA가 움직이는지, JDBC가 움직이는지, R2DBC가 움직이는지에 따라 transaction, loading, query execution, return type이 달라져요. Spring Data repository의 장점은 반복 query 구현을 줄이고, method 이름이나 @Query로 의도를 모을 수 있다는 점이에요. 하지만 method 이름이 너무 길어지면 오히려 읽기 어려워져요.
이 정도가 되면 “repository가 편하다”보다 “query 의도를 다른 방식으로 드러내야 한다”는 신호일 수 있어요. JPA라면 @Query, Querydsl, Specification을 볼 수 있고, SQL 중심 조회라면 JdbcClient나 별도 query adapter가 더 읽기 쉬울 수도 있어요.
Spring Data가 query 구현을 만들어줄 수는 있지만, 어떤 SQL이 나가는지와 어떤 index가 필요한지는 여전히 개발자가 확인해야 해요.

R2DBC는 “JPA의 reactive 버전”이 아니에요

R2DBC(Reactive Relational Database Connectivity)는 관계형 데이터베이스에 reactive 방식으로 접근하기 위한 API예요. JDBC가 blocking API라면, R2DBC는 reactive stream 흐름으로 결과를 다뤄요. 코드는 이런 느낌이에요.
반환값이 List<Todo>Optional<Todo>가 아니라 Flux<Todo>, Mono<Todo>죠. 지난 글에서 WebClient를 볼 때와 같은 실행 모델이에요. “지금 값이 있다”가 아니라 “나중에 값이 흐를 수 있다”는 표현이에요. R2DBC는 이런 경우에 검토할 수 있어요. 반대로 Spring MVC 앱에서 단순히 “성능이 좋아 보인다”는 이유로 R2DBC를 넣으면 복잡도만 늘 수 있어요. 이 그림의 핵심은 R2DBC도 WebClient처럼 앱 전체의 실행 모델과 같이 봐야 한다는 점이에요. R2DBC 하나만 넣는다고 애플리케이션 전체가 reactive 장점을 얻지는 않아요.
R2DBC는 reactive relational access이고, JPA의 persistence context나 lazy loading 모델을 그대로 reactive로 옮긴 도구가 아니에요. JPA가 필요한 문제와 R2DBC가 필요한 문제는 겹칠 수 있지만 같은 해법은 아니에요.

선택표로 보면 이렇게 정리할 수 있어요

처음 고를 때는 아래 표 정도로 시작하면 좋아요. 하지만 실제 프로젝트는 한 가지 도구만 쓰지 않을 수도 있어요. 예를 들어 주문 서비스는 이렇게 나눌 수 있어요.
  • 주문 생성과 상태 변경은 JPA entity로 다뤄요.
  • 관리자 통계 화면은 JdbcClient로 SQL 조회를 명시해요.
  • 외부 검색 시스템은 Elasticsearch를 따로 붙여요.
  • 이벤트 처리나 cache는 Redis, Kafka 같은 다른 저장소와 메시징을 써요.
이 조합 자체는 이상하지 않아요. 다만 경계가 분명해야 해요. 한 service method 안에서 JPA entity 상태 변경과 raw SQL update가 섞이면 persistence context가 알고 있는 상태와 DB 실제 상태가 어긋날 수 있어요. 도구를 섞을수록 transaction, flush, cache, 조회 모델 경계를 더 분명히 해야 해요.
다만 첫 선택은 단순해야 해요. 핵심 쓰기 모델은 하나로 잡고, 특별히 SQL이 필요한 조회나 배치성 작업에 다른 도구를 붙이는 식이 유지보수하기 쉬워요.

starter를 넣으면 무엇이 준비될까요?

Spring Boot에서는 보통 starter로 데이터 접근 도구를 시작해요.
JPA를 쓰면 이런 조합을 자주 보게 돼요. JDBC 중심이라면 이런 쪽이죠.
Spring Data JDBC는 repository abstraction이 필요할 때 선택해요.
R2DBC는 JDBC driver가 아니라 R2DBC driver와 spring.r2dbc.* 설정이 필요해요.
이 예시에서 중요한 건 starter 이름을 외우는 게 아니에요. JDBC 계열은 DataSource와 JDBC driver, R2DBC 계열은 reactive connection factory와 R2DBC driver라는 실행 기반이 다르다는 점이에요.
starter는 Spring 쪽 자동 설정을 준비하지만, 실제 데이터베이스와 통신하는 driver는 별도로 필요할 수 있어요. PostgreSQL, MySQL, MariaDB, H2처럼 어떤 DB를 쓸지에 따라 runtime 의존성과 URL 모양을 같이 확인해야 해요.

transaction 경계는 도구보다 먼저 설계해야 해요

데이터 접근을 고를 때 가장 자주 뒤늦게 만나는 문제가 transaction이에요. 주문 생성 흐름을 생각해볼게요.
  1. 주문 row를 만들어요.
  2. 주문 항목 row를 만들어요.
  3. 재고를 줄여요.
  4. 결제 요청을 보낼 준비 상태로 바꿔요.
이 중 2번에서 실패했다면 1번도 같이 되돌아가야 할 수 있어요. 이걸 “어느 repository를 쓰나요?”보다 먼저 정해야 해요.
여기서 @Transactional은 “DB 작업을 묶어줘요”라고만 기억하면 반쪽이에요. 더 정확히는 service method 진입과 종료 사이에 transaction boundary를 만들고, 그 안에서 repository 호출들이 같은 작업 단위로 묶이게 하는 runtime 경계예요. 다음 편에서 transaction을 더 깊게 볼 예정이지만, 지금은 이것만 잡아도 좋아요.
결제 API가 느려지는 동안 DB transaction과 lock이 오래 유지될 수 있어요. DB 변경과 외부 호출이 같이 필요한 흐름은 상태 machine, outbox, 보상 처리 같은 설계를 나중에 검토해야 해요.

API DTO와 Entity는 같은 객체가 아니에요

REST API 글에서 DTO를 봤죠. 데이터베이스를 붙이면 이 경계가 더 중요해져요. 처음에는 이렇게 하고 싶어져요.
동작은 할 수 있어요. 하지만 API response가 JPA entity와 묶여버려요. 그래서 API 경계에서는 DTO를 따로 두는 편이 안전해요.
이건 “파일을 많이 만들자”는 규칙이 아니에요. API 계약과 persistence model이 다른 속도로 변한다는 사실을 코드에 반영하는 거예요.

실무에서는 이 냄새를 먼저 확인해요

데이터 접근 코드를 리뷰하거나 장애를 볼 때는 도구 이름보다 냄새가 먼저 보여요. 특히 “테스트는 되는데 운영 DB에서 실패해요”는 데이터 접근에서 흔해요. H2 같은 embedded DB는 빠르고 편하지만, PostgreSQL이나 MySQL의 실제 타입, index, constraint, lock, SQL 문법과 다를 수 있어요. 그래서 중요한 repository나 query는 실제 DB에 가까운 환경에서 검증해야 해요. 나중에 Testcontainers와 database migration 글에서 이 부분을 다시 볼 거예요.

그럼 Todo API 다음 단계는 무엇이 자연스러울까요?

앞에서 만든 Todo API는 아직 인메모리 저장소를 썼어요. 재시작하면 데이터가 사라지는 구조였죠. 다음에 DB를 붙인다면 선택은 이렇게 나눠볼 수 있어요. 이 블로그의 다음 데이터 편은 JPA의 entity 상태와 persistence context를 볼 거예요. 이유는 단순해요. Spring Boot 실무 프로젝트에서 JPA를 많이 만나고, JPA는 처음에는 편하지만 나중에 N+1, lazy loading, dirty checking, transaction 경계 때문에 헷갈리는 지점이 많거든요. 하지만 오늘의 결론은 “JPA가 정답”이 아니에요.
데이터 접근 도구는 문제 모양에 맞춰 고르는 지도예요.
그리고 어떤 도구를 쓰든 SQL, transaction, connection은 끝까지 따라와요.

참고한 링크


자, 정리해볼까요?

  • JDBC와 JdbcClient는 SQL을 직접 드러내고 통제하고 싶을 때 강한 선택지예요.
  • Spring Data JDBC는 repository 모델을 주지만 JPA의 persistence context나 lazy loading을 기대하는 도구가 아니에요.
  • JPA와 Spring Data JPA는 entity, 관계, dirty checking, persistence context를 중심으로 동작해요.
  • Spring Data repository는 여러 저장소 기술 위에 올라가는 공통 패턴이지, 그 자체가 JPA를 뜻하지는 않아요.
  • R2DBC는 reactive relational access이고, WebFlux와 reactive driver까지 이어지는 실행 모델에서 의미가 커져요.
  • 어떤 도구를 쓰든 SQL, connection, transaction, schema migration, 테스트 환경은 운영 품질을 좌우해요.
다음 글에서는 JPA를 더 가까이 볼게요. Entity가 언제 새 객체이고, 언제 관리되는 객체이고, 언제 DB row로 반영되는지, 그리고 persistence context와 dirty checking이 왜 편하면서도 위험한지 이어서 살펴볼 거예요.