FixtureMonkey로 만든 엔티티가 저장되지 않던 이유: save()의 persist와 merge

@yunhobb· November 19, 2025 · 3 min read

통합 테스트(IntegrationTestSupport 상속, 실제 DB 사용)에서 FixtureMonkey로 엔티티를 만들어 저장한 뒤 다시 조회하는 코드가 있었습니다. 저장은 분명히 했는데 조회가 비거나 검증이 어긋났고, 원인은 FixtureMonkey가 @Id 필드에까지 랜덤 값을 채운 데 있었습니다. 이 글은 그 랜덤 ID가 어떻게 저장을 어긋나게 하는지를 save() 내부의 persist·merge 분기로 정리합니다.

대상 엔티티는 모두 아래와 같은 형태입니다.

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

1. 현상: 저장한 엔티티를 다시 찾지 못한다

FixtureMonkey의 giveMeBuilder(...).sample()은 명시하지 않은 필드까지 포함해 객체의 모든 필드를 임의 값으로 채웁니다. @Idid도 예외가 아니라서, 아래 코드의 entity는 저장하기 전부터 랜덤 Long id를 가집니다.

ContractApplicationEntity entity = fixtureMonkey.giveMeBuilder(ContractApplicationEntity.class)
    .set("contractorName", "이완료")
    .set("branchId", BranchId.SOONGIN)
    .sample();              // id 에도 랜덤 Long 이 들어가 있음

repository.save(entity);
// 이후 entity.getId() 로 조회하면 기대한 행을 찾지 못하는 경우가 있음

DB에 행 자체는 들어가는데, 테스트가 들고 있는 entity의 id와 실제 저장된 행의 id가 달라지는 것이 문제였습니다. ID 생성을 DB(IDENTITY)에 맡기고 있는데 객체에는 이미 다른 id가 들어 있다는 점이 단서였습니다.


2. 원인: save()가 persist가 아니라 merge로 분기한다

Spring Data JPA의 SimpleJpaRepository.save()는 새 엔티티인지 아닌지를 보고 분기합니다.

// org.springframework.data.jpa.repository.support.SimpleJpaRepository
@Override
public <S extends T> S save(S entity) {
  if (entityInformation.isNew(entity)) {
    entityManager.persist(entity);
    return entity;
  } else {
    return entityManager.merge(entity);
  }
}

여기서 isNew()의 기본 구현(JpaMetamodelEntityInformation)은 @Id 필드가 null인지로 새 엔티티 여부를 판단합니다. id가 null이면 새 엔티티로 보고 persist(), id가 있으면 기존 엔티티로 보고 merge()를 호출합니다.

FixtureMonkey가 id에 랜덤 값을 채워 두었으니 isNew()false가 되고, save()persist()가 아니라 merge()로 들어갑니다. 의도(새 행 삽입)와 실제 호출(merge)이 어긋나는 지점이 여기입니다.


3. merge가 존재하지 않는 id를 만났을 때

persist()merge()는 비슷해 보이지만 다루는 인스턴스가 다릅니다. 이 차이가 ID 동작을 가릅니다.

  • persist(entity)는 넘긴 인스턴스 자체를 영속 상태로 만든다. IDENTITY 전략이면 INSERT 후 DB가 생성한 id가 그 인스턴스에 다시 채워진다. 즉 호출 뒤 entity.getId()가 실제 저장된 id가 된다.
  • merge(entity)는 넘긴 인스턴스를 직접 관리하지 않고, 그 상태를 복사한 별도의 관리 인스턴스를 반환한다. 원본 인스턴스(detached)는 그대로 남는다.

merge는 먼저 넘어온 id로 영속 컨텍스트와 DB에 해당 행이 있는지 확인합니다(필요 시 SELECT). 랜덤 id에 해당하는 행은 DB에 없으므로, Hibernate는 이를 존재하지 않는 엔티티로 보고 새로 INSERT를 예약합니다. IDENTITY 전략에서는 이때 DB가 새 id를 부여하고, 그 id는 merge가 반환한 관리 인스턴스에만 반영됩니다.

정리하면 이렇게 어긋납니다.

entity.id = 8421 (FixtureMonkey 랜덤)
  └ save() → isNew()=false → merge(entity)
       └ id=8421 행 조회 → 없음 → INSERT
            └ DB가 새 id(예: 1) 부여
                 └ 이 id는 merge 가 "반환한" 인스턴스에만 있음
entity.id = 8421  ← 원본은 그대로, 반환값을 안 받으면 끝까지 8421

테스트가 repository.save(entity)의 반환값을 따로 받지 않고 원본 entity의 id로 조회·검증하면, DB의 실제 행(id=1)을 찾지 못합니다. "저장은 됐는데 조회가 빈다"는 현상이 여기서 나옵니다.

참고: 위 INSERT·SELECT 흐름은 IDENTITY 전략과 JPA merge 의미론에 근거해 설명한 것입니다. 실제 쿼리 로그까지 그대로 대조해 확인하지는 못했고, 어긋나는 결과(반환 인스턴스에만 새 id가 반영됨)는 동작으로 확인했습니다.


4. 해결: setNull("id")로 persist 경로를 타게 한다

수정은 빌더에 .setNull("id") 한 줄을 더하는 것입니다.

ContractApplicationEntity entity = fixtureMonkey.giveMeBuilder(ContractApplicationEntity.class)
    .setNull("id")                 // id 를 null 로 → isNew()=true
    .set("contractorName", "이완료")
    .set("branchId", BranchId.SOONGIN)
    .sample();

repository.save(entity);           // persist() 실행, INSERT 후 entity.getId() 에 생성된 id 반영

id를 null로 두면 isNew()true가 되어 save()persist()를 호출합니다. persist()는 넘긴 인스턴스를 그대로 관리하므로 INSERT 후 생성된 id가 entity에 채워지고, 이후 entity.getId()로 하는 조회·검증이 실제 행과 맞아떨어집니다.

이번 PR에서는 통합 테스트와 Fixture 헬퍼(MscFixture, SpaceFixture)에서 FixtureMonkey로 엔티티를 만드는 자리마다 .setNull("id")를 추가했습니다.

주의: ID를 명시적으로 다뤄야 하는 경우(예: UPDATE 동작을 검증하는 테스트)나 @GeneratedValue 없이 ID를 직접 할당하는 엔티티는 예외입니다. 이때는 오히려 id를 채워 두는 것이 맞습니다.


5. 마무리: 같은 실수를 규칙으로 막는다

이 문제의 근본 원인은 "테스트 객체 생성기가 채운 값"과 "DB가 채워야 할 값"이 같은 필드(@Id)에서 충돌한 데 있습니다. FixtureMonkey는 명시하지 않은 필드를 알아서 채워 주는 것이 장점이지만, ID 생성을 DB에 위임한 엔티티에서는 그 장점이 그대로 문제를 일으킵니다.

한 번 겪고 끝낼 문제가 아니라 팀의 누구나 다시 마주칠 수 있는 문제라, 이번에 테스트 작성 가이드(test-guidelines.md)에 규칙으로 넣었습니다. FixtureMonkey로 JPA 엔티티를 만들 때 .setNull("id") 누락은 코드 리뷰에서 짚는 항목(P1)으로 두었습니다. 개별 수정으로 끝내기보다 같은 실수가 리뷰에서 걸리도록 만드는 편이 비용이 적다고 봤습니다.

@yunhobb
녹차 주도 개발