-
[스프링 인 액션] Chapter 3 - 데이터로 작업하기 :: JDBC를 사용해서 데이터 읽고 쓰기개발서적읽기/Spring in Action 제 5판 2020. 7. 29. 13:42
이 장에서는 RDB를 이용해 Data Persistence(저장 및 지속성 유지) 기능을
타코 클라우드 애플리케이션에 추가한다.
RDB를 사용하는 방법 몇 가지가 있다. 그 중 JDBC와 JPA가 가장 많이 사용된다.
JDBC와 JPA는 상용구 코드(Boilerplate code)를 상당히 없애준다는 장점이 있다.
스프링은 위 두 방법 모두 지원하고, 쉽게 사용할 수 있도록 해준다.
상용구 코드(Boilerplate code)란?
언어의 문법이나 형식 등의 이유로 거의 수정 없이
여러 곳에 반복적으로 사용해야 하는 코드
먼저 스프링에서의 JDBC 사용법을 익혀보자.
스프링은 JdbcTemplate 클래스를 사용하여 JDBC를 지원한다.
JdbcTemplate은 JDBC를 사용할 때 요구되는 모든 형식적이고 상투적인 코드 없이
개발자가 RDB에 대한 SQL 연산을 수행할 수 있는 방법을 제공한다.
JdbcTemplate이 무슨 일을 하는지 이해하기 위해 우선 다음 예를 살펴보자.
@Override
public Ingredient findById(String id) {
Connection connection null;
PreparedStatement statement null;
ResultSet resultSet null;
try {
connection dataSource.getConnection();
statement = connection.prepareStatement("select id, name, type from Ingredient where id = ?");
statement.setString(1, id);
resultSet = statement.executeQuery();
Ingredient ingredient = null;
if (resultSet.next()) {
ingredient = new Ingredient(
resultSet.getString("id"),
resultSet.getString("name"),
Ingredient.Type.valueof(resultSet.getString("type")));
}
return ingredient;
} catch (SQLException e) {
// 여기서는 무엇을 해야 할까?
} finally {
if (resultSet != null) {
try {
resultSet.close();
} catch (SQLException e) {
}
}
if (statement != null) {
try {
statement.close();
} catch (SQLException e) {
}
}
if (connection != null) {
try {
connection.close();
} catch (SQLException e) {
}
}
}
return null;
}위 코드 어딘가에는 식자재를 조회하는 쿼리가 있을 것이다.
하지만 찾기가 힘들다.
왜냐하면 DB 연결 생성, 명령문 생성, 그리고 연결과 명령문 및 결과 세트를 닫고
클린업하는 코드들로 쿼리 코드가 둘러싸여 있기 때문이다.
설상가상으로 연결이나 명령문 등의 객체를 생성할 때
또는 쿼리를 수행할 때 얼마든지 많은 일들이 잘못될 수 있다.
따라서 SQLException 예외를 처리해야 한다.
하지만 이 처리도 문제의 해결 방법을 찾는데 도움이 될 수 도 있고 안될 수도 있다.
SQLException은 catch 블록으로 반드시 처리해야 하는 checked 예외다.
그러나 DB 연결 생성 실패나 작성 오류가 있는 쿼리와 같은 대부분의 흔한 문제들은
catch 블록에서 해결될 수 없다.
따라서 현재 메서드를 호출한 상위 코드로 예외 처리를 넘겨야 한다.
위 단점들을 해결한 코드를 보자.
private JdbcTemplate jdbc;
@Override
public Ingredient findById(String id) {
return jdbc.queryForObject("select id, name, type from Ingredient where id=?", this::mapRowToIngredient, id);
}
private Ingredient mapRowToIngredient(ResultSet rs, int rowNum) throws SQLException {
return new Ingredients(
rs.getString("id"),
rs.getString("name"),
Ingredient.Type.valueOf(rs.getString("type")));
}훨씬 간단해졌다.
또한 명령문이나 DB 연결 객체를 생성하는 코드도 사라졌다.
메서드의 실행이 끝난 후 객체들을 클린업하는 코드도 없다.
catch 블록에서 올바르게 처리할 수 없는 예외를 처리하는 코드 역시 없다.
쿼리를 수행하고 그 결과를 Ingredient 객체로 생성하는 것에 초점을 두는 코드만 존재한다.
지금부터는 이 JDBC를 타코 애플리케이션에 적용해보자.
■Persistence를 고려한 도메인 객체 수정하기
객체를 DB에 저장할 때는 해당 객체를 식별할 수 있는 필드를 추가하는 것이 좋다.
Ingredient 클래스는 이미 id 필드를 갖고 있다.
그러나 Taco와 Order에는 id 필드가 없으므로 추가해야 한다.
또한 Taco가 언제 생성되었는지, Order가 언제 생성되었는지 알면 유용하다.
그리고 객체가 저장된 날짜와 시간을 갖는 필드를 각 객체에 추가할 필요가 있다.
package tacos;
/*
* @USER JungHyun
* @DATE 2020-07-04
* @DESCRIPTION
*/
import lombok.Data;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.util.Date;
import java.util.List;
@Data
public class Taco {
private Long id;
private Date createdAt;
@NotNull
@Size(min = 5, message = "Name must be at least 5 characters long")
private String name;
@Size(min = 1, message = "You must choose at least 1 ingredient")
private List<String> ingredients;
}package tacos;
/*
* @USER JungHyun
* @DATE 2020-07-04
* @DESCRIPTION
*/
import lombok.Data;
import org.hibernate.validator.constraints.CreditCardNumber;
import javax.validation.constraints.Digits;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import java.util.Date;
@Data
public class Order {
private Long id;
private Date createdAt;
@NotBlank(message = "Name is required")
private String deliveryName;
@NotBlank(message = "Street is required")
private String deliveryStreet;
@NotBlank(message = "City is required")
private String deliveryCity;
@NotBlank(message = "State is required")
private String deliveryState;
@NotBlank(message = "Zip code is required")
private String deliveryZip;
@CreditCardNumber(message = "Not a valid credit card number")
private String ccNumber;
@Pattern(regexp = "^(0[1-9]|1[0-2])([\\/])([1-9][0-9])$", message = "Must be formatted MM/YY")
private String ccExpiration;
@Digits(integer = 3, fraction = 0, message = "Invalid CVV")
private String ccCVV;
}이제 JdbcTemplate을 사용해서 이 객체들을 DB에 읽고 쓰는 방법을 알아보자.
■JdbcTemplate 사용하기
JdbcTemplate을 사용하려면 JdbcTemplate을 프로젝트의 classpath에 추가해야 한다.
아래와 같이 스프링 부트의 JDBC 스타터 의존성을 빌드 명세에 추가한다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>또한 데이터를 저장하는 DB가 필요하다.
H2 내장 DB 의존성을 추가하자.
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>H2 DB는 의존성 추가와 함께 버전 정보도 추가해야 한다.
<properties>
<java.version>1.8</java.version>
<h2.version>1.4.196</h2.version>
</properties>JDBC repository 정의하기
식자재 repository는 다음 연산을 수행해야 한다.
1. DB의 모든 식자재 데이터를 조회하여 Ingredient 객체 컬렉션(List)에 넣는다.
2. id를 사용해서 하나의 Ingredient를 조회한다.
3. Ingredient 객체를 DB에 저장한다.
다음 IngredientRepository 인터페이스는 위의 3가지 연산을 정의한다.
package tacos.data;
/*
* @USER JungHyun
* @DATE 2020-07-29
* @DESCRIPTION
*/
import tacos.Ingredient;
public interface IngredientRepository {
Iterable<Ingredient> findAll();
Ingredient findById(String id);
Ingredient save(Ingredient ingredient);
}이제는 JdbeTemplate을 사용해서 DB 작업을 할 수 있도록
이 IngredientRepository 인터페이스를 구현해야 한다.
먼저 JdbcIngredientRepository 클래스를 생성하자.
package tacos.data;
/*
* @USER JungHyun
* @DATE 2020-07-29
* @DESCRIPTION
*/
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
@Repository
public class JdbcIngredientRepository {
private JdbcTemplate jdbc;
@Autowired
public JdbcIngredientRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
}@Repository 애노테이션은 @Controller와 @Component 외에 스프링이 정의하는
몇 안 되는 스트레오타입 애노테이션 중 하나다.
즉 JdbcIngredientRepository 클래스에 @Repository를 지정하면
스프링 컴포넌트 검색에서 이 클래스를 자동으로 찾아서
스프링 애플리케이션 컨텍스트의 빈으로 생성된다.
스트레오타입 애노테이션이란?
스프링에서 주로 사용하는 역할 그룹을 나타내는 애노테이션이다.
예를 들어, @Component는 클래스를 빈으로 지정하는 클래스 수준의 어노테이션이다.
@Repository는 @Component가 특화된, 데이터 액세스 관련 애토네이션이다.
@Controller 역시 @Component가 특화된,
해당 클래스를 스프링 웹 MVC 컨트롤러로 지정하는 애노테이션이다.
그리고 JdbcIngredientRepository 빈이 생성되면 @Autowired 애노테이션을 통해서
JdbcTemplate이 JdbcIngredientRepository 빈에 주입된다.
주입된 JdbcTemplate 변수는 DB의 데이터를 조회하고 추가하기 위해
다른 메서드에서 사용될 것이다.
JdbcIngredientRepository가 IngredientRepository 인터페이스를 구현하도록 수정해보자.
package tacos.data;
/*
* @USER JungHyun
* @DATE 2020-07-29
* @DESCRIPTION
*/
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import tacos.Ingredient;
import java.sql.ResultSet;
import java.sql.SQLException;
@Repository
public class JdbcIngredientRepository implements IngredientRepository {
private JdbcTemplate jdbc;
@Autowired
public JdbcIngredientRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public Iterable<Ingredient> findAll() {
return jdbc.query("select id, name, type from Ingredient", this::mapRowToIngredient);
}
@Override
public Ingredient findById(String id) {
return jdbc.queryForObject("select id, name, type from Ingredient where id = ?", this::mapRowToIngredient, id);
}
private Ingredient mapRowToIngredient(ResultSet rs, int rowNum) throws SQLException {
return new Ingredient(
rs.getString("id"),
rs.getString("name"),
Ingredient.Type.valueOf(rs.getString("type"))
);
}
}findAll과 findById 모두 유사한 방법으로 JdbcTemplate을 사용한다.
객체가 저장된 컬렉션을 반환하는 findAll 메서드는
JdbcTemplate의 query 메서드를 사용한다.
query 메서드는 두 개의 인자를 받는다.
첫 번째 인자는 쿼리를 수행하는 SQL이고
두 번째 인자는 스프링의 RowMapper 인터페이스를 구현한 mapRowToIngredient다.
이 메서드는 쿼리로 생성된 결과 세트(ResultSet 객체)의 행 개수만큼 호출되며
결과 세트의 모든 행을 각각 객체(Ingredient)로 생성하고 List에 저장한 후 반환한다.
또한 query 메서드는 해당 쿼리에서 요구하는 매개변수들의 내역을
마지막 인자로 받을 수 있다. 이 코드에선 생략하였다.
=> "mapRowToIngredient 메소드가 행 개수만큼 호출되며 결과 세트의 모든 행을
각각 객체로 생성하고, query 메소드가 이를 List에 저장하는것" 아닌가??
findById 메서드는 하나의 Ingredient 객체만 반환한다.
따라서 query 대신 JdbcTemplate의 queryForObject 메서드를 사용한다.
이 메서드는 query와 동일하게 실행되지만,
객체의 List를 반환하는 대신 하나의 객체만 반환하는 것이 다르다.
queryForObject 메서드의 첫 번째와 두 번째 인자는 query와 같으며
세 번째 인자로는 검색할 행의 id를 전달한다.
그러면 이 id가 첫 번째 인자로 전달된 SQL에 있는 물음표 대신 교체되어 쿼리에 사용된다.
데이터 추가하기
이제는 IngredientRepository의 save 메서드를 구현해보자.
JdbcTemplate의 update 메서드는
DB에 데이터를 추가하거나 변경하는 모든 쿼리에 사용될 수 있다.
@Override
public Ingredient save(Ingredient ingredient) {
jdbc.update("insert into Ingredient (id, name, type) values(?,?,?)",
ingredient.getId(),
ingredient.getName(),
ingredient.getType().toString());
return ingredient;
}JdbcTemplate의 update 메서드는 결과 세트의 데이터를
객체로 생성할 필요가 없으므로 query나 queryForObject보다 훨씬 간단하다.
JdbcIngredientRepository가 완성되었다.
이제 JdbcIngredientRepository를 DesignTacoController에 주입하고
하드코딩했던 Ingredient 객체의 List를 DB로부터 조회한 데이터로 바꿔보자.
package tacos.web;
/*
* @USER JungHyun
* @DATE 2020-07-04
* @DESCRIPTION
*/
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.Errors;
import tacos.Ingredient;
import tacos.Taco;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import tacos.data.IngredientRepository;
import javax.validation.Valid;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Controller
@RequestMapping("/design")
public class DesignTacoController {
private final IngredientRepository ingredientRepo;
@Autowired
public DesignTacoController(IngredientRepository ingredientRepo) {
this.ingredientRepo = ingredientRepo;
}
@GetMapping
public String showDesignForm(Model model) {
List<Ingredient> ingredients = new ArrayList<>();
ingredientRepo.findAll().forEach(i -> ingredients.add(i));
Ingredient.Type[] types = Ingredient.Type.values();
for (Ingredient.Type type : types) {
model.addAttribute(type.toString().toLowerCase(), filterByType(ingredients, type));
}
model.addAttribute("taco", new Taco());
return "design";
}
@PostMapping
public String processDesign(@Valid Taco design, Errors errors) {
if (errors.hasErrors()) {
return "design";
}
log.info("Processing design: " + design);
return "redirect:/orders/current";
}
private Object filterByType(List<Ingredient> ingredients, Ingredient.Type type) {
return ingredients.stream().filter(x -> x.getType().equals(type))
.collect(Collectors.toList());
}
}이제는 주입된 IngredientRepository의 findAll 메서드를
showDesignForm 메서드에서 호출한다.
findAll 메서드는 모든 식자재 데이터를 DB에서 조회한다.
그 다음에 타입별로 식자재가 필터링된다.
이제는 애플리케이션을 실행할 준비가 거의 다 됐다.
하지만 쿼리에서 참조되는 Ingredient 테이블로부터 데이터를 읽기 위해서는
먼저 이 테이블을 생성한 후 식자재 데이터를 미리 추가해 놓아야 한다.
■스키마 정의하고 데이터 추가하기
Ingredient 테이블 외에도 주문 정보와 타코 디자인 정보를 저장할 테이블들이 필요하다.
위 테이블들은 다음의 용도로 사용된다.
- Ingredient : 식자재 정보를 저장한다.
- Taco : 사용자가 식자재를 선택하여 생성한 타코 디자인에 관한 정보를 저장한다.
- Taco_Ingredients : Taco와 Ingredient 테이블 간의 관계를 나타내며,
Taco 테이블의 각 행에 대해 하나 이상의 행을 포함한다.
(하나의 타코에는 하나 이상의 식자재가 포함될 수 있다)
- Taco_Order : 주문 정보를 저장한다.
- Taco_Order_Tacos : Taco_Order와 Taco 테이블 간의 관계를 나타내며,
Taco_Order 테이블의 각 행에 대해 하나 이상의 행을 포함한다.
(한 건의 주문에는 하나 이상의 타코가 포함될 수 있다)
create table if not exists Ingredient (
id varchar(4) not null,
name varchar(25) not null,
type varchar(10) not null
);
create table if not exists Taco (
id identity,
name varchar(50) not null,
createdAt timestamp not null
);
create table if not exists Taco_Ingredients (
taco bigint not null,
ingredient varchar(4) not null
);
alter table Taco_Ingredients
add foreign key (taco) references Taco(id);
alter table Taco_Ingredients
add foreign key (ingredient) references Ingredient(id);
create table if not exists Taco_Order (
id identity,
deliveryName varchar(50) not null,
deliveryStreet varchar(50) not null,
deliveryCity varchar(50) not null,
deliveryState varchar(2) not null,
deliveryZip varchar(10) not null,
ccNumber varchar(16) not null,
CcExpiration varchar(5) not null,
CCCW varchar(3) not null,
placedAt timestamp not null
);
create table if not exists Taco_Order_Tacos (
tacoOrder bigint not null,
taco bigint not null
);
alter table Taco_Order_Tacos
add foreign key (tacoOrder) references Taco_Order(id);
alter table Taco_Order_Tacos
add foreign key (taco) references Taco(id);위 sql을 src/main/resources/schema.sql 이라는 이름의 파일로 저장한다.
delete from Taco_Order_Tacos;
delete from Taco_Ingredients;
delete from Taco;
delete from Taco_Order;
delete from Ingredient;
insert into Ingredient (id, name, type)
values ('FLTO', 'Flour Tortilla', 'WRAP');
insert into Ingredient (id, name, type)
values ('COTO', 'Corn Tortilla', 'WRAP');
insert into Ingredient (id, name, type)
values ('GRBF', 'Ground Beef', 'PROTEIN');
insert into Ingredient (id, name, type)
values ('CARN', 'Carnitas', 'PROTEIN');
insert into Ingredient (id, name, type)
values ('TMTO', 'Diced Tomatoes', 'VEGGIES');
insert into Ingredient (id, name, type)
values ('LETC', 'Lettuce', 'VEGGIES');
insert into Ingredient (id, name, type)
values ('CHED', 'Cheddar', 'CHEESE');
insert into Ingredient (id, name, type)
values ('JACK', 'Monterrey Jack', 'CHEESE');
insert into Ingredient (id, name, type)
values ('SLSA', 'Salsa', 'SAUCE');
insert into Ingredient (id, name, type)
values ('SRCR', 'Sour Cream', 'SAUCE');또한 위 sql을 src/main/resources/schema.sql 이라는 이름의 파일로 저장한다.
그리고 http://localhost:8080/design 페이지를 접속하면
SQL로 생성한 데이터를 확인할 수 있다.
■타코와 주문 데이터 추가하기
지금까지 JdbcTemplate을 사용해서 DB에 데이터를 저장하는 방법을 알아보았다.
JdbcIngredientRepository의 save 메서드는
JdbcTemplate의 update 메서드를 사용해서 Ingredient 객체를 DB에 저장한다.
앞으로는 더 많고 더 복잡하게 데이터를 저장해야 한다.
JdbcTemplate을 사용해서 데이터 저장하기
JdbcTemplate을 사용해서 데이터를 저장하는 방법은 다음 두 가지가 있다.
- update 사용
- SimpleJdbcInsert Wrapper 클래스 사용
먼저 update 메서드를 더 자세히 사용하는 방법을 알아보자.
앞의 식자재에서 했던 것처럼, 우선 타코와 주문 repository에서
Taco와 Order 객체를 저장하기 위한 인터페이스를 정의하자.
Taco 객체를 저장하는데 필요한 TacoRepository 인터페이스는 다음과 같다.
package tacos.data;
/*
* @USER JungHyun
* @DATE 2020-07-29
* @DESCRIPTION
*/
import tacos.Taco;
public interface TacoRepository {
Taco save(Taco taco);
}한편 사용자가 식자재를 선택하여 생성한 타코 디자인을 저장하려면
해당 타코와 연관된 식자재 데이터도 Taco_Ingredients 테이블에 저장해야 한다.
어떤 식자재를 해당 타코에 넣을지 알 수 있어야 하기 때문이다.
마찬가지로 주문을 저장하려면 해당 주문과 연관된 타코 데이터를
Taco_Order_Tacos 테이블에 저장해야 한다.
해당 주문에 어떤 타코들이 연관된 것인지 알 수 있어야 하기 때문이다.
이러한 이유로 식자재를 저장하는 것보다 타코와 주문을 저장하는 것이 조금 더 복잡하다.
TacoRepository의 구현체를 생성해보자.
package tacos.data;
/*
* @USER JungHyun
* @DATE 2020-07-29
* @DESCRIPTION
*/
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.jdbc.core.PreparedStatementCreatorFactory;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;
import tacos.Ingredient;
import tacos.Taco;
import java.sql.Timestamp;
import java.sql.Types;
import java.util.Arrays;
import java.util.Date;
@Repository
public class JdbcTacoRepository implements TacoRepository {
private JdbcTemplate jdbc;
public JdbcTacoRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public Taco save(Taco taco) {
long tacoId = saveTacoInfo(taco);
taco.setId(tacoId);
for (Ingredient ingredient : taco.getIngredients()) {
saveIngredientToTaco(ingredient, tacoId);
}
return taco;
}
private long saveTacoInfo(Taco taco) {
taco.setCreatedAt(new Date());
PreparedStatementCreatorFactory preparedStatementCreatorFactory = new PreparedStatementCreatorFactory(
"insert into Taco (name, createdAt) values (?, ?)",
Types.VARCHAR, Types.TIMESTAMP
);
preparedStatementCreatorFactory.setReturnGeneratedKeys(true);
PreparedStatementCreator psc =
preparedStatementCreatorFactory.newPreparedStatementCreator(
Arrays.asList(
taco.getName(),
new Timestamp(taco.getCreatedAt().getTime())));
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbc.update(psc, keyHolder);
return keyHolder.getKey().longValue();
}
private void saveIngredientToTaco(Ingredient ingredient, long tacoId) {
jdbc.update("insert into Taco_Ingredients (taco, ingredient) " + "values (?, ?)", tacoId, ingredient.getId());
}
}save 메서드에서는 우선 Taco 테이블에 각 식자재를 저장하는
saveTacoInfo 메서드를 호출한다.
그리고 이 메서드에서 반환된 타코 ID를 사용해서
타코와 식자재의 연관 정보를 저장하는 saveIngredientToTaco를 호출한다.
골치아픈 코드는 saveTacoInfo다.
Taco 테이블에 하나의 행을 추가할 때는 DB에서 생성되는 ID를 알아야 한다.
그래야만 각 식자재를 저장할 때 참조할 수 있기 때문이다.
식자재 데이터를 저장할 때 사용했던 update 메서드로는 생성된 타코 ID를
얻을 수 없으므로 여기서는 다른 update 메서드가 필요하다.
여기서 사용하는 update 메서드는 PreparedStatementCreator 객체와
KeyHolder 객체를 인자로 받는다. 생성된 타코 ID를 제공하는 것이
바로 이 KeyHolder다. 그러나 이것을 사용하기 위해서는
PreparedStatementCreator도 생성해야 한다.
PreparedStatementCreator 객체의 생성은 간단하지 않다.
실행할 SQL 명령과 각 쿼리 매개변수의 타입을 인자로 전달하여
PreparedStatemenCreatorFactory 객체를 생성하는 것으로 시작한다.
그리고 이 객체의 newPreparedStatementCreator를 호출하며
이 때 PreparedStatementCreator를 생성하기 위해
쿼리 매개변수의 값을 인자로 전달한다.
이렇게 하여 PreparedStatementCreator 객체가 생성되면
이 객체와 KeyHolder 객체를 인자로 전달하여 update를 호출할 수 있다.
그리고 update의 실행이 끝나면 keyholder.getKey().longValue()의 연속 호출로
타코 ID를 반환할 수 있다.
그 다음에 save 메서드로 제어가 복귀된 후 saveIngredientToTaco를 호출하여
Taco 객체의 List에 저장된 각 Ingredient 객체를 반복 처리한다.
saveIngredientToTaco 메서드는 더 간단한 형태의 update를 사용해서
타코 ID와 Ingredient 객체 참조를 Taco_Ingredients 테이블에 저장한다.
이제는 TacoRepository를 DesignTacoController에 주입하고
Taco를 저장할 때 사용하는 일만 남았다.
package tacos.web;
/*
* @USER JungHyun
* @DATE 2020-07-04
* @DESCRIPTION
*/
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.*;
import tacos.Ingredient;
import tacos.Order;
import tacos.Taco;
import tacos.data.IngredientRepository;
import tacos.data.TacoRepository;
import javax.validation.Valid;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Controller
@RequestMapping("/design")
@SessionAttributes("order")
public class DesignTacoController {
private final IngredientRepository ingredientRepo;
private TacoRepository tacoRepo;
@Autowired
public DesignTacoController(IngredientRepository ingredientRepo, TacoRepository tacoRepo) {
this.ingredientRepo = ingredientRepo;
this.tacoRepo = tacoRepo;
}
@ModelAttribute(name = "order")
public Order order() {
return new Order();
}
@ModelAttribute(name = "taco")
public Taco taco() {
return new Taco();
}
@GetMapping
public String showDesignForm(Model model) {
List<Ingredient> ingredients = new ArrayList<>();
ingredientRepo.findAll().forEach(i -> ingredients.add(i));
Ingredient.Type[] types = Ingredient.Type.values();
for (Ingredient.Type type : types) {
model.addAttribute(type.toString().toLowerCase(), filterByType(ingredients, type));
}
model.addAttribute("taco", new Taco());
return "design";
}
@PostMapping
public String processDesign(@Valid Taco design, Errors errors, @ModelAttribute Order order) {
if (errors.hasErrors()) {
return "design";
}
Taco saved = tacoRepo.save(design);
order.addDesign(saved);
return "redirect:/orders/current";
}
private Object filterByType(List<Ingredient> ingredients, Ingredient.Type type) {
return ingredients.stream().filter(x -> x.getType().equals(type))
.collect(Collectors.toList());
}
}DesignTacoController 생성자에서는
IngredientRepository와 TacoRepository를 인자로 받는다.
그리고 또 주목할 점은 DesignTacoController에 @SessionAttributes("order")가 추가되고
order 메서드와 taco 메서드에는 메서드 애노테이션인 @ModelAttribute가
추가되었다는 것이다.
taco 메서드와 동일하게 order 메서드의 @ModelAttribute 애노테이션은
Order 객체가 모델에 생성되도록 해준다.
그러나 하나의 세션에서 생성되는 Taco 객체와 다르게
Order는 다수의 HTTP 요청에 걸쳐 존재해야 한다.
다수의 Taco를 생성하고 그것들을 하나의 주문으로 추가할 수 있게 하기 위해서다.
이때 클래스 수준의 @SessionAttributes 애노테이션을
Order와 같은 모델 객체에 지정하면 된다.
그러면 세션에 계속 보존될 것이고 다수의 요청에 걸쳐 사용할 수 있다.
하나의 Taco 디자인을 실제로 저장하는 일은 processDesign 메서드에서 수행된다.
이제는 이 메서드에서 Taco 및 Errors 객체와 더불어 Order 객체도 인자로 받는다.
Order 매개변수에는 @ModelAttribute 애노테이션이 지정되었다.
이 매개변수의 값이 모델로부터 전달되어야 한다는 것과
스프링 MVC가 이 매개변수에 요청 매개변수를
바인딩하지 않아야 한다는 것을 나타내기 위해서다.
전달된 데이터의 유효성 검사를 한 후 processDesign 메서드 에서는
주입된 TacoRepository를 사용해서 Taco를 저장한다.
그다음에 세션에 보존된 Order에 Taco 객체를 추가한다.
order.addDesign 메서드에서 에러가 날 것이다.
이 메서드를 구현해야 한다.
또한 해당 주문과 연관된 Taco 객체들을 저장하는 List 타입의 속성인 tacos도 추가한다.
package tacos;
/*
* @USER JungHyun
* @DATE 2020-07-04
* @DESCRIPTION
*/
import lombok.Data;
import org.hibernate.validator.constraints.CreditCardNumber;
import javax.validation.constraints.Digits;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
@Data
public class Order {
private Long id;
private Date createdAt;
@NotBlank(message = "Name is required")
private String deliveryName;
@NotBlank(message = "Street is required")
private String deliveryStreet;
@NotBlank(message = "City is required")
private String deliveryCity;
@NotBlank(message = "State is required")
private String deliveryState;
@NotBlank(message = "Zip code is required")
private String deliveryZip;
@CreditCardNumber(message = "Not a valid credit card number")
private String ccNumber;
@Pattern(regexp = "^(0[1-9]|1[0-2])([\\/])([1-9][0-9])$", message = "Must be formatted MM/YY")
private String ccExpiration;
@Digits(integer = 3, fraction = 0, message = "Invalid CVV")
private String ccCVV;
private List<Taco> tacos = new ArrayList<>();
public void addDesign(Taco design) {
this.tacos.add(design);
}
}사용자가 주문 폼에 입력을 완료하고 제출할 때까지
Order 객체는 세션에 남아있고 DB에 저장되지 않는다.
이제 주문을 저장하기 위해 OrderController가 OrderRepository를 사용할 수 있어야 한다.
OrderRepository를 구현하는 클래스를 만들자.
SimpleJdbcInsert를 사용해서 데이터 추가하기
앞에서 얘기했듯이, Taco를 저장할 때는 해당 Taco의 이름과 생성 시간을
Taco 테이블에 저장하는 것은 물론이고, 해당 타코의 id 및 이것과 연관된
식자재들의 id도 Taco_Ingredients 테이블에 저장하도록 한다.
그리고 이때 KeyHolder와 PreparedStatementCreator를 사용해서 Taco의 id를 얻는다.
Order를 저장하는 것도 Taco와 유사하다.
Order를 Taco_Order 테이블에 저장하는 것은 물론이고,
해당 Order의 각 Taco에 대한 id도 Taco_Order_Tacos 테이블에 저장해야 한다.
그러나 이 경우는 복잡한 PreparedStatementCreator 대신
SimpleJdbcInsert를 사용할 것이다.
SimpleJdbcInsert는 데이터를 DB에 더 쉽게 추가하기 위해 JdbcTemplate을 래핑한 객체다.
우선, OrderRepository를 구현하는 JdbcOrderRepository를 생성하자.
JdbcOrderRepository의 생성자는 Taco_Order와 Taco_Order_Tacos 테이블에 데이터를
추가하기 위해 두 개의 SimpleJdbcInsert 인스턴스를 생성한다.
package tacos.data;
/*
* @USER JungHyun
* @DATE 2020-07-29
* @DESCRIPTION
*/
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
import org.springframework.stereotype.Repository;
@Repository
public class JdbcOrderRepository implements OrderRepository {
private SimpleJdbcInsert orderInserter;
private SimpleJdbcInsert orderTacoInserter;
private ObjectMapper objectMapper;
@Autowired
public JdbcOrderRepository(JdbcTemplate jdbc) {
this.orderInserter = new SimpleJdbcInsert(jdbc)
.withTableName("Taco_Order")
.usingGeneratedKeyColumns("id");
this.orderTacoInserter = new SimpleJdbcInsert(jdbc)
.withTableName("Taco_Order_Tacos");
this.objectMapper = new ObjectMapper();
}
}눈여겨 볼 점은, orderTacoInserter에는 어떤 id 값들을 Taco_Order_Tacos 테이블의
데이터에 생성할 것인지는 지정하지 않는다.
DB에서 생성해주는 것을 사용하지 않고 이미 생성된 주문 id 및 이것과 연관된
타코들의 id를 우리가 지정하기 때문이다.
이제 save 메소드도 마저 구현해보자.
package tacos.data;
/*
* @USER JungHyun
* @DATE 2020-07-29
* @DESCRIPTION
*/
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
import org.springframework.stereotype.Repository;
import tacos.Order;
import tacos.Taco;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Repository
public class JdbcOrderRepository implements OrderRepository {
private SimpleJdbcInsert orderInserter;
private SimpleJdbcInsert orderTacoInserter;
private ObjectMapper objectMapper;
@Autowired
public JdbcOrderRepository(JdbcTemplate jdbc) {
this.orderInserter = new SimpleJdbcInsert(jdbc)
.withTableName("Taco_Order")
.usingGeneratedKeyColumns("id");
this.orderTacoInserter = new SimpleJdbcInsert(jdbc)
.withTableName("Taco_Order_Tacos");
this.objectMapper = new ObjectMapper();
}
@Override
public Order save(Order order) {
order.setPlacedAt(new Date());
long orderId = saveOrderDetails(order);
order.setId(orderId);
List<Taco> tacos = order.getTacos();
for (Taco taco : tacos) {
saveTacoToOrder(taco, orderId);
}
return order;
}
private long saveOrderDetails(Order order) {
@SuppressWarnings("unchecked")
Map<String, Object> values = objectMapper.convertValue(order, Map.class);
values.put("placedAt", order.getPlacedAt());
long orderId = orderInserter.executeAndReturnKey(values)
.longValue();
return orderId;
}
private void saveTacoToOrder(Taco taco, long orderId) {
Map<String, Object> values = new HashMap<>();
values.put("tacoOrder", orderId);
values.put("taco", taco.getId());
orderTacoInserter.execute(values);
}
}save 메서드는 실제로 저장하는 일은 하지 않으며,
Order 및 이것과 연관된 Taco 객체들을 저장하는 쿼리를 총괄한다.
그리고 실제로 저장하는 일은 saveOrderDetails()와 saveTacoToOrder()에 위임한다.
SimpleJdbcInsert는 데이터를 추가하는 두 개의 유용한 메서드인
execute()와 excuteAndReturnKey()를 갖고 있다.
두 메서드는 모두 Map<String, Object>를 인자로 받는다.
이 Map의 키는 데이터가 추가되는 테이블의 열 이름과 대응되며
Map의 값은 열에 추가되는 값이다.
Order 객체의 속성 값들을 Map의 항목으로 복사하는 것은 쉽다.
하지만 Order 객체는 여러 개의 속성을 가지며
속성 모두가 테이블의 열과 같은 이름을 갖는다.
따라서 saveOrderDetails() 에서는
ObjectMapper의 convertValue() 를 사용해서 Order를 Map으로 변환한다.
해당 주문 데이터의 모든 속성값을 갖는 Map이 준비되었다.
이제는 orderInserter의 executeAndReturnKey()를 호출할 수 있다.
그리고 이 메서드를 호출하면 해당 Order가 Taco_Order 테이블에 저장된 후
DB에서 생성된 id가 Number 객체로 반환된다.
따라서 chain으로 longValue()를 호출하여
saveOrderDetails()의 반환 타입인 long으로 변환한다.
saveTacoToOrder()는 더 간단하다.
객체를 Map으로 변환하기 위해 ObjectMapper를 사용하는 대신
우리가 Map을 생성하고 각 항목에 적합한 값을 설정한다.
다시 말하지만, 여기서 Map의 키는 테이블의 열 이름과 같다.
따라서 간단하게 orderTacoInserter의 excute()를 호출하여 데이터를 저장할 수 있다.
이제는 OrderRepository를 OrderController에 주입하고 사용할 수 있다.
package tacos.web;
/*
* @USER JungHyun
* @DATE 2020-07-04
* @DESCRIPTION
*/
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.bind.support.SessionStatus;
import tacos.Order;
import tacos.data.OrderRepository;
import javax.validation.Valid;
@Slf4j
@Controller
@RequestMapping("/orders")
@SessionAttributes("order")
public class OrderController {
private OrderRepository orderRepo;
public OrderController(OrderRepository orderRepo) {
this.orderRepo = orderRepo;
}
@GetMapping("/current")
public String orderForm(Model model) {
return "orderForm";
}
@PostMapping
public String processOrder(@Valid Order order, Errors errors, SessionStatus sessionStatus) {
if (errors.hasErrors()) {
return "orderForm";
}
orderRepo.save(order);
sessionStatus.setComplete();
return "redirect:/";
}
}생성자에서 OrderRepository를 Controller에 주입하는 것 외에
OrderController에서 변경된 것 중 가장 중요한 것은 processOrder()다.
이 메서드에서는 주입된 OrderRepository의 save()를 통해 폼에서 제출된
Order 객체를 저장한다.
따라서 Order 객체도 세션에 보존되어야 한다.
Order가 DB에 저장된 후에는 더 이상 세션에 보존할 필요가 없다.
그러나 만일 제거하지 않으면 이전 주문 및 이것과 연관된 타코가 세션에 남아 있게 되어
다음 주문은 이전 주문에 포함되었던 타코 객체들을 가지고 시작하게 될 것이다.
따라서 processOrder()는 SessionStatus를 인자로 전달받아
이것의 setComplete()를 호출하여 세션을 재설정한다.
이제는 모든 JDBC persistence 코드가 완성되었다. 그러나 사용자가 생성한
Taco 내역을 주문 폼(orderForm.html)에 보여준다면 더 좋을 것이다.
사용자는 자신이 원하는 식자재를 포함하는 여러 종류의 Taco를 생성할 수 있기 때문이다.
orderForm.html 내의 <form>에 아래 코드를 추가하자.
<ul>
<li th:each="taco : ${order.tacos}">
<span th:text="${taco.name}">taco name</span>
</li>
</ul>마지막으로 데이터의 타입을 변환해주는 Converter 클래스를 만들자.
이 클래스는 스프링의 Converter 인터페이스에 정의된 convert()를 구현한다.
따라서 우리가 Converter에 지정한 타입 변환이 필요할 때 conver()가 자동 호출된다.
우리 애플리케이션에서는 String 타입의 식자재 ID를 사용해서 DB에 저장된 특정 식자재
데이터를 읽은 후 Ingredient 객체로 변환하기 위해 Converter가 사용된다.
(그리고 이 Converter로 변환된 Ingredient 객체는 다른 곳에서 List에 저장된다.)
package tacos.web;
/*
* @USER JungHyun
* @DATE 2020-07-29
* @DESCRIPTION
*/
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;
import tacos.Ingredient;
import tacos.data.IngredientRepository;
@Component
public class IngredientByIdConverter implements Converter<String, Ingredient> {
private IngredientRepository ingredientRepo;
@Autowired
public IngredientByIdConverter(IngredientRepository ingredientRepo) {
this.ingredientRepo = ingredientRepo;
}
@Override
public Ingredient convert(String id) {
return ingredientRepo.findById(id);
}
}이제는 타코 클라우드 애플리케이션을 시작시키고
http://localhost:8080/design에 접속하여 데이터를 조회, 저장할 수 있다.
또한 H2 DB에 접속하여 데이터를 확인할 수도 있다.
http://localhost:8080/h2-console / 사용자명 : sa
'개발서적읽기 > Spring in Action 제 5판' 카테고리의 다른 글
[스프링 인 액션] Chapter 4 - 스프링 시큐리티 :: 스프링 시큐리티 활성화하기 (0) 2020.07.30 [스프링 인 액션] Chapter 3 - 데이터로 작업하기 :: 스프링 데이터 JPA를 사용해서 데이터 저장하고 사용하기 (0) 2020.07.30 [스프링 인 액션] Chapter 2 - 웹 애플리케이션 개발하기 :: 뷰 컨트롤러로 작업하기 (0) 2020.07.29 [스프링 인 액션] Chapter 2 - 웹 애플리케이션 개발하기 :: 폼 입력 유효성 검사하기 (0) 2020.07.28 [스프링 인 액션] Chapter 2 - 웹 애플리케이션 개발하기 :: 폼 제출 처리하기 (0) 2020.07.28 댓글