[JPA] JPA 양방향 연관관계 세팅 및 주의사항
JPA 양방향 연관관계 세팅 및 주의사항
Spring Data JPA와 JPA 양방향 연관관계를 세팅하고 사용 시 주의할 점에 대해서 정리하였다. 밑은 간단한 예제 코드를 준비하였다.
User
는 다양한 Knowledge
을 등록할 수 있다. 정리하면 User
는 N
개의 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
을 활용하여 단방향 연관관계를 세팅해주었다.
양방향 연관관계 세팅하기
연관관계의 주인은 외래키가 있는 곳이다. User
와 Knowledge
중 user_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차 캐시를 비운 뒤 다시 user
의 knowledges
를 조회하게 되면 아무 데이터도 조회되지 않는다.
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.