들어가며
지난 글에서는 JPA의 핵심 메커니즘인 영속성 컨텍스트(Persistence Context)에 대해 정리했다. 데이터가 메모리 상에서 어떻게 관리되고 플러시되는지를 이해했다면, 이제 근본적인 의문이 생긴다.
어떻게 @Entity 어노테이션 하나 붙였다고 DB와 연동될까?
또한 우리는 흔히 스프링 데이터 JPA를 사용하며 인터페이스만 선언하고 구현체는 만들지 않는다. 그럼에도 save() 메서드는 동작한다.
이번 글에서는 어노테이션이라는 메타데이터가 컴파일과 런타임 과정을 거쳐, 어떻게 리플렉션과 프록시 기술을 통해 실제 DB 동작으로 이어지는지 그 내부를 파헤쳐본다.
어노테이션의 정체: 그냥 주석일 뿐이다
어노테이션은 아무 의미 없는 주석과도 같다
@Entity나 @Getter 같은 어노테이션을 붙이면 뭔가 자동으로 동작하는 것처럼 보인다. 하지만 어노테이션 자체는 그저 메타데이터일 뿐이다. 이 어노테이션을 보고 처리하는 별도의 프로세서나 프레임워크가 있어야 비로소 의미를 가진다.
어노테이션 처리는 크게 두 가지 시점으로 나뉜다.
1. 컴파일 시점
대상: Lombok 등 (@Getter, @Builder, @NoArgsConstructor ...)
해당 어노테이션들은 코드가 실행되기도 전, 즉 .java 파일이 .class (바이트코드)로 변환되는 컴파일 시점에 작동한다.
작동 메커니즘
- 파싱: javac 컴파일러가 소스 코드를 읽어서 AST (Abstract Syntax Tree, 추상 구문 트리)라는 트리 구조의 데이터로 만든다.
- 인터셉트: 컴파일러가 AST를 만드는 과정에서 Lombok의 Annotation Processor가 개입한다.
- AST 조작: 어노테이션을 보고 Lombok 프로세서가 AST 구조를 직접 뜯어고쳐, 소스 코드에는 없던 getMemberId(), builder() 같은 메서드 노드(Node)를 강제로 트리에 주입한다.
- 바이트코드 생성: 컴파일러는 수정된 AST를 기반으로 .class 파일을 만든다.
2. 런타임 - 앱 로딩 시점
대상: Spring Framework 등 (@Component, @Service, @Controller, @Autowired, @Transactional ...)
해당 어노테이션들은 애플리케이션이 시작(SpringApplication.run)되고 스프링 컨테이너가 초기화될 때 작동한다.
작동 메커니즘
- 컴포넌트 스캔: 스프링이 클래스패스를 스캔하여 @Component(Service, Repository 포함)가 붙은 클래스를 찾는다.
- 빈 정의: 클래스를 찾았다고 바로 new 하지 않는다. 먼저 '빈 설계도(BeanDefinition)'라는 메타데이터를 생성한다. ('이 클래스는 싱글톤이고, 생성자에 MemberRepository가 필요하다' 등의 정보 기록)
- 인스턴스화 & 의존성 주입 (DI):
- Reflection: 설계도를 보고 Constructor.newInstance()를 호출해 객체를 생성한다.
- Injection: 필드나 생성자에 @Autowired가 있으면, 컨테이너에 있는 다른 빈을 찾아 리플렉션으로 주입해 준다.
- AOP 프록시 생성:
- 만약 클래스나 메서드에 @Transactional이 붙어 있다면 스프링은 순수 객체를 그대로 빈으로 등록하지 않고, CGLIB 라이브러리를 사용해 해당 클래스를 상속받은 프록시(Proxy) 객체를 동적으로 생성해 등록한다. (프록시에 대한 부분은 나중에 정리 예정)
3. 런타임 - 실행 및 데이터 접근 시점
대상: JPA (@Entity, @Table, @Id, @ManyToOne ...)
JPA 어노테이션은 애플리케이션 로딩 시(메타데이터 분석)와 실제 DB 트랜잭션이 일어나는 실행 시점(프록시 동작)에 나누어 작동한다.
작동 메커니즘
스캐닝과 리플렉션
- 스캔: 모든 클래스를 로드하면서 @Entity 가 있는지 확인한다.
- 리플렉션: @Entity가 붙은 클래스를 발견하면, Java Reflection API를 사용해 클래스 정보를 뜯어본다.
프록시 객체 생성
- 프록시 생성: Hibernate는 런타임에 클래스를 상속받은 가짜 객체(Proxy)를 동적으로 생성한다.
- 요청 가로채기: 개발자가 호출하면, 실제로는 프록시 객체의 메서드가 먼저 호출된다. 이 프록시가 영속성 컨텍스트에 초기화를 요청하고, DB 조회 후 진짜 객체를 연결하여 값을 반환한다.
JPA 생태계: 누가 어노테이션을 처리하는가?
여기서 중요한 질문이 생긴다. @Entity를 붙였을 때, 실제로 이 어노테이션을 읽고 DB와 매핑하는 주체는 누구일까?
답을 이해하려면 구성요소를 알아야한다.
JPA 표준 명세 (Jakarta Persistence)
import jakarta.persistence.*를 임포트하면 사용할 수 있는 @Entity, @Table, @Id 같은 어노테이션들은 JPA 표준 명세에 정의되어 있다.
- DB랑 매핑할 때는 @Entity라고 붙이기
- 테이블 이름은 @Table로 정하기
- 관계는 @ManyToOne, @OneToMany로 표현하기
역할: 표준 어노테이션과 인터페이스를 정의. 단, 실제 구현은 하지 않는다.
Hibernate
JPA 명세는 "이렇게 하자"고 약속만 했을 뿐, 실제로 동작하는 코드는 없다.
여기서 Hibernate가 등장한다. Hibernate는 Jakarta EE의 명세를 실제로 구현한 JPA 구현체이다.
- 개발자가 @Entity를 붙여놓으면 → Hibernate가 리플렉션으로 읽어서 DB 테이블과 매핑
- em.persist()를 호출하면 → Hibernate가 실제로 SQL을 생성하고 DB에 저장
역할: Jakarta EE의 명세를 실제 동작하는 코드로 구현. 우리가 사용하는 실제 구현체.
Spring Data JPA
Hibernate를 직접 쓰면 EntityManager를 매번 주입받고, em.find(), em.persist() 같은 메서드를 일일이 호출해야 한다.
Spring Data JPA는 이런 반복 작업을 인터페이스 하나로 추상화해준다.
public interface MemberRepository extends JpaRepository<Member, Long> {
// 메서드 구현 없이 인터페이스만 선언해도 동작!
}
역할: Hibernate를 더 쉽게 사용할 수 있도록 Repository 패턴으로 추상화.
이제 @Entity를 붙이면 왜 DB와 매핑되는지 명확해졌다. Hibernate라는 JPA 구현체가 런타임에 어노테이션을 읽고 실제 동작을 수행하기 때문이다.
@Entity: 단순한 표시가 아닌 계약
보통 @Entity를 "DB 테이블과 매핑되는 클래스"라고만 이해한다. 틀린 말은 아니지만, 정확하지도 않다.
@Entity
는 단순한 주석 개념이 아니라 JPA 영속성 컨텍스트(Persistence Context)가 관리하는 상태 머신의 대상이자 데이터베이스 스키마의 객체 지향적 투영이다. (스프링 빈(@Component)은 스프링 컨테이너가 관리, 엔티티(@Entity)는 JPA 영속성 컨텍스트가 관리)
@Entity를 붙이면 Hibernate가 런타임에 이 클래스를 특별하게 다룬다. 그러려면 몇 가지 기술적 제약을 반드시 따라야 한다.
1. 기본 생성자 필수
- 이유: JPA는 Reflection API (Class.newInstance())를 사용하여 객체를 생성한다. 인자가 있는 생성자만 있다면 리플렉션으로 객체를 인스턴스화할 수 없다.
- 접근 제어자: public 또는 protected여야 합니다. (private은 프록시 객체가 상속받을 수 없거나 접근이 제한될 수 있다.
보통 Entity의 불변성 보호를 위해 어노테이션에 access = AccessLevel.PROTECTED 옵션을 주어, 외부에서의 무분별한 new Account() 생성을 막고 JPA 프록시 매커니즘만 접근하도록 제한하는 것이 일반적)
2. final 클래스/메서드 금지
- 이유: 프록시(Proxy) 패턴 때문이다. Hibernate는 지연 로딩(Lazy Loading)을 위해 Entity 클래스를 상속받은 가짜 객체(프록시)를 만든다. final 클래스는 상속이 불가능하고, final 메서드는 오버라이딩이 불가능하므로 프록시를 생성할 수 없다.
3. 최상위 클래스여야 함
- enum이나 interface에는 붙일 수 없다.
4. 식별자(Identity) 필수
- @Id 어노테이션을 사용하여 Primary Key(PK)를 반드시 매핑해야 한다.
그렇다면 스프링 빈인 Repository는 어떻게 영속성 컨텍스트의 Entity를 저장하고 관리할까?
Repository는 어떻게 Entity를 저장할까?
핵심부터 말하자면 스프링 데이터 JPA가 뒤에서 몰래 구현체를 만들어주고, 그 안에 EntityManager를 주입(Injection)해주는 것이다.
우리가 흔히 쓰는 Repository 인터페이스는 껍데기일 뿐이고, 실제로는 EntityManager라는 녀석이 모든 일을 다 한다.
구조적 관계
- Repository (Spring Bean): 개발자가 만든 인터페이스
- Proxy: 스프링이 런타임에 이 인터페이스를 구현한 프록시 객체를 만들어 빈으로 등록
- EntityManager: 이 프록시 객체 내부에는 EntityManager가 의존성 주입(DI) 되어 있음
- Persistence Context: EntityManager가 관리하는 메모리 공간
- Entity: 영속성 컨텍스트 안에 들어있는 데이터 객체
개발자가 인터페이스만 선언해도 동작하는 이유는, 스프링 부트가 SimpleJpaRepository 라는 기본 구현 클래스를 사용하기 때문이다.
실제 코드는 대략 다음과 같다.
// @Repository가 붙어있으므로 스프링 빈으로 등록됨
public class SimpleJpaRepository<T, ID> implements JpaRepository<T, ID> {
// 영속성 컨텍스트를 관리하는 매니저가 주입됨
@PersistenceContext
private final EntityManager em;
public SimpleJpaRepository(EntityManager em) {
this.em = em;
}
@Override
@Transactional
public <S extends T> S save(S entity) {
// 1. 엔티티의 상태를 확인 (새로 만든 건지, 이미 있던 건지)
if (entityInformation.isNew(entity)) {
// 2. 새로운 놈이면 영속화 (persist)
em.persist(entity);
return entity;
} else {
// 3. 있던 놈이면 병합 (merge)
return em.merge(entity);
}
}
// ...
}
- Repository 빈(Bean)은 내부 필드로 EntityManager를 가지고 있다.
- save() 메서드가 호출되면, Repository는 단순히 em.persist(entity)를 호출하여 책임을 EntityManager에게 넘긴다.
- 이 순간, 파라미터로 넘어온 순수 자바 객체(Entity)가 EntityManager의 관할 구역(영속성 컨텍스트)으로 넘어간다.
repository.save() 호출 시 내부 동작
memberRepository.save(newMember);
이 한 줄이 실행될 때 일어나는 일:
1. Proxy Intercept: 스프링이 만든 프록시 Repository가 호출을 가로챔
2. Delegate to EntityManager: em.persist(newMember) -> EntityManager에게 책임 전가
3. 영속성 컨텍스트에 등록:
- newMember를 1차 캐시(Map)에 저장
- 상태를 비영속(Transient) → 영속(Managed)으로 변경
4. 쓰기 지연 SQL 저장소에 INSERT 쿼리 생성:
- 아직 DB에 보내지 않음! 메모리에만 쌓아둠
5. 트랜잭션 커밋 시점:
@Transactional // 메서드 종료 시 자동 커밋
public void saveMember(Member member) {
memberRepository.save(member);
} // 여기서 em.flush() 자동 호출!
- flush()가 호출되면 쌓아둔 SQL을 DB로 전송
- 실제 INSERT가 이때 실행됨
마치며
우리가 무심코 쓰던 @Entity와 Spring Data JPA에는 복잡한 메커니즘이 숨어있었다.
- 어노테이션은 그저 메타데이터일 뿐, 실제로는 Annotation Processor와 Reflection이 처리한다.
- Repository는 껍데기일 뿐, 진짜 일하는 주체는 EntityManager이다.
- EntityManager는 영속성 컨텍스트를 통해 Entity의 생명주기를 관리한다
하지만 여기서 끝이 아니다. EntityManager가 관리하는 Entity들 사이의 연관관계는 어떻게 처리될까? Member를 조회할 때 연관된 객체도 함께 조회할까, 아니면 나중에 조회할까?
다음 글에서는 지연 로딩(Lazy Loading)과 프록시에 대해 알아보자. @Entity에 final을 쓸 수 없는 진짜 이유가 거기에 있다.
'Java' 카테고리의 다른 글
| JPA 영속성 컨텍스트 (0) | 2026.01.31 |
|---|---|
| [우아한테크코스] 출석부 (1) | 2025.05.29 |
| [우아한테크코스] 로또 (2) | 2025.05.02 |
| [우아한테크코스] 자동차 경주 (0) | 2025.04.29 |