728x90

지금까지 데이터베이스를 조작하기 위해 계속 무언가를 만들었는데, 사실 스프링에서는 이러한 것들을 편리하게 템플릿으로 이미 제공해준다.

 

일단 JdbcTemplate은 생성자로 dataSource를 받기 때문에 멤버로 JdbcTemplate을 만들어주고, 생성자에서 dataSource를 넣어서 만들어준다.

    public UserDao(
            DataSource dataSource) {
        this.dataSource = dataSource;
        this.jdbcContext = new JdbcContext(dataSource);
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

 

그리고는 다음과 같이 3가지 방법 중 하나로 쿼리문을 작성하면 되는데

public void deleteAll(){
        this.jdbcTemplate.update("delete from users");
        this.jdbcTemplate.update(con -> con.prepareStatement("delete from users"));
        this.jdbcTemplate.update(new PreparedStatementCreator() {
            @Override
            public PreparedStatement createPreparedStatement(Connection con) throws SQLException {
                return con.prepareStatement("delete from users");
            }
        });
    }

 

아무리봐도 가장 위가 단순해보인다.

 

add()에 관한 메서드도 기존

this.jdbcContext.workWithStatementStrategy(c -> {
            PreparedStatement ps = c.prepareStatement("insert into users(id, name, password) values(?, ?, ?)");

            ps.setString(1, user.getId());
            ps.setString(2, user.getName());
            ps.setString(3, user.getPassword());

            return ps;
        });

방법에서

 

    this.jdbcTemplate.update(
                "insert into users(id, name, password) values(?, ?, ?)",
                user.getId(), user.getName(), user.getPassword()
        );

이렇게 더 간단하게 사용이 가능하다.

 

이렇게 Create, Update, Delete와 같이 데이터베이스의 정보가 변경되는 쿼리는 update를 통해 작성해주면 된다.

 

이제는 정보를 가져오는 쿼리를 작성해보자.

getCount()는 유저의 수를 가져오는 메서드였다.

수를 가져오는 쿼리는 간단하게 queryForObject()를 사용한다.

쿼리를 작성하고, 원하는 타입을 지정해주면 된다.

    public int getCount() {
        Integer result = jdbcTemplate.queryForObject("select count(*) from users", Integer.class);
        return result == null ? 0 : result;
    }

 

이 방법으로 user의 정보 또한 읽어올 수 있다.

    public User get(String id){
        return jdbcTemplate.queryForObject(
                "select * from users where id = ?", (rs, rowNum) -> new User(
                        rs.getString("id"),
                        rs.getString("name"),
                        rs.getString("password")
                ), id);
    }

엄청 간단해진 get() 메서드이다.

RowMapper를 상속받아서 mapRow() 메서드를 구현해야 하지만, 이 부분도 lambda로 간단하게 작성했다.

 

대신 queryForObject는 하나의 row만 가져오기 때문에, 해당하는 row가 없는 경우에도 에러가 발생한다.

그 때 발생하는 에러가 EmptyResultDataAccessException이기 때문에, 해당 에러를 핸들링하며 개발하면 된다.

 

다음 row를 가져오고 싶다면 rs.next()를 호출해가며 다음 값을 읽어오면 된다.

 

그러면 리스트로 user들을 조회하는 메서드를 만들어보자.

일단 TDD 느낌으로 테스트를 먼저 만들어보면

    @Test
    public void getAll() throws SQLException {
        User user1 = new User("user1", "user1", "pw1");
        User user2 = new User("user2", "user2", "pw2");

        userDao.deleteAll();

        userDao.add(user1);
        List<User> users1 = userDao.getAll();
        Assertions.assertEquals(1, users1.size());
        Assertions.assertEquals(user1, users1.get(0));

        userDao.add(user2);
        List<User> users2 = userDao.getAll();
        Assertions.assertEquals(2, users2.size());
        Assertions.assertEquals(user1, users2.get(0));
        Assertions.assertEquals(user2, users2.get(1));
    }

 

그리고는 getAll() 메서드를 다음과 같이 작성해준다.

    public List<User> getAll() {
        return this.jdbcTemplate.query(
                "SELECT * FROM users ORDER BY id",
                (rs, rowNum) ->
                        new User(rs.getString("id"), rs.getString("name"), rs.getString("password"))
        );
    }

query() 메서드를 사용해 값을 가져온다.

query()는 여러 개의 로우가 결과로 나오는 일반적인 경우에 사용한다.

그렇기 때문에 리턴 타입은 List<T>이다.

query()를 사용하면 next를 사용하지 않아도, 로우의 개수만큼 RowMapper 콜백으로 데이터를 mapping 해준다.

 

이렇게 작성하고 테스트를 실행하면 깔끔하게 성공하는 것을 볼 수 있다.

 

이렇게하면 끝나지만 이 책에서는 항상 예외적인 상황까지 생각해보라고 말한다.

 

데이터베이스가 빈 경우에는 과연 null이 리턴될까? 빈 list가 리턴될까?의 질문이다.

사실 당연히 빈 list가 나오겠지만, 그래도 테스트 코드를 작성해보자.

    @Test
    public void getAllWithEmpty() {
        userDao.deleteAll();

        List<User> users = userDao.getAll();
        Assertions.assertEquals(0, users.size());
    }

 

이렇게 작성하고 테스트를 해보니

테스트를 통과한다.

 

이런 당연한 경우도, 혹시 모를 변경에 대비하여 미리 테스트 코드를 작성해두라고 한다.

 

package seungkyu;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.lang.NonNullApi;

import javax.sql.DataSource;
import java.sql.*;
import java.util.List;

public class UserDao {

    private final JdbcTemplate jdbcTemplate;

    public UserDao(
            DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    public void add(User user) throws SQLException {
        this.jdbcTemplate.update(
                "insert into users(id, name, password) values(?, ?, ?)",
                user.getId(), user.getName(), user.getPassword()
        );
    }

    public void deleteAll(){
        this.jdbcTemplate.update("delete from users");
    }

    public User get(String id){
        return jdbcTemplate.queryForObject(
                "select * from users where id = ?", (rs, rowNum) -> new User(
                        rs.getString("id"),
                        rs.getString("name"),
                        rs.getString("password")
                ), id);
    }

    public int getCount() {
        Integer result = jdbcTemplate.queryForObject("select count(*) from users", Integer.class);
        return result == null ? 0 : result;
    }

    public List<User> getAll() {
        return this.jdbcTemplate.query(
                "SELECT * FROM users ORDER BY id",
                (rs, rowNum) ->
                        new User(rs.getString("id"), rs.getString("name"), rs.getString("password"))
        );
    }
}

코드가 기존에 dataSource를 가져오던 때에 비해 많이 깔끔해졌다.

 

하지만 그럼에도 중복되는 부분이 보인다.

(rs, rowNum) -> new User(...) 부분이다.

 

이 정도면 깨끗하긴 하지만, 더 깨끗한 코드를 위해서가 아니라 확장을 위해서다.

앞으로 Dao에 이렇게 Mapping 하는 코드를 더 작성할텐데, 그 때마다 이 코드를 작성해 줄 수가 없기 때문이다.

 

private final RowMapper<User> userRowMapper = (rs, rowNum) -> new User(
            rs.getString("id"),
            rs.getString("name"),
            rs.getString("password"));

 

이렇게 Mapper 인스턴스를 만들어서 사용하자.

 

뭔가 함수를 변수로 가지고 있는 것 같지만

private final RowMapper<User> userRowMapperRaw = new RowMapper<>() {
        @Override
        public User mapRow(ResultSet rs, int rowNum) throws SQLException {
            return new User(
                    rs.getString("id"),
                    rs.getString("name"),
                    rs.getString("password"));
        }
    };

그냥 이렇게 RowMapper 인터페이스를 구현해서 가지고 있는 것이다.

@FunctionalInterface
public interface RowMapper<T> {
    @Nullable
    T mapRow(ResultSet rs, int rowNum) throws SQLException;
}

이렇게 @FunctionalInterface를 가지고 있기 때문에 가능한 일이다.

 

일단 이렇게해서 템플릿에 대해 공부했다.

뭔가 JDBC가 많이 나왔지만, 그래도 최대한 템플릿과 callback으로 이해해보려고 한다.

사실 JDBC는 원래도 사용할 줄 알았고, 보통은 JPA를 더 많이 사용하기에

728x90

저번 workWithStatementStrategy로 StatementStrategy 구현체를 넘기는 과정에서 ide가 자동으로 콜백을 추천했었다.

callback은 실행되는 것을 목적으로 다른 오브젝트의 메서드에 넘겨주는 오브젝트를 말한다.

안에 무언가 정보는 신경쓰지 않고, 그 오브젝트의 메서드를 실행하기 위해서이다.

 

인터페이스가 여러 개의 메서드를 가지는 전략 패턴과 달리, 콜백은 단일 메서드만을 가지는 인터페이스에서 시작한다.

어차피 그 메서드 하나만 실행되기 때문이다.

 

저번에 만들었던 workWithStatementStrategy()에 콜백으로 넘겨보자.

workWithStatementStrategy()에 StatementStrategy를 넘겨주어야 하기 때문에

public interface StatementStrategy {
    PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}

 

해당 인터페이스의 구현체를 만들어 넘겨줬었고, 그럼 또 makePreparedStatement에 Connection을 넘겨줘야 했었다.

 

하지만 callback을 이용하면 해당 구현체를 만들지 않고 사용 할 수 있다.

    public void add(User user) throws SQLException {
        this.jdbcContext.workWithStatementStrategy(c -> {
            PreparedStatement ps = c.prepareStatement("insert into users(id, name, password) values(?, ?, ?)");

            ps.setString(1, user.getId());
            ps.setString(2, user.getName());
            ps.setString(3, user.getPassword());

            return ps;
        });
    }

 

이렇게 람다식으로 connection을 이용해 preparedStatement를 만드는 과정만 넘겨주면 된다.

 

콜백을 작업 흐름은 다음과 같다고 한다.

나는 그냥 파라미터로 함수를 넘긴다고 생각하려고 한다.

그리고 호출된 메서드에서 해당 함수로 작업을 수행한다고 이해했다.

 

이런 callback 패턴은 try-catch-finally에도 굉장히 많이 사용한다고 한다.

예제를 위해 테스트 코드를 작성해보자.

package seungkyu;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.Objects;

public class CalcSumTest {

    static class Calculator{

        public Integer calcSum(String filePath) throws IOException {
            BufferedReader bufferedReader = new BufferedReader(new FileReader(filePath));
            int sum = 0;
            String line;
            while ((line = bufferedReader.readLine()) != null) {
                sum += Integer.parseInt(line);
            }

            bufferedReader.close();
            return sum;
        }
    }

    @Test
    public void sumOfNumbers() throws IOException {
        ClassLoader classLoader = getClass().getClassLoader();
        Calculator calculator = new Calculator();
        int sum = calculator.calcSum(Objects.requireNonNull(classLoader.getResource("numbers.txt")).getFile());
        Assertions.assertTrue(sum > 0);
    }
}

 

그냥 단순하게 파일 읽어서 값 테스트하는 코드이다.

이제 throws한 IOException을 잡아보자.

 

public class CalcSumTest {

    static class Calculator{

        public Integer calcSum(String filePath) {
            try (BufferedReader bufferedReader = new BufferedReader(new FileReader(filePath))) {
                int sum = 0;
                String line;
                while ((line = bufferedReader.readLine()) != null) {
                    sum += Integer.parseInt(line);
                }

                bufferedReader.close();
                return sum;
            } catch (IOException e) {
                System.out.println(e.getMessage());
            }
            return 1;
        }
    }

    @Test
    public void sumOfNumbers() {
        ClassLoader classLoader = getClass().getClassLoader();
        Calculator calculator = new Calculator();
        int sum = calculator.calcSum(Objects.requireNonNull(classLoader.getResource("numbers.txt")).getFile());
        Assertions.assertTrue(sum > 0);
    }
}

 

일단 try-catch-finally로 핸들링 하도록 했다.

 

하지만 메서드들이 추가될 때마다 예외처리 코드를 복사할 수는 없을 것이다.

이제 이 중복이 될 코드들을 템플릿/콜백으로 제거해보도록 하자.

 

가장 간단하게 생각이 드는 것은 템플릿이 파일을 열고 각 라인을 읽어올 수 있는 BufferedReader를 만들어서 콜백에게 전달해주고, 콜백이 각 라인을 읽어서 알아서 처리한 후 결과를 돌려주는 것이다.

 

일단 파일을 읽어서 결과를 반환해주는 메서드를 만들어준다.

    public int fileReadTemplate(String filePath, BufferedReaderCallback bufferedReaderCallback){
            try (BufferedReader bufferedReader = new BufferedReader(new FileReader(filePath))) {
                return bufferedReaderCallback.doSomethingWithBufferedReader(bufferedReader);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }

parameter로는 파일의 위치와 어떤 작업을 수행 할건지에 대한 callback을 작성해준다.

 

넘겨주는 callback의 interface도 만들어준다.

public interface BufferedReaderCallback {

    int doSomethingWithBufferedReader(BufferedReader bufferedReader) throws IOException;
}

 

이제 더하기에 대한 연산을 만들어보자.

    public Integer calcSum(String filePath) {
            BufferedReaderCallback sumCallback = bufferedReader -> {
                int sum = 0;
                String line;
                while ((line = bufferedReader.readLine()) != null) {
                    sum += Integer.parseInt(line);
                }
                return sum;
            };

            return fileReadTemplate(filePath, sumCallback);
        }

BufferedReaderCallback의 구현체를 lambda로 만들어서 바로 fileReadTemplate의 결과를 반환해준다.

 

이런 식으로 작성하면 일단 try-catch-finally에 대한 중복은 제거할 수 있다.

 

더 간결하게 만들 수 있을까?

만약 여기 곱하기가 추가된다고 해보자.

그러면 while문으로 버퍼를 읽는 부분도 중복이 될거고, 그 부분도 추출하고 싶어진다.

 

그러기 위해서는 또 callback을 위한 인터페이스를 만들고

public interface LineCallback {
    int doSomethingWithLine(String line, int value);
}

 

해당 인터페이스를 이용하는 메서드도 만든다. (fileReadTemplate메서드 처럼)

 

    public int lineReadTemplate(String filePath, LineCallback callback, int initVal){
            BufferedReader bufferedReader;
            try{
                bufferedReader = new BufferedReader(new FileReader(filePath));
                int res = initVal;
                String line;
                while ((line = bufferedReader.readLine()) != null) {
                    res = callback.doSomethingWithLine(line, res);
                }
                return res;
            }
            catch (IOException e){
                System.out.println(e.getMessage());
            }
            return initVal;
        }

 

메서드를 읽어보자면, 파일을 읽어오기 위한 경로와 작업을 수행할 메서드 그리고 초기값을 받아온다.

 

res 값을 callback 메서드로 계속 변경하기 때문에 더하기, 곱하기에 대한 메서드만 넣어주면 된다.

    public Integer calcSum(String filePath) {
            return lineReadTemplate(filePath, (line, value) -> {
                return Integer.parseInt(line) + value;
            }, 0);
        }

덕분에 calcSum 메서드도 더하기에 대한 연산만 넣어주면 끝나게 된다.

 

그리고 만약 곱하기에 대한 메서드도 필요하다면 

    public Integer calcMulti(String filePath) {
            return lineReadTemplate(filePath, (line, value) -> {
                return Integer.parseInt(line) * value;
            }, 1);
        }

이렇게 간단하게 곱하기에 대한 얀산만 넣어준다.

 

calcSum, calcMulti를 보면 중복되는 부분도 없이 리펙토링 된 것을 볼 수 있다.

 

굉장히 좋은 방법이지만, 솔직히 실전에서는 callback을 쓸 생각이 잘 안난다.

프론트 개발자들은 자주 사용하던데, 나도 좀 적극적으로 사용하려고 노력해야겠다.

728x90

전략 패턴의 구조로 본다면 UserDao가 클라이언트고, 익명의 내부 클래스로 만들어지는 것이 개별적인 전략이고, jdbcContextWithStatementStrategy() 메서드는 컨텍스트다.

jdbcContextWithStatementStrategy()와 같은 컨텍스트 메서드는 다른 Dao에서도 공유가 가능하다.

그렇기에 UserDao만 사용할 것이 아니라 모든 Dao가 사용하도록 만들 수 있다.

 

JdbcContext라는 이름으로 분리해보자.

public class JdbcContext {

    private final DataSource dataSource;

    public JdbcContext(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void workWithStatementStrategy(StatementStrategy statementStrategy) throws SQLException {
        Connection connection = null;
        PreparedStatement preparedStatement = null;

        try{
            connection = this.dataSource.getConnection();
            preparedStatement = statementStrategy.makePreparedStatement(connection);
            
            preparedStatement.executeUpdate();
        }finally {
            if(preparedStatement != null) {try{preparedStatement.close();}catch(SQLException ignored){}}
            if(connection != null) {try{connection.close();}catch(SQLException ignored){}}
        }
    }
}

이렇게 StatementStrategy를 받아서 컨텍스트를 처리해주는 클래스로 분리가 가능하다.

 

이제 UserDao에서도 자체적으로 컨텍스트를 가지고 있는게 아니라 JdbcContext를 가져와서 사용할 수 있다.

public class UserDao {

    private final DataSource dataSource;
    private final JdbcContext jdbcContext;

    public UserDao(
            DataSource dataSource,
            JdbcContext jdbcContext) {
        this.dataSource = dataSource;
        this.jdbcContext = jdbcContext;
    }

    public void add(User user) throws SQLException {
        this.jdbcContext.workWithStatementStrategy(c -> {
            PreparedStatement ps = c.prepareStatement("insert into users(id, name, password) values(?, ?, ?)");

            ps.setString(1, user.getId());
            ps.setString(2, user.getName());
            ps.setString(3, user.getPassword());

            return ps;
        });
    }
}

하지만 당연히 알고 있을 것이다.

이렇게 하고 실행하면 jdbcContext를 가져올 수 없기에 빈으로 설정을 해주어야 한다.

그렇기에 applicationContext.xml에서 등록을 해주었다.

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="
           http://www.springframework.org/schema/beans
           http://www.springframework.org/schema/beans/spring-beans.xsd">


    <bean id="dataSource"
          class = "org.springframework.jdbc.datasource.SimpleDriverDataSource">
        <property name="driverClass" value="com.mysql.cj.jdbc.Driver"/>
        <property name="url" value="jdbc:mysql://localhost:3306/test"/>
        <property name="username" value="root"/>
        <property name="password" value="1204"/>
    </bean>

    <bean id="jdbcContext"
          class = "seungkyu.JdbcContext">
        <constructor-arg ref="dataSource"/>
    </bean>

    <bean id="userDao" class="seungkyu.UserDao">
        <constructor-arg ref="dataSource" />
        <constructor-arg ref="jdbcContext" />
    </bean>


</beans>

 

intellij의 다이어그램을 보니 다음과 같이 나오는 것을 볼 수 있었다.

물론 이제 userDao의 모든 메서드에서 dataSource를 통한 connection을 제거하고, userDao가 dataSource에 의존하지 않도록 만들어야 하지만, add와 deleteAll만 수정해두었다.

 

그럼 이 JdbcContext를 주목해보자.

현재 인터페이스를 사용하지 않고 클래스로 바로 구현하고 있다.

기존의 DI에 맞지 않는 느낌이다.

클래스 레벨에서의 의존관계가 결정되기 때문이다. 의존관계 주입의 개념에 따르면, 인터페이스를 통해 클래스 레벨에서는 의존관계가 생기지 않도록 하고, 런타임 시에 의존할 오브젝트와의 관계를 동적으로 주입해주는 것이 맞다.

그렇기에 인터페이스를 통하지 않았다면 DI라고 볼 수 없을 것이다.

 

이렇게 인터페이스를 통해 클래스를 자유롭게 변경할 수 있도록 하지 않았지만, 그럼에도 이렇게 만든 이유에 대해 생각해보자.

  • JdbcContext를 싱글톤으로 만들기 위해서다.
  • JdbcContext가 DI를 통해 다른 빈에 의존하고 있기 때문이다. (Datasource에 의존)

이러한 장점들 때문에 JdbcContext를 스프링 빈으로 생성한 것이다.

그리고 현재 UserDao와 JdbcContext가 길밀하게 연관되어 있다.

어차피 UserDao는 한상 JdbcContext와 함께 사용되어야 하며, UserDao에 따라 JdbcContext를 수정할 가능성도 높다.

그렇기에 굳이 인터페이스를 두지 않고 이렇게 강력한 결합을 만들어도 되는 것이다.

이런 경우에는 DI의 필요성을 통해 빈으로 등록해도 나쁘지는 않은 생각인 것이다.

 

스프링 빈으로 등록하지 않고 UserDao에 DI 하는 방법 대신 다른 방법도 존재한다.

UserDao 내부에서 직접 DI를 적용하는 방법이다.

public class UserDao {

    private final DataSource dataSource;
    private final JdbcContext jdbcContext;

    public UserDao(
            DataSource dataSource) {
        this.dataSource = dataSource;
        this.jdbcContext = new JdbcContext(dataSource);
    }
}

이렇게 UserDao의 생성자에서 JdbcContext를 생성하고 가져가는 방법으로 JdbcContext를 이용할 수 있다.

하지만 이 방법도 UserDao와 JdbcContext간에 긴밀한 연결이 생긴다.

그리고 JdbcContext가 싱글톤으로 생성되지 않고 Dao마다 하나씩 생성된다는 문제가 있다.

 

대신 JdbcContext가 UserDao를 내부에서 만들어서 사용하기에 연결관계가 외부로 드러나지 않는다는 장점이 있다.

 

빈으로 등록하기 VS 생성자에서 만들어서 사용하기

 

상황에 맞게 사용하도록 하자(나는 그냥 빈으로 등록해서 사용할 거 같다)

728x90

이전에는 deleteAll()에서 변하지 않는 부분, 자주 변하는 부분을 전략 패턴을 사용해 분리했다.

독립된 JDBC 작업 흐름이 담긴 jdbcContextWithStatementStrategy()는 Dao 메서드들이 공유할 수 있게 되었다.

    public void jdbcContextWithStatementStrategy(StatementStrategy statementStrategy) throws SQLException {
        Connection connection = null;
        PreparedStatement preparedStatement;
        try{
            connection = dataSource.getConnection();

            preparedStatement = statementStrategy.makePreparedStatement(connection);

            preparedStatement.executeUpdate();
            preparedStatement.close();
        } finally{
            if(connection != null){
                connection.close();
            }
        }
    }

 

Dao 메서드는 전략 패턴의 클라이언트로서 컨텍스트에 해당하는 jdbcContextWithStatementStrategy() 메서드에 적절한 전략을 제공해주는 방법으로 사용할 수 있다.

 

deleteAll() 말고도 add() 메서드에도 저장해보자.

 

AddStatement라는 전략 클래스를 또 생성했다.

public class AddStatement implements StatementStrategy {

    User user;

    public AddStatement(User user) {
        this.user = user;
    }

    @Override
    public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
        PreparedStatement preparedStatement = c.prepareStatement("insert into users(id, name, password) values (?, ?, ?)");

        preparedStatement.setString(1, user.getId());
        preparedStatement.setString(2, user.getName());
        preparedStatement.setString(3, user.getPassword());

        return preparedStatement;
    }
}

 

그리고 UserDao의 add() 메서드도 다음과 같이 수정한다.

    public void add(User user) throws SQLException {
        StatementStrategy statementStrategy = new AddStatement(user);
        jdbcContextWithStatementStrategy(statementStrategy);
    }

 

그리고 일단 테스트를 돌려보니 다음과 같이 성공한다.

 

자 일단 그럼 여기까지는 성공이다.

 

지금까지 코드를 더 깔끔하게 만들기는 했지만, 몇가지 문제가 있다.

우선 Dao에서 사용할 메서드마다 StatementStrategy를 구현한 클래스를 만들어야 한다.

또한 add()와 같이 User로 무언가 정보를 넘기는 경우, 이를 해당 구현 클래스의 생성자에 넣어서 전달해야 한다.

 

이런 문제를 로컬 클래스로 해결가능 할 거 같다.

    public void add(User user) throws SQLException {
        class AddStatement implements StatementStrategy{

            @Override
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                PreparedStatement ps = c.prepareStatement("insert into users(id, name, password) values(?, ?, ?)");

                ps.setString(1, user.getId());
                ps.setString(2, user.getName());
                ps.setString(3, user.getPassword());

                return ps;
            }
        }
        StatementStrategy statementStrategy = new AddStatement();
        jdbcContextWithStatementStrategy(statementStrategy);
    }

 

이렇게 add() 메서드 안에 클래스를 만들어서 별도의 클래스 파일을 만들지 않도록 했다.

user 변수에 접근도 가능하기 때문에 추가적으로 생성자에 User를 넘겨줄 필요가 없다.

 

하지만 이 방법은 진짜 클래스파일을 추가적으로 만들지 않는 것이지, 코드의 작성량은 비슷하다.

그래도 더 간결하게는 만들어보자.

지금 AddStatement는 어차피 내부에서만 사용하기에 이름도 필요 없다.

그냥 익명 내부 클래스로 만들어보자.

 

    public void add(User user) throws SQLException {
        StatementStrategy statementStrategy = new StatementStrategy() {
            @Override
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                PreparedStatement ps = c.prepareStatement("insert into users(id, name, password) values(?, ?, ?)");

                ps.setString(1, user.getId());
                ps.setString(2, user.getName());
                ps.setString(3, user.getPassword());

                return ps;
            }
        };
        jdbcContextWithStatementStrategy(statementStrategy);
    }

이렇게 만들면 더욱 간결하게 코드를 작성할 수 있다.

728x90

저번에는 UserDao에 try-catch-finally를 추가했다.

이제 이걸 적용하려면 이 예외처리를 기존의 코드에 하나하나 추가해줘야 한다.

 

당연히 말도 안된다.

코드를 빼먹을 수도 있고, 수정할 때마다 모두 찾아서 수정을 해줘야한다.

 

그렇기에 반드시 개선해야 하는 부분이다.

    public void deleteAll() throws SQLException {
        Connection connection = null;
        PreparedStatement preparedStatement;
        try{
            connection = dataSource.getConnection();

            preparedStatement = connection.prepareStatement("delete from users");

            preparedStatement.executeUpdate();
            preparedStatement.close();
        } finally{
            if(connection != null){
                connection.close();
            }
        }
    }

 

코드를 보면 Connection, PreparedStatement를 준비하는 부분과 finally 부분은 모두 공통으로 변하지 않는 것을 볼 수 있다.

중간 부분만 바꾸면 되는것이다.

 

메서드로 추출하는 방법도 있지만 큰 이점은 없어보이기에, 템플릿 메서드 패턴을 적용해보려고 한다.

템플릿 메서드 패턴은 상속을 통해 기능을 확장해서 사용하는 방법이다.

변하지 않는 부분은 슈퍼클래스에 두고, 변하는 부분은 추상 메서드로 정의해서 서브클레스에서 오버라이드해서 새롭게 정의해 쓰도록 만드는 것이다.

 

public class UserDaoDeleteAll extends UserDao {

    public UserDaoDeleteAll(DataSource dataSource) {
        super(dataSource);
    }

    @Override
    protected PreparedStatement makeStatement(Connection c) throws SQLException {
        return c.prepareStatement("delete from users");
    }
}

 

하지만 여기서 템플릿 메서드의 단점은 사용하고 싶은 sql문마다 dao 클래스를 만들어줘야 한다는 것이다.

 

이거보다 더 확장적인 방법은 오브젝트를 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만드는 전략 패턴이다.

좌측의 Context의 contextMethod()에서 일정한 구조를 가지고 동작하다가 특정 확장 기능은 Strategy 인터페이스를 통해 외부의 독립된 전략 클래스에 위임하는 것이다.

 

deleteAll()에서 변하지 않는 부분이라고 한 것이 바로 contextMethod()가 되는 것이다.

 

변하는 부분을 StatementStrategy로 하고 인터페이스를 만든다.

public interface StatementStrategy {

    PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}

 

그리고 이거를 상속받아, 변하는 부분을 작성한다.

여기에서는 deleteAll이다.

public class DeleteAllStatement implements StatementStrategy {

    @Override
    public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
        return c.prepareStatement("DELETE FROM users");
    }
}

 

그리고 삭제할 때마다 해당 클래스를 생성해서 함수를 호출하는 것이다.

    public void deleteAll() throws SQLException {
        Connection connection = null;
        PreparedStatement preparedStatement;
        try{
            connection = dataSource.getConnection();

            StatementStrategy statementStrategy = new DeleteAllStatement();

            preparedStatement = statementStrategy.makePreparedStatement(connection);

            preparedStatement.executeUpdate();
            preparedStatement.close();
        } finally{
            if(connection != null){
                connection.close();
            }
        }
    }

 

이렇게하면 뭐..가능은 하지만 항상 클래스를 생성해줘야 하고, 구체적인 클래스인 DeleteAllStatement에 의존하기 때문에 OCP에 잘 맞는다고 할 수는 없다.

 

더 발전시키기 위해 전략패턴을 더 생각해보자.

Context가 어떤 전략을 사용할지는 Context를 사용하는 Client가 결정하도록 한다.

Client가 구체적인 전략을 선택해서 Context에 전달하는 것이다.

    public void jdbcContextWithStatementStrategy(StatementStrategy statementStrategy) throws SQLException {
        Connection connection = null;
        PreparedStatement preparedStatement;
        try{
            connection = dataSource.getConnection();

            preparedStatement = statementStrategy.makePreparedStatement(connection);

            preparedStatement.executeUpdate();
            preparedStatement.close();
        } finally{
            if(connection != null){
                connection.close();
            }
        }
    }

 

이렇게 공통적으로 동작하는 부분을 분리하고

    public void deleteAll() throws SQLException {
        StatementStrategy strategy = new DeleteAllStatement();
        jdbcContextWithStatementStrategy(strategy);
    }

deleteAll()을 클라이언트로 전략 오브젝트를 만들어서 컨텍스트를 호출하도록 한다.

사용할 전략 클래스는 DeleteAllStatement이고 컨텍스트로 분리한 jdbcContextWithStatementStrategy() 메서드를 호출한다.

 

 

728x90

지난번에 작성한 UserDao를 다시보자.

    public void deleteAll() throws SQLException {
        Connection connection = dataSource.getConnection();

        PreparedStatement preparedStatement = connection.prepareStatement("delete from users");

        preparedStatement.executeUpdate();

        preparedStatement.close();
        connection.close();
    }

 

바로 알겠지만 executeUpdate()에서 에러가 발생하면 connection등의 리소스가 닫히지 않고 메서드가 종료된다.

이렇게 반환되지 않고 connection이 계속 열리게 된다면 서버의 운영 중에 큰 문제가 발생한다.(일반적으로는 커넥션 풀을 사용하지만)

 

그렇기 때문에 어떤 상황에서도 connection이 닫힐 수 있도록 try/catch/finally 구문을 사용해야 한다.

 

    public void deleteAll() throws SQLException {
        Connection connection = null;
        PreparedStatement preparedStatement;
        try{
            connection = dataSource.getConnection();

            preparedStatement = connection.prepareStatement("delete from users");

            preparedStatement.executeUpdate();
            preparedStatement.close();
        } finally{
            if(connection != null){
                connection.close();
            }
        }
    }

 

 

이렇게 변경해보았다.

 

만약 connection을 생성하는 도중에 에러가 발생할 수 있고, 그런 connection에서 close()를 호출하면 NPE가 발생하기 때문에 null 체크를 해줘야 한다.

 

JDBC를 통해서 조회를 하는 경우에는 예외처리가 더 어려워진다.

ResultSet이 추가되기 때문이다.

 

그렇기 때문에 ResultSet까지 체크하며 닫아주어야 한다.

    public int getCount() throws SQLException {
        Connection connection = null;
        PreparedStatement preparedStatement = null;
        ResultSet resultSet = null;

        try{
            connection = dataSource.getConnection();
            preparedStatement = connection.prepareStatement("select count(*) from users");
            resultSet = preparedStatement.executeQuery();
            resultSet.next();
            return resultSet.getInt(1);
        }finally {
            if(resultSet != null){
                try{
                    resultSet.close();
                }catch (SQLException ignored){

                }
            }

            if(preparedStatement != null){
                try{
                    preparedStatement.close();
                }catch (SQLException ignored){

                }
            }

            if(connection != null){
                try {
                    connection.close();
                }
                catch (SQLException ignored){

                }
            }
        }
    }
728x90

개발자는 보통 자신이 작성한 코드에 대하여 테스트를 작성하지만, 다른 사람이 작성하거나 프레임워크에 대해서도 테스트를 작성한다고 한다.

진짜 테스트하려는 목적보다 사용방법에 대해 배우려고 하는 것이다.

 

이렇게 작성한 학습테스트의 장점은 다음이 있다고 한다.

  • 다양한 조건에 따른 기능을 손쉽게 확인해볼 수 있다.
  • 학습 테스트 코드를 개발 중에 참고할 수 있다.
  • 프레임워크나 제품을 업그레이드 할 때 호환성 검증을 도와준다.
  • 테스트 작성에 대한 좋은 훈련이 된다.
  • 새로운 기술을 공부하는 과정이 즐거워진다??

그렇기에 몇가지의 예시 테스트로 테스트에 대해 좀 더 알아보자.

public class JUnitTest {

    static JUnitTest testObject;

    @Test
    public void test1(){
        System.out.println(this);
        System.out.println(testObject);
        Assertions.assertNotEquals(this, testObject);
        System.out.println(this);
        System.out.println(testObject);
        System.out.println();
    }

    @Test
    public void test2(){
        System.out.println(this);
        System.out.println(testObject);
        Assertions.assertNotEquals(this, testObject);
        testObject = this;
        System.out.println(this);
        System.out.println(testObject);
        System.out.println();
    }

    @Test
    public void test3(){
        System.out.println(this);
        System.out.println(testObject);
        Assertions.assertNotEquals(this, testObject);
        testObject = this;
        System.out.println(this);
        System.out.println(testObject);
        System.out.println();
    }
}

 

일단 위는 성공하는 테스트이다.

 

근데 이상하지 않나?

분명 test2, test3에서 testObject를 this로 해주었기에, 둘 중 하나는 testObject가 this로 테스트는 실패해야 한다.

 

성공하는 이유는 각 메서드가 실행될 때마다 새로운 테스트 오브젝트가 생성이 되기에 일치하지 않는 것이다.

실제로 실행하면 다음과 같이 로그가 작성된다.

 

testObject가 바뀌는 것이 아니라, 테스트 클래스 자체가 새로운 오브젝트로 변경된다.

 

이렇게 테스트를 작성하다보면 스프링에 대해 깊게 알 수 있다고 한다...

728x90

@Before annotation을 사용해서 모든 메서드에 미리 데이터베이스에 대한 정보를 넣었었다.

하지만 여기서의 문제는 애플리케이션 컨텍스트가 계속 초기화된다.

 

애플리케이션 컨텍스트가 만들어질 때는 모든 싱글톤 빈 오브젝트를 초기화하기 때문에 테스트에 많은 비용이 소모될 수 있다.

테스트마다 로그를 찍어보니

모든 다른 애플리케이션 오브젝트로 생성이 되고, 거기서 생긴 UserDao도 싱글톤이라고 하기에는 다른 인스턴스인 것을 볼 수 있다.

 

그렇기에 테스트마다 애플리케이션 컨텍스트를 생성하는 방법이 아닌, 스프링에서 직접 제공하는 애플리케이션 컨텍스트 테스트 지원 기능을 사용하는 것이 더 좋다고 한다.

 

package seungkyu;

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.GenericXmlApplicationContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import java.sql.SQLException;
import java.sql.SQLIntegrityConstraintViolationException;
import java.util.UUID;

import static org.junit.jupiter.api.Assertions.*;

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"/applicationContext.xml"})
class UserDaoTest {

    private UserDao userDao;

    @Autowired
    private ApplicationContext applicationContext;

    @BeforeEach
    public void setUp() throws SQLException, ClassNotFoundException {
        this.userDao = applicationContext.getBean("userDao", UserDao.class);
        userDao.deleteAll();
    }
}

이렇게 수정해주었다.

applicationContext.xml에서 빈의 정보를 읽어서 주입해달라는 코드이다.

그렇게 만든 애플리케이션 오브젝트와 빈은 모두 싱글톤으로 하나만 만들어서 사용하는 것을 볼 수 있다.

이렇게 해서 하나의 테스트 클래스 내의 테스트 메서드는 같은 애플리케이션 컨텍스트를 공유해서 사용할 수 있음을 확인했다.

 

만약 다른 테스트까리 하나의 설정파일을 읽어와서 테스트를 진행한다면, 해당 테스트는 애플리케이션 컨텍스트를 하나만 만들어서 공유하도록 해준다.

이 덕분에 애플리케이션 컨텍스트를 하나만 만들 수 있게 되어 테스트의 속도가 대폭 빨라진다.

 

그리고 여기서 주입을 위해 @Autowired를 사용했는데, 간단하게 말하자면 @Autowired가 붙은 인스턴스 변수가 있으면 테스트 컨텍스트 프레임워크는 변수 타입과 일치하는 컨텍스트 내의 빈을 찾는다. 그리고 타입이 일치한다면 인스턴스 변수에 주입해준다. setter와 생성자가 없어도 스프링이 리플랙션을 통해 주입해준다고 한다. 만약 같은 타입이 2개 이상이라면 변수의 이름과 같은 이름의 빈이 있는지 확인한 후 있다면 주입한다.

 

@Autowired
private ApplicationContext applicationContext;

그리고 해당 코드를 보면 알겠지만, applicationContext 또한 자기 자신을 빈으로 등록한다.

 

이제 테스트를 진행해볼텐데, 이걸 실제 데이터베이스에 연결하면 안된다는 것을 알것이다.

만약 실제 데이터베이스에 연결하고 deleteAll을 해버린다면, 모든 사용자의 정보가 삭제되기 때문이다.

그렇기에 테스트를 위한 별도의 데이터베이스를 설정해야 한다.

 

지금 작성한 applicationContext.xml 말고, test-applicationContext.xml을 만들어서 테스트 환경에서만 해당 설정을 사용하도록 해보자.

 

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="
           http://www.springframework.org/schema/beans
           http://www.springframework.org/schema/beans/spring-beans.xsd">


    <bean id="dataSource"
          class = "org.springframework.jdbc.datasource.SimpleDriverDataSource">
        <property name="driverClass" value="com.mysql.cj.jdbc.Driver"/>
        <property name="url" value="jdbc:mysql://localhost:3306/test"/>
        <property name="username" value="root"/>
        <property name="password" value="1204"/>
    </bean>

    <bean id="userDao" class="seungkyu.UserDao">
        <constructor-arg ref="dataSource" />
    </bean>


</beans>

 

이렇게 스키마를 변경하고

UserDaoTest의 ContextConfiguration을 다음과 같이 변경한다.

@ContextConfiguration(locations = {"/test-applicationContext.xml"})

 

해당 스키마를 사용하는지 확인해보기 위해, @AfterEach로 데이터를 남기며 테스트해보았다.

이렇게 테스트 데이터베이스를 사용해 테스트를 진행하는 것을 볼 수 있다.

 

테스트를 진행하면서 별도의 테스트 데이터베이스를 통해 더 완벽하게 테스트를 진행하도록 해야겠다.

+ Recent posts