📌 문제 상황
토이 프로젝트에서 JPA 엔티티의 PK를 래퍼 클래스 Long으로 지정하고 Repository의 save에 대한 테스트 코드를 작성하던 중, 다음 오류가 발생했습니다.
Hibernate: select ue1_0.id,ue1_0.created_at,ue1_0.email,ue1_0.nickname,ue1_0.password,ue1_0.updated_at from users ue1_0 where ue1_0.id=?
Hibernate: select ue1_0.id,ue1_0.created_at,ue1_0.email,ue1_0.nickname,ue1_0.password,ue1_0.updated_at from users ue1_0 where ue1_0.id=?
Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [toy.triplog.domain.user.UserEntity#0]
org.springframework.orm.ObjectOptimisticLockingFailureException: Row was upda ted or deleted by another transaction (or unsaved-value mapping was incorrect): [toy.triplog.domain.user.UserEntity#0]
오류의 내용은 작업 수행 중 다른 트랜잭션에 의해 row가 수정되거나 삭제되었다는 내용입니다. 그런데 테스트하려던 save 메서드는 다음과 같습니다.
@Repository
@RequiredArgsConstructor
public class UserRepositoryImpl implements UserRepository {
private final JpaUserRepository jpaUserRepository;
@Override
public User save(User user) {
UserEntity userEntity = UserEntity.from(user);
UserEntity savedUserEntity = jpaUserRepository.save(userEntity);
return savedUserEntity.toUser();
}
}
위 메서드를 보면 save 외에 별 다른 작업 내용이 존재하지 않습니다. 왜 오류가 발생하는걸까요?
상황을 좀 더 자세히 보기 위해, User와 UserEntity에 대해서 살펴보겠습니다.
User.java
@Getter
public class User {
private Long id;
private final String email;
private final String password;
private final String nickname;
@Builder
private User(long id, String email, String password, String nickname) {
this.id = id;
this.email = email;
this.password = password;
this.nickname = nickname;
}
public static User create(SignUpRequest signUpRequest) {
return User.builder()
.email(signUpRequest.email())
.password(signUpRequest.password())
.nickname(signUpRequest.nickname())
.build();
}
}
UserEntity.java
@Entity
@Table(name = "users")
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class UserEntity extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
private String password;
private String nickname;
public static UserEntity from(User user) {
return UserEntity.builder()
.id(user.getId())
.email(user.getEmail())
.password(user.getPassword())
.nickname(user.getNickname())
.build();
}
public User toUser() {
return User.builder()
.id(this.id)
.email(this.email)
.password(this.password)
.nickname(this.nickname)
.build();
}
}
📌 에러의 원인은 PK
위 코드를 보고 원인을 찾으신 분들도 있으시겠지만, 문제의 원인은 PK에 있었습니다.
현재 UserEntity의 PK 생성 전략은 DB의 자동 생성 전략을 사용하고 있습니다. 제가 생각했던 유저 생성 시, PK가 등록되는 과정은 다음과 같습니다.
- User.create(signUpRequest) 실행 시, User의 id는 null 상태로 존재
- UserEntity.from(user) 실행 시, null 상태인 user의 id가 그대로 전달
- jpaUserRepository.save(userEntity) 실행 시, null 상태에서 DB가 auto_increment를 통해 PK 생성하여 주입
하지만 디버그 모드로 실행하면 User의 id는 0으로 저장이 되어있습니다.

Long의 0과 long의 0
Long id의 값은 null이 들어가 있을 것이라는 제 생각과 다르게 0이 저장되어 있었습니다. primitive 타입인 long 값에 0이 있다면, JPA는 PK가 아직 생성되지 않았다고 판단하여 PK를 생성하여 주입할 것입니다. 그렇다면 Long 값에 0이 있다면 어떻게 될까요?
우선 JpaRepository의 save 수행 시, 들어온 엔티티가 새로 생성된 엔티티인지 확인합니다. 새로운 Entity인지 여부는 JpaEntityInformation의 isNew(T entity)에 의해 판단됩니다. 다른 설정이 없으면 JpaEntityInformation의 구현체 중 JpaMetamodelEntityInformation 클래스가 동작합니다. @Version이 사용된 필드가 없거나 @Version이 사용된 필드가 primitive 타입이면 AbstractEntityInformation의 isNew()를 호출합니다.
AbstractEntityInformation 클래스의 isNew() 메서드에 디버그 포인트를 설정하고 테스트를 실행하면, 해당 포인트에서 멈추게 됩니다.
public boolean isNew(T entity) {
ID id = (ID)this.getId(entity);
Class<ID> idType = this.getIdType();
if (!idType.isPrimitive()) {
return id == null;
} else if (id instanceof Number) {
Number n = (Number)id;
return n.longValue() == 0L;
} else {
throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType));
}
}
코드를 보면 primitive 타입이 아닌 경우, null 여부를 통해 새로운 엔티티인지 확인합니다. 결국, 제가 작성한 코드에서는 새로운 엔티티로 판단하지 않습니다. 그래서 JPA는 엔티티를 삽입하지 않고 병합을 시도하게 됩니다. JPA는 id가 0인 Row를 검색하였지만, 실제 해당 엔티티는 존재하지 않으므로 JPA는 존재해야 할 데이터가 없어졌다고 생각하기 때문에 해당 row가 다른 트랜잭션에 의해 삭제되었다고 판단합니다. 에러 로그를 다시 살펴보겠습니다.
Hibernate: select ue1_0.id,ue1_0.created_at,ue1_0.email,ue1_0.nickname,ue1_0.password,ue1_0.updated_at from users ue1_0 where ue1_0.id=?
Hibernate: select ue1_0.id,ue1_0.created_at,ue1_0.email,ue1_0.nickname,ue1_0.password,ue1_0.updated_at from users ue1_0 where ue1_0.id=?
Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [toy.triplog.domain.user.UserEntity#0]
org.springframework.orm.ObjectOptimisticLockingFailureException: Row was upda ted or deleted by another transaction (or unsaved-value mapping was incorrect): [toy.triplog.domain.user.UserEntity#0]
이제 위 로그들을 이해할 수 있게 됩니다.
- 이미 존재하는 엔티티로 판단하여 병합을 위해 조회
- 존재하는 엔티티가 사라졌다고 판단하여 에러 발생
왜 Long id가 0일까?
유저의 생성 로직은 다음과 같습니다.
@Getter
public class User {
private Long id;
private final String email;
private final String password;
private final String nickname;
@Builder
private User(long id, String email, String password, String nickname) {
this.id = id;
this.email = email;
this.password = password;
this.nickname = nickname;
}
public static User create(SignUpRequest signUpRequest) {
return User.builder()
.email(signUpRequest.email())
.password(signUpRequest.password())
.nickname(signUpRequest.nickname())
.build();
}
}
User.create(SignUpRequest) 메서드는 id를 지정하지 않습니다. 따라서 null인 상태일 것이라 생각했는데, 왜 0이 들어간 것일까요?
User 생성자를 확인해 보면 다음과 같습니다.
@Builder
private User(long id, String email, String password, String nickname) {
this.id = id;
this.email = email;
this.password = password;
this.nickname = nickname;
}
생성자의 id 파라미터를 보면 primitive 타입인 long으로 되어 있습니다. Lombok의 @Builder를 통해 생성된 코드를 확인해 보기 위해 컴파일된 User 클래스에서 생성된 코드 내용을 확인해보겠습니다.
@Generated
public static class UserBuilder {
@Generated
private long id;
@Generated
private String email;
@Generated
private String password;
@Generated
private String nickname;
@Generated
UserBuilder() {
}
@Generated
public UserBuilder id(final long id) {
this.id = id;
return this;
}
@Generated
public UserBuilder email(final String email) {
this.email = email;
return this;
}
@Generated
public UserBuilder password(final String password) {
this.password = password;
return this;
}
@Generated
public UserBuilder nickname(final String nickname) {
this.nickname = nickname;
return this;
}
@Generated
public User build() {
return new User(this.id, this.email, this.password, this.nickname);
}
@Generated
public String toString() {
return "User.UserBuilder(id=" + this.id + ", email=" + this.email + ", password=" + this.password + ", nickname=" + this.nickname + ")";
}
UserBuilder 클래스의 id는 long 타입이므로 0으로 저장됩니다. 결국, Builder에서 id를 지정하지 않더라도, UserBuilder는 id로 0을 가지고 있기 때문에 User의 id는 0이 됩니다. 따라서 이 문제를 해결하려면, User의 id 타입과 생성자의 파라미터 타입을 일치시켜야 합니다.
PK로서 Long vs long
이 주제와 관련된 인프런 질문이 있습니다.
https://www.inflearn.com/community/questions/35759/long-타입에-대한-질문입니다
위 내용에 따르면, Long을 사용하는 이유는 주로 null을 통해 PK가 없다는 것을 보장할 수 있기 때문입니다. 하지만 primitive 타입인 경우 0이 실제로 id 값이 0인지, 값이 없는 건지 구분하기가 어렵습니다.
또한 Hibernate도 nullable한 타입을 사용하기를 권장합니다. 따라서 래퍼 클래스인 Long을 사용하는 것이 더 좋습니다.
📌 정리
사실 이번 문제의 큰 원인은 생성자와 Builder에 있었습니다. User의 id는 Long 타입이지만, 생성자의 id 파라미터의 타입은 primitive 타입인 long이었습니다. 이로 인해 Builder 생성 시, long 타입으로 생성되고, primitive 타입이기에 값을 지정하지 않아도 내부에서 0으로 저장됩니다.
저의 실수로 인한 에러였지만 로그로부터 원인을 찾는데 생각보다 오래 걸렸습니다. 이번 기회에 롬복의 @Builder에 대해 자세히 알아볼 수도 있었고, PK 타입의 차이에 대해서도 확인할 수 있었습니다.
'트러블 슈팅' 카테고리의 다른 글
EC2 CPU 사용량 급증 문제 (0) | 2025.01.09 |
---|---|
배포 시 레디스 초기화 문제 (1) | 2025.01.07 |
Spring Data JPA 구현체 순환참조 오류 (1) | 2024.12.18 |