요즘 클로드 코드 등의 ai 도구를 다루게 되면서 tmux라는 걸 이용하면 터미널을 분할해서 작업할 수 있다~ 류의 이야기를 종종 듣게 된 것 같습니다. 그래서 tmux 사용법을 알아보던 중 세션, 윈도우같은 개념들이 나와 당황스럽기도 하고 tmux가 어떤 원리이길래 이런 게 가능한건지가 궁금해서 살펴봤었는데요. 찾아보니 tmux는 크게 두 가지를 해주는 도구였습니다.

 

 

  1. 터미널 에뮬레이터을 닫아도 그 안에서 돌던 작업이 죽지 않게 해줍니다. 노트북을 덮었다가 다시 열어도, SSH가 끊겼다가 다시 붙어도 작업을 그대로 이어서 할 수 있게 해줍니다.
  2. 하나의 터미널을 여러 영역으로 분할해서 여러 작업을 동시에 볼 수 있게 해줍니다. 한쪽에선 서버를 띄우고, 다른 쪽에선 로그를 보고, 또 다른 쪽에선 코드를 편집하는 식으로 활용할 수 있습니다.

 

 

이 두 가지가 어떻게 가능한지 살펴보던 과정에서 가장 중요했던 건 "터미널 에뮬레이터와 쉘 프로세스가 어떤 관계인가"였던 것 같습니다. 그래서 이 글에서는 그 관계부터 짚어보고, 그걸 tmux가 어떻게 바꿔놓는지, 그 위에서 패널, 윈도우, 세션이라는 단위들은 무엇을 의미하는지를 차례로 풀어보려고 합니다. 이 글을 통해 tmux가 어떤 원리의 도구인지, 그리고 왜 지금 다시 주목받고 있는지 이해할 수 있을 것으로 기대합니다.

 

※ tmux 사용법(단축키 등)은 이 글에서 다루지 않았습니다.

※ 이 글에서는 터미널 에뮬레이터로 Ghostty를 사용한 것을 기준으로 작성했습니다.

 

 

터미널 에뮬레이터


우선 터미널 에뮬레이터의 개념부터 알아야 합니다. Ghostty, iTerm2 같은 앱들을 터미널 에뮬레이터라고 부르는데요,. 이 앱들은 화면을 렌더링하는 롤, 구체적으론 키보드 입력을 받아 쉘 프로세스에 전달하고 쉘 프로세스의 출력을 화면에 표시하는 역할을 맡습니다.

 

실제로 ls, cd, cat 같은 명령어를 해석하고 OS에 전달하는 건 쉘 프로세스입니다(애당초 쉘 프로세스 자체가 명령어를 해석하는 프로세스입니다). Ghostty를 켜는 순간 내부적으로 시스템에 설정된 기본 쉘(제 맥북 기준으론 zsh)를 자식 프로세스로 생성하며, 구조로 보면 이렇습니다. 여담으로 운영체제 시간이었나? cat 등의 프로세스를 만들어보던 걸 실습해보던 기억이 났습니다

 

Ghostty (부모 프로세스)
  └── zsh (자식 프로세스)
        └── vim, python, ... (손자 프로세스)

 

 

저는 맥북에서 터미널 애뮬레이터로 Ghostty를 사용 중인데요, 실제로 pstree -p $$를 입력한 결과를 보면 다음처럼 Ghostty의 자식으로 zsh가 잡혀있는 것을 볼 수 있었습니다.

 

 

이를 통해 Ghostty를 끄게 되면 쉘 프로세스로 작업하던 내용들이 같이 종료되는 이유도 유추해볼 수 있습니다. 바로 SIGHUP(Signal Hang Up) 때문인데요, 부모 프로세스가 종료되면서 자식 프로세스들에게도 SIGHUP 신호가 전파되면서 함께 종료되는 것이 원인입니다.

 
Ghostty 종료
  ↓ SIGHUP 전송
zsh 종료
  ↓ SIGHUP 전송
vim, python, ... 종료
 
 

SSH로 원격 서버에 붙어 작업하다가 인터넷이 끊기면 작업이 날아가는 것도 같은 이유입니다. SSH 연결이 끊기는 순간 SIGHUP이 전파되어 실행 중이던 모든 프로세스가 종료됩니다.

 

 

 

tmux는


만약 쉘 프로세스가 Ghostty가 아닌 다른 독립적인 프로세스의 자식 프로세스라면 Ghostty를 꺼도 해당 쉘은 살아 있으니 쉘에 다시 붙을 수 있겠다는 아이디어를 얻을 수 있는데요, tmux가 바로 이 점을 활용하는 도구입니다. Ghostty가 아닌 tmux라는 독립적인 프로세스의 자식으로 쉘 프로세스들을 만들어두고, 이 쉘 프로세스들에 붙게 만들어주는 식으로 동작하게 되는 겁니다.

 

tmux를 실행하면 다음과 같은 일이 일어납니다.

 

1. tmux 서버 프로세스 생성

쉘에서 tmux 명령어 입력 시, tmux 서버 프로세스가 백그라운드에서 별도로 띄워집니다. (즉 쉘 프로세스의 자식으로 만들어지지 않습니다)

 

2. tmux 서버가 쉘 프로세스를 자식으로 생성

실제 작업이 이루어지는 쉘 프로세스는 tmux 서버의 자식 프로세스로 생성됩니다.

 

3. tmux 클라이언트가 tmux 서버에 연결

Ghostty 등의 터미널 애물레이터에서 보이는 것은 tmux 클라이언트로, tmux 서버와 소켓으로 연결되어 화면 출력만 주고받습니다.

 

구조를 정리하면 이렇습니다.

init (PID 1)
  └── tmux server          ← Ghostty와 무관한 독립 프로세스
        └── zsh            ← 실제 작업하는 쉘

Ghostty
  └── zsh
        └── tmux client    ← 서버에 연결만 하는 클라이언트
 
 
 

실제로 tmux를 통해 접속하게 된 쉘에서 pstree -p $$를 하게 되면, Ghostty에서 pstree -p $$를 했을 때와는 다르게 쉘 프로세스의 부모가 Ghostty가 아니라 tmux로 잡히는 것을 볼 수 있습니다.

 

 

 

정리하면, 결국 tmux는 터미널 애뮬레이터와 쉘 프로세스 사이에 독립적인 서버를 끼워 넣어서, 에뮬레이터가 꺼져도 쉘 프로세스는 살아있게 만드는 도구인 셈입니다. 그림으로 나타내면 다음과 같습니다.

 

 

이제 Ghostty를 닫으면 tmux 클라이언트는 죽지만 tmux 서버와 그 아래 zsh는 살아남아 있게 됩니다. Ghostty를 다시 열고 tmux attach를 입력하면 tmux 서버에 클라이언트가 다시 붙어 해당 쉘 프로세스에서 작업하던 것을 그대로 이어서 할 수 있게 됩니다.

 

 

 

패널, 윈도우, 세션


tmux에서는 패널, 윈도우, 세션이라는 개념을 사용합니다. 처음에는 세션 == 쉘 프로세스를 의미하는 것으로 오해하기 쉽지만 알고 보면 그렇지 않습니다. tmux 서버 아래로 여러 쉘 프로세스들이 만들어질 수 있는데, tmux가 이들을 논리적으로 묶어서 관리하는 단위가 바로 패널, 윈도우, 세션입니다.

 

구조는 세 계층으로 이루어져 있습니다.

 

패널(Pane)

가장 작은 단위를 말하며, 하나하나가 쉘 프로세스에 대응합니다. 

 

윈도우

패널들이 묶인 집단을 일컫는 단위입니다. 하나의 패널은 하나의 윈도우 안에 있고, 윈도우는 하나 이상의 패널로 구성되게 됩니다.

 

세션

윈도우들이 묶인 집단을 일컫는 단위입니다. 세션은 하나 이상의 클라이언트에 연결(attach)될 수 있고, 연결되면 클라이언트가 실행 중인 외부 터미널(ex: Ghostty)에도 표시됩니다.

 

 

패널 / 윈도우 / 세션의 관계를 도식화해보면 다음과 같습니다.

tmux server
  ├── 세션 A ("work")
  │     ├── 윈도우 1
  │     │     ├── 패널 1 (zsh)
  │     │     └── 패널 2 (zsh)
  │     └── 윈도우 2
  │           └── 패널 1 (zsh)
  └── 세션 B ("personal")
        └── 윈도우 1
              └── 패널 1 (zsh)
 
 
화면 기준으로 설명하면 다음과 같습니다. 파란색으로 표기한 영역이 패널, 빨간색으로 표기한 영역이 윈도우입니다.

 

 
 
 

그러면 왜 요즘 들어 tmux가 다시 주목을 받는가


최근 AI 에이전트들이 코딩을 해주는 워크플로가 보편화되면서, 그에 맞춰 tmux가 주목을 많이 받고 있는데요. 사실 tmux는 2000년대에 나온 도구로, 기술의 발전에 따라 최근에 나온 기술이 아닙니다. 이 도구가 다시 주목을 받는 이유는 다음처럼 정리해볼 수 있습니다.

 

  1. AI 에이전트는 오래 걸리는 작업을 백그라운드로 돌려둘 일이 많습니다. Claude Code 같은 도구에 개발이나 리팩토링, 또는 테스트 작성을 시켜두면 짧게는 몇 분, 길게는 수십 분이 걸립니다. 복잡한 작업을 시작했는데 회의 때문에 노트북을 닫아야 하는 상황 등이 생겨도 세션을 detach해두면 작업은 계속 돌릴 수 있습니다. 나중에 다시 attach하면 대화 기록과 실행 중이던 프로세스, 진행 중이던 빌드까지 그대로 이어서 할 수 있습니다.
  2. 멀티 에이전트 기반의 워크플로가 보편화되고 있습니다. 예전에 tmux 화면 분할의 사용 예시는 "코드를 편집하면서 서버 로그도 같이 보고 싶다" 정도로 추측해볼 수 있지만, 이제는 tmux 화면 분할을 통해 멀티 에이전트 기반으로 이뤄지는 작업을 봐야 하는 케이스들이 많습니다. 예를 들면 한 에이전트는 인증 모듈을 리팩토링하고, 다른 에이전트는 API 엔드포인트를 작성하고, 또 다른 에이전트는 테스트를 돌릴 때, 이 모든 걸 한 화면에서 보려면 결국 화면 분할이 필요합니다.

 

 

 

 

요즘은 클로드 코드 등이 알아서 tmux를 쓰기도 하기 때문에, 좋든 싫든 tmux를 몰라도 한 번쯤은 tmux를 마주하게 되는 것 같습니다. 이 글을 읽는 분들도 이 글을 통해 tmux의 기본 원리에 대해 이해할 수 있었으면 좋겠습니다

 

 

 

 

 

 

 

 

 

 

 

이전 글들에서, JDBC 기반으로 직접 DB를 다루는 방법들을 살펴봤었습니다.

 

[JDBC] (1편) MyBatis, JPA 없이 JDBC로만 DB를 다루는 세상이었다면

[JDBC] (2편) JDBC 기반으로 직접 트랜잭션을 처리해보고, JDBC 스펙에서의 커넥션풀 알아보기

 

JDBC만으로 DB에서 데이터들을 가져와서 다루는 예시 코드를 다시 살펴보면 다음과 같습니다.

 

public List<Customer> getCustomers() {
   List<Customer> customers = new ArrayList<>();
   String selectSql = "SELECT CUST_ID, CUST_NAME, RES_ID, CUST_TYPE FROM TB_CUSTOMER LIMIT ?";

   try (Connection conn = JdbcSample.getConnectionByDriverManager();
        PreparedStatement pstmt = conn.prepareStatement(selectSql)) {

       pstmt.setInt(1, 10);

       try (ResultSet rs = pstmt.executeQuery()) {
           while (rs.next()) {
               Long custId = rs.getLong("CUST_ID");
               String custName = rs.getString("CUST_NAME");
               String resId = rs.getString("RES_ID");
               String custType = rs.getString("CUST_TYPE");

               customers.add(new Customer(custId, custName, resId, custType));
           }
       }

   } catch (SQLException e) {
       System.out.println("Database error: " + e.getMessage());
   }

   return customers;
}

 

보시다시피 이렇게 JDBC만으로 DB를 접근하게 되면,

 

  1. SQL 문자열을 작성하는 코드
  2. Connection 얻어오고, PreparedStatement 생성하고, 파라미터를 설정하는 코드
  3. ResultSet을 객체(Customer)로 매핑시키는 코드

 

를 매번 반복해서 작성해야 합니다. Connection, Statement, ResultSet 등을 close하는 것도 계속 신경써줘야 하기도 하구요. 이런 코드에서 중요한 것은 결국 SQL인데, 매번 부수적인 작업들도 직접 반복(보일러플레이트라고도 합니다)해서 해주는건 힘들 수밖에 없습니다. 또한 SQL문과 자바 코드가 강결합되어 있어 유지보수성이 안 좋아지는 문제도 생기게 됐습니다. 자연스럽게 SQL은 개발자가 작성하도록 냅두되 나머지 작업들을 자동화하여 보일러플레이트를 제거하자는 니즈가 생기게 됐고, SQL문을 자바 코드로부터 분리하여 보관하자는 니즈도 생기게 됐습니다.

 

이런 흐름 속에서 나온 기술이 SQL Mapper입니다. 이번 글에서는 SQL Mapper의 대표 주자인 MyBatis의 동작 원리를 살펴보며, 어떻게 이 문제들을 해결했는지 알아본 것을 공유하고자 합니다.

 

 

목차는 다음과 같습니다.

 

  1. Mybatis란
  2. Mybatis가 실행할 SQL들을 찾는 방법
  3. Mybatis가 SQL을 실행하는 방법 (1) - 메서드 호출이 PreparedStatement가 되기까지
  4. Mybatis가 SQL을 실행하는 방법 (2) - MyBatis는 파라미터를 어떻게 자동으로 바인딩하는가
  5. Mybatis가 SQL을 실행하는 방법 (3) - ResultSet이 객체가 되기까지
  6. 전체 흐름 정리
  7. Mybatis가 포기한 것

 

 

 

1. Mybatis란


Mybatis는 앞서 설명했듯 SQL mapper 기술의 일종으로, JDBC로 처리하던 상당 부분을 대신 해주는 프레임워크입니다. 핵심 컨셉은 다음과 같이 이해해볼 수 있습니다.

 

SQL문을 자바 코드에서 분리 보관한다. 

SQL문이 문자열로 자바 코드에서 함께 관리가 된다면, 관련 코드가 지저분해지고 DBA와의 협업도 어려워진다는(ex: 개발자와 DBA가 동일한 파일을 수정하게 되는 상황 등) 문제가 생길 수 있습니다. Mybatis는 SQL문을 자바 코드에서 분리해서 다음과 같이 xml파일에 보관하도록 합니다. 

<?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="org.example.TestMapper" >
    <select id="getCustomers" resultType="org.example.Customer" parameterType="int">
        SELECT CUST_ID, CUST_NAME, RES_ID, CUST_TYPE
        FROM TB_CUSTOMER
        LIMIT #{limit}
    </select>
</mapper>

 

이 글에서는 Mybatis의 사용법이 아닌 원리를 다루려고 하기 때문에, resultType 등이 무슨 문법인지 등에 대한 설명은 하지 않으려고 합니다.

 

SQL문들은 개발자가 직접 작성하게 하되, 나머지는 프레임워크가 처리한다.

Mybatis를 통해 DB를 접근하는 간단한 예제 코드를 봐보면 다음과 같습니다.

// (1) SqlSession 요청
try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
     // (2) 쿼리 실행 요청
     List<Customer> customers = sqlSession.selectList("org.example.TestMapper.getCustomers", 10);

     for (Customer customer : customers) {
          System.out.println(customer);
     }
}

 

기존 JDBC만로 DB에 접근하던 것과는 다르게, Connection을 획득하거나 Statement를 생성하는 등의 코드가 없어진 것을 볼 수 있습니다. 다만 중요한 점은 Mybatis는 JDBC를 대체하여 보일러플레이트들을 없애는 기술이 아니라, JDBC 위에서 동작하면서 개발자 대신 Connection 획득이나 파라미터 설정 등의 작업을 대신 해주고 있는 기술이라는 점입니다. 위 코드에 나오는 SqlSession이라는 컴포넌트가 그 작업들을 대신 한다고 이해할 수 있습니다.

 

즉,  Mybatis를 통해 DB를 접근할 때의 과정은 내부적으로 어떤 일들이 일어나는지는 아직까지는 모르나, 전체 흐름은 간단하게 다음처럼 도식화할 수 있습니다.

 

계속해서 Mybatis가 어떤 구성요소들을 어떻게 활용해 위 그림의 작업들을 개발자 대신 해주고 있는지 볼 텐데, 결국에는 이전에 살펴봤던 JDBC 스펙에서의 인터페이스들이 등장한다는 것을 미리 알아두면 흐름을 따라가는게 좀 더 편할 것 같습니다.

 

다시 한 번 이전에 쓴 글들을 공유합니다. 위에서 나온 PreparedStatement 등의 용어가 낯설다면 1편을 봐보시는 걸 추천드립니다.

[JDBC] (1편) MyBatis, JPA 없이 JDBC로만 DB를 다루는 세상이었다면

[JDBC] (2편) JDBC 기반으로 직접 트랜잭션을 처리해보고, JDBC 스펙에서의 커넥션풀 알아보기

 

 

 

2. Mybatis가 실행할 SQL들을 찾는 방법


애플리케이션이 특정 SQL의 실행을 요청하면, Mybatis는 해당 SQL을 찾아서 실행해주게 됩니다. Mybatis의 핵심 컨셉 중 하나는 SQL문을 코드에서 분리해서 보관하는 것이었고, 그로 인해 xml파일에 따로 SQL들을 다음과 같이 가지고 있습니다.

<?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="org.example.TestMapper" >
    <select id="getCustomers" resultType="org.example.Customer" parameterType="int">
        SELECT CUST_ID, CUST_NAME, RES_ID, CUST_TYPE
        FROM TB_CUSTOMER
        LIMIT #{limit}
    </select>
</mapper>

 

그러나 애플리케이션이 SQL 실행을 요청할 때마다 xml파일을 디스크에서 읽는다면, 매 요청마다 파일 I/O는 물론 xml을 파싱하는 비용 등이 들게 됩니다. 그래서 Mybatis는 애플리케이션이 시작될 때 xml파일을 딱 한 번 파싱하고, 그 결과를 메모리에 두는 방식을 사용하기로 했습니다. 다만 파싱한 결과로써 SQL문 하나만 가지고 있는 건 의미가 없습니다. 따라서

 

  1. 해당 SQL의 식별자(id값)이 뭔지
  2. 해당 SQL이 어떤 타입의 SQL인지(SELECT, INSERT 등)
  3. 해당 SQL에 어떤 파라미터들이 쓰이는지
  4. 해당 SQL의 결과를 어떤 DTO로 매핑해야 하는지

 

등 파싱하여 얻을 수 있는 다른 정보들도 가지고 있게 했고, Mybatis은 이 정보들을 MappedStatement라는 객체에 저장하게 했습니다. xml과 MappedStatement의 관계를 간단하게 도식화하면 다음과 같습니다.

보시다시피 xml에서 작성한 <insert>, <select>, <update>, <delete> 태그들 하나당 MappedStatemet라는 객체가 만들어져 메모리에 캐싱되게 됩니다.

 

그러면 자연스럽게 xml파일들을 파싱해서 MappedStatement를 생성하는 작업은 언제 어떻게 누가 하는지에 대한 의문이 생깁니다. Mybatis를 사용 시 Mybatis 설정 정보들을 mybatis-config.xml에 작성하고, SQL문들이 있는 xml들의 위치를 적어주는데요. Mybatis를 사용할 땐 이 정보들을 바탕으로 애플리케이션을 기동할 때 SqlSessionFactory라는 컴포넌트를 생성해야 하는데, 바로 이 과정에서 MappedStatement들이 생성됩니다.

 

 mybatis-config.xml이 아닌 다른 이름을 써도 됩니다.

 SQL문들이 있는 xml들을 mapper xml이라고도 부릅니다.

 MappedStatement는 Map<String, MappedStatement> 형태로 메모리에 캐싱되며, id값이 해당 SQL을 찾는 키값이 됩니다. 

 

 

SqlSessionFactory를 생성하는 예제 코드는 다음과 같습니다.

// mybatis-config.xml을 classpath에서 읽어서 사용
InputStream inputStream = Resources.getResourceAsStream("mybatis-config.xml");

// SqlSessionFactoryBuilder를 통해 sqlSessionFactory 생성 (이 과정에서 xml들을 파싱)
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

 

디버깅을 통해 실제 코드 흐름을 따라가보면 다음과 같이 mybatis-config.xml을 파싱한 뒤, 파싱 결과를 바탕으로 mapper xml들을 파싱하며 MappedStatement들을 생성하는 과정을 볼 수 있습니다.

// XMLConfigBuilder.class  ->  mybatis-config.xml을 파싱하는 역할
// 제가 임의로 간결하게 작성했습니다.

public Configuration parse() {
   this.parseConfiguration(this.parser.evalNode("/configuration"));
   return this.configuration;
}


// XMLMapperBuilder.class  ->  mapper xml을 파싱하는 역할
// 제가 임의로 간결하게 작성했습니다.

private void configurationElement(XNode context) {
    this.parameterMapElement(context.evalNodes("/mapper/parameterMap"));
    this.resultMapElements(context.evalNodes("/mapper/resultMap"));
    this.sqlElement(context.evalNodes("/mapper/sql"));
    this.buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
}


// MapperBuilderAssistant.class  ->  MappedStatement를 등록하는 역할
// 제가 임의로 간결하게 작성했습니다.

public MappedStatement addMappedStatement(String id, SqlSource sqlSource, ... 생략) {
    MappedStatement statement = statementBuilder.build();
    this.configuration.addMappedStatement(statement);
    return statement;
}

 

즉 SqlSessionFactory가 생성되면서 MappedStatement를 만드는 과정을 간단히 도식화하면 다음과 같습니다.

 

 

실제로 SqlSessionFactory가 생성될 때 갖고 있게 되는 Configuration 객체를 보면, 다음과 같이 MappedStatement와 관련 메서드를 가지고 있는 것을 확인할 수 있습니다.

// Configuration.class

public class Configuration {

    // ... 생략

    protected final Map<String, MappedStatement> mappedStatements;

    public void addMappedStatement(MappedStatement ms) {
        // ... 생략
    }

    public MappedStatement getMappedStatement(String id, boolean validateIncompleteStatements) {
        // ... 생략
    }

 

※ Configuration: MyBatis가 XML로부터 파싱한 모든 설정정보 등을 담고 있는 객체

 

 

3. Mybatis가 SQL을 실행하는 방법 (1) - 메서드 호출이 PreparedStatement가 되기까지


Mybatis를 통해 SQL을 실행하게 되면, 처음에 말씀드렸듯이 Connection을 획득하고 PrepatedStatement를 만든 뒤, 파라미터들을 설정하여 쿼리를 실행하고 ResultSet을 DTO로 변환하는 과정을 내부적으로 수행하게 됩니다. 앞서 살펴본 MappedStatement를 바탕으로 이 과정을 어떻게 하는건지 하나씩 살펴보려고 하며, 우선 Connection 획득과 PreparedStatement 생성을 어떻게 하는지부터 보도록 하겠습니다.

 

이를 위해, Mybatis가 사용하는 다음 구성 요소들을 알고 넘어가야 합니다.

 

SqlSession

  • 애플리케이션과의 인터페이스 역할을 하는 요소입니다
  • 애플리케이션으로부터 특정 SQL의 실행 요청을 받아, 내부적으론 Executor에게 실행을 위임하여 처리합니다

Executor

  • Mybatis의 실행 엔진 역할을 하는 요소입니다.
  • SqlSession에게 위임받은 작업을 처리하기 위해 StatementHandler를 생성하고 쿼리 실행 흐름을 조율합니다.
  • SqlSessionFactory가 SqlSession을 만들어줄 때마다 SqlSession당 하나씩 Executor의 구현체를 만들어줍니다
  • 이때 Configuration이 들고 있는 ExecutorType(SIMPLE, REUSE, BATCH)에 따라 서로 다른 구현체를 갖게 됩니다.

StatementHandler

  • JDBC Statement 계열의 객체를 준비하고, 파라미터를 설정과 실제 쿼리/업데이트를 실행하는 역할의 인터페이스입니다.
  • Executor가 쿼리 실행시점에 내부적으로 생성하여 사용합니다.

 

 

(1) MappedStatement에서 실제로 실행할 SQL 가져오기 (BoundSql)

BoundSql

  • MappedStatement가 가지고 있는 SQL문 정보로부터 동적콘텐츠들을 처리한 후 가져온 실제로 실행 예정인 "SQL 문자열"을 의미하는 객체입니다. (MappedStatement가 기본적으로 가지고 있는 SQL문은 동적콘텐츠(<if> 등..)을 가지고 있습니다)
  • 파라미터 매핑 정보(ParameterMappings)도 함께 가지고 있습니다.

 

MappedStatement가 SQL문의 정보를 가지고 있는 건 맞습니다만, xml파일에는 SQL문을 다음처럼 동적콘텐츠들을 활용해 작성할 수도 있습니다.

<select id="getCustomersByCustomerType" resultType="org.example.Customer" parameterType="map">
    SELECT CUST_ID, CUST_NAME, RES_ID, CUST_TYPE
    FROM TB_CUSTOMER
    <where>
        <if test="customerType != null">
            AND CUST_TYPE = #{customerType}
        </if>
    </where>
    LIMIT #{limit}
</select>

 

이 경우 실행시점에 실질적으로 어떤 SQL이 수행되는건지 결정되어야 합니다만, MappedStatement를 만드는 시점 즉 애플리케이션 구동 시점에 이를 결정하는 건 불가능합니다. 따라서 MappedStatement는 런타임에 파라미터 정보를 바탕으로 실질적으로 실행되어야 하는 SQL문을 BoundSql이란 객체로 만들어서 리턴하는 메서드를 제공해주고 있습니다.

 

실제로 애플리케이션이 쿼리 실행 요청을 보내면, MappedStatement를 활용해 다음처럼 BoundSql을 가져와서 쿼리의 실행에 사용합니다.

// (1) 애플리케이션에서 쿼리 실행 요청
List<Customer> customers = sqlSession.selectList("org.example.TestMapper.getCustomers", 10);


// (2) DefaultSqlSession.class - MappedStatement를 찾아서 실행 
// 제가 임의로 간결하게 작성했습니다
private <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds, ResultHandler handler) {
    MappedStatement ms = this.configuration.getMappedStatement(statement);
    this.executor.query(ms, this.wrapCollection(parameter), rowBounds, handler);
}


// (3) CachingExecutor.class - MappedStatement로부터 BoundSql 획득하여 실행
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    BoundSql boundSql = ms.getBoundSql(parameterObject);
    CacheKey key = this.createCacheKey(ms, parameterObject, rowBounds, boundSql);
    return this.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

 

참고로 CachingExecutor는 SimpleDecorator를 멤버변수로 갖고 있는 Executor로, 2차 캐싱 적용을 위한 일종의 데코레이터 패턴을 적용한 요소로 이해할 수 있습니다.

 

(2) Connection 획득 ~ PreparedStatement 생성

이후의 흐름을 살펴보면, 가져온 BoundSql을 활용하여 StatementHandler를 생성하여 쿼리를 실행하게 됩니다. 이 부분을 뜯어보면 다음과 같습니다.

// SimpleExecutor.class
// 제가 임의로 간결하게 작성했습니다.

public <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
    Configuration configuration = ms.getConfiguration();
    
    // (1) StatementHandler 생성
    StatementHandler handler = configuration.newStatementHandler(this.wrapper, ms, parameter, rowBounds, resultHandler, boundSql);

    // (2) Connection 획득 + PreparedStatement 생성 + 파라미터 설정
    Statement stmt = this.prepareStatement(handler, ms.getStatementLog());

    // (3) 쿼리 실행
    List var9 = handler.query(stmt, resultHandler);

    return var9;
}

private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException {
    // Connection 획득
    Connection connection = this.getConnection(statementLog);
    
    // PreparedStatement 생성
    Statement stmt = handler.prepare(connection, this.transaction.getTimeout());
    
    // 파라미터 설정 (이 부분은 다음 섹션에서 설명합니다)
    handler.parameterize(stmt);
    
    return stmt;
}

 

Connection을 획득하는 코드를 따라가보면, 다음처럼 JDBC에서 쓰던 DataSource.getConnection을 통해 Connection을 획득하는 것을 볼 수 있습니다.

// JDBCTransaction.class

protected void openConnection() throws SQLException {
    // ... 생략

    this.connection = this.dataSource.getConnection();
    
    // ... 생략
}

 

또한 PreparedStatment를 생성하는 코드를 따라가보면, BoundSql과 Connection을 활용해 PreparedStatement를 생성하는 것을 볼 수 있습니다.

// PreparedStatementHandler.class

protected Statement instantiateStatement(Connection connection) throws SQLException {
    String sql = this.boundSql.getSql();
    if (this.mappedStatement.getKeyGenerator() instanceof Jdbc3KeyGenerator) {
        String[] keyColumnNames = this.mappedStatement.getKeyColumns();
        return keyColumnNames == null ? connection.prepareStatement(sql, 1) : connection.prepareStatement(sql, keyColumnNames);
    } else {
        return this.mappedStatement.getResultSetType() == ResultSetType.DEFAULT ? connection.prepareStatement(sql) : connection.prepareStatement(sql, this.mappedStatement.getResultSetType().getValue(), 1007);
    }
}

 

 

 파라미터를 설정하는 부분은 설명할 내용들이 조금 있어, 바로 아래에 별도 챕터로 기재합니다

 

 

 

4. Mybatis가 SQL을 실행하는 방법 (2) - MyBatis는 파라미터를 어떻게 자동으로 바인딩하는가


ParameterHandler

  • StatementHandler가 멤버변수로 들고 있는 필드입니다.
  • PreparedStatement의 파라미터를 설정하는 역할의 인터페이스입니다.

 

Executor가 StatementHandler의 parameterize 메서드를 호출하면 StatementHandler는 내부적으로 ParameterHandler에게 파라미터 바인딩를 위임합니다. ParameterHandler가 어떻게 바인딩하는지 살펴보려면 앞서 xml파일들을 스캔하며 MappedStatement들을 만들 때 해당 SQL의 파라미터 정보들을 어떻게 들고 있는지 봐야 합니다. 

 

보시는 것처럼 SQL의 파라미터 부분(#{...}을 말합니다)들을 객체로 만들어서 MappedStatement에 담게 되는데요, 이 객체들을 ParameterMapping이라고 부릅니다. 이 객체는 여러 속성이 있겠으나 주요 속성들을 설명드리면 다음과 같습니다.

 

  • property : 해당 파라미터의 이름을 의미합니다.
  • javaType : 해당 파라미터의 java 클래스 타입을 의미합니다. 기본적으론 Object이나, parameterType으로 준 클래스에 해당 필드의 getter가 있다면 해당 getter의 리턴 타입으로 설정됩니다. 또한 SQL에서 파라미터를 작성할 때 #{customerType, javaType=string} 처럼 명시적으로 적어줄 수도 있습니다.
  • jdbcType : JDBC는 자바 기반 프로그램이 어떤 DB와 붙어도 컬럼 타입들을 공통으로 가리킬 수 있는 표준 상수들을 java.sql.type에 정의하고 있는데요. Mybatis는 편한 사용을 위해 이 상수들을 감싸서 객체화했는데 이를 JDBC Type이라고 부릅니다. 즉 DB에서의 컬럼타입을 의미한다고 이해할 수 있습니다. ParameterMapping에서 이 속성은 기본적으론 null이나, SQL에서 파라미터를 작성할 때 #{customerType, jdbcType=VARCHAR} 처럼 적어주면 해당 값으로 세팅됩니다. 참고로 ParameterMapping에서의 jdbcType은 파라미터로 null값을 세팅하게 되는 경우 등에 활용됩니다
  • typeHandler : PreparedStatement에 파라미터 세팅을 하려면 파라미터 타입에 따라 setInt, setString 등 어떤 메서드들을 호출해야 하는지 결정해줘야 하는데요, 그 역할을 TypeHandler가 해줍니다. 즉 PreparedStatement에 파라미터를 바인딩할 때, java 객체를 적절한 JDBC Type으로 변환해주는 역할로 이해할 수 있습니다. PameterMapping 생성 시 javaType속성값과 jdbcType속성값에 따라 Mybatis가 만들어둔 typeHandler들 중 하나가 선택되며, 개발자가 직접 커스텀한 typeHandler를 사용할 수도 있습니다.

 

즉 사용자가 쿼리 실행을 요청하게 되면, ParameterHandler가 각 ParameterMapping의 정보들을 활용해 사용자가 넘겨준 객체로부터 적절한 값들을 뽑아내서 PreparedStatement에 파라미터를 바인딩하게 됩니다. 사용자가 넘겨준 객체(자바빈 객체 또는 Map 등)에서 적절한 값을 빼오는 것은 리플렉션 등으로 구현되어 있으며, 파라미터를 바인딩하는 흐름을 코드로 살펴보면 다음과 같습니다.

// DefaultParameterHandler.class
// 제가 임의로 메서드를 간결화하여 작성했습니다

public void setParameters(PreparedStatement ps) {
    List<ParameterMapping> parameterMappings = this.boundSql.getParameterMappings();
    MetaObject metaObject = this.configuration.newMetaObject(this.parameterObject);

    for (int i = 0; i < parameterMappings.size(); ++i) {
        ParameterMapping parameterMapping = (ParameterMapping) parameterMappings.get(i);

        String propertyName = parameterMapping.getProperty();
        // 파라미터로 넣을 값. 리플렉션 등을 활용해 가져옴
        Object value = metaObject.getValue(propertyName);

        TypeHandler typeHandler = parameterMapping.getTypeHandler();
        JdbcType jdbcType = parameterMapping.getJdbcType();

        typeHandler.setParameter(ps, i + 1, value, jdbcType);
    }
}

 

위 코드의 마지막 부분을 좀 더 따라가면, TypeHandler의 종류에 따라 최종적으로 PreparedStatement에 다음처럼 파라미터를 세팅하는 모습을 볼 수 있습니다. 예시 코드는 TypeHandler가 StringTypeHandler인 경우입니다.

// StringTypeHandler.class

public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
    ps.setString(i, parameter);
}

 

 

 

5. Mybatis가 SQL을 실행하는 방법 (3) - ResultSet이 객체가 되기까지


ResultSetHandler

  • StatementHandler가 멤버변수로 들고 있는 필드입니다.
  • ResultSet를 읽어서 resultType, resultMap 기준으로 자바 객체로 변환하는 역할의 인터페이스입니다.

 

ResultSet을 DTO로 매핑하는 것은 ResultSetHandler라는 컴포넌트가 담당하며, 어떻게 매핑하는지 살펴보려면 앞서 xml파일들을 스캔하며 MappedStatement들을 만들 때 해당 SQL의 ResultSet 매핑 정보를 어떻게 들고 있는지 봐야 합니다. 

 

보시는 것처럼 resultMap 태그에서 작성해준 정보들을 객체로 만들어서 MappedStatement에 담게 되는데요, 이 객체들을 ResultMapping이라고 부릅니다. 말 그대로 DB에서 가져온 데이터를 객체에 어떻게 매핑해줄지(어떤 컬럼을 어떤 필드에 매핑할지)를 정의하는 객체입니다. 여러 속성들이 있으나 주요 속성들을 설명드리면 다음과 같습니다. (ParameterMapping에서의 속성들과 매우 유사합니다)

 

  • property : 해당 컬럼값이 매핑될 객체의 필드명을 의미합니다.
  • column : DB에서의 컬럼명을 의미합니다.
  • javaType : 해당 컬럼값이 어떤 java 클래스 타입으로 매핑될지를 의미합니다. DTO에서 해당 필드의 setter가 받는 파라미터 타입으로 설정되며 기본적으론 Object입니다. result 태그 등에서 <result property="customerName", javaType="String" ... /> 처럼 명시적으로 적어줄 수도 있습니다.
  • jdbcType : ParameterMapping에서와 마찬가지로 DB에서의 컬럼타입을 의미한다고 이해할 수 있습니다. 기본적으론 null이나, <result property="customerName", jdbcType="VARCHAR" ... /> 처럼 명시적으로 적어줄 수도 있습니다. 참고로 ResultMapping에서의 jdbcType은 typeHandler 결정 시에 사용됩니다.
  • typeHandler : ResultSet을 DTO에 매핑하려면 DB에서 가져온 컬럼의 타입에 따라 getInt, getString 등 어떤 메서드들을 호출해야 하는지 결정해줘야 하는데요, 그 역할을 TypeHandler가 해줍니다. ParameterMapping에서의 TypeHandler의 역할과 반대인 것으로 이해할 수 있습니다. ResultMapping 생성 시 javaType값과 jdbcType값에 따라 Mybatis가 만들어둔 typeHandler들 중 하나가 선택됩니다.

 

즉 사용자가 요청한 쿼리가 실행되고 나면, ResultSetHandler가 각 ResultMapping의 정보들을 활용해 ResultSet으로부터  적절한 값들을 뽑아내서 DTO 객체의 필드들에 매핑하게 됩니다.

// DefaultResultSetHandler.class
// 제가 임의로 메서드를 간결화하여 작성했습니다

private Object getRowValue(ResultSetWrapper rsw, ResultMap resultMap, String columnPrefix) throws SQLException {
    // DTO 객체 생성
    Object rowValue = this.createResultObject(rsw, resultMap, lazyLoader, columnPrefix);
    
    MetaObject metaObject = this.configuration.newMetaObject(rowValue);
    // ResultMappings 순회하면서 DTO에 setter로 값 세팅
    this.applyPropertyMappings(rsw, resultMap, metaObject, lazyLoader, columnPrefix);

    return rowValue;
}

 

applyPropertyMappings 메서드 흐름을 좀 더 따라가보면, ResultMapping의 TypeHandler를 통해 ResultSet으로부터 적절한 getter를 사용하여 컬럼값을 빼내고, DTO의 setter를 호출하여 값을 세팅하는 모습을 볼 수 있습니다.

// DefaultResultSetHandler.class
// 제가 임의로 메서드를 간결화하여 작성했습니다

private boolean applyPropertyMappings(ResultSetWrapper rsw, ResultMap resultMap, MetaObject metaObject, ResultLoaderMap lazyLoader, String columnPrefix) throws SQLException {
    boolean foundValues = false;

    for (ResultMapping propertyMapping : resultMap.getPropertyResultMappings()) {
        // ResultMapping 정보 활용해 ResultSet으로부터 컬럼값 추출
        Object value = this.getPropertyMappingValue(rsw.getResultSet(), metaObject, propertyMapping, lazyLoader, columnPrefix);
        String property = propertyMapping.getProperty();

        if (value != null) foundValues = true;
        // DTO 객체의 setter를 호출하여 값 세팅
        if (property != null && value != null) {
            metaObject.setValue(property, value);
        }
    }

    return foundValues;
}

private Object getPropertyMappingValue(ResultSet rs, MetaObject metaResultObject, ResultMapping propertyMapping, ResultLoaderMap lazyLoader, String columnPrefix) throws SQLException {
    TypeHandler<?> typeHandler = propertyMapping.getTypeHandler();
    String column = this.prependPrefix(propertyMapping.getColumn(), columnPrefix);
    // ResultMapping의 타입핸들러를 꺼내서 ResultSet으로부터 컬럼값 추출
    return typeHandler.getResult(rs, column);
}


// StringTypeHandler.class

public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
    return rs.getString(columnName);
}

 

 

 

 

6. 전체 흐름 정리


Mybatis를 통해 DB를 접근할 때의 과정은 간단하게 다음처럼 도식화할 수 있다고 말씀드렸습니다.

 

이제 Mybatis의 동작 원리를 살펴봤으니, 위 그림을 좀 더 세분화해서 다음처럼 나타낼 수 있습니다. 

 

 

 

 

7. Mybatis가 포기한 것


지금까지 Mybatis가 기존에 JDBC로 DB에 접근할 때 발생하던 보일러 플레이트들을 어떻게 해결하는지 살펴봤습니다. 하지만 모든 기술이 그러하듯 장점만 있을 수는 없는데요, Mybatis도 포기한 문제(내지는 해결하지 못한 문제)가 존재합니다.

 

앞서 설명했듯 Mybatis의 핵심 컨셉은 "SQL문들은 개발자가 직접 작성하게 하되, 나머지는 프레임워크가 처리한다"입니다. 이 말은 개발자가 관계형 DB의 방식으로 사고해야 한다는 한 가지 전제를 포함하는 의미이기도 합니다. 데이터를 테이블로 보고, 관계를 JOIN으로 표현하고, 결과를 행과 컬럼으로 다뤄야 한다는 뜻입니다. 이는 Java라는 언어가 갖는 객체 지향적 관점과는 다른 패러다임을 갖는 것이기 때문에, 개발자가 두 언어의 차이점을 중간에서 메꿔주는 데 시간을 많이 쏟게 되는 문제가 남게 됩니다. 예를 들면 코드베이스가 커질수록 단순한 CRUD도 모든 엔티티마다 추가해줘야 하고, 연관관계가 복잡해질수록 resultMap도 복잡해지고, DB 스키마가 바뀌면 SQL도 직접 바꿔줘야 하는 것 등이 있습니다.

 

그래서 "SQL도 개발자가 직접 쓰지 않게 하는 건 어떨까?" 라는 생각이 나오게 됐고, 후에 객체와 객체 간의 관계만 정의하면, SQL 생성부터 연관 객체 로딩까지 프레임워크가 처리하는 ORM 기술들이 나오게 됩니다.

 

정리하면 MyBatis는 "JDBC를 통한 DB 직접 접근"의 보일러 플레이트를 제거하는 데 성공했지만, 여전히 SQL 중심으로 사고를 요구하게 됩니다. 복잡한 집계나 추출 등 SQL을 직접 제어해야 하는 요구사항이 있는 시스템들이 존재하므로, "SQL 중심의 사고를 요구"한다는 것은 단점이 아닌 패러다임으로 봐야 합니다. 다만 이는 객체 중심 사고와의 간극이 존재하는 것이므로, SQL 중심의 사고 및 제어가 필요하면 Mybatis같은 SQL Mapper를, 객체 중심의 사고가 필요하다면 ORM을 선택하는 것이 필요합니다.

 

 

 

 

레퍼런스


https://mybatis.org/mybatis-3/

https://mybatis.org/mybatis-3/apidocs/index.html

 

 

 

 

 

 

 

 

 

 

 

 

지난 글에서는 MyBatis나 JPA없이 순수 JDBC만을 활용해 Connection, Statement, ResultSet 등을 사용하면서 DB에 접근하는 방법을 다뤘었습니다. 

 

지난 글 : https://jofestudio.tistory.com/153

 

애플리케이션을 개발하면서 @Transactional 어노테이션을 통해 트랜잭션을 편리하게 사용하기도 하고, 커넥션풀들도 이미 사용 중이기도 한데요. JDBC만으로는 트랜잭션을 어떻게 다루고, 또 JDBC 스펙에서는 커넥션풀을 어떻게 다루도록 설계했는지 그 원리가 궁금하여 이번에 공부를 했고, 그 내용을 공유하고자 합니다. 

 

목차는 다음과 같습니다.

 

  1. JDBC로 트랜잭션 직접 다루기
    1. Auto Commit 모드 끄기
    2. rollback 메서드를 명시적으로 쓰지 않으면 어떻게 될까?
    3. SavePoint 인터페이스
    4. JDBC와 트랜잭션 격리 수준
  2. 커넥션 풀
    1. ConnectionPoolDataSource
    2. PooledConnection
    3. ⭐️ 하지만 실제로 이 설계들을 항상 지키진 않는다.
    4. 커넥션 풀 관련 속성

 

 

 

1. JDBC로 트랜잭션 직접 다루기


1 - 1. Auto Commit 모드 끄기

JDBC는 Connection 객체가 만들어지면, Auto Commit 모드가 켜진 채로 만들어지는 것을 디폴트로 하고 있습니다. 이는 각 SQL문들이 서로 독립적인 트랜잭션으로 실행되며 실행된 후에 곧바로 커밋된다는 것을 의미하는데요, 여러 SQL문들을 하나의 트랜잭션으로 처리하고 싶으면 Auto Commit 모드를 다음과 같이 직접 꺼줘야 합니다.

 

conn.setAutoCommit(false);

 

이렇게 하고 나면, 직접 명시적으로 Connection 객체의 commit 메서드를 호출해야만 트랜잭션이 커밋됩니다. 예외가 발생한 경우, rollback 메서드를 명시적으로 호출할 것을 권고하고 있습니다. 예시는 다음과 같습니다. (제가 토이 프로젝트로 하고 있는 프로젝트의 테이블을 사용해습니다.)

 

public void insertNewProduct() {
   String insertProductSql = "INSERT INTO TB_PRODUCT (PROD_CODE, PROD_NAME, PROD_TYPE) values (?, ?, ?)";
   String insertProductHistorySql = "INSERT INTO TB_PRODUCT_HISTORY " +
           "(PROD_CODE, PROD_NAME, PROD_TYPE, PROCESSED_TYPE) VALUES (?, ?, ?, ?)";

   try (Connection conn = JdbcSample.getConnectionByDriverManager()) {
       conn.setAutoCommit(false); // 오토 커밋 꺼주기

       try (PreparedStatement insertProductPstmt = conn.prepareStatement(insertProductSql);
            PreparedStatement insertProductHistoryPstmt = conn.prepareStatement(insertProductHistorySql)) {

           insertProductPstmt.setString(1, "600");
           insertProductPstmt.setString(2, "테스트입출금통장");
           insertProductPstmt.setString(3, "10");
           insertProductPstmt.executeUpdate();

           insertProductHistoryPstmt.setString(1, "600");
           insertProductHistoryPstmt.setString(2, "테스트입출금통장");
           insertProductHistoryPstmt.setString(3, "10");
           insertProductHistoryPstmt.setString(4, "I");
           insertProductHistoryPstmt.executeUpdate();

           conn.commit(); // 커밋 메서드 명시

       } catch (SQLException e) {
           conn.rollback();
           throw e;
       }

   } catch (SQLException e) {
       System.out.println("Database error: " + e.getMessage());
   }
}

 

참고) MySQL JDBC Driver 기준, Connection 생성 시 SET autocommit=1이 DB로 날아가고, Auto Commit 모드를 끄면 SET autocommit=0이 DB로 날아갑니다 .

 

 

1 - 2. rollback 메서드를 명시적으로 쓰지 않으면 어떻게 될까?

예외가 발생하면 rollback 메서드를 호출해야 한다고 하지만, 그럼 만약 예외 발생 시 rollback 메서드를 명시적으로 사용하지 않으면 롤백이 안 되는 걸까요? 뜯어보니 MySQL JDBC Driver가 제공하는 Connection 구현체 중 ConnectionImpl은 close될 때 Auto Commit 모드를 꺼둔 상태라면 롤백을 해주도록 되어있었습니다.

 

MySQL JDBC Driver 內 ConnectionImpl의 doClose 메서드 내부

 

따라서 Auto Commit 모드를 끈 상태라면 좌우지간 개발자가 명시적으로 commit 메서드를 호출하지 않아도 롤백이 자동으로 되고, 예외가 터졌을 때도 명시적으로 rollback 메서드를 호출하지 않아도 롤백이 자동으로 되긴 합니다. 심지어 commit 메서드를 수행한 뒤에도 close될 때 ROLLBACK이 DB로 날라가는 모습도 확인했었습니다. 그러면 굳이 rollback 메서드를 호출하지 않아도 되는 것 아니냐는 생각을 했습니다만, DataSource 구현체를 어떤 것을 사용하는지에 따라 사용하는 Connection 구현체가 바뀌는 등 드라이버의 처리에 의존하는 것보다는 개발자가 명시적으로 rollback 메서드를 호출하는 것이 안정성 있는 시스템을 만들 수 있다는 생각이 듭니다.

 

 

1 - 3. SavePoint 인터페이스

JDBC API는 SavePoint라는 인터페이스를 통해 rollback 메서드 수행 시 특정 지점으로 되돌아가는 기능도 제공합니다. 트랜잭션 전체를 롤백하지 않고, Savepoint가 설정된 곳 이후의 작업만 롤백하는 형태로 이해할 수 있습니다. Connection 인터페이스의 다음 메서드들을 통해 SavePoint들을 다룰 수 있습니다

 

  1. setSavePoint() : 현재 트랜잭션에 익명의 SavePoint를 만들어 리턴
  2. setSavePoint(String) : 현재 트랜잭션에 이름이 있는 SavePoint를 만들어 리턴
  3. rollback(SavePoint) : SavePoint가 set된 곳 이후의 작업들을 undo (Auto commit이 꺼져있을 때 사용되어야 합니다)
  4. releaseSavePoint(SavePoint) : 현재 트랜잭션에서 해당 SavePoint를 포함해 이후에 설정된 SavePoint들을 전부 해제 (한 트랜잭션이 과도하게 많은 SavePoint를 set하면 그만큼 DB 리소스를 많이 쓰는 거라, 이 메서드를 통해 SavePoint들을 중간중간 제거하여 리소스를 해제하는 역할을 할 수 있습니다)

 

예제는 다음과 같습니다. 아까 보여드린 코드를 재활용했습니다.

 

public void insertNewProduct() {
   String insertProductSql = "INSERT INTO TB_PRODUCT (PROD_CODE, PROD_NAME, PROD_TYPE) values (?, ?, ?)";
   String insertProductHistorySql = "INSERT INTO TB_PRODUCT_HISTORY " +
           "(PROD_CODE, PROD_NAME, PROD_TYPE, PROCESSED_TYPE) VALUES (?, ?, ?, ?)";

   try (Connection conn = JdbcSample.getConnectionByDataSource()) {
       conn.setAutoCommit(false);

       Savepoint savepoint = conn.setSavepoint("BeforeInsert");

       try (PreparedStatement insertProductPstmt = conn.prepareStatement(insertProductSql);
            PreparedStatement insertProductHistoryPstmt = conn.prepareStatement(insertProductHistorySql)) {

           insertProductPstmt.setString(1, "600");
           insertProductPstmt.setString(2, "테스트입출금통장");
           insertProductPstmt.setString(3, "10");
           insertProductPstmt.executeUpdate();

           savepoint = conn.setSavepoint("AfterProductInsert");

           insertProductHistoryPstmt.setString(1, "600");
           insertProductHistoryPstmt.setString(2, "테스트입출금통장");
           insertProductHistoryPstmt.setString(3, "10");
           insertProductHistoryPstmt.setString(4, "I");
           insertProductHistoryPstmt.executeUpdate();
           
           throw new SQLException("고의로 에러 발생");

       } catch (SQLException e) {
           conn.rollback(savepoint);
           throw e;
       }

   } catch (SQLException e) {
       System.out.println("Database error: " + e.getMessage());
   }
}

 

MySQL에서는 다음처럼 날아갑니다.

 

아래에서 위로 보면 됩니다

 

근데 마지막에 롤백이 실행된 것을 볼 수 있는데요, 이는 앞서 말한 것처럼 MySQL JDBC Driver는 Connection의 close가 호출됐을 때 Auto Commit 모드가 꺼져 있으면 롤백이 수행되게끔 구현되어있기 때문입니다. 그렇다면 "어차피 트랜잭션을 닫을 때 전체가 롤백되는 거라면 SavePoint를 사용한 부분 롤백은 아무 의미 없는 거 아닌가?" 라는 생각이 들었는데요. 사실 이것은 SavePoint의 사용 패턴이 트랜잭션을 부분 롤백한 채로 끝내는 데 목적이 있는 게 아니라 부분 롤백 후 트랜잭션을 계속 진행 하는 데 목적이 있기 때문입니다. 예를 들자면 배치 작업 등에서 일부 실패를 허용할 때 등이 있겠고, 이런 상황을 코드로 간략하게 보면 다음과 같습니다.

 

conn.setAutoCommit(false);

작업A();
Savepoint sp = conn.setSavepoint("sp1");

try {
    작업B(); // 실패 가능성 있는 작업
} catch (SQLException e) {
    con.rollback(sp); // sp1까지만 롤백, 트랜잭션을 여기서 닫지 않음!
}

작업C(); // 계속 진행

conn.commit(); // A와 C가 반영됨 ← 이걸 해야 의미가 있음

 

참고) 스프링의 트랜잭션 전파 옵션이 내부적으로 SavePoint를 활용한다고 하는데요, 곧 스프링 트랜잭션에 대해서도 공부를 하며 이 부분을 좀 더 파볼려고 합니다.

 

 

1 -  4. JDBC와 트랜잭션 격리 수준

JDBC API는 다음과 같은 5가지의 격리 수준을 지원합니다.

 

  1. TRANSACTION_NONE (0)
  2. TRANSACTION_READ_UNCOMMITTED (1)
  3. TRANSACTION_READ_COMMITTED (2)
  4. TRANSACTION_REPEATABLE_READ (4)
  5. TRANSACTION_SERIALIZABLE (8)

 

Connection의 setTransactionIsolation 메서드를 호출하여 특정한 격리수준을 현재 세션에 설정할 수 있으며, 현재 세션의 격리 수준은 getTransactionIsolation메서드로 조회할 수 있습니다. MySQL JDBC Driver는 setTransactionIsolation 메서드 호출 시 DB로 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED 등이 날라갑니다. 참고로 벤더사들이 구현한 JDBC Driver 별로 특정 격리수준에 대한 setting이 드라이버 레벨에서 불가능하게 만들어져있다든지, 아니면 DB별로 특정 트랜잭션 격리 수준으로 SET SESSION하는게 안 될 수도 있습니다. (예를 들면, MySQL에서는 TRANSACTION_NONE을 설정할 수 없습니다)

 

참고) 각 격리 수준의 의미와 발생 가능한 문제들을 다루는 것은 JDBC 범위를 넘어간다고 생각하지 않아 따로 이 글에선 작성하지 않으려고 하는데요, 혹시 궁금하신 분들은 제가 예전에 쓴 글을 보시면 좋을 것 같습니다 :)

https://jofestudio.tistory.com/145

 

 

2. 커넥션 풀


"[JDBC] (1편) MyBatis, JPA 없이 JDBC로만 DB를 다루는 세상이었다면"에서 JDBC를 사용하는 애플리케이션은 DriverManager 또는 DataSource로부터 Connection을 얻는다고 소개했었는데요. 커넥션 풀링은 JDBC API에서는 DataSource에서 지원되는 기능으로 설계됐습니다. 즉 DataSource는 커넥션 풀링을 지원하냐 안 하냐에 따라 구분할 수 있습니다. 풀링을 지원하지 않는 DataSource는 getConnection을 통해 얻은 Connection이 DB와의 물리적인 연결을 의미하는 요소이고, close할 때는 실제로 물리적인 연결이 끊어지게 됩니다. 하지만 JDBC API는 풀링을 지원하는 DataSource는 내부 동작이 조금 달라지도록 설계했는데, 이를 이해하려면 JDBC API가 제공하는 다음 두 인터페이스에 대한 이해가 필요합니다.

 

2 - 1. ConnectionPoolDataSource

ConnectionPoolDataSource는 DataSource 인터페이스가 Connection타입의 객체를 반환하는 getConnection 메서드를 가졌던 것과는 다르게 , PooledConnection 타입의 객체를 만들어 반환하는 getPooledConnection이라는 메서드를 갖고 있는 인터페이스입니다. 즉 PooledConnection들의 팩토리 역할을 하는 객체로 볼 수 있습니다.

 

2 - 2. PooledConnection

DB와 맺는 실제 물리적인 연결을 래핑한 객체로, 커넥션풀이 저장되는 단위가 되는 커넥션이 PooledConnection입니다. 다시 말하면 커넥션풀을 통해 재사용되는 커넥션이 PooledConnection이라는 이야기입니다. 이 인터페이스는 getConnection이라는 메서드를 가지며, 이 메서드가 Connection 타입의 객체(DataSource의 getConnection을 하면 받는 그 타입)을 리턴합니다. 즉 PooledConnection과 Connection은 서로 다른 객체입니다.

 

 

아직까진 이해가 조금 힘들 수 있는데요, (왜냐하면 제가 그랬으니까..) 간략히 말하자면 커넥션 풀링을 지원하는 DataSource는 두 인터페이스들을 사용해서 다음처럼 동작합니다.

 

  1. ConnectionPoolDataSource를 통해 DB와의 물리적 연결을 의미하는 PooledConnection들을 만들어 풀에 저장해둠
  2. JDBC 애플리케이션이 DataSource의 getConnection을 통해 커넥션 요청 (= DataSource API를 통해 커넥션 요청)
  3. 커넥션 풀 매니저(= DataSource의 구현체)는 커넥션풀에 있는 PooledConnection을 하나 골라, getConnection을 통해 JDBC Application이 사용할 수 있는 Connection을 만들어 반환

 

그림으로 도식화하면 다음과 같습니다.

 

JSR-221에서 캡처했습니다

 

즉 커넥션 풀링을 지원하는 DataSource를 사용하는 경우, JDBC 애플리케이션에게 반환한 Connection은 논리적인 의미의 커넥션일 뿐 DB와의 실제 물리적 연결을 의미하진 않는 거고, ConnectionPoolDataSource가 만든 PooledConnection이 DB와의 실제 물리적 연결을 의미하는 셈입니다.

 

이로 인해 PooledConnection의 getConnection을 통해 얻은 Connection의 close 메서드가 호출될 때는 조금 다르게 동작하게 되는데요. 바로 물리적인 연결을 끊는 것이 아니라, 해당 Connection을 비활성화하고 연관된 PooledConnection을 다시 풀에 반납하는 식으로 동작하게 됩니다. 즉, 풀링을 지원하지 않는 DataSource든 풀링을 지원하는 DataSource든 똑같이 getConnection을 통해  Connection을 획득해서 사용할 수 있으나, 엄연히 말하면 이 둘은 타입은 같지만 서로 다른 구현체를 쓰는 것임을 알 수 있습니다. 또한 뻔하긴 합니다만 PooledConnection은 생애주기동안 여러 Connection을 만들게 됨도 알 수 있습니다.

 

참고 1) PooledConnection가 기존에 반환한 Connection이 활성 상태일 때 getConnection을 호출하면, 기존에 활성 상태로 있던 Connection이 비활성화됩니다. 이를 통해 강제로 클라이언트가 사용하는 커넥션을 끊을 수 있습니다.

참고 2) PooledConnection의 close를 호출해야 실제 DB와의 물리적 연결을 끊게 됩니다.

 

조금 더 세부적으로 파보면, PooledConnection에서 Connection을 만들어 줄 때 ConnectionEventListener라는 JDBC에서 제공하는 또다른 인터페이스의 구현체를 PooledConnection에 등록하는 과정이 있습니다. 이 인터페이스는 connectionClosed 메서드와 connectionErrorOccured라는 메서드를 가지는데요, PoolecConnection이 반환한 Connection이 close될 때 이 인터페이스의 connectionClosed 메서드를 통해 PooledConnection가 풀로 반환되게 됩니다. 

 

 

⭐️ 2 - 3. 하지만 실제로 이 설계들을 항상 지키진 않는다.

방금 본 것처럼, JDBC는 ConnectionPoolDataSource, PooledConnection을 사용해서 커넥션 풀링을 사용하도록 설계했습니다. 하지만 실제 커넥션풀 라이브러리로 사용되는 HikariCP나 Apache Commons DBCP 등은 이 표준을 지키지 않고 구현한 경우가 많습니다. DataSource에서 getConnection을 해서 Connection을 얻는 건 똑같고 커넥션 풀링이 지원되는 건 똑같지만, 내부적으로는 ConnectionPoolDataSource나 PooledConnection을 쓰지 않고 본인들만의 방식으로 풀링을 구현했습니다.

 

왜 그랬을까요? 표준은 지켜야 하는게 아니었을까요!

 

사실 HikariCP나 Apache Commons DBCP 개발자가 아닌 이상 그 이유를 우리가 100% 명확히 알 수는 없습니다만, 그래도 깃허브 이슈나 남아있는 문서 등을 통해 그 분들이 왜 표준을 지키지 않고 독자적인 방식으로 구현하는 방향으로 기술적인 의사결정을 했는지 유추해볼 수 있습니다.

 

깃허브 HikariCP 레포지토리에서 다음 위키문서와 이슈를 찾아볼 수 있었습니다.

 

https://github.com/brettwooldridge/HikariCP/wiki/Down-the-Rabbit-Hole

https://github.com/brettwooldridge/HikariCP/issues/54

 

 

특히나 이슈의 내용이 흥미로운데요! 요약하면 다음과 같습니다.

 

  • dimzon(이슈 작성자) : HikariCP가 ConnectionPoolDataSource를 지원하도록 구조를 개선해보는게 어때?
  • brettwooldridge : PooledConnection이 좋은 의도로 만들어진 인터페이스긴 하지만 풀링에 필요한 풍부한 기능은 없어. 예컨대 HikariCP는 자체적으로 Connection의 프록시를 제공하는데, 얘는 setTransactionIsolationLevel 등이 호출되면 가로채서 상태 변화도 추적한 뒤, 커넥션이 반납될 때 상태 변화가 없었다면 리셋하지 않아. 또 Postgre JDBC Driver의 ConnectionPoolDataSource 구현을 확인해보니, PooledConnection이 예상치 않게 동작해. JDBC Driver의 ConnectionPoolDataSource 구현이 우리가 제공하는 고성능 풀링을 위한 설정들이 제대로 적용되어있어야 할텐데, 그렇지가 않아..

 

커넥션풀 라이브러리를 구현하는 입장에서, JDBC 스펙대로 ConnectionPoolDataSource와 PooledConnection을 사용해서 풀링을 구현하려면 벤더사들이 제공하는 JDBC Driver이 만드는 구현체들에 의존할 수밖에 없는데, 그렇게 되면 어떤 드라이버를 쓰느냐에 따라 성능이 뒤죽박죽이 될 수도 있고 예상치 못한 동작이 가능할 수도 있습니다. 따라서 스스로 독자적으로 풀링을 구축하는 것이 더 안전하고 빠르다. 라고 생각한 듯 합니다.

 

하지만 해당 이슈에 24년도에도 ConnectionPoolDataSource 지원을 제고하자고 말한 내용이 있는 걸 보니.. 역시 개발과 기술에 정답은 없는 것 같습니다. 항상 트레이드 오프가 있고, 어떤 선택을 어떤 상황에서 어떤 근거로 했는지가 중요한 것 같네요.

 

 

2 - 4. 커넥션 풀 관련 속성

JDBC API는 커넥션 풀에 관련하여 다음 속성들을 정의하고 있습니다.

 

  1. maxStatements : 풀이 캐싱하는 Statement의 수
  2. initialPoolSize : 풀이 처음 만들어질 때 가질 커넥션 수
  3. minPoolSize : 풀이 항상 '사용 가능'해야 하는 최소 커넥션의 수. 0개면 필요할 때마다 만들겠다는 의미
  4. maxPoolSize : 풀이 가질 수 있는 최대 커넥션 수, 0이면 제한없다는 의미
  5. maxIdleTime : 커넥션이 닫히기 전, 물리적 연결이 사용되지 않는 채로 남아있어야 하는 시간(초)
  6. propertyCycle : 커넥션풀 설정을 반영하기 위한 간격(초)

 

커넥션풀 공급업체는 스스로만의 속성들을 사용 가능하나, 그러면 위 이름이 아닌 다른 속성들을 사용해야 합니다.

 

 

레퍼런스

JSR-221 (https://download.oracle.com/otndocs/jcp/jdbc-4_3-mrel3-spec/)

https://docs.oracle.com/javase/tutorial/jdbc/basics/transactions.html

https://docs.oracle.com/javase/tutorial/jdbc/basics/sqldatasources.html

https://docs.oracle.com/javase/8/docs/api/javax/sql/ConnectionPoolDataSource.html

https://docs.oracle.com/javase/8/docs/api/javax/sql/PooledConnection.html

https://docs.oracle.com/javase/8/docs/api/javax/sql/DataSource.html

 

 

 

 

자바는 DB를 다룰 때 각 DB별로 API를 만들지 않고 어떤 DB든 간에 JDBC라는 표준을 통해 접근할 수 있도록 인터페이스를 제공하고 있습니다. 아무래도 저는 학창 시절에는 JPA를 잠깐 사용하고, 현재 회사에서는 MyBatis를 사용하면서 DB 접근 기술의 근간에 위치하고 있는 JDBC만을 사용해서 DB를 다뤄본 적은 없는데요. 자바와 스프링 환경에 대한 공부를 하면서 JPA나 MyBatis가 추상화하고 있는 '내부적인 동작 방식'이 궁금해져서 공부를 했었고, 그 내용을 공유하고자 합니다.

 

 

먼저 JDBC가 어떤 계층 구조를 가지고 애플리케이션과 DB 사이를 연결하는지에 대한 구조도입니다.

JDBC 아키텍처 구조도

 

우리가 비즈니스 로직을 짜는 애플리케이션 레이어에서 JDBC API를 호출해서 DB에 접근하게 되는데요. 이렇게 JDBC를 통해 DB를 접근하게 되면 다음 절차를 따르게 됩니다.

 

1) 커넥션 생성

2) 구문(Statement) 생성 및 컴파일

3) 구문 실행

4) 실행 결과 처리

5) 커넥션 해제

 

이제 각 절차들을 조금 더 자세히 알아보고, JDBC를 통해 DB를 다뤄보는 예시를 보겠습니다.

 

참고) 공식 문서 상에서 커넥션을 맺게 되는 target DB를 'data source'라고 표현하는데요. 아무래도 자바 표준 스펙에서 정의하고 있고 이 글에서도 자주 쓰이는 'DataSource'와 헷갈릴 소지가 있어 이 글에서는 'data source'를 '데이터 원천'으로 작성했습니다.

 

 

1. 커넥션 생성 (Establishing Connection)


커넥션 생성은 특정한 데이터 원천과 커넥션(연결)을 맺는 것을 의미합니다. 데이터 원천은 DBMS가 될 수도 파일시스템이 될 수도 있습니다. 커넥션은 JDBC 인터페이스에서 Connection 객체로 표현되는데요, 일반적으로 JDBC를 사용하는 애플리케이션은 커넥션 생성 시 다음 두 클래스 중 하나를 사용하여 커넥션을 생성합니다.

 

 

1) DriverManager

여러 JDBC Driver들을 등록하고 관리하는 역할을 하는 매니저 클래스입니다. DriverManager를 사용해 커넥션을 얻을 경우, 데이터베이스 URL을 사용해 애플리케이션을 데이터 원천에 연결합니다.

 

2) DataSource

DriverManager보다 개선된 기능(커넥션 풀링 등)을 제공하는 인터페이스입니다. 벤더사(MySQL, Oracle 등)마다 DataSource 구현체 클래스를 제공하며, DataSource 객체의 속성(properties)들은 특정한 데이터 원천을 나타내도록 설정됩니다. DataSource 사용 시 아무래도  애플리케이션 입장에서는 인터페이스에 의존하게 되는 것이므로, 내부적인 연결 정보(properties 등으로 표현되는)나 구현체가 바뀌어도 영향을 받지 않게 된다는 장점이 있습니다. 

 

참고) 커넥션 풀링 : DB Connection 객체를 미리 생성해 풀에 보관하고, 필요할 때마다 빌려 쓴 뒤 반납하는 관리 기법을 말합니다.

 

 

1 -  1.DriverManager를 통한 커넥션 생성

참고) dbms는 mysql을 쓴다고 가정합니다.

public Connection getConnectionByDriverManager(String username, String password) throws SQLException {
    Properties connectionProps = new Properties();
    connectionProps.put("user", username);
    connectionProps.put("password", password);

    Connection conn = DriverManager.getConnection(
            "jdbc:mysql://localhost:3306/bank_demo",
            connectionProps
    );
    
    System.out.println("Connected to database successfully.");
    return conn;
}

 

DriverManager의 getConnection 메서드가 커넥션을 생성하며, 코드에 드러나있는 것처럼 데이터베이스URL을 사용하여 커넥션을 생성합니다.

 

 

1 - 2. DataSource를 통한 커넥션 생성

앞서 말했듯 DataSource는 인터페이스로, 벤더사마다 DataSource 인터페이스의 구현체 클래스를 제공합니다. 보통 시스템 관리자가 Tomcat 등을 사용해서 DataSource를 배포(등록)한 뒤 애플리케이션 개발자가 등록된 DataSource를 사용해 커넥션을 얻는 식으로 사용합니다. 참고로 DataSource는 커넥션 풀링과 분산 트랜잭션을 제공할 수 있다는 장점도 가집니다.

 

DataSource는 다음 3가지 방식으로 구현될 수 있습니다. (1번 선택지는 무조건 포함해야 하고, 대개 분산 트랜잭션을 지원하는 DataSource는 커넥션 풀링도 지원합니다.)

 

1) 풀링되지 않거나 분산 트랜잭션에서 쓰이지 않는 심플한 커넥션을 제공하는 Basic한 DataSource

2) 커넥션 풀링을 지원하는 DataSource (즉 여기서 쓰이는 Connection 객체는 재사용될 수 있는 Connection 객체)

3) 분산 트랜잭션을 지원하는 DataSource (즉 여기서 쓰이는 Connection 객체는 분산 트랜잭션에서 사용될 수 있는 Connection 객체)

 

 

1 - 2 - 1. DataSource 객체 배포(등록)

시스템 관리자가 애플리케이션 개발자들이 사용 가능하도록 DataSource 객체를 배포(등록)하는 과정이며, 보통 다음과 같은 형식의 3단계로 구성됩니다.

 

1) DataSource 객체 생성

2) 해당 객체의 properties 세팅

3) JNDI(Java Naming and Directory Interface) API 등의 방법을 통해 해당 객체를 등록

 

참고) 과거 WAS 환경에서는 이러한 DataSource를 JNDI라는 디렉토리 서비스에 등록해두고 찾아 쓰는 방식을 주로 사용했습니다. 현대 스프링 기반 환경에서는 빈으로 등록해서 많이 활용합니다.

 

MysqlDataSource dataSource = new MysqlDataSource();

dataSource.setServerName("localhost");
dataSource.setPortNumber(3306);
dataSource.setDatabaseName("bank_demo");
dataSource.setUser(username);
dataSource.setPassword(password);

Context ctx = new InitialContext();
ctx.bind("jdbc/BankDemoDS", dataSource);

 

 

1 - 2 - 2. DataSource 객체 사용 (애플리케이션 개발자 역할)

애플리케이션 개발자는 시스템 관리자가 등록한 DataSource를 다음과 같이 활용합니다.

 

1) JNDI를 통해 DataSource 조회 (lookup)

2) 반환된 DataSource의 getConnection() 메서드를 호출하여 커넥션 획득

3) 커넥션 풀링 사용 시 커넥션을 다 쓰고 나면 커넥션 반환 (close 호출)

 

Context ctx = new InitialContext();

DataSource dataSource = (DataSource) ctx.lookup("jdbc/BankDemoDS");

Connection conn = dataSource.getConnection();

 

 

 

2. 쿼리문 생성 및 작성 (Create Statements)


JDBC 인터페이스에서 쿼리문(구문)을 표현하는 인터페이스는 Statement입니다. 해당 객체를 '실행'하게 되면 실행결과로 ResultSet 객체(데이터베이스 결과 집합을 의미하는 JDBC 인터페이스 상에서의 객체)를 만들어줍니다. Statement 객체를 만들려면 Connection 객체가 필요하며 생성 예시는 다음과 같습니다.

 

stmt = con.createStatement();

 

 

Statement에는 다음 3종류가 있습니다.

 

1) Statement

파라미터가 없는 간단한 SQL문 생성에 쓰입니다.

 

2) PreparedStatement

Statement를 상속하며, 파라미터가 존재하는 SQL문을 미리 컴파일하는데 쓰입니다.

 

3) CallableStatement

PreparedStatement를 상속하며, 파라미터가 존재할 수 있는 프로시저들을 실행할 때 쓰입니다.

 

 

2 - 1. PreparedStatement

Statement를 상속하는 클래스로, PreparedStatement의 주요 특징은 Statement와는 다르게 생성될 때 SQL문을 받는다는 점입니다. 이렇게 되면 대부분 해당 SQL문이 DBMS로 즉시 보내져 컴파일되는데요, 이를 통해 PreparedStatement가 실행되면 DBMS는 컴파일 과정 없이 SQL문을 바로 실행할 수 있게 된다는 장점을 얻을 수 있습니다.

 

참고) JDBC는 JVM과 DBMS 사이의 연결을 표준화한 스펙인 거고, 미리 컴파일된 쿼리를 저장하는 건 JDBC 쪽이 아니라 MySQL 등이 PREPARE 등을 통해 제공하는 기능입니다. 저희 회사에서 쓰고 있는 SingleStore의 경우는 PREPARE 기능을 제공하지 않는데, 이런 경우는 클라이언트(JDBC 쪽) 수준에서 최적화를 진행합니다. 또한 JDBC Driver 별로 특정 설정들을 통해 DB의 PREPARE를 사용할지 안 할지도 고를 수 있습니다. 이렇게 DB의 기능을 활용하는 PreparedStatement를 ServerPreparedStatement, 클라이언트 수준에서 최적화하는 PreparedStatement를 ClientPreparedStatement라고 부릅니다. 

아래는 MySQL의 PREPARE를 사용하는 예제이며, 이 글 하단 레퍼런스 탭에 있는 카카오페이 테크블로그를 참고했습니다.

-- prepared statement 생성
PREPARE pstmt FROM 'SELECT * FROM TB_CUSTOMER WHERE id = ?';

-- 파라미터를 지정하여 prepared statement 실행
SET @a = 1;
EXECUTE pstmt USING @a;

-- prepared statement 제거
DEALLOCATE PREPARE pstmt;

 

 

PreparedStatement는 파라미터가 없는 SQL문의 실행에도 쓰일 수 있지만, 대개 파라미터가 있는 SQL문의 실행에 자주 쓰입니다. 이때 PreparedStatement는 클라이언트가 제공한 데이터를 SQL문장의 일부로서가 아니라 파라미터로만 취급하기 때문에 악의적인 입력이 코드로 해석되지 않아서, SQL Injection을 자연스럽게 예방하는 효과도 얻을 수 있습니다.

 

참고) 조금 더 풀어서 설명해보면,  PreparedStatement는 SQL문 생성시 파라미터가 들어가는 위치들은 ?(Question Mark)를 두고, 이 구조를 컴파일한 뒤 나중에 ?가 있던 위치에 setter메서드를 통해 파라미터들을 세팅하게 되는데요. 이때 파라미터 값들을 코드가 아니라 '단순한 문자열 값'이나 숫자 데이터로만 처리하게 되어 SQL Injection이 예방되는 효과를 얻게 됩니다.

 

 

PreparedStatement의 생성과 파라미터 세팅, 실행 예시는 다음과 같습니다. SQL문에서 파라미터가 들어갈 곳은 ?(Question Mark)로 표시하며, 파라미터 설정은 PreparedStatement의 setter 메서드 중 적절한 것을 골라 사용합니다. setter 메서드의 파라미터로는 인덱스 번호와 설정할 값을 넘기며 인덱스는 1부터 시작합니다. 참고로 한 번 설정된 파라미터는 해당 인덱스 번호에 대한 setter 메서드를 호출하거나, PreparedStatement의 clearParameters()를 호출하기 전에는 설정된 값을 유지합니다.

 

String updateSql = "UPDATE account SET balance = balance + ? WHERE id = ?";
PreparedStatement pstmt = conn.prepareStatement(updateSql);

pstmt.setLong(1, 10000L);
pstmt.setString(2, "00000001029301");
        
pstmt.executeUpdate();

 

 

3. 쿼리 실행 (Executing Queries)


Statement 객체에서 execute 계열 메서드를 호출하여 쿼리를 실행 가능하며,  execute 계열 메서드는 다음 3종류가 있습니다.

 

1) execute

두 개 이상의 ResultSet 객체를 리턴하는 SELECT 수행 시 사용하며, 쿼리가 반환하는 첫 번째 결과가 ResultSet객체면 true를 리턴합니다. Statement객체의 getResultSet 메서드를 반복 호출하여 ResultSet객체들을 하나하나 가져올 수 있습니다.

 

2) executeQuery

하나의 ResultSet 객체를 리턴하는 SELECT 수행 시 사용합니다.

 

3) executeUpdate

DML 수행 시 사용하며, 작성된 쿼리를 통해 영향을 받은 row의 수를 리턴합니다. 

 

예를 들면 다음처럼 사용합니다.

ResultSet rs = stmt.executeQuery(query);

 

 

4. 실행 결과 처리 (Processing ResultSet Objects)


4 - 1. ResultSet?

Statement를 실행하여 ResultSet을 얻었다면, Cursor를 통해 ResultSet 객체의 데이터들에 접근할 수 있습니다. ResultSet은 데이터베이스 결과 집합을 나타내는 데이터 테이블로, 일반적으로 SELECT문을 실행하여 생성됩니다. PreparedStatement 등 Statement 인터페이스를 구현하는 모든 객체를 통해 생성 가능하며, 실행된 쿼리 결과를 조작하고 검색하는 다양한 메서드를 제공합니다.

 

Cursor를 통해 ResultSet 객체의 데이터들에 접근할 수 있는데요, 여기서의 Cursor는 DB Cursor의 개념이 아니라, ResultSet 객체의 데이터 하나하나를 가리키는 포인터를 말합니다. 최초에 Cursor는 첫 번째 row 앞에 위치하고 있고, ResultSet객체가 가지는 여러 메서드들을 통해 Cursor가 가리키는 위치(= 가리키는 row)를 바꿀 수 있습니다. 대표적으로 ResultSet의 next메서드는 Cursor를 다음 행으로 이동시키는데, Cursor가 마지막 행 뒤에 위치하게 되면 false를 리턴하는 점을 활용해 while루프를 통하여 ResultSet의 모든 데이터를 순회할 수 있습니다.  그 외에 next말고도 previos, first 등등 Cursor를 움직일 수 있는 방법은 다양합니다.

 

 

4 - 2. ResultSet에서 행의 특정 컬럼값 가져오기

ResultSet은 현재 Cursor가 가리키는 행에서 원하는 컬럼값을 가져올 수 있는 getter메서드들을 제공합니다. 컬럼의 인덱스 번호(1부터 시작)를 사용하거나 컬럼이름을 사용하여 값을 가져올 수 있습니다.

 

String selectSql = "SELECT * FROM TB_CUSTOMER WHERE CUST_ID = ?";
PreparedStatement pstmt = conn.prepareStatement(selectSql);

pstmt.setLong(1, 1L);
ResultSet rs = pstmt.executeQuery();

while (rs.next()) {
    Long custId = rs.getLong(1); // CUST_ID. 1은 컬럼의 인덱스
    String custName = rs.getString("CUST_NAME"); // CUST_NAME

    System.out.println("custId: " + custId + ", custName: " + custName);
}

 

 

 

5. 커넥션 해제 (반납)


Connection객체와 Statement객체, ResultSet객체들을 다 사용했다면 close메서드를 통해 리소스들을 해제합니다. 자바에서 제공하는 try-with-resources 문법을 사용하면 try 블록을 벗어날 때 자동으로 자원 해제를 할 수 있습니다.

 

예를 들면 다음과 같습니다.

String sql = "UPDATE account SET balance = balance + ? WHERE id = ?";
    
try (Connection conn = dataSource.getConnection();
    PreparedStatement pstmt = conn.prepareStatement(sql)) {
        
    pstmt.setLong(1, amount);
    pstmt.setString(2, accountId);
    pstmt.executeUpdate();
        
} catch (SQLException e) {
    // 예외 처리 로직
    log.error("Database error", e);
}

 

참고) SELECT 쿼리일 경우 ResultSet도 해제해야 합니다. 

 

 

JDBC만을 사용한 DB 접근 실습


이제 JDBC만을 사용해 DB에 접근하는 것을 간단하게 실습해봤습니다. 실습 환경은 다음과 같았습니다

 

1) Java 21

2) mysql-connector-j 9.3.0

3) Mysql 8.0.33

 

테이블은 현재 제가 개인적으로 하는 토이 프로젝트에서 쓰고 있는 테이블 중 다음 테이블에 대해 SELECT하는 것으로 해봤습니다.

 

 

 

public static List<Customer> getCustomers() {
    List<Customer> customers = new ArrayList<>();
    String selectSql = "SELECT CUST_ID, CUST_NAME, RES_ID, CUST_TYPE FROM TB_CUSTOMER LIMIT ?";

    try (Connection conn = JdbcSample.getConnectionByDriverManager();
         PreparedStatement pstmt = conn.prepareStatement(selectSql)) {

        pstmt.setInt(1, 10);

        try (ResultSet rs = pstmt.executeQuery()) {
            while (rs.next()) {
                Long custId = rs.getLong("CUST_ID");
                String custName = rs.getString("CUST_NAME");
                String resId = rs.getString("RES_ID");
                String custType = rs.getString("CUST_TYPE");

                customers.add(new Customer(custId, custName, resId, custType));
            }
        }

    } catch (SQLException e) {
        System.out.println("Database error: " + e.getMessage());
    }

    return customers;
}

 

개인적으론, PreparedStatement나 ResultSet 등 MyBatis나 JPA가 추상화해주고 있는 영역을 직접 공부도 해보고 처리도 해보니 애플리케이션과 DB가 어떻게 대화하고 있는지(?) 좀 더 잘 이해하게 된 것 같습니다. 역시 해보길 잘했다는 생각입니다. 다만 간단한 쿼리를 처리하고 있음에도 불구하고 리소스의 해제를 신경써야 한다거나 파라미터 세팅을 자꾸 해줘야 한다는 점, ResultSet을 매번 꺼내서 내가 사용할 객체의 값으로 세팅해줘야 하는 점들이 "아 이거 반복되면 번거롭겠다"라는게 느껴졌습니다.

 

이를 토대로 MyBatis와 JPA가 어떤 것들을 어떻게 추상화했는지 등도 공부할 수 있겠다는 생각이 듭니다. 또한 커넥션풀은 어떻게 활용되고 있는지에 대한 궁금증도 생기고 있습니다. 그래서 여기서 멈추지 않고, 커넥션풀을 사용하는 DataSource는 커넥션풀을 어떻게 활용하고 있는건지, 그리고 MyBatis와 JPA가 어떤 점들을 해결하려고 했고 어떤 것들을 포기한 기술인지도 공부를 해서 블로그에 올려보겠습니다!

 

글이 잘 읽힐 지 모르겠네요. 그래도 끝까지 봐주셔서 감사합니다.

 

 

레퍼런스


https://docs.oracle.com/javase/tutorial/jdbc/basics/index.html

 

Lesson: JDBC Basics (The Java™ Tutorials > JDBC Database Access)

The Java Tutorials have been written for JDK 8. Examples and practices described in this page don't take advantage of improvements introduced in later releases and might use technology no longer available. See Dev.java for updated tutorials taking advantag

docs.oracle.com

https://tech.kakaopay.com/post/how-preparedstatement-works-in-our-apps/

 

우리의 애플리케이션에서 PreparedStatement는 어떻게 동작하고 있는가 | 카카오페이 기술 블로그

JDBC를 직접 핸들링하지 않고, 다양한 추상화 계층 위에서 동작하는 우리의 애플리케이션에서 PreparedStatement는 어떻게 동작하는지 탐구한 결과를 공유합니다.

tech.kakaopay.com

 

당시 개발했던 기능 소개

2025년 올해, 회사에서 했던 개발 업무 중 가장 기억에 남는 경험을 꼽으라면 결재문서 데이터를 연동하고 알림을 발송하는 기능에 트랜잭션 아웃박스 패턴을 적용한 것입니다. 회사 내에서 기안되는 결재문서 중에는 CEO까지 결재해야 하는 문서들이 있는데요. 해당 문서들에 비리나 이상, 위법성 등이 없는지 감사팀에서 매번 감사를 해왔는데, 제가 운영하던 감사시스템에 이렇게 CEO까지 결재하는 문서들을 연동해서 새로운 감사 대상 문서가 기안되면 감사팀에게 알림을 보내고, 결재가 진행되다가 반려가 되면 반려 알림 등을 보내도록 하는 기능입니다. "일상감사 기능"이라고도 불렀고, CEO가 결재하는 문서들을 감사하는 만큼 비즈니스 중요도가 매우 높은 업무였습니다.

 

 

데이터 연동 방법에 대한 고민

먼저 데이터를 연동하는 방법부터 고민을 했었습니다. 처음에는 감사시스템에 데이터 수신용 API를 만들고 전자결재시스템에서 새로운 문서가 기안되거나 기안된 문서의 결재가 진행될 때마다 제가 만든 API를 호출하게 해서 결재문서 데이터들을 공급받을까 했지만, 네트워크 오류 등으로 API 호출 등이 실패했을 때 재시도 전략을 어떻게 할 건지 등이 고민됐습니다. 예를 들어 API 호출에 실패해서 재시도해도 그 재시도가 또 실패하면? 등등.. 결국에는 API 호출을 통한 방법으로는 결재문서 데이터가 누락될 수 있다는 점에 보였는데, 이 "일상감사 기능"은 앞서 설명했듯이 비즈니스 중요도가 매우 높아서 데이터가 누락되는 일은 최대한 지양해야 했습니다. 따라서 다른 방법들을 알아봤는데요, 사내에 이기종 DB 간의 데이터 교환을 특정 주기로 해주는 솔루션(통상 EAI 라고 부르는)이 있어서 이를 활용하기로 했습니다.

 

 

결재문서시스템 DB에서 연동해야 하는 데이터들(처리여부 == N)을 조회하고, 이 데이터들을 감사시스템 DB에 적재하고, 결재문서시스템 DB에서 조회했던 데이터들의 처리여부를 갱신(N → Y)합니다. 이 작업을 10분마다 수행하도록 스케쥴해주면, 오류 등이 발생해서 데이터 연동에 실패해도 자연스럽게 10분마다 재시도될 수 있도록 설정할 수 있었습니다. (결과적 정합성이 보장됨) 물론 이렇게 하면 결재문서에 상태변화가 생겨도 즉각적으로 감사시스템에 연동되는 건 아니지만, 감사팀 분들과 소통하며 완전한 실시간성의 필요성보다는 누락없이 안정적으로 데이터를 연동하는 것이 더 중요함을 도출했었기 때문에 이 방법을 통해 데이터를 연동하기로 했었습니다.

※CEO까지 결재하는 문서들은 하루에 많으면 2개 정도 기안되는 상황이라, Kafka같은 컴포넌트의 사용은 연동 대상 데이터 양에 비해 과한 방법이라는 생각이 들어 고려하지 않았습니다

 

 

알림을 보내는 방법에 대한 고민

이후에는 알림을 보내는 방법을 고민했습니다. 사내 표준 상 EAI 솔루션으로 주고받는 데이터들은 실제 감사 업무에서 사용하는 테이블에 연동하는게 아니라 인터페이스 용도로 만든 테이블에 둬야 했기 때문에, 이 인터페이스 테이블에 있는 데이터들을 업무 테이블로 연동하고 알림을 보내야 했습니다. 

 

 

사실 알림을 보내는 것에 대해서 처음에는 연동 후에 알림 보내지 뭐.. 라는 생각이었습니다. 하지만 개발 과정에서 대표적으로 다음과 같은 엣지 케이스가 발생 가능함을 알게 됐습니다.

 

 

1. 결재문서가 기안되고 감사시스템에 연동됨

2. 감사팀에서 확인 후 해당 결재문서를 이상없음으로 처리

3. 이후 해당 결재문서의 결재가 진행되다가 중간에 반려되고, 문서 내용이 수정된 채로 재기안됨

4. 하지만 반려 알림이나 재기안 알림이 발송되지 않음

5. 감사팀 입장에서는 이전에 이상 없다고 처리한 문서의 내용이 바뀌어 있게 됨

 

 

이 기능이 비즈니스 중요도가 높은 업무였던 만큼, 위와 같은 상황이 펼쳐지면 감사 업무에 굉장한 리스크가 될 수 있다는 생각이 들었습니다. 따라서 결재문서 데이터 연동과 알림 발송이라는 두 작업의 원자성을 보장해줘야 한다는 생각이 들었고, 어떻게 해야 데이터가 연동된 후 알림이 어떤 상황이든 발송될 수 있도록 하지? 를 고민했습니다.

 

 

처음에는 다음과 같이 데이터 연동에 성공하면 그 결과에 따라 알림을 발송하는 방법을 떠올렸는데요.

 

이렇게 하면 알림 발송이 실패할 수도 있기 때문에, 데이터는 연동됐지만 알림 발송에 실패하는 케이스가 발생할 수 있게 됩니다.

 

 

두 번째론 알림 발송 결과에 따라 데이터를 연동하는 방법을 떠올렸는데요.

 

이렇게하면 데이터 연동이 실패할 수도 있기 때문에 알림은 발송됐지만 데이터 연동은 실패하는 케이스가 발생할 수 있게 됩니다.

 

 

세 번째로는 데이터 연동 트랜잭션 내에서 알림 발송 결과에 따라 트랜잭션을 커밋 or 롤백하는 방법을 떠올렸는데요.

 

마찬가지로 알림 발송엔 성공했지만 데이터 연동은 실패하는 케이스가 발생할 수 있었습니다.

 

 

즉 데이터 연동과 알림 발송이라는 두 작업의 원자성을 하나의 DB 트랜잭션으로 정확히 보장해주는 건 매우 힘들었습니다. 2 phase commit 같은 분산 트랜잭션도 알아봤지만 연동해야 하는 데이터 양에 비해 오버 엔지니어링이 될 거라는 생각이 들었는데요. 찾아보니 최소 1번은 이벤트를 전달하는 방법으로 트랜잭션 아웃박스 패턴(트랜잭셔널 아웃박스 패턴)이 있었고, 이를 활용해 다음처럼 설계할 수 있겠다는 생각에 도입을 하게 됐습니다.

 

 

 

2번 과정을 보면, 결재문서이력 테이블 (EAI를 통해 전달받은 데이터가 있는 인터페이스 테이블)을 처리해서 업무 테이들에 결재문서 데이터들을 연동하고, 감사팀에게 발송할 메시지를 별도의 outbox테이블에 적재하는데요. 이 과정들을 하나의 DB 트랜잭션에서 처리합니다. 그리고 outbox테이블에 있는 메시지들을 조회해서 알림을 발송하는 별도의 작업이 10분 주기로 수행되도록 만들었습니다. 이렇게 하면 outbox테이블을 조회해서 알림을 발송하는 작업이 10분마다 수행되므로, 알림 발송이 실패해도 성공할 때까지 재시도가 자연스럽게 진행되게 됩니다. 따라서 최소 1번은 알림을 보낼 수 있게 되는 것이고, 이를 통해 결재문서 데이터 연동과 알림 발송이라는 두 작업이 결과적으로 모두 진행되게 하여 원자성을 보장할 수 있었습니다.

 

 

정말 트랜잭션 아웃박스 패턴이 필요했을까?

'데이터 연동 & 알림 발송'이라는 기능을 구현하는 과정이 트랜잭션 아웃박스 패턴을 곁들이면서 다소 복잡해지긴 했습니다만, 데이터 연동과 알림 발송이라는 두 작업의 원자성을 보장해야 하는 것이 우선이었던 만큼 이런 복잡함은 감수해야 했다는 생각이었습니다. 그리고 감사팀 입장에서 왜 이 기능이 필요할지를 직접 고민해보면서 두 작업의 원자성이 필요했던 것을 스스로 도출했던 것이었고, 이를 보장하는 방법들을 여러 가지로 알아보고 스스로 가장 괜찮은 방법이라고 생각되는 것을 적용했던 만큼 솔직하게는 나름대로의 뿌듯함도 있었습니다.

 

하지만 시간이 흐르고 다시 이 기능 개발을 회고해봤습니다. 정말 트랜잭션 아웃박스 패턴이 필요했던 것일까? 다시 시간을 되돌린다고 해도 데이터 연동과 알림 발송이라는 두 작업의 원자성은 필요했던 상황이라고 생각합니다. 하지만 정말 트랜잭션 아웃박스 패턴이 당시의 최선이었을까?

 

뭐 다시 회고해보니, 굳이 필요하지 않았었다.. 라는 생각이 들었습니다. ㅎㅎ..

 

 

다시 돌아가서, 데이터를 연동하고 알림을 발송하던 것을 한 트랜잭션 내에서 처리하던 형태를 살펴봤습니다.

 

이 방법을 당시 적용하지 않았던 것은 앞서 설명드렸듯이 커밋 과정에서 예외가 터지면 알림은 발송됐지만 트랜잭션은 롤백되어 데이터가 연동되지 않기 때문이었습니다.

 

 

하지만 다시 생각해보면, 어차피 데이터 연동 & 알림 발송은 10분 주기로 반복되고 있습니다. 이렇게 알림이 발송됐지만 데이터가 연동되지 않은 케이스가 발생됐다고 해도, 10분 뒤 혹은 그 뒤에 이 프로세스가 재수행되면서 결국 데이터가 연동됩니다. 결국은 이 방법을 써도 결과적으로는 데이터 연동과 알림 발송의 원자성이 보장되는 것입니다.

 

 

트랜잭션 아웃박스 패턴은 결국 쓸 필요가 없던 거였습니다 ㅎㅎ.. 데이터 연동과 알림 발송이라는 두 작업의 원자성을 보장해야 한다는 제약을 스스로 도출해냈었고, 이를 위한 여러 방법을 스스로 고민하면서 오버 엔지니어링 없이 가장 적절한 선택을 했던 거라고 생각했는데, 사실 이조차도 오버 엔지니어링이었겠다는 생각에 당혹스럽기도 하고 멘탈이 조금.. 힘들기도 했습니다. "아 내가  더 넓은 관점에서 보지 못했던 건가? 좁은 관점에서 기술 적용에만 순간적으로 몰두했던 거였나? 불필요한 과한 설계를 내가 적용하고 있던 걸까?" 라는 생각에 스스로를 조금 갉아먹기도 했던 것 같습니다.

 

 

..그래도.. 앞으로가 중요한 것 아니겠습니까! 데이터 정합성 보장이라는 좁은 관점에 매몰되어 10분 주기로 반복 수행되고 있다는 더 큰 관점에서의 맥락을 놓쳤었고, 결국 간단한 길을 두고 다소 돌아간 셈이 됐지만, 이를 통해 어떻게 구현할까 보다 왜 필요한가를 좀 더 신중하게 다시 살펴보자는 교훈을 얻을 수 있었습니다. 완벽함을 위한 '노력'이 사실은 복잡함을 더할 수 있는 '욕심'이 될 수 있음을 배웠고, 덕분에 단순함이 주는 강력함도 느낄 수 있었습니다.

 

 

또한 이 경험을 성장을 위한 발판으로 삼고, 제가 했던 고민의 흔적들로 남길려고 합니다. 단순히 데이터 정합성을 고려하지 않고 개발했다면 트랜잭션 아웃박스 패턴이라는 일종의 오버 엔지니어링은 없었겠지만, 반면 데이터 정합성을 지켜주는 방법과 기술들에 대해 이렇게 고민하지 않았을 거라고 생각합니다. 이 경험에서 학습한 것들을 발판으로 삼아, 앞으로 기술적인 의사결정들을 내릴 때마다 조금 더 넓은 관점에서, 조금 더 신중하게 고민하게 하는 양분으로 삼으려고 합니다.

 

 

앞으로 더 휼룽한 개발자, 엔지니어가 되기를 바라며! ㅎㅎ.. 부끄럽네여

 

 

 

 

 

 

+ Recent posts