Spring Boot에서 NamedParameterJdbcTemplate을 사용한 빈 객체 매핑 원리 분석

Spring Boot 애플리케이션에서 데이터베이스 조회 작업을 보다 직관적으로 처리하기 위해 NamedParameterJdbcTemplate을 자주 사용한다. 이 템플릿은 위치 기반 파라미터 대신 이름 기반 파라미터(:name, :id 등)를 지원하여 SQL 가독성을 높이며, 특히 동적 쿼리 작성 시 유리하다. 아래는 이러한 메커니즘이 어떻게 작동하여 결과를 자바 빈(Bean) 객체로 변환하는지를 소스 코드 수준에서 분석한 내용이다.

public void queryUserAsBean() {
    String sql = "SELECT * FROM PT_USER WHERE status = :status";
    MapSqlParameterSource params = new MapSqlParameterSource("status", "ACTIVE");

    List<PtUser> users = namedParameterJdbcTemplate.query(sql, params, 
        new BeanPropertyRowMapper<>(PtUser.class));

    System.out.println(users);
}

위 예제에서 BeanPropertyRowMapper는 쿼리 결과의 각 행을 PtUser 클래스의 인스턴스로 자동 매핑한다. 이 과정은 단순한 반사(reflection) 기반 설정이 아니라, 내부적으로 세밀하게 제어되는 메타데이터 기반 접근 방식을 따른다.

1. 이름 기반 파라미터의 내부 변환

NamedParameterJdbcTemplate은 최종적으로 여전히 JDBC의 PreparedStatement를 사용하므로, 이름 기반 파라미터는 위치 기반으로 변환되어야 한다. 이 역할을 수행하는 핵심 메서드는 getPreparedStatementCreator이다. 이 메서드는 :status 같은 명명된 파라미터를 ?로 치환하고, 해당 값을 올바른 순서에 바인딩할 수 있도록 파라미터 메타데이터를 재구성한다. 이후에는 일반 JdbcTemplate의 실행 흐름을 그대로 따르게 된다.

2. PreparedStatement 실행 및 결과 추출

쿼리 실행의 핵심은 추상 클래스 내부에 정의된 템플릿 메서드 패턴에 있다. 다음은 그 핵심 로직의 간소화된 표현이다.

public <T> T query(PreparedStatementCreator creator,
                  PreparedStatementSetter setter,
                  ResultSetExtractor<T> extractor) {

    return execute(creator, ps -> {
        ResultSet rs = null;
        try {
            if (setter != null) {
                setter.setValues(ps); // 파라미터 바인딩
            }
            rs = ps.executeQuery();
            ResultSet targetRs = nativeJdbcExtractor != null ?
                nativeJdbcExtractor.getNativeResultSet(rs) : rs;
            return extractor.extractData(targetRs); // 결과 추출
        } finally {
            JdbcUtils.closeResultSet(rs);
            if (setter instanceof ParameterDisposer) {
                ((ParameterDisposer) setter).cleanupParameters();
            }
        }
    });
}

이 구조는 ResultSetExtractor 인터페이스를 통해 다양한 형태의 결과 처리가 가능하도록 하며, BeanPropertyRowMapper는 이 인터페이스의 구현체로서 결과 집합을 객체 리스트로 변환하는 책임을 진다.

3. BeanPropertyRowMapper의 초기화와 필드 매핑

BeanPropertyRowMapper는 생성 시 타겟 클래스의 프로퍼티 정보를 분석하여 매핑 규칙을 사전에 구성한다. 다음은 초기화 과정의 핵심 로직이다.

protected void initialize(Class<T> targetClass) {
    this.mappedClass = targetClass;
    this.mappedFields = new HashMap<>();
    this.mappedProperties = new HashSet<>();

    PropertyDescriptor[] descriptors = BeanUtils.getPropertyDescriptors(targetClass);
    for (PropertyDescriptor pd : descriptors) {
        if (pd.getWriteMethod() == null) continue;

        String lowerName = pd.getName().toLowerCase();
        mappedFields.put(lowerName, pd);

        // 카멜 케이스 → 스네이크 케이스 변환: userName → user_name
        String snakeCase = toSnakeCase(pd.getName());
        if (!lowerName.equals(snakeCase)) {
            mappedFields.put(snakeCase, pd);
        }

        mappedProperties.add(pd.getName());
    }
}

여기서 중요한 점은, 자바 필드명이 카멜 케이스(camelCase)일 경우 이를 자동으로 스네이크 케이스(snake_case)로 변환하여 데이터베이스 컬럼과 일치시킨다는 것이다. 예를 들어 userName 필드는 user_name 컬럼과 매핑된다.

4. 실제 매핑 수행: mapRow 메서드

결과셋의 각 행을 객체로 변환하는 작업은 mapRow 메서드에서 이루어진다.

@Override
public T mapRow(ResultSet rs, int rowNum) throws SQLException {
    T instance = BeanUtils.instantiateClass(mappedClass);
    BeanWrapper wrapper = PropertyAccessorFactory.forBeanPropertyAccess(instance);

    ResultSetMetaData meta = rs.getMetaData();
    int colCount = meta.getColumnCount();

    for (int i = 1; i <= colCount; i++) {
        String columnName = JdbcUtils.lookupColumnName(meta, i);
        String normalizedCol = columnName.replaceAll("\\s+", "").toLowerCase();

        PropertyDescriptor pd = mappedFields.get(normalizedCol);
        if (pd != null) {
            Object value = getColumnValue(rs, i, pd);
            try {
                wrapper.setPropertyValue(pd.getName(), value);
            } catch (TypeMismatchException ex) {
                handleTypeMismatch(value, pd, ex);
            }
        }
    }
    return instance;
}

이 메서드는 모든 컬럼 이름을 소문자 기준으로 정규화한 후, 미리 구성된 mappedFields 맵에서 일치하는 프로퍼티를 찾아 값을 설정한다. Oracle처럼 대소문자를 구분하지 않는 DB도 안정적으로 지원할 수 있다.

5. 결론: 컨벤션 기반 매핑의 중요성

BeanPropertyRowMapper는 개발자의 수고를 크게 줄여주지만, 그만큼 명확한 네이밍 컨벤션이 요구된다. 자바 빈의 프로퍼티는 카멜 케이스를 따르고, 데이터베이스 컬럼은 스네이크 케이스를 사용하는 것이 일반적인 스프링 애플리케이션의 관례이다. 이 규칙을 지키지 않으면 자동 매핑이 실패하거나 누락될 수 있으므로, 필요 시 커스텀 RowMapper를 구현해야 한다.

태그: Spring Boot JDBC NamedParameterJdbcTemplate BeanPropertyRowMapper Row Mapping

6월 11일 01:00에 게시됨