JPA 영속성 컨텍스트에서 '동일성'이 보장되는 이유

13 min read

1. '동일성'이 의미하는 건 정확히 무엇인가요?

여기서 말하는 동일성은 자바 객체 관점의 동일성을 의미합니다.

동일성(Identity)

a == b가 true인 상태로, 두 변수가 완전히 같은 객체 참조를 가리키는 경우입니다.

동등성(Equality)

a.equals(b)가 true인 상태로, 객체의 값이 같다고 정의한 경우입니다.

JPA 스펙의 정의

"영속성 컨텍스트 안에서는 같은 엔티티 식별자(PK)에 대해 유일한 엔티티 인스턴스가 존재한다"

즉, 같은 PK를 가진 엔티티라면 영속성 컨텍스트 내부에서는 항상 같은 객체로 관리된다는 의미입니다.

이 개념이 바로 우리가 말하는 영속성 컨텍스트의 동일성의 핵심입니다.


2. 동일성은 어떻게 구현되나요? (아이덴티티 맵 / 1차 캐시)

핵심 요약 한 줄

영속성 컨텍스트는 (엔티티 타입 + PK) → 엔티티 객체로 매핑하는 맵 구조를 내부적으로 관리합니다.

Hibernate 기준으로 설명하면, EntityManager(JPA) 뒤에는 Session(Hibernate)이 존재하고, 이 Session 내부에 PersistenceContext, 즉 흔히 말하는 1차 캐시가 있습니다.

이 구조는 개념적으로 다음과 같습니다:

  • Key: 엔티티 식별자 (엔티티 타입 + PK)
  • Value: 엔티티 인스턴스(객체 참조)

그래서 같은 트랜잭션, 정확히는 같은 영속성 컨텍스트 범위 안에서 아래 코드가 성립합니다:

Member a = em.find(Member.class, 1L);
Member b = em.find(Member.class, 1L);
 
System.out.println(a == b); // true
  1. 첫 번째 find() 호출 시: DB에서 데이터를 조회한 뒤 엔티티 객체를 생성하고, 그 객체를 1차 캐시에 저장합니다.
  2. 두 번째 find() 호출 시: DB를 다시 조회하지 않고, 1차 캐시에 이미 존재하는 객체 참조를 그대로 반환합니다.

이것이 바로 영속성 컨텍스트의 동일성이 보장되는 이유입니다.

즉, 동일성은 "우연히" 성립하는 것이 아니라 프레임워크 설계 차원에서 강제되는 성질입니다.


3. "같은 트랜잭션"이라는 표현이 은근히 위험한 이유

많은 분들이 한 번쯤 헷갈리는 지점이 있습니다.

보통 "같은 트랜잭션이면 동일성이 보장된다"라고 말하지만, 보다 정확한 표현은 다음과 같습니다:

같은 영속성 컨텍스트(EntityManager / Session)를 공유할 때 동일성이 보장된다

트랜잭션과 영속성 컨텍스트는 대부분 함께 움직이지만, 항상 100% 동일한 범위를 가지지는 않습니다.

대표적인 예시

  • 트랜잭션은 종료되었지만 영속성 컨텍스트는 살아 있는 경우 (OSIV 패턴에서 자주 체감되는 상황)
  • 같은 요청 흐름처럼 보이지만 실제로는 영속성 컨텍스트가 분리되는 경우 (새 EntityManager 생성, REQUIRES_NEW, 비동기 처리 등)

그래서 실무에서 동일성 관련 이슈를 디버깅할 때는 **"이 코드가 같은 EntityManager를 공유하고 있는가?"**를 가장 먼저 의심해보는 것이 좋습니다.


4. 동일성이 깨지는 대표적인 상황들

영속성 컨텍스트의 동일성은 **영속 상태(Managed)**일 때 가장 강력하게 보장됩니다. 하지만 엔티티의 상태가 바뀌면, 동일성은 쉽게 깨질 수 있습니다.

4-1) 영속성 컨텍스트가 달라진 경우

가장 흔한 사례입니다.

  • 서비스 A 트랜잭션에서 조회한 Member(1)
  • 서비스 B 트랜잭션에서 조회한 Member(1)

두 엔티티는 PK는 같지만 서로 다른 영속성 컨텍스트에 속해 있으므로 객체 참조는 다릅니다. 따라서 == 비교는 false가 됩니다.

동일성은 전역적으로 보장되는 개념이 아니라, 영속성 컨텍스트 내부에서만 보장되는 개념입니다.

4-2) detach / clear / close 이후

em.detach(entity);
em.clear();
em.close();

이러한 메서드를 통해 엔티티가 영속성 컨텍스트에서 분리되면, 해당 엔티티는 더 이상 "유일한 관리 객체"가 아닙니다.

특히 clear()는 영속성 컨텍스트 자체를 비워버리기 때문에, 이후 동일한 PK로 다시 조회하면 새로운 객체 인스턴스가 생성될 수 있습니다.

4-3) merge가 만들어내는 혼란

merge()는 동일성과 관련해 가장 많이 오해되는 메서드 중 하나입니다.

merge()는 전달한 객체를 그대로 영속 상태로 만드는 것이 아닙니다. 동작 방식은 다음과 같습니다:

  1. 영속성 컨텍스트 안에서 관리 중인 엔티티를 찾습니다.
  2. 그 관리 객체에 전달받은 객체의 상태를 복사합니다.
  3. 관리 중인 엔티티 객체를 반환합니다.

따라서 아래 코드는 정상적인 결과입니다:

Member detached = new Member(1L);
Member managed = em.merge(detached);
 
System.out.println(detached == managed); // false

이 상황을 두고 "동일성이 깨졌다"고 해석하면 안 됩니다. 애초에 merge()의 설계가 이런 방식이기 때문입니다.

실무에서는 반환된 managed 객체를 기준으로 로직을 이어가는 것이 매우 중요합니다.

4-4) 프록시와 지연 로딩으로 인한 혼선

프록시(getReference())와 실제 엔티티(find())가 섞이면 동일성에 대한 체감이 더 어려워질 수 있습니다.

같은 영속성 컨텍스트, 같은 PK라면 Hibernate는 최대한 일관된 객체를 유지하려고 하지만, "지금 내가 들고 있는 게 프록시인지, 실제 엔티티인지"를 명확히 인지하지 못한 상태에서 == 비교를 사용하면 혼란을 키우기 쉽습니다.

결론적으로, 엔티티를 ==로 비교하는 코드는 웬만하면 작성하지 않는 것이 가장 안전합니다.


5. 동일성 때문에 오히려 조심해야 할 부분들

5-1) equals / hashCode를 PK 기준으로 구현할 때의 문제

엔티티를 HashSet, HashMap의 키로 사용하는 경우 특히 주의가 필요합니다.

  • 영속화 전에는 PK가 null
  • 영속화 후에는 PK가 할당됨

이 상태에서 equals/hashCode가 PK만 기준으로 구현되어 있으면 객체의 hashCode 값이 중간에 변경될 수 있습니다. 그 결과, Set이나 Map에서 해당 엔티티를 갑자기 찾을 수 없는 상황이 발생할 수 있습니다.

실무에서의 일반적인 선택지

  • 엔티티를 컬렉션의 키로 사용하지 않는다
  • equals/hashCode를 변하지 않는 비즈니스 키로 정의한다
  • 혹은 기본 Object 동일성에 맡긴다

영속성 컨텍스트 동일성이 존재하기 때문에 equals/hashCode를 성급하게 건드리면 오히려 디버깅 난이도가 급격히 올라갑니다.

5-2) 1차 캐시로 인해 DB 최신 값이 보이지 않는 경우

같은 영속성 컨텍스트 안에서 같은 엔티티를 여러 번 조회하면 DB를 다시 조회하지 않습니다.

따라서 다른 트랜잭션에서 DB 값을 변경한 경우, 즉시 그 변경이 반영되지 않을 수 있습니다.

이런 경우에는 refresh()를 사용하거나 영속성 컨텍스트를 초기화해야 할 수 있습니다.

동일성은 성능과 일관성 측면에서는 큰 장점이지만, 항상 DB의 최신 상태를 보장하는 것은 아닙니다.


6. 동일성과 변경 감지(Dirty Checking)의 연결

영속성 컨텍스트가 "같은 PK면 같은 객체"로 엔티티를 관리하기 때문에:

  1. 개발자는 단순히 객체의 값을 변경하기만 하면 되고
  2. 영속성 컨텍스트는 스냅샷과 비교해 변경 여부를 감지한 뒤
  3. flush 시점에 필요한 SQL을 생성합니다

만약 동일성이 보장되지 않고 같은 PK를 가진 객체가 여러 개 존재한다면:

  • 어떤 객체가 최신 상태인지
  • 어떤 변경을 DB에 반영해야 하는지

이를 판단하기가 매우 어려워집니다.

그래서 영속성 컨텍스트 동일성은 변경 감지와 트랜잭션 일관성을 가능하게 하는 핵심 전제 조건입니다.


7. 실무에서 자주 겪는 실수 사례

실무에서 흔히 겪는 사례 중 하나는 다음과 같습니다:

  1. 컨트롤러에서 DTO를 엔티티로 변환
  2. 기존 엔티티를 조회하지 않고
  3. "어차피 ID가 같으니까"라는 이유로 new Entity(id)를 만들어서 저장

처음에는 정상 동작하는 것처럼 보이지만, 어느 순간부터:

  • 업데이트가 누락되거나
  • 연관관계가 꼬이거나
  • merge()로 인해 실제 관리 객체와 내가 들고 있는 객체가 달라지면서

디버깅이 매우 어려워집니다.

권장하는 방식

// 1. 관리 상태의 엔티티를 조회
Member member = em.find(Member.class, id);
 
// 2. 그 객체의 값을 변경
member.setName(newName);
 
// 3. 트랜잭션 커밋과 flush에 맡긴다
// (별도의 save 호출 불필요)

이 방식이 흔히 말하는 **"JPA스럽게 개발하는 방식"**이며, 그 바탕에는 영속성 컨텍스트 동일성이 존재합니다.


8. 한 줄 요약

핵심 포인트설명
동일성의 정의같은 컨텍스트 안에서 (엔티티 타입 + PK)에 대해 유일한 객체 참조를 보장
구현 방식아이덴티티 맵(1차 캐시)
주의 상황detach, clear, 트랜잭션 분리, merge, 프록시, equals/hashCode
핵심 역할변경 감지와 트랜잭션 일관성의 기반