3장 템플릿

Jiwon An·2021년 11월 22일
0

백엔드/스프링

목록 보기
3/6

개방 폐쇄 원칙은 코드에서 어떤 부분은 변경을 통해 그 기능이 다양해지고 확장하려는 성질이 있고, 어떤 부분은 고정되어 있고 변하지 않으려는 성질이 있음을 말해준다. 변화의 특성이 다른 부분을 구분해주고, 각각 다른 목적과 다른 이유에 의해 다른 시점에 독립적으로 변경될 수 있는 효율적인 구조를 만들어 주는 것이 바로 이 개방 폐쇄 원칙이다.

템플릿이란 이렇게 바뀌는 성질이 다른 코드 중에서 변경이 거의 일어나지 않으며 일정한 패턴으로 유지되는 특성을 가진 부분을 자유롭게 변경되는 성질을 가진 부분으로부터 독립시켜서 효과적으로 활용할 수 있도록 하는 방법이다.

1. 다시 보는 초난감 DAO

UserDao의 코드는 개선됐지만, 아직 예외상황에 대한 처리면에서 심각한 문제점이 있다.

1.1 예외 처리 기능을 갖춘 DAO

DB 커넥션이라는 제한적인 리소스를 공유해 사용하는 서버에서 동작하는 JDBC 코드에는 반드시 예외처리를 해줘야 한다. JDBC 코드 실행 중간에 예외가 발생했을 경우에 사용한 리소스를 반드시 반환하도록 만들지 않으면 시스템에 심각한 문제를 일으킬 수 있기 때문이다.

1.1.1 JDBC 수정 기능의 예외처리 코드

UserDao의 가장 단순한 메서드인 deleteAll()을 살펴보자.

이 메서드에서는 PreparedStatement를 처리하는 중에 예외가 발생하면 메서드 실행을 끝마치지 못하고 바로 메서드를 빠져나가게 된다. 이때 문제는 ConnectionPreparedStatementclose() 메서드가 실행되지 않아 제대로 리소스가 반환되지 않을 수 있다는 점이다.

일반적으로 서버에서는 제한된 개수의 DB 커넥션을 만들어서 재사용 가능한 풀로 관리한다. 그래서 이런 식으로 오류가 날 때마다 미처 반환되지 못한 Connection이 계속 쌓이면 어느 순간에 커넥션 풀에 여유가 없어지고 리소스가 모자란다는 심각한 오류를 내며 서버가 중단될 수 있다.

📌 리소스 반환과 close()
Connection이나 PreaparedStatement에는 close() 메서드가 있다. ConnectionPreparedStatement는 보통 풀(pool) 방식으로 운영된다. 미리 정해진 풀 안에 제한된 수의 리소스(Connection, Statement)를 만들어두고 필요할 때 이를 할당하고, 반환하면 다시 풀에 넣는 방식으로 운영된다. 여기서 close() 메서드는 사용한 리소스를풀로 다시 돌려주는 역할을 한다.

그래서 이런 JDBC 코드에서는 어떤 상황에서도 가져온 리소스를 반환하도록 try/catch/finally 구문 사용을 권장하고 있다.

이제 예외상황에서도 안전한 코드가 됐다. finallytry 블록을 수행한 후에 예외가 발생하든 정상적으로 처리되든 상관없이 반드시 실행되는 코드를 넣을 때 사용한다.

그런데 문제는 예외가 어느 시점에 나는가에 따라 ConnectionPreparedStatement 중 어떤 것의 close() 메서드를 호출해야 할 지가 달라진다. null 상태의 변수에 close() 메서드를 호출하면 NullPointerException이 발생하기 때문에 close() 메서드를 호출하면 안 된다.

  • getConnection()에서 예외 발생시 -> c, ps 모두 null 상태다.
  • PreparedStatement를 생성하다가 예외 발생시 -> c 변수가 커넥션 객체를 갖고 있는 상태, psnull 상태다.
  • ps를 실행하다가 예외 발생시 -> c, ps 모두 close() 메서드를 호출해줘야 한다.

문제는 이 close()SQLException이 발생할 수 있는 메서드라는 점이다. 따라서 try/catch문으로 처리해줘야 한다. 이미 deleteAllSQLException이 던져진다고 선언되어 있어도, try/catch 블록 없이 ps.close()를 처리하다가 예외가 발생하면 아래 c.close() 부분이 실행되지 않고 메서드를 빠져나가는 문제가 발생하기 때문이다.

현재 catch 블록에서는 딱히 하는 일이 없어 블록을 빼버려도 되지만, 예외가 발생한 경우에 보통 로그를 남기는 등의 부가작업이 필요할 수 있으니 catch 블록은 일단 만들어두는 편이 좋다.

1.1.2 JDBC 조회 기능의 예외처리

조회를 위한 JDBC 코드에는 ResultSet이 추가된다. ResultSet도 반환해야 하는 리소스이기 때문에 예외상황에서도 close() 메서드가 반드시 호출되도록 해야한다.

이제 UserDao의 모든 메서드에 동일한 방식으로 try/catch/finally 블록을 적용해 예외상황에 대한 처리까지 모두 마쳤다.

2. 변하는 것과 변하지 않는 것

2.1 JDBC try/catch/finally 코드의 문제점

이제 try/catch/finally 블록도 적용돼서 완성도 높은 DAO 코드가 된 UserDao이지만, 복잡한 블록이 2중으로 중첩되고, 모든 메서드마다 반복된다.

이럴때 가장 효과적인 방법을 뭘까?

1) 복사 - 붙여넣기

  • ResultSet이 있는 것과 없는 두 가지 종류의 메서드를 각각 하나씩 만들어두고 계속 코드를 복사한다.
  • 코드가 늘어날수록 실수가 일어날 수 있다.

2) 테스트를 통해 DAO마다 예외상황에서 리소스 반납하는지 체크

  • 예외상황을 처리하는 코드는 테스트하기가 매우 어렵고, 모든 DAO 메서드에 대해 이런 테스트를 만드는 건 매우 번거롭다.

이 외에 효과적으로 다룰 수 있는 방법은 없을까?

2.2 분리와 재사용을 위한 디자인 패턴 적용

UserDao의 메서드를 개선해보자. 가장 먼저 할 일은 변하는 성격이 다른 것을 찾아내는 것이다.

그렇다면 메서드에서 변하지 않는 부분을 분리하여 재사용할 수 있는 방법이 있지 않을까?

2.2.1 메서드 추출

먼저 메서드로 빼는 방법이 있다. 변하지 않는 부분이 변하는 부분을 감싸고 있어서 변하지 않는 부분을 추출하기가 어려워 보이기 때문에 반대로 해봤다.

보통 메서드 추출 리팩토링을 적용하는 경우에는 분리시킨 메서드를 다른 곳에서 재사용할 수 있어야 하는데, 이건 뭔가 반대로 됐다.

2.2.2 템플릿 메서드 패턴의 적용

템플릿 메서드 패턴은 상속을 통해 기능을 확장해서 사용하는 부분이다. 변하지 않는 부분은 슈퍼클래스에 두고 변하는 부분은 추상 메서드로 정의해둬서 서브클래스에서 오버라이드하여 새롭게 정의해 쓰도록 하는 것이다.

추출해서 별도의 메서드로 독립시킨 makeStatement() 메서드를 다음과 같이 추상 메서드 선언으로 변경한다. 물론 UserDao 클래스도 추상 클래스가 돼야 할 것이다.

abstract protected PreparedStatement makeStatement(Connection c) throws 
	SQLException;

그리고 이를 상속하는 서브클래스를 만들어서 거기서 이 메서드를 구현한다. 고정된 JDBC try/catch/finally 블록을 가진 슈퍼클래스 메서드와 필요에 따라서 상속을 통해 구체적인 PreparedStatement를 바꿔서 사용할 수 있게 만드는 서브클래스로 깔끔하게 분리할 수 있다.

하지만 템플릿 메서드 패턴으로의 접근은 제한이 많다. 가장 큰 문제는 DAO 로직마다 상속을 통해 새로운 클래스를 만들어야 한다는 점이다.

또 확장구조가 이미 클래스를 설계하는 시점에서 고정되어 버린다는 점이다. 변하지 않는 코드를 가진 UserDao의 JDBC try/catch/finally 블록과 변하는 PreparedStatement를 담고 있는 서브클래스들이 이미 클래스 레벨에서 컴파일 시점에 이미 그 관계가 결정되어 있다. 따라서 그 관계에 대한 유연성이 떨어져 버린다.

2.2.3 전략 패턴의 적용

개방 폐쇄 원칙을 잘 지키는 구조이면서도 템플릿 메서드 패턴보다 유연하고 확장성이 뛰어난 것이, 오브젝트를 아예 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만드는 전략 패턴이다.

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

deleteAll() 메서드에서 변하지 않는 부분이라고 명시한 것이 contextMethod()가 된다.

deleteAll()의 컨텍스트

  • DB 커넥션 가져오기
  • PreparedStatement를 만들어줄 외부 기능 호출하기
  • 전달받은 PreparedStatement 실행하기
  • 예외가 발생하면 이를 다시 메서드 밖으로 던지기
  • 모든 경우에 만들어진 PreparedStatementConnection을 적절히 닫아주기

두 번째 작업에서 사용하는 PreparedStatement를 만들어주는 외부 기능이 바로 전략 패턴에서 말하는 전략이라고 볼 수 있다. 전략 패턴의 구조를 따라 이 기능을 인터페이스로 만들어두고 인터페이스의 메서드를 통해 PreparedStatement 생성 전략을 호출해주면 된다. PreparedStatement를 만드는 전략의 인터페이스는 컨텍스트가 만들어둔 Connection을 전달받아서, PreparedStatement를 만들고 만들어진 PreparedStatement 오브젝트를 돌려준다.

이 인터페이스를 상속해서 실제 전략, 즉 바뀌는 부분인 PreparedStatement를 생성하는 클래스를 만들어보자.

이제 확장된 PreparedStrategy 전략인 DeleteAllStatement가 만들어졌다.

하지만 전략 패턴은 필요에 따라 컨텍스트는 그대로 유지되면서 전략을 바꿔쓸 수 있다는 것인데, 컨텍스트가 StatementStrategy 인터페이스뿐 아니라 특정 구현 클래스인 DeleteAllStatement를 직접 알고 있다는 건, 전략 패턴에도 OCP에도 잘 들어맞는다고 볼 수 없다.

2.2.4 DI 적용을 위한 클라이언트/컨텍스트 분리

전략 패턴에 따르면 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라고 부를 수도 있다.

3. JDBC 전략 패턴의 최적화

지금까지
1. 기존의 메서드에 담겨 있던 변하지 않는 부분, 자주 변하는 부분을 전략 패턴을 사용해 분리해냈다.
2. 독립된 작업 흐름이 담긴 메서드로 분리해서 DAO 메서드들이 공유할 수 있게 됐다.
3. DAO 메서드는 전략 패턴의 클라이언트로서 컨텍스트에 해당하는 메서드에 적절한 전략, 즉 바뀌는 로직을 제공해주는 방법으로 사용할 수 있다.

3.1 전략 클래스의 추가 정보

add() 메서드에도 변하는 부분을 AddStatement 클래스로 옮겨 담는다.

  • 변하는 부분 : PreparedStatement 만드는 코드

add()에서는 PreparedStatement를 만들 때 user라는 부가적인 정보가 필요하다.
따라서 클라이언트가 AddStatement의 전략을 수행하려면 부가정보인 user를 제공해줘야 한다.

클라이언트인 UserDaoadd() 메서드를 user 정보를 생성자를 통해 전달해주도록 한다.

이렇게 해서 deleteAll()add() 두 군데에서 모두 PreparedStatement를 실행하는 JDBC try/catch/finally 컨텍스트를 공유해서 사용할 수 있게 됐다.

3.2 전략과 클라이언트의 동거

아직 2가지 문제점이 있다.

  • DAO 메서드마다 새로운 StatementStrategy 구현 클래스를 만들어야 한다.
  • DAO 메서드에서 StatementStrategy에 전달할 User와 같은 부가적인 정보가 있는 경우, 이를 위해 오브젝트를 전달받는 생성자와 이를 저장해둘 인스턴스 변수를 번거롭게 만들어야 한다.

3.2.1 로컬 클래스

클래스 파일이 많아지는 문제는 StatementStrategy 전략 클래스를 UserDao 클래스 안에 내부 클래스로 정의해버리는 것으로 해결할 수 있다.

📌 중첩 클래스
중첩 클래스는 다른 클래스 내부에 정의되는 클래스를 말한다.

  • 중첩 클래스의 종류
    • 스태틱 클래스 : 독립적으로 오브젝트로 만들어 질 수 있다.
    • 내부 클래스 : 자신이 정의된 클래스의 오브젝트 안에서만 만들어질 수 있다.
      • 범위에 따라 멤버 내부 클래스, 로컬 클래스, 익명 내부 클래스로 나눠진다.
      • 멤버 내부 클래스 : 멤버 필드처럼 오브젝트 레벨에 정의된다.
      • 로컬 클래스 : 메서드 레벨에 정의된다.
      • 익명 내부 클래스 : 이름을 갖지 않고, 범위는 선언된 위치에 따라 다르다.

AddStatement 클래스를 로컬 클래스로서 add() 메서드 안에 집어넣은 것이다.

장점

  • 사용될 곳이 한 메서드뿐이라면, 사용 전에 바로 정의해서 쓰는 것도 나쁘지 않다.
  • 클래스 파일이 하나 줄고 코드를 이해하기도 좋다.
  • 로컬 클래스는 클래스가 내부 클래스이기 때문에 자신이 선언된 곳의 정보에 접근할 수 있다.
    • ex) AddStatementadd() 메서드의 User 오브젝트 접근
    • 생성자와 인스턴스 변수를 제거할 수 있다.
    • 내부 메서드는 자신이 정의된 메서드의 로컬 변수에 직접 접근할 수 있기 때문이다.
    • 다만, 내부 클래스에서 외부의 변수를 사용할 때는 외부 변수는 반드시 final로 선언해줘야 한다.

3.2.2 익명 내부 클래스

AddStatement 클래스는 add() 메서드에서만 사용할 용도로 만들어졌기 때문에 좀 더 간결하게 클래스 이름도 제거할 수 있다.

👉 익명 내부 클래스
이름을 갖지 않는 클래스다.

  • new 인터페이스이름() { 클래스 본문 };
  • 클래스 선언과 오브젝트 생성이 결합된 형태로 만들어진다.
  • 선언과 동시에 오브젝트를 생성하며, 클래스 자신의 타입을 가질 수 없고, 구현한 인터페이스 타입의 변수에만 저장할 수 있다.
  • 클래스를 재사용할 필요없고, 구현한 인터페이스 타입으로만 사용할 경우에 유용하다.

만들어진 익명 내부 클래스의 오브젝트는 딱 한 번만 사용할 테니 굳이 변수에 담아두지 말고 jdbcContextWithStatementStrategy() 메서드의 파라미터에서 바로 생성하는 편이 낫다.

DeleteAllStatementdeleteAll() 메서드로 가져와서 익명 내부 클래스로 처리한다.

4. 컨텍스트와 DI

4.1 JdbcContext의 분리

jdbcContextWithStatementStrategy()는 JDBC의 일반적인 작업 흐름을 담고 있으므로 다른 DAO에서도 사용 가능하기 때문에 독립시키자.

4.1.1 클래스 분리

변경 사항

  • 분리해서 만들 클래스 이름 : JdbcContext
  • 컨텍스트 메서드의 변경할 이름 : workWithStatementStrategy()
  • JdbcContextDataSource 타입 빈을 DI 받을 수 있게 한다.

4.1.2 빈 의존관계 변경

UserDao가 의존하는 JdbcContext가 구체 클래스다. 스프링의 DI는 기본적으로 인터페이스를 사이에 두고 의존 클래스를 바꿔서 사용하도록 하는 게 목적이지만, JdbcContext의 경우는 그 자체로 독립적인 JDBC 컨텍스트를 제공해주는 서비스 오브젝트로서 의미가 있을 뿐이고 구현 방법이 바뀔 가능성은 없다. 따라서 UserDaoJdbcContext는 인터페이스를 사이에 두지 않고 DI를 적용하는 특별한 구조가 된다.

스프링의 빈 설정은 클래스 레벨이 아니라 런타임 시에 만들어지는 오브젝트 레벨의 의존관계에 따라 정의된다. 아래는 빈으로 정의되는 오브젝트 사이의 관계다. 기존에는 userDao 빈이 dataSource 빈을 직접 의존했지만 이제는 jdbcContext 빈이 그 사이에 끼게 된다.

바뀐 빈 의존관계를 따라 XML 설정파일을 수정하자.

4.2 JdbcContext의 특별한 DI

지금까지 적용했던 DI와는 다르게 UserDaoJdbcContext 사이에는 인터페이스를 사용하지 않고 DI를 적용했다. 즉, UserDao는 인터페이스를 거치지 않고 코드에서 바로 JdbcContext 클래스를 사용하고 있다. UserDaoJdbcContext는 클래스 레벨에서 의존관계가 결정된다.

4.2.1 스프링 빈으로 DI

스프링 DI의 기본 의도에 맞게 JdbcContext의 메서드를 인터페이스로 뽑아내어 정의해두고, 이를 사용하게 해야 하지 않을까? 물론 그렇게 해도 상관없지만, 꼭 그럴 필요는 없다.

의존관계 주입이라는 개념을 충실히 따르자면, 인터페이스를 사이에 둬서 클래스 레벨에서는 의존관계가 고정되지 않게 하고, 런타임 시에 의존할 오브젝트와의 관계를 다이내믹하게 주입해주는 것이 맞다. 그러나 스프링의 DI는 IoC라는 개념을 포괄하기 때문에 JdbcContext를 스프링을 이용해 UserDao 객체에서 사용하게 주입했다는 건 DI의 기본을 따르고 있다고 볼 수 있다.

그럼 JdbcContextUserDao와 DI 구조로 만들어야 할 이유는 뭘까?

  1. JdbcContext가 스프링 컨테이너의 싱글톤 레지스트리에서 관리되는 싱글톤 빈이 되기 때문이다.
    • JdbcContext는 그 자체로 변경되는 상태정보를 갖고 있지 않다.
    • JdbcContext는 JDBC 컨텍스트 메서드를 제공해주는 일종의 서비스 오브젝트로서 의미가 있고, 그래서 싱글톤으로 등록돼서 여러 오브젝트에서 공유해 사용되는 것이 이상적이다.
  2. JdbcContext가 DI를 통해 다른 빈에 의존하고 있기 때문이다.
    • DI를 위해서는 주입되는 오브젝트와 주입받는 오브젝트 양쪽 모두 스프링 빈으로 등록돼야 한다.

실제로 스프링에는 드물지만 이렇게 인터페이스를 사용하지 않는 클래스를 직접 의존하는 DI가 등장하는 경우도 있다. 인터페이스가 없다는 건 강한 결합도, 응집도를 갖고 있다는 의미다.

그럼 왜 인터페이스를 사용하지 않았을까?

JdbcContextDataSource와 달리 테스트에서도 다른 구현으로 대체해서 사용할 이유가 없다. 이런 경우는 굳이 인터페이스를 두지 말고 강력한 결합을 가진 관계를 허용하면서 위에서 말한 두 가지 이유인, 싱글톤으로 만드는 것과 JdbcContext에 대한 DI 필요성을 위해 스프링의 빈으로 등록해서 UserDao에 DI 되도록 만들어도 좋다.

📌 단, 이런 클래스를 바로 사용하는 코드 구성을 DI에 적용하는 것은 가장 마지막 단계에서 고려해볼 사항임을 잊지 말자.

4.2.2 코드를 이용하는 수동 DI

JdbcContext를 스프링의 빈으로 등록해서 UserDao에 DI 하는 대신 사용할 수 있는 방법이 있다. UserDao 내부에서 직접 DI를 적용하는 방법이 있다.

이전과 달리 변경되는 것
1. DAO마다 하나의 JdbcContext 오브젝트를 갖고 있게 해야한다.

  • 이 방법을 쓰려면 JdbcContext를 싱글톤으로 만들려는 것은 포기해야 한다.
  • JdbcContext에는 내부에 두는 상태정보가 없기 때문에 오브젝트 수백개가 만들어진다고 해도 메모리에 주는 부담은 거의 없다. 또한 자주 만들어졌다가 제거되는 게 아니기 때문에 GC에 대한 부담도 없다.
  • JdbcContext를 스프링 빈으로 등록하지 않았으므로 제어권은 UserDao가 갖는 것이 적당하다. 자신이 사용할 오브젝트를 직접 만들고 초기화하는 전통적인 방법을 사용하는 것이다.
  1. JdbcContext에 대한 제어권을 갖고 생성과 관리를 담당하는 UserDao에게 DI까지 맡기게 한다.
    • 남은 문제는 JdbcContext는 다른 빈을 인터페이스를 통해 간접적으로 의존하고 있다. 다른 빈을 의존하고 있다면 자신도 빈으로 등록돼야 한다고 했다.
    • UserDao가 임시로 DI 컨테이너처럼 동작하게 만드는 것이다.
    • UserDao는 직접 DataSource 빈을 필요로 하지 않지만 JdbcContext에 대한 DI 작업에 사용할 용도로 제공받는 것이다.

코드로 변경

  • 스프링 설정파일에 userDaodataSource만 빈으로 정의한다.
  • userDaoJdbcContext 오브젝트를 만들면서 DI 받은 DataSource 오브젝트를 JdbcContext의 수정자 메서드로 주입해준다. 만들어진 JdbcContext 오브젝트는 인스턴스 변수에 저장해두고 사용한다.

이 방법의 장점은 굳이 인터페이스를 두지 않아도 될 만큼 긴밀한 관계를 갖는 DAO 클래스와 JdbcContext를 따로 빈으로 분리하지 않고 내부에서 직접 만들어 사용하면서도 다른 오브젝트에 대한 DI를 적용할 수 있다는 점이다. 이렇게 한 오브젝트의 수정자 메서드에서 다른 오브젝트를 초기화하고 코드를 이용해 DI 하는 것은 스프링에서도 종종 사용되는 기법이다.

두 방법의 차이

  • 스프링 빈으로 DI
    • 장점 : 오브젝트 사이의 실제 의존관계가 설정파일에 명확하게 드러난다.
    • 단점 : DI의 근본적인 원칙에 부합하지 않는 구체적인 클래스와의 관계가 설정에 직접 노출된다.
  • 코드를 이용하는 수동 DI
    • 장점 : JdbcContextUserDao의 내부에서 만들어지고 사용되면서 그 관계를 외부에는 드러내지 않는다. 필요에 따라 내부에서 은밀히 DI를 수행하고 그 전략을 외부에는 감출 수 있다.
    • 단점 : JdbcContext를 여러 오브젝트가 사용하더라도 싱글톤으로 만들 수 없고, DI 작업을 위한 부가적인 코드가 필요하다.

📌 일반적으로는 어떤 방법이 더 낫다고 말할 수는 없다. 하지만 둘 중 어떤 방법을 써도 왜 그렇게 선택했는지에 대한 분명한 이유와 근거가 있어야 한다. 분명하게 설명할 자신이 없다면 차라리 인터페이스를 만들어서 평범한 DI 구조로 만드는 게 나을 수도 있다.

5. 템플릿과 콜백

전략 패턴은 복잡하지만 바뀌지 않는 일정한 패턴을 갖는 작업 흐름이 존재하고 그 중 일부분만 자주 바꿔서 사용해야 하는 경우에 적합한 구조다. 이건 전략 패턴의 기본 구조에 익명 내부 클래스를 활용한 방식이다. 이런 방식을 스프링에서는 템플릿/콜백 패턴이라고 부른다.

📌 템플릿/콜백 패턴

  • 템플릿 : 전략 패턴의 컨텍스트
  • 콜백 : 익명 내부 클래스로 만들어지는 오브젝트

👉 템플릿
템플릿은 어떤 목적을 위해 미리 만들어둔 모양이 있는 틀을 가리킨다. 템플릿 메서드 패턴은 고정된 틀의 로직을 가진 템플릿 메서드를 슈퍼클래스에 두고, 바뀌는 부분을 서브클래스의 메서드에 두는 구조로 이뤄진다.

👉 콜백
콜백은 실행되는 것을 목적으로 다른 오브젝트의 메서드에 전달되는 오브젝트를 말한다. 파라미터로 전달되지만 값을 참조하기 위한 것이 아니라 특정 로직을 담은 메서드를 실행시키기 위해 사용한다. 자바에선 메서드 자체를 파라미터로 전달할 방법은 없기 때문에 메서드가 담긴 오브젝트를 전달해야 한다. 그래서 펑셔널 오브젝트(functional object)라고도 한다.

5.1 템플릿/콜백의 동작원리

5.1.1 템플릿/콜백의 특징

  • 일반적으로 하나의 메서드를 가진 인터페이스를 구현한 익명 내부 클래스로 만들어진다.
    • 템플릿의 작업 흐름 중 특정 기능을 위해 한 번 호출되는 경우가 일반적이기 때문이다.
    • 하나의 템플릿에 여러 가지 전략을 사용해야 한다면 하나 이상의 콜백 오브젝트를 사용할 수도 있다.
  • 콜백 인터페이스의 메서드에는 보통 파라미터가 있다.
    • 이 파라미터는 템플릿의 작업 흐름 중에 만들어지는 컨텍스트 정보를 전달받을 때 사용된다.
    • ex) 템플릿인 workWithStatementStrategy() 내에서 생성한 Connection 오브젝트를 콜백의 메서드인 makePreparedStatement()를 실행할 때 파라미터로 넘겨준다.

  • 클라이언트의 역할은 템플릿 안에서 실행될 로직을 담은 콜백 오브젝트를 만들고, 콜백이 참조할 정보를 제공하는 것이다. 만들어진 콜백은 클라이언트가 템플릿의 메서드를 호출할 때 파라미터로 전달된다.
  • 템플릿은 정해진 작업 흐름을 따라 작업을 진행하다가 내부에서 생성한 참조정보를 가지고 콜백 오브젝트의 메서드를 호출한다. 콜백은 클라이언트 메서드에 있는 정보와 템플릿이 제공한 참조정보를 이용해서 작업을 수행하고 그 결과를 다시 템플릿에 돌려준다.
  • 템플릿은 콜백이 돌려준 정보를 사용해서 작업을 마저 수행한다. 경우에 따라 최종 결과를 클라이언트에 다시 돌려주기도 한다.

템플릿/콜백 방식은 전략 패턴과 DI의 장점을 익명 내부 클래스 사용 전략과 결합한 독특한 활용법이라고 이해할 수 있다. 단순한 전략 패턴이 아닌 고유한 디자인 패턴으로 기억해두면 편리하다.

DI 방식과 다른 점

  • 템플릿/콜백 방식에선 매번 메서드 단위로 사용할 오브젝트를 새롭게 전달받는다.
  • 템플릿/콜백 방식에선 콜백 오브젝트가 내부 클래스로서 자신을 생성한 클라이언트 메서드 내의 정보를 직접 참조한다.
  • 템플릿/콜백 방식에선 클라이언트와 콜백이 강하게 결합된다.

5.1.2 JdbcContext에 적용된 템플릿/콜백

UserDao, JdbcContext, StatementStrategy의 코드에 적용된 템플릿/콜백 패턴을 보자.

  • 템플릿과 클라이언트가 메서드 단위인 것이 특징이다.
  • 조회 작업에서는 보통 템플릿의 작업 결과를 클라이언트에 리턴해준다.
  • 템플릿의 흐름이 복잡한 경우에는 한 번 이상 콜백을 호출하기도 하고 여러 개의 콜백을 클라이언트로부터 받아서 사용하기도 한다.

5.2 편리한 콜백의 재활용

템플릿/콜백 방식에서 한 가지 아쉬운 점이 있다. DAO 메서드에서 매번 익명 내부 클래스를 사용하기 때문에 상대적으로 코드를 작성하고 읽기가 조금 불편하다는 점이다.

5.2.1 콜백의 분리와 재활용

복잡한 익명 내부 클래스의 사용을 최소화할 수 있는 방법을 찾아보자. JDBC의 try/catch/finally에 적용했던 방법을 적용해보는 것이다. 분리를 통해 재사용이 가능한 코드를 찾아내는 것이다.

콜백 오브젝트 코드를 살펴보면 단순 SQL 쿼리 하나를 담아서 PreparedStatement를 만드는 게 전부다. 그럼 중복될 가능성이 있는 자주 바뀌지 않는 부분을 분리해보자.

재활용 가능한 콜백을 담은 메서드

  • SQL 문장을 파라미터로 받는 executeSql() 메서드를 생성한다.
  • SQL을 담은 파라미터를 final로 선언해 익명 내부 클래스인 콜백 안에서 직접 사용할 수 있게 하는 것에 주의한다.

5.2.2 콜백과 템플릿의 결합

재사용 가능한 콜백을 담고 있는 executeSql() 메서드를 UserDao만 사용하긴 아깝다. DAO가 공유할 수 있는 템플릿 클래스 안으로 옮겨도 된다.

템플릿 클래스 안으로 이동

  • 메서드 접근자는 public으로 바꿔서 외부에서 바로 접근이 가능하게 한다.

이제 JdbcContext안에 클라이언트와 템플릿, 콜백이 모두 함께 공존하면서 동작하는 구조가 됐다.

일반적으로는 성격이 다른 코드들은 가능한 한 분리하는 편이 낫지만, 이 경우는 반대다. 하나의 목적을 위해 서로 긴밀하게 연관괴어 동작하는 응집력이 강한 코드들이기 때문에 한 군데 모여 있는 게 유리하다. 구체적인 구현과 내부의 전략 패턴, 코드에 의한 DI, 익명 내부 클래스 등의 기술은 최대한 감춰두고, 외부에는 꼭 필요한 기능을 제공하는 단순한 메서드만 노출해주는 것이다.

📌 add() 메서드에서의 적용 방법

  • SQL 문장과 함께 PreparedStatement에 바인딩될 파라미터 내용이 추가돼야 한다.
  • 바인딩 파라미터 개수는 일정하지 않으므로 가변인자로 정의해두는 것이 좋다.
  • 콜백에서 PreparedStatement를 만든 뒤에 바인딩할 파라미터 타입을 살펴서 적절한 설정 메서드를 호출해준다.

5.3 템플릿/콜백의 응용

스프링에는 다양한 자바 엔터프라이즈 기술에서 사용할 수 있도록 미리 만들어져 제공되는 수십 가지 템플릿/콜백 클래스와 API가 있다. 스프링을 사용하는 개발자라면 스프링의 기본이 되는 전략 패턴과 DI, 템플릿/콜백 패턴도 익숙해지도록 학습할 필요가 있다.

고정된 작업 흐름을 갖고 있으면서 여기저기서 자주 반복되는 코드가 있다면, 중복되는 코드를 분리할 방법을 생각해보는 습관을 기르자.

템플릿/콜백 적용해보는 방법
1. 중복된 코드는 먼저 메서드로 분리하는 간단한 시도를 해본다.
2. 그중 일부 작업을 필요에 따라 바꾸어 사용해야 한다면 인터페이스를 사이에 두고 분리해서 전략 패턴을 적용하고 DI로 의존관계를 관리하도록 만든다.
3. 바뀌는 부분이 한 애플리케이션 안에서 동시에 여러 종류가 만들어질 수 있다면 템플릿/콜백 패턴을 적용하는 것을 고려하자.

📌 가장 전형적인 템플릿/콜백 패턴의 후보는 try/catch/finally 블록을 사용하는 코드다.

5.3.1 테스트와 try/catch/finally

간단한 템플릿/콜백 예제를 만들어보자.

  • 파일의 모든 라인의 숫자를 더한 합을 돌려주는 코드를 만든다.
    • 클래스 : Calculator, 메서드 : calcSum()
  • 테스트할 숫자가 담긴 파일을 먼저 만들어둔다.
    • 파일명 : numbers.text

추가 사항

  • 어떤 경우에라도 파일이 열렸으면 반드시 닫아주도록 만들자. - try/finally 블록
  • 파일을 처리하다 예외상황이 발생하면 로그를 남기는 기능도 추가하자. - catch 블록

5.3.2 중복의 제거와 템플릿/콜백 설계

추가 사항

  • 파일에 있는 모든 숫자의 곱을 계산하는 기능

고려 사항

  • 반복되는 작업 흐름은 어떤 것인지
  • 템플릿이 콜백에게 전달해줄 내부의 정보는 무엇인지
  • 콜백이 템플릿에게 돌려줄 내용은 무엇인지

1. 가장 쉽게 생각해볼 수 있는 구조로 분리

  • 콜백 인터페이스
    • 템플릿이 파일을 열고 각 라인을 읽어올 수 있는 BufferedReader를 만들어서 콜백에게 전달해주고, 콜백이 각 라인을 읽어서 알아서 처리한 후에 최종 결과만 템플릿에게 돌려준다.
  • 템플릿 메서드
    • BufferedReaderCallback 인터페이스 타입의 콜백 오브젝트를 받아서 적절한 시점에 실행해주고, 콜백이 돌려준 결과는 최종적으로 모든 처리를 마친 후에 다시 클라이언트에 돌려준다.
  • 템플릿/콜백 적용한 덧셈 계산 기능 메서드
    • 템플릿으로 분리한 부분 제외한 나머지 코드를 BufferedReaderCallback 인터페이스로 만든 익명 내부 클래스에 담는다. 처리할 파일의 경로와 함께 준비된 익명 내부 클래스의 오브젝트를 템플릿에 전달한다. 템플릿이 리턴하는 값을 최종 결과로 사용한다.

2. 곱 계산 기능의 테스트 메서드 추가 및 적용

  • 테스트 메서드 사이에 사용할 클래스의 오브젝트와 파일 이름이 공유된다.
  • 템플릿/콜백 적용한 곱 계산 기능 메서드

5.3.3 템플릿/콜백의 재설계

고려 사항

  • calcSum()calcMultiply() 콜백 사이에서도 공통적인 패턴이 발견되지 않을까?

포인트

  • 여기서 바뀌는 코드는 실제로 네 번째 줄 뿐
  • 앞에서 네 번째 라인으로 전달하는 정보는 처음에 선언한 변수 값인 multiply 또는 sum이다.
  • 그 네 번째 라인을 처리하고 다시 외부로 전달되는 것은 multiply 또는 sum과 각 라인의 숫자 값을 가지고 계산한 결과다.

1. 템플릿, 콜백 분리

  • 콜백 인터페이스

    • 파일의 각 라인과 현재까지 계산한 값을 넘겨주도록 되어 있다.
    • 새로운 계산 결과를 리턴 값을 통해 다시 전달받는다.
  • 템플릿 메서드

    • 파일의 각 라인을 읽는 작업이 추가됐다.
    • 계산 결과를 담을 변수를 초기화할 값도 파라미터도 전달받게 만들었다.
    • while 루프 안에서 콜백을 호출한다. 콜백을 여러 번 반복적으로 호출하는 구조가 되었다.
  • 템플릿/콜백 적용한 calcSum(), calcMultiply() 메서드

5.3.4 제네릭스를 이용한 콜백 인터페이스

LineCallbacklineReadTemplate()은 템플릿과 콜백이 만들어내는 결과가 Integer 타입으로 고정되어 있었다.
제네릭스를 이용하면 다양한 오브젝트 타입을 지원하는 인터페이스나 메서드를 정의할 수 있다.

추가 사항

  • 파일의 각 라인에 있는 문자를 모두 연결해서 하나의 스트링으로 돌려주는 기능을 만들어보자.
  • 기존에 만들었던 콜백과 템플릿을 스트링 타입의 값도 처리할 수 있도록 확장해보자.

1. 기존 템플릿/콜백에 제네릭스 적용

  • 콜백 메서드 변경

    • 콜백 메서드의 리턴 값과 파라미터 값의 타입을 제네릭 타입 파라미터 T로 선언한다.
  • 템플릿 메서드 변경

    • 콜백의 타입 파라미터와 초기값(initVal)의 타입, 템플릿의 결과 값 타입을 모두 제네릭 타입 파라미터 T로 선언한다.

이제 LineCallback 콜백과 lineReadTemplate() 템플릿은 파일의 라인을 처리해서 T 타입의 결과를 만들어내는 범용적인 템플릿/콜백이 됐다.

2. 변경된 템플릿/콜백을 이용해 문자열 연결 기능을 가진 메서드 추가

  • lineReadTemplate() 메서드 결과도 스트링 타입이 돼서 concatenate() 메서드의 리턴 타입도 스트링으로 정의할 수 있게 됐다.

  • 파일의 각 라인의 내용을 스트링으로 연결하기 때문에 최종 결과가 "1234"가 돼야 한다.

6. 스프링의 JDBCTEMPLATE

스프링은 JDBC를 이용하는 DAO에서 사용할 수 있는 다양한 템플릿/콜백 제공한다.

  • JdbcTemplate : 스프링이 제공하는 JDBC 코드용 기본 템플릿

변경 사항

  • JdbcContext -> JdbcTemplate
  • 현재 : UserDaoDataSource를 DI 받아서 JdbcContext에 주입해 템플릿 오브젝트로 만들어서 사용한다.
  • 변경 : JdbcTemplate은 생성자의 파라미터로 DataSource를 주입하면 된다.

6.1 update()

1. deleteAll()

1.1 콜백 변경

  • 동일한 콜백의 구조 : 템플릿으로부터 Connection을 제공받아 PreparedStatement를 만들어 돌려준다.
  • 현재 콜백 : StatementStrategy 인터페이스의 makePreparedStatement() 메서드
  • JdbcTemplate의 콜백 : PreparedStatementCreator 인터페이스의 createPreparedStatement() 메서드
  • update() : PreparedStatementCreator 타입의 콜백을 받아서 사용하는 JdbcTemplate의 템플릿 메서드

1.2 executeSql()

  • 동일한 기능 : SQL 문장만 전달하면 미리 준비된 콜백을 만들어서 템플릿을 호출한다.
  • JdbcTemplateupdate() 메서드 사용(파라미터 다름)

1.3 add() 메서드에 대한 편리한 메서드

  • JdbcTemplateupdate() : 치환자를 가진 SQL로 PreparedStatement를 만들고 함께 제공하는 파라미터를 순서대로 바인딩해준다.
    • SQL과 함께 가변인자로 선언된 파라미터를 제공해주면 된다.
this.jdbcTemplate.update("insert into users(id, name, password) values(?,?,?)", user.getId(), user.getName(), user.getPassword());

  • add() 메서드에서 만드는 콜백은 PreparedStatement를 만드는 것과 파라미터를 바인딩하는 작업을 수행한다.

6.2 queryForInt()

템플릿/콜백 방식을 적용하지 않았던 메서드에 JdbcTemplate을 적용해보자.

getCount()

  • 동일한 기능 : SQL 쿼리를 실행하고 ResultSet을 통해 결과 값을 가져온다.
  • JdbcTemplatequery() : PreparedStatementCreator 콜백과 ResultSetExtractor 콜백을 파라미터로 받는다.
  • ResultSetExtractor 콜백 : 템플릿이 제공하는 ResultSet을 이용해 원하는 값을 추출해서 템플릿에 전달하면, 템플릿은 나머지 작업을 수행한 뒤에 그 값을 query() 메서드의 리턴 값으로 돌려준다.
  • 동작 방식
    • PreparedStatementCreator 콜백 : 템플릿으로부터 Connection을 받고 PreparedStatement를 돌려준다.
    • ResultSetExtractor 콜백 : 템플릿으로부터 ResultSet을 받고 거기서 추출한 결과를 돌려준다.

📌 제네릭스 타입 파라미터를 갖는 ResultSetExtractor
ResultSet에서 추출할 수 있는 값의 타입이 다양하기 때문에 타입 파라미터를 사용한 것이다.

2. queryForInt()

JdbcTemplateResultSetExtractor 콜백과 같은 기능을 가진 콜백을 내장하고 있는 queryForInt() 라는 메서드를 제공한다.
Integer 타입의 결과를 가져올 수 있는 SQL 문장만 전달해주면 된다.

📌 JdbcTemplate은 스프링이 제공하는 클래스이지만 DI 컨테이너를 굳이 필요로 하지 않기 때문에 직접 JdbcTemplate 오브젝트를 생성하고 필요한 DataSource를 전달해주기만 하면 모든 기능을 자유롭게 활용할 수 있다.

6.3 queryForObject()

1. get()

  • ResultSetExtractor 콜백과 RowMapper 콜백 차이
    • 공통점 : 템플릿으로부터 ResultSet을 전달받고, 필요한 정보를 추출해서 리턴한다.
    • 차이점
      • ResultExtractor : ResultSet을 한 번 전달받아 알아서 추출 작업을 모두 진행하고 최종 결과만 리턴해준다.
      • RowMapper : ResultSet의 로우 하나를 매칭하기 위해 사용되기 때문에 여러 번 호출될 수 있다.

  • 파라미터
    • 첫 번째 파라미터 : PreparedStatement를 만들기 위한 SQL
    • 두 번째 파라미터 : 첫 번째 파라미터에 바인딩할 값들, 가변인자 대신 Object 타입 배열 사용
    • 세 번째 파라미터 : RowMapper 콜백

queryForObject() 동작 방식

  1. 실행한 SQL에서 한 개의 로우를 얻는다.
    • queryForObject()는 SQL을 실행하면 한 개의 로우만 얻을 것이라고 기대한다.
  2. ResultSet의 next()를 실행해 첫 번째 로우로 이동시킨 후, RowMapper 콜백을 호출한다.
    • 이미 RowMapper가 호출되는 시점에서 ResultSet은 첫 번째 로우를 가리키고 있으므로 다시 rs.next()를 호출할 필요는 없다.
  3. RowMapper에서는 현재 ResultSet이 가리키고 있는 로우의 내용을 User 오브젝트에 그대로 담아서 리턴해준다.

의문 사항

  • Q : 기존 get() 메서드는 조회 결과가 없을 때 EmptyResultDataAccessException을 던지도록 만들었는데, queryForObject()를 이용할 때는 조회 결과가 없는 예외상황을 어떻게 처리해야 할까?
  • A : 이미 SQL을 실행해서 받은 로우의 개수가 하나가 아니라면 EmptyResultDataAccessException을 예외를 던지도록 만들어져 있다.

6.4 query()

6.4.1 기능 정의와 테스트 작성

  • 추가 사항
    • 현재 등록되어 있는 모든 사용자 정보를 가져오는 getAll() 메서드를 추가한다.
  • 변경 사항
    • getAll() 메서드는 여러 개의 로우이기 때문에 User 오브젝트의 컬렉션인 List<User> 타입으로 돌려주는 게 가장 나을 것 같다.
    • 기본키인 id 순으로 정렬해서 리스트에 가져오도록 만들자.

테스트 코드 작성

  • 고려 사항
    • User 타입 오브젝트인 user1, user2, user3 세 개를 DB에 등록하고 getAll()을 호출하면 List<User> 타입으로 결과를 돌려받아야 한다.
    • 리스트의 크기는 3이어야 하고, user1, user2, user3과 동일한 내용을 가진 오브젝트가 id 순서대로 담겨있어야 한다.

      📌 저장할 때의 User 오브젝트와 getAll()로 가져온 User 오브젝트를 비교할 때는 동일성이 아닌 동등성 비교를 해야 한다는 점에 주의하자.

    • user1, user2, user3를 하나씩 추가하면서 매번 getAll()을 실행해서 결과를 확인해보자.
      • 최소한 두 가지 이상의 테스트 조건에 대해 기대한 결과를 확인해봐야 한다.

  • user3는 가장 마지막에 추가되지만 getAll()의 결과에선 가장 첫 번째여야 한다.
  • User의 값을 비교하는 코드가 반복되기 때문에 별도의 메서드로 분리한다.

📌 @Test@Before 등의 애노테이션이 붙지 않는 메서드에 테스트 코드에서 반복적으로 나타나는 코드를 담아두고 재사용하는 건 좋은 습관이다. 여러 테스트 클래스에 걸쳐 재사용되는 코드라면 별도의 클래스로 분리하는 것도 고려해볼 수 있다.

6.4.2 query() 템플릿을 이용하는 getAll() 구현

  • 변경 사항
    • JdbcTemplatequery() 메서드를 사용한다.
      • queryForObject()는 쿼리의 결과가 로우 하나일 때 사용하고, query()는 여러 개의 로우가 결과로 나오는 일반적인 경우에 쓸 수 있다.
      • query()의 리턴 타입은 List<T>다.
      • query()는 제네릭 메서드로 타입은 파라미터로 넘기는 RowMapper<T> 콜백 오브젝트에서 결정된다.

  • 파라미터
    • 첫 번째 파라미터 : 실행할 SQL 쿼리
    • 두 번째 파라미터(선택) : 첫 번째 파라미터에 바인딩할 파라미터
    • 마지막 파리멑 : RowMapper 콜백
  • query() 동작 원리
    • query() 템플릿은 SQL을 실행해서 얻은 ResultSet의 모든 로우를 열람하면서 로우마다 RowMapper 콜백을 호출한다.
    • RowMapper은 현재 로우의 내용을 User 타입 오브젝트에 매핑해서 돌려준다.
    • 만들어진 User 오브젝트는 템플릿이 미리 준비한 List<User> 컬렉션에 추가된다.
    • 모든 로우에 대한 작업을 마치면 모든 로우에 대한 User 오브젝트를 담고 있는 List<User> 오브젝트가 리턴된다.

6.4.3 테스트 보완

  • 고려 사항
    • get()과 마찬가지로 getAll()에서도 예외적인 조건에 대한 테스트를 추가해야 된다.
    • 네거티브 테스트라고 불리는, 예외상황에 대한 테스트를 항상 작성하는 습관을 들이자.
      • get()이라면 id가 없을 때는 어떻게 되는지 검증
      • getAll()이라면 결과가 하나도 없는 경우에 어떻게 되는지 검증

📌 같은 개발자가 만든 조회용 메서드라도 데이터가 없을때 메서드가 null을 리턴하거나, 빈 리스트 오브젝트를 리턴하거나, 예외를 던지거나, NullPointerException 같은 런타임 예외가 발생하기도 한다. 미리 예외상황에 대한 일관성 있는 기준을 정해두고 이를 테스트로 만들어 검증해둬야 한다.

테스트 작성

  • getAll()에 사용한 query() 템플릿이 어떤 결과를 돌려주는지 알아야 한다.
    • query()는 결과가 없을 경우에 크기가 0인 List<T> 오브젝트를 돌려준다.

의문 사항

  • Q : getAll()에서 query() 결과에 손댈 것도 아니면서 굳이 검증 코드를 추가해야 할까?
  • A : 물론이다.
    • UserDao를 사용하는 쪽의 입장에선 getAll()이라는 메서드가 어떻게 동작하는지에만 관심이 있다. UserDaoTest 클래스의 테스트는 UserDaogetAll()이라는 메서드에 기대하는 동작방식에 대한 검증이 먼저기 때문에 예상되는 결과를 모두 검증하는 게 옳다.
    • 테스트 코드를 작성해두면 나중에 JdbcTemplatequery() 대신 다른 방법으로 구현을 바꿔도 동일한 기능을 유지하는 UserDao인지 확인 가능하다.
    • 또, 내부적으로 query()를 사용했다고 하더라도 getAll() 메서드가 다른 결과를 리턴하게 할 수도 있다.
    • JdbcTemplatequery() 메서드에 대한 학습 테스트로서 의미가 있다.

6.5 재사용 가능한 콜백의 분리

6.5.1 DI를 위한 코드 정리

  • DataSource
    • UserDao의 모든 메서드가 JdbcTemplate을 이용하도록 만들었으니 DataSource 인스턴스 변수는 제거하자.
    • JdbcTemplate을 생성하면서 직접 DI 해주기 위해 필요한 DataSource를 전달받아야 하니 수정자 메서드는 남겨둔다.

  • JdbcTemplate을 직접 스프링 빈으로 등록하는 방식을 사용하고 싶다면 setJdbcTemplate으로 바꿔주면 된다.

6.5.2 중복 제거

get()getAll()에 사용한 RowMapper가 중복된다.

  • 고려 사항
    • Q : 매번 RowMapper 오브젝트를 새로 만들어야 할까?
    • A : RowMapper 콜백 오브젝트에는 상태정보가 없다. 따라서 하나의 콜백 오브젝트를 멀티스레드에서 동시에 사용해도 문제가 안되기 때문에 하나만 만들어서 공유하자.

6.5.3 템플릿/콜백 패턴과 UserDao

최종적으로 완성된 UserDao 클래스다.

  • UserDao에는 User 정보를 DB에 넣거나 가져오거나 조작하는 방법에 대한 핵심적인 로직만 담겨 있다. 만약 사용할 테이블과 필드정보가 바뀌면 UserDao의 거의 모든 코드가 함께 바뀐다. 따라서 응집도가 높다고 할 수 있다.
  • 반면에 JDBC API를 사용하는 방식, 예외처리, 리소스의 반납, DB 연결을 어떻게 가져올지에 관한 책임과 관심은 모두 JdbcTemplate에게 있다. 따라서 변경이 일어난다고 해도 UserDao 코드에는 아무런 영향을 주지 않는다. 그런 면에서 책임이 다른 코드와는 낮은 결합도를 유지하고 있다.
  • JdbcTemplate라는 템플릿 클래스를 직접 이용한다는 면에서 특정 템플릿/콜백 구현에 대한 강한 결합을 갖고 있다. JdbcTemplate은 스프링에서 JDBC를 이용해 DAO를 만드는 데 사용되는 사실상 표준 기술이지만, 더 낮은 결합도를 유지하고 싶다면 JdbcTemplate을 독립적인 빈으로 등록하고 JdbcTemplate이 구현하고 있는 JdbcOperations 인터페이스를 통해 DI 받아 사용하도록 만들어도 된다. 하지만 JdbcTemplate은 DAO 안에서 직접 만들어 사용하는 게 스프링의 관례다.

추가 개선사항

  1. userMapper가 인스턴스 변수로 설정되어 있고, 한 번 만들어지면 변경되지 않는 프로퍼티와 같은 성격을 띠고 있으니 아예 userDao 빈의 DI용 프로퍼티로 만들자.
    • 이렇게 분리한다면 User 프로퍼티와 User 테이블의 필드 이름이 바뀌거나 매핑 방식이 바뀌는 경우에 UserDao 코드를 수정하지 않고도 매핑정보를 변경할 수 있다.
  2. DAO 메서드에서 사용하는 SQL 문장을 UserDao 코드가 아닌 외부 리소스에 담고 이를 읽어와 사용하자.
    • 이렇게 해두면 DB 테이블의 이름이나 필드 이름을 변경하거나 SQL 쿼리를 최적화해야 할 때도 UserDao 코드에는 손을 댈 필요가 없다.

📌 스프링에서 클래스 이름이 Template으로 끝나거나 인터페이스 이름이 Callback으로 끝난다면 템플릿/콜백이 적용된 것이라고 보면 된다.

7. 정리

3장에선 예외처리와 안전한 리소스 반환을 보장해주는 DAO 코드를 만들고 이를 객체지향 설계 원리와 디자인 패턴, DI 등을 적용해서 깔끔하고 유연하며 단순한 코드로 만들었다.

  • JDBC와 같은 예외가 발생할 가능성이 있으며 공유 리소스의 반환이 필요한 코드는 반드시 try/catch/finally 블록으로 관리해야 한다.
  • 일정한 작업 흐름이 반복되면서 그중 일부 기능만 바뀌는 코드가 존재한다면 전략 패턴을 적용한다. 바뀌지 않는 부분은 컨텍스트로, 바뀌는 부분은 전략으로 만들고 인터페이스를 통해 유연하게 전략을 변경할 수 있도록 구성한다.
  • 같은 애플리케이션 안에서 여러 가지 종류의 전략을 다이내믹하게 구성하고 사용해야 한다면 컨텍스트를 이용하는 클라이언트 메서드에서 직접 전략을 정의하고 제공하게 만든다.
  • 클라이언트 메서드 안에 익명 내부 클래스를 사용해서 전략 오브젝트를 구현하면 코드도 간결해지고 메서드의 정보를 직접 사용할 수 있어서 편리하다.
  • 컨텍스트가 하나 이상의 클라이언트 오브젝트에서 사용된다면 클래스를 분리해서 공유하도록 만든다.
  • 컨텍스트는 별도의 빈으로 등록해서 DI 받거나 클라이언트 클래스에서 직접 생성해서 사용한다. 클래스 내부에서 컨텍스트를 사용할 때 컨텍스트가 의존하는 외부의 오브젝트가 있다면 코드를 이용해서 직접 DI 해줄 수 있다.
  • 단일 전략 메서드를 갖는 전략 패턴이면서 익명 내부 클래스를 사용해서 매번 전략을 새로 만들어 사용하고, 컨텍스트 호출과 동시에 전략 DI를 수행하는 방식을 템플릿/콜백 패턴이라고 한다.
  • 콜백의 코드에도 일정한 패턴이 반복된다면 콜백을 템플릿에 넣고 재활용하는 것이 편리하다.
  • 템플릿과 콜백의 타입이 다양하게 바뀔 수 있다면 제네릭스를 이용한다.
  • 스프링은 JDBC 코드 작성을 위해 JdbcTemplate을 기반으로 하는 다양한 템플릿과 콜백을 제공한다.
  • 템플릿은 한 번에 하나 이상의 콜백을 사용할 수도 있고, 하나의 콜백을 여러 번 호출할 수도 있다.
  • 템플릿/콜백을 설계할 때는 템플릿과 콜백 사이에 주고받는 정보에 관심을 둬야 한다.
profile
🚀 백엔드 2년차 개발자입니다 🚀 성장의 즐거움으로 아자자자!🤣

0개의 댓글