개방 폐쇄 원칙은 코드에서 어떤 부분은 변경을 통해 그 기능이 다양해지고 확장하려는 성질이 있고, 어떤 부분은 고정되어 있고 변하지 않으려는 성질이 있음을 말해준다. 변화의 특성이 다른 부분을 구분해주고, 각각 다른 목적과 다른 이유에 의해 다른 시점에 독립적으로 변경될 수 있는 효율적인 구조를 만들어 주는 것이 바로 이 개방 폐쇄 원칙이다.
템플릿이란 이렇게 바뀌는 성질이 다른 코드 중에서 변경이 거의 일어나지 않으며 일정한 패턴으로 유지되는 특성을 가진 부분을 자유롭게 변경되는 성질을 가진 부분으로부터 독립시켜서 효과적으로 활용할 수 있도록 하는 방법이다.
UserDao
의 코드는 개선됐지만, 아직 예외상황에 대한 처리면에서 심각한 문제점이 있다.
DB 커넥션이라는 제한적인 리소스를 공유해 사용하는 서버에서 동작하는 JDBC 코드에는 반드시 예외처리를 해줘야 한다. JDBC 코드 실행 중간에 예외가 발생했을 경우에 사용한 리소스를 반드시 반환하도록 만들지 않으면 시스템에 심각한 문제를 일으킬 수 있기 때문이다.
UserDao
의 가장 단순한 메서드인 deleteAll()
을 살펴보자.
이 메서드에서는 PreparedStatement
를 처리하는 중에 예외가 발생하면 메서드 실행을 끝마치지 못하고 바로 메서드를 빠져나가게 된다. 이때 문제는 Connection
과 PreparedStatement
의 close()
메서드가 실행되지 않아 제대로 리소스가 반환되지 않을 수 있다는 점이다.
일반적으로 서버에서는 제한된 개수의 DB 커넥션을 만들어서 재사용 가능한 풀로 관리한다. 그래서 이런 식으로 오류가 날 때마다 미처 반환되지 못한 Connection
이 계속 쌓이면 어느 순간에 커넥션 풀에 여유가 없어지고 리소스가 모자란다는 심각한 오류를 내며 서버가 중단될 수 있다.
📌 리소스 반환과
close()
Connection
이나PreaparedStatement
에는close()
메서드가 있다.Connection
과PreparedStatement
는 보통 풀(pool) 방식으로 운영된다. 미리 정해진 풀 안에 제한된 수의 리소스(Connection
,Statement
)를 만들어두고 필요할 때 이를 할당하고, 반환하면 다시 풀에 넣는 방식으로 운영된다. 여기서close()
메서드는 사용한 리소스를풀로 다시 돌려주는 역할을 한다.
그래서 이런 JDBC 코드에서는 어떤 상황에서도 가져온 리소스를 반환하도록 try/catch/finally
구문 사용을 권장하고 있다.
이제 예외상황에서도 안전한 코드가 됐다. finally
는 try
블록을 수행한 후에 예외가 발생하든 정상적으로 처리되든 상관없이 반드시 실행되는 코드를 넣을 때 사용한다.
그런데 문제는 예외가 어느 시점에 나는가에 따라 Connection
과 PreparedStatement
중 어떤 것의 close()
메서드를 호출해야 할 지가 달라진다. null
상태의 변수에 close()
메서드를 호출하면 NullPointerException
이 발생하기 때문에 close()
메서드를 호출하면 안 된다.
getConnection()
에서 예외 발생시 -> c
, ps
모두 null
상태다.PreparedStatement
를 생성하다가 예외 발생시 -> c
변수가 커넥션 객체를 갖고 있는 상태, ps
는 null
상태다.ps
를 실행하다가 예외 발생시 -> c
, ps
모두 close()
메서드를 호출해줘야 한다.문제는 이 close()
도 SQLException
이 발생할 수 있는 메서드라는 점이다. 따라서 try/catch
문으로 처리해줘야 한다. 이미 deleteAll
에 SQLException
이 던져진다고 선언되어 있어도, try/catch
블록 없이 ps.close()
를 처리하다가 예외가 발생하면 아래 c.close()
부분이 실행되지 않고 메서드를 빠져나가는 문제가 발생하기 때문이다.
현재 catch
블록에서는 딱히 하는 일이 없어 블록을 빼버려도 되지만, 예외가 발생한 경우에 보통 로그를 남기는 등의 부가작업이 필요할 수 있으니 catch
블록은 일단 만들어두는 편이 좋다.
조회를 위한 JDBC 코드에는 ResultSet
이 추가된다. ResultSet
도 반환해야 하는 리소스이기 때문에 예외상황에서도 close()
메서드가 반드시 호출되도록 해야한다.
이제 UserDao
의 모든 메서드에 동일한 방식으로 try/catch/finally
블록을 적용해 예외상황에 대한 처리까지 모두 마쳤다.
이제 try/catch/finally
블록도 적용돼서 완성도 높은 DAO 코드가 된 UserDao
이지만, 복잡한 블록이 2중으로 중첩되고, 모든 메서드마다 반복된다.
이럴때 가장 효과적인 방법을 뭘까?
1) 복사 - 붙여넣기
ResultSet
이 있는 것과 없는 두 가지 종류의 메서드를 각각 하나씩 만들어두고 계속 코드를 복사한다.2) 테스트를 통해 DAO마다 예외상황에서 리소스 반납하는지 체크
이 외에 효과적으로 다룰 수 있는 방법은 없을까?
UserDao
의 메서드를 개선해보자. 가장 먼저 할 일은 변하는 성격이 다른 것을 찾아내는 것이다.
그렇다면 메서드에서 변하지 않는 부분을 분리하여 재사용할 수 있는 방법이 있지 않을까?
먼저 메서드로 빼는 방법이 있다. 변하지 않는 부분이 변하는 부분을 감싸고 있어서 변하지 않는 부분을 추출하기가 어려워 보이기 때문에 반대로 해봤다.
보통 메서드 추출 리팩토링을 적용하는 경우에는 분리시킨 메서드를 다른 곳에서 재사용할 수 있어야 하는데, 이건 뭔가 반대로 됐다.
템플릿 메서드 패턴은 상속을 통해 기능을 확장해서 사용하는 부분이다. 변하지 않는 부분은 슈퍼클래스에 두고 변하는 부분은 추상 메서드로 정의해둬서 서브클래스에서 오버라이드하여 새롭게 정의해 쓰도록 하는 것이다.
추출해서 별도의 메서드로 독립시킨 makeStatement()
메서드를 다음과 같이 추상 메서드 선언으로 변경한다. 물론 UserDao
클래스도 추상 클래스가 돼야 할 것이다.
abstract protected PreparedStatement makeStatement(Connection c) throws
SQLException;
그리고 이를 상속하는 서브클래스를 만들어서 거기서 이 메서드를 구현한다. 고정된 JDBC try/catch/finally
블록을 가진 슈퍼클래스 메서드와 필요에 따라서 상속을 통해 구체적인 PreparedStatement
를 바꿔서 사용할 수 있게 만드는 서브클래스로 깔끔하게 분리할 수 있다.
하지만 템플릿 메서드 패턴으로의 접근은 제한이 많다. 가장 큰 문제는 DAO 로직마다 상속을 통해 새로운 클래스를 만들어야 한다는 점이다.
또 확장구조가 이미 클래스를 설계하는 시점에서 고정되어 버린다는 점이다. 변하지 않는 코드를 가진 UserDao
의 JDBC try/catch/finally
블록과 변하는 PreparedStatement
를 담고 있는 서브클래스들이 이미 클래스 레벨에서 컴파일 시점에 이미 그 관계가 결정되어 있다. 따라서 그 관계에 대한 유연성이 떨어져 버린다.
개방 폐쇄 원칙을 잘 지키는 구조이면서도 템플릿 메서드 패턴보다 유연하고 확장성이 뛰어난 것이, 오브젝트를 아예 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만드는 전략 패턴이다.
아래는 좌측에 있는 Context
의 contextMethod()
에서 일정한 구조를 가지고 동작하다가 특정 확장 기능은 Strategy
인터페이스를 통해 외부의 독립된 전략 클래스에 위임하는 것이다.
deleteAll()
메서드에서 변하지 않는 부분이라고 명시한 것이 contextMethod()
가 된다.
deleteAll()
의 컨텍스트
PreparedStatement
를 만들어줄 외부 기능 호출하기PreparedStatement
실행하기PreparedStatement
와 Connection
을 적절히 닫아주기두 번째 작업에서 사용하는 PreparedStatement
를 만들어주는 외부 기능이 바로 전략 패턴에서 말하는 전략이라고 볼 수 있다. 전략 패턴의 구조를 따라 이 기능을 인터페이스로 만들어두고 인터페이스의 메서드를 통해 PreparedStatement
생성 전략을 호출해주면 된다. PreparedStatement
를 만드는 전략의 인터페이스는 컨텍스트가 만들어둔 Connection
을 전달받아서, PreparedStatement
를 만들고 만들어진 PreparedStatement
오브젝트를 돌려준다.
이 인터페이스를 상속해서 실제 전략, 즉 바뀌는 부분인 PreparedStatement
를 생성하는 클래스를 만들어보자.
이제 확장된 PreparedStrategy
전략인 DeleteAllStatement
가 만들어졌다.
하지만 전략 패턴은 필요에 따라 컨텍스트는 그대로 유지되면서 전략을 바꿔쓸 수 있다는 것인데, 컨텍스트가 StatementStrategy
인터페이스뿐 아니라 특정 구현 클래스인 DeleteAllStatement
를 직접 알고 있다는 건, 전략 패턴에도 OCP에도 잘 들어맞는다고 볼 수 없다.
전략 패턴에 따르면 Context가 어떤 전략을 사용하게 할 것인가는 Context를 사용하는 앞단의 Client가 결정하는 게 일반적이다. Client가 구체적인 전략의 하나를 선택하고 오브젝트로 만들어서 Context에 전달하면, Context는 전달받은 그 Strategy
구현 클래스의 오브젝트를 사용한다.
이 그림은 1장에서 봤다. 컨텍스트(UserDao
)가 필요러 하는 전략(ConnectionMaker
)의 특정 구현 클래스(DConnectionMaker
) 오브젝트를 클라이언트(UserDaoTest
)가 만들어서 제공해주는 방법이다.
결국 이 구조에서 전략 오브젝트 생성과 컨텍스트로의 전달을 담당하는 책임을 분리시킨 것이 바로 ObjectFactory
이며, 이를 일반화한 것이 DI였다. 결국 DI란 이러한 전략 패턴의 장점을 일반적으로 활용할 수 있도록 만든 구조라고 볼 수 있다.
이 패턴 구조를 코드에 적용해보자. 중요한 것은 이 컨텍스트에 해당하는 JDBC try/catch/finally
코드를 클라이언트 코드인 StatementStrategy
를 만드는 부분에서 독립시켜야 한다는 점이다. 현재 deleteAll()
메서드에서 다음 코드는 클라이언트에 들어가고, 나머지 코드는 컨텍스트 코드이므로 별도의 메서드로 분리해야 한다.
StatementStrategy strategy = new DeleteAllStatement();
클라이언트는 DeleteAllStatement 오브젝트와 같은 전략 클래스의 오브젝트를 컨텍스트의 메서드를 호출하며 전달해야 한다.
컨텍스트 메서드의 작업
StatementStrategy
타입의 전략 오브젝트를 제공받고 JDBC try/catch/finally
구조로 만들어진 컨텍스트 내에서 작업을 수행한다.PreparedStatement
생성이 필요한 시점에 호출해서 사용한다.클라이언트(DAO deleteAll()
)의 작업
DeleteAllStatement
jdbcContextWithStatementStrategy()
메서드📌 정리
컨텍스트 :jdbcContextWithStatementStrategy()
메서드
- 컨텍스트는
PreparedStatement
를 실행하는 JDBC의 작업 흐름이고, 전략은PreparedStatement
를 생성하는 것이다.클라이언트 : DAO 메서드 - ex)
deleteAll()
- 컨텍스트에 해당하는 메서드에 적절한 전략, 즉 바뀌는 로직을 제공해주는 방법으로 사용할 수 있다.
이제 구조로 볼 때 완벽한 전략 패턴의 모습을 갖췄다. 비록 클라이언트와 컨텍스트는 클래스를 분리하진 않았지만, 의존과계와 책임으로 볼 때 이상적인 클라이언트/컨텍스트 관계를 갖고 있다. 특히 클라이언트가 사용할 전략을 정해서 전달한다는 면에서 DI 구조라고 이해할 수도 있다.
👉 마이크로 DI
DI의 가장 중요한 개념은 제3자의 도움을 통해 두 오브젝트 사이의 유연한 관계가 설정되도록 만든다는 것이다. 이러한 DI는 매우 작은 단위의 코드와 메서드 사이에서 일어나기도 한다. 이렇게 DI의 장점을 단순화해서 IoC 컨테이너의 도움 없이 코드 내에서 적용한 경우를 마이크로 DI라고도 한다. 또는 코드에 의한 DI라는 의미로 수동 DI라고 부를 수도 있다.
지금까지
1. 기존의 메서드에 담겨 있던 변하지 않는 부분, 자주 변하는 부분을 전략 패턴을 사용해 분리해냈다.
2. 독립된 작업 흐름이 담긴 메서드로 분리해서 DAO 메서드들이 공유할 수 있게 됐다.
3. DAO 메서드는 전략 패턴의 클라이언트로서 컨텍스트에 해당하는 메서드에 적절한 전략, 즉 바뀌는 로직을 제공해주는 방법으로 사용할 수 있다.
add()
메서드에도 변하는 부분을 AddStatement
클래스로 옮겨 담는다.
PreparedStatement
만드는 코드add()
에서는 PreparedStatement
를 만들 때 user
라는 부가적인 정보가 필요하다.
따라서 클라이언트가 AddStatement
의 전략을 수행하려면 부가정보인 user
를 제공해줘야 한다.
클라이언트인 UserDao
의 add()
메서드를 user
정보를 생성자를 통해 전달해주도록 한다.
이렇게 해서 deleteAll()
과 add()
두 군데에서 모두 PreparedStatement
를 실행하는 JDBC try/catch/finally
컨텍스트를 공유해서 사용할 수 있게 됐다.
아직 2가지 문제점이 있다.
StatementStrategy
구현 클래스를 만들어야 한다.StatementStrategy
에 전달할 User와 같은 부가적인 정보가 있는 경우, 이를 위해 오브젝트를 전달받는 생성자와 이를 저장해둘 인스턴스 변수를 번거롭게 만들어야 한다.클래스 파일이 많아지는 문제는 StatementStrategy
전략 클래스를 UserDao
클래스 안에 내부 클래스로 정의해버리는 것으로 해결할 수 있다.
📌 중첩 클래스
중첩 클래스는 다른 클래스 내부에 정의되는 클래스를 말한다.
- 중첩 클래스의 종류
- 스태틱 클래스 : 독립적으로 오브젝트로 만들어 질 수 있다.
- 내부 클래스 : 자신이 정의된 클래스의 오브젝트 안에서만 만들어질 수 있다.
- 범위에 따라 멤버 내부 클래스, 로컬 클래스, 익명 내부 클래스로 나눠진다.
- 멤버 내부 클래스 : 멤버 필드처럼 오브젝트 레벨에 정의된다.
- 로컬 클래스 : 메서드 레벨에 정의된다.
- 익명 내부 클래스 : 이름을 갖지 않고, 범위는 선언된 위치에 따라 다르다.
AddStatement
클래스를 로컬 클래스로서 add()
메서드 안에 집어넣은 것이다.
장점
AddStatement
가 add()
메서드의 User 오브젝트 접근final
로 선언해줘야 한다. AddStatement
클래스는 add()
메서드에서만 사용할 용도로 만들어졌기 때문에 좀 더 간결하게 클래스 이름도 제거할 수 있다.
👉 익명 내부 클래스
이름을 갖지 않는 클래스다.
new 인터페이스이름() { 클래스 본문 };
- 클래스 선언과 오브젝트 생성이 결합된 형태로 만들어진다.
- 선언과 동시에 오브젝트를 생성하며, 클래스 자신의 타입을 가질 수 없고, 구현한 인터페이스 타입의 변수에만 저장할 수 있다.
- 클래스를 재사용할 필요없고, 구현한 인터페이스 타입으로만 사용할 경우에 유용하다.
만들어진 익명 내부 클래스의 오브젝트는 딱 한 번만 사용할 테니 굳이 변수에 담아두지 말고 jdbcContextWithStatementStrategy()
메서드의 파라미터에서 바로 생성하는 편이 낫다.
DeleteAllStatement
도 deleteAll()
메서드로 가져와서 익명 내부 클래스로 처리한다.
JdbcContext
의 분리jdbcContextWithStatementStrategy()
는 JDBC의 일반적인 작업 흐름을 담고 있으므로 다른 DAO에서도 사용 가능하기 때문에 독립시키자.
변경 사항
JdbcContext
workWithStatementStrategy()
JdbcContext
에 DataSource
타입 빈을 DI 받을 수 있게 한다.UserDao
가 의존하는 JdbcContext
가 구체 클래스다. 스프링의 DI는 기본적으로 인터페이스를 사이에 두고 의존 클래스를 바꿔서 사용하도록 하는 게 목적이지만, JdbcContext
의 경우는 그 자체로 독립적인 JDBC 컨텍스트를 제공해주는 서비스 오브젝트로서 의미가 있을 뿐이고 구현 방법이 바뀔 가능성은 없다. 따라서 UserDao
와 JdbcContext
는 인터페이스를 사이에 두지 않고 DI를 적용하는 특별한 구조가 된다.
스프링의 빈 설정은 클래스 레벨이 아니라 런타임 시에 만들어지는 오브젝트 레벨의 의존관계에 따라 정의된다. 아래는 빈으로 정의되는 오브젝트 사이의 관계다. 기존에는 userDao
빈이 dataSource
빈을 직접 의존했지만 이제는 jdbcContext
빈이 그 사이에 끼게 된다.
바뀐 빈 의존관계를 따라 XML 설정파일을 수정하자.
JdbcContext
의 특별한 DI지금까지 적용했던 DI와는 다르게 UserDao
와 JdbcContext
사이에는 인터페이스를 사용하지 않고 DI를 적용했다. 즉, UserDao
는 인터페이스를 거치지 않고 코드에서 바로 JdbcContext
클래스를 사용하고 있다. UserDao
와 JdbcContext
는 클래스 레벨에서 의존관계가 결정된다.
스프링 DI의 기본 의도에 맞게 JdbcContext
의 메서드를 인터페이스로 뽑아내어 정의해두고, 이를 사용하게 해야 하지 않을까? 물론 그렇게 해도 상관없지만, 꼭 그럴 필요는 없다.
의존관계 주입이라는 개념을 충실히 따르자면, 인터페이스를 사이에 둬서 클래스 레벨에서는 의존관계가 고정되지 않게 하고, 런타임 시에 의존할 오브젝트와의 관계를 다이내믹하게 주입해주는 것이 맞다. 그러나 스프링의 DI는 IoC라는 개념을 포괄하기 때문에 JdbcContext
를 스프링을 이용해 UserDao
객체에서 사용하게 주입했다는 건 DI의 기본을 따르고 있다고 볼 수 있다.
JdbcContext
를 UserDao
와 DI 구조로 만들어야 할 이유는 뭘까?JdbcContext
는 그 자체로 변경되는 상태정보를 갖고 있지 않다.JdbcContext
는 JDBC 컨텍스트 메서드를 제공해주는 일종의 서비스 오브젝트로서 의미가 있고, 그래서 싱글톤으로 등록돼서 여러 오브젝트에서 공유해 사용되는 것이 이상적이다.실제로 스프링에는 드물지만 이렇게 인터페이스를 사용하지 않는 클래스를 직접 의존하는 DI가 등장하는 경우도 있다. 인터페이스가 없다는 건 강한 결합도, 응집도를 갖고 있다는 의미다.
JdbcContext
는 DataSource
와 달리 테스트에서도 다른 구현으로 대체해서 사용할 이유가 없다. 이런 경우는 굳이 인터페이스를 두지 말고 강력한 결합을 가진 관계를 허용하면서 위에서 말한 두 가지 이유인, 싱글톤으로 만드는 것과 JdbcContext
에 대한 DI 필요성을 위해 스프링의 빈으로 등록해서 UserDao
에 DI 되도록 만들어도 좋다.
📌 단, 이런 클래스를 바로 사용하는 코드 구성을 DI에 적용하는 것은 가장 마지막 단계에서 고려해볼 사항임을 잊지 말자.
JdbcContext
를 스프링의 빈으로 등록해서 UserDao
에 DI 하는 대신 사용할 수 있는 방법이 있다. UserDao
내부에서 직접 DI를 적용하는 방법이 있다.
이전과 달리 변경되는 것
1. DAO마다 하나의 JdbcContext
오브젝트를 갖고 있게 해야한다.
JdbcContext
를 싱글톤으로 만들려는 것은 포기해야 한다. JdbcContext
에는 내부에 두는 상태정보가 없기 때문에 오브젝트 수백개가 만들어진다고 해도 메모리에 주는 부담은 거의 없다. 또한 자주 만들어졌다가 제거되는 게 아니기 때문에 GC에 대한 부담도 없다. JdbcContext
를 스프링 빈으로 등록하지 않았으므로 제어권은 UserDao
가 갖는 것이 적당하다. 자신이 사용할 오브젝트를 직접 만들고 초기화하는 전통적인 방법을 사용하는 것이다.JdbcContext
에 대한 제어권을 갖고 생성과 관리를 담당하는 UserDao
에게 DI까지 맡기게 한다.JdbcContext
는 다른 빈을 인터페이스를 통해 간접적으로 의존하고 있다. 다른 빈을 의존하고 있다면 자신도 빈으로 등록돼야 한다고 했다. UserDao
가 임시로 DI 컨테이너처럼 동작하게 만드는 것이다. UserDao
는 직접 DataSource
빈을 필요로 하지 않지만 JdbcContext
에 대한 DI 작업에 사용할 용도로 제공받는 것이다. userDao
와 dataSource
만 빈으로 정의한다.userDao
는 JdbcContext
오브젝트를 만들면서 DI 받은 DataSource
오브젝트를 JdbcContext
의 수정자 메서드로 주입해준다. 만들어진 JdbcContext
오브젝트는 인스턴스 변수에 저장해두고 사용한다. 이 방법의 장점은 굳이 인터페이스를 두지 않아도 될 만큼 긴밀한 관계를 갖는 DAO 클래스와 JdbcContext
를 따로 빈으로 분리하지 않고 내부에서 직접 만들어 사용하면서도 다른 오브젝트에 대한 DI를 적용할 수 있다는 점이다. 이렇게 한 오브젝트의 수정자 메서드에서 다른 오브젝트를 초기화하고 코드를 이용해 DI 하는 것은 스프링에서도 종종 사용되는 기법이다.
JdbcContext
가 UserDao
의 내부에서 만들어지고 사용되면서 그 관계를 외부에는 드러내지 않는다. 필요에 따라 내부에서 은밀히 DI를 수행하고 그 전략을 외부에는 감출 수 있다.JdbcContext
를 여러 오브젝트가 사용하더라도 싱글톤으로 만들 수 없고, DI 작업을 위한 부가적인 코드가 필요하다.📌 일반적으로는 어떤 방법이 더 낫다고 말할 수는 없다. 하지만 둘 중 어떤 방법을 써도 왜 그렇게 선택했는지에 대한 분명한 이유와 근거가 있어야 한다. 분명하게 설명할 자신이 없다면 차라리 인터페이스를 만들어서 평범한 DI 구조로 만드는 게 나을 수도 있다.
전략 패턴은 복잡하지만 바뀌지 않는 일정한 패턴을 갖는 작업 흐름이 존재하고 그 중 일부분만 자주 바꿔서 사용해야 하는 경우에 적합한 구조다. 이건 전략 패턴의 기본 구조에 익명 내부 클래스를 활용한 방식이다. 이런 방식을 스프링에서는 템플릿/콜백 패턴이라고 부른다.
📌 템플릿/콜백 패턴
- 템플릿 : 전략 패턴의 컨텍스트
- 콜백 : 익명 내부 클래스로 만들어지는 오브젝트
👉 템플릿
템플릿은 어떤 목적을 위해 미리 만들어둔 모양이 있는 틀을 가리킨다. 템플릿 메서드 패턴은 고정된 틀의 로직을 가진 템플릿 메서드를 슈퍼클래스에 두고, 바뀌는 부분을 서브클래스의 메서드에 두는 구조로 이뤄진다.👉 콜백
콜백은 실행되는 것을 목적으로 다른 오브젝트의 메서드에 전달되는 오브젝트를 말한다. 파라미터로 전달되지만 값을 참조하기 위한 것이 아니라 특정 로직을 담은 메서드를 실행시키기 위해 사용한다. 자바에선 메서드 자체를 파라미터로 전달할 방법은 없기 때문에 메서드가 담긴 오브젝트를 전달해야 한다. 그래서 펑셔널 오브젝트(functional object)라고도 한다.
workWithStatementStrategy()
내에서 생성한 Connection
오브젝트를 콜백의 메서드인 makePreparedStatement()
를 실행할 때 파라미터로 넘겨준다. 템플릿/콜백 방식은 전략 패턴과 DI의 장점을 익명 내부 클래스 사용 전략과 결합한 독특한 활용법이라고 이해할 수 있다. 단순한 전략 패턴이 아닌 고유한 디자인 패턴으로 기억해두면 편리하다.
DI 방식과 다른 점
JdbcContext
에 적용된 템플릿/콜백UserDao
, JdbcContext
, StatementStrategy
의 코드에 적용된 템플릿/콜백 패턴을 보자.
템플릿/콜백 방식에서 한 가지 아쉬운 점이 있다. DAO 메서드에서 매번 익명 내부 클래스를 사용하기 때문에 상대적으로 코드를 작성하고 읽기가 조금 불편하다는 점이다.
복잡한 익명 내부 클래스의 사용을 최소화할 수 있는 방법을 찾아보자. JDBC의 try/catch/finally
에 적용했던 방법을 적용해보는 것이다. 분리를 통해 재사용이 가능한 코드를 찾아내는 것이다.
콜백 오브젝트 코드를 살펴보면 단순 SQL 쿼리 하나를 담아서 PreparedStatement
를 만드는 게 전부다. 그럼 중복될 가능성이 있는 자주 바뀌지 않는 부분을 분리해보자.
재활용 가능한 콜백을 담은 메서드
executeSql()
메서드를 생성한다. final
로 선언해 익명 내부 클래스인 콜백 안에서 직접 사용할 수 있게 하는 것에 주의한다.재사용 가능한 콜백을 담고 있는 executeSql()
메서드를 UserDao
만 사용하긴 아깝다. DAO가 공유할 수 있는 템플릿 클래스 안으로 옮겨도 된다.
템플릿 클래스 안으로 이동
이제 JdbcContext
안에 클라이언트와 템플릿, 콜백이 모두 함께 공존하면서 동작하는 구조가 됐다.
일반적으로는 성격이 다른 코드들은 가능한 한 분리하는 편이 낫지만, 이 경우는 반대다. 하나의 목적을 위해 서로 긴밀하게 연관괴어 동작하는 응집력이 강한 코드들이기 때문에 한 군데 모여 있는 게 유리하다. 구체적인 구현과 내부의 전략 패턴, 코드에 의한 DI, 익명 내부 클래스 등의 기술은 최대한 감춰두고, 외부에는 꼭 필요한 기능을 제공하는 단순한 메서드만 노출해주는 것이다.
📌 add() 메서드에서의 적용 방법
- SQL 문장과 함께
PreparedStatement
에 바인딩될 파라미터 내용이 추가돼야 한다.- 바인딩 파라미터 개수는 일정하지 않으므로 가변인자로 정의해두는 것이 좋다.
- 콜백에서
PreparedStatement
를 만든 뒤에 바인딩할 파라미터 타입을 살펴서 적절한 설정 메서드를 호출해준다.
스프링에는 다양한 자바 엔터프라이즈 기술에서 사용할 수 있도록 미리 만들어져 제공되는 수십 가지 템플릿/콜백 클래스와 API가 있다. 스프링을 사용하는 개발자라면 스프링의 기본이 되는 전략 패턴과 DI, 템플릿/콜백 패턴도 익숙해지도록 학습할 필요가 있다.
고정된 작업 흐름을 갖고 있으면서 여기저기서 자주 반복되는 코드가 있다면, 중복되는 코드를 분리할 방법을 생각해보는 습관을 기르자.
템플릿/콜백 적용해보는 방법
1. 중복된 코드는 먼저 메서드로 분리하는 간단한 시도를 해본다.
2. 그중 일부 작업을 필요에 따라 바꾸어 사용해야 한다면 인터페이스를 사이에 두고 분리해서 전략 패턴을 적용하고 DI로 의존관계를 관리하도록 만든다.
3. 바뀌는 부분이 한 애플리케이션 안에서 동시에 여러 종류가 만들어질 수 있다면 템플릿/콜백 패턴을 적용하는 것을 고려하자.
📌 가장 전형적인 템플릿/콜백 패턴의 후보는
try/catch/finally
블록을 사용하는 코드다.
간단한 템플릿/콜백 예제를 만들어보자.
Calculator
, 메서드 : calcSum()
numbers.text
추가 사항
try/finally
블록catch
블록추가 사항
고려 사항
BufferedReader
를 만들어서 콜백에게 전달해주고, 콜백이 각 라인을 읽어서 알아서 처리한 후에 최종 결과만 템플릿에게 돌려준다.BufferedReaderCallback
인터페이스 타입의 콜백 오브젝트를 받아서 적절한 시점에 실행해주고, 콜백이 돌려준 결과는 최종적으로 모든 처리를 마친 후에 다시 클라이언트에 돌려준다.BufferedReaderCallback
인터페이스로 만든 익명 내부 클래스에 담는다. 처리할 파일의 경로와 함께 준비된 익명 내부 클래스의 오브젝트를 템플릿에 전달한다. 템플릿이 리턴하는 값을 최종 결과로 사용한다.고려 사항
calcSum()
과 calcMultiply()
콜백 사이에서도 공통적인 패턴이 발견되지 않을까?포인트
multiply
또는 sum
이다.multiply
또는 sum
과 각 라인의 숫자 값을 가지고 계산한 결과다. 콜백 인터페이스
템플릿 메서드
while
루프 안에서 콜백을 호출한다. 콜백을 여러 번 반복적으로 호출하는 구조가 되었다.템플릿/콜백 적용한 calcSum()
, calcMultiply()
메서드
LineCallback
과 lineReadTemplate()
은 템플릿과 콜백이 만들어내는 결과가 Integer
타입으로 고정되어 있었다.
제네릭스를 이용하면 다양한 오브젝트 타입을 지원하는 인터페이스나 메서드를 정의할 수 있다.
추가 사항
콜백 메서드 변경
T
로 선언한다.템플릿 메서드 변경
initVal
)의 타입, 템플릿의 결과 값 타입을 모두 제네릭 타입 파라미터 T
로 선언한다.이제 LineCallback
콜백과 lineReadTemplate()
템플릿은 파일의 라인을 처리해서 T
타입의 결과를 만들어내는 범용적인 템플릿/콜백이 됐다.
lineReadTemplate()
메서드 결과도 스트링 타입이 돼서 concatenate()
메서드의 리턴 타입도 스트링으로 정의할 수 있게 됐다."1234"
가 돼야 한다.JDBCTEMPLATE
스프링은 JDBC를 이용하는 DAO에서 사용할 수 있는 다양한 템플릿/콜백 제공한다.
JdbcTemplate
: 스프링이 제공하는 JDBC 코드용 기본 템플릿JdbcContext
-> JdbcTemplate
UserDao
가 DataSource
를 DI 받아서 JdbcContext
에 주입해 템플릿 오브젝트로 만들어서 사용한다.JdbcTemplate
은 생성자의 파라미터로 DataSource
를 주입하면 된다.update()
deleteAll()
Connection
을 제공받아 PreparedStatement
를 만들어 돌려준다.StatementStrategy
인터페이스의 makePreparedStatement()
메서드JdbcTemplate
의 콜백 : PreparedStatementCreator
인터페이스의 createPreparedStatement()
메서드update()
: PreparedStatementCreator
타입의 콜백을 받아서 사용하는 JdbcTemplate
의 템플릿 메서드executeSql()
JdbcTemplate
의 update()
메서드 사용(파라미터 다름)add()
메서드에 대한 편리한 메서드JdbcTemplate
의 update()
: 치환자를 가진 SQL로 PreparedStatement
를 만들고 함께 제공하는 파라미터를 순서대로 바인딩해준다.this.jdbcTemplate.update("insert into users(id, name, password) values(?,?,?)", user.getId(), user.getName(), user.getPassword());
add()
메서드에서 만드는 콜백은 PreparedStatement
를 만드는 것과 파라미터를 바인딩하는 작업을 수행한다.queryForInt()
템플릿/콜백 방식을 적용하지 않았던 메서드에 JdbcTemplate
을 적용해보자.
getCount()
ResultSet
을 통해 결과 값을 가져온다.JdbcTemplate
의 query()
: PreparedStatementCreator
콜백과 ResultSetExtractor
콜백을 파라미터로 받는다. ResultSetExtractor
콜백 : 템플릿이 제공하는 ResultSet을 이용해 원하는 값을 추출해서 템플릿에 전달하면, 템플릿은 나머지 작업을 수행한 뒤에 그 값을 query()
메서드의 리턴 값으로 돌려준다. PreparedStatementCreator
콜백 : 템플릿으로부터 Connection
을 받고 PreparedStatement를 돌려준다. ResultSetExtractor
콜백 : 템플릿으로부터 ResultSet
을 받고 거기서 추출한 결과를 돌려준다.📌 제네릭스 타입 파라미터를 갖는
ResultSetExtractor
ResultSet에서 추출할 수 있는 값의 타입이 다양하기 때문에 타입 파라미터를 사용한 것이다.
queryForInt()
JdbcTemplate
은 ResultSetExtractor
콜백과 같은 기능을 가진 콜백을 내장하고 있는 queryForInt()
라는 메서드를 제공한다.
Integer
타입의 결과를 가져올 수 있는 SQL 문장만 전달해주면 된다.
📌
JdbcTemplate
은 스프링이 제공하는 클래스이지만 DI 컨테이너를 굳이 필요로 하지 않기 때문에 직접JdbcTemplate
오브젝트를 생성하고 필요한DataSource
를 전달해주기만 하면 모든 기능을 자유롭게 활용할 수 있다.
queryForObject()
get()
ResultSetExtractor
콜백과 RowMapper
콜백 차이ResultSet
을 전달받고, 필요한 정보를 추출해서 리턴한다.ResultSet
을 한 번 전달받아 알아서 추출 작업을 모두 진행하고 최종 결과만 리턴해준다.ResultSet
의 로우 하나를 매칭하기 위해 사용되기 때문에 여러 번 호출될 수 있다.PreparedStatement
를 만들기 위한 SQLRowMapper
콜백queryForObject()
동작 방식queryForObject()
는 SQL을 실행하면 한 개의 로우만 얻을 것이라고 기대한다.next()
를 실행해 첫 번째 로우로 이동시킨 후, RowMapper
콜백을 호출한다.RowMapper
가 호출되는 시점에서 ResultSet은 첫 번째 로우를 가리키고 있으므로 다시 rs.next()
를 호출할 필요는 없다. RowMapper
에서는 현재 ResultSet
이 가리키고 있는 로우의 내용을 User
오브젝트에 그대로 담아서 리턴해준다.get()
메서드는 조회 결과가 없을 때 EmptyResultDataAccessException
을 던지도록 만들었는데, queryForObject()
를 이용할 때는 조회 결과가 없는 예외상황을 어떻게 처리해야 할까?EmptyResultDataAccessException
을 예외를 던지도록 만들어져 있다. query()
getAll()
메서드를 추가한다.getAll()
메서드는 여러 개의 로우이기 때문에 User
오브젝트의 컬렉션인 List<User>
타입으로 돌려주는 게 가장 나을 것 같다.id
순으로 정렬해서 리스트에 가져오도록 만들자.User
타입 오브젝트인 user1
, user2
, user3
세 개를 DB에 등록하고 getAll()
을 호출하면 List<User>
타입으로 결과를 돌려받아야 한다.user1
, user2
, user3
과 동일한 내용을 가진 오브젝트가 id
순서대로 담겨있어야 한다. 📌 저장할 때의 User 오브젝트와 getAll()로 가져온 User 오브젝트를 비교할 때는 동일성이 아닌 동등성 비교를 해야 한다는 점에 주의하자.
user1
, user2
, user3
를 하나씩 추가하면서 매번 getAll()
을 실행해서 결과를 확인해보자.user3
는 가장 마지막에 추가되지만 getAll()
의 결과에선 가장 첫 번째여야 한다. User
의 값을 비교하는 코드가 반복되기 때문에 별도의 메서드로 분리한다. 📌
@Test
나@Before
등의 애노테이션이 붙지 않는 메서드에 테스트 코드에서 반복적으로 나타나는 코드를 담아두고 재사용하는 건 좋은 습관이다. 여러 테스트 클래스에 걸쳐 재사용되는 코드라면 별도의 클래스로 분리하는 것도 고려해볼 수 있다.
query()
템플릿을 이용하는 getAll()
구현JdbcTemplate
의 query()
메서드를 사용한다.queryForObject()
는 쿼리의 결과가 로우 하나일 때 사용하고, query()
는 여러 개의 로우가 결과로 나오는 일반적인 경우에 쓸 수 있다.query()
의 리턴 타입은 List<T>
다. query()
는 제네릭 메서드로 타입은 파라미터로 넘기는 RowMapper<T>
콜백 오브젝트에서 결정된다.query()
동작 원리query()
템플릿은 SQL을 실행해서 얻은 ResultSet
의 모든 로우를 열람하면서 로우마다 RowMapper
콜백을 호출한다. RowMapper
은 현재 로우의 내용을 User
타입 오브젝트에 매핑해서 돌려준다. User
오브젝트는 템플릿이 미리 준비한 List<User>
컬렉션에 추가된다. User
오브젝트를 담고 있는 List<User>
오브젝트가 리턴된다. get()
과 마찬가지로 getAll()
에서도 예외적인 조건에 대한 테스트를 추가해야 된다. get()
이라면 id
가 없을 때는 어떻게 되는지 검증getAll()
이라면 결과가 하나도 없는 경우에 어떻게 되는지 검증📌 같은 개발자가 만든 조회용 메서드라도 데이터가 없을때 메서드가
null
을 리턴하거나, 빈 리스트 오브젝트를 리턴하거나, 예외를 던지거나,NullPointerException
같은 런타임 예외가 발생하기도 한다. 미리 예외상황에 대한 일관성 있는 기준을 정해두고 이를 테스트로 만들어 검증해둬야 한다.
getAll()
에 사용한 query()
템플릿이 어떤 결과를 돌려주는지 알아야 한다.query()
는 결과가 없을 경우에 크기가 0인 List<T>
오브젝트를 돌려준다. getAll()
에서 query()
결과에 손댈 것도 아니면서 굳이 검증 코드를 추가해야 할까?UserDao
를 사용하는 쪽의 입장에선 getAll()
이라는 메서드가 어떻게 동작하는지에만 관심이 있다. UserDaoTest
클래스의 테스트는 UserDao
의 getAll()
이라는 메서드에 기대하는 동작방식에 대한 검증이 먼저기 때문에 예상되는 결과를 모두 검증하는 게 옳다. JdbcTemplate
의 query()
대신 다른 방법으로 구현을 바꿔도 동일한 기능을 유지하는 UserDao
인지 확인 가능하다. query()
를 사용했다고 하더라도 getAll()
메서드가 다른 결과를 리턴하게 할 수도 있다.JdbcTemplate
의 query()
메서드에 대한 학습 테스트로서 의미가 있다. UserDao
의 모든 메서드가 JdbcTemplate
을 이용하도록 만들었으니 DataSource
인스턴스 변수는 제거하자.JdbcTemplate
을 생성하면서 직접 DI 해주기 위해 필요한 DataSource
를 전달받아야 하니 수정자 메서드는 남겨둔다.JdbcTemplate
을 직접 스프링 빈으로 등록하는 방식을 사용하고 싶다면 setJdbcTemplate
으로 바꿔주면 된다.get()
과 getAll()
에 사용한 RowMapper
가 중복된다.
RowMapper
오브젝트를 새로 만들어야 할까?UserDao
최종적으로 완성된 UserDao
클래스다.
UserDao
에는 User
정보를 DB에 넣거나 가져오거나 조작하는 방법에 대한 핵심적인 로직만 담겨 있다. 만약 사용할 테이블과 필드정보가 바뀌면 UserDao
의 거의 모든 코드가 함께 바뀐다. 따라서 응집도가 높다고 할 수 있다.JdbcTemplate
에게 있다. 따라서 변경이 일어난다고 해도 UserDao
코드에는 아무런 영향을 주지 않는다. 그런 면에서 책임이 다른 코드와는 낮은 결합도를 유지하고 있다. JdbcTemplate
라는 템플릿 클래스를 직접 이용한다는 면에서 특정 템플릿/콜백 구현에 대한 강한 결합을 갖고 있다. JdbcTemplate
은 스프링에서 JDBC를 이용해 DAO를 만드는 데 사용되는 사실상 표준 기술이지만, 더 낮은 결합도를 유지하고 싶다면 JdbcTemplate
을 독립적인 빈으로 등록하고 JdbcTemplate
이 구현하고 있는 JdbcOperations
인터페이스를 통해 DI 받아 사용하도록 만들어도 된다. 하지만 JdbcTemplate
은 DAO 안에서 직접 만들어 사용하는 게 스프링의 관례다.userMapper
가 인스턴스 변수로 설정되어 있고, 한 번 만들어지면 변경되지 않는 프로퍼티와 같은 성격을 띠고 있으니 아예 userDao 빈의 DI용 프로퍼티로 만들자.User
프로퍼티와 User
테이블의 필드 이름이 바뀌거나 매핑 방식이 바뀌는 경우에 UserDao
코드를 수정하지 않고도 매핑정보를 변경할 수 있다. UserDao
코드가 아닌 외부 리소스에 담고 이를 읽어와 사용하자.UserDao
코드에는 손을 댈 필요가 없다. 📌 스프링에서 클래스 이름이 Template으로 끝나거나 인터페이스 이름이 Callback으로 끝난다면 템플릿/콜백이 적용된 것이라고 보면 된다.
3장에선 예외처리와 안전한 리소스 반환을 보장해주는 DAO 코드를 만들고 이를 객체지향 설계 원리와 디자인 패턴, DI 등을 적용해서 깔끔하고 유연하며 단순한 코드로 만들었다.
try/catch/finally
블록으로 관리해야 한다. JdbcTemplate
을 기반으로 하는 다양한 템플릿과 콜백을 제공한다.