Programming/JPA

[JPA] JPA 양방향 연관관계 세팅 및 주의사항

hyeonic 2021. 8. 28. 19:28

JPA 양방향 연관관계 세팅 및 주의사항

Spring Data JPA와 JPA 양방향 연관관계를 세팅하고 사용 시 주의할 점에 대해서 정리하였다. 밑은 간단한 예제 코드를 준비하였다.

 

User는 다양한 Knowledge을 등록할 수 있다. 정리하면 UserN개의 Knowledge를 등록할 수 있다고 가정한다. 두 엔티티 간의 관계는 1:N이다.

소스 코드

User.java

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long id;

    @Column(length = 60)
    private String email;

    @Builder
    public User(String email) {
        this.email = email;
    }
}

Knowledge.java

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Knowledge {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "knowledge_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Column(length = 20)
    private String title;

    @Column(columnDefinition = "TEXT")
    private String content;

    @Builder
    public Knowledge(User user, String title, String content) {
        this.user = user;
        this.title = title;
        this.content = content;
    }
}

현재 @ManyToOne을 활용하여 단방향 연관관계를 세팅해주었다.

양방향 연관관계 세팅하기

연관관계의 주인은 외래키가 있는 곳이다. UserKnowledgeuser_id를 외래키로 가지고 있는 곧은 N 쪽의 Knowledge가 된다. 그렇기 때문에 외래키는 Knowledge 테이블에 세팅되어 있다.

 

이제 User 쪽에서 자신이 등록한 knowledge 목록에 접근할 수 있도록 추가적인 연관관계를 세팅해준다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long id;

    @Column(length = 60)
    private String email;

    @OneToMany(mappedBy = "user")
    private List<Knowledge> knowledges = new ArrayList<>();

    @Builder
    public User(String email) {
        this.email = email;
    }
}

MappedBy는 주인이 아님을 설정한다. 속성의 값은 연관관계의 주인이 Knowledge.user 라는 것을 암시한다.

 

User.Knowledges와 Knowledge.User 중 연관관계의 주인은 Knowledge.user이 된다.

연관관계의 주인?

연관관계의 주인이라는 의미는 두 엔티티 관계에서 주인으로서 역할은 하는 것이 아니다. 단순히 데이터베이스에서 외래키를 가지고 있기 때문에 외래키를 관리하는 주인이 된다. 그렇기 때문에 주인이 아닌 User.knowledges를 통해서는 단순히 데이터베이스의 값을 읽기만 가능하다.

양방향 연관관계 사용 시 주의할 점

연관관계의 주인이 아닌 곳에 데이터 삽입

가장 치명적인 실수는 연관관계의 주인이 아닌 곳에서 데이터를 삽입하는 것이다. 오직 연관관계의 주인 만이 외래키의 값을 변경할 수 있다.

@DataJpaTest
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryTest {

    @Autowired
    EntityManager em;

    @Autowired
    UserRepository userRepository;

    @Autowired
    KnowledgeRepository knowledgeRepository;

    static Knowledge generateKnowledge(User user, String title, String content) {
        return Knowledge.builder()
                .user(user)
                .title(title)
                .content(content)
                .build();
    }

    @Test
    @DisplayName("user가 등록한 knowledge 조회")
    void findUserKnowledges() {

        // given
        String email = "dev.hyeonic@gmail.com";
        User user = User.builder()
                .email(email)
                .build();

        user.getKnowledges().add(generateKnowledge(user, "지식1", "지식1 내용")); // 주인이 아닌 쪽에서 데이터를 추가
        user.getKnowledges().add(generateKnowledge(user, "지식2", "지식2 내용")); // 주인이 아닌 쪽에서 데이터를 추가

        userRepository.save(user);

        // 영속성 컨텍스트를 비워준다.
        em.flush();
        em.clear();

        // when
        User findUser = userRepository.findById(user.getId()).get();
        List<Knowledge> knowledges = findUser.getKnowledges();

        // then
        assertAll(
                () -> assertEquals(2, knowledges.size()),
                () -> assertEquals(2, knowledgeRepository.findAll().size())
        );
    }
}

위 테스트는 실패한다.

 

User.Knowledges 쪽에 Knowledge 엔티티를 추가하여 저장한다. 하지만 주인이 아니기 때문에 영속성 컨텍스트와 1차 캐시를 비운 뒤 다시 userknowledges를 조회하게 되면 아무 데이터도 조회되지 않는다.

 

knowledgeRepository.findAll().size() 또한 데이터베이스에 아무것도 저장되지 않는다. 위 테스트 코드는 의도대로 동작하지 않는다.

    @Test
    @DisplayName("user가 등록한 knowledge 조회")
    void findUserKnowledges() {

        // given
        String email = "dev.hyeonic@gmail.com";
        User user = User.builder()
                .email(email)
                .build();

        userRepository.save(user);
        knowledgeRepository.save(generateKnowledge(user, "지식1", "지식1 내용"));
        knowledgeRepository.save(generateKnowledge(user, "지식2", "지식2 내용"));

        // 영속성 컨텍스트를 비워준다.
        em.flush();
        em.clear();

        // when
        User findUser = userRepository.findById(user.getId()).get();
        List<Knowledge> knowledges = findUser.getKnowledges();

        // then
        assertAll(
                () -> assertEquals(2, knowledges.size()),
                () -> assertEquals(2, knowledgeRepository.findAll().size())
        );
    }
}

knowledgeRepository를 활용하여 직접 엔티티를 저장하였다. 이때 저장한 user 엔티티를 함께 전달한다. Knowledge.User는 주인이기 때문에 적절하게 값이 채워진다.

 

실제로 조회를 진행하여도 null이 아닌 실제 user 엔티티가 조회된다. 물론 객체 탐색 또한 가능하다.

for (Knowledge knowledge : knowledges) {
    System.out.println(knowledge.getUser().getId());
    System.out.println(knowledge.getUser().getEmail());
}
1
dev.hyeonic@gmail.com
1
dev.hyeonic@gmail.com

순수한 객체까지 고려한 양방향 연관관계

현재 위 예제 코드는 연관관계의 주인에게만 값을 저장하고 있다. 만약 같은 영속성 컨텍스트에 있다고 가정하고 강제로 flush 하지 않고 User를 통해 knowledges를 조회하게 되면 아무것도 조회되지 않을 것이다.

    @Test
    @DisplayName("user가 등록한 knowledge 조회")
    void findUserKnowledges() {

        // given
        String email = "dev.hyeonic@gmail.com";
        User user = User.builder()
                .email(email)
                .build();

        userRepository.save(user);
        knowledgeRepository.save(generateKnowledge(user, "지식1", "지식1 내용"));
        knowledgeRepository.save(generateKnowledge(user, "지식2", "지식2 내용"));

        // when
        User findUser = userRepository.findById(user.getId()).get();
        List<Knowledge> knowledges = findUser.getKnowledges();

        // then
        assertAll(
                () -> assertEquals(2, knowledges.size()),
                () -> assertEquals(2, knowledgeRepository.findAll().size())
        );
    }
}

위 테스트는 실패한다. findUser.getKnowledges()를 통해서 조회하면 영속성 컨텍스트 1차 캐시 안에 있는 user를 그대로 꺼내게 된다. 실제로 두 객체를 비교하면 동등성을 만족하게 된다.

 

knowledgeRepository.save(...);이 과정에서 user 쪽에도 aknowledges에 aknowledge를 추가해주어야 한다. 이것은 데이터베이스에 엔티티를 추가하기 위한 용도가 아니라 단순히 순수한 객체 상태에서 양쪽 관계를 모두 세팅해주기 위한 용도이다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Knowledge {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "knowledge_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Column(length = 20)
    private String title;

    @Column(columnDefinition = "TEXT")
    private String content;

    @OneToMany(mappedBy = "knowledge")
    private List<Tag> tags = new ArrayList<>();

    @OneToMany(mappedBy = "knowledge")
    private List<Locker> lockers = new ArrayList<>();

    @Builder
    public Knowledge(User user, String title, String content) {
        this.user = user;
        this.user.getKnowledges().add(this); // User.Knowledges에도 knowledge를 추가해준다.
        this.title = title;
        this.content = content;
    }
}

그 역할은 생성자를 통해 해결하였다. 생성자에서 User.Knowledges에도 knowledge를 추가 하기 때문에 해당 User를 통해 조회하여도 적절히 반영되는 것을 확인할 수 있었다.

정리

양방향 매핑은 단순히 데이터베이스의 값을 조회하여 객체 그래프 탐색 기능이 추가된 것이다. 주인이 아닌 곳에서 엔티티를 추가해도 데이터베이스에는 절대 반영되지 않는다.

 

또한 순수한 객체를 고려하여 양뱡향 매핑을 설정할 때는 엔티티를 통해 외래키를 추가하거나 수정하는 부분에서 주인이 아닌 엔티티까지 고려하여 처리해야 한다.


References.

김영한, 『자바 ORM 표준 JPA 프로그래밍』, 에이콘(201), p178-194.