기술 면접(JPA)

유요한·2024년 3월 16일
0

기술면접

목록 보기
26/27
post-thumbnail

💡ORM이란?

  • Object-relational mapping(객체관계매핑)
  • 객체는 객체대로 설계
  • 관계형 데이터베이스는 관계형 데이터베이스대로 설계
  • ORM 프레임워크가 중간에서 매핑
  • 대중적인 언어에는 대부분 ORM 기술이 존재

객체와 관계형 데이터베이스의 데이터를 자동으로 매핑(연결) 해주는 것을 말한다.

Java 소스코드안에 SQL문을 작성하면 코드가 길어져서 유지보수 및 분업이 쉽지 않습니다. MyBatis는 기존 JDBC 방식과는 달리 SQL문을 XML 파일에 작성함으로써 코드가 줄어들고 SQL문 수정이 편해진다. 또한 DBCP를 사용하여 커넥션을 여러개 생성하기 때문에 JDBC만 사용하는 것보다 작업 효율과 가독성이 좋아집니다.

object와 DB테이블을 매핑하여 데이터를 객체화하는 기술

  • 개발자가 반복적인 SQL문을 직접 작성하지 않음
  • DBMS에 종속적이지 않음
  • 복잡한 쿼리의 경우 JPQL을 사용하거나 SQL Mapper을 혼용하여 사용 가능

객체와 RDBMS를 자동으로 매핑해주는 것을 말한다.

객채 ↔ 관계형 데이터베이스

객체 지향 프로그맹은 객체를 사용하고 관계형 데이터베이스는 테이블을 사용하기 때문에 상호간 필드가 불일치가 존재한다. MyBatis와 같은 ORM을 통해 객체와 관계형데이터베이스를 연동해 SQL문을 생성하여 문제점을 해결한다. ORM에는 JPA, Hibernate, JDBC 가 있다. Hibernate는 최근 Spring boot에 채택되어 사용되어 진다.


영속성(Persistence)

  • 데이터를 생성한 프로그램이 종료되더라도 사리지지 않는 데이터의 특성을 말한다.

  • 영속성을 갖지 않는 데이터는 단지 메모리에서만 존재하기 때문에 프로그램이 종료되면 모두 잃어버리게 된다. 때문에 파일 시스템, 관계형 데이터베이스 혹은 객체 데이터베이스 등을 활용하여 데이터를 영구적으로 저장하여 영속성을 부여한다.

영속성 레이어

  • 프로그램의 아키텍처에서 데이터에 영속성을 부여해주는 계층

  • JDBC를 이용하여 직접 구현할 수 있지만 Persistance Framework를 이용한 개발이 많이 이루어진다.

  • JDBC 프로그래밍의 복잡함이나 번거로움 없이 간단한 작업만으로 데이터베이스와 연동되는 시스템을 빠르게 개발 할 수 있으며, 안정적인 구동을 보장한다.

영속성 컨텍스트

JPA를 이해하기 위해서는 영속성 컨텍스트를 이해하는 것이 가장 중요합니다. 영속성 컨텍스트는 애플리케이션과 데이터베이스 사이에서 엔티티와 레코드의 괴리를 해소하는 기능과 객체를 보관하는 기능을 수행합니다. 엔티티 객체가 영속성 컨텍스트에 들어오면 JPA는 엔티티 객체의 매핑 정보를 데이터베이스에 반영하는 작업을 수행합니다. 이처럼 엔티티 객체가 영속성 컨텍스트에 들어와 JPA의 관리 대상이 되는 시점부터는 해당 객체를 영속 객체라고 부릅니다. 엔티티를 영구 저장하는 환경으로 엔티티 매니저를 통해 영속성 컨텍스트에 접근합니다.

영속성 컨텍스트는 세션 단위의 생명주기를 가집니다. 데이터베이스에 접근하기 위한 세션이 생성되면 영속성 컨텍스트가 만들어지고, 세션이 종료되면 영속성 컨텍스트도 없어집니다. 엔티티 매니저는 이러한 일련의 과정에서 영속성 컨텍스트에 접근하기 위한 수단으로 사용됩니다.

생명주기내용
비영속(new)new 키워드를 통해 생성된 상태로 영속성 컨텍스트와 관련이 없는 상태
영속- 엔티티가 영속성 컨텍스트에 저장된 상태로 영속성 컨텍스트에 의해 관리되는 상태
- 영속 상태에서 데이터베이스에 저장되지 않으며, 트랜잭션 커밋 시점에 데이터베이스에 반영
준영속 상태(detached)영속성 컨텍스트에 엔티티가 저장되었다가 분리된 상태
삭제 상태(removed)영속성 컨텍스트와 데이터베이스에서 삭제된 상태

설명만 보고는 어떻게 동작하는지 알기 어려우니 상품 엔티티를 만들어서 영속성 컨텍스트에 저장후 데이터베이스 반영하는 코드를 살펴보겠습니다.

Item item = new Item();  → ①
item.setItemNum("테스트 상품");

EntityManager em = entityManagerFactory.createEntityManger(); → ②

EntityTransaction transaction = em.getTransaction() → ③
transaction.begin();

em.persistence(item);	→ ④

transaction.commit();	→ ⑤

em.close();	→ ⑥
emf.close() → ⑦

① 영속성 컨텍스트에 저장할 상품 엔티티를 하나 생성합니다. new 키워드를 통해 생성했으므로 영속성 컨텍스트와 관련이 없는 상태입니다.

② 엔티티 매니저 팩토리로부터 엔티티 매니저를 생성합니다.

③ 엔티티 매니저는 데이터 변경 시 데이터의 무결성을 위해 반드시 트랜잭션을 시작해야 합니다. 여기서의 트랜잭션도 데이터베이스의 트랜잭션과 같은 의미입니다.

영속성 컨텍스트의 특징

  1. 1차 캐시
    영속성 컨택스트 내부에는 1차 캐시라고 불리는 캐시를 가지고 있습니다. 영속상태의 엔티티는 모두 1차 캐시에 저장되고, 1차 캐시는 @Id를 키로 가지고 있는 Map이 존재합니다. 엔티티를 조회할 때 바로 DB에 접근하는 것이 아니고 1차 캐시에 있는 데이터를 먼저 조회한 후 없는 경우에만 DB에 접근하여 조회 후 다시 1차 캐시에 저장 합니다. 즉, 먼저 DB에 접근하는 것이 아닌 1차 캐시에 먼저 접근함으로서 데이터의 결과를 빠르게 가져올 수 있습니다.

  2. 동일성 보장
    1번 특징과 연관되며 모든 엔티티의 데이터들은 1차 캐시에 저장되어지기 때문에 식별자가 동일한 엔티티의 경우 동일성이 보장됩니다. 여기서 동일성이란 같은 객체를 참조한다는 의미입니다. 하나의 트랜잭션에서 같은 키 값으로 영속성 컨텍스트에 저장된 엔티티 조회 시 같은 엔티티 조회를 보장합니다. 바로 1차 캐시에 저장된 엔티티를 조회하기 때문에 가능합니다.

  3. 트랜잭션을 지연하는 쓰기지연
    트랜잭션은 DB에서 하나의 작업 단위를 나타냅니다. 영속성 컨텍스트에서 DML이 발생했을 때 바로 DB에 저장하지 않고, 트랜잭션이 커밋될 때 영속성 컨텍스트의 쓰기지연 SQL 저장소에 모아둔 쿼리들을 한 번에 저장합니다. 이때 쿼리들은 영속성 컨텍스트에 따로 저장이 되며 커밋을 실행하게 되면 flush를 통해 쿼리들을 DB에 저장하게 되고 최종적으로 commit을 하여 DB에 쿼리를 반영합니다. 즉, DB에 접근하는 횟수가 줄어들기 때문에 성능면에서 뛰어납니다.

  4. 변경 감지
    영속성 컨텍스트의 1차 캐시에는 스냅샷을 통해 엔티티의 변경을 감지합니다. JPA는 1차 캐시에 데이터베이스에서 처음 불러온 엔티티의 스냅샷을 갖고 있습니다. 그리고 1차 캐시에 저장된 엔티티와 스냅샷을 비교 후 변경 내용이 있다면 UPDATE SQL문을 쓰기 지연 SQL 저장소에 담아둡니다. 그리고 데이터베이스에 커밋 시점에 변경 내용을 자동으로 반영합니다. 즉, 따로 update문을 호출할 필요가 없습니다. 변경감지는 오직 영속 상태의 엔티티에만 적용이 됩니다. 순서는 아래와 같습니다.

    1. 트랜잭션을 커밋하면, flush가 호출되고, 엔티티와 스냅샷을 비교해서 변경된 엔티티를 찾습니다.
    2. 변경된 엔티티가 존재하면, 쿼리를 생성해서 쓰기지연 SQL 저장소에 저장합니다.
    3. 쓰기지연SQL 저장소에 생성된 쿼리들을 데이터베이스에 flush하고 commit 합니다.

      flush()가 이루어지면 엔티티와 스냅샷을 비교합니다. 스냡샷이란 값을 읽어온 시점, 즉 최초 시점을 스냅샷으로 떠놓는 겁니다. 만약에 엔티티가 변경이 되면 전체적으로 비교를 했을 때 차이가 생기게 되는데 그러면 update query를 SQL 저장소에 생성을 합니다. 그리고 update query를 DB에 반영을 하고 commit을 하게 됩니다.

영속성 컨텍스트 사용시 이점

JPA는 왜 이렇게 영속성을 사용할까? 그 이유는 애플리케이션과 데이터베이스 사이에 영속성 컨텍스트라는 중간 계층을 만들었기 때문입니다. 이렇게 중간 계층을 만들면 버퍼링, 캐싱 등을 할 수 있습니다.


JPA란?

JPA(Java Persistence API)은 자바 진영의 ORM 기술 표준입니다.

SQL을 반복적으로 사용하면 단순 반복 작업을 많이 해야하는데 그것 외에도 패러다임 불 일치 문제가 있습니다. 관계형 데이터베이스는 어떻게 데이터를 저장할지에 초점이 맞춰진 기술입니다.

반대로 객체지향 프로그래밍 언어는 메시지를 기반으로 기능과 속성을 한 곳에서 관리하는 기술입니다. 관계형 데이터베이스와 객체지향 프로그래밍 언어의 패러다임이 서로 다른데, 객체를 데이터베이스에 저장하려고 하니 여러 문제가 발생합니다. 이를 패러다임 불일치라고 합니다.

영역을 중간에서 패러다임 일치를 시켜주기 위한 기술입니다. 즉, 개발자는 객체지향적으로 프로그래밍을 하고, JPA가 이를 관계형 데이터베이스에 맞게 SQL을 대신 생성해서 실행합니다. 개발자는 항상 객체지향적으로 코드를 표현할 수 있으니 더는 SQL에 종속적인 개발을 하지 않아도 됩니다.

JPA는 ORM 기술의 표준 명세로 자바에서 제공하는 API입니다. 즉, JPA는 인터페이스고 이를 구현한 대표적인 예로 Hibernate, EclipseLink, DataNucleus, OpenJpa, TopLink 등이 있습니다.

JPA특징?

  • 쿼리를 일일히 작성할 필요가 없어 코드 작업량이 줄어든다.
  • 가독성이 뛰어나다.
  • 수정이 간편해 유지보수, 리팩토링에 용이하다.

저장

조회

💡JPA를 왜 사용해야 하는가?

  • SQL 중심적인 개발에서 객체 중심으로 개발
  • 생산성
  • 유지보수
  • 패러다임의 불일치 해결
  • 성능
  • 데이터 접근 추상화와 벤더 독립성

JPA의 성능 최적화 기능

  1. 1차 캐시와 동일성(identity) 보장
  2. 트랜잭션을 지원하는 쓰기 지연(transactional write-behind)
  3. 지연 로딩(Lazy Loading)

지연로딩은 필요할 때 필요한 부분을 가져오는 것이다.

💡JPA 장점

  1. 특정 데이터베이스에 종속되지 않음

    애플리케이션 개발을 위해 데이터베이스로 오라클을 사용해서 개발을 했다고 가정해보겠습니다. 여기서 만약 MongoDB같은 걸로 변경한다면 데이터베이스마다 쿼리문이 다르기 때문에 전체를 일일이 수정해줘야 합니다. 왜냐하면 각각의 데이터베이스가 제공하는 SQL 문법과 함수는 조금씩 다르기 때문이다. 그렇기 때문에 처음 선택한 데이터베이스를 바꿔주는 것은 어렵습니다. 하지만 JPA는 추상화한 데이터 접근 계층을 제공합니다. 설정 파일에 어떤 데이터베이스를 사용하는지 알려주면 얼마든지 데이터베이스를 변경할 수 있습니다.

  2. 객체지향적인 프로그래밍

    JPA를 사용하면 데이터베이스 설계 중심의 패러다임에서 객체지향적으로 설계가 가능합니다. 이를 통해 좀 더 직관적이고 비즈니스 로직에 집중할 수 있습니다.

  3. 생산성 향상

    데이터베이스 테이블에 새로운 컬럼이 추가되었을 경우, 해당 테이블의 컬럼을 사용하는 DTO 클래스의 필드도 모두 변경해야 합니다. JPA에서는 테이블과 매핑된 클래스에 필드만 추가한다면 쉽게 관리가 가능합니다. 또한 SQL문을 직접 작성하지 않고 객체를 사용하여 동작하기 때문에 유지보수 측면에서 좋고 재사용성도 증가합니다.

  4. 지연 로딩과 즉시 로딩

  • 지연 로딩 : 객체가 실제 사용될 때 로딩
  • 즉시 로딩 : JOIN SQL로 한번에 연관된 객체까지 미리 조회

JPA는 이 두 개를 모두 지원하는데 이게 장점이 될 수 있는 이유는 상황에 따라 사용할 수 있기 때문입니다.

💡JPA 단점

  1. 복잡한 쿼리 처리

    통계 처리 같은 복잡한 쿼리를 사용할 경우는 SQL문을 사용하는게 나을 수 있습니다. JPA에서는 Native SQL을 통해 기존의 SQL문을 사용할 수 있지만 그러면 특정 데이터베이스에 종속된다는 단점이 생깁니다. 이를 보완하기 위해서 SQL과 유사한 기술인 JPQL을 지원합니다.

  2. 성능 저하 위험

    객체 간의 매핑 설계를 잘못했을 때 성능 저하가 발생할 수 있으며, 자동으로 생성되는 쿼리가 많기 때문에 개발자가 의도하지 않는 쿼리로 인해 성능이 저하되기도 합니다.

  3. 동적 쿼리를 사용하면 가독성이 떨어져 유지보수에 어려움이 있다.

    이것도 QueryDsl을 사용하면 쉽게 해결 할 수 있습니다.

💡JPA와 MyBatis 차이는?

MyBatis에서는 SQL을 직접 작성하고 쿼리 수행 결과를 객체와 매핑하고 XML로 쿼리문을 분리 가능하고 복잡한 쿼리를 작성할 수 있지만 CRUD 메소드를 모두 직접 다 구현해야 합니다. JPA는 CRUD를 직접 구현할 필요가 없고 1차 캐싱, 쓰기 지연, 변경감지, 지연로딩이 제공되고 MyBatis는 쿼리가 수정되어 데이터 정보가 바뀌면 그에 사용 되고 있던 DTO와 함께 수정해주어야 하는 반면에, JPA 는 객체만 바꾸면 됩니다.


💡벌크성 수정 쿼리

 @Modifying(clearAutomatically = true)
 @Transactional
 @Query("update MemberEntity m set m.age = m.age + 1 where m.age >= :age")
 int bulkAgePlus(@Param("age") int age);

여기서 보면 @Modifying이 있는데 Spring Data Jpa에서 변경할 때 꼭 넣어줘야 합니다.

그렇다면 이거는 언제 사용해줘야 할까?

@Query어노테이션을 통해 작성된 변경이 일어나는 쿼리(INSERT, DELETE, UPDATE )를 실행할 때 사용된다. @Modifying을 변경이 일어나는 쿼리와 함께 사용해야 JPA에서 변경 감지와 관련된 처리를 생략하고 더 효율적인 실행이 가능하다. 즉, JPA에서 벌크 연산은 단 건 데이터를 변경(더티 체킹)하는 것이 아닌, 여러 데이터에 변경 쿼리를 날리는 작업을 말한다.

@Modifying 애노테이션은 기본적으로 @Transactional과 함께 사용된다. 변경 작업은 트랜잭션 내에서 실행되어야 하며, 완료되지 않은 변경 작업이 여러 작업에 영향을 줄 수 있기 때문이다. 이를 통해 데이터베이스에 대한 변경 작업을 수행할 때 원자성(Atomicity), 일관성(Consistency), 독립성(Isolation), 지속성(Durability)을 보장할 수 있게 됩니다.

주의점
JPA 에서는 1차 캐시라는 기능이 있다. 1차 캐시를 간단하게 설명하면 영속성 컨텍스트에 있는 1차 캐시를 통해 엔티티를 캐싱하고, DB의 접근 횟수를 줄임으로써 성능 개선 한다.

그런데 @Modifying과 @Query 를 사용한 벌크 연산에서 1차 캐시와 관련하여 문제가 발생한다. JPA 에서 조회를 실행할 시에 1차 캐시를 확인해서 해당 엔티티가 1차 캐시에 존재한다면 DB에 접근하지 않고, 1차 캐시에 있는 엔티티를 반환한다. 하지만 벌크 연산은 1차 캐시를 포함한 영속성 컨텍스트를 무시하고 바로 Query를 실행하기 때문에 영속성 컨텍스트는 데이터 변경을 알 수가 없다. 즉, 벌크 연산 실행 시, 1차 캐시(영속성 컨텍스트)와 DB의 데이터 싱크가 맞지 않게 되는 것이다. (만약 벌크 쿼리를 실행하고 다시 해당 데이터를 조회하면 영속성 컨텍스트에 과거 값이 남아 문제가 발생)

이 경우 변경된 데이터를 사용하기 전에 영속성 컨텍스트를 비워주는 작업이 필요한데, @Modifying의 clearAutomatically=true 속성을 사용해 변경 후 자동으로 영속성 컨텍스트를 초기화 할 수 있다. 해당 속성을 추가하게 되면, 조회를 실행할 때 1차캐시에 해당 엔티티가 존재하지 않기 때문에 DB 조회 쿼리를 실행하게 된다. (데이터 동기화 문제를 해결)


사용자 정의 레포지토리 구현

  • Spring Data JPA 레포지토리는 인터페이스만 정의하고 구현체는 스프링이 자동 생성

  • Spring Data JPA가 제공하는 인터페이스를 직접 구현해야 하는 기능이 너무 많음

  • 다양한 이유로 인터페이스의 메소드를 직접 구현하고 싶다면?

    • JPA 직접 사용(EntityManager)
    • 스프링 JDBC Template 사용
    • MyBatis 사용
    • 데이터베이스 커넥션 직접 사용
    • QueryDsl 사용 등
public interface MemberRepositoryCustom {
    // Spring Data JPA말고 직접 구현한 것을 사용하고 싶을 때
    List<MemberEntity> findMemberCustom();
}
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom{

    private final EntityManager em;

    @Override
    public List<MemberEntity> findMemberCustom() {
        return em.createQuery("select m from MemberEntity m")
                .getResultList();
    }
}

이렇게 순수한 ORM JPA를 사용하거나 네이티브 쿼리를 쓰고 싶은데 JPA기능이 아니라 약간 JDBC 템플릿을 사용하고 싶을 때 사용하면 된다. 하지만 한 가지를 더해줘야 합니다.

public interface MemberRepository extends JpaRepository<MemberEntity, Long>, MemberRepositoryCustom {
}

Spring Data JPA를 상속받은 인터페이스에 상속해줘야 합니다.

보통 실무에서는 복잡한 동적 쿼리같은 것들을 해결하기 위해 QueryDsl을 많이 구현하는 식으로 사용합니다.

참고

항상 사용자 정의 레포지토리가 필요한 것은 아니다. 그냥 임의의 레포지토리를 만들어도 된다. 예를 들어, MemberQueryRepository를 인터페이스가 아닌 클래스로 만들고 스프링 빈으로 등록해서 사용해도 된다. 물론 이 경우 Spring Data JPA와는 아무런 관계없이 별도로 동작한다.

이 말은 커스텀에 뭉쳐넣지 말고 실무에서 필요에따라 핵심 비즈니스로직인지 아니면 화면의 복잡한 것을 보여주는 로직인지 판별해서 나눠서 넣는 것이 좋습니다.

Business Logic?

비즈니스 로직(Business logic)은 컴비즈니스 로직은 주로 비즈니스 프로세스와 관련이 있습니다. 시스템이 수행하는 핵심 비즈니스 기능이나 규칙을 정의합니다. 예를 들어, 은행 시스템에서 계좌 이체, 입출금, 이자 계산 등과 같은 핵심 비즈니스 프로세스가 비즈니스 로직에 해당할 수 있습니다.

Domain Logic?

도메인 로직(Domain Logic)은 소프트웨어에서 가장 핵심적인 부분 중 하나로, 소프트웨어 시스템의 핵심 비즈니스 규칙을 포함하는 부분으로, 비즈니스 로직과 유사한 개념이지만 보다 추상적인 개념입니다. 도메인 로직은 계좌 잔액 체크, 이체 가능 여부 판단, 이체 기록 작성 등과 같은 비즈니스 규칙을 구현합니다.

Data Logic?

데이터 로직은 데이터의 저장, 검색, 수정, 삭제 등과 같은 데이터 관련 작업을 수행하는 코드를 말합니다. 이는 데이터베이스와 관련된 작업을 처리하는 데에 사용되며, 데이터에 대한 유효성 검사, 데이터 편집, 데이터베이스 연결 등을 수행합니다. 기본적인 CRUD 기능이라 생각하면 좋다!

데이터 로직은 도메인 모델을 이용하여 계좌 정보와 이체 기록을 데이터베이스에 저장합니다.


엔티티

엔티티란 데이터베이스의 테이블에 대응하는 클래스라고 생각하면 됩니다. @Entity가 붙은 클래스는 JPA에서 관리하며 엔티티라고 합니다. 데이터베이스에 item 테이블을 만들고, 이에 대응되는 Item 클래스를 만들어서 @Entity 어노테이션을 붙이면 이 클래스가 엔티티가 되는 것입니다. 클래스 자체나 생성한 인스턴스도 엔티티라고 합니다.

엔티티 매니저 팩토리

엔티티 매니저 팩토리는 엔티티 매니저 인스턴스를 관리하는 주체입니다. 애플리케이션 실행 시 한 개만 만들어지며 사용자로부터 요청이 오면 엔티티 매니저 팩토리로부터 엔티티 매니저를 생성합니다.

엔티티 매니저

엔티티 매니저는 이름 그대로 엔티티를 관리하는 매니저입니다. 엔티티 매니저는 데이터베이스에 접근해서 CRUD 작업을 수행합니다. Spring Data JPA를 사용하면 레포지토리를 사용해서 데이터베이스에 접근합니다. 엔티티 매니저는 엔티티 매니저 팩토리가 만듭니다. 엔티티 매니저 팩토리는 데이터베이스에 대응하는 객체로서 스프링 부트에서는 자동 설정 기능이 있기 때문에 application.properties에서 작성한 최소한의 설정으로도 동작하지만 JPA의 구현체 중 하나인 하이버네이트에서는 persistence.xml이라는 설정 파일을 구성하고 사용해야 하는 객체입니다. 엔티티 매니저란 영속성 컨텍스트에 접근하여 엔티티에 대한 데이터베이스 작업을 제공합니다. 내부적으로 데이터베이스 커넥션을 사용해서 데이터베이스에 접근합니다. 엔티티 매니저의 몇 가지 메소드를 살펴보겠습니다.

  1. find() 메소드 : 영속성 컨텍스트에서 엔티티를 검색하고 영속성 컨텍스트에 없을 경우 데이터베이스에서 데이터를 찾아 영속성 컨텍스트에 저장합니다.

  2. persist() 메소드 : 엔티티를 영속성 컨텍스트에 저장합니다.

  3. remove() 메소드 : 엔티티 클래스를 영속성 컨텍스트에서 삭제합니다.

  4. flush() 메소드 : 영속성 컨텍스트에 저장된 내용을 데이터베이스에 반영합니다.

주의

  • 엔티티의 매니저 팩토리는 하나만 생성해서 애플리케이션 전체에서 공유
  • 엔티티 매니저는 쓰레드간의 공유X (사용하고 버려야 한다.)
  • JPA는 모든 데이터 변경은 트랜잭션 안에서 실행

엔티티 생명주기

  • 비영속(new/transient)
    영속성 컨텍스트와 전혀 관계가 없는 새로운 형태

  • 영속(managed)
    영속성 컨텍스트에 관리되는 상태

  • 준영속(detached)
    영속성 컨텍스트에 저장되었다가 분리된 상태

  • 삭제(removed)
    삭제된 상태

엔티티 설계시 주의점

  • 엔티티에는 가급적 setter를 사용하면 안된다!

    변경 포인트가 너무 많아서 유지 보수가 어려움

  • 모든 연관관계는 지연로딩으로 설정!

    • 즉시로딩(EAGER)은 예측이 어렵고, 어떤 SQL이 실행될지 추적하기 어렵다. 특히 JPQL을 실행할 때 N+1 문제가 자주 발생한다.
    • 실무에서 모든 연관관계는 지연로딩(LAZY)으로 설정해야 한다.
    • 연관된 엔티티를 함께 DB에서 조회해야 하면, fetch join 또는 엔티티 그래프 기능을 사용한다.
    • @XToOne(OneToOne, ManyToOne) 관계는 기본이 즉시로딩이므로 직접 지연로딩으로 설정해야 한다.
  • 컬렉션은 필드에서 초기화 하자.

    • null 문제에서 안전
    • 하이버네이트는 엔티티를 영속화 할 때,컬렉션을 감싸서 하이버네이트가 제공하는 내장 컬렉션으로 변경한다. 만약 getOrders() 처럼 임의의 메서드에서 컬렉션을 잘못 생성하면 하이버네이트 내부 메커니즘에 문제가 발생할 수 있다. 따라서 필드레벨에서 생성하는 것이 가장 안전하고 코드도 간결하다.

💡EAGER와 LAZY 차이

  • 즉시로딩 : 엔티티를 조회할 때, 연관된 엔티티도 함께 조회한다.
    (Question을 조회할 때, List도 조회)

  • 지연로딩 : 연관된 엔티티를 실제 사용할 때 조회한다.
    (Quesion을 조회할 때, List도 사용한다면 그 때만 조회)

실무에서는 보통 지연로딩을 사용한다고 합니다. 즉시로딩은 할 때마다 모두 가져오지만 지연로딩은 필요한 것만 가져오고 전부 가져올 때 Fetch Join으로 가져오면 되니 성능적으로 좋습니다. 즉시로딩으로 가져오면 예상치 못한것도 한번에 가져올 수 있으니 안좋습니다.


💡컬렉션 조회와 페이징 문제 해결

컬렉션 조회를 사용할 경우 단점이 있습니다.

  • 컬렉션을 페치 조인하면 페이징이 불가능하다.

    • 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
    • 일대다에서 일(1)을 기준으로 페이징을 하는 것이 목적이다. 그런데 데이터는 다(N)를 기준으로 row가 생성된다.
    • Order를 기준으로 페이징하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버린다.
  • 이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다. 최악의 경우 장애로 이어질 수 있다.

문제 해결

  • 먼저, ToOne(OneToOne, ManyToOne) 관계를 모두 페치조인한다. ..ToOne관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.

  • 컬렉션은 지연 로딩으로 조회한다.

  • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size@BatchSize를 적용한다.

    • hibernate.default_batch_fetch_size : 글로벌 설정
    • @BatchSize : 개별 최적화

    이 옵션을 사용하면 컬렉션이나 프록시 객체를 한꺼번에 설정한 size만큼 IN쿼리로 조회한다.

    예를들어,
    yml에


    이렇게 default_batch_fetch_size: 100 설정하고 join fetch는 ToOne관계만 해주고 컬렉션은 LAZY로 설정하면서 필요한 경우에 @BatchSize를 추가로 사용하는 것이 일반적인 최적화 방법입니다. JQPL에는 추가 안시켜주면 컬렉션 조회를 사용할 수 있고 페이지처리도 가능합니다.

정리

  • where 절을 보게 되면 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
  • default_batch_fetch_size 개수만큼 모일 때까지 쌓아두었다가, 해당 개수가 다 모이면 쿼리를 보낸다.
  • 쿼리 호출 수가 1+N → 1 + 1로 최적화된다.
  • 조인보다 DB 데이터 전송량이 최적화된다.
  • 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.
  • default_batch_fetch_size의 크기는 적당한 사이즈

    100~1000 사이를 선택하는 것이 좋다.
    이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기도 한다.

  • 1000으로 잡으면 한 번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다.

@EntityGraph란?

Fetch Join을 JPQL로 처리하지 않고 어노테이션으로 처리할 수 있습니다.

    // findAll할 때 멤버도 조회하면서 팀까지 조회하고 싶을 때
    // 기본 적으로 findAll 을 제공하기 때문에 Override 하여 재정의 후 사용
    @Override
    // DataJpa 에서 fetch 조인을 하기 위한 설정
    @EntityGraph(attributePaths = {"team"})
    List<MemberEntity> findAll();

여기서 attributePaths = {"team"}이름은 private team 'team'; 을 적으시면 됩니다.

JPQL과 조합으로도 사용할 수 있습니다.

    // JPQL과 같이 사용하는 방법
    @EntityGraph(attributePaths = {"team"})
    @Query("select m from MemberEntity m")
    List<MemberEntity> findMemberEntityGraph();

그렇다면 언제 JPQL에서 패치 조인을 하고 언제 @EntityGraph을 사용할까?

좀 복잡해지면 JPQL에서 패치조인을 사용하고 간단하면 @EntityGraph을 사용하면 됩니다.


💡JPA N + 1 문제와 발생하는 이유 그리고 해결하는 방법을 설명해주세요.

N+1이란 1번의 쿼리를 날렸을 때 의도하지 않은 N번의 쿼리가 추가적으로 실행되는 것을 의미합니다. 즉, N+1 문제는 하위 엔티티들을 첫 쿼리 실행 시 한 번에 가져오지 않고, 지연 로딩으로 프록시가 들어온 상태에서 후에 이것을 사용하면서 조회 쿼리가 다시 나가게 되어 발생하는 문제입니다. 이거는 JPA Repository를 활용해 인터페이스 메소드를 호출할 때 발생합니다. 일대다 또는 다대일 관계를 메소드를 조회할 때 발생합니다. JPA Fetch 전략이 LAZY 전략으로 데이터를 가져온 이후에 연관 관계인 하위 엔티티를 다시 조회하는 경우에서 발생하고 EAGER 전략으로 데이터를 조회할 때 발생합니다. 발생하는 이유는 JPA Repository로 find 시 실행하는 첫 쿼리에서 하위 엔티티까지 한 번에 가져오지 않고, 하위 엔티티를 사용할 때 추가로 조회하기 때문입니다.

예를 들어, 학생(N)과 팀(1)에서 양방향관계를 갖고 DB에서 팀을 10개를 꺼낸다고 가정해보자. Fetch Join을 사용하면 Lazy로딩으로 프록시로 들어오던 것을 join으로 한 번에 땡겨올 수 있다. Batch Size는 N+1문제가 발생하던 것 처럼 프록시로 가져오고 학생들 가져오게 될 때 쿼리가 한번 더 나가게 되는데 이때 in쿼리로 Batch size 개수만큼 가져온다. 가져온 팀이 10개이고 Batch size가 5라면, 최초에 학생을 가져오는 쿼리에서 where 조건문 in 쿼리로 5개의 team id값을 넣어서 쿼리를 날린다. 이렇게 되면 결과적으로 학생을 가져오는 쿼리는 2번이 나가게 되어 총 쿼리는 3(팀 가져오는 쿼리 + 학생 가져오는 쿼리)개의 쿼리가 나가게 된다. 참고로 @EntityGraph를 사용해도 Fetch join으로 가져올 수 있다. 위에서는 지연로딩으로 설명했지만 팀을 가져올 때, 학생들을 즉시 로딩으로 설정해둬도 팀 땡겨오고 각 팀에 대한 학생도 땡겨오기 때문에 N+1 문제가 발생한다.

발생이유
N+1 문제가 발생하는 이유는 JPA가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 Fetch 전략을 참고하지 않고 오직 JPQL 자체만을 사용한다. 즉, 아래와 같은 순서로 동작

  • Fetch 전략이 즉시 로딩인 경우
  1. findAll()을 한 순간 select t from Team t 이라는 JPQL 구문이 생성되고 해당 구문을 분석한 select * from team 이라는 SQL이 생성되어 실행된다.
  2. DB의 결과를 받아 team 엔티티의 인스턴스들을 생성한다.
  3. team과 연관되어 있는 user 도 로딩을 해야 한다.
  4. 영속성 컨텍스트에서 연관된 user가 있는지 확인한다.
  5. 영속성 컨텍스트에 없다면 2에서 만들어진 team 인스턴스들 개수에 맞게 select * from user where team_id = ? 이라는 SQL 구문이 생성된다. ( N+1 발생 )

    한 개의 부모 객체와 N개의 자식 객체가 있을 때, 부모 객체를 로딩할 때 자식 객체들도 함께 로딩되는데, 이때 자식 객체들을 각각 로딩하는 추가적인 N개의 쿼리가 발생하게 됩니다. 이렇게 총 N+1번의 쿼리가 실행되는 것이 N+1 문제입니다.

  • Fetch 전략이 지연 로딩인 경우
    LAZY 로딩은 연관된 객체들을 실제로 필요한 시점에 로딩하는 전략입니다. 즉, 부모 객체를 로딩할 때 연관된 자식 객체들은 초기에는 로딩되지 않고, 실제로 접근이 필요한 시점에서 로딩됩니다.
  1. findAll()을 한 순간 select t from Team t 이라는 JPQL 구문이 생성되고 해당 구문을 분석한 select * from team 이라는 SQL이 생성되어 실행된다.
  2. DB의 결과를 받아 team 엔티티의 인스턴스들을 생성한다.
  3. 코드 중에서 team 의 user 객체를 사용하려고 하는 시점에 영속성 컨텍스트에서 연관된 user가 있는지 확인한다
  4. 영속성 컨텍스트에 없다면 2에서 만들어진 team 인스턴스들 개수에 맞게 select * from user where team_id = ? 이라는 SQL 구문이 생성된다. (N+1 발생)

    N+1 문제는 LAZY 로딩에서도 발생할 수 있습니다. 예를 들어, 부모 객체를 로딩한 후에 N개의 자식 객체를 접근하는 경우, 각 자식 객체에 대해 추가적인 N개의 쿼리가 실행될 수 있습니다.

겪은일

위에서 말한 상위 레포지토리와 하위 레포지토리를 동시 저장해서 N+1이 발생한 것도 있지만 게시글에서 유저의 닉네임, 상품의 id를 넘겨주려고 할 때 엔티티에서 직접 가져와서 DTO에 반환할 때 N+1문제가 발생했습니다.

fetch join을 쓰기전에 아마 유저와 상품을 다시 조회하기 때문에 N+1문제가 발생하는 것으로 보입니다. 예를들어, board.getMember().getNickName()를 호출할 때마다 해당 회원의 닉네임을 가져오기 위해 추가적인 쿼리가 실행될 수 있습니다. 이는 각각의 게시글에 대한 회원 정보를 가져올 때마다 추가적인 쿼리가 실행되어 N+1 문제가 발생하게 됩니다.

이럴 때 fetch join을 사용해서 한 번의 쿼리로 필요한 모든 데이터를 한꺼번에 가져올 수 있으므로 N+1 문제를 해결할 수 있습니다.

N+1문제를 해결하는 것이 Fetch Join이고 이건 연관된 테이블을 한번에 가져오는 것이면 즉시로딩을 사용하지 않고 Fetch Join을 사용하는 이유는?

: 필요할 때만 같이 불러오기 위해서입니다. A만 필요할 때는 A만 부르고 A와 B를 같이 부르고 싶을 때 fetch join을 사용하는 것입니다. 즉시로딩을 남발할 때 예상치 못한 SQL이 작성될 수 있습니다. jpa는 어디까지나 자동으로 SQL을 만들어주기 때문에 원래 의도했던 SQL과는 다르게 SQL이 나갈 수 있습니다.


💡동적 쿼리란 무엇이고 언제 동적 쿼리를 사용하나요?

동적 쿼리란 실행시에 특정 조건이나 상황에 따라 쿼리 문장이 변경되어 실행되는 쿼리문을 말합니다. 컴파일시에 SQL 문장을 확정할 수 없는 경우에 사용합니다. 실행 시점에 따라 where절에 조건이 달라질 때 사용합니다. 쿼리문이 변하냐 변하지 않느냐에 따라 정적쿼리/동적쿼리가 됩니다.


JPQL이란?

JPQL은 JPA Query Language의 줄임말로 JPA에서 사용할 수 있는 쿼리 의미합니다. JPQL의 문법은 SQL과 매우 비슷해서 데이터베이스 쿼리에 익숙한 사람들은 어렵지 않게 사용할 수 있습니다.

SQL과의 차이점은 SQL에서는 테이블이나 칼럼의 이름을 사용하는 것과 달리 JPQL은 엔티티 객체를 대상으로 수행하는 쿼리이기 때문에 매핑된 엔티티의 이름과 필드의 이름을 사용하는 점입니다.

  • JPA를 사용하면 엔티티 객체를 중심으로 개발

  • 검색을 할 때도 테이블이 아닌 엔티티 객체를 대상으로 검색

  • 모든 DB 데이터를 객체로 변환해서 검색하는 것은 불가능

  • 애플리케이션이 필요한 데이터만 DB에서 불러오려면 결국 검색 조건이 포함된 SQL이 필요

  • 테이블이 아닌 객체를 대상으로 검색하는 객체 지향 쿼리

  • SQL을 추상화해서 특정 데이터베이스 SQL에 의존X

  • JPQL을 한마디로 정의하면 객체 지향 SQL


연관관계 주인(Owner)

연관관계의 주인을 정하는 기준

  • 비즈니스 로직을 기준으로 연관관계의 주인을 선택하면 안됨
  • 연관관계의 주인은 외래 키의 위치를 기준으로 정해야함

양방향 매핑 규칙

  • 객체의 두 관계 중 하나를 연관관계의 주인으로 지정
  • 연관관계의 주인만이 외래 키를 관리(등록, 수정)
  • 주인이 아닌쪽은 읽기만 가능
  • 주인은 mappedBy 속성 사용x
  • 주인이 아니면 mappedBy 속성으로 주인 지정

양방향 매핑시 가장 많이 하는 실수

  • 연관관계의 주인에 값을 입력하지 않음

    양방향 매핑시 연관관계의 주인에 값을 입력해야 합니다. 하지만 순수한 객체 관계를 고려하면 항상 양쪽에 다 값을 입력해야 합니다.

  • 양방향 매핑시에 무한 루프를 조심하자

    예) toString(), lombok, JSON 생성 라이브러리


관계

일대일[1:1]

다대다[N:M]

관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없음. 연결 테이블을 추가해서 일대다, 다대일 관계로 풀어내야함

다대다 매핑의 한계

  • 편리해 보이지만 실무에서 사용X
  • 연결 테이블이 단순히 연결만 하고 끝나지 않음
  • 주문시간, 수량 같은 데이터가 들어올 수 있음

다대다 한계 극복

  • 연결 테이블용 엔티티 추가(연결 테이블을 엔티티로 승격)
  • @ManyToMany -> @OneToMany, @ManyToOne

조인 전략

장점

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

단점

  • 조회시 조인을 많이 사용, 성능 저하
  • 조회 쿼리가 복잡함
  • 데이터 저장시 INSERT SQL 2번 호출

@MappedSuperclass

  • 상속 관계x

  • 엔티티x, 테이블과 매핑x

  • 부모 클래스를 상속받는 자식 클래스에 매핑 정보만 제공

  • 조회, 검색 불가

  • 직접 생성해서 사용할 일이 없으므로 추상 클래스 권장

  • 테이블과 관계 없고, 단순히 엔티티가 공통으로 사용하는 매핑 정보를 모으는 역할


프록시

프록시는 "대신하다"라는 의미를 가지고 있는 단어인데 동작을 대신해주는 가짜 객체의 개념이라고 생각하면 됩니다. 하이버네이트는 지연 로딩을 구현하기 위해 프록시를 사용합니다. 지연 로딩을 하려면 연관된 엔티티의 실제 데이터가 필요할 때 까지 조회를 미뤄야 합니다. 그렇다고 해당 엔티티를 연관관계로 가지고 있는 엔티티의 필드에 null 값을 넣어 둘 수는 없겠죠?

하이버네이트는 지연 로딩을 사용하는 연관관계 자리에 프록시 객체를 주입하여 실제 객체가 들어있는 것처럼 동작하도록 합니다. 덕분에 우리는 연관관계 자리에 프록시 객체가 들어있든 실제 객체가 들어있든 신경쓰지 않고 사용할 수 있습니다. 참고로 프록시 객체는 지연 로딩을 사용하는 것 외에도 em.getReference를 호출하여 프록시를 호출할 수도 있습니다.

  • 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 객체 조회, Id값만 가지고 있는 가짜가 반환이 된다.

  • 실제 클래스를 상속 받아서 만들어짐

    프록시가 실제 객체처럼 동작할 수 있는 이유

  • 실제 클래스와 겉 모양이 같다.

  • 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 됨(이론상)

특징

  • 프록시 객체는 실제 객체의 참조(target)를 보관

  • 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메소드 호출

  • 프록시 객체는 처음 사용할 때 한 번만 초기화

  • 프록시 객체를 초기화 할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아님, 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근 가능

  • 프록시 객체는 원본 엔티티를 상속받음, 따라서 타입 체크시 주의해야함 (== 비교 실패, 대신 instance of 사용)

  • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 실제 엔티티 반환

  • 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하면문제 발생

    하이버네이트는 org.hibernate.LazyInitializationException 예외를 터트림

프록시 객체의 초기화

최초 지연 로딩 시점에는 당연히 참조 값이 없습니다. 때문에 실제 객체의 메서드를 호출할 필요가 있을 때 데이터베이스를 조회해서 참조 값을 채우게 되는데요, 이를 프록시 객체를 초기화한다고 합니다. 앞서 말씀드렸듯이 실제 객체의 메서드를 호출할 필요가 있을 때 select 쿼리를 실행하여 실제 객체를 데이터베이스에서 조회해오고, 참조 값을 저장하게 됩니다.


고아 객체

부모 엔티티와 연관관계가 끊어진 자식 엔티티

고아 객체 - 주의

  • 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능

  • 참조하는 곳이 하나일 때 사용해야 함

  • 특정 엔티티가 개인 소유일 때 사용

  • @OneToOne, @OneToMany만 가능

  • 개념적으로 부모를 제거하면 자식은 고아가 된다. 따라서 고아 객체 제거 기능을 활성화하면, 부모를 제거할 때 자식도 함께 제거된다. 이것은 cascadeType.REMOVE처럼 동작을 한다.


엠베디드 타입

  • 새로운 값 타입을 직접 정의할 수 있음
  • JPA는 임베디드 타입(embedded type)이라 함
  • 주로 기본 값 타입을 모아서 만들어서 복합 값 타입이라고도 함
  • int, String과 같은 값 타입

임베디드 타입 사용법

  • @Embeddable : 값 타입을 정의하는 곳에 표시
  • @Embedded : 값 타입을 사용하는 곳에 표시
  • 기본 생성자 필수

장점

  • 재사용

  • 높은 응집도

  • Period.isWork()처럼 해당 값 타입만 사용하는 의미 있는 메소드를 만들 수 있음

  • 임베디드 타입을 포함한 모든 값 타입은, 값 타입을 소유한 엔티티에 생명주기를 의존함

임베디드 타입과 테이블 매핑

  • 임베디드 타입은 엔티티의 값일 뿐이다.

  • 임베디드 타입을 사용하기 전과 후에 매핑하는 테이블은 같다.

  • 객체와 테이블을 아주 세밀하게(find-grained) 매핑하는 것이 가능

  • 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많음

스프링 부트와 JPA에서 엠베디드 값 타입(Embedded Value Type)을 사용할 때, DTO 객체에도 따로 값 타입 클래스를 만들어 사용하는 것은 일반적인 접근 방법입니다. 이를 통해 엔티티와 DTO 사이의 의존성을 줄이고, API의 요청/응답 구조를 명확하게 유지할 수 있습니다.

DTO는 엔티티와 API 간의 데이터 교환을 위한 객체이며, 엔티티에서 필요한 정보를 선택적으로 포함하고 구조를 재조정하여 API의 요청/응답 구조에 적합하게 설계됩니다. 값 타입은 주로 엔티티의 속성으로 사용되는 객체이므로, 엔티티와 마찬가지로 DTO에서도 값 타입 객체를 사용하는 것이 바람직합니다.

예를 들어, Member 엔티티에서 Address 값을 타입으로 사용한다고 가정해봅시다. 이 경우 MemberDto라는 DTO를 만들 때, MemberDto 내부에 AddressDto 클래스를 만들어서 사용하는 것이 좋습니다. AddressDto는 Address 값을 타입으로 갖는 DTO로써, 필요한 필드와 구조를 가지고 있습니다. DTO에도 별도의 값 타입 클래스를 만들어 사용함으로써, 엔티티와 DTO의 구조적인 차이를 유지하고, 유지보수성과 확장성을 높일 수 있습니다.


객체 타입의 한계

  • 항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다.

  • 문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이다.

  • 자바 기본 타입에 값을 대입하면 값을 복사한다.

  • 객체 타입은 참조 값을 직접 대입하는 것을 막을 방법이 없다.

  • 객체의 공유 참조는 피할 수 없다.


불변 객체

  • 객체 타입을 수정할 수 없게 만들면 부작용을 원천 차단
  • 값 타입은 불변 객체(immutable object)로 설계해야 함
  • 불변 객체 : 생성 시점 이후 절대 값을 변경할 수 없는 객체
  • 생성자로만 값을 설정하고 수정자(Setter)를 만들지 않으면 됨
  • 참고 : Integer, String은 자바가 제공하는 대표적인 불변 객체

값 타입 컬렉션

db에는 컬렉션을 저장할 수 없습니다. 따라서 jpa의 값 타입 컬렉션은 @ElementCollection과 @CollectionTable 어노테이션을 통해 구현할 수 있습니다.

  • 값 타입을 하나 이상 저장할 때 사용
  • @ElementCollection, @CollectionTable 사용
  • 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없다.
  • 컬렉션을 저장하기 위한 별도의 테이블이 필요함

개념

값 타입을 컬렉션에 담아 사용하는 것을 의미합니다.DB에서는 따로 컬렉션을 저장할 수 없으므로, 컬렉션에 해당하는 테이블을 하나 추가하여 컬렉션을 구현합니다. 이를 위해 @ElementCollection과 @CollectionTable 어노테이션을 사용합니다.

특징

① 값 타입 컬렉션은 값 타입과 마찬가지로, 따로 생명주기를 가지지 않고 엔티티와 같은 생명주기를 갖습니다. 일대다 관계에서 CASCADE = ALL, orphanREmoval = TRUE를 설정해준 것과 같습니다. 아래의 예를 통해 이해해보겠습니다.

@Entity(name = "member_ex")
@Table(name = "MBR")
@Setter
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@SequenceGenerator(name = "member_seq_generator")
public class Member extends BaseEntity {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    @Column(name = "user_name", nullable = false)
    private String userName;

    // 기간
    @Embedded
    private Period workPeriod;

    // 주소
    @Embedded
    private Address homeAddress;

    @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<>();

② 지연로딩전략을 사용합니다.

try {
    Member member = new Member();
    member.setUsername("member1");

    member.getAddressList().add(new Address("city1", "street1", "1"));
    member.getAddressList().add(new Address("city2", "street2", "2"));

    em.persist(member);

    em.flush();
    em.clear();

    System.out.println("================== START ================");
    Member foundMember = em.find(Member.class, member.getId());
    System.out.println("================== 지연로딩 ================");
    List<Address> addressList = foundMember.getAddressList();
    for (Address address : addressList) {
        System.out.println("address.getCity() = " + address.getCity());
    }

    tx.commit();

member를 find할 때 바로 값 타입 컬렉션을 꺼내오는 것이 아니라, 필요한 순간에 지연로딩 됩니다. 아래의 결과를 보고 이해할 수 있습니다.

값 타입 컬렉션의 수정

remove 후 add 하는 방식
값 타입 컬렉션은 call by reference 이므로 값만 단순히 수정해줄 수 없습니다. 따라서 remove후 add하는 방식으로 값 타입을 수정할 수 있지만 독특한 부분이 있습니다. update로 원하는 부분만 수정해주는 것이아니라, delete로 모두 삭제하고 insert 쿼리가 나가기 때문입니다.

값 타입은 엔티티와 다르게 식별자 개념이 없습니다. 값은 변경하면 추적이 어렵습니다. JPA에서는 값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 관련된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장하게 됩니다.

참고 : 값 타입 컬렉션은 영속성 전에(Cascade) + 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.

 Member member = new Member();
            member.setUserName("member1");
            member.setHomeAddress(new Address("homeCity", "street", "10000"));
            member.getFavoriteFoods().add("치킨");
            member.getFavoriteFoods().add("족발");
            member.getFavoriteFoods().add("파스타");

            member.getAddressHistory().add(new Address("old1", "street", "10000"));
            member.getAddressHistory().add(new Address("old2", "street", "10000"));

            entityManager.persist(member);

            entityManager.flush();
            entityManager.clear();

            System.out.println("=====================================");
            Member findMember = entityManager.find(Member.class, member.getId());

            Address a = findMember.getHomeAddress();
            findMember.setHomeAddress(new Address("new City", a.getStreet(), a.getZipcode()));

            findMember.getFavoriteFoods().remove("치킨");
            findMember.getFavoriteFoods().add("한식");

            findMember.getAddressHistory().remove(new Address("old1", "street", "10000"));
            findMember.getAddressHistory().add(new Address("newCity1", "street", "10000"));

값 타입 컬렉션의 제약사항

  • 값 타입은 엔티티오 다르게 식별자 개념이 없다.

  • 값은 변경하면 추적이 어렵다.

  • 값 타입 컬렉션에 변경 사항이 발생하면 주인 엔티티와 연관된 모든 데이터를 삭제하고 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.

  • 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야함

    null 입력x, 중복 저장x

값 타입 컬렉션 대안

  • 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려
  • 일대다 관계를 위한 엔티티를 만들고 여기에서 값 타입을 사용
  • 영속성 전이(Cascade) + 고아 객체 제거를 사용해서 값 타입 컬렉션 처럼 사용

값 타입은 정말 값 타입이라 판단돌때만 사용하고 엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안된다. 식별자가 필요하고, 지속해서 값을 추적, 변경해야 한다면 그것은 값 타입이 아니라 엔티티여야 한다.


Fetch Join이란?

  • SQL 조인 종류 x
  • JPQL에서 성능 최적화를 위해 제공하는 기능
  • 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능

    회원을 조회하면서 연관된 팀도 함께 조회

페치 조인을 사용하면서 DISTINCT를 자주 사용하는데 하이버네이트6 부터는 DISTINCT 명령어를 사용하지 않아도 애플리케이션에서 중복 제거가 자동으로 적용됩니다.

페치 조인과 일반 조인의 차이

  • JPQL은 결과를 반환할 때 연관관계 고려 X
  • 단지 select 절에 지정한 엔티티만 조회할 뿐
  • 여기서는 팀 엔티티만 조회하고 회원 엔티티는 조회 X
  • 페치 조인을 사용할 때만 연관된 엔티티도 함께 조회(즉시 로딩)
  • 페치 조인은 객체 그래프를 SQL 한 번에 조회하는 개념

페치 조인의 특징과 한계

  • 페치 조인 대상에는 별칭을 줄 수 없다.

    하이버네이트는 가능, 가급적 사용X

  • 둘 이상의 컬렉션은 페치 조인 할 수 없다.

  • 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다.

    • 일대일, 다대일 같은 단일 값 연관 필드들은 페치 조인해도 페이징 가능
    • 하이버네이트는 경고 로그를 남기고 메모리에서 페이징(매우 위험)
  • 연관된 엔티티들을 SQL 한 번으로 조회

    성능 최적화

  • 엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선함

    @OneToMany(fetch = FetchType.LAZY) // 글로벌 로딩 전략

  • 실무에서 글로벌 로딩 전략은 모두 지연 로딩

  • 최적화가 필요한 곳은 페치 조인 적용


JPQL : named 쿼리

  • 미리 정의해서 이름을 부여해주고 사용하는 JPQL
  • 정적 쿼리
  • 어노테이션, XML에 정의
    • XML이 항상 우선권을 가진다.
    • 애플리케이션 운영 환경에 따라 다른 XML을 배포할 수 있다.
  • 애플리케이션 로딩 시점에 초기화 후 재사용
  • 애플리케이션 로딩 시점에 쿼리를 검증

Cascade란?

특정 엔티티를 영속 상태로 만들 경우, 연관된 엔티티도 함께 영속 상태로 만들고 싶을 경우 영속성 전이를 사용합니다. JPA에서는 영속성 전이를 Cascade옵션을 통해서 설정하고 관리할 수 있습니다. 쉽게 말해서 부모 엔티티를 다룰 경우, 자식 엔티티까지 다룰 수 있다는 뜻이죠.

Cascade는 6가지의 옵션을 가지고 있습니다.

  • ALL
  • PERSIST
  • MERGE
  • REMOVE
  • REFRESH
  • DETACH

여기서 주로 사용하는 것은 ALL과 PERSIST와 REMOVE입니다.

CascadeType.PERSIST

PERSIST는 부모와 자식엔티티를 한 번에 영속화할 수 있습니다.

CascadeType.REMOVE

PERSIST로 함께 저장했던 부모와 자식의 엔티티를 모두 제거할 경우에 CascadeType.REMOVE를 사용합니다. CascadeType.REMOVE옵션을 사용했을 경우에는 부모객체를 삭제하면 연관되어 있는 자식 객체들이 줄줄이 사라지게 되죠.

CascadeType.ALL

CascadeType.PERSIST 와 CascadeType.REMOVE의 기능을 모두 수행 해주는 옵션입니다.


페이징 한계 돌파

  • 컬렉션을 페치 조인하면 페이징이 불가능하다.

    • 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
    • 일대다에서 일(1)을 기준으로 페이징을 하는 것이 목적이다. 그런데 데이터는 다(N)를 기준으로 row가 생성된다.
    • Order를 기준으로 페이징하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버린다.
  • 이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다. 최악의 경우 장애로 이어질 수 있다.

한계 돌파

그렇다면 페이징과 컬렉션 엔티티를 함께 조회하라면 어떻게 해야할까?

  • 먼저, ToOne(OneToOne, ManyToOne) 관계를 모두 페치조인한다. ..ToOne관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.

  • 컬렉션은 지연 로딩으로 조회한다.

  • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize를 적용한다.

    • hibernate.default_batch_fetch_size : 글로벌 설정
    • @BatchSize : 개별 최적화

    이 옵션을 사용하면 컬렉션이나 프록시 객체를 한꺼번에 설정한 size만큼 IN쿼리로 조회한다.

default_batch_fetch_size의 크기는 적당한 사이즈를 골라야 하는데 100~1000 사이를 선택하는 것을 권장합니다. 이 전략을 SQL IN 절을 사용하는데 데이터베이스에 따라 IN절 파라미터를 1000으로 제한하기도 한다. 1000으로 한번에 1000개를 DB에서 애플리케이션으로 불러오므로 DB에 순간 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다. 1000으로 설정하면 성능상 가장 좋지만 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는지로 결정하면 좋다.


엔티티 조회하는 이유

엔티티 조회 방식은 JPA가 많은 부분을 최적화 해주기 때문에 단순한 코드를 유지하면서 성능을 최적화 할 수 있다. 반면에 DTO 조회 방식은 SQL을 직접 다루는 것과 유사합니다.


Spring Data JPA

JPA는 인터페이스로서 자바 표준명세서입니다. 인터페이스인 JPA를 사용하기 위해서는 구현체가 필요합니다. 구현체들을 좀 더 쉽게 사용하고자 추상화시킨 Spring Data JPA라는 모듈을 이용하여 JPA 기술을 다룹니다.

Hibernate를 쓰는 것과 Spring Data JPA를 쓰는 것 사이에는 큰 차이가 없습니다. 그럼에도 스프링 진영에서는 Spring Data JPA를 개발했고 이를 권장합니다. Spring Data JPA가 등장한 이유는 크게 두 가지가 있습니다.

  • 구현체 교체의 용이성
  • 저장소 교체의 용이성

먼저, 구현체 교체의 용이성이란 Hibernate 외에 다른 구현체로 쉽게 교체하기 위함입니다.

Hibernate가 언젠가 수명을 다해서 새로운 JPA 구현체가 대세로 떠오를 때 Spring Data JPA를 쓰는 중이라면 아주 쉽게 교체 가능합니다. 내부에서 구현체 매핑을 지원해주기 때문입니다.

저장소 교체의 용이성이란 관계형 데이터베이스 외에 다른 저장소로 쉽게 교체하기 위함입니다. 서비스 초기에는 관계형 데이터베이스로 모든 기능을 처리했지만 점점 트래픽이 많아져 관계형 데이터베이스로는 도저히 감당이 안될 때 올 수 있는데 이 때 MongoDB로 교체가 필요하다면 개발자는 Spring Data JPA에서 Spring Data MongoDB로 의존성만 교체하면 됩니다. 이는 Spring Data의 하위 프로젝트들은 기본적인 CRUD의 인터페이스가 같기 때문입니다.


QueryDsl이란?

QueryDSL은 하이버네이트 쿼리 언어(HQL: Hibernate Query Language)의 쿼리를 타입에 안전하게 생성 및 관리해주는 프레임워크이다. QueryDSL은 정적 타입을 이용하여 SQL과 같은 쿼리를 생성할 수 있게 해 준다.

자바 백엔드 기술은 Spring Boot와 Spring Data JPA를 함께 사용한다. 하지만, 복잡한 쿼리, 동적 쿼리를 구현하는 데 있어 한계가 있다. 이러한 문제점을 해결할 수 있는 것이 QueryDSL이다. QueryDSL이 등장하기 이전에는 Mybatis, JPQL, Criteria 등 문자열 형태로 쿼리문을 작성하여 컴파일 시에 오류를 발견하는 것이 불가능했다. 하지만, QueryDSL은 자바 코드로 SQL 문을 작성할 수 있어 컴파일 시에 오류를 발생하여 잘못된 쿼리가 실행되는 것을 방지할 수 있다.

장점

  • 문자가 아닌 코드로 쿼리를 작성할 수 있어 컴파일 시점에 문법 오류를 확인할 수 있다.

  • 인텔리제이와 같은 IDE의 자동 완성 기능의 도움을 받을 수 있다.

  • 복잡한 쿼리나 동적 쿼리 작성이 편리하다.

  • 쿼리 작성 시 제약 조건 등을 메서드 추출을 통해 재사용할 수 있다.

  • JPQL 문법과 유사한 형태로 작성할 수 있어 쉽게 적응할 수 있다.

profile
발전하기 위한 공부

0개의 댓글