콘텐츠 대표 이미지 - ☕ JPA 완전정복! 자바 개발자를 위한 데이터베이스 마법사 되기

☕ JPA 완전정복! 자바 개발자를 위한 데이터베이스 마법사 되기

Java Persistence API로 시작하는 객체-관계 매핑의 세계

안녕! 😊 오늘은 자바 개발자라면 꼭 알아야 할 JPA(Java Persistence API)에 대해 이야기해볼까 해.
데이터베이스 작업할 때마다 SQL 쿼리 작성하느라 머리 아팠던 경험 있지?
JPA를 제대로 이해하고 나면, 마치 마법처럼 객체 지향적으로 데이터베이스를 다룰 수 있게 돼.

이 글에서는 JPA의 기초부터 실전 활용까지, 친구에게 설명하듯 쉽고 재미있게 풀어볼 거야.
재능넷의 지식인의 숲에서 여러분과 함께 JPA의 세계로 떠나보자! 🚀

🎯 JPA가 뭐길래 이렇게 난리야?

먼저 JPA가 정확히 뭔지부터 알아보자.
JPA는 Java Persistence API의 약자로, 자바 진영에서 ORM(Object-Relational Mapping) 기술의 표준으로 사용되는 인터페이스의 모음이야.

여기서 중요한 포인트는 JPA는 '명세(specification)'일 뿐이라는 거야.
실제 구현체는 Hibernate, EclipseLink, DataNucleus 같은 프레임워크들이 담당하지.
그중에서도 Hibernate가 가장 널리 사용되고 있어. 🐻

💡 ORM이 뭐냐고?

ORM은 Object-Relational Mapping의 약자야.
쉽게 말하면 객체와 관계형 데이터베이스의 데이터를 자동으로 매핑해주는 기술이지.

우리가 자바에서 사용하는 객체(Object)와 데이터베이스의 테이블(Relational)은 패러다임이 달라.
이 둘 사이의 불일치를 해결해주는 게 바로 ORM이야!

전통적인 JDBC 방식으로 개발하면 이런 코드를 작성해야 했어:

String sql = "SELECT * FROM users WHERE id = ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setLong(1, userId);
ResultSet rs = pstmt.executeQuery();

if (rs.next()) {
    User user = new User();
    user.setId(rs.getLong("id"));
    user.setName(rs.getString("name"));
    user.setEmail(rs.getString("email"));
    // ... 반복적인 매핑 작업
}

하지만 JPA를 사용하면? 이렇게 간단해져:

User user = entityManager.find(User.class, userId);

와우! 😲 한 줄로 끝이야.
이게 바로 JPA의 매력이지.
SQL을 직접 작성하지 않아도 되고, 결과를 객체로 매핑하는 반복 작업도 필요 없어.

Java Application 객체 지향 세계 User user = new User(); user.setName("홍길동"); Database 관계형 세계 TABLE users id | name | email JPA 자동 매핑 마법사 생산성 향상 유지보수 용이 데이터베이스 독립성 객체지향 설계 JPA의 4가지 핵심 장점

🏗️ JPA의 핵심 구조 이해하기

JPA를 제대로 활용하려면 그 구조를 이해하는 게 중요해.
마치 건물의 설계도를 보듯이, JPA의 핵심 구성요소들을 하나씩 살펴보자. 🏛️

EntityManagerFactory와 EntityManager

JPA의 가장 핵심이 되는 두 가지 개념이야.

EntityManagerFactory는 이름 그대로 EntityManager를 생성하는 공장이야.
애플리케이션 전체에서 딱 한 번만 생성되고, 모든 스레드가 공유해서 사용해.
생성 비용이 크기 때문에 애플리케이션 시작 시점에 만들어두고 계속 재사용하는 거지.

// EntityManagerFactory 생성 (애플리케이션 전체에서 한 번만!)
EntityManagerFactory emf = 
    Persistence.createEntityManagerFactory("myPersistenceUnit");

// EntityManager 생성 (요청마다 생성)
EntityManager em = emf.createEntityManager();

EntityManager는 실제로 엔티티를 저장하고, 수정하고, 삭제하고, 조회하는 작업을 수행하는 친구야.
데이터베이스 커넥션과 밀접한 관계가 있어서 스레드 간에 공유하면 안 돼.
보통 요청이 들어올 때마다 생성해서 사용하고, 작업이 끝나면 닫아줘야 해.

🎯 실전 팁: Spring에서는 어떻게?

Spring Framework를 사용하면 이런 복잡한 관리를 자동으로 해줘.
@PersistenceContext 어노테이션만 붙이면 EntityManager를 주입받을 수 있고,
트랜잭션 관리도 @Transactional 어노테이션으로 간단하게 처리할 수 있어.

그래서 실무에서는 대부분 Spring Data JPA를 함께 사용하지!

영속성 컨텍스트 (Persistence Context)

이건 JPA를 이해하는 데 정말정말 중요한 개념이야! 🌟

영속성 컨텍스트는 "엔티티를 영구 저장하는 환경"이라는 뜻이야.
쉽게 말하면, 엔티티 객체들을 관리하는 일종의 캐시 공간이라고 생각하면 돼.

EntityManager를 통해 엔티티를 저장하거나 조회하면,
그 엔티티는 영속성 컨텍스트에 보관돼.

EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();

// 엔티티 생성 (비영속 상태)
User user = new User();
user.setId(1L);
user.setName("김철수");

// 엔티티를 영속성 컨텍스트에 저장 (영속 상태)
em.persist(user);

// 실제 DB에 반영
tx.commit();

영속성 컨텍스트가 제공하는 엄청난 이점들이 있어:

1차 캐시 💾
영속성 컨텍스트 내부에는 1차 캐시가 있어.
한 번 조회한 엔티티는 1차 캐시에 저장되고, 같은 트랜잭션 내에서 다시 조회하면
데이터베이스에 가지 않고 캐시에서 바로 가져와. 성능이 좋아지는 거지!

// 첫 번째 조회: DB에서 가져와서 1차 캐시에 저장
User user1 = em.find(User.class, 1L);

// 두 번째 조회: 1차 캐시에서 바로 가져옴 (DB 접근 X)
User user2 = em.find(User.class, 1L);

// user1과 user2는 같은 인스턴스!
System.out.println(user1 == user2); // true

동일성 보장 🎯
같은 트랜잭션 내에서 같은 엔티티를 조회하면 항상 같은 인스턴스를 반환해.
위 코드에서 봤듯이 user1 == user2가 true가 나와.
마치 자바 컬렉션에서 객체를 꺼내는 것처럼 동작하는 거야.

트랜잭션을 지원하는 쓰기 지연 ✍️
이게 진짜 신기한 기능이야!
엔티티를 저장할 때 바로 INSERT SQL을 데이터베이스에 보내지 않아.
대신 SQL을 모아뒀다가 트랜잭션을 커밋하는 순간 한 번에 보내는 거지.

EntityTransaction tx = em.getTransaction();
tx.begin();

em.persist(userA); // SQL을 쓰기 지연 저장소에 모아둠
em.persist(userB); // SQL을 쓰기 지연 저장소에 모아둠
em.persist(userC); // SQL을 쓰기 지연 저장소에 모아둠

// 커밋하는 순간 DB에 INSERT SQL을 한 번에 전송
tx.commit();

변경 감지 (Dirty Checking) 🔍
이건 정말 마법 같은 기능이야!
영속 상태의 엔티티의 값을 변경하면, JPA가 자동으로 감지해서 UPDATE SQL을 생성해줘.
따로 update 메서드를 호출할 필요가 없어.

EntityTransaction tx = em.getTransaction();
tx.begin();

// 영속 엔티티 조회
User user = em.find(User.class, 1L);

// 엔티티 데이터 수정
user.setName("이영희");

// em.update(user) 같은 코드 필요 없음!
// 커밋 시점에 자동으로 UPDATE SQL 실행
tx.commit();

✅ 변경 감지의 원리

JPA는 엔티티를 영속성 컨텍스트에 보관할 때, 최초 상태를 복사해서 저장해둬 (스냅샷).
그리고 트랜잭션 커밋 시점에 스냅샷과 현재 엔티티를 비교해서
변경된 부분이 있으면 자동으로 UPDATE SQL을 생성하는 거야.

이게 바로 Dirty Checking이야! 🎨

지연 로딩 (Lazy Loading) 🐌
연관된 엔티티를 실제로 사용하는 시점에 조회하는 기능이야.
예를 들어 User와 Order가 연관관계일 때,
User를 조회할 때 Order까지 함께 조회하지 않고,
실제로 Order를 사용하는 시점에 조회하는 거지.

영속성 컨텍스트의 생명주기 비영속 new User() 객체만 생성 영속 em.persist() 관리 중 준영속 em.detach() 분리됨 삭제 em.remove() 제거 영속성 컨텍스트의 5가지 핵심 기능 1차 캐시 빠른 조회 동일성 보장 쓰기 지연 배치 처리 변경 감지 자동 UPDATE 지연 로딩 필요시 조회

📦 엔티티 매핑의 기초

이제 본격적으로 엔티티를 어떻게 매핑하는지 알아보자! 🎨
엔티티 매핑은 크게 4가지로 나눌 수 있어:

• 객체와 테이블 매핑: @Entity, @Table
• 필드와 컬럼 매핑: @Column
• 기본 키 매핑: @Id
• 연관관계 매핑: @ManyToOne, @OneToMany 등

@Entity와 @Table

@Entity는 이 클래스가 JPA 엔티티라는 걸 알려주는 어노테이션이야.
이 어노테이션이 붙은 클래스는 JPA가 관리하게 돼.

@Entity
@Table(name = "users")  // 테이블명 지정 (생략 시 클래스명 사용)
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "user_name", nullable = false, length = 50)
    private String name;
    
    @Column(unique = true)
    private String email;
    
    private int age;  // @Column 생략 가능 (필드명이 컬럼명)
    
    @Temporal(TemporalType.TIMESTAMP)
    private Date createdDate;
    
    @Enumerated(EnumType.STRING)
    private UserRole role;
    
    @Lob  // CLOB, BLOB 타입 매핑
    private String description;
    
    @Transient  // DB에 저장하지 않는 필드
    private String tempData;
    
    // Getter, Setter...
}

🎯 @Entity 사용 시 주의사항

• 기본 생성자는 필수야 (public 또는 protected)
• final 클래스, enum, interface, inner 클래스에는 사용할 수 없어
• 저장할 필드에 final을 사용하면 안 돼

이런 제약이 있는 이유는 JPA가 리플렉션을 사용해서 객체를 생성하고 관리하기 때문이야!

기본 키 매핑 전략

기본 키를 어떻게 생성할지 정하는 건 정말 중요해.
JPA는 다양한 기본 키 생성 전략을 제공하고 있어. 🔑

IDENTITY 전략
기본 키 생성을 데이터베이스에 위임하는 방식이야.
MySQL의 AUTO_INCREMENT 같은 기능을 사용해.

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

SEQUENCE 전략
데이터베이스 시퀀스를 사용해서 기본 키를 생성해.
Oracle, PostgreSQL, H2 같은 데이터베이스에서 사용 가능해.

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

TABLE 전략
키 생성 전용 테이블을 만들어서 시퀀스를 흉내내는 방식이야.
모든 데이터베이스에서 사용 가능하지만 성능이 좀 떨어져.

AUTO 전략
데이터베이스 방언에 따라 자동으로 전략을 선택해줘.
기본값이기도 해.

⚠️ IDENTITY 전략의 특별한 점

IDENTITY 전략은 쓰기 지연이 동작하지 않아.
왜냐하면 기본 키 값을 데이터베이스에 INSERT 해봐야 알 수 있거든.

그래서 em.persist()를 호출하는 즉시 INSERT SQL이 실행되고,
데이터베이스에서 식별자를 조회해와.

이게 IDENTITY 전략의 단점이지만, 대부분의 경우 큰 문제는 없어!

필드와 컬럼 매핑

필드를 데이터베이스 컬럼에 매핑할 때 사용하는 어노테이션들을 살펴보자.

@Column
가장 많이 사용하는 어노테이션이야. 다양한 속성을 제공해:

@Column(
    name = "user_name",           // 컬럼명
    nullable = false,             // NOT NULL 제약조건
    unique = true,                // UNIQUE 제약조건
    length = 100,                 // 문자 길이 (String에만)
    precision = 10,               // 숫자 전체 자릿수 (BigDecimal)
    scale = 2,                    // 소수점 자릿수 (BigDecimal)
    columnDefinition = "TEXT"     // 직접 컬럼 정의
)
private String name;

@Enumerated
Enum 타입을 매핑할 때 사용해.
반드시 EnumType.STRING을 사용해야 해!

public enum UserRole {
    ADMIN, USER, GUEST
}

// 올바른 사용
@Enumerated(EnumType.STRING)
private UserRole role;

// 잘못된 사용 (절대 사용하지 마!)
@Enumerated(EnumType.ORDINAL)  // 0, 1, 2로 저장됨
private UserRole role;

✅ EnumType.ORDINAL을 사용하면 안 되는 이유

ORDINAL은 Enum의 순서를 숫자로 저장해 (0, 1, 2...).
만약 나중에 Enum에 새로운 값이 추가되면?
기존 데이터의 의미가 완전히 바뀌어버려! 😱

예를 들어 ADMIN(0), USER(1), GUEST(2)였는데,
SUPER_ADMIN을 맨 앞에 추가하면 모든 순서가 바뀌잖아?

그래서 무조건 EnumType.STRING을 사용해야 해!

@Temporal
날짜 타입을 매핑할 때 사용해.
Java 8 이후로는 LocalDate, LocalDateTime을 사용하면 생략 가능해.

// Java 8 이전
@Temporal(TemporalType.TIMESTAMP)
private Date createdDate;

// Java 8 이후 (권장)
private LocalDateTime createdDate;  // @Temporal 불필요
private LocalDate birthDate;

@Lob
CLOB, BLOB 같은 대용량 데이터를 저장할 때 사용해.

@Lob
private String content;  // CLOB으로 매핑

@Lob
private byte[] image;    // BLOB으로 매핑

@Transient
이 필드는 데이터베이스에 저장하지 않고 메모리에서만 사용할 때 붙여.

@Transient
private String tempPassword;  // DB에 저장 안 됨

🔗 연관관계 매핑의 세계

이제 JPA의 꽃이라고 할 수 있는 연관관계 매핑을 알아보자! 🌸
객체는 참조를 사용하고, 테이블은 외래 키를 사용하는데,
이 둘의 패러다임 차이를 JPA가 어떻게 해결하는지 보자.

단방향 연관관계

가장 기본적인 다대일(N:1) 관계를 예로 들어볼게.
여러 명의 회원이 하나의 팀에 속하는 상황이야.

@Entity
public class Member {
    
    @Id @GeneratedValue
    private Long id;
    
    private String username;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")  // 외래 키 컬럼명
    private Team team;
    
    // Getter, Setter...
}

@Entity
public class Team {
    
    @Id @GeneratedValue
    private Long id;
    
    private String name;
    
    // Getter, Setter...
}

이렇게 하면 Member에서 Team을 참조할 수 있어:

// 팀 저장
Team team = new Team();
team.setName("개발팀");
em.persist(team);

// 회원 저장
Member member = new Member();
member.setUsername("김철수");
member.setTeam(team);  // 연관관계 설정
em.persist(member);

// 조회
Member findMember = em.find(Member.class, member.getId());
Team findTeam = findMember.getTeam();  // 객체 그래프 탐색
System.out.println("팀 이름: " + findTeam.getName());

양방향 연관관계

이번엔 Team에서도 Member를 참조할 수 있게 만들어보자.
양방향 연관관계는 사실 단방향 연관관계 2개야!

@Entity
public class Member {
    
    @Id @GeneratedValue
    private Long id;
    
    private String username;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
    
    // 연관관계 편의 메서드
    public void setTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}

@Entity
public class Team {
    
    @Id @GeneratedValue
    private Long id;
    
    private String name;
    
    @OneToMany(mappedBy = "team")  // 연관관계의 주인이 아님!
    private List<Member> members = new ArrayList<>();
    
    // Getter, Setter...
}

💡 연관관계의 주인이란?

양방향 연관관계에서는 연관관계의 주인을 정해야 해.

연관관계의 주인만이 외래 키를 관리할 수 있어 (등록, 수정).
주인이 아닌 쪽은 읽기만 가능해.

주인은 mappedBy 속성을 사용하지 않고,
주인이 아닌 쪽에서 mappedBy로 주인을 지정해줘.

규칙: 외래 키가 있는 곳을 주인으로 정해!
위 예제에서는 Member.team이 주인이야. (Member 테이블에 team_id 외래 키가 있으니까)

양방향 연관관계를 사용하면 이렇게 할 수 있어:

// 팀에서 회원 목록 조회
Team team = em.find(Team.class, teamId);
List<Member> members = team.getMembers();

for (Member member : members) {
    System.out.println("회원: " + member.getUsername());
}

⚠️ 양방향 연관관계 주의사항

양방향 연관관계를 설정할 때는 양쪽 모두에 값을 설정해줘야 해!

잘못된 예:

Member member = new Member();
member.setUsername("김철수");
member.setTeam(team);  // 한쪽만 설정
em.persist(member);

올바른 예:

Member member = new Member();
member.setUsername("김철수");
member.setTeam(team);           // 연관관계의 주인에 설정
team.getMembers().add(member);  // 반대편에도 설정
em.persist(member);

그래서 위 예제처럼 연관관계 편의 메서드를 만들어서 사용하는 게 좋아!

다양한 연관관계

JPA는 다양한 연관관계를 지원해. 하나씩 살펴보자! 📚

일대다 (1:N) - @OneToMany
일대다 단방향은 권장하지 않아. 왜냐하면 외래 키가 다(N) 쪽 테이블에 있는데,
일(1) 쪽에서 관리하려고 하면 복잡해지거든.

// 권장하지 않는 방식
@Entity
public class Team {
    
    @OneToMany
    @JoinColumn(name = "team_id")  // 다른 테이블의 외래 키를 관리
    private List<Member> members = new ArrayList<>();
}

// 이렇게 하면 Team을 저장할 때 Member 테이블에 UPDATE SQL이 추가로 실행됨
// 성능과 관리 측면에서 비효율적!

일대일 (1:1) - @OneToOne
일대일 관계는 주 테이블이나 대상 테이블 중 어느 곳에나 외래 키를 둘 수 있어.

@Entity
public class Member {
    
    @Id @GeneratedValue
    private Long id;
    
    private String username;
    
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "locker_id")
    private Locker locker;
}

@Entity
public class Locker {
    
    @Id @GeneratedValue
    private Long id;
    
    private String name;
    
    @OneToOne(mappedBy = "locker")
    private Member member;
}

다대다 (N:M) - @ManyToMany
관계형 데이터베이스는 다대다 관계를 표현할 수 없어서 중간 테이블이 필요해.
하지만 실무에서는 @ManyToMany를 사용하지 않아!

// 이론적으로는 가능하지만 실무에서는 사용 X
@Entity
public class Member {
    
    @ManyToMany
    @JoinTable(name = "member_product")
    private List<Product> products = new ArrayList<>();
}

// 대신 중간 엔티티를 만들어서 일대다, 다대일로 풀어내!
@Entity
public class MemberProduct {
    
    @Id @GeneratedValue
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;
    
    @ManyToOne
    @JoinColumn(name = "product_id")
    private Product product;
    
    private int orderAmount;  // 중간 테이블에 추가 정보 가능!
    private LocalDateTime orderDate;
}
JPA 연관관계 매핑 종류 다대일 (N:1) @ManyToOne 가장 많이 사용 외래키가 N쪽에 ★ 권장 ★ 일대다 (1:N) @OneToMany 단방향은 비추천 양방향 권장 △ 주의 필요 일대일 (1:1) @OneToOne 주테이블에 외래키 또는 대상테이블에 ○ 상황따라 다대다 (N:M) @ManyToMany 실무에서 사용X 중간엔티티로 풀기 ✗ 비권장 연관관계 설정 시 체크리스트 연관관계의 주인을 명확히 설정했는가? 양방향이라면 양쪽 모두 값을 설정했는가? 지연 로딩(LAZY)을 사용하고 있는가?

⚡ 즉시 로딩과 지연 로딩

연관관계를 설정할 때 가장 중요한 개념 중 하나가 바로 로딩 전략이야! 🎯
이걸 잘못 설정하면 성능 문제가 발생할 수 있어.

즉시 로딩 (EAGER Loading)

엔티티를 조회할 때 연관된 엔티티도 함께 조회하는 방식이야.

@Entity
public class Member {
    
    @ManyToOne(fetch = FetchType.EAGER)  // 즉시 로딩
    private Team team;
}

// Member를 조회하면 Team도 함께 조회됨
Member member = em.find(Member.class, 1L);
// SELECT * FROM member m JOIN team t ON m.team_id = t.id

지연 로딩 (LAZY Loading)

연관된 엔티티를 실제로 사용할 때 조회하는 방식이야.

@Entity
public class Member {
    
    @ManyToOne(fetch = FetchType.LAZY)  // 지연 로딩
    private Team team;
}

// Member만 조회됨
Member member = em.find(Member.class, 1L);
// SELECT * FROM member WHERE id = 1

// Team을 실제로 사용할 때 조회됨
String teamName = member.getTeam().getName();
// SELECT * FROM team WHERE id = ?

✅ 실무에서의 권장 사항

모든 연관관계는 지연 로딩(LAZY)을 사용해!

즉시 로딩을 사용하면 예상하지 못한 SQL이 발생할 수 있어.
특히 JPQL을 사용할 때 N+1 문제가 발생하기 쉬워.

N+1 문제란?
1번의 쿼리로 N개의 데이터를 가져왔는데,
연관된 데이터를 가져오기 위해 N번의 추가 쿼리가 발생하는 문제야.

예를 들어 회원 100명을 조회했는데,
각 회원의 팀 정보를 가져오기 위해 100번의 추가 쿼리가 실행되는 거지! 😱

N+1 문제 해결 방법

N+1 문제를 해결하는 방법은 여러 가지가 있어:

1. Fetch Join 사용
JPQL에서 fetch join을 사용하면 한 번의 쿼리로 연관된 엔티티까지 조회할 수 있어.

// JPQL
String jpql = "select m from Member m join fetch m.team";
List<Member> members = em.createQuery(jpql, Member.class)
                         .getResultList();

// 실행되는 SQL
// SELECT m.*, t.* FROM member m INNER JOIN team t ON m.team_id = t.id

2. @EntityGraph 사용
Spring Data JPA에서 제공하는 기능이야.

public interface MemberRepository extends JpaRepository<Member, Long> {
    
    @EntityGraph(attributePaths = {"team"})
    List<Member> findAll();
}

3. @BatchSize 사용
연관된 엔티티를 지정한 size만큼 IN 쿼리로 조회해.

@Entity
public class Team {
    
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}

// 100개씩 묶어서 조회
// SELECT * FROM member WHERE team_id IN (?, ?, ... ?)

🎯 로딩 전략 선택 가이드

• @ManyToOne, @OneToOne: 기본이 EAGER → LAZY로 변경 필수!
• @OneToMany, @ManyToMany: 기본이 LAZY → 그대로 사용

실무에서는 모든 연관관계를 LAZY로 설정하고,
필요한 곳에서만 fetch join이나 @EntityGraph를 사용하는 게 베스트야! 👍

🔍 JPQL과 Criteria API

JPA는 다양한 쿼리 방법을 제공해.
그중에서도 가장 중요한 JPQL에 대해 알아보자! 📝

JPQL이란?

JPQL은 Java Persistence Query Language의 약자야.
SQL과 비슷하지만, 테이블이 아닌 엔티티 객체를 대상으로 쿼리한다는 게 핵심이야.

// SQL (테이블 대상)
SELECT * FROM member WHERE age > 18

// JPQL (엔티티 객체 대상)
SELECT m FROM Member m WHERE m.age > 18

JPQL의 장점은 데이터베이스에 독립적이라는 거야.
JPQL로 작성하면 JPA가 각 데이터베이스 방언에 맞게 SQL로 변환해줘.

기본 JPQL 문법

TypedQuery와 Query

// TypedQuery: 반환 타입이 명확할 때
TypedQuery<Member> query = 
    em.createQuery("SELECT m FROM Member m", Member.class);
List<Member> members = query.getResultList();

// Query: 반환 타입이 명확하지 않을 때
Query query = 
    em.createQuery("SELECT m.username, m.age FROM Member m");
List results = query.getResultList();

파라미터 바인딩

// 이름 기준 파라미터 (권장)
String jpql = "SELECT m FROM Member m WHERE m.username = :username";
List<Member> members = em.createQuery(jpql, Member.class)
    .setParameter("username", "김철수")
    .getResultList();

// 위치 기준 파라미터 (비권장)
String jpql = "SELECT m FROM Member m WHERE m.username = ?1";
List<Member> members = em.createQuery(jpql, Member.class)
    .setParameter(1, "김철수")
    .getResultList();

⚠️ 절대 문자열 연결로 쿼리를 만들지 마!

잘못된 예:

String username = request.getParameter("username");
String jpql = "SELECT m FROM Member m WHERE m.username = '" + username + "'";
// SQL Injection 공격에 취약!

올바른 예:

String jpql = "SELECT m FROM Member m WHERE m.username = :username";
query.setParameter("username", username);
// 안전하게 파라미터 바인딩!

프로젝션
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

// NEW 명령어로 DTO 직접 조회
SELECT new com.example.MemberDTO(m.username, m.age) 
FROM Member m

페이징
JPA는 데이터베이스에 독립적인 페이징 API를 제공해.

String jpql = "SELECT m FROM Member m ORDER BY m.age DESC";
List<Member> members = em.createQuery(jpql, Member.class)
    .setFirstResult(10)   // 10번째부터 (0부터 시작)
    .setMaxResults(20)    // 20개 조회
    .getResultList();

// MySQL: LIMIT 10, 20
// Oracle: ROWNUM
// 각 DB에 맞게 자동 변환!

조인

// 내부 조인
SELECT m FROM Member m INNER JOIN m.team t
WHERE t.name = '개발팀'

// 외부 조인
SELECT m FROM Member m LEFT JOIN m.team t

// 세타 조인
SELECT m FROM Member m, Team t
WHERE m.username = t.name

// Fetch 조인 (성능 최적화)
SELECT m FROM Member m JOIN FETCH m.team

Criteria API

Criteria는 JPQL을 자바 코드로 작성할 수 있게 해주는 API야.
문자열이 아닌 코드로 작성하기 때문에 컴파일 시점에 오류를 잡을 수 있어.

// JPQL
String jpql = "SELECT m FROM Member m WHERE m.age > 18";

// Criteria
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Member> query = cb.createQuery(Member.class);

Root<Member> m = query.from(Member.class);
query.select(m)
     .where(cb.gt(m.get("age"), 18));

List<Member> members = em.createQuery(query).getResultList();

하지만 솔직히 말하면, Criteria는 너무 복잡해서 실무에서 잘 안 써. 😅
대신 QueryDSL이라는 오픈소스 라이브러리를 많이 사용해.
QueryDSL은 Criteria의 장점은 살리면서 훨씬 간결하고 직관적이야!

// QueryDSL 예제
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
QMember member = QMember.member;

List<Member> members = queryFactory
    .selectFrom(member)
    .where(member.age.gt(18))
    .orderBy(member.username.asc())
    .fetch();

// 깔끔하고 직관적이지? 😊

💡 쿼리 방법 선택 가이드

• 간단한 조회: EntityManager의 find() 메서드
• 복잡한 조회: JPQL 또는 QueryDSL
• 동적 쿼리: QueryDSL (강력 추천!)
• 네이티브 SQL: 정말 필요한 경우에만 (최후의 수단)

재능넷에서 JPA 관련 강의를 찾아보면
QueryDSL 활용법을 더 자세히 배울 수 있을 거야! 🎓

🎨 고급 매핑 기법

이제 좀 더 고급 기능들을 알아보자.
실무에서 자주 마주치는 상황들에 대한 해결책이야! 💪

상속관계 매핑

객체는 상속 관계가 있지만, 관계형 데이터베이스는 상속 관계가 없어.
JPA는 이를 해결하기 위해 3가지 전략을 제공해.

1. 조인 전략 (JOINED)
각각의 테이블로 변환하는 방식이야. 가장 정규화된 방법이지.

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "dtype")  // 구분 컬럼
public abstract class Item {
    
    @Id @GeneratedValue
    private Long id;
    
    private String name;
    private int price;
}

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

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {
    private String director;
    private String actor;
}

@Entity
@DiscriminatorValue("B")
public class Book extends Item {
    private String author;
    private String isbn;
}

이렇게 하면 Item, Album, Movie, Book 테이블이 각각 생성되고,
조회할 때 JOIN을 사용해서 데이터를 가져와.

장점:
• 테이블이 정규화됨
• 외래 키 참조 무결성 제약조건 활용 가능
• 저장공간 효율적

단점:
• 조회 시 조인이 많이 사용되어 성능 저하 가능
• 조회 쿼리가 복잡함
• 데이터 저장 시 INSERT SQL 2번 실행

2. 단일 테이블 전략 (SINGLE_TABLE)
하나의 테이블에 모든 데이터를 저장하는 방식이야. JPA의 기본 전략이지.

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
public abstract class Item {
    // ...
}

장점:
• 조인이 필요 없어서 조회 성능이 빠름
• 조회 쿼리가 단순함

단점:
• 자식 엔티티가 매핑한 컬럼은 모두 null 허용
• 테이블이 커질 수 있어서 상황에 따라 조회 성능이 느려질 수 있음

3. 구현 클래스마다 테이블 전략 (TABLE_PER_CLASS)
각 구현 클래스마다 테이블을 만드는 방식이야. 추천하지 않아!

@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Item {
    // ...
}

// Album, Movie, Book 테이블만 생성됨 (Item 테이블 없음)

이 전략은 여러 자식 테이블을 함께 조회할 때 UNION을 사용해야 해서 성능이 안 좋아.
실무에서는 거의 사용하지 않아.

🎯 상속관계 매핑 전략 선택 가이드

• 조인 전략: 정석적인 방법. 복잡하지 않다면 이걸 선택!
• 단일 테이블 전략: 단순하고 성능이 중요하면 선택
• 구현 클래스마다 테이블 전략: 사용하지 마! ❌

대부분의 경우 조인 전략이나 단일 테이블 전략 중 하나를 선택하면 돼.

@MappedSuperclass

공통 매핑 정보가 필요할 때 사용하는 어노테이션이야.
예를 들어 모든 엔티티에 생성일시, 수정일시 같은 공통 속성이 필요할 때 유용해.

@MappedSuperclass
public abstract class BaseEntity {
    
    @Column(name = "created_date")
    private LocalDateTime createdDate;
    
    @Column(name = "modified_date")
    private LocalDateTime modifiedDate;
    
    @Column(name = "created_by")
    private String createdBy;
    
    @Column(name = "modified_by")
    private String modifiedBy;
    
    @PrePersist
    public void prePersist() {
        this.createdDate = LocalDateTime.now();
        this.modifiedDate = LocalDateTime.now();
    }
    
    @PreUpdate
    public void preUpdate() {
        this.modifiedDate = LocalDateTime.now();
    }
}

@Entity
public class Member extends BaseEntity {
    
    @Id @GeneratedValue
    private Long id;
    
    private String username;
    
    // createdDate, modifiedDate 등이 자동으로 상속됨!
}

@Entity
public class Order extends BaseEntity {
    
    @Id @GeneratedValue
    private Long id;
    
    private int orderAmount;
    
    // 이것도 BaseEntity의 속성들을 상속받음!
}

@MappedSuperclass는 엔티티가 아니고, 테이블과 매핑되지도 않아.
단순히 자식 클래스에 매핑 정보만 제공하는 역할이야.

실무에서 정말 유용하게 사용되는 기능이니까 꼭 기억해둬! 📌

복합 키와 식별 관계

데이터베이스 설계를 하다 보면 복합 키를 사용해야 할 때가 있어.
JPA에서는 @IdClass나 @EmbeddedId를 사용해서 복합 키를 매핑할 수 있어.

@IdClass 사용

// 식별자 클래스
public class MemberId implements Serializable {
    
    private String id1;
    private String id2;
    
    // 기본 생성자
    public MemberId() {}
    
    // equals, hashCode 구현 필수!
    @Override
    public boolean equals(Object o) { ... }
    
    @Override
    public int hashCode() { ... }
}

@Entity
@IdClass(MemberId.class)
public class Member {
    
    @Id
    private String id1;
    
    @Id
    private String id2;
    
    private String username;
}

// 사용
MemberId memberId = new MemberId();
memberId.setId1("id1");
memberId.setId2("id2");

Member member = em.find(Member.class, memberId);

@EmbeddedId 사용 (더 객체지향적)

@Embeddable
public class MemberId implements Serializable {
    
    private String id1;
    private String id2;
    
    // 기본 생성자
    public MemberId() {}
    
    public MemberId(String id1, String id2) {
        this.id1 = id1;
        this.id2 = id2;
    }
    
    // equals, hashCode 구현 필수!
    @Override
    public boolean equals(Object o) { ... }
    
    @Override
    public int hashCode() { ... }
}

@Entity
public class Member {
    
    @EmbeddedId
    private MemberId id;
    
    private String username;
}

// 사용
MemberId memberId = new MemberId("id1", "id2");
Member member = em.find(Member.class, memberId);

✅ 복합 키 사용 시 주의사항

• 식별자 클래스는 Serializable을 구현해야 해
• equals와 hashCode를 반드시 구현해야 해
• 기본 생성자가 있어야 해
• 식별자 클래스는 public이어야 해

하지만 솔직히 말하면, 실무에서는 복합 키보다 대리 키(Long id)를 권장해.
복합 키는 관리가 복잡하고 JPA에서도 제약이 많거든! 🤔

🚀 실전 활용 팁

이제 실무에서 JPA를 사용할 때 알아두면 좋은 팁들을 공유할게! 💡

엔티티 설계 시 주의사항

1. Setter를 무분별하게 사용하지 마
Setter를 public으로 열어두면 어디서든 엔티티를 변경할 수 있어서 유지보수가 어려워져.

// 나쁜 예
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String username;
    private int age;
    
    // public setter는 위험!
    public void setUsername(String username) {
        this.username = username;
    }
    
    public void setAge(int age) {
        this.age = age;
    }
}

// 좋은 예
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String username;
    private int age;
    
    // 생성자로 필수 값 설정
    protected Member() {}  // JPA용 기본 생성자
    
    public Member(String username, int age) {
        this.username = username;
        this.age = age;
    }
    
    // 비즈니스 의미가 있는 메서드로 변경
    public void changeUsername(String username) {
        // 비즈니스 로직 검증
        if (username == null || username.isEmpty()) {
            throw new IllegalArgumentException("이름은 필수입니다");
        }
        this.username = username;
    }
    
    public void aging() {
        this.age++;
    }
}

2. 모든 연관관계는 지연 로딩으로
앞에서도 말했지만 정말 중요해서 다시 한번! 😊

// 모든 @XToOne 관계는 LAZY로!
@ManyToOne(fetch = FetchType.LAZY)
private Team team;

@OneToOne(fetch = FetchType.LAZY)
private Locker locker;

3. 컬렉션은 필드에서 초기화
null 문제에서 안전하고, Hibernate가 제공하는 내장 컬렉션으로 변경되는 것을 방지할 수 있어.

@Entity
public class Team {
    
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();  // 필드에서 초기화!
    
    // 생성자에서 초기화하지 마!
    // public Team() {
    //     this.members = new ArrayList<>();
    // }
}

4. 양방향 연관관계는 신중하게
정말 필요한 경우가 아니면 단방향 연관관계만 사용하는 게 좋아.
양방향은 복잡도를 높이거든.

성능 최적화

1. 영속성 컨텍스트를 플러시하는 시점 이해하기

// 플러시가 발생하는 경우:
// 1. em.flush() 직접 호출
// 2. 트랜잭션 커밋
// 3. JPQL 쿼리 실행

// 예제
Member member = new Member("김철수");
em.persist(member);

// JPQL 실행 전에 자동으로 flush 발생!
// (영속성 컨텍스트의 변경 내용을 DB에 반영)
List<Member> members = em.createQuery("select m from Member m", Member.class)
                         .getResultList();

2. 벌크 연산 주의
한 번에 여러 데이터를 수정하거나 삭제하는 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리를 날려.

// 벌크 연산
int resultCount = em.createQuery("update Member m set m.age = m.age + 1")
                    .executeUpdate();

// 문제: 영속성 컨텍스트에는 변경 전 데이터가 남아있음!

// 해결책 1: 벌크 연산 후 영속성 컨텍스트 초기화
em.clear();

// 해결책 2: 벌크 연산을 먼저 실행
// 해결책 3: Spring Data JPA의 @Modifying(clearAutomatically = true)

3. 읽기 전용 쿼리 힌트 사용
조회만 하고 수정하지 않을 데이터는 읽기 전용으로 조회하면 성능이 향상돼.

// Spring Data JPA
@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Member findReadOnlyByUsername(String username);

4. 배치 처리
대량의 데이터를 처리할 때는 주기적으로 flush와 clear를 해줘야 해.

int batchSize = 100;

for (int i = 0; i < 100000; i++) {
    Member member = new Member("member" + i);
    em.persist(member);
    
    if (i % batchSize == 0) {
        em.flush();   // DB에 반영
        em.clear();   // 영속성 컨텍스트 초기화
    }
}

Spring Data JPA 활용

실무에서는 JPA를 직접 사용하기보다 Spring Data JPA를 사용하는 경우가 많아.
Spring Data JPA는 JPA를 한 단계 더 추상화해서 사용하기 쉽게 만든 거야.

// 인터페이스만 작성하면 구현체는 Spring이 자동으로 생성!
public interface MemberRepository extends JpaRepository<Member, Long> {
    
    // 메서드 이름으로 쿼리 생성
    List<Member> findByUsername(String username);
    
    List<Member> findByUsernameAndAge(String username, int age);
    
    // @Query로 JPQL 직접 작성
    @Query("select m from Member m where m.username = :username")
    Member findByUsernameQuery(@Param("username") String username);
    
    // DTO로 직접 조회
    @Query("select new com.example.MemberDto(m.id, m.username) from Member m")
    List<MemberDto> findMemberDto();
    
    // 페이징
    Page<Member> findByAge(int age, Pageable pageable);
}

// 사용
@Service
public class MemberService {
    
    @Autowired
    private MemberRepository memberRepository;
    
    public void saveMember() {
        Member member = new Member("김철수", 25);
        memberRepository.save(member);
    }
    
    public List<Member> findMembers() {
        return memberRepository.findAll();
    }
    
    public Page<Member> findMembersWithPaging(int page) {
        PageRequest pageRequest = PageRequest.of(page, 10);
        return memberRepository.findByAge(25, pageRequest);
    }
}

🎯 Spring Data JPA 메서드 이름 규칙

• findBy: 조회
• countBy: 카운트
• existsBy: 존재 여부
• deleteBy: 삭제

• And: 그리고
• Or: 또는
• Between: 사이
• LessThan: 미만
• GreaterThan: 초과
• Like: 포함
• OrderBy: 정렬

예: findByUsernameAndAgeGreaterThanOrderByAgeDesc

JPA 실무 활용 체크리스트 ✅ 필수 체크사항 모든 연관관계는 지연 로딩(LAZY) 양방향 연관관계는 신중하게 Setter 사용 최소화 컬렉션은 필드에서 초기화 @Enumerated는 STRING 사용 기본 생성자는 protected N+1 문제 주의 트랜잭션 범위 명확히 영속성 컨텍스트 생명주기 이해 대량 데이터는 배치 처리 QueryDSL 적극 활용 ❌ 피해야 할 것들 즉시 로딩(EAGER) 사용 @ManyToMany 사용 양방향 연관관계 남발 엔티티를 DTO로 직접 사용 영속성 컨텍스트 무시한 벌크 연산 트랜잭션 없이 데이터 수정 문자열로 쿼리 조합 준영속 상태 엔티티 수정 시도 과도한 fetch join 복합 키 남용 Lombok @Data 사용

🎓 마치며

와! 여기까지 읽었다면 정말 대단해! 👏
JPA는 처음에는 어렵게 느껴질 수 있지만, 한번 익숙해지면 정말 강력한 도구야.

이 글에서 다룬 내용을 정리하면:

🎯 핵심 개념
• JPA는 자바 ORM 기술의 표준 명세
• 영속성 컨텍스트가 JPA의 핵심
• 1차 캐시, 동일성 보장, 쓰기 지연, 변경 감지, 지연 로딩 등의 기능 제공

🔗 연관관계 매핑
• 단방향과 양방향 연관관계 이해
• 연관관계의 주인 개념
• 다양한 연관관계 종류 (@ManyToOne, @OneToMany, @OneToOne)
• 지연 로딩(LAZY) 필수 사용

⚡ 성능 최적화
• N+1 문제 해결 (fetch join, @EntityGraph, @BatchSize)
• JPQL과 QueryDSL 활용
• 벌크 연산 주의
• 배치 처리 시 flush/clear

🚀 실무 팁
• Spring Data JPA 적극 활용
• 엔티티 설계 원칙 준수
• 양방향 연관관계는 신중하게
• QueryDSL로 동적 쿼리 작성

💡 더 공부하고 싶다면?

JPA는 정말 방대한 기술이라 이 글에서 다루지 못한 내용도 많아.

• 2차 캐시
• 네이티브 SQL
• Stored Procedure
• 컨버터
• 리스너와 콜백
• 엔티티 그래프

재능넷에서 JPA 관련 전문가들의 강의나 멘토링을 찾아보면
더 깊이 있는 내용을 배울 수 있을 거야! 🎓

특히 실무 경험이 풍부한 개발자들의 노하우를 배우면
책에서는 배울 수 없는 실전 팁들을 얻을 수 있어.

JPA를 처음 배울 때는 SQL을 직접 작성하는 게 더 편하다고 느낄 수 있어.
하지만 프로젝트 규모가 커지고 복잡해질수록 JPA의 진가가 드러나.

객체 지향적으로 설계하고, 데이터베이스에 독립적이며,
생산성이 높고 유지보수가 쉬운 코드를 작성할 수 있게 되거든! 💪

처음에는 어렵더라도 포기하지 말고 꾸준히 연습해봐.
간단한 프로젝트부터 시작해서 점점 복잡한 기능을 추가해가면서
JPA의 각 기능들을 하나씩 익혀가는 거야.

✅ 학습 로드맵 추천

1단계: 기초 다지기
• 영속성 컨텍스트 이해
• 엔티티 매핑 연습
• 기본 CRUD 작성

2단계: 연관관계 마스터
• 단방향/양방향 연관관계
• 다양한 연관관계 종류
• 지연 로딩과 즉시 로딩

3단계: 쿼리 작성
• JPQL 기본 문법
• QueryDSL 도입
• N+1 문제 해결

4단계: 실전 프로젝트
• Spring Data JPA 활용
• 성능 최적화
• 실무 패턴 적용

각 단계마다 충분히 연습하고 이해한 후 다음 단계로 넘어가는 게 중요해!

마지막으로, JPA는 도구일 뿐이라는 걸 잊지 마.
무조건 JPA를 사용해야 하는 건 아니고, 상황에 맞게 선택하면 돼.

간단한 쿼리는 MyBatis가 더 나을 수도 있고,
복잡한 통계 쿼리는 네이티브 SQL이 더 적합할 수도 있어.

중요한 건 각 기술의 장단점을 이해하고 적재적소에 활용하는 능력이야! 🎯

자, 이제 여러분도 JPA 마법사가 될 준비가 되었어! ✨
오늘 배운 내용을 바탕으로 멋진 프로젝트를 만들어보자.

궁금한 점이 있거나 더 깊이 배우고 싶다면
재능넷에서 관련 전문가들을 찾아보는 것도 좋은 방법이야.

행복한 코딩 되길 바라! 화이팅! 🚀💻

☕🎨🔗⚡🚀

Happy Coding with JPA!

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.