본문으로 건너뛰기

10.1 ORM 패러다임과 영속성 컨텍스트 (JPA 마법)

백엔드 개발에서 데이터를 저장하려면 무조건 데이터베이스(DB)를 거쳐야 하지만, 자바의 객체 지향 패러다임(Object)RDBMS의 관계형 표 패러다임(Relational) 사이에는 좁힐 수 없는 근원적 모순, 이른바 패러다임의 불일치(객체-관계 불일치) 가 존재합니다.

이 불일치를 파괴하고, 자바 코드처럼 데이터베이스를 우아하게 다루게 해주는 혁명적 마법 도구가 바로 ORM (Object-Relational Mapping), 자바 진영의 JPA (Java Persistence API) 입니다.


🚫 1. 과거 SQL 매핑의 고통 (MyBatis / JDBC)

JPA 이전 시대에는 자바 코드가 SQL 쿼리라는 문자열(String) 무더기에 심각하게 종속되어 있었습니다.

// 테이블에 "이메일" 컬럼 하나가 새로 추가되면?
public class MemberDao {
public void save(Member member) {
// 기존 쿼리에 이메일 필드 1개를 텍스트로 일일이 타자쳐서 추가해야 함. (노가다 지옥)
String sql = "INSERT INTO members (id, name, email) VALUES (?, ?, ?)";
jdbcTemplate.update(sql, member.getId(), member.getName(), member.getEmail());
}
// 나중에 findById() 검색 쿼리 만들 때도 별도로 조인 쿼리와 컬럼 나열을 똑같이 수동으로 해줘야 함.
}

JPA는 "네가 SQL을 짜지 마! 네가 자바 컬렉션의 List 객체 구조만 이쁘게 만들어 두면(@Entity), 런타임에 내가 알아서 네가 쓰는 DB 방언(Postgres, MySQL 등)에 맞춰 완벽히 튜닝된 쿼리를 동적으로 만들어 쏴줄게!" 라고 선언합니다.


🧠 2. JPA의 심장: 영속성 컨텍스트 (Persistence Context)

대부분 입문자들은 save() 메서드 한방에 INSERT 쿼리가 바로 날아가 꽂힌다고 오해합니다. JPA의 내부에는 자바 애플리케이션과 원격 데이터베이스 사이에 "영속성 컨텍스트(1차 캐시라는 이름의 거대한 쿠션 공간)" 가 존재합니다.

트랜잭션(Transaction)이 시작되면 이 논리적 공간이 열리고 엔티티(Entity) 객체들을 관리(Managed)하다가, 트랜잭션이 끝나는 시점에 쿼리를 일제히 쏟아냅니다. 이 구조 덕분에 무시무시한 4가지 폭력적 장점이 탄생합니다.

① 1차 캐시 (First-Level Cache)와 동일성 보장

디비에 있는 id=1 유저를 한 트랜잭션 내에서 백 번 조회(findById)해도, DB에 SELECT 쿼리는 최초 단 1번 만 날아갑니다.

@Transactional
public void logic() {
// 1. 처음 조회: DB에 찐 SELECT 쿼리 날림 -> 가져온 데이터를 "1차 캐시(메모리 Map)"에 킵해둠.
Member member1 = memberRepository.findById(1L);

// 2. 두 번째 조회: 어? 내 메모리에 이놈 있는데?
// DB 네트워크 안 타고 내 메모리에서 인스턴스를 즉각 꺼내 돌려줌 (SELECT 쿼리 0회 날아감)
Member member2 = memberRepository.findById(1L);

// 이 둘의 메모리 참조 주소값은 완벽히 동일한 '싱글톤 객체'임! (완전 동일성 보장)
System.out.println(member1 == member2); // true
}

② 쓰기 지연 (Transactional Write-Behind) 폭격

INSERT를 발생시키는 save()나 객체 수정을 100번 호출하더라도, 그 즉시 DB로 네트워크를 타지 않습니다. 1차 캐시 옆의 쓰기 지연 SQL 저장소 큐(Queue)에 쿼리문을 100개 꾹꾹 차곡히 모아 참고 있다가, 트랜잭션이 커밋(Commit)되는 (메서드 종료를 뜻함) 마지막 0.1초 순간에 100개의 쿼리를 한방 뭉치로 Flush 발사합니다. 네트워크 병목 지연을 막는 최강의 배칭(Batching) 최적화입니다.

@Transactional
public void saveBulk() {
// 쿼리가 이때 안 날아갑니다. 모읍니다.
em.persist(new Member(1L, "Alice"));
em.persist(new Member(2L, "Bob"));
em.persist(new Member(3L, "Charlie"));

System.out.println("====== 진짜 쿼리 발사 직전 ======");
// 이 메서드의 대괄호 '}' 가 닫힐 때 비로소 [Transaction Commit] 마법이 터지며 INSERT 3방이 일렉트로닉하게 발사됨
}

③ 변경 감지 (Dirty Checking) 매직 - UPDATE 쿼리가 필요 없는 이유

이것이 JPA 최고의 혁명입니다. JPA에는 update() 라는 메서드 코드가 아예 존재하지 않습니다.

@Transactional
public void changeName(Long id) {
// 1. 1차 캐시에 스냅샷(현재 원본 상태)을 복사해두며 엔티티를 영속 영역으로 끌고 옴
Member member = memberRepository.findById(id);

// 2. 자바 객체의 필드값만 그냥 바꿈 (setter 사용 등)
member.changeNickname("킹왕짱");

// repository.save(member); <--- 이거 할 필요 절대 없음!!

// 3. 트랜잭션이 종료(Commit)되는 순간, JPA가 스스로 "최초 스냅샷"과 현재 이 "Member 자바 객체"를
// 필드 단위로 쫙 풀어서 비교 검사함 (더티 체킹)
// "어? nickname 필드가 틀려졌네? 내(JPA)가 알아서 UPDATE문 만들어서 디비에 날려줄게!"
}

🎯 3. 고수 팁 (Pro Tips)

💡 하이버네이트 생태계 구조: JPA vs Hibernate vs Spring Data JPA 인터페이스 구분

  • JPA: 순수 자바의 명세서, 가이드라인 인터페이스 모음집일 뿐 실체가 없음 (문서 껍데기)
  • Hibernate(하이버네이트): JPA 명세서를 충실히 구현하여 진짜 SQL을 찍어내고 객체를 변환하는 실제 1등 압도적 근육 작업자 (구현체)
  • Spring Data JPA: "하이버네이트의 기본 로직인 EntityManager.find() 조차도 쓰기 귀찮아!" 라며 스프링 진영이 덮어 씌운 최고의 래퍼. 여러분이 늘 쓰는 interface *Repository extends JpaRepository<T, ID> 이 녀석이 바로 Spring Data JPA의 인터페이스입니다. 알아서 findById, save를 매핑해줍니다.