JPA란 무엇인가

StrayCat·2026년 2월 14일

1. JPA 개요

JPA(Java Persistence API)는 Java 애플리케이션에서 관계형 데이터베이스를 객체지향적으로 다루기 위한 표준 인터페이스(스펙)이다.
핵심 키워드: ORM(Object-Relational Mapping)

Java 객체(Entity)  ↔  JPA(표준 스펙)  ↔  Hibernate(구현체)  ↔  DB 테이블

JPA는 스펙일 뿐이고, 실제 동작하는 구현체는 Hibernate, EclipseLink 등이 있다. Spring Boot에서는 기본적으로 Hibernate를 사용한다. (업계 비공식 표준)


2. 왜 JPA를 사용하는가

SQL 중심 개발의 문제

// JDBC 직접 사용 - 반복적이고 실수하기 쉬움
String sql = "INSERT INTO member (name, email) VALUES (?, ?)";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setString(1, member.getName());
pstmt.setString(2, member.getEmail());
pstmt.executeUpdate();

테이블에 컬럼 하나가 추가되면 -> 관련된 모든 SQL을 수정해야 한다

// JPA 사용 - SQL을 직접 작성하지 않아도 됨
em.persist(member); // INSERT
Member found = em.find(Member.class, id); // SELECT
found.setName("새이름"); // UPDATE (변경 감지)
em.remove(found); // DELETE

JPA의 핵심 이점

  • 생산성 - CRUD용 SQL을 자동 생성
  • 유지보수 - 필드 변경 시 엔티티 클래스만 수정
  • 패러다임 불일치 해결 - 상속, 연관관계 등 객체와 RDB 간의 차이를 해결
  • DB 벤더 독립성 - Dialect 설정 변경으로 DB간 전환 용이

3. 핵심 개념 정리

3-1. Entity

  • DB 테이블과 매핑되는 java 클래스
@Entity
@Table(name = "members")
public class Member {

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

    @Column(nullable = false, length = 50)
    private String name;

    @Column(unique = true)
    private String email;

    // 기본 생성자 필수
    protected Member() {}

    public Member(String name, String email) {
        this.name = name;
        this.email = email;
    }
}
  • 기본 생성자를 protected로 생성하는 이유는?
  1. JPA 스펙이 기본 생성자를 요구한다
    JPA(Hibernate)는 DB에서 데이터를 조회할 때, 리플렉션으로 객체를 먼저 생성한 뒤 필드값을 채워넣는다. 이 때 기본 생성자 (파라미터 없는 생성자)가 반드시 필요하다.
  2. 외부의 무분별한 생성을 막기 위해 protected를 사용한다.
  • public으로 해도 JPA는 잘 동작한다. 하지만 그러면 아무데서나 new Member()로 불완전한 객체를 만들 수 있게 된다.
  • protected로 선언하면 JPA(리플렉션)는 접근 가능하지만, 외부 코드에서는 의미 있는 생성자(new Member(name, email))만 사용하도록 유도할 수 있다.
  • private는 안되는가? -> Hibernate 프록시 (지연 로딩 시 상속으로 만드는 가짜 객체)가 기본 생성자에 접근해야 하므로 private는 JPA 스펙 상 허용하지 않는다. 최소 protected만 가능
protected Member() {}  // JPA용 - 외부 직접 호출 차단, 프록시 접근은 허용

public Member(String name, String email) {  // 실제 사용할 생성자
    this.name = name;
    this.email = email;
}
  • 정리하면 JPA가 내부적으로 쓸 수 있되, 개발자가 실수로 빈 객체를 만들지 못하게 하는 최소 접근 범위가 protected

3-2. EntityManager

JPA의 핵심 인터페이스, 엔티티의 CRUD를 담당한다.

EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();

tx.begin();
em.persist(new Member("John", "john@example.com")); // 영속성 컨텍스트에 저장
tx.commit();
em.close();

3-3. 영속성 컨텍스트 (Persistence Context)

JPA를 이해하는 데 가장 핵심적인 개념

영속성 컨텍스트(Persistence Context)는 엔티티를 영구 저장하기 전에 머무르는 1차 캐시 공간의 개념이다. EntityManager가 이 컨텍스트를 통해 엔티티를 관리하게 된다.

엔티티의 4가지 생명주기

비영속(new) → persist() → 영속(managed) → detach()/clear() → 준영속(detached)
                              ↓
                           remove() → 삭제(removed)
  • 비영속 : new Member()만 한 상태. JPA는 모름
  • 영속 : em.persist() 호출 후. 컨텍스트가 관리 중
  • 준영속 : 컨텍스트에서 분리됨. 더 이상 변경 감지 안됨
  • 삭제 : em.remove() 호출. 커밋 시 DELETE 쿼리 나감

영속성 컨텍스트가 제공하는 기능 :

  • 1차 캐시 - 같은 트랙잭션 내에서 동일 엔티티 조회 시 DB를 다시 찌르지 않는다
// 1차 캐시
Member member = em.find(Member.class, 1L); // DB 조회 → 캐시 저장
Member same = em.find(Member.class, 1L);   // 캐시에서 반환 (SQL 안 나감)
System.out.println(member == same); // true (동일성 보장)
  • 동일성 보장 - em.find(Member.class, 1L) == em.find(Member.class, 1L) -> true

  • 변경 감지(Dirty Checking) - setter로 값만 바꿔도 트랜잭션 커밋 시 자동 UPDATE
    영속 상태 엔티티의 최초 스냅샷과 현재 상태를 비교해서, 달라졌으면 UPDATE 쿼리 자동 생성

// 변경 감지
Member member = em.find(Member.class, 1L);
member.setName("변경된이름"); // setter만 호출
// em.update() 같은 건 없다!
// 트랜잭션 커밋 시 자동으로 UPDATE 쿼리 발생
  • 쓰기 지연 - persist() 호출 시 바로 INSERT 하지 않고, 커밋 시점에 한번에 SQL 실행
// 쓰기 지연
em.persist(memberA);  // INSERT SQL을 쓰기 지연 저장소에 모음
em.persist(memberB);  // 여기서도 아직 DB에 안 감

transaction.commit();  // 이 시점에 INSERT 2개가 한꺼번에 flush

영속성 컨텍스트는 JPA 이해에 있어 왜 중요한가?
-> 영속성 컨텍스트를 모르면 JPA 동작 구조를 이해하기 어려움
-> 실무에서 겪는 대부분의 JPA 관련 트러블슈팅이 여기서 나옴

  • LazyInitializationException -> 준영속 상태에서 지연 로딩 시도 ( 컨텍스트가 닫힌 후 프록시 접근 )
  • UPDATE 쿼리가 왜 나가지? -> Dirty Checking을 모르면 의도치 않는 UPDATE 발생 원인을 파악할 수 없음
  • 동일한 엔티티를 두 번 조회했는데 왜 SELECT는 한번이지? -> 1차 캐시 동작 원리
  • @Transactional 범위 설계 -> 영속성 컨텍스트의 생존 범위 = 트랙잭션 범위라서, 트랜잭션 설계가 곧 엔티티 관리 전략

SpringBoot에서는 @Transactional이 트랜잭션과 영속성 컨텍스트를 함께 열고 닫아주므로, 서비스 계층 설계 시 이 개념을 이해해야 어디서 엔티티가 살아있고, 어디서 죽는지 (생명주기)를 파악, 판단할 수 있다. 이는 곳 API 응답 시점에 엔티티 상태를 예측하는 능력과 직결된다.

//EntityManager의 여러 메서드

EntityManager em = emf.createEntityManager();
em.persist(entity);   // 영속화
em.find(...)          // 조회
em.remove(entity);    // 삭제
em.detach(entity);    // 준영속으로 전환
em.merge(entity);     // 준영속 → 다시 영속
em.flush();           // 쓰기 지연 SQL 강제 실행
em.clear();           // 1차 캐시 전체 초기화

SpringBoot에서는 직접 EntityManager를 다룰 일은 적음. Spring Data JPA의 JpaRepository가 내부적으로 EntityManager를 감싸서 save(),findById(),delete() 등의 메서드로 추상화해주고 있기 때문.

3-4. 연관관계 매핑

객체의 참조와 테이블의 FK (Foreign Key)를 매핑하는 것

@Entity
public class Order {

    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)  // 대부분의 경우 LAZY 사용
    @JoinColumn(name = "member_id")
    private Member member;
}

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "member")  // 읽기 전용 (FK를 관리하지 않음)
    private List<Order> orders = new ArrayList<>();
}
관계Annotation예시
다대일@ManyToOne주문 -> 회원
일대다@OneToMany회원 -> 주문목록
일대일@OneToOne회원 -> 프로필
다대다@ManyToMany사용하지 않음 (중간 테이블 필요)

Q. FetchType이란 무엇인가?

A. FetchType이란 연관된 엔티티를 언제 DB에서 가져올 것인가를 결정하는 전략이다.

  1. FetchType.EAGER ( 즉시 로딩 )
@ManyToOne(fetch = FetchType.EAGER)
private Team team;
  • Member를 조회하면 Team도 즉시 JOIN해서 함께 가져옴. 편해 보이지만 연관 엔티티가 많아지면 불필요한 쿼리가 늘어남
  1. FetchType.LAZY ( 지연 로딩 )
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
  • Member만 먼저 조회하고, member.getTeam().getName() 처럼 실제 접근이 필요한 경우 그 때 Team을 SELECT함. 내부적으로는 프록시 객체를 넣어두는 방식

  • @ManyToOne @OneToOne은 기본적으로 EAGER 타입

  • 가급적 모든 연관관계를 LAZY로 설정하고, 필요시에만 fetch join으로 한번에 가져오는 것이 JPA 성능 최적화의 기본 공식임

// JPQL fetch join으로 필요한 시점에 한 번에 조회
@Query("SELECT m FROM Member m JOIN FETCH m.team")
List<Member> findAllWithTeam();
  • EAGER로 두면 N+1 문제에 취약하고, 어디서 쿼리가 나가는지 추적이 어려워짐
  • LAZY + fetch join 방식이 쿼리 제어권을 개발자가 쥘 수 있음

4. JPQL

JPA는 SQL이 아닌 객체를 대상으로 쿼리하는 JPQL을 제공한다.

// SQL: SELECT * FROM members WHERE name = 'Tom'
// JPQL: 테이블이 아닌 엔티티를 대상으로 쿼리
String jpql = "SELECT m FROM Member m WHERE m.name = :name";
List<Member> result = em.createQuery(jpql, Member.class)
                        .setParameter("name", "Tom")
                        .getResultList();

5. SpringData JPA

Spring Boot에서는 JPA를 더 편하게 쓸 수 있도록 Spring Data JPA를 제공한다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    // 메서드 이름만으로 쿼리 자동 생성
    Optional<Member> findByEmail(String email); // SELECT * FROM member WHERE email = {email};
    List<Member> findByNameContaining(String keyword);
    boolean existsByEmail(String email);
}
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {

    private final MemberRepository memberRepository;

    @Transactional
    public Long join(String name, String email) {
        Member member = new Member(name, email);
        memberRepository.save(member);  // persist() 호출
        return member.getId();
    }

    public Member findById(Long id) {
        return memberRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("회원 없음"));
    }
}

계층 구조

Spring Data JPA  →  JPA(표준 인터페이스)  →  Hibernate(구현체)  →  JDBC  →  DB

6. 사용 시 주의 사항

① N+1 문제
연관된 엔티티를 조회할 때 추가 쿼리가 N번 나가는 문제. fetch join이나 @EntityGraph로 해결한다.

② 즉시 로딩(EAGER) 사용 금지
@ManyToOne의 기본값이 EAGER이므로, 반드시 fetch = FetchType.LAZY로 설정한다.

③ 양방향 매핑은 필요할 때만
단방향으로 충분하면 양방향을 만들지 않는다. 양방향은 복잡도만 높인다.

④ @Transactional 범위
영속성 컨텍스트는 트랜잭션과 생명주기를 같이 한다. Service 계층에 @Transactional을 선언하는 것이 일반적이다.


참고 자료

profile
알면 좋은 것보단 잊어버리기 싫은 것들을 기록합니다.

0개의 댓글