티스토리 뷰

반응형

✏️ 전체 그림

셋은 경쟁 관계가 아니라 레이어 관계다. 아래로 갈수록 저수준(직접 제어), 위로 갈수록 고수준(자동화)이다. 즉 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를 감싼 템플릿

JdbcTemplateSpring이 제공하는 클래스로, 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은 영속성 컨텍스트 자체가 없으므로 이런 문제에서 자유롭지만, 대신 변경 감지나 캐시 같은 편의도 없다.
반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/08   »
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
글 보관함