백엔드 개발을 하다 보면 데이터베이스와 Java 코드 사이에 항상 벽이 하나 있다. DB는 테이블과 행(Row)으로 데이터를 다루고, Java는 클래스와 객체로 데이터를 다룬다. 이 두 세계는 구조 자체가 다르다.
ORM(Object-Relational Mapping)은 이 두 세계 사이의 간극을 채워주는 기술이다. Java 객체와 DB 테이블을 자동으로 매핑해서, SQL을 직접 쓰지 않고도 DB를 다룰 수 있게 해준다.
Spring Boot 생태계에서 ORM의 표준 스펙은 JPA(Jakarta Persistence API)이고,
그 구현체로 Hibernate를 사용한다.
우리가 쓰는 spring-boot-starter-data-jpa가 이 조합을 자동으로 세팅해준다.
ORM 없이 순수 JDBC로 DB를 다루면 어떻게 되는지 먼저 보자.
// ORM 없이 JDBC로 직접 처리하는 코드
public User findById(Long id) {
String sql = "SELECT id, email, name, created_at FROM users WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setLong(1, id);
ResultSet rs = pstmt.executeQuery();
if (rs.next()) {
User user = new User();
user.setId(rs.getLong("id"));
user.setEmail(rs.getString("email"));
user.setName(rs.getString("name"));
user.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
return user;
}
return null;
} catch (SQLException e) {
throw new RuntimeException("DB 조회 실패", e);
}
}
이게 단순 조회 하나다. 저장, 수정, 삭제까지 하면 이런 코드가 수십 배로 늘어난다. 문제점이 보인다.
users 테이블에 컬럼 하나 추가하면 관련된 모든 SQL과 매핑 코드를 찾아서 수정해야 함User가 Order를 가지면 JOIN 쿼리를 직접 작성하고 수동으로 매핑해야 함ORM은 이 문제들을 구조적으로 해결한다. 반복 코드를 없애고, 객체 중심으로 DB를 다루게 해주고, DB 벤더 변경의 영향을 최소화한다.
JPA 기반으로 user.save()를 호출하면 내부에서 어떤 일이 벌어지는지 보자.
[Application Code]
|
| userRepository.save(user) 호출
↓
[Spring Data JPA - SimpleJpaRepository]
|
| JPA 표준 API 호출 (entityManager.persist(user))
↓
[JPA 구현체 - Hibernate]
|
| ① 엔티티 상태 확인 (새 객체인지, 기존 객체인지)
| ② 영속성 컨텍스트(1차 캐시)에 엔티티 등록
| ③ 쓰기 지연 SQL 저장소에 INSERT SQL 생성해서 보관
↓
[트랜잭션 커밋 시점]
|
| ④ flush() — 쓰기 지연 SQL을 실제 DB로 전송
| → "INSERT INTO users (email, name) VALUES (?, ?)" 실행
↓
[JDBC]
|
| ⑤ DB 드라이버를 통해 실제 DB에 쿼리 전송
↓
[Database - MySQL / PostgreSQL 등]
|
| ⑥ 쿼리 실행 후 결과 반환
↓
[Hibernate]
|
| ⑦ 생성된 PK를 엔티티 객체에 다시 세팅
↓
[Application Code]
user.getId() 로 생성된 ID 조회 가능
중요한 건 우리가 SQL을 한 줄도 작성하지 않았다는 것이다. Hibernate가 엔티티 정보를 보고 SQL을 자동으로 생성하고 실행한다.
DB 테이블과 1:1로 매핑되는 Java 클래스다.
@Entity 애노테이션을 붙이면 JPA가 이 클래스를 관리 대상으로 인식한다.
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String name;
}
JPA가 엔티티를 관리하는 1차 캐시 공간이다.
EntityManager가 이 컨텍스트를 통해 엔티티를 관리한다.
영속성 컨텍스트 안에 있는 엔티티는 변경 감지(Dirty Checking)가 작동한다. 값을 바꾸기만 해도 트랜잭션 커밋 시점에 UPDATE SQL이 자동으로 나간다.
영속성 컨텍스트와 상호작용하는 핵심 인터페이스다.
persist(), find(), merge(), remove() 등의 메서드로 엔티티를 다룬다.
Spring Data JPA를 쓰면 직접 쓸 일이 많지 않지만, 내부적으로 이걸 통해 동작한다.
DB 접근 로직을 캡슐화하는 인터페이스다.
Spring Data JPA에서 JpaRepository를 상속하면 기본 CRUD 메서드가 자동으로 제공된다.
spring-boot-starter-data-jpa를 의존성에 추가하면 아래가 자동으로 세팅된다.
DataSource 자동 설정 (application.yml의 DB 접속 정보 기반)EntityManagerFactory 자동 생성TransactionManager 자동 등록@Entity 클래스 자동 스캔# application.yml 최소 설정
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb
username: root
password: 1234
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: validate # 운영: validate / 개발: create 또는 update
show-sql: true # 실행되는 SQL 콘솔 출력
properties:
hibernate:
format_sql: true # SQL 줄바꿈 포맷
default_batch_fetch_size: 100 # N+1 문제 완화
JpaRepository를 상속한 인터페이스만 만들면 Spring이 구현체를 자동으로 생성해서 Bean으로 등록한다.
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
List<User> findByNameContaining(String keyword);
}
메서드 이름만 잘 지으면 Spring Data JPA가 쿼리를 자동으로 만들어준다.
findByEmail은 SELECT * FROM users WHERE email = ?로 변환된다.
같은 기능을 두 방식으로 비교해보자. User를 저장하고 이메일로 조회하는 코드다.
// ❌ JDBC 방식 — 반복, 장황, 취약
public void save(User user) {
String sql = "INSERT INTO users (email, name) VALUES (?, ?)";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, user.getEmail());
pstmt.setString(2, user.getName());
pstmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
public Optional<User> findByEmail(String email) {
String sql = "SELECT id, email, name FROM users WHERE email = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, email);
ResultSet rs = pstmt.executeQuery();
if (rs.next()) {
User user = new User(rs.getLong("id"), rs.getString("email"), rs.getString("name"));
return Optional.of(user);
}
return Optional.empty();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
// ✅ JPA 방식 — 간결, 타입 안전, 유지보수 쉬움
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email); // 이게 전부
}
// 사용할 때
userRepository.save(user); // INSERT
userRepository.findByEmail(email); // SELECT WHERE email = ?
userRepository.findById(id); // SELECT WHERE id = ?
userRepository.delete(user); // DELETE
ORM의 핵심 편의 기능 중 하나다. 수정 메서드나 UPDATE SQL을 직접 호출하지 않아도 된다.
@Service
@RequiredArgsConstructor
@Transactional
public class UserService {
private final UserRepository userRepository;
public void updateName(Long userId, String newName) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("사용자 없음"));
user.changeName(newName); // 값만 바꾸면 끝
// save() 호출 안 해도 됨!
// 트랜잭션 커밋 시점에 Hibernate가 변경 감지 후 UPDATE SQL 자동 실행
}
}
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY) // User와 N:1 관계
@JoinColumn(name = "user_id")
private User user;
private int totalPrice;
}
// 조회 시 — JOIN 쿼리 자동 처리
Order order = orderRepository.findById(orderId).orElseThrow();
String userName = order.getUser().getName(); // 연관 엔티티 접근
JpaRepository를 제공해서 더 편하게 쓸 수 있게 해줌
우리가 JpaRepository를 쓰면 내부적으로 Spring Data JPA → Hibernate → JDBC → DB 순으로 흐른다.
ORM이 편하다고 모든 쿼리를 ORM으로 처리하려 하면 오히려 복잡해지는 경우가 있다.
@EntityGraph, Batch Fetch Size로 해결create — 애플리케이션 시작 시 테이블을 새로 생성. 기존 데이터 전부 날아감create-drop — 시작 시 생성, 종료 시 삭제. 데이터 전부 날아감update — 스키마 변경을 반영. 컬럼 삭제는 안 함. 개발용으로도 위험할 수 있음validate — 엔티티와 테이블 구조가 일치하는지만 검사. 운영 환경 권장none — 아무것도 안 함. Flyway나 Liquibase로 직접 스키마 관리할 때
ORM을 쓸 때 가장 많이 마주치는 성능 문제다.
List<Order>를 조회하고 각 Order의 User에 접근하면,
Order 조회 1번 + User 조회 N번이 실행된다.
// N+1 발생하는 코드
List<Order> orders = orderRepository.findAll(); // 쿼리 1번
for (Order order : orders) {
System.out.println(order.getUser().getName()); // 각각 쿼리 1번씩 → N번
}
// Fetch Join으로 해결
@Query("SELECT o FROM Order o JOIN FETCH o.user")
List<Order> findAllWithUser();
영속성 컨텍스트는 트랜잭션이 시작할 때 생성되고, 트랜잭션이 끝날 때 함께 종료된다.
@Transactional 밖에서 Lazy Loading을 시도하면
LazyInitializationException이 발생한다.
연관 엔티티에 접근해야 한다면 반드시 트랜잭션 범위 안에서 해야 한다.
실무에서 ddl-auto로 스키마를 관리하는 건 위험하다.
Flyway나 Liquibase로 마이그레이션 파일을 버전 관리하는 방식을 권장한다.
V1__create_users.sql, V2__add_column_phone.sql 처럼 변경 이력을 명시적으로 관리하면
팀 환경에서 DB 스키마 동기화가 훨씬 안전해진다.
save() 없이도 UPDATE가 된다ddl-auto는 반드시 validate 또는 none으로 설정한다처음 JDBC 코드를 보고 "이걸 다 써야 해?"라는 생각에 JPA를 배우기 시작했는데, 막상 JPA를 쓰다 보니 편함 뒤에 이해해야 할 개념들이 꽤 있다는 걸 느꼈다. 영속성 컨텍스트, Dirty Checking, Lazy Loading, N+1 문제...
ORM은 SQL을 몰라도 되게 해주는 게 아니라, SQL을 알면서 더 효율적으로 쓸 수 있게 해주는 도구다. SQL을 이해하지 못한 채 JPA만 쓰면 N+1 같은 문제에서 왜 쿼리가 터지는지 감도 안 온다. ORM을 제대로 쓰려면 결국 기본 SQL과 DB 구조를 함께 알아야 한다는 걸 느꼈다.