좋은 Entity 를 설계하기 위한 개념들

kangsan·2021년 2월 11일
9

inflearn 김영한님의 강의 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 중 도메인 분석 설계 챕터를 듣고 배운것을 정리한 내용입니다.


JPA 와 함께 Entity 를 잘 만드는 방법에 대한 이야기

연관관계 매핑

연관관계?

DB 테이블 간의 관계를 나타내기 위해선 FK 를 사용한다. 만약, Entity 클래스도 똑같은 방식으로 설계하게 된다면 어떻게 될까?

public class Member {
	/*...*/
    private Long teamId;
}

public class Team {
    private Long id;
    /*...*/
}

회원의 Team을 가져오기 위해선 매번 teamId 를 빼내서 Team 쪽에 쿼리를 날리는 코드를 넣어줘야 할 것이다. 이것은 객체지향적이라 보긴 어려운 모습이다.
객체를 테이블에 맞추어 데이터 중심으로 모델링하면, 객체간 협력 관계를 만들 수 없다.

객체간 연관관계를 나타내기엔 다음과 같은 방법이 더 좋을 것이다.

public class Member {
	/*...*/
	private Team team;
}

이렇게 Entity 클래스 간의 관계를 대상 Entity 의 참조를 가지는 형태로 만들어 줄 수 있고,
양 Entity 가 서로의 참조를 가지는 경우 양방향 연관관계
한 Entity 만 다른 Entity의 참조를 가지는 경우 단방향 연관관계라고 한다.

양방향 연관관계

일대다, 다대일

"회원, 주문 Entity 가 있고 한 회원은 여러 주문을 할 수 있어야 한다" 라는 요구사항이 존재하는 경우
회원 Entity 입장에서는 주문에 대해 일대다 관계가, 주문 Entity 입장에서는 회원에 대해 다대일의 양방향 연관관계를 만들 수 있다.

일대다 관계에서는 '다' 부분에 FK 가 존재해야 한다. 즉 주문이 회원의 FK 를 가진다.

양방향 연관관계에서는 연관관계의 주인 을 정하는 것이 중요하다.
객체에는 양방향 연관관계라는 것은 사실 없고, 서로 다른 단방향 연관관계 2개를 잘 묶어서 양방향으로 보이게 하는 것 뿐이다. 반면, DB 는 FK 하나로 연관관계가 성립되므로 객체와 DB 둘 간에 차이점이 생긴다. 이 때문에 JPA 에서는 연관관계 중 한 객체를 정해서 FK 를 관리하게 해야하고 이를 연관관계의 주인이라고 한다.

FK 가 있는 곳을 연관관계의 주인으로 정한다 (비지니스상 우위에 있다고 주인이 아니다)
자동차와 바퀴로 예를들면 바퀴가 '다' -> FK존재 -> 연관관계의 주인이 된다.
연관관계의 주인을 이런 규칙으로 정하는 이유는 쉬운 유지보수와 업데이트 쿼리의 성능을 목적으로 한다.

연관관계의 주인이 FK 를 관리 (등록, 수정, 삭제) 할 수 있고 연관관계의 거울(주인이 아닌 곳) 에서는 mappedBy 속성을 통해 읽기만 가능하다.

// 연관관계의 주인 (Member의 FK를 가짐)
public class Order {
    /*...*/
    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
}

// 연관관계의 거울
public class Member {
    /*...*/
    @OneToMany(mappedBy = "member") // Order의 member 필드와 매핑
    private List<Order> orders = new ArrayList<>();
}
// 실제 member.getOrders().add(order) 를 하고 persist() 해도 null이 저장된다.
// (연관관계의 주인인 Order.member 에 들어가지 않았기 때문)

일대일

1:1 관계는 둘 중 어디든 FK 를 둘 수 있다. 더 많이 조회하는 Entity 에 두자.

다대다

실무에서는 @ManyToMany 를 사용하지 않는다.
M2M 은 중간에 mapping table 이 생길 수 밖에 없는데 이 테이블에는 등록일, 수정일 같은 필드가 없이 mapping key 만 존재해야 해서 운영상으로 어려움이 크다.

값 타입(임베디드 타입)

Address 같은 애들을 값을 표현하기 위한 타입으로 만들 수 있다.
값 타입은 변경하면 안된다 생성시에만 값을 주고 변경 불가능하도록 만들어야 한다.

@Embeddable
@Getter
public class Address {
    private String city;
    private String street;
    private String zipcode;
    
    protected Address() {}
    public Address(String city, String street, String zipcode) { /*...*/}
}

public class Member {
    /*...*/
    @Embedded
    private Address address;
}

값 타입에는 @Embeddable 을 사용하는 필드에는 @Embedded 를 준다.

변경 불가능하게 설계하자

  • Setter 를 쓰지 않고 생성자를 통해서만 생기도록 한다.
  • default 생성자는 protected 로 만들어서 조금 더 안전하게 하자
    • JPA 구현 라이브러리가 객체를 생성할 때 리플렉션을 사용할 수 있어야 해서 private 으로 만들 수는 없다. protected는 (변경불가능한)의도를 나타내는 최선이다.

Entity 설계시 주의점

Entity에는 가급적 Setter를 쓰지 않는다.

변경 포인트가 많아지고 유지보수가 어려워진다. 스프링을 하며 많이 듣는 이야기 중 하나.

필드에 Enum 타입 사용 시 유의점

@Enumberated(EnumType.ORDINAL) vs @Enumberated(EnumType.STRING)
무조건 STRING 을 사용한다.
ORDINAL 사용 시 0,1,2 이런식으로 내부적으로 숫자로 타입을 나누는데, 이 경우 중간에 새로운 type을 추가할 경우 기존에 만들어놨던 0,1,2 의 순서와 꼬이게 된다면 난리가 난다. 즉 위험하다.

(중요) 모든 연관관계의 FetchType 은 LazyLoading 으로 설정한다.

EAGER(즉시로딩) 은 예측이 어렵고 어떤 SQL 이 실행될지 추적이 어렵다.
EAGER 사용 시 조회 하나 때리면 관련된 애들을 모두 join 해서 가져온다. em.find 를 통해 하나만 조회하는 경우는 큰 문제가 없겠지만, JPQL(JPA 가 지원하는 쿼리) 로 가져올 경우 예를들어 select * from order limit 100 를 수행할 때 EAGER 로 fetchType이 지정된 경우 order 가 member를 가져오기 위해 쿼리 100개가 같이 날아감. 즉 101개가 날아가는 것. 이걸 N+1 문제라고 한다.

실무상의 모든 연관관계는 지연로딩(LAZY) 로 설정해야 한다.
@XToOne(OneToOne, ManyToOne) 의 경우 EAGER 가 default 이므로 직접 LAZY 로 설정해줘야 한다
(@XToMany 는 기본이 LAZY 라서 그냥 둬도 된다)

만일 내가 연관관계에 필요한 entity 를 한번에 가져오고 싶은경우?
=> FETCH JOIN 을 사용하자

EAGER 를 고려하지 말자.

Collection 은 필드에서 초기화

Collection 은 생성자나 setter 등이 아닌 필드에서 초기화 하는 것이 hibernate best practice 이다.

public class Member {
	/*...*/
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

그 이유는 다음과 같다

  • 객체의 초기화에 대한 고민을 하지 않아도 된다. (NPE 걱정 X)
  • Hibernate 가 Entity 를 Persist 하는 순간 collection 을 감싸서 hibernate 용 내장 컬렉션으로 변경한다. Hibernate 가 collection 의 변경사항을 추적하기 위함인데, 이것을 setter 등을 이용해서 new ArrayList<>(); 를 통해 초기화를 시켜버릴 경우 Hibernate 가 원하는 메커니즘으로 동작하지 않을 수 있다.
Member member = new Member();
System.out.println(member.getOrders().getClass());
// class java.util.ArrayList

em.persist(member);

System.out.println(member.getOrders().getClass());
// class org.hibernate.collection.internal.PersistentBag

필드에서 초기화 한 후 읽기만 하자. 건들지 말자.

기타

Cascade 설정

모든 엔티티는 저장하고 싶으면 각각 persist를 해줘야 한다.

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();

와 같이 써주면 collection에 있는 orderItem 들을 각각 persist 할 필요 없이 persist(order) 로 한방에 insert 또는 delete 할 수 있다.

연관관계 편의 메서드

양방향 연관관계에서 setting을 할 경우 양쪽 다 해줘야 하는 불편함이 있는데 이를 원자적으로 처리할 수 있게 편의 메서드를 만들어 둘 수 있다.

public class Order {
    /*...*/
    public void setMember(Member member) {
        this.member = member;
        member.getOrders().add(this);
        // member에 대한 order setting 도 원자적으로 일어나게 함
    }
}

연관관계 편의 메서드는 핵심적으로 컨트롤 하는 쪽이 들고있는 쪽이 좋다.

Table & Column Naming

  • 예약어
    주문을 나타내는 Order 의 경우 예약어를 피하기 위해 객체는 Order로 table은 Orders 로 나타내는게 관례이다.

  • Table 네이밍
    소문자의 underscore (snake_case) 가 일반적이다.

  • Entity의 id
    객체의 필드는 id 라고 만들고 @Column(name = "member_id") 이런 식으로 만드는 것이 좋다.
    객체는 명확히 어떤것의 id 인지 구분할 수 있는데 테이블은 id라고만 적어놓으면 구분이 쉽지 않다. join 시에도 알아보기가 힘들어서 관례적으로 이런 스타일로 많이 적는다.

  • 객체와 Table의 매핑
    Hibernate 의 기존 구현에서는 Entity의 필드명을 그대로 테이블의 필드명으로 적지만, springboot 사용 시 SpringPhysicalNamingStrategy 를 사용해서 필드명의 기본 전략이 camelCase -> snake_case 이고, 대문자를 소문자로, '.' 을 '_' 로 변경한다. (커스텀한 테이블 네이밍을 하고 싶은 경우에도 변경해서 활용 가능하다)

FK 를 걸어야 할까?

시스템마다 다르다. 실시간 트레픽이 엄청나고 정합성보다 성능이 중요한 경우 FK 를 빼고 idx 만 잘 잡기도 한다.
늘 궁금했던 내용이다. 반정규화나 FK를 빼는 것을 고려할 정도로 많은 트레픽은 어느정도일지 감이 안오긴 한다.

즐거운 인생

새로 알게된 intellij 단축키

  • Class 찾기: cmd + O
  • File 찾기: cmd + shift + O

0개의 댓글