Bootcamp/Fundamentals

Spring 입문 - 2

개발자 오리 2026. 6. 23. 15:19

💡  오늘 학습 키워드

  • JDBC
  • IoC/DI
  • JPA
  • Persistence Context
  • SpringBoot Data JPA

 

🎯  학습 내용 정리

1. JDBC

애플리케이션이 데이터베이스와 통신하기 위해서는 여러 단계의 작업이 필요하다.

예를 들어 사용자의 정보를 조회한다고 가정해보자.

Application
 ↓
Database 연결
 ↓
SQL 전송
 ↓
결과 조회
 ↓
연결 종료

구체적으로는 다음과 같은 과정을 거친다.

  1. 데이터베이스와 연결(Connection)을 생성한다.
  2. SQL을 작성한다.
  3. SQL을 데이터베이스에 전달한다.
  4. 실행 결과(ResultSet)를 조회한다.
  5. 사용이 끝난 리소스를 해제한다.

 

문제는 데이터베이스마다 구현 방식이 다를 수 있다는 점이다.

예를 들어 기존에 사용하던 MySQL을 PostgreSQL로 변경한다고 가정해보자.

MySQL
 ↓
애플리케이션
 ↓
PostgreSQL
 ↓
애플리케이션

만약 데이터베이스마다 연결 방식이나 SQL 실행 방식이 다르다면 애플리케이션의 데이터 접근 코드를 모두 수정해야 한다.

이러한 문제를 해결하기 위해 JDBC가 등장했다.

 

 

JDBCJava Database Connectivity의 약자Java에서 데이터베이스에 접근하기 위한 표준 API이다.

JDBC는 데이터베이스와 통신하기 위한 공통 인터페이스를 제공한다.

Application
 ↓
JDBC API
 ↓
JDBC Driver
 ↓
Database

각 데이터베이스 벤더(MySQL, PostgreSQL, Oracle 등)는 JDBC 인터페이스를 구현한 JDBC Driver를 제공한다.

 

대표적인 예시는 다음과 같다.

  • MySQL JDBC Driver
  • PostgreSQL JDBC Driver
  • Oracle JDBC Driver

 

애플리케이션은 JDBC API만 사용하고, 실제 데이터베이스와의 통신은 JDBC Driver가 담당한다.

따라서 데이터베이스가 변경되더라도 JDBC Driver만 교체하면 대부분의 코드를 그대로 사용할 수 있다.

 

 

JDBC의 등장으로 데이터베이스 변경이 쉬워졌지만 여전히 불편한 점이 존재했다.

예를 들어 데이터를 조회할 때마다 다음과 같은 작업을 반복해야 한다.

Connection connection = ...
PreparedStatement statement = ...
ResultSet resultSet = ...

그리고 사용이 끝나면 반드시 자원을 해제해야 한다.

resultSet.close();
statement.close();
connection.close();

이러한 코드는 거의 모든 Repository에서 반복된다.

 

즉,

  • Connection 생성
  • Statement 생성
  • SQL 실행
  • ResultSet 처리
  • 자원 해제

와 같은 보일러플레이트 코드가 계속 발생한다.

이를 해결하기 위해 Spring은 JdbcTemplate을 제공한다.

 

 

JdbcTemplateSpring에서 제공하는 JDBC 추상화 기술이다.

반복적으로 작성해야 하는 JDBC 코드를 대신 처리해주기 때문에 개발자는 SQL 작성과 결과 매핑에만 집중할 수 있다.

 

JdbcTemplate이 대신 처리해주는 작업은 다음과 같다.

  • Connection 생성
  • PreparedStatement 생성
  • SQL 실행
  • ResultSet 처리
  • 예외 변환
  • Connection 종료

즉, JDBC를 더 편리하게 사용할 수 있도록 만들어진 도구라고 생각하면 된다.

 

 

JdbcTemplate을 사용하기 위해서 진행해야 하는 설정은 다음과 같다.

application.properties에서 먼저 데이터베이스 연결 정보를 설정한다.

spring.datasource.url=jdbc:mysql://localhost:3306/memo
spring.datasource.username=root
spring.datasource.password=비밀번호
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

 

 

build.gradle에서 JdbcTemplate과 MySQL 드라이버를 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.mysql:mysql-connector-j'

 

Spring Boot는 위 설정을 기반으로 DataSource와 JdbcTemplate Bean을 자동 생성한다.

 

 

Repository에서 JdbcTemplate을 생성자 주입 방식으로 사용할 수 있다.

@Repository
public class MemoRepository {

    private final JdbcTemplate jdbcTemplate;

    public MemoRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
}

Spring이 자동으로 JdbcTemplate 객체를 주입해준다.

 

 

JdbcTemplate을 사용하는 방법은 다음과 같다.

 

데이터 저장하기 (INSERT)

String sql = "INSERT INTO memo (username, contents) VALUES (?, ?)";

jdbcTemplate.update(sql, "Student", "오늘 하루도 화이팅!");

update() 메서드는 INSERT, UPDATE, DELETE 작업에 사용된다.

?파라미터 바인딩을 의미하며 SQL Injection을 방지할 수 있다.

 

데이터 수정하기 (UPDATE)

String sql = "UPDATE memo SET username = ? WHERE id = ?";

jdbcTemplate.update(sql, "Student", 1L);

 

데이터 삭제하기 (DELETE)

String sql = "DELETE FROM memo WHERE id = ?";

jdbcTemplate.update(sql, 1L);

 

데이터 조회하기 (SELECT)

SELECT는 여러 행(Row)의 결과를 반환할 수 있기 때문에 RowMapper를 사용한다.

String sql = "SELECT * FROM memo";

return jdbcTemplate.query(
        sql,
        new RowMapper<MemoResponseDto>() {

            @Override
            public MemoResponseDto mapRow(
                    ResultSet rs,
                    int rowNum
            ) throws SQLException {

                return new MemoResponseDto(
                        rs.getLong("id"),
                        rs.getString("username"),
                        rs.getString("contents")
                );
            }
        }
);

RowMapper는 ResultSet의 한 행(Row)을 원하는 객체로 변환하는 역할을 한다.

ResultSet
 ↓
RowMapper
 ↓
MemoResponseDto

조회 결과가 여러 건인 경우 각 행마다 mapRow()가 호출된다.

 

 

JdbcTemplate의 장점은 다음과 같다.

 

1. 반복 코드 제거

Connection 생성과 종료를 직접 관리하지 않아도 된다.

 

2. 예외 처리 단순화

JDBC의 Checked Exception을 Spring의 DataAccessException으로 변환해준다.

 

3. 생산성 향상

개발자는 SQL 작성과 데이터 매핑에만 집중할 수 있다.

 

4. SQL을 직접 작성 가능

복잡한 쿼리도 자유롭게 작성할 수 있다.

 

 

2. IoC/DI

Spring을 처음 공부하게 되면 가장 먼저 접하게 되는 개념 중 하나가 바로 IoC(Inversion of Control)와 DI(Dependency Injection)이다. 많은 개발 입문자들이 IoC와 DI를 Spring이 새롭게 만든 기술이라고 생각하지만, 사실 이는 Spring 이전부터 존재하던 설계 원칙과 디자인 패턴이다.

Spring은 이러한 개념을 구현하고 쉽게 사용할 수 있도록 지원하는 프레임워크일 뿐이다.

 

좋은 코드를 작성하기 위해서는 여러 가지 요소를 고려해야 한다.

  • 코드의 논리가 단순해야 한다.
  • 중복을 최소화해야 한다.
  • 처음 보는 사람도 쉽게 이해할 수 있어야 한다.
  • 수정과 확장이 쉬워야 한다.
  • 객체 간 의존성이 낮아야 한다.
  • 새로운 기능이 추가되어도 구조 변경이 최소화되어야 한다.

하지만 이러한 조건을 모두 만족하는 코드를 작성하는 것은 쉽지 않다.

Spring은 개발자가 객체지향 원칙을 쉽게 적용할 수 있도록 다양한 기능을 제공하는데, 그중 대표적인 것이 IoC와 DI이다.

 

 

다음 코드를 살펴보자.

public class Consumer {

    void eat() {
        Chicken chicken = new Chicken();
        chicken.eat();
    }

    public static void main(String[] args) {
        Consumer consumer = new Consumer();
        consumer.eat();
    }
}

class Chicken {

    public void eat() {
        System.out.println("치킨을 먹는다.");
    }
}

현재 Consumer는 Chicken 객체를 직접 생성하고 있다.

Chicken chicken = new Chicken();

이 구조에서는 Consumer와 Chicken이 강하게 결합되어 있다.

만약 사용자가 치킨 대신 피자를 먹고 싶다면 어떻게 될까?

Pizza pizza = new Pizza();

Consumer의 코드를 직접 수정해야 한다.

 

즉, 새로운 음식이 추가될 때마다 Consumer 코드가 변경된다.

이는 유지보수성과 확장성이 떨어지는 구조이며 강한 결합(Coupling)이라고 한다.

 

 

객체 간 결합도를 낮추기 위해 인터페이스를 사용할 수 있다.

public class Consumer {

    void eat(Food food) {
        food.eat();
    }

    public static void main(String[] args) {
        Consumer consumer = new Consumer();

        consumer.eat(new Chicken());
        consumer.eat(new Pizza());
    }
}

interface Food {
    void eat();
}

class Chicken implements Food {

    @Override
    public void eat() {
        System.out.println("치킨을 먹는다.");
    }
}

class Pizza implements Food {

    @Override
    public void eat() {
        System.out.println("피자를 먹는다.");
    }
}

이제 Consumer는 구체적인 구현체를 알 필요가 없다.

오직 Food 인터페이스에만 의존한다.

void eat(Food food)

따라서 새로운 음식이 추가되더라도 Consumer는 변경되지 않는다.

이를 느슨한 결합(Loose Coupling)이라고 한다.

 

 

DIDependency Injection의 약자의존성 주입을 의미한다.

객체가 필요한 의존 객체를 직접 생성하지 않고 외부에서 전달받는 패턴이다.

쉽게 말하면 필요한 객체를 주입받아 사용하는 것이다.

 

필드 주입

public class Consumer {

    Food food;

    void eat() {
        food.eat();
    }
}
consumer.food = new Chicken();

필드에 직접 객체를 넣어주는 방식이다.

하지만 외부에서 언제든지 변경 가능하기 때문에 실무에서는 거의 사용하지 않는다.

 

Setter 주입

public class Consumer {

    private Food food;

    public void setFood(Food food) {
        this.food = food;
    }

    void eat() {
        food.eat();
    }
}
consumer.setFood(new Chicken());

Setter를 통해 의존성을 주입한다.

객체 생성 이후에도 의존성을 변경할 수 있다는 특징이 있다.

 

생성자 주입

public class Consumer {

    private final Food food;

    public Consumer(Food food) {
        this.food = food;
    }

    void eat() {
        food.eat();
    }
}
Consumer consumer = new Consumer(new Chicken());

생성 시점에 필요한 객체를 주입받는다.

현재 Spring에서는 생성자 주입을 가장 권장한다.

 

그 이유는

  • 객체의 불변성 보장
  • Null 방지
  • 테스트 작성 용이
  • 순환 참조 감지 가능

등의 장점이 있기 때문이다.

 

 

IoCInversion of Control의 약자제어의 역전을 의미한다.

기존에는 객체가 직접 필요한 객체를 생성했다.

Chicken chicken = new Chicken();

즉, 객체 생성에 대한 제어권이 Consumer에게 있었다.

Consumer → Chicken

하지만 DI를 사용하면 객체를 외부에서 주입받는다.

Consumer consumer = new Consumer(new Chicken());

이제 객체 생성과 관리의 책임이 외부로 이동한다.

Chicken → Consumer

즉, 객체에 대한 제어권이 애플리케이션 코드가 아닌 외부 컨테이너로 이동한 것이다.

이러한 개념을 IoC라고 한다.

 

 

Spring은 IoC Container를 통해 객체를 생성하고 관리한다.

개발자는 객체 생성 코드를 직접 작성하지 않고 Spring에게 맡긴다.

Application
        ↓
Spring IoC Container
        ↓
Bean 생성 및 관리

 

 

 

BeanSpring IoC 컨테이너가 관리하는 객체를 의미한다.

예를 들어 다음과 같이 작성하면

@Component
public class OrderService {
}

Spring은 서버 시작 시 OrderService 객체를 생성한다.

OrderService orderService = new OrderService();

그리고 IoC 컨테이너에 저장한다.

OrderService
      ↓
Spring IoC Container

이 객체를 Bean이라고 부른다.

 

 

@Component

Spring에게 Bean으로 등록하라고 알려주는 Annotation이다.

@Component
public class OrderService {
}

Spring Boot가 실행되면 해당 객체가 자동 생성되어 IoC 컨테이너에 등록된다.

기본 Bean 이름은 클래스명에서 첫 글자만 소문자로 변경된다.

OrderService
↓
orderService

 

 

@ComponentScan

Spring은 @ComponentScan을 통해 Bean을 자동 탐색한다.

@Configuration
@ComponentScan(basePackages ="com.example")
public class BeanConfig {
}

실제로는 Spring Boot의

@SpringBootApplication

안에 이미 포함되어 있기 때문에 별도로 설정하지 않는 경우가 많다.

 

 

Spring은 @Autowired를 통해 Bean을 주입할 수 있다.

과거에는 다음과 같이 사용했다.

@Autowired
private OrderRepository orderRepository;

하지만 현재는 생성자 주입을 사용하는 것이 일반적이다.

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }
}

Spring 4.3부터는 생성자가 하나뿐인 경우 @Autowired를 생략할 수 있다.

 

Lombok과 생성자 주입

실무에서는 Lombok의 @RequiredArgsConstructor를 많이 사용한다.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

}

Lombok이 생성자를 자동 생성해준다.

public OrderService(OrderRepository orderRepository) {
    this.orderRepository = orderRepository;
}

따라서 코드가 훨씬 간결해진다.

 

Spring에서는 역할에 따라 Bean을 구분하기 위해 다음 Annotation을 제공한다.

@Controller
@Service
@Repository

각 Annotation은 내부적으로 모두 @Component를 포함하고 있다.

따라서 Bean 등록 기능은 동일하지만 역할을 명확하게 표현할 수 있다.

 

각 역할은 다음과 같다.

 

Controller

사용자의 HTTP 요청을 처리한다.

 

Service

비즈니스 로직을 담당한다.

 

Repository

데이터베이스 접근을 담당한다.

 

 

3. JPA

백엔드 개발을 시작하면 데이터를 저장하고 조회하기 위해 데이터베이스를 사용하게 된다. 하지만 객체지향 언어인 Java와 관계형 데이터베이스는 데이터를 표현하는 방식이 서로 다르다. 이러한 차이를 해결하기 위해 등장한 기술이 바로 ORM이며, Java 진영에서는 JPA가 대표적으로 사용된다.

 

 

예를 들어 아래와 같은 학생 객체가 있다고 가정해보자.

public class Student {
    private Long id;
    private String name;
    private String major;
}

이 객체를 데이터베이스에 저장하기 위해서는 생각보다 많은 작업이 필요하다.

 

1. 테이블 생성

먼저 객체 구조에 맞는 테이블을 직접 생성해야 한다.

CREATE TABLE student (
    id BIGINT NOT NULL AUTO_INCREMENT,
    name VARCHAR(255) NOT NULL,
    major VARCHAR(255) NOT NULL,
    PRIMARY KEY (id)
);

 

2. SQL 작성

데이터를 저장하거나 조회하기 위한 SQL도 직접 작성해야 한다.

String insertSql = "INSERT INTO student (name, major) VALUES (?, ?)";

String selectSql = "SELECT * FROM memo";

 

3. JDBC를 이용한 SQL 실행

작성한 SQL을 JDBC를 통해 실행해야 한다.

jdbcTemplate.update(insertSql, "Student", "Computer Science");

jdbcTemplate.query(selectSql, ...);

 

4. 조회 결과를 객체로 변환

데이터베이스에서 조회한 결과를 다시 Java 객체로 변환하는 작업도 필요하다.

@Override
public StudentResponseDto mapRow(ResultSet rs, int rowNum) throws SQLException {

    Long id = rs.getLong("id");
    String name = rs.getString("name");
    String major = rs.getString("major");

    return new StudentResponseDto(id, name, major);
}

단순한 CRUD 기능 하나를 구현하기 위해서도 많은 반복 작업이 발생한다.

 

 

더 큰 문제는 데이터 구조가 변경될 때 발생한다.

예를 들어 학생 정보에 나이를 추가해야 한다고 가정해보자.

public class Student {

    private Long id;
    private String username;
    private String contents;

    private Integer age;
}

단순히 필드 하나가 추가되었을 뿐인데 여러 부분을 수정해야 한다.

 

SQL 수정

String sql = "INSERT INTO student (name, major, age) VALUES (?, ?, ?)";

 

객체 매핑 로직 수정

@Override
public StudentResponseDto mapRow(ResultSet rs, int rowNum) throws SQLException {

    Long id = rs.getLong("id");
    String name = rs.getString("name");
    String major = rs.getString("major");
    Integer age = rs.getInteger("age");

    return new StudentResponseDto(id, name, major, age);
}

필드 하나가 추가되었는데도 테이블 구조, SQL, 매핑 코드까지 모두 수정해야 한다.

때문에 비즈니스 로직보다 SQL 작성과 유지보수에 더 많은 시간을 사용하는 경우가 많았고, 이를 해결하기 위해서 ORM이 등장하게 되었다.

 

 

ORMObject-Relational Mapping의 약자객체와 관계형 데이터베이스를 매핑해주는 기술이다.

  • Object : 객체지향 언어(Java, Kotlin 등)
  • Relational : 관계형 데이터베이스(MySQL, PostgreSQL, Oracle 등)
  • Mapping : 객체와 테이블을 연결

즉, 객체와 데이터베이스 사이의 변환 작업을 자동으로 처리해주는 기술이다.

기존에는 개발자가 직접 SQL을 작성하고 결과를 객체로 변환해야 했지만, ORM을 사용하면 이러한 반복 작업을 프레임워크가 대신 처리해준다.

 

예를 들어 다음과 같은 객체가 있다면

Student student = new Student();
student.setName("Student");
student.setMajor("Computer Science");

ORM은 이 객체를 데이터베이스의 테이블 데이터로 자동 변환하여 저장할 수 있도록 도와준다.

 

 

JPAJava Persistence API의 약자Java 진영의 ORM 기술 표준 명세이다.

여기서 중요한 점은 JPA가 라이브러리나 프레임워크가 아니라는 것이다.

 

JPA는 ORM을 구현하기 위한 규칙과 인터페이스를 정의한 표준이다.

쉽게 말해 다음과 같은 관계로 이해할 수 있다.

JPA = 표준(인터페이스)
Hibernate = 구현체
Spring Data JPA = JPA 사용을 더욱 편리하게 만들어주는 기술

 

 

JPA는 애플리케이션과 JDBC 사이에서 동작한다.

Application
     ↓
    JPA
     ↓
   JDBC
     ↓
Database

 

기존에는 개발자가 직접 JDBC를 사용하여 데이터베이스와 통신해야 했다.

Application
     ↓
   JDBC
     ↓
Database

 

하지만 JPA를 사용하면 객체 중심으로 개발할 수 있게 된다.

em.persist(memo);

JPA가 내부적으로 적절한 SQL을 생성하고 JDBC를 통해 데이터베이스에 전달한다.

따라서 개발자는 SQL 자체보다 비즈니스 로직에 더욱 집중할 수 있다.

 

 

JPA는 표준 명세일 뿐 실제 동작을 구현하지는 않는다. 그래서 JPA를 사용하려면 JPA를 구현한 ORM 프레임워크가 필요하다.

그 대표적인 구현체가 Hibernate이다.

JPA (표준)
      ↑
Hibernate (구현체)

현재 Java ORM 생태계에서는 Hibernate가 사실상 표준으로 사용되고 있다.

Spring Boot에서도 별도의 설정이 없다면 Hibernate를 기본 JPA 구현체로 사용한다.

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

위 의존성을 추가하면 내부적으로 Hibernate가 함께 포함된다.

 

 

ORM과 JPA를 사용하면 다음과 같은 장점을 얻을 수 있다.

 

1. 생산성 향상

반복적인 SQL 작성과 객체 매핑 작업을 줄일 수 있다.

 

2. 유지보수성 향상

객체 구조가 변경되어도 수정 범위를 최소화할 수 있다.

 

3. 객체지향적인 개발

데이터베이스 테이블이 아닌 객체 중심으로 개발할 수 있다.

 

4. 데이터베이스 독립성 향상

MySQL에서 PostgreSQL로 변경하더라도 애플리케이션 코드 수정이 크게 줄어든다.

 

5. 풍부한 기능 제공

  • 1차 캐시
  • 변경 감지(Dirty Checking)
  • 지연 로딩(Lazy Loading)
  • 트랜잭션 지원
  • 쓰기 지연(Write Behind)

등 다양한 기능을 활용할 수 있다.

 

 

 

4. Persistence Context

JPA를 처음 학습할 때 가장 어렵게 느껴지는 개념 중 하나가 바로 영속성 컨텍스트이다.

CRUD는 금방 구현할 수 있지만, 왜 JPA가 SQL을 직접 실행하지 않는지, 왜 save()만 호출했는데 데이터가 저장되는지, 왜 update() 메서드가 존재하지 않는지 이해하려면 영속성 컨텍스트를 알아야 한다.

 

 

영속성 컨텍스트는 영어로 Persistence Context라고 한다. Persistence를 직역하면 영속성, 지속성이라는 의미를 가진다. JPA에서 영속성은 단순히 데이터를 오래 보관한다는 뜻이 아니라, 객체를 데이터베이스와 연결하여 관리하는 개념에 가깝다.

 

즉, JPA는 객체를 직접 데이터베이스에 저장하거나 조회하지 않는다.

 

중간에 객체를 관리하는 공간을 두고 그 공간을 통해 데이터베이스와 상호작용한다.

그 공간이 바로 영속성 컨텍스트이다.

 

 

영속성 컨텍스트는 쉽게 말해 Entity 객체를 관리하는 저장소라고 생각하면 된다.

개발자가 Entity를 저장하거나 조회하면 JPA는 먼저 영속성 컨텍스트를 사용하여 객체를 관리하고, 이후 데이터베이스와 동기화한다.

애플리케이션
      ↓
영속성 컨텍스트
      ↓
데이터베이스

즉, JPA는 Entity를 바로 DB에 저장하지 않고 영속성 컨텍스트에서 먼저 관리한다.

 

 

영속성 컨텍스트에 접근하기 위해서는 EntityManager 가 필요하다.

이름 그대로 Entity를 관리하는 객체이다.

EntityManager em = emf.createEntityManager();

개발자는 EntityManager를 통해 다음과 같은 작업을 수행할 수 있다.

em.persist(entity); // 저장
em.find(Entity.class, id); // 조회
em.remove(entity); // 삭제

실제로는 EntityManager가 내부적으로 영속성 컨텍스트를 이용하여 Entity를 관리한다.

 

 

EntityManager는 직접 생성하지 않고, EntityManagerFactory를 통해 생성한다.

EntityManagerFactory emf = Persistence.createEntityManagerFactory("student");

EntityManager em = emf.createEntityManager();

 

역할은 다음과 같다.

  • EntityManager 생성
  • 애플리케이션 전체에서 하나만 생성
  • 생성 비용이 매우 비쌈

 

따라서 일반적으로

EntityManagerFactory
          ↓
    EntityManager
          ↓
 Persistence Context

구조로 동작한다.

 

 

영속성 컨텍스트를 이해하려면 트랜잭션을 먼저 알아야 한다. 트랜잭션데이터베이스 작업을 하나의 논리적인 작업 단위로 묶는 것이다.

예를 들어 계좌 이체를 생각해보자.

A 계좌 출금
B 계좌 입금

출금만 성공하고 입금이 실패하면 큰 문제가 발생한다. 따라서 두 작업은 반드시 함께 성공하거나 함께 실패해야 한다.

이를 보장하는 것이 트랜잭션이다.

 

 

JPA는 데이터 변경 작업을 반드시 트랜잭션 안에서 수행해야 한다.

EntityTransaction tx = em.getTransaction();

tx.begin();

try {
    // 작업 수행

    tx.commit();
} catch (Exception e) {
    tx.rollback();
}

 

begin()

트랜잭션 시작

 

commit()

변경 사항 반영

 

rollback()

변경 사항 취소

 

 

영속성 컨텍스트의 가장 중요한 기능 중 하나가 1차 캐시이다.

영속성 컨텍스트 내부에는 다음과 같은 형태의 저장소가 존재한다.

Key : 식별자(ID)
Value : Entity 객체

예를 들어

em.persist(student);

를 호출하면

1L → Student 객체

형태로 저장된다.

 

 

다음 코드를 살펴보자.

Student student = em.find(Student.class, 1L);

JPA는 먼저 영속성 컨텍스트를 조회한다.

 

1차 캐시에 없는 경우

1차 캐시 조회
      ↓
없음
      ↓
DB 조회
      ↓
1차 캐시에 저장
      ↓
반환

DB에 SELECT가 발생한다.

 

1차 캐시에 있는 경우

1차 캐시 조회
      ↓
존재
      ↓
즉시 반환

DB 조회 없이 캐시에서 반환한다.

 

 

같은 트랜잭션에서 동일한 Entity를 조회하면 같은 객체를 반환한다.

Student student1 = em.find(Student.class, 1L);
Student student2 = em.find(Student.class, 1L);

System.out.println(student1 == student2);

결과

true

이는 JPA가 동일한 데이터를 하나의 객체로 관리하기 때문이다. 이를 객체 동일성 보장이라고 한다.

 

 

많은 사람들이 persist()를 호출하면 바로 INSERT SQL이 실행된다고 생각한다. 하지만 실제로는 그렇지 않다.

em.persist(memo);

를 호출하면

영속성 컨텍스트 저장
      ↓
쓰기 지연 저장소 등록

까지만 수행된다.

실제 INSERT SQL은 나중에 실행된다.

 

왜 SQL을 바로 실행하지 않을까?

다음과 같은 코드가 있다고 가정해보자.

em.persist(student1);
em.persist(student2);
em.persist(student3);

매번 INSERT를 보내는 것보다

INSERT
INSERT
INSERT

를 모아두었다가 한 번에 처리하는 것이 효율적이다. 그래서 JPA는 SQL을 쓰기 지연 저장소(Action Queue)에 보관한다.

 

 

쓰기 지연 저장소에 모아둔 SQL을 실제 DB로 전송하는 작업을 Flush라고 한다.

em.flush();

실행 시

영속성 컨텍스트
      ↓
SQL 생성
      ↓
DB 전송

이 이루어진다.

 

중요한 점은 Flush는 DB에 SQL을 전송할 뿐 트랜잭션을 커밋하지 않는다는 것이다.

 

 

트랜잭션 커밋 시 내부적으로 Flush가 먼저 수행된다.

tx.commit();

실제 동작

flush()
    ↓
SQL 전송
    ↓
DB commit

따라서 대부분의 경우 개발자가 직접 Flush를 호출할 필요는 없다.

 

 

JPA에는 update() 메서드가 존재하지 않는다. 그 이유는 변경 감지 기능 때문이다.

다음 코드를 보자.

Student student = em.find(Student.class, 1L);

memo.setName("student123");
memo.setMajor("AI");

단순히 값을 변경했을 뿐인데

tx.commit();

을 수행하면 UPDATE SQL이 실행된다. 그렇다면 변경 감지는 어떻게 동작할까?

 

 

JPA는 Entity를 조회할 때 최초 상태를 스냅샷으로 저장한다. 이후 트랜잭션 종료 시점에 최초 상태와 현재 상태를 비교한다.

차이 있음
      ↓
UPDATE SQL 생성
      ↓
쓰기 지연 저장소 등록
      ↓
DB 반영

이 과정을 변경 감지(Dirty Checking)이라고 한다.

 

 

Entity 삭제도 마찬가지로 영속성 컨텍스트를 통해 수행된다.

Student student = em.find(Student.class, 1L);

em.remove(student);

실행 시

MANAGED 상태
      ↓
REMOVED 상태

로 변경된다.

실제 DELETE SQL은 트랜잭션 커밋 시점에 실행된다.

 

 

5. SpringBoot Data JPA

JPA를 학습하다 보면 EntityManager, 영속성 컨텍스트, 트랜잭션과 같은 개념들을 직접 다루게 된다. 하지만 실무에서는 대부분 Spring Boot 환경에서 개발하기 때문에 EntityManagerFactory를 직접 생성하거나 트랜잭션을 수동으로 관리하는 경우는 거의 없다.

 

Spring은 JPA를 더욱 편리하게 사용할 수 있도록 Spring Data JPA를 제공하며, 트랜잭션 관리 또한 선언적으로 처리할 수 있도록 지원한다.

 

 

Spring Boot에서는 JPA 사용을 위한 대부분의 설정을 자동으로 처리해준다.

개발자는 의존성을 추가하고 데이터베이스 정보를 설정하기만 하면 된다.

 

build.gradle에 Spring Data JPA 의존성을 추가한다.

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

 

 

JPA 구현체로 Hibernate를 사용하며, 주요 설정은 application.properties에서 관리한다.

spring.jpa.hibernate.ddl-auto=update

spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true

 

ddl-auto 옵션은 다음과 같다.

create 기존 테이블 삭제 후 재생성
create-drop 종료 시점에 테이블 삭제
update 변경된 스키마만 반영
validate 엔티티와 테이블 매핑만 검증
none 아무 작업도 수행하지 않음

 

SQL 출력 옵션은 다음과 같다.

spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true

위 옵션을 사용하면 Hibernate가 실행하는 SQL을 로그에서 확인할 수 있다.

 

 

JPA가 관리할 객체는 @Entity를 사용하여 정의한다.

@Entity
@Getter
@Setter
@Table(name = "student")
@NoArgsConstructor
public class Student {

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

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, length = 500)
    private String major;

    public Student(StudentRequestDto requestDto) {
        this.name = requestDto.getName();
        this.major = requestDto.getMajor();
    }

    public void update(StudentRequestDto requestDto) {
        this.name = requestDto.getName();
        this.major = requestDto.getMajor();
    }
}

 

 

주요 어노테이션은 다음과 같다.

  • @Entity : JPA가 관리하는 엔티티 지정
  • @Table : 매핑할 테이블 지정
  • @Id : 기본 키 지정
  • @GeneratedValue : 기본 키 생성 전략 설정
  • @Column : 컬럼 속성 설정

 

 

순수 JPA에서는 다음과 같이 객체를 생성해야 했다.

EntityManagerFactory emf = Persistence.createEntityManagerFactory("student");

EntityManager em = emf.createEntityManager();

하지만 Spring Boot에서는 데이터베이스 설정만 작성하면 EntityManagerFactory와 EntityManager를 자동으로 생성해준다.

@PersistenceContext
private EntityManager em;

@PersistenceContext를 사용하면 Spring이 관리하는 EntityManager를 주입받아 사용할 수 있다.

 

 

JPA에서 데이터를 저장하거나 수정, 삭제하기 위해서는 반드시 트랜잭션이 필요하다.

Spring은 이를 쉽게 사용할 수 있도록 @Transactional 을 제공한다.

@Transactional
public void createStudent() {
    ...
}

메서드 실행 시 트랜잭션이 시작되고,

  • 정상 종료 → Commit
  • 예외 발생 → Rollback

이 자동으로 수행된다.

 

 

Spring Data JPA 내부의 save() 메서드를 살펴보면 다음과 같다.

@Transactional
@Override
public <S extends T> S save(S entity) {

    if (entityInformation.isNew(entity)) {
        em.persist(entity);
        return entity;
    }

    return em.merge(entity);
}

save() 메서드에는 이미 @Transactional이 적용되어 있기 때문에 별도의 트랜잭션 설정 없이 데이터를 저장할 수 있다.

 

 

조회 전용 메서드는 다음과 같이 readOnly = true 옵션을 사용하여 작성할 수 있다.

@Transactional(readOnly = true)
public List<Memo> findAll() {
    ...
}

 

장점은 다음과 같다.

  • Dirty Checking 비활성화
  • Flush 최소화
  • 조회 성능 최적화

조회만 수행하는 서비스에서는 적극적으로 사용하는 것이 좋다.

 

 

다음 코드는 정상적으로 동작한다.

@Test
@Transactional
@Rollback(false)
void test() {

    Student student = new Student();
    
    student.setName("Student);
    student.setMajor("Computer Science);
    
    em.persist(student);
}

하지만 트랜잭션을 제거하면 예외가 발생한다.

@Test
void test() {

    Student student = new Student();
    
    student.setName("Student);
    student.setMajor("Computer Science);
    
    em.persist(student);
}

왜냐하면 persist()는 데이터를 변경하는 작업이기 때문이다.

JPA는 데이터 변경 작업을 수행할 때 반드시 트랜잭션이 존재해야 한다.

 

즉,

  • INSERT
  • UPDATE
  • DELETE

는 트랜잭션 필수

 

반면,

  • SELECT

는 트랜잭션 없이도 수행 가능하다.

 

 

Spring 환경에서는 영속성 컨텍스트와 트랜잭션의 생명주기가 거의 동일하다.

즉,

트랜잭션 시작
        ↓
영속성 컨텍스트 생성
        ↓
엔티티 관리
        ↓
Commit 또는 Rollback
        ↓
영속성 컨텍스트 종료

트랜잭션이 유지되는 동안에는 영속성 컨텍스트 역시 살아 있기 때문에

  • 1차 캐시
  • Dirty Checking
  • 쓰기 지연 저장소

와 같은 기능을 사용할 수 있다.

 

 

서비스 계층에서 Repository를 호출할 때 트랜잭션은 어떻게 유지될까?

Spring은 이를 위해 트랜잭션 전파(Transaction Propagation) 기능을 제공한다.

 

기본 전파 옵션은 REQUIRED이다.

@Transactional
public void parent() {
    child();
}
@Transactional
public void child() {
    ...
}

동작 과정은 다음과 같다.

parent() 트랜잭션 시작
          ↓
child() 호출
          ↓
부모 트랜잭션에 합류
          ↓
child() 종료
          ↓
parent() 종료
          ↓
Commit

즉, 자식 메서드가 별도의 트랜잭션을 생성하는 것이 아니라 부모 트랜잭션에 참여한다.

 

@Transactional
public Student createStudent(EntityManager em) {
	
    Student student = em.find(Student.class, 1L);
    
    student.setName("Student);
    student.setMajor("Computer Science);
    
    return student;
}
@Test
@Transactional
@Rollback(false)
void test() {

    studentRepository.createStudent(em);

    System.out.println("test 종료");
}

실행 결과를 확인하면 createStudent()가 종료되는 시점이 아니라 test() 메서드가 종료되는 시점에 UPDATE SQL이 실행된다.

이는 두 메서드가 동일한 트랜잭션을 공유하고 있기 때문이다.

 

 

Spring은 JPA의 트랜잭션을 훨씬 편리하게 사용할 수 있도록 도와준다.

순수 JPA에서는

EntityTransaction tx = em.getTransaction();

tx.begin();

try {
    ...
    tx.commit();
} catch (Exception e) {
    tx.rollback();
}

처럼 직접 관리해야 했다.

 

반면 Spring에서는

@Transactional
public void save() {
    ...
}

한 줄만 추가하면 된다.

덕분에 개발자는 비즈니스 로직에 집중할 수 있고, 트랜잭션 관리에 대한 반복 코드를 작성할 필요가 없다.

 

 

📚  한줄 정리

  1. JDBC는 Java에서 데이터베이스에 접근하기 위한 표준 API이며, 데이터베이스 종류가 변경되더라도 동일한 방식으로 데이터 접근이 가능하도록 만들어졌다.
  2. IoC는 객체 생성과 관리의 제어권을 외부 컨테이너에 위임하는 설계 원칙이며, DI는 이를 구현하기 위해 객체를 외부에서 주입하는 디자인 패턴이다.
  3. JPA는 Java에서 ORM을 사용하기 위한 표준 명세이고, Spring Boot는 Hibernate를 통해 이를 구현하여 객체 중심의 개발을 가능하게 해준다.
  4. 영속성 컨텍스트는 JPA가 Entity를 효율적으로 관리하고 데이터베이스와 동기화하기 위해 사용하는 메모리상의 관리 공간이다.
  5. Spring Data JPA는 JPA를 더 쉽고 편리하게 사용할 수 있도록 Repository 인터페이스 기반의 데이터 접근 기능을 제공하는 Spring의 모듈이다.

'Bootcamp > Fundamentals' 카테고리의 다른 글

MSA 요약  (0) 2026.06.29
Spring 심화  (0) 2026.06.26
Spring 숙련 - 2  (0) 2026.06.25
Spring 숙련 - 1  (0) 2026.06.24
Spring 입문 - 1  (0) 2026.06.22