본문 바로가기

SearchDeveloper/SpringBoot

Spring Data Mongo save 시 DuplicateKeyException 나는 이유 및 해결

Spring Data MongoDB

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 필드에 생성시간 값을 넣어준다.

※ 참고 링크: https://stackoverflow.com/questions/35584271/spring-data-mongodb-annotation-createddate-isnt-working-when-id-is-assigned-m

바로 이 @Version 이 원인이었던 것이다! 위에서 본 SimpleMongoRepository.java 에서 파라미터로 받은 entityisNew 메소드로 새 문서인지 아닌지 판단하는 것을 봤을 것이다.

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

https://stackoverflow.com/questions/35584271/spring-data-mongodb-annotation-createddate-isnt-working-when-id-is-assigned-m