본문 바로가기

학습 기록 (Learning Logs)/Today I Learned

ObjectOptimisticLockingFailureException

❗ 왜 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 발생

 

 

 

✅  해결방법: 가장 안전한 방법 (정석)

  1. 신규 생성 시에는 @GeneratedValue로 UUID 자동 생성
  2. 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를 지정하고 싶다면?

  1. 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