Thumbnail image

Table of Contents

[스프링인액션] 데이터로 작업하기

개요


스프링인액션 2장을 읽고 스프링에서 JDBC, JPA를 이용하는 방법에 대해 작성하였다.

  • JDBC template 사용하기
  • SimpleJdbcInsert를 이용해 데이터 가공하기
  • Custom Converter 사용법

에 대해 기술하였다.

JDBC로 작업하기


스프링의 JDBC 지원은 JDBCTemplate 클래스에 기반을 둔다.

JDBC Template은 Spring JDBC 접근 방법 중 하나로,

내부적으로 Plain JDBC API를 사용하지만 Plain JDBC의 문제점들을 제거하고 원래의 JDBC에서 지원되지 않는 편리한 기능을 제공하는 Spring에서 제공하는 class이다.


Plain JDBC API의 문제점

  1. 쿼리를 실행하기 전과 후에 연결부터 resultSet을 닫고 클린업하는 등등 많은 코드를 작성해야한다.
  2. 데이터베이스 로직에서 예외 처리 코드를 수행해야 한다.
  3. 트랜잭션을 처리해야 한다.
  4. 이러한 모든 코드를 반복하는 것으로, 시간이 낭비된다.

JDBC Template을 사용하지 않은 예시

 1@Override
 2public Ingredient findById(String id) {
 3    Connection connection = null;
 4    PreparedStatement statement = null;
 5    ResultSet resultSet = null;
 6
 7    try {
 8        connection = dataSource.getConnection();
 9        statement = connection.prepareStatement(
10                "select id, name, type from Ingredient where id = ?");
11        statement.setString(1, id);
12        resultSet = statement.executeQuery();
13
14        Ingredient ingredient = null;
15        if (resultSet.next()) {
16            ingredient = new Ingredient(resultSet.getString("id"), resultSet.getString("name"), 
17                                resultSet.Type.valueOf(resultSet.getString("type")));
18        }
19        return ingredient;
20
21    } catch (SQLException e) {
22
23    } finally {
24        if (resultSet != null) {
25            try {
26                resultSet.close();
27            } catch (SQLException e) {}
28        }
29        if (statement != null) {
30            try {
31                statement.close();
32            } catch (SQLException e) {}
33        }
34        if (connection != null) {
35            try {
36                connection.close();
37            } catch (SQLException e) {}
38        }
39    }
40}

이와 같이 데이터베이스 연결 생성, 명령문 생성, 연결과 명령문 및 resultSet을 닫고 클린업하는 코드들로 쿼리 코드가 둘러싸여 있다.
또 예외처리 시 catch 블록에서 해결될 수 없으므로 현재 메서드를 호출한 상위 코드로 예외 처리를 넘겨야 하는 단점이 존재한다.

JDBC Template을 이용한 예시

 1private JdbcTemplate jdbc;
 2
 3@Override
 4public Ingredient findById(String id) {
 5    return jdbc.queryForObject(
 6            "select id, name, type from Ingredient where id=?",
 7            this::mapRowToIngredient, id);
 8}
 9
10private Ingredient mapRowToIngredient(ResultSet rs, int rowNum) throws SQLException {
11    return new Ingredient(rs.getString("id"), rs.getString("name"), 
12                            Ingredient.Type.valueOf(rs.getString("type")));
13}

명령문(statement)나 데이터베이스 연결 객체를 생성, 메서드의 실행이 끝난 후 객체들을 클린업하는 코드가 없는 것을 알 수 있다.
또한 예외처리 (catch 블록)를 수행하는 코드도 없어 코드가 훨씬 간결해졌다.

JDBC Template 사용하기


빌드명세에 추가

1dependencies {
2    implementation "org.springframework.boot:spring-boot-starter-jdbc"
3    runtimeOnly "com.h2database:h2:2.1.212"
4}

스프링 부트의 JDBC 스타터 의존성을 build.gradle 파일에 추가한다.

또한 H2 내장 데이터베이스를 사용하기 위해 h2database에 대한 의존성도 추가한다. (https://mvnrepository.com/artifact/com.h2database/h2)

JDBC repository 정의하기

 1@Repository
 2public class JdbcIngredientRepository implements IngredientRepository {
 3
 4    private JdbcTemplate jdbc;
 5
 6    @Autowired
 7    public JdbcIngredientRepository(JdbcTemplate jdbc) {
 8        this.jdbc = jdbc;
 9    }
10
11    @Override
12    public Iterable<Ingredient> findAll() {
13        return jdbc.query("select id, name, type from Ingredient", this::mapRowToIngredient);
14    }
15
16    @Override
17    public Ingredient findById(String id) {
18        return jdbc.queryForObject(
19                "select id, name, type from Ingredient where id=?",
20                this::mapRowToIngredient, id);
21    }
22
23    @Override
24    public Ingredient save(Ingredient ingredient) {
25        jdbc.update(
26            "insert into Ingredient(id, name, type) values(?, ?, ?)",
27            ingredient.getId(),
28            ingredient.getName(),
29            ingredient.getType().toString()
30        );
31        return ingredient;
32    }
33
34    private Ingredient mapRowToIngredient(ResultSet rs, int rowNum) throws SQLException {
35        return new Ingredient(rs.getString("id"), rs.getString("name"), 
36                                Ingredient.Type.valueOf(rs.getString("type")));
37    }
38}
  • findAll() : 객체가 저장된 컬렉션을 반환 - 쿼리로 생성된 ResultSet의 행 개수만큼 호출하고 각각의 객체로 생성하여 List에 저장한다. JdbcTemplate의 query() 메서드는 두개의 인자를 받는데, 첫번째 인자는 (1)쿼리를 수행하는 SQL, 두번째 인자는 스프링의 (2)RowMapper 인터페이스를 구현한 mapRowToIngredient 메서드이다.

  • findById() : 하나의 객체만 반환 JdbcTemplate의 queryForObject() 메서드는 세개의 인자를 받는데, 첫번째와 두번째 인자는 query() 메서드와 같고, 세번째 인자는 (3)검색할 행의 id를 전달한다.

  • update() : 데이터를 추가하거나 변경하는 쿼리에 사용할 수 있다. JdbcTemplate의 update() 메서드는 (1)쿼리를 수행하는 SQL, (2)쿼리 매개변수에 지정할 값을 인자로 전달한다.

RowMapper 인터페이스를 이용해 구현

 1@Override
 2public Ingredient findById(String id) {
 3    return jdbc.queryForObject(
 4        "select id, name, type from Ingredient where id=?",
 5        new RowMapper<Ingredient>() {
 6            public Ingredient mapRow(ResultSet rs, int rowNum) throws SQLException {
 7                return new Ingredient(rs.getString("id"), rs.getString("name"), 
 8                                        Ingredient.Type.valueOf(rs.getString("type")));
 9            };
10        }, id);
11}

RowMapper를 구현한 익명 클래스를 이용하여 findById() 메서드를 생성할 수 있다.
findById() 메서드가 호출될 때마다 익명 클래스 인스턴스가 생성되어 인자로 전달된 후 mapRow() 메서드가 실행되는 방식이다.

엔티티를 삽입한 후 자동 생성된 키를 받을 경우

다른 테이블과 외래키를 통해 참조되는 경우 객체에 대한 primary key 값이 필요한데, 상단의 update() 메서드를 사용할 경우 해당 객체의 id 값을 받아올 수 없다.

이때, 사용하는 것이 KeyHolder이다.

 1private long saveTacoInfo(Taco taco) {
 2    taco.setCreatedAt(new Date());
 3    PreparedStatementCreatorFactory factory = new PreparedStatementCreatorFactory(
 4            "insert into Taco (name, createdAt) values (?, ?)",
 5            Types.VARCHAR, Types.TIMESTAMP
 6    );
 7    factory.setReturnGeneratedKeys(true);
 8
 9    PreparedStatementCreator psc =
10        factory.newPreparedStatementCreator(
11            Arrays.asList(
12                taco.getName(),
13                new Timestamp(taco.getCreatedAt().getTime())));
14
15    KeyHolder keyHolder = new GeneratedKeyHolder();
16    jdbc.update(psc, keyHolder);
17    return keyHolder.getKey().longValue();
18}

여기에서 update() 메서드는 (1) PreparedStatementCreator 객체와 (2) KeyHolder 객체를 인자로 받는다.

PreparedStatementCreator 객체를 만들기 위해서는

  1. 우선 실행할 SQL 명령어와 각 쿼리 매개변수의 타입을 인자로 전달하는 PreparedStatementCreatorFactory를 만들고,
  2. newPreparedStatementCreator()를 호출하여 쿼리 매개변수 값을 인자로 전달한다.

update() 메서드의 실행이 끝나면 keyHolder.getKey() 를 통해 ID 값을 반환할 수 있다.

예시 2.

1jdbc.update(connection -> {
2        PreparedStatement ps = connection
3                .prepareStatement("insert into Taco (name, createdAt) values (?, ?)");
4        ps.setString(1, taco.getName());
5        ps.setTimestamp(2, new Timestamp(taco.getCreatedAt().getTime()));
6        return ps;
7    }, keyHolder);

이와 같이 사용할 수도 있다.

SimpleJdbcInsert를 사용하기


복잡한 PreparedStatementCreator 대신 SimpleJdbcInsert를 이용하면 손쉽게 id 값을 받아올 수 있다.
SimpleJdbcInsert는 데이터를 더 쉽게 테이블에 추가하기 위해 JdbcTemplate를 래핑한 객체이다.


SimpleJdbcInsert 인스턴스 초기화

 1@Repository
 2public class JdbcOrderRepository implements OrderRepository {
 3
 4    private SimpleJdbcInsert orderInserter;
 5    private ObjectMapper objectMapper;
 6
 7    @Autowired
 8    public JdbcOrderRepository(JdbcTemplate jdbc) {
 9        this.orderInserter = new SimpleJdbcInsert(jdbc).withTableName("Taco_Order")
10                                .usingGeneratedKeyColumns("id");
11
12        this.objectMapper = new ObjectMapper();
13    }
14}

JdbcTemplate를 이용하여 SimpleJdbcInsert 인스턴스를 생성하는 코드이다.
SimpleJdbcInsert 객체에는 테이블 이름이 Taco_Order이고, id는 데이터베이스가 생성해주는 값으로 사용한다고 명시해주었다.

1private long saveOrderDetails(Order order) {
2    @SuppressWarnings("unchecked")
3    Map<String, Object> values = objectMapper.convertValue(order, Map.class);
4    values.put("placedAt", order.getPlacedAt());
5    long orderId = orderInserter.executeAndReturnKey(values).longValue();
6
7    return orderId;
8}

SimpleJdbcInsert는 데이터를 추가하는 execute() 메서드와 executeAndReturnKey() 메서드가 있다. - 두 메서드는 Map<String, Object>의 타입의 인자를 받는다.

Order 객체를 Map으로 변환하기 위해 objectMapper의 convertValue() 메소드를 사용하였다.
executeAndReturnKey() 메서드를 이용하면 데이터가 테이블에 저장된 후 데이터베이스에서 생성된 ID가 Number 객체로 반환된다.

Custom Converter 사용


ID 값을 사용하여 특정 객체로 변환이 필요할 경우가 있는데, 이때 Converter 클래스를 사용한다.
Converter를 사용하면 가독성을 높이는 효과도 있다.

 1@Component
 2public class IngredientByIdConverter implements Converter<String, Ingredient> {
 3    private final IngredientRepository ingredientRepo;
 4
 5    @Autowired
 6    public IngredientByIdConverter(IngredientRepository ingredientRepo) {
 7        this.ingredientRepo = ingredientRepo;
 8    }
 9
10    @Override
11    public Ingredient convert(String id) {
12        return ingredientRepo.findById(id);
13    }
14}

Converter<S,T> 인터페이스를 이용하면 S타입을 T타입으로 변환할 수 있다.
상태 정보가 없기 때문에 얼마든지 빈으로 등록해서 사용해도 상관없다.

Posts in this Series