[Spring] Spring Boot JPA 동작 원리와 CRUD 정리

2026. 7. 31. 09:16·개발/Spring
반응형

Entity 매핑부터 영속성 컨텍스트, 연관관계와 트랜잭션까지

Spring Boot로 데이터베이스를 다루기 시작하면 보통 JpaRepository를 상속하고 save(), findById() 같은 메서드를 먼저 사용하게 된다.

코드는 매우 간단하다.

public interface ProductRepository
        extends JpaRepository<Product, Long> {
}

하지만 JPA를 제대로 사용하려면 단순히 Repository 메서드를 외우는 것보다 다음 질문에 답할 수 있어야 한다.

JPA는 객체를 어떻게 데이터베이스 테이블과 연결할까?
save()를 호출하면 SQL이 즉시 실행되는가?
조회한 Entity의 값만 바꿨는데 왜 UPDATE가 실행될까?
@Transactional은 어떤 원리로 Commit과 Rollback을 처리할까?

이번 글에서는 JPA의 기본 개념부터 Entity 매핑, 영속성 컨텍스트, 연관관계, Repository와 트랜잭션까지 하나의 흐름으로 정리한다.

마지막에는 간단한 상품 CRUD 예제를 통해 각 개념이 실제 코드에서 어떻게 연결되는지도 살펴본다.


1. JPA와 ORM은 무엇인가?

객체와 관계형 데이터베이스의 차이

Java 애플리케이션은 객체를 중심으로 동작한다.

Java 객체

Product
- id
- name
- price
- status

관계형 데이터베이스는 테이블을 중심으로 데이터를 저장한다.

products 테이블

id | name | price | status

둘은 비슷해 보이지만 데이터를 표현하는 방식은 다르다.

객체지향 관계형 데이터베이스
객체와 참조 테이블과 외래키
상속 테이블 구조
Collection 연관 테이블
객체 동일성 기본키
메서드와 상태 컬럼과 데이터

객체를 DB에 저장하려면 객체의 필드를 SQL 컬럼으로 변환하고, 조회 결과를 다시 객체로 만들어야 한다.

JDBC만 사용한다면 개발자가 이 과정을 직접 처리한다.

String sql =
        "SELECT id, name, price FROM products WHERE id = ?";

ResultSet resultSet = statement.executeQuery();

Product product = new Product(
        resultSet.getLong("id"),
        resultSet.getString("name"),
        resultSet.getBigDecimal("price")
);

이러한 객체와 테이블 사이의 변환 문제를 해결하는 기술이 ORM이다.

Java Object
↕
ORM
↕
Relational Database

ORM은 Object-Relational Mapping의 약자로, Java 객체와 관계형 데이터베이스 테이블을 연결하는 기술이다.


JPA는 구현체가 아니라 표준이다

JPA는 Java Persistence API의 약자다.

JPA 자체가 데이터베이스에 SQL을 실행하는 완성된 라이브러리는 아니다. Java에서 ORM을 사용하기 위한 인터페이스와 어노테이션의 표준을 정의한다.

실제 동작은 Hibernate와 같은 구현체가 담당한다.

애플리케이션 코드
→ JPA 표준 인터페이스
→ Hibernate
→ SQL 생성
→ Database
@Entity
@Table(name = "products")
public class Product {
}

@Entity, @Id, EntityManager 등은 JPA가 정의한 표준이고, 이를 해석해 SQL을 만드는 대표적인 구현체가 Hibernate다. JPA는 객체와 테이블을 매핑하는 ORM 표준이며, 인터페이스와 어노테이션을 제공하고 Hibernate 등이 실제 구현을 담당한다.


JPA와 Spring Data JPA의 차이

두 개념도 구분해야 한다.

JPA
→ ORM 사용 방법을 정의한 표준

Hibernate
→ JPA 표준을 구현한 ORM 프레임워크

Spring Data JPA
→ JPA를 더 편리하게 사용하도록 돕는 Spring 프로젝트

Spring Data JPA를 사용하면 DAO 구현 클래스를 직접 작성하지 않고 Repository 인터페이스만 선언할 수 있다.

public interface ProductRepository
        extends JpaRepository<Product, Long> {
}

Spring Data JPA가 실행 시점에 해당 인터페이스의 구현체를 만들어 Bean으로 등록한다.


JPA와 JDBC의 차이

구분 JPA JDBC
개발 중심 Entity 객체 SQL
데이터 매핑 자동 매핑 ResultSet 직접 변환
CRUD 기본 SQL 자동 생성 SQL 직접 작성
변경 처리 변경 감지 UPDATE 직접 실행
트랜잭션 선언적 관리 가능 Connection 직접 제어
장점 생산성, 객체지향적 코드 SQL 제어가 명확함
주의점 ORM 원리와 SQL 이해 필요 반복 코드 증가

JPA는 JDBC를 없애는 기술이 아니다.

JPA 구현체도 내부적으로 JDBC를 이용해 데이터베이스에 SQL을 전달한다.

JPA
→ JDBC를 추상화하여 편리하게 사용

자료에서도 JDBC는 SQL과 ResultSet을 직접 다루는 방식이고, JPA는 Entity 중심으로 CRUD·매핑·트랜잭션과 변경 감지 등을 자동화하는 방식으로 비교한다.


2. Entity와 테이블 매핑

JPA가 관리하는 객체를 Entity라고 한다.

@Entity
@Table(name = "products")
public class Product {
}

@Entity가 붙은 객체는 단순한 DTO가 아니다.

JPA가 영속성 컨텍스트에서 상태를 추적하고, 데이터베이스 테이블과 연결하여 저장·조회·수정·삭제하는 객체다.


기본 Entity 구조

@Entity
@Table(name = "products")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {

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

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

    @Column(
            nullable = false,
            precision = 12,
            scale = 2
    )
    private BigDecimal price;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private ProductStatus status;

    public Product(
            String name,
            BigDecimal price
    ) {
        validatePrice(price);

        this.name = name;
        this.price = price;
        this.status = ProductStatus.ON_SALE;
    }

    public void changePrice(BigDecimal newPrice) {
        validatePrice(newPrice);
        this.price = newPrice;
    }

    private void validatePrice(BigDecimal price) {
        if (price == null ||
                price.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException(
                    "상품 가격은 0 이상이어야 합니다."
            );
        }
    }
}
public enum ProductStatus {
    ON_SALE,
    SOLD_OUT,
    DISCONTINUED
}

@Entity

@Entity
public class Product {
}

해당 클래스를 JPA가 관리하는 Entity로 등록한다.

Entity에는 JPA가 객체를 생성할 때 사용할 기본 생성자가 필요하다.

protected Product() {
}

기본 생성자를 public으로 열어 둘 필요는 없기 때문에 일반적으로 protected를 사용한다.

또한 지연 로딩 Proxy와 같은 JPA 구현 기능을 안정적으로 사용하려면 Entity 클래스와 연관관계 메서드를 무조건 final로 만들지 않는 것이 일반적이다.


@Table

@Table(name = "products")

Entity가 매핑될 테이블을 지정한다.

@Table을 생략하면 기본적으로 Entity 이름을 기준으로 테이블을 찾는다. 하지만 실제 클래스명과 테이블명이 다를 수 있으므로 명시적으로 지정하면 구조를 이해하기 쉽다.

복합 Unique 제약도 지정할 수 있다.

@Table(
    name = "products",
    uniqueConstraints = {
        @UniqueConstraint(
            name = "uk_product_name_seller",
            columnNames = {
                "name",
                "seller_id"
            }
        )
    }
)

단일 컬럼의 Unique 제약은 @Column(unique = true)로 지정할 수 있고, 여러 컬럼의 조합은 @Table의 uniqueConstraints를 사용한다.


@Id

@Id
private Long id;

Entity를 구분하는 기본키를 지정한다.

JPA는 기본키를 이용해 다음을 판단한다.

  • 서로 같은 Entity인지
  • 영속성 컨텍스트에 이미 존재하는지
  • 어떤 행을 수정하거나 삭제할지
  • 연관된 Entity를 어떻게 참조할지

모든 Entity에는 식별자가 필요하다.


@GeneratedValue

@GeneratedValue(
    strategy = GenerationType.IDENTITY
)

기본키 생성 전략을 지정한다.

전략 설명
AUTO 구현체가 DB에 맞는 전략 선택
IDENTITY DB의 Auto Increment 사용
SEQUENCE DB Sequence 사용
TABLE 별도 Key 테이블 사용

MySQL이나 MariaDB에서는 IDENTITY가 자주 사용된다.

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

PostgreSQL이나 Oracle의 Sequence를 명시적으로 사용할 수도 있다.

@Id
@GeneratedValue(
    strategy = GenerationType.SEQUENCE,
    generator = "product_seq"
)
@SequenceGenerator(
    name = "product_seq",
    sequenceName = "product_sequence",
    allocationSize = 50
)
private Long id;

@GeneratedValue는 반드시 @Id와 함께 사용하며, 데이터베이스의 Auto Increment와 Sequence 등 여러 생성 전략을 지원한다.


@Column

@Column(
    name = "product_name",
    nullable = false,
    length = 100
)
private String name;

필드와 컬럼의 세부 매핑을 지정한다.

속성 의미
name 실제 컬럼명
nullable null 허용 여부
length 문자열 길이
unique 단일 컬럼 Unique
insertable INSERT에 포함할지
updatable UPDATE에 포함할지
precision 숫자 전체 자릿수
scale 소수점 자릿수

 

@Column(updatable = false)
private LocalDateTime createdAt;

updatable=false로 설정하면 JPA가 생성하는 UPDATE SQL에서 해당 컬럼을 제외한다.

금액처럼 정확한 소수 계산이 필요할 때는 double보다 BigDecimal을 사용하는 것이 좋다.

@Column(precision = 12, scale = 2)
private BigDecimal price;

@Column은 컬럼명, null 허용 여부, 길이, Unique, 수정 가능 여부와 숫자 자릿수 등을 설정할 수 있다.


@Enumerated

Enum을 데이터베이스에 저장할 방법을 지정한다.

@Enumerated(EnumType.STRING)
private ProductStatus status;

ORDINAL 방식

ON_SALE      → 0
SOLD_OUT     → 1
DISCONTINUED → 2

Enum 순서를 숫자로 저장한다.

중간에 새로운 값을 추가하거나 순서를 변경하면 기존 DB 값의 의미가 달라질 수 있다.

STRING 방식

ON_SALE
SOLD_OUT
DISCONTINUED

Enum 이름을 문자열로 저장한다.

따라서 실무에서는 일반적으로 EnumType.STRING을 사용한다.

@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private ProductStatus status;

자료에서도 Enum 저장 방식으로 ORDINAL과 STRING을 설명하며, 실무에서는 STRING 방식을 권장한다.


@Lob

긴 텍스트나 바이너리 데이터를 저장할 때 사용한다.

@Lob
private String description;
String + @Lob
→ CLOB
@Lob
private byte[] profileImage;
byte[] + @Lob
→ BLOB

다만 이미지 파일 자체를 DB에 저장하기보다 Object Storage나 파일 시스템에 저장하고, DB에는 URL이나 경로를 저장하는 구조도 많이 사용된다.


@Transient

@Transient
private BigDecimal discountedPrice;

해당 필드를 DB 컬럼과 매핑하지 않는다.

다음과 같은 값에 활용할 수 있다.

  • 화면 출력용 계산값
  • 임시 상태
  • 입력 확인 필드
  • DB 저장이 필요 없는 보조 데이터
@Transient
private String passwordConfirm;

JPA의 @Transient는 필드를 영속화 대상에서 제외하는 어노테이션이다.


Entity에 @Data 사용을 조심해야 하는 이유

@Entity
@Data
public class Product {
}

코드는 짧지만 Entity에는 무분별한 Setter가 생기고, equals(), hashCode(), toString()에 연관관계 필드가 포함될 수 있다.

이로 인해 다음 문제가 발생할 수 있다.

  • 아무 곳에서나 Entity 상태 변경
  • 양방향 연관관계의 toString() 무한 반복
  • 지연 로딩이 예상치 못한 시점에 실행
  • 식별자 생성 전후 equals() 동작 혼란
  • Entity의 불변식이 깨짐

Entity에는 필요한 Lombok 어노테이션만 선택적으로 사용하는 편이 안전하다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Product {
}

값 변경은 Setter 대신 의미 있는 메서드로 제한한다.

product.changePrice(newPrice);
product.changeStatus(ProductStatus.SOLD_OUT);

3. 영속성 컨텍스트와 Entity 상태

JPA의 핵심은 단순히 SQL을 자동 생성하는 데 있지 않다.

가장 중요한 개념은 영속성 컨텍스트다.

영속성 컨텍스트는 Entity를 저장하고 추적하는 논리적인 관리 공간이다.

애플리케이션
→ 영속성 컨텍스트
→ Database

트랜잭션이 시작되면 JPA는 Entity를 영속성 컨텍스트에서 관리한다.


Entity의 생명주기

Entity는 다음 상태를 가질 수 있다.

비영속 New/Transient
→ 영속 Managed
→ 준영속 Detached
→ 삭제 Removed

비영속 상태

JPA와 관계없이 Java 코드로만 생성된 객체다.

Product product = new Product(
        "무선 마우스",
        BigDecimal.valueOf(15000)
);

아직 영속성 컨텍스트가 관리하지 않는다.

영속 상태

JPA가 Entity를 추적하고 있는 상태다.

entityManager.persist(product);

또는 Repository로 조회한 객체도 트랜잭션 안에서는 영속 상태가 된다.

Product product = repository.findById(id)
        .orElseThrow();

준영속 상태

한때 영속 상태였지만 현재 영속성 컨텍스트의 관리를 받지 않는 상태다.

entityManager.detach(product);

트랜잭션이 끝나고 영속성 컨텍스트가 종료된 Entity도 일반적으로 준영속 상태로 볼 수 있다.

삭제 상태

삭제 대상으로 등록된 상태다.

entityManager.remove(product);

Flush 시 DELETE SQL이 실행된다.


1차 캐시

영속성 컨텍스트는 내부에 1차 캐시를 가진다.

Key
→ Entity 타입 + 기본키

Value
→ Entity 객체

같은 트랜잭션에서 동일한 Entity를 두 번 조회하면, 조건에 따라 두 번째 조회는 1차 캐시에서 반환될 수 있다.

Product first = entityManager.find(
        Product.class,
        1L
);

Product second = entityManager.find(
        Product.class,
        1L
);

System.out.println(first == second); // true

영속성 컨텍스트는 같은 식별자의 Entity에 대해 동일한 객체를 반환하는 동일성을 보장한다.

단, 1차 캐시는 애플리케이션 전체가 공유하는 Cache가 아니라 일반적으로 트랜잭션 또는 영속성 컨텍스트 범위에서만 유지된다.


쓰기 지연

JPA는 INSERT, UPDATE, DELETE SQL을 매번 즉시 DB에 전송하지 않고 영속성 컨텍스트에 모아 둘 수 있다.

Entity 저장
→ 영속성 컨텍스트 등록
→ 쓰기 지연 SQL 저장소
→ Flush 시 SQL 실행
repository.save(productA);
repository.save(productB);
repository.save(productC);

변경 내용은 영속성 컨텍스트에 먼저 반영되고, 일반적으로 Commit 직전 Flush가 발생하면서 SQL이 DB에 전달된다. 자료도 JPA가 변경 내용을 영속성 컨텍스트에 보관하고 Commit 시점에 Flush하여 SQL을 실행한다고 설명한다.

다만 기본키 전략이 IDENTITY인 경우에는 생성된 기본키를 알아야 하므로 INSERT가 비교적 빠른 시점에 실행될 수 있다.


변경 감지 Dirty Checking

JPA에서 가장 중요한 기능 중 하나다.

트랜잭션 안에서 영속 Entity의 필드 값을 변경하면 별도로 UPDATE 메서드를 호출하지 않아도 변경이 DB에 반영된다.

@Transactional
public void changePrice(
        Long productId,
        BigDecimal newPrice
) {
    Product product = productRepository
            .findById(productId)
            .orElseThrow();

    product.changePrice(newPrice);
}

repository.save(product)를 다시 호출하지 않았다.

그런데 트랜잭션 Commit 시점에는 UPDATE가 실행된다.

1. Entity 조회
2. 영속성 컨텍스트가 최초 상태 보관
3. Entity 필드 변경
4. Flush 시 최초 상태와 현재 상태 비교
5. 변경된 컬럼에 대한 UPDATE SQL 생성
6. Commit

이를 변경 감지라고 한다.

UPDATE products
SET price = ?
WHERE id = ?

변경 감지는 다음 조건에서 동작한다.

  • Entity가 영속 상태일 것
  • 트랜잭션 범위 안일 것
  • 변경 내용이 Flush될 것

준영속 Entity의 값을 변경한다고 자동 UPDATE가 발생하지는 않는다.


Flush와 Commit은 다르다

두 개념을 구분해야 한다.

Flush
→ 영속성 컨텍스트의 변경 내용을 DB SQL로 동기화

Commit
→ 현재 트랜잭션의 변경을 최종 확정
repository.flush();

Flush가 호출되면 SQL은 DB에 전달되지만, 트랜잭션이 Rollback되면 최종 데이터는 취소될 수 있다.

Flush
→ SQL 실행
→ 아직 트랜잭션 진행 중
→ Rollback 가능

반면 Commit은 트랜잭션을 끝내고 변경을 확정한다.


save()의 실제 의미

Spring Data JPA의 save()는 Entity가 새로운 객체인지에 따라 내부 동작이 달라질 수 있다.

새 Entity
→ EntityManager.persist()

기존 Entity
→ EntityManager.merge()
Product saved = repository.save(product);

특히 merge()는 전달받은 객체 자체를 영속 상태로 만드는 것이 아니라, 상태가 복사된 새로운 영속 객체를 반환할 수 있다.

따라서 반환값을 사용하는 습관이 안전하다.

Product managedProduct =
        repository.save(detachedProduct);

하지만 트랜잭션에서 조회한 영속 Entity를 수정하는 경우에는 변경 감지가 있으므로 일반적으로 save()를 다시 호출할 필요가 없다.

@Transactional
public void updateProduct(...) {
    Product product = repository.findById(id)
            .orElseThrow();

    product.changePrice(newPrice);

    // repository.save(product); 생략 가능
}

4. 연관관계 매핑

관계형 DB에서는 외래키로 테이블 관계를 만든다.

orders.customer_id
→ customers.id 참조

Java 객체에서는 다른 객체를 필드로 참조한다.

private Customer customer;

JPA의 연관관계 매핑은 객체 참조와 외래키를 연결하는 과정이다.


다중성

관계 어노테이션 예시
N:1 @ManyToOne 여러 상품이 한 판매자를 참조
1:N @OneToMany 판매자 한 명이 여러 상품 보유
1:1 @OneToOne 사용자와 프로필
N:M @ManyToMany 사용자와 관심 종목

@ManyToOne

여러 상품이 하나의 판매자를 참조한다고 가정한다.

@Entity
public class Product {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "seller_id")
    private Seller seller;
}

DB에서는 products 테이블의 seller_id가 외래키가 된다.

products

id
name
price
seller_id → sellers.id

외래키를 가진 Product.seller가 연관관계의 주인이다.


@OneToMany와 mappedBy

@Entity
public class Seller {

    @Id
    @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "seller")
    private List<Product> products =
            new ArrayList<>();
}

mappedBy = "seller"에서 seller는 DB 컬럼명이 아니다.

반대편 Entity인 Product의 필드 이름이다.

private Seller seller;
Product.seller
→ 외래키 관리
→ 연관관계의 주인

Seller.products
→ 읽기용 반대 방향
→ mappedBy 사용

연관관계 편의 메서드

양방향 관계에서는 객체 양쪽을 함께 설정하는 메서드를 만드는 것이 좋다.

@Entity
public class Product {

    @ManyToOne(fetch = FetchType.LAZY)
    private Seller seller;

    public void assignSeller(Seller seller) {
        this.seller = seller;
        seller.addProduct(this);
    }
}
@Entity
public class Seller {

    @OneToMany(mappedBy = "seller")
    private List<Product> products =
            new ArrayList<>();

    public void addProduct(Product product) {
        products.add(product);
    }
}

DB 외래키는 연관관계의 주인만 변경하지만, 애플리케이션 메모리의 객체 관계를 일관되게 유지하려면 양쪽 값을 함께 설정해야 한다.


지연 로딩

@ManyToOne(fetch = FetchType.LAZY)
private Seller seller;

Product를 조회할 때 Seller의 모든 정보를 즉시 가져오지 않고, 실제로 product.getSeller()를 사용하는 시점에 조회하도록 할 수 있다.

Product 조회
→ Seller는 Proxy

product.getSeller().getName()
→ 필요 시 Seller 조회 SQL 실행

일반적으로 @ManyToOne, @OneToOne에는 명시적으로 LAZY를 설정하는 습관이 좋다.

@ManyToOne(fetch = FetchType.LAZY)

연관관계를 무조건 즉시 로딩하면 필요하지 않은 데이터와 Join이 늘어날 수 있다.


N+1 문제

상품 목록 100개를 조회한 뒤 각 상품의 판매자를 조회한다고 가정해 보자.

List<Product> products =
        productRepository.findAll();

for (Product product : products) {
    System.out.println(
            product.getSeller().getName()
    );
}

다음과 같은 SQL이 발생할 수 있다.

상품 목록 조회 1회
+
각 상품의 판매자 조회 N회
=
총 N+1회

이를 N+1 문제라고 한다.

필요한 연관 데이터를 한 번에 가져오려면 Fetch Join을 사용할 수 있다.

@Query("""
    select p
    from Product p
    join fetch p.seller
""")
List<Product> findAllWithSeller();

모든 연관관계를 EAGER로 바꾸는 것은 해결책이 아니다. API나 업무에 필요한 조회별로 Fetch 전략을 설계해야 한다.


@ManyToMany를 조심해야 하는 이유

@ManyToMany
private List<Category> categories;

간단해 보이지만 중간 테이블에 추가 필드를 넣기 어렵다.

product_category

product_id
category_id
created_at
display_order

created_at, display_order가 필요해지는 순간 단순 관계 테이블이 하나의 도메인 개념이 된다.

따라서 실무에서는 중간 Entity를 명시적으로 만드는 것이 유연하다.

Product
1:N
ProductCategory
N:1
Category
@Entity
public class ProductCategory {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Product product;

    @ManyToOne(fetch = FetchType.LAZY)
    private Category category;

    private int displayOrder;
}

@Embedded와 값 객체

주소처럼 독립적인 Entity는 아니지만 여러 값을 하나의 개념으로 묶고 싶을 수 있다.

@Embeddable
public class Address {

    private String city;
    private String street;
    private String zipcode;

    protected Address() {
    }
}
@Entity
public class Seller {

    @Id
    @GeneratedValue
    private Long id;

    @Embedded
    private Address address;
}

별도 Address 테이블이 만들어지는 것은 아니다.

sellers 테이블

id
city
street
zipcode

@Embeddable은 독립 Entity가 아닌 값 객체를 정의하며, @Embedded로 Entity에 포함하면 값 객체의 필드가 해당 테이블의 컬럼으로 들어간다.


5. Spring Data JPA Repository

JpaRepository

public interface ProductRepository
        extends JpaRepository<Product, Long> {
}

첫 번째 Generic은 Entity 타입, 두 번째는 기본키 타입이다.

JpaRepository<Product, Long>

Product
→ 관리할 Entity

Long
→ Product의 @Id 타입

Spring Data Repository 구조는 다음과 같이 확장된다.

Repository
→ CrudRepository
→ PagingAndSortingRepository
→ JpaRepository

JpaRepository는 CRUD, Paging, Sorting과 JPA 전용 기능을 제공한다.


기본 메서드

조회

Optional<Product> findById(Long id);

List<Product> findAll();

boolean existsById(Long id);

long count();

findById()가 Optional을 반환하는 이유는 해당 ID의 데이터가 존재하지 않을 수 있기 때문이다.

Product product = repository.findById(id)
        .orElseThrow(
                () -> new ProductNotFoundException(id)
        );

저장

Product saved = repository.save(product);

삭제

repository.delete(product);

repository.deleteById(id);

페이징과 정렬

Page<Product> findAll(Pageable pageable);

List<Product> findAll(Sort sort);

Query Method

메서드 이름으로 조회 조건을 표현할 수 있다.

List<Product> findByNameContaining(
        String keyword
);
List<Product> findByPriceBetween(
        BigDecimal min,
        BigDecimal max
);
List<Product>
findByStatusOrderByPriceAsc(
        ProductStatus status
);
boolean existsByName(String name);

주요 키워드는 다음과 같다.

키워드 의미
And, Or 조건 연결
Between 범위
LessThan 미만
GreaterThan 초과
In 목록 포함
IsNull null 검사
Containing 문자열 포함
StartingWith 문자열 시작
OrderBy 정렬

단순 조건에서는 편리하지만 메서드 이름이 지나치게 길어진다면 다른 방식을 사용하는 것이 좋다.


JPQL과 @Query

@Query("""
    select p
    from Product p
    where p.price between :min and :max
    order by p.price asc
""")
List<Product> findProductsInPriceRange(
        @Param("min") BigDecimal min,
        @Param("max") BigDecimal max
);

JPQL은 테이블과 컬럼이 아닌 Entity와 필드를 대상으로 작성한다.

SQL
→ products 테이블
→ product_name 컬럼

JPQL
→ Product Entity
→ name 필드
SELECT *
FROM products p
WHERE p.price BETWEEN ? AND ?
select p
from Product p
where p.price between :min and :max

Hibernate가 JPQL을 실제 DB SQL로 변환한다.


Native Query

DB 고유 기능이 필요할 때 실제 SQL을 작성할 수 있다.

@Query(
    value = """
        SELECT *
        FROM products
        WHERE price >= :minPrice
    """,
    nativeQuery = true
)
List<Product> findByNativeQuery(
        @Param("minPrice") BigDecimal minPrice
);

장점은 SQL을 직접 제어할 수 있다는 것이고, 단점은 특정 DB의 테이블명·문법에 종속될 수 있다는 것이다.


QueryDSL

검색 조건이 동적으로 달라질 때 사용하기 좋다.

keyword가 있으면 상품명 검색
status가 있으면 상태 조건
minPrice가 있으면 최소 가격
maxPrice가 있으면 최대 가격

JPQL 문자열을 이어 붙이는 대신 Java 코드로 조건을 조립한다.

BooleanBuilder builder =
        new BooleanBuilder();

if (keyword != null) {
    builder.and(
        product.name.contains(keyword)
    );
}

if (status != null) {
    builder.and(
        product.status.eq(status)
    );
}

QueryDSL은 타입 안전성과 재사용성이 높지만, Q-Type 생성과 JPAQueryFactory 설정이 필요하다.


6. 트랜잭션과 동시성 제어

트랜잭션

트랜잭션은 여러 DB 작업을 하나의 논리 단위로 묶는다.

상품 주문을 예로 들면 다음 작업이 모두 성공해야 한다.

고객 포인트 차감
→ 상품 재고 차감
→ 주문 저장

재고 차감 후 주문 저장에서 오류가 발생했는데 포인트와 재고만 변경되면 데이터가 일관되지 않게 된다.

모두 성공
→ Commit

하나라도 실패
→ 전체 Rollback

ACID

원칙 의미
Atomicity 모든 작업이 함께 성공하거나 실패
Consistency 작업 전후 데이터 규칙 유지
Isolation 동시에 실행되는 작업의 간섭 제어
Durability Commit 결과를 영구 보존

트랜잭션은 일련의 DB 작업을 하나의 단위로 묶고, 성공 시 Commit하며 실패 시 전체를 Rollback한다.


@Transactional

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository repository;

    @Transactional
    public void changePrice(
            Long productId,
            BigDecimal newPrice
    ) {
        Product product = repository
                .findById(productId)
                .orElseThrow();

        product.changePrice(newPrice);
    }
}

Spring은 @Transactional이 붙은 Bean을 Proxy로 감싼다.

Proxy 진입
→ 트랜잭션 시작
→ 실제 Service 메서드 실행
→ 정상 종료
   → Flush
   → Commit
→ RuntimeException 발생
   → Rollback

@Transactional 메서드가 예외 없이 종료되면 Commit되고, 기본적으로 RuntimeException이나 Error가 발생하면 Rollback된다.


읽기 전용 트랜잭션

조회만 하는 메서드는 readOnly=true를 사용할 수 있다.

@Transactional(readOnly = true)
public ProductResponse findById(Long id) {
    Product product = repository.findById(id)
            .orElseThrow();

    return ProductResponse.from(product);
}

읽기 전용임을 명시하여 코드의 의도를 보여 주고, JPA 구현체와 DB 환경에 따라 일부 최적화가 적용될 수 있다.


트랜잭션 범위

트랜잭션 안에서 외부 API나 파일 작업을 오래 수행하면 DB Connection을 장시간 점유할 수 있다.

@Transactional
public void process() {
    uploadLargeFile();
    saveDatabase();
    callExternalApi();
}

가능한 한 트랜잭션은 DB 작업 중심으로 짧게 유지한다.

파일 처리
→ DB 트랜잭션
→ 외부 API 호출

자료에서도 데이터 변경에는 트랜잭션을 사용하되, 파일이나 외부 API와 같은 장시간 I/O는 DB 트랜잭션 범위에서 분리하도록 설명한다.


Self-Invocation 문제

같은 클래스 내부에서 @Transactional 메서드를 호출하면 Proxy를 거치지 않는다.

@Service
public class OrderService {

    public void order() {
        saveOrder();
    }

    @Transactional
    public void saveOrder() {
    }
}
외부 → OrderService Proxy → order()
                         ↓
                     this.saveOrder()
                         ↓
                  Proxy를 거치지 않음

따라서 saveOrder()의 트랜잭션이 적용되지 않을 수 있다.

트랜잭션 경계가 필요한 메서드를 별도 Bean으로 분리한다.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderWriter orderWriter;

    public void order() {
        orderWriter.saveOrder();
    }
}
@Service
public class OrderWriter {

    @Transactional
    public void saveOrder() {
    }
}

Rollback 규칙

기본적으로 Rollback되는 예외는 다음과 같다.

RuntimeException
Error

Checked Exception까지 Rollback하려면 직접 지정한다.

@Transactional(
    rollbackFor = Exception.class
)
public void process()
        throws ExternalSystemException {
}

또한 예외를 내부에서 잡아 버리면 Proxy는 정상 종료로 판단할 수 있다.

@Transactional
public void process() {
    try {
        execute();
    } catch (Exception exception) {
        log.error("실패", exception);
        // 다시 던지지 않으면 Commit 가능
    }
}

Rollback이 필요한 오류라면 예외를 다시 던지거나 Rollback-only 상태로 표시해야 한다.


낙관적 Lock

충돌이 적다고 가정하고 Version을 비교한다.

@Entity
public class Product {

    @Id
    @GeneratedValue
    private Long id;

    @Version
    private Long version;

    private int stock;
}
A와 B가 version=1 조회
→ A가 먼저 수정 후 Commit
→ DB version=2
→ B가 version=1로 수정 시도
→ 충돌 예외 발생

DB Row Lock을 오래 유지하지 않아 처리량이 높지만, 충돌 시 재시도나 오류 처리가 필요하다.


비관적 Lock

조회할 때 DB Row를 직접 잠근다.

public interface ProductRepository
        extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("""
        select p
        from Product p
        where p.id = :id
    """)
    Optional<Product> findByIdForUpdate(
            @Param("id") Long id
    );
}

재고 차감처럼 동시에 같은 데이터를 수정할 가능성이 높은 경우 사용할 수 있다.

다만 Lock 대기와 Deadlock, 처리량 저하를 고려해야 한다.


7. 간단한 상품 CRUD 예제

이제 앞의 내용을 하나의 상품 API로 연결해 보자.

구조는 다음과 같다.

Client
→ ProductController
→ ProductService
→ ProductRepository
→ JPA / Hibernate
→ Database

프로젝트 의존성

dependencies {
    implementation(
        'org.springframework.boot:' +
        'spring-boot-starter-web'
    )

    implementation(
        'org.springframework.boot:' +
        'spring-boot-starter-data-jpa'
    )

    implementation(
        'org.springframework.boot:' +
        'spring-boot-starter-validation'
    )

    runtimeOnly 'com.h2database:h2'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

application.yml

spring:
  datasource:
    url: jdbc:h2:mem:shop
    driver-class-name: org.h2.Driver
    username: sa
    password:

  h2:
    console:
      enabled: true

  jpa:
    hibernate:
      ddl-auto: create

    show-sql: true

    properties:
      hibernate:
        format_sql: true

H2 Console은 일반적으로 다음 경로에서 확인할 수 있다.

/h2-console

Product Entity

@Entity
@Table(name = "products")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {

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

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

    @Column(
        nullable = false,
        precision = 12,
        scale = 2
    )
    private BigDecimal price;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private ProductStatus status;

    public Product(
            String name,
            BigDecimal price
    ) {
        validateName(name);
        validatePrice(price);

        this.name = name;
        this.price = price;
        this.status = ProductStatus.ON_SALE;
    }

    public void update(
            String name,
            BigDecimal price
    ) {
        validateName(name);
        validatePrice(price);

        this.name = name;
        this.price = price;
    }

    public void markSoldOut() {
        this.status = ProductStatus.SOLD_OUT;
    }

    private void validateName(String name) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException(
                "상품명은 필수입니다."
            );
        }
    }

    private void validatePrice(BigDecimal price) {
        if (price == null ||
                price.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException(
                "가격은 0 이상이어야 합니다."
            );
        }
    }
}

Repository

public interface ProductRepository
        extends JpaRepository<Product, Long> {

    List<Product> findByNameContaining(
            String keyword
    );

    boolean existsByName(String name);
}

요청 DTO

public record ProductCreateRequest(

        @NotBlank(
            message = "상품명은 필수입니다."
        )
        @Size(
            max = 100,
            message = "상품명은 100자 이하여야 합니다."
        )
        String name,

        @NotNull(
            message = "가격은 필수입니다."
        )
        @DecimalMin(
            value = "0.0",
            message = "가격은 0 이상이어야 합니다."
        )
        BigDecimal price
) {
}

응답 DTO

public record ProductResponse(
        Long id,
        String name,
        BigDecimal price,
        ProductStatus status
) {

    public static ProductResponse from(
            Product product
    ) {
        return new ProductResponse(
                product.getId(),
                product.getName(),
                product.getPrice(),
                product.getStatus()
        );
    }
}

Service

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository repository;

    @Transactional
    public ProductResponse create(
            ProductCreateRequest request
    ) {
        if (repository.existsByName(request.name())) {
            throw new IllegalArgumentException(
                    "이미 등록된 상품명입니다."
            );
        }

        Product product = new Product(
                request.name(),
                request.price()
        );

        Product saved = repository.save(product);

        return ProductResponse.from(saved);
    }

    @Transactional(readOnly = true)
    public ProductResponse findById(Long id) {
        Product product = getProduct(id);

        return ProductResponse.from(product);
    }

    @Transactional(readOnly = true)
    public List<ProductResponse> search(
            String keyword
    ) {
        return repository
                .findByNameContaining(keyword)
                .stream()
                .map(ProductResponse::from)
                .toList();
    }

    @Transactional
    public ProductResponse update(
            Long id,
            ProductCreateRequest request
    ) {
        Product product = getProduct(id);

        product.update(
                request.name(),
                request.price()
        );

        /*
         * repository.save(product)를 호출하지 않아도
         * 트랜잭션 Commit 시 변경 감지가 UPDATE를 실행한다.
         */

        return ProductResponse.from(product);
    }

    @Transactional
    public void delete(Long id) {
        Product product = getProduct(id);
        repository.delete(product);
    }

    private Product getProduct(Long id) {
        return repository.findById(id)
                .orElseThrow(
                    () -> new NoSuchElementException(
                        "상품을 찾을 수 없습니다. id=" + id
                    )
                );
    }
}

Controller

@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    @PostMapping
    public ResponseEntity<ProductResponse> create(
            @Valid
            @RequestBody
            ProductCreateRequest request
    ) {
        ProductResponse response =
                productService.create(request);

        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(response);
    }

    @GetMapping("/{id}")
    public ProductResponse findById(
            @PathVariable Long id
    ) {
        return productService.findById(id);
    }

    @GetMapping
    public List<ProductResponse> search(
            @RequestParam(defaultValue = "")
            String keyword
    ) {
        return productService.search(keyword);
    }

    @PutMapping("/{id}")
    public ProductResponse update(
            @PathVariable Long id,
            @Valid
            @RequestBody
            ProductCreateRequest request
    ) {
        return productService.update(id, request);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(
            @PathVariable Long id
    ) {
        productService.delete(id);

        return ResponseEntity.noContent().build();
    }
}

상품 등록 요청

POST /api/products
Content-Type: application/json
{
  "name": "무선 마우스",
  "price": 15000
}

처리 흐름은 다음과 같다.

JSON 요청
→ ProductCreateRequest 바인딩
→ @Valid 검증
→ ProductService.create()
→ Product Entity 생성
→ repository.save()
→ persist
→ INSERT SQL
→ ProductResponse 반환

상품 가격 수정 요청

PUT /api/products/1
Content-Type: application/json
{
  "name": "무선 마우스",
  "price": 17000
}
ProductService.update()
→ findById()
→ Product가 영속 상태가 됨
→ product.update()
→ Service 메서드 정상 종료
→ Flush
→ 변경 감지
→ UPDATE SQL
→ Commit

Service에 repository.save(product)가 없는 이유가 바로 변경 감지 때문이다.


전체 흐름 다시 보기

@Entity
→ Java 객체를 DB 테이블과 연결

EntityManager
→ Entity를 영속성 컨텍스트에서 관리

영속성 컨텍스트
→ 1차 캐시
→ 동일성 보장
→ 쓰기 지연
→ 변경 감지

JpaRepository
→ 기본 CRUD
→ 페이징·정렬

Query Method
→ 단순 조건

JPQL
→ 복잡한 고정 조회

QueryDSL
→ 동적 조회

@Transactional
→ Proxy가 트랜잭션 경계 관리

Commit
→ Flush
→ SQL 실행
→ 데이터 확정

핵심 정리

JPA의 정체

JPA
→ ORM 표준 명세

Hibernate
→ JPA 구현체

Spring Data JPA
→ Repository 사용을 편리하게 만드는 추상화

Entity 매핑

@Entity
@Table
@Id
@GeneratedValue
@Column
@Enumerated

영속성 컨텍스트

Entity를 추적하는 관리 공간
→ 1차 캐시
→ 쓰기 지연
→ 변경 감지

수정 원리

Entity 조회
→ 영속 상태
→ 필드 변경
→ Commit 시 변경 감지
→ UPDATE

연관관계

외래키를 가진 Entity
→ 연관관계의 주인

반대쪽 Collection
→ mappedBy

조회 구현

단순 조건
→ Query Method

고정된 복잡한 조건
→ JPQL

동적 조건
→ QueryDSL

트랜잭션

정상 종료
→ Commit

RuntimeException
→ Rollback

같은 클래스 내부 호출
→ Proxy를 거치지 않아
  @Transactional 미적용 가능

JPA를 제대로 사용하는 핵심은 어노테이션을 많이 아는 것이 아니다.

Entity가 현재 어떤 상태인지, 영속성 컨텍스트가 무엇을 관리하는지, SQL이 어느 시점에 실행되는지를 이해하는 것이 중요하다.

이 원리를 이해하면 save()를 언제 호출해야 하는지, 왜 변경 감지가 동작하지 않는지, 왜 Lazy Loading 예외나 N+1 문제가 생기는지를 훨씬 정확하게 판단할 수 있다.

반응형

'개발 > Spring' 카테고리의 다른 글

[Spring AI] Spring AI란? 개념부터 전체 구조까지 이해하기  (0) 2026.08.28
[Spring] Swagger UI와 OpenAPI 문서화 정리  (0) 2026.07.31
[Spring] MVC 패턴과 Spring MVC 동작 원리 정리  (0) 2026.07.28
[Spring] DTO는 무엇일까?  (0) 2025.09.08
[Spring] Spring Boot란?  (0) 2025.07.07
'개발/Spring' 카테고리의 다른 글
  • [Spring AI] Spring AI란? 개념부터 전체 구조까지 이해하기
  • [Spring] Swagger UI와 OpenAPI 문서화 정리
  • [Spring] MVC 패턴과 Spring MVC 동작 원리 정리
  • [Spring] DTO는 무엇일까?
danieLee
danieLee
개발일지
  • danieLee
    Code log
    danieLee
  • 전체
    오늘
    어제
    • 분류 전체보기 (77)
      • 개발 (76)
        • C++ (3)
        • java (6)
        • JavaScript (9)
        • python (0)
        • AWS (2)
        • Docker (6)
        • git (0)
        • 백엔드 (4)
        • Spring (7)
        • Django (2)
        • AI (3)
        • 코테 준비 (13)
        • 알고리즘 (6)
        • SKALA 4기 (13)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    대학생
    프론트
    4기
    프로그래머스
    알고리즘
    API
    java
    코테
    개발
    서버
    JavaScript
    vue.js
    백엔드
    spring
    js
    Ai
    파이썬
    개념
    skala
    개발자
  • 최근 댓글

  • 최근 글

  • 반응형
  • hELLO· Designed By정상우.v4.10.1
danieLee
[Spring] Spring Boot JPA 동작 원리와 CRUD 정리
상단으로

티스토리툴바