요즘 자소서를 쓰면서 스스로 너무 부족한 사람이라는 것을 실감하게됐다...
우울하기도 했지만 동기부여로 삼고 열심히 공부해보자.
관련 오류도 수정할 겸 연관관계를 복습하고자 한다.
참고: 책 '자바 ORM 표준 JPA 프로그래밍'
양방향 연관관계
일대다 연관관계일 때

- 데이터베이스 테이블은 외래 키 하나로 양방향으로 조회 가능 -> 처음부텅 양방향 관계
연관관계 매핑
- 회원 엔티티
@Entity
public class Member {
// 중략
@ManyToOne
@JoinColumn(name = "userId")
private User user;
// 연관관계 설정
public void setTeam(Team team) {
this.team = team;
}
}
- 팀 엔티티
@Entity
public class Team {
// 중략
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<Member>();
}
일대다 컬렉션 조회
public void diDirection() {
Team team = em.find(Team.class, "team1");
List<Member> members = team.getMembers(); // 팀 -> 회원 객체그래프 탐색
}
mappedBy가 필요한 이유?
- 객체에는 양방향 연관관계라는 것이 없다.
- 반면에 데이터베이스 테이블은 외래 키 하나로 양쪽이 서로 조인할 수 있다.
- 엔티티를 양방향 연관관계로 설정하면 객체의 참조는 둘인데 외래 키는 하나 -> 둘 사이에 차이가 발생
- 이로 인해 JPA에서는 두 객체 연관관계 중 하나를 정해서 테이블의 외래 키를 관리해야 하는데 이것을 연관관계의 주인이라 한다.
연관관계의 주인만이 외래 키를 관리할 수 있다. 주인이 아닌 쪽은 읽기만 할 수 있다.
- 주인은 mappedBy 속성을 사용하지 않는다.
- 주인이 아니면 mappedBy 속성을 사용해서 연관관계의 주인을 지정해야 한다.
- 연관관계의 주인은 테이블에 외래 키가 있는 곳으로 정해야 한다.
데이터베이스 테이블은 항상 '다' 쪽이 외래 키를 가진다. 따라서 @ManyToOne에는 mappedBy 속성이 없다.
양방향 연관관계 저장
public void testSave() {
// 팀1 저장
Team team1 = new Team("team1", "팀1");
em.persist(team1);
// 회원1 저장
Member member1 = new Member("member1", "회원1");
member1.setTeam(team1); // 연관관계 설정 member1 -> team1
em.persist(member1);
// 회원2 저장
Member member2 = new Member("member2", "회원2");
member2.setTeam(team1); // 연관관계 설정 member2 -> team1
em.persist(member2);
}
- 양방향 연관관계는 연관관계의 주인이 외래 키를 관리한다. 따라서 주인이 아닌 방향은 값을 설정하지 않아도 데이터베이스에 외래 키 값이 정상 입력된다.
team1.getMembers().add(member1); // 무시
team1.getMembers().add(member2); // 무시
주인이 아닌 곳에는 값을 저장하지 않아도 될까?
- 객체 관점에서 양쪽 방향에 모두 값을 입력해주는 것이 가장 안전하다.
- JPA를 사용하지 않는 순수한 객체 상태에서는 심각한 문제가 발생할 수 있다.
연관관계 편의 메소드
- 양방향 관계에서 두 코드는 하나인 것처럼 사용하는 것이 안전하다.
member.setTeam(team);
team.getMembers().add(member);
- setTeam() 메소드를 수정
public void setTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
연관관계 편의 메소드 작성 시 주의사항
- 연관관계를 변경할 때에는 기존 관계가 있으면 삭제해야 한다.
member1.setTeam(teamA); // 1
member1.setTeam(teamB); // 2
Member findMember = teamA.getMember(); // 여전히 member1이 조회된다.
* teamA -> member1 관계가 제거되지 않아도 데이터베이스 외래 키 변경에는 문제가 없으나, 관계를 변경하고 영속성 컨텍스트가 아직 살아있는 상태에서 teamA의 getMembers()를 호출하면 member1이 반환된다.
- 기존 관계 제거하도록 메소드 수정
public void setTeam(Team team) {
if(this.team != null) {
this.team.getMembers().remove(this);
}
this.team = team;
team.getMembers().add(this);
}
다대다 연관관계
- 관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없다.
- 일대다, 다대일 관계로 풀어낼 수 있다.

다대다 매핑의 한계
- @ManyToMany를 사용하면 연결 테이블을 자동으로 처리해주므로 도메인 모델이 단순해지고 편리하다.
- 그러나 보통 연결 테이블에 주문 날짜나 수량 같은 컬럼이 더 필요하다.
- 따라서 연결 엔티티를 만들고 이곳에 추가한 컬럼들을 매핑해야 한다.
회원과 회원상품을 양방향 관계로

- 외래 키를 가지고 있는 회원상품 엔티티가 연관관계의 주인이다.
- 상품 엔티티에서 회원상품 엔티티로 객체 그래프 탐색 기능이 필요하지 않다고 판단해서 연관관계를 만들지 않았다.
@Entity
public class Member {
// 생략
// 역방향
@OneToMany(mappedBy = "member")
private List<MemberProduct> memberProducts;
}
@Entity
@IdClass(MemberProductId.class)
public class MemberProduct {
@Id
@ManyToOne
@JoinColumn(name = "MEMBER_ID")
private Member member; // MemberProductId.member와 연결
@Id
@ManyToOne
@JoinColumn(name = "PRODUCT_ID")
private Product product; // MemberProductId.product와 연결
// ...
}
- @IdClass를 사용해 복합 기본키를 매핑
public class MemberProductId implemets Serializable {
private String member; // MemberProduct.member와 연결
private String product; // MemberProduct.product와 연결
// hashCode and equals...
}
- 복합 기본키: 별도의 식별자 클래스를 만들어야 한다.
- Serializable을 구현해야 한다.
- equals와 hashCode 메소드를 구현해야 한다.
- 기본 생성자가 있어야 한다.
- 식별자 클래스는 public이어야 한다.
- 식별 관계: 부모 테이블의 기본 키를 받아서 자신의 기본 키 + 외래 키로 사용하는 것을 데이터베이스 용어로 식별 관계라 한다.
양방향 다대다 관계 저장
public void save() {
// 회원 저장
Member member1 = new Member();
member1.setId("memberId");
member1.setUsername("회원1");
em.persist(member1);
// 상품 저장
Product productA = new Product();
productA.setId("productA");
productA.setName("상품1");
em.persist(productA);
// 회원상품 저장
MemberProduct memberProduct = new MemberProduct();
memberProduct.setMember(member1);
memberProduct.setProduct(productA);
memberProduct.setOrderAmount(2);
em.persist(memberProduct);
}
새로운 기본 키 사용
@Entity
public class Order {
@Id @GeneratedValue
@Column(name = "ORDER_ID")
private Long id;
// ...
}
수정할 오류
배경
즐겨찾기 기능을 개발하면서 카테고리와 즐겨찾기 간의 다대다 연관관계가 존재한다.
- 사용자는 여러 개의 즐겨찾기 카테고리를 생성할 수 있다.
- 사용자는 카테고리에 여러 개의 즐겨찾기를 등록할 수 있다.
- 사용자는 즐겨찾기를 여러 개의 카테고리에 등록할 수 있다.
장소 하나에 대한 즐겨찾기를 등록할 때 사용자는 한 번에 여러 개의 카테고리를 등록하고 이를 API로 요청한다.

- 사용자가 카테고리를 조회할 때 즐겨찾기 내역을 조회하기 때문에 카테고리와 카테고리즐겨찾기를 양방향으로 설정하였다.
- 즐겨찾기를 수정할 때 카테고리에 존재하는지 확인하는 과정에서 카테고리즐겨찾기를 양방향으로 설정하였다.
엔티티
- 카테고리
@Entity
@Getter
@Table(name = "tb_category")
@NoArgsConstructor
public class Category extends BaseEntity {
// 생략
@OneToMany(mappedBy = "category", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<CategoryBookmark> categoryBookmarkList = new ArrayList<>();
}
- 즐겨찾기
@Entity
@Getter
@Table(name = "tb_bookmark")
@NoArgsConstructor
public class Bookmark {
// 생략
@OneToMany(mappedBy = "bookmark", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<CategoryBookmark> categoryBookmarkList = new ArrayList<>();
}
카테고리즐겨찾기는 카테고리와 즐겨찾기 둘 다 존재하는 경우에만 존재해야 한다.
따라서 CascadeType.REMOVE와 orphanRemoval = true를 추가하였다.
- 카테고리즐겨찾기
@Entity
@Getter
@NoArgsConstructor
@Table(name = "tb_category_bookmark")
public class CategoryBookmark {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "bookmark_id")
private Bookmark bookmark;
public void setCategoryAndBookmark(Category category, Bookmark bookmark) {
addCategoryBookmarkForCategory(category);
addCategoryBookmarkForBookmark(bookmark);
setCategory(category);
setBookmark(bookmark);
}
}
오류
Cannot add or update a child row: a foreign key constraint fails . . .
장소를 즐겨찾기하여 카테고리 리스트와 함께 저장하는 API를 호출하면
카테고리즐겨찾기를 저장할 대 위와 같이 bookmark_id를 bookmark 테이블에서 찾을 수 없다는 예외가 반환되었다.
문제점
// . . .
Bookmark bookmark = bookmarkRepository.findByLocationIdAndLocationTypeAndCategoryBookmarkList_CategoryIn(req.getLocationId(), req.getLocationType(), userCategoryList);
if(bookmark == null) {
bookmark = bookmarkRepository.save(new Bookmark(req));
}
for (Category category : userCategoryList) {
boolean isSelected = req.getCategoryIdList().contains(category.getId());
Bookmark existedBookmark = bookmarkRepository.findByLocationIdAndLocationTypeAndCategoryBookmarkList_Category(
req.getLocationId(), req.getLocationType(), category);
if (existedBookmark != null && !isSelected) { // 북마크 o -> 선택 x
// 해당 카테고리 내의 즐겨찾기 삭제
CategoryBookmark categoryBookmark = categoryBookmarkRepository.findByCategoryAndBookmark(
category, existedBookmark)
.orElseThrow(() -> new GlobalException(NOT_FOUND_IN_CATEGORY));
deleteCategoryBookmark(categoryBookmark);
} else if (existedBookmark == null && isSelected) { // 북마크 x -> 선택 o
// 해당 카테고리의 북마크 생성
CategoryBookmark categoryBookmark = new CategoryBookmark();
categoryBookmark.setCategoryAndBookmark(category, bookmark);
categoryBookmarkRepository.save(categoryBookmark); // -> 오류 발생
}
}
// . . .
카테고리1, 카테고리2와 즐겨찾기1이 있다.
기존에 카테고리1에 즐겨찾기1이 담겨있었는데, 즐겨찾기1에 대하여 카테고리2로 수정을 요청한 경우를 생각해보자.
for에 의해 처음에는 if문이 실행되고 카테고리1에 대한 카테고리즐겨찾기를 삭제한다.
그런데 카테고리즐겨찾기를 삭제하는 과정에서 만약 아무런 카테고리에도 속하지 않는 즐겨찾기가 생기면 어떡할까?
이전에 이를 고려해서 카테고리즐겨찾기를 삭제할 때는 카테고리, 즐겨찾기 각각에 대한 연관관계를 끊은 후에 삭제하고, 중간관계 객체가 없는 즐겨찾기라면 함께 삭제하도록 코드를 작성했었다.
private void deleteCategoryBookmark(CategoryBookmark categoryBookmark) {
Bookmark bookmark = categoryBookmark.getBookmark();
categoryBookmark.remove();
categoryBookmarkRepository.delete(categoryBookmark);
if(bookmark.getCategoryBookmarkList().isEmpty()) {
bookmarkRepository.delete(bookmark);
}
}
이 메서드에 의해 카테고리즐겨찾기가 없어진 즐겨찾기가 함께 삭제된 것이다.
이후 다시 for문을 돌면서 카테고리2에 즐겨찾기1를 저장하고자 하고 else if문이 실행된다.
하지만 즐겨찾기1이 삭제되면서 카테고리즐겨찾기를 저장할 때 오류가 발생하게 된 것이다.
따라서 위 코드를 다음과 같이 수정하였다.
// 카테고리 리스트를 돌면서 저장 및 삭제
for (Category category : userCategoryList) {
boolean isSelected = req.getCategoryIdList().contains(category.getId());
Bookmark existedBookmark = bookmarkRepository.findByLocationIdAndLocationTypeAndCategoryBookmarkList_Category(
req.getLocationId(), req.getLocationType(), category);
if (existedBookmark != null && !isSelected) { // 기존 북마크 o -> 선택 x
// 해당 카테고리 내의 즐겨찾기 삭제
CategoryBookmark categoryBookmark = categoryBookmarkRepository.findByCategoryAndBookmark(
category, existedBookmark)
.orElseThrow(() -> new GlobalException(NOT_FOUND_IN_CATEGORY));
deleteCategoryBookmark(categoryBookmark);
} else if (existedBookmark == null && isSelected) { // 기존 북마크 x -> 선택 o
// 해당 카테고리의 북마크 생성
Bookmark bookmark = bookmarkRepository.findByLocationIdAndLocationTypeAndCategoryBookmarkList_CategoryIn(req.getLocationId(), req.getLocationType(), userCategoryList);
if(bookmark == null) {
bookmark = bookmarkRepository.save(new Bookmark(req));
}
CategoryBookmark categoryBookmark = new CategoryBookmark();
categoryBookmark.setCategoryAndBookmark(category, bookmark);
categoryBookmarkRepository.save(categoryBookmark);
}
}

'Spring & JPA' 카테고리의 다른 글
고급 매핑 - 2. @MappedSuperclass (0) | 2025.03.19 |
---|---|
고급 매핑 - 1. 상속 관계 매핑 (0) | 2025.03.19 |
[고대로] Redisson을 통한 분산 락 적용 (0) | 2025.03.05 |
[고대로] static 변수와 영속성 컨텍스트 (0) | 2024.08.17 |
[고대로] 운영 시간 관리 (0) | 2024.08.17 |