티스토리 뷰
[Spring Boot / 스프링 부트] - Spring Boot의 JDBC, JdbcTemplate, JPA
poopooreum 2026. 8. 13. 19:45✏️ 전체 그림
셋은 경쟁 관계가 아니라 레이어 관계다. 아래로 갈수록 저수준(직접 제어), 위로 갈수록 고수준(자동화)이다. 즉 JPA도 결국 내부적으로는 JDBC를 통해 SQL을 실행한다. 세 가지는 "SQL을 얼마나 직접 다루느냐"의 스펙트럼 위에 있는 선택지들이다.
JPA (Hibernate 등) ← 객체와 테이블을 매핑, SQL을 자동 생성
↓ 내부적으로 사용
JdbcTemplate ← JDBC를 감싼 템플릿, 보일러플레이트 제거
↓ 내부적으로 사용
JDBC (Java Database Connectivity) ← DB와 통신하는 표준 저수준 API
↓
DB Driver → 실제 DB
✏️ 1. JDBC
JDBC는 자바에서 DB에 접속하고 SQL을 실행하기 위한 표준 인터페이스다. java.sql 패키지에 정의되어 있고, 각 DB 벤더가 이 인터페이스를 구현한 드라이버(MySQL Connector/J, PostgreSQL JDBC Driver 등)를 제공한다. 핵심 흐름은 항상 같다: Connection 획득 → Statement/PreparedStatement 생성 → 실행 → ResultSet 순회 → 자원 해제
public List<Member> findAll() {
List<Member> members = new ArrayList<>();
String sql = "SELECT id, name, email FROM member";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql);
ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
Member member = new Member(
rs.getLong("id"),
rs.getString("name"),
rs.getString("email")
);
members.add(member);
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
return members;
}
JDBC의 문제점
- 반복 코드가 많다: 조회 하나 하려고 커넥션 열고, 예외 처리하고, finally(또는 try-with-resources)로 자원을 닫는 코드를 매번 써야 한다.
- 체크 예외(SQLException)를 강제한다: 호출하는 모든 곳에서 throws SQLException을 달거나 잡아야 한다. Spring은 이걸 런타임 예외인 DataAccessException 계층으로 변환해서 이 문제를 해결해준다(JdbcTemplate이 이 역할을 한다).
- 자원 해제를 깜빡하면 커넥션 누수(leak)로 이어진다.
- ResultSet → 객체 매핑을 손으로 직접 짜야 한다.
Spring Boot에서 순수 JDBC를 직접 쓰는 경우는 거의 없다. spring-boot-starter-jdbc 의존성을 추가하면 DataSource가 자동 구성되지만, 그 위에 JdbcTemplate을 얹어서 쓰는 게 사실상 표준이다.
✏️ 2. JdbcTemplate - JDBC를 감싼 템플릿
JdbcTemplate은 Spring이 제공하는 클래스로, JDBC의 반복 패턴(커넥션 획득, 자원 해제, 예외 변환)을 템플릿 메서드 패턴으로 감싸서 없애준다. SQL은 여전히 개발자가 직접 작성한다 - 그 점에서 JPA와 근본적으로 다르다.
@Repository
@RequiredArgsConstructor
public class MemberJdbcRepository {
private final JdbcTemplate jdbcTemplate;
public List<Member> findAll() {
String sql = "SELECT id, name, email FROM member";
return jdbcTemplate.query(sql, (rs, rowNum) ->
new Member(
rs.getLong("id"),
rs.getString("name"),
rs.getString("email")
)
);
}
public Member findById(Long id) {
String sql = "SELECT id, name, email FROM member WHERE id = ?";
return jdbcTemplate.queryForObject(sql, (rs, rowNum) ->
new Member(rs.getLong("id"), rs.getString("name"), rs.getString("email")),
id
);
}
public int save(Member member) {
String sql = "INSERT INTO member (name, email) VALUES (?, ?)";
return jdbcTemplate.update(sql, member.getName(), member.getEmail());
}
}
주요 메서드
| 메서드 | 용도 |
| query(sql, RowMapper, ...) | SELECT, 여러 row를 객체 리스트로 매핑 |
| queryForObject(sql, RowMapper, ...) | SELECT, 단일 결과 반환 (0건이면 예외) |
| update(sql, ...) | INSERT / UPDATE / DELETE |
| batchUpdate(sql, ...) | 배치 처리 |
JDBC 대비 장점
- Connection 획득/반환, try-catch-finally를 JdbcTemplate이 대신 처리한다.
- SQLException을 Spring의 DataAccessException(런타임 예외)으로 변환해준다 - 매 호출부에서 체크 예외를 처리할 필요가 없어진다.
- RowMapper로 ResultSet → 객체 매핑 로직만 집중해서 작성하면 된다.
그럼에도 남는 한계
- SQL은 여전히 문자열로 직접 작성한다. 컬럼이 바뀌면 SQL 문자열도 손으로 고쳐야 하고, 오타는 런타임에야 발견된다.
- 객체 그래프(연관관계)를 다루려면 JOIN과 매핑을 전부 손으로 짜야 한다.
- 영속성 컨텍스트, 변경 감지(dirty checking) 같은 개념이 없다. 조회한 객체를 수정해도 자동으로 UPDATE 쿼리가 나가지 않는다 - 다시 update()를 명시적으로 호출해야 한다.
✏️ 3. JPA - 객체와 테이블을 매핑하는 ORM 표준
JPA(Java Persistence API)는 자바 ORM(Object-Relational Mapping) 표준 명세다. JPA 자체는 인터페이스 모음이고, 실제 구현체는 Hibernate(Spring Boot의 기본 구현체), EclipseLink 등이다. 즉 "JPA를 쓴다"는 건 보통 "Hibernate를 JPA 인터페이스를 통해 쓴다"는 뜻이다. 엔티티 클래스에 어노테이션으로 테이블 매핑 정보를 선언해두면, JPA(Hibernate)가 SQL을 대신 생성해준다.
@Entity
@Table(name = "member")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();
}
@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {
@PersistenceContext
private EntityManager em;
public Member findById(Long id) {
return em.find(Member.class, id); // SELECT 쿼리 자동 생성
}
public void save(Member member) {
em.persist(member); // INSERT 쿼리 자동 생성
}
public void updateName(Long id, String newName) {
Member member = em.find(Member.class, id);
member.setName(newName); // UPDATE 쿼리 없음! 그냥 필드만 바꿈
// 트랜잭션 커밋 시점에 JPA가 변경을 감지해서 자동으로 UPDATE 실행
}
}
핵심 개념: 영속성 컨텍스트(Persistence Context)
JPA를 이해하는 데 가장 중요한 개념이다. EntityManager가 관리하는, 엔티티를 담아두는 1차 캐시 같은 공간이다.
- 1차 캐시: 같은 트랜잭션 안에서 같은 id로 두 번 조회하면, 두 번째부터는 DB에 쿼리를 날리지 않고 캐시에서 반환한다.
- 변경 감지(Dirty Checking): 엔티티 필드를 수정하면, 트랜잭션이 끝나는 시점(flush)에 JPA가 최초 조회 시점의 스냅샷과 비교해서 바뀐 필드만 UPDATE 쿼리로 만들어 실행한다. save()를 명시적으로 호출할 필요가 없다.
- 지연 로딩(Lazy Loading): 연관 엔티티는 실제로 접근하는 시점에야 조회 쿼리가 나간다. 앞서 다룬 LazyInitializationException이 바로 이 지연 로딩이 트랜잭션(영속성 컨텍스트) 밖에서 발생할 때 터지는 예외다.
- 쓰기 지연(Write-Behind): persist()를 호출해도 즉시 INSERT가 나가는 게 아니라, 트랜잭션 커밋 시점에 모아서 flush된다.
Spring Data JPA - JPA를 더 편하게
보통은 EntityManager를 직접 쓰기보다 Spring Data JPA의 Repository 인터페이스를 쓰는 게 표준이다. 인터페이스만 선언하면 Spring Data JPA가 런타임에 구현체를 만들어 빈으로 등록해준다. save(), findById(), findAll(), delete() 같은 기본 CRUD는 이미 JpaRepository에 구현되어 있고, 메서드 이름 규칙(findBy..., existsBy... 등)으로 쿼리를 자동 생성해주는 게 가장 큰 생산성 이점이다.
public interface MemberRepository extends JpaRepository<Member, Long> {
// 메서드 이름만으로 쿼리 자동 생성
List<Member> findByNameContaining(String keyword);
Optional<Member> findByEmail(String email);
// 복잡한 쿼리는 JPQL로 직접 작성
@Query("SELECT m FROM Member m WHERE m.name = :name AND m.email LIKE %:domain%")
List<Member> findByNameAndEmailDomain(@Param("name") String name, @Param("domain") String domain);
}
Spring Data JPA의 대표적인 함정 - N+1문제
연관관계가 있는 엔티티를 리스트로 조회한 뒤 각 엔티티의 연관 필드에 접근하면, 조회 1번 + 연관 엔티티 조회 N번(N = 리스트 개수)만큼 쿼리가 추가로 나갈 수 있다. fetch join, @EntityGraph, batch size 설정으로 대응한다. 앞서 정리한 트랜잭션 글에서 다룬 것처럼 readOnly = true는 이 문제를 해결해주지 않는다.
✏️ 4. 비교하기
| JDBC | JdbcTemplate | JPA (+ Spring Data JPA) | |
| SQL 작성 | 직접 | 직접 | 자동 생성 (필요 시 JPQL/네이티브 쿼리 직접 작성도 가능) |
| 보일러플레이트 | 많음 | 적음 | 거의 없음 |
| 객체-테이블 매핑 | 수동 | 수동(RowMapper) | 자동(어노테이션 기반) |
| 변경 감지 | 없음 (직접 UPDATE 호출) | 없음 (직접 UPDATE 호출) | 있음 (Dirty Checking) |
| 1차 캐시 | 없음 | 없음 | 있음 |
| 지연 로딩 | 해당 없음 | 해당 없음 | 있음 |
| 학습 곡선 | 낮음(개념은 단순) | 낮음 | 높음(영속성 컨텍스트, N+1, fetch 전략 등 이해 필요) |
| 성능 제어 | 완전히 세밀하게 가능 | 세밀하게 가능 | 자동 생성 SQL이라 튜닝이 상대적으로 까다로움 |
| 복잡한 통계/리포트 쿼리 | 적합 | 적합 | 상대적으로 부적합 (네이티브 쿼리로 우회 필요) |
✏️ 5. 어떻게 선택하는게 좋을까
- 일반적인 CRUD, 도메인 로직 중심의 서비스 → Spring Data JPA. 생산성이 압도적으로 높고, 영속성 컨텍스트 덕분에 도메인 객체 중심으로 코드를 짤 수 있다.
- 복잡한 통계 쿼리, 대량 배치, 성능이 극도로 중요한 조회 → JdbcTemplate이나 MyBatis 같은 SQL Mapper를 병행해서 쓴다. JPA로 어거지로 짜면 생성되는 SQL을 예측하기 어렵고 튜닝도 힘들다.
- 실제로 많은 Spring Boot 프로젝트가 "기본은 JPA, 조회가 복잡하거나 성능이 중요한 특정 케이스만 JdbcTemplate/QueryDSL" 식으로 혼용한다. 하나만 고집할 필요는 없다.
- JDBC를 직접 쓰는 경우는 거의 없다고 봐도 된다 - JdbcTemplate이나 JPA가 이미 그 위에서 충분히 편의를 제공하기 때문이다. 다만 이 둘이 결국 JDBC 위에서 동작한다는 걸 알아두면, 커넥션 풀 설정(HikariCP), 트랜잭션과 커넥션의 관계, 지난 트랜잭션 글에서 다룬 "커넥션을 오래 붙잡으면 안 된다"는 원칙을 이해하는 데 도움이 된다.
✏️ 6. 트랜잭션 글과의 연결
앞서 정리한 @Transactional 글의 내용이 여기서도 그대로 이어진다.
- JPA의 변경 감지(Dirty Checking)는 트랜잭션이 열려 있어야만 동작한다. @Transactional이 없는 메서드에서 엔티티를 수정해봐야 flush가 안 되므로 DB에 반영되지 않는다.
- 지연 로딩도 마찬가지로 영속성 컨텍스트, 즉 트랜잭션이 살아있는 동안에만 가능하다. 트랜잭션이 끝난 뒤 지연 로딩 필드에 접근하면 LazyInitializationException이 난다.
- JdbcTemplate은 영속성 컨텍스트 자체가 없으므로 이런 문제에서 자유롭지만, 대신 변경 감지나 캐시 같은 편의도 없다.
'Back-End > 개인 공부' 카테고리의 다른 글
| [Spring Boot / 스프링 부트] - JPA 영속성 컨텍스트 (0) | 2026.08.14 |
|---|---|
| [Spring Boot / 스프링 부트] - 세모산 코드에 트랜잭션 적용해서 이해하기 (0) | 2026.08.12 |
| [Spring Boot / 스프링 부트] - Spring @Transactional과 Proxy (0) | 2026.08.11 |
| [K8s / 쿠버네티스] 쿠버네티스(Kubernetes) 수업 정리 (0) | 2026.06.02 |
| [K8s / 쿠버네티스] 쿠버네티스 MinIO 버킷 데이터 Mac으로 다운로드하기 (0) | 2026.05.31 |
- Total
- Today
- Yesterday
- Spring Boot
- 반복문
- 자료구조
- 알고리즘 공부
- 스프링 부트 crud 게시판 구현
- 알고리즘
- 우선순위 큐
- DP
- HTML5
- 세그먼트 트리
- 백준
- DFS
- 백준 풀이
- CSS
- 스택
- 유클리드 호제법
- html
- 이분 매칭
- c++ string
- java
- 유니온 파인드
- 에라토스테네스의 체
- 투 포인터
- 자바
- 카운팅 정렬
- js
- Do it!
- C++
- 자바스크립트
- BFS
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 | 29 |
| 30 | 31 |
