오늘은 JDBC, SQL Mapper, ORM 에 대해서 알아보도록 하겠습니다. 이 세 가지의 공통점이 무엇인지 아시나요?
바로 영속성 (Persistence) 입니다. 영속성이란 데이터를 생성한 프로그램이 종료되어도 사라지지 않는 데이터의 특성을 말합니다. 무슨 말인지 감이 오시나요?
애플리케이션에서 생성된 데이터는 램에 저장되게 됩니다. 램은 휘발성 메모리이죠. 즉, 전원이 꺼지게 되면 우리가 만들어낸 데이터는 모두 날아갑니다. 하지만 우리는 생성된 데이터가 영구히 남아있길 원합니다. 이렇듯 데이터를 영구화시키는 작업을 영속화라고 하고, 데이터의 이런 특성을 영속성 이라고합니다.
JDBC, SQL Mapper, ORM 은 바로 데이터의 영속성을 구현하는 기술입니다.

도메인 영역에서 생성된 데이터들이 영속화 작업을 거쳐 영속성이라는 특성을 지니고 데이터베이스에 저장되는 것입니다.
그러면 각각의 기술들이 어떻게 다르며, 어떠한 배경을 가지고 나온 것인지 알아보도록 하겠습니다.
JDBC (Java Database Connectivity) 는 자바로 작성된 응용 프로그램이 데이터베이스 구현체에 상관없이 표준화된 방식으로 접근할 수 있도록 해주는 JAVA API 입니다. 즉, 개발자는 데이터베이스에 상관없이 JDBC API 명세에 맞춰 개발하면 동일한 코드로 접근할 수 있는 것입니다.
JDBC는 1997년에 처음 등장했습니다. 90년대 중후반, 자바 언어와 인터넷의 폭발적인 성장과 함께 웹 기반의 비즈니스 애플리케이션 개발이 활발해졌습니다. 하지만 당시에는 각 데이터베이스 (Oracle, MySQL, MS SQL Server 등) 마다 자바 애플리케이션과 연결하는 방식이 모두 달랐습니다. 이는 개발자가 특정 데이터베이스에 종속적인 코드를 작성하게 만들었고, 만약 사용하는 데이터베이스를 변경해야 할 경우 애플리케이션 코드를 대폭 수정해야 했습니다.
이러한 문제를 개선하고자, 데이터베이스 접근을 위한 표준 인터페이스인 JDBC를 개발하게 되었습니다.
따라서 개발자가 표준 JDBC API 를 사용하여 코드를 작성하면, 사용하는 데이터베이스가 변경되더라도 JDBC 드라이버만 교체하면 되었습니다.
static void detailCustomer() {
Connection conn = null;
Statement stmt = null;
ResultSet rs = null;
try {
conn = DriverManager.getConnection(url, user, pwd);
stmt = conn.createStatement();
String selectSql = "SELECT * FROM Customer WHERE custid = 4; ";
rs = stmt.executeQuery(selectSql);
if (rs.next()) {
System.out.println(rs.getInt("custid") + " | "
+ rs.getString("name") + " | " + rs.getString("address") + " | " + rs.getString("phone"));
}
} catch (SQLException e) {
e.printStackTrace();
} finally {
try {
stmt.close();
conn.close();
rs.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
데이터를 조회하는 select 작업을 수행하는 코드입니다. JDBC 에서는 DriverManager 를 이용해 DB 와 연결하는 Connection 객체를 얻습니다. 이후 Statement 나 PreparedStatement 를 이용해 SQL 쿼리를 실행하며, ResultSet 을 통해 가져온 데이터를 처리합니다.

그런데 위의 코드 좀 많이 복잡하지 않나요?
select 작업 하나에 부가적으로 반복되는 코드들이 너무 많습니다. 당시 개발자들도 불편함을 느꼈을 것입니다. 이러한 불편을 해소하고자 JDBC Template 이 등장하였습니다.
Spring 진영에서 제공하는 JDBC Template 은 순수 JDBC 의 단점인 반복적인 코드와 수동 리소스 관리의 불편함을 해결하고자 등장했습니다. JDBC Template 은 Connection, Statement, ResultSet 처리 루프 등의 반복적인 작업을 내부적으로 처리 해줍니다. 개발자는 SQL 문과 파라미터, 그리고 ResultSet의 데이터를 어떻게 자바 객체로 매핑할지만 정의하면 됩니다.
이때, JDBC Template 과는 다른 관점으로 JDBC 의 문제 해결에 접근했던 것이 MyBatis 입니다. 이들의 목표는 바로 자바 코드와 SQL 을 분리하자는 것이었습니다. 비즈니스 로직으로부터 SQL 을 완전히 분리하여 관리하는 것이 이들의 목표였습니다.
SQL Mapper 는 객체 지향 언어의 객체와 SQL 사이의 매핑을 도와주는 프레임워크입니다. 핵심은 SQL 문을 자바 코드로부터 완전히 분리하여 별도의 파일에 관리하는 것입니다. (MyBatis 는 SQL 문을 XML 파일에 분리하여 관리합니다.)
따라서, SQL 쿼리가 자바 코드에 흩어져 있는 것을 해결하고, ResultSet 의 데이터를 자바 객체의 필드에 자동으로 매핑하도록 하여 생산성을 향상시켰습니다.

MyBatis 는 애플리케이션 시작 시 설정 파일을 기반으로 SqlSessionFactory 를 생성하고, 이를 통해 각 요청마다 SqlSession 을 생성합니다. SqlSession 은 실제 데이터베이스 작업을 수행하는 객체로, JDBC의 Connection 과 유사한 역할을 합니다. SqlSession 은 mapper interface 와 mapping 파일을 연결하여 SQL 을 실행하고, 결과를 객체로 변환해 반환합니다.
| 구성 요소 | 역할 설명 |
|---|---|
| MyBatis Config File | DB 연결 정보, 매퍼 경로 등의 설정 포함 |
| SqlSessionFactoryBuilder | 설정 파일 기반으로 SqlSessionFactory 생성 |
| SqlSessionFactory | SqlSession을 생성하는 객체 |
| SqlSession | 실제 SQL을 실행하고 트랜잭션을 관리하는 객체 |
| Mapper Interface | DAO 인터페이스, 실제 SQL은 매핑 파일에 있음 |
| Mapping File (XML) | SQL 구문과 자바 메서드를 매핑 |
사실 이러한 동작 방식보다는 아래의 예제 코드를 보면 이해가 더 쉬울 것입니다.
public interface BookDao {
List<BookDto> listBook();
BookDto detailBook(int bookId);
int insertBook(BookDto book);
int updateBook(BookDto book);
int deleteBook(int bookId);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="dao.BookDao">
<select id="listBook" resultType="dto.BookDto">
select bookid bookId, bookname bookName, publisher, price from book;
</select>
<select id="detailBook" resultType="dto.BookDto" parameterType="int">
select bookid bookId, bookname bookName, publisher, price
from book
where bookid = #{bookId};
</select>
<insert id="insertBook" parameterType="dto.BookDto">
insert into book (bookid, bookname, publisher, price)
values (#{bookId}, #{bookName}, #{publisher}, #{price});
</insert>
<update id="updateBook" parameterType="dto.BookDto">
update book
set bookname = #{bookName}, publisher = #{publisher}, price = #{price}
where bookid = #{bookId};
</update>
<delete id="deleteBook" parameterType="int">
delete from book
where bookid = #{bookId};
</delete>
</mapper>
SQL 쿼리를 별도의 xml 파일로 완전히 분리시키고, resultType 과 parameterType 을 명시하면, 개발자가 직접 SQL 쿼리의 결과를 자바 객체에 매핑하지 않아도 됩니다. 이전 JDBC 코드와 비교하면 반복적인 코드가 거의 사라진 것을 확인할 수 있습니다. 또한 자바 코드와 SQL 을 완전히 분리하여 프로젝트의 복잡성을 낮출 수 있게 되었습니다.
그럼에도 문제는 남아있었습니다. 여전히 SQL 작성이 필요했습니다. 물론 SQL 작성이 무조건적인 단점은 아니지만, 모든 SQL 문을 개발자가 직접 작성해야 된다는 점에서 생산성 개선의 여지는 아직도 남아 있었습니다. 또한 복잡한 관계를 매핑할 때는 resultMap 을 사용해서 직접 매핑 규칙을 정하기도 해야 했습니다.
ORM (Object-Relational-Mapping) 은 객체와 테이블을 자동으로 매핑해주는 프레임워크입니다.
우리가 SQL 문을 사용한다면 객체의 변경이 일어났을 때, 해당 객체를 참조하는 SQL 쿼리도 함께 수정해야 합니다. 즉, SQL Mapper 가 겉으로는 자바 코드와 SQL 을 분리시키는데 성공했지만, 논리적으로는 여전히 아주 강하게 연결되어 있는 것입니다.
객체 지향 프로그래밍에서는 상속, 다형성, 관계(1:N, N:M 등) 와 같은 개념을 사용하여 현실 세계를 모델링합니다. 반면, 관계형 데이터베이스는 데이터를 테이블의 형태로 저장합니다. 따라서 이러한 객체 지향적 개념들을 직접적으로 표현하기 어렵습니다. 객체의 상속 구조를 데이터베이스 테이블로 어떻게 표현할지, 객체 간의 복잡한 관계를 어떻게 테이블 간의 관계로 나타낼지 매우 어렵지 않나요?
이를 객체 - 관계 임피던스 불일치(Object-Relational Impedance Mismatch)라고 합니다.
ORM은 이러한 불일치 문제를 프레임워크 차원에서 해결하고, 개발자가 데이터베이스의 세부 사항보다는 비즈니스 로직과 객체 모델 자체에 더 집중할 수 있도록 하기 위해 등장했습니다. ORM 은 개발자가 정의한 객체 와 데이터베이스 테이블 간의 매핑 정보를 바탕으로, 필요한 SQL을 자동으로 생성하고 실행하며, 조회 결과를 객체로 자동 변환해줍니다.
@Entity
@Table(name = "book")
public class Book {
@Id
private int bookId;
private String bookName;
private String publisher;
private int price;
// Getter, Setter
}
public class BookDaoJpa {
private final EntityManager em;
public BookDaoJpa(EntityManager em) {
this.em = em;
}
public List<Book> listBook() {
return em.createQuery("SELECT b FROM Book b", Book.class)
.getResultList();
}
public Book detailBook(int bookId) {
return em.find(Book.class, bookId);
}
public void insertBook(Book book) {
EntityTransaction tx = em.getTransaction(); // 트랜잭션 시작
tx.begin();
em.persist(book); // 영속화
tx.commit(); // 실제 커밋 시점
}
public void updateBook(Book book) {
EntityTransaction tx = em.getTransaction();
tx.begin();
em.merge(book); // Id 가 같으면 update
tx.commit();
}
public void deleteBook(int bookId) {
EntityTransaction tx = em.getTransaction();
tx.begin();
Book book = em.find(Book.class, bookId);
if (book != null) {
em.remove(book);
}
tx.commit();
}
}
위의 예시 코드는 스프링 진영에서 제공하는 spring data JPA 가 아닌 순수 JPA 이다. 엔티티 객체는 데이터베이스 테이블과 매핑되는 클래스 입니다. ORM 은 엔티티 객체를 통해 DB 데이터를 객체처럼 다룰 수 있습니다. 엔티티 객체의 각 필드는 테이블의 컬럼에 해당하고, 인스턴스 하나하나가 각각의 행이라고 생각하면 됩니다.
두 번째 JPA 예시 코드를 이해하기 위해서는 JPA 가 어떻게 엔티티를 관리하는지 알아야 합니다.
JPA 는 기본적으로 엔티티들을 영속성 컨텍스트(Persistence Context)에서 관리합니다. JPA 는 DB 와 직접적으로 데이터를 주고 받는 것이 아니라, 영속성 컨텍스트라는 메모리 공간에서 엔티티들을 관리합니다. 영속성 컨텍스트는 쉽게 말해 영속화 된 엔티티들이 저장되는 곳입니다.
JPA 는 트랜잭션 내부에서 영속성 컨텍스트에 저장된 엔티티들을 추적하다가 변경이 감지되면 쿼리를 미리 생성해둔 후 commit 시점에 DB 에 실제 쿼리를 날리게 됩니다. 즉, 영속화 된 객체가 변경될 때마다 쿼리를 날리는 것이 아니라, 객체를 추적하다가 변경이 감지되는 시점에는 쿼리를 준비만 합니다.
JPA 가 내부적으로 이렇게 동작하는 이유는 성능 향상을 위해서입니다. 같은 객체를 여러 번 수정해도 실제 쿼리는 최종적으로 한 번만 날아가기 때문에 쿼리 중복을 피할 수 있습니다. 또한 영속성 컨텍스트라는 1차 캐시에서 엔티티들을 관리하기 때문에 불필요하게 DB 에 접근하는 과정을 줄일 수 있습니다.
다음으로는 JPA 의 메서드들이 어떤 식으로 동작하는지 알아보겠습니다.
영속화 (Persist) 는 JPA 가 대상 엔티티를 영속성 컨텍스트에 저장하는 작업입니다. 따라서 persist 된 객체는 영속성 컨텍스트에서 관리되며, JPA 는 해당 객체들을 dirtcy checking 하게 됩니다. dirty checking 은 변경을 감지하는 작업으로서, 위에서 설명했듯이 JPA 는 영속화 된 객체들을 추적하며 변경을 감지합니다. 이후 commit 시점에 변경된 내용들과 함께 실제 쿼리가 날라가 DB 에 저장됩니다.
JPA 의 find 메서드는 파라미터로 입력된 Id 와 class 정보를 바탕으로 대상 객체를 찾습니다. 먼저 1차 캐시 (영속성 컨텍스트) 에서 객체를 찾은 후, 만약 존재한다면 DB 에 접근하지 않고 해당 객체를 반환합니다. 1차 캐시에 대상 객체가 없다면 DB 에서 찾은 후 객체를 반환합니다. 이때 객체는 영속화 된 상태로 반환하므로 find 를 통해 반환된 객체는 모두 영속화되어 있습니다. 따라서 이후 해당 객체를 수정하게 되면, dirty checking 되어 commit 시점에 변경 내용이 실제 DB 에 반영됩니다.
merge 는 영속화되지 않은 객체를 전달받아 영속성 컨텍스트에 새롭게 영속화 된 객체를 반환합니다. 즉, 원본 객체는 영속화되지 않음을 주의해야 합니다. 이후 merge 시점에 ID 를 이용하여, SELECT 를 통해 DB 에 해당 객체가 존재하는지 검사합니다. 만약 존재한다면 UPDATE, 존재하지 않는다면 INSERT 와 같은 작업을 수행합니다.
detach 는 대상 객체를 영속성 컨텍스트에서 분리시킵니다. 이후 상태는 준영속 상태가 됩니다. DB 에서 객체를 삭제 시키는 것이 아니라, 단순히 영속성 컨텍스트에서 분리시키는 역할을 수행합니다. 따라서 트랜잭션 내부에서 해당 객체를 더 이상 dirty checking 하지 않게 됩니다. 따라서 변경 감지가 불필요한 객체는 영속성 컨텍스트에서 분리시켜 관리할 수 있습니다.
remove 는 대상 객체를 영속성 컨텍스트에서 제거하고, 커밋 시점에 DELETE 쿼리를 DB 에 날리게 됩니다. 따라서 remove 이후 해당 객체는 비영속 상태가 됩니다.

위의 흐름도는 지금까지 설명한 JPA 메서드들의 흐름을 나타낸 것입니다. persist, find, merge, detach, remove 이외에도 다른 메서드들이 있지만, JPA 의 기본적인 내부 동작을 이해하기 위해서는 이정도만 알아도 충분할 것입니다. 참고로 flush 는 변경 사항을 DB 에 즉시 반영하는 메서드 입니다. 따라서 커밋 직전에 flush 가 자동으로 호출되게 됩니다. 물론 직접 호출하여 사용할 수도 있습니다.
사실 순수 JPA 이외에 스프링 진영에서 제공하는 Spring Data JPA 가 있습니다. 하지만 기본적으로 JPA 를 알지 못하면 스프링의 영역과 JPA 의 영역을 구분하기 힘들어서 순수 JPA 를 우선적으로 다루어 보았습니다. Spring Data JPA 의 내용은 이후 다른 글에서 함께 다루도록 하겠습니다.
오늘은 데이터의 영속성을 구현하는 JDBC, SQL Mapper, ORM 에 대해서 알아보았습니다. 사실 하나하나 내용이 너무 많아서 깊게 다루지는 못했습니다. 그럼에도 최대한 필요한 내용들만 간략하게 소개하고자 하였습니다.
최근에는 MyBatis 와 JPA 를 주로 사용한다고 하지만, 그 원천이 되는 JDBC 에 대해서도 우리는 알아야 합니다. 이 글을 통해 각각의 기술이 어떤 의미와 특성을 지니는지 알고, 더 깊게 탐구하였으면 좋겠습니다!