도입
제가 진행했던 프로젝트에서의 Entity 설계를 바탕으로 글을 남겨봅니다.
이 글에서는 제가 Entity 설계에서 상속을 이용해서 생긴 문제와 해당 문제를 위임으로 해결한 과정을 살펴보겠습니다.
상속관계로 설계
해당 프로젝트는 아티스트 지원형 웹 플랫폼으로써, 모든 사용자는 처음에 일반 사용자가 됩니다.
만약, 아티스트로 가입하고 싶다면 일반 사용자로 가입을 하고 나서 이후에 아티스트로 전환을 해야 합니다.
DB ERDiagram은 다음과 같습니다.
그림에서 볼 수 있듯이, artist가 member를 상속하고 있는 형태의 구조입니다.
artist는 유저의 PK를 그대로 PK로 사용하고 있죠.
그래서 저는 처음에 상속이라는 키워드에 주목해서, Entity역시 상속관계로 Entity를 설계했습니다.
초기 상속으로 설계한 Entity 코드는 아래와 같습니다.
▸ Member
import lombok.*;
import lombok.experimental.SuperBuilder;
import javax.persistence.*;
@Entity
@Getter @Setter @ToString
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn
@SuperBuilder
@AllArgsConstructor @NoArgsConstructor
public class Member {
@Id @GeneratedValue
@Column(name="member_id")
private Long id;
private String userName;
}
▸ Artist
import lombok.*;
import lombok.experimental.SuperBuilder;
import javax.persistence.*;
@Entity
@Getter @Setter @SuperBuilder
@DiscriminatorValue("Artist")
@AllArgsConstructor @NoArgsConstructor
public class Artist extends Member{
@Column(name="artist_name")
private String artistName;
}
- 상속전략은 JOINED를 사용하였습니다.
- 상속관계가 있을 때는 @Builder가 아니라 @SuperBuilder를 사용해주어야 Builder패턴이 동작합니다. ( 아니면 컴파일 에러가 납니다. )
- @DiscriminatorColumn 을 부모에 작성해주면 부모 테이블에 DTYPE이 생성됩니다. 자식에서는 @DiscriminatorValue로 자신이 어떤 타입인지 명시해줄 수 있습니다.
위의 엔티티 설계를 바탕으로 아래와 같이 테스트 코드를 작성해서 테스트 해보면 여러 문제가 발생합니다.
일단 코드를 먼저 봐보겠습니다.
package com.after.bdot.repository;
import com.after.bdot.entity.Artist;
import com.after.bdot.entity.Member;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;
import javax.transaction.Transactional;
import java.util.Optional;
@SpringBootTest
@Rollback(false)
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@Autowired
private ArtistRepository artistRepository;
@Test
@Transactional
@Rollback(false)
public void registerMember(){
Member userB = new Member();
userB.setUserName("userB");
memberRepository.save(userB);
}
@Test
@Transactional
public void registerArtist(){
Optional<Member> userBOp = memberRepository.findByUserName("userB");
Member userB = userBOp.get();
Optional<Member> memberBOptional = memberRepository.findById(userB.getId());
memberBOptional.ifPresent(member -> {
Member registerMember = Member.builder()
.userName(member.getUserName())
.build();
memberRepository.delete(member);
Artist newArtist = Artist.builder()
.userName(registerMember.getUserName())
.artistName("artistA")
.build();
artistRepository.save(newArtist);
});
}
}
- registerMember()에서는 member를 등록해줍니다.
- registerArtist()에서는 userB를 아티스트로 등록을 해주려고 합니다.
이 때, registerArtist()에서 문제가 발생합니다.
위의 로직에서는
artist를 등록하기 위해서 기존에 있던 member를 삭제하고 새로운 member를 생성해서 artist를 등록합니다.
그런데, member를 삭제하고 새로운 member를 생성하는 과정에서 member의 index 값이 증가하게 됩니다.
이는 member와 연관관계를 맺고 있는 테이블을 모두 찾아서 FK의 값을 수정해주어야 한다는 의미입니다. 속도상으로도 좋지 않고, 유지보수상으로도 좋지 않겠죠.
그런데 이보다 더 치명적인 문제가 있습니다.
바로, Artist가 Entity임에도 불구하고 PK가 없다는 것입니다.
public class Artist extends Member{
@Column(name="artist_name")
private String artistName;
}
Artist는 Member의 PK값을 이어받지만, 독자적인 PK가 없으므로 다른 테이블과 연관관계를 맺기가 상당히 까다롭습니다.
이는 유지보수도 매우 힘들어짐을 의미하겠죠.
그래서 오랜 기간 고민하다가, 수강 중인 강의의 강사님이셨던 김영한님에게 질문을 드렸습니다.
그리고 강사님의 힌트를 얻어서 문제를 해결할 수 있게 됐습니다.
(질문 링크 : https://www.inflearn.com/questions/85190)
상속보다 위임
강사님께서는 상속보다 위임을 사용하라고 조언해 주셨습니다. 상속은 실무에서 사용할 일이 거의 없다는 말도 덧붙여주셨죠.
위임은 '어떤 일의 책임을 다른 클래스 또는 메소드에게 넘김' 이라는 의미입니다.
이는 Member와 Artist를 각각 독립적인 Entity 분리해서 각자의 일은 각자가 처리하게 만드는 것을 의미합니다.
위의 상속관계에서는 Aritst만 등록해도 Member가 알아서 등록됐지만, 위임을 이용해서 설계했을 때는 Member와 Artist가 각각 다른 Entity이기 때문에 따로따로 등록을 해주어야 합니다.
즉, Aritst가 Member를 상속받는 관계가 아닌 독립적인 Entity로 승격해주어서 Entity 관계를 풀어나가야함을 의미합니다.
아래는 위임을 이용해서 재설계한 Entity입니다.
▸ Member
import lombok.*;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
@Entity
@Getter @Setter @ToString
@Builder @AllArgsConstructor @NoArgsConstructor
public class Member {
@Id @GeneratedValue
@Column(name="member_id")
private Long id;
private String userName;
}
▸ Artist
import lombok.*;
import javax.persistence.*;
@Entity @Getter @Setter
@Builder @AllArgsConstructor @NoArgsConstructor
public class Artist {
@Id @Column(name = "artist_id")
private Long id;
@Column(name="artist_name")
private String artistName;
@MapsId
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name="artist_id",referencedColumnName = "user_id")
private Member member;
}
- Artist를 Entity로 승격시키고, Artist와 Member의 관계를 @OneToOne 관계로 연결하였습니다.
▸ @MapsId
It can be used also to share the same primary key between 2 tables.
MapsId lets you use the same primary key between two different entities/tables.
- @MapsId는 다른 두 개의 테이블이 같은 PK를 공유하도록 연결해주는 annotation입니다.
▸ referencedColumnName
Artist와 Member를 연결할 때, 아래와 같이 연결해야하는 거 아닌가 생각할 수 있습니다.
public class Artist {
@Id @Column(name = "artist_id")
private Long id;
@Column(name="artist_name")
private String artistName;
@MapsId
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name="user_id")
private Member member;
}
그런데 위와 같이 연결하고 코드를 실행해보면 아래와 같이 에러가 생깁니다.
- Field ~ doesn't have a default value
그 이유는 DB를 들여다보면 알 수 있습니다.
user_id가 FK로 생겨버렸습니다.
( ddl-auto option을 create로 주어서 실행하면 Entity Mapping에 따라 db 테이블이 알아서 생성됩니다 )
즉, user_id에는 값이 들어갔지만, artist_id에는 값이 들어가지 않아서 생기는 오류입니다.
- @Column ( name = " " )
@Column(name = " " )은 FK 이름입니다. 이 때, referencedColumnName이 자동으로 지정되는데 referencedColumnName은 기본값으로 해당 테이블의 PK로 지정됩니다.
이 글의 상황처럼 user_id를 FK이자 PK로 지정하고 싶다면, FK의 이름을 artist_id로 주고, referencedColumnName은 user_id로 주면 되겠죠.
즉, user_id를 PK이자 FK로 설정하려면 아래와 같이 설정하면 됩니다.
@MapsId
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name="artist_id",referencedColumnName = "user_id")
private Member member;
위와 같이 Entity 연결을 설정하고 테스트 코드를 돌려보면 아래와 같이 연결이 성공적으로 이루어졌음을 확인할 수 있습니다.
▸ MemberResitoryTest
package com.after.bdot.repository;
import com.after.bdot.entity.Artist;
import com.after.bdot.entity.Member;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;
import javax.transaction.Transactional;
import java.util.Optional;
@SpringBootTest
@Rollback(false)
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@Autowired
private ArtistRepository artistRepository;
@Test
@Transactional
@Rollback(false)
public void registerMember(){
Member userB = Member.builder()
.userName("memberB")
.build();
memberRepository.save(userB);
}
@Test
@Transactional
public void registerArtist(){
Optional<Member> userBOp = memberRepository.findByUserName("memberB");
Member userB = userBOp.get();
Optional<Member> memberBOptional = memberRepository.findById(userB.getId());
memberBOptional.ifPresent(member -> {
Artist newArtist = Artist.builder()
.member(member)
.artistName("artistB")
.build();
artistRepository.save(newArtist);
});
}
}
cf) ddl-auto : create -> update로 바꿔서 실행했습니다.
마무리
위 Entity 설계때문에 1-2주 가량을 헤맸었습니다.
그래도 어찌저찌 잘 해냈을 때는 정말 뿌듯했었죠. ( 물론 뒤에 다시 엄청난 기간동안 헤매는 기간이 있었지만 )
교훈으로는 역시 기본기라고 할 수 있는 해당 프레임워크에 대한 지식이 정말 중요하다고 생각이 들었네요.
긴글봐주셔서 감사하고, 다음 글로 찾아뵙겠습니다!

참조링크
위임
https://zetawiki.com/wiki/%EC%9C%84%EC%9E%84_%ED%8C%A8%ED%84%B4
field ~ doesnt' have a default value
https://wickedmagic.tistory.com/534
@MapsId
https://stackoverflow.com/questions/9923643/can-someone-please-explain-me-mapsid-in-hibernate
@JoinColumn ( name = " ") , referencedColumnName
https://www.inflearn.com/questions/113969
referencedColumnName
https://hanke-r.tistory.com/135
https://stackoverflow.com/questions/11244569/what-is-referencedcolumnname-used-for-in-jpa
'Dev > 프로젝트 관련 정리' 카테고리의 다른 글
[ssayeon] jenkins 배포시 jenkins내 docker daemon이 실행되지 않는 문제 (0) | 2022.05.04 |
---|---|
Invalid Host Header 문제 : react 배포 시의 문제 (0) | 2022.03.15 |
댓글