현재 UserDao.deleteAll()
의 모양은 이러하다.
public void deleteAll() throws SQLException {
Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement("DELETE FROM users");
ps.executeUpdate();
ps.close(); // close() 실패 시 일어나는 에러도 대충 SQLException으로 퉁쳐져 있음
c.close();
}
이것을 close()를 체크하는 방식으로 바꿔보자.
public void deleteAll() throws SQLException {
Connection c = null;
PreparedStatement ps = null;
try {
c = dataSource.getConnection();
ps = c.prepareStatement("DELETE FROM users");
ps.executeUpdate();
} catch (SQLException e) {
throw e;
} finally {
if (ps != null)
try {
ps.close();
} catch (SQLException e) {
// 이제 여기서 ps 리소스 반환 실패를 잡을 수 있다
}
if (c != null)
try {
c.close();
} catch (SQLException e) {
// 이제 여기서 c 리소스 반환 실패를 잡을 수 있다
// (이런 방법으로 처리 가능하다, 까지만 다룸)
}
// ResultSet을 사용하는 메소드는 resultSet도 같은 방식으로 처리해주어야 함 (ex. getCount)
}
}
이러한 방식의 문제점은, 모든 메소드마다 try/catch/finally 구문이 반복된다는 것. 리팩토링을 해보자.
🖐 여기서 잠깐!
만들어진 순서의 역순으로 리소스 반환하는 것이 원칙이다.
finally는 return 후에도 작동한다.
현재 반복되는 부분(변하지 않는 부분)은 리소스 호출 & 리소스 반환
파트.
"변하지 않는 부분이 변하는 부분을 감싸고 있어서 변하지 않는 부분을 추출하기가 어려워 보이기 때문에 반대로 해봤다"
// 이 메소드를 기존 ps 선언 부분에서 사용함
private PreparedStatement makeStatement(Connection c) throws SQLException{
PreparedStatement ps;
ps = c.prepareStatement("delete from user");
return ps;
}
일단 바뀌는 부분과 바뀌지 않는 부분이 분리는 되었으나, 매우 구림. 모든 메소드에서 똑같이 변하지 않는 부분이 반복되고, 변하는 부분은 매번 새로 만들어주기 때문에 아무것도 좋아지지 않음.
"변하지 않는 부분(try/catch/finally
)은 슈퍼클래스에 두고 변하는 부분(statement
)은 추상 메소드로 정의해둬서 서브클래스에서 오버라이드하여 새롭게 정의해 쓰도록 하는 것"
package springbook.user.dao;
// ...
public abstract class UserDao {
// ...
// executeDao()같은 메소드를 두고, 매번 반복되는 try~finally 구문을 기록하고
// 그 사이에 이 아래 makeStatement()를 끼워 넣어둔다
public abstract PreparedStatement makeStatement(Connection c) throws SQLException;
}
package springbook.user.dao;
// ...
public class UserDaoDeleteAll extends UserDao{
private PreparedStatement makeStatement(Connection c) throws SQLException{
PreparedStatement ps;
ps = c.prepareStatement("delete from user");
return ps;
}
}
바뀌는 부분은 makeStatement()
뿐이고, 새로 메소드를 만들때 봐야하는 부분도 이 부분 뿐이다. 실제 호출은 책에 예제는 없지만 이런 느낌일 것이다.
UserDaoDeleteAll userDaoDeleteAll = new UserDaoDeleteAll();
userDaoDeleteAll.executeDao();
// 또 다른 메소드에 대응하는 서브클래스
UserDaoAdd userDaoAdd = new UserDaoAdd();
UserDaoAdd .exeucteDao(); // 단순한 예시를 위해 User 파라미터 생략..
두 가지 문제점이 있다.
모든 DAO 로직(메소드)마다 상속을 통해 새로운 클래스를 만들어야 된다.
컴파일 시점에 클래스 간(슈퍼-서브) 관계가 결정되어 있어서 유연하지 못하다.
"변하는 부분을, 아예 별도의 클래스로 만들어 추상화된 인터페이스를 통해 소통하도록 구성한다"
deleteAll()의 컨텍스트(변하지 않는 부분) :
DB 커넥션 획득
PreparedStatement 생성
PreparedStatement 실행
예외는 throws
모든 리소스 반환 (PreparedStatement, Connection, ResultSet)
🤔 사견 : ResultSet은 상황의 단순화를 위해 예시에 잘 나오지 않음. 일부러 ResultSet이 없는 deleteAll을 고른 듯.
package springbook.user.dao;
// ...
public interface StatementStrategy {
PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}
package springbook.user.dao;
// ...
public class DeleteAllStatement implements StatementStrategy {
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
PreparedStatement ps = null;
ps = c.prepareStatement("DELETE FROM users");
return ps;
}
}
package springbook.user.dao;
// ...
public void deleteAll() throws SQLException {
Connection c = null;
PreparedStatement ps = null;
try {
c = dataSource.getConnection();
// 이젠 이 아래 한 줄만 메서드에 따라서 바꾸면 된다.
StatementStrategy stmt = new DeleteAllStatement();
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) {
}
}
}
🤔 사견 :
여전히 클래스를 매번 만들어주기는 해야되지만, 이건 '괜찮은 것'으로 간주하는 듯하다.아래에서 개선 방법 제시
이제는 UserDao 안에서, StatementStrategy를 상속한 클래스들을 이용해 객체를 만들고 똑같이 ps = stmt.makePreparedStatement(c)
구문으로 해결할 수 있다.
그런데 이렇게 되면 컨텍스트(UserDao)가 무엇을 실행할 지 컴파일 시점에 알고 있다. 템플릿 메소드 패턴과 동일한 문제를 가진 것이다.
이를 해결하기 위해, Context를 사용하는 Client가 전략을 선택하도록 수정하자.
즉, 전략 패턴은 다음과 같은 세 요소를 지니고 있다.
여러 전략은 하나의 (전략) 인터페이스를 implement한다.
컨텍스트는 이 각각의 전략들과 '인터페이스를 매개로' 느슨하게 연결되어 있기 때문에 어떤 전략이든 사용할 수 있다.
클라이언트는 특정 전략을 선택하고 생성하고 컨텍스트에게 전달한다.
이미 1장(83p)에서 같은 결론에 도달한 적이 있다. UserDao가 DConnectionMaker를 본인 메소드 안에서 새로 생성하는 것이 문제가 되어서,
UserDaoTest에서 DConnectionMaker / NConnectionMaker (특정 전략들)를 생성하고
UserDao를 생성할 때 (생성자 DI 주입 방식으로) 특정 전략을 제공하고
UserDao는 이 주입받은 전략을 이용하였다. 이용할 때는 ConnectionMaker 타입으로 사용했기 때문에, 어떤 전략이든 문제없이 실행할 수 있었다.
이러한 관계, 즉 "전략 오브젝트 생성과 컨텍스트로의 전달을 담당하는 하나의 책임"을 통째로 드러내고 사용하는 방식이 DI며 전략 패턴을 잘 살리는 방식이라고 말할 수 있다.
🤔 사견 : 전략 패턴을 잘 유도하는 것이 DI의 장점이고, 스프링이 DI를 적극적으로 도와주는 프레임워크라면, 스프링은 전략 패턴이 거의 Primary Strategy라고 생각해도 되는걸까?
아무튼 deleteAll을 하나의 Client로 본다면, 우리는 반복되는 부분을 Context로 뽑아내고, 거기에 전략을 주입해볼 수 있다.
여기서 이해하는 데 시간을 많이 잡아 먹었다.
1장에서 습득한 것은 UserDaoTest-UserDao-Datasource가Client-Context-Strategy
의 전략 패턴 구조인데, 지금은 UserDao의 내부 메소드가 또다른 전략 패턴 구조를 만들어가고 있다.deleteAll-PreparedStatement가
Context-Strategy
의 구조를 보이고 있는 상태이며, 이제부터 할 것은 deleteAll-jdbc-statement를Client-Context-Strategy
형태로 바꾸어 나가는 작업이다.참고로 아까부터 계속 PreparedStatement랑 Statement, statement를 혼용해서 쓰고 있는데 다 같은 거라고 이해하시면 되겠다.
jdbc와 관련된 (리소스 요청, 반납 등의) 작업들을 Context라고 본다면, 아래와 같은 3단 전략 패턴 구조를 만들어 볼 수 있다.
// Client
public void deleteAll() throws SQLException {
StatementStrategy stmt = new DeleteAllStatement();
jdbcContextWithStatementStrategy(stmt);
}
// Context
public void jdbcContextWithStatementStrategy(StatementStrategy stmt) throws SQLException{
// SQL 쿼리(PreparedStatement)와는 결합도가 낮은 "JDBC 작업 흐름"을 분리해 냄
Connection c = null;
PreparedStatement ps = null;
try {
c = dataSource.getConnection();
ps = stmt.makeStatement(c); // 이 부분에서 strategy 사용
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) {
}
}
}
// Strategy (이전과 바뀐 것 없음)
public interface StatementStrategy {
PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}
// ...
public class DeleteAllStatement implements StatementStrategy {
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
PreparedStatement ps = null;
ps = c.prepareStatement("DELETE FROM users");
// add() 같은 경우엔 여기에 ps.setString()같은 추가 작업이 필요함 (아래에서 추가 설명)
return ps;
}
}
3.3.2 챕터부터 진행되는 내용으로, 여기의 소제목은 '전략과 클라이언트의 동거'이다.
위 코드의 문제점 두 가지.
모든 DAO 메소드마다 새로운 Strategy 구현 클래스를 만든다.
부가정보를 전달하려면, Strategy 구현체에 이것저것(생성자와 인스턴스 변수 코드) 손이 많이 간다. 예를 들어 add()
메소드는 User 정보를 Strategy 구현체에 추가해 주어야 한다.
🖐 잠깐! 중첩 클래스(nested class)의 분류
- 스태틱 클래스
- 내부 클래스
a. (멤버) 내부 클래스 : 스코프가 클래스에 걸림
b. 로컬 클래스 : 스코프에 메소드에 걸림
c. 익명 내부 클래스 : 어디에 선언했는지에 따라 스코프가 다름
어차피 각 전략은 각 메소드에서만 쓰이니까, 로컬 클래스로 선언해볼 수 있다.
🤔 사견 : 사실 이걸 말하고 싶었던 듯 하다...
⚠️ 주의! 익명 내부 클래스를 사용할 때는...
- 클래스 밖의 변수는 final 키워드가 붙어있어야만 쓸 수 있다.
아래는 Client와 Strategy가 한 몸이 된 모습니다.
// Client (원래 여기 있었음)
public void deleteAll() throws SQLException {
// Context (선언은 분리되어 있음..)
jdbcContextWithStatementStrategy(
// Strategy (선언 따로 없음)
new StatementStrategy() {
@Override
public PreparedStatement makeStatement(Connection c) throws SQLException {
return c.prepareStatement("DELETE FROM users");
}
}
);
}
jdbcContextWithStatementStrategy()
는 UserDao
외에도 적용할 수 있으므로 별도 클래스로 분리할 수 있다. 분리하면서 'JdbcContext
클래스의 workWithStatementStrategy()
메소드'로 이름도 바뀌었다.
그런데 이때, 한 가지 새로운(?) 문제가 발생한다. 바로 dataSource
다.
jdbcContext 메소드는 지금까지 UserDao 내부에 위치했다. 따라서 그냥, UserDao의 인스턴스 변수 dataSource를 사용하면 되었다. 이 변수는 현재 UserDaoTest 클래스의 @ContextConfiguration(locations = "/test-applicationContext.xml")
어노테이션에 의해 xml 파일에서 주입받고 있으며, 생성자 DI 방식을 사용하고 있다.
<!-- ... -->
<bean id="userDao" class="springbook.user.dao.UserDao">
<property name="dataSource" ref="dataSource" />
</bean>
<!-- ... -->
그런데 클래스를 별도로 두면, 어떤 식으로는 jdbcContext에 dataSource를 전달해주어야 한다. 이때 컴파일 단계의 의존관계는 다음과 같다. (오른쪽이 왼쪽에 의존하고 있음)
dataSource(Interface) <-- jdbcContext(Class) <-- UserDao(Class)
그런데, UserDao는 구체 클래스에 의존하고 있다. "스프링의 DI는 기본적으로 인터페이스를 사이에 두고 의존 클래스(예를 들면 dataSource 구현체인 SimpleDriverDataSource)를 바꿔서 사용하도록 하는 게 목적이다" 라고 책에 써있고 그 직후 "하지만 이 경우 JdbcContext는 그 자체로 독립적인 JDBC 컨텍스트를 제공해주는 '서비스 오브젝트'로서 의미가 있을 뿐이고 구현 방법이 바뀔 가능성은 거의 없다"라고 하고 있다. 일단 그냥 넘어가자 라고 읽으면 되는거같다
아무튼 먼저 xml을 이용해 의존성을 주입해보자.
<!-- ... -->
<bean id="dataSource" class="org.springframework.jdbc.datasource.SimpleDriverDataSource">
<!-- DB 접속 정보 properties -->
</bean>
<!-- datasource가 jdbcContext에 주입됨 -->
<bean id="jdbcContext" class="springbook.user.dao.JdbcContext">
<property name="dataSource" ref="dataSource" />
</bean>
<!-- jdbcContext가 userDao에 주입됨 -->
<bean id="userDao" class="springbook.user.dao.UserDao">
<property name="jdbcContext" ref="jdbcContext" />
<!-- dataSource를 안지우는 것은, 아직 다른 메소드들 리팩토링이 덜 끝나서 -->
<property name="dataSource" ref="dataSource" />
</bean>
<!-- ... -->
jdbcContext 구체 클래스를 빈으로 등록하는 세 가지 이유가 있다.
싱글톤이 되기에 충분한 조건을 갖추고 있음 - 따라서 공유자원으로 활용하면 좋음
UserDao에서 사용하고 있음 - "DI를 위해서는 주입되는/주입하는 두 오브젝트가 모두 스프링 빈으로 등록돼야 한다
두 오브젝트 사이의 실제 의존관계가 설정 파일에 명확하게 드러난다.
하지만 DI의 근본적인 원칙에 부합하지 않는다. 즉, 구체적인 클래스 간 관계가 컴파일 단에 노출된다.
각 DAO 마다 JdbcContext 클래스 하나를 보유하고 해당 DAO 내부에서 돌려쓰는 것 정도를 용인할 수 있다면, 이 방법을 시도할 수 있다. 이는 UserDao가 JdbcContext에 대한 제어권(생성, 초기화(의존성 주입), 사용)을 가진다는 말이다.
코드로는 JdbcContext 내부에 setDataSource 메소드를 두고, UserDao 생성자를 다음과 같이 수정하여 구현한다.
public class UserDao {
JdbcContext jdbcContext;
DataSource dataSource;
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
this.jdbcContext = new JdbcContext();
this.jdbcContext.setDataSource(dataSource);
}
// ...
}
이 방법의 장점 :
굳이 인터페이스도 없는 '긴밀한 관계'를 같은 두 객체를 '어색하게' 빈으로 분리하지 않는다.
DI는 나름대로 은밀하게 구색을 갖추었다.
2번 덕분에, 두 클래스 사이 관계가 노출되지 않는다.
하지만 싱글톤으로 사용이 불가하고, DI를 위한 추가 코드가 발생했다는 단점이 있다.
🤔 사견 : Javascript에서는 함수가 일급 객체다. 따라서 콜백 함수를 별다른 문제없이 바로 구현할 수 있다. 콜백이 필요한 자리에 그냥 function을 던진다는 소리다.
그런데 놀랍게도 자바는 함수를 파라미터로 전달할 수 없다고 한다. 그러면 당연히, function을 감싼 class를 전달해야 할 것이다.이제부터 나올 템플릿/콜백 패턴은 자바 스타일의 콜백 함수 사용법이라고 말 할 수 있을 것 같다.
템플릿/콜백 패턴에서,
템플릿 : 전략 패턴의 Context
콜백 : 익명 내부 클래스로 구현된 오브젝트. 일반적으로 단일 메소드 '인터페이스'를 지닌다.
고유한 특징으로는, 자신을 호출한 Client의 정보를 직접 참조한다는 것. (final 변수들을 가져다 쓸 수 있다)
왜 인터페이스인가 : 틀은 그대로 두면서 값이나 구현을 즉석에서 '바꾸기' 위해서. 구현체면 바꿀 수 없으니까!
(엄청 당연한건데 아직 자바가 익숙치 않아서 적음...)
사실 이 패턴은 이미 위에서 익명 내부 클래스 예시로 등장했다.
이 패턴은 "메소드 레벨에서 일어나는 DI"라고 할 수 있다. Context가 무슨 메서드를 쓸 것인지를, Client의 메서드 안에서, 바로 짜내어서 삽입하는 느낌으로 말이다.
이 패턴을, "전략패턴과 수동 DI"의 결합체라고 이해할 수 있다.
Javascript에서의 callback은 asyncronous다. 즉, caller는 callback이 언제 어디서 실행될지 알지 못하고 알 필요도 없다. Java는 그와 다르다. 결과를 클라이언트가 받아야만 한다.
- 클라이언트가 템플릿에 콜백을 전달하고,
- 템플릿은 자기 로직 실행 후 콜백을 실행한다.
- 콜백 내부에서 리턴된 값은 템플릿으로 돌아가고,
- 템플릿은 자기 로직 나머지를 수행한 후, 최종 결과를 클라이언트에 전달한다.
그리고 여기에 더해, 클라이언트 메소드 안에서 콜백 함수가 구현된다는 특징까지,가 템플릿/콜백 패턴이라고 말할 수 있을 것이다.
책은 이 장면에서 갑자기 사칙연산을 이용해 리팩토링 with 템플릿/콜백 패턴
을 진행하기 시작한다. 요약하자면,
성공을 전제로 하는 테스트 코드를 미리 작성하고 출발
(Client) BufferedReader를 열고 닫는 과정 때문에 try~finally가 반복됨 (데자부..)
(Callback) BufferedReaderCallback
이라는 인터페이스를 만들고, 그 안에 Integer doSomethingWithReader(BufferedReader br)
메소드를 선언
(Template) try~finally 부분을 통째로 빼서, Integer fileReadTemplate(String filepath, BufferedReaderCallback cb)
라는 메소드로 분리
아래와 같은 결과물. filepath는 numbers.txt
인데, Test 코드에서 전달받았고 파일에는 숫자 4개가 한 줄에 하나씩 쓰여있다.
public Integer calcSum(String filepath) throws IOException {
BufferedReaderCallback sumCallback = new BufferedReaderCallback() {
@Override
public Integer doSomethingWithBufferedReader(BufferedReader br) throws IOException {
String line = null;
Integer sum = 0;
while ((line = br.readLine()) != null) {
sum += Integer.valueOf(line);
}
return sum;
}
};
// fileReadTemplate의 자세한 코드는 생략한다.
return fileReadTemplate(filepath, sumCallback);
/* 좀 더 줄이면 아래와 같다.
return fileReadTemplate(filepath, new BufferedReaderCallback() {
@Override
public Integer doSomethingWithBufferedReader(BufferedReader br) throws IOException {
String line = null;
Integer sum = 0;
while ((line = br.readLine()) != null) {
sum += Integer.valueOf(line);
}
return sum;
}
});*/
}
Integer sum = 0
, sum *= Integer.valudOf(line)
으로 바꾸는 걸로 충분하다. 즉, 아직도 변하지 않는 부분이 변하는 부분 위아래로 조금씩 붙어있다. 따라서 이를 살짝 개량한다.public Integer calcSum(String filepath) throws IOException {
LineCallback callback = new LineCallback<Integer>() {
@Override
public Integer doSomethingWithLine(String line, Integer value) {
return value + Integer.valueOf(line);
}
};
return lineReadTemplate(filepath, callback, 0); // 곱셈의 경우에는 0 대신 1
}
private Integer lineReadTemplate(String filepath, LineCallback callback, Integer initVal) throws IOException {
BufferedReader br = null;
try {
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;
} finally {
if (br != null) try {
br.close();
} catch (IOException e) {
System.out.println(e.getMessage());
}
}
}
private <T> T lineReadTemplate(String filepath, LineCallback<T> callback, T initVal) throws IOException
로 바뀌었다.스프링은 JDBC를 이용하는 DAO에서 사용할 수 있도록 다양한 템플릿, 콜백을 제공한다. 여기서는 JdbcTemplate 클래스를 사용한다.
update()
queryForInt()
queryForObject()
query()
... 정도가 언급된 것 같은데, 내부 기술을 이해하는 게 더 중요하니 자세한 언급은 하지 않겠다. 다만 여기서 한 가지 예시를 짚고 가자면,
public class UserDao {
private JdbcContext jdbcContext;
private JdbcTemplate jdbcTemplate;
// 생성자 코드 생략
// 자주 쓰는 콜백(익명 내부 클래스)를 이렇게 미리 생성해 둘 수 있다
private RowMapper<User> userMapper =
new RowMapper<User>() {
@Override
public User mapRow(ResultSet rs, int rowNum) throws SQLException {
User user = new User(rs.getString("id"), rs.getString("name"), rs.getString("password"));
user.setUnique_id(rs.getString("unique_id"));
return user;
}
};
public void add(final User user) throws SQLException {
this.jdbcTemplate.update("INSERT INTO users(id, name, password) VALUES(?, ?, ?)",
user.getId(), user.getName(), user.getPassword());
}
// ...
}
🤔 사견 : 위 익명 내부 클래스는 엄밀히 말하면 스코프가 객체에 걸리기 때문에 기존의 메소드 속에서 선언된 것과 질적으로 다르다고 생각한다. 다만 이렇게 쓸 수 있는 것은 (책에서 언급했다시피) stateless하기 때문이 아닐까.
공유 리소스의 반환이 필요한 코드는 반드시 try/catch/finally 블록으로 관리한다
전략 패턴 짱짬맨
중복 코드는 분리하자. 외부에서도 사용될 수 있으면 클래스도 분리하라.
컨텍스트는 빈으로 등록해서 DI 받거나, 수동으로(클라이언트 클래스에서) 직접 컨텍스트를 생성(+ 의존성 주입)하는 두 가지 방법으로 운용할 수 있다.
템플릿/콜백 패턴은 컨텍스트 호출과 동시에 전략 DI를 수행하는 패턴이다.
콜백에서도 일정 패턴이 반복된다면 그것마저 템플릿에 넣고 재활용할 수 있다.