-
토비의 스프링[3] 템플릿Spring 2021. 1. 14. 18:31
OCP(OpenClosePrinciple) : 기존의 코드가 변경되지 않으면서 기능을 추가할 수 있도록 설계
리소스의 반환
초난감 DAO에서의 PreparedStatement와 Connection을 사용하는 메소드들은 에러가 발생할 경우 close() 메소드들을 실행을 하여 리소스를 반환을 할 수 없기에 어떤 상황에서도 리소스를 반환할 수 있도록 try/catch/finally 구문을 사용을 권장하고 있다.
public int getCount() throws SQLException { Connection c = null; PreparedStatement ps = null; ResultSet rs = null; try { c = dataSource.getConnection(); ps = c.prepareStatement("select count(*) from users"); rs = ps.executeQuery(); rs.next(); return rs.getInt(1); } catch (SQLException e) { throw e; } finally { if(rs != null) { try { rs.close(); } catch (SQLException e) {} } if(ps != null) { try { ps.close(); } catch (SQLException e) {} } if(c != null) { try { c.close(); } catch (SQLException e) {} } } }
변하는 것과 변하지 않는 것
계속해서 SQL을 사용하는 메소드가 생길 수록 동일한 try/catch/finally 블록의 반복을 막기 위하여 변하지 않는 부분을 분리하여 재사용할 수 있도록 디자인 패턴을 적용할 수 있다.
- 메소드 추출
- 재사용이 없으며, 새로운 DAO기능에 맞게 확장이 되어야 하기에 불필요
- 템플릿 메소드 패턴
- 상속을 통하여 기능을 확장시키기에 메소드 하나마다 서브클래스가 생기며, 컴파일 시점에 관계가 설정되어 있어 유연성이 하락된다.
- 전략 패턴 적용
- 전략 패턴은 오브젝트를 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 하는 전략 패턴이다. OCP 관점에 보면 확장에 해당하는 변하는 부분을 별도의 클래스로 만들어 추상화된 인터페이스에 위임하는 방식이다.
- 일정한 구조를 가지고 동작하는 Context를 가지고 있으며, 특정 확장 기능은 Strategy인터페이스를 통해 외부의 독립된 전략 클래스에 위임한다.
- 매번 변경되는 PreparedStatement를 만들어주는 외부 기능이 전략패턴의 전략이라고 할 수 있다.
public interface StatementStrategy { PreparedStatement makePreparedStatement(Connection c) throws SQLException; } // 결국 메소드 확장마다 클래스가 생김 템플릿 메소드 패턴의 단점이 그대로? public class DeleteAllStatement implements StatementStrategy { @Override public PreparedStatement makePreparedStatement(Connection c) throws SQLException { PreparedStatement ps = c.prepareStatement("delete from users"); return ps; } public void deleteAll() throws SQLException { ... StatementStrategy st = new DeleteAllStatement(); // 어떤 전략을 사용할지 알고있음 ps = st.makePreparedStatement(c); ps.executeUpdate(); ... } }
DI 적용을 위한 클라이언트/컨텍스트 분리
전략패턴은 Context가 어떤 전략을 사용하게 할 것인가는 Context를 사용하는 Client가 결정하며, 결정한 전략을 오브젝트로 만들어 Context에 전달한다. 그 이후 Context는 전달받은 Strategy구현 클래스의 오브젝트를 사용한다.
DeleteAll() 메소드에 패턴 구조를 적용해보면 컨텍스트에 해당하는 JDBC try/catch/finally 코드를 클라이언트 코드인 StatementStrategy를 만드는 부분을 독립시켜야 한다.// 메소드로 컨텍스트 코드 독립 public void jdbcContextWithStatementStrategy(StatementStrategy stmt) throws SQLException { Connection c = null; PreparedStatement ps = null; try { c= dataSource.getConnection(); ps = stmt.makePreparedStatement(c); ps.executeUpdate(); } catch (SQLException e) { throw e; } finally { if(ps != null) { try { ps.close(); } catch (SQLException e) { } } if(c != null) { try { c.close(); } catch (SQLException e) { } } } } public void deleteAll() throws SQLException { StatementStrategy st = new DeleteAllStatement(); // 컨텍스트 오브젝트 생성 jdbcContextWithStatementStrategy(st); // 컨텍스트 오브젝트 전달 }
마이크로 DI
- DI는 다양한 형태로 적용될 수 있으며, 가장 중요한 개념으로는 제 3자의 도움을 통해 두 오브젝트 사이의 유연한 관계가 설정되도록 만드는 것이다.
- 클래스 부터 더 작은 단위의 코드와 메소드 사이에도 DI가 일어날 수 있다.
JDBC 전략 패턴의 최적화
DeleteAll() 메소드의 경우 추가 정보가 필요없이 Delete 쿼리만 발생하지만 Add() 메소드와 같이 Insert 쿼리가 발생하는 경우에는 Insert하는 정보가 필요하다.public class AddStatement implements StatementStrategy { User user; public AddStatement(User user) { this.user = user; } @Override public PreparedStatement makePreparedStatement(Connection c) throws SQLException { ... ps.setString(1, user.getId(); ps.setString(2, user.getPwd(); ... return ps; } } public void add(User user) throws SQLException { StatementStrategy st = new AddStatement(user); jdbcContextWithStatementStrategy(st); }
전략과 클라이언트의 동거
이렇게 Add() 메소드 마저 전략 패턴을 이용하여 try/catch/finally 코드를 절약하여 개선했지만 2가지의 문제점이 존재한다.- 템플릿 메소드 패턴처럼 매번 새로운 전략을 적용한 클래스 구현하는 문제점
- 전략에 전달할 부가적인 정보가 있는경우 매번 새로운 생정자를 만들어 저장을 해야하는 문제점
로컬 클래스
가장 쉬운 해결 방법으로 전략마다 독립된 클래스로 생성하는 것이 아닌 DAO안에 내부 클래스로 정의하는 방법이다.
이 경우 User의 정보를 별도 생성자로 전달할 필요 없이 사용할 수 있다.- 외부 클래스 변수를 내부 클래스에서 사용할 경우 변수는 상수로 선언되어야 한다.
public void add(final 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, pwd) values(?, ?, ?)"); ps.setString(1, user.getId()); ps.setString(2, user.getName()); ps.setString(3, user.getPwd()); return ps; } } StatementStrategy st = new AddStatement(user); jdbcContextWithStatementStrategy(st);
익명 내부 클래스
두번째 해결 방법으로는 AddStatement를 익명 클래스로 만드는거다.- 이 경우 구현한 인터페이스 타입의 변수에만 저장할 수 있다.
- Add() 메소드에서 생성된 객체를 재사용할 이유가 없기에 바로 변수에서 선언하여 코드를 줄일 수 있다.컨텍스트와 DI
전략 패턴의 구조로 보자면 UserDAO의 메소드가 클라이언트고 익명 내부 클래스로 만들어지는것이 개별적인 전략이고, jdbcContextWithStatementStrategy() 메소드가 컨텍스트 이다.
jdbcContextWithStatementStrategy() 메소드를 다른 DAO에서 사용할 수 있도록 클래스 밖으로 독립시켜보자.독립시킨 JdbcContext 클래스와 UserDao는 새롭게 의존을 하고 있지만 기존 의존관계 주입의 개념을 따르자면 인터페이스를 사이에 둬서 클래스 레벨에서는 의존관계가 고정되지 않게 하고 런타임 시에 의존할 오브젝트와의 관계를 주입해주는 것이라 온전한 DI라 볼 수 없지만 스프링의 DI를 넓게 보면 객체의 생성과 관계설정에 대한 제어권한을 오브젝트에서 제거하고 외부로 위임했다는 IoC라는 개념을 포괄하기에 JdbcContext를 스프링을 이용해 UserDao 객체에서 사용하게 주입했다는건 DI의 기본을 따르고 있다고 볼 수 있다. public class JdbcContext { private DataSource dataSource; public JdbcContext(DataSource dataSource) { this.dataSource = dataSource; } public void workWithStatementStrategy(StatementStrategy stmt) throws SQLException { Connection c = null; PreparedStatement ps = null; try { c = this.dataSource.getConnection(); ps = stmt.makePreparedStatement(c); ps.executeUpdate(); } catch (SQLException e) { throw e; } finally { if(ps != null) { try { ps.close(); } catch (SQLException e) {} } if(c != null) { try { c.close(); } catch (SQLException e) {} } } } } public class UserDao { ... prviate JdbcContext jdbcContext; public UserDao(JdbcContext jdbcContext) { this.jdbcContext = jdbcContext; } }
public vodi add(final User user) throws SQLException { jdbcContextWithStatementStrategy( new StatementStragtegy { public PreapredStatement makePreparedStatement(Conenction c) throws SQLException { PreparedStatement ps = c.preparesStatment("insert into users(id, name, pwd) vlaues(?, ?, ?)"); ps.setString(1, user.getId()); ps.setString(2, user.getName()); ps.setString(3, user.getPwd()); return ps; } } ); // 람다 jdbcContextWorkWithStatementStrategy(c -> { PreparedStatement ps = c.prepareStatement("insert into users(id, name, pwd) values(?, ?, ?)"); ps.setString(1, user.getId()); ps.setString(2, user.getName()); ps.setString(3, user.getPwd()); return ps; }); }
- DI 적용 이유
- JdbcContext가 싱글톤 빈이기에(변경 상태값을 가지고 있지 않음)
- 다른 빈을 DI받고 있음(DataSource) DI를 위해서는 주입과 받는쪽 모두 빈으로 등록되어 한다.
JdbcContext와 UserDAO는 인터페이스를 사용하지 않았기에 매우 긴밀한 관계를 가지고 강하게 결합되어 있다는 의미이다. UserDAO는 JdbcContext와 항상 같이 사용이 되어야한다. 비록 클래스로 구분되어 있지만 이 둘은 강한 응집도를 갖고 있어 JPA나 하이버네이트 ORM을 사용한다면 JdbcContext도 통채로 바뀌어야 한다. (DataSource와 달리 다른 구현체로 변경할 수 없다.) 이런 경우 굳이 인터페이스를 사용하기 보다는 강력한 결합을 가진 관계를 허용하며, DI 적용의 이유를 충족하는 경우에 스프링 빈으로 등록해도 좋다. (단 최후의 방법)
코드를 이용하는 수동 DI
JdbcContext를 스프링 빈으로 등록해서 UserDao에 DI 하는 대신 사용할 수 있는 방법이 있다.- 단 싱글톤은 포기해야 한다.
하지만 DataSourc의 DI를 주입받아야 하기에 UserDAO에게 JdbcContext에 대한 제어권을 갖고 생성과 관리를 담당한다. - 오브젝트 생성 후 수정자 메소드로 의존 오브젝트를 주입하여 임시로 UserDAO를 DI 컨테이너처럼 동작하게 해준다.이 방법의 장점은 인터페이스를 두지 않아도 될 긴밀한 관계를 같는 클래스를 빈으로 분리하지 않고 내부에서 생성 후 사용 관리 하며 다른 오브젝트에 대한 DI를 적용할 수 있다.
... private JdbcContext jdbcContext; public UserDao(DataSource dataSource) { this.dataSource = dataSource; // jdbcContext를 사용하지 않은 메소드를 위하여 잠시 보류 this.jdbcContext = new JdbcContext(dataSource); } ...
템플릿과 콜백
위에서 사용한 전략 패턴을 스프링에서는 템플릿/콜백 패턴이라 부르며, 전략 패턴의 컨텍스트가 템플릿, 익명 내부 클래스로 만들어지는 오브젝트(전략)를 콜백이라고 부른다.
1. 클라이언트의 역할은 템플릿 안에서 실행될 로직을 담을 콜백 오브젝트를 만들고, 콜백이 참조할 정보를 제공하는 것이다. 만들어진 콜백은 클라이언트가 템플릿의 메소드를 호출할 때 파라미터로 전달된다.
2. 템플릿은 정해진 작업 흐름을 따라 작업을 진행하다가 내부에서 생성한 참조정보를 가지고 콜백 오브젝트의 메소드를 호출한다. 콜백은 클라이언트 메소드에 있는 정보와 템플릿이 제공한 참조정보를 이용해서 작업을 수행하고 그 결과를 다시 템플릿에 돌려준다.
3. 템플릿은 콜백이 돌려준 정보를 사용해서 작업을 마저 수행한다. 경우에 따라 최종 결과를 클라이언트에 다시 돌려주기도 한다.
예제 코드에서의 템플릿/콜백을 살펴보면 아래와 같이 된다.
- UserDao.add() : 클라이언트
- StatementStrategy : 콜백
- JdbcContext.workWithStatementStrategy() : 템플릿
편리한 콜백의 재활용
매번 내부 클래스를 사용하기 때문에 상대적으로 크드를 작성하고 읽기가 조금 불편하는 단점과 익숙하지 않은 코드 스타일인 익명 내부 클래스 코드도 답답하다는 두 가지의 단점이 존재한다.
콜백의 분리와 재활용
편리한 콜백의 재활용에서 말하는 단점을 해결하기 위하여 바뀌지 않는 부분을 메소드로 제작하여 매번 답답한 익명 클래스를 이용하여 콜백을 만드는 방법을 최소화 할 수 있다.
public void deleteAll() throws SQLException { executeSql("delete from users"); } public void executeSql(final String query) throws SQLException { this.jdbcContext.workWithStatementStrategy(new StatementStrategy() { // 메소드 파라미터로 이전한 익명 내부 클래스 @Override public PreparedStatement makePreparedStatement(Connection c) throws SQLException { return c.prepareStatement("delete from users"); } }); }
이 처럼 바뀌지 않는 부분을 추출하여 executeSql() 메소드로 제작한 이후 SQL을 담은 파라미터를 fianl로 선언하여 콜백 안에서 직접 사요할 수 있게 해준다. 이렇게 콜백을 담은 메소드를 완성할 수 있게된다.
하지만 추가로 확장이 되면서 UserDao 뿐만 아니라 다른 Dao가 생기게 되어 콜백을 담은 메소드를 사용할 수 있기에 해당 executeSql() 메소드는 JdbcContext 클래스로 옮겨줘서 모든 Dao가 사용할 수 있게 public 접근자로 변경해주면 된다.
public class JdbcContext { private DataSource dataSource; public JdbcContext(DataSource dataSource) { this.dataSource = dataSource; } public void executeSql(final String query) throws SQLException { workWithStatementStrategy(new StatementStrategy() { @Override public PreparedStatement makePreparedStatement(Connection c) throws SQLException { return c.prepareStatement(query); } }); } public void workWithStatementStrategy(StatementStrategy stmt) throws SQLException { Connection c = null; PreparedStatement ps = null; try { c = this.dataSource.getConnection(); ps = stmt.makePreparedStatement(c); ps.executeUpdate(); } catch (SQLException e) { throw e; } finally { if(ps != null) { try { ps.close(); } catch (SQLException e) {} } if(c != null) { try { c.close(); } catch (SQLException e) {} } } } }
테스트와 try/catch/finally
파일을 읽어들여 숫자를 더하는 템플릿/콜백 예제를 하나 제작해보자.
public class CalcSumTest { Calculator calculator; String numFilepath; @BeforeEach public void setUp() { this.calculator = new Calculator(); this.numFilepath = getClass().getResource("/numbers.txt").getPath(); } @Test public void sumOfNumbers() throws IOException { assertThat(calculator.calcSum(numFilepath), Matchers.is(10)); } } public class Calculator { public static Integer calcSum(String filePath) throws IOException { BufferedReader br = null; try { br = new BufferedReader(new FileReader(filePath)); Integer sum = 0; String line = null; while((line = br.readLine()) != null) { sum += Integer.valueOf(line); } return sum; } catch (IOException e) { throw e; } finally { if (br != null) { try { br.close(); } catch (Exception e) { throw e; } } } } }
값을 더하는 기능만 존재할 경우 별도 템플릿/콜백을 사용할 이유 없이 곧바로 해결할 수 있지만 모든 숫자를 곱하는 추가 기능 및 계속해서 추가 기능을 제작하는 경우가 발생한 경우 아래의 해결사항을 해결하며 템플릿/콜백을 이용하여 코드를 제작하여 객체지향 설계적으로 코드를 제작해보자.
1. 어떤 공통된 부분을 템플릿에 담을것인지?
2. 템플릿이 콜백에게 전달해줄 내부의 정보는 무엇이고, 콜백이 템플릿에게 돌려줄 내용은 무엇인지?
3. 최종적으로 클라이언트에게 돌려줄 값은 무엇인지?
// BufferedReader을 받아 각 라인을 읽어 값을 처리하는 콜백 public interface BufferedReaderCallback { Integer doSomethingWithReader(BufferedReader br) throws IOException; } // 파일을 열고 각 라인을 읽을 수 있는 BufferedReader 템플릿 public Integer fileReadTemplate(String filepath, BufferedReaderCallback callback) throws IOException { try(BufferedReader br = new BufferedReader((new FileReader(filepath)))) { int ret = callback.doSomethingWithReader(br); return ret; } catch (IOException e) { System.out.println(e.getMessage()); throw e; } } // 템플릿/콜백을 적용한 Calculator public Integer calcSum(String filePath) throws IOException { BufferedReaderCallback callback = new BufferedReaderCallback() { @Override public Integer doSomethingWithReader(BufferedReader br) throws IOException { Integer resultSum = 0; String line = null; while((line = br.readLine()) != null) { resultSum += Integer.valueOf(line); } return resultSum; } }; return fileReadTemplate(filePath, callback); } } public Integer calcMultiply(String filePath) throws IOException { BufferedReaderCallback callback = new BufferedReaderCallback() { @Override public Integer doSomethingWithReader(BufferedReader br) throws IOException { Integer resultMultiply = 1; String line = null; while((line = br.readLine()) != null) { resultMultiply *= Integer.valueOf(line); } return resultMultiply; } }; return fileReadTemplate(filePath, callback); } }
템플릿/콜백의 재설계
템플릿/콜백을 적용하여 코드를 깔끔하게 만들었지만 아직도 2% 부족한 감이 없지않아 존재한다. calcSum(), calcMultiply() 2개의 콜백을 비교하면 상당히 유사하다는걸 알 수 있다. 그렇기에 한번더 개선해보자.
public interface LineCallBack<T> { Integer doSomethingWithLine(String line, T value) throws IOException; } public Integer lineReadTemplate(String filepath, LineCallBack<Integer> callBack, Integer initVal) throws IOException { try(BufferedReader br = new BufferedReader(new FileReader(filepath))) { Integer res = initVal; String line = null; while((line = br.readLine()) != null) { res = callBack.doSomethingWithLine(line, res); } return res; } catch (IOException e) { System.out.println(e.getMessage()); throw e; } } // 개선한 템플릿/콜백을 적용한 덧셈 public Integer calcSum(String filepath) throws IOException { LineCallBack<Integer> sumCallback = new LineCallBack<Integer>() { @Override public Integer doSomethingWithLine(String line, Integer value) throws IOException { return value + Integer.valueOf(line); } }; return lineReadTemplate(filepath, sumCallback, 0); } // 개선한 템플릿/콜백을 적용한 곱셈 public Integer calcMultiply(String filepath) throws IOException { LineCallBack<Integer> multiplyCallback = new LineCallBack<Integer>() { @Override public Integer doSomethingWithLine(String line, Integer value) throws IOException { return value * Integer.valueOf(line); } }; return lineReadTemplate(filepath, multiplyCallback, 1); } // 마찬가지로 코드를 변경한 경우 작동의 유무는 테스트 코드를 통하여 쉽게 알 수 있다. public class CalcSumTest { Calculator calculator; String numFilepath; @BeforeEach public void setUp() { this.calculator = new Calculator(); this.numFilepath = getClass().getResource("/numbers.txt").getPath(); } @Test public void sumOfNumbers() throws IOException { assertThat(calculator.calcSum(numFilepath), Matchers.is(10)); } @Test public void multiOfNumbers() throws IOException { assertThat(calculator.calcMultiply(numFilepath), Matchers.is(24)); } }
제네릭스를 이용한 콜백 인터페이스
기존의 코드에서는 덧셈, 곱셈만 사용하여 Integer을 반환한 반면 모든 숫자들을 String으로 연결시켜 반환하고 싶은경우 제네릭 타입을 이용하면 해결할 수 있다. 이 처럼 결과값에 대한 타입을 유동적으로 설정하여 반환해주고 싶은경우 제네릭 타입을 사용하면 해결할 수 있다.
public interface LineCallBack<T> { T doSomethingWithLine(String line, T value) throws IOException; } public <T> T lineReadTemplate(String filepath, LineCallBack<T> callBack, T initVal) throws IOException { try(BufferedReader br = new BufferedReader(new FileReader(filepath))) { T res = initVal; String line = null; while((line = br.readLine()) != null) { res = callBack.doSomethingWithLine(line, res); } return res; } catch (IOException e) { System.out.println(e.getMessage()); throw e; } } // 숫자 이어붙이기 public String addString(String filepath) throws IOException { LineCallBack<String> stringAddCallback = new LineCallBack<String>() { @Override public String doSomethingWithLine(String line, String value) throws IOException { return value + line; } }; return lineReadTemplate(filepath, stringAddCallback, ""); } @Test public void addStringNumbers() throws IOException { assertThat(calculator.addString(numFilepath), Matchers.is("1234")); }
스프링의 JdbcTemplate
jdbcTemplate는 스프링에서 JDBC를 이용하는 DAO에서 사용할 수 있도록 준비된 템플릿/콜백을 제공한다. 자주 사용되는 패턴을 가진 콜백은 다시 템플릿에 결합시켜서 간단한 메소드 호출만으로 사용이 가능하도록 만들어져 있기 때문에 템플릿/콜백 방식의 기술을 사용하고 있는지 모르고 쓸 수 있을정도로 편리하다.
public class UserDao { private JdbcTemplate jdbcTemplate; public UserDao(DataSource dataSource) { this.jdbcTemplate = new JdbcTemplate(dataSource); // 마찬가지로 Datasource를 생성자로 받는다. } // 내장 콜백을 사용하는 update()를 이용하여 손쉽게 deleteAll() 메소드를 변경할 수 있다. public void deleteAll() { this.jdbcTemplate.update("delete from users"); } }
queryForObject()
deleteAll() 뿐만 아니라 get() 메소드에 JdbcTemplate를 적용해보자.
첫 번째 파라미터는 PreparedStatement를 만들기 위한 SQL이고, 두 번째는 여기에 바인딩할 값들이다.
update()에서처럼 가변인자를 사용하면 좋겟지만 뒤에 다른 파라미터가 있기 때문에 이 경우엔 가변인자 대신
Object 타입의 배열을 사용하며, 배열 초기화 블록에서 SQL ? 에 바인딩할 id 값을 전달한다.
queryForObject() 내부에서 이 두가지 파라미터를 사용하는 PreapredStatment 콜백이 만들어질 것이다.
추가로 찾으려는 값이 없는경우 EmptyResultDataAccessException을 던지도록 만들어져 있으며, 이전
예제 코드에서는 이에 대한 테스트까지 만들었다.public User get(String id) throws SQLException { return this.jdbcTemplate.queryForObject("select * from users where id = ?", new Object[]{id}, new RowMapper<User>() { @Override public User mapRow(ResultSet rs, int rowNumber) throws SQLException { User user = new User(); user.setId(rs.getString("id")); user.setName(rs.getString("name")); user.setPwd(rs.getString("pwd")); return user; } }); }
getAll() 과 테스트 보완
JdbcTemplate의 query() 를 이용하여 모든 정보를 조회할 수 있는 getAll() 메소드를 만들어 보자.
첫 번째 파라미터에는 실행할 SQL 쿼리를 넣으며, 바인딩할 파라미터가 있다면 두 번째 파라미터에 추가할 수 있다. (없으면 생략가능)
마지막 파라미터는 RowMapper 콜백을 넣어준다.
public List<User> getAll() { return this.jdbcTemplate.query("select * from users order by id", new RowMapper<User>() { @Override public User mapRow(ResultSet rs, int rowNumber) throws SQLException { User user = new User(); user.setId(rs.getString("id")); user.setName(rs.getString("name")); user.setPwd(rs.getString("pwd")); return user; } }); } @Test @DisplayName("getAll 예제") public void getAll() { User user1 = new User("abc1", "inu", "abc1234"); User user2 = new User("abc2", "inWoo", "abc1"); User user3 = new User("abc3", "jiw", "abc12"); dao.deleteAll(); List<User> users0 = dao.getAll(); assertThat(users0.size(), is(0)); dao.add(user1); List<User> users1 = dao.getAll(); assertThat(users1.size(), is(1)); checkSameUser(user1, users1.get(0)); dao.add(user2); List<User> users2 = dao.getAll(); assertThat(users2.size(), is(2)); checkSameUser(user1, users1.get(0)); checkSameUser(user2, users2.get(1)); dao.add(user3); List<User> users3 = dao.getAll(); assertThat(users3.size(), is(3)); checkSameUser(user1, users1.get(0)); checkSameUser(user2, users2.get(1)); checkSameUser(user3, users3.get(2)); } private void checkSameUser(User user1, User user2) { assertThat(user1.getId(), is(user2.getId())); assertThat(user1.getName(), is(user2.getName())); assertThat(user1.getPwd(), is(user2.getPwd())); }
getAll() 테스트도 get()과 마찬가지로 예외적인 조건에 대한 테스트를 빼먹지 말아야한다. 항상 부정적인 테스트를 빼먹지 않는 자세를 가져야 한다. 만약 getAll() 값이 존재하지 않아 Null 값이 들어올 경우 NullPointException이 발생하는지 등등을 확인해야 한다.
query() 메소드는 예외적인 경우 크기가 0인 리스트 오브젝트를 반환을 해주지만 UserDao를 사용하는 입장에서는 어떻게 사용되는지 알 수 없기에 테스트 코드에 이러한 동작방식에 대한 검증이 먼저이다.
재사용 가능한 콜백의 분리
rowMapper 같은 콜백의 경우 Dao에서의 재사용이 될 수 있기에 독립을 시켜두면 더 편하게 사용할 수 있게 된다.
package com.example.toby.초난감DAO; import com.example.toby.초난감DAO.user.User; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import javax.sql.DataSource; import java.sql.*; import java.util.List; public class UserDao { private JdbcTemplate jdbcTemplate; private RowMapper<User> userRowMapper = (rs, rowNumber) -> { User user = new User(); user.setId(rs.getString("id")); user.setName(rs.getString("name")); user.setPwd(rs.getString("pwd")); return user; }; public UserDao() {} public UserDao(DataSource dataSource) { this.jdbcTemplate = new JdbcTemplate(dataSource); } public void setDataSource(DataSource dataSource) { this.jdbcTemplate = new JdbcTemplate(dataSource); } public void add(final User user) { this.jdbcTemplate.update("insert into users(id, name, pwd) values(?,?,?)", user.getId(), user.getName(), user.getPwd()); } public User get(String id) throws SQLException { return this.jdbcTemplate.queryForObject("select * from users where id = ?", new Object[]{id}, this.userRowMapper); } public List<User> getAll() { return this.jdbcTemplate.query("select * from users order by id", this.userRowMapper); } public void deleteAll() { this.jdbcTemplate.update("delete from users"); } public int getCount() { return this.jdbcTemplate.queryForObject("select count(*) from users", Integer.class); } }
정리
3장에서는 예외처리와 안전한 리소스를 반환해주는 DAO 코드를 만들고 이를 객체지향 설계 원리와 디자인 패턴, DI등을 적용해보았다.
1. JDBC와 같은 예외가 발생할 수 있으며, 반환해주어야 하는 자원이 있는경우 반드시 try/catch를 사용해야한다.
2. 일정한 작업 흐름이 반복되면서 그중 일부 기능만 바뀌는 코드가 존재한다면 바뀌지 않는 부분을 컨텍스트, 바뀌는 부분을 전략으로 만들고 인터페이스를 통해 유연하게 전략을 변경할 수 있는 전략 패턴을 적용한다.
3. 컨텍스트가 여러 클라이언트 오브젝트에서 사용된다면 클래스를 분리해서 공유하도록 만든다.
4. 컨텍스트는 별도의 빈으로 등록해서 DI 받거나 클라이언트 클래스에서 직접 생성해서 사용한다. 클래스 내부에서 컨텍스트가 의존하는 외부 오브젝트가 있으면 코드를 이용하여 직접 DI해줄 수 있다.
5. 전략패턴을 템플릿/콜백이라 부르며, 콜백의 코드에도 일정한 패턴이 반복되면 템플릿에 넣고 재활용이 가능하다.
6. 템플릿/콜백이 다양한 타입으로 바꾸고 싶다면 제네릭스를 이용한다.
7. 스프링은 JDBC 코드 작성을 위한 JdbcTemplate을 기반으로 하는 다양한 템플릿/콜백을 제공한다.
8. 템플릿/콜백을 설계할 때는 템플릿과 콜백 사이에 주고받는 정보에 관심을 두어야 하며, 템플릿은 하나 이상의 콜백을 사용할 수 있고, 하나의 콜백을 여러번 호출할 수 있다.
'Spring' 카테고리의 다른 글
Spring Boot Test (0) 2021.02.09 토비의 스프링[4] 예외처리 (0) 2021.01.27 토비의 스프링[2] 테스트 (0) 2020.12.23 토비의 스프링[1] 오브젝트와 의존관계 (0) 2020.12.17 Spring Boot로 알아가는 Swagger (0) 2020.04.07 - 메소드 추출