[JPA] JPA 더 맛있게(?) 사용하기 1부

sinbom·2021년 11월 29일
0
post-thumbnail

소개

제가 속한 개발 환경에서는 JAVA 진영의 표준 ORM 인터페이스인 JPA를 사용해왔습니다. JPA를 사용했던 프로젝트들은 구현체인 Hibernate를 사용했고, Spring 기반의 웹 애플리케이션이었기 때문에 개발 생산성과 편의성을 위해 항상 Spring Data JPA를 함께 사용했습니다. 하지만, Spring Data JPA가 제공하는 쿼리 메소드 기능만을 사용해도 대부분의 간단한 CRUD 쿼리를 사용할 수 있었고, 러닝커브가 낮지 않기 때문에 개발팀의 인원들이 JPA의 기본적인 동작 방식에 대한 이해가 부족한 상태에서 개발을 하는 경우가 많았습니다. 그로인해, 데이터의 일관성이 깨지거나 불필요한 쿼리들이 많이 발생하면서 심각한 수준의 성능 이슈들이 발생하기도 했습니다.
실무에서 발생했던 문제들을 해결하기 위해 JPA를 공부하고 사용해오면서 애플리케이션의 성능을 개선하고 문제들을 해결해온 경험들을 정리하고자 합니다.


내용

1부에서는 실제 개발 환경에서 볼 수 있었던 잘못된 엔티티 설계들을 예제로 재연하고, 발생할 수 있는 에러나 성능 문제에 대해 살펴보면서 개선 방법을 소개해보겠습니다.

잘못된 설계로 인해 발생할 수 있는 문제

@Setter
@Getter
@Entity
@NoArgsConstructor
public class Teacher {

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

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    @Enumerated(value = EnumType.STRING)
    private Gender gender;

    @ManyToMany(mappedBy = "teachers", fetch = FetchType.EAGER)
    private List<Student> students;

    public Teacher(String name, Gender gender) {
        this.name = name;
        this.gender = gender;
    }

}
@Setter
@Getter
@Entity
@NoArgsConstructor
public class Student {

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

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    @Enumerated(value = EnumType.STRING)
    private Gender gender;
    
    private boolean graduated;
    
    @ManyToOne(fetch = FetchType.EAGER)
    private School school;
    
    @ManyToMany(fetch = FetchType.EAGER)
    private List<Teacher> teachers;

    public Student(String name, Gender gender) {
        this.name = name;
        this.gender = gender;
    }

    public Student(String name, Gender gender, boolean graduated) {
        this.name = name;
        this.gender = gender;
        this.graduated = graduated;
    }

}
@Setter
@Getter
@Entity
@NoArgsConstructor
public class School {

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

    @Column(nullable = false)
    private String name;

    public School(String name) {
        this.name = name;
    }

}

다음의 강사, 학생, 학교 엔티티를 예제 코드에서 사용하겠습니다.

  • Teacher 엔티티의 이름, 성별은 필수 값 필드입니다.
  • Student 엔티티의 이름, 성별, 졸업여부, 학교는 필수 값 필드입니다(학교 연관관계는 설계 관점에서는 필수가 아니지만 요구사항 관점에서 필수라고 하겠습니다).
  • School 엔티티의 이름은 필수 값 필드입니다.
  • Teacher, Student 엔티티가 N:M 양방향 관계입니다.
  • Student, School 엔티티가 N:1 단방향 관계입니다.
  • 모든 엔티티 클래스는 getter/setter를 가지고 있습니다.
  • 모든 엔티티 클래스는 기본 생성자, 기본키와 필수 값이 아닌 필드를 제외한 필드들을 파라미터로 받는 생성자를 가지고 있습니다.

1. 기본키 생성 전략의 설정을 프레임워크에게 위임했을 때, 어떤 기본키 생성 전략이 사용되는지 정확하게 인지하지 못하는 경우에 의도와 다르게 동작할 수 있다.

(테이블 생성 및 제약조건 쿼리 로그 생략...)

2021-11-23 10:32:00.112 DEBUG 21051 --- [    Test worker] org.hibernate.SQL : create sequence hibernate_sequence start with 1 increment by 1

기본키 생성 전략을 생략하거나 GenerationType.AUTO을 사용하게 되면 기본키 생성 전략 설정을 하이버네이트에게 위임하게 됩니다.
예제 코드에서 사용되는 DBMS는 H2이므로 기본키 생성 전략으로 GenerationType.AUTO를 사용하면 GenerationType.SEQUENCE으로 설정됩니다.

Mysql은 사용중인 Spring Boot 버전에 따라 적용되는 Hibernate 버전마다 다른 기본키 생성 전략을 사용합니다.

  • Spring Boot 1.5.x(Hibernate 5.0.x) = GenerationType.IDENTITY
  • Spring Boot 2.0.x(Hibernate 5.2.x) = GenerationType.TABLE
@Test
@Transactional
@Rollback(value = false)
void 기본키_생성전략으로_AUTO를_사용한다() {
    // given
    Teacher teacher = new Teacher("홍길동", Gender.MALE);

    // when
    entityManager.persist(teacher); // 다음 sequence 조회 쿼리가 발생한다.
    entityManager.clear(); // 영속성 컨텍스트를 초기화하여 insert 쿼리가 발생하지 않는다.
}
(테이블 생성 및 제약조건 쿼리 로그 생략...)

2021-11-23 10:32:02.960 DEBUG 21051 --- [    Test worker] org.hibernate.SQL : call next value for hibernate_sequence

기본키 생성 전략으로 GernertionType.SEQUENCE를 사용하게 되면 엔티티를 영속화할 때, 기본키 채번을 위해 다음 시퀀스 값을 조회합니다. insert 쿼리는 영속성 컨텍스트가 반영될 때 발생하기 때문에 반영되기 전에 영속성 컨텍스트를 초기화하게 되면 insert 쿼리가 발생하지 않습니다.

영속성 컨텍스트가 반영되는 시점은 entityManager에 설정된 FlushModeType에 따라 결정됩니다. 기본 값은 FlushMOdeType.AUTO입니다.

  • FlushModeType.AUTO = flush(), 트랜잭션 커밋, JPQL 수행
  • FlushModeType.COMMIT = flush(), 트랜잭션 커밋
@Test
@Transactional
@Rollback(value = false)
void 기본키_생성전략으로_IDENTITY를_사용한다() {
    // given
    Teacher teacher = new Teacher("홍길동", Gender.MALE);

    // when
    entityManager.persist(teacher); // insert 쿼리가 발생한다.
    entityManager.clear(); // 영속성 컨텍스트를 초기화하더라도 이미 insert 쿼리가 발생했다.
}
(테이블 생성 및 제약조건 쿼리 로그 생략...)

2021-11-23 10:43:26.257 DEBUG 21089 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        teacher
        (id, gender, name) 
    values
        (null, ?, ?)
2021-11-23 10:43:26.266 TRACE 21089 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [MALE]
2021-11-23 10:43:26.268 TRACE 21089 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [VARCHAR] - [홍길동]

기본키 생성 전략으로 GenerationType.IDENTITY를 사용하게 되면 auto_increment가 적용되어 insert 쿼리를 수행해야 기본키를 채번하여 설정할 수 있으므로 엔티티를 영속화 할 때, insert 쿼리가 발생합니다.

영속성 컨텍스트는 기본키와 엔티티 객체가 key, value로 구성된 해시 맵 자료구조로 엔티티들을 관리하기 때문에 영속화 상태의 엔티티는 반드시 기본키 필드의 값을 가지고 있어야합니다.

기본키 생성 전략으로 GenerationType.AUTO를 사용하는 경우, 의도한 기본키 생성 전략과 다른 전략을 사용하게 되는 상황이 발생할 수 있습니다. 설정된 전략에 따라 추가적인 쿼리 발생 여부와 쿼리가 발생하는 시점이 다르기 때문에 의도한 대로 동작하지 않을 수 있고 어떤 기본키 생성 전략이 설정되는지 정확하게 인지해야합니다.

2. 필수 필드의 값을 설정해주지 않으면 제약조건 에러가 발생하지만 기본 생성자로 객체를 생성했을 때, 필수 값의 설정을 누락하기 쉽다.

@Test
void 기본생성자로_생성후_setter로_값을_설정하지않으면_에러가발생한다() {
    // given
    Teacher teacher = new Teacher();
    
    teacher.setName("홍길동");
    
    // when
    entityManager.persist(teacher); // 에러 발생
}

필수 필드들을 파라미터로 받는 생성자를 사용하지 않고 기본 생성자로 객체를 생성하게 되면 필드가 많을수록 필수 값의 설정을 누락하기 쉽습니다. 필수 값이 누락 되거나 유효하지 않은 경우, 데이터베이스에서 쿼리를 수행하기 전에 애플리케이션에서 필수 값의 존재 유무를 검증하고 에러를 발생시킵니다.

Hibernate는 런타임 프록시(엔티티의 프록시 객체)를 리플렉션(Class.newInstance)으로 생성하기 때문에, 기본 생성자는 필수 요구 스펙입니다.

3. 무분별한 setter 또는 명확하지 않은 의미로 인해 의도하지 않은 update 쿼리가 발생할 수 있다.

@Test
@Transactional
@Rollback(value = false)
void 명확하지않은_의미로인해_의도하지않은_update쿼리를_예상하지_못할수있다() {
    // given
    Teacher teacher = new Teacher("홍길동", Gender.MALE);
    
    entityManager.persist(teacher);
    entityManager.flush(); // 영속성 컨텍스트를 데이터베이스에 반영
    
    // when
    teacher.setName("홍길순"); // 객체의 상태 값 변경
}
(테이블 생성 및 제약조건 쿼리 로그 생략...)

2021-11-22 18:17:46.490 DEBUG 19045 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        teacher
        (gender, name, id) 
    values
        (?, ?, ?)
2021-11-22 18:17:46.492 TRACE 19045 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [MALE]
2021-11-22 18:17:46.494 TRACE 19045 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [VARCHAR] - [홍길동]
2021-11-22 18:17:46.497 TRACE 19045 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [BIGINT] - [1]
2021-11-22 18:17:46.499 DEBUG 19045 --- [    Test worker] org.hibernate.SQL                        : 
    update
        teacher 
    set
        gender=?,
        name=? 
    where
        id=?
2021-11-22 18:17:46.503 TRACE 19045 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [MALE]
2021-11-22 18:17:46.506 TRACE 19045 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [VARCHAR] - [홍길순]
2021-11-22 18:17:46.506 TRACE 19045 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [BIGINT] - [1]

트랜잭션 범위에서 영속성 컨텍스트에서 관리 중인 엔티티의 상태 값을 변경하게 되면 트랜잭션이 종료되기 전에 엔티티 조회 시점의 스냅샷과 비교하여 update 쿼리를 발생시킵니다. 변경 감지(dirty check)와 쓰기 지연(write behind) 등 프레임워크의 동작에 대한 이해가 부족한 경우, setter 호출로 인해 단순히 객체의 상태 값을 변경하는 것 뿐만아니라 데이터베이스에 update 쿼리를 발생시키는 것을 예상하지 못할 수 있습니다.
모든 필드를 대상으로 객체의 상태 값을 수정하는 무분별한 메소드를 사용하지 않고, 변경이 가능한 필드에 대해서만 객체의 상태 값을 수정하는 명확한 의미를 가진 메소드를 사용해야합니다.

4. N:M 관계를 @ManyToMany로 매핑하는 경우, 스키마 변경사항에 대응할 수 없고 데이터의 추가 및 삭제 시, 불필요한 쿼리가 발생하여 성능 문제가 발생할 수 있다.

(테이블 생성 및 제약조건 쿼리 로그 생략...)

2021-11-22 19:11:25.936 DEBUG 19348 --- [    Test worker] org.hibernate.SQL                        : 
    
    create table student_teachers (
       students_id bigint not null,
        teachers_id bigint not null
    )
2021-11-22 19:11:25.941 DEBUG 19348 --- [    Test worker] org.hibernate.SQL                        : 
    
    alter table student_teachers 
       add constraint FKq4kvujupvu9h1cvaiuicvdy9r 
       foreign key (teachers_id) 
       references teacher
2021-11-22 19:11:25.949 DEBUG 19348 --- [    Test worker] org.hibernate.SQL                        : 
    
    alter table student_teachers 
       add constraint FKfuna8hxmfyk7p360eaf2vrf6s 
       foreign key (students_id) 
       references student

@ManyToMany 연관관계의 타입이 List인 경우, N:M 매핑 테이블은 두 부모 테이블의 기본키를 참조하는 외래키를 가진 기본키가 없는 테이블로 생성됩니다.

@Test
@Transactional
@Rollback(value = false)
void List타입의_ManyToMany연관관계는_삭제가_불가능하고_삽입시_전체삭제후_삽입하게된다() {
    // given
    Teacher teacher = new Teacher("홍길동", Gender.MALE);
    Teacher teacher2 = new Teacher("홍길순", Gender.MALE);
    Teacher teacher3 = new Teacher("아무개", Gender.MALE);
    Teacher teacher4 = new Teacher("신나리", Gender.MALE);
    Student student = new Student("신영진", Gender.MALE);
    List<Teacher> teachers = new ArrayList<>();

    teachers.add(teacher);
    teachers.add(teacher2);
    teachers.add(teacher3);
    student.setTeachers(teachers);

    entityManager.persist(teacher);
    entityManager.persist(teacher2);
    entityManager.persist(teacher3);
    entityManager.persist(teacher4);
    entityManager.persist(student);
    entityManager.flush(); // 영속성 컨텍스트를 데이터베이스에 반영

    // when
    // 해당하는 student id를 가진 데이터 전체 삭제 후, 삭제 데이터를 제외한 데이터를 전체 삽입
    student.getTeachers().add(teacher4);
}
(테이블 생성 및 제약조건 쿼리 로그 생략...)

2021-11-22 20:38:28.148 DEBUG 19991 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student_teachers
        (students_id, teachers_id) 
    values
        (?, ?)
2021-11-22 20:38:28.149 TRACE 19991 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-22 20:38:28.150 TRACE 19991 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [1]
2021-11-22 20:38:28.151 DEBUG 19991 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student_teachers
        (students_id, teachers_id) 
    values
        (?, ?)
2021-11-22 20:38:28.151 TRACE 19991 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-22 20:38:28.152 TRACE 19991 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [2]
2021-11-22 20:38:28.152 DEBUG 19991 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student_teachers
        (students_id, teachers_id) 
    values
        (?, ?)
2021-11-22 20:38:28.152 TRACE 19991 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-22 20:38:28.152 TRACE 19991 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [3]
2021-11-22 20:38:28.172 DEBUG 19991 --- [    Test worker] org.hibernate.SQL                        : 
    delete 
    from
        student_teachers 
    where
        students_id=?
2021-11-22 20:38:28.174 TRACE 19991 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-22 20:38:28.177 DEBUG 19991 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student_teachers
        (students_id, teachers_id) 
    values
        (?, ?)
2021-11-22 20:38:28.178 TRACE 19991 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-22 20:38:28.178 TRACE 19991 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [1]
2021-11-22 20:38:28.179 DEBUG 19991 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student_teachers
        (students_id, teachers_id) 
    values
        (?, ?)
2021-11-22 20:38:28.181 TRACE 19991 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-22 20:38:28.181 TRACE 19991 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [2]
2021-11-22 20:38:28.182 DEBUG 19991 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student_teachers
        (students_id, teachers_id) 
    values
        (?, ?)
2021-11-22 20:38:28.183 TRACE 19991 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-22 20:38:28.183 TRACE 19991 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [3]
2021-11-22 20:38:28.184 DEBUG 19991 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student_teachers
        (students_id, teachers_id) 
    values
        (?, ?)
2021-11-22 20:38:28.185 TRACE 19991 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-22 20:38:28.185 TRACE 19991 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [4]

매핑 테이블에 기본키가 없으므로 students_id, teachers_id가 중복인 데이터가 존재할 수 있습니다. 데이터베이스의 관점에서는 존재하는 데이터 중에서 어떤 데이터가 추가 및 삭제되었는지 정확히 식별할 수 없기 때문에 students_id가 1인 데이터를 전체 삭제 한 후, 삭제 한 데이터를 제외한 전체 데이터를 삽입합니다. 이로인해, N:M 테이블에 매핑된 데이터가 많을수록 불필요한 쿼리가 많이 발생하게 됩니다.

엔티티 타입이 아닌 기본 타입이나 임베디드 타입에 사용할 수 있는 @ElementCollection도 동일한 성능 문제가 발생하기 때문에 사용하지 않습니다.

(테이블 생성 및 제약조건 쿼리 로그 생략...)

2021-11-22 20:53:04.925 DEBUG 20048 --- [    Test worker] org.hibernate.SQL                        : 
    
    create table student_teachers (
       students_id bigint not null,
        teachers_id bigint not null,
        primary key (students_id, teachers_id)
    )
2021-11-22 20:53:04.932 DEBUG 20048 --- [    Test worker] org.hibernate.SQL                        : 
    
    alter table student_teachers 
       add constraint FKq4kvujupvu9h1cvaiuicvdy9r 
       foreign key (teachers_id) 
       references teacher
2021-11-22 20:53:04.938 DEBUG 20048 --- [    Test worker] org.hibernate.SQL                        : 
    
    alter table student_teachers 
       add constraint FKfuna8hxmfyk7p360eaf2vrf6s 
       foreign key (students_id) 
       references student

@ManyToMany 연관관계의 타입이 Set인 경우, N:M 매핑 테이블은 두 부모 테이블의 기본키를 참조하는 외래키가 기본키로 구성되는 식별관계의 테이블로 생성됩니다.

자바의 컬렉션 프레임워크인 Set은 중복 데이터가 존재할 수 없는 해시 자료구조입니다.
ORM은 관계형 데이터베이스를 객체로 모델링하기 때문에 필드가 가진 특징이 테이블 스키마에 동일하게 적용됩니다.

@Test
@Transactional
@Rollback(value = false)
void Set타입의_ManyToMany연관관계는_삭제가_가능하고_삽입시_삽입데이터만_삽입한다() {
    // given
    Teacher teacher = new Teacher("홍길동", Gender.MALE);
    Teacher teacher2 = new Teacher("홍길순", Gender.MALE);
    Teacher teacher3 = new Teacher("아무개", Gender.MALE);
    Teacher teacher4 = new Teacher("신나리", Gender.MALE);
    Student student = new Student("신영진", Gender.MALE);
    Set<Teacher> teachers = new HashSet<>();

    teachers.add(teacher);
    teachers.add(teacher2);
    teachers.add(teacher3);
    student.setTeachers(teachers);

    entityManager.persist(teacher);
    entityManager.persist(teacher2);
    entityManager.persist(teacher3);
    entityManager.persist(teacher4);
    entityManager.persist(student);
    entityManager.flush(); // 영속성 컨텍스트를 데이터베이스에 반영

    // when
    student.getTeachers().remove(teacher2);
    student.getTeachers().add(teacher4);
}
(테이블 생성 및 제약조건 쿼리 로그 생략...)

2021-11-22 21:50:49.560 DEBUG 20169 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student_teachers
        (students_id, teachers_id) 
    values
        (?, ?)
2021-11-22 21:50:49.563 TRACE 20169 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-22 21:50:49.564 TRACE 20169 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [1]
2021-11-22 21:50:49.566 DEBUG 20169 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student_teachers
        (students_id, teachers_id) 
    values
        (?, ?)
2021-11-22 21:50:49.566 TRACE 20169 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-22 21:50:49.566 TRACE 20169 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [2]
2021-11-22 21:50:49.567 DEBUG 20169 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student_teachers
        (students_id, teachers_id) 
    values
        (?, ?)
2021-11-22 21:50:49.567 TRACE 20169 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-22 21:50:49.567 TRACE 20169 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [3]
2021-11-22 21:50:49.584 DEBUG 20169 --- [    Test worker] org.hibernate.SQL                        : 
    delete 
    from
        student_teachers 
    where
        students_id=? 
        and teachers_id=?
2021-11-22 21:50:49.586 TRACE 20169 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-22 21:50:49.587 TRACE 20169 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [2]
2021-11-22 21:50:49.588 DEBUG 20169 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student_teachers
        (students_id, teachers_id) 
    values
        (?, ?)
2021-11-22 21:50:49.589 TRACE 20169 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-22 21:50:49.589 TRACE 20169 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [4]

매핑 테이블에 식별관계의 기본키가 존재하므로 students_id, teachers_id가 중복인 데이터가 존재할 수 없습니다. 추가 및 삭제되는 데이터를 정확하게 식별할 수 있기 때문에 불필요한 쿼리가 발생하지 않습니다. 하지만, 테이블에 두 외래키를 제외한 별도의 컬럼을 추가할 수 없으며 확장성 측면에서 매우 좋지 않습니다.

식별 관계는 부모 테이블의 기본 키를 참조하는 외래키가 자식 테이블의 기본키를 구성하는 관계입니다.
비식별 관계는 부모 테이블의 기본키를 참조하는 외래키가 자식 테이블의 기본키를 구성하지 않는 관계입니다.

5. 연관관계 엔티티를 항상 조회할 필요가 없는 경우에도 패치 타입을 Eager(즉시)로 사용하는 경우, 불필요한 조인 쿼리가 발생할 수 있다.

@Test
@Transactional
@Rollback(value = false)
void 엔티티를_조회할때_연관관계_엔티티의_패치타입이_Eager인경우_조인쿼리가_발생한다() {
    // given
    Teacher teacher = new Teacher("홍길동", Gender.MALE);
    Teacher teacher2 = new Teacher("홍길순", Gender.MALE);
    Teacher teacher3 = new Teacher("아무개", Gender.MALE);
    Teacher teacher4 = new Teacher("신나리", Gender.MALE);
    Student student = new Student("신영진", Gender.MALE);
    List<Teacher> teachers = new ArrayList<>();

    teachers.add(teacher);
    teachers.add(teacher2);
    teachers.add(teacher3);
    student.setTeachers(teachers);

    entityManager.persist(teacher);
    entityManager.persist(teacher2);
    entityManager.persist(teacher3);
    entityManager.persist(teacher4);
    entityManager.persist(student);
    entityManager.flush(); // 영속성 컨텍스트를 데이터베이스에 반영
    entityManager.clear(); // 영속성 컨텍스트 1차 캐시 제거

    // when
    entityManager.find(Student.class, student.getId());
}
(테이블 생성 및 제약조건 쿼리 로그 생략...)

2021-11-23 11:20:28.713 DEBUG 21258 --- [    Test worker] org.hibernate.SQL                        : 
    select
        student0_.id as id1_1_0_,
        student0_.gender as gender2_1_0_,
        student0_.graduated as graduate3_1_0_,
        student0_.name as name4_1_0_,
        student0_.school_id as school_i5_1_0_,
        school1_.id as id1_0_1_,
        school1_.name as name2_0_1_,
        teachers2_.students_id as students1_2_2_,
        teacher3_.id as teachers2_2_2_,
        teacher3_.id as id1_3_3_,
        teacher3_.gender as gender2_3_3_,
        teacher3_.name as name3_3_3_ 
    from
        student student0_ 
    left outer join
        school school1_ 
            on student0_.school_id=school1_.id 
    left outer join
        student_teachers teachers2_ 
            on student0_.id=teachers2_.students_id 
    left outer join
        teacher teacher3_ 
            on teachers2_.teachers_id=teacher3_.id 
    where
        student0_.id=?
2021-11-23 11:20:28.725 TRACE 21258 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [5]

연관관계 엔티티의 조회 여부는 상황에 따라 다를 수 있지만 패치 타입이 Eager(즉시)로 설정된 경우, 반드시 해당 연관관계 엔티티를 조회하게 됩니다.

단일 엔티티(@ManyToOne, @OneToOne)의 기본 패치 타입은 Eager(즉시)입니다.
컬렉션(@OneToMany, @ManyToMany)의 기본 패치 타입은 Lazy(지연)입니다.

6. 요구사항 관점에서는 필수이지만 연관관계 매핑에 필수로 설정되지 않은 경우, 불필요한 outer join 쿼리를 사용할 수 있다.

@Test
@Transactional
@Rollback(value = false)
void 연관관계의_필수여부에따라_조인쿼리가_다르게_발생한다() {
    // given
    Student student = new Student("신영진", Gender.MALE);
    School school = new School("서울대학교");

    student.setSchool(school);
    entityManager.persist(school);
    entityManager.persist(student);
    entityManager.flush(); // 영속성 컨텍스트를 데이터베이스에 반영
    entityManager.clear(); // 영속성 컨텍스트 1차 캐시 제거

    // when
    entityManager.find(Student.class, student.getId());
}
(테이블 생성 및 제약조건 쿼리 로그 생략...)

2021-11-23 16:50:15.494 DEBUG 22803 --- [    Test worker] org.hibernate.SQL                        : 
    select
        student0_.id as id1_1_0_,
        student0_.gender as gender2_1_0_,
        student0_.graduated as graduate3_1_0_,
        student0_.name as name4_1_0_,
        student0_.school_id as school_i5_1_0_,
        school1_.id as id1_0_1_,
        school1_.name as name2_0_1_,
        teachers2_.students_id as students1_2_2_,
        teacher3_.id as teachers2_2_2_,
        teacher3_.id as id1_3_3_,
        teacher3_.gender as gender2_3_3_,
        teacher3_.name as name3_3_3_ 
    from
        student student0_ 
    left outer join
        school school1_ 
            on student0_.school_id=school1_.id 
    left outer join
        student_teachers teachers2_ 
            on student0_.id=teachers2_.students_id 
    left outer join
        teacher teacher3_ 
            on teachers2_.teachers_id=teacher3_.id 
    where
        student0_.id=?
2021-11-23 16:50:15.534 TRACE 22803 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [2]

연관관계가 필수가 아닌 경우, 조회 결과가 누락되는 것을 방지하기 위해 outer join 쿼리를 사용합니다. 요구사항 관점에서 필수이지만 연관관계의 필수 여부를 설정하지 않으면 기본 값으로 필수가 아닌 것으로 설정되기 때문에 불필요한 outer join 쿼리를 사용하게됩니다.

연관관계 매핑이 컬렉션(@OneToMany 또는 @ManyToMany)인 경우, 외래키를 가진 연관관계의 주인이 아니기 때문에 필수 여부를 설정할 수 없습니다. 데이터베이스 관점에서 해당 테이블에서 매핑되는 데이터가 존재하는지 알 수 없기 때문에 조회 결과에서 누락되는 것을 방지하기 위해 outer join 쿼리를 사용합니다.

7. 영속성 컨텍스트의 초기화로 인한 동일성(==), 동등성(equals)이 보장되지 않을 수 있다.

@Test
@Transactional
@Rollback(value = false)
void 동일성_동등성_보장이_깨지는경우가_발생할수있다() {
    // given
    Student student = new Student("신영진", Gender.MALE);
    
    entityManager.persist(student);
    entityManager.flush(); // 영속성 컨텍스트를 데이터베이스에 반영
    entityManager.clear(); // 영속성 컨텍스트 1차 캐시 제거

    // when
    // 영속성 컨텍스트를 초기화함으로써 조회 결과를 가진 새로운 엔티티 객체를 생성하고 관리합니다.
    Student find = entityManager.find(Student.class, student.getId());

    // then
    assertNotSame(saved, find);
    assertNotEquals(saved, find);
}

영속성 컨텍스트가 열려있고 캐싱된 결과가 존재한다면 조회 쿼리를 발생시키지 않고 캐시된 결과를 반환합니다. 하지만, 영속성 컨텍스트가 초기화 되면 동일한 결과 값을 가진 엔티티를 조회하더라도 다른 객체이기 때문에 동일성 및 동등성이 일치하지 않습니다.
자바의 컬렉션 프레임워크 자료구조 클래스는 equals, hashCode 메소드를 사용하여 객체의 동등성을 비교하기 때문에 동등성 보장이 깨지게 되면 값이 중복되거나 누락되는 등 예상치 못한 문제가 발생할 수 있습니다.

데이터베이스 관점에서 기본키가 같다면 동일한 데이터로 취급하지만 객체 지향적인 관점에서는 기본적으로 동일 객체에 대해서만 동일성 및 동등성이 일치합니다.


잘못된 설계에 대한 개선

@Getter
@Entity
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Teacher {

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

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, updatable = false)
    @Enumerated(value = EnumType.STRING)
    private Gender gender;

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "teacher")
    private List<StudentTeacher> studentTeachers = new ArrayList<>();

    public Teacher(String name, Gender gender) {
        Assert.hasText(name, "Name is must not be blank");
        Assert.notNull(gender, "Gender is must not be null");
        this.name = name;
        this.gender = gender;
    }

    public void changeName(String name) {
        Assert.hasText(name, "Name is must not be blank");
        this.name = name;
    }

}
@Getter
@Entity
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Student {

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

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, updatable = false)
    @Enumerated(value = EnumType.STRING)
    private Gender gender;

    private boolean graduated;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private School school;

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "student")
    private List<StudentTeacher> studentTeachers = new ArrayList<>();

    @Builder
    public Student(String name, Gender gender, boolean graduated, School school) {
        Assert.notNull(gender, "Gender is must not be null");
        Assert.hasText(name, "Name is must not be blank");
        Assert.notNull(school, "School is must not be null");
        this.name = name;
        this.gender = gender;
        this.graduated = graduated;
        this.school = school;
    }

    public void graduate() {
        this.graduated = true;
    }

    public void changeName(String name) {
        Assert.hasText(name, "Name is must not be blank");
        this.name = name;
    }

    public void transferSchool(School school) {
        Assert.notNull(school, "School is must not be null");
        this.school = school;
    }

}
@Table(
        uniqueConstraints = @UniqueConstraint(
                columnNames = {"student_id", "teacher_id"}
        )
)
@Entity
@Getter
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StudentTeacher {

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

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Student student;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Teacher teacher;

    public StudentTeacher(Student student, Teacher teacher) {
        Assert.notNull(student, "Student is must not be null");
        Assert.notNull(teacher, "Teacher is must not be null");
        this.student = student;
        this.teacher = teacher;
        student
                .getStudentTeachers()
                .add(this);
        teacher
                .getStudentTeachers()
                .add(this);
    }

    public void updateStudent(Student student) {
        Assert.notNull(student, "Student is must not be null");
        this.student = student;
        student
                .getStudentTeachers()
                .add(this);
    }

    public void updateTeacher(Teacher teacher) {
        Assert.notNull(teacher, "Teacher is must not be null");
        this.teacher = teacher;
        teacher
                .getStudentTeachers()
                .add(this);
    }

}
@Getter
@Entity
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class School {

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

    @Column(nullable = false)
    private String name;

    public School(String name) {
        Assert.hasText(name, "Name is must not be blank");
        this.name = name;
    }

    public void changeName(String name) {
        Assert.hasText(name, "Name is must not be blank");
        this.name = name;
    }

}

다음의 강사, 학생, 강사-학생 매핑, 학교 엔티티를 예제 코드에서 사용하겠습니다.

  • Teacher 엔티티의 이름, 성별은 필수 값 필드이고, 성별은 수정 불가능 필드입니다.
  • Student 엔티티의 이름, 성별, 졸업여부, 학교는 필수 값 필드이고, 성별은 수정 불가능 필드입니다.
  • StudentTeacher 엔티티의 학생, 강사는 필수 값 필드입니다.
  • School 엔티티의 이름은 필수 값 필드입니다.
  • StudentTeacher, Teacher 엔티티가 N:1 양방향 관계입니다.
  • StudentTeacher, Student 엔티티가 N:1 양방향 관계입니다.
  • Student, School 엔티티가 N:1 단방향 관계입니다.
  • 모든 엔티티 클래스의 기본 생성자의 접근 제한자는 protected입니다.
  • 모든 엔티티 클래스는 기본키와 필수 값이 아닌 필드를 제외한 필드들을 파라미터로 받는 생성자를 가지고 있습니다.
  • 모든 엔티티 클래스는 수정 가능한 필드를 수정할 수 있는 메소드를 가지고 있습니다.
  • 모든 엔티티 클래스의 연관관계 패치타입은 Lazy(지연)입니다.

1. 기본키 생성 전략 설정을 프레임워크에게 위임하지 않고 사용중인 DBMS에서 사용할 수 있는 기본키 생성 전략을 명시적으로 설정한다.

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

GenerationType.AUTO를 사용하지 않고 사용중인 DBMS에서 지원하는 기본키 생성 전략 중에서 사용하고자 하는 기본키 생성 전략을 명시적으로 설정하여 사용합니다.

2. 기본 생성자의 접근 제한자를 protected로 사용하고 생성자에 검증 로직을 작성한다.

@NoArgsConstructor(access = AccessLevel.PROTECTED)

엔티티 클래스 간의 상속 구조가 생성되는 경우, 하위 엔티티 클래스 객체의 기본 생성자에서 상위 엔티티 클래스의 기본 생성자를 호출할 수 있어야 하므로 protected 접근 제한자로 설정합니다. 기본 생성자의 접근 범위를 제한함으로써 기본 생성자로 객체를 생성하여 값의 설정을 누락하기 쉬운 상황을 방지합니다.

@Builder
public Student(String name, Gender gender, boolean graduated, School school) {
    // 필수 값 필드의 유효성을 검증합니다.
    Assert.notNull(gender, "Gender is must not be null");
    Assert.hasText(name, "Name is must not be blank");
    Assert.notNull(school, "School is must not be null");
    this.name = name;
    this.gender = gender;
    this.graduated = graduated;
    this.school = school;
}

기본 생성자를 직접 사용하지 않고 필수 파라미터를 사용하는 생성자를 사용하여 null 값을 전달하거나 빌더 패턴을 사용하여 값의 설정을 누락하는 경우에는 null 값이 설정 될 수 있습니다. null 값이 주입되어 비즈니스 로직을 처리하는 도중에 에러가 발생하는 것보다 생성자에서 검증하여 발생할 에러를 더 빠르게 발생시키고, 동일한 코드 라인에서 에러를 발생시켜 디버깅에 도움을 줄 수 있습니다.
공백 문자열 확인과 같은 검증은 값이 존재하지 않는 것이 아니기 때문에 별도의 검증 로직을 작성해야 합니다.

3. 수정이 가능한 필드에 한해서 객체의 상태 값을 변경하고자 할 때, setter가 아닌 명확한 의미를 지닌 메소드 네이밍을 사용한다.

@Test
@Transactional
@Rollback(value = false)
void 명확한의미를가진_메소드를통해_엔티티의_상태값을_변경하여_update쿼리가_발생한다() {
    // given
    School school = new School("서울대학교");
    Student student = Student
            .builder()
            .name("홍길동")
            .gender(Gender.MALE)
            .school(school)
            .build();

    entityManager.persist(school);
    entityManager.persist(student);

    // when
    student.graduate(); // 학생의 졸업 여부를 졸업으로 변경합니다.
    student.changeName("고길동"); // 학생의 이름을 변경합니다.
}
(테이블 생성 및 제약조건 쿼리 로그 생략...)

2021-11-24 23:03:58.189 DEBUG 26029 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        school
        (id, name) 
    values
        (null, ?)
2021-11-24 23:03:58.199 TRACE 26029 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [서울대학교]
2021-11-24 23:03:58.244 DEBUG 26029 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student
        (id, gender, graduated, name, school_id) 
    values
        (null, ?, ?, ?, ?)
2021-11-24 23:03:58.247 TRACE 26029 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [MALE]
2021-11-24 23:03:58.249 TRACE 26029 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BOOLEAN] - [false]
2021-11-24 23:03:58.250 TRACE 26029 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [VARCHAR] - [홍길동]
2021-11-24 23:03:58.251 TRACE 26029 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [4] as [BIGINT] - [1]
2021-11-24 23:03:58.285 DEBUG 26029 --- [    Test worker] org.hibernate.SQL                        : 
    update
        student 
    set
        graduated=?,
        name=?,
        school_id=? 
    where
        id=?
2021-11-24 23:03:58.289 TRACE 26029 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BOOLEAN] - [true]
2021-11-24 23:03:58.290 TRACE 26029 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [VARCHAR] - [고길동]
2021-11-24 23:03:58.291 TRACE 26029 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [BIGINT] - [1]
2021-11-24 23:03:58.292 TRACE 26029 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [4] as [BIGINT] - [1]

수정이 불가능한 필드를 대상으로 @Column(updatable = false)를 적용하여 해당 필드를 대상으로 영속성 컨텍스트에 의한 변경 감지(dirty check) 및 쓰기 지연(write behind)을 사용하지 않고 발생하는 update 쿼리의 set 절에서 제외합니다.
수정이 불가능한 필드를 대상으로 값을 수정하는 메소드와 명확한 의미를 알 수 없는 setter를 사용하지 않습니다. 수행하는 역할에 대해서 명확하게 알 수 있는 네이밍으로 객체의 상태 값을 수정하는 메소드를 정의하여 사용합니다.

4. N:M 관계를 별도의 엔티티 클래스를 생성하고 두 개의 N:1 관계로 매핑하여 사용한다.

@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode
public class StudentTeacherId implements Serializable {

    private Long student;

    private Long teacher;

}
@Getter
@Embeddable
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode
public class StudentTeacherId implements Serializable {

    private Long studentId;

    private Long teacherId;

}

식별자 클래스를 사용하는 엔티티는 기본키 생성 전략을 사용할 수 없고 기본키를 직접 설정해야 합니다.
식별자 클래스를 다음과 같은 조건들을 만족하도록 정의해야합니다.

  • 반드시 Serializable 인터페이스를 구현해야합니다.
  • 반드시 기본키를 구성하는 모든 필드를 사용하도록 equals, hashCode 메소드를 오버라이딩해야합니다.
  • 반드시 기본 생성자가 필요합니다.
  • 반드시 public의 접근제한자가 필요합니다.
  • @EmbeddedId로 사용하는 경우, 반드시 @Embeddable이 적용되어야합니다.

식별관계(@IdClass)

@IdClass(StudentTeacherId.class)
@Entity
@Getter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StudentTeacher {

    @Id
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Student student;

    @Id
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Teacher teacher;

    @EqualsAndHashCode.Include
    public Long getStudentId() {
        return student.getId();
    }

    @EqualsAndHashCode.Include
    public Long getTeacherId() {
        return teacher.getId();
    }
    
    public StudentTeacher(Student student, Teacher teacher) {
        Assert.notNull(student, "Student is must not be null");
        Assert.notNull(teacher, "Teacher is must not be null");
        this.student = student;
        this.teacher = teacher;
        student
                .getStudentTeachers()
                .add(this);
        teacher
                .getStudentTeachers()
                .add(this);
    }

    (메소드 선언 생략...)

}

식별자 클래스에서 정의한 필드에 해당하는 엔티티의 필드에 @Id를 적용합니다. 연관관계 매핑 필드가 기본키로 구성되는 식별관계의 패치 타입으로 Lazy(지연)을 사용하는 경우, 동등성을 비교하는 과정에서 프록시 객체의 equals, hashCode 메소드가 호출되면 오버라이딩한 메소드가 아닌 Object 클래스로부터 상속받은 모든 필드를 대상으로 비교하는 메소드가 호출됩니다. 그 과정에서 이미 조회하여 값을 가지고 있는 기본키 필드를 제외한 다른 필드들의 값을 조회하기 위해 지연 로딩이 발생하게 됩니다. 불필요한 지연 로딩을 방지하기 위해 연관관계 엔티티의 기본키를 리턴하는 getter를 정의하고 equals, hashCode 메소드에서 사용하도록 오버라이딩합니다.

@Test
@Transactional
@Rollback(value = false)
public void 식별관계_매핑을_IdClass를_사용한다() {
    // given
    School school = new School("서울대학교");
    Student student = Student
            .builder()
            .name("홍길동")
            .gender(Gender.MALE)
            .school(school)
            .build();
    Teacher teacher = new Teacher("아무개", Gender.FEMALE);
    StudentTeacher studentTeacher = new StudentTeacher(student, teacher);

    entityManager.persist(school);
    entityManager.persist(student);
    entityManager.persist(teacher);
    entityManager.persist(studentTeacher);
    entityManager.flush();
    entityManager.clear(); // 영속성 컨텍스트 1차 캐시 제거

    // when
    StudentTeacherId studentTeacherId = new StudentTeacherId(student.getId(), teacher.getId());
    StudentTeacher find = entityManager.find(StudentTeacher.class, studentTeacherId);

    // then
    assertEquals(studentTeacher, find);
}
2021-11-28 23:19:56.039 DEBUG 31494 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        school
        (id, name) 
    values
        (null, ?)
2021-11-28 23:19:56.046 TRACE 31494 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [서울대학교]
2021-11-28 23:19:56.069 DEBUG 31494 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student
        (id, gender, graduated, name, school_id) 
    values
        (null, ?, ?, ?, ?)
2021-11-28 23:19:56.076 TRACE 31494 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [MALE]
2021-11-28 23:19:56.082 TRACE 31494 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BOOLEAN] - [false]
2021-11-28 23:19:56.083 TRACE 31494 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [VARCHAR] - [홍길동]
2021-11-28 23:19:56.083 TRACE 31494 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [4] as [BIGINT] - [1]
2021-11-28 23:19:56.087 DEBUG 31494 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        teacher
        (id, gender, name) 
    values
        (null, ?, ?)
2021-11-28 23:19:56.088 TRACE 31494 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [FEMALE]
2021-11-28 23:19:56.088 TRACE 31494 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [VARCHAR] - [아무개]
2021-11-28 23:19:56.104 DEBUG 31494 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student_teacher
        (student_id, teacher_id) 
    values
        (?, ?)
2021-11-28 23:19:56.104 TRACE 31494 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-28 23:19:56.106 TRACE 31494 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [1]
2021-11-28 23:19:56.121 DEBUG 31494 --- [    Test worker] org.hibernate.SQL                        : 
    select
        studenttea0_.student_id as student_1_2_0_,
        studenttea0_.teacher_id as teacher_2_2_0_ 
    from
        student_teacher studenttea0_ 
    where
        studenttea0_.student_id=? 
        and studenttea0_.teacher_id=?
2021-11-28 23:19:56.122 TRACE 31494 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-28 23:19:56.122 TRACE 31494 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [1]

식별관계(@EmbeddedId)

@Entity
@Getter
@EqualsAndHashCode(of = "studentTeacherId")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StudentTeacher {

    @EmbeddedId
    private StudentTeacherId studentTeacherId;

    @MapsId(value = "studentId")
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Student student;

    @MapsId(value = "teacherId")
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Teacher teacher;

    public StudentTeacher(Student student, Teacher teacher) {
        Assert.notNull(student, "Student is must not be null");
        Assert.notNull(teacher, "Teacher is must not be null");
        this.studentTeacherId = new StudentTeacherId(student.getId(), teacher.getId());
        this.student = student;
        this.teacher = teacher;
        student
                .getStudentTeachers()
                .add(this);
        teacher
                .getStudentTeachers()
                .add(this);
    }
    
    (메소드 선언 생략...)

}

식별자 클래스를 @EmbeddedId를 적용하여 엔티티 클래스의 기본키 필드로 정의합니다. 엔티티가 영속화되는 시점에 식별자 클래스의 객체가 생성되지 않은 상태라면 하이버네이트가 연관관계의 기본키 필드의 값을 식별자 객체의 필드에 기본키를 주입할 수 없으므로 에러가 발생하기 때문에 생성자에서 식별자 객체를 생성하여 초기화합니다.

@Test
@Transactional
@Rollback(value = false)
public void 식별관계_매핑을_EmbeddedId를_사용한다() {
    // given
    School school = new School("서울대학교");
    Student student = Student
            .builder()
            .name("홍길동")
            .gender(Gender.MALE)
            .school(school)
            .build();
    Teacher teacher = new Teacher("아무개", Gender.FEMALE);
    StudentTeacher studentTeacher = new StudentTeacher(student, teacher);

    entityManager.persist(school);
    entityManager.persist(student);
    entityManager.persist(teacher);
    entityManager.persist(studentTeacher);
    entityManager.flush();
    entityManager.clear(); // 영속성 컨텍스트 1차 캐시 제거

    // when
    StudentTeacher find = entityManager.find(StudentTeacher.class, studentTeacher.getStudentTeacherId());

    // then
    assertEquals(studentTeacher, find);
}
(테이블 생성 및 제약조건 쿼리 로그 생략...)

2021-11-28 22:42:23.096 DEBUG 31194 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        school
        (id, name) 
    values
        (null, ?)
2021-11-28 22:42:23.102 TRACE 31194 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [서울대학교]
2021-11-28 22:42:23.167 DEBUG 31194 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student
        (id, gender, graduated, name, school_id) 
    values
        (null, ?, ?, ?, ?)
2021-11-28 22:42:23.168 TRACE 31194 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [MALE]
2021-11-28 22:42:23.171 TRACE 31194 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BOOLEAN] - [false]
2021-11-28 22:42:23.171 TRACE 31194 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [VARCHAR] - [홍길동]
2021-11-28 22:42:23.172 TRACE 31194 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [4] as [BIGINT] - [1]
2021-11-28 22:42:23.177 DEBUG 31194 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        teacher
        (id, gender, name) 
    values
        (null, ?, ?)
2021-11-28 22:42:23.177 TRACE 31194 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [FEMALE]
2021-11-28 22:42:23.177 TRACE 31194 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [VARCHAR] - [아무개]
2021-11-28 22:42:23.185 DEBUG 31194 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student_teacher
        (student_id, teacher_id) 
    values
        (?, ?)
2021-11-28 22:42:23.186 TRACE 31194 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-28 22:42:23.187 TRACE 31194 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [1]
2021-11-28 22:42:23.198 DEBUG 31194 --- [    Test worker] org.hibernate.SQL                        : 
    select
        studenttea0_.student_id as student_1_2_0_,
        studenttea0_.teacher_id as teacher_2_2_0_ 
    from
        student_teacher studenttea0_ 
    where
        studenttea0_.student_id=? 
        and studenttea0_.teacher_id=?
2021-11-28 22:42:23.199 TRACE 31194 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-28 22:42:23.199 TRACE 31194 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [1]

@IdClass, @EmbeddedId 비교

// @IdClass
entityManager.createQuery("SELECT p.student, p.teacher FROM StudentTeacher st);
// @EmbeddedId
entityManager.createQuery("SELECT p.studentTeacherId.studentId, p.studentTeacherId.teacherId FROM StudentTeacher st);

식별관계를 매핑하는 방법으로 @IdClass, @EmbeddedId를 사용할 수 있습니다. @IdClass는 식별자 클래스에 정의된 필드가 엔티티 클래스에서 중복으로 정의될 수 있지만 @EmbeddedId는 식별자 클래스에 정의된 필드를 중복으로 정의하지 않아 중복이 없고 객체 지향적으로 필드에 접근할 수 있습니다. 하지만, 객체 지향적인 방식으로 접근하게 되면서 JPQL의 필드 접근 부분이 @IdClass 방식에 비해 더 길어질 수 있습니다.
식별관계 엔티티를 사용하는 경우, 참조가 계속 이어지게되면 자식 테이블에서 참조하는 부모 테이블의 기본 키 컬럼이 점점 증가하게 되면서 복잡도가 증가할 수 있습니다. 데이터베이스 관점에서는 테이블 스키마 변경에 대응하기 어려워질 수 있으며 쿼리가 복잡해 지고 인덱스의 크기가 불필요하게 커질 수 있습니다. 또한 애플리케이션 관점에서도 복합키 클래스를 별도로 정의해야하며 연관관계를 매핑하기 어려워지고 소스가 복잡해질 수 있습니다.

비식별 관계(별도의 기본키 필드)

@Table(
        uniqueConstraints = @UniqueConstraint(
                columnNames = {"student_id", "teacher_id"}
        )
)
@Entity
@Getter
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StudentTeacher {

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

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Student student;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Teacher teacher;

    public StudentTeacher(Student student, Teacher teacher) {
        Assert.notNull(student, "Student is must not be null");
        Assert.notNull(teacher, "Teacher is must not be null");
        this.student = student;
        this.teacher = teacher;
        student
                .getStudentTeachers()
                .add(this);
        teacher
                .getStudentTeachers()
                .add(this);
    }

    (메소드 선언 생략...)

}

별도의 기본키 필드를 정의하고 Student, Teacher의 중복 데이터를 허용하지 않는 경우, @Table(uniqueConstraints = @UniqueConstraint(columnNames = {...}))으로 unique 제약조건을 설정합니다. 절대로 변경 사항이 없음을 확신할 수 있는 경우가 아니라면 별도의 기본키 필드를 정의해서 비식별 관계로 사용하는 것이 복잡도를 증가시키지 않고 변경에 유연하게 대응할 수 있는 구조를 유지할 수 있습니다.

엔티티 그래프의 양방향 탐색의 필요 여부에 따라 양방향 연관관계가 설정된 경우, 반대쪽 엔티티에서 cascade 옵션 사용 여부와 관계 없이 연관관계를 매핑할 때, 항상 양방향으로 객체를 참조할 수 있도록 설정해주는 것이 좋습니다.

5. 모든 연관관계 엔티티의 패치 타입을 Lazy(지연)로 사용한다.

@OneToMany(fetch = FetchType.LAZY)

@ManyToOne(fetch = FetchType.LAZY)

모든 연관관계의 패치 타입을 FetchType.LAZY으로 설정합니다.

@Test
@Transactional
@Rollback(value = false)
void 엔티티를_조회할때_연관관계_엔티티의_패치타입이_Lazy인경우_필요한_데이터만_조회한다() {
    // given
    School school = new School("서울대학교");
    Student student = Student
            .builder()
            .name("홍길동")
            .gender(Gender.MALE)
            .school(school)
            .build();

    entityManager.persist(school);
    entityManager.persist(student);
    entityManager.clear(); // 영속성 컨텍스트 1차 캐시 제거

    // when
    Student find = entityManager.find(Student.class, student.getId());
    find
            .getSchool()
            .getName(); // 지연 로딩 발생
}
(테이블 생성 및 제약조건 쿼리 로그 생략...)

2021-11-29 00:08:25.559 DEBUG 31795 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        school
        (id, name) 
    values
        (null, ?)
2021-11-29 00:08:25.566 TRACE 31795 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [서울대학교]
2021-11-29 00:08:25.588 DEBUG 31795 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student
        (id, gender, graduated, name, school_id) 
    values
        (null, ?, ?, ?, ?)
2021-11-29 00:08:25.590 TRACE 31795 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [MALE]
2021-11-29 00:08:25.592 TRACE 31795 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BOOLEAN] - [false]
2021-11-29 00:08:25.592 TRACE 31795 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [VARCHAR] - [홍길동]
2021-11-29 00:08:25.593 TRACE 31795 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [4] as [BIGINT] - [1]
2021-11-29 00:08:25.603 DEBUG 31795 --- [    Test worker] org.hibernate.SQL                        : 
    select
        student0_.id as id1_1_0_,
        student0_.gender as gender2_1_0_,
        student0_.graduated as graduate3_1_0_,
        student0_.name as name4_1_0_,
        student0_.school_id as school_i5_1_0_ 
    from
        student student0_ 
    where
        student0_.id=?
2021-11-29 00:08:25.604 TRACE 31795 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-11-29 00:08:25.621 DEBUG 31795 --- [    Test worker] org.hibernate.SQL                        : 
    select
        school0_.id as id1_0_0_,
        school0_.name as name2_0_0_ 
    from
        school school0_ 
    where
        school0_.id=?
2021-11-29 00:08:25.622 TRACE 31795 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]

연관관계 엔티티의 패치타입이 Lazy(지연)으로 설정된 경우, 엔티티가 가진 필드만을 조회하는 쿼리가 발생하고 연관관계 엔티티에는 프록시 객체가 주입됩니다. 이후 연관관계 엔티티의 값이 필요한 경우, 프록시 객체의 필드를 접근하는 시점에 추가적인 조회 쿼리가 발생합니다. 지연 로딩을 사용함으로써 엔티티 그래프 탐색을 통해 상황에 따라 필요한 쿼리만을 사용할 수 있습니다.

6. 연관관계 매핑에 대한 필수 여부를 반드시 설정한다.

@ManyToOne(optional = false)

연관관계가 필수인 경우에는 반드시 @ManyToOne(optional = false), @OneToOne(optional = false) 또는 @Joincolumn(nullable = false)을 적용하여 필수 여부를 설정합니다.

@Test
@Transactional
@Rollback(value = false)
void 연관관계의_필수여부에따라_조인쿼리가_다르게_발생한다() {
    // given
    School school = new School("서울대학교");
    Student student = Student
            .builder()
            .name("홍길동")
            .gender(Gender.MALE)
            .school(school)
            .build();

    entityManager.persist(school);
    entityManager.persist(student);
    entityManager.clear(); // 영속성 컨텍스트 1차 캐시 제거

    // when
    // 패치 조인으로 연관관계 엔티티를 함께 조회한다.
    entityManager
            .createQuery("SELECT s FROM Student s JOIN FETCH s.school WHERE s.id = :id", Student.class)
            .setParameter("id", student.getId())
            .getResultList();
}
(테이블 생성 및 제약조건 쿼리 로그 생략...)

2021-11-28 23:54:45.837 DEBUG 31727 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        school
        (id, name) 
    values
        (null, ?)
2021-11-28 23:54:45.847 TRACE 31727 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [서울대학교]
2021-11-28 23:54:45.876 DEBUG 31727 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        student
        (id, gender, graduated, name, school_id) 
    values
        (null, ?, ?, ?, ?)
2021-11-28 23:54:45.879 TRACE 31727 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [MALE]
2021-11-28 23:54:45.880 TRACE 31727 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BOOLEAN] - [false]
2021-11-28 23:54:45.880 TRACE 31727 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [VARCHAR] - [홍길동]
2021-11-28 23:54:45.881 TRACE 31727 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [4] as [BIGINT] - [1]
2021-11-28 23:54:45.993 DEBUG 31727 --- [    Test worker] org.hibernate.SQL                        : 
    select
        student0_.id as id1_1_0_,
        school1_.id as id1_0_1_,
        student0_.gender as gender2_1_0_,
        student0_.graduated as graduate3_1_0_,
        student0_.name as name4_1_0_,
        student0_.school_id as school_i5_1_0_,
        school1_.name as name2_0_1_ 
    from
        student student0_ 
    inner join
        school school1_ 
            on student0_.school_id=school1_.id 
    where
        student0_.id=?
2021-11-28 23:54:45.998 TRACE 31727 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]

필수 조건의 연관관계를 조회할 때, 해당 외래키 필드가 null 값을 가진 경우가 없기 때문에 inner join 쿼리로 조회하더라도 결과가 누락되는 경우가 없습니다. outer join 보다는 inner join 쿼리가 성능적으로 더 유리하기 때문에 더 효율적으로 조회할 수 있습니다.

7. 기본키를 사용하는 equals, hashCode 메소드를 오버라이딩하여 동등성을 보장한다.

@EqualsAndHashCode(of = "id")

equals, hashCode 메소드를 중복이 없는 고유한 기본키 필드를 사용하도록 오버라이딩합니다. 동등성 비교에 양방향의 연관관계 필드가 포함되는 경우, 순환참조가 형성되어 스택 오버 플로우 에러가 발생하므로 절대로 사용하지 않도록 주의가 필요합니다.

@Test
@Transactional
@Rollback(value = false)
void 동일성_보장이_깨지는경우_동등성을_보장하도록_equals_hashCode를_재정의한다() {
    // given
    School school = new School("서울대학교");
    Student student = Student
            .builder()
            .name("홍길동")
            .gender(Gender.MALE)
            .school(school)
            .build();

    entityManager.persist(school);
    entityManager.persist(student);
    entityManager.clear(); // 영속성 컨텍스트 1차 캐시 제거

    // when
    Student find = entityManager.find(Student.class, student.getId());

    // then
    assertNotSame(student, find);
    assertEquals(student, find);
    assertEquals(find.hashCode(), student.hashCode());
}

equals, hashCode 메소드를 기본키를 사용하도록 오버라이딩하여 영속성 컨텍스트가 초기화 되더라도 기본키가 동일한 다른 엔티티 객체의 동등성을 보장할 수 있습니다.


정리

엔티티 설계는 모든 상황에서 절대적으로 가장 좋은 한 가지 방법만 존재하는 것이 아닙니다. 팀에서 선호하는 방식과 현재 개발 환경에 맞는 가장 적합한 방법을 선택하여 사용하는 것이 가장 중요합니다.
2부에서는 JPA를 사용하면서 발생할 수 있는 성능 문제들을 해결하고 최적화 할 수 있는 방법들에 대해서 정리해보겠습니다.

profile
Backend Developer

0개의 댓글