엔티티 설계

ttomy·2022년 7월 26일
0

intro

최근 프로젝트를 하면서 엔티티 설계의 복잡함을 다시 한 번 깨닫게 되었다.아직 spring에서의 엔티티가 db에서 어떻게 구현되는지 잘 알지 못하고 조회가 어떤 식으로 되며, 양방향 연결관계에서 유의할 점 ,순환참조, 각 연결관계의 장단점 등은 무엇인지 등이 잘 숙지가 안되있다는 느낌을 받았다. 아래를 목표로 학습하고자 한다.

  • 연관관계들의 요건,장단점을 파악해 선택해서 사용
  • 양방향 관계에서 순환참조, 편의메소드의 무한루프 등을 고려해 설계
  • lazy/eager로딩 ,영속성전이(casacade)를 고려해 조회로직 설계

연관관계

다대일,일대다 연관관계

단방향

단방향의 경우는 상대적으로 고려할 것이 적다.
team에 member들이 속해있는 경우를 생각해보자.
아래처럼 member들이 team을 알거나 team이 member들을 알거나 2가지 선택이 있다.

  • 1)

  • 2)

이 중 일반적으로 FK가 다(여러 개)인 쪽에 가야 객체가 생성될 때마다 추가적인 update문을 실행하지 않을 수 있다.
(db는 다(여러 개)인 쪽에 FK가 있도록 구현되므로 User에서 List< member >에 변동이 생기면 해당 member를 찾아간 다음 member_id를 바꿔야 하는 비효율이 생긴다.)

때문에 웬만하면 @ManytoOne으로 매핑하되, 서비스로직 상 one인 쪽이 알기만하면 되는 경우에만 @OnetoMany의 사용을 고려하면 되겠다.

일대다 유의점

아래의 예시처럼 @joincolumn을 지정해주어야 member_team과 같은 중간테이블이 생기지 않고 id를 FK로 매핑한다.
이런 설정을 하지 않는다면 hibernate는 기본적으로 중간테이블을 생성해 onetoMany를 매핑한다.

@Entity
@Getter
@Setter
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

}
@Entity
@Getter
@Setter
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;
    private String name;

    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    private List<Member> members = new ArrayList<>();
}

다대다

우선 spring에서는 @ManytoMany라는 어노테이션을 지원하기는 하지만 관계형DB에서는 이것이 중간테이블을 두는 방식으로 구현된다.

@JoinTable이라는 어노테이션으로 중간테이블을 지정할 수는 있지만, 객체는 그렇게 만들어져 있지 않다.
이렇게 엔티티와 db테이블의 괴리가 생기면 spring에서 데이터를 추가,수정하기 힘들고 관리가 어렵다.

때문에 다대다가 필요한 상황이라면 중간테이블을 직접 엔티티로 등록하여 @ManytoOne, @OnetoMany 관계로 나누는 것이 좋은방법이다.

  • ex)
@Entity
public class Member {    
@id
private Long member_id;
...  
@OneToMany(mappedBy = "member")    
private List<MemberProduct> memberProducts = new ArrayList<>();
...
}
@Entity
public class Product {
@Id
private Long product_id;
...
@OneToMany(mappedBy = "product")    
private List<MemberProduct> members = new ArrayList<>();
...
}
@Entity
@Getter
@Setter
public class MemberProduct {

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

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

@ManyToOne    
@JoinColumn(name = "product_id")    
private Product product;}

여기서 중간테이블의 PK는 member_id와 product_id의 복합키로 하는 것도 가능은 하지만, 중간테이블의 PK(id)를 따로 만드는 것이 좋다. 운영중에 중간테이블에 추가적인 데이터가 들어가야 하는 상황이 올 수도 있고 두 개의 테이블에 종속적이지 않은 더 유연한 확장이 가능해진다.

양방향 연관관계


서비스에서 양방향이 필요한 경우가 생긴다. 위 그림처럼 academy도 어떤 subject가 있는지 알아야하고, subject도 자신이 속한 acadedemy를 알아야 하는 경우가 있다.

객체와 다르게 DB에서는 외래키를 한 쪽만 가지면서 양방향의 참조가 가능하다(join). 때문에 외래키를 관리하는, 연관관계의 주인을 정해야 한다. 연관관계의 주인이 아닌쪽에서 객체의 데이터를 변경해도 DB에서는 그것이 반영되지 않는다.

이런 괴리가 발생하지 않도록 양방향 연관관계인 객체는 변경에 대해 견고한 로직이 있어야 한다. 편의메소드(양쪽 객체를 한번에 전부 변경하는 메소드)를 작성해 양방향 중 주인이 아닌 객체에서 변경이 일어나더라도 두 객체를 변경하게 할 수 있지만 이는 번거로운 일이다.

이때 로직에 실수가 있다면 객체가 의도치 않게 변경되거나 순환참조가 일어나는 사고가 발생한다. 때문에 양방향은 꼭 필요하지 않으면 지양하는 것이 낫다.

ex)

@Entity
@Getter
@NoArgsConstructor
public class Academy {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(cascade = CascadeType.ALL)
    @JoinColumn(name="academy_id")
    private List<Subject> subjects = new ArrayList<>();

    @Builder
    public Academy(String name, List<Subject> subjects) {
        this.name = name;
        if(subjects != null){
            this.subjects = subjects;
        }
    }

    public void addSubject(Subject subject){
        this.subjects.add(subject);
        subject.updateAcademy(this);
    }
}
  • 위 Academy의 addSubject() 메소드에서 객체본인의 subjects리스트도 변경하지만 subject엔티티의 updateAcademy()도 실행한다. Academy는 연관관계의 주인이 아니기에 subject의 academy를 변경해야 db에 반영이 되기 때문이다.
@Entity
@Getter
@NoArgsConstructor
public class Subject {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "academy_id", foreignKey = @ForeignKey(name = "FK_SUBJECT_ACADEMY"))
    private Academy academy;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "teacher_id", foreignKey = @ForeignKey(name = "FK_SUBJECT_TEACHER"))
    private Teacher teacher;

    @Builder
    public Subject(String name, Academy academy, Teacher teacher) {
        this.name = name;
        this.academy = academy;
        this.teacher = teacher;
    }

	//편의 메소드를  위해 만든 것으로 사용에 주의
    public void updateAcademy(Academy academy){
        this.academy = academy;
    }
}

subject는 연관관계의 주인이기에 academy만 변경해도 db에는 맞게 저장이 된다.

그런데 이런 의문을 제기할 수도 있다.
객체와 db의 괴리를 없애기 위해서는 Subject클래스의 updateAcademy()에도 추가된 Academy의 subjects에 subject을 추가하는 것이 더 좋지 않을까? 즉 편의메소드를 객체 양쪽에 만들어놓는게 편하지 않을까 하는 생각이 들 수 있다. 아래를 보자.

출처: https://jojoldu.tistory.com/165

편의메소드의 루프

위의 예에서 편의메소드를 이런식으로 작성했다면 메소드는 무한루프에 빠질 것이다.


//Academy 클래스
    public void addSubject(Subject subject){
        this.subjects.add(subject);
        subject.updateAcademy(this);
    }
    
//subject 클래스
   public void updateAcademy(Academy academy){
        this.academy = academy;
        academy.addSubject(this);
        
    }

아래와 같이 이미 반영이 되었는지 확인하는 if문을 추가해야 루프에 빠지지 않는다.


//Academy 클래스
    public void addSubject(Subject subject){
        this.subjects.add(subject);
        if(subject.getAcademy()!=this)
        	subject.updateAcademy(this);
    }
    
//subject 클래스
   public void updateAcademy(Academy academy){
        this.academy = academy;
        if(!academy.getSubjects().contains(this))
        	academy.addSubject(this);
        
    }

user와 checkingOutInfo라는 엔티티가 양방향 관계를 맺고 다쪽인 checkingOutInfo가 연관관계의 주인이라 하자.아래와 같이 편의메소드를 작성해볼 수 있다.


참조: jehpark.log

하지만 매번 편의메소드마다 이런 확인을 하는 것은 번거롭고 실수할 경우 루프에 빠지는 가능성이 있다. 때문에 애초에 편의메소드를 엔티티의 한 쪽에만 선언해놓는 것을 권장하기도 한다.

편의 메소드의 위치?
위의 글에서 "그러면 편의메소드는 두 개의 엔티티 중 어디에 위치하는게 맞는 걸까?" 하는 의문이 들 수 있다. 이에 대한 김영한님의 답변은 "정답은 없지만 비즈니스 로직 상 편한 쪽에 두는 게 좋다" 이다.
위의 Academy-Subject의 예에서는 서비스 상 Academy를 통해 subject가 추가될 일이 많다고 판단해 Academy에 편의메소드가 선언된 경우라 생각할 수 있다.

참고: https://www.inflearn.com/questions/16308

순환참조?

엔티티가 클라이언트에 그대로 노출되어 반환되면 순환참조가 일어날 수 있다. spring은 엔티티를 http로 반환할 떄 json형식으로 직렬화를 할 때 jackson라이브러리가 실행된다.

이 때 만약 team,member라는 서로 양방향인 엔티티가 직렬화된다면. team을 직렬화하기 위해 그 안의 memeber를 직렬화 하려하고, 이 떄 member 안의 team을 직렬화 하려하고, 이떄 다시 member를 직렬화하려 하는 순환에 빠지게 된다.

team>member>team>member ...

이를 막기 위해서는 @JsonIgnore같은 어노테이션을 사용할 수도 있겠지만 DTO를 사용해 필요한 만큼만 클라이언트에게 노출시킨다면 해결되는 문제이다. DTO는 엔티티처럼 서로 이어지는 연관관계가 없기에 직렬화의 루프에 빠지지 않는다. DTO를 사용해야 하는 또 하나의 이유이다.

리팩토링

Reference

JAVA ORM표준 JPA 프로그래밍
https://ssoco.tistory.com/96
https://ict-nroo.tistory.com/127
velog jwkim.log블로그
https://jojoldu.tistory.com/165

0개의 댓글