🤔 문제 인식
우리는 엔티티를 설계하다 보면 여러 Table을 매핑 할 때가 너무 너무 많고,
어쩌면 "내부로직"을 모르고 가져다 쓰는 경우가 많은 것 같다.
한 번 내 코드를 봐보자.
@Entity
@Table(name = "user")
@Builder
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class User extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ... 중략
// Mapping
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<UserData> userData;
// 연관관계 설정
public void setUserData(List<UserData> userData) {
this.userData = userData;
}
}
@Builder
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Table(name = "user_data")
@Entity
public class UserData extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 각 답변의 평균 값 (level 1 쪽이 추움 ~ level 5 쪽이 더움)
private Double level1;
private Double level2;
private Double level3;
private Double level4;
private Double level5;
// 계절
@Enumerated(EnumType.STRING)
private Season season;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
public void setUser(User user) {
this.user = user;
}
// UserData 기본값으로 설정
public static UserData createUserData(Season season) {
switch (season) {
case SPRING_AUTUMN:
return UserData.builder()
.level1(10.0)
.level2(17.0)
.level3(24.0)
.level4(27.0)
.level5(30.0)
.season(season)
.build();
case SUMMER:
return UserData.builder()
.level1(15.0)
.level2(24.0)
.level3(30.0)
.level4(33.0)
.level5(36.0)
.season(season)
.build();
case WINTER:
return UserData.builder()
.level1(-17.0)
.level2(-7.0)
.level3(0.0)
.level4(6.0)
.level5(12.0)
.season(season)
.build();
default:
throw new CustomException(ErrorCode.INVALID_SEASON);
}
}
public static List<UserData> createUserDataList(User user) {
return Arrays.stream(Season.values())
.map(season -> {
UserData userData = createUserData(season);
// 연관관계 설정
userData.setUser(user);
return userData;
})
.toList();
}
}
비지니스 로직은 떼어놓고 보자.
그냥 간단히 User와 UserData를 @OneToMany로 매핑한 코드이다.
여담으로,
public void addUserData(UserData userData) {
this.userData.add(userData);
userData.setUser(this);
}
를 쓰고, list 순회하며 추가해주는 게 낫지 않냐 등의 의문이 들겠지만..
한 유저가 Season 3개의 Column을 묶어서 가지고 있기 떄문에, 추가도 list로 해주었다.
(이게 중요한 이야기는 아니니까 넘어가자)
그런데, UserData를 초기화 하는 과정에서 나는 단순히
"해당 User의 Data를 찾아서 삭제하고, 다시 새로운 DefaultData를 Create해주면 되겠다"라고 생각했다.
💡 초기 코드
// 사용자 맞춤 서비스 제공 설정 변경
public void updateCustom(User user, SettingReqDto.CustomDto customDto) {
boolean previousCustomSetting = user.isCustom();
user.setCustom(customDto.custom());
// 맞춤 서비스를 껐다 켜면 데이터 "초기화"
// 사용자가 맞춤 서비스를 끄는 경우
// 데이터 삭제
if (previousCustomSetting && !customDto.custom()) {
userDataRepository.deleteAllByUser(user);
}
// 사용자가 맞춤 서비스를 켜는 경우
// Default 데이터로 생성
if (!previousCustomSetting && customDto.custom()) {
List<UserData> newUserDataList = UserData.createUserDataList(user);
// 연관관계 설정
newUser.setUserData(userDataList);
}
userRepository.save(user);
}
// 참고 : 여기서 userData->user의 연관관계를 설정한다.
public static List<UserData> createUserDataList(User user) {
return Arrays.stream(Season.values())
.map(season -> {
UserData userData = createUserData(season);
// 연관관계 설정
userData.setUser(user);
return userData;
})
.toList();
}
그러나 다음과 같은 에러를 마주한다..
왜 이러한 코드를 생각했냐면.. 그냥 처음에 회원가입할 때 Default Data를 이렇게 생성했기 때문에 똑같이 하면 될 줄 알았다.
💡 에러
Request processing failed: java.lang.UnsupportedOperationException
에러 메시지를 자세히 살펴보면, 문제의 근본 원인이 java.lang.UnsupportedOperationException이다.
이는 불변 컬렉션(Immutable Collection)을 수정하려고 시도할 때 발생한다.
차근차근 문제 해결을 해보자..
🖋️ 해결방법
이 부분은 JPA와 Hibernate의 내부 동작과 깊은 관련이 있다.
💡 이유 찾기
Hibernate는 엔티티의 컬렉션 필드를 자체적인 컬렉션 구현체로 래핑합니다.
이 래퍼 클래스들(예: PersistentBag, PersistentSet 등)은 변경 감지, 지연 로딩 등의 기능을 제공한다.
일반적으로 Hibernate의 래퍼 클래스들은 수정 가능하다!
UnsupportedOperationException 발생된 걸 보니,
Hibernate가 관리하는 컬렉션에서 이 예외가 발생한다는 것은, 해당 컬렉션이 어떤 이유로 불변 상태가 되었다는 의미이다.
💡 가능한 시나리오
User 엔티티의 userData와 userMedian 필드가 불변 컬렉션으로 초기화되어 있을 수 있다는 점이다.
그러고보니 평소에 ArrayList<> 로 초기화 하는 코드가 안적혀 있어서
코드를 수정했다.
💡 해결 방법
1. User 엔티티에서 컬렉션을 초기화하는 방식을 변경
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<UserData> userData = new ArrayList<>();
엔티티에서 List를 사용할 경우, 초기화해주는 것이 관례!!
초기화를 하지 않을 경우, add() 사용할 때 null이 발생한다.
2. resetUserData 메서드에서 기존 컬렉션을 수정하는 방식으로 변경
// 사용자 맞춤 서비스 제공 설정 변경
public void updateCustom(User user, SettingReqDto.CustomDto customDto) {
boolean previousCustomSetting = user.isCustom();
user.setCustom(customDto.custom());
// 맞춤 서비스를 껐다 켜면 데이터 "초기화"
// 사용자가 맞춤 서비스를 끄는 경우
// 데이터 삭제
if (previousCustomSetting && !customDto.custom()) {
userDataRepository.deleteAllByUser(user);
}
// 사용자가 맞춤 서비스를 켜는 경우
// Default 데이터로 생성
if (!previousCustomSetting && customDto.custom()) {
List<UserData> newUserDataList = UserData.createUserDataList(user);
// UserData 컬렉션 업데이트
user.getUserData().clear();
user.getUserData().addAll(newUserDataList);
}
userRepository.save(user);
}
이 방법은 기존 컬렉션 객체를 유지하면서 내용만 교체하므로 불변 컬렉션 문제를 우회할 수 있다.
🖋️ 분석
💡 왜 초기생성은 되고, 삭제&생성은 안돼?
분명 회원가입 할때 초기에 UserData,UserMedian을 만들때는 set을 써서 했는데,
왜 초기화 할 때 "삭제&생성"하는 건 안될까..??
이 차이점은 JPA와 Hibernate의 내부 동작 방식, 그리고 엔티티의 생명주기와 관련이 있다.
회원가입 시 (초기 설정):
회원가입 시에는 User 엔티티가 아직 영속화되지 않은 상태(transient state)이다.
이 때 setUserData와 setUserMedian을 사용하여 컬렉션을 설정하면,
JPA는 이를 그대로 받아들이고 나중에 영속화할 때 이 컬렉션을 사용한다.
이미 영속화된 엔티티 수정 시:
엔티티가 이미 데이터베이스에 저장되고 영속 상태가 된 후에는, JPA/Hibernate가 해당 엔티티의 컬렉션을 특별한 방식으로 관리한다.
이때 컬렉션을 직접 교체하려고 하면 (setter를 통해) 문제가 발생할 수 있다.
JPA는 영속 엔티티의 컬렉션을 자체적으로 관리하는 프록시 객체로 감싸는 경우가 많다. ("래핑!!!")
이 프록시 객체는 변경 감지, 지연 로딩 등의 기능을 제공한다.
setter로 컬렉션을 교체하려고 하면, 이 프록시 객체가 제거되고 JPA의 관리 기능이 상실될 수 있다...
-> clear()와 addAll()를 사용
이 방법은 기존 컬렉션 객체(JPA가 관리하는 프록시 객체)를 유지하면서 내용만 변경한다.
따라서 JPA의 변경 감지 및 기타 기능이 정상적으로 작동한다.
💡 결론
1. 하이버네이트는 엔티티를 영속 상태로 만들 때 컬렉션 필드를 하이버네이트에서 미리 만들어둔 컬렉션으로 감싸서 사용한다.
하이버네이트는 컬렉션을 효율적으로 관리하기 위해 엔티티를 영속 상태로 만들 때, 원본 컬렉션을 감싸고 있는 래퍼 컬렉션을 생성하고 이 래퍼 컬렉션을 사용하도록 참조를 변경한다.
하이버네이트는 이런 특징 때문에 컬렉션을 사용할 때 다음처럼 즉시 초기화해서 사용할 것을 권장한다.
List<User> user = new ArrayList<>();
2. set을 사용하면, 기존 컬렉션 객체를 완전히 새로운 객체로 교체하려고 시도한다.
그러나 JPA가 관리하는 래퍼 객체를 제거하려고 하므로 UnsupportedOperationException이 발생할 수 있다.
💡 코드 개선
// 새로운 UserData와 UserMedian 생성
List<UserData> newUserDataList = UserData.createUserDataList(user);
List<UserMedian> newUserMedianList = UserMedian.createUserMedianList(newUserDataList, user);
// UserData UserMedian 컬렉션 업데이트
user.getUserData().clear();
user.getUserMedian().clear();
userRepository.saveAndFlush(user); // <= 이거!!
user.getUserData().addAll(newUserDataList);
user.getUserMedian().addAll(newUserMedianList);
* 아래 링크 두 개 참고
😭 마치며.
우리는 백엔드 개발을 하며, 이러한 과정으로 코딩을 한다.
엔티티 설계 -> 비즈니스 로직을 서비스에 만들고, 컨트롤러를 완성하고...
그러나 개발을 하면 할수록 기본적인 개념을 가볍게 하고 넘어왔다는 점이 항상 발목을 잡는다..
거의 프로젝트를 여러 번 하다보면 복사 붙여넣기 하듯이 그대로 끌어다 쓴다.
코드 한 줄 한 줄의 이유를 설명할 수 있는가...
에러가 터질 때마다 나를 돌아보게 된다ㅜㅜ
cascade = CascadeType.ALL, orphanRemoval = true 와 같은 옵션도 알고 사용하자 알고!