독서/토비의 스프링

토비의 스프링 Vol.1 3장 템플릿

Debin 2022. 7. 23.
반응형

3장은 템플릿에 관련된 내용입니다.

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

3.1 초난감 DAO 예외 처리

우리는 1장에서 초난감 DAO를 멋지게 리팩토링했다.
전략 패턴을 바탕으로 추상화에 의존하며, DI를 바탕으로 OCP(개방폐쇄 원칙)을 지키도록 했다.

또한 관심사를 멋지게 분리해냈다. 그러나 저번 시간에 잠깐 언급했는데 예외 처리에 관한 부분을 다루지 않았다.

잠깐만 UserDao 실습 코드를 살펴보자.

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

이 데이터 접근 로직을 수행하는 코드에서 예외가 발생하면 바로 메서드 실행이 중단된다.

여기서 메서드 실행이 중단되면 큰 문제가 발생한다.

바로 Connection, PreparedStatement라는 두 개의 공유 리소스를 가져와서 사용하는데 이를 close()하지 못하는 것이다.

그러면 커넥션을 돌려주지 못하므로 커넥션 풀에 여유가 없어지고 이는 리소스가 모자라 심각한 프로그램의 오류를 야기할 수 있다.

특히 스프링은 웹 애플리케이션을 위해 존재하는 기술인데 웹 애플리케이션 입장에서 이런 리소스가 사라진다는 것은 치명적이다.

따라서 JDBC 코드에서는 try/catch/finally 구문을 사용하는 것을 권장한다.

이건 필자가 이전에 스프링 강의를 보면서 느낀건데 예외처리 코드가 정말 더럽다.

 public void deleteAllUsers() throws SQLException {
        Connection con = null;
        PreparedStatement pstmt = 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) {
            }
        }
        if (c != null) {
            try {
                c.close();
            } 
            catch (SQLException e) {
            }
        }
    }
}

정말 코드가 더럽다.. 그리고 try, catch, finally 동일한 예외 처리 코드가 반복되는 것을 확인할 수 있다. 역시나 반복이 문제다.

그러나 이렇게 계속 코드를 복붙하면 언젠가 괄호를 잊어버렸을 때 정말 무시무시한 일이 생긴다고 한다.

그러므로 예외 처리 코드를 중복시키지 않고 효과적으로 다룰 수 있는 방법을 우리 개발자들이 생각해야 한다.

 

제일 먼저 생각해 볼 수 있는 방법은 바로 메서드 추출이다.

변하는 부분을 메서드로 추출한 위 예시 코드(deleteAll)은 다음과 같다.

public void deleteAll() throws SQLException{
    // 생략 ..
    
    try{
        c = datasource.getConnection();
        
        ps = makeStatement();
        
        ps.executeUpdate();
    }
    catch(SQLException e)
  	// 생략 ..
}

private PreparedStatement makeStatement(Connection c) throws SQLException{
    PreparedStatement ps;
    ps = c.prepareStatement("delete from users");
    return ps;
}

자주 바뀌는 부분을 메서드로 독립시켰다. 별로 이득인 부분이 없어 보인다.

보통 메서드 추출 리팩토링을 적용하는 경우에는 분리시킨 메서드를 다른 곳에서 사용하기 때문이다.

그러나 이 경우에는 반대로 분리시키고 남은 부분(try-catch-finally)를 재사용한다. 반대로 된 것이다.

템플릿 메서드 패턴 적용

이제 저번 시간에 배운 템플릿 메서드 패턴을 적용해보자. 사실 필자는 스프링 고급편 강의에서도 이 패턴을 배운적이 있다.

복습하는 겸 착실하게 정리해보자. 

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

변하지 않는 부분을 슈퍼클래스에 두고 변하는 부분은 추상 메서드로 정의해둬서 서브클래스에서 오버라이딩해 새롭게 기능을 확장해 사용한다.

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

짧게 코드로 살펴보면 다음과 같다.

public abstract class UserDao{
    public void fixTryCatchFinallyFun(){
        //고정된 try-catch-finally 로직을 수행하는 슈퍼클래스의 메서드
    }
    abstract protected PreparedStatement makeStatement(Connection c) throws SQLException;
}

public class UserDalDeleteAll extends UserDao{
    protected PreparedStatement makeStatement(Connection c) throws SQLException{
        PreparedStatement ps = c.prepareStatement("delete from users");
        return ps;
    }
}

이제 상속을 통해 기능을 자유롭게 확장할 수 있다. OCP를 지키는 멋진 구조가 된 것 같다.

그러나 저번 시간에 배웠지만 템플릿 메서드 패턴은 제약사항이 있다.

우선 DAO 로직마다 상속을 통해 새로운 클래스를 만들어야 한다는 점이다.

만약 이 방식을 사용한다면 UserDao의 JDBC 메서드가 10개라면 10개의 서브 클래스를 만들어서 사용해야한다.

또한 공통 된 메서드(try-catch-finally)도 결국 코드의 중복이다.

 

확장 구조가 이미 클래스 설계 시점에서 고정된다.

변하지 않는 코드 (try-catch-finally) 를 담는 블럭과 PreparedStatement를 담고 있는

서브 클래스들이 클래스 레벨에서 컴파일 시점에 관계가 결정되는 것이다. 

따라서 그 관계에 대한 유연성이 떨어진다.

개인적으로 이 문구가 정말 충격이었다. 당연한거지만 역시 런타임 시점에서 클래스들의 관계들이 결정되는 것이 좋구나.

추상화와 인터페이스 굳. 어쨌든 이렇게 템플릿 메서드 패턴을 적용한 방법을 살펴보았다.

전략 패턴의 적용

전략 패턴은 OCP 관점에 보면 확장에 해당하는 변하는 부분을 별도의 클래스로 만들어 추상화된 인터페이스를 통해 위임하는 방식이다.

아래 그림은 전략 패턴의 구조를 나타낸다. 

전략 패턴

좌측에 있는 Context의 contextMethod()에서 일정한 구조를 가지고 동작하다가 특정 확장 기능은 

Strategy 인터페이스를 통해 외부의 독립된 전략 클래스에 위임하는 것이다.

 

deleteAll() 메서드에서 변하지 않은 부분(try-catch-finally)이 바로 contextMethod()가 된다.

deleteAll() 메서드는 JDBC를 이용해 DB를 업데이트하는 작업이라는 변하지 않는 맥락(context)을 가진다.

전략 패턴을 적용한 StatementStrategy 대략적인 코드는 아래와 같다.

//전략 패턴에서 인터페이스
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;    
    }
}

//전략 패턴을 사용한 contextMethod() 역할
public void deleteAll(StatementStrategy strategy) throws SQLException{
    // 코드 생략..
    try{
    	c = dataSource.getConnection();
        ps = strategy.makePreparedStatenment(c);
    }
    catch(SQLException e){
    // 코드 생략..
}

deleteAll() 메서드는 파라미터를 통해 전략 오브젝트를 주입받는다. 

이 방식은 client가 Context가 어떤 전략을 사용하게 할 지 결정한다.

Client가 구체적인 전략의 하나를 선택하고 오브젝트로 만들어서 Context에게 전달하면

추상화를 통해 Context가 유연하게 구체적인 전략을 받아들일 수 있다.

결국 이 구조에서 전략 오브젝트 생성과 컨텍스트로의 전달을 담당하는 책임을 분리시킨 것이 바로 ObjectFactory며,

이를 일반화한 것이 앞에서 살펴본 DI(의존관계 주입)이다.

DI는 바로 전략 패턴의 장점을 일반적으로 활용할 수 있도록 만든 구조다.

 

이제 전략 패턴을 적용한 코드를 빠르게 살펴보자. (컨텍스트 코드와 client 코드는 같은 클래스에 존재한다)

//client 역할 메서드
public void deleteAll() throws SQLException{
     StatementStrategy st = new DeleteAllStatement(); //선정한 전략 클래스의 오브젝트 생성. 구체 클래스
     jdbcContextWithStatementStrategy(st); //컨텍스트 호출. 전략 오브젝트 전달
}

//컨텍스트 코드
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{
       //공유 리소스 close 로직. 
   }
}

 

일단 전략 패턴의 모습을 갖춘 것 같다. 좀 더 전략 패턴의 최적화에 대해 알아보자.

전략 패턴 최적화

이번에는 deleteAll()이 아니라 add() 메서드와 add()메서드를 사용하는 클라이언트를 살펴보겠다. 코드는 다음과 같다.

//전략 패턴 인터페이스를 구현한 구체 클래스
public class AddStatement implements StatementStrategy{

    User user;
    
    public AddStatement(User user){
        this.user = user;
    }
    
    @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());
    }
}

//클라이언트
public void add(User user) throws SQLException{
    StatementStrategy st = new AddStatement(user);
    jdbcContextWithStatementStrategy(st);
}

현재 만들어진 구조에는 2가지 문제가 있다.

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

먼저 책에서 소개한 방법은 다음과 같다.

  1. 로컬 클래스 사용: add 메서드내에서 전략 인터페이스를 구현한 로컬 클래스를 만들어서 사용한다.
  2. 익명 내부 클래스: 익명 내부 클래스는 선언과 동시에 오브젝트를 생성한다. 인터페이스를 구현하는 느낌이라고 이해하면 될 것 같다.
    매개변수로 익명 내부 클래스를 만들어 전략 오브젝트로 넘겼다.

본인이 잘 사용하지 않았던 방식이라 낯설었지만 흥미로웠다. 책을 보면서 직접 코드를 보면 좋을 것 같다.

컨텍스트와 DI

전략 패턴의 구조로 보면 UserDao 메서드는 클라이언트다. 익명 내부 클래스로 만들어지는 것이 개별 전략이고,

jdbcContextWithStatementStrategy() 메서드가 컨텍스트다. 그러다 이 메서드는 다른 DAO에서 일반적으로 사용할 수 있다.

따라서 다른 DAO에서도 사용가능하게 메서드를 클래스 밖으로 독립시켜서 새로운 클래스에 메서드로 만들자. 

클래스를 분리한 코드는 아래와 같다.

public class JdbcContext{
    private DataSource dataSource;
    
    public void setDataSource(DataSource dataSource){
        this.dtataSource = dataSource;
    }
    
    public void workWithStatementStrategy(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{
            //공유 리소스 close 로직. 
        }
    }
}

다음은 UserDao가 분리된 JdbcContext를 DI 받아서 사용할수 있게 만든다.

public class UserDao{

    private JdbcContext jdbcContext;
    
    public void setJdbcContext(JdbcContext jdbcContext){
        this.jdbcContext = jdbcContext; //DI
    }
    
    public void add(final User user) throws SQLException{
        this.jdbcContext.workWithStatementStrategy{ //DI 받은 JdbcContext 컨텍스트 메서드를 사용하도록 변경
            new StatementStrategy() { ... } 
        )
    }
    
    public void deleteAll() throws SQLException{
        this.jdbcContext.workWithStatementStrategy{
            new StatementStrategy() { ... } 
        )
    }
}

이렇게 클래스를 분리했다.

빈 의존관계

지금 UserDao는 JdbcContext에 의존하고 있다.

그러나 잘 보면 JdbcContext는 인터페이스가 아니라 구체클래스다.

스프링의 DI는 기본적으로 인터페이스를 사이에 두고 의존 클래스를 바꿔서 사용하도록 하는 것이 목적이다.

하지만 잘 생각해보자. JdbcContext는 구현 방법이 바뀔리가 없다. 왜냐하면

독립적인 JDBC 컨텍스트를 제공해주는 서비스 오브젝트로서 의미가 있고 구현 방법이 바뀔 가능성이 없다.

(try-catch-finally로 예외를 처리해주고 있기 때문이다. 자바 기본 문법.) 사실 그래서 인터페이스로 의존관계를 맺을 필요가 없다고 한다.

 

사실 DI의 개념은 인터페이스를 사이에 둬서 클래스 레벨에서는 의존관계가 고정되지 않게 하고,

런타임 시에 의존할 오브젝트와의 관계를 다이나믹하게 주입해주는 것이 맞다.

따라서 엄밀하게 말하면 인터페이스를 사용하지 않으면 온전한 DI라고 볼 수 없다고 한다.

 

그러나! 스프링의 DI는 넓게 보자면 객체의 생성과 관계설정에 대한 제어권한을 오브젝트에서 제거하고 외부로 위임했다는 IOC라는 개념을 포괄한다. 따라서 JdbcContext를 스프링을 이용해 UserDao 객체에서 사용하게 주입한 건 기본 DI를 따르므로 DI라고 할 수 있다.

그러면 JdbcContext와 UserDao를 인터페이스로 엮지 않아도 스프링 컨테이너를 사용해 DI를 사용하는 이유는 무엇일까?

  1. JdbcContext가 스프링 컨테이너의 싱글톤 레지스트리에서 관리되는 싱글톤 빈이 되기 때문이다. 읽기 전용이라 문제가 없다.
  2. JdbcContext가 DI를 통해 다른 빈에 의존하기 때문이다. (DataSource)

반대로 스프링 컨테이너를 사용해 DI를 안하면 어떨까?

  1. 우선 싱글톤으로 만드는 것을 포기해야할 것이다. 사실 JdbcContext에는 상태정보도 없어서 메모리에 주는 부담이 거의 없다고 한다.
  2. 매우 강한 의존 관계를 지니는 UserDao가 제어권을 가지면 사실 DI에서 큰 문제는 없다.
  3. 역시 문제는 DataSource다. 스프링 빈으로 등록되어야 DI를 할 수 있다.
    DataSource는 스프링 빈인데 JdbcContext가 스프링 빈이 아니므로 의존관계를 직접 주입해주어야 한다.
  4. 사실 이 문제를 해결할 수 있다. JdbcContext의 제어권을 가지는 UserDao가 스프링 빈이므로 여기서 DataSource를 주입 받고, 동시에 JdbcContext에도 DataSource를 주입하는 것이다. 그러면 빈의 의존 관계는 아래와 같다. 

코드를 통한 DI 구조

이런 코드로 의존 관계를 주입하는 장점은 다음과 같다.

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

어떤 방식이든 개발자가 상황에 맞게 방법을 고르는 것이 중요하다.

매우 강하거나 긴밀한 의존관계는 굳이 인터페이스를 사용할 필요가 없다고 한다. 너무 과한 설계라는 것을 알아두자.

템플릿과 콜백

지금까지 UserDao와 StatementStrategy, JdbcContext를 이용해 전략 패턴을 적용했다.

전략 패턴의 기본 구조에 익명 내부 클래스를 활용한 방식이다.

이런 방식을 스프링에서는 템플릿/콜백 패턴이라고 부른다.

전략 패턴의 컨텍스트를 템플릿이라고 부르고, 익명 내부 클래스로 만들어지는 오브젝트를 콜백이라고 부른다.

 

스프링에서는 JdbcTemplate , RestTemplate , TransactionTemplate , RedisTemplate 처럼 다양한 템플릿 콜백 패턴이 사용된다. 스프링에서 이름에 XxxTemplate 가 있다면 템플릿 콜백 패턴으로 만들어져 있다 생각하면 된다.

템플릿/콜백의 특징

여러 개의 메서드를 가진 일반적인 인터페이스를 사용할 수 있는 전략 패턴의 전략과 달리 템플릿/콜백 패턴의 콜백은 보통 단일 메서드 인터페이스를 사용한다. 단일 메서드 인터페이스를 보고 제일 먼저 떠오른 생각은 함수형 인터페이스다. 

무튼 템플릿의 작업 흐름 중 특정 기능을 위해 한 번 호출되는 경우가 일반적이기 때문이다.

콜백 인터페이스의 메서드에는 보통 파라미터가 있다.

이 파라미터는 템플릿의 작업 흐름 중에 만들어지는 컨텍스트 정보를 전달받을 때 사용된다.

일반적인 템플릿/콜백의 작업 역할과 순서는 다음과 같다.

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

클라이언트가 템플릿 메서드를 호출하면서 콜백 오브젝트를 전달하는 것은 메서드 레벨에서 일어나는 DI다.

템플릿이 사용할 콜백 인터페이스를 구현한 오브젝트를 메서드를 통해 주입해주는 DI 작업이 클라이언트가 템플릿의 기능을 호출하는 것과 동시에 일어난다. 템플릿/콜백 방식에서는 매번 메서드 단위로 사용할 오브젝트를 새롭게 전달받는다는 것이 특징이다.

콜백 오브젝트가 내부 클래스로서 자신을 생성한 클라이언트 매서드 내의 정보를 직접 참조한다는 것도 템플릿/콜백의 고유한 특징이다.

 

JdbcContext에 적용된 템플릿/콜백을 이미지로 정리하면 아래와 같다.

템플릿/콜백 패턴 적용

템플릿/콜백 패턴에도 아쉬운점이 존재하는데 바로 매번 DAO 메서드에서 익명 내부 클래스를 사용하기 때문에 코드를 작성하고 읽는 것이 불편하다는 점이다. 어떻게 해야 이 문제를 해결할 수 있을까?

책에서 실습 코드를 살피면 익명 내부 클래스를 전달할 때 바뀌는 부분은 SQL 문장인 것을 확인할 수 있다.

그러면 SQL 문장만 파라미터로 받아서 바꿀 수 있게 하고 메서드 내용을 분리해 별도의 메서드로 만들자.

public void deleteAll() throws SQLException{
    executeSql("delete from users");
}

private void executeSql(String query) throws SQLException{
    this.jdbcContext.workWithStatementStrategy{
        new StatementStrategy(){
        public PreparedStatement makePreparedStatement(Connection c) throws SQLException{
                return c.prepareStatement(query);
            }
        }
    }
}

별도의 메서드만 빼내면 재활용 가능한 콜백을 담은 메서드가 만들어진다.

이제 String만 변경하고 executeSql을 사용하면 손쉽게 데이터 액세스 로직을 사용할 수 있다.

변하는 것과 변하지 않는 것을 분리하고 변하지 않는 건 유연하게 재활용할 수 있게 하는 그런 설계가 중요하다!

 

그러나 책을 다음장으로 넘겼을 때 정말 억 소리 나는 깔끔한 코드를 확인했다. 바로 executeSql을 jdbcContext 메서드로 변경하는 것이다. 그럼 코드가 정말 이뻐진다..

public class JdbcContext{

     // 코드 생략
    private void executeSql(String query) throws SQLException{
        workWithStatementStrategy{ //내부 메서드 호출
            new StatementStrategy(){
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException{
                    return c.prepareStatement(query);
                }
            }
        }
    }
}

//클라이언트 코드
public void deleteAll() throws SQLException{
     this.jdbcContext.executeSql("delete from users");
}

클라이언트가 정말 너무나도 이뻐졌다. 정말 정말 대단한 리팩토링.. 또한 설계는 정말 어려운 것 같다고 느꼈다..

일반적으로 성격이 다른 코드들은 가능한 한 분리하지만, 이 경우는 반대다.

하나의 목적을 위해 서로 긴밀하게 연관되어 동작하는 응집력이 강한 코드들이기 때문에 한 군데 모여 있는게 유리하다.

최대한 클래스의 정보는 은닉화하고 외부에는 꼭 필요한 기능을 제공하는 단순한 메서드만 제공하는 것이다.

 

이번 3장도 스프링 고급편에서 배운 내용을 복습하는 것 같았고 무척이나 재밌었다.

그리고 저자님께서 리팩토링하는 과정을 보니 우와소리가 절로 나왔다.
나도 더욱 열심히 해야지..

 

이상으로 포스팅을 마칩니다. 감사합니다.

 

참고 자료

토비의 스프링 3.1 Vol. 1 (3장 템플릿)

반응형

댓글