JPA, Spring Data JPA

leedong617·2024년 10월 3일
post-thumbnail

JPA(Java Persistence API)는 JAVA ORM(Object-Relational Mapping 객체 관계 매핑) 기술에 대한 표준 명세로 인터페이스의 모음임.
따라서 실제로 동작하는 것이 아니기 때문에 구현체가 필요한데, JPA 표준을 구현한 구현체는 아래와 같이 Hibernate, EclipseLink, DataNucleus가 있으며 대표적으로 Hibernate를 사용함.
JPA는 객체와 관계형 데이터베이스 간의 데이터를 자동으로 변환해 주는 역할을 함.
JPA는 자바 객체와 데이터베이스의 테이블 간의 매핑을 지원하며, 데이터베이스 접근을 객체 지향적으로 처리할 수 있게 해줌.
JPA는 자바 애플리케이션에서 데이터베이스 연동 시 SQL을 직접 작성하지 않고도 데이터를 쉽게 관리할 수 있도록 도와줌.

JPA의 주요 개념

ORM (Object-Relational Mapping)

  • ORM은 객체지향 프로그래밍 언어와 관계형 데이터베이스 간의 불일치를 해결하는 기법임.
  • JPA는 객체지향 프로그래밍에서 사용되는 자바 객체와 관계형 데이터베이스의 테이블을 매핑함으로써 객체 지향적인 방법으로 데이터를 관리할 수 있게 해줌.
  • 각 자바 클래스는 데이터베이스의 테이블에 대응하고, 각 필드는 테이블의 컬럼에 매핑됨.

Entity

  • Entity는 데이터베이스 테이블에 매핑되는 자바 클래스임. JPA에서는 엔터티 클래스를 사용하여 테이블의 행을 자바 객체로 표현함.
  • @Entity 어노테이션을 사용하여 클래스가 엔터티임을 명시함.
@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    private int age;
    
    // Getter와 Setter 생략
}
  • @Id: 해당 필드는 기본 키(Primary Key)를 나타냄.
  • @GeneratedValue: 기본 키 값이 자동으로 생성되도록 설정함.

EntityManager

  • Entity(엔터티)를 Manager(관리)해주는 역할을 하는 메모리상에 존재하는 "가상의 데이터베이스"라고 할 수 있음.
  • EntityManager는 JPA에서 엔터티를 관리하고 데이터베이스와 상호작용하기 위한 핵심 인터페이스로, 엔터티 객체의 생성, 삭제, 수정, 조회 작업을 담당함.
  • EntityManager는 Persistence Context와 연결되어 있으며, 이를 통해 엔터티 객체의 상태를 관리함.
public class UserRepository {

    @PersistenceContext
    private EntityManager entityManager;
    
    public User findUser(Long id) {
        return entityManager.find(User.class, id);  // 특정 사용자 조회
    }

    public void saveUser(User user) {
        entityManager.persist(user);  // 새로운 사용자 저장
    }
}

EntityManager의 주요 메소드

  • persist(Object entity): 새로운 엔터티를 영속성 컨텍스트에 저장하고, 데이터베이스에 해당 데이터를 삽입함. 즉, 새로운 레코드를 추가하는 메소드임.
User user = new User();
user.setName("John");
entityManager.persist(user);  // user 엔터티가 영속성 컨텍스트에 저장됨.
  • find(Class<T> entityClass, Object primaryKey): 기본 키로 엔터티 객체를 조회함. 영속성 컨텍스트에 엔터티가 있으면 캐시된 엔터티를 반환하고, 없으면 데이터베이스에서 조회함.
User user = entityManager.find(User.class, 1L);  // ID가 1인 User 엔터티를 조회
  • merge(Object entity): 변경된 엔터티를 영속성 컨텍스트에 병합하고 저장함. 분리된 엔터티(Detached 상태)도 병합할 수 있음.
user.setName("Jane");
entityManager.merge(user);  // 분리되거나 변경된 엔터티를 영속성 컨텍스트에 병합하고 저장함.
  • remove(Object entity): 엔터티를 영속성 컨텍스트에서 삭제하고, 데이터베이스에서 해당 엔터티를 제거함.
entityManager.remove(user);  // 영속성 컨텍스트에서 엔터티 제거 후, 데이터베이스에서 삭제
  • flush(): 영속성 컨텍스트의 변경 사항을 데이터베이스에 반영함(동기화). 자동으로 데이터베이스에 반영되지 않도록 하기 위해 수동으로 명시할 때 사용됨.
entityManager.flush();  // 현재 영속성 컨텍스트의 변경 사항을 데이터베이스에 반영

트랜잭션 commit과 EntityManager의 flush차이

flush는 영속성 컨텍스트의 내용을 데이터베이스에 반영(동기화)하는 과정이며, 이는 데이터베이스와 영속성 컨텍스트 사이의 스냅샷을 일치시키는 작업임 이는 에러 발생 시 롤백이 가능한 단계까지만 반영하는 작업임을 설명. 반면, commit은 진행 중인 트랜잭션을 완료하고 해당 트랜잭션내의 모든 변경 사항들을 데이터베이스에 영구적으로 반영하는 과정임. 해당 단계 이후에는 롤백이 불가능함. commit은 영속성 컨텍스트를 비우지만 flush는 영속성 컨텍스트를 비우지 않아 1차캐시 사용가능함.

요약: flush해서 db에 삽입 혹은 변경이 이루어졌더라도 이후 해당 트랜잭션을 commit하지 않는다면 데이터베이스에 영구적인 저장이 이루어지지 않음.

Entitymanager와 Thread-safe

EntityManager는 Thread-Safe하지 않기 때문에 동시성 문제가 발생할 수 있음.
그래서 EntityManager는 스레드간에 공유를 절대로 해서는 안됨.
일반적으로 EntityManager를 @PersistenceContext로 Spring이 관리해주는 방식으로 사용함.
@PersistenceContext를 사용해서 EntityManager를 주입받으면 Spring에서 EntityManager를 Proxy로 감싼 EntityManager를 생성해서 주입해주기에 Thread-Safe를 보장함.

EntityManager의 생명주기

  • EntityManager는 트랜잭션과 밀접하게 연관됨. 트랜잭션이 시작될 때 EntityManager는 영속성 컨텍스트를 통해 엔터티 객체를 관리하고, 트랜잭션이 끝나면(커밋 또는 롤백) 영속성 컨텍스트에 있는 변경 사항을 데이터베이스에 반영함.
  • 트랜잭션 범위 안에서만 활성화되며, 트랜잭션이 끝나면 영속성 컨텍스트도 종료됨.

영속성 컨텍스트 (Persistence Context)

  • Persistence Context는 엔터티 객체를 관리하는 환경으로, 엔터티의 생명주기를 관리함.
  • 애플리케이션과 데이터베이스 간의 중개자 역할을 하며, 엔터티 객체의 상태를 추적하고 캐싱하는 역할을 함.
  • JPA에서 EntityManager가 Persistence Context를 통해 엔터티 객체를 관리함.

Persistence Context의 주요 특징

1차 캐시 (영속 엔티티의 동일성 보장)

  • 영속성 컨텍스트는 1차 캐시로 동작하여, 이미 조회된 엔터티를 메모리에 저장함.
  • 같은 트랜잭션 안에서 동일한 엔터티를 다시 조회하면, 데이터베이스에 다시 쿼리를 보내지 않고 1차 캐시에서 값을 조회함.
  • 이를 통해 불필요한 데이터베이스 접근을 줄이고 성능을 최적화할 수 있음.
  • 1차 캐시는 영속성 컨텍스트 내부에서 관리되며, 특정 트랜잭션 내에서 엔터티 객체가 캐시되는 영역임.
User user1 = entityManager.find(User.class, 1L);  // 데이터베이스에서 조회됨
User user2 = entityManager.find(User.class, 1L);  // 1차 캐시에서 조회됨 (쿼리 발생하지 않음)

트랜잭션 쓰기 지연

EntityManager는 동일한 트랜잭션 내에서 트랜잭션을 commit하기 전까지 데이터베이스에 Entity를 저장하지 않고 영속성 컨텍스트 내부의 SQL저장소에 쿼리를 저장해둠.
commit을 하게 되면 SQL저장소에 저장해두었던 쿼리를 데이터베이스에 보내고 EntityManager는 영속성 컨텍스트를 flush함.
이 후 트랜잭션을 commit함.

EntityTransaction transaction = entityManager.getTransaction();
//엔티티 매니저는 데이터 변경시 트랜잭션을 시작해야 한다.
transaction.begin(); // 트랜잭션 시작
entityManager.persist(user1);
entityManager.persist(user2);

//여기까지 INSERT SQL을 데이터베이스에 보내지 않는다.
//커밋하는 순간 데이터베이스에 INSERT SQL을 보낸다.
transaction.commit(); // 트랜잭션 커밋

변경 감지 (Dirty Checking)

  • 영속성 컨텍스트는 변경 감지(Dirty Checking) 기능을 통해, 영속 상태의 엔터티가 변경되었는지 추적함(스냅샷 비교). 트랜잭션이 커밋되기 전 혹은 flush 전 까지 엔터티의 상태를 감지하고, 변경된 필드가 있으면 따로 Entity 업데이트에 대한 메서드 필요 없이 자동으로 데이터베이스에 업데이트함.
user.setName("KIM");  // 엔터티 필드 값 변경 (변경 감지)
transaction.commit();    // 변경 사항이 데이터베이스에 자동으로 반영됨

지연로딩 (Lazy Loading)

연관관계에 있는 엔티티를 조회시 한번에 가져오지 않고 필요시에 가져오는 것임.
연관관계에 있는 객체는 프록시 상태로 초기화되지 않은 상태로 존재함.
필요시에 가져오기 때문에 불필요한 쿼리를 실행하지 않을 수 있음.

각 연관관계의 Default 로딩값
OneToMany : Lazy
ManyToMany : Lazy
ManyToOne : Eager
OneToOne : Eager

엔터티의 생명주기 (Entity LifeCycle)

엔터티 객체는 생명주기(Lifecycle)에 따라 상태가 변함. JPA는 엔터티의 상태를 관리하며, 엔터티는 다음 네 가지 상태 중 하나에 속함.

New/Transient(비영속 상태)

엔터티 객체가 영속성 컨텍스트에 저장되지 않았으며, 데이터베이스와 연관이 없는 상태.

// 객체를 생성만 하고 저장하지 않은 상태 (비영속)
User user1 = new User();
user1.setName("KIM");

Managed(영속 상태)

엔터티가 영속성 컨텍스트에 저장된 상태.

// 객체를 영속성 컨텍스트에 저장한 상태 (영속)
entityManager.persist(user1);
entityManager.find(User.class, 1);

Detached(준영속 상태)

엔터티가 한때 영속성 컨텍스트에 저장되었지만, 현재는 영속성 컨텍스트에서 분리된 상태.
비영속 상태에 가까움.
영속성 컨텍스트가 제공하는 어떠한 기능도 동작하지 않는다.(지연로딩, 1차 캐시, 쓰기 지연, 변경 감지)
비영속 상태는 식별자 값이 없지만, 준영속 상태는 이미 한번 영속 상태였으므로 식별자 값(ID)을 가지고 있다.

//회원 엔티티를 영속성 컨텍스트에서 분리 (준영속)
entityManager.detach(user1);

//영속성 컨텍스트를 완전히 초기화
entityManager.clear();

//영속성 컨텍스트를 종료
entityManager.close();

entityManager.detach(entity): 1차 캐시부터 쓰기 지연 SQL 저장소까지 해당 엔티티를 관리하기 위한 모든 정보가 제거됨.

entityManager.clear(): 영속성 컨텍스트를 초기화해서 해당 영속성 컨텍스트의 모든 엔티티를 준영속 상태로 만든다.

entityManager.close(): 해당 영속성 컨텍스트가 관리하던 영속 상태의 엔티티가 모두 준영속 상태가 된다.

Removed(삭제)

엔터티가 영속성 컨텍스트와 데이터베이스에서 삭제된 상태.

entityManager.remove(user1);

JPQL (Java Persistence Query Language)

JPQL(Java Persistence Query Language)은 JPA(Java Persistence API)에서 사용하는 객체 지향적 쿼리 언어임. JPQL은 데이터베이스 테이블을 대상으로 하는 SQL과 달리, 엔터티 객체를 대상으로 쿼리를 작성함. 즉, JPQL은 SQL과 유사한 문법을 사용하지만, 실제로는 객체를 기반으로 동작하며, JPA에서 엔터티 매핑을 통해 데이터베이스 테이블과 연동됨.

  • JPQL은 데이터베이스 독립적으로 작동하도록 설계되어 있으며, 데이터베이스의 테이블이나 컬럼을 직접 참조하는 대신, 엔터티 클래스와 필드를 참조함.
  • 이를 통해 객체지향적인 방법으로 데이터베이스를 조회하거나 조작할 수 있음.
String jpql = "SELECT u FROM User u WHERE u.age > :age";
TypedQuery<User> query = entityManager.createQuery(jpql, User.class);
query.setParameter("age", 20);
List<User> users = query.getResultList();
  • JPQL은 SQL과 다르게 엔터티와 엔터티 필드를 대상으로 쿼리를 수행함.
  • createQuery(): JPQL 쿼리를 생성하고, TypedQuery를 반환함.

JPQL의 주요 특징

객체 지향적 쿼리

  • JPQL은 SQL처럼 데이터베이스 테이블과 컬럼을 대상으로 쿼리를 실행하는 것이 아니라, 엔터티 클래스와 필드를 대상으로 쿼리를 작성함.
  • 따라서 JPQL 쿼리에서는 데이터베이스의 테이블 이름 대신 엔터티 클래스 이름을 사용하고, 테이블의 컬럼 대신 엔터티 필드를 사용함.
-- SQL에서는
SELECT * FROM User WHERE age > 20;

-- JPQL에서는
SELECT u FROM User u WHERE u.age > 20;
  • SQL의 테이블(User) 대신, JPQL에서는 엔터티 클래스(User)를 참조함.
  • SQL의 컬럼(age) 대신, JPQL에서는 엔터티의 필드(age)를 사용함.

데이터베이스 독립성

  • JPQL은 특정 데이터베이스에 종속되지 않으며, JPA가 JPQL을 실행할 때 적절한 네이티브 SQL로 변환해 각 DBMS에 맞는 SQL 쿼리로 변환하여 실행함.
  • 이를 통해 개발자는 JPQL을 사용해 다양한 데이터베이스에서 데이터베이스 독립적인 쿼리를 작성할 수 있음.

엔터티 관계 지원

  • JPQL은 객체지향 모델을 기반으로 하기 때문에, JPA 엔터티 간의 연관 관계(1:1, 1:N, N:M)를 활용하여 객체 간의 관계를 쉽게 쿼리할 수 있음.
-- 예를 들어, User 엔터티와 Address 엔터티가 연관 관계가 있을 때,
SELECT u FROM User u JOIN u.address a WHERE a.city = 'Seoul';
  • 위 JPQL은 User 엔터티와 연관된 Address 엔터티의 city 필드를 조회함.

JPQL 문법

JPQL은 SQL과 매우 유사한 문법을 가지고 있음.

주요 문법 요소

1. SELECT

SELECT 쿼리는 엔터티 객체 또는 특정 필드를 조회하기 위해 사용됨.

SELECT u FROM User u
  • User 엔터티에서 u라는 별칭을 사용하여 모든 User 엔터티를 조회함.
  • SQL의 SELECT * FROM User에 해당.

2. WHERE

WHERE 절은 조건에 맞는 데이터를 조회할 때 사용됨.

SELECT u FROM User u WHERE u.age > 20
  • u.age > 20이라는 조건에 맞는 User 엔터티를 조회함.
  • SQL의 WHERE age > 20과 동일하게 작동.

3. JOIN

JOIN은 연관된 엔터티를 조회할 때 사용됨. JPQL에서는 객체 간의 연관 관계를 기반으로 조인을 수행함.

SELECT u FROM User u JOIN u.address a WHERE a.city = 'Seoul'
  • User 엔터티와 Address 엔터티 간의 연관 관계를 조인하여, city 필드가 Seoul인 사용자들을 조회함.

4. ORDER BY

ORDER BY는 조회한 결과를 정렬할 때 사용됨.

SELECT u FROM User u ORDER BY u.name ASC
  • 이름 순으로 오름차순 정렬하여 User 엔터티를 조회함.

5. GROUP BY

GROUP BY는 특정 필드를 기준으로 그룹화하여 조회할 때 사용됨.

SELECT u.city, COUNT(u) FROM User u GROUP BY u.city
  • city별로 그룹화하고, 각 도시별 사용자 수를 카운트함.

6. HAVING

HAVING은 GROUP BY와 함께 사용하여, 그룹화된 결과에 조건을 부여할 때 사용됨.

SELECT u.city, COUNT(u) FROM User u GROUP BY u.city HAVING COUNT(u) > 10

각 도시에서 10명 이상의 사용자가 있는 도시만 조회함.

7. LIMIT (setMaxResults)

LIMIT은 JPQL에서는 제공하지 않지만, setMaxResults() 메소드를 사용하여 결과 수를 제한할 수 있음.

TypedQuery<User> query = entityManager.createQuery("SELECT u FROM User u", User.class);
query.setMaxResults(10);  // 최대 10개의 결과만 반환

8. 서브쿼리

JPQL은 서브쿼리도 지원하며, 주로 SELECT, WHERE 절에서 사용됨.

SELECT u FROM User u WHERE u.age > (SELECT AVG(u2.age) FROM User u2)
  • 나이가 평균보다 많은 사용자들을 조회하는 쿼리.

FETCH JOIN

JPQL에서 성능 최적화를 위해 제공하는 기능으로 연관된 엔티티나 컬렉션들을 한번의 SQL 쿼리로 함께 조회할 수 있음.
연관된 엔티티에 대해 추가적인 쿼리를 실행할 필요 없이 효율적인 로드를 할 수 있음.
즉, 패치 조인은 성능 최적화에 주로 사용되며, N+1 문제를 해결하는 데 효과적임.

일반 Join 과 Fetch Join의 차이

일반 Join과 Fetch Join의 차이는 데이터를 조회할 때 어떻게 가져오느냐에 있음.

일반 Join

일반 Join은 데이터베이스 테이블을 조인하여 연관된 데이터만 가져옴. 하지만 가져온 데이터를 사용하려고 할 때 별도의 쿼리를 수행해야 함.
예를 들어, 멤버(Member)와 팀(Team)이 있는데, 팀에 소속되 있는 모든 멤버를 조회하고 싶다면, 일반 Join에서는 멤버와 팀 테이블을 조인하여 팀 정보를 가져옴.
SELECT t FROM Team t JOIN t.members
Join 조건을 제외하고 실제 질의하는 대상 Entity에 대한 컬럼만 SELECT

Fetch Join

조회의 주체가 되는 엔티티와 그 관련 엔티티들 까지 함께 조회한다.
이를 객체 그래프 로드라고 하는데, 즉시 원하는 정보를 사용할 수 있도록 데이터를 로드해온다.
이 방식을 통해 한 번의 쿼리로 필요한 정보를 모두 가져와서 성능을 향상시킬 수 있다.
위 예시에서 Fetch Join을 사용하면 팀과 해당 팀에 소속된 모든 멤버 데이터를 한번에 가져와 별도의 쿼리 없이 바로 멤버 정보에 접근할 수 있다.
SELECT t FROM Team t JOIN FETCH t.members
실제 질의하는 대상 Entity와 Fetch join이 걸려있는 Entity를 포함한 컬럼 함께 SELECT

One-to-One 이나 Many-to-One 관계에 Fetch Join 팁

SELECT m FROM Member m JOIN FETCH m.team WHERE m.team.id = :teamId
m.team.id: 리스트 엔터티가 아닌 단일 엔터티를 참조하는 경우 연관 테이블 컬럼에 접근 할 수 있음.

Fetch Join을 사용하면 좋은 경우

연관 엔티티의 데이터를 자주 사용하는 경우

지연 로딩의 경우 연관 엔티티를 자주 가져오는 경우에 성능 저하가 발생할 수 있음.
이 때, 연관 엔티티의 데이터를 함께 가져오면 지연 로딩에 따른 추가 쿼리를 줄이고 성능을 향상시킬 수 있음.
즉, A만 필요할 때는 FetchType.Lazy 지연 로딩으로 A 만 불러 사용하고, 
A와 B를 같이 부르고 싶을 경우에는 FetchType.Lazy  + Fetch Join 조합을 사용하여 성능을 향상 시킬 수 있음.

N+1 문제가 발생하는 경우

N+1 문제는 연관 엔티티가 실제 사용될 때마다 별도의 쿼리가 발생하여 성능 이슈를 초래하는 문제임.
이 경우, 추가적인 쿼리가 불필요하게 발생하여 성능 저하가 발생함.
Fetch Join은 연관 엔티티와 함께 즉시 로딩되므로, N+1 문제를 제거할 수 있음.

주의할점

Fetch Join을 사용할 때 일대다(OnetoMany) 또는 다대다(ManytoMany) 관계의 엔티티가 많이 포함되면, 불필요한 중복 데이터를 가져오는 일이 발생할 수 있음.
이 경우, DISTINCT 키워드를 사용하여 중복 데이터를 제거할 수 있음.

JPQL의 DISTINCT

  • SQL의 DISTINCT 기능에 추가하여, 동일한 엔티티면 중복 제거 (Application Level에서 엔티티 중복을 제거함)
  • 즉, 같은 식별자를 가진 Entity는 중복제거가 가능함.
    SELECT DISTINCT o FROM Order o JOIN FETCH o.products WHERE o.id = :orderId

Fetch join의 한계

2개 이상의 컬렉션을 한 번에 Fetch Join 불가

  • 2개 이상의 컬렉션에 대해 Fetch Join을 사용하면, 결과 데이터의 크기가 곱셈 원리로 인해 급증하게 됨.

컬렉션을 Fetch Join하면 페이징 API를 사용할 수 없음
페이징 기능을 사용하여 데이터를 검색할 때, 일반적으로 데이터베이스에서 필요한 범위의 데이터만 가져온 후 이를 화면에 표시함.
그러나 Fetch Join을 사용할 경우 서로 관련된 데이터를 함께 로드하게 되며, 이 때문에 페이징 처리가 제대로 이루어지지 않을 수 있음.
이 문제는 OneToMany와 ManyToMany 연관관계에서 데이터가 중복되거나 데이터의 크기가 곱이 되면서 가장 뚜렷하게 나타남.

Fetch Join 대상 Entity에는 별칭 불가

  • 별칭을 사용하면 결과 열과 그 이름을 바뀔 수 있음.
  • 이 경우 JPA는 연관된 엔티티 대신 별칭을 사용하여 결과를 리턴함. 
  • 이로 인해 예기치 않은 결과를 리턴할 수 있음.
  • 하지만 일관성을 해치지 않는 경우에는 별칭을 사용해도 문제가 되지 않음.
  • 일관성이 깨져도 조회용도로만 사용하면 크게 문제가 되지는 않음.
  • 패치 조인 대상에는 별칭을 줄 수 없기에 SELECT, WHERE, 서브 쿼리에 패치 조인 대상을 사용할 수 없음.

JPQL의 데이터 조작 (INSERT, UPDATE, DELETE)

JPQL에서는 데이터 조작을 위한 INSERT 문은 존재하지 않음. 대신, persist() 메소드를 사용하여 새로운 엔터티를 저장함. 그러나 UPDATE와 DELETE는 JPQL에서 지원함.

UPDATE

UPDATE 쿼리는 엔터티 객체의 데이터를 수정하는 데 사용됨.

UPDATE User u SET u.age = 30 WHERE u.name = 'John'
  • 이름이 John인 사용자의 나이를 30으로 업데이트함.

DELETE

DELETE 쿼리는 엔터티 객체를 삭제하는 데 사용됨.

DELETE FROM User u WHERE u.age < 20
  • 나이가 20살 미만인 사용자를 삭제함.

JPQL 사용 예시

기본 조회 쿼리

String jpql = "SELECT u FROM User u WHERE u.age > :age";
TypedQuery<User> query = entityManager.createQuery(jpql, User.class);
query.setParameter("age", 20);
List<User> users = query.getResultList();
  • 나이가 20살 이상인 사용자들을 조회하는 JPQL 쿼리.

JOIN 쿼리

String jpql = "SELECT u FROM User u JOIN u.address a WHERE a.city = :city";
TypedQuery<User> query = entityManager.createQuery(jpql, User.class);
query.setParameter("city", "Seoul");
List<User> users = query.getResultList();
  • 서울에 사는 사용자를 조회하는 JPQL 쿼리.

집계 함수와 GROUP BY

String jpql = "SELECT u.city, COUNT(u) FROM User u GROUP BY u.city";
TypedQuery<Object[]> query = entityManager.createQuery(jpql, Object[].class);
List<Object[]> results = query.getResultList();
  • 도시별 사용자 수를 그룹화하여 조회하는 JPQL 쿼리.

JPQL의 장점

  • 객체 지향적 쿼리: JPQL은 엔터티 객체를 기반으로 하여 쿼리를 작성하므로, 객체 지향적 패러다임을 유지하면서 데이터베이스 작업을 할 수 있음.

  • 데이터베이스 독립성: JPQL은 특정 DBMS에 종속되지 않으며, JPA가 JPQL을 각 데이터베이스에 맞는 SQL로 변환해주므로, 데이터베이스 독립적인 코드를 작성할 수 있음.

  • 자동 변환 및 최적화: JPA는 JPQL을 적절한 네이티브 SQL로 변환하고, 성능을 최적화함.

JPA의 동작 과정

JPA는 JAVA 애플리케이션과 JDBC 사이에서 동작한다. JAVA 애플리케이션에서 JPA를 사용하면 내부에서 JDBC API를 통해 SQL을 DB에 전달하고 결과를 반환받음.

JPA 어노테이션

@Entity

  • 클래스가 JPA 엔터티임을 나타냄. 이 클래스는 데이터베이스 테이블과 매핑됨.
  • JPA에서 엔터티 클래스는 반드시 @Entity 어노테이션으로 선언되어야 함.

속성

  • name: JPA에서 사용할 엔터티 이름 지정, 지정하지 않으면 클래스 명을 사용.
    @Entity(name = "Users")

주의사항

  1. 접근 제어자가 public 혹은 protected 인 기본생성자 필수.
  2. final, enum, interface, inner클래스에는 사용할 수 없음.
  3. 저장하려는 필드에 final을 사용 할 수 없음.

엔티티의 이름을 언제 사용하나?

JPQL은 테이블이 아닌 엔티티를 대상으로 쿼리를 수행하는데, 이때 이 쿼리에 엔티티의 이름이 사용됨.
SELECT u FROM Users u WHERE u.name = "KIM"

@Id

  • 테이블의 기본 키와 매핑되는 식별자 변수를 매핑
  • 식별자 변수는 테이블의 기본 키(Primary Key)와 매핑되는 변수를 의미한다.
@Entity(name = "Users")
@Data
public class User {
    @Id
    private String id;
}

@GeneratedValue

  • 변수에 자동으로 증가된 값을 할당.
  • 보통 식별자 값(PK)에 사용

속성

속성기능기본값
strategy식별자 생성 전략을 선택GenerationType.AUTO
generator사용할 식별자 생성 전략을 명시적으로 지정된 생성기 이름을 사용하여 설정할 때 사용

식별자 생성 전략

GenerationType.AUTO

@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id
  • JPA 구현체(예: Hibernate)가 사용하는 데이터베이스에 맞는 식별자 생성 전략을 자동으로 선택
  • 직접 DBMS에 맞는 전략을 지정해주는 것이 좋음.

GenerationType.IDENTITY

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
  • 기본 키 생성을 데이터베이스에 위임하는 전략
  • 주로 MySQL, PostgreSQL, SQL Server에서 사용
  • 자동 증가 열을 지원하는 데이터베이스(예: MySQL, PostgreSQL, SQL Server)에 적합
  • 데이터베이스에 자동 증가(AUTO_INCREMENT) 기능을 사용하는 방식
  • AUTO_INCREMENT처럼 데이터베이스에 값을 저장하고 나서야 기본 키 값을 구할 수 있을 때 사용
    주의점

엔티티가 영속 상태가 되기 위해서는 식별자가 필수임.
그런데 IDENTITY 전략을 사용하면 식별자를 데이터베이스에서 지정하기 전까지는 알 수 없기 때문에, em.persist()를 하는 즉시 INSERT SQL이 데이터베이스에 전달됨.
따라서 이 전략은 트랜잭션을 지원하는 쓰기 지연이 동작하지 않음.

GenerationType.SEQUENCE

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

}
  • 데이터베이스의 시퀀스(Sequence) 객체를 사용해 식별자를 생성
  • 시퀀스를 지원하는(예: 오라클, PostgreSQL, H2) 데이터베이스에서만 사용 가능
  • @SequenceGenerator를 사용하여 시퀀스 생성기를 등록한 후, @GeneratedValue의 generator 속성으로 시퀀스 생성기를 선택.

@SequenceGenerator 속성

속성기능기본값
name식별자 생성기 이름필수
sequenceName데이터베이스에 등록되어 있는 시퀀스 이름hibernate_sequence
initialValueDDL 생성 시에만 사용된다. 시퀀스 DDL을 생성할 때 처음 시작하는 수를 지정한다.1
allocationSize시퀀스를 한 번 호출할 때 증가하는 수50
catalog, schema식별자 생성기의 catalog, schema 이름

IDENTITY와 SEQUENCE의 차이

SEQUENCE 전략은 em.persist()를 호출할 때 먼저 데이터베이스 SEQUENCE를 사용해서 식별자를 조회함.
그리고 조회한 식별자를 엔티티에 할당한 후 해당 엔티티를 영속성 컨텍스트에 저장함.
이후 트랜잭션 commit 시점에 flush가 발생하면 엔티티를 데이터베이스에 저장함.

IDENTITY 전략은 먼저 엔티티를 데이터베이스에 저장한 후에 식별자를 조회하여 엔티티의 식별자에 할당한 후 영속성 컨텍스트에 저장함.

GenerationType.TABLE

@TableGenerator(name = "USER_SEQ_GENERATOR", table = "SEQUENCE_TABLE", valueColumnName = "NEXT_VAL",pkColumnName = "SEQUENCE_NAME", pkColumnValue = "USER_SEQ", initialValue = 1, allocationSize = 1)
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.TABLE,
    generator = "USER_SEQ_GENERATOR")
    @Column(name = "user_id")
    private Long id;
    

}
  • 별도의 테이블을 사용하여 식별자를 생성하는 방식
  • 식별자 값을 관리하는 전용 테이블을 만들어, 그 테이블에서 식별자를 가져와 사용
  • 데이터베이스에 시퀀스나 자동 증가 기능이 없을 때 사용할 수 있음.

@TableGenerator 속성

속성기능기본값
name식별자 생성기 이름필수
table키생성 테이블명hibernate_sequence
pkColumnName시퀀스 컬럼명sequence_name
valueColumnName시퀀스 값 컬럼명next_val
pkColumnValue키로 사용할 값 이름엔티티 이름
initialValue초기 값. 마지막으로 생성된 값이 기준0
allocationSize시퀀스를 한 번 호출할 때 증가하는 수50
catalog, schema식별자 생성기의 catalog, schema 이름
uniqueConstraints (DDL)유니크 제약 조건을 지정

@Table

  • 엔티티와 매핑할 테이블을 지정 함.
  • 생략하게 되면 매핑한 엔티티 이름을 테이블 이름으로 사용하게 됨.
  • 이 전략은 다른 전략보다 성능이 떨어질 수 있어 일반적으로 사용되지 않음.

속성

속성기능기본값
name매핑할 테이블 이름 지정엔티티 이름
catalogcatalog 기능이 있는 DB에서 지정한 catalog를 매핑하여 사용
schema스키마 기능이 있는 DB에서 schema 를 매핑해서 사용
uniqueConstraints (DDL)DDL 생성시 유니크 제약 조건을 만듦, 이 기능은 스키마 자동 생성 기능을 사용해서 DDL을 만들때만 사용('spring.jpa.hibernate.ddl-auto=create' 등)
@Table(name = "user_table",
    uniqueConstraints = {
   	@UniqueConstraint(name = "UniqueUsername", columnNames = {"username"}),
   	@UniqueConstraint(name = "UniqueNickname", columnNames = {"nickname"})
   }
)

주의사항

  • columnNames는 필드가 아닌 컬럼명과 일치해야 함.

@Column

  • 객체 필드를 테이블의 컬럼에 매핑시켜줌.

속성

속성기능기본값
name필드와 매핑할 테이블의 컬럼 이름을 지정객체의 필드 이름
insertable엔티티 저장 시 이 필드도 같이 저장. false로 설정하면 이 필드는 데이터베이스에 저장하지 않음. false 옵션은 읽기 전용일 때 사용true
updatable엔티티 수정 시 이 필드도 같이 수정. false로 설정하면 데이터베이스에 수정하지 않음.true
table하나의 엔티티를 두 개 이상의 테이블에 매필할 때 사용함(@SecondaryTable 사용). 지정한 필드를 다른 테이블에 매핑할 수 있음현재 클래스가 매핑된 테이블
nullable (DDL)DDL 생성 시 null 값의 허용 여부를 설정. false로 설정하면 not null 제약조건true
unique (DDL)@Table의 uniqueConstraints와 같으나 한 컬럼에 간단히 유니크 제약조건을 걸 때 사용false
columnDefinition (DDL)데이터베이스 컬럼 정보를 직접 줄 수 있음자바 필드의 타입과, 데이터베이스 방언 설정 정보를 사용해적절히 생성
length (DDL)문자 길이 제약조건, String 타입에만 사용255
precision, scale (DDL)BigDecimal 타입(혹은 BigInteger)에서 사용. precision은 소수점을 포함한 전체 자리수, scale은 소수의 자리수임precision = 0, scale = 0
@Entity(name = "Users")
@Data
public class User {
    @Id
    @Column(name = "user_id")
    private String id;
    @Column(columnDefinition = "varchar (100) default 'EMPTY'")
    private String profileContent
}

주의사항

@Column의 unique 속성을 true로 사용할 경우 유니크 제약조건의 이름을 랜덤으로 지정하여 알아보기 힘듬. 따라서 @Column의 unique 속성보다는 @Table의 uniqueConstraints 속성을 사용하여, 제약조건의 이름을 걸어주는 방법을 사용하는 것이 가독성이 좋음.

@Temporal

  • 날짜 타입(java.util.Date, java.util.Calendar)을 매핑할 때 사용.
  • 생략하면 자바의 Date와 가장 유사한 timestamp로 정의.

속성

속성기능기본값
valueTemporalType.DATE : 날짜, 데이터베이스 date 타입과 매핑 (예 : 2011-11-11), TemporalType.TIME : 시간, 데이터베이스 time 타입과 매핑 (예 : 11:11:11), TemporalType.TIMESTAMP : 날짜와 시간, 데이터베이스 timestamp 타입과 매핑 (예 : 2011-11-11 11:11:11)TemporalType 필수 지정
@Entity
public class User{

    @Id
    private Long id;
    @Temporal(value = TemporalType.DATE)
    private LocalDateTime createdDate;
}

@Enumerated

자바 엔티티 필드의 Enum 타입을 데이터베이스에 매핑할 때 어떻게 매핑할 것인지 정해주는 어노테이션임.

속성

속성기능
EnumType.ORDINALenum 순서(숫자) 값을 DB에 저장
EnumType.STRINGenum 이름 값을 DB에 저장
enum Gender {
   MALE,
   FEMALE
}

@Enumerated(EnumType.ORDINAL)
private Gender gender    // MALE로 세팅하면 1, FEMALE 은 2

@Enumerated(EnumType.STRING)
private Gender gender;   // "MALE", "FEMALE" 문자열 자체가 저장

@Embeddable, @Embedded, @EmbeddedId

  • 한 클래스를 엔티티의 컬럼 객체로 사용하기 위해 사용.
  • 주소를 city, street, zipcode 등으로 정의할 수 있음. 이를 한 엔티티에 펼처 놓는것이 아닌 밸류 타입으로 쓰는 것임.
  • @Embeddable: 값 타입을 정의하는 곳에 표시
  • @Embedded: 값 타입을 사용하는 곳에 표시
  • 임베디드 타입은 기본 생성자가 필수
  • 식별자를 임베디드타입으로 사용하려고 하는 경우, @Id 대신 @EmbeddedId 어노테이션을 사용해야 함.
  • JPA 에서 식별자 타입은 Serializable 타입이어야 하므로, Serializable 인터페이스를 상속 받아야 함.
@Embeddable
public class Address {

	private city;
	private street;
	private zipcode;
}
@Entity
public class User {

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

	@Embedded
	private Address address;
}
@Entity
public class Order {
	@Embeddedid
	private OrderNumber number;
	...
}
@Embeddable
public class OrderNumber implements Serializable {
	@Column(name="order_number")
	private String number;
	...
}

@AttributeOverrides

  • 임베디드 타입에 정의한 매핑정보를 재정의
@Embeddable
public class Address {

    private String city;
    private String street;

    @Embedded
    private Zipcode zipcode;
}
@Embeddable
public class Zipcode {

    private String zip;
    private String plusFour;
}
}
@Entity
public class User {

	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
 	private Long id;
    
 	@Embedded
    @AttributeOverrides({
            @AttributeOverride(name = "city", column = @Column(name = "HOME_CITY")),
            @AttributeOverride(name = "street", column = @Column(name = "HOME_STREET")),
            @AttributeOverride(name = "zipcode.zip", column = @Column(name = "HOME_ZIP")),
            @AttributeOverride(name = "zipcode.plusFour", column = @Column(name = "HOME_PLUS_FOUR")),
    })
    private Address homeAddress;


    @Embedded
    @AttributeOverrides({
            @AttributeOverride(name = "city", column = @Column(name = "COMPANY_CITY")),
            @AttributeOverride(name = "street", column = @Column(name = "COMPANY_STREET")),
            @AttributeOverride(name = "zipcode.zip", column = @Column(name = "COMPANY_ZIP")),
            @AttributeOverride(name = "zipcode.plusFour", column = @Column(name = "COMPANY_PLUS_FOUR")),
    })
    private Address companyAddress;
}

임베디드 타입과 null

  • 임베디드 타입이 null이면 매핑한 컬럼 값은 모두 null이됨.

@JoinColumn

  • 외래 키를 매핑할 때 사용
속성기능기본값
name매핑할 외래 키 이름필드명 +_+ 참조하는 테이블의 기본 키 컬럼명
referencedColumnName외래 키가 참조하는 대상 테이블의 컬럼명참조하는 테이블의 기본 키 컬럼명
foreignKey(DDL)외래 키가 참조하는 대상 테이블의 컬럼명
unique nullable insertable. updatable columnDefinition table@Column의 속성과 같다.

@ManyToOne

  • 다대일 관계 매핑
속성기능기본값
optionalfalse로 설정하면 연관된 엔티티가 항상 있어야 한다.true
fetch글로벌 페치 전략을 설정한다.@ManyToOne=FetchType.EAGER @OneToMany=FetchType.LAZY
cascade속성 전이 기능을 사용한다.
targetEntity연관된 엔티티의 타입 정보를 설정한다. 이 기능은 거의 사용하지 않는다. 컬렉션을 사용해도 제네릭으로 타입 정보를 알 수 있다.

OneToMany

  • 다대일 관계 매핑
속성기능기본값
mappedBy연관관계의 주인 필드를 선택한다.
fetch글로벌 페치 전략을 설정한다.@ManyToOne=FetchType.EAGER @OneToMany=fetchType.LAZY
cascade속성 전이 기능을 사용한다.
targetEntity연관된 엔티티의 타입 정보를 설정한다. 이 기능은 거의 사용하지 않는다. 컬렉션을 사용해도 제네릭으로 타입정보를 알 수 있다.

JPA와 데이터베이스 관계 매핑

JPA는 객체 관계 모델링에서 매우 중요한 다중 관계를 지원함. 객체와 데이터베이스 테이블 간의 관계를 매핑할 때 1:1, 1:N, N:M과 같은 관계를 처리할 수 있음.

일대일(1:1) 관계 (@One-to-One)

한 엔터티가 다른 엔터티와 일대일(1:1)로 매핑될 때 사용됨. 일대다(1:N), 다대일(N:1) 관계에서는 다(N) 쪽이 항상 외래 키를 가지고 있지만, 일대일(1:1) 관계에서는 주 테이블이나 대상이 되는 테이블 양쪽 모두 외래 키를 가질 수 있음. 때문에 일대일 관계를 적용할 때는 주 테이블과 대상이 되는 테이블, 어느 쪽에 외래 키를 둘지 선택해야 함. 일반적으로는 주 테이블에 외래키를 두는 방식을 채택함

JPA에서는 외래 키를 갖는 쪽이 연관 관계의 주인이 되고, 연관 관계의 주인이 데이터베이스 연관 관계와 매핑되어 외래 키를 관리(등록, 수정, 삭제)할 수 있기 때문에 해당 설정이 중요함.

데이터베이스는 컬렉션을 담을 수 없기 때문에 일대다, 다대일의 경우 일이 되는 쪽에서 외래 키를 가지는 것이 불가능함.
따라서 해당 경우에는 항상 다가 되는 쪽에서 외래 키를 가지게 되는 것임.

일대일(1:1) 단방향 (주 테이블에 외래 키가 있는 경우)

이 경우는 많이 사용되는 객체인 주 객체가 대상 객체의 참조를 가지는 것처럼, 주 테이블에 외래 키를 두고 대상 테이블을 찾는 방식.
주 테이블만 조회해도 대상 테이블에 데이터가 있는지 확인할 수 있다는 장점이 있지만, 만약 값이 없는 경우 외래 키에 Null을 허용해야 하는데, 이것은 DB 측면이나 비즈니스 로직의 검증 부분에서 봤을 때 좋지 않은 부분이 됨.

Member - 주 테이블
Locker - 대상 테이블

@Entity
public class Member{
 
    @Id 
    @GeneratedValue
    @Column(name="MEMBER_ID")
    private Long id;
 
    private String username;
 
    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;
 
}
 
@Entity
public class Locker{
 
    @Id 
    @GeneratedValue
    @Column(name="LOCKER_ID")
    private Long id;
 
    private String lockername;
}

일대일(1:1) 양방향 (주 테이블에 외래 키가 있는 경우)

반대 방향에도 추가적으로 @OneToOne을 설정해줌. 단, 양방향 연관관계의 대상이 되는 테이블이므로 다대일 양방향 연관관계 매핑과 마찬가지로 mappedBy 속성을 추가해주어야함.

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

일대일(1:1) 단방향 (대상 테이블에 외래 키가 있는 경우)

JPA에서 일대다 단방향 관계은 대상 테이블로 연관관계를 매핑을 지원하지만, 대상 테이블에 외래키가 있는 일대일 단방향 관계는 매핑을 지원하지 않음.

이 때는 단방향 관계를 아래와 같이 Locker ➡️ Member로 수정하거나, 양방향 관계로 만들고 Locker를 연관관계의 주인으로 설정해야 함.

Locker ➡️ Member로 수정

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

양방향 관계로 수정

@Entity
public class Member{
 
    @Id 
    @GeneratedValue
    @Column(name="MEMBER_ID")
    private Long id;
 
    private String username;
 
    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;
 
}
 
@Entity
public class Locker{
 
    @Id 
    @GeneratedValue
    @Column(name="LOCKER_ID")
    private Long id;
 
    private String lockername;
    
    @OneToOne
    private Member member; 
}

일대일(1:1) 양방향 (대상 테이블에 외래 키가 있는 경우)

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

일대일 매핑에서 대상 테이블에 외래키를 두고 싶으면 양방향으로 매핑하고, 대상 엔티티인 Locker를 연관관계의 주인으로 만들어서 Locker 테이블의 외래키를 관리하도록 설정하면 됨.

@OneToOne FetchType.LAZY 적용이 안 되는 이슈(N+1 문제)

JPA 구현체인 Hibernate에서는 @OneToOne 양방향 매핑 시 지연 로딩으로 설정하여도 지연 로딩(LAZY)이 동작하지 않고, 즉시 로딩(EAGER)이 동작하는 문제가 있음.

정확하게는 테이블을 조회할 때 외래 키를 가지고 있는 테이블(연관 관계의 주인)에서는 외래 키를 가지지 않은 쪽에 대한 지연 로딩은 동작하지만, mappedBy 속성으로 연결된 외래 키를 가지지 않은 쪽에서 테이블을 조회할 경우 외래 키를 가지고 있는 테이블(연관 관계의 주인)에 대해서는 지연 로딩이 동작하지 않고 N+1 쿼리가 발생하게 됨.

해당 이슈는 JPA의 구현체인 Hibernate에서 프록시 기능의 한계로 인해 발생함.

Member 테이블이 외래 키인 LOKCER_ID를 가지고 있는 연관 관계의 주인이고, Locker 테이블은 외래 키가 없는 경우를 예시로 생각했을 때, Locker 테이블의 입장에서는 Member 테이블에 대한 외래 키가 없기 때문에 Locker Entity 입장에서는 Locker에 연결되어 있는 Member가 null인지 아닌지를 조회해보기 전까지는 알 수 없음.

이처럼 Locker에 연결된 Member 객체가 null 인지 여부를 알 수 없기 때문에 Proxy 객체를 만들 수 없는 것이며, 때문에 무조건 연결된 Member가 있는지 여부를 확인하기 위한 쿼리가 실행됨.

(Locker Entity를 조회했을 때 연결된 Member Entity에 대해서 무조건 즉시 로딩이 적용되며, 따라서 N+1 조회가 발생하게 되는 것)

해결 방법

해결 방법이라고 했지만 완벽하게 이 문제를 해결할 수 있는 방법은 존재하지 않음. 따라서 아래와 같은 해결책을 고려한 후 현재 상황에 알맞게 적용해야 함.

구조 변경하기

  • 양방향 매핑이 반드시 필요한 상황인지.
  • OneToOne -> OneToMany 또는 ManyToOne 관계로 변경이 가능한지.

구조를 유지한채 해결하기

  • CART를 조회할때 USER도 함께 조회. (Fetch Join)
  • batch fetch size를 사용.

1:N 관계 (One-to-Many)

한 엔터티가 다른 엔터티와 1:N 관계를 가질 때 사용됨.

@Entity
public class User {
    @OneToMany(mappedBy = "user")
    private List<Order> orders;
}
  • mappedBy: 양방향 관계에서 관계의 주인을 지정할 때 사용함.

N:M(다대다) 관계 (Many-to-Many)

두 엔터티 간에 N:M관계가 있을 때 사용됨. 중간에 연결 테이블을 두어 매핑함.

@Entity
public class Student {
    @ManyToMany
    @JoinTable(
        name = "student_course",
        joinColumns = @JoinColumn(name = "student_id"),
        inverseJoinColumns = @JoinColumn(name = "course_id")
    )
    private List<Course> courses;
}
  • @JoinTable: 두 엔터티 간의 조인 테이블을 정의함.

주의) 실무에서 N:M(다대다) 관계 (Many-to-Many)를 사용하지 않는 이유

실무에서 ManyToMany 관계를 사용하면 안 되는 이유는 주로 복잡한 데이터 모델링 및 성능 이슈 때문임. ManyToMany 관계는 두 엔터티 간에 서로 다대다의 관계를 나타내기 위한 편리한 방법이지만, 실무에서는 이 방식이 가지는 몇 가지 문제 때문에 중간 테이블을 사용하는 방식으로 구현하는 것이 더 선호됨.

성능 문제

1. 조인 테이블로 인한 성능 저하

ManyToMany 관계는 중간 테이블(조인 테이블)을 자동으로 생성하여 두 테이블 간의 매핑을 처리함. 하지만, 데이터가 많아질수록 조인 테이블을 사용하는 쿼리가 매우 복잡해지고 느려질 수 있음.

  • 쿼리 복잡도 증가: 다대다 관계를 처리하기 위해 여러 테이블을 조인해야 하며, 특히 조인 테이블을 사용한 다중 조인 쿼리가 성능에 영향을 미칠 수 있음.
  • 트랜잭션 복잡성: 중간 테이블에서 삽입, 삭제, 갱신 등의 작업이 추가되면서 트랜잭션 관리가 복잡해질 수 있음.

2. 지연 로딩(Lazy Loading) 문제

ManyToMany 관계에서 지연 로딩을 사용하는 경우, 관련된 엔터티들을 모두 로드할 때 다중 쿼리가 발생할 수 있음. 특히, N+1 문제가 발생할 수 있어, 많은 데이터를 처리할 때 성능 저하를 초래할 수 있음.

  • N+1 문제: 한 번의 쿼리로 모든 데이터를 가져오지 못하고, 추가적인 쿼리가 반복적으로 실행되는 현상임. 예를 들어, 특정 엔터티의 리스트를 조회할 때, 각 엔터티에 대해 추가적인 쿼리가 발생함.

확장성 및 유연성 부족

1. 중간 테이블에 추가 속성을 저장할 수 없음

ManyToMany 관계는 두 엔터티 간의 관계를 자동으로 처리하는 조인 테이블을 사용하지만, 중간 테이블에 추가적인 정보를 저장할 수 없음. 예를 들어, 두 엔터티 간의 관계에 추가적인 속성이 필요할 때, 예를 들어 연관된 날짜, 상태 정보 등을 저장해야 하는 경우 ManyToMany 관계는 이를 지원하지 않음.

  • 실무 예시: 학생(Student)과 수업(Course) 간의 다대다 관계에서, 학생이 수업을 언제 수강했는지와 같은 정보를 기록하려면, 중간 테이블에 수강 날짜 등의 추가적인 컬럼이 필요함. 그러나 ManyToMany 관계는 이를 지원하지 않음.

2. 관계에 대한 세밀한 제어 부족

실무에서는 두 엔터티 간의 관계를 더 세밀하게 제어해야 하는 경우가 많음. 그러나 ManyToMany는 기본적으로 자동화된 매핑에 의존하므로, 관계에 대한 세밀한 제어가 어려움.

  • 예시: 관계에서 삭제 정책(CascadeType)이나 고유 제약 조건을 걸어야 할 경우, 자동 생성된 조인 테이블을 사용하면 이를 명확하게 제어할 수 없음.

실무에서의 대안: ManyToMany 대신 OneToMany와 ManyToOne으로 처리

실무에서는 ManyToMany 관계를 피하고, 대신 중간 엔터티를 사용하여 OneToManyManyToOne 관계로 풀어내는 것이 더 일반적임. 이렇게 하면 중간 테이블에 추가적인 정보를 저장할 수 있고, 관계에 대한 세밀한 제어가 가능해짐.

중간 엔터티 사용 예시

예를 들어, 학생(Student)과 수업(Course) 간의 관계를 ManyToMany 대신 중간 엔터티를 사용하여 풀어내면 다음과 같음.

엔터티 설계

@Entity
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @OneToMany(mappedBy = "student")
    private List<Enrollment> enrollments;
}

@Entity
public class Course {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;

    @OneToMany(mappedBy = "course")
    private List<Enrollment> enrollments;
}

@Entity
public class Enrollment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "student_id")
    private Student student;

    @ManyToOne
    @JoinColumn(name = "course_id")
    private Course course;

    private LocalDate enrollmentDate;  // 추가 속성
}

장점

  • 확장성: 중간 엔터티에 추가적인 속성을 쉽게 저장할 수 있음. 예를 들어, 수강 등록 날짜, 상태 등의 추가 정보를 저장할 수 있음.

  • 유연성: 관계에 대한 세밀한 제어가 가능하며, Cascade 옵션 및 삭제 정책을 명확하게 정의할 수 있음.

  • 성능 최적화: 쿼리 성능을 더 세밀하게 최적화할 수 있으며, N+1 문제 등을 더 쉽게 관리할 수 있음.

JPA의 장점

객체 지향적 개발 지원

JPA는 객체 지향적으로 데이터를 처리할 수 있기 때문에, 데이터베이스의 테이블과 객체를 매핑하여 객체 중심의 개발을 지원함.
객체지향 설계를 따르면서 데이터베이스와의 매핑 코드를 줄일 수 있음.

데이터베이스 독립성

JPA는 데이터베이스에 종속되지 않음. JPA는 표준 API이므로, 특정 데이터베이스에 의존하지 않고 다양한 데이터베이스와 연동 가능함.
예를 들어, MySQL, PostgreSQL, Oracle 등 다양한 DBMS에서 JPA를 사용할 수 있음.

자동화된 SQL 처리

JPA는 SQL을 자동으로 생성해주므로, 직접 SQL을 작성하지 않고도 CRUD 작업을 수행할 수 있음.
복잡한 쿼리를 작성할 필요 없이, 객체 지향적으로 데이터를 처리할 수 있음.

지연 로딩(Lazy Loading)

JPA는 기본적으로 지연 로딩을 지원함. 이는 엔터티의 관련 데이터가 실제로 필요할 때에만 로딩됨을 의미함.
필요하지 않은 데이터를 미리 로딩하지 않으므로 성능 최적화에 유리함.

트랜잭션 관리

JPA는 트랜잭션 처리를 지원함. 트랜잭션 단위로 엔터티의 상태를 관리하며, 이를 통해 데이터의 일관성과 안정성을 유지할 수 있음.

패러다임 불일치 해결

개발자는 객체지향적으로 프로그래밍을 하고, JPA가 이를 관계형 데이터베이스에 맞게 SQL을 대신 생성해 실행함.
JPA를 이용함으로써 개발자는 항상 객체 지향적으로 코드를 표현할 수 있게되어 더는 SQL에 종속적인 개발을 하지 않아도 됨.
JPA를 통해 객체 중심으로 개발을 할 수 있게 되다보니 생산성 향상은 물론 유지 보수또한 정말 편리해짐.

JPA의 단점

복잡한 쿼리의 처리 한계

JPA는 간단한 CRUD 작업에 적합하지만, 복잡한 쿼리를 처리하는 데에는 한계가 있을 수 있음.
복잡한 쿼리가 필요한 경우 JPQL이나 네이티브 SQL을 사용해야 함.

러닝커브

JPA를 사용하기 위해서는 학습해야 할 것들이 많음.
JPA를 사용해도 SQL을 알아야 Hibernate가 수행한 쿼리 확인 및 성능 모니터링이 가능함.

성능 문제

대규모 데이터를 처리하거나 잘못된 매핑이나 지연 로딩 설정이 문제를 일으킬 경우 성능 문제가 발생할 수 있음.
성능 최적화와 관련된 부분은 주의 깊게 관리해야 함.
따라서 JPA를 사용할 때는 성능에 대한 이해가 필요함.

Spring Data JPA

Spring Data JPA는 Spring Framework에서 제공하는 JPA(Java Persistence API)의 확장된 기능을 지원하는 ORM(Object-Relational Mapping) 기술임. Spring Data JPA는 JPA의 사용을 보다 간단하고 효율적으로 만들어주며, 데이터 접근 계층에서 자주 사용하는 CRUD 기능, 쿼리 생성, 페이징, 정렬 등을 자동화하여 개발자의 생산성을 크게 향상시킴.

Spring Data JPA는 JPA의 기본 기능을 기반으로 추가적인 데이터 접근 기능을 제공하며, JPA 구현체인 Hibernate와 같은 ORM 프레임워크와 함께 사용됨. 특히 Spring Boot와 함께 사용할 경우, 자동 설정과 함께 간편하게 데이터베이스와 상호작용할 수 있음.

Spring Data JPA의 주요 특징

Repository 인터페이스

Spring Data JPA는 Repository 인터페이스를 제공하여 데이터베이스와 상호작용함. 이 인터페이스를 통해 CRUD 작업을 쉽게 수행할 수 있으며, 개발자는 기본적인 CRUD 작업에 대한 구현 코드를 작성할 필요가 없음. JpaRepository 인터페이스를 상속받아 사용하며, 기본적인 CRUD 메소드를 자동으로 제공함.

public interface UserRepository extends JpaRepository<User, Long> {
}

JpaRepository: JpaRepository<User, Long>는 User 엔터티를 다루며, 기본 키(PK) 타입이 Long임. Spring Data JPA에서 제공하는 기본 인터페이스로, 다양한 데이터 접근 기능을 제공함.
UserRepository는 별도의 구현 없이 CRUD 기능을 사용할 수 있음.

자동 CRUD 메소드 제공

Spring Data JPA는 JpaRepository를 통해 자동으로 CRUD 메소드를 제공함. save(), findAll(), findById(), deleteById(), count(), existsById()와 같은 메소드를 기본적으로 사용할 수 있음.

save(): 새로운 엔터티를 저장하거나 기존 엔터티를 업데이트.
findById(): 기본 키로 엔터티를 조회.
findAll(): 모든 엔터티를 조회.
deleteById(): 기본 키로 엔터티를 삭제.
count(): 전체 엔터티 개수 반환.
existsById(): 특정 엔터티가 존재하는지 확인.

@Autowired
private UserRepository userRepository;

public void example() {
    // 새로운 사용자 저장
    User user = new User();
    user.setName("John");
    userRepository.save(user);

    // 모든 사용자 조회
    List<User> users = userRepository.findAll();

    // ID로 사용자 조회
    User userById = userRepository.findById(1L);

    // 사용자 삭제
    userRepository.deleteById(1L);
    
    // 전체 사용자수
    userRepository.count();
    
    // ID로 해당 사용자 존재여부
    userRepository.existsById(1L);
}

Query Method (쿼리 메소드)

Spring Data JPA는 메소드 이름만으로 쿼리를 자동으로 생성하는 쿼리 메소드 기능을 제공함. 메소드 이름을 규칙에 따라 작성하면, 해당 메소드에 맞는 SQL 쿼리가 자동으로 생성됨.

예)

public interface UserRepository extends JpaRepository<User, Long> {
    List<User> findByName(String name);  // 이름으로 사용자 조회
    List<User> findByAgeGreaterThan(int age);  // 나이가 특정 값 이상인 사용자 조회
    List<User> findByNameAndAge(String name, int age);  // 이름과 나이가 모두 일치하는 사용자 조회
}

findByName: 이름으로 사용자 조회.
findByAgeGreaterThan: 나이가 특정 값 이상인 사용자 조회.
findByNameAndAge: 이름과 나이가 모두 일치하는 사용자 조회.

KeywordSampleJPQL snippet
DistinctfindDistinctByLastnameAndFirstnameselect distinct … where x.lastname = ?1 and x.firstname = ?2
AndfindByLastnameAndFirstname… where x.lastname = ?1 and x.firstname = ?2
OrfindByLastnameOrFirstname… where x.lastname = ?1 or x.firstname = ?2
Is, EqualsfindByFirstname,findByFirstnameIs,findByFirstnameEquals… where x.firstname = ?1
BetweenfindByStartDateBetween… where x.startDate between ?1 and ?2
LessThanfindByAgeLessThan… where x.age < ?1
LessThanEqualfindByAgeLessThanEqual… where x.age <= ?1
GreaterThanfindByAgeGreaterThan… where x.age > ?1
GreaterThanEqualfindByAgeGreaterThanEqual… where x.age >= ?1
AfterfindByStartDateAfter… where x.startDate > ?1
BeforefindByStartDateBefore… where x.startDate < ?1
IsNull, NullfindByAge(Is)Null… where x.age is null
IsNotNull, NotNullfindByAge(Is)NotNull… where x.age is not null
LikefindByFirstnameLike… where x.firstname like ?1
NotLikefindByFirstnameNotLike… where x.firstname not like ?1
StartingWithfindByFirstnameStartingWith… where x.firstname like ?1 (parameter bound with appended %)
EndingWithfindByFirstnameEndingWith… where x.firstname like ?1 (parameter bound with prepended %)
ContainingfindByFirstnameContaining… where x.firstname like ?1 (parameter bound wrapped in %)
OrderByfindByAgeOrderByLastnameDesc… where x.age = ?1 order by x.lastname desc
NotfindByLastnameNot… where x.lastname <> ?1
InfindByAgeIn(Collection ages)… where x.age in ?1
NotInfindByAgeNotIn(Collection ages)… where x.age not in ?1
TruefindByActiveTrue()… where x.active = true
FalsefindByActiveFalse()… where x.active = false
IgnoreCasefindByFirstnameIgnoreCase… where UPPER(x.firstname) = UPPER(?1)

JPQL과 Native Query 지원

Spring Data JPA는 JPQL(Java Persistence Query Language)과 nativeQuery를 지원하여, 복잡한 쿼리를 직접 작성할 수 있음.

  • JPQL: 객체를 대상으로 한 쿼리.
  • nativeQuery : 데이터베이스에 맞는 SQL 쿼리.
// JPQL 쿼리
@Query("SELECT u FROM User u WHERE u.age > :age")
List<User> findUsersOlderThan(@Param("age") int age);

// 네이티브 SQL 쿼리
@Query(value = "SELECT * FROM users WHERE age > :age", nativeQuery = true)
List<User> findUsersByNativeQuery(@Param("age") int age);

@Query: 어노테이션을 사용하여 복잡한 JPQL 또는 네이티브 쿼리를 직접 정의할 수 있음. 기본 CRUD 외에 복잡한 쿼리를 작성해야 할 때 사용됨.
nativeQuery = true: 네이티브 SQL 쿼리임을 명시함.

Pageable 인터페이스

Pageable 인터페이스는 Spring Data JPA에서 제공하는 페이징 처리와 정렬을 위한 기능으로, 대규모 데이터를 효율적으로 처리할 수 있도록 지원하는 페이징 요청 객체임. Pageable 인터페이스는 페이징 및 정렬 정보를 메소드 호출 시 인자로 전달하여, 데이터를 페이지 단위로 나누고, 특정 기준에 따라 정렬된 결과를 조회할 수 있게 해줌.

Pageable 인터페이스의 개념

Pageable은 페이징 처리를 위한 정보를 담고 있는 객체로, 주로 페이징 요청을 나타냄.
페이징 처리 시, 클라이언트는 데이터를 페이지 단위로 요청하고, Pageable 인터페이스는 해당 페이지의 페이지 번호, 페이지 크기, 정렬 방식과 같은 정보를 담음.
이를 통해 개발자는 대량의 데이터를 페이지별로 나누어 클라이언트에 응답할 수 있으며, 필요한 만큼의 데이터만 조회하여 성능을 최적화할 수 있음.

Pageable의 주요 메소드

Pageable 인터페이스는 페이징 처리에 필요한 다양한 정보를 제공하는 메소드를 포함하고 있음. 대표적인 메소드는 다음과 같음:

getPageNumber(): 현재 요청의 페이지 번호를 반환함. (0부터 시작)
getPageSize(): 한 페이지에 포함될 데이터의 크기(개수)를 반환함.
getOffset(): 데이터베이스 조회 시 시작 위치를 반환함. (예: LIMIT 쿼리의 시작점)
getSort(): 정렬 기준을 반환함. (정렬 방식이 없는 경우 UNSORTED)
hasPrevious(): 이전 페이지가 있는지 여부를 반환함.
Pageable을 구현한 기본 구현체는 PageRequest 클래스임. 이는 페이징 요청 객체를 생성할 때 자주 사용됨.

PageRequest 클래스

PageRequest 클래스는 Pageable 인터페이스의 구현체로, 페이지 번호, 페이지 크기, 정렬 기준을 생성할 수 있음. 페이징과 정렬을 함께 처리할 수 있도록 지원함.

PageRequest.of() 메소드

페이징 요청 객체를 생성할 때는 PageRequest.of() 메소드를 사용함. 이 메소드는 다음과 같은 매개변수를 받음:

int page: 조회할 페이지 번호 (0부터 시작).
int size: 한 페이지에 포함될 데이터의 크기(몇 개의 데이터를 가져올지).
Sort sort: 데이터를 정렬할 때 사용하는 정렬 기준(optional).

PageRequest 예시

// 1. 페이지 번호가 0이고, 페이지 크기가 10인 페이징 요청 (정렬 없음)
Pageable pageable = PageRequest.of(0, 10);

// 2. 페이지 번호가 1이고, 페이지 크기가 20이며, name 필드를 기준으로 오름차순 정렬
Pageable pageable = PageRequest.of(1, 20, Sort.by("name").ascending());

// 3. 페이지 번호가 0이고, 페이지 크기가 10이며, 여러 필드를 기준으로 정렬
Pageable pageable = PageRequest.of(0, 10, Sort.by("name").descending().and(Sort.by("age").ascending()));

페이지 번호는 0부터 시작함.
Sort는 여러 필드에 대해 복합 정렬도 가능하며, 필드 이름에 따라 오름차순(ascending) 또는 내림차순(descending)으로 정렬할 수 있음.

Pageable을 사용하는 Spring Data JPA 메소드

Pageable 인터페이스는 Spring Data JPA에서 페이징된 데이터를 조회할 때 주로 사용됨. JpaRepository에서 제공하는 메소드에 Pageable을 인자로 전달하여 페이징된 데이터를 쉽게 조회할 수 있음.

기본 페이징 메소드

Page<User> findAll(Pageable pageable);
위 메소드는 페이징된 결과를 Page<User> 객체로 반환함.
// 사용자 레포지토리에서 1페이지, 10개의 데이터를 오름차순 정렬로 조회
Pageable pageable = PageRequest.of(1, 10, Sort.by("name").ascending());
Page<User> page = userRepository.findAll(pageable);

List<User> users = page.getContent();  // 조회된 사용자 리스트
long totalUsers = page.getTotalElements();  // 전체 사용자 수
int totalPages = page.getTotalPages();  // 전체 페이지 수
boolean hasNextPage = page.hasNext();  // 다음 페이지 여부

커스텀 쿼리에서 Pageable 사용

@Query 어노테이션과 Pageable을 함께 사용하여 커스텀 JPQL 쿼리에서 페이징 처리도 가능함.

@Query("SELECT u FROM User u WHERE u.age > :age")
Page<User> findUsersByAgeGreaterThan(@Param("age") int age, Pageable pageable);

해당 쿼리는 나이가 특정 값 이상인 사용자들을 페이징하여 반환함.

Pageable pageable = PageRequest.of(0, 5, Sort.by("name").ascending());
Page<User> users = userRepository.findUsersByAgeGreaterThan(20, pageable);

Page와 Slice 인터페이스

Spring Data JPA에서는 Pageable을 사용하여 데이터를 페이징 처리할 때, Page와 Slice라는 두 가지 인터페이스를 사용할 수 있음. 두 인터페이스 모두 페이징 처리를 위한 결과를 담고 있지만, 차이점이 있음.

Page 인터페이스

Page는 페이징된 전체 데이터 정보를 제공함. 예를 들어, 총 페이지 수, 총 데이터 수, 현재 페이지의 데이터 등의 정보를 포함함.
주로 findAll(Pageable pageable) 메소드를 사용할 때 반환됨.

Page<User> page = userRepository.findAll(pageable);
List<User> users = page.getContent();  // 현재 페이지의 데이터
long totalElements = page.getTotalElements();  // 전체 데이터 수
int totalPages = page.getTotalPages();  // 전체 페이지 수

Slice 인터페이스

Slice는 Page보다 가벼운 버전으로, 현재 페이지의 데이터와 다음 페이지가 있는지 여부만 제공함. 전체 데이터 수나 전체 페이지 수는 제공하지 않음.
이는 성능 최적화가 필요할 때 사용되며, 전체 데이터를 다 계산할 필요가 없을 때 유용함.

Slice<User> slice = userRepository.findAll(pageable);
List<User> users = slice.getContent();  // 현재 페이지의 데이터
boolean hasNext = slice.hasNext();  // 다음 페이지가 있는지 여부

페이징 및 정렬 사용 시 주의점

페이지 번호는 0부터 시작

Spring Data JPA에서 페이지 번호는 0부터 시작함. 클라이언트에서 1 페이지를 요청하는 경우, 서버에서 0 페이지를 요청해야함을 기억해야 함.

성능 최적화

페이징은 대규모 데이터를 처리할 때 매우 유용하지만, 잘못 사용하면 성능 이슈가 발생할 수 있음. 예를 들어, 복잡한 조인 쿼리에서 페이징 처리를 적용할 때 N+1 문제가 발생할 수 있음. 이런 경우에는 JPQL을 최적화하거나, 필요한 필드만 조회하는 프로젝션을 고려해야 함.

Slice 사용 시 전체 데이터 개수 정보가 없음

  • Slice는 전체 데이터 개수를 제공하지 않기 때문에, 총 페이지 수나 전체 데이터 수를 클라이언트에게 전달할 필요가 있을 경우 Page를 사용해야 함.

Spring Data JPA의 장점

자동화된 CRUD 작업

Spring Data JPA는 자동으로 CRUD 메소드를 제공하므로, 개발자가 별도의 구현 없이도 데이터 조작 작업을 할 수 있음. 이를 통해 코드의 중복을 줄이고, 개발 시간을 절약할 수 있음.

간편한 쿼리 생성

쿼리 메소드를 통해 메소드 이름만으로 쿼리를 생성할 수 있음. 단순한 조회 작업이나 조건에 맞는 데이터 조회는 메소드 이름을 통해 처리할 수 있어 코드 가독성이 높아짐.

커스텀 쿼리 지원

Spring Data JPA는 복잡한 쿼리가 필요한 경우, @Query를 통해 JPQL이나 네이티브 쿼리를 사용할 수 있어 성능 최적화나 고급 기능을 구현할 수 있음.

페이징 및 정렬 지원

페이징 및 정렬 기능을 자동으로 제공하여 대규모 데이터를 처리할 때 유용함. 개발자는 간단한 설정으로 페이징 처리와 정렬을 적용할 수 있음.

Spring Boot와의 통합성

Spring Data JPA는 Spring Boot와의 통합이 잘 되어 있어, 별도의 설정 없이도 자동 설정으로 빠르게 데이터베이스와 연동할 수 있음. 이는 개발 생산성을 크게 향상시킴.

Spring Data JPA의 단점

복잡한 쿼리 처리 한계

Spring Data JPA는 단순한 CRUD 작업에 매우 유용하지만, 매우 복잡한 쿼리를 처리할 때는 한계가 있을 수 있음. 이 경우에는 네이티브 쿼리 또는 JPQL을 직접 작성해야 함.

성능 문제

대규모 데이터를 처리하거나 잘못된 매핑이나 지연 로딩 설정이 문제를 일으킬 경우 성능 문제가 발생할 수 있음. 성능 최적화와 관련된 부분은 주의 깊게 관리해야 함.

초기 설정 복잡성

Spring Data JPA는 설정이 비교적 간편한 편이지만, 복잡한 엔터티 매핑을 처리하는 대규모 시스템에서는 초기 설정이 복잡해질 수 있음. 올바른 매핑 전략을 세워야 함.

profile
웹개발자 취업 준비생

0개의 댓글