기록은 기억의 연장선

더 많은 것을 기억하기 위해 기록합니다


  • Home

  • Tags

  • Categories

  • Archives

  • Search

[jpa] 신규 엔티티에 기존 엔티티의 id를 넣어줄때 발생하는 일

Posted on 2018-12-27 | Edited on 2020-11-02 | In jpa | Comments:

JPA를 개발하다 보면, 아래와 같은 행위를 할떄가 가끔씩 있다.
(Spring Data JPA를 사용한다고 가정)

1
2
3
4
5
6
Member newMember = newMemberDTO.toEntity();
newMember.setId(1); // 이렇게 id를 직접 넣어주는 행위

// ...

memberRepositroy.save(newMember);

이는 간단하다.
Repository를 만들때 implements하는 JpaRepository의 구현체인 SimpleJpaRepository의 save 메서드를 보면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// repository.save
@Transactional
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}

// entityInformation.isNew
public boolean isNew(T entity) {

ID id = getId(entity);
Class<ID> idType = getIdType();

if (!idType.isPrimitive()) {
return id == null;
}

if (id instanceof Number) {
return ((Number) id).longValue() == 0L;
}

throw new IllegalArgumentException(String.format("Unsupported primitive id type %s!", idType));
}

보다시피 엔티티의 식별자가 전달되었다면 merge, 그렇지 않다면 persist를 수행한다.
위와 같이 식별자를 강제로 넣어서 전달하면 해당 엔티티에 대해서 em.merge가 발생하게 되는 것이다.
merge니까, 해당 식별자로 member 테이블을 조회하고, 엔티티가 존재하면 영속성 컨텍스트에 넣게 된다.
그리고 flush 되면 변경감지에 의해 update 문이 발생하게 될 것이다.(newEntity로 완전 대체되었기 때문에)
만약 해당 식별자에 해당하는 엔티티가 없다면, 새로 insert 될 것이다.
PUT REST API를 작성하기에 적절한 방법이다.(틀릴수도…)

그렇다면 아래와 같이 하면 어떻게 될까?

1
2
3
4
5
6
7
8
9
10
Member newMember = newMemberDTO.toEntity();

newMember.setId(1); // 기존 member의 id값을 그대로 유지
// oldItem의 값들이 몇개 필요해서 들고와서 세팅
Member oldMember = memberRepository.findById(1);
newMember.setXXX(oldMember.getXXX());

oldItem.setXXX(~~~); // 이렇게 하면 어떻게 되지?

memberRepository.save(newMember);

newItem으로 대체하는데 oldItem의 값들이 몇개 필요해서 oldEntity를 조회한 다음 해당 값을 사용하는 케이스이다.
이럴 경우, find에 의해 처음 영속성 컨텍스트로 들고오면 아래와 같을 것이다.

key entity
1 oldEntity

이 상태에서 아래의 save에 의해 merge가 수행될 것이고, 결과적으로 아래처럼 변할것이다.

key entity
1 newEntity

즉 oldEntity는 아예 영속성 컨텍스트에 의해 관리되지 않는 준영속 상태가 되어버리므로, 위처럼 oldItem에 무언가 변경을 수행해도 아무일도 일어나지 않는다.
(당연한가?)

자식이 있을 경우

해당 엔티티에 속하는 자식 엔티티들이 있을 경우 조금 복잡해진다.(안 복잡할수도 있다)

1
2
3
4
class Member{
@OneToMany(mappedBy = "member", cascade = CascadeType.PERSIST)
private List<Order> orderList = new ArrayList<>();
}

위와 같다고 할 때, 아래와 같이 작성하면 문제가 발생한다.

1
2
3
4
5
Member newMember = newMemberDTO.toEntity(); // orderList를 가지고 있음

newMember.setId(1); // 기존 member의 id값을 그대로 유지

memberRepository.save(newMember);

이때는, newMember의 orderList에 대해 연쇄적으로 persist를 수행하고 끝나버릴 것이므로,
만약 기존의 1번 member에 해당하는 order가 있었다고 할 경우, 해당 order는 그대로 있고 신규로 전달된 order들이 저장되게 될 것이다.
그러므로 만약 기존 1번 member에 해당하는 order가 2개 있고, 전달된 order가 2개인 상태에서 위의 메서드를 실행하게 되면 order가 총 4개가 되어버린다.

사실상 이건 명확한 교체가 아니므로,
의도한 상황이라면 상관없는데, 그게 아니라면 orphanRemoval = true를 사용하여 존재하지 않는 엔티티에 대해 삭제하게끔 해야한다.

1
2
3
4
class Member{
@OneToMany(mappedBy = "member", cascade = CascadeType.PERSIST, orphanRemoval = true)
private List<Order> orderList = new ArrayList<>();
}
1
2
3
4
Member newMember = newMemberDTO.toEntity(); // orderList를 가지고 있음
newMember.setId(1); // 기존 member의 id값을 그대로 유지

memberRepository.save(newMember);

이렇게 하면 기존 1번 member에 있던 order는 모두 삭제되고, newMember에 있는 order가 전부 insert 된다.
만약 기존 order에 새로 전달받은 order를 덧붙이고 싶으면 newMember의 orderList에 살려놓을 order들을 추가해줘야 한다.

1
2
3
4
5
6
7
8
9
Member newMember = newMemberDTO.toEntity(); // orderList를 가지고 있음
newMember.setId(1); // 기존 member의 id값을 그대로 유지

Member oldMember = memberRepository.findById(1);
List<Order> aliveOrder = 살려놓을_주문_구하기(oldMember.getOrder());

newMember.getOrderList().addAll(aliveOrder);

memberRepository.save(newMember);

참고로 여기서 merge가 진행되고 나면 기존에 oldMember의 자식들에 변형이 가해진다(무슨 기준인지는 모르겠다)
이거까지 신경쓰는건 좋지 않은 것 같다…
id를 바꾸고 merge 할 생각이라면 merge 할 대상만 신경쓰도록 하고,
기존 entity의 list에 뭔가를 수행하고 싶을 경우 merge 전에 해주는게 좋겠다.

Read more »

[jpa] CascadeType.PERSIST를 함부로 사용하면 안되는 이유

Posted on 2018-12-27 | Edited on 2020-11-02 | In jpa | Comments:

엔티티의 자식에 CascadeType.PERSIST를 지정할 경우 JPA에서 추가적으로 수행하는 동작이 있고,
이 때문에 예상치 못한 사이드 이펙트가 발생할 수 있으므로 이를 남겨두고자 한다.

일단 기본적으로 cascade(영속성 전이)는 간단하다.
EntityManager를 통해 영속성 객체에 수행하는 행동이 자식까지 전파되는 것이다.

1
2
3
4
5
em.persist(parent);
==
em.persist(parent);
em.persist(child1);
em.persist(child2);

변경 감지에서의 CascadeType.PERSIST

근데 여기 JPA 2.2 specification 문서의 3.2 장 Entity Instance's Life Cycle에
변경감지 부분인 3.2.4 Synchronization to the Database에 보면 아래와 같은 내용이 추가적으로 있음을 볼 수 있다.

The semantics of the flush operation, applied to an entity X are as follows:
• If X is a managed entity, it is synchronized to the database.
• For all entities Y referenced by a relationship from X, if the relationship to Y has been annotated with the cascade element value cascade=PERSIST or cascade=ALL, the persist operation is applied to Y.

flush가 발생할 때 CascadeType.PERSIST나 CascadeType.ALL이 있을 경우 자식에 연쇄적으로 persist operation이 발생한다는 의미이다.

이 특징을 기반으로 아래의 행위들을 설명할 수 있다.
Member와 Order의 관계는 아래와 같다고 가정한다.

1
2
3
4
5
6
7
8
9
class Member{
@OneToMany(mappedBy = "member", cascade = CascadeType.PERSIST)
private List<Order> orderList = new ArrayList<>();
}
class Order{
@ManyToOne
@JoinColumn(name = "member_id")
private Member member;
}
  1. em.persist
1
2
3
4
5
6
7
8
9
10
11
12
13
Member member = new Member();
Order order1 = new Order();
Order order2= new Order();

member.addOrder(order1);
member.addOrder(order2);

em.persist(member);

Order order3 = new Order();
member.addOrder(order3);

// order1, order2, order3 insert 됨

member를 persist할 때 order1, order2 까지 연쇄적 persist가 발생하고,
트랜잭션이 끝나고 flush 될 때 자식들에 대해 다시 persist operation을 수행하게 된다.
spec에 보면 persist operation은 아래와 같다.

• If X is a new entity, it becomes managed. The entity X will be entered into the database at or before transaction commit or as a result of the flush operation.
• If X is a preexisting managed entity, it is ignored by the persist operation. // …

즉 member의 orderList 3개에 대해 모두 persist operation이 발생하고,
앞의 2개는 이미 존재하던 것이므로 무시되고, 마지막 order3는 추가적으로 insert 되는 것이다.

  1. em.merge
1
2
3
4
5
6
7
8
9
10
11
12
13
Member member = new Member();
Order order1 = new Order();
Order order2 = new Order();

member.addOrder(order1);
member.addOrder(order2);

member = em.merge(member);

Order order3 = new Order();
member.addOrder(order3);

// order1, order2, order3 insert 됨

CascadeType.PERSIST 이므로 em.merge 할 때 자식까지 연쇄적으로 merge가 발생하지는 않는다.
하지만 flush 될 때 CascadeType.PERSIST에 의해 member 3개에 대해 모두 persist operation이 발생한다.

  1. em.find
1
2
3
4
5
6
7
8
Member member = em.find(Member.class, 1);
Order order1 = new Order();
Order order2 = new Order();

member.addOrder(order1);
member.addOrder(order2);

// order1, order2 insert 됨

이 또한 flush 될 떄 CascadeType.PERSIST에 의해 자식 order1, order2에 대해 persist operation이 수행된다.

즉 모든 행위는 flush의 CascadeType에 대한 특징 때문이다.

이러한 특징으로 봤을때, 우리가 의문을 가졌던 아래 코드 또한 설명이 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Member{
@OneToMany(mappedBy = "member", cascade = CascadeType.MERGE)
private List<Order> orderList = new ArrayList<>();
}

Member member = new Member();
Order order1 = new Order();
Order order2= new Order();

member.addOrder(order1);
member.addOrder(order2);

member = em.merge(member);

Order order3 = new Order();
member.addOrder(order3);
// order1, order2 insert 됨

반면에 CascadeType.MERGE의 경우 flush와 관련이 없기 떄문에, em.merge 메서드에 전달한 엔티티까지만 연쇄적으로 merge가 되고, 아래는 그냥 무시되었던 것이다.

persist operation의 대상

위에서 언급했다시피 CascadeType.ALL, CascadeType.PERSIST 어노테이션이 추가된 자식에 대해 모두 persist operation을 발생시킨다. (소스를 정확히 본것은 아니므로 틀릴 수 있음)
그러므로 아래의 두 코드에서 발생하는 insert가 동일하게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void cascadeTest(){
Member member = em.find(Member.class, 1); // 5개의 orderList가 있다고 가정

member.addOrder(order1);
member.addOrder(order2);
}

// ==

public void cascadeTest(){
Member member = em.find(Member.class, 1); // 5개의 orderList가 있다고 가정
member.getOrderList().clear(); // 기존의 애들을 다 지워버려도

member.addOrder(order1);
member.addOrder(order2);
}

첫번쨰의 경우 총 7개의 order에 대해 persist operation을 수행하여 5개는 무시되고, 2개가 insert 된것이고,
두번쨰의 경우 clear로 날려버렸기 때문에 총 2개의 order에 대해 persist operation이 수행되어 2개가 insert 된 것이다.(기존에 있던 것을 삭제하고 싶으면 orphanRemoval = true를 줘야한다)
그러므로 위의 두 행위는 결과적으로 데이터베이스에 동일한 행위를 수행하게 되는 것이다.

예상치 못한 동작1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Member{
// ...

@OneToMany(mappedBy = "member", cascade = CascadeType.PERSIST)
private List<Order> orderList = new ArrayList<>();
}
class Order{
// ...

@ManyToOne
@JoinColumn(name = "member_id")
private Member member;
}

public void deleteTest(){
Member member = em.find(Member.class, 1);

List<Order> orderList = member.getOrderLsit();

for(Order order : orderList){
em.remove(order);
}
}

(지금은 예제가 간단하지만, 위와 같은 상황은 얼마든지 나올 수 있음)
order가 삭제될 것이라고 예상할 수 있지만, flush시에 orderList에 남아있는 모든 order에 대해 persist 연산을 수행하므로 결과적으로 delete 메서드가 날라가지 않는 현상이 발생한다.
그러므로 CascadeType.PERSIST를 사용하고자 할 경우 삭제하는 order에 맞춰 orderList에서 요소를 삭제해주거나,
orphanRemoval = true를 사용해 orderList에서 삭제되면 자동으로 delete 가 날라가게끔 해야한다.

예상치 못한 동작2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Entity
class Member{
// ...

@OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Order> orders;
}

@Entity
class Item{
// ...

@OneToMany(mappedBy = "item", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Order> orders;
}

@Entity
class Order{
// ...

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(nane = "item_id")
private Item item;
}

Member, Item이 있고 중간에 연결테이블로 Order를 가지고 있는 일반적인 구조이다.
여기서 만약 아래와 같은 행위를 수행하면 어떻게 될까?

1
2
3
4
5
6
7
8
9
Member member = em.find(Member.class, 1);

List<Order> orders = member.getOrders();
for(Order order : orders){
Integer size = order.getItem().getOrders().size();
// ...
}

member.getOrders().clear(); // expecting delete operation

Member가 가진 order들의 item이 가진 order들의 size를 가져오고 있다.
(좀 어거지스럽긴 하지만 뭐 발생할려고 하면 어떻게든 발생할 수는 있는 상황이다.)
어찌됐든 여기서 중요한것은, size를 얻기 위한 행위 때문에 Item이 lazy 로딩 되었고, item의 Order들이 lazy 로딩 되었다는 점이다.

하고싶은 행위를 다 하고… 결과적으로 member 내의 order들이 다 쓸모없다고 판단해서 버리기로 한 모양이다.
orphanRemoval에 의해 clear() 만 해줘도 다 삭제되어야 하는데, 삭제가 잘 될까?

아쉽게도 삭제되지 않는다.
Member의 입장에서는 orders의 개수가 0개가 되었으므로 삭제를 시도하려고 할 것이다.
하지만 위에서 size를 얻기위해 Item, Item의 orders를 Lazy 로딩 시킨것이 문제이다.
(굳이 lazy 로딩이 아닌 EAGER로 초기 로딩 등등 어떻게든 반대편도 영속성 컨텍스트에 올라갔다는 점이 중요 포인트이다)

Item이 영속성 컨텍스트에 올라가게 되었는데, orders에는 CascadeType.ALL(PERSIST)가 걸려있다.

그러므로 member의 orders를 비워서 delete operation을 수행하고 싶어도,
item의 orders 요소들이 아직 남아있기 때문에 PERSIST operation이 발생하게 되고,
결과적으로 delete가 씹히게 되는 것이다.

이를 해결하기 위한 가장 적절한(?) 방법으로는, 양쪽 다 CascadeType.ALL을 걸지않고 정말 필요한 한쪽만 거는 것이다.
위의 경우도 다시보면 item쪽에서 order를 cascade로 여러개 등록시킬 상황은 굳이 존재하지 않는다(물론 있을수도 있다)
과감히 제거해주면, delete 명령이 정상적으로 동작할 것이다.

Read more »

[jpa] 프록시와 연관관계 관리

Posted on 2018-12-18 | Edited on 2020-11-02 | In jpa | Comments:

JPA에는 지연 로딩이라는 기능이 있다.
아래와 같은 엔티티가 있다고 할 때,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Entity
class Member{
@Id
private Long id;

// ...

@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
}

@Entity
class Team{
@Id
private Long id;

// ...
}

아래와 같이 Member를 조회하면 Team 테이블이 join 되어 같이 조회되었었다.

1
2
Member member = em.find(Member.class, 1);
// do something
1
2
3
4
SELECT T.*
FROM MEMBER M
LEFT JOIN TEAM T ON M.TEAM_ID = T.ID
WHERE M.ID = 1;

만약 Member 테이블만 필요하다고 하면, 매번 위처럼 조회되는 것은 매우 비효율적이다.
JPA는 이런 상황을 위해 지연 로딩이라는 기능(명세?)을 제공한다.
말 그대로 지연해서 로딩하는 것으로써, Member의 Team이 실제로 사용되는 순간에 해당 엔티티를 데이터베이스에서 조회해올 수 있다.

JPA의 표준 명세는 지연로딩의 구현 방법을 JPA 구현체에 위임했다.
하이버네이트는 지연로딩을 지원하기 위해 프록시를 사용하는 방법과 바이트코드를 수정하는 방법을 사용한다.

프록시

em.getReference()

EntityManager의 getRefernece() 메서드를 호출하면 엔티티를 바로 조회해오지 않고, 실제 사용하는 시점에 조회해올 수 있다.(find는 바로 조회해온다)
이러한 지연 로딩을 지원하기 위해 프록시 객체를 사용하는데, 반환되는 프록시 객체의 모습은 아마 아래와 같을 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MemberProxy extends Member{
Member target = null;

public String getName(){
if(target == null){
// DB 조회
// 실제 엔티티 생성 및 참조 보관
this.target = // ...
}

return this.target.getName();
}
}

// 사용
@Test
public void getReferneceTest(){
Member member = em.getReference(Member.class, 1);
member.getName();
}

보다시피 프록시 객체는 상속을 사용하여 구현한다.
if(target == null) 내부에서 영속성 컨텍스트에 의해
데이터베이스를 조회해 실제 엔티티를 생성하는 것을 프록시 객체의 초기화라고 한다.
흐름은 아래와 같다.(영속성 컨텍스트는 비어있다고 가정한다)

  1. getReference()를 호출하면 프록시 객체를 생성한 뒤 영속성 컨텍스트(1차 캐시)에 저장한다.
  2. 실제 데이터를 얻기 위해 getName()을 호출한다.
  3. 프록시 객체는 영속성 컨텍스트에 실제 엔티티 생성을 요청한다(초기화)
  4. 영속성 컨텍스트는 데이터베이스를 조회해서 실제 엔티티 객체를 생성하고, 해당 객체의 참조를 target 변수에 보관한다.
  5. 프록시 객체는 target 변수에 저장된 실제 엔티티 객체의 getName()을 호출해서 결과를 반환한다.

영속성 컨텍스트는 1차 캐시를 포함한 큰 개념이므로, 헷갈릴 수 있음에 유의

프록시 객체는 다음과 같은 특징을 가진다.

  • 프록시 객체의 초기화는 딱 한번만 실행된다. target 객체에 저장되면 그것을 계속 사용하기 때문이다.
  • 원본 엔티티를 상속받은 객체이므로 타입 체크에 주의해야 한다.
  • em.getReference() 실행 시 영속성 컨텍스트에 찾는 엔티티가 이미 있으면(식별자로 조회), 프록시 객체 대신 실제 엔티티를 반환한다.
1
2
3
4
5
Member member1 = em.getReference(Member.class, 1);
Member member2 = em.find(Member.class, 1);

System.out.println("member1 : " + member1.getClass().getName());
System.out.println("member2 : " + member2.getClass().getName());

member1 : Member$HibernateProxy8guhz2idmemver2:Member8guhz2id memver2 : Member8guhz2idmemver2:MemberHibernateProxy$8guhz2id

처음 호출될 때 식별자를 이용해 1차 캐시에 저장하고,
초기화 되면 해당 프록시 객체내의 target 변수에 값이 저장되게 되는것이다.
이후에 em.find로 엔티티를 조회해와도 이미 프록시 객체가 저장되어 있기 떄문에 해당 객체가 반환된다.
영속성 컨텍스트의 동작 방식을 더 잘 설명하기 위해 일부러 반대로 예시를 들었다.

참고로 아래는 getReference() 에서 추적한 내용인데, 보다시피 처음 초기화 될 때 proxy 객체들을 따로 저장해둔다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 초기화
public void initialize(MetadataImplementor mappingMetadata, JpaMetaModelPopulationSetting jpaMetaModelPopulationSetting) {
// ...
for ( final PersistentClass model : mappingMetadata.getEntityBindings() ) {
// ...
// entity 객체 저장
entityPersisterMap.put( model.getEntityName(), cp );

// ....
// proxy 객체 저장
final String old = entityProxyInterfaceMap.put( cp.getConcreteProxyClass(), cp.getEntityName() );
// ...
}
}

// 사용
@Override
public EntityPersister locateEntityPersister(Class byClass) {
EntityPersister entityPersister = entityPersisterMap.get( byClass.getName() );
if ( entityPersister == null ) {
String mappedEntityName = entityProxyInterfaceMap.get( byClass );
// ...
}
}

프록시와 식별자

프록시 객체는 target 변수만 가지고 있는것이 아니라, 전달받은 식별자 값도 같이 저장한다.
그러므로 아래와 같이 식별자 값만 조회할 경우 직접적인 데이터베이스 조회가 일어나지 않는다.

1
2
Member member = em.getRefernece(Member.class, 1);
member.getId(); // SQL 실행하지 않음

이러한 특징을 이용하면 연관관게를 설정할 때 유용하게 사용할 수 있다.

1
2
3
4
5
6
7
8
9
Member member = new Member();
member.setName("joont");
member.setAge(27);

// team setting
Team team = em.getReference(Team.class, 1);
member.setTeam(team);

em.persist(member);

데이터베이스에서 연관관계를 설정할때 외래키로 해당 데이터베이스의 식별자밖에 사용하지 않는다.
즉, member를 persist 할 때 team의 id만 필요할것이고, 실제로도 그렇게 처리될것이다.
이럴 경우 team을 전체 조회해오는 find 보다는 getReference()를 사용해서 데이터베이스 접근 횟수를 줄일 수 있다.
참고로 현업에서는 외래키 제약조건을 안거는 경우가 많으니… DB레벨에서 오류가 발생하지 않으므로 위험할 수 있다.

프록시 확인

JPA에서 제공하는 PersistenceUnitUtil.isLoaded(Object entity) 메서드를 사용하면 프록시 객체의 초기화 여부를 확인할 수 있다.
아직 초기화 되지 않은 엔티티의 경우 false를 반환한다.
쓸일이 있을랑가…

즉시로딩, 지연로딩

JPA에서는 연관된 엔티티를 조회해올 때도 프록시 객체를 사용하여 지연로딩을 할 수 있다.
지연로딩 여부는 연관관계를 맺는 어노테이션(@ManyToOne, @OneToMany…)의 속성(fetch)으로 제공하여 상황에 따라 개발자가 선택해서 사용할 수 있게 해준다.

제공되는 속성은 즉시로딩, 지연로딩 두 가지이다.

즉시로딩

fetch 속성을 FetchType.EAGER로 주면 된다.

1
2
3
4
5
6
7
8
9
@Entity
class Member{
@Id
private Long id;

@ManyToOne(fetch = FetchType.EAGER) // 즉시로딩으로 설정
@JoinColumn(name = "team_id")
private Team team;
}
1
2
Member member = em.find(Member.class, 1); // team 까지 같이 조회됨  
Team team = em.getTeam(); // 실제 엔티티

이렇게 설정해두면 Member 엔티티가 조회될 때 Team 엔티티가 항상 같이 조회된다.
대부분의 JPA 구현체는 즉시로딩을 최적화하기 위해 가능하면 조인 쿼리를 사용한다.

지연로딩

fetch 속성을 FetchType.LAZY로 주면 된다.

1
2
3
4
5
6
7
8
9
@Entity
class Member{
@Id
private Long id;

@ManyToOne(fetch = FetchType.LAZY) // 지연로딩으로 설정
@JoinColumn(name = "team_id")
private Team team;
}
1
2
3
Member member = em.find(Member.class, 1);  
Team team = member.getTeam(); // 프록시 객체
team.getName(); // 이때 조회됨!

em.find(Member.class, 1)을 호출하면 Member만 조회하고 team 멤버변수에는 프록시 객체를 넣어둔다.
그리고 아래 실제 사용되는 부분에서 데이터가 조회된다.(동작 방식은 em.getReference()와 동일하다)
사용 시점에 조회해오므로 쿼리는 당연히 따로따로 날라간다.

컬렉션 래퍼
하이버네이트는 엔티티를 영속상태로 만들 때 엔티티에 컬렉션이 있으면
해당 컬렉션을 추적하고 관리할 목적으로 원본 컬렉션을 하이버네이트가 제공하는 내장 컬렉션으로 변경한다.
이를 컬렉션 래퍼라고 하고, org.hibernate.collection.internal.PersistentBag 클래스이다.
에 클래스가 컬렉션 레벨에서 프록시 객체의 역할까지 같이 해주므로, 이 클래스를 통해 지연로딩을 달성할 수 있다.
참고로 컬렉션의 실제 데이터를 조회할 때 데이터베이스를 조회해서 초기화한다.

1
2
member.getTeam(); // SQL 실행안함  
member.getTeam().get(0); // SQL 실행

그래서 뭘 설정해야 하는데?

양방향 연관관계 설정과 똑같다. 사용되는 곳에 따라 어떤 전략을 선택하면 좋을지 체크해보고, 선택하면 된다.
참고로 각 연관관계들은 default fetch 값이 있다.

  • @OneToOne : EAGER
  • @ManyToOne : EAGER
  • @OneToMany : LAZY
  • @ManyToMany : LAZY

보다시피 default값은
추가적으로 하나만 로딩해도 될때는 즉시로딩 되도록,
추가적으로 많은 데이터가 로딩될 수 있을 경우에는 지연로딩 되도록 설정되어 있다.
(컬렉션을 로딩하는 것은 비용도 많이들고, 한번에 너무 많은 데이터를 로딩할 수 있기 때문이다.)

추천하는 방법은 전부 FetchType.LAZY를 사용하는 것이다.
그리고 어플리케이션 개발이 어느정도 완료단계에 왔을 때, 실제 사용 상황을 보고 꼭 필요한 곳에만 즉시 로딩을 사용하도록 최적화하면 된다.

참고로 SQL Mapper를 사용하면 이런 유연한 최적화가 어렵다.(ㅎㅎ)

컬렉션에 FetchType.EAGER를 사용할 때 주의할 점

  1. EAGER를 하나 이상 설정하는 것은 권장하지 않는다.
    컬렉션은 기본적으로 일대다 관계에서 사용되므로, 조인되는 테이블이 많아질수록 출력되는 row가 급격하게 증가하기 때문이다.
    예를 들어 A 테이블과 N, M 테이블을 일대다 조인하면 N * M 개수의 행이 반환되고, 결과적으로 성능이 저하될 수 있다.
    또한 JPA는 이렇게 조회된 결과 N과 M을 메모리에서 필터링 해서 반환하므로, 2개 이상의 컬렉션을 즉시 로딩으로 설정하는 것은 권장되지 않는다.

  2. 컬렉션 즉시 로딩은 항상 외부 조인을 사용한다.
    내부 조인을 사용하면 자식이 없는 엔티티가 조회되지 않는 결과가 발생한다.
    이를 제약조건으로 막을 수 있는 방법이 없으므로, 무조건 외부 조인을 사용한다.

영속성 전이(CASCADE)

특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶을 때 영속성 전이를 사용한다.
CASCADE라는 옵션으로 제공하고, 실제 데이터베이스의 CASCADE와 동일하다.

영속성 전이는 매우 간단하다.
EntityManager를 통해 영속성 객체에 수행하는 행동이 자식까지 전파된다고 보면 된다.
객체에 선언한 관계(@OneToOne, @OneToMany…)에 cascade 라는 속성값으로 지정해 줄 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Entity
class Parent{
@Id
private Long id;

@OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
private List<Child> children = new ArrayList<>();
}

class Child{
@Id
private Long id;
}

public void save(){
Parent parent = new Parent();
// ...

Child child1 = new Child();
Child child2 = new Child();

parent.addChild(child1);
parent.addChild(child2);

em.persist(parent); // 한방으로 해결
}

원래라면 child, child2 따로따로 다 저장해줬어야 했을것이지만, 영속성 전이를 사용하여 편리하게 저장함을 볼 수 있다.

쿼리는 아래와 같이 날라간다.

1
2
3
4
insert into parent values(...);

insert into child values(...);
insert into child values(...);

간단하게 설명해 CascadeType.PERSIST를 설정함으로써 아래와 같아졌다고 보면 된다.

1
2
3
4
5
em.persist(parent);
==
em.persist(parent);
em.persist(child1);
em.persist(child2);

영속성 전이의 범위는 위의 cascade 속성의 값으로 준 애들에 대해서만 동작한다.
cascade의 종류는 아래와 같다.

1
2
3
4
5
6
7
8
public enum CascadeType { 
ALL,
PERSIST,
MERGE,
REMOVE,
REFRESH,
DETACH
}

CascadeType.MERGE를 주고 em.merge를 실행하면 자식까지 모두 em.merge가 실행되는 것이고,
CascadeType.REMOVE를 주고 em.remove를 실행하면 자식까지 모두 em.remove가 실행되는 것이다. 간단하다.
키워드는 간단히 부모가 XXX 될 때, 자식들도 전부 XXX 시켜라 정도로 이해하면 된다.

당연한 얘기지만 cascade 속성을 줬을 경우 속성이 설정된 엔티티의 자식까지 모두 cascade 속성을 줘야한다.
그렇지 않으면 CascadeType.PERSIST를 줬을 경우 자식 엔티티가 전부 저장되지 않을 것이고, CascadeType.REMOVE를 줬을 경우 FK 제약조건에 걸려 에러가 발생할 것이다.

어디에, 어떻게 사용하는게 좋은가?

https://vladmihalcea.com/a-beginners-guide-to-jpa-and-hibernate-cascade-types/
이 블로그에서 영속성 전이의 Best Practice에 대해 설명하고 있다(짱짱맨)

첫 부분에서 JPA와 Hibernate의 CascadeType 속성에 대해 비교해주고 있는데, 보다시피 hibernate가 더 많은 CascadeType을 지원함을 볼 수 있다.

여기서 문제가 될 수 있는건, CascadeType.ALL을 지정했을 경우다.
구현체가 hibernate이기 때문에, JPA의 CascadeType.ALL 을 지정하면 hibernate의 LOCK CascadeType 등등이 다 적용될 수 있다.
그러므로 주의해서 사용해야 한다. 개인적으로 ALL보다는 사용하는 애들만 적절히 써주는게 좋을것 같다고 생각함.

그리고 그 아래에서 각 관계에서의 Best Pracetice에 대해 설명해주고 있는데, 간단히 요약하면 아래와 같다.

  • @OneToOne은 bidirection 하게 설정해주는 것이 좋고, orphanRemoval 까지 주는 것이 좋다. 편의 메서드 또한 같이 넣어주는 것이 좋다.
  • 기본적으로 @OneToMany 에서 사용하는 것이 일반적이고, 가장 많이 사용된다.
  • @ManyToOne에서 cascade를 거는 것은 비정상적인 행위이니, 하지 않도록 한다.
  • @ManyToMany에서 CascadeType.REMOVE를 사용하게 되면 내가 예상한 것 보다 훨씬 많은 삭제가 일어날 수 있다.

고아 객체

JPA는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는데, 이를 고아(orphan)객체 제거라고 한다.
주가 되는 엔티티에서 연관된 객체의 참조가 제거되면, 그것을 고아 객체로 보고 삭제하는 기능이다.

1
2
3
4
5
6
7
// @OneToOne
Member member = em.find(Member.class, 1);
member.setLocker(null); // locker 삭제됨

// @OneToMany
Parent parent = em.find(Parent.class, 1);
member.getChildren().remove(0); // child 삭제됨

자식이 부모의 생명주기에 묶여있는(특정 엔티티가 개인 소유하는) 엔티티에만 이 기능을 적용하는 것이 좋다.
삭제한 엔티티를 다른 곳에서도 참조하면 문제가 발생할 수 있기 떄문이다.
그래서 orphanRemoval은 @OneToMany, @OneToOne 관계에만 사용할 수 있다.

참고로 orphanRemoval에는 추가적인 기능이 하나 더 있는데,
부모를 자식까지 같이 제거되는 CascadeType.REMOVE의 기능이다.
개념적으로 부모를 제거하면 자식이 고아가 되기 떄문이다.

영속성 전이 + 고아객체

CacadeType.ALL + orphanRemoval = true 를 동시에 사용하면 부모를 통해서 자식 엔티티의 생명주기를 관리할 수 있게 된다.

1
2
3
4
5
6
7
// child insert
Parent parent = em.find(Parent.class, 1);
parent.addChild(child);

// child remove
Parent parent = em.find(Parent.class, 1);
parent.getChildren().remove(0);

CacadeType.PERSIST로 자식을 컨트롤 할 수 있게 한 이유가 이해가 안간다.

일단, EntityManager로 수행하는 메서드와 CascadeType은 별개라고 생각해야 한다.

Read more »

[spring] spring boot test

Posted on 2018-12-16 | Edited on 2020-11-02 | In spring | Comments:

https://meetup.toast.com/posts/124

spring boot test 모듈은 아래의 2개가 존재함

  • spring-boot-test
  • spring-boot-test-autoconfigure

대부분 spring-boot-starter-test로 충분함(위의 2개를 다 포함하고 있나?)

@SpringBootTest

@ContextConfiguration의 발전된 기능?
테스트에 사용할 ApplicationContext를 쉽게 조작할 수 있음
반드시 @RunWith(SpringRunner.class)와 함께 사용해야함

  • Bean 설정

    • classes 속성을 통해 할 수 있음
    • 설정한 클래스들만 빈으로 등록함
    • @Configuration을 설정할 경우 내부의 빈들도 전부 등록됨
  • @TestConfiguration

    • 기존에 정의한 @Congifuration을 재정의하고 싶을 경우
    • @TestConfiguration에 정의된 bean으로 override 된다
    • 테스트 클래스내에 선언할수도 있지만, 이러면 @ComponentScan시에만 감지되므로 classes를 명시했을 경우 사용할 수 없다는 단점이 있다
    • @Import를 써서 직접 사용하는 것이 더 좋다
      • 이렇게 하면 여러클래스에서도 사용할 수 있다
  • @MockBean

    • @MockBean 어노테이션을 사용하면 mock 객체를 빈드로 등록할 수 있다
    • @Autowired 등으로 주입받는 객체가 @MockBean으로 선언된 객체라면, 해당 mock 객체가 주입된다
    • @MockBean으로 선언한 객체가 이미 빈에 등록되어져 있다면 override 된다
  • properties

    • 스프링은 테스트시에 기본적으로 class path의 application.properties(yml)을 참조한다
    • properties 속성으로 별도의 테스트를 위한 설정파일을 지정할 수 있다
    • test classpath의 application.yml이 있으면 그걸 먼저 참조…하나?

TestRestTemplate

  • @SpringBootTest와 RestTestTemplate를 같이 사용한다면 편리하게 웹 통합테스트가 가능하다
  • @SpringBootTest에서 Web Environment를 설정했다면 TestRestTemplate는 그에맞춰 자동으로 빈으로 생성된다
  • MockMvc는 servlet container를 생성하지 않고, TestRestTemplate는 servlet container를 생성함
    • 그러므로 실제 동작되는 것처럼 테스트를 수행할 수 있다
    • 클라이언트가 수행하는 것 처럼 테스트할 수 있다

트랜잭션

  • @Test 어노테이션과 @Transactional 어노테이션을 함꼐 사용했을 경우 테스트가 끝나면 rollback 됨

    • 기존의 spring-test와 동일
    • 만약 롤백하고 싶지 않다면 아래와 같이 함
      1
      2
      3
      4
      5
      @Test
      @Rollback(false)
      public void insetTest(){
      // ...
      }
  • 하지만 webEnvironment의 RANDOM_PORT나 DEFINED_PORT로 테스트를 설정하면 테스트가 별도의 스레드에서 수행되기 때문에 rollback이 수행되지 않음

@JsonTest

  • json serialize, deserialize를 편하게 테스트해볼 수 있다

@WebMvcTest

  • 서버사이드 API Test?

Async web Test

  • async 테스트?

@DataJpaTest

  • memory db를 사용하고 테스트가 끝날떄마다 롤백됨
  • @Transactional 어노테이션을 포함하고 있음
  • 테스트를 위한 TestEntityManager 클래스가 빈으로 등록된다
    • Spring Data JPA를 쓰지 않을 경우 유용할까?

@JdbcTest

  • @DataJpaTest와 유사하게 순수 JDBC를 테스트하고 싶을 경우 사용
  • 테스트를 위한 JdbcTemplate가 생성된다

@DataMongoTest

  • mongodb

@RestClientTest

  • 좀 더 리얼환경에 가깝게 api 테스트를 할 수 있음
  • RestTemplate에 반응하는 가상의 mock 서버라고 생각하면 된다?
Read more »

[db] externalId의 용도

Posted on 2018-12-16 | Edited on 2020-11-02 | In db | Comments:

사용방법

PK를 그냥 key로 사용할 경우 외부에서 마스터 데이터의 양을 어느정도 알 수 있기 때문에 externalId 컬럼을 따로 만들어주고, 이것을 외부에 노출시키는 것이 좋다.

externalId는 PK와 동일하게 unique key가 되어야 한다(unique constraints 필요)
내부적인 룰을 통해 이 값을 지정하거나 특정한 룰이 없다면 UUID로 생성한다

주의

externalId를 PK로 잡으면 클러스터드 인덱스의 특징상 미친듯한 재정렬이 일어나므로, 추가 컬럼으로 사용하는 것이 좋다.

Read more »

liquibase

Posted on 2018-12-16 | Edited on 2020-11-02 | In etc | Comments:

개념

database schema 변경을 tracking 하여 관리할 수 있게 해주는 open source이다.
liquibase 문법에 맞춰 xml(yml) 파일을 작성한 뒤 liquibase 를 실행하면 해당 파일의 내용이 데이터베이스에 반영된다.
반영 방법은 command line, maven, gradle 등 다양한 방법으로 사용가능하다.
(gradle은 liquibase-gradle-plugin을 사용하면 된다)

이 라이브러리를 사용한 이후 부터는 데이터베이스 스키마를 직접 수정하는 일은 지양(금지)해야 한다.

그리고 어느 라이브러리에서 추가해준 task인지는 모르곘으나…(jhipster애서 generate 된걸 그대로 사용하다보니…)
gradle에 있는 liquibase 관련 task들 중 liquibaseDiffChangeLog를 실행하면 프로젝트에 작성한 entity와 데이터베이스 내 스키마를 참고하여
다른 부분을 liquibase 문법의 xml 파일로 생성해준다.
(diff가 완벽하게 생성되지는 않으니, xml 파일을 다시 보면서 잘못된 부분이 없나 최종적으로 확인해야한다. 직접 작성해도 됨)

문법

아래는 liquibase의 간단한 예제이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd
http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd">

<preConditions>
<runningAs username="liquibase"/>
</preConditions>

<changeSet id="1" author="nvoxland">
<createTable tableName="person">
<column name="id" type="int" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="firstname" type="varchar(50)"/>
<column name="lastname" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="state" type="char(2)"/>
</createTable>
</changeSet>

<changeSet id="2" author="nvoxland">
<addColumn tableName="person">
<column name="username" type="varchar(8)"/>
</addColumn>
</changeSet>
</databaseChangeLog>

changeSet에 어떤 행위들을 할 지 나열되어 있다.(createTable, addColumn 등)

이 파일을 liquibase로 실행하면 실제 데이터베이스에 반영될 것이다.
그리고 변경사항이 반영되면 DATABASECHANGELOG라는 테이블에(liquibase에서 사용하는 테이블) 위의 changeLog id값으로 로우가 쌓이게 된다.
이 말은 해당 변경사항은 적용되었다는 뜻이다.
liquibase는 이 테이블 로우를 참고하여 changeSet이 이미 반영되었으면 skip, 반영되지 않았다면 반영한다.

sql 쿼리를 그대로 적용하는법

1
2
3
4
5
6
7
8
9
<changeSet author="junyoung.park (generated)" id="20190307124000-7">
<sql>
CREATE TABLE BATCH_STEP_EXECUTION_SEQ (
ID BIGINT NOT NULL,
UNIQUE_KEY CHAR(1) NOT NULL,
constraint UNIQUE_KEY_UN unique (UNIQUE_KEY)
) ENGINE=InnoDB;
</sql>
</changeSet>

여러 파일 적용

한번에 여러 changelog 파일을 실행할 수 있다.

1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.5.xsd">

<include file="config/liquibase/changelog/00000000_initial_schema.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/2018103001_added_spring_acl_schema.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/2018122001_refactor-column-length.xml" relativeToChangelogFile="false"/>
</databaseChangeLog>

이 파일을 liquibase로 실행하면 위에서부터 순서대로 반영한다.

주의사항

liquibase는 변경사항을 반영하기 전에 DATABASECHANGELOGLOCK 테이블에 LOCKED=1 인 상태로 레코드를 넣고 락을 건다
일반적인 상황에서는 문제되지 않지만, 한번에 서버를 2개 이상 띄워야 할 경우 데드락이 발생할 수 있으니 작성전에 이 레코드가 들어있는지 체크해보면 좋다
혹은 잘 뜨지 않는다면 이 테이블에 데이터가 있는지 체크해보자…

Read more »

MapStruct

Posted on 2018-12-16 | Edited on 2020-11-02 | In etc | Comments:

기본 사용법

  • installation
    http://mapstruct.org/documentation/stable/reference/html/#setup

  • 기본 사용법
    http://mapstruct.org/documentation/stable/reference/html/#basic-mappings

클래스간 변환을 간편하게 해주는 라이브러리이다(Car <> CarDTO)
아래는 Mapper 선언법이다.

1
2
3
4
5
6
7
8
9
10
11
12
@Mapper(componentModel = "spring", uses = {
OwnerMapper.class // 3
})
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper( CarMapper.class );

@Mapping(source = "numberOfSeats", target = "seatCount") // 1
CarDto toDto(Car car);

@Mapping(target = "id", ignore = true) // 2
Car toEntity(CarDTO dto);
}

보다시피 entity를 DTO로 변환해주는 작업을 한다(지금은 엔티티와 DTO를 예시로 들었지만 어떤 클래스든 상관없다).
아래의 generate 된 코드를 보면 알 수 있곘지만,
변환될 클래스는 setter가 필요하고, 변환대상 클래스는 getter가 필요하다.

  1. source와 target의 필드 이름이 다를 경우 직접 지정할 수 있다
  2. ignore를 통해 특정 필드는 변환되지 않도록 설정할 수 있다. target만 신경쓰면 된다.
  3. 기본적으로 deep mapping 하는 코드까지 generation 해주긴하나, 기본적인 방식으로만 generation 된다(이름에 맞춰서 get/set)
    그러므로 custom한 mapper가 필요하다면 위와 같이 선언해줘야 한다.

install한 library로 빌드하면 @Mapper 인터페이스들을 찾아서 XXXImpl의 형태로 구현체를 모두 만든다.(빌드 방식 알아봐야함)

현재 componentModel을 spring으로 줬기때문에 생성되는 Impl은 스프링의 싱글톤 빈으로 관리된다(@Component 붙음)

생성된 구현체는 아래와 같다. 간단하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public CarDTO toDto(Car entity) {
if ( entity == null ) {
return null;
}

CarDTO carDTO = new CarDTO();

carDTO.setName( entity.getName() );
carDTO.setSeatCount( entity.numberOfSeats() );
carDTO.setOwner( ownerMapper.toDto(entity.getOwner() ) );

return carDTO;
}

아래는 간단한 사용법이다.

1
2
3
4
5
6
7
8
9
10
11
12
// some service
public void save(CarDTO carDTO){
Car car = CarMapper.INSTANCE.toEntity(carDTO);

em.persist(car);
}

public CarDTO select(){
Car car = em.find(Car.class, 1);

return CarMapper.INSTANCE.toDTO(car);
}
Read more »

REST API Best Practice

Posted on 2018-12-16 | Edited on 2020-11-02 | In rest | Comments:

REST API 좋은 예제(읽어봐야함)

https://developer.github.com/v3/
REST API는 Github API가 좋은 에제이다.
구글이나 페이스북의 경우 사람이 많아서 오히려 관리가 잘 안되고 있댜.
그에 비해 Github은 300명 정도로 운영하기 때문에, 좀 더 낫다고 함.

Rest API 좋은 가이드라인(읽어봐야함)

https://allegro-restapi-guideline.readthedocs.io/en/latest/
https://docs.microsoft.com/ko-kr/azure/architecture/best-practices/api-design

리스트 response에 대한 안티패턴

1
2
3
4
5
6
[
{
...
}
...
]

추가적인 정보는 대부분 헤더에 내려주긴 하지만, 가끔씩 헤더에 넣기 애매한 애들이 있다(page meta information 등).
그런 애들은 응답 바디에 넣어주는 것이 좋은데, 위와 같이 api 설계를 하면 추가적인 정보를 내려줄 수 없게된다.

그러므로 아래와 같이 해주는 것이 좋다.

1
2
3
4
5
6
7
8
{
"data": [
{
...
},
...
]
}
Read more »

[java] lombok practice 정리

Posted on 2018-12-13 | Edited on 2020-11-02 | In java | Comments:

상속 클래스에 builder 적용

https://reinhard.codes/2015/09/16/lomboks-builder-annotation-and-inheritance/
또는 @SuperBuilder를 사용할 수 있음
intellij 구조적 문제로 에러로 표시된다는 특징이 있다… 컴파일은 잘 된다.

Builder에서 필수값, 선택값 구분하기

빌더 패턴은 필수값과 선택값을 구분할 수 있다는 장점도 가지고있는데, 클래스 위에 그냥 @Builder를 선언하면 그 장점을 누리지 못하게 된다.
아래와 같이 작성해서 필수값, 선택값을 구분하게끔 할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Builder
@AllArgsConstructor
public class Member {
private String name;
private Integer age;
private String nickname;
private String address;

public static MemberBuilder builder(String name, Integer age){
return new MemberBuilder()
.name(name)
.age(age);
}
}

사용은 아래와 같이 할 수 있다.

1
2
3
Member.builder("joont",28)
.address("somewhere in seoul")
.build();
Read more »

[jpa] 고급 매핑

Posted on 2018-12-13 | Edited on 2020-11-02 | In jpa | Comments:

상속 관계 매핑

RDB는 객체지향 언어처럼 상속이라는 개념이 없다.
대신 슈퍼타입 서브타입 관계라는 모델링 기법이 있는데, 이게 상속 개념과 가장 유사하다.
상속 관계 매핑 기법은
물리 모델로 구현된 어떠한 슈퍼타입 서브타입 관계든, 객체 지향 상속 기법으로 추상화해서 접근할 수 있게 해준다는 것이 핵심이다.
DB를 슈퍼타입 서브타입 관계의 조인 전략으로 모델링했든, 단일 테이블 전략으로 모델링했든 객체 입장에서는 동일한 상속 구조로 접근할 수 있다.

각각의 테이블로 변환(조인 전략)

엔티티 각각(자식, 부모 전부)을 테이블로 만들고, 자식 테이블이 부모의 기본키를 받아서 기본키 + 외래키로 사용하는 방법이다.

joined tale

클래스 상속 구조랑 가깝게 생기긴 했다.

  • 장점

    • 테이블이 정규화된다
    • 외래키 참조 무결성 제약 조건 사용 가능
    • 저장 공간을 효율적으로 사용함(불필요하게 null을 넣는 부분이 없으므로)
  • 단점

    • 조회할 때 항상 조인해서 들고와야함
    • 등록할 때 INSERT를 항상 2번 실행해야함

초반에 모든 것이 예측된 케이스가 아니라면 이 전략을 사용할 일이 별로 없을 것 같다.
객체는 리팩토링을 통해서 공통 부분을 추출해내는 등 자유롭게 변경될 수 있지만, 데이터베이스는 마이그레이션이 필요하기 때문이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* 엔티티 정의
**/
@Entity
@Inheritance(strategy = InheritanceType.JOINED) // 1
@DiscriminatorColumn(name = "DTYPE") // 2
public abstract class Item{
@Id
@GeneratedValue
private Integer id;

private String name;

private int price;
}

@Entity
@DiscriminatorValue("A") // 3
public class Album extends Item{
private String author;
}

@Entity
@DiscriminatorValue("M") // 3
@PrimaryKeyJoinColumn(name = "MOVIE_ID")
public class Movie extends Item{
private String director;

private String actor;
}

/**
* 등록, 조회
**/
public void save(){
Album album = new Album();
// set...

em.persist(album);
}

public void select(){
Album album = em.find(Album.class, 1);
}

사용하는 부분은 별로 다를 것 없다.

  1. 상속 매핑을 사용할 것이고, 조인 전략을 사용할 것이라는 의미이다.
  2. 자식 테이블을 구분할 컬럼이다. 실제 테이블의 컬럼으로 생성된다. 기본값이 DTYPE 이다.

조인 전략에서는 이 컬럼이 생략 가능하다.(hibernate는 그렇고, 다른 구현체는 아닐수도 있다)
자식으로 직접 접근할때는(e.g. Album 엔티티로 접근) 생략해도 문제가 되지 않는데, 부모로 직접 접근할때는(e.g. 통계를 위해 Item 엔티티로 접근) TYPE이 없으면 해당 데이터가 어느 데이터를 나타내는지 알 수 없다.
그러므로 hibernate는 아래와 같은 전략을 선택한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
select
-- ~~~
case
-- 상속된 테이블의 개수만큼 when 반복
when itemlist0_1_.id is not null then 1
when itemlist0_2_.id is not null then 2
when itemlist0_.id is not null then 0
end as clazz_1_
from
item itemlist0_
left outer join
movie itemlist0_1_
on itemlist0_.id=itemlist0_1_.id
left outer join
album itemlist0_2_
on itemlist0_.id=itemlist0_2_.id
-- 상속된 테이블의 개수만큼 left join 반복

결국 이런식으로 처리되기 때문에, TYPE을 지정해주는 것이 좋다.

  1. 구분 컬럼에 저장될 값이다. 생략하면 엔티티 이름을 사용한다.

https://stackoverflow.com/questions/3639225/single-table-inheritance-strategy-using-enums-as-discriminator-value
@DiscriminatorValue에 enum을 사용하는 방법이라는데, 왜 이렇게 쓰는지 모르겠다.

  1. 기본적으로 자식 테이블은 부모 테이블의 ID 컬럼명을 그대로 사용하는데, 이를 바꿔주고 싶을 때 사용한다.

실행결과

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- 삽입
INSERT INTO ITEM(id, name, price, DTYPE) VALUES(1, '앨범', 10000, 'A');
INSERT INTO ALBUM(id, author) VALUES(1, '소녀시대');

INSERT INTO ITEM(id, name, price, DTYPE) VALUES(1, '인셉션', 10000, 'M');
INSERT INTO MOVIE(id, director, actor) VALUES(1, '크리스토퍼 놀란', '디카프리오');

-- 조회
select
*
from
album album0_
inner join
item album0_1_
on album0_.id=album0_1_.id
where
album0_.id = 1
-- and i.DTYPE = 'A'

(hibernate에서는 조건절에 따로 DTYPE이 추가되지 않았다)

통합 테이블로 변환(단일 테이블 전략)

전략 이름 그대로 하나의 테이블에 다 때려넣는 전략이다.
저장된 서브 타입마다 사용하지 않는 컬럼들에는 null이 들어가게 된다.

single table

조인 전략과 달리 구분 컬럼은 생략이 불가능하다.
생략하면 기본값이 사용된다.

  • 장점

    • 조인이 필요없다
  • 단점

    • 자식 엔티티가 매핑한 컬럼은 모두 null을 허용해야 한다.(데이터 관점에서 아주 좋지 않음)
    • 단일 테이블에 모든 것을 저장하므로 테이블이 커질 수 있다. 그러므로 오히려 성능이 느려질 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE) // 1
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item{
@Id
@GeneratedValue
private Long id;

private String name;

private int price;
}

@Entity
@DiscriminatorValue("A")
public class Album extends Item{
}
@Entity
@DiscriminatorValue("M")
public class Movie extends Item{
}
  1. 단일 테이블 전략을 사용할 것이라는 의미이다.

실행 결과

1
2
3
4
5
6
7
8
9
10
11
-- 삽입
INSERT INTO ITEM(id, name, price, artist, DTYPE) VALUES(1, '앨범', 10000, '소녀시대', 'A');

-- 조회
select
*
from
item album0_
where
album0_.id=1
and album0_.DTYPE='A'

서브타입 테이블로 변환(구현 클래스마다 테이블 전략)

실제 데이터들을 모두 별도의 테이블에 저장하는 방법이다.
테이블이 모두 별개이다 보니, 공통 부분에 대한 내용이 보장되지도 않고, 관리하기도 힘들다.
쿼리도 전부 UNION으로 날라가서 성능 로스가 극심하다.

concrete table

데이터베이스 설계자와 객체지향 설계자 둘 다 추천하지 않는 방법이다.
조인이나 단일 테이블 전략을 고려하는 것이 좋다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) // 1
public abstract class Item{
@Id
@GeneratedValue
private Long id;

private String name;

private int price;
}

@Entity
@DiscriminatorValue("A")
public class Album extends Item{
}
@Entity
@DiscriminatorValue("M")
public class Movie extends Item{
}
  1. 구현 클래스마다 테이블 전략을 사용하겠다는 의미이다.

매핑 정보만 상속(@MappedSuperClass)

부모 클래스는 테이블과 매핑하지 않고 부모 클래스를 상속 받는 자식 클래스에게 매핑 정보만 제공하고 싶을 경우 사용한다.
단순히 매핑 정보만 상속할 목적으로 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@MappedSuperClass // 1
public abstract class BaseEntity{
@Id
@GeneratedValue
private Long id;

@Temporal(TemporalType.TIMESTAMP)
private Date createdDate;

@Temporal(TemporalType.TIMESTAMP)
private Date lastModifiedDate;
}

@Entity
class Member extends BaseEntity{
// ...
}

@Entity
@AttributeOverride(name = "id", column = @Column(name = "TEAM_ID")) // 2
class Team extends BaseEntity{
// ...
}

BaseEntity는 테이블과 매핑되지 않고 단순히 자식 엔티티에게 매핑 정보만 제공하는 용도로 사용된다.
(참고로 ORM에서 말하는 진정한 상속 매핑은 처음 설명했던 상속 관계 매핑을 말한다.)

  1. 매핑 정보만 제공할 클래스라는 의미이다.
  2. 매핑정보를 재정의 하고 싶을 경우 사용한다. 여러개를 지정하고 싶을 경우 @AttributeOverrides를 사용한다.
  3. 위에 명시하진 않았지만 관계를 재정의 하고 싶을 경우 @AssociationOverride를 사용한다.

근데 이것보다 그냥 @Embeddable을 쓰는게 나을 것 같다.
@MappedSuperClass는 추상 클래스만이 가능한데, 다중 상속이 안되는 자바에서 단순히 매핑 정보를 추가 정의하기 위해 상속을 써버리는 것은 좋지 않은 것 같다…
일단 위에는 createdDate, lastModifiedDate로 작성했지만, 이렇게 사용하는게 좋은 예시는 아닌 것 같다.

복합키 매핑

JPA에서 식별자를 둘 이상 사용하려면 별도의 식별자 클래스를 만들어야 한다.
그냥 자바 기본 타입 2개 쓰고 @Id 선언하면 안된다.

JPA에서 별도의 식별자 클래스를 만드는 방법은 2가지가 있다.
두 방식의 장단점이 있으니, 원하는 방식을 선택해서 일관성 있게 하나만 사용하는 것이 좋다.

@IdClass

@IdClass를 이용한 복합키 선언은 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Entity
@IdClass(ParendId.class)
public class Parent{
@Id
@Column(name = "PARENT_ID1")
private String id1;

@Id
@Column(name = "PARENT_ID2")
private String id2;
}

@NoArgsConstructor
@AllArgsConstructor
public class ParentId implements Serializable{
private String id1; // Parent.id1 에 대한 정보 제공
private String id2; // Parent.id2 에 대한 정보 제공

// equals, hashCode
}

@IdClass가 정보 제공용도(식별자 정보는 여기를 참고해라) 정도로 쓰이고 있다.
@IdClass로 사용된 식별자 클래스는 아래 조건을 만족해야 한다.

  • 식별자 클래스의 속성명과 엔티티에서 사용하는 식별자의 속성명이 같아야 함

Entity에 매핑 정보를 적고, IdClass에서 해당 변수명에 맞춰 정보를 제공해주고 있다.
아래 식별/비식별 관계에서 복합키 사용 매핑하는 부분에서 더 상세히 볼 수 있다.

  • Serializable 인터페이스 구현해야 함
  • equals, hashCode 구현해야함
  • 기본 생성자 필요
  • 식별자 클래스는 public 이어야 함

실제 사용은 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// save
public void save(){
Parent parent = new Parent();
parent.setId1("id1");
parent.setId2("id2");

em.persist(parent);
}

// select
public void select()}{
ParentId parentId = new ParentId("id1", "id2");
Parent foundParent = em.find(Parent.class, parentId);
}

@IdClass의 장점은 엔티티에 flat한 attribute를 제공해서 그나마 RDB와 가깝다는 것인데, 저장만 그렇지 조회는 또 그렇지도 않다.
(저장의 경우 em.persist를 호출하면 JPA가 내부에서 Parent.id1, Parent.id2 값을 이용해서 ParentId를 생성하고 영속성 컨텍스트의 키로 사용한다.)

@EmbededId

@IdClass보다 좀 더 객체지향적인 방법이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity
public class Parent{
@EmbeddedId
private ParentId id;
}

@NoArgsConstructor
@AllArgsConstructor
@Embeddable
public class ParentId implements Serializable{
@Column(name = "PARENT_ID1")
private String id1;
@Column(name = "PARENT_ID2")
private String id2;

// equals, hashCode
}

(사용하는 쪽에서 @EmbeddedId로 사용하므로 @Id를 사용할 필요없고, 복합키이므로 자동생성을 사용할 수 없다)
@IdClass 처럼 정보 제공 용도로 사용하지 않고 직접 엔티티에서 사용해버렸다.
매핑 정보도 ParentId 클래스에 들어감으로써 키를 명확히 하나의 클래스로 분리한 느낌이난다. 좀 더 객체지향적인 방법이다.(id1에 접근하고자 할 경우 entity.getId().getId1()처럼, 언뜻보기에 좀 이상한 접근법이 사용되긴 하지만)

@EmbeddedId를 사용한 식별자 클래스는 아래 조건을 만족해야 한다.

  • @Embeddable 어노테이션을 붙여주어야 함
  • Serializable 인터페이스 구현해야 함
  • equals, hashCode 구현해야함
  • 기본 생성자 필요
  • 식별자 클래스는 public 이어야 함

실제 사용은 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// save
public void save(){
Parent parent = Parent.builder()
.id(new ParentId("id1", "id2"))
.build();

em.persist(parent);
}

// select
public void select()}{
ParentId parentId = new ParentId("id1", "id2");
Parent foundParent = em.find(Parent.class, parentId);
}

의문 : @EmbeddedId 사용시 JPQL에서 id.id1 의 형태로 접근해야하는데, delegate 메서드를 활용할 순 없나?
결과 : 엔티티의 대상이 필드이기 때문에 delegate 메서드로는 불가능하다 (대상이 아니기때문)

복합키의 equals, hashCode

위의 복합키 조건을 보면 equals와 hashCode를 필수로 구현해줘야 한다고 하는데,
이는 JPA는 영속성 컨텍스트에 엔티티를 보관할 때 엔티티의 식별자를 키로 사용하고,
식별자를 구분하기 위해 equals와 hashCode를 사용해서 동등성 비교를 하기 때문이다.

이게 단일 식별자일 경우에는 자바의 기본 타입을 사용하므로 별 문제없이 동등성이 보장되지만,
복합 식별자일 경우에는 클래스를 사용하므로 equals와 hashCode를 구현해주지 않으면 동등성을 보장할 수 없다.

1
2
3
4
ParentId id1 = new ParentId("id1", "id2");
ParentId id2 = new Parentid("id1", "id2");

assertTrue(id1.equas(id2)); // fail

같은 id 값을 가졌지만, 동등하지 않은 것이 된다.
java는 equals, hashCode를 오버라이드 하지 않으면 기본적으로 Object의 것을 사용하기 때문이다.
기본적으로 Object의 equals는 동일성 비교(==)를 하기 때문에 위의 두 키는 동등하지 않은 것이 된다.

JPA는 엔티티의 식별자를 가지고 영속성 컨텍스트를 관리하기 때문에
식별자의 동등성이 지켜지지 않으면 예상과 다른 엔티티가 조회되거나 엔티티를 찾을 수 없는 등 심각한 문제가 발생할 수 있다.
그러므로 equals와 hashCode는 필수로 구현해줘야 한다.

식별/비식별 관계에서 복합키 사용

식별 관계와 비식별 관계

식별관계

부모 테이블의 기본키를 내려받아서 자식 테이블의 기본키 + 외래키로 사용하는 관계이다.

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE parent(
parent_id integer,
PRIMARY KEY(parent_id)
)

CREATE TABLE child(
parent_id integer,
child_id integer,
PRIMARY KEY(parent_id, child_id),
FOREIGN KEY(parent_id) REFERENCES parent(parent_id)
)

비식별 관계

부모 테이블의 기본키를 내려받아서 자식 테이블의 외래키로만 사용하는 관계이다.
요즘은 비식별 관계를 주로 사용하고, 필요할 때만 식별 관계를 사용하는 추세이다.

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE parent(
parent_id integer,
PRIMARY KEY(parent_id)
)

CREATE TABLE child(
parent_id integer,
child_id integer,
PRIMARY KEY(child_id),
FOREIGN KEY(parent_id) REFERENCES parent(parent_id)
)
  1. 필수적 비식별 관계 : FK NOT NULL(INNER JOIN 사용됨)
  2. 선택적 비식별 관계 : FK NULLALBE(OUTER JOIN 사용됨)

식별 관계 매핑

부모, 자식, 손자까지 계속 기본키를 전달하는 식별관계이다.
식별관계는 부모의 키를 포함해 복합키를 구성해야 하므로 @IdClass나 @EmbeddedId를 사용해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CREATE TABLE parent(
parent_id integer,
PRIMARY KEY(parent_id)
)

CREATE TABLE child(
parent_id integer,
child_id integer,
PRIMARY KEY(parent_id, child_id),
FOREIGN KEY(parent_id) REFERENCES parent(parent_id)
)

CREATE TABLE grandchild(
parent_id integer,
child_id integer,
grandchild_id integer,
PRIMARY KEY(parent_id, child_id, grandchild_id),
FOREIGN KEY(parent_id) REFERENCES parent(parent_id),
FOREIGN KEY(child_id) REFERENCES child(child_id)
)

@IdClass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Entity
public class Parent{
@Id
private String parentId;
}

@Entity
@IdClass(ChildId.class)
public class Child{
// 매핑 정보 나열
@Id
@ManyToOne
@JoinColumn(name = "parent_id")
private Parent parent;

@Id
private String childId;
}

@EqualsAndHashCode
public class ChildId implements Serializable{
private String parent; // Child.parent 에 대한 정보 제공
private String childId; // Child.childId 에 대한 정보 제공
}

@Entity
@IdClass(GrandChildId.class)
public class GrandChild {
@Id
@ManyToOne
@JoinColumns({
@JoinColumn(name = "parent_id"),
@JoinColumn(name = "child_id")
})
private Child child;

@Id
private String grandChildId;
}

@EqualsAndHashCode
public class GrandChildId implements Serializable {
private ChildId child; // GrandChild.child 에 대한 정보 제공
private String grandChildId; // GrandChild.grandChildId 에 대한 정보 제공
}

@IdClass가 pk에 매핑되는 애들에게 정보를 바로 제공하고 있다.

@EmbeddedId

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Entity
public class Parent {
@Id
private String parentId;
}

@Entity
public class Child {
@EmbeddedId
private ChildId childId;

@MapsId("parentId")
@ManyToOne
@JoinColumn(name = "parent_id")
private Parent parent;
}

@EqualsAndHashCode
@Embeddable
public class ChildId implements Serializable {
private String parentId; // @MapsId("paretnId") 로 매핑
private String childId;
}

@Entity
public class GrandChild {
@EmbeddedId
private GrandChildId grandChildId;

@MapsId("childId")
@ManyToOne
@JoinColumns({
@JoinColumn(name = "parent_id"),
@JoinColumn(name = "child_id")
})
private Child child;
}

@EqualsAndHashCode
@Embeddable
public class GrandChildId implements Serializable {
private ChildId childId; // @MapsId("childId") 로 매핑
private String grandChildId;
}

id들을 따로 묶고 @MapsId를 통해 연관관계와 id를 연결했다.
(@IdClass에서 id들을 class로 모으는 과정이 추가된 형태라고 봐도 될듯하다.)

@mapsid는 @id로 지정한 컬럼에 @OnetoOne이나 @ManyToOne 관계를 매핑시키는 역할을 한다.
http://docs.jboss.org/hibernate/jpa/2.2/api/javax/persistence/MapsId.html
매핑의 대상이 되는 속성은 @OnetoOne이나 @ManyToOne의 기본키와 타입이 같아야한다.

※ 번외로 아래와 같이 세팅 할수도 있는데, 이는 잘못된 방식이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Entity
public class Parent {
@Id
private String parentId;
}

@Entity
public class Child {
@EmbeddedId
private ChildId childId;
}

@EqualsAndHashCode
@Embeddable
public class ChildId implements Serializable {
@ManyToOne
@JoinColumn(name = "parent_id")
private Parent parent;

private String childId;
}

@Entity
public class GrandChild {
@EmbeddedId
private GrandChildId grandChildId;
}

@EqualsAndHashCode
@Embeddable
public class GrandChildId implements Serializable {
@ManyToOne
@JoinColumns({
@JoinColumn(name = "parent_id"),
@JoinColumn(name = "child_id")
})
private Child child;

private String grandChildId;
}

얼핏보면 more 객체지향스럽긴 하지만,
연관관계를 항상 id를 통해 접근하는 이상한 방식이 탄생하게 되고,
@Embeddable 에서 연관관계까지 equals, hashCode의 대상이 되는 이상한 구조가 탄생한다.
id는 id대로 놔둬야 한다.

일대일 식별 관계(feat.@MapsId)

일대일 식별 관계는 자식 테이블의 기본키 값으로 부모 테이블의 기본키 값을 사용하는 조금 특별한 관계이다.
이 경우 연관관계의 주인이 될 외래키 칼럼이 없으므로 @MapsId를 사용하여 매핑해줘야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Entity
public class Board{
@Id
private Long boardId;

private String title;

@OneToOne(mappedBy = "board")
private BoardDetail boardDetail;
}

@Entity
public class BoardDetail{
@Id
private Long boardId;

@Lob
private String content;

@MapsId("boardId")
@OneToOne
@JoinColumn(name = "board_id")
private Board board;
}

board.getBoardDetail().getContent()로 접근해야해서 사용이 조금 자연스럽지 않게 느껴지지만, @Delegate 같은것으로 충분히 해결할 수 있다.

비식별 관계 매핑

비식별 관계는 복합키를 사용하지 않기 때문에 아주 심플하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Entity
public class Parent {
@Id
private String parentId;
}

@Entity
public class Child {
@Id
private String childId;

@ManyToOne
@JoinColumn(name = "parent_id")
private Parent parent;
}

@Entity
public class GrandChild {
@Id
private String grandChildId;

@ManyToOne
@JoinColumn(name = "child_id")
private Child child;
}

그래서 식별이냐 비식별이냐?

데이터베이스 설계관점에서 보면, 아래와 같은 이유로 비식별 관계를 선호한다.

  • 식별 관계는 부모 테이블의 기본키를 자식 테이블로 전파하면서 자식 테이블의 기본키 컬럼이 점점 늘어나는 구조이다.
    depth가 깊어질수록 기본키 인덱스가 불필요하게 커지고, 조인할 때 SQL이 복잡해진다.
  • 식별 관계는 2개 이상의 컬럼을 묶어서 복합 기본키를 만들어야 하는 경우가 많다.
    복합 기본키는 컬럼이 하나인 단일 기본키보다 작성하는데 많은 노력이 필요하다.
  • 식별 관계의 경우 기본키로 비즈니스 로직이 있는 자연키 컬럼을 조합하는 경우가 많고,
    비식별 관계의 경우 기본키로 비즈니스와 전혀 관계없는 대리키를 주로 사용한다.
    변하지 않는 요구사항이란 세상에 존재하지 않는다. 자연키 컬럼 조합은 나중에 변경될 가능성이 있다.
    이런 상태에서 식별 관계로 구성할 경우 나중에 변경하기 매우 힘들어진다.

e.g. 주민등록번호

  • 언급했듯이 비식별 관계의 경우 대리키를 주로 사용하는데, JPA는 @GeneratedValue 처럼 대리키를 생성하기 위한 편리한 방법을 제공한다.

그래서 정리하면!

  • 될수있으면 비식별 관계를 사용하고
  • 기본키는 Long 타입의 대리키를 사용히고
  • 필수적 비식별 관계를 사용하자(optional = false)

조인 테이블

데이터베이스의 테이블의 연관관계를 설정하는 방법은 총 2가지이다.

  • 조인 컬럼

일반적인 외래키 컬럼을 사용하여 연관관계를 관리하는 것

  • 조인 테이블

별도의 테이블을 사용하여 연관관계를 관리하는 것

조인 테이블의 경우 테이블을 하나 추가해야 된다는 단점이 있다.(추가 조인 필요)
그러므로 기본적으로 조인 컬럼을 사용하고, 필요할 때만 조인 테이블을 사용하도록 해야한다.

조인 테이블 == 연결 테이블 == 링크 테이블

하나의 테이블이 여러 테이블과 관계를 맺을 수 있는 구조라던가,
원래 관계가 없었는데 관계가 생겼다거나(FK를 일괄 추가하기에는 너무 부담스럽),
관계 변경(update) 때문에 메인 테이블에 락이 걸리는 걸 방지하기 위해(연결 테이블만 컨트롤 하므로써 성능향상) 사용하는 등 여러 상황에서 사용될 수 있을 것이다.

일대일 조인테이블

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Entity
public class A {
@Id
private String id;

@OneToOne(optional = false)
@JoinTable(name = "a_b",
joinColumns = @JoinColumn(name = "a_id"),
inverseJoinColumns = @JoinColumn(name = "b_id"))
private B b;
}

@Entity
public class B {
@Id
private String id;

@OneToOne(mappedBy = "b") // optional
private A a;
}

생성되는 DDL은 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CREATE TABLE A(
id varchar(255) not null,
primary key (id)
)

create table B (
id varchar(255) not null,
primary key (id)
)

create table a_b (
b_id varchar(255) not null,
a_id varchar(255) not null,
primary key (a_id),
FOREIGN KEY(a_id) REFERENCES A(a_id),
FOREIGN KEY(b_id) REFERENCES B(b_id)
)

alter table a_b
add constraint UK_pam4mvekk45ceoippm3ffvi2t unique (b_id)

a_id가 primary key, b_id에 unique constraints가 걸리면서 1:1 관계가 형성된다.

다대일 조인테이블

B를 다로 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Entity
public class B {
@Id
private String id;

@ManyToOne
@JoinTable(name = "a_b",
joinColumns = @JoinColumn(name = "a_id"),
inverseJoinColumns = @JoinColumn(name = "b_id"))
private A a;
}

@Entity
public class A {
@Id
private String id;

@OneToMany(mappedBy = "a") // optional
private List<B> bList;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CREATE TABLE A(
id varchar(255) not null,
primary key (id)
)

create table B (
id varchar(255) not null,
primary key (id)
)

CREATE TABLE a_b(
a_id varchar(255) not null,
b_id varchar(255) not null,
PRIMARY KEY(b_id),
FOREIGN KEY(a_id) REFERENCES A(a_id),
FOREIGN KEY(b_id) REFERENCES B(b_id)
)

다 쪽이 primary key로 생성됨으로써 다대일 관계 형성이 가능하다.

일대다 조인테이블

일대다 조인컬럼처럼 일쪽에서 연관관계를 컨트롤 하고 싶을 경우 형성하는 방법이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity
public class A {
@Id
private String id;

@OneToMany
@JoinTable(name = "a_b",
joinColumns = @JoinColumn(name = "a_id"),
inverseJoinColumns = @JoinColumn(name = "b_id"))
private List<B> bList;
}

@Entity
public class B {
@Id
private String id;
}

일대다 조인컬럼때와 같이 단방향만을 지원한다.

아래는 생성되는 DDL이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CREATE TABLE A(
id varchar(255) not null,
primary key (id)
)

create table B (
id varchar(255) not null,
primary key (id)
)

CREATE TABLE a_b(
a_id varchar(255) not null,
b_id varchar(255) not null,
FOREIGN KEY(a_id) REFERENCES A(a_id),
FOREIGN KEY(b_id) REFERENCES B(b_id)
)

alter table a_b
add constraint UK_pam4mvekk45ceoippm3ffvi2t unique (b_id)

pk 대신 unique로 생성되는게 조금 다르다.

다대다 조인테이블

앞서 나왔으므로 작성하지 않겠다.
parent_id, child_id 에 각각 FK가 생성되고, PK로 묶이지는 않는다.

엔티티 하나에 여러 테이블 매핑

아까 위의 일대일 식별 관계에서 나왔었던 형태이다.

board와 board_detail을 나눠서 저장하고, 같은 PK를 쓰는 형태

자주 사용하는 형태는 아니지만 가끔 나오기도 한다.

1
2
3
4
5
6
7
8
9
10
11
12
@Entity
@SecondaryTable(name = "board_detail", // 1
pkJoinColumns = @PrimaryKeyJoinColumn(name = "board_detail_id")) // 2
public class Board{
@Id
private Long boardId;

private String title;

@Column(table = "board_detail") // 3
private String content;
}

@SecondaryTable을 사용해 board_detail 테이블을 추가로 매핑했다.

  1. 추가로 매핑할 테이블의 이름이다.
  2. 추가로 매핑된 테이블의 기본키 컬럼명이다.
  3. 추가로 매핑된 테이블에 저장될 속성이다.
1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE board (
board_id BIGINT NOT NULL,
title VARCHAR(255),
PRIMARY KEY (board_id)
)

CREATE TABLE board_detail (
board_detail_id BIGINT NOT NULL,
content VARCHAR(255),
PRIMARY KEY (board_detail_id)
)

위의 일대일 식별관계와 반대로, board.getContent()로 접근할 수 있어 사용은 자연스럽다.
하지만 이 방식의 경우, 매핑이 부자연스럽다.(lazy loading도 안된다)
결과적으로 사용의 자연스러움 보다는 매핑의 자연스러움을 추구하는 것이 맞는것 같다. 그니까… 이거 어디서 쓸일은 없을꺼 같음…ㅋ

Read more »
1…111213…19

JunYoung Park

182 posts
18 categories
344 tags
RSS
© 2020 JunYoung Park
Powered by Hexo v3.6.0
|
Theme – NexT.Muse v7.1.0