Spring Boot 에서 MongoDB 에 접근하기 위해 ORM을 지원하는 Spring Data MongoDB 라이브러리를 사용하였다.
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
에러내용
MongoDB 도큐먼트와 중복된 _id 를 가진 DTO 를 넣기 위해 repository의 save
메소드를 실행했다.
public interface CollectionRepository extends MongoRepository<CollectionDto, String> {
}
CollectionDto collectionDto = new CollectionDto();
// fill in dto
collectionRepository.save(collectionDto);
그런데, 다음과 같은 익셉션을 내뿜었다.
Caused by: org.springframework.dao.DuplicateKeyException: Bulk write operation error on server ... . Write errors: [BulkWriteError{index=0, code=11000, message='E11000 duplicate key error collection: ... index: _id_ dup key: { _id: "4168166017" }', details={}}]. ; nested exception is com.mongodb.MongoBulkWriteException
DuplicateKeyException
?? Spring Data 프로젝트에서 제공되는 save
메소드는 기본적으로 upsert로 구현되어 있기 때문에 당연히 문제 없이 성공할 줄 알았는데, 뜻밖에 익셉션이었다.
※ upsert: 새 도큐먼트일 경우 insert, 기존 도큐먼트일 경우 update
원인파악
내가 생각한 DuplicateKeyException
발생 원인에 대한 가설은 2가지이다.
1번) save
메소드가 upsert 를 지원 안 하고 무조건 insert만 하나..? 내가 잘못 알고 있었나?
2번) 삽입할 도큐먼트가 뉴비라고만 인식해서 update가 아닌 새 insert 만 일어나는건가?
결국 답은 2번이었다! 왜 답이 1번이 아닌 2번인지 공유하려한다.
[1번 가설] save 메소드 가 upsert 를 지원 안 하고 무조건 insert만 하나..? 내가 잘못 알고 있었나?
MongoRepository
인터페이스에 있는 save
메소드의 실제 구현 내용을 보자
SimpleMongoRepository.java
@Override
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null!");
if (entityInformation.isNew(entity)) {
return mongoOperations.insert(entity, entityInformation.getCollectionName());
}
return mongoOperations.save(entity, entityInformation.getCollectionName());
}
entity
가 null 이 아닌지 검증 후 entity
가 new 이면 insert()
를, new 가 아니면 save()
를 호출하고 그 결과값을 반환하는 내용이다. 그렇다면 upsert를 지원한다는 뜻이 된다.
아래 링크에서도 _id 가 중복인 문서를 save
메소드를 통해 정상적으로 update 하는 모습을 확인할 수 있다.
https://www.concretepage.com/spring-5/spring-data-mongorepository-update
그러므로, 1번 가설은 틀렸다. save
메소드는 upsert 를 지원한다!
[2번 가설] 삽입할 도큐먼트가 뉴비라고만 인식해서 update가 아닌 새 insert 만 일어나는건가?
DTO는 이렇게 생겼다.
@Document("collection_name")
@Getter @Setter @Builder
public class CollectionDto {
@Id
private String _id;
...
@CreatedDate
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime lastModifiedDate;
@Version
private Long version; // 없으면 @CreatedDate 필드 주입 안됨
}
@CreatedDate
, @LastModifiedDate
: MongoDB Auditing 을 위해 추가
@Version
: @CreatedDate
의 정상 작동을 위해 추가
@Version 애노테이션 설명
@Version
는 중복된 _id 가 들어왔을 때 버전 관리를 위한 필드이다. @Version
필드가 없으면 @CreatedDate
필드값이 null이다. 새 도큐먼트일 경우 version을 0으로 초기화하고 @CreatedDate
필드에 생성시간 값을 넣어준다.
바로 이 @Version
이 원인이었던 것이다! 위에서 본 SimpleMongoRepository.java
에서 파라미터로 받은 entity
를 isNew
메소드로 새 문서인지 아닌지 판단하는 것을 봤을 것이다.
isNew
메소드의 구현 내용은 이렇다.
PersistentEntityIsNewStrategy.java
@Override
public boolean isNew(Object entity) {
Object value = valueLookup.apply(entity);
if (value == null) {
return true;
}
if (valueType != null && !valueType.isPrimitive()) {
return false;
}
if (value instanceof Number) {
return ((Number) value).longValue() == 0;
}
throw new IllegalArgumentException(
String.format("Could not determine whether %s is new! Unsupported identifier or version property!", entity));
}
읽어보면 알 수 있듯이 value
혹은 valueType
으로 판단하는데, 디버깅해본 결과 @Version
애노테이션이 존재하면 @Version
값이, 존재하지 않으면 @Id
값이 value 에 저장된다.
@Version
필드 타입은 Long 혹은 long 이다. 그리고 이 값은 프로그래머가 제어하지 않기 때문에 Long 타입일 경우 null, long 타입일 경우 0 의 초기값을 갖는다.
그래서 Long 타입일 때는 if (value == null) {return true;}
에 걸리고, long 타입일 때는if (value instanceof Number) {return ((Number) value).longValue() == 0;}
에 걸려 true 가 반환 되고 결국 중복된 _id가 들어와도 무조건 새 문서라고 판단해 DuplicateKeyException
를 내뱉었던 것이다.
해결방법
DuplicateKeyException
를 해결하려면 2가지 방법이 있다.
방법1) @Version
필드를 없애면 된다.
방법2) @Version
필드를 없앨 수 없다면, find
메소드를 통해 MongoDB 에서 먼저 받아온 도큐먼트로 업데이트 작업을 한다.
방법1) @Version
필드를 없애면 된다.
위의 isNew 메소드에서 새 문서라고 판단하는 값의 우선순위는 @Version
이 @Id
보다 높다. 그렇기 때문에 @Version
필드가 없어지면, @Id
로 판단하게 되고 null 이 아닌 이상 대부분 if (valueType != null && !valueType.isPrimitive()) {return false;}
에 걸려 새 문서가 아니라고 판단하여 insert 가 아닌 update 를 실행하게 될 것이다.
@Document("collection_name")
@Getter @Setter @Builder
public class CollectionDto {
@Id
private String _id;
...
@LastModifiedDate
private LocalDateTime lastModifiedDate;
}
※ @LastModifiedDate
는 @Version
이 없어도 정상적인 값이 적재된다.
방법2) @Version
필드를 없앨 수 없다면, find
메소드를 통해 MongoDB 에서 먼저 받아온 도큐먼트로 업데이트 작업을 한다.
MongoDB 에 이미 적재된 도큐먼트는 version 이 초기값이 아니기 때문에 find
메소드를 통해 받아온 도큐먼트로 작업 후 save
메소드를 호출하면 정상적으로 upsert가 실행될 것이다.
CollectionDto collectionDto = collectionRepository.findById("111111").get();
// something to update
collectionRepository.save(collectionDto);
※ 이 방법은 find 메소드 호출로 인해 네트워크 통신이 추가적으로 들어 실행시간이 더 소요될 수 있다. 그래서 find 메소드를 필수로 호출해야하는 상황이 아니라면 방법1 을 추천한다.
Spring Data Mongo save 시 DuplicateKeyException 나는 이유 및 해결
글 읽어주셔서 언제나 감사합니다. 좋은 피드백, 개선 피드백 너무나도 환영합니다.
참고링크
https://docs.spring.io/spring-data/couchbase/docs/current/reference/html/#version
https://www.concretepage.com/spring-5/spring-data-mongorepository-update
'SearchDeveloper > SpringBoot' 카테고리의 다른 글
[JPA] Multi Datasource 설정하기 (1) | 2022.11.06 |
---|---|
[JPA] Querydsl 시작하기 (0) | 2022.10.21 |
[JPA] Querydsl 에서 Fetch Join 적용 안되는 이유 (3) | 2022.10.12 |
Spring REST Docs 이해 (2) | 2022.09.24 |
스프링부트 jar 구성 및 실행 원리 (4) | 2022.09.08 |