이 글은 강의 : 김영한님의 - "[스프링 DB 2편 - 데이터 접근 활용 기술]"을 듣고 정리한 내용입니다. 😁😁
🎈 SQL을 직접 사용하는 경우에 스프링이 제공하는 JdbcTemplate은 아주 좋은 선택지다. JdbcTemplate 은 JDBC를 매우 편리하게 사용할 수 있게 도와준다.
🎈 설정이 간편하다.
🎈 반복문제 해결
JdbcTemplate은 템플릿 콜백 패턴을 사용해서, JDBC를 직접 사용할 때 발생하는 대부분의 반복 작업을 처리해준다.
개발자는 SQL을 작성하고, 전달할 파라미터 정의 + 응답 값 매핑만 하면 된다.
아래 반복 작업을 대신 해준다.
커넥션 획득
statement를 준비하고 실행
결과를 반복하도록 루프를 실행
Connetion, Statement, ResultSet 종료
트랜잭션을 위한 Connection 동기화
예외 발생 시, 스프링 예외 변환기 실행
🎈 동적 SQL을 해결하기 어렵다.
🎈 동적 SQL은 MyBatis / Query DSL을 이용해서 처리할 수 있다.
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
//JdbcTemplate 추가
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
//H2 데이터베이스 추가
runtimeOnly 'com.h2database:h2'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
//테스트에서 lombok 사용
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
}
🎈 query() : 실행 결과가 List 형태로 반환됨. QueryDSL의 Fetch와 동일
🎈 queryForObject : 실행 결과는 하나의 객체만 반환됨. QueryDSL의 FetchOne과 동일
🧨 ItemRepository 인터페이스를 구현한 JdbcTemplateRepositoryV1를 개발한다.
private final JdbcTemplate template;
// JdbcTemplate은 Connection이 필요하기 때문에 DataSource 주입이 필요함.
public JdbcTemplateItemRepositoryV1(DataSource dataSource) {
this.template = new JdbcTemplate(dataSource);
}
🎃 기본
🎃 JdbcTemplate을 한번만 생성해서 주입 받은 후, 사용한다.
@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 - 개발자가 ID값을 직접 지정하는 것이 아니라 비워두고 저장해야한다. 그러면 데이터베이스가 PK인 ID를 대신 생성해준다.
🎃 Identity 전략을 사용할 때는 위 형식으로 JdbcTemplate을 사용한다.
🎈 위의 형식으로 처리하게 되면 KeyHolder는 생성된 PK값을 가지고 있게 된다. 따라서 생성된 PK 값을 기존 객체에다가 처리해주면, DB와 Application 상의 상태가 동기화 된다.
@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()를 이용해서 처리함.
template.update(sql, ps -> {
ps.setString(1,updateParam.getItemName());
ps.setInt(2, updateParam.getPrice());
ps.setInt(3, updateParam.getQuantity());
});
@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()
🎃 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에서 사용하는 RowMapper를 생성함.
Query 결과로 받아온 ResultSet + RowNum을 바탕으로 객체를 만들어주고, Return 해주는 역할을 한다.
순수 Jdbc를 사용할 경우, ResultSet의 Cursor를 직접 옮겼어야 했는데, RowMapper는 이 Loop 부분을 해결해준다.
@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가 전달됨.
🎈 JdbcTemplate을 사용할 경우, String 기반으로 쿼리를 작성한다. 따라서 위와 같이 복잡한 형태로 동적 쿼리를 작성해야함.
JdbcTemplate은 String으로 파라미터 바인딩이 되기 때문에 협업하는 입장에서 누군가 파라미터의 순서를 바꾸게 될 경우, 잘못된 파라미터끼리 바인딩 될 수 있다. 그리고 그 상황을 개발자들이 인지하지 못할 수 있다. 위처럼 파라미터를 순서대로 바인딩 하는 것은 편리하기는 하지만 순서가 맞지 않아서 버그가 발생할 수 있으므로 주의해서 사용해야 한다.
🎃 위의 SQL을 실행하게 되면 다음과 같이 파라미터 바인딩이 진행된다. 가격와 양이 서로 다른 값에 매칭되는 것이다.(심각한 문제) 이런 문제는 DB까지 다시 복원을 해야되기 때문에 매우 중대한 타격을 준다. 따라서 이런 부분을 해결하기 위해 NamedParameterJdbcTemplate을 사용한다.
JdbcTemplate은 이런 문제를 보완하기 위해 NamedParameterJdbcTemplate 라는 이름을 지정해서 파라미터를 바인딩 하는 기능을 제공한다.
private final NamedParameterJdbcTemplate template;
public JdbcTemplateItemRepositoryV2(DataSource dataSource) {
this.template = new NamedParameterJdbcTemplate(dataSource);
}
🎃 앞서 발생한 파라미터 바인딩 문제를 해결하기 위해 NamedParameterJdbcTemplate을 사용한다.
🎃 NamedParamaterJdbcTemplate은 JdbcTemplate과 동일한 형태로 생성해서 사용한다. 내부에 dataSource 필요.
// 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);
@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;
}
@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 실행 시 전달할 수 있다.
@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 실행 시 전달하는 방법도 있다.
@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());
}
private RowMapper<Item> itemRowMapper() {
// Spring이 제공하는 RowMapper를 이용해서 넣어주면 됨.
return BeanPropertyRowMapper.newInstance(Item.class); // Camel 변환 지원함.
}
Item item = new Item();
item.setId(rs.getLong("id"));
item.setPrice(rs.getInt("price"));
RowMapper의 문제점 → 별칭(as) 사용
RowMapper는 자바 빈 프로퍼티 규약으로 값을 Mapping 해준다.
그런데 위와 같은 두 가지 문제가 존재할 수 있다. 첫번째 경우처럼 itemName(카멜 표기법)과 item_name(스네이크 표기법)의 차이는 rowMapper가 자동으로 처리를 해준다. 그렇지만 두번째 경우는 자동으로 처리해주지 않는다.
select member_name as userName
🎃 이럴 때는 별칭(as)를 사용해서 SQL 조회 결과의 이름을 변경하는 방법이 있다. 주로 데이터베이스 컬럼 이름과 객체 이름이 완전히 다를 때 문제를 해결할 수 있다. 예를 들어 member_name으로 DB에 저장된 값을 username에 저장할 수 있다.
🎈 key : ":파라미터 이름"으로 지정한 파라미터 이름
🎈 value : 해당 파라미터의 값
이름 지정 바인딩에서 자주 사용하는 파라미터의 종류는 크게 3가지가 있다.
🎈 Map
🎈 SqlParameterSource
Map<String, Long> param = Map.of("id", id);
Item item = template.queryForObject(sql, param,itemRowMapper());
return Optional.of(item);
MapSqlParameterSource param = new MapSqlParameterSource()
.addValue("itemName", updateParam.getItemName())
.addValue("price", updateParam.getPrice())
.addValue("quantity", updateParam.getQuantity())
.addValue("id", itemId);
template.update(sql, param);
@Override
public List<Item> findAll(ItemSearchCond cond) {
...
SqlParameterSource param = new BeanPropertySqlParameterSource(cond);
...
}
🎈 BeanPropertySqlParameterSource는 객체를 넘겨주면 자동으로 객체의 클래스 정보를 바탕으로 필요한 형태의 값을 만들어 줌.
🎈 자바 빈 프로퍼티 규약을 통해서 자동으로 파라미터 객체를 생성한다. (getItemName() → 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은 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가 필요함.
🎈 문법
🎃 SimpleJdbcInsert는 생성 시점에 DB 테이블의 메타 데이터를 조회한다. 따라서 어떤 컬럼이 있는지 확인할 수 있음으로 usingColumns를 생략할 수 있다. 특정 컬럼만 지정하고 싶을 때 사용하면 된다.
❤ JdbcTemplate
❤ NamedParameterJdbcTemplate
❤ SimpleJdbcInsert
JdbcTemplate에 대한 사용법은 스프링 공식 메뉴얼에 자세히 소개되어 있다. 기능 간단히 정리 !!
❤각종 메뉴얼
❤ 주요 메서드
단건 조회 → 숫자 조회
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);
목록 조회 → 객체
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);
데이터를 변경할 때는 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));
template.execute(
"create table mytable (id integer, name varchar(100))");
실무에서 가장 간단하고 실용적인 방법으로 SQL을 사용하려면 JdbcTemplate을 사용하면 된다.
JPA와 같은 ORM 기술을 사용하면서 동시에 SQL을 직접 작성해야 할 때가 있는데, 그때도 JdbcTemplate을 함께 사용하면 된다.
그런데 JdbcTemplate의 최대 단점이 있는데, 바로 동적 쿼리 문제를 해결하지 못한다는 점이다. 그리고 SQL을 자바 코드로 작성하기 때문에 SQL 라인이 코드를 넘어갈 때 마다 문자 더하기를 해주어야 하는 단점도 있다.
🎈 동적 쿼리 문제를 해결하면서 동시에 SQL도 편리하게 작성할 수 있게 도와주는 기술이 바로 MyBatis이다