❗ 왜 ChatRoomsEntity에서는 문제였고, UserEntity에서는 문제 아니었을까?
1. 두 엔티티 모두 UUID 직접 지정
→ 동일
2. 두 엔티티 모두 @Version 없음
→ 동일
✅ 3. 차이점은 다음 중 하나일 가능성:
항목 | ChatRoomsEntity | UserEntity |
동일 UUID 요청이 중복으로 들어옴 | O (다중 트랜잭션 충돌 가능) | ❌ |
merge() 시 해당 UUID row가 존재 | ❌ (그러나 Hibernate가 "존재한다고 착각") | ❌ (정상 판단) |
이전에 rollback되었거나 Detached 상태였음 | O | ❌ |
즉, ChatRoomsEntity는 Hibernate 내부적으로 해당 UUID가 이미 detached 상태로 인식되거나,
flush 타이밍 문제로 충돌을 일으킨 반면, UserEntity는 깨끗한 상태에서 merge insert가 성공한 케이스입니다.
@Entity
@Table(name = "p_chat_rooms")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Getter
public class ChatRoomsEntity extends BaseEntity {
@Id
@GeneratedValue(generator = "UUID")
@Column(name = "chat_room_id", updatable = false, nullable = false, unique = true)
private UUID chatRoomId;
@Column(name = "chat_room_name", length = 50, nullable = false)
private String chatRoomName;
// 주인, 저장O
@ManyToOne
@JoinColumn(name = "owner_id", nullable = false)
private UsersEntity ownerId;//FK
@Enumerated(EnumType.STRING)
@Column(name = "room_type", nullable = false)
private ChatTypeEnum roomType;
//종속, 저장X
@OneToMany(mappedBy = "chatRoom", cascade = CascadeType.REMOVE)
private List<ChatRoomParticipantsEntity> chatParticipants = new ArrayList<>();
@Builder
private ChatRoomsEntity(
UUID chatRoomId
, String chatRoomName
, UsersEntity ownerId
, ChatTypeEnum roomType
, List<ChatRoomParticipantsEntity> chatParticipants
) {
this.chatRoomId = chatRoomId;
this.chatRoomName = chatRoomName;
this.ownerId = ownerId;
this.roomType = roomType;
this.chatParticipants = chatParticipants;
}
public static ChatRoomsEntity create(
UUID chatRoomId
, String chatRoomName
, UsersEntity ownerId
, ChatTypeEnum roomType
) {
return ChatRoomsEntity.builder()
.chatRoomId(chatRoomId)
.chatRoomName(chatRoomName)
.ownerId(ownerId)
.roomType(roomType)
.build();
}
public void updateChatRoomInfo(
UUID chatRoomId
, String chatRoomName
, UsersEntity ownerId
, ChatTypeEnum roomType
) {
this.chatRoomId = chatRoomId;
this.chatRoomName = chatRoomName;
this.ownerId = ownerId;
this.roomType = roomType;
}
}
public ChatRoomResponseDto createChatRoom(ChatRoomDto dto) {
//사용자 검증
UsersEntity userEntity = userService.getUserEntity(dto.getOwnerId());
// 서비스 레이어에서 UUID 생성
final UUID chatRoomId = dto.getChatRoomId() != null ? dto.getChatRoomId() : UUID.randomUUID();
dto.createId(chatRoomId);
ChatRoomsEntity chatRoom = ChatRoomsEntity.create(
dto.getChatRoomId()
, dto.getChatRoomName()
, userEntity
, dto.getRoomType()
);
ChatRoomsEntity saved = chatRoomRepository.save(chatRoom);//에러
return ChatRoomResponseDto.fromChatRoom(saved);
}
@DynamicUpdate
@Entity
@Table(name = "p_users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(value = {AuditingEntityListener.class})
public class UsersEntity extends BaseEntity {
@Id
@Column(name = "user_id", columnDefinition = "UUID", nullable = false, unique = true)
private UUID userId;
@Email
@Column(name = "user_email", length = 320, nullable = false, unique = true)
private String email;
@JsonIgnore
@Column(name = "user_password", length = 60, nullable = false)
private String password;
@Column(name = "user_name", length = 50, nullable = false)
private String userName;
@Column(name = "user_phone_number", length = 20)
private String phoneNumber;
@Enumerated(EnumType.STRING)
@Column(name = "user_role", nullable = false)
private UserRoleEnum role;
@Embedded
private AddressEntity addressEntity;
@Column(name = "is_block", nullable = false)
private boolean isBlock = false;
@Builder
private UsersEntity(
UUID userId
, String email
, String password
, String userName
, String phoneNumber
, UserRoleEnum role
, AddressEntity addressEntity
, boolean isBlock
) {
this.userId = userId;
this.email = email;
this.password = password;
this.userName = userName;
this.phoneNumber = phoneNumber;
this.role = role;
this.addressEntity = addressEntity;
this.isBlock = isBlock;
}
public static UsersEntity create(
UUID userId
, String email
, String passwordEncode
, String userName
, String phoneNumber
, UserRoleEnum role
, String zipCode
, String address1
, String address2
) {
return UsersEntity.builder()
.userId(userId)
.email(email)
.password(passwordEncode)
.userName(userName)
.phoneNumber(phoneNumber)
.role(role != null ? role : UserRoleEnum.CUSTOMER)
.addressEntity(AddressEntity.create(zipCode, address1, address2))
.build();
}
public void updateUserInfo(
String userName
, String phoneNumber
, UserRoleEnum role
) {
this.userName = userName;
this.phoneNumber = phoneNumber;
this.role = role;
}
public void updateMyInfo(String userName, String phoneNumber) {
this.userName = userName;
this.phoneNumber = phoneNumber;
}
public void changePassword(String password) {
this.password = password;
}
public void updateUserRole(UserRoleEnum role) {
this.role = role;
}
public void updateAddress(String zipCode, String address1, String address2) {
this.addressEntity.updateAddress(zipCode, address1, address2);
}
public void blockUser() {
this.isBlock = true;
}
public void unblockUser() {
this.isBlock = false;
}
}
public UserResponseDto createUser(UserDto dto) {
// email 중복 확인
if (usersRepository.existsByEmail(dto.getEmail())) {
throw new IllegalArgumentException("Email already exists: " + dto.getEmail());
}
// 서비스 레이어에서 UUID 생성
final UUID userId = dto.getUserId() != null ? dto.getUserId() : UUID.randomUUID();
dto.createId(userId);
// 비밀번호 암호화
final String encodedPassword = passwordEncoder.encode(dto.getPassword());
UsersEntity user = UsersEntity.create(
dto.getUserId(),
dto.getEmail(),
encodedPassword,
dto.getUserName(),
dto.getPhoneNumber(),
dto.getRole(),
dto.getZipCode(),
dto.getAddress1(),
dto.getAddress2()
);
return UserResponseDto.fromUser(usersRepository.save(user));
}
userEntity, chatRoomEntity 둘다 @Version 없는 환경
user 생성 되는데, chatRoom은 생성안됨 ObjectOptimisticLockingFailureException 발생
→ 현재 `createChatRoom()`은 단순히 새로 생성된 방을 저장하는 로직임.
→ 하지만 이미 DB에 존재하는 `chatRoomId`로 `save()`하면 merge 동작이 발생.
→ 그때 `@Version` 값이 다르면 → 낙관적 락 충돌 발생 → 에러 터짐
왜 save()에서 merge가 발생했을까?
public ChatRoomResponseDto createChatRoom(ChatRoomDto dto) {
//사용자 검증
UsersEntity userEntity = userService.getUserEntity(dto.getOwnerId());
// 서비스 레이어에서 UUID 생성
final UUID chatRoomId = dto.getChatRoomId() != null ? dto.getChatRoomId() : UUID.randomUUID();
dto.createId(chatRoomId);
ChatRoomsEntity chatRoom = ChatRoomsEntity.create(
dto.getChatRoomId()
, dto.getChatRoomName()
, userEntity
, dto.getRoomType()
);
ChatRoomsEntity saved = chatRoomRepository.save(chatRoom);
return ChatRoomResponseDto.fromChatRoom(saved);
}
Spring Data JPA의 save()는 아래처럼 동작
조건 | 동작 방식 |
id == null | persist() → insert |
id != null and DB 존재 | merge() → update |
id != null and DB 없음 | merge() → insert, but 버전 필드가 불일치하면 실패 |
if (entity.id != null) {
// 영속 상태 아님 → merge()
// chatRoomId가 세팅돼 있어서 merge()를 타게 된 것.
// DB에는 실제로 해당 row가 없으니 Hibernate가 "row가 삭제되었거나, 버전이 안 맞음"으로 판단한 것임.
} else {
// 새 엔티티로 보고 → persist()
}
chatRoomId가 명시적으로 들어왔고, JPA가 merge()로 처리하려다가 version 충돌이 발생
✅ 문제 발생 과정
- chatRoomId는 외부에서 넘어온 값(dto.getChatRoomId())으로 설정됨
- 하지만 DB에는 해당 chatRoomId 값에 해당하는 row가 없음
- 이 상태에서 JpaRepository.save(entity) 호출 시 merge() 동작 → Hibernate는 "update or delete된 row"라고 오해
- → 결국 StaleObjectStateException 발생
✅ 해결방법: 가장 안전한 방법 (정석)
- 신규 생성 시에는 @GeneratedValue로 UUID 자동 생성
- ID를 직접 지정하고 저장할 땐:
- existsById() 등으로 명확한 존재 여부 판단
- 또는 @Version 적용해서 낙관적 락으로 안정성 확보
- 또는 EntityManager.persist(...) 명시 호출
☑️ 방법 1. ChatRoomRepository 구현체에서 persist() 사용
DDD 스타일로 Infrastructure Layer에서 EntityManager 사용하기
public class ChatRoomRepositoryImpl implements ChatRoomRepository {
private final EntityManager em;
public ChatRoomRepositoryImpl(EntityManager em) {
this.em = em;
}
@Override
public ChatRoomsEntity save(ChatRoomsEntity chatRoom) {
em.persist(chatRoom);
return chatRoom;
}
}
☑️ 방법 2. save() 호출 시 ID를 null로 두도록 수정 → persist()로 자동 유도
현재 문제는 Hibernate가 ID가 있으면 merge()로 처리해서 발생했죠?
→ 그래서 ID를 아예 null로 두면 Hibernate가 알아서 persist()로 처리해요:
✅ @GeneratedValue랑 @GenericGenerator 붙였는데 왜 의미 없었을까?
@Entity
@Table(name = "p_chat_rooms")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Getter
public class ChatRoomsEntity extends BaseEntity {
@Id
@GeneratedValue(generator = "UUID")
@GenericGenerator(
name = "UUID",
strategy = "org.hibernate.id.UUIDGenerator"
)
@Column(name = "chat_room_id", updatable = false, nullable = false, unique = true)
private UUID chatRoomId;
@GeneratedValue는 Hibernate가 ID를 생성할 때만 의미가 있어요.
직접 UUID를 넣어버리면 → Hibernate는 ID가 이미 있으니 “수정이구나!” → merge() 실행
🅱️ 직접 UUID를 지정하고 싶다면?
- save()가 아닌 entityManager.persist(entity)로 명시적으로 persist를 호출
'학습 기록 (Learning Logs) > Today I Learned' 카테고리의 다른 글
이미지 모델 테스트 (0) | 2025.04.17 |
---|---|
LLM (0) | 2025.04.17 |
주소, 세그멘테이션, 페이징 (0) | 2025.04.14 |
분산 시스템 장애 복구 (0) | 2025.04.14 |
DB (0) | 2025.04.14 |