Spring boot + JdbcTemplate 사용해보기 (5) NamedParameterJdbcTemplate 적용

Peter·2024년 7월 19일
post-thumbnail

Introduction

Spring boot + JdbcTemplate 사용해보기 (3) 에서 작성했던 코드를 보면 sql 에 물음표(?) 여러개가 있고, 해당 물음표에 assign 할 데이터를 update 메서드의 파라미터로 순차적으로 나열해 주어야 했다.
컬럼 추가하는 유지보수 건이 접수되면, 컬럼 수가 많은 경우 상당히... 짜증이 날 것 같다.

@Override
public void update(Long memberId, MemberUpdateParam updateParam) {
    String sql = "UPDATE members SET name = ?"
                                + ", gender = ?"
                                + ", position = ?"
                                + ", birth_date = ?"
                                + ", address = ?"
                                + ", phone_number = ?"
                + " WHERE id = ?";
    template.update(sql, updateParam.getName(), updateParam.getGender().name(), updateParam.getPosition(),
        updateParam.getBirthDate(), updateParam.getAddress(), updateParam.getPhoneNumber(), memberId);
}

NamedParameterJdbcTemplate 는 이런 문제를 해결해 주는데, 간단히 말하자면 SQL 내에서 물음표(?)를 사용하는 것이 아니라 :변수명 형태로 작성해주고, parameter 를 :변수명 -> 변수값 의 Map 형태로 넘겨주는 것이다.

이렇게 하면 파라미터 순서를 신경쓸 필요가 없고, 변수명만 잘 신경써주면 된다.

NamedParameter 종류

NamedParameterJdbcTemplate 클래스 소스코드를 살펴보면, SqlParameterSource 또는 Map<String, ?> 을 사용하고 있다.

	@Override
	public int update(String sql, SqlParameterSource paramSource) throws DataAccessException {
		return getJdbcOperations().update(getPreparedStatementCreator(sql, paramSource));
	}

	@Override
	public int update(String sql, Map<String, ?> paramMap) throws DataAccessException {
		return update(sql, new MapSqlParameterSource(paramMap));
	}

	@Override
	public int update(String sql, SqlParameterSource paramSource, KeyHolder generatedKeyHolder)
			throws DataAccessException {

		return update(sql, paramSource, generatedKeyHolder, null);
	}

	@Override
	public int update(
			String sql, SqlParameterSource paramSource, KeyHolder generatedKeyHolder, @Nullable String[] keyColumnNames)
			throws DataAccessException {

		PreparedStatementCreator psc = getPreparedStatementCreator(sql, paramSource, pscf -> {
			if (keyColumnNames != null) {
				pscf.setGeneratedKeysColumnNames(keyColumnNames);
			}
			else {
				pscf.setReturnGeneratedKeys(true);
			}
		});
		return getJdbcOperations().update(psc, generatedKeyHolder);
	}

SqlParameterSource 는 인터페이스고 여러 구현체들을 제공한다.
이들 중에 대표적으로 사용되는 구현체로는 MapSqlParameterSource, BeanPropertySqlParameterSource 가 있다.

NamedParameter 로 사용가능한 것은 Map<String, ?>SqlParameterSource 인터페이스가 있고, SqlParameterSource 인터페이스 중 MapSqlParameterSource, BeanPropertySqlParameterSource 가 대표적으로 사용된다.

NamedParameterJdbcTemplate 적용해보기

NamedParameterJdbcTemplate 객체생성

기본 JdbcTemplate 객체생성할 때와 방법은 동일하다.
JdbcTemplate -> NamedParameterJdbcTemplate 로만 바뀌었다.

물론, 별도의 config 클래스를 만들어서 bean 으로 등록해놓고 DI 해도 상관없다.

public class JdbcTemplateMemberRepositoryV2 implements MemberRepository {

    private final NamedParameterJdbcTemplate template;

    public JdbcTemplateMemberRepositoryV2(DataSource dataSource) {
        this.template = new NamedParameterJdbcTemplate(dataSource);
    }
}

Map<String, ?> 적용해보기

findByIddelete 와 같은 메서드는 파라미터를 id 하나 사용하기 때문에 비교적 간단하게 사용할 수 있다. 이 경우 아래와 같이 Map<String, ?> 을 적용할 수 있다.

@Override
public void delete(Long memberId) {
    String sql = "DELETE FROM members WHERE id=:id";
    Map<String, Long> param = Map.of("id", memberId);
    template.update(sql, param);
}

memberId 가 Long 이기에 Map<String, Long> 로 선언했지만 Map<String, Object> 를 사용해도 무방하다.

MapSqlParameterSource 적용해보기

MapSqlParameterSource 는 Map 과 같이 key-value 쌍을 저장할 수 있다.

	@Override
    public void update(Long memberId, MemberUpdateParam updateParam) {
        String sql = "UPDATE members SET name = :name"
                                    + ", gender = :gender"
                                    + ", position = :position"
                                    + ", birth_date = :birthDate"
                                    + ", address = :address"
                                    + ", phone_number = :phoneNumber"
                                    + " WHERE id = :id";

        SqlParameterSource param = new MapSqlParameterSource()
            .addValue("name", updateParam.getName())
            .addValue("gender", updateParam.getGender().name())
            .addValue("position", updateParam.getPosition())
            .addValue("birthDate",updateParam.getBirthDate() )
            .addValue("address", updateParam.getAddress())
            .addValue("phoneNumber", updateParam.getPhoneNumber())
            .addValue("id", memberId);

        template.update(sql, param);
    }

여기서 주의할 점은 template.update 메서드가 실행될 때 enum 타입은 그대로 getter 를 쓰면 오류가 난다.

위 코드에서 updateParam.getGender().name() 부분을 updateParam.getGender() 로 바꾸고 실행하면 오류가 발생한다.

필자는 enum 타입의 경우, enum 명 그대로 DB 저장되는 것을 원했기에 updateParam.getGender().name() 와 같이 enumname() 메서드를 한번 더 호출하여 매핑했다.

BeanPropertySqlParameterSource 적용해보기

BeanPropertySqlParameterSource 는 JavaBean property 를 따르는 객체를 대상으로 작동하는데, 우리가 흔하게 사용하는 getter 와 setter 기반으로 동작한다고 이해하면 된다.

예를 들어, 아래 코드의 경우 :namemember.getName() 을 매핑하는 식으로 동작한다.

(위와 같은 매커니즘이라 사실 getter 만 있어서 동작할 것 같은데 테스트 해보니 잘 동작한다.)

@Override
public Member save(Member member) {
    String sql = "INSERT INTO members (name, gender, position, birth_date, address, phone_number)"
        + " VALUES (:name,:gender,:position,:birthDate,:address,:phoneNumber)";

    SqlParameterSource param = new BeanPropertySqlParameterSource(member);

    KeyHolder keyHolder = new GeneratedKeyHolder();
    template.update(sql, param, keyHolder);

    long key = keyHolder.getKey().longValue();
    member.setId(key);
    return member;
}

그런데, 위 코드를 실행하면 에러가 발생한다.
원인은 위 MapSqlParameterSource 예시 에서와 같이 enum 컬럼 때문이다.

debugger 를 이용해 template.update 메서드 로직 오류를 추적해보면, 아래와 같은 쿼리가 생성되는 것을 확인할 수 있다.

쿼리 내용을 보면 gender value에 '** STREAM DATA **' 라는 문자열이 들어가 있는 것을 확인할 수 있다.
gender value 에는 'MALE', 'FEMALE' 만 올 수 있기 때문에 Data truncated for column 'gender' SQL 오류가 발생한다.

위 문제를 해결하기 위해서는 그냥 MapSqlParameterSource 를 사용하거나 아래와 같이 enum 에 대한 처리를 별도로 해주면 된다.

@Override
public Member save(Member member) {
    String sql = "INSERT INTO members (name, gender, position, birth_date, address, phone_number)"
        + " VALUES (:name,:gender,:position,:birthDate,:address,:phoneNumber)";

    SqlParameterSource param = new BeanPropertySqlParameterSource(member) {
        @Override
        public Object getValue(String paramName) throws IllegalArgumentException {
            Object value = super.getValue(paramName);
            if(value instanceof Enum enumValue) {
                return enumValue.name();
            }
            return value;
        }
    };

    KeyHolder keyHolder = new GeneratedKeyHolder();
    template.update(sql, param, keyHolder);

    long key = keyHolder.getKey().longValue();
    member.setId(key);
    return member;
}

BeanPropertySqlParameterSource 클래스를 상속한 익명 클래스를 생성한 뒤, getValue 메서드만 override 해서 enum 인 경우만 enumValue.name() 을 리턴하도록 적용하였다.

이제, 오류가 발생하지 않고 정상적으로 동작한다.

프로젝트 전반에서 enum 을 위와 같이 처리한다면 BeanPropertySqlParameterSource 를 상속받은 클래스를 별도로 정의해서 재활용하면 좋을 것 같다.

결론

  • JdbcTemplate 를 사용하는 것보다 NamedParameterJdbcTemplate 를 사용하는 것이 유지보수 성이 좋아보이니, NamedParameterJdbcTemplate 를 적극적으로 사용하자.
  • findById 와 같이 단일 파라미터의 경우 Map<String, ?> 을 사용하자.
  • 위 예시의 save 메서드 에서와 같이 sql 내에 named parameter 들이 메서드 파라미터와 모두 매핑되는 경우 BeanPropertySqlParameterSource 를 사용하자.
  • 위 예시의 update 메서드 에서와 같이 sql 내에 name parameter 와 매핑되지 않는 메서드 파라미터가 존재하는 경우 MapSqlParameterSource 를 사용하자.
  • 도메인 Entity에서 enum 을 사용하는 경우, 별도의 처리를 해줘야 함을 주의하자.

참고

0개의 댓글