문제 상황
Spring Boot와 JPA를 사용하여 User와 UserCategory 간의 일대다 관계를 구현하던 중 예상치 못한 문제에 직면했습니다. User 엔티티에서 관심 카테고리를 수정하는 기능을 구현했는데, 두 번째 수정부터 다음과 같은 에러가 발생했습니다.
Column 'user_id' cannot be null
초기 구현 - 단방향 일대다 관계
처음에는 User에서 UserCategory로의 단방향 일대다 관계로 구현했습니다.
@Entity
public class User {
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true)
@JoinColumn(name = "user_id")
private List<UserCategory> categories = new ArrayList<>();
public void updateCategories(List<Integer> categoryIds) {
this.categories.clear();
if (categoryIds != null && !categoryIds.isEmpty()) {
for (Integer categoryId : categoryIds) {
UserCategory category = new UserCategory(this.id, categoryId);
this.categories.add(category);
}
}
}
}
@Entity
public class UserCategory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "category_id", nullable = false)
private Integer categoryId;
}
문제 분석: Hibernate의 단방향 일대다 처리 방식
단방향 일대다 관계에서 Hibernate가 컬렉션을 수정할 때의 SQL 실행 순서가 문제의 원인이었습니다.
Hibernate의 SQL 실행 순서
updateCategories() 메서드가 실행될 때 Hibernate는 다음과 같은 순서로 SQL을 생성합니다:
- INSERT: 새로운 UserCategory 레코드들 삽입
- UPDATE: 기존 UserCategory 레코드들의 user_id를 NULL로 설정
- DELETE: user_id가 NULL인 레코드들 삭제
문제는 2단계에서 발생합니다. user_id 컬럼이 NOT NULL 제약조건을 가지고 있어서 NULL로 업데이트하려는 시점에서 에러가 발생하는 것입니다.
왜 이런 순서로 실행되는가?
단방향 일대다 관계에서는 자식 엔티티(UserCategory)가 부모 엔티티(User)에 대한 정보를 모릅니다. 따라서 Hibernate는 관계 변경 시 다음과 같이 처리해야 합니다:
- 연관관계의 소유자가 아닌 쪽(User)에서 관계를 관리
- 외래키가 있는 테이블(user_categories)을 직접 제어할 수 없음
- 기존 관계를 끊기 위해 외래키를 NULL로 설정 후 삭제하는 방식 사용
이는 JPA 명세에서 정의된 동작이며, Hibernate가 단방향 일대다 관계를 처리하는 표준적인 방법입니다.
비유로 이해하기
상황: 회사에서 팀원 관리
단방향 일대다 = 팀장이 원격으로 팀원들 관리
팀장 (User) 팀원들 (UserCategory)
[서울 본사] ←→ [부산 지사, 대구 지사, 광주 지사]
팀장은 서울에 있고, 팀원들은 각자 다른 지사에 있습니다.
왜 복잡한 과정을 거치는가?
1. 팀장은 팀원 명단을 가지고 있음 (User.categories)
팀장의 수첩: "내 팀원들: 김철수(부산), 이영희(대구), 박민수(광주)"
2. 하지만 실제 인사기록은 각 지사에 있음 (user_categories 테이블)
부산지사 인사기록: "김철수 - 소속팀장: 홍길동"
대구지사 인사기록: "이영희 - 소속팀장: 홍길동"
광주지사 인사기록: "박민수 - 소속팀장: 홍길동"
3. 팀원을 교체하려면?
팀장이 원하는 것: "김철수, 이영희는 빼고 → 최지훈, 정수민으로 교체"
하지만 문제: 팀장은 서울에 있어서 각 지사 인사기록을 직접 수정할 수 없음!
Hibernate의 어쩔 수 없는 처리 방식
1단계: 새 팀원들 먼저 등록
새로운 인사기록 생성:
- "최지훈 - 소속팀장: 홍길동"
- "정수민 - 소속팀장: 홍길동"
2단계: 기존 팀원들과의 관계 끊기
기존 인사기록 수정:
- "김철수 - 소속팀장: (없음)" ← NULL로 변경
- "이영희 - 소속팀장: (없음)" ← NULL로 변경
여기서 문제! 회사 규정상 "모든 직원은 반드시 소속팀장이 있어야 함 (NOT NULL)"인데, 소속팀장을 (없음)으로 바꾸려니 규정 위반!
3단계: 소속팀장이 없는 직원들 퇴사 처리
- 김철수 퇴사
- 이영희 퇴사
왜 이런 복잡한 과정을 거치는가?
핵심 문제: 관리 주체와 실제 데이터 위치가 다름
- 관리 주체: 팀장(User) - "내가 팀원들을 관리한다"
- 실제 데이터: 각 지사(UserCategory 테이블) - "소속팀장 정보는 여기 있다"
Hibernate 입장: "팀장님이 팀원을 바꾸고 싶다고? 그런데 인사기록은 각 지사에 있네... 일단 새 사람 등록하고, 기존 사람들 소속팀장 지우고, 그 다음에 퇴사시켜야겠다"
양방향 관계로 해결
각 팀원이 직접 자신의 소속팀장을 관리
팀원: "내 팀장은 홍길동입니다" (UserCategory.user)
팀장: "내 팀원들 목록은 여기서 확인해" (User.categories - 단순 조회용)
이제 팀원 교체할 때:
- 기존 팀원들 바로 퇴사 처리 (자신이 직접 관리하므로 가능)
- 새 팀원들 입사 처리
NULL 설정 과정이 필요 없어짐!
핵심 정리
단방향 일대다의 문제: "나는 관리한다고 하는데, 실제 정보는 다른 곳에 있어서 복잡한 우회 과정 필요"
양방향 관계의 해결: "실제 정보를 가진 쪽이 직접 관리하니까 단순하고 직접적인 처리 가능"
시도한 해결책들과 실패 이유
1. orphanRemoval = true 추가
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "user_id")
private List<UserCategory> categories = new ArrayList<>();
실패 이유: orphanRemoval은 고아 객체를 삭제하는 기능이지만, 여전히 먼저 외래키를 NULL로 설정하는 과정을 거치기 때문에 근본적인 해결책이 되지 못했습니다.
2. 개별 제거 방식 (removeIf 사용)
public void updateCategories(List<Integer> categoryIds) {
Set<Integer> newIds = new HashSet<>(categoryIds);
// clear() 대신 개별 제거
this.categories.removeIf(category -> !newIds.contains(category.getCategoryId()));
// 새로운 것만 추가
// ...
}
실패 이유: removeIf()를 사용해도 Hibernate의 기본 동작 방식은 변하지 않습니다. 여전히 외래키를 NULL로 설정한 후 삭제하는 순서로 처리됩니다.
3. 컬렉션 완전 교체
public void updateCategories(List<Integer> categoryIds) {
List<UserCategory> newCategories = new ArrayList<>();
// 새 컬렉션 생성...
this.categories = newCategories; // 필드 교체
}
실패 이유: 단순한 필드 교체는 기존 레코드를 삭제하지 않고 새로운 연관관계만 추가하므로, 데이터베이스에 고아 레코드가 남게 됩니다.
최종 해결책: 양방향 관계로 전환
결국 단방향 일대다 관계의 근본적인 한계를 인정하고 양방향 관계로 전환하기로 결정했습니다.
UserCategory 엔티티 수정

User 엔티티 수정

양방향 관계의 장점
1. 명확한 관계 관리
mappedBy 속성을 통해 UserCategory가 관계의 소유자임을 명시적으로 선언합니다. 이로 인해 Hibernate는 외래키를 직접 관리할 수 있게 됩니다.
2. 효율적인 SQL 생성
양방향 관계에서는 다음과 같은 순서로 SQL이 실행됩니다:
- DELETE: 기존 UserCategory 레코드들 직접 삭제
- INSERT: 새로운 UserCategory 레코드들 삽입
NULL UPDATE 단계가 제거되어 NOT NULL 제약조건과의 충돌이 사라집니다.
3. 도메인 모델의 일관성
User가 여전히 루트 애그리거트로서 UserCategory의 생명주기를 완전히 관리하면서도, 기술적인 제약 없이 관계를 조작할 수 있습니다.
양방향 관계에 대한 오해와 진실
기존 인식: "양방향 = 나쁨"
"양방향은 복잡하고, 순환 참조 위험하고, 결합도 높아서 안 좋다"
실제: 상황에 따라 다름
양방향이 좋은 경우 (일대다):
- 부모가 자식들의 생명주기를 완전히 관리
- 자식 엔티티가 독립적인 비즈니스 로직이 거의 없음
- 애그리거트 내부의 강한 결합이 오히려 바람직한 경우
애그리거트 설계의 딜레마
선택지 1: Repository로 분리 (독립 애그리거트)
// UserCategory를 독립 애그리거트로 취급
public class UserCategoryService {
@Autowired
private UserCategoryRepository userCategoryRepository;
public void updateUserCategories(Long userId, List<Integer> categoryIds) {
userCategoryRepository.deleteByUserId(userId);
categoryIds.forEach(id ->
userCategoryRepository.save(new UserCategory(userId, id))
);
}
}
문제점:
- DDD 애그리거트 경계 위반: User가 UserCategory를 직접 관리하지 못함
- 트랜잭션 경계 모호: 여러 Repository 조작 시 일관성 보장 어려움
- 비즈니스 로직 분산: 도메인 규칙이 서비스 계층으로 흩어짐
선택지 2: 양방향 관계 (동일 애그리거트)
// User 애그리거트가 UserCategory 생명주기 완전 관리
public class User {
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<UserCategory> categories;
public void updateCategories(List<Integer> categoryIds) {
// User가 모든 카테고리 변경을 책임짐
this.categories.clear();
categoryIds.forEach(id ->
this.categories.add(new UserCategory(this, id))
);
}
}
현재 상황 분석: 왜 양방향이 적합한가?
1. 복잡도 분석
UserCategory의 비즈니스 로직:
- 생성: User + categoryId만 있으면 됨
- 수정: 거의 없음 (삭제 후 재생성)
- 삭제: User가 결정
- 조회: User를 통해서만 의미 있음
UserFollow의 비즈니스 로직:
- 생성: 팔로우 관계 성립
- 삭제: 언팔로우
- 조회: 팔로잉/팔로워 목록
UserRating의 비즈니스 로직:
- 생성: 평가 등록 (수정/삭제 없음)
- 집계: User 점수 계산에 사용
→ 모두 단순하고, User 없이는 의미가 없는 엔티티들
2. 독립 애그리거트가 되려면?
// 만약 UserCategory가 독립 애그리거트라면...
public class UserCategory {
private Long id;
private Long userId; // 단순 참조키만
private Integer categoryId;
// 복잡한 비즈니스 로직이 있어야 함
public void validateBusinessRules() { /* ??? */ }
public void processComplexLogic() { /* ??? */ }
public SomeResult calculateSomething() { /* ??? */ }
}
// 그리고 별도 Repository
public interface UserCategoryRepository extends JpaRepository<UserCategory, Long> {
// 복잡한 쿼리 메서드들이 필요할 것
}
하지만 현실:
- UserCategory에는 복잡한 비즈니스 로직이 없음
- 단순히 User의 속성을 확장하는 역할
- 독립적으로 존재할 이유가 없음
3. 트랜잭션 경계 고려
// Repository 분리 시 트랜잭션 문제
@Transactional
public void updateUserAndCategories(Long userId, UpdateRequest request) {
// 1. User 업데이트
User user = userRepository.findById(userId);
user.updateProfile(request.getProfile());
// 2. UserCategory 업데이트 (별도 Repository)
userCategoryRepository.deleteByUserId(userId);
userCategoryRepository.saveAll(newCategories);
// 3. 중간에 실패하면? 일관성 문제 발생
if (someCondition) {
throw new BusinessException(); // 1번은 커밋, 2번은 롤백?
}
}
양방향 관계에서는:
@Transactional
public void updateUserAndCategories(Long userId, UpdateRequest request) {
User user = userRepository.findById(userId);
user.updateProfile(request.getProfile());
user.updateCategories(request.getCategoryIds());
// 하나의 애그리거트, 하나의 트랜잭션, 일관성 보장
}
결론: 상황별 최적 선택
현재 상황에서 양방향이 적합한 이유
- 단순한 하위 엔티티: 복잡한 독립 로직 없음
- 강한 생명주기 결합: User 없으면 의미 없는 데이터
- 트랜잭션 일관성: 하나의 애그리거트로 관리해야 함
- 기술적 제약 해결: JPA 단방향 일대다 문제 해결
미래에 독립 애그리거트로 분리할 시점
// 만약 UserCategory가 이렇게 복잡해진다면...
public class UserCategory {
// 복잡한 카테고리별 설정
private CategoryPreference preference;
private CategoryStatistics statistics;
private List<CategoryHistory> history;
// 복잡한 비즈니스 로직
public void updatePreference(PreferenceRequest request) { ... }
public CategoryAnalysisResult analyzeUsage() { ... }
public void processRecommendation() { ... }
}
이런 수준이 되면 그때 독립 애그리거트로 분리를 고려해야겠죠.
핵심: "복잡도와 책임에 따라 애그리거트 경계를 결정하되, 현재는 양방향 관계가 가장 적합한 선택"
추가로 해결한 쿼리 문제
양방향 관계로 전환하면서 기존의 팔로우 조회 쿼리도 수정이 필요했습니다.
// 변경 전
@Query("""
SELECT u FROM User u
WHERE u.id IN (
SELECT uf.followingId -- 필드 직접 참조
FROM UserFollow uf
WHERE uf.followerId = :userId
)
""")
// 변경 후
@Query("""
SELECT u FROM User u
WHERE u.id IN (
SELECT uf.following.id -- 엔티티 참조를 통한 접근
FROM UserFollow uf
WHERE uf.follower.id = :userId
)
""")
결론
단방향 일대다 관계는 이론적으로는 가능하지만, 실무에서는 다음과 같은 한계가 있습니다:
- NULL UPDATE 문제: NOT NULL 제약조건과의 충돌
- 비효율적인 SQL: 불필요한 UPDATE 쿼리 발생
- 예측 불가능한 동작: 개발자의 의도와 다른 SQL 실행 순서
반면 양방향 관계는:
- 명확한 관계 관리: mappedBy를 통한 소유권 명시
- 효율적인 SQL 생성: 직접적인 DELETE/INSERT
- 도메인 모델 보호: 루트 애그리거트 패턴 유지
이러한 이유로 JPA/Hibernate 커뮤니티에서는 일대다 관계에서 양방향 매핑을 권장하며, 이번 경험을 통해 그 이유를 명확히 이해할 수 있었습니다.
'Spring 7기 프로젝트 > 모임 플렛폼 프로젝트' 카테고리의 다른 글
| AWS ECS를 통한 베포 과정 (0) | 2025.08.12 |
|---|---|
| RabbitMQ 이벤트 처리에서 발생한 LinkedHashMap 역직렬화 문제와 해결 과정 (1) | 2025.08.11 |
| EventWrapper 패턴을 활용한 RabbitMQ 이벤트 시스템 통일화 (1) | 2025.08.11 |
| DDD 애그리거트 패턴을 활용한 User 도메인 리팩토링 (1) | 2025.08.11 |
| WebClient의 비동기 처리와 블로킹: 성능 최적화의 핵심 이해 (3) | 2025.08.09 |