11-12주차 자료의 모든 토픽을 두 주에 걸쳐 정리한 학습 경로.
1) 11주차 — JPA의 정체와 영속성 컨텍스트 (싱글톤 → SQL Mapper → ORM → 엔티티 매핑 → 영속성 컨텍스트)
2) 12주차 — 연관관계와 성능 최적화 (4가지 연관관계 → 프록시 → N+1 문제 → CASCADE → JPQL/QueryDSL)7주차에서 JPA의 입문을 봤다면, 11-12주차는 JPA의 모든 메커니즘을 깊이 파헤친다.
자바·Spring 학습의 또 다른 정점이며, 실무에서 가장 자주 만나는 영역이다.
[Part A — 11주차: JPA의 정체와 영속성 컨텍스트]
[Phase 1] 싱글톤 + SQL Mapper의 역사
↓
[Phase 2] ORM과 JPA의 본질
↓
[Phase 3] 엔티티와 테이블 매핑 기초
↓
[Phase 4] EntityManager + 영속성 컨텍스트
↓
[Phase 5] 영속성 컨텍스트의 4가지 장점 ◄ 11주차 정점
[Part B — 12주차: 연관관계와 성능 최적화]
[Phase 6] JPA 연관관계 4가지 (1:1, N:1, 1:N, N:M)
↓
[Phase 7] mappedBy와 연관관계의 주인
↓
[Phase 8] 프록시와 지연/즉시 로딩
↓
[Phase 9] N+1 문제와 해결 ◄ 12주차 정점
↓
[Phase 10] 영속성 전이 + JPQL/QueryDSL
총 10 Phase × 39 Unit — 8-9주차와 비슷한 분량의 통합 커리큘럼.
| 주차 | 주제 | 핵심 변화 |
|---|---|---|
| 1주차 | OOP·JVM·GC·컬렉션·I/O 개론 | 자바 큰 그림 |
| 2주차 | JVM 내부·바이트코드·G1 GC | "어떻게 돌아가나" |
| 3주차 | 컬렉션·제네릭·함수형 | 자바 표현력 |
| 4주차 | 멀티스레딩·동시성·Executor | 동시성 정복 |
| 5주차 | Atomic + Spring IoC/DI 입문 | 자바 → Spring 다리 |
| 6주차 | 테스트 + 웹 인프라 + DB 접근 진화 | Spring 실전 환경 |
| 7주차 | JPA/ORM 입문 + 트랜잭션 추상화 | DB 추상화 입문 |
| 8주차 | 프록시의 진화 | AOP 메커니즘 |
| 9주차 | Spring AOP 실전 + 트랜잭션 전파 | AOP 실전 활용 |
| 10주차 | 트랜잭션 정리 + 빈 라이프사이클 함정 + 격리 수준 | 트랜잭션 마무리 |
| 11주차 (지금) | JPA의 정체와 영속성 컨텍스트 | JPA 메커니즘 완전 이해 |
| 12주차 (지금) | 연관관계 + 성능 최적화 (N+1 등) | JPA 실전 활용 |
| Day | Phase | 학습 목표 |
|---|---|---|
| Week 1 (11주차) | ||
| 1일차 | Phase 1 | 싱글톤 + SQL Mapper 역사 |
| 2일차 | Phase 2 | ORM/JPA 본질 + Hibernate |
| 3일차 | Phase 3 | 엔티티 매핑 + 임베디드 타입 |
| 4-5일차 | Phase 4 | EntityManager + 영속성 컨텍스트 + 생명주기 |
| 6-7일차 | Phase 5 | 4가지 장점 (★ 정점) |
| Week 2 (12주차) | ||
| 8-9일차 | Phase 6 | 4가지 연관관계 |
| 10일차 | Phase 7 | mappedBy와 연관관계의 주인 |
| 11일차 | Phase 8 | 프록시와 지연/즉시 로딩 |
| 12-13일차 | Phase 9 | N+1 문제 + 해결책 (★ 정점) |
| 14일차 | Phase 10 | CASCADE + JPQL + QueryDSL |
여유 일정 (21일): Phase 5와 9에 각 +2일. 영속성 컨텍스트와 N+1은 직접 SQL 로그를 보며 체화.
목표: JPA가 어떤 흐름의 산물인지를 SQL Mapper(iBatis/MyBatis)와의 비교로 이해한다.
선수 지식: 5주차 Phase 8 (싱글톤 빈)
핵심 개념
싱글톤 패턴의 정의:
"클래스의 인스턴스가 단 하나만 존재 하도록 보장 + 전역 접근"
핵심 3요소:
1. static 변수로 유일 인스턴스 저장
2. private 생성자로 외부 생성 차단
3. getInstance() 정적 메서드로 반환
public class Singleton {
private static Singleton instance;
private Singleton() {} // private!
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
대표적인 싱글톤 사례:
자기 점검
선수 지식: Unit 1.1, 4주차 Phase 4, 10주차 Phase 4
핵심 위험
문제:
해결 방향:
1. stateless 설계 (가장 권장) — 멤버 변수에 가변 상태를 두지 않음
2. synchronized (성능 비용)
3. Atomic (5주차 Phase 2 — CAS)
4. ThreadLocal (8주차 Phase 1 — 스레드별 독립 저장소)
JPA에서의 적용:
EntityManagerFactory = 싱글톤 (애플리케이션 전역)EntityManager = 트랜잭션 단위 (스레드별로 다름)자기 점검
선수 지식: 6주차 Phase 7 (JdbcTemplate)
핵심 개념
iBatis (Internet Based Abstraction for Tabular Information Systems):
MyBatis = iBatis의 후계자:
@Select, @Insert)<if>, <foreach>)JDBC vs MyBatis 비교:
| JDBC 직접 | MyBatis | |
|---|---|---|
| SQL 작성 | 자바 코드 안 | XML 또는 어노테이션 |
| 자원 관리 | 수동 (try/finally) | 자동 |
| 결과 매핑 | 수동 (rs.getInt 등) | 자동 (resultType) |
| 코드량 | 많음 | 적음 |
SQL Mapper의 한계:
자기 점검
목표: ORM의 패러다임과 JPA-Hibernate의 관계를 명확히 잡는다.
선수 지식: Phase 1, 7주차 Phase 2~3
핵심 비교
| SQL Mapper (MyBatis, JdbcTemplate) | ORM (JPA/Hibernate) | |
|---|---|---|
| 매핑 | SQL ↔ 객체 | 객체 ↔ 테이블 |
| SQL 작성 | 개발자 직접 | JPA가 자동 생성 |
| 학습 곡선 | 낮음 | 높음 |
| DBMS 종속성 | 있음 | 적음 |
| 복잡 통계 쿼리 | 자유롭게 | 어렵 (JPQL/네이티브 쿼리 필요) |
SQL Mapper의 시각:
"SQL을 작성하고, 결과를 객체에 채워주세요"
ORM의 시각:
"객체를 다루세요. SQL은 제가 만들겠습니다"
객체-관계 패러다임 불일치:
자기 점검
선수 지식: Unit 2.1
핵심 구조
JPA (Java Persistence API):
EntityManager, EntityManagerFactory 인터페이스JPA 구현체:
의존 관계:
[Application Code]
↓
[JPA Interface] ← javax.persistence (또는 jakarta.persistence)
↓
[Hibernate] ← JPA 구현
↓
[JDBC]
↓
[DB]
ILIC 사례:
spring-boot-starter-data-jpa 의존성 추가Spring Data JPA와의 구분:
자기 점검
선수 지식: Unit 2.2
핵심 불일치 5가지:
| 측면 | 객체 (OOP) | 관계 DB |
|---|---|---|
| 모델링 | 상태 + 행동 | 행과 열 |
| 상속 | 있음 | 없음 |
| 연관 | 참조 (order.member) | 외래키 |
| 식별 | 객체 동일성 (==) | PK |
| 데이터 타입 | 풍부 (List, Map) | 제한적 |
불일치를 해결하는 방법 (JPA의 일):
JPA의 본질:
"객체와 RDB 사이의 번역기"
자기 점검
Booking 객체와 bookings 테이블의 매핑이 자동화되는 이점은?목표: 자바 객체 → DB 테이블 매핑의 기본 어노테이션을 손에 익힌다.
선수 지식: 7주차 Phase 4
핵심 개념
@Entity:
엔티티의 필수 조건 ⭐ :
@Id)final 클래스 사용 불가 (Hibernate가 프록시 만들어야 함)enum, interface, inner class 불가final 사용 불가기본 생성자가 왜 필요한가?:
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
public Member() {} // 기본 생성자 필수!
public Member(String name) {
this.name = name;
}
}
자기 점검
선수 지식: Unit 3.1
핵심 개념
기본 매핑:
Member 클래스 → member 테이블@Table 으로 명시:
@Entity
@Table(name = "MBR") // 실제 테이블명을 MBR로
public class Member {
// ...
}
@Column 매핑 (7주차 Phase 4 복습):
@Column(name = "user_name", length = 100, nullable = false)
private String userName;
Spring Boot의 자동 변환 (7주차 Unit 4.5):
userName (camelCase)user_name (snake_case)자기 점검
@Table 을 생략하면 어떻게 되는가?@SecondaryTable)선수 지식: Unit 3.2
핵심 개념
임베디드 타입(Embedded Type):
예시 — 주소를 임베디드 타입으로:
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
protected Address() {} // 기본 생성자 필수
public Address(String city, String street, String zipcode) {
this.city = city;
this.street = street;
this.zipcode = zipcode;
}
// Getter만 (값 타입은 불변 권장)
}
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@Embedded
private Address address; // 임베디드 사용
}
DB 테이블 결과:
CREATE TABLE Member (
id BIGINT PRIMARY KEY,
name VARCHAR(255),
city VARCHAR(255), -- Address의 필드
street VARCHAR(255),
zipcode VARCHAR(255)
);
@AttributeOverrides — 같은 임베디드 타입을 여러 번 사용 시:
@Entity
public class Employee {
@Id @GeneratedValue
private Long id;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "city", column = @Column(name = "home_city")),
@AttributeOverride(name = "street", column = @Column(name = "home_street")),
@AttributeOverride(name = "zipcode", column = @Column(name = "home_zipcode"))
})
private Address homeAddress;
@Embedded
@AttributeOverrides({...})
private Address workAddress;
}
값 타입의 원칙:
자기 점검
선수 지식: Unit 3.3
핵심 개념
spring.jpa.hibernate.ddl-auto 옵션:
| 옵션 | 동작 |
|---|---|
create | 시작 시 기존 테이블 삭제 + 새로 생성 |
create-drop | create + 종료 시 삭제 |
update | 변경 사항만 반영 (안전하지 않음) |
validate | 스키마 매핑만 검증 (변경 없음) |
none | 자동 처리 안 함 |
⚠️ 운영 환경 절대 금지 ⭐ :
create, create-drop, update → 데이터 손실 위험개발 단계별 권장:
create 또는 updatevalidatenone (Flyway/Liquibase 사용)SQL 분류 참고:
| 분류 | 의미 | 명령 |
|---|---|---|
| DDL (Data Definition Language) | 골격 정의 | CREATE, ALTER, DROP, TRUNCATE |
| DML (Data Manipulation Language) | 데이터 조작 | SELECT, INSERT, UPDATE, DELETE |
| DCL (Data Control Language) | 권한·트랜잭션 | GRANT, REVOKE, COMMIT, ROLLBACK |
자기 점검
목표: JPA의 핵심 메커니즘 — 영속성 컨텍스트의 정체와 엔티티 생명주기를 이해한다.
선수 지식: Phase 1, 2
핵심 개념
EntityManagerFactory (EMF):
EntityManager를 생성하는 팩토리Spring Boot에서의 동작:
@SpringBootApplication 실행 시 자동 생성자바 표준 사용 (참고):
EntityManagerFactory emf = Persistence.createEntityManagerFactory("my-pu");
Spring 환경 (실무):
@PersistenceUnit
private EntityManagerFactory emf; // 자동 주입
자기 점검
선수 지식: Unit 4.1, 7주차 Phase 7
핵심 개념
EntityManager (EM):
Spring + @Transactional 조합:
핵심 메서드:
persist(entity): 영속화 (저장)find(Class, id): 조회 (1차 캐시 활용)getReference(Class, id): 프록시 반환 (Phase 8)remove(entity): 삭제merge(entity): 준영속 → 영속detach(entity): 영속 → 준영속중요 — 스레드 안전 X:
"EntityManager는 공유 X — 트랜잭션마다 별도 인스턴스"
이게 EntityManagerFactory(싱글톤)와의 결정적 차이.
자기 점검
선수 지식: Unit 4.2
핵심 개념
"엔티티 매니저 내부에서 동작하는 메모리 공간 — 엔티티를 보관하고 관리하는 곳"
위치:
EntityManager
└── 영속성 컨텍스트 (Persistence Context)
└── 1차 캐시 (엔티티 보관)
역할:
중요 통찰:
"JPA의 모든 신기한 동작은 영속성 컨텍스트 안에서 일어난다"
이게 Phase 5에서 설명할 4가지 장점의 출발점.
자기 점검
선수 지식: Unit 4.3
핵심 4가지 상태 ⭐ :
new ─persist()──> [영속(Managed)] ──remove()──> [삭제(Removed)]
[비영속] │
(Transient) │ detach()
│ close()
│ clear()
↓
[준영속(Detached)]
│
│ merge()
↓
[영속(Managed)]
4가지 상태 자세히:
| 상태 | 정의 | 영속성 컨텍스트 | DB |
|---|---|---|---|
| 비영속(Transient) | new로 생성, 영속화 X | ❌ | ❌ |
| 영속(Managed) | persist 또는 find로 관리 중 | ✅ | (커밋 시 반영) |
| 준영속(Detached) | 영속이었으나 분리됨 | ❌ | (이전 데이터 존재) |
| 삭제(Removed) | remove로 삭제 예정 | ✅ (제거 표시) | (커밋 시 삭제) |
상태 변경 메서드:
persist(entity): 비영속 → 영속find(Class, id): → 영속remove(entity): 영속 → 삭제detach(entity): 영속 → 준영속 (개별)clear(): 모든 영속 → 준영속close(): EM 종료 → 모두 준영속merge(entity): 준영속 → 영속중요한 차이 ⭐ :
detach(): 영속성 컨텍스트에서만 분리 — DB는 그대로remove(): DB에서도 삭제 (커밋 시)자기 점검
목표: JPA가 왜 강력한지를 영속성 컨텍스트의 4가지 동작 메커니즘으로 이해한다. 면접·실무 단골.
선수 지식: Phase 4
핵심 개념
1차 캐시:
동작 흐름:
EntityManager em = emf.createEntityManager();
User user1 = em.find(User.class, 1L); // ① DB 조회 + 1차 캐시 저장
User user2 = em.find(User.class, 1L); // ② 1차 캐시에서 즉시 반환 (SQL 실행 X)
1차 캐시의 6단계:
1. 첫 조회: 1차 캐시 비어있음
2. DB에서 엔티티 조회
3. 1차 캐시에 저장
4. 결과 반환
5. 같은 엔티티 재조회 시 1차 캐시에서 반환
6. 객체 동일성 (==) 보장
범위:
자기 점검
선수 지식: Unit 5.1
핵심 개념
User user1 = em.find(User.class, 1L);
User user2 = em.find(User.class, 1L);
System.out.println(user1 == user2); // true!
왜 가능한가:
객체 동등성 vs 동일성:
JPA는 트랜잭션 안에서 동일성을 보장한다.
의의:
자기 점검
선수 지식: Unit 5.2
핵심 개념
쓰기 지연(Write-Behind):
persist() 호출 시 즉시 INSERT 안 함예시:
em.getTransaction().begin();
em.persist(memberA); // INSERT SQL 안 보냄
em.persist(memberB); // INSERT SQL 안 보냄
em.persist(memberC); // INSERT SQL 안 보냄
// 여기까지 DB는 아무것도 모름
em.getTransaction().commit();
// → 커밋 직전에 INSERT SQL 3개를 한꺼번에 보냄!
내부 동작:
효과:
자기 점검
선수 지식: Unit 5.3
핵심 개념 — JPA의 가장 신기한 동작
em.getTransaction().begin();
Member member = em.find(Member.class, 1L); // 영속 상태
member.setUsername("hi"); // setter 호출만!
member.setAge(10);
em.getTransaction().commit();
// → UPDATE 쿼리 자동 실행!
em.update() 호출 X. setter만 호출했는데 UPDATE 가 일어났다.
내부 동작 — 스냅샷 비교:
1. 엔티티가 영속성 컨텍스트에 들어올 때 스냅샷 저장 (1차 캐시 옆)
2. setter 호출 → 엔티티 변경
3. 커밋 시점에 스냅샷과 현재 엔티티 비교
4. 차이가 있으면 → UPDATE SQL 자동 생성
5. 쓰기 지연 저장소에 추가
6. DB 반영
비교 흐름:
[조회 시점] [커밋 시점]
엔티티: {name: "A", age: 20} 엔티티: {name: "B", age: 25}
스냅샷: {name: "A", age: 20} 스냅샷: {name: "A", age: 20}
↓
[차이 검출] → UPDATE SQL 생성
중요한 함정:
merge() 또는 다시 영속화 필요자기 점검
Hibernate.unproxy(), readOnly)선수 지식: Unit 5.4
플러시(Flush):
"영속성 컨텍스트의 변경 내용을 DB에 반영하는 시점"
플러시 발생 3가지 시점:
1. em.flush() 직접 호출
2. 트랜잭션 커밋 직전 (자동)
3. JPQL 쿼리 실행 직전 (자동)
3번이 중요한 이유:
플러시 ≠ 커밋:
2차 캐시 (보너스):
Ehcache, Redis, Hazelcast)왜 복사본을 반환하는가:
1차 vs 2차 캐시:
| 1차 캐시 | 2차 캐시 | |
|---|---|---|
| 범위 | 트랜잭션 | 애플리케이션 전역 |
| 기본 제공 | ✅ | ❌ (별도 설정) |
| 객체 반환 | 같은 인스턴스 | 복사본 |
| 동시성 | 안전 | 복사본으로 격리 |
자기 점검
목표: 객체 참조와 외래 키를 어떻게 매핑하는지 4가지 관계로 정리한다.
선수 지식: Phase 5
핵심 개념
4가지 연관관계:
@OneToOne)@ManyToOne) — 가장 많이 사용 ⭐@OneToMany)@ManyToMany) — 실무에서 잘 안 씀단방향 vs 양방향:
핵심 통찰:
"양방향은 사실 단방향 2개"
객체 입장: A → B 와 B → A 의 두 참조
DB 입장: 외래 키 1개로 충분
→ 그래서 연관관계의 주인 개념이 등장 (Phase 7)
자기 점검
선수 지식: Unit 6.1
핵심 개념
언제 사용:
FK 위치 결정:
단방향 매핑:
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@OneToOne
@JoinColumn(name = "locker_id") // FK는 Member 테이블에
private Locker locker;
}
@Entity
public class Locker {
@Id @GeneratedValue
private Long id;
}
양방향 매핑:
@Entity
public class Locker {
@Id @GeneratedValue
private Long id;
@OneToOne(mappedBy = "locker") // 주인이 아님
private Member member;
}
자기 점검
선수 지식: Unit 6.2
핵심 개념
가장 자주 등장하는 관계:
FK 위치:
@ManyToOne ⭐@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩 권장!
@JoinColumn(name = "team_id") // FK는 Member에
private Team team;
}
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
}
⚠️ 주의 사항:
1. FK는 N쪽에 — 항상
2. fetch = FetchType.LAZY 권장 — N+1 방지 (Phase 9)
3. 잘못된 양방향 설정 시 무한 루프 — toString, JSON 직렬화 시
자기 점검
선수 지식: Unit 6.3
핵심 개념
보통 양방향에서 N:1 + 1:N 조합으로 사용:
mappedBy (읽기 전용)@Entity
public class Team {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "team") // 주인이 아님!
private List<Member> members = new ArrayList<>();
}
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@ManyToOne
@JoinColumn(name = "team_id") // 주인 (FK 관리)
private Team team;
}
@OneToMany 가 주인이 되는 경우는?:
⚠️ 주의 사항:
mappedBy 없으면 → 조인 테이블 자동 생성 (비효율)@ManyToOne 쪽이 주인이어야 함자기 점검
선수 지식: Unit 6.4
핵심 개념
N:M의 직접 사용 — 비추천:
@ManyToMany
@JoinTable(name = "MEMBER_PRODUCT")
private List<Product> products = new ArrayList<>();
왜 안 좋은가:
중간 엔티티 패턴 (실무 표준):
@Entity
public class MemberProduct { // 또는 Order로 이름 변경
@Id @GeneratedValue
private Long id;
@ManyToOne
@JoinColumn(name = "MEMBER_ID")
private Member member;
@ManyToOne
@JoinColumn(name = "PRODUCT_ID")
private Product product;
private int amount; // 부가 정보!
private int price; // 부가 정보!
private LocalDateTime orderDateTime; // 부가 정보!
}
원칙:
"N:M은 항상 1:N + N:1로 풀어라"
의미 부여:
MemberProduct → Order 같이 비즈니스 의미 있는 이름으로 변경자기 점검
목표: 양방향 연관관계의 가장 어려운 개념을 정리한다.
선수 지식: Phase 6
핵심 문제
양방향은 객체 입장: A → B + B → A (2개 참조)
DB 입장: FK 1개로 표현
문제:
해결:
"한쪽만 주인 으로 정하고, 그 쪽만 DB FK 관리"
다른 쪽은 읽기 전용
규칙:
@ManyToOne 쪽)mappedBy자기 점검
선수 지식: Unit 7.1
핵심 개념
mappedBy:
@OneToMany(mappedBy = "team")
// ↑ Member 엔티티의 team 필드를 가리킴
private List<Member> members;
의미:
"나는 주인이 아니고, 저쪽(Member.team)이 주인이다"
JPA가 하는 일:
자주 하는 실수:
연관관계 편의 메서드 (실무 표준):
@Entity
public class Member {
public void setTeam(Team team) {
this.team = team;
team.getMembers().add(this); // 양쪽 동기화!
}
}
자기 점검
선수 지식: Unit 7.2
핵심 패턴
가장 흔한 양방향 — 회원과 팀:
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team; // 주인 (FK 관리)
// 편의 메서드
public void changeTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
}
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "team") // 주인 X
private List<Member> members = new ArrayList<>();
}
5가지 권장 사항:
1. ✅ N쪽이 주인 (@ManyToOne)
2. ✅ fetch = FetchType.LAZY 명시
3. ✅ 컬렉션은 필드 초기화 (new ArrayList<>())
4. ✅ 편의 메서드로 양쪽 동기화
5. ✅ Lombok @ToString 에서 컬렉션 제외 (무한 루프 방지)
자기 점검
목표: JPA의 프록시 메커니즘과 로딩 전략 — 8-9주차 프록시와 다른 맥락의 프록시.
선수 지식: Phase 5
핵심 차이:
em.find() | em.getReference() | |
|---|---|---|
| 반환 | 실제 엔티티 | 프록시 객체 |
| SQL 실행 | 즉시 | 지연 (필드 접근 시) |
| 타입 | User | User$Proxy1 (CGLIB) |
예시:
User user = em.find(User.class, 1L); // 즉시 SELECT
System.out.println(user.getName()); // SQL 실행 X (이미 로드됨)
User proxyUser = em.getReference(User.class, 1L); // SQL 안 함
System.out.println(proxyUser.getName()); // 여기서 SELECT 실행
프록시 객체 확인:
System.out.println(proxyUser.getClass().getName());
// 출력: com.sun.proxy.$Proxy1
자기 점검
선수 지식: Unit 8.1
핵심 동작 흐름:
Step 1 — getReference() 호출:
User proxyUser = em.getReference(User.class, 1L);
// 프록시 객체 반환
// proxyUser.target = null (실제 엔티티 미로드)
Step 2 — 필드 접근 시점:
proxyUser.getName();
// ↓ 이 순간:
// 1. 영속성 컨텍스트에 실제 엔티티 로드 요청
// 2. DB SELECT 실행
// 3. 영속성 컨텍스트에 실제 엔티티 보관
// 4. proxyUser.target = 실제 엔티티 연결
// 5. proxyUser.target.getName() 호출
프록시의 특징:
⚠️ 주의 — 비교 시 instanceof 사용:
if (user instanceof User) { ... } // 프록시도 잡힘 ✅
if (user.getClass() == User.class) { ... } // 프록시는 false ❌
자기 점검
선수 지식: Unit 8.2
핵심 개념
지연 로딩(Lazy Loading):
@Entity
public class Member {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
}
동작 시나리오:
Member member = em.find(Member.class, 1L);
// SELECT * FROM member WHERE id = 1;
// member.team = 프록시 객체 (Team$Proxy1)
System.out.println(member.getTeam()); // SQL X (프록시만 출력)
System.out.println(member.getTeam().getName());
// ↑ 이 시점에 SELECT * FROM team WHERE id = ?;
언제 사용:
자기 점검
선수 지식: Unit 8.3
핵심 개념
즉시 로딩(Eager Loading):
@ManyToOne(fetch = FetchType.EAGER)
private Team team;
Member member = em.find(Member.class, 1L);
// SELECT m.*, t.* FROM member m JOIN team t ON ... WHERE m.id = 1;
// 또는: SELECT * FROM member WHERE id = 1; + SELECT * FROM team WHERE id = ?;
연관관계별 기본값 (외워야 함) ⭐ :
| 관계 | 기본 fetch |
|---|---|
@ManyToOne | EAGER (위험!) |
@OneToOne | EAGER (위험!) |
@OneToMany | LAZY |
@ManyToMany | LAZY |
⚠️ 즉시 로딩의 문제 ⭐ :
실무 결론:
"모든 연관관계를 LAZY로 명시"
EAGER가 필요하면 fetch join (Phase 9)
@ManyToOne(fetch = FetchType.LAZY) // 항상 명시!
@OneToOne(fetch = FetchType.LAZY) // 항상 명시!
자기 점검
목표: 실무에서 가장 자주 만나는 JPA 성능 문제를 4단계로 해결한다.
선수 지식: Phase 8
핵심 정의
N+1 문제:
"한 번의 쿼리(1)를 실행한 후, 추가로 N개의 쿼리 가 실행되는 문제"
즉시 로딩(EAGER) 시 발생:
// User에 @OneToMany(fetch = EAGER) private Set<Article> articles;
List<User> users = em.createQuery("SELECT u FROM User u").getResultList();
실행되는 SQL:
-- (1) User 100명 조회
SELECT * FROM user;
-- (N=100) User마다 article 조회
SELECT * FROM article WHERE user_id = 1;
SELECT * FROM article WHERE user_id = 2;
SELECT * FROM article WHERE user_id = 3;
... (100번)
→ 총 101개의 쿼리!
지연 로딩(LAZY) 시에도 발생 가능:
List<User> users = em.createQuery("SELECT u FROM User u").getResultList();
// (1) User 100명 조회 — LAZY라서 article은 안 가져옴
for (User user : users) {
System.out.println(user.getArticles().size());
// ↑ getter 호출 시점에 100번 SQL 실행!
}
→ 결국 같은 N+1 문제
핵심 통찰:
"단순히 LAZY로 바꾼다고 해결 안 됨. JPQL/getter 패턴 자체 가 문제"
자기 점검
선수 지식: Unit 9.1
핵심 차이 ⭐ :
일반 JOIN — N+1 해결 안 됨:
List<Member> members = em.createQuery(
"SELECT m FROM Member m JOIN m.team t", Member.class)
.getResultList();
실행되는 SQL:
SELECT m.* -- ← Member만 SELECT, Team은 미포함
FROM Member m
JOIN Team t ON m.team_id = t.id;
→ Team은 영속성 컨텍스트에 안 들어옴 → getTeam() 호출 시 추가 SQL → N+1 여전!
FETCH JOIN — 한 번에 해결 ⭐ :
List<Member> members = em.createQuery(
"SELECT m FROM Member m JOIN FETCH m.team", Member.class)
.getResultList();
실행되는 SQL:
SELECT m.*, t.* -- ← Team도 함께 SELECT!
FROM Member m
JOIN Team t ON m.team_id = t.id;
→ Team도 영속성 컨텍스트에 들어옴 → getTeam() SQL X → N+1 해결!
JOIN vs FETCH JOIN 정리:
| JOIN | FETCH JOIN | |
|---|---|---|
| 결과 | Member만 | Member + Team |
| 영속성 컨텍스트 | Member만 | 둘 다 |
| getter 시 추가 쿼리 | 발생 | 없음 |
자기 점검
선수 지식: Unit 9.2
핵심 함정 ⭐ :
@ManyToOne + 페이징 — 정상 동작:
List<Member> members = em.createQuery(
"SELECT m FROM Member m JOIN FETCH m.team ORDER BY m.id", Member.class)
.setFirstResult(0)
.setMaxResults(3) // ✅ 정상
.getResultList();
SELECT m.*, t.* FROM Member m JOIN Team t ON ... ORDER BY m.id LIMIT 3;
→ Member 기준 페이징 OK
@OneToMany + 페이징 — 데이터 조회 문제 발생 ⭐ :
List<Team> teams = em.createQuery(
"SELECT t FROM Team t JOIN FETCH t.members", Team.class)
.setFirstResult(0)
.setMaxResults(3) // ❌ 위험!
.getResultList();
무엇이 문제인가:
-- 의도: Team 3개
-- 실제 SQL:
SELECT t.*, m.* FROM Team t JOIN Member m ON t.id = m.team_id LIMIT 3;
LIMIT 3 가 JOIN 결과의 행 수 에 적용됨!
Hibernate의 대응:
firstResult/maxResults specified with collection fetch; applying in memory!왜 이 문제가 일어나는가:
"OneToMany는 1행이 N행으로 늘어남. 페이징의 단위가 깨짐"
자기 점검
선수 지식: Unit 9.3
핵심 해결
@BatchSize:
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
@BatchSize(size = 10) // 최대 10개씩 묶어서
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members;
}
페이징 + @BatchSize 사용:
List<Team> teams = em.createQuery(
"SELECT t FROM Team t ORDER BY t.id", Team.class)
.setFirstResult(0)
.setMaxResults(3) // ✅ Team 기준 정확한 페이징
.getResultList();
for (Team team : teams) {
team.getMembers().size();
}
실행되는 SQL:
-- (1) Team 페이징 조회
SELECT id FROM Team ORDER BY id LIMIT 3;
-- (2) IN 절로 한 번에 멤버 조회
SELECT * FROM Member WHERE team_id IN (1, 2, 3);
→ 2번의 쿼리로 끝! (1+N → 2 또는 1+N/배치사이즈)
전역 설정:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
⚠️ 주의 — fetch join과 함께 쓰지 말 것:
3가지 해결책 비교 ⭐ :
| 도구 | 적합 상황 |
|---|---|
| fetch join | ManyToOne 단건, 페이징 없는 컬렉션 |
| @BatchSize | OneToMany + 페이징, 일반 LAZY 최적화 |
| EntityGraph | 동적으로 fetch 전략 결정 |
자기 점검
목표: JPA 학습을 마무리하는 4가지 도구.
선수 지식: Phase 5, 6
핵심 개념
CASCADE:
@Entity
public class Parent {
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
private List<Child> children = new ArrayList<>();
}
CASCADE 옵션:
| 옵션 | 동작 |
|---|---|
ALL | 모든 작업 전파 |
PERSIST | 저장만 전파 |
REMOVE | 삭제만 전파 |
MERGE | 병합만 전파 |
REFRESH | 새로고침 |
DETACH | 분리 |
실무 사례:
Parent parent = new Parent();
parent.addChild(child1);
parent.addChild(child2);
em.persist(parent); // child1, child2도 자동 저장!
언제 사용:
자기 점검
선수 지식: Unit 10.1
핵심 개념
orphanRemoval:
@Entity
public class Parent {
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> children = new ArrayList<>();
public void removeChild(Child child) {
children.remove(child);
child.setParent(null);
}
}
동작:
Parent parent = em.find(Parent.class, 1L);
parent.removeChild(parent.getChildren().get(0));
// → DELETE 자동 실행
CASCADE.REMOVE vs orphanRemoval ⭐ :
| CASCADE.REMOVE | orphanRemoval | |
|---|---|---|
| 트리거 | 부모 자체 삭제 | 자식 컬렉션 제거 |
| 사용 시점 | em.remove(parent) | parent.children.remove(child) |
둘 다 있어야 완벽한 부모-자식 관리:
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
⚠️ 주의:
자기 점검
선수 지식: Phase 5
핵심 개념
JPQL (Java Persistence Query Language):
List<User> users = em.createQuery(
"SELECT u FROM User u WHERE u.age > 20", User.class)
.getResultList();
JPQL → SQL 변환:
SELECT * FROM user WHERE age > 20;
SQL과의 차이:
SQL이 필요한 시점:
em.createNativeQuery(...)자기 점검
선수 지식: Unit 10.3
핵심 개념
QueryDSL:
JPQL의 문제:
QueryDSL의 해결:
List<User> users = queryFactory
.selectFrom(qUser)
.where(qUser.age.gt(20))
.orderBy(qUser.name.asc())
.offset(10)
.limit(5)
.fetch();
자동 SQL 변환:
SELECT * FROM user WHERE age > 20 ORDER BY name ASC LIMIT 5 OFFSET 10;
QueryDSL의 장점 ⭐ :
1. 타입 안전 — 컴파일 시점 오류 검출
2. 자동완성 — IDE 지원
3. 동적 쿼리 — BooleanBuilder, 조건문 자유롭게
4. 재사용성 — 메서드로 추출 가능
실무 표준 조합:
[Spring Data JPA] - 단순 CRUD, 메서드 이름 쿼리
+
[QueryDSL] - 복잡 쿼리, 동적 쿼리
=
실무 JPA 프로젝트
ILIC 사례 (가능성):
자기 점검
★★★ 면접·실무 단골 (반드시):
★★ 매우 권장:
[ Part A — 11주차 ]
[ ] Phase 1 — 싱글톤 + SQL Mapper의 역사 (Unit 1.1~1.3)
[ ] Phase 2 — ORM/JPA 본질 (Unit 2.1~2.3)
[ ] Phase 3 — 엔티티 매핑 (Unit 3.1~3.4)
[ ] Phase 4 — EntityManager + 영속성 컨텍스트 (Unit 4.1~4.4)
[ ] Phase 5 — 4가지 장점 (Unit 5.1~5.5) ★ 11주차 정점
[ Part B — 12주차 ]
[ ] Phase 6 — 4가지 연관관계 (Unit 6.1~6.5)
[ ] Phase 7 — mappedBy (Unit 7.1~7.3)
[ ] Phase 8 — 프록시와 로딩 (Unit 8.1~8.4)
[ ] Phase 9 — N+1 문제와 해결 (Unit 9.1~9.4) ★ 12주차 정점
[ ] Phase 10 — CASCADE + JPQL/QueryDSL (Unit 10.1~10.4)
[ ] 종합 자기 점검 33문항 통과
11주차 정점 — Phase 5 (4가지 장점):
12주차 정점 — Phase 9 (N+1 문제):
Java 학습의 또 하나의 클라이맥스:
→ 8-9주차가 AOP의 정점, 11-12주차가 JPA의 정점
이번 2주차는 반드시 SQL 로그를 켜고 학습하세요:
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
use_sql_comments: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
특히 Phase 5 (4가지 장점) 와 Phase 9 (N+1) 는 SQL 로그 없이는 이해 불가능. 직접 실행하면서 쿼리가 어떻게 나가는지를 시각적으로 확인하세요.