이전 글들에서, 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

 

https://youtu.be/pt7cNy7KlpE?si=OUiwPpv_nywXW7ko

 

들어가며

최근 사내에서 Virtual Thread에 대한 기술 세미나를 열었는데요. 사내 세미나 한 번으로 해당 내용을 묵혀두기는 아까워서 유튜브 영상으로도 공유하고.. 블로그에도 남겨볼려고 합니다. 다만 사내 세미나와 영상에서는 비IT 직무 인원들도 이해할 수 있게끔 최대한 비유를 들며 쉽게 설명하려고 했던 것과 다르게 블로그에는 최대한 기술적으로(?) 작성해보고자 합니다. 

 

Virtual Thread는 JDK21버전부터 공식 Feature로 포함된 기술로, 블록체인이나 AI같이 산업의 변화를 이끌고 있는 기술보다는 성능 최적화를 위해 쓰이는 뒷단의 기술에 가깝습니다. Java를 기반으로 하는 플랫폼이 기존에 어떤 컨셉의 모델을 사용해왔고, 이게 어떤 한계가 있었고, 이를 바탕으로 Virtual Thread가 어떤 목적으로 나온 기술인지 이번 포스트에서 살펴보겠습니다. 그리고 Virtual Thread의 Context Switching, Throughput에 대한 테스트 내용과 사용 시 주의사항에 대해 소개하겠습니다.

 

목차는 다음과 같습니다.

 

  1. Thread per Request & Thread Pool (Java가 전통적으로 사용해 온 방식)
  2. Reactive Programming의 등장 및 한계
  3. Virtual Thread의 개념과 장점
  4. Virtual Thread의 동작 방식 및 원리
  5. Virtual Thread 테스트 - Context Switching
  6. Virtual Thread 테스트 - Throughput
  7. Virtual Thread 주의사항 - Pinning
  8. 정리

 

 

1. Thread per Request & Thread Pool

Spring으로 대표되는 Java 기반 플랫폼은 사용자로부터 하나의 Request가 들어오면 하나의 스레드로 이를 처리하는 Thread per Request라는 방식을 옛날부터 사용해왔습니다. 그러나 매 Request마다 새로운 스레드를 생성하는 것은 비용 부담이 크기 때문에, 미리 일정량의 스레드를 만들어두고 Request가 들어올 때마다 만들어둔 스레드에게 해당 Request의 처리를 맡기는 Thread Pool이란 개념을 사용해왔습니다.

 

Thread Pool 도식화

 

 

그러나 시간이 흐르며 서비스의 규모가 커지고 Request의 양이 늘어남에 따라, 서비스의 처리량을 늘려야 하는 미션이 생기게 됩니다. 가장 심플한 방법은 스레드풀의 사이즈, 즉 스레드의 개수를 늘리는 것이지만 공간적인 한계 때문에 스레드의 개수가 제한될 수밖에 없었고, 이는 서비스의 처리량이 일정 수준으로 한정되는 결과로 이어졌습니다. 그리고 Java는 자바 스레드(유저 스레드)와 커널 스레드가 1 : 1로 매핑되는 스레딩 모델을 사용하기 때문에, 스레드 간 전환은 곧 커널 스레드간의 전환을 의미하고 이는 Context Switching 비용 부담이 점점 부각되는 문제점을 보이게 됐습니다. 또한 자바 스레드가 I/O 작업 등으로 인해 Blocking되면 매핑된 커널 스레드까지 함께 Blocking됐기 때문에, 만들어둔 스레드들이 실질적으로 task를 수행하는 시간보다 대기하는 시간 즉 idle time이 훨씬 더 많아지는 문제점도 부각되기 시작했습니다.

 

자바 스레드가 Blocking되면 커널 스레드도 Blocking되어 처리량에 한계가 생기게 됩니다

 

 

이런 상황에서 어쨌거나 처리량을 올려야 했고, 가장 단순한 방법인 "스레드 늘리기"는 공간적 제약으로 할 수 없는 상황입니다. 전통적으로 사용해 온 Thread per Request는 하나의 스레드가 하나의 요청을 처리하는 방식인데, "하나의 스레드가 두 개 이상의 요청을 동시에 처리하게 한다면 처리량이 올라가지 않는가?"라는 아이디어가 나오게 됩니다.

이 때의 "동시"는 시간적으로 동시에 한다는 개념이 아닌, 컴퓨터 공학에서 말하는 동시성의 개념으로 보면 됩니다.

 

Thread per Request : 하나의 스레드가 하나의 일을 처리

 

하나의 스레드가 두 개 이상의 Request을 동시에 처리

 

 

이 아이디어를 바탕으로 Reactive Programming이 등장하게 됩니다.

 

 

2. Reactive Programming의 등장 및 한계

"하나의 스레드가 동시에 두 개 이상의 Request를 처리하게 한다"라는 생각을 바탕으로 등장한 비동기 + Non-Blocking 방식의 프로그래밍 패러다임입니다.

 

[동기 비동기 차이] 이미지 출처 : https://wikidocs.net/228265

 

컴퓨터 자원을 좀 더 효율적으로 사용할 수 있었기 때문에, Reactive Programming을 통해 더 적은 스레드로 더 많은 Request를 처리할 수 있었습니다. 즉 주어진 미션대로 처리량을 높일 수 있었습니다.

 

그러나.. Reactive Programming은 다음과 같은 단점들이 있었습니다.

 

1. 높은 진입장벽

"하나의 스레드가 동시에 두 개 이상의 Request를 처리하게 한다"라는 것은 기존의 동기적인 관점에서 벗어나 비동기라는 관점에서 프로그래밍을 해야 했습니다. 그렇다보니 러닝커브가 높아지게 됐고, 이는 기존에 사용하던 동기적인 방식의 코드와 로직을 전환하는 것에도 부담이 되는 요소로 작용하게 됐습니다. 여러 로직이 맞물릴 때, 하나만 비동기로 바꾸면 되는 게 아니라 전체를 비동기로 바꿔야 하기 때문인 것도 있습니다.

 

2. 직관적인 코드 이해의 어려움

기존의 동기적인 관점에서 코드를 작성할 땐, 주어진 비즈니스 요구사항 즉 비즈니스 프로세스가 이 코드에 잘 반영됐는지 파악하는 것이 상대적으로 쉬웠습니다. 또한 새로운 기능을 추가해야 할 때, 기존 코드의 어느 부분에 추가해야 할지 판단하는 것도 어렵지 않았습니다. 하지만 Reactive Programming에서는 우리의 비즈니스 프로세스가 이 코드에 잘 반영됐는지, 새로운 기능을 추가한다면 어느 부분에 추가해야 하는지를 판단하는 것이 상대적으로 어려웠습니다. 1번과 이어지는 얘기지만, 결국 Reactive Programming은 프로그래밍 패러다임, 즉 프로그래밍을 하는 관점 자체를 달리 하는 것이 원인인 것이죠.

 

3. 어려워진 디버깅, 예외처리

기존에는 Thread per Request, 하나의 스레드가 하나의 Request을 처리하는 컨셉이었기 때문에, 문제가 발생하면 콜스택 등을 통해 디버깅하거나 예외처리를 해주는 것이 상대적으로 쉬웠습니다. 그러나 Reactive Programming은 Request(다른 말로는 task)를 여러 스텝으로 쪼개고 각 스텝들을 파이프라인(다른 말로는 스트림) 형태로 엮어서 여러 스레드에서 처리하게 됩니다. 그렇다보니 콜스택을 통해 디버깅하거나 예외처리하는 것이 상대적으로 어려워지게 됐습니다.

 

 

정리하면, Reactive Programming을 통해 분명 처리량을 높일 수 있었습니다. 하지만 그에 반비례해서 개발 생산성이 떨어진다는 리스크가 있었죠. "Reactive Programming 이거 분명 잘 쓰면 좋아. 근데 잘 쓰기가 너무 어려워!"라는 문제점이 점점 부각되기 시작했습니다.

 

그래서 다시 Thread per Request & Thread Pool 이라는 원점으로 돌아옵니다. 주어진 미션은 여전히 "처리량을 높이는 것"입니다. 하지만 여기에 한 스푼 더 얹어서 "기존의 동기적인 코드 흐름을 유지할 순 없을까?" 라는 욕심이 들기 시작합니다. 이를 충족시키는 가장 간단한 방법은 역시나 "스레드 개수 늘리기"이지만, 공간적인 제약 때문에 그렇게 할 수 없었죠. (스레드는 메모리에서 2MB정도까지 공간을 차지할 수 있다고 합니다) 근데 여기서, "그러면 스레드를 가볍게 만들면 엄청 많이 만들 수 있는 거 아냐?" 라는 관점이 나오기 시작합니다.

 

이 아이디어를 바탕으로, 스레드를 가볍게, "경량"으로 만들어서

  1. 서비스의 처리량 향상
  2. 기존의 동기적인 코드 흐름 유지

라는 2개의 목표를 달성하기 위해 본 포스트의 메인 주제인 Virtual Thread가 등장하게 됩니다.

 

 

3. Virtual Thread의 개념과 장점

 

앞서 말씀드렸듯이 Java가 사용하는 스레딩 모델은 자바 스레드와 커널 스레드의 1 : 1로 매핑 모델입니다. CPU의 실질적인 스케쥴링 대상은 커널 스레드로, 커널 스레드가 CPU를 점유하면 그에 매핑된 자바 스레드가 수행되는 구조로 이해할 수 있습니다. Virtual Thread는 기존의 자바 스레드보다 더 가벼운 스레드를 만들어 하나의 자바 스레드에 다수의 Virtual Thread가 매핑시키는 구조로,  커널 스레드가 CPU를 점유하면 매핑된 자바 스레드에 mount된 Virtual Thread가 실행되는 개념입니다.

 

동작 과정도 간단히 살펴보면, 기존에는 자바 스레드가 Blocking되면 매핑된 커널 스레드까지 Blocking되는 구조였습니다. 그러나 Virtual Thread는 실행 중 Blocking된다면 mount되어 있던 자바 스레드에서 unmount되고, 다른 Virtual Thread가 자바 스레드에 mount되어 실행되는 구조입니다. 따라서 실질적으로 CPU를 점유하고 있는 커널 스레드는 Blocking되지 않게 됩니다.

 

이러한 특징 덕분에, Virtual Thread 사용 시 기존에 사용하던 동기적인 코드 흐름을 그대로 유지할 수 있으면서도, 내부적으론 비동기 방식과 비슷하게 동작하게 되어 Non-Blocking 처리가 가능해져 처리량을 높일 수 있게 됩니다. 또한 기존 스레드 간의 Context Switching은 커널 레벨에서 발생하지만 Virtual Thread간의 Context Switching은 유저 레벨에서 발생한다는 특징이 있는데요, 이를 통해 Context Switching 비용을 좀 더 아낄 수 있게 된다는 장점도 있습니다. 

 

구체적인 동작 방식을 통해 좀 더 살펴보겠습니다.

 

 

4. Virtual Thread의 동작 방식 및 원리

0. Virtual Thread 주요 구성 요소

동작 방식 설명 전 Virtual Thread의 주요 구성 요소들을 살펴보면 다음과 같습니다.

 

Virtual Thread 주요 구성 요소

 

1) ForkJoinPool

Virtual Thread들의 static 멤버(즉 모든 Virtual Thread가 공유)로, Virtual Thread들을 스케쥴링하는 스케쥴러 역할을 합니다.

 

2) Carrier Thread

Virtual Thread이 mount되어 실행되는 실질적인 자바 스레드이며,  Carrier Thread들은 저마다 하나씩 WorkQueue라 불리는 work-stealing 방식으로 동작하는 큐를 가집니다. 논리적인 관점에서 Carrier Thread는 Virtual Thread와 1 : N의 매핑 관계를 가지며, Carrier Thread와 커널 스레드는 1 : 1의 매핑 관계를 가집니다.

work-stealing : Carrier Thread들이 자신의 workQueue에 아무 것도 없으면 다른 Carrier Thread의 workQueue에 있는 걸 훔쳐와서 사용하는데, 이를 work-stealing이라고 표현합니다.

 

3) Continuation

Virtual Thread가 실행해야 하는 작업과 그 작업에 대한 정보(어디까지 실행했는지 등)을 가지는 객체입니다.

 

4) runContinuation

Continuation을 실질적으로 실행시키는 일종의 람다식입니다.

 

 

1. 동작 방식 - Virtual Thread가 Carrier Thread에 mount되는 과정

 

 

기존 자바 스레드는 start 메서드를 호출하면 start0라는 native 메서드를 거쳐 JNI를 통해 해당 자바 스레드와 매핑될 새로운 커널 스레드를 생성하게 됩니다. 반면 Virtual Thread는 start메서드 호출 시 submitRunContinuation 메서드가 호출되고, 스케쥴러(ForkJoinPool)의 execute 메서드가 호출됩니다. 그러면 스케쥴러가 적당한 Carrier Thread를 하나 골라 runContinuation을 해당 Carrier Thread의 WorkQueue에 넣어주게 됩니다. 이후 Carrier Thread가 본인의 WorkrQueue에 들어있는 runContiation을 뽑아 실행시키면 Virtual Thread와 Carrier Thread가 mount되고 Virtual Thread가 실행됩니다.

 

 

2. 동작 방식 - Virtual Thread가 실행 중 Blocking될 때 unmount되는 과정

 

 

Virtual Thread가 실행되다가 I/O 요청 등으로 인해 Blocking되면 내부적으로 park메서드가 호출됩니다. park메서드는 doYield라는 native 메서드를 거쳐 JNI를 통해 jvm단에서 memcpy라는 표준 C 라이브러리 함수를 호출하게 됩니다. Virtual Thread가 사용 중이던 스택 프레임을 Heap에다가 복사해두는 기능을 수행하는 것이며, 이를 통해 나중에 Blocking이 끝나고 Virtual Thread가 재개될 때 중단 지점부터 재개하는 것이 가능해집니다. (memcpy 호출 등은 대표적으로 GitHub에 있는 openjdk를 뜯어보면 볼 수 있습니다)

 

이런 과정을 거치며 unmount가 진행되고, Carrier Thread는 다른 Virtual Thread와 mount되게 됩니다.

 

 

3. 동작 방식 - Blocking됐던 Virtual Thread가 다시 재개되는 과정

 

 

Blocking상태가 끝나면 내부적으로 unpark() 메서드가 호출되며, 다시 submitRunContinuation이 호출되면서 Carrier Thread의 WorkQueue에 runContinuation필드가 들어갑니다. 이후 Carrier Thread가 이를 뽑아내서 mount할 때 아까 Heap에 복사해뒀던 스택 프레임을 다시 복구시켜서 중단 지점부터 작업을 재개합니다.

 

 

4. Virtual Thread 동작 방식 최종 정리

 

 

  1. Virtual Thread는 Carrier Thread의 WorkQueue에서 work-strealing 방식으로 mount되어 실행됩니다.
  2. mount된 Virtual Thread는 실행이 끝나면 다른 Virtual Thread로 갈아끼워집니다. (Context Switching)
  3. mount된 Virtual Thread는 실행 중 Blocking되면 현재까지의 실행 정보(스택 프레임)를 Heap에 저장하고 다른 Virtual Thread로 갈아끼워집니다. (Context Switching)
  4. Blocking이 끝난 Virtual Thread는 mount될 때 Heap에 저장해둔 스택 프레임을 불러와 중단 지점부터 다시 작업을 재개합니다.

 

 

5. Virtual Thread 테스트 - Context Switching

앞서 살펴본 것처럼 Virtual Thread 간의 Context Switching은 Virtual Thread의 실행이 끝났을 때 또는 Virtual Thread가 실행되다가 Blocking됐을 때 발생합니다. 그리고 이 과정은 코드 레벨에서 살펴봤듯 스택 프레임을 Heap에 저장하는 과정 등을 거치며 유저 레벨에서 발생됩니다. (참고 : 스택 프레임을 Heap에 복사할 때 사용되는 memcpy는 표준 C 라이브러리 함수로 시스템콜이 아닙니다

 

코드 상에선 그렇게 확인했지만, 실제로 Virtual Thread간의 Context Switching이 유저 레벨에서 발생하는지를 직접 검증하고 싶었습니다. 그래서 다음과 같은 코드를 준비했습니다. 코드 상에서 회사명이 나오는 부분들은 가렸습니다.

 

기존 스레드를 사용

 

총 200개(THREAD_COUNT)의 자바 스레드를 만들고, 각각의 스레드가 10ms 동안 대기하는 과정을 100번(ITERATIONS)만큼 반복하여, 프로그램 실행 중 최소 20,000번(THREAD_COUNT X ITERATIONS) 이상의 Context Switching이 발생하도록 합니다. 동일한 코드를 Virtual Thread를 활용한 방식으로도 작성해줍니다.

 

Virtual Thread를 사용

 

이 각각의 프로그램들을 컴파일한 뒤, Linux에서 제공하는 커널 퍼포먼스 측정 도구인 perf를 활용해 각 프로그램을 실행하는 과정에서 Context Switching이 몇 번 발생했는지 측정해줍니다. 이때 커널 레벨에서 발생한 Context Switching이 측정되는 것이므로, 정말 Virtual Thread가 Context Switching이 유저 레벨에서 발생한다면 기존 스레드를 사용했을 때가 Context Switching 횟수가 더 높게 측정될 것임을 예측할 수 있습니다.

 

총 3번씩 측정했으며, 결과는 다음과 같습니다.

 

기존 스레드에 대한 측정 결과
Virtual Thread에 대한 측정 결과

 

  기존 스레드 Virtual Thread
Context Switching 횟수 약 19,900 / s 약 5,300 / s
유저 모드, 커널 모드 실행 시간 비율 약 1.3 : 1 약 6.2 : 1

 

물론 이 테스트에서 기존 스레드는 커널 스레드가 생성되는 과정이 함께 측정되는 부분도 감안해야 합니다. 그러나 Virtual Thread를 사용한 프로그램이 기존 스레드를 사용한 프로그램보다 3배 이상 커널 레벨에서 Context Switching이 덜 발생했고, 유저 모드와 커널 머드에서의 실행 시간 비율을 볼 때도 Virtual Thread를 사용했을 때가 유저 레벨에서 실행된 비율이 더 높음을 쉽게 파악할 수 있습니다. 이를 통해 Virtual Thread간의 Context Switching이 유저 레벨에서 발생한다는 것을 실질적으로 확인할 수 있었고, 따라서 Virtual Thread를 통해 Context Switching 비용을 줄일 수 있음을 도출할 수 있습니다.

 

 

6. Virtual Thread 테스트 - Throughput

이번에는 Virtual Thread 사용시 정말 처리량을 높일 수 있는지를 테스트해봤습니다. 실제 업무에서 쓰이는 API에 테스트해보면 좋겠지만 그럴 수 없던 관계로.. 다음과 같이 Spring Boot를 활용해 간단히 2개의 API를 만들어줬습니다.

 

 

첫 번째로는 500ms씩 2번 Sleep하게 하는 API를 만들어주고, 두 번째로는 1부터 1억까지의 연산을 2번 반복하는 API를 만들어줘서 각각 I/O 작업 위주로 비즈니스 로직을 처리하는 상황과 CPU 연산 위주로 비즈니스 로직을 처리하는 상황을 가정해줍니다. 이를 토대로 I/O Bound 상황과 CPU Bound 상황에 대해, 기존 스레드 사용 시와 Virtual Thread의 사용 시의 처리량을 측정해봅니다.

 

테스트 환경은 다음과 같습니다.

 

  1. Ubuntu 24.04
  2. 2 core CPU / 4GB RAM
  3. Spring Boot 3.3.4 / OpenJDK21
  4. K6로 VU를 2,000까지 늘리며 진행, 각 3회씩 측정

 

먼저 I/O Bound 상황에 대한 결과입니다.

 

 

  TPS Response Time (avg)
Virtual Thread - 1회 911 1.06 s
Virtual Thread - 2회 881 1.07 s
Virtual Thread - 3회 839 1.08 s
기존 스레드 - 1회 189 5.27 s
기존 스레드 - 2회 188 5.27 s
기존 스레드 - 3회 189 5.27 s

 

물론 간단한 코드를 기반으로 테스트한 것이므로 해당 결과가 실제 운영 환경을 대변하진 않습니다. 그래도 결과를 해석해보면, I/O Bound 상황에서 Virtual Thread는 Non-Blocking 처리가 가능하기 때문에 기존 스레드보다 더 높은 처리량을 보이고 있음을 눈으로 확인해볼 수 있습니다. 또한 Spring Boot에 내장된 톰캣의 스레드풀 사이즈를 별도로 설정해주지 않아 스레드풀 사이즈가 디폴트값인 200으로 만들어졌었는데요. 500ms씩 2번, 총 1초 정도를 대기하도록 API를 구성했기 때문에 기존 스레드를 사용한 결과가 TPS가 200 가까이는 올라가지만 200을 넘지 못하고 있는 것도 확인해볼 수 있었습니다.

 

다음으론 CPU Bound 상황에 대한 결과입니다.

 

 

  TPS Response Time (avg)
Virtual Thread - 1회 21 28.19 s
Virtual Thread - 2회 20 28.40 s
Virtual Thread - 3회 20 28.38 s
기존 스레드 - 1회 23 27.10 s
기존 스레드 - 2회 24 26.91 s
기존 스레드 - 3회 23 27.12 s

 

마찬가지로 이 결과가 운영 환경을 대변하진 않지만, 결과를 보면 CPU Bound 상황에서는 Virtual Thread 사용시 오히려 처리량이 낮아지는 걸 볼 수 있습니다. Virtual Thread는 앞서 살펴본 것처럼 실행 시 Carrier Thread의 WorkQueue에 들어갔다가 mount되고, 다 끝난 뒤엔 unmount를 하는 과정 등을 거칩니다. 따라서 작업을 실행하는 데 드는 비용 자체는 기존 스레드가 Virtual Thread보다 더 저렴합니다. Blocking을 처리하는 비용이 Virtual Thread가 기존 스레드보다 훨씬 더 저렴한 것이죠. 따라서 작업을 실행하는 도중에 Blocking이 발생하지 않는 경우라면 오히려 Virtual Thread를 사용하는 것이 안티 패턴이 될 수도 있음을 도출할 수 있습니다. 이 테스트에서 사용된 CPU Bound 상황에 대한 것을 그 예로 볼 수 있습니다.

 

 

7.Virtual Thread 주의사항 - Pinning

Virtual Thread는 Carrier Thread에 mount되어 수행되다가, Blocking되면 Carrier Thread로부터 unmount된다고 소개했습니다. 근데 Virtual Thread가 Carrier Thread에 mount된 상태로 고정되어, Blocking이 발생해도 unmount가 되지 않는 상황이 발생할 수 있습니다. 이를 Pinning현상(Pinned 현상이라고도 부릅니다)이라고 부르며, 다음 상황에서 발생합니다.

 

  1. Virtual Thread에서 synchronized 블록 사용 시
  2. Virtual Thread에서 native method 사용 시

 

synchronized 블록은 모니터 락을 사용하여 동시 접근을 제어하는데, Virtual Thread가 아닌 Carrier Thread 수준(jvm 단)에서 이 락을 잡다보니 락을 획득한 Carrier Thread가 연관된 작업을 처리하는 동안 다른 작업을 처리할 수 없도록 고정되는 것이 원인입니다. Native method 역시 비슷한 이유로 고정됩니다. 이렇게 Pinning 현상이 발생하면 Virtual Thread의 장점이자 존재 이유(?)인 "Blocking 시 갈아끼우기"를 시전할 수 없게 되므로 성능 저하의 직접적인 원인이 될 수 있습니다.

 

Pinning 현상을 직접 테스트하기 위해 다음과 같은 코드를 준비했습니다.

 

 

2개의 Virtual Thread를 만들어 실행하는데, 각 Virtual Thread는 현재 스레드의 정보를 로그로 남기고, 2초 대기하다가 다시 스레드의 정보를 로그로 남기는 메서드를 실행하도록 합니다. 이 때 run이란 메서드를 synchronized로 묶지 않았을 때와 묶을 때 각각 어떻게 결과가 나오는지를 테스트합니다.

 

참고로 Carrier Thread는 여러 개가 만들어질 수 있는데, 제가 만든 2개의 Virtual Thread가 서로 다른 Carrier Thread에 mount되어 실행된다면 이 테스트는 의미가 없습니다. 따라서 다음과 같이 옵션을 줘서 Carrier Thread가 하나만 만들어지게 하고 테스트를 진행했습니다.

 

-Djdk.virtualThreadScheduler.parallelism=1 
-Djdk.virtualThreadScheduler.maxPoolSize=1 
-Djdk.virtualThreadScheduler.minRunnable=1

 

 테스트 결과는 다음과 같습니다.

 

Pinning이 발생하지 않은 경우
Pinning이 발생한 경우

 

우선 로그에 남아있는 스레드 정보가 ForkJoinPool-1-worker-1로 동일하기 때문에, 하나의 Carrier Thread에서 Virtual Thread들이 실행됐음을 볼 수 있습니다. Pinning이 발생하지 않은 경우 VT1이 먼저 로그를 찍고 sleep에 들어가면서 VT2로 갈아끼워지게 되고, VT2도 로그를 찍은 뒤 sleep에 들어가게 된 것을 볼 수가 있습니다. 2초 뒤에 VT1, VT2 둘 다 깨어나면서 로그를 찍게 되므로 전체 실행 시간이 약 2초임을 볼 수 있습니다. 반면 Pinning이 발생한 경우 VT1이 먼저 로그를 찍고 sleep에 들어가지만 VT2로 갈아끼워지지 못하게 되고, 2초 뒤에야 VT2로 갈아끼워지면서 전체 실행 시간이 4초 정도로 나오는 것을 볼 수 있습니다.

 

이를 통해 실질적으로 Virtual Thread 사용 중 Pinning이 발생한다면 성능 저하의 원인이 될 수 있음을 도출했습니다. Pinning이 발생된 구간에서 Blocking이 발생하지 않는다고 해도, 앞서 말씀드린 것처럼 작업을 실행하는 비용 자체는 기존 스레드가 더 저렴하기 때문에 Virtual Thread 사용 시 Pinning이 발생하지 않도록 주의할 필요가 있습니다.

 

글을 작성하는 시점인 24년 10월 9일 현재, Pinning에 대해 다음과 같이 조치할 수 있습니다.

 

  1. synchronized block은 ReentrantLock으로 대체
  2. native method 사용은 최애한 지양

 

참고로 현재 여러 Java Library들이 Virtual Thread와의 호환을 위해 내부적으로 사용하던 synchronized를 ReentrantLock으로 바꾸는 작업을 진행하고 있습니다.

 

 

 

8. 정리

1) 그래서 언제 쓰면 좋을까?

우선 다시 한 번 상기할 점은 작업을 실행하는 데 드는 비용 자체는 기존 스레드가 더 저렴하나, Blocking 처리 비용이 Virtual Thread가 훨씬 저렴하다는 것입니다. 또한 Virtual Thread는 지연시간이 아닌 처리량을 높이는 기술이므로, API 응답 시간을 줄이는 것 등이 주어진 미션일 때는 Virtual Thread 도입을 권장하지 않습니다.

 

현재 시스템이 Thread per Request를 기반으로 하는 Spring mvc 등을 사용 중일 때 Virtual Thread를 도입을 고려할 수 있습니다. 만약 현재 시스템이 Reactive Programming의 대표 격인 Spring Webflux 등을 사용 중이라면, Virtual Thread와는 패러다임 자체가 다르므로(Reactive Programming인 비동기, Virtual Thread는 동기) Virtual Thread 도입은 안티 패턴이 될 수 있습니다.

 

또한 현재 시스템이 받는 워크로드가 I/O 작업 위주이며, 시스템의 주 병목 원인이 이 I/O 작업들 위주인 것이 분명하다면 Virtual Thread 도입 시 처리량 향상을 꾀할 수 있습니다. 만약 워크로드가 CPU 연산 위주라면, 아까 살펴본 것처럼 Virtual Thread의 도입은 안티 패턴이 될 수 있습니다.

 

2) Virtual Thread 도입 시 검토해볼 것들

Pinning 발생을 방지하기 위해, 사용 중인 코드나 사용 중인 라이브러리 또는 프레임워크 내부에 synchronized 등을 사용하는 곳이 있는지 검토해야 합니다. -Djdk.tracePinnedThreads 옵션을 통해 Pinning을 trace할 수 있으며, 특히 Pinning이 발생한 구간에서 Blocking되는 부분이 있는지도 검토해야 합니다. 

 

그리고 Virtual Thread 사용 시, 시스템과 연동되는 타 시스템으로 폭발적인 트래픽 전파가 발생할 수 있음을 주의해야 합니다. 처리량이 올라가게 되면, 평소에 다른 시스템으로 10개의 요청을 보내다가 갑자기 100개 1,000개 10,000개 이상의 트래픽을 보내게 될 수 있습니다. Virtual Thread에는 배압을 조절하는 기능이 아직 없다보니, 상대 시스템도 이러한 트래픽을 처리할 수 있는 능력(?)이 되는지를 검토해볼 필요가 있습니다. 특히 DB connection같은 한정된 개수의 자원에 접근하는 경우, 내가 보낸 요청들이 되려 timeout이 나면서 떨어질 수 있는 가능성도 있습니다. 현재는 세마포어를 통해 배압을 조절하는 것이 권장되고 있으나, 결국 개발자 본인들이 Virtual Thread를 통해 해당 내용에 대해 인지하고 있을 필요가 있습니다.

 

이 외에도, Virtual Thread는 기존 스레드와 달리 매우 많은 수가 생성될 수 있으므로 ThreadLocal 등을 사용하는 곳이 있는지 검토할 필요가 있습니다.

 

정리하면, Virtual Thread를 실질적으로 도입하게 될 경우

 

  1. 우리가 사용 중인 라이브러리 등의 대응이 가능한가?
  2. 우리 서비스가 놓여진 인프라가 뒷밤침될 수 있는가?

 

를 검토해야겠습니다.

 

 

 

참고한 레퍼런스

https://junhkang.tistory.com/37

https://velog.io/@kimyj1234/Java-가상-스레드-Virtual-Thread

https://kkyu0718.tistory.com/126

https://findstar.pe.kr/2023/04/17/java-virtual-threads-1/

https://tech.kakaopay.com/post/ro-spring-virtual-thread/

https://0soo.tistory.com/259

https://spring.io/blog/2022/10/11/embracing-virtual-threads

https://dev.java/learn/new-features/virtual-threads/

https://dzone.com/articles/the-long-road-to-java-virtual-threads

https://d2.naver.com/helloworld/1203723

https://notypie.dev/java-virtual-thread-와-platform-thread/

https://dev.to/elayachiabdelmajid/java-21-virtual-threads-1h5b

https://nomoresanta.tistory.com/4

https://velog.io/@appti/Virtual-Thread

https://blog.honeybomb.kr/9

 

JNI란?

Java Native Interface의 약자로, 자바 코드에서 네이티브 코드(하드웨어와 운영체제가 직접 실행할 수 있는 기계어 또는 바이너리 코드를 의미)를 호출하거나, 반대로 네이티브 코드에서 Java 코드를 호출할 수 있게 해주는 프레임워크를 말합니다. C언어 또는 C++언어로 작성된 코드는 컴파일 시 해당 하드웨어에서 직접 구동될 수 있는 기계어로 컴파일되기 때문에 네이티브 코드에 해당되는데요(자바는 플랫폼에 독립적인 바이트코드로 컴파일된다는 점을 참고하면 좋습니다). 따라서 자바 코드에서 JNI를 통해 C언어 또는 C++언어로 작성된 코드를 실행할 수 있게 됩니다.

 

주로 다음과 같은 상황들에 JNI를 활용할 수 있습니다.

 

  • 성능 최적화 : 성능이 중요한 부분을 C/C++로 구현하고, 이를 자바 코드에서 호출할 때 사용합니다.
  • 기존 라이브러리 사용 : 이미 C/C++로 작성된 기존 라이브러리나 API를 자바 애플리케이션에서 활용하고자 할 때 사용
  • 플랫폼 종속 기능 : 플랫폼에 특화된 기능(시스템콜 호출, 하드웨어 제어 등)이 필요한 경우 사용

 

사용 방법

메서드 앞에 native를 붙임으로써 네이티브 메서드임을 명시할 수 있습니다.

 

public class Jofe {
    public native void nativeMethod();

    static {
        System.loadLibrary("jofe");
    }

    public static void main(String[] args) {
        new Jofe().nativeMethod();
    }
}

 

native로 선언된 nativeMethod는 해당 메서드가 네이티브 라이브러리에서 구현됨을 의미합니다. Jofe클래스가 처음 로드될 때 네이티브 라이브러리인 jofe를 로드하는 것도 확인 가능합니다. main 메서드에서 Jofe 객체를 생성하고 nativeMethod를 호출하면, JVM은 네이티브 코드로 연결되어 해당 네이티브 메서드가 실행되게 됩니다.

 

다음 명령어의 실행을 통해 JNI를 사용하기 위한 헤더 파일을 만들 수 있습니다.

 

javac -h {헤더파일을 둘 위치} {Jofe.java의 경로}
# ex : javac -h . Jofe.java

 

 

그러면 다음과 같이 헤더파일이 만들어집니다.

 

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class Jofe */

#ifndef _Included_Jofe
#define _Included_Jofe
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     Jofe
 * Method:    nativeMethod
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_Jofe_nativeMethod
  (JNIEnv *, jobject);

#ifdef __cplusplus
}
#endif
#endif

 

이 헤더파일을 기반으로, c언어로 해당 네이티브 메서드를 다음과 같이 구현할 수 있습니다.

 

#include <jni.h>
#include "Jofe.h"

JNIEXPORT void JNICALL Java_Jofe_nativeMethod(JNIEnv *env, jobject obj) {
       printf("이거 하나 출력할라고 개고생중입니다");
}

 

이 c파일을 컴파일하여 다음과 같이 라이브러리를 만들 수 있습니다. 저는 macos여서 다음 커맨드를 사용했습니다.

 

gcc -I$JAVA_HOME/include -I$JAVA_HOME/include/darwin -shared -m64 -o {생성할 라이브러리 이름} {c파일 경로}
# ex : gcc -I$JAVA_HOME/include -I$JAVA_HOME/include/darwin -shared -m64 -o libjofe.dylib Jofe.c

 

그리고 컴파일된 Jofe.class를 다음과 같이 네이티브 라이브러리의 경로를 지정해서 실행해줍니다.

 

java -Djava.library.path={생성한 라이브러리 위치} -classpath {컴파일된 클래스파일위치} {실행할 클래스명}
# java -Djava.library.path=/Users/jofejofe/Development/JNI/src/main/java/org/example -classpath . Jofe

 

그러면 c파일에서 구현된 메서드가 실행되는 걸 확인할 수 있습니다.

 

 

실제로 자바에선 JNI가 어떻게 활용되고 있을까?

대표적으로 스레드의 생성 등에 활용하고 있습니다. 다음과 같이 Thread의 start()메서드를 까보면, 내부적으로 start0()이란 네이티브 메서드를 호출하고 있음을 볼 수 있습니다.

 

    // Thread.java
    
    public void start() {
        synchronized (this) {
            // zero status corresponds to state "NEW".
            if (holder.threadStatus != 0)
                throw new IllegalThreadStateException();
            start0();
        }
    }
    
    private native void start0();

 

해당 메서드의 구현체는 다음과 같습니다.

 

https://github.com/openjdk/jdk/blob/221e1a426070088b819ddc37b7ca77d9d8626eb4/src/java.base/share/native/libjava/Thread.c

 

jdk/src/java.base/share/native/libjava/Thread.c at 221e1a426070088b819ddc37b7ca77d9d8626eb4 · openjdk/jdk

JDK main-line development https://openjdk.org/projects/jdk - openjdk/jdk

github.com

#include "jni.h"
#include "jvm.h"

#include "java_lang_Thread.h"

#define THD "Ljava/lang/Thread;"
#define OBJ "Ljava/lang/Object;"
#define STE "Ljava/lang/StackTraceElement;"
#define STR "Ljava/lang/String;"

#define ARRAY_LENGTH(a) (sizeof(a)/sizeof(a[0]))

static JNINativeMethod methods[] = {
    {"start0",           "()V",        (void *)&JVM_StartThread},
    {"isAlive0",         "()Z",        (void *)&JVM_IsThreadAlive},
    {"setPriority0",     "(I)V",       (void *)&JVM_SetThreadPriority},
    {"yield0",           "()V",        (void *)&JVM_Yield},
    {"sleep0",           "(J)V",       (void *)&JVM_Sleep},
    {"currentCarrierThread", "()" THD, (void *)&JVM_CurrentCarrierThread},
    {"currentThread",    "()" THD,     (void *)&JVM_CurrentThread},
    // ...생략

 

네이티브 메서드의 이름과 c로 작성된 함수의 포인터를 매핑하고 있는 걸 볼 수 있습니다.

JVM_StartThread는 다음과 같이 구현되어 있습니다.

 

// jobject : 여기선 Java 스레드 객체(java.lang.Thread)를 나타냄. 이를 통해 스레드를 시작
JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
#if INCLUDE_CDS
  // .. 생략
#endif
  // JVM 내에서 자바스레드(유저스레드)를 표현하고 관리하는 핵심 클래스
  // 자바스레드(유저스레드)와 커널 스레드를 연결하는 역할
  JavaThread *native_thread = NULL;

  // 이미 시작된 스레드를 다시 시작하려고 하는 경우 예외를 던지는지 여부를 결정하는 플래그
  bool throw_illegal_thread_state = false;

  {
    // 뮤텍스 잠그고 시작
    MutexLocker mu(Threads_lock);

    // 해당 Java 스레드 객체가 이미 시작된 스레드라면 => 플래그를 설정
    if (java_lang_Thread::thread(JNIHandles::resolve_non_null(jthread)) != NULL) {
      throw_illegal_thread_state = true;
    } else {
      // 스레드가 이미 시작되지 않은 경우, 스택 크기를 가져와서 새로운 JavaThread 객체 생성
      jlong size =
             java_lang_Thread::stackSize(JNIHandles::resolve_non_null(jthread));
      // 
      NOT_LP64(if (size > SIZE_MAX) size = SIZE_MAX;)
      size_t sz = size > 0 ? (size_t) size : 0;
      // 여기서 커널스레드가 새로 만들어짐
      native_thread = new JavaThread(&thread_entry, sz);

      // 커널스레드가 만들어졌다면, 자바스레드(유저스레드)를 매핑
      if (native_thread->osthread() != NULL) {
        native_thread->prepare(jthread);
      }
    }
  }
  
  // 플래그 설정에 따른 예외 던지기
  if (throw_illegal_thread_state) {
    THROW(vmSymbols::java_lang_IllegalThreadStateException());
  }

  assert(native_thread != NULL, "Starting null thread?");
   
  // 일종의 예외 처리
  if (native_thread->osthread() == NULL) {
    ResourceMark rm(thread);
    log_warning(os, thread)("Failed to start the native thread for java.lang.Thread \"%s\"",
                            JavaThread::name_for(JNIHandles::resolve_non_null(jthread)));
 
    native_thread->smr_delete();
    if (JvmtiExport::should_post_resource_exhausted()) {
      JvmtiExport::post_resource_exhausted(
        JVMTI_RESOURCE_EXHAUSTED_OOM_ERROR | JVMTI_RESOURCE_EXHAUSTED_THREADS,
        os::native_thread_creation_failed_msg());
    }
    THROW_MSG(vmSymbols::java_lang_OutOfMemoryError(),
              os::native_thread_creation_failed_msg());
  }

  JFR_ONLY(Jfr::on_java_thread_start(thread, native_thread);)
  
  // 스레드 시작
  Thread::start(native_thread);

JVM_END

 

주석에도 명시했지만, new JavaThread()에서 다음과 같이 커널스레드가 생성되는 과정도 볼 수 있습니다.

 

JavaThread::JavaThread(ThreadFunction entry_point, size_t stack_sz) : JavaThread() {
  _jni_attach_state = _not_attaching_via_jni;
  set_entry_point(entry_point);
  os::ThreadType thr_type = os::java_thread;
  thr_type = entry_point == &CompilerThread::thread_entry ? os::compiler_thread :
                                                            os::java_thread;
  os::create_thread(this, thr_type, stack_sz);
}

 

 

이와 같이.. 자바 코드에서 시스템콜이 필요해지는 경우, 개발자가 직접 사용하든 안 하든 JNI를 활용해 네이티브 코드를 호출하여 시스템콜을 수행하게 됩니다. System.out.println도 다음과 같이 JNI를 사용합니다.

 

private native void write(int b, boolean append) throws IOException;

 

 

 

+ Recent posts