JPA 프로젝트를 다시 하면서 정리
- Spring/JPA
- 2022. 1. 9.
0. 설계 주의 사항
단방향 연관관계를 기본으로 설계해라. 필요한 양방향을 추가한다연관관계의 주인은 FK와 가까운 곳으로 설정해라.다대다 연관관계는 일대다 / 다대일로 풀어낸다. Setter는 가급적 제공하지 않는다. 대신, 필요한 별도의 메서드를 제공해서 변경 지점을 확실하게 한다.
객체를 기준으로 설계한다. 맵핑은 연관관계를 기준으로 한다.
EnumType은 String으로 설정한다. ORDINAL로 설정 시, 변경 및 확장에 취약하다.
일대일에서 FK는 많이 접근하는 쪽에 둔다.
1. Join Column
DB의 테이블과 테이블의 연관 관계를 정해주는 것이다. 즉, 이 테이블(엔티티)의 이 Column을 FK로 사용할껀데, 이 FK는 다른 테이블의 어떤 PK라는 것을 알려주는 것이다. 이걸 하면 DB끼리 관계는 설정이 되지만, 자바 코드 상으로는 어떠한 관계도 설정되지 않는다.
따라서 따로 따로 업데이트를 해줘야한다.
2. FK는 어디에 있어야 하는가?
FK는 당연한 '다'쪽에 있는 것이 맞다. '일'에 FK가 있다고 가정해보자. 일을 하나 불러보면 그와 관련된 수십, 수백개의 다가 함께 불러질 것이다. 즉, 불필요한 쿼리가 많이 생긴다. 반면에 '다'에 FK가 있다고 가정해보자. '다' 중 1개만 찝어서 부르게 되면, 결국 '다'와 관련된 '일'도 1개만 불러진다. 이런 이유 때문에 FK는 '다'에 있어야 한다.
3. 객체와 DB Join Column의 맵핑
DB는 Join Column으로 각 테이블의 연관관계가 설정되어있다. 아직까지 객체 사이의 연관관계는 설정되어있지 않다. 이 연관관계는 객체에 다른 객체를 넣어 '참조'할 수 있게 하면서 시작된다. 그리고 '참조'하는 객체와 현재 객체 간의 어떤 연관관계를 가진다는 것을 @ManyToOne, @OneToMany 같은 것들로 알려줘야한다. 이렇게 되면 각 객체는 Table이 가지는 것처럼 연관관계를 가질 수 있다.
4. 객체의 참조는 FK다 + 연관관계의 주인
DB는 Join Column을 통해서 FK를 관리한다. 그런데 객체는 객체의 참조를 가진다는 것이 'FK'가 된다. 왜냐하면 그 값을 기준으로 다른 객체를 찾을 수 있기 때문이다. 이걸 양방향 연관관계에 대입을 해보면, 각 객체는 FK가 2개가 있다는 이야기다. 왜냐하면 각 객체가 각 객체를 가지고 있기 때문이다.
DB는 FK를 1개만 가진다. 따라서 객체들도 FK를 딱 1개만 가지는 작업이 필요하다. 이것이 연관관계의 주인을 설정하는 것이다. 연관관계의 주인을 설정하면 연관관계의 주인만이 FK로 동작한다. 위의 이미지를 보면 DB 관점에서 ORDER는 MEMBER_ID 값만 가진다. 따라서, FK를 맞춰주기 위해서 ORDER의 member가 연관관계의 주인이 되도록 해줘야한다.
5. em.persist
em.persist는 엔티티 객체를 영속화 한다는 뜻이다. DB에 바로 저장하는 것이 아니라, 영속성 컨텍스트 내에 있는 저장소에 객체를 저장한다는 뜻이다. 실제로 DB에 저장되는 시점은 트랜잭션이 커밋되는 시점에 영속성 컨텍스트 내에 있는 객체를 더티체킹한 후 필요한 부분을 쿼리로 만들어서 발송해준다.
6. ManyToMany 관계에서 @JoinTable로 만들기
@Join Table로 직접 ManyToMany를 구현한다고 하면, 위의 느낌으로 구현을 하면 될 것 같다. 제품 관점에서 제품_공급자 테이블을 만든다고 가정해보자. 그러면 제품은 List<공급자>를 가지고 있을텐데, 여기에 @JoinTable로 조인 테이블을 직접 만들게 된다.
@ManyToMany
@JoinTable(
name = "category_item",
joinColumns = @JoinColumn(name = "category_id"),
inverseJoinColumns = @JoinColumn(name = "item_id"))
private List<Item> items = new ArrayList<>();
이 때, JoinColumn을 선택해줘야 하는데 JoinColumn, InverseJoinColumn으로 지정을 할 수 있다. 그런데 순번을 따로 상관이 없을 것 같은데... 보통은 이렇게 하는 거 같다. 나중에 테스트를 좀 해봐야 알 것 같다.
7. 상속 관계 구현
- parent는 자식을 기준으로 하나 밖에 없다. 따라서 필드의 parent는 @ManyToOne으로 맵핑할 수 있다.
- 내가 부모인 경우 자식은 여러명일 수 있다. 따라서 필드의 chlidren은 @OneToMany로 맵핑할 수 있다.
8. Getter는 열어둔다. Setter는 닫아둔다.
Getter는 조회만 한다. 따라서 데이터 변경이 없다. Setter는 데이터의 변경이 일어난다. 따라서 무분별하게 열어두면 변경 지점을 알 수 없게 된다. 변경 지점을 명확하게 알기 위해서 Setter는 닫아두고, 값 셋팅을 위한 특별 메서드를 제공해서 변경 지점을 명확하게 해둬야한다.
9. Embedded 타입(값 타입)은 Setter를 반드시 없앤다.
10. JPA는 기본 생성자가 반드시 필요하다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
JPA는 스펙상 기본 생성자가 반드시 필요하다. 따라서, 기본 생성자를 구현은 해두되 다른 곳에서 쓸 수 없도록 Protected로 보호를 해준다. Lombok에서 이런 기능을 위의 코드로 지원해준다.
11. 모든 연관관계는 지연로딩으로 설정!
즉시로딩을 하게 되면, Member를 조회할 때 이와 관련된 모든 엔티티(Order 같은 것들을) 한번에 Join으로 조회한다. 즉시 로딩은 예측이 어렵고 어떤 SQL이 실행될지 추적하기 어렵다. 특히 JPQL을 실행할 때 N+1 문제가 자주 발생한다.
만약 연관된 엔티티를 함께 DB에서 조회한다면 Fetch Join 혹은 엔티티 그래프 기능으로 한번에 데리고 와야한다.
N+1 예시는 다음과 같다.
em.find(order)로 order를 하나 가져오면, 연관된 Member가 1개만 나온다. 그렇지만 JPQL로 select o from order o를 하면, SQL select * from order 쿼리가 나간다. 쿼리 1번에 모든 Order가 오게 된다. 그런데 Order를 가져왔더니, 딸려있는 Member Entity가 있는 것을 알게 된다.
예를 들어 Order 조회 쿼리 1번으로 Order가 100개가 왔다면, 관련된 Member 100개를 조회하기 위한 Select 쿼리가 100개가 또 나가게 된다.
12. 지연 로딩 설정은 프록시 객체와 관련있다.
프록시 객체는 실제 클래스를 상속받아 만들어진 객체이다. 가짜 객체라고 보면 되고, 프록시 객체는 빈 껍데기에 불과하다. 영속성 컨텍스트에 영속화 하기 위해서 PK값만 가지고 있고, 내부적으로 진짜 객체를 참조하고자 하는 필드를 가지고 있다. 그렇지만 이 참조 Target은 초기에는 null 값이 되어있다.
프록시 객체는 가짜이기 때문에 조회를 위해서 DB까지 다녀올 필요는 없다. 따라서, 프록시 객체를 getRefernce로 얻을 때 DB로 Select 쿼리가 나가지 않는다. 입력해두었던 PK값만 셋팅되어 영속화된다.
프록시 객체는 Target값이 실제로 필요한 시점이 오면, 그 때 Select 쿼리를 보내준다. 그렇지만 Select 쿼리를 보내서 실제 객체를 가져온다고 하더라도 영속화 된 프록시 객체는 사라지지 않는다. 프록시 객체가 내부적으로 가지고 있는 Target 값만 실제 Taget을 참조하도록 바뀔 뿐이다.
13. 연관관계를 위한 컬렉션은 반드시 필드에서 초기화 하는 것이 안전하다.
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
연관관계를 위한 컬렉션은 반드시 필드에서 초기화해준다. 하이버네이트는 엔티티를 영속화하는 순간에 Collection을 감싸서 하이버네이트가 제공하는 내장 컬렉션으로 변경한다. 이런 것은 하이버네이트가 동작을 추적할 수 있어야하기 때문이다.
하이버네이트가 필요한 형태로 컬렉션을 변경했는데 뒤에서 누군가가 수정한다고 하면 원하는대로 동작하지 않을 수 있다. 따라서 처음에 엔티티가 만들어질 때 함께 만들어지고, 이후에는 절대로 건드리지 않도록 한다.
14. 영속성 전이
@Entity
@Table(name = "Orders")
public class Order {
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) // 객체 맵핑
@JoinColumn(name = "member_id") // 테이블 맵핑
private Member member;
}
영속성 전이는 부모 엔티티의 영속성 상태가 변경되면, 자식들에게도 영속성 상태의 변화가 전파된다는 것이다. 예를 들어 부모 엔티티가 삭제되면 자식 엔티티들도 삭제된다. 반대로 부모 엔티티가 저장되면 자식 엔티티들은 자동으로 저장된다.
부모 엔티티는 현재 클래스이며, 자식 엔티티는 부모 클래스에서 cascade 설정으로 지정된 필드가 된다. 위의 경우 Order에서 영속성 변화가 있으면, member에도 동일하게 전파된다.
영속성 전이는 부모 / 자식 관계로 보면 아주 잘 들어맞는다. 그리고 영속성 전이는 연관관계와는 별개로 봐야한다. 왜냐하면 전통적인 부모 / 자식 관계로 보면 자식이 '다'이기 때문에 연관관계의 주인이 된다. 부모 / 자식 관계와 연관관계의 주인이 맞지 않는 상황이다.
따라서 영속성 전이는 부모 → 자식으로 전달되는 것이고, 연관관계의 주인과는 별개로 이루어진다. 예를 들어 연관관계를 고려한다면 em.persist(자식)을 한 후에 em.persist(부모)를 해줬어야했다. 그런데 영속성 전이는 em.persist(부모)를 하면 자동으로 em.persist(자식)까지 해주겠다는 것이다.
영속성 전이는 철저히 부모 → 자식의 관계만을 가지고 있을 때, 사용을 고려해볼 수 있다. 부모만 자식을 참조하는 경우에 쓸 수 있고, 다른 엔티티가 자식 엔티티를 참조하면 사용할 수 없다. 왜냐하면 변화를 이해할 수 없기 때문이다.
15. JPA 영속화 관련
JPA는 영속성 컨텍스트 안에 PK로 엔티티를 관리한다. Persist나 Find는 엔티티를 영속화 하는 과정이고, detach나 remove는 영속화 된 상태에서 이루어져야한다. 특히 Remove를 할 때, 그냥 객체를 집어 넣게 되면 당연한 얘기지만 오류가 난다. 왜냐하면 JPA가 이 객체의 PK를 모르고 있기 때문이다. 따라서 삭제할 객체가 있다면, 먼저 영속화를 시킨 후 Remove를 해줘야한다.
16. 연관관계의 주인
- 테이블은 FK를 한 군데에서만 가진다. 따라서 양방향 연관관계를
- 가진 엔티티가 있다면, 이것은 FK를 2개를 가진 것이다. MappedBy를 해주면 FK가 하나 없어지는 것과 동일하게 작용한다.
- 연관관계의 주인이 아닌 쪽은 FK가 없기 때문에 가짜 엔티티에는 연관관계 주인 엔티티를 아무리 넣어도, 둘 사이에 어떤 연관관계도 만들어지지 않는다.
- https://github.com/chickenchickenlove/JPA1/tree/main/jpaTest (테스트 코드)
17. Child(연관관계의 주인)을 Parent(가짜 객체)에만 넣고 Em.persist를 하게 되면? (이 때 Parent는 Cascade 부모 엔티티다)
- Parent는 Cascade 부모 엔티티이기 때문에, Parent에 저장된 Child 엔티티는 영속성 전이가 발생해 함께 Em.persist가 이루어진다.
- 그렇지만 Parent에 있는 Child는 FK로 작용을 하지 못하기 때문에 DB에는 Parent와 Child가 각각 저장되지만, Child의 FK는 Null 값으로 저장된다.
- 이후 Parent를 다시 불러오면, Parent는 어떠한 Child도 없는 것으로 나온다. 왜냐하면 DB에서 Child는 Parent에 대한 FK를 가지고 있지 않았기 때문이다.
18. 연관관계의 주인, 영속성 전이 정리 간략 생각 정리
테스트 코드(https://github.com/chickenchickenlove/JPA1/tree/main/jpaTest)
연관관계의 주인은 FK를 가진다는 것으로 이해할 수 있다. 엔티티와 엔티티를 FK로 묶어주는 작업이라고 할 수 있다. 연관관계의 주인이 Join Column으로 FK를 가지는데, 이 때 엔티티를 넣어주게 된다면 엔티티와 엔티티는 FK로 묶어진다. 따라서 DB 테이블에서도 잘 맵핑되어 저장이 된다.
영속성 전이는 이것과는 별개로 동작한다. 예를 들어 부모 엔티티를 만들고, 자식 엔티티를 만든다. 그리고 부모 엔티티에 자식 엔티티를 넣어준다. 연관관계의 주인은 자식 엔티티기 때문에 FK값이 매칭이 되어 있지 않은 상황이다. 이 때 부모 엔티티를 영속성 전이로 em.persit()해주면 자식은 em.persit()를 하지 않아도 잘 저장이 된다. 그런데 DB에 저장된 값을 보면 부모, 자식이 확인은 되지만 자식과 부모가 Join되지 않았다는 것을 알 수 있다.
해결을 위해서 연관관계 편의 메서드를 이용해서 연관관계의 주인에도 엔티티를 포함해주고, 부모 객체를 통해 영속화시켰다. 이렇게 하면 em.persist(parent)를 하게 되면 em.persit(childA), em.persit(childB)가 이루어진다. 그런데 이 때, childA와 childB가 FK로 parent를 가지고 있기 때문에 테이블에는 정상적으로 저장이 되는 것을 확인할 수 있다.
19. 연관관계의 주인과 연관관계 편의 메서드
연관관계의 주인은 FK로 동작한다고 이야기를 했다. 따라서 원칙적으로는 연관관계의 주인에만 Join할 엔티티를 저장해준다면, DB에 잘 맵핑이 되어 저장이 된다. 그렇지만 그건 DB 관점에서의 이야기다. DB는 FK로 양방향으로 탐색이 가능하다. 그렇지만 자바에서는 객체의 참조를 가진 쪽만 탐색이 된다.
이런 관점에서 봤을 때, 양방향 연관관계를 가진다면 자바와 DB를 연동시키기 위해 양쪽 다 서로를 참조할 수 있는 객체를 가지는 것이 좋다. 그리고 한쪽 객체에 다른 객체를 넣는다면, 다른 객체에도 똑같이 동시에 넣어줘야한다. 그래야 자바에서 그래프 탐색 형식으로의 이동이 가능해진다.
public void addParent(Parent parent) {
this.parent = parent;
parent.getChildren().add(this);
}
그런데 이걸 일일이 치게 될 경우, 누락될 가능성이 높다. 이런 것을 방지하기 위해서 연관관계의 편의 메서드라는 것을 만들어서 사용한다. 하나를 셋팅할 때 다른 하나도 같이 셋팅을 해주는 것이다. 위의 코드가 대표적인 연관관계 편의 메서드다.
연관관계 편의 메서드는 연관관계의 주인에게 굳이 놓지 않아도 된다. 연관관계 편의 메서드의 위치는 주로 사용되는 객체에 놓는 것이 좋다.
20. 영속성 전이와 고아 객체
고아 객체는 영속성 전이 관점에서 나온 이야기다. 부모 객체가 없어진 놈, 부모 객체에서 제거된 놈은 부모를 잃었기 때문에 고아 객체로 본다. 영속성 전이에는 이런 고아 객체를 제거하는 것까지 함께 설정을 할 수 있다.
'고아'는 부모 개념이 있을 때만 성립한다. 부모는 한 명이다. 그렇기 때문에 고아 객체를 제거하냐, 마냐에 대한 옵션은 @OneToOne, @OneToMany에만 있다. 옵션에 보면 orphanRemoval이라는 것이 있는데 default 값은 False다. 즉, 고아 객체를 삭제하지 않는다는 소리다.
@Test
@Rollback(false)
void 부모객체_영속성_전이_삭제() throws Exception{
Parent parent = new Parent();
Child childA = new Child();
Child childB = new Child();
childA.addParent(parent);
childB.addParent(parent);
em.persist(parent);
em.flush();
em.clear();
Parent findParent = em.find(Parent.class, parent.getId());
em.remove(findParent);
}
위 코드로 고아 객체에 대한 속성을 알아볼 수 있다. 예를 들어 orpharnRemoval = True로 해두었을 때, 부모 객체를 em.remove()를 하면, 부모 객체의 PK를 FK로 가지고 있는 child들이 동시에 em.remove()가 된다. 반대로 orpharnRemoval = False로 하고 부모 객체를 em.remove()를 하면 부모 객체만 삭제된다. 그런데 이 때, 부모 객체를 FK로 가지고 있는 자식 객체들 때문에 무결성 에러가 발생해버린다.
@Test
@Rollback(false)
void 고아객체_영속성_전이_삭제() throws Exception{
Parent parent = new Parent();
Child childA = new Child();
Child childB = new Child();
childA.addParent(parent);
childB.addParent(parent);
em.persist(parent);
em.flush();
em.clear();
Parent findParent = em.find(Parent.class, parent.getId());
findParent.getChildren().remove(0);
}
orpharnRemoval = true라면 위의 코드에서 parent에서 참조가 없어진 0번째 Child에 대해서는 delete 쿼리가 자동으로 나가게 된다. orpharnRemoval = False라면 위의 코드에서 parent 참조가 없어진 것은 DB에 아무런 영향을 미치지 못한다. 왜냐하면 연관관계의 주인이 일대다인 Child쪽에 있기 때문이다.
정리
- 고아 객체는 부모 객체로부터 참조가 제거되거나, 부모 객체가 삭제된 객체를 뜻한다.
- orpharnRemoval=true면 부모 객체가 연관관계의 주인이 아니더라도 영속성 전이를 통해 자식 객체까지 함께 지워진다.
- orpharnRemoval=false면 부모 객체는 삭제할 수 없다. 왜냐하면 DB의 자식 객체 FK 무결성 제약 때문이다.
- orpharnRemoval=false면 부모 객체에서 참조를 제거하는 것이 DB에 아무런 영향을 미치지 않는다. 왜냐하면 연관관계의 주인은 자식 객체이기 때문이다.
21. 엔티티 / 서비스 / 리포지토리 / 컨트롤러 계층
엔티티
도메인 영역으로 주요 비즈니스 로직을 제공하도록 한다. DB와 연결하는 로직은 제외한다. 왜냐하면 DB와 직접 연결하는 곳은 리포지토리이기 때문이다.
리포지토리
DB와 직접 데이터를 주고 받아야 하는 메서드만을 구현한다.
서비스
엔티티와 DB를 아우르는 영역이다. 트랜잭션이 부여되는 영역으로 설정한다. 그리고 리포지토리는 바로 아래 계층의 리포지토리만 불러오고, 다른 클래스의 리포지토리는 직접 호출하지 않도록 한다. 대신에 다른 클래스의 서비스 영역을 호출해서 DB 접근하도록 한다.
컨트롤러 계층
컨트롤러에서는 엔티티를 가급적이면 사용하지 않도록 한다. 트랜잭션이 걸리는 영역에서 엔티티를 선언하고 사용하도록 한다. 그렇게 해야 엔티티가 영속화 된 상태로 그 의미 그대로 사용 가능하기 때문이다. 또한 영속성이 유지되어야 더티 체킹 같은 것들이 잘 유지된다.
따라서 컨트롤러에서는 엔티티를 사용하는 것이 아니 Form 객체등을 사용해서 처리한다.
'Spring > JPA' 카테고리의 다른 글
JPA : JPA를 활용한 회원 관련 API 개발 (0) | 2022.01.15 |
---|---|
JPA : @Transacitonal 관련 정리 (0) | 2022.01.10 |
JPA : Fetch Join (0) | 2021.12.02 |
JPA : CASCADE를 통한 영속성 전이, 고아 객체 (0) | 2021.11.28 |
JPA : 상속관계 맵핑 MappedSuperClass (0) | 2021.11.28 |