Spring DB : JdbcTemplate

    이 글은 인프런 김영한님의 강의를 복습하며 작성한 글입니다

    JdbcTemplate 소개와 설정

    SQL을 직접 사용하는 경우, 스프링이 제공하는 JdbcTemplate을 사용하는 것은 좋은 방법이다. JdbcTemplate은 JDBC를 매우 편리하게 사용할 수 있도록 도와준다.

     

    JDBC 장점

    • 설정이 간편함
      • 별도의 복잡한 설정 없이 바로 사용할 수 있다. (아래 설정 참고)
    • 반복문제 해결
      • JdbcTemplate은 템플릿 콜백 패턴을 사용해서, JDBC를 직접 사용할 때 발생하는 대부분의 반복 작업을 처리해준다. 
      • 개발자는 SQL을 작성하고, 전달할 파라미터 정의 + 응답 값 매핑만 하면 됨. 
      • 아래 반복 작업을 대신해줌.
        1. 커넥션 획득
        2. statement를 준비하고 실행
        3. 결과를 반복하도록 루프를 실행
        4. Connetion, Statement, ResultSet 종료
        5. 트랜잭션을 위한 Connection 동기화
        6. 예외 발생 시, 스프링 예외 변환기 실행

     

    JDBC 단점

    • 동적 SQL을 해결하기 어렵다. 
    • 동적 SQL은 MyBatis / Query DSL을 이용해서 처리할 수 있음. 

     

    JDBC 설정

    implementation 'org.springframework.boot:spring-boot-starter-jdbc'
    • 사용하기 위해서는 위 코드를 build.gradle에 추가하면 된다.

     

    JDBC 주요 쿼리

    • query() : 실행 결과가 List 형태로 반환됨. QueryDSL의 Fetch와 동일
    • queryForObject : 실행 결과는 하나의 객체만 반환됨. QueryDSL의 FetchOne과 동일

     

    JDBC 적용 (JdbcTemplateRepositoryV1 개발)

    ItemRepository 인터페이스를 구현한 JdbcTemplateRepositoryV1를 개발한다. 

     

    JdbcTemplate 필드 변수 선언 (DataSource 필요)

    private final JdbcTemplate template;
    
    // JdbcTemplate은 Connection이 필요하기 때문에 DataSource 주입이 필요함.
    public JdbcTemplateItemRepositoryV1(DataSource dataSource) {
        this.template = new JdbcTemplate(dataSource);
    }
    • JdbcTemplate을 한번만 생성해서 주입 받은 후, 사용한다.
      1. DataSource를 클래스에서 주입 받아서 JdbcTemplate을 생성해서 사용
      2. Config 클래스에서 JdbcTemplate Bean 생성 후, 주입 받아서 사용 

     

    JdbcTemplate을 이용한 save() 개발

    @Override
    public Item save(Item item) {
    
        String sql = "insert into item(item_name, price, quantity) values (?,?,?)";
    
        // DB에서 생성해준 ID값을 가져오는 방법
        KeyHolder keyHolder = new GeneratedKeyHolder();
        template.update(connection -> {
            PreparedStatement ps = connection.prepareStatement(sql, new String[]{"id"});
            ps.setString(1, item.getItemName());
            ps.setInt(2, item.getPrice());
            ps.setInt(3, item.getQuantity());
            return ps;
        }, keyHolder);
    
        // DB에 들어간 Key 값을 KeyHolder가 가지고 있고, 이 값을 KeyHolder가 Return 해준다.
        long key = keyHolder.getKey().longValue();
        item.setId(key);
        return item;
    }
    • 현재 PK값 관리 전략은 Identity임
      • DB에 데이터가 저장되는 순간, DB에서 PK값을 설정해준다. 따라서 Query에는 Id값을 넣지 않는다. 
      • KeyHolder는 DB에서 생성된 ID값을 가지고 있는다. 
    • Identity 전략을 사용할 때는 위 형식으로 JdbcTemplate을 사용함
      • 익명함수로 Connection을 처리함.
      • PreparedStatemnet에 sql과 "id"값을 넘겨준 후, 파라미터 바인딩 처리한다. 

    위의 형식으로 처리하게 되면 KeyHolder는 생성된 PK값을 가지고 있게 된다. 따라서 생성된 PK 값을 기존 객체에다가 처리해주면, DB와 Application 상의 상태가 동기화 된다. 

     

     

    JdbcTemplate을 이용한 update() 개발

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        String sql = "update item set item_name=?, price=?, quantity=? where id=?";
        template.update(sql,
                updateParam.getItemName(),
                updateParam.getPrice(),
                updateParam.getQuantity(),
                itemId);
    }
    • update는 update()를 이용해서 처리함.
      • Parameter Binding을 나열하는 방식으로 처리할 수 있음.
      • 물론, PreparedSteatement를 직접 만들어서 처리할 수도 있음. 
    template.update(sql, ps -> {
        ps.setString(1,updateParam.getItemName());
        ps.setInt(2, updateParam.getPrice());
        ps.setInt(3, updateParam.getQuantity());
    });

     

     

    JdbcTemplate을 이용한 findById() 개발

    @Override
    public Optional<Item> findById(Long id) {
        String sql = "select id, item_name, price, quantity from item where id = ?";
        try {
            Item item = template.queryForObject(sql, itemRowMapper(), id);
            return Optional.of(item);
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty(); 
        }
    }
    • QueryForObject()
      • 검색 결과를 하나의 객체로 바인딩해서 가져온다. 
      • 검색 결과가 없는 경우 EmptyResultDataAcessException이 발생함. 
    • QueryForObject를 사용하는 경우, 위의 Exception을 고려해서 Try ~ Catch 문을 사용해야함.
    • QueryForObject 사용 시에는 객체로 Mapping 해줄 RowMapper가 필요함. 

     

    private RowMapper<Item> itemRowMapper() {
        return (rs, rowNum) -> {
            Item item = new Item();
            item.setId(rs.getLong("id"));
            item.setItemName(rs.getString("item_name"));
            item.setPrice(rs.getInt("price"));
            item.setQuantity(rs.getInt("quantity"));
            return item;
        };
    }
    • QueryForObject에서 사용하는 RowMappe를 생성함.
      • Query 결과로 받아온 ResultSet + RowNum을 바탕으로 객체를 만들어주고, Return 해주는 역할을 한다. 
      • 순수 Jdbc를 사용할 경우, ResultSet의 Cursor를 직접 옮겼어야 했는데, RowMapper는 이 Loop 부분을 해결해준다. 

     

     

     

    JdbcTemplate을 이용한 FindAll() 개발

    @Override
    public List<Item> findAll(ItemSearchCond cond) {
        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();
    
        String sql = "select id, item_name, price, quantity from item";
    
        // 동적 쿼리ㅏ
        if (StringUtils.hasText(itemName) || maxPrice != null) {
            sql += " where";
        }
    
        boolean andFlag = false;
        List<Object> param = new ArrayList<>();
        if (StringUtils.hasText(itemName)) {
            sql += " item_name like concat('%',?,'%')";
            param.add(itemName);
            andFlag = true;
        }
    
        if (maxPrice != null) {
            if (andFlag) {
                sql += " and";
            }
            sql += " price <= ?";
            param.add(maxPrice);
        }
    
        log.info("sql = {}", sql);
        // Query는 List 가져올 때 사용. QueryForObject는 전체를 가져올 때
        return template.query(sql, itemRowMapper());
    }
    • FindAll() 메서드는 ItemSearchCond가 전달됨.
      • ItemSearchCond는 검색 조건을 담은 DTO임. 
      • 검색 조건이 모두 null일 경우, 실제 SQL에서는 모든 데이터가 전체 검색되기 때문에 데이터 유/무에 따라서 동적 쿼리를 작성해야함. 
    • JdbcTemplate을 사용할 경우, String 기반으로 쿼리를 작성한다. 따라서 위와 같이 복잡한 형태로 동적 쿼리를 작성해야함. 

     

    JDBC 파라미터 바인딩 (JdbcTempalteItemRepositoryV2)

    개발을 할 때는 코드를 몇줄 줄이는 편리함도 중요하지만, 모호함을 제거해서 코드를 명확하게 만드는 것이 유지보수 관점에서 매우 중요하다. 

    JdbcTemplate을 기본으로 사용하면 파라미터는 순서대로 바인딩된다. 

    JdbcTemplate은 String으로 파라미터 바인딩이 되기 때문에 협업하는 입장에서 누군가 파라미터의 순서를 바꾸게 될 경우, 잘못된 파라미터끼리 바인딩 될 수 있다. 그리고 그 상황을 개발자들이 인지하지 못할 수 있다. 위처럼 파라미터를 순서대로 바인딩 하는 것은 편리하기는 하지만 순서가 맞지 않아서 버그가 발생할 수 있으므로 주의해서 사용해야 한다. 

    위의 SQL을 실행하게 되면 다음과 같이 파라미터 바인딩이 진행된다. 가격와 양이 서로 다른 값에 매칭되는 것이다. 이런 문제는 DB까지 다시 복원을 해야되기 때문에 매우 중대한 타격을 준다. 따라서 이런 부분을 해결하기 위해 NamedParameterJdbcTemplate을 사용한다. 

     

    NamedParameterJdbcTemplate

    private final NamedParameterJdbcTemplate template;
    
    public JdbcTemplateItemRepositoryV2(DataSource dataSource) {
        this.template = new NamedParameterJdbcTemplate(dataSource);
    }
    • 앞서 발생한 파라미터 바인딩 문제를 해결하기 위해 NamedParameterJdbcTemplate을 사용한다. 
    • NamedParamaterJdbcTemplate은 JdbcTemplate과 동일한 형태로 생성해서 사용한다. 

     

    NamedParameterJdbcTemplate의 사용방법

    // JdbcTemplate
    String sql = "insert into item(item_name, price, quantity) values (?,?,?)";
    template.update(connection -> {
                
    	PreparedStatement ps = connection.prepareStatement(sql, new String[]{"id"});
    	ps.setString(1, item.getItemName());
    	ps.setInt(2, item.getPrice());
    	ps.setInt(3, item.getQuantity());
        return ps;}, keyHolder);
    
    // NamedParameterJdbcTemplate
    String sql = "insert into item(item_name, price, quantity) values (:itemName,:price,:quantity)";
    BeanPropertySqlParameterSource param = new BeanPropertySqlParameterSource(item);
    template.update(sql, param, keyHolder);
    • JdbcTemplate은 Parameter 바인딩을 위해 "?"를 사용하고,  PreparedStatement에 값을 직접 셋팅했었다.
    • NamedParameterJdbcTemplate은 PreparedStatemnet를 사용하는 것 대신, Parameter Map을 넘겨주는 방식으로 처리가 된다.
    • 이 때 Parameter Map에는 객체의 Parameter와 DB에 들어갈 값을 Binding 하는데, 이 때 String 기반에서 많이 벗어나게 된다. 

     

    1. Save() 메서드 : BeanPropertySqlParameterSource

    @Override
    public Item save(Item item) {
    
        String sql = "insert into item(item_name, price, quantity) " +
                "values (:itemName,:price,:quantity)";
    
        BeanPropertySqlParameterSource param = new BeanPropertySqlParameterSource(item);
        KeyHolder keyHolder = new GeneratedKeyHolder();
        template.update(sql, param, keyHolder);
    
        long key = keyHolder.getKey().longValue();
        item.setId(key);
        return item;
    }
    • NamedParameterJdbcTemplate을 사용하기 때문에 "?"가 아닌 ":parameter" 형식으로 SQL을 작성해야함.
    • NamedParameterJdbcTemplate을 사용할 때는 반드시 Parameter Map을 넘겨줘야함. 
    • BeanPropertySqlParameterSource에 DB에 넣을 객체(item)을 전달해주면, item은 "필드 명 : 값" 형식으로 Parameter Map에 저장됨. 

     

    2. Update() 메서드 : MapSqlParameterSource로 구현 

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
    
        String sql = "update item set item_name=:itemName, price=:price, quantity=:quantity where id=:id";
    
        MapSqlParameterSource param = new MapSqlParameterSource()
                .addValue("itemName", updateParam.getItemName())
                .addValue("price", updateParam.getPrice())
                .addValue("quantity", updateParam.getQuantity())
                .addValue("id", itemId);
    
        template.update(sql, param);
    }
    • MapSqlParameterSource를 사용할 수 있음. 
    • MapSqlParameterSource에는 값을 하나씩 "필드명 / 값" 형식으로 추가해서 만들고, SQL 실행 시 전달할 수 있음. 

     

    FindById() : Map으로 구현

    @Override
    public Optional<Item> findById(Long id) {
        String sql = "select id, item_name, price, quantity from item where id = :id";
    
        try {
            Map<String, Long> param = Map.of("id", id);
            Item item = template.queryForObject(sql, param,itemRowMapper());
            return Optional.of(item);
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty(); // 이 문제가 발생했다는 것은 데이터가 없다는 것이기 때문에 Empty를 반환해준다.
        }
    }
    • 앞선 클래스를 사용하지 않더라도, 직접 Map에 값을 넣어서 SQL 실행 시 전달하는 방법도 있음. 

     

     

    3. FindAll() : BeanPropertySqlParameterSource

    @Override
    public List<Item> findAll(ItemSearchCond cond) {
        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();
    
        SqlParameterSource param = new BeanPropertySqlParameterSource(cond);
        String sql = "select id, item_name, price, quantity from item";
    
        // 동적 쿼리
        if (StringUtils.hasText(itemName) || maxPrice != null) {
            sql += " where";
        }
    
        boolean andFlag = false;
        if (StringUtils.hasText(itemName)) {
            sql += " item_name like concat('%',:itemName,'%')";
            andFlag = true;
        }
    
        if (maxPrice != null) {
            if (andFlag) {
                sql += " and";
            }
            sql += " price <= :maxPrice";
        }
    
        log.info("sql = {}", sql);
        return template.query(sql, param,itemRowMapper());
    }
    • 동적 쿼리에도 Parameter Map을 전달할 수 있음. 
    • SQL 작성 시, 내부적으로 "?" 대신 ":parameterName"으로 바꿔주기만 하면 됨. 
    • SQL 실행할 때, parameter Map을 전달해줘야함. 

     

     

    RowMapper 생성

    private RowMapper<Item> itemRowMapper() {
        // Spring이 제공하는 RowMapper를 이용해서 넣어주면 됨.
        return BeanPropertyRowMapper.newInstance(Item.class); // Camel 변환 지원함.
    }
    • 기존에는 RowMapper 인터페이스를 익명 클래스로 직접 구현해서 사용했었음. 
    • BeanPropertyRowmapper.newInstance()에 맵핑해야할 클래스를 넘겨주면, 자동으로 Mapper가 생성됨. 
    • 예를 들어 DB에서 조회한 결과가 'select id, price'라고 한다면 다음 코드를 작성해준다. (실제로는 리플렉션 같은 기능 사용)
    ```java
    Item item = new Item();
    item.setId(rs.getLong("id"));
    item.setPrice(rs.getInt("price"));
    ```
    • DB에서 조회한 결과 이름을 기반으로 setId(), setPrice()처럼 자바 빈 프로퍼티 규약에 맞춘 메서드를 호출함.

     

    RowMapper의 문제점 → 별칭(as) 사용

    rowMapper는 자바 빈 프로퍼티 규약으로 값을 Mapping 해준다.

    • 객체에는 itemName, DB에는 item_name으로 저장됨
    • 객체에는 userName, DB에는 member_name으로 저장됨 

    그런데 위와 같은 두 가지 문제가 존재할 수 있다. 첫번째 경우처럼 itemName(카멜 표기법)과 item_name(스네이크 표기법)의 차이는 rowMapper가 자동으로 처리를 해준다. 그렇지만 두번째 경우는 자동으로 처리해주지 않는다. 

     

    ``` SQL
    select member_name as userName
    ```

    이럴 때는 별칭(as)를 사용해서 SQL 조회 결과의 이름을 변경하는 방법이 있다. 주로 데이터베이스 컬럼 이름과 객체 이름이 완전히 다를 때 문제를 해결할 수 있다. 예를 들어 member_name으로 DB에 저장된 값을 username에 저장할 수 있다. 

     

    이름 지정 파라미터 

    • key : ":파라미터 이름"으로 지정한 파라미터 이름
    • value : 해당 파라미터의 값

    파라미터를 전달하려면 Map 형식의 Key/Value 데이터 구조를 만들어서 전달해야한다. 

    • Map
    • SqlParameterSource (인터페이스)
      • MapSqlParameterSource
      • BeanPropertySqlParameterSource

    이름 지정 바인딩에서 자주 사용하는 파라미터의 종류는 크게 3가지가 있다. 

     

     1. Map

    Map<String, Long> param = Map.of("id", id);
    Item item = template.queryForObject(sql, param,itemRowMapper());
    return Optional.of(item);
    • 단순히 Map을 사용하면 된다. 

     

    2. MapSqlParmeterSource

    MapSqlParameterSource param = new MapSqlParameterSource()
            .addValue("itemName", updateParam.getItemName())
            .addValue("price", updateParam.getPrice())
            .addValue("quantity", updateParam.getQuantity())
            .addValue("id", itemId);
    
    template.update(sql, param);
    • SQL 타입을 지정할 수 있는 등 SQL에 특화된 기능을 제공한다. 
    • MapSqlParameterSource는 메서드 체인을 통해 편리한 사용법도 제공한다. 

     

    3. BeanPropertySqlParameterSource (Best)

    @Override
    public List<Item> findAll(ItemSearchCond cond) {
        ...
        
        SqlParameterSource param = new BeanPropertySqlParameterSource(cond);
        
        ...
        
    }
    • BeanPropertySqlParameterSource는 객체를 넘겨주면 자동으로 객체의 클래스 정보를 바탕으로 필요한 형태의 값을 만들어 줌. 
    • 자바 빈 프로퍼티 규약을 통해서 자동으로 파라미터 객체를 생성한다. (getItemName() → itemName)
      • 예를 들어서 getItemName()이 있으면, key는 "itemName"이 된다. 
    String sql = "update item set item_name=:itemName, price=:price, quantity=:quantity where id=:id";
    
    //NamedParameterJdbcTemplate을 사용하면서 다음과 같이 해결함.
    MapSqlParameterSource param = new MapSqlParameterSource()
            .addValue("itemName", updateParam.getItemName())
            .addValue("price", updateParam.getPrice())
            .addValue("quantity", updateParam.getQuantity())
            .addValue("id", itemId);
    • BeanPropertySqlParameterSource를 항상 사용할 수 있는 것은 아님.
      • update()에서는 SQL에 :id를 바인딩 해야하는데, update()에서 사용하는 ItemUpdateDto에는 itemId가 없다. 따라서 BeanPropertySqlParameterSource를 사용할 수 없음.
      • 이럴 때는  MapSqlParameterSource를 사용해야함. 

     

     

    JdbcTemplate - SimpleJdbcInsert (JdbcTemplateRepositoryV3)

    JdbcTemplate은 INSERT SQL을 직접 작성하지 않아도 되도록 'SimpleJdbcInsert'라는 기능을 제공한다.

    사용하기 위한 준비

    public JdbcTemplateItemRepositoryV3(DataSource dataSource) {
        this.template = new NamedParameterJdbcTemplate(dataSource);
    
        // Item 테이블을 안다 → DB에서 메타 데이터 읽어서 자동 파싱을 한다.
        this.jdbcInsert = new SimpleJdbcInsert(dataSource)
                .withTableName("item")
                .usingGeneratedKeyColumns("id") // 자동으로 키값 생성되는 것
                .usingColumns("item_name", "price", "quantity"); // 이 부분 생략 가능
    }
    • 생성해서 클래스가 가지고 있어야 함.
    • 의존관계 주입은 DataSource가 필요함.
    • 문법
      • withTableName : 데이터를 저장할 테이블 명을 지정함.
      • usingGeneratedKeyColumns : Key를 생성하는 PK 컬럼 명을 지정한다. 
      • usingColumns : INSERT SQL에 사용할 컬럼을 지정함. 특정 값만 저장하고 싶을 때 사용한다. 생략 가능함

    SimpleJdbcInsert는 생성 시점에 DB 테이블의 메타 데이터를 조회한다. 따라서 어떤 컬럼이 있는지 확인할 수 있음으로 usingColumns를 생략할 수 있다. 특정 컬럼만 지정하고 싶을 때 사용하면 된다. 

    어플리케이션을 실행해보면 위와 같이 SimpleJdbcInsert가 어떤 INSERT SQL을 만들어서 사용하는지 로그로 확인할 수 있음. 

     

     

    JdbcTemplate 기능 정리

    주요 기능

    • JdbcTemplate
      • 순서 기반 파라미터 바인딩을 지원한다.
    • NamedParameterJdbcTemplate
      • 이름 기반 파라미터 바인딩을 지원한다. (권장)
    • SimpleJdbcInsert
      • INSERT SQL을 편리하게 사용할 수 있다. 
    • SimpleJdbcCall
      • Stored Proceduer를 편리하게 호출할 수 있음. 
    • 각종 메뉴얼

     

    주요 메서드

    • queryForObject : 단건 조회
    • query : 여러 건 조회 

     

    조회

    단건 조회 → 숫자 조회

    int rowCount = jdbcTemplate.queryForObject("select count(*) from t_actor",
    Integer.class);

    단건 조회 → 숫자 조회 / 파라미터 바인딩

    int countOfActorsNamedJoe = jdbcTemplate.queryForObject(
     			"select count(*) from t_actor where first_name = ?", Integer.class,
    			"Joe");

     

     

    단건 조회 → 문자 조회

    String lastName = jdbcTemplate.queryForObject("select last_name from t_actor where id = ?",
     				String.class, 1212L);

    단건 조회 → 객체 조회

    Actor actor = jdbcTemplate.queryForObject(
     		"select first_name, last_name from t_actor where id = ?",
     			(resultSet, rowNum) -> {
     				Actor newActor = new Actor();
     				newActor.setFirstName(resultSet.getString("first_name"));
     				newActor.setLastName(resultSet.getString("last_name"));
     				return newActor;
     		},1212L);
    • 객체를 Mapping하기 위해 RowMapper를 넘겨줘야 함. 

    목록 조회 → 객체

    List<Actor> actors = jdbcTemplate.query(
    		 "select first_name, last_name from t_actor",
     				(resultSet, rowNum) -> {
     					Actor actor = new Actor();
     					actor.setFirstName(resultSet.getString("first_name"));
     					actor.setLastName(resultSet.getString("last_name"));
     					return actor;
     			});
    private final RowMapper<Actor> actorRowMapper = (resultSet, rowNum) -> {
                 Actor actor = new Actor();
                 actor.setFirstName(resultSet.getString("first_name"));
                 actor.setLastName(resultSet.getString("last_name"));
                 return actor;
    };
    
    public List<Actor> findAllActors() {
    		 return this.jdbcTemplate.query("select first_name, last_name from t_actor",
    				actorRowMapper);

     

    변경 (INSERT / UPDATE / DELETE)

    데이터를 변경할 때는 jdbcTempalte.update()를 사용하면 된다. 반환값은 int이고, SQL 실행 결과에 영향을 받은 Row 수를 반환함. 

    등록

    template.update(
            "insert into t_actor (first_name, last_name) values (?,?)",
            "Leonor", "watling");

    수정

    template.update(
            "update t_actor set last_name = ? where id = ? ",
            "Banjo", 5276L);

    삭제

    template.update(
            "delete from t_actor where id = ?",
            Long.valueOf(actorId));

     

    기타 기능

    임의의 SQL을 실행할 때는 execute()를 사용하면 된다. 테이블을 생성하는 DDL등에 사용할 수 있음. 

    template.execute(
            "create table mytable (id integer, name varchar(100))");

     

    JdbcTemplate 정리

    • 가장 간단하게 SQL을 사용하기 위해서는 JdbcTemplate을 사용하면 됨. 
    • JdbcTemplate의 최대 단점은 동적 쿼리 작성 문제를 해결하지 못한다. 이 부분은 굉장히 번거롭다.
    • 동적 쿼리 작성을 해결하기 위해 MyBatis / Query DSL을 사용함.

    댓글

    Designed by JB FACTORY