ORM이 필요한 이유 — SQL을 직접 쓰면 안 되는 건가?

최병현·2026년 6월 8일

spring boot

목록 보기
15/34

1. 개념 소개

백엔드 개발을 하다 보면 데이터베이스와 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가 이 조합을 자동으로 세팅해준다.


2. 왜 필요한가

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);
    }
}

이게 단순 조회 하나다. 저장, 수정, 삭제까지 하면 이런 코드가 수십 배로 늘어난다. 문제점이 보인다.

  • 반복이 너무 많다 — 연결, 쿼리 실행, ResultSet 매핑, 예외 처리가 매번 반복됨
  • 컬럼 추가가 두렵다users 테이블에 컬럼 하나 추가하면 관련된 모든 SQL과 매핑 코드를 찾아서 수정해야 함
  • SQL이 Java 코드 안에 문자열로 박혀 있다 — 오타가 있어도 컴파일 타임에 잡히지 않고 런타임에 터진다
  • 객체 간 관계 처리가 복잡하다UserOrder를 가지면 JOIN 쿼리를 직접 작성하고 수동으로 매핑해야 함
  • DB가 바뀌면 SQL도 바꿔야 한다 — MySQL에서 PostgreSQL로 이전하면 문법 차이 때문에 SQL을 전부 검토해야 함

ORM은 이 문제들을 구조적으로 해결한다. 반복 코드를 없애고, 객체 중심으로 DB를 다루게 해주고, DB 벤더 변경의 영향을 최소화한다.


3. 전체 동작 흐름

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을 자동으로 생성하고 실행한다.


4. 핵심 구성 요소

Entity

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;
}

영속성 컨텍스트 (Persistence Context)

JPA가 엔티티를 관리하는 1차 캐시 공간이다. EntityManager가 이 컨텍스트를 통해 엔티티를 관리한다.

영속성 컨텍스트 안에 있는 엔티티는 변경 감지(Dirty Checking)가 작동한다. 값을 바꾸기만 해도 트랜잭션 커밋 시점에 UPDATE SQL이 자동으로 나간다.

EntityManager

영속성 컨텍스트와 상호작용하는 핵심 인터페이스다. persist(), find(), merge(), remove() 등의 메서드로 엔티티를 다룬다. Spring Data JPA를 쓰면 직접 쓸 일이 많지 않지만, 내부적으로 이걸 통해 동작한다.

Repository

DB 접근 로직을 캡슐화하는 인터페이스다. Spring Data JPA에서 JpaRepository를 상속하면 기본 CRUD 메서드가 자동으로 제공된다.


5. Spring Boot에서 어떻게 연결되는가

spring-boot-starter-data-jpa를 의존성에 추가하면 아래가 자동으로 세팅된다.

  • Hibernate를 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가 쿼리를 자동으로 만들어준다. findByEmailSELECT * FROM users WHERE email = ?로 변환된다.


6. 간단한 예제 코드

6-1. ORM 없이 (JDBC) vs ORM 사용 (JPA) 비교

같은 기능을 두 방식으로 비교해보자. 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

6-2. 변경 감지 (Dirty Checking) 예제

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 자동 실행
    }
}

6-3. 연관관계 매핑 예제

@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(); // 연관 엔티티 접근

7. 자주 헷갈리는 부분

JPA vs Hibernate vs Spring Data JPA

  • JPA — 인터페이스 스펙. "ORM을 이렇게 써야 한다"는 표준 규약
  • Hibernate — JPA 스펙의 구현체. 실제 SQL 생성과 실행을 담당
  • Spring Data JPA — Hibernate 위에 올라간 Spring 추상화 레이어. JpaRepository를 제공해서 더 편하게 쓸 수 있게 해줌

우리가 JpaRepository를 쓰면 내부적으로 Spring Data JPA → Hibernate → JDBC → DB 순으로 흐른다.

ORM이 만능은 아니다

ORM이 편하다고 모든 쿼리를 ORM으로 처리하려 하면 오히려 복잡해지는 경우가 있다.

  • 복잡한 집계 쿼리, 대량 벌크 업데이트 → JPQL, QueryDSL, 또는 Native Query가 나을 수 있다
  • N+1 문제 — 연관 엔티티를 잘못 조회하면 쿼리가 폭발적으로 늘어남 → Fetch Join, @EntityGraph, Batch Fetch Size로 해결

ddl-auto 설정은 운영에서 절대 create나 create-drop으로 쓰면 안 된다

  • create — 애플리케이션 시작 시 테이블을 새로 생성. 기존 데이터 전부 날아감
  • create-drop — 시작 시 생성, 종료 시 삭제. 데이터 전부 날아감
  • update — 스키마 변경을 반영. 컬럼 삭제는 안 함. 개발용으로도 위험할 수 있음
  • validate — 엔티티와 테이블 구조가 일치하는지만 검사. 운영 환경 권장
  • none — 아무것도 안 함. Flyway나 Liquibase로 직접 스키마 관리할 때

8. 실무에서 중요한 포인트

N+1 문제를 항상 의식해야 한다

ORM을 쓸 때 가장 많이 마주치는 성능 문제다. List<Order>를 조회하고 각 OrderUser에 접근하면, 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이 발생한다. 연관 엔티티에 접근해야 한다면 반드시 트랜잭션 범위 안에서 해야 한다.

스키마 관리는 Flyway로

실무에서 ddl-auto로 스키마를 관리하는 건 위험하다. Flyway나 Liquibase로 마이그레이션 파일을 버전 관리하는 방식을 권장한다. V1__create_users.sql, V2__add_column_phone.sql 처럼 변경 이력을 명시적으로 관리하면 팀 환경에서 DB 스키마 동기화가 훨씬 안전해진다.


9. 정리

  • ORM은 Java 객체와 DB 테이블 사이의 패러다임 불일치를 해결하는 기술이다
  • 반복적인 JDBC 코드, 수동 매핑, SQL 문자열 관리 문제를 구조적으로 없애준다
  • Spring Boot에서는 JPA(스펙) + Hibernate(구현체) + Spring Data JPA(추상화) 조합을 사용한다
  • 영속성 컨텍스트가 1차 캐시와 변경 감지를 제공해서 save() 없이도 UPDATE가 된다
  • ORM이 편하다고 모든 상황에 맞는 건 아니다 — 복잡한 쿼리는 JPQL, QueryDSL을 함께 써야 한다
  • N+1 문제와 Lazy Loading 범위는 ORM 사용 시 항상 의식해야 하는 포인트다
  • 운영 환경에서 ddl-auto는 반드시 validate 또는 none으로 설정한다

10. 느낀 점

처음 JDBC 코드를 보고 "이걸 다 써야 해?"라는 생각에 JPA를 배우기 시작했는데, 막상 JPA를 쓰다 보니 편함 뒤에 이해해야 할 개념들이 꽤 있다는 걸 느꼈다. 영속성 컨텍스트, Dirty Checking, Lazy Loading, N+1 문제...

ORM은 SQL을 몰라도 되게 해주는 게 아니라, SQL을 알면서 더 효율적으로 쓸 수 있게 해주는 도구다. SQL을 이해하지 못한 채 JPA만 쓰면 N+1 같은 문제에서 왜 쿼리가 터지는지 감도 안 온다. ORM을 제대로 쓰려면 결국 기본 SQL과 DB 구조를 함께 알아야 한다는 걸 느꼈다.

profile
Develop

0개의 댓글