JPA 연관관계 편의 메서드의 위치에 대한 내 생각

kindof

·

2023. 3. 15. 22:25

1. 문제

JPA에서 연관관계 편의 메서드는 양방향 연관관계를 한 번에 설정하기 위해 작성하는 메서드 입니다.
이 때, 통상적으로 엔티티 A(Many), B(One)가 서로 양방향 관계일 때 연관관계 편의 메서드는 총 3가지 케이스로 작성할 수 있습니다.
 

  • [1] A에 작성하고, B까지 관리한다.
  • [2] B에 작성하고, A까지 관리한다.
  • [3] A, B 각각에 대해 작성한다.

 
그런데 여기서 [3]번 방식은 둘 중의 한 곳에서라도 메서드 호출을 빼먹으면 논리적인 문제가 생기고 코드의 관리 측면도 복잡해져서 지양하는 패턴입니다.
 
그러면 [1], [2] 케이스 중에 어디에 연관관계 편의 메서드를 정의하는 게 좋을까요?
 
이번 글에서는 이 질문에 대한 제 생각을 기록해보려고 합니다.
 
 

2. 단순한 ManyToOne 양방향 관계

아래와 같이 두 엔티티가 있다고 해보겠습니다.
 

[Pin.java]

@Entity
@Table(name = "pin")
public class Pin extends BaseTimeEntity {

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

    @OneToMany(mappedBy = "pin", cascade = CascadeType.ALL, orphanRemoval = true)
    @Builder.Default
    private List<PinTag> pinTags = new ArrayList<>();

    ...
}

[Picture.java]

@Entity
@Table(name = "picture")
public class Picture extends BaseTimeEntity {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;
    
    @ManyToOne(fetch = FetchType.LAZY)
    private Pin pin;
}

여기서 연관관계 편의 메서드는 Pin 엔티티에 있어야 할까요? Picture 엔티티에 있어야 할까요?
 
사실 두 곳 어디에 둬도 상관 없습니다. 하지만, 저는 Pin 엔티티에 두는 쪽을 컨벤션으로 지향합니다.
 
그 이유는 비즈니스 로직 관점에서 Pin 엔티티가 Picture를 활용하지, Picture 엔티티가 Pin을 활용하지 않기 때문입니다. 실제로 코드를 작성하면 Pin과 관련된 코드들에서 Picture를 저장/업데이트/삭제하지, Picture 쪽에서는 Pin을 거의 손대지 않기 때문입니다.
 
뿐만 아니라, Cascade 옵션을 통해 부모가 자식의 생명주기를 관리한다는 측면을 생각해보면 Pin 입장에서 자신의 Picture 리스트를 관리하는 것이 그 의도를 분명히 드러낼 수 있기도 합니다.
 
따라서, 아래와 같이 Pin 엔티티에 연관관계 편의 메서드를 작성합니다.

@Entity
@Table(name = "pin")
public class Pin extends BaseTimeEntity {

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

    @OneToMany(mappedBy = "pin", cascade = CascadeType.ALL, orphanRemoval = true)
    @Builder.Default
    private List<PinTag> pinTags = new ArrayList<>();


    ####### 연관관계 편의 메서드 #######
    public void addPicture(Picture picture) {
        if(!getPictures().contains(picture)){
            getPictures().add(picture);
        }
        picture.setPin(this);
    }

    public void removePicture(Picture picture) {
        this.getPictures().remove(picture);
        picture.setPin(null);
    }
}

 

3. ManyToMany 관계에서는 어떻게 할까?

ManyToMany 관계를 풀어내기 위해서는 두 엔티티를 연결하는 연결 엔티티를 사용합니다.
 
아래 엔티티 관계는 한 유저가 여러 써클에 가입할 수 있고, 한 써클도 여러 유저를 갖을 수 있다는 요구사항에서 도출되었습니다.
 
그리고 Users - UserCircle, Circle - UserCircle에 대해 각각 양방향 맵핑을 적용했습니다.
 

[Users.java]

@Entity
@Table(name = "users")
public class Users extends BaseTimeEntity {

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

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    @Builder.Default
    private List<UserCircle> userCircleList = new ArrayList<>();
    
    ...
}

[Circle.java]

@Entity
@Table(name = "circle")
public class Circle extends BaseTimeEntity {

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

    @OneToMany(mappedBy = "circle", cascade = CascadeType.ALL, orphanRemoval = true)
    @Builder.Default
    private List<UserCircle> userCircleList = new ArrayList<>();

    ...
}

[UserCircle.java]

@Entity
@Table(name = "user_circle")
public class UserCircle extends BaseTimeEntity {

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

    @ManyToOne(fetch = FetchType.LAZY)
    private Users user;

    @ManyToOne(fetch = FetchType.LAZY)
    private Circle circle;
    
    ...
}

 
자, 그러면 이 상황에서도 Users, Circle 엔티티 쪽에 연관관계 편의 메서드를 작성하는 게 좋을까요?
 
제 생각은 '아니오'입니다. 이 경우에는 UserCircle 쪽에 연관관계 편의 메서드를 작성하는 게 더 좋다고 생각합니다.
 
그 이유는 아래 테스트 코드를 통해 살펴보겠습니다. Users, Circle에 연관관계 편의 메서드(addUserCircle)가 위치한다고 가정합니다.

테스트 코드 - 실패

이 테스트 코드는 실패합니다. Circle에 작성한 연관관계 편의 메서드를 호출했지만, Users 쪽에 작성한 연관관계 편의 메서드를 호출하지 않았기 때문입니다.
 
이 테스트 코드가 성공하기 위해서는 u1.addUserCircle(uc1); 이라는 메서드를 추가로 호출했어야 합니다.
 
이 상황은 맨 처음 도입부에서 말했던 양쪽에 연관관계 편의 메서드를 쓰는 [3]번 상황을 지양했던 이유와 비슷해집니다.
 
따라서 ManyToMany 관계를 풀어내기 위한 연결 엔티티에 세 개의 엔티티를 관리하는 연관관계 편의 메서드를 만드는 게 좋아 보입니다.

@Entity
@Table(name = "user_circle")
public class UserCircle extends BaseTimeEntity {

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

    @ManyToOne(fetch = FetchType.LAZY)
    private Users user;

    @ManyToOne(fetch = FetchType.LAZY)
    private Circle circle;
    
    ####### 연관관계 편의 메서드 #######
    public void setUserAndCircle(Users user, Circle circle) {
        addUserCircleForUser(user);
        addUserCircleForCircle(circle);
        setUser(user);
        setCircle(circle);
    }

    private void addUserCircleForUser(Users user) {
        if(!user.getUserCircleList().contains(this)){
            user.getUserCircleList().add(this);
        }
    }
    
    private void setUser(Users user) {
        this.user = user;
    }

    private void addUserCircleForCircle(Circle circle) {
        if(!circle.getUserCircleList().contains(this)){
            circle.getUserCircleList().add(this);
        }
    }
    private void setCircle(Circle circle) {
        this.circle = circle;
    }
    
}

 
그리고 아래와 같이 테스트 코드를 작성하면, 한 줄의 연관관계 편의 메서드 호출로 테스트가 성공합니다. 그리고 파라미터를 Users, Circle을 필수로 받도록 함으로써 컴파일 시점에 잘못된 로직 사용을 방지할 수 있게 됩니다.

테스트 코드 - 성공

 

4. 정리

지금까지 말한 내용을 정리하면 크게 두 가지입니다.

  • ManyToOne 관계의 두 엔티티가 양방향 맵핑일 때는 부모 쪽에 연관관계 편의 메서드를 작성한다.
  • ManyToMany 관계를 풀어내기 위해 중간 연결 엔티티를 두고 양방향 맵핑을 한다면 연관관계 편의 메서드를 작성한다.

 
연관관계 편의 메서드 작성을 하는 방식은 사람마다 다를 것 같고, 제가 생각한 방식이 정답은 아니니 참고정도만 해보면 좋을 것 같습니다.