Spring Boot 실무 가이드 · Part 8

JPA 기초

객체로 다루면서 실제로 어떤 SQL이 나가는지 이해하기

작성 기준2026년 7월버전과 지원 현황은 이후 달라질 수 있으니 공식 문서를 함께 확인하세요.

이 파트에서 다루는 내용

JPA·Hibernate·Spring Data 구분엔티티와 리포지토리영속성 컨텍스트연관관계와 N+1
01

JPA, Hibernate, Spring Data JPA는 서로 다른 층입니다

세 이름이 섞여 쓰이면서 혼란이 생깁니다. 계층을 구분해 두면 문서를 찾을 때도 어디를 봐야 할지 알 수 있습니다.

JPA
표준 명세

자바에서 객체와 관계형 DB를 매핑하는 방법을 정의한 규격입니다. 인터페이스와 애노테이션만 있고 실제 동작 코드는 없습니다.

Hibernate
구현체

JPA 규격을 실제로 구현한 라이브러리입니다. Spring Boot에서 JPA를 쓰면 기본으로 Hibernate가 동작합니다. SQL 생성과 성능 특성은 여기서 결정됩니다.

Spring Data JPA
편의 계층

리포지토리 인터페이스만 선언하면 구현체를 만들어 주는 Spring 모듈입니다. JPA를 대체하는 것이 아니라 반복 코드를 줄여 줍니다.

문제 해결 순서

쿼리가 이상하게 나가면 Spring Data JPA 문서가 아니라 Hibernate 동작을 봐야 합니다. 메서드 이름 규칙이나 페이징 사용법은 Spring Data JPA 문서를 봅니다. 어느 층의 문제인지 먼저 구분하면 검색 시간이 크게 줄어듭니다.

02

엔티티와 리포지토리로 시작합니다

엔티티는 테이블 한 행에 대응하는 객체입니다. 리포지토리는 그 엔티티를 저장하고 조회하는 통로입니다.

Spring Data JPA에서는 인터페이스만 선언하면 기본 CRUD 메서드가 제공되고, 메서드 이름 규칙으로 간단한 조회 쿼리도 만들 수 있습니다.

@Enumerated(EnumType.STRING)
필수 습관

기본값인 ORDINAL은 enum 순서를 숫자로 저장합니다. 나중에 enum 중간에 값을 추가하면 기존 데이터의 의미가 통째로 바뀝니다.

메서드 이름 쿼리
적정선

조건이 두세 개를 넘어가면 메서드 이름이 읽기 어려워집니다. 그 시점에는 @Query나 별도 조회 전용 구현으로 넘어갑니다.

엔티티와 리포지토리 선언java
@Entity
@Table(name = "orders")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 20)
    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    @Column(nullable = false)
    private int totalAmount;

    private LocalDateTime orderedAt;

    // 상태 변경은 의미가 드러나는 메서드로 제공합니다
    public void cancel() {
        if (this.status != OrderStatus.PAID) {
            throw new BusinessException("결제 완료 상태만 취소할 수 있습니다");
        }
        this.status = OrderStatus.CANCELED;
    }
}

public interface OrderRepository extends JpaRepository<Order, Long> {

    // 메서드 이름으로 쿼리를 만듭니다
    List<Order> findByStatusOrderByOrderedAtDesc(OrderStatus status);

    // 복잡해지면 JPQL을 직접 씁니다
    @Query("select o from Order o where o.totalAmount >= :amount")
    List<Order> findExpensiveOrders(@Param("amount") int amount);
}

기본 생성자를 protected로 막고 setter를 두지 않은 점을 보세요. 아무 데서나 상태를 바꾸지 못하게 하면 잘못된 데이터가 저장되는 경로가 줄어듭니다.

03

영속성 컨텍스트를 알아야 동작이 설명됩니다

JPA를 쓰다 보면 '저장 코드를 부르지 않았는데 UPDATE가 나갔다', '같은 조회를 두 번 했는데 SQL은 한 번만 나갔다' 같은 일이 생깁니다. 모두 영속성 컨텍스트 때문입니다.

영속성 컨텍스트는 트랜잭션 안에서 엔티티를 보관하는 공간입니다. 조회한 엔티티를 여기에 담아 두고, 트랜잭션이 끝날 때 변경된 부분을 찾아 SQL로 내보냅니다.

1차 캐시
중복 조회 방지

같은 트랜잭션에서 같은 식별자를 조회하면 DB에 다시 가지 않고 보관 중인 객체를 돌려줍니다. 그래서 두 객체는 == 비교도 참입니다.

변경 감지
dirty checking

조회한 엔티티의 값을 바꾸면 트랜잭션 종료 시점에 변경을 자동으로 감지해 UPDATE를 실행합니다. save를 부르지 않아도 반영됩니다.

쓰기 지연
SQL 모아 실행

저장 요청을 즉시 보내지 않고 모았다가 flush 시점에 함께 보냅니다. 그래서 코드 순서와 실제 SQL 실행 순서가 다를 수 있습니다.

준영속 상태
주의

트랜잭션이 끝나면 엔티티는 관리 대상에서 벗어납니다. 이후 값을 바꿔도 DB에 반영되지 않고, 지연 로딩도 실패합니다.

변경 감지로 수정하기java
@Transactional
public void cancelOrder(Long orderId) {
    Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new BusinessException("주문을 찾을 수 없습니다"));

    order.cancel();   // 값만 바꿉니다

    // save를 호출하지 않아도 트랜잭션 종료 시 UPDATE가 실행됩니다
}

반대로 말하면, 조회한 엔티티를 무심코 수정하면 의도하지 않은 UPDATE가 나갑니다. 조회 전용 메서드에 readOnly를 붙이는 이유이기도 합니다.

04

연관관계는 지연 로딩을 기본으로 둡니다

엔티티끼리 연결하면 객체 그래프를 따라가며 데이터를 꺼낼 수 있습니다. 다만 언제 SQL이 나가는지를 통제하지 않으면 쿼리가 폭발합니다.

@ManyToOne과 @OneToOne은 기본값이 즉시 로딩(EAGER)입니다. 실무에서는 거의 항상 지연 로딩(LAZY)으로 바꿔 씁니다.

즉시 로딩의 문제
EAGER

연관 엔티티를 항상 함께 가져옵니다. 필요 없는 조인이 붙고, 목록 조회에서 예측할 수 없는 쿼리가 발생합니다.

지연 로딩
LAZY · 권장

실제로 사용할 때 조회합니다. 언제 SQL이 나가는지 개발자가 통제할 수 있습니다.

양방향은 필요할 때만
설계

양쪽에서 서로를 참조하면 관리 지점이 늘고 직렬화에서 순환이 생깁니다. 단방향으로 충분한지 먼저 검토합니다.

open-in-view
false 권장

기본값이 true라 뷰 렌더링까지 영속성 컨텍스트가 열려 있습니다. 커넥션 점유 시간이 길어지므로 false로 두고 서비스 안에서 필요한 데이터를 모두 조회합니다.

05

N+1은 JPA에서 가장 자주 만나는 성능 문제입니다

목록 10건을 조회한 뒤 각 건의 연관 데이터를 꺼내면, 목록 조회 1번에 연관 조회 10번이 추가로 나갑니다. 이것이 N+1입니다.

데이터가 적을 때는 눈에 띄지 않다가 운영에서 건수가 늘면 갑자기 느려집니다. 개발 단계에서 실행되는 SQL을 확인하는 습관이 중요합니다.

fetch join
가장 직접적

JPQL에서 연관 엔티티를 한 번에 조인해 가져옵니다. 다만 컬렉션을 조인하면 페이징이 정상 동작하지 않습니다.

@EntityGraph
선언적

리포지토리 메서드에 애노테이션으로 함께 조회할 연관을 지정합니다. JPQL을 직접 쓰지 않아도 됩니다.

batch size
컬렉션에 유용

지연 로딩을 IN 절로 묶어 조회 횟수를 줄입니다. 컬렉션 페이징이 필요한 경우 현실적인 대안입니다.

조회 전용 DTO
근본 해결

화면에 필요한 값만 선택해 바로 DTO로 받습니다. 엔티티를 거치지 않으므로 연관 로딩 자체가 없습니다.

N+1 확인과 해결java
// 1) 개발 환경에서 실행되는 SQL을 눈으로 확인합니다
// application-local.yml
// spring.jpa.show-sql: true
// spring.jpa.properties.hibernate.format_sql: true

// 2) fetch join으로 한 번에 조회
@Query("select o from Order o join fetch o.member where o.status = :status")
List<Order> findWithMember(@Param("status") OrderStatus status);

// 3) @EntityGraph로 선언
@EntityGraph(attributePaths = {"member"})
List<Order> findByStatus(OrderStatus status);

// 4) 컬렉션은 batch size로 조회 횟수를 줄입니다
// spring.jpa.properties.hibernate.default_batch_fetch_size: 100

show-sql은 개발 환경에서만 켭니다. 운영에서 켜 두면 로그가 폭증하고 성능에 영향을 줍니다.

06

실무에서 사고가 나는 지점

운영에서 ddl-auto
최우선 주의

create나 update로 두면 애플리케이션이 스키마를 바꿉니다. 운영은 validate 또는 none으로 두고 스키마는 마이그레이션 도구로 관리합니다.

엔티티에 setter 전면 개방
데이터 오염

어디서든 상태를 바꿀 수 있으면 잘못된 값이 저장된 경로를 찾기 어렵습니다. 의미 있는 메서드로만 상태를 바꿉니다.

엔티티를 API 응답으로 반환
Part 5 연계

지연 로딩 직렬화 오류와 내부 구조 노출이 함께 발생합니다. 응답은 DTO로 변환합니다.

실행 SQL을 보지 않음
습관

객체로만 코드를 보면 문제가 보이지 않습니다. 개발 중에는 어떤 SQL이 나가는지 확인하는 습관이 필요합니다.

정리

JPA는 SQL을 감추는 도구가 아니라 SQL 작성을 대신해 주는 도구입니다. 결국 나가는 것은 SQL이므로, 무엇이 실행되는지 모른 채로 쓰면 성능 문제를 예측할 수 없습니다.

버전

3.x와 4.x 차이

본문은 현장에서 가장 많이 쓰는 3.x 기준입니다. 4.x에서 달라진 부분만 아래에 정리합니다.

  • Spring Boot 3.x는 Hibernate 6를 사용합니다. 2.x의 Hibernate 5와 생성되는 SQL과 일부 매핑 기본값이 달라, 2.x에서 올릴 때 쿼리 결과를 반드시 재검증해야 합니다.
  • javax.persistence 패키지가 3.x부터 jakarta.persistence로 바뀌었습니다. 레거시 코드를 옮길 때 임포트 전체를 수정해야 합니다.
  • 4.x도 Hibernate 6 계열을 사용하며 이 파트의 개념은 그대로 적용됩니다. 자동 설정이 모듈로 분리되어 의존성 구성만 달라집니다.
체크

이 파트 완료 기준