# JPA 소개 - SQL 중심 개발의 문제점과 JPA

# SQL 중심 개발의 문제점

관계형 DB가 데이터 저장의 사실상 표준이기 때문에, 객체를 RDB에 저장하려면 개발자가 직접 SQL을 작성해야 한다. 이 과정에서 다음 문제들이 발생한다.

  • 무한 반복되는 CRUD SQL: 객체 하나당 INSERT/UPDATE/SELECT/DELETE를 직접 매핑해야 한다. 개발자가 사실상 "SQL 매퍼" 역할을 한다.
  • 필드 추가 시 모든 SQL 수정: tel 같은 필드 하나를 추가하면 관련된 INSERT, SELECT, UPDATE를 전부 고쳐야 한다. 누락 시 런타임 버그로 이어진다.
  • 결국 SQL에 의존적인 개발을 피하기 어렵다.

iOS 비유

UIKit에서 모델 프로퍼티 추가할 때마다 init?(coder:), encode(with:), 그리고 수동 파싱 코드까지 다 손봐야 했던 상황과 같다. JPA는 Codable이 자동 직렬화를 처리해주는 것과 비슷한 역할을 한다.

# 패러다임 불일치 (객체 vs 관계형 DB)

객체 지향(상속, 다형성, 참조)과 관계형 DB(테이블, FK, 조인)는 설계 철학이 달라 다음 4가지 영역에서 충돌한다.

  • 상속: 객체는 상속 관계를 갖지만 테이블에는 상속이 없다(슈퍼타입/서브타입으로 흉내). Album 저장 시 ITEM, ALBUM 테이블에 각각 INSERT, 조회 시 조인 SQL을 직접 짜야 해서 복잡하다. 그래서 실무에서는 DB에 저장할 객체에 상속을 안 쓰게 된다.
  • 연관관계: 객체는 참조(member.getTeam())를, 테이블은 FK(JOIN ON M.TEAM_ID = T.TEAM_ID)를 사용한다. 테이블에 맞춰 모델링하면 객체에 Long teamId를 두게 되어 객체답지 못하고, 객체답게 Team team 참조를 두면 저장/조회 시 변환 코드가 늘어난다.
  • 객체 그래프 탐색: 객체는 자유롭게 그래프를 탐색할 수 있어야 하지만, SQL 중심에서는 처음 실행한 SQL의 조인 범위가 탐색 가능 범위를 결정한다. member.getTeam()은 되는데 member.getOrder()는 null이 될 수 있어, 엔티티를 신뢰할 수 없다.
  • 비교(동일성): DB에서 같은 ID를 두 번 조회하면 member1 == member2false(매번 new 객체). 자바 컬렉션에서 꺼내면 true. SQL 중심 개발은 컬렉션처럼 동작하지 않는다.

핵심 문제

객체답게 모델링할수록 객체 ↔ SQL 변환(매핑) 작업만 늘어난다. "객체를 자바 컬렉션에 넣듯이 DB에 저장할 수 없을까?"가 JPA의 출발점이다.

# JPA란

  • JPA(Java Persistence API): 자바 진영의 ORM 표준 명세(인터페이스의 모음).
  • ORM: 객체는 객체대로, RDB는 RDB대로 설계하고 그 사이를 ORM 프레임워크가 매핑.
  • 구현체: Hibernate(대표), EclipseLink, DataNucleus.
  • 동작 위치: 애플리케이션과 JDBC 사이에서 동작. JPA가 SQL 생성 → JDBC API 호출 → ResultSet 매핑까지 처리한다.

iOS 비유

JPA(표준 명세) ↔ Hibernate(구현체) 관계는, 프로토콜을 정의하고 구체 타입이 채택하는 구조와 같다. 우리가 URLSession 프로토콜에 의존하고 구현은 갈아끼우는 것과 비슷하다.

# JPA를 쓰면 해결되는 것

CRUD가 객체 중심으로 단순해진다.

저장: jpa.persist(member)
조회: Member member = jpa.find(memberId)
수정: member.setName("변경할 이름")   // 객체 값만 바꾸면 UPDATE 자동 처리
삭제: jpa.remove(member)
  • 생산성/유지보수: 엔티티에 필드만 추가하면 SQL은 JPA가 처리. 반복 SQL 작성에서 해방.
  • 상속 자동 처리: jpa.persist(album) 하면 ITEM/ALBUM INSERT를, jpa.find(Album.class, id) 하면 조인 SELECT를 JPA가 생성.
  • 연관관계/그래프 탐색: member.setTeam(team) 후 persist, 조회 후 member.getTeam()으로 신뢰성 있는 그래프 탐색 가능.
  • 동일성 보장: 같은 트랜잭션에서 두 번 조회한 엔티티는 == 비교 시 같음을 보장.

# JPA의 성능 최적화 기능

실무에서 JPA를 쓰는 강력한 이유.

  • 1차 캐시와 동일성 보장: 같은 트랜잭션 안에서 같은 엔티티는 캐시에서 반환 → SQL 1번만 실행. DB Isolation이 Read Committed여도 애플리케이션 레벨에서 Repeatable Read를 보장.
    • 1차 캐시의 키로 @Id가 사용되어 같은 키에 대해서는 동일한 엔티티를 반환한다.
  • 트랜잭션 쓰기 지연(write-behind):
    • INSERT: 커밋 시점까지 모았다가 JDBC BATCH로 한 번에 전송.
    • UPDATE/DELETE: 커밋 시점에 실행하므로 비즈니스 로직 수행 동안 ROW 락 시간 최소화.
    • ROW별로 락을 각각 걸고 해제하는 데에 추가로 소요되는 시간 / 여러 ROW의 수정사항을 한 번의 락으로 커밋하는 시간 (후자가 효율적)
  • 지연 로딩 vs 즉시 로딩:
    • 지연 로딩: 연관 객체를 실제 사용하는 시점에 SELECT 실행(별도 쿼리).
    • 즉시 로딩: 처음부터 JOIN으로 연관 객체까지 한 번에 조회.

실무 주의

지연/즉시 로딩은 설정만으로 성능과 쿼리 발생 패턴이 크게 달라진다. 잘못 설정하면 N+1 문제 등 성능 이슈로 직결되므로, 이후 강의에서 가장 신경 써야 할 포인트다.

# JPA 시작 - Hello JPA

# 프로젝트 설정 핵심

JPA는 인터페이스 표준이고, 실제 구현체로 Hibernate를 사용한다.

의존성은 두 가지가 핵심이다.

  • JPA(Hibernate) 구현체
  • 사용할 DB 드라이버 (H2 등)

실무 참고

강의는 H2 + Maven 기준이지만, 실무는 대부분 Spring Boot + Gradle 환경이다. Spring Boot에서는 spring-boot-starter-data-jpa 하나로 Hibernate, JPA, 커넥션 풀(HikariCP)이 자동 구성되고, persistence.xml 없이 application.yml로 설정한다. 따라서 아래 persistence.xml 방식은 "JPA가 어떻게 구동되는지" 원리 이해용으로만 보면 된다.

# persistence.xml 설정

JPA 표준 설정 파일로 /META-INF/persistence.xml 위치에 둔다.

  • persistence-unit name 으로 영속성 유닛 이름 지정 (예: hello)
  • javax.persistence. 로 시작 → JPA 표준 속성
  • hibernate. 로 시작 → Hibernate 전용 속성

필수 속성은 JDBC 연결 정보(driver, user, password, url)와 hibernate.dialect다.

유용한 옵션:

  • hibernate.show_sql : 실행 SQL 콘솔 출력
  • hibernate.format_sql : SQL 보기 좋게 정렬
  • hibernate.use_sql_comments : SQL에 주석(JPQL 등) 표시

# 데이터베이스 방언 (Dialect)

JPA는 특정 DB에 종속되지 않는다. DB마다 SQL 문법/함수가 다른데(VARCHAR vs VARCHAR2, LIMIT vs ROWNUM 등), 이 차이를 추상화하는 것이 Dialect다.

hibernate.dialect 속성에 지정한다.

  • H2 : org.hibernate.dialect.H2Dialect
  • Oracle : org.hibernate.dialect.Oracle10gDialect
  • MySQL : org.hibernate.dialect.MySQL5InnoDBDialect

TIP

DB만 교체하면 Dialect 변경으로 코드 수정 없이 다른 DB에 대응 가능하다. 이게 JPA의 핵심 장점 중 하나다.

# JPA 구동 방식

원리는 다음 흐름이다.

  1. Persistencepersistence.xml 설정 정보를 조회
  2. 설정 기반으로 EntityManagerFactory 생성
  3. EntityManagerFactory 가 요청마다 EntityManager 생성
public static void main(String[] args) {

    EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
    EntityManager em = emf.createEntityManager();
    //code

    em.close();
    emf.close();
}

가장 중요한 사용 규칙

  • EntityManagerFactory : 애플리케이션 전체에서 딱 하나만 생성해 공유한다 (생성 비용이 큼).
  • EntityManager : 쓰레드 간 공유 절대 금지. 요청마다 만들고 쓰고 버린다.
  • JPA의 모든 데이터 변경은 트랜잭션 안에서 실행해야 한다. (tx.begin() → 작업 → tx.commit())

# 엔티티 매핑

  • @Entity : JPA가 관리할 객체임을 선언
  • @Id : DB의 PK와 매핑
@Entity
public class Member {
    @Id
    private Long id;
    private String name;
    // Getter, Setter
}

기본 CRUD는 EntityManager로 처리한다.

  • 등록 : em.persist(member)
  • 조회(단건) : em.find(Member.class, id)
  • 수정 : 조회한 엔티티의 값을 변경하면 트랜잭션 커밋 시점에 자동 UPDATE (별도 persist 호출 불필요 — 변경 감지)
  • 삭제 : em.remove(member)

# JPQL

em.find() 와 객체 그래프 탐색(a.getB().getC())만으로는 "나이 18살 이상 회원 검색" 같은 조건 검색이 불가능하다. 전체를 객체로 올려 필터링하는 것도 불가능하므로, 검색 조건이 포함된 쿼리가 필요하다.

JPQL 은 JPA가 제공하는 객체 지향 쿼리 언어다.

  • SQL을 추상화 → 특정 DB SQL에 의존하지 않음
  • 문법은 SQL과 유사 (SELECT, FROM, WHERE, GROUP BY, HAVING, JOIN 지원)
  • 차이점: SQL은 테이블 대상, JPQL은 엔티티 객체 대상으로 쿼리
List<Member> result = em.createQuery("select m from Member m", Member.class)
        .getResultList();

TIP

한마디로 JPQL은 "객체 지향 SQL"이다. 페이징, 조건 검색 등 복잡한 조회는 JPQL로 처리하며, 자세한 내용은 객체지향 쿼리 파트에서 다룬다.

# 영속성 관리 - JPA 내부 구조

# 영속성 컨텍스트 개념

JPA에서 가장 중요한 두 가지는 ORM 매핑영속성 컨텍스트다.

영속성 컨텍스트는 "엔티티를 영구 저장하는 환경"이라는 뜻의 논리적 개념이다. 눈에 보이지 않으며, EntityManager를 통해 접근한다.

  • J2SE 환경: EntityManager : 영속성 컨텍스트 = 1:1
  • 스프링 같은 컨테이너 환경: N:1

# 엔티티 생명주기

면접 단골 주제

네 가지 상태와 전이 메서드는 정확히 외워두는 게 좋다.

  • 비영속 (new/transient): new로 객체만 생성, 영속성 컨텍스트와 무관
  • 영속 (managed): em.persist()로 영속성 컨텍스트가 관리하는 상태
  • 준영속 (detached): 영속 상태였다가 분리된 상태
  • 삭제 (removed): em.remove()로 삭제 예정 상태
                  detach() / clear() / close()
            ┌──────────────────────────────────────┐
            │                                       ▼
        [영속 managed] ◀──── merge() ───── [준영속 detached]
          ▲      │
 persist()│      │ remove()
          │      ▼
   [비영속 new]  [삭제 removed]


   find() / JPQL ──▶ [영속]        (DB에서 조회하면 영속 상태로)
   [영속] ──▶ flush() ──▶ DB        (변경 내용 DB 동기화)
Member member = new Member();   // 비영속
member.setId(1L);
member.setUsername("회원1");

em.persist(member);   // 영속
em.detach(member);    // 준영속 (분리)
em.remove(member);    // 삭제

WARNING

persist()는 DB에 즉시 INSERT 하는 게 아니라, 영속성 컨텍스트(1차 캐시)에 "영속 상태로 등록"하는 것이다. 실제 SQL은 트랜잭션 커밋 시점에 나간다.

# 영속성 컨텍스트의 이점

영속성 컨텍스트가 제공하는 핵심 기능 5가지다.

  • 1차 캐시
  • 동일성(identity) 보장
  • 트랜잭션을 지원하는 쓰기 지연
  • 변경 감지(Dirty Checking)
  • 지연 로딩(Lazy Loading)

# 1차 캐시와 동일성 보장

em.find()로 조회 시 1차 캐시에 있으면 DB를 거치지 않고 캐시에서 바로 반환한다. 없으면 DB 조회 → 1차 캐시에 저장 → 반환한다.

Member a = em.find(Member.class, 1L);  // DB 조회 → 1차 캐시 저장
Member b = em.find(Member.class, 1L);  // 1차 캐시에서 조회
System.out.println(a == b);  // true (동일성 보장)

같은 트랜잭션 안에서 같은 PK로 조회하면 항상 같은 인스턴스를 반환한다. 이를 통해 애플리케이션 차원에서 REPEATABLE READ 격리 수준을 제공한다.

실무 주의

1차 캐시는 하나의 트랜잭션(EntityManager) 범위 안에서만 유효하다. 트랜잭션이 끝나면 사라지므로, 흔히 말하는 "캐시"(전역 캐시)와는 다르다. 성능 최적화 효과는 크지 않고, 동일성 보장이 더 본질적인 이점이다.

# 쓰기 지연 (Transactional Write-Behind)

persist()를 호출하면 INSERT SQL을 바로 보내지 않고 쓰기 지연 SQL 저장소에 모아둔다. 트랜잭션 커밋 시점에 한꺼번에 flush 하여 DB로 전송한다. 쓰기 지연 SQL 저장소(write-behind SQL store)는 영속성 컨텍스트 안에 있는, DB로 보낼 SQL을 모아두는 버퍼이다.

transaction.begin();

em.persist(memberA);  // 쓰기 지연 SQL 저장소에 INSERT A 쌓임
em.persist(memberB);  // INSERT B 쌓임
// 아직 DB에 SQL 전송 안 됨

transaction.commit();  // 이 시점에 INSERT A, B 한 번에 전송

실무 활용

쓰기 지연 덕분에 JDBC batch insert 같은 최적화가 가능하다 (hibernate.jdbc.batch_size 설정).

# 변경 감지 (Dirty Checking)

영속 엔티티의 값을 변경하면 update() 호출 없이 자동으로 UPDATE가 나간다. 동작 원리는 1차 캐시에 저장된 스냅샷(최초 조회 시점의 값)과 현재 엔티티를 비교해, 차이가 있으면 UPDATE SQL을 생성한다.

transaction.begin();

Member memberA = em.find(Member.class, 1L);  // 영속 엔티티 조회
memberA.setUsername("hi");   // 값만 변경
// em.update(memberA) 같은 코드 필요 없음

transaction.commit();  // flush 시점에 변경 감지 → UPDATE SQL 자동 생성

실무에서 매우 중요

이 메커니즘 때문에 준영속 엔티티는 변경 감지가 동작하지 않는다. 영속 상태가 아닌 엔티티의 값을 바꿔도 DB에 반영되지 않으니, 수정 로직은 반드시 영속 상태에서 처리해야 한다. (트랜잭션 안에서 조회 → 값 변경 패턴)

# 플러시 (flush)

플러시는 영속성 컨텍스트의 변경 내용을 DB에 동기화하는 작업이다. 발생 시 변경 감지 → 수정 엔티티를 쓰기 지연 저장소에 등록 → 저장소의 쿼리(등록/수정/삭제)를 DB로 전송한다.

플러시가 호출되는 시점:

  • em.flush() 직접 호출
  • 트랜잭션 커밋 시 자동 호출
  • JPQL 쿼리 실행 시 자동 호출

JPQL 실행 시 자동 플러시되는 이유

persist()로 영속화만 하고 아직 DB에 안 들어간 상태에서 JPQL로 조회하면, 방금 저장한 데이터가 누락될 수 있다. 그래서 JPQL 실행 직전 flush를 자동 호출해 DB와 동기화한 뒤 쿼리를 실행한다.

플러시에 대한 핵심 오해 정리:

  • 플러시는 1차 캐시를 비우지 않는다 (clear와 다름). 동기화만 한다. (영속성 컨텍스트를 비우지 않는다)
  • 플러시 모드 옵션: AUTO(커밋·쿼리 시점, 기본값) / COMMIT(커밋 시점만). 실무에서는 기본값 AUTO를 거의 그대로 쓴다.

# 준영속 상태

영속 상태의 엔티티가 영속성 컨텍스트에서 분리(detached) 된 상태. 1차 캐시, 변경 감지 등 영속성 컨텍스트가 제공하는 기능을 사용할 수 없다.

준영속으로 만드는 방법:

  • em.detach(entity): 특정 엔티티 하나만 준영속으로 전환
  • em.clear(): 영속성 컨텍스트를 완전히 초기화 (전부 준영속)
  • em.close(): 영속성 컨텍스트 종료

# 엔티티 매핑

JPA에서 객체와 테이블을 매핑하는 핵심 어노테이션들을 정리한다.

# 객체와 테이블 매핑 (@Entity, @Table)

@Entity가 붙은 클래스는 JPA가 관리하며, 테이블과 매핑할 클래스에는 필수다.

@Entity 사용 시 주의사항

  • 기본 생성자 필수 (파라미터 없는 public 또는 protected 생성자)
  • final 클래스, enum, interface, inner 클래스 사용 불가
  • 저장할 필드에 final 사용 불가

@Table은 엔티티와 매핑할 테이블을 지정한다. 주요 속성은 name(매핑할 테이블 이름), uniqueConstraints(유니크 제약 조건)다.

# 데이터베이스 스키마 자동 생성

DDL을 애플리케이션 실행 시점에 자동 생성하며, 데이터베이스 방언을 활용해 적절한 DDL을 만들어준다.

hibernate.hbm2ddl.auto 옵션:

옵션 설명
create 기존 테이블 삭제 후 다시 생성 (DROP + CREATE)
create-drop create와 같으나 종료 시점에 테이블 DROP
update 변경분만 반영
validate 엔티티와 테이블이 정상 매핑되었는지만 확인
none 사용하지 않음

운영 환경 절대 금지

운영 장비에는 create, create-drop, update절대 사용하면 안 된다.

  • 개발 초기: create 또는 update
  • 테스트 서버: update 또는 validate
  • 스테이징/운영: validate 또는 none

DDL 생성 기능

@Column(nullable = false, length = 10) 같은 제약 조건은 DDL 자동 생성 시에만 사용되고, JPA 실행 로직에는 영향을 주지 않는다.

# 필드와 컬럼 매핑

어노테이션 설명
@Column 컬럼 매핑
@Enumerated enum 타입 매핑
@Lob BLOB, CLOB 매핑
@Transient 컬럼 매핑 제외 (DB 저장/조회 X, 메모리 임시 보관용)
@Temporal 날짜 타입 매핑 (LocalDate/LocalDateTime은 생략 가능)

@Column 주요 속성:

속성 설명 기본값
name 매핑할 컬럼 이름 필드 이름
insertable, updatable 등록/변경 가능 여부 TRUE
nullable null 허용 여부 (false면 not null 제약 조건) -
unique 한 컬럼에 간단히 유니크 제약 조건 -
length 문자 길이 제약 (String 전용) 255
columnDefinition 컬럼 정보 직접 지정 -

insertable / updatable에 대한 정확한 이해

  • insertable = false로 지정하면, 컬럼에 값을 채워 persist하더라도 객체에는 그 값이 유지되지만 INSERT SQL 생성 시점에 해당 컬럼이 제외된다. 즉 나머지 컬럼만으로 INSERT가 수행되고, 해당 컬럼은 DB의 기본값(또는 NULL)으로 저장된다.
  • updatable = false는 런타임에 값이 변경되어 JPA가 dirty checking으로 변경을 인지하더라도, 해당 컬럼을 UPDATE SQL에서 제외하는 옵션이다. 같은 엔티티의 다른 컬럼이 변경되면 UPDATE 쿼리 자체는 수행되며, 그 안에서 해당 컬럼만 빠진다.
    • 컴파일 레벨에서 값 수정을 막을 방법은 없고, 정적 팩토리 메서드 등으로 생성자에서만 값 할당이 가능하게 강제하는 등 설계 패턴으로 불필요한 런타임 값 수정을 막을 수 있다.

@Enumerated 필수 주의

EnumType.ORDINAL(기본값) 사용 금지. enum 순서를 저장하므로 enum이 추가되면 데이터가 꼬인다. 반드시 @Enumerated(EnumType.STRING)을 사용한다.

ORDINAL로 저장 시 DB에 저장되는 값은 0,1,2와 같이 enum의 순서를 나타내는 숫자값이다. 중간에 값이 끼어들어오거나 수정이 되는 경우 DB와 값 매핑이 잘못되어 애플리케이션 레벨에서 예기치 못한 동작을 하게 될 수 있다.

# 기본 키 매핑

@Id(직접 할당)와 @GeneratedValue(자동 생성)를 사용한다.

자동 생성 전략:

전략 설명 주 사용 DB
IDENTITY 기본 키 생성을 DB에 위임 MySQL, PostgreSQL
SEQUENCE DB 시퀀스 오브젝트 사용 (@SequenceGenerator 필요) Oracle, PostgreSQL, H2
TABLE 키 생성 전용 테이블 사용 (@TableGenerator 필요) 모든 DB
AUTO 방언에 따라 자동 지정 (기본값) -

DB 시퀀스 오브젝트는 유일한 숫자 값을 순서대로 자동으로 만들어주는, 데이터베이스 안의 독립된 객체이다. 여러 사용자가 동시에 INSERT를 해도 PK가 겹치지 않게 하기 위한 목적으로 사용한다.

-- 시퀀스 생성
CREATE SEQUENCE member_seq
    START WITH 1      -- 1부터 시작
    INCREMENT BY 1;   -- 호출할 때마다 1씩 증가

-- 다음 값 꺼내기
SELECT member_seq.NEXTVAL FROM dual;  -- 1
SELECT member_seq.NEXTVAL FROM dual;  -- 2
SELECT member_seq.NEXTVAL FROM dual;  -- 3

IDENTITY 전략의 특징

AUTO_INCREMENT는 INSERT 후에야 ID를 알 수 있으므로, IDENTITY 전략은 em.persist() 시점에 즉시 INSERT SQL을 실행하고 DB에서 식별자를 조회한다. (커밋 시점 INSERT가 아님)

SEQUENCE 전략의 allocationSize

@SequenceGeneratorallocationSize 기본값은 50이다. 미리 시퀀스 값을 메모리에 확보해 성능을 최적화하는 용도다. DB 시퀀스 증가값이 1로 설정되어 있다면 반드시 1로 맞춰야 한다.

AUTO_INCREMENT(IDENTITY) 전략은 row-by-row로, INSERT를 실제로 실행한 이후에야 생성된 ID 값을 알 수 있다. 반면 SEQUENCE 전략은 시퀀스 오브젝트에서 가용한 ID 대역을 미리 반환받는 구조다.

받아온 ID 대역을 전부 사용하지 않더라도, 안 쓰인 번호에 대해서는 INSERT 자체가 일어나지 않으므로 DB에 불필요한 데이터(빈 row, null row)가 저장되지는 않는다. 단지 PK 번호에 gap이 생길 뿐이다. 시퀀스는 한 번 발급한 번호를 재발급하지 않으므로, 버려진 대역이 다른 트랜잭션과 충돌하지도 않는다.

ID를 미리 확보하는 allocationSize는 시퀀스 조회(NEXTVAL) 왕복 횟수를 줄이기 위한 것이다. 또한 SEQUENCE 전략은 INSERT 전에 ID를 알 수 있어, hibernate.jdbc.batch_size 설정과 함께 batch insert를 활용할 수 있다는 이점도 있다. (IDENTITY는 row-by-row라 batch insert가 불가능하다.)

@Entity
@SequenceGenerator(
    name = "MEMBER_SEQ_GENERATOR",
    sequenceName = "MEMBER_SEQ",
    initialValue = 1, allocationSize = 1)
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE,
        generator = "MEMBER_SEQ_GENERATOR")
    private Long id;
}

권장 식별자 전략

기본 키는 null이 아니고, 유일하며, 변하면 안 된다. 이 조건을 미래까지 만족하는 자연키(주민번호 등)는 찾기 어려우므로 대리키를 사용한다.
권장: Long형 + 대체키 + 키 생성 전략

# 실전 예제 - 데이터 중심 설계의 문제점

요구사항(회원-주문-상품)에 따라 테이블의 외래키를 객체 필드에 그대로 가져오는 방식(OrdermemberId: Long을 가짐)은 문제가 있다.

데이터 중심 설계의 한계

  • 객체 설계를 테이블 설계에 억지로 맞춘 방식
  • 테이블의 외래키를 객체에 그대로 가져옴 (memberId, itemId)
  • 객체 그래프 탐색이 불가능 (order.getMember() 불가)
  • 참조가 없으므로 UML도 잘못됨

→ 이 문제는 다음 챕터의 연관관계 매핑(@ManyToOne, @JoinColumn)으로 해결한다.

# 연관관계 매핑 기초

회원(Member)과 팀(Team)의 다대일(N:1) 관계를 예시로, 객체의 참조와 테이블의 외래 키를 어떻게 매핑하는지 다룬다.

# 연관관계가 필요한 이유

테이블에 맞춰 외래 키를 그대로 필드로 들고 있으면(teamId) 객체지향적인 협력 관계를 만들 수 없다.

// 테이블 중심 모델링 - 안티패턴
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    @Column(name = "TEAM_ID")
    private Long teamId; // FK를 그대로 들고 있음
}

// 저장 시 식별자를 직접 다룸
member.setTeamId(team.getId());

// 조회 시 연관관계가 없어 객체 그래프 탐색 불가
Member findMember = em.find(Member.class, member.getId());
Team findTeam = em.find(Team.class, team.getId()); // 별도 조회 필요

핵심 차이

  • 테이블: 외래 키 조인으로 연관 테이블을 찾는다
  • 객체: 참조(member.getTeam())로 연관 객체를 찾는다

# 단방향 연관관계

객체의 참조와 테이블의 외래 키를 @ManyToOne + @JoinColumn으로 매핑한다.

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    @Column(name = "USERNAME")
    private String name;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}
// 저장 - 참조를 직접 설정
Member member = new Member();
member.setName("member1");
member.setTeam(team);
em.persist(member);

// 조회 - 객체 그래프 탐색
Member findMember = em.find(Member.class, member.getId());
Team findTeam = findMember.getTeam();

// 수정 - 참조만 바꾸면 됨
member.setTeam(teamB);

# 양방향 연관관계와 연관관계의 주인

Member는 단방향과 동일하고, Team에 컬렉션을 추가하면 양방향이 된다.

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}
// 역방향 조회 가능
Team findTeam = em.find(Team.class, team.getId());
int memberSize = findTeam.getMembers().size();

객체 vs 테이블 관계 수

  • 객체 연관관계 = 2개 (회원→팀, 팀→회원, 서로 다른 단방향 2개)
  • 테이블 연관관계 = 1개 (FK 하나로 양방향 모두 처리)

# 연관관계의 주인 규칙

객체는 단방향 참조가 2개라, 둘 중 하나만 외래 키를 관리하도록 정해야 한다. 이것이 연관관계의 주인이다.

  • 연관관계의 주인만 외래 키를 등록·수정 가능
  • 주인이 아닌 쪽은 읽기 전용
  • 주인은 mappedBy 사용 X
  • 주인이 아니면 mappedBy로 주인을 지정

주인 선정 기준

외래 키가 있는 곳을 주인으로 정한다. 여기서는 Member.team이 주인.
비즈니스 로직 기준으로 정하면 안 된다. FK 위치 기준이다.

# 양방향 매핑 시 흔한 실수

주인이 아닌 쪽(Team.members)에만 값을 넣으면 FK가 null로 저장된다.

// 잘못된 코드 - 역방향만 설정
team.getMembers().add(member);
em.persist(member);
// 결과: TEAM_ID = null
// 올바른 코드 - 주인에 값 설정
team.getMembers().add(member);  // 순수 객체 관계 고려
member.setTeam(team);           // 연관관계의 주인에 값 설정 (필수)
em.persist(member);
// 결과: TEAM_ID = 정상 저장

양방향 주의사항

  • 순수 객체 상태를 고려해 항상 양쪽 모두 값을 설정한다
    • members.getTeam, team.getMembers 두 메서드 호출 모두 정상적으로 결과가 반환되어야 한다.
    • 양방향 연관관계에서 한 객체에 대해서만 구현하는 경우 한쪽 호출에서 null이 반환된다.
  • 연관관계 편의 메서드를 만들어 한 번에 양쪽을 설정한다
  • 무한 루프 조심: toString(), Lombok, JSON 직렬화 라이브러리에서 양방향 참조가 서로를 호출
@Entity
public class Member {
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    // 연관관계 편의 메서드
    public void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);  // 반대편도 자동으로
    }
}
  • 위와 같이 연관관계 매핑 메서드 호출 시 양방향 모두 매핑을 처리해주도록 코드를 작성하는 것이 좋다.
class Member {
    Team team;
    public String toString() {
        return "Member{team=" + team + "}";  // team.toString() 호출
    }
}
class Team {
    List<Member> members;
    public String toString() {
        return "Team{members=" + members + "}"; // 각 member.toString() 호출
    }
}
  • 위의 경우 team.toString -> members.toString -> .. 구조로 무한루프에 빠지게 된다.
  • 객체 자체를 리턴하지 않고 DTO를 리턴하도록 개선해야 한다.

# 양방향 매핑 설계 정리

  • 단방향 매핑만으로도 연관관계 매핑은 이미 완료된 상태다
  • 양방향은 역방향 조회(객체 그래프 탐색) 기능이 추가된 것뿐
  • 양방향 추가는 테이블에 영향을 주지 않으므로, 단방향으로 설계하고 필요할 때 양방향을 추가한다
  • JPQL에서 역방향 탐색이 필요한 경우가 많다

# 실전 예제 - 연관관계 매핑

기존 식별자 필드를 참조로 변경한다. (Member 1 : N Order, Order 1 : N OrderItem, OrderItem N : 1 Item)

@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    @OneToMany(mappedBy = "order")
    private List<OrderItem> orderItems = new ArrayList<>();
}

@Entity
public class OrderItem {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "ITEM_ID")
    private Item item;

    @ManyToOne
    @JoinColumn(name = "ORDER_ID")
    private Order order;
}

# 다양한 연관관계 매핑

다중성(N:1, 1:N, 1:1, N:M) × 방향(단방향/양방향)별 매핑 방법과, 각 상황에서 무엇을 실무에서 쓰고 무엇을 피해야 하는지를 다룬다.

# 연관관계 매핑 3가지 고려사항

  • 다중성: @ManyToOne / @OneToMany / @OneToOne / @ManyToMany
  • 단방향 / 양방향: 참조용 필드가 한쪽만 있으면 단방향, 양쪽 다 있으면 양방향
  • 연관관계의 주인: 외래 키를 관리하는 참조. 반대편은 조회만 가능

다중성 판단법

헷갈리면 DB 입장에서 본다. "회원 입장에서 팀은 하나(1), 팀 입장에서 회원은 여럿(N)" → 회원이 N, 팀이 1 → 회원 기준 @ManyToOne.

# 다대일 [N:1] — 가장 많이 쓰는 관계

실무에서 압도적으로 많이 쓰는 기본 형태. FK가 있는 N쪽(Member)이 항상 주인이다.

// 단방향: Member만 Team을 참조
@Entity
public class Member {
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team; // FK 주인
}
// 양방향: Team에 읽기 전용 컬렉션 추가
@Entity
public class Team {
    @OneToMany(mappedBy = "team") // 주인이 아님 → mappedBy
    private List<Member> members = new ArrayList<>();
}

다대일 양방향 핵심

  • FK가 있는 쪽(Member.team)이 주인
  • 양방향은 단방향에 mappedBy 컬렉션만 추가하면 됨 (테이블 변화 없음)

# 일대다 [1:N] — 권장하지 않음

1(Team)쪽이 주인이 되는 특이한 구조. 1쪽 엔티티가 관리하는 FK가 다른 테이블(Member)에 있어서 문제가 생긴다.

@Entity
public class Team {
    @OneToMany
    @JoinColumn(name = "TEAM_ID") // 이걸 빼면 조인 테이블이 생성됨
    private List<Member> members = new ArrayList<>();
}

일대다 단방향의 단점

관리하는 FK가 다른 테이블(MEMBER)에 있어서, Member를 저장한 뒤 연관관계를 맺으려고 추가 UPDATE SQL이 한 번 더 나간다. 어느 테이블이 손대지는지도 직관적이지 않다.

결론: 다대일 양방향을 써라

  • 일대다 단방향 → 추가 UPDATE 발생, 직관성 떨어짐
  • 일대다 양방향 → 공식 스펙에 없음. @JoinColumn(insertable=false, updatable=false)로 읽기 전용 필드를 억지로 끼워 흉내내는 편법
  • 둘 다 쓰지 말고 다대일 양방향으로 해결한다

# 일대일 [1:1]

반대도 1:1. 두 테이블 중 어디에 FK를 둘지 선택할 수 있고, FK에는 반드시 유니크(UNI) 제약이 붙는다.

// 주 테이블(Member)에 FK — 다대일 단방향과 매핑 방식 동일
@Entity
public class Member {
    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker; // FK 주인
}

// 양방향이면 반대편에 mappedBy
@Entity
public class Locker {
    @OneToOne(mappedBy = "locker")
    private Member member;
}

주 테이블 vs 대상 테이블 FK

주 테이블에 FK (Member에 LOCKER_ID)

  • 장점: 주 테이블만 조회해도 연관 데이터 유무 확인 가능
  • 단점: 값 없으면 FK가 null 허용
  • 객체지향 개발자 선호, JPA 매핑 편리

대상 테이블에 FK (Locker에 MEMBER_ID)

  • 장점: 1:1 → 1:N으로 바뀌어도 테이블 구조 유지
  • 단점: 지연 로딩으로 설정해도 항상 즉시 로딩됨 (프록시 한계)
    • 주 테이블(Member) row만 봐서는 대상 테이블(Locker)이 자신을 참조하고 있는지 여부를 알 수 없다. FK가 대상 테이블에 있기 때문이다. 따라서 연관 객체의 존재 여부와 PK를 확인하기 위해 반드시 대상 테이블을 조회해야 하고, 그 결과 프록시를 만들 수 없어 즉시 로딩처럼 동작한다.
    • 프록시는 PK만 들고 있다가, 값을 쓸 때 진짜 데이터를 채우는 가짜 객체이다. 그래서 PK를 알 수 있으면 지연 로딩이 되고, PK조차 모르면 프록시를 못 만들어 즉시 로딩이 된다.
    • member.getLocker() 호출 시 PK를 가진 프록시를 반환하고, 실제 필드 접근 시점에 조회 쿼리가 나가 프록시가 초기화된다.
    • member.getLocker() 시, Member row에는 Locker의 PK 정보가 없어 프록시를 만들 재료가 없다. 그래서 존재 여부와 PK를 알아내려 즉시 조회를 수행한다.
  • 전통적 DBA 선호

대상 테이블 FK의 단방향

대상 테이블에 FK를 두는 단방향은 JPA가 지원하지 않는다. 양방향만 가능하다.

# 다대다 [N:M] — 실무 사용 금지

객체는 컬렉션으로 N:M이 가능하지만, 관계형 DB는 정규화된 테이블 2개로 N:M을 표현할 수 없다. @ManyToMany + @JoinTable로 연결 테이블이 자동 생성되긴 하나, 실무에서는 쓰지 않는다.

@ManyToMany를 쓰면 안 되는 이유

연결 테이블이 단순 연결로 끝나지 않는다. 주문수량·주문날짜 같은 추가 컬럼이 거의 항상 필요한데, @ManyToMany는 연결 테이블에 필드를 추가할 수 없다. 게다가 쿼리가 중간 테이블을 숨겨버려 예측이 어렵다.

해결책은 연결 테이블을 엔티티로 승격하는 것이다.

// @ManyToMany 대신 연결 엔티티를 두고 @OneToMany + @ManyToOne 으로 분해
@Entity
public class OrderItem { // 승격된 연결 엔티티
    @ManyToOne
    @JoinColumn(name = "ORDER_ID")
    private Order order;

    @ManyToOne
    @JoinColumn(name = "ITEM_ID")
    private Item item;

    private int orderPrice; // 연결 테이블에 자유롭게 컬럼 추가 가능
    private int count;
}

이렇게 하면 N:M이 Order (1:N) OrderItem (N:1) Item 형태로 풀린다. 연결 엔티티는 별도 PK(ORDER_ITEM_ID)를 두는 방식을 권장한다.

# 주요 어노테이션 속성

@JoinColumn (FK 매핑):

  • name: 매핑할 FK 컬럼명
  • referencedColumnName: FK가 참조하는 대상 테이블 컬럼명 (기본은 대상 PK)
  • foreignKey: DDL 생성 시 FK 제약조건 직접 지정

@ManyToOne 주요 속성:

  • optional = false: 연관 엔티티가 항상 있어야 함
  • fetch: 기본값 EAGER (실무에선 LAZY로 변경 권장)
  • cascade: 영속성 전이

@OneToMany 주요 속성:

  • mappedBy: 연관관계 주인 필드 지정
  • fetch: 기본값 LAZY
  • cascade: 영속성 전이

fetch 기본값 차이

@ManyToOne은 기본 EAGER, @OneToMany는 기본 LAZY. ToOne 계열의 EAGER는 N+1 문제의 주범이라 실무에선 전부 LAZY로 바꾼다. (프록시·페치 전략은 이후 강의에서 다룸)

# 고급 매핑 - 상속 관계 매핑

# 상속관계 매핑 개요

관계형 DB에는 상속 관계가 없다. 대신 슈퍼타입/서브타입 모델링 기법이 객체 상속과 유사하며, 이를 매핑하는 것이 상속관계 매핑이다.

물리 모델 구현 방법은 3가지다.

  • 조인 전략 (JOINED): 각각을 테이블로 변환
  • 단일 테이블 전략 (SINGLE_TABLE): 통합 테이블로 변환
  • 구현 클래스마다 테이블 전략 (TABLE_PER_CLASS): 서브타입 테이블로 변환

주요 어노테이션:

@Inheritance(strategy = InheritanceType.JOINED) // 또는 SINGLE_TABLE, TABLE_PER_CLASS
@DiscriminatorColumn(name = "DTYPE") // 부모에 선언, 자식 구분 컬럼
@DiscriminatorValue("XXX")           // 자식에 선언, 구분 값

# 조인 전략 (JOINED)

부모 테이블(ITEM)과 자식 테이블(ALBUM, MOVIE, BOOK)을 각각 만들고, 자식은 부모 PK를 PK이자 FK로 가져간다. 자식 테이블은 ITEM_ID (PK, FK) + 자기 고유 컬럼만 가진다. 자식 테이블 자체적인 대리키를 사용하지 않고, 부모 테이블의 PK를 그대로 자신의 PK로 사용한다.

  • 장점: 테이블 정규화, FK 참조 무결성 제약조건 활용 가능, 저장공간 효율적
  • 단점: 조회 시 조인이 많아 성능 저하, 조회 쿼리 복잡, 저장 시 INSERT SQL 2번 호출

실무 기본 선택

JOINED가 정석이고 가장 무난하다. 정규화와 데이터 무결성이 중요하거나, 비즈니스가 복잡해질 여지가 있으면 JOINED를 기본으로 선택한다.

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class Item {
    @Id @GeneratedValue
    private Long id;   // 식별자는 여기 한 번만
    private String name;
}

@Entity
public class Album extends Item {
    private String artist;  // @Id 없음. id는 부모 걸 그대로 씀
}

# 단일 테이블 전략 (SINGLE_TABLE)

ITEM 테이블 하나에 모든 자식의 컬럼(ARTIST, DIRECTOR, ACTOR, AUTHOR, ISBN...)을 다 넣고 DTYPE으로 구분한다.

  • 장점: 조인 불필요 → 조회 성능 빠름, 쿼리 단순
  • 단점: 자식이 매핑한 컬럼은 모두 nullable, 테이블이 커지면 상황에 따라 오히려 조회가 느려질 수 있음

DTYPE 자동 생성

SINGLE_TABLE은 @DiscriminatorColumn을 명시하지 않아도 DTYPE 컬럼이 자동 생성된다. (JOINED는 명시해야 생성)

성능 단순한 경우 선택

테이블이 작고 단순하며 확장 가능성이 낮으면 SINGLE_TABLE이 가장 빠르고 편하다. 단, 모든 자식 컬럼이 null 허용이 된다는 점이 데이터 무결성 측면의 트레이드오프다.

# 구현 클래스마다 테이블 전략 (TABLE_PER_CLASS)

부모 테이블 없이 자식 테이블(ALBUM, MOVIE, BOOK)마다 부모 컬럼까지 모두 중복해서 가진다.

  • 장점: 서브타입을 명확히 구분 처리, not null 제약조건 사용 가능
  • 단점: 여러 자식을 함께 조회 시 UNION SQL 필요로 성능 저하, 자식 통합 쿼리 어려움

사용 금지

이 전략은 DB 설계자와 ORM 전문가 둘 다 추천하지 않는다. 부모 타입으로 조회할 때 모든 자식 테이블을 UNION 해야 하고, 부모 컬럼 변경 시 모든 자식 테이블에 영향이 간다. 실무에서 선택하지 않는다.

# @MappedSuperclass

공통 매핑 정보(id, name, 등록일, 수정일 등)를 모아서 자식 엔티티에 매핑 정보만 제공하는 용도다. 상속관계 매핑과는 무관하다.

@MappedSuperclass
public abstract class BaseEntity {
    private LocalDateTime createdDate;
    private LocalDateTime lastModifiedDate;
}

@Entity
public class Member extends BaseEntity { ... }

핵심 특징:

  • 엔티티가 아니고, 테이블과 매핑되지 않는다
  • 조회·검색 불가 (em.find(BaseEntity) 불가)
  • 부모 클래스를 상속받는 자식에게 매핑 정보만 물려준다
  • 직접 생성할 일이 없으므로 추상 클래스 권장

실무 활용

주로 등록일/수정일/등록자/수정자 같은 전체 엔티티 공통 정보를 모을 때 쓴다. 실무에서는 JPA Auditing(@CreatedDate, @LastModifiedDate)과 함께 BaseEntity로 묶는 패턴이 매우 흔하다.

상속 가능 대상

@Entity 클래스는 엔티티이거나 @MappedSuperclass로 지정한 클래스만 상속할 수 있다.

Embeddable vs MappedSuperClass

@MappedSuperclass는 상속 관계라서 자식은 하나의 부모만 가질 수 있다(Java 단일 상속). 주로 등록일/수정일처럼 모든 엔티티에 가로로 깔리는 공통 메타 정보에 사용한다.

임베디드 타입은 포함 관계라서 한 엔티티가 여러 개를 가질 수 있고, 의미적으로 묶이는 값들(주소: city+street+zipcode, 기간: 시작일+종료일)을 하나의 객체로 응집시킬 때 사용한다.

# 실전 예제 적용

요구사항: 상품 종류는 음반/도서/영화이며 추후 확장 가능, 모든 데이터는 등록일·수정일 필수.

적용 방향:

  • Item(부모) ← Album / Book / Movie(자식)을 상속관계 매핑으로 처리
  • 등록일·수정일은 BaseEntity(@MappedSuperclass)로 분리해 전체 엔티티에 공통 적용
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn // DTYPE
public abstract class Item extends BaseEntity {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private int price;
    private int stockQuantity;
}

@Entity
@DiscriminatorValue("A")
public class Album extends Item {
    private String artist;
}

# 프록시와 연관관계 관리

# 프록시 기초

프록시란, 진짜 엔티티인 척하면서, 실제로 쓰일 때까지 DB 조회를 미뤄두는 가짜 대역 객체를 말한다.

em.find() vs em.getReference()의 차이가 핵심이다.

  • em.find(): DB를 조회해서 실제 엔티티 반환
  • em.getReference(): DB 조회를 미루는 가짜(프록시) 엔티티 반환

프록시 객체는 실제 클래스를 상속받아 만들어지고 겉모양이 같다. 내부에 실제 엔티티의 참조(target)를 들고 있다가, 처음 사용하는 시점에 영속성 컨텍스트에 초기화를 요청해서 실제 엔티티를 조회하고, 이후 호출을 실제 엔티티에 위임(delegate)한다.

프록시는 원본 타입을 상속하기 때문에 원본 타입의 속성들을 메모리 구조상 모두 보유한다. 다만 이 슬롯들은 기본값/null로 비어 있고 사용되지 않으므로, 프록시의 필드에 직접 접근하면 null 또는 기본값만 반환된다. 실제 값은 getter를 통해 내부 target(실제 엔티티)에 위임해야 얻을 수 있다.

Hibernate 구현 기준으로 상속 때문에 사용되지 않는 빈 슬롯이 프록시에 딸려오지만, 이는 타입 호환을 얻기 위한 트레이드오프이며 이득이 더 크다. 프록시는 런타임에 부모 타입으로 다형성을 활용할 수 있어, 연관된 다른 엔티티의 전체 데이터를 미리 보유하지 않고도 그 자리(필드)에 들어갈 수 있다.

Member가 Team을 참조할 때, Member를 조회하는 시점에 Team의 모든 데이터를 즉시 가져올 필요가 없다. team 필드에는 프록시만 채워두고, 실제로 team.getName() 등으로 값을 사용하는 시점에 비로소 SELECT가 발생해 실제 데이터가 조회·초기화된다. 이것이 LAZY 로딩이며, 단 초기화는 영속성 컨텍스트가 살아있는 동안(트랜잭션 종료 전)에 이루어져야 한다. 준영속 상태에서 초기화를 시도하면 LazyInitializationException이 발생한다.

Member member = em.getReference(Member.class, "id1");
member.getName(); // 이 시점에 DB 조회 발생 → 프록시 초기화

# 프록시 특징 (실무 주의점)

  • 프록시는 처음 사용할 때 한 번만 초기화된다.
  • 초기화돼도 프록시가 실제 엔티티로 바뀌는 게 아니다. 프록시를 통해 실제 엔티티에 접근할 수 있게 될 뿐이다.
  • 영속성 컨텍스트에 이미 엔티티가 있으면 getReference()를 호출해도 실제 엔티티가 반환된다. (반대로 이미 프록시가 있으면 find()해도 프록시가 나옴 → JPA는 한 영속성 컨텍스트 안에서 == 동일성을 보장하기 때문)
    • em.getReference를 의미한다.

em.find / em.reference 호출 순서에 따른 차이

find와 getReference 동작의 차이는 아래와 같다.

em.find(Member.class, 1L)
  ├─ 1차 캐시 확인 → 없으면
  └─ 즉시 DB SELECT → 실제 엔티티 생성·반환

em.getReference(Member.class, 1L)
  ├─ 1차 캐시 확인 → 없으면
  └─ SELECT 안 함 → 프록시 생성·반환

프록시가 있을때 find를 호출하면 프록시가 반환되고, 엔티티가 있을때 getReference를 호출하면 엔티티가 반환된다.

1. em.getReference(Member.class, 1L)
   → 프록시 생성, target = null, SELECT 안 함, 캐시에 등록

2. em.find(Member.class, 1L)
   → 캐시에 id=1 객체(프록시) 있음 확인
   → 동일성 보장 위해 새 객체 안 만듦
   → 단, find는 실제 데이터를 줘야 하므로 이 시점에 SELECT 실행
   → 프록시의 target에 실제 Member 데이터 채움 (= 프록시 초기화)

3. 반환되는 건 프록시 껍데기 그대로,
   다만 target이 실제 엔티티로 채워진 "초기화된 프록시"

반대로 em.find를 먼저 호출하는 경우 아래와 같이 동작한다.

1. em.find(Member.class, 1L)
   → 캐시에 없음 → SELECT 실행 → 실제 엔티티 생성, 캐시에 등록

2. em.getReference(Member.class, 1L)
   → 캐시에 id=1 실제 엔티티 이미 있음
   → 프록시를 만들 이유가 없음 → 있던 실제 엔티티 그대로 반환

타입 비교 주의

프록시는 원본 엔티티를 상속한 별도 클래스다. 따라서 타입 비교 시 ==는 실패한다. instanceof를 사용해야 한다.

준영속 상태 초기화 예외

영속성 컨텍스트의 도움을 받을 수 없는 준영속(detached) 상태에서 프록시를 초기화하려고 하면 Hibernate가 LazyInitializationException을 던진다. 트랜잭션/영속성 컨텍스트가 끝난 뒤(예: 컨트롤러·뷰 단) 지연 로딩 필드를 건드릴 때 가장 흔하게 터지는 실무 에러다.

// 트랜잭션 안
Member member = em.getReference(Member.class, 1L); // 프록시 받음 (아직 미초기화)
// ...트랜잭션 종료 → 영속성 컨텍스트 닫힘 → member는 준영속 상태가 됨...

// 컨트롤러/뷰 단 (트랜잭션 끝난 뒤)
member.getName();  // ← 여기서 초기화 시도
// 그런데 영속성 컨텍스트가 이미 닫혀서 DB 조회를 할 수가 없음
// → LazyInitializationException!

프록시 초기화란 비어 있던 프록시가 DB를 조회해서 내부 target에 실제 데이터를 채워 넣는 그 동작을 말한다. 준영속 상태의 미초기화 프록시 객체에 대해 getter를 호출만 해도 내부적으로는 DB를 조회하여 프록시를 초기화 해야한다는 트리거가 호출되어버린다.

준영속 프록시라도 getId()만 부르거나 그냥 참조로만 쓰면 예외가 안 난다. ID 식별자는 프록시도 보유하기 때문이다.

# 즉시 로딩과 지연 로딩

연관 엔티티를 언제 조회할지 결정하는 전략이다.

@ManyToOne(fetch = FetchType.LAZY)  // 지연 로딩 → team은 프록시
@JoinColumn(name = "TEAM_ID")
private Team team;
  • 지연 로딩(LAZY): 연관 엔티티를 프록시로 두고, 실제 사용하는 시점(team.getName())에 DB 조회
  • 즉시 로딩(EAGER): Member 조회 시 Team도 항상 함께 조회. JPA 구현체는 가능하면 조인으로 한 번에 가져온다.

실무 결론: 무조건 지연 로딩

  • 모든 연관관계에 지연 로딩(LAZY) 을 사용한다. 실무에서 즉시 로딩을 쓰지 않는다.
  • 즉시 로딩은 예상치 못한 SQL이 나가고, 특히 JPQL에서 N+1 문제를 일으킨다.
  • 함께 조회가 필요하면 즉시 로딩이 아니라 JPQL fetch join 또는 엔티티 그래프로 해결한다.

기본값 함정

@ManyToOne, @OneToOne은 기본이 즉시 로딩(EAGER) 이다. 반드시 명시적으로 fetch = FetchType.LAZY로 바꿔줘야 한다. 반면 @OneToMany, @ManyToMany는 기본이 지연 로딩이다.

기본값이 EAGER인 것에 대한 논쟁으로 Github Issue (opens new window)가 실제로 등록되어 있다. jakarta.persistence.defaultFetchType값을 설정 프로퍼티에 도입하자는 의견도 존재한다.

# 영속성 전이: CASCADE

부모 엔티티를 영속화할 때 연관된 자식 엔티티도 함께 영속화하는 편의 기능이다.

@OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
private List<Child> children = new ArrayList<>();
// parent만 persist 해도 child1, child2가 함께 저장됨

CASCADE 종류: ALL(모두), PERSIST(영속), REMOVE(삭제), MERGE(병합), REFRESH, DETACH

연관관계 매핑과 무관

CASCADE는 연관관계 매핑(mappedBy 등)과 아무 관련이 없다. 단지 영속화/삭제를 전파해주는 편의 기능일 뿐이다. 그리고 자식을 여러 부모가 공유한다면 함부로 쓰면 안 된다.

# 고아 객체 (orphanRemoval)

부모와 연관관계가 끊어진 자식 엔티티를 자동 삭제하는 기능이다.

@OneToMany(mappedBy = "parent", orphanRemoval = true)
private List<Child> children = new ArrayList<>();

parent.getChildren().remove(0); // 컬렉션에서 빼면
// → DELETE FROM CHILD WHERE ID = ?  자동 실행

단일 소유일 때만 사용

참조가 제거된 자식을 "아무도 안 쓰는 고아"로 보고 삭제하는 기능이라, 참조하는 곳이 하나뿐(개인 소유) 일 때만 써야 한다. 여러 곳에서 공유하는 엔티티에 켜면 다른 곳에서 쓰는 데이터가 삭제된다. @OneToOne, @OneToMany에서만 가능하다.

부모를 제거하면 자식도 함께 제거되며, 이는 CascadeType.REMOVE처럼 동작한다.

# CASCADE + 고아 객체 = 생명주기 위임

@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> children = new ArrayList<>();

두 옵션을 모두 켜면, 자식의 생명주기를 부모 엔티티를 통해 전부 관리할 수 있다. (자식 따로 em.persist() / em.remove() 할 필요 없음)

DDD Aggregate Root

이 조합은 도메인 주도 설계(DDD)의 Aggregate Root 를 구현할 때 유용하다. 자식 엔티티의 Repository를 따로 두지 않고 Root를 통해서만 자식 생명주기를 제어하는 패턴이다.

# 실전 예제 적용

  • 글로벌 페치 전략: 모든 연관관계를 지연 로딩으로. @ManyToOne, @OneToOne은 기본이 즉시 로딩이므로 전부 LAZY로 변경.
  • 영속성 전이: Order → Delivery, Order → OrderItemCascadeType.ALL 설정. Order가 Delivery와 OrderItem의 생명주기를 책임지는 구조(Order가 Aggregate Root).

# 값 타입

# JPA 데이터 타입: 엔티티 vs 값 타입

JPA의 데이터 타입은 크게 두 가지로 나뉜다.

  • 엔티티 타입: @Entity로 정의. 식별자(id) 가 있어 값이 변해도 추적 가능하고, 생명주기를 직접 관리하며, 공유할 수 있다.
  • 값 타입: int, String처럼 값으로만 쓰는 것. 식별자가 없어 변경 시 추적 불가하고, 생명주기를 엔티티에 의존한다.

값 타입은 다시 세 가지로 분류된다: 기본값 타입(int, Integer, String 등), 임베디드 타입(복합 값 타입), 컬렉션 값 타입.

판단 기준

식별자가 필요하고, 값을 지속적으로 추적·변경해야 한다면 그건 값 타입이 아니라 엔티티다. 헷갈려서 엔티티를 값 타입으로 만들면 안 된다. "정말 값일 때만" 값 타입으로 쓴다.

# 임베디드 타입 (복합 값 타입)

여러 기본값 타입을 묶어 새로운 값 타입을 직접 정의하는 것. 예를 들어 city/street/zipcodeAddress로, startDate/endDatePeriod로 묶는다.

@Embeddable
public class Address {
    private String city;
    private String street;
    private String zipcode;
    // 기본 생성자 필수
}

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    @Embedded
    private Address homeAddress;
    @Embedded
    private Period workPeriod;
}
  • @Embeddable: 값 타입을 정의하는 클래스에 선언
  • @Embedded: 값 타입을 사용하는 필드에 선언
  • 기본 생성자 필수

장점은 재사용과 높은 응집도, 그리고 Period.isWork()처럼 그 값 타입만의 의미 있는 메서드를 둘 수 있다는 점이다.

테이블은 그대로

임베디드 타입을 써도 매핑되는 테이블은 동일하다. Address로 묶든 안 묶든 MEMBER 테이블에 CITY/STREET/ZIPCODE 컬럼이 그대로 들어간다. 객체를 더 세밀하게 설계하는 것일 뿐 테이블 구조는 바뀌지 않는다. (잘 설계된 ORM은 테이블 수보다 클래스 수가 더 많다.)

# @AttributeOverride: 컬럼명 재정의

한 엔티티에서 같은 값 타입을 두 번 쓰면 컬럼명이 충돌한다. (예: homeAddress, companyAddress 둘 다 Address)

// @Embeddable이 적용된 두개의 address 속성값
@Embedded
private Address homeAddress;

@Embedded
@AttributeOverrides({
    @AttributeOverride(name = "city",
        column = @Column(name = "WORK_CITY")),
    @AttributeOverride(name = "street",
        column = @Column(name = "WORK_STREET"))
})
private Address companyAddress;

companyAddress 속성에 @AttributeOverrides 애노테이션을 적용하여 컬럼명이 충돌되지 않도록 해준다. column에 들어갈 컬럼명은 실제 DB 컬럼명과 일치해야 한다.

참고로 임베디드 타입의 값이 null이면 매핑된 컬럼은 모두 null이 된다.

# 값 타입 공유 참조의 위험과 불변 객체

값 타입을 여러 엔티티가 공유하면 부작용(side effect)이 생긴다. 회원1과 회원2가 같은 Address 인스턴스를 참조하는 상태에서 한쪽이 city를 바꾸면 다른 쪽도 같이 바뀐다.

// 위험: 같은 인스턴스 공유
member1.setAddress(address);
member2.setAddress(address);
address.setCity("New"); // member1, member2 둘 다 바뀜

근본 원인은 자바의 객체 타입 한계다. 기본 타입은 대입 시 값이 복사되지만, 객체 타입은 참조가 전달되므로 공유 참조를 막을 방법이 없다.

해법: 값 타입은 불변 객체로 설계

공유 참조 사고를 원천 차단하려면 값 타입을 불변 객체(immutable) 로 만든다. 생성자로만 값을 세팅하고 Setter를 만들지 않는다. 값을 바꾸고 싶으면 새 인스턴스를 만들어 통째로 교체한다. (Integer, String이 대표적 불변 객체) "불변이라는 작은 제약으로 부작용이라는 큰 재앙을 막는다."

@Embeddable
public class Address {
    private String city;
    protected Address() {} // JPA용 기본 생성자
    public Address(String city) { this.city = city; }
    public String getCity() { return city; } // getter만, setter 없음
}

# 값 타입의 비교: equals() 재정의

값 타입은 인스턴스가 달라도 내부 값이 같으면 같은 것으로 봐야 한다. 따라서 ==(동일성, 참조 비교)가 아니라 equals()(동등성, 값 비교)를 써야 한다.

Address a = new Address("서울시");
Address b = new Address("서울시");
a == b;        // false (참조가 다름)
a.equals(b);   // true 여야 함 → equals() 재정의 필요

equals/hashCode 재정의

값 타입은 모든 필드를 사용해 equals()hashCode()를 재정의해야 한다. IDE 자동 생성을 쓰되, getter 기반으로 생성하면 프록시 상황에서도 안전하다. hashCode도 같이 재정의해야 HashSet 등 컬렉션에서 정상 동작한다.

# 값 타입 컬렉션

값 타입을 하나 이상 저장할 때 쓴다. DB는 컬렉션을 한 테이블에 못 담으므로 별도 테이블이 필요하다.

@ElementCollection
@CollectionTable(name = "FAVORITE_FOOD",
    joinColumns = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FOOD_NAME")
private Set<String> favoriteFoods = new HashSet<>();

@ElementCollection
@CollectionTable(name = "ADDRESS",
    joinColumns = @JoinColumn(name = "MEMBER_ID"))
private List<Address> addressHistory = new ArrayList<>();

값 타입 컬렉션은 지연 로딩이 기본이고, 영속성 전이(Cascade) + 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다(주인 엔티티에 생명주기를 완전히 의존).

값 타입 컬렉션의 치명적 제약

값 타입은 식별자가 없어 값이 바뀌면 추적이 안 된다. 그래서 컬렉션에 변경이 생기면, 주인 엔티티와 연관된 모든 데이터를 DELETE한 뒤 현재 값 전체를 다시 INSERT한다. 데이터가 많으면 성능에 치명적이다. 또한 매핑 테이블은 모든 컬럼을 묶어 기본키로 구성해야 해서 null·중복 저장이 불가능하다.

실무 대안: 값 타입 컬렉션 대신 일대다 엔티티

실무에서는 값 타입 컬렉션 대신 일대다 관계용 엔티티를 별도로 만드는 것을 권장한다. 예: Address를 감싼 AddressEntity를 만들어 @OneToMany로 매핑하고, Cascade + orphanRemoval을 걸어 값 타입 컬렉션처럼 쓰되 식별자와 추적·수정이 가능하게 한다.

# 실전 예제 적용

  • Member, Delivery: 주소를 Address 값 타입(@Embeddable)으로 만들어 각각 @Embedded로 사용. Address는 불변 객체로 설계(Setter 없음).
  • Address는 식별자 없이 값으로만 쓰이고 소속 엔티티(Member/Delivery)의 생명주기에 의존하므로 값 타입이 적합한 전형적 사례다.

# 객체지향 쿼리 언어(JPQL)

JPA는 엔티티 객체를 중심으로 개발하지만, 검색 시 모든 데이터를 객체로 변환할 수 없어 검색 조건이 담긴 SQL이 필요하다. JPQL은 SQL을 추상화한 객체지향 쿼리 언어로, 테이블이 아닌 엔티티 객체를 대상으로 쿼리한다.

쿼리 방법 선택 (실무 기준)

  • JPQL: 기본. 동적이지 않은 단순 조회
  • QueryDSL: 실무 권장. 자바 코드로 작성, 컴파일 시점 오류 검출, 동적 쿼리에 강력
  • 네이티브 SQL: 특정 DB 종속 기능(오라클 CONNECT BY, SQL 힌트 등)
  • Criteria: 너무 복잡하고 실용성 없음 → 쓰지 말 것 (QueryDSL로 대체)

# JPQL 기본 문법

select_문 ::= select_절 from_절 [where_절] [groupby_절] [having_절] [orderby_절]
update_문 ::= update_절 [where_절]
delete_문 ::= delete_절 [where_절]
String jpql = "select m from Member m where m.age > 18";
List<Member> result = em.createQuery(jpql, Member.class).getResultList();

대소문자 / 별칭 규칙

  • 엔티티명, 속성명: 대소문자 구분 O (Member, age)
  • JPQL 키워드: 대소문자 구분 X (SELECT, from)
  • 테이블 이름이 아닌 엔티티 이름 사용
  • 별칭은 필수 (m), as는 생략 가능

# TypedQuery vs Query

// 반환 타입이 명확할 때
TypedQuery<Member> query = em.createQuery("SELECT m FROM Member m", Member.class);
// 반환 타입이 명확하지 않을 때
Query query2 = em.createQuery("SELECT m.username, m.age from Member m");
Query query = em.createQuery("SELECT m.username, m.age FROM Member m");
List<Object[]> resultList = query.getResultList();

for (Object[] row : resultList) {
    String username = (String) row[0];
    int age = (int) row[1];
    System.out.println("username = " + username + ", age = " + age);
}

위와 같이 서로 다른 타입의 컬럼들을 각 row별로 조회할때 Query 타입을 사용한다. 컴파일 타임에 특정 타입으로 확정할 수가 없기 때문이다. 위의 경우 Object[] 타입의 데이터로 저장된다.

# 결과 조회 API

  • getResultList(): 결과가 하나 이상 → 리스트 반환, 없으면 빈 리스트
  • getSingleResult(): 결과가 정확히 하나일 때 단일 객체 반환
    • 없으면 NoResultException, 둘 이상이면 NonUniqueResultException

getSingleResult() 주의

실무에서 결과 없음/다수일 때 예외가 터지므로 부담스럽다. Spring Data JPA의 Optional 반환이나 리스트 조회 후 처리하는 방식을 선호하는 경우가 많다.

# 파라미터 바인딩

이름 기준만 사용한다 (위치 기준 ?1은 순서 바뀌면 위험).

em.createQuery("SELECT m FROM Member m where m.username = ?1 and m.age > ?2")
  .setParameter(1, "회원1")
  .setParameter(2, 20);

위와 같이 위치를 기준으로 바인딩을 해줄 수도 있지만 위험하다.

em.createQuery("SELECT m FROM Member m where m.username = :username")
  .setParameter("username", usernameParam);

위와 같이 이름을 기준으로 하는 바인딩 방식을 사용하는것이 강하게 권장된다.

# 프로젝션

프로젝션은 SQL의 SELECT절과 같이 조회 대상에서 어떤 것을 꺼내올지 지정하는 것이다. 대상은 엔티티 / 임베디드 타입 / 스칼라 타입.

SELECT m FROM Member m              -- 엔티티 프로젝션
SELECT m.team FROM Member m         -- 엔티티 프로젝션 (조인 발생)
SELECT m.address FROM Member m      -- 임베디드 타입
SELECT m.username, m.age FROM Member m  -- 스칼라 타입

여러 값을 조회할 때는 DTO로 바로 조회하는 방식이 깔끔하다.

// 패키지명 포함 전체 클래스명 + 순서/타입 일치하는 생성자 필요
SELECT new jpabook.jpql.UserDTO(m.username, m.age) FROM Member m

엔티티를 통째로 가져오면 불필요한 컬럼까지 다 조회되니까, 필요한 것만 프로젝션해서 성능과 코드 가독성을 챙기는 것이 핵심이다.

패키지 전체 경로를 명시해야 하기 때문에 코드가 지저분해진다. 이때 Spring Data JPA를 쓰면 이 부분이 훨씬 깔끔해진다. @Query에 DTO 생성자 표현식을 쓰거나, 아예 인터페이스 기반 Projection으로 생성자 없이 처리하기도 한다.

# 페이징 API

DB 방언에 독립적으로 페이징을 추상화한다.

em.createQuery("select m from Member m order by m.name desc", Member.class)
  .setFirstResult(10)   // 조회 시작 위치 (0부터 입력 가능)
  .setMaxResults(20)    // 조회 개수
  .getResultList();

순수 JPA에서는 setFirstResult 파라미터로 사용될 페이지 관련 값들을 애플리케이션 레벨에서 로직으로 직접 처리해줘야 한다. Spring Data JPA를 사용하면 이러한 부분이 편리해진다.

# 조인

내부 조인: SELECT m FROM Member m [INNER] JOIN m.team t
외부 조인: SELECT m FROM Member m LEFT [OUTER] JOIN m.team t
세타 조인: select count(m) from Member m, Team t where m.username = t.name

ON 절 활용 (JPA 2.1+)

  1. 조인 대상 필터링: ... LEFT JOIN m.team t on t.name = 'A'
  2. 연관관계 없는 엔티티 외부 조인: ... LEFT JOIN Team t on m.username = t.name

# 서브 쿼리

-- 나이가 평균보다 많은 회원
select m from Member m where m.age > (select avg(m2.age) from Member m2)

지원 함수: [NOT] EXISTS, {ALL | ANY | SOME}, [NOT] IN

서브 쿼리 한계

  • JPA는 WHERE, HAVING 절에서만 서브 쿼리 사용 가능 (SELECT 절은 하이버네이트 지원)
  • FROM 절 서브 쿼리는 JPQL에서 불가능 → 조인으로 풀거나 네이티브 SQL 사용
  • 단, 하이버네이트6부터 FROM 절 서브쿼리 지원

# 조건식 - CASE

select
  case when m.age <= 10 then '학생요금'
       when m.age >= 60 then '경로요금'
       else '일반요금'
  end
from Member m
  • COALESCE(a, b): 하나씩 조회해 null이 아니면 반환 (기본값 처리에 유용)
  • NULLIF(a, b): 두 값이 같으면 null, 다르면 첫 번째 값 반환

# 기본 함수

CONCAT, SUBSTRING, TRIM, LOWER/UPPER, LENGTH, LOCATE, ABS/SQRT/MOD, SIZE(컬렉션 크기), INDEX

# 경로 표현식

.(점)으로 객체 그래프를 탐색하는 것. 종류에 따라 동작이 다르다.

  • 상태 필드(m.username): 경로 탐색의 끝, 더 이상 탐색 X
  • 단일 값 연관 필드(m.team, @ManyToOne/@OneToOne): 묵시적 내부 조인 발생, 탐색 O
  • 컬렉션 값 연관 필드(m.orders, @OneToMany/@ManyToMany): 묵시적 내부 조인 발생, 탐색 X (별칭 필요)
select t.members from Team t              -- 성공
select t.members.username from Team t     -- 실패 (컬렉션에서 더 그래프 탐색 불가능)
select m.username from Team t join t.members m  -- 성공 (명시적 조인 별칭)

멤버의 팀명을 조회할 때 Member ↔ Team 관계가 @ManyToOne으로 참조하는 엔티티가 하나(단일 값 연관 필드)이면 객체 그래프 탐색이 가능하다. 이때 내부적으로는 묵시적 내부 조인(inner join)이 수행된다.

-- JPQL
select m.team.name from Member m
-- 실행 SQL (묵시적 조인 발생)
select t.name from Member m inner join Team t on m.team_id = t.id

단, 묵시적 조인은 항상 내부 조인만 가능하고 쿼리에 조인이 드러나지 않아 파악이 어렵다. 가급적 명시적 조인을 사용하는 것이 좋다.

select t.members from Team t            -- 여기까지는 OK (컬렉션 자체)
select t.members.username from Team t   -- 불가능! members는 여러 개라 .username 못 찍음

위와 같이 컬렉션에 대한 경로표현식은 어떤 객체를 선택해야 할지 몰라서 사용 불가능하다.

묵시적 조인 금지, 명시적 조인 사용

  • 묵시적 조인은 항상 내부 조인이며, FROM(JOIN) 절에 영향을 주지만 한눈에 파악이 어렵다
  • 조인은 SQL 튜닝의 핵심 포인트 → 가급적 명시적 조인(join m.team t) 사용

# 페치 조인 (fetch join) — 실무에서 중요

SQL 조인 종류가 아니라, JPQL이 성능 최적화를 위해 제공하는 기능. 연관 엔티티/컬렉션을 SQL 한 번에 함께 조회한다 (즉시 로딩).

페치 조인 ::= [ LEFT [OUTER] | INNER ] JOIN FETCH 조인경로
-- 엔티티 페치 조인 (회원 + 팀 한 번에)
select m from Member m join fetch m.team
-- 실행 SQL: SELECT M.*, T.* FROM MEMBER M INNER JOIN TEAM T ON M.TEAM_ID = T.ID

페치 조인 vs 일반 조인

  • 일반 조인: SELECT 절 엔티티만 조회, 연관 엔티티는 조회 X (지연 로딩으로 추가 쿼리)
  • 페치 조인: 연관 엔티티까지 함께 조회 → 객체 그래프를 SQL 한 번에

컬렉션 페치 조인과 DISTINCT

일대다 컬렉션 페치 조인은 데이터가 뻥튀기(중복)된다. JPQL의 DISTINCT는 두 가지 일을 한다.

  1. SQL에 DISTINCT 추가
  2. 애플리케이션에서 같은 식별자 엔티티 중복 제거

(하이버네이트6부터는 DISTINCT 없이도 애플리케이션 레벨 중복 제거가 자동 적용됨)

페치 조인의 한계

TEAM.ID TEAM.NAME MEMBER.ID MEMBER.NAME
1 팀A 1 회원1
1 팀A 2 회원2

모든 한계의 뿌리는 "일대다 컬렉션 조인 시 row가 뻥튀기된다" 는 점이다. 팀 1개에 회원 2명이면 SQL 결과 row가 2개로 늘어 엔티티 수와 안 맞게 된다.

  • 페치 조인 대상에는 별칭을 줄 수 없다 (하이버네이트는 가능하나 가급적 X)
    • 별칭으로 컬렉션을 WHERE 필터링하면 일부만 담긴 반쪽짜리 객체가 되어 객체 그래프가 오염됨. JPA 표준은 이 위험의 입구인 별칭 자체를 차단. (필터링이 아닌 ORDER BY 등의 용도면 하이버네이트는 허용)
    • 페치 조인은 "연관된 컬렉션을 온전히 다 채워서 객체 그래프를 완성한다"는 게 목적인데, 거기에 WHERE로 필터를 걸면 컬렉션 일부만 담긴 오염된(반쪽짜리) 엔티티가 만들어진다.
  • 둘 이상의 컬렉션은 페치 조인 불가
    • 회원(N) × 주문(M) 카테시안 곱으로 row가 폭발하고 정합성이 깨짐
  • 컬렉션 페치 조인 시 페이징 API 사용 불가 → 하이버네이트가 경고 로그 남기고 메모리에서 페이징(매우 위험)
    • row 뻥튀기로 LIMIT이 엔티티 기준과 어긋남. DB로 못 자르니 전체를 메모리에 올려 자름 → 데이터 많으면 OOM
    • 해결: @BatchSize(또는 hibernate.default_batch_fetch_size)로 페이징 후 IN 쿼리로 컬렉션 조회 — 실무 표준
  • 단일 값 연관 필드(일대일, 다대일)는 페치 조인해도 페이징 가능
    • 1:1이라 뻥튀기가 없어 row 수 = 엔티티 수 → LIMIT 정상 동작
select t from Team t
join fetch t.members
join fetch t.orders
where t.name = '팀A'

위와 같이 팀 객체에 members, orders 컬렉션이 존재할때 멤버와 주문 컬렉션에 대해 페치 조인을 수행하게 되면 row 폭발이 일어난다.또한 단순 카테시안 곱으로 모든 조합을 만들어내기 때문에 어떤 것이 실제 데이터인지 알 수가 없다. 정합성이 깨지는 문제가 더 큰 문제이다. 어떤 데이터가 진짜 데이터인지 알 수 없다.

글로벌 로딩 전략 vs 페치 조인

  • fetch EAGER / LAZY 옵션이 글로벌 로딩 전략이다.
  • 글로벌 로딩 전략은 모두 지연 로딩(LAZY) 으로 두는 것이 실무 원칙
  • 최적화가 필요한 곳에서만 페치 조인으로 처리 (페치 조인이 글로벌 전략보다 우선)
  • 전혀 다른 모양의 결과가 필요하면 페치 조인 대신 일반 조인 + DTO 조회가 효과적

# 다형성 쿼리 (상속 구조)

  • TYPE: 조회 대상을 특정 자식으로 한정
  select i from Item i where type(i) IN (Book, Movie)
  -- SQL: ... where i.DTYPE in ('B', 'M')
  • TREAT (JPA 2.1): 부모 타입을 특정 자식 타입으로 캐스팅 (자바 다운캐스팅과 유사)
  select i from Item i where treat(i as Book).author = 'kim'

# 엔티티 직접 사용

JPQL에서 엔티티를 직접 사용하면 SQL에서 해당 엔티티의 기본 키 값으로 변환된다.

// 엔티티 직접 / 식별자 직접 → 실행 SQL 동일 (where m.id = ?)
"select m from Member m where m = :member"
"select m from Member m where m.id = :memberId"

// 외래 키도 마찬가지 (where m.team_id = ?)
"select m from Member m where m.team = :team"
"select m from Member m where m.team.id = :teamId"

# Named 쿼리

미리 정의해 이름을 부여해두는 정적 쿼리. 어노테이션이나 XML에 정의한다.

@Entity
@NamedQuery(name = "Member.findByUsername",
            query = "select m from Member m where m.username = :username")
public class Member { ... }

em.createNamedQuery("Member.findByUsername", Member.class)
  .setParameter("username", "회원1")
  .getResultList();

Named 쿼리의 핵심 장점

애플리케이션 로딩 시점에 쿼리를 검증한다. 문법 오류가 있으면 앱이 뜰 때 바로 잡힌다. (Spring Data JPA의 @Query도 내부적으로 이 방식 활용)
XML이 어노테이션보다 우선권을 가지며, 운영 환경별로 다른 XML 배포 가능.

애플리케이션이 로딩될때 JPQL을 SQL로 파싱한뒤 몇가지를 체크한다.

  • JPQL 문법이 올바른가 (select, from 등 구조가 맞는가)
  • 참조한 엔티티가 실제로 존재하는가 (Member라는 엔티티가 있나)
  • 참조한 필드가 그 엔티티에 있는가 (m.username이 진짜 있나)
  • 타입이 맞는가

위의 정보들은 엔티티 메타데이터만으로도 확인할 수 있다. 쿼리를 직접 수행하지 않고 검증하는 내용들이다.

# 벌크 연산

변경 감지(dirty checking)는 건마다 UPDATE를 날리므로(100건이면 100번), 대량 수정/삭제는 쿼리 한 번으로 처리하는 벌크 연산을 쓴다.

// 1. 재고 10개 미만 상품 다 조회
List<Product> products = em.createQuery(
    "select p from Product p where p.stockAmount < 10", Product.class)
    .getResultList();

// 2. 하나씩 가격 변경
for (Product p : products) {
    p.setPrice(p.getPrice() * 1.1);
}
// 3. 커밋 → 변경 감지로 UPDATE 실행

엔티티 값 변경 시 영속성 컨텍스트가 변경 사실을 기록해뒀다가, 트랜잭션 커밋 시점에 변경 감지(dirty checking)로 쌓인 변경분을 flush한다. 이때 변경된 엔티티마다 건건이 UPDATE가 나가기 때문에 대용량 데이터 수정에서는 부하가 클 수 있다.

dirty checking 방식

변경 감지는 영속성 컨텍스트가 관리하는 엔티티를 하나씩 순회하며 스냅샷과 비교한다. 그리고 비교해서 바뀐 걸 찾아도 UPDATE를 벌크로 합쳐주진 않는다.

String qlString = "update Product p set p.price = p.price * 1.1 " +
                  "where p.stockAmount < :stockAmount";
int resultCount = em.createQuery(qlString)
                    .setParameter("stockAmount", 10)
                    .executeUpdate();  // 영향받은 엔티티 수 반환
  • UPDATE, DELETE 지원 (INSERT는 하이버네이트가 insert into .. select 지원)

벌크 연산 주의 (실무 함정)

벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리한다. 그래서 영속성 컨텍스트와 DB 상태가 어긋난다. 해결책:

  1. 벌크 연산을 가장 먼저 실행, 또는
  2. 벌크 연산 수행 후 영속성 컨텍스트 초기화(em.clear())

(Spring Data JPA에서는 @Modifying(clearAutomatically = true) 활용)
createQuery로 날리는 JPQL은 select든 update든 영속성 컨텍스트(1차 캐시)를 보지 않고 곧장 DB에 SQL을 날린다. em.find()만 1차 캐시를 먼저 뒤지지, JPQL은 항상 DB로 간다.

하지만, select JPQL은 DB에서 가져온 결과를 영속성 컨텍스트에 등록(동기화) 한다. 반면 update/delete 벌크 연산은 DB의 데이터를 직접 바꿔놓고는, 영속성 컨텍스트엔 그 변화를 전혀 반영하지 않는다.

따라서 벌크 연산 후엔 어긋난 컨텍스트를 비워줘야 한다. (em.clear())