들어가며
JPA는 ORM의 일종입니다. 릴레이션을(RDB) 객체와 매핑하여 객체지향적인 데이터 관리를 돕습니다. 이 방식을 자바 영속성 API로 구현하여 JPA라 부릅니다. JPA를 사용할 때 엔티티 객체의 필드 값만 변경해도 별도의 update(save등) 메서드 호출 없이 데이터베이스에 UPDATE 쿼리가 실행됩니다. 이는 영속성 컨텍스트의 변경 감지(Dirty Checking) 기능 때문입니다. 하지만 동일한 방식으로 엔티티를 수정해도 때로는 변경사항이 데이터베이스에 반영되지 않는 경우가 있습니다. 이러한 차이를 이해하려면 영속성 컨텍스트의 동작 원리를 정확히 알아야 합니다. (사실 이런 내용은 김영한이나 백기선의 유튜브와 기타 블로그들에 아주 잘 정리되어있습니다.)
영속성 컨텍스트란?
JPA 명세에 따르면 영속성 컨텍스트는 "엔티티 인스턴스들의 집합(a set of entity instances)"으로 정의됩니다. 애플리케이션과 데이터베이스 사이에 위치하여 엔티티를 관리하는 일종의 캐시 계층입니다.
memberRepository.save(member);
위 코드에서 save() 메서드는 member 엔티티를 영속성 컨텍스트에 저장합니다. 실제 INSERT 쿼리는 트랜잭션이 커밋될 때 실행됩니다.
이러한 중간 계층의 존재 이유는 성능 최적화와 객체지향적인 데이터 접근의 양립입니다. 동일 트랜잭션 내에서 같은 엔티티를 여러 번 조회해도 SELECT 쿼리는 한 번만 실행되고, 쓰기 쿼리를 모아서 한꺼번에 처리하여 데이터베이스 왕복 비용을 감소시킵니다.
엔티티의 생명주기
엔티티는 영속성 컨텍스트와의 관계에 따라 네 가지 상태를 가집니다.
비영속 (Transient)
일반적인 자바 객체를 생성한 상태입니다. JPA가 관여하지 않습니다.
Member member = new Member();
member.setName("홍길동");
영속 (Managed)
EntityManager를 통해 영속성 컨텍스트에 저장된 상태입니다.
memberRepository.save(member);
영속 상태가 되면 1차 캐시에 저장되고, 이후 변경사항이 자동으로 추적됩니다. 중요한 점은 아직 데이터베이스에 저장된 것은 아니라는 것입니다. 실제 INSERT 쿼리는 트랜잭션 커밋 시점에 실행됩니다.
준영속 (Detached)
준영속 상태는 실무에서 자주 발생하지만 간과하기 쉬운 개념입니다. 영속 상태였던 엔티티가 영속성 컨텍스트에서 분리된 상태를 의미합니다.
Spring에서 가장 흔한 발생 시점은 트랜잭션 종료 시입니다. 아래 예제를 보겠습니다.
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
@Transactional(readOnly = true)
public Member findMember(Long id) {
return memberRepository.findById(id).orElseThrow();
}
// 주의: @Transactional이 없습니다
public void updateMemberName(Long id, String name) {
Member member = findMember(id);
member.setName(name);
// 데이터베이스에 반영되지 않습니다!
}
}
updateMemberName() 메서드에는 @Transactional이 없습니다. findMember()를 호출하면 해당 메서드의 읽기 전용 트랜잭션이 시작되고, member를 조회한 뒤 메서드가 종료되면서 트랜잭션도 함께 종료됩니다. 이 시점에 member는 준영속 상태가 됩니다. 따라서 이후 setName()을 호출해도 영속성 컨텍스트가 이미 종료되었으므로 변경 감지가 동작하지 않습니다.
올바른 구현은 다음과 같습니다.
@Transactional
public void updateMemberName(Long id, String name) {
Member member = memberRepository.findById(id).orElseThrow();
member.setName(name);
// 트랜잭션 커밋 시 자동으로 UPDATE 쿼리 실행
}
준영속 상태의 엔티티는 변경 감지, 지연 로딩 등 영속성 컨텍스트가 제공하는 모든 기능을 사용할 수 없습니다.
지연 로딩과 준영속 상태
준영속 상태에서 가장 흔히 마주치는 문제는 지연 로딩 실패입니다. 연관 엔티티가 프록시 객체로 로딩된 경우, 영속성 컨텍스트가 종료된 후 접근하면 예외가 발생합니다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
private Team team; // 프록시 객체로 로딩됨
}
@Transactional(readOnly = true)
public Member findMember(Long id) {
return memberRepository.findById(id).orElseThrow();
// team은 아직 프록시 상태
}
// Controller 또는 트랜잭션 밖에서
Member member = memberService.findMember(1L);
String teamName = member.getTeam().getName(); // LazyInitializationException!
프록시 객체는 실제 데이터가 필요한 시점에 데이터베이스를 조회하는데, 이미 영속성 컨텍스트가 종료되었으므로 조회할 수 없습니다. 이 문제를 해결하려면 트랜잭션 내에서 필요한 연관 데이터를 미리 로딩하거나, Fetch Join을 사용하거나, DTO로 변환하여 반환해야 합니다.
참고로 Spring Boot는 기본적으로 OSIV(Open Session In View)가 활성화되어 있어서(spring.jpa.open-in-view=true) Controller까지 영속성 컨텍스트가 유지됩니다. 이 경우 지연 로딩은 가능하지만, 트랜잭션은 Service 계층에서 이미 종료되었으므로 변경 감지는 동작하지 않습니다. OSIV의 동작 방식과 트레이드오프는 별도의 글에서 다루겠습니다.
삭제 (Removed)
엔티티를 영속성 컨텍스트와 데이터베이스에서 삭제하기로 예정된 상태입니다.
memberRepository.delete(member);
변경 감지 (Dirty Checking)
영속 상태의 엔티티는 값을 변경하기만 하면 자동으로 데이터베이스에 UPDATE 쿼리가 실행됩니다.
@Transactional
public void updateMember(Long id, String name) {
Member member = memberRepository.findById(id).orElseThrow();
member.setName(name);
// 별도의 메서드 호출이 필요 없습니다
// 트랜잭션 커밋 시 자동으로 UPDATE 쿼리 실행
}
이는 영속성 컨텍스트가 엔티티를 최초로 저장할 때 스냅샷을 함께 보관하기 때문입니다. 트랜잭션 커밋 시점에 현재 엔티티와 스냅샷을 비교해서 변경된 필드가 있으면 UPDATE 쿼리를 생성합니다.
Spring Data JPA에서 영속 상태의 엔티티를 수정할 때 불필요하게 save()를 호출하는 경우가 있습니다.
@Transactional
public void updateMember(Long id, String name) {
Member member = memberRepository.findById(id).orElseThrow();
member.setName(name);
memberRepository.save(member); // 불필요
}
member는 이미 영속 상태이므로 변경 감지가 자동으로 동작합니다. 따라서 save() 호출은 생략 가능합니다.
반면 준영속 상태의 엔티티를 수정할 때는 save()를 명시적으로 호출해야 합니다.
@Transactional
public void updateMember(Member detachedMember) {
memberRepository.save(detachedMember); // merge가 호출됩니다
}
Spring Data JPA의 save() 메서드는 내부적으로 엔티티가 새로운 것인지 판단해서 새로운 엔티티면 persist(), 기존 엔티티면 merge()를 호출합니다.
save() 메서드 동작 원리
요즘은 EntityManager를 직접 사용하기보다 Spring Data JPA Repository와 @Transactional을 주로 사용합니다. Repository의 save() 메서드가 내부적으로 어떻게 동작하는지 이해하면 준영속 엔티티 문제를 더 잘 해결할 수 있습니다.
save() 구현
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
save() 메서드는 isNew()로 엔티티가 새로운 것인지 판단합니다.
isNew() 판단 기준
기본적으로 다음 순서로 판단합니다:
- 식별자가 null인 경우: 새로운 엔티티
- 식별자가 primitive 타입 0인 경우: 새로운 엔티티
- Persistable 인터페이스 구현: isNew() 메서드 결과 사용
- @Version 필드가 null인 경우: 새로운 엔티티
@Entity
public class Member {
@Id
@GeneratedValue
private Long id; // null이면 새로운 엔티티
private String name;
}
persist()와 merge()의 차이
persist()는 비영속 엔티티를 영속 상태로 만듭니다. 파라미터로 전달한 엔티티 자체가 영속성 컨텍스트에 등록됩니다.
@Transactional
public void createMember() {
Member member = new Member(); // 비영속
member.setName("홍길동");
Member saved = memberRepository.save(member); // persist() 호출
// member == saved (동일한 객체)
// 둘 다 영속 상태
}
merge()는 준영속 또는 비영속 엔티티의 값을 복사한 새로운 영속 엔티티를 반환합니다. 원본 엔티티는 여전히 준영속 상태입니다.
@Transactional
public void updateMember(Member detachedMember) {
Member merged = memberRepository.save(detachedMember); // merge() 호출
// detachedMember != merged (다른 객체!)
// detachedMember: 여전히 준영속
// merged: 영속 상태
}
merge() 사용 시 흔한 실수
@Transactional
public void updateMember(Member detachedMember) {
memberRepository.save(detachedMember);
detachedMember.setEmail("new@test.com"); // 반영 안됨!
}
save()가 내부적으로 merge()를 호출하면, 파라미터로 전달한 detachedMember가 영속 상태로 바뀌는 것이 아닙니다. merge()는 detachedMember의 값을 복사한 새로운 영속 엔티티를 생성하여 반환합니다. 따라서 save() 호출 이후에 detachedMember를 수정해도 데이터베이스에 반영되지 않습니다.
@Transactional
public void updateMember(Member detachedMember) {
Member managed = memberRepository.save(detachedMember);
managed.setEmail("new@test.com"); // 반영됨
}
반드시 save()의 리턴 값을 사용해야 합니다.
merge()의 내부 동작
merge()는 다음 순서로 동작합니다.
먼저 영속성 컨텍스트에 해당 식별자의 엔티티가 있는지 확인합니다. 있으면 SELECT 없이 해당 엔티티에 값을 복사합니다.
영속성 컨텍스트에 없는 경우 SELECT로 데이터베이스에서 조회합니다. 조회한 엔티티에 파라미터 엔티티의 값을 복사하고, 변경 감지로 UPDATE 쿼리를 생성합니다.
데이터베이스에도 없는 경우 새 엔티티로 판단하고 INSERT를 실행합니다. 이 동작은 의도치 않은 INSERT를 발생시킬 수 있으므로 주의해야 합니다.
// merge()가 호출되는 일반적인 흐름
@Transactional
public void updatePost(Post detachedPost) {
Post merged = postRepository.save(detachedPost);
// 1. 영속성 컨텍스트에서 detachedPost.getId()로 조회
// 2. 없으면 SELECT 실행
// 3. 조회한 엔티티에 detachedPost의 값 복사
// 4. 커밋 시 변경 감지로 UPDATE 실행
}
따라서 일반적인 준영속 엔티티 병합 상황에서는 SELECT가 실행됩니다.
@Transactional과 영속성 컨텍스트
Spring에서 @Transactional은 트랜잭션의 시작과 종료를 관리합니다. 기본 설정에서 영속성 컨텍스트의 생명주기는 트랜잭션과 동일합니다.
@Service
public class MemberService {
@Transactional
public void businessLogic() {
// 트랜잭션 시작 → 영속성 컨텍스트 생성
Member member = memberRepository.findById(1L).orElseThrow();
// member는 영속 상태
member.setName("변경");
// 변경 감지 활성화
// 메서드 종료 → flush & commit → 영속성 컨텍스트 종료
// member는 준영속 상태가 됨
}
@Transactional(readOnly = true)
public Member findMember(Long id) {
Member member = memberRepository.findById(id).orElseThrow();
return member;
// 메서드 종료 → 트랜잭션 종료 → 영속성 컨텍스트 종료
// 반환된 member는 준영속 상태
}
}
readOnly = true 옵션은 플러시를 생략하여 약간의 성능 향상을 가져옵니다. PostgreSQL의 Replication 구성에서는 Slave로부터 데이터를 읽는 동작을 지원합니다.
핵심은 @Transactional 범위 안에서 엔티티를 조회하고 수정해야 변경 감지가 동작한다는 것입니다. 트랜잭션 밖에서 조회한 엔티티를 수정하거나, 다른 트랜잭션에서 조회한 엔티티를 현재 트랜잭션에서 수정하려면 merge()가 필요합니다.
마치며
엔티티의 생명주기, 특히 준영속 상태를 정확히 이해하면 실무에서 발생하는 대부분의 문제를 예방할 수 있습니다. 영속 상태 엔티티는 변경 감지로 자동 UPDATE되지만, 준영속 상태 엔티티는 명시적인 save() 호출이 필요합니다.
save() 메서드의 내부 동작을 이해하는 것이 중요합니다. persist()와 merge()의 차이, 특히 merge() 호출 시 반드시 리턴 값을 사용해야 한다는 점을 기억해야 합니다.
실무에서는 대부분의 경우 @Transactional 내에서 조회와 수정을 함께 수행하는 패턴이 가장 안전하고 효율적입니다. 이렇게 하면 merge()의 불필요한 SELECT 쿼리를 피할 수 있고, 코드도 더 명확해집니다.
참고 자료
'설계 전략' 카테고리의 다른 글
| Spring Data JPA에서 NPE를 방지하는 Optional 활용법 (0) | 2026.01.28 |
|---|---|
| JPA Auditing과 BaseEntity를 활용하여 Audit 필드 자동 관리 (0) | 2025.11.24 |