[UMC] 성능을 고려한 연관관계 매핑과 데이터 정합성
이번주 UMC 4주차 시니어 미션의 첫 번째 주제는 성능을 고려한 연관관계 매핑과 orphanRemoval 적용이었다.
한 유저가 여러 음식 취향을 선택한다
주어진 기획에서 한 명의 User는 여러 개의 음식 취향을 선택할 수 있다. 나는 Enum(FoodCategory)을 값 타입으로 두고, 중간 엔티티 UserFoodPreference로 풀어 논리적 N:M을 1:N + N:1로 모델링했다.
이때, 같은 음식 취향이 DB에 여러 번 저장되는 중복을 막기 위해 DB에 UNIQUE (user_id, food_category) 제약도 추가했다.
// User.java
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@Builder.Default
private List<UserFoodPreference> foodPreferences = new ArrayList<>();
// UserFoodPreference.java
@Table(name = "user_food_preference",
uniqueConstraints = @UniqueConstraint(name = "uk_user_food", columnNames = {"user_id", "food_category"}))
그런데, 미션에서 List를 Set으로 변경 후 차이점을 분석하라고 한다. JPA/Hibernate에서 “컬렉션을 뭐로 잡을 것인가”는 꽤 중요하다. 특히 @OneToMany는 SQL이 어떻게 나가느냐와 직결된다. 이번 글에서는 다음 두 가지를 중심으로 리팩토링하고 관찰한 내용을 정리한다.
List VS Set : Hibernate 입장에서 무엇이 다른가?
@OneToMany는 '하나의 유저가 여러 개의 선호 데이터를 가진다' 같은 관계를 표현한다. 그런데 이 컬렉션을 List로 할 수도 있고 Set으로 할 수도 있다. 둘의 차이는 무엇일까?
| 항목 | List | Set |
|---|---|---|
| 중복 | 가능 | 불가능 |
| 순서 | 있음 | 없음 |
| 성능 | 순서 유지 때문에 약간 느림 | 중복 검사가 빠름 |
| SQL 처리 | 가끔 전체 다시 저장함 | 변경된 항목만 업데이트함 |
즉, 중복이 생기면 안 되는 관계리면 Set이 훨씬 효율적이고 정확하다.
그런데, 여기서 궁금한 점이 생긴다.
프론트에서도 중복을 막을 수 있는데 왜 서버에서 또 막아야 하지?
기획에서 제시한 와이어프레임을 보면 유저가 선호하는 음식 카테고리를 고르는 화면은 단순히 버튼 클릭으로 이루어진다. 즉, 프론트 자체에서 버튼을 클릭한 채로 'submit'을 하면 선택된 버튼의 카테고리 데이터가 요청으로 날아오는 것이다. 그러면 애초에 프론트에서 이미 중복 클릭을 막았는데 서버에서 또 막아야 할 이유가 있는걸까?
... 이런 생각을 잠깐 했었는데, 프론트는 신뢰할 수 없는 환경이라는 점을 간과했다.
항상 악의적인 사용자가 있을 것이고, API를 직접 호출해서 여러 번 보내는 경우도 있을 것이다. 즉, 서버에서도 데이터의 중복이 불가하다는 제약을 걸어두어야 데이터 무결성 차원에서 진짜 DB를 보호할 수 있어 안전한 구조가 되는 것이다.
그런데, 여기서 또 궁금한 점이 생긴다.
아니, UNIQUE 제약으로 막았는데 Set으로 또 막아?
DB 테이블에는 이미 이렇게 제약이 걸려 있다.
@Table(name = "user_food_preference",
uniqueConstraints = @UniqueConstraint(name = "uk_user_food", columnNames = {"user_id", "food_category"}))
즉, DB 입장에서는 이미 같은 유저가 같은 음식 카테고리를 두 번 저장할 수 없다. 그럼 이미 중복을 막고 있는데 왜 Set이 필요하지?
중복을 DB UNIQUE 제약으로만 막으면 어떻게 되나?
“요청이 각각 별도의 트랜잭션으로 처리되니까, 두 번 들어와도 괜찮은 거 아니야?”라고 생각할 수도 있다.
실제 서비스 환경에서는 버튼을 두 번 눌렀다면 요청이 두 번 가고, 각 요청은 각각의 트랜잭션으로 실행된다.
| 시점 | 요청 | 트랜잭션 |
|---|---|---|
| T1 | 첫 번째 클릭 | 트랜잭션 A 시작 → 커밋 성공 |
| T2 | 두 번째 클릭 | 트랜잭션 B 시작 → UNIQUE 위반 → B만 롤백 |
즉, 첫 번째 요청은 정상 등록, 두 번째 요청만 실패한다.
DB에는 한 건만 남는다.
"그럼 괜찮은 거 아니야?"
하지만 여전히 문제는 남는다
비록 "전체 롤백"은 없지만, 다음과 같은 운영상의 문제는 여전히 생긴다.
(1) 불필요한 예외 로그
두 번째 요청마다 DataIntegrityViolationException 로그가 남는다.
로그 자체는 사용자에게 보이지 않지만, 예외를 따로 처리하지 않으면 컨트롤러까지 전파되어 HTTP 500 에러 페이지나 JSON으로 그대로 노출된다.
{
"timestamp": "2025-10-07T14:23:45.123Z",
"status": 500,
"error": "Internal Server Error",
"message": "could not execute statement",
"path": "/api/users/1/food-preferences"
}
결국 사용자는 "버튼을 한 번 더 눌렀을 뿐인데 서버 오류" 를 보게 된다. (물론 백엔드 개발자라면 이런 예외를 잡아 500 대신 적절한 응답을 내려주겠지만, 로그는 여전히 남는다. 운영 환경에서는 이게 꽤 불필요한 노이즈다.)
(2) 다중 서버 환경에서는 동시에 중복 요청이 날아간다
서버가 2대 이상인 상황을 생각해보자. 사용자가 같은 요청(예: “한식 추가”)을 거의 동시에 두 번 보냈을 때, (따-닥!) 로드밸런서는 이 두 요청을 각각 서버 A와 서버 B로 분산시킬 수 있다.
이때 두 서버는 서로 독립된 트랜잭션으로 요청을 처리한다. 둘 다 “아직 DB에 한식이 없다”고 판단하고 INSERT를 시도하는 것이다.
시간축:
T0: 사용자가 "한식 추가" 버튼 클릭
T1: 로드밸런서가 요청을 서버 A와 B에 분산
T2: 서버 A - SELECT ... WHERE user_id=1 AND food_category='KOREAN' (없음)
T3: 서버 B - SELECT ... WHERE user_id=1 AND food_category='KOREAN' (없음)
T4: 서버 A - INSERT ... (성공)
T5: 서버 B - INSERT ... (UNIQUE 위반 에러 🚨)
| 서버 | 트랜잭션 | 결과 |
|---|---|---|
| A | INSERT 성공 | 커밋 완료 |
| B | INSERT 시도 | UNIQUE 위반 → 예외 발생 |
여기서 중요한 건,
이건 단순히 **“버튼을 두 번 눌렀다”**의 문제가 아니라
“로드밸런싱된 서버들이 동시에 INSERT를 시도한” 경쟁 상태(race condition) 라는 점이다.
게다가 만약 이 요청에 "포인트 적립", "알림 발송" 같은 부수 효과가 있다면?
- 서버 A에서는 성공 → 포인트 +10, 알림 발송
- 서버 B에서는 실패 → 하지만 포인트와 알림은 이미 처리됨
결과적으로 한 건의 데이터에 대해 포인트가 2번 적립되고, 알림이 2번 발송되는 상황이 발생할 수 있다. 😱
즉, 이 문제를 근본적으로 차단해야 한다.
DB의 UNIQUE는 '저장 이후' 중복을 막고, Set은 '저장 이전' 중복을 막는다.
즉,
- DB 제약 조건은 “실제 INSERT가 나가야” 에러가 터진다. (사후 차단)
- Set 구조는 “JPA가 SQL을 날리기 전”에 중복 객체를 감지한다. (사전 차단)
JPA는 결국 자바 컬렉션을 감시해서 SQL로 바꿔주는 역할을 한다.
- Set에 새 객체를 추가하려고 하면, 자바가 먼저 equals()와 hashCode()를 사용해서 “이미 같은 게 있는지” 검사함.
- 만약 같은 게 이미 있다면 → 추가하지 않음.
- 즉, Hibernate가 나중에 INSERT 쿼리를 만들 기회조차 안 줌.
반면 List는 이런 비교를 하지 않고 '추가하라고 하면 무조건 뒤에 붙여!'라는 단순한 구조다.
그래서 여러 개의 INSERT를 시도하면서 DB까지 가서야 터져버리는 것이다.
성능 관점에서 Hibernate 내부에서의 차이도 존재한다. Hibernate가 List를 관리할 때는 '요소 중복'을 추적하기 어려워서, 변경 시 diff 계산을 못 한다. 그래서 지우거나 순서가 바뀌면 전체를 delete 후 다시 insert를 해버린다.
하지만 Set은 내부적으로 hash 기반 비교를 쓰기 때문에, 정확히 바뀐 요소만 잡아서 부분 delete/insert가 가능하다.
- List: “뭔가 바뀐 거 같아? 다 지우고 다시 넣자.”
- Set: “바뀐 항목만 지우면 되겠군.”
... 그러니까 DB UNIQUE 제약이 있더라도, JPA 컬렉션이 중복 없는 구조라면 Set을 쓰는 게 올바른 설계다. 왜냐하면 Set은 DB에 가기 전 단계의 무결성을 보장하기 때문이다.
이 차이가 결국 “운영에서 발생할 수 있는 중복 처리 이슈”를 사전에 예방하느냐, 사후에 뒤처리하느냐의 차이를 만든다.
List를 Set으로 바꿔 테스트해보자
그럼, 실제로 테스트를 통해 눈으로 확인해보자.
@Test
@DisplayName("List 사용 시 - 중복 허용됨")
void testListAllowsDuplicates() {
// Given
User user = createTestUser();
UserFoodPreference preference1 = UserFoodPreference.builder()
.user(user)
.foodCategory(FoodCategory.KOREAN)
.build();
UserFoodPreference preference2 = UserFoodPreference.builder()
.user(user)
.foodCategory(FoodCategory.KOREAN) // 같은 카테고리
.build();
List<UserFoodPreference> foodPreferencesList = new ArrayList<>();
// When
foodPreferencesList.add(preference1);
foodPreferencesList.add(preference2);
// Then
assertEquals(2, foodPreferencesList.size(),
"List는 중복을 허용하므로 2개가 저장됨");
System.out.println("=== List 중복 테스트 ===");
System.out.println("저장된 개수: " + foodPreferencesList.size());
System.out.println("결과: 중복 허용됨 ❌");
}
@Test
@DisplayName("Set 사용 시 - 중복 방지됨 (equals/hashCode 필요)")
void testSetPreventsDuplicates() {
// Given
User user = createTestUser();
UserFoodPreference preference1 = UserFoodPreference.builder()
.user(user)
.foodCategory(FoodCategory.KOREAN)
.build();
UserFoodPreference preference2 = UserFoodPreference.builder()
.user(user)
.foodCategory(FoodCategory.KOREAN) // 같은 카테고리
.build();
Set<UserFoodPreference> foodPreferencesSet = new HashSet<>();
// When
foodPreferencesSet.add(preference1);
foodPreferencesSet.add(preference2);
System.out.println("=== Set 중복 테스트 ===");
System.out.println("저장된 개수: " + foodPreferencesSet.size());
if (foodPreferencesSet.size() == 1) {
System.out.println("결과: 중복 방지됨 ✅");
} else {
System.out.println("결과: equals/hashCode 재정의 필요 ⚠️");
System.out.println("현재는 객체 참조로 비교하므로 2개로 인식됨");
}
}
위 테스트 코드를 실행해보면 결과가 어떨까?
[예상한 결과]
- List 중복 테스트: 저장된 개수 2개
- Set 중복 테스트: 저장된 개수 1개
그러나...
equals/hashCode의 재정의가 필요하다는 문구가 출력되면서 예상과는 다른 결과가 보인다.
왜 이런 결과가 나오나?
현재 UserFoodPreference는 @EqualsAndHashCode(callSuper = true)로 인해 BaseEntity의 ID 기반 비교를 사용하고 있었다.
하지만 테스트 시점에서는 아직 DB에 저장되지 않았기 때문에 모든 id 값이 null이다.
즉, JPA 입장에서는 “아직 식별 불가능한, 완전히 새로운 객체”로 본 것이다.
HashSet이 객체의 중복을 판단하는 과정은 다음 3단계로 이루어진다.
1️⃣ hashCode() 호출
preference1.hashCode() // 객체1의 해시코드 계산
preference2.hashCode() // 객체2의 해시코드 계산
equals/hashCode를 직접 구현하지 않으면, 기본적으로 Object.hashCode()가 호출된다.
이는 메모리 주소 기반 해시코드를 사용하기 때문에 객체마다 다르다.
preference1.hashCode() // 예: 98765432 (메모리 주소 기반)
preference2.hashCode() // 예: 11223344 (메모리 주소 기반)
2️⃣ 해시 버킷 매핑
HashSet 내부는 해시 테이블 구조를 사용한다. 따라서 preference1, preference2는 서로 다른 해시 버킷에 들어간다.
Bucket 98765432 → [preference1]
Bucket 11223344 → [preference2]
해시코드가 다르므로 preference1과 preference2는 서로 다른 버킷으로 이동한다. 같은 버킷에 있어야 equals()로 비교할 기회가 생기는데, 이미 다른 버킷에 있는 것.
3️⃣ equals() 최종 비교
같은 버킷에 있을 때만 equals()가 호출되지만, 이 경우는 다른 버킷에 있으므로 비교조차 하지 않는다.
결과적으로...
set.add(preference1); // Bucket 98765432에 저장
set.add(preference2); // Bucket 11223344에 저장 (다른 버킷!)
set.size(); // 2 ❌ (중복 방지 실패!)
equals/hashCode 구현 후 결과는 어떨까?
이번엔 equals()와 hashCode()를 도메인 필드 기반으로 직접 구현했다. 그 결과, HashSet이 중복 객체를 정확히 식별할 수 있었다.
중복은 확실히 방지된 것으로 보인다. 그러나... 성능으로 따졌을 때는 List가 근소하게 우위를 보였다.
이는 중복이 아주 많을 때 Set의 초기 해시 계산 오버헤드 때문이다.
하지만 결과 Set 크기가 12라는 사실이 훨씬 중요하다. 이는 equals/hashCode가 올바르게 동작했고, 그 덕분에 메모리 사용을 극적으로 절감했고, 중복 INSERT/DB 예외를 사전에 방지할 수 있다는 뜻이다.
즉, 운영 환경 기준(멱등성, 예외/로그 감소, 정합성, 중복 방지)의 총합 가치는 Set + equals/hashCode 쪽이 확실히 우위라고 볼 수 있다.
orphanRemoval 설정, 어디에 쓰고 어디에 쓰지 말아야 하나?
-
orphanRemoval = true부모-자식 연관관계가 해제(컬렉션 remove/clear) 되면 자식이 자동으로 DELETE 된다. ex)
user.getFoodPreferences().remove(pref);→DELETE FROM user_food_preference ... -
cascade = REMOVE와 다름부모를 remove할 때 자식에게 remove를 전파. 반면
orphanRemoval은 관계 해제만으로도 삭제가 일어남.
“이 자식 데이터, 부모 없이는 존재 의미가 없나요?”
- Yes →
orphanRemoval = true - No(이력/금전/감사/참조 다수) →
false
프로젝트를 점검해보자...
true가 적절한 곳 (부모 종속 데이터)
- User → OwnerProfile / UserConsent / NotificationSetting (1:1)
- User → Notification (임시성)
- User → UserFoodPreference (선호 설정, 재생성 가능)
- Store → StoreImage, Store → Mission (가게 종속)
- OwnerProfile → Store (점주 종속)
- Review → ReviewImage / ReviewReply
- Inquiry → InquiryImage / InquiryReply
- ReviewReply → childReplies(대댓글)
이유: 부모 없으면 의미가 없는 데이터이거나, 설정/부가 데이터라 삭제되어도 무방하다.
false가 맞는 곳 (보존/이력/감사 데이터)
- User → PointTransaction / Review / ReviewReply / Inquiry / InquiryReply / UserMission
- Store → Review
이유: 평판, 고객 지원, 참여 기록, 금전 이력은 보존되어야 한다.
🚨 문제였던 지점
- Mission → UserMission / PointTransaction 가
orphanRemoval = true로 되어 있던 경우
이 설정은 당장 오류를 일으키는 건 아니지만, “컬렉션을 조작하는 순간” 의도치 않은 삭제가 일어날 수 있다.
예를 들어 “미션 수정” 기능을 구현하면서, 기존 참여자 목록을 새로 덮어쓰려는 의도로 아래처럼 코드를 작성했다고 해보자.
mission.getUserMissions().clear();
mission.addUserMission(new UserMission(userA, mission));
mission.addUserMission(new UserMission(userB, mission));
개발자 입장에서는 “단순히 컬렉션을 초기화하고 다시 채운다”는 의도였지만, orphanRemoval = true가 설정되어 있어서 Hibernate가 이렇게 해석한다:
“연관관계가 끊어진 자식(UserMission)이 생겼네 → 고아 객체 → DELETE 실행!”
그 결과, DB의 user_mission 테이블에서 기존 참여자 전체가 실제로 삭제된다.
이처럼 UserMission, PointTransaction 같은 이력성 데이터는
삭제되면 안 되는 데이터이므로,
orphanRemoval = false로 변경하여 데이터 보존성을 확보해야 한다.
적용 기준
Q1. 부모가 없으면 자식이 무의미한가?
- 예) 이미지/설정/프로필/대댓글 →
true
Q2. 이력/감사/금전 데이터인가?
- 예) 포인트 거래, 참여 이력, 리뷰/문의 기록 →
false
Q3. 다른 엔티티(또는 외부 시스템)가 참조하는가?
- 다수 참조/의존 →
false
Q4. 비즈니스 규정/법적 요구로 보존해야 하는가?
- 예) 회계/감사/분쟁 대응 →
false
Q5. 안전성에 확신이 없으면?
- 기본은
false(안전 우선)