orm

ORM(Object-Relational Mapping)

ORM(Object-Relational Mapping)은 객체지향 애플리케이션의 객체 모델과 관계형 데이터베이스의 관계 모델을 연결하여, 개발자가 객체 중심으로 데이터를 다룰 수 있도록 하는 기술이다.

co2plant
ORM

배경/문제

객체지향 애플리케이션과 관계형 데이터베이스(RDBMS)는 데이터를 표현하고 관계를 다루는 방식에 차이가 있다.
Java와 같은 객체지향 언어에서는 데이터를 객체(Object)로 표현하고 객체 간의 관계를 참조(Reference)를 통해 표현한다. 반면 관계형 데이터베이스에서는 데이터를 테이블(Table)과 행(Row)으로 표현하고, 외래 키(Foreign Key)와 JOIN을 이용하여 데이터 간의 관계를 표현한다.
차이를 간단히 표로 나타내면 아래와 같다.

불일치 객체지향 모델 관계형 모델
구조 표현 방식 Class Table
데이터 표현 방식 Object Row
관계 표현 방식 Reference Foreign Key
식별 방식 Object Identity Primary Key
상속 표현 Inheritance 직접 대응되는 기본 개념 없음(일부 존재)
탐색 방식 Object Graph JOIN
다중 관계 Collection 별도 Table / Relation
데이터 접근 방법 Encapsulation Column 중심

예를 들어 객체지향 코드에서는 다음과 같이 객체의 참조를 따라 자연스럽게 탐색할 수 있다.

order.getMember().getName();

하지만 관계형 데이터베이스에서는 Order가 Member 객체를 직접 참조하지 않는다. 대신 member_id와 같은 Foreign Key를 저장하고 필요한 경우 JOIN을 수행해야 한다.

SELECT m.name
FROM orders o
JOIN member m ON o.member_id = m.id
WHERE o.id = ?;

이러한 객체 모델과 관계형 모델 사이의 구조적 차이를 객체-관계 불일치(Object-Relational Impedance Mismatch) 라고 한다.
ORM은 이러한 차이를 완화하기 위해 등장했다.

정의

ORM(Object-Relational Mapping)은 객체지향 애플리케이션의 객체 모델과 관계형 데이터베이스의 관계 모델을 연결하여, 개발자가 객체 중심으로 데이터를 다룰 수 있도록 하는 기술이다.
즉,

flowchart TB
    A["Object Model"]
    B["ORM"]
    C["Relational Model"]

    A --> B
    B --> C

    C --> B
    B --> A

이라는 변환 계층을 제공한다.
여기서 한 가지 구분할 점이 있다.
ORM은 개념이고 JPA는 Java에서 ORM을 사용하기 위한 표준 명세(API)이며, Hibernate는 대표적인 JPA 구현체이다.
Java 진영에는 Spring Data JPA를 흔히 그냥 JPA라고 부르기도 하기 때문에 상세한 설명을 하는 경우에는 분리할 필요가 있다.

ORM (개념)
ㄴ JPA (명세)
		ㄴ Hibernate (JPA 구현체)
				ㄴJDBC
						ㄴRDBMS

따라서 이후의 예제에서는 ORM의 대표적인 Java 구현 방식으로 JPA/Hibernate를 사용한다.

구성/아키텍처

flowchart TB
    A["Object-Oriented Application"]
    B["JPA / ORM Implementation"]
    C["JDBC"]
    D["RDBMS"]

    A --> B
    B --> C
    C --> D

    D --> C
    C --> B
    B --> A

각 계층은 역할이 서로 다르다. 애플리케이션은 객체를 중심으로 데이터를 다루고, ORM 계층은 객체의 상태와 관계를 관계형 데이터베이스가 이해할 수 있는 SQL과 데이터 구조로 변환한다.
JDBC는 Java 애플리케이션이 실제 데이터베이스와 통신하기 위한 저수준 API 역할을 담당한다.
JPA를 사용하면 JDBC를 직접 사용하는 일이 거의 없기 때문에 ORM이 JDBC를 대체한다고 생각할 수도 있다.
하지만 실제로는 Hibernate와 같은 JPA 구현체 역시 내부적으로 JDBC를 사용하여 데이터베이스와 통신한다. 실제로 SpringDataJPA를 사용할 때는 반드시 Hibernate와 같은 JPA 구현체를 같이 넣어주어야한다.

flowchart LR
	A[Application]
	B[JPA API]
	C[Hibernate]
	D[JDBC]
	E[RDBMS]
	A --> B
	B --> C
	C --> D
	D --> E

결국 ORM은 JDBC를 없애는 것이 아니라 개발자가 직접 처리하던 SQL 생성, 객체 매핑, 상태 관리, 변경 감지 등의 작업을 한 단계 추상화하여 대신 처리한다.
따지고 보면 객체 세계와 관계형 데이터베이스 세계 사이에서 일하는 통역가나 비서 정도로 생각해도 될 것 같다.

동작원리

JDBC를 직접 사용하는 경우

public List<User> getActiveAdultUsers(Connection conn) throws SQLException {

    String sql =
        "SELECT id, name, age, status " +
        "FROM users WHERE age >= ? AND status = ?";

    List<User> users = new ArrayList<>();

    try (PreparedStatement stmt = conn.prepareStatement(sql)) {

        stmt.setInt(1, 20);
        stmt.setString(2, UserStatus.ACTIVE.name());

        try (ResultSet rs = stmt.executeQuery()) {
            while (rs.next()) {
                users.add(new User(
                    rs.getLong("id"),
                    rs.getString("name"),
                    rs.getInt("age"),
                    UserStatus.valueOf(rs.getString("status"))
                ));
            }
        }
    }

    return users;
}

"요즘은 AI가 있는데 그냥 이런 코드도 생성해서 사용하면 되는 것 아닌가?"라고 생각할 수도 있다.
물론 코드 생성 자체는 어려운 일이 아니다.
애플리케이션 전체에서 이러한 코드가 반복되기 시작하면 개발자는 SQL뿐만 아니라 Connection, PreparedStatement, Parameter Binding, ResultSet 변환, 자원 반납 등의 코드까지 지속적으로 관리하고 리뷰해야 한다.

SQL Mapper를 사용하는 경우

대표적인 SQL Mapper인 MyBatis를 사용하면 다음과 같이 작성할 수 있다.

@Mapper
public interface UserMapper {

    List<User> findActiveAdultUsers(
        @Param("age") int age,
        @Param("status") UserStatus status
    );
}

SQL은 XML 등에 별도로 정의할 수 있다.

<select id="findActiveAdultUsers"
        resultType="com.example.User">

    SELECT id, name, age, status
    FROM users
    WHERE age >= #{age}
      AND status = #{status}

</select>

JDBC를 직접 사용할 때와 비교하면 Connection 관리, PreparedStatement 생성, Parameter Binding, ResultSet → Object 변환, 자원 반납 등의 반복적인 작업을 프레임워크가 대신 처리한다.
하지만 SQL Mapper와 ORM에는 중요한 차이가 있다.
MyBatis에서는 SQL을 개발자가 직접 정의하고 그 결과를 객체에 매핑한다.
반면 ORM에서는 객체와 테이블 사이의 매핑을 정의하고 객체의 상태 변화를 기반으로 SQL을 생성한다.
단순화하면 다음과 같다.

flowchart LR
A[JDBC SQL + JDBC + Mapping을 개발자가 관리]
B[MyBatis, SQL은 개발자가 관리, JDBC / Result Mapping은 Framework가 처리]
C[JPA / Hibernate, 객체와 Mapping을 개발자가 정의, SQL 생성 + Mapping + Entity 상태를 ORM이 관리]

A-->B
B-->C

따라서 MyBatis가 객체지향과 거리가 멀다고 표현하기보다는, 추상화의 중심이 다르다고 보는 편이 정확하다.
MyBatis는 SQL 중심의 데이터 접근 방식을 유지하면서 반복적인 JDBC 코드를 추상화하고, ORM은 객체 모델을 중심으로 관계형 데이터베이스와의 차이를 추상화한다.

ORM을 사용하는 경우

JPA를 사용하면 다음과 같이 Entity를 정의할 수 있다.

@Entity
public class User {

    @Id
    private Long id;

    private String name;

    private int age;

    @Enumerated(EnumType.STRING)
    private UserStatus status;
}

Spring Data JPA를 사용한다면 Repository를 다음과 같이 정의할 수도 있다.

public interface UserRepository
        extends JpaRepository<User, Long> {

    List<User> findByAgeGreaterThanEqualAndStatus(
        int age,
        UserStatus status
    );
}

처음 ORM을 사용하면 특히 단순 CRUD에서 상당히 편하다는 느낌을 받는다.
개발자가 직접 INSERT, UPDATE, SELECT SQL을 작성하지 않아도 객체를 조회하고 수정하는 방식으로 데이터를 다룰 수 있기 때문이다.
하지만 ORM의 핵심은 단순히 SQL을 자동으로 만들어주는 것에 있지 않다.
ORM은 객체의 생명주기와 상태를 관리하고, 객체의 변경을 추적하여 적절한 시점에 관계형 데이터베이스와 동기화한다.
이 차이를 이해하려면 ORM 내부의 핵심 메커니즘을 살펴봐야 한다.

핵심기술

핵심 기술 목적 DB와의 관계
Mapping 객체 ↔ Table 매핑 데이터 모델
Persistence Context Entity 생명주기 및 상태 관리 DB 동기화
First-Level Cache 동일 Persistence Context 내 Entity 캐싱 SELECT 감소
Identity Map 동일 PK Entity의 동일 인스턴스 보장 Primary Key
Dirty Checking Entity 변경 감지 UPDATE
Lazy Loading 필요한 시점에 연관 Entity 조회 SELECT
Eager Loading 연관 Entity 즉시 로딩 SELECT/JOIN
SQL Generation 객체 연산을 SQL로 변환 SQL
Flush Persistence Context의 변경 내용을 DB에 동기화 INSERT/UPDATE/DELETE
Transaction 작업 단위 관리 ACID
Locking 동시성 제어 Lock / MVCC
Batch Processing SQL 실행 횟수 및 네트워크 왕복 감소 I/O 최적화

ORM은 단순히 SQL문을 없애는 것이 아니라 여러 메커니즘을 통해 객체의 상태와 데이터베이스의 상태를 관리한다.
그중 JPA를 이해하는 데 가장 중요한 개념 중 하나가 Persistence Context(영속성 컨텍스트) 이다.

Persistence Context

영속성 컨텍스트는 Entity를 관리하는 논리적인 공간이다.
JPA에서 Entity를 조회했다고 해서 단순히 DB의 Row를 Java Object로 변환하고 끝나는 것이 아니다.
조회된 Entity는 영속성 컨텍스트에 의해 관리되며, JPA는 이를 기반으로 동일성 보장, 1차 캐시, 변경 감지 등의 기능을 제공한다.

flowchart LR
A[Database]
    ↑ ↓
Persistence Context
    ↑ ↓
Entity

이 구조가 있기 때문에 JPA는 단순한 SQL Mapper와 다른 동작을 할 수 있다.

Dirty Checking

ORM을 사용하면 UPDATE SQL을 개발자가 직접 작성하지 않아도 되는 경우가 많다.
그 이유가 Dirty Checking(변경 감지) 이다.
예를 들어 다음과 같이 영속 상태의 Entity를 수정했다고 해보자.

@Transactional
public void changeName(Long id) {

    User user = userRepository.findById(id)
        .orElseThrow();

    user.changeName("Kim");
}

코드에는 UPDATE SQL도 없고 save()도 없다.
하지만 JPA는 영속성 컨텍스트가 관리하는 Entity의 초기 상태와 현재 상태를 비교하여 변경 사항을 감지할 수 있다.

Entity 조회
    ↓
Persistence Context에서 관리
    ↓
Entity 상태 변경
    ↓
Dirty Checking
    ↓
Flush
    ↓
UPDATE SQL 실행

여기서 중요한 것은 Transaction 종료 자체가 Dirty Checking의 직접적인 원인이라기보다, Flush 시점에 변경 감지가 수행되고 SQL이 데이터베이스에 반영된다는 점이다.
일반적인 Spring의 @Transactional 환경에서는 트랜잭션 commit 과정에서 flush가 발생하기 때문에 개발자 입장에서는 트랜잭션 종료 시 UPDATE가 실행되는 것처럼 보이는 경우가 많다.

Lazy Loading

연관된 Entity를 항상 함께 조회하면 필요하지 않은 데이터까지 읽어오는 문제가 발생할 수 있다.
이를 피하기 위해 ORM은 Lazy Loading(지연 로딩) 을 사용할 수 있다.
예를 들어 Order가 Member를 참조하고 있다고 하자.

@ManyToOne(fetch = FetchType.LAZY)
private Member member;

Order를 조회하는 시점에는 Member를 바로 조회하지 않고, 실제로 Member가 필요한 시점까지 조회를 미룰 수 있다.

Order order = orderRepository.findById(id)
    .orElseThrow();

// 이 시점에 연관 Entity 조회가 발생할 수 있다.
order.getMember().getName();

Hibernate는 이러한 지연 로딩을 구현하기 위해 프록시 등의 메커니즘을 사용할 수 있다.
다만 여기서 중요한 점은 프록시 객체 자체가 N+1 문제를 발생시키는 것은 아니라는 것이다.
N+1은 여러 Entity를 조회한 뒤 각각의 연관 관계를 개별적으로 조회하면서 추가 SQL이 반복되는 접근 패턴에서 발생한다.
예를 들어 Order 100개를 조회했다고 하자.

SELECT * FROM orders;

이후 각각의 Order에 대해 Member에 접근한다.

for (Order order : orders) {
    order.getMember().getName();
}

상황에 따라 다음과 같은 SQL이 추가로 반복될 수 있다.

Order 조회       → 1 query

Member 조회      → N queries

---------------------------
총               → 1 + N

따라서 Lazy Loading은 불필요한 조회를 줄이는 중요한 최적화 수단인 동시에, 조회 패턴을 제대로 이해하지 못하면 N+1과 같은 성능 문제를 발견하기 어렵게 만들 수도 있다.

장점 / 한계 · Trade-off

관점 장점 한계
생산성 반복 CRUD 코드 감소 ORM 학습 비용
객체지향 객체 중심 개발 객체-관계 불일치를 완전히 제거하지는 못함
유지보수 반복 SQL 및 Mapping 감소 추상화 내부 동작 이해 필요
DB 독립성 Dialect를 통한 DB 차이 추상화 DB 고유 기능 활용 시 종속성 발생 가능
성능 1차 캐시, Lazy Loading 등 활용 N+1 등 성능 문제 발생 가능
SQL SQL 자동 생성 예상하지 못한 SQL 생성 가능
관계 객체 그래프 탐색 가능 연관관계 설정에 따라 추가 SQL 발생
Transaction Entity 상태 변화 자동 반영 Flush와 Transaction의 관계 이해 필요

ORM의 장점과 단점은 사실 같은 곳에서 나온다.
ORM이 데이터베이스 접근을 추상화한다는 것이다.
추상화 덕분에 개발자는 SQL과 JDBC의 반복적인 작업에서 벗어날 수 있지만, 반대로 추상화 내부에서 어떤 SQL이 언제 실행되는지 이해하지 못하면 예상하지 못한 성능이나 데이터 정합성 문제를 만나게 된다.
결국 ORM을 잘 사용한다는 것은 SQL을 몰라도 된다는 의미가 아니라, 객체와 SQL 두 세계가 어떻게 연결되는지를 이해하고 적절한 추상화 수준에서 제어할 수 있다는 의미에 가깝다.

실무 적용 및 장애·성능 사례

최근 실제 업무에서 JPA와 MyBatis를 동시에 사용하는 환경에서 Flush와 관련된 문제를 경험한 적이 있다.
하나의 Transaction 안에서 JPA를 통해 Entity를 수정한 뒤, 이어서 MyBatis Mapper가 동일한 데이터를 조회하는 구조였다.
처음에는 다음과 같이 생각하기 쉽다.

JPA Entity 수정

        ↓

Database 데이터 변경

        ↓

MyBatis 조회

하지만 JPA에서 Entity의 값을 변경했다고 해서 그 순간 UPDATE SQL이 반드시 실행되는 것은 아니다.
변경된 Entity의 상태는 우선 영속성 컨텍스트에서 관리될 수 있다.

JPA Entity 수정

        ↓

Persistence Context
변경 사항 보유

        ↓

아직 Flush되지 않음

        ↓

Database는 기존 값

        ↓

MyBatis SELECT

        ↓

기존 값 조회 가능

MyBatis는 JPA의 영속성 컨텍스트를 통해 데이터를 조회하는 것이 아니라 실제 데이터베이스를 대상으로 SQL을 실행한다.
따라서 JPA의 변경 사항이 아직 Flush되지 않았다면 MyBatis에서는 기대했던 최신 상태가 보이지 않을 수 있다.
이 경우 필요한 시점에 JPA의 변경 내용을 데이터베이스와 동기화해야 했다.

entityManager.flush();

또는 Spring Data JPA를 사용한다면 상황에 따라 다음과 같은 방법을 사용할 수도 있다.

repository.saveAndFlush(entity);

그러면 흐름은 다음과 같이 바뀐다.

JPA Entity 수정
        ↓
Persistence Context
        ↓
Flush
        ↓
UPDATE SQL 실행
        ↓
Database
        ↓
MyBatis SELECT
        ↓
변경된 값 조회

여기서 한 가지 중요한 점이 있다.
Flush와 Commit은 같은 것이 아니다.
Flush는 영속성 컨텍스트의 변경 내용을 SQL로 만들어 데이터베이스와 동기화하는 과정이고, Commit은 해당 Transaction의 변경 사항을 최종적으로 확정하는 과정이다.

Flush
Persistence Context → Database

Commit
Transaction → 확정

따라서 Flush가 실행되었다고 해서 Transaction이 끝난 것은 아니며, 이후 Transaction이 Rollback되면 Flush를 통해 실행된 변경 역시 Rollback될 수 있다.
이 경험을 통해 ORM의 추상화가 생산성을 높여주는 동시에, JPA와 MyBatis처럼 서로 다른 데이터 접근 방식을 함께 사용할 때는 Persistence Context → Flush → Database → Transaction의 관계를 정확히 이해해야 한다는 점을 알 수 있었다.
결국 ORM을 제대로 사용하려면 ORM이 만들어주는 SQL만 볼 것이 아니라,

객체의 상태는 어디에 존재하는가?

언제 SQL이 생성되는가?

언제 SQL이 실행되는가?

언제 DB와 동기화되는가?

언제 Transaction이 확정되는가?

를 구분해서 생각해야 한다.