JPA에 대해 알아보기

leejihyeon·2026년 4월 26일

Spring

목록 보기
2/2

먼저 객체와 관계형 DB에 대해 알아보자!

객체와 RDB

  • 객체는 클래스를 통해 만들어지며 속성(field)와 기능(method)를 포함하며 RDB(관계형 데이터베이스)는 데이터를 테이블 형식으로 표현하며 각 테이블은 열(colunm)과 행(row)으로 구성된다.
// 객체
public class User {   // User라는 객체
	private String name; // 속성(필드)
    private String age; // User안에 있는 속성
    
    public void 메서드명(매개변수) {
    	...
    }
}    
// RDB
CREATE TABLE user (
	id BIGINT PRIMARY KEY,
    name VARCHAR(100),
    age INT
);
  • 객체 지향 언어 Java
    • 객체를 저장할 수 있는 다양한 종류의 DB
      1. RDB(무결성, 일관성)
      2. NoSQL
      3. File 등이 있다.
    • RDB와 객체 지향에서의 패러다임 불일치 문제가 발생한다.

RDB에 객체 저장 시 발생하는 문제점

  1. 리소스별 CRUD 반복
  • 리소스 : 우리가 관리하려는 데이터 단위인 User, Post(게시물), Comment(댓글) 등을 말한다.
  • 만약 ORM 없이 User와 Post라는 두 개의 리소스를 관리한다면, 개발자는 다음과 같은 작업을 각각 해야 한다.
    • insert User(), selectUser(), updateUser, delete User() SQL과 메서드 작성
    • insert Post(), selectPost() ... SQL과 메서드 작성
    • 구조는 테이블명과 컬럼 이름만 다르지 형식은 거의 똑같다. 하지만 똑같은 패턴의 코드를 계속해서 추가되면 그만큼 코드가 길어지고 수정해야 하는 코드도 많아진다.
    • 결과적으로 SQL에 의족적인 개발을 해야한다.

구체적인 문제점

  • 코드의 비대화 : 리소스가 100개면 CRUD 메서드는 최소 400개 정도가 된다. 관리해야 할 코드 양이 어마어마해진다.
  • 단순 반복으로 인한 실수 : 복사해서 붙여넣거나 컬럼 하나를 빠트리거나 타입 하나를 잘못 적는 실수가 비번하게 발생할 수 있다.
  • 수정의 지옥 : DB의 공통 구조가 바뀌면 수백 개의 SQL문을 일일이 찾아가서 수정해야 한다.

이 문제를 ORM이 해결해준다.

ORM이 해결하는 방법

  • "자바의 User객체를 넘겨줄 테니까, 네가 알아서 INSERE문을 만들어서 넣어줘" 한다.
  • 개발자는 리소스마다 SQL을 일일이 작성하지 않고 공통된 인터페이스 (예: save(), findAll())를 호출하기만 하면 된다.
  • 반복되는 코드를 추상화해서 자동화해버린다.

이제 RDB와 객체지향의 패러다임 불일치 문제에 대해 알아보자!

패러다임 불일치 문제 1

패러다임 불일치란?

  • 객체 지향 프로그래밍과 관계형 데이터베이스가 서로 지향하는 목적과 구조가 달라서 발생하는 충돌
  • 객체에서는 상속과 다형성을 통해 객체 관계를 표현할 수 있다.
  • 객체는 참조로 관계를 표현한다.
  • RDB는 상속과 다형성의 개념을 직접 지원하지 않고 별도 매핑이 필요하다.
  • RDB는 JOIN을 사용하여 관계를 결합한다.

1. 상속과 다형성

  • 객체(Java) : 부모 클래스 - 자식 클래스 관계를 통해 상속을 아주 자연스럽게 구현한다.
    • 예시) Payment클래스를 상속받는 kakaoPayment, naverPayment
    • Payment라는 타입 하나로 kakao, naver 결제 수단을 사용할 수 있다. 이를 다형성이라고 한다.
  • RDB(SQL) : 테잉블 간에 '상속'이라는 개념이 아예 없다.

이때 저장할 때와 조회할 때 패러다임 불일치 문제가 발생한다.

  • 저장할 때 : 자바에서 CardPayment 객체 하나를 저장하고 싶은데, DB입장에서는 payment 테이블에 넎고 card 테이블에도 나눠서 넣어야 한다.(쿼리 2번 작성)
  • 조회할 때 : 결제 내역을 불러오고 싶은데, 이게 카드인지 현금인지 구분해서 각각 다른 테이블과 JOIN을 해야 한다.

그래서 관계형 DB에서는 Data를 슈퍼타입, 서브타입 관계로 설정한다.

  • 슈퍼타입 : 공통적인 속성을 가진 상위 엔티티(예: 결제, 상품, 직원)
  • 서브타입 : 공통 속성 외에 자신만의 특수 속성을 가진 하위 엔티티(예: 카드결제/현금 결제, 앨범/도서, 정규직/계약직)

ORM에서는 이 슈퍼타입/서브타입 관계를 실제 DB테이블로 만들 때 보통 3가지 전략을 시도한다.

i. 조인 전략

  • 각각 테이블로 만들고 조인으로 구성

  • 특징 : 가장 정석적이고 데이터가 깔끔하다.

    • Tutor 저장 : 위 이미지를 보면 Tutor테이블에 데이터를 추가하려고 한다면 튜터에만 데이터를 INSERT하는게 아니라 Person에도 INSERT해야 하는 문제가 생긴다.

    • Tutor 조회 : 위 이미지처럼 튜터 테이블과 펄슨 테이블이 두 개로 나눠져 있는데 연관관계를 맺어져 있어 SELECT를 할 때 JOIN을 사용해서 외래키로 접근해 person테이블과 tutor테이블을 한꺼번에 조회할 수 있게끔 한다.

      Join문을 사용하면 까다롭기 때문에 DB에 저장할 객체는 상속 관계를 사용하지 않는다.

ii. 연관관계

  • 테이블의 연관관계는 외래키를 사용한다.

    // DB 중심 객체 설계
    public class Tutor {
        private Long id; // PK
        private Long companyId; // FK
        private String name;
    }
    
    public class Company {
        private Long id; // PK
        private String name;
    }
    # 저장 SQL
    INSERT INTO tutor(id, company_id, name) VALUES ...
    INSERT INTO company(id, name) VALUES ...
    
    # 조회 SQL
    SELECT * FROM tutor where ...
    SELECT * FROM company where ...
    • 하나의 INSERT SQL로 저장이 가능하다.
    • 조회하고자 하는 테이블만 조회하면 된다.
  • 객체 중심 설계

    • 객체의 연관관계는 참조를 사용한다.(참조 - 단방향)
    • User 객체 안에 Team객체를 필드로 가진다.(user.getTeam())
      • 화살표가 한 방향으로 흐르기 때문에, 반대 방향(팀 -> 유저)으로 가려면 별도의 참조가 또 필요하다.
      • DB를 두 번 조회해야 한다.
      • 알맞은 데이터를 직접 세팅해야 한다.
    // 객체 중심 설계
    public class Tutor {
        private Long id; // PK
        private Company company; // 참조 연관관계
        private String name;
    
        public Company getCompany() {
            return company;
            }
    
        public Company setCompany(Company company) {
            this.company = company;
            }
    }
    
    public Company {
        private Long id; // PK
        private String name;
    }
    // 조회 코드
    public Tutor findById(Long tutorId) {
    
        Tutor tutor = tutorRepository.findById(tutorId);
        Company company = CompanyRepository.findByTutorId(tutorId);
    
        tutor.setCompany(company);
    
        return tutor;
    }


  • 조회 SQL

    • RDB테이블은 외래키와 JOIN을 사용한다.(외래키 - 양방향)
    • 어느 테이블에 외래키만 있으면, 어느쪽에서든 서로 JOIN해서 찾아낼 수 있다.
    • 방향의 개념이 딱히 없다.
    # 조회
    # 조회 JOIN SQL 사용
    SELECT t.*, c.* FROM tutor t JOIN company c ON t.company_id = c.id;
    • DB 성능을 위해 JOIN을 사용하여 한 번에 조회한다.

패러다임 불일치 문제 2

  • 객체지향언에서 데이터와 동작을 함께 캡슐화하는 방식과 RDB가 데이터를 정규화된 테이블에 관계 중심으로 저장하는 방식의 차이에서 발생
  • 이로 인해 객체를 DB에 저장하거나 조회할 때 복잡한 매핑과 변환이 필요해지고 코드의 복잡성과 개발자의 부담이 증가한다.

나중에 정리


JPA란?

  • JPA는 Java의 ORM 기술 표준으로 사용되는 인터페이스의 모음이다.
  • 대표적인 구현체로 Hibernate를 주로 사용한다.

여기서 ORM이란?

  • 관계형 DB와 객체 지향 프로그래밍 언어를 매핑해주는 기술이다.
  • 객체와 관계형 DB를 자동으로 매핑하여 패러다임 불일치 문제를 해결한다.

예를 들어보면

CREATE TABLE user (
	id BIGINT PRIMARY KEY,
    name VARCHAR(100),
    age INT
);
  • 관계형 DB에서는 위의 코드처럼 user 테이블이 있다.
public class User {
	private Long id;
    private String name;
    private int age;
}
  • 자바에서는 위의 코드처럼 객체와 필드로 표현한다.

ORM은 이 둘의 사이를 연결한다.

  • 테이블의 한 행(row)을 객체 하나로 본다. (예: 관계형 DB의 테이블에 user를 자바의 User 객체로 본다.)
  • 컬럼(column)을 객체의 필드로 본다.(관계형 DB의 age INT -> 자바 User클래스 안에 private int age; 로 본다.
  • CRUD 작업을 객체 조작 중심으로 처리하게 도와준다.

ORM과 SQL의 CRUD 작업 비교
SQL 중심

저장(Create)
INSETE INTO user ... 쿼리 작성

조회(Read)
SELECT * FROM user WHERE id=1;

수정(Update)
UPDATE user SET name = ...

삭제(Delete)
DELETE FROM user WHERE ...

ORM(객체) 중심

저장(Create)
repository.save(user); // 객체 저장

조회(Read)
repository.findById(1L); // ID로 객체 찾기

수정(Update)
user.update("새이름");  // 객체 값 변경

삭제(Delete)
repository.delete(user);  // 객체 삭제

=> 정리하자면
테이블은 데이터 저장 구조이고, 객체는 애플리케이션이 다루는 구조
ORM은 객체와 관계형 DB를 자동으로 매핑(연결)하는 계층이다.

이 부분에 ORM 따로 정리한 부분 링크 첨부하기

JPA를 사용하는 이유

블라블라 나중에 추가


영속성 컨텍스트

영속성 컨텍스트란?

  • 영속성 컨텍스트는 "엔티티를 영구 저장하는 환경(영속성 상태)"이라는 뜻의 공간
  • 애플리케이션과 DB 사이에서 객체를 관리하는 일종의 중간 계층(캐시) 역할을 수행한다.
  • 영속성 컨텍스트에 저장된 엔티티는 DB와 자동으로 동기화하며 같은 트랜잭션 내에서는 동일한 객체가 유지된다.

  • 눈에 보이지 않는 논리적인 개념
    • 눈에 보이지 않는 메모리 상의 공간이 생긴다.
  • 엔티티 메니저를 통해서 영속성 컨텍스트에 접근/제어한다.
    • EntityManager.persist(entity);
  • 위의 코드처럼 엔티티를 호출하면 객체가 DB에 바로 저장되는 것이 아니라, 엔티티(객체)를 영속성 컨텍스트에 영속(저장)한다.

Entity

  • DB에서 엔티티를 저장할 수 있는 데이터의 집합을 의미한다.
  • JPA에서 엔티티란 DB의 테이블을 나타내는 클래스를 의미한다.
  • 엔티티는 영속성 컨텍스트와의 관계에 따라 4가지 상태를 가진다.
  1. 비영속(new/transient)
    • 상태 : 영속성 컨텍스트가 모르는 새로운 객체(순수한 자바 객체 상태)
    • 특징 : 영속성 컨텍스트나 DB와 전혀 연관이 없는 객체
    • 코드
      Tutor tutor = new Tutor();
      // 객체를 생성하고 영속성 컨텍스트에 저장하지 않은 상태
      Tutor tutor = new Tutor(1L, "wonuk", 100);
  1. 영속(Managed)
    • 상태 : 영속성 컨텍스트에 저장되고 관리되고 있는 상태
    • 특징 : 1차 캐시, 동일성 보장, 쓰기 지연등의 DB와 동기화되는 상태(commti시점에 DB에 반영)
    • 코드 : em.persist(tutor); 또는 em.find()로 가져온 객체
public static void main(String[] args) {
    // EntityManagerFactory 생성
    EntityManagerFactory emf = Persistence.createEntityManagerFactory("test");
    // EntityManager 생성
    EntityManager em = emf.createEntityManager();
    // Transaction 생성
    EntityTransaction transaction = em.getTransaction();

    // 트랜잭션 시작
    transaction.begin();

    try {
        // 비영속
        Tutor tutor = new Tutor(1L, "wonuk", 100);

        System.out.println("persist 전");
        // 영속
        em.persist(tutor);
        System.out.println("persist 후");

        // transaction이 commit되며 실제 SQL이 실행된다.
        transaction.commit();
    } catch (Exception e) {
        // 실패 -> 롤백
        e.printStackTrace();
        transaction.rollback();
    } finally {
        // 엔티티 매니저 연결 종료
        em.close();
    }
    
    emf.close();

}
  • em.persist()가 호출되며 영속 상태가 된다.
  • 실행결과
  • persist 후 출력 이후에 SQL이 실행된다.
  • 트랙잭션 Commti 시점에 SQL이 실행된다.
  1. 준영속(Detached)
    • 상태 : 영속성 컨텍스트에 저장되었다가 분리되어 더 이상 기억하지 않는 상태
    • 특징
      • 영속성 컨텍스트가 제공하는 기능을 더 이상 사용할 수 없다.(값은 가지고 있지만 관리 대상은 아님)
      • detach는 영속성 컨텍스트에서 지우고 준영속 상태로 만든다.
      • 영속성 컨텍스트가 제공하는 기능을 사용하지 못한다.
    • 준영속 상태로 만드는 방법
      1. em.detach() : 특정 엔티티만 준영속 상태로 변경한다.
      2. em.clear() : 영속성 컨텍스트를 초기화한다.
      3. em.close() : 영속성 컨텍스트를 종료한다.
    • 코드 : em.detach(tutor);, en.clear();, em.close();
  2. 삭제(Removed)
    • 상태 : 영속성 컨텍스트에 의해 삭제로 표시된 상태
    • 특징 : 트랙잭션 커밋 시점에 실제 DB에서 DELETE 쿼리가 실행되면서 DB에서 제거
    • 코드 : em.remove(tutor); (객체를 삭제한 상태)
      • remove는 실제 데이터 삭제를 영속성 컨텍스트에 요청한 상태

!! 엔티티 상태는 영속 상태일 때만 JPA의 영속성 컨텍스트가 데이터를 관리한다.

다음으로는 JPA가 객체지향적 편리함과 성능 최적화를 동시에 잡을 수 있게 햊주는 핵심 기능을 알아보자!!

1차 캐시

  • 영속성 컨텍스트 내부에는 엔티티를 보관하는 "1차 캐시"가 존재한다.

  • 엔티티를 영속성 컨텍스트에 저장할 때 생성되는 메모리 내 캐시이다.

  • 동작 방식

  1. 영속성 컨텍스트의 1차 캐시에 저장된다.

    // 비영속
    Tutor tutor = new Tutor(1L, "wonuk", 100);
    
    // 영속, 1차 캐시에 저장
    em.persist(tutor);
  2. 영속된 엔티티 조회할 때 DB가 아닌 1차 캐시에 저장된 엔티티를 먼저 조회한다.

  • 이때 find()로 호출 시 캐시에 데이터가 있다면 DB를 조회 X 메모리에서 즉시 반환한다.
// 1차 캐시에서 조회
Tutor findTutor = em.find(Tutor.class, 1L);

1차 캐시 전체 코드 예시

public static void main(String[] args) {
    // EntityManagerFactory 생성
    EntityManagerFactory emf = Persistence.createEntityManagerFactory("test");
    // EntityManager 생성
    EntityManager em = emf.createEntityManager();
    // Transaction 생성
    EntityTransaction transaction = em.getTransaction();

    // 트랜잭션 시작
    transaction.begin();

    try {
        // 비영속
        Tutor tutor = new Tutor(1L, "wonuk", 100);

        // 영속
        System.out.println("persist 전");
        em.persist(tutor);
        System.out.println("persist 후");

        Tutor findTutor = em.find(Tutor.class, 1L);

        System.out.println("findTutor.getId() = " + findTutor.getId());
        System.out.println("findTutor.getName() = " + findTutor.getName());
        System.out.println("findTutor.getAge() = " + findTutor.getAge());

        // transaction이 commit되며 실제 SQL이 실행된다.
        transaction.commit();
    } catch (Exception e) {
        // 실패 -> 롤백
        e.printStackTrace();
        transaction.rollback();
    } finally {
        // 엔티티 매니저 연결 종료
        em.close();
    }
    emf.close();
}
  • 실행 결과
    • 1차 캐시의 엔티티를 조회한다.
      • 조회 SQL이 실행되지 않는다.(DB에서 조회하는게 아니기 때문에)
    • 트랙잭션 Commit 시점에 INSERT SQL이 실행된다.

DB에 저장된 데이터 조회

  • 위에서는 DB에 저장되기 전에 조회였다면 DB에 저장된 후의 데이터 조회를 알아보자

  • 캐시에 데이터가 없으면 그때 DB를 조회하고, 조회된 데이터를 1차 캐시를 저장한 뒤 반환한다.

  • 1차 캐시는 동일한 트랜잭션 안에서만 사용이 가능하다.

    • 동일한 트랜잭션 내에서 중복된 조회가 발생할 경우 DB접근을 획기적으로 줄여준다.
  • 요청이 들어오고 트랙잭션이 종료되면 영속성 컨텍스트는 삭제된다.

  • 코드 : xml <property name="hibernate.hbm2ddl.auto" value="none"/> 설정

    • DDL을 자동으로 생성하지 않는다.
public static void main(String[] args) {
    // EntityManagerFactory 생성
    EntityManagerFactory emf = Persistence.createEntityManagerFactory("test");
    // EntityManager 생성
    EntityManager em = emf.createEntityManager();
    // Transaction 생성
    EntityTransaction transaction = em.getTransaction();

    // 트랜잭션 시작
    transaction.begin();

    try {

        // 데이터베이스에서 조회 후 1차 캐시에 저장
        Tutor findTutor = em.find(Tutor.class, 1L);
        // 1차 캐시에서 조회
        Tutor findCacheTutor = em.find(Tutor.class, 1L);

        // transaction이 commit되며 실제 SQL이 실행된다.
        transaction.commit();
    } catch (Exception e) {
        // 실패 -> 롤백
        e.printStackTrace();
        transaction.rollback();
    } finally {
        // 엔티티 매니저 연결 종료
        em.close();
    }
}
  • 실행 결과
  • 조회 SQL이 한번만 실행된다.

동일성 보장

  • 같은 주소값 : 동일 트랙잭션 내에서 같은 ID로 조회하면 항상 같은 메모리 주소를 가진 객체를 반환한다.
    1. 첫 번째 조회 : em.find(Menber.class, "member1") 호출 시, 1차 캐시에 데이터가 없으므로 DB에서 조회하고 결과를 1차 캐시에 저장한 뒤 반환한다.
    2. 두 번째 조회 : 동일한 ID로 다시 조회하면, JPA는 DB를 보지 않고 1차 캐시에 있는 기존 객체 주소값을 그대로 반환한다.
  • DB 부하 감소 : 이미 조회된 객체는 1차 캐시에서 바로 꺼내오므로 중복된 SELETE 쿼리가 발생하지 않는다.
  • 중복 조회해도 동일한 객체를 참조하게 하여 일관성을 유지한다.
public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin(); // 트랜잭션 시작

        try {
            // 1. 첫 번째 조회 (DB에서 조회 후 1차 캐시에 저장)
            System.out.println("=== FIRST FIND START ===");
            Member findMember1 = em.find(Member.class, "member1");
            System.out.println("member1.name = " + findMember1.getName());

            // 2. 두 번째 조회 (1차 캐시에서 즉시 반환)
            System.out.println("=== SECOND FIND START ===");
            Member findMember2 = em.find(Member.class, "member1");
            System.out.println("member2.name = " + findMember2.getName());

            // 3. 동일성 비교 (==)
            System.out.println("result = " + (findMember1 == findMember2));

            tx.commit(); // 트랜잭션 커밋
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
        }
        emf.close();
    }
}
=== FIRST FIND START ===
Hibernate: 
    select
        member0_.id as id1_0_0_,
        member0_.name as name2_0_0_ 
    from
        Member member0_ 
    where
        member0_.id='member1'
member1.name = 홍길동

=== SECOND FIND START ===
member2.name = 홍길동

// 비교 결과 로그
result = true
  • 두 번째 조회 시에는 SELECT 쿼리가 발생하지 않아 한 번만 실행된다.
  • 자바의 일반적인 방식(SQL 직접 핸들링)이라면 각각 new로 생성되어 false가 나왔겠지만, JPA 영속성 컨텍스트 덕분에 메모리 주소가 같은 동일 인스턴스임이 증명된다.

쓰기 지연

  • 엔티티 객체의 변경 사항을 DB에 바로 반영하지 않는다.
  • 동일한 트랜잭션 내에서 생성된 SQL들을 커밋 시점에 한 번에 반영하는 방식
    • tutor1 1차 캐시에 저장
    • 쓰기 지연 저장소에 tutor1 INSERT SQL 저장

  • tutor2 저장

    • tutor2 1차 캐시에 저장
    • 쓰기 지연 저장소에 tutor2 INSERT SQL 저장

  • flush 실행
    • flush되면서 SQL 쿼리 실행
    • 트랜잭션이 Commit되면서 실제 DB에 반영
  • 이를 통해 성능을 최적화하고 트랜잭션 내에서의 불필요한 DB 쓰기 작업을 최소화한다.

텍스트

정리

  • ORM : 객체와 관계형 DB를 매핑하는 방식
  • JPA : 자바에서 ORM을 사용하기 위한 표준 명세
  • Hibernate : JPA를 실제로 동작하게 만드는 대표 구현체

참고 자료
aws ORM이란 무엇인가요
스파르타 Spring 4기 강의 자료

0개의 댓글