본문 바로가기
Back-End/토비의 스프링

토비의 스프링 | 3장. 템플릿2 - 응용

by Havi 2017. 3. 3.

3.5 템플릿과 콜백

템플릿/콜백의 동작원리

템플릿: 어떤 목적을 위해 미리 만들어둔 모양이나 틀을 가리킨다. JSP는 HTML이라는 고정된 부분에 EL과 스크립릿이라는 변하는 부분을 넣은 일종의 템플릿 파일이다.
콜백: 실행되는 것을 목적으로 다른 오브젝트의 메소드에 전달되는 오브젝트를 말한다. 자바에선 메소드 자체를 파라미터로 전달할 방법이 없기 때문에 메소드가 담긴 오브젝트를 전달한다. 이것을 functional object라 한다.

  • UserDao와 StatementStrategy, JdbcContext를 이용한 전략 패턴의 기본 구조에 익명 내부 클래스를 활용한 방식을 템플릿/콜백 패턴이라 한다.
  • 전략 패턴의 컨텍스트를 템플릿이라 부르고, 익명 내부 클래스를 콜백이라 부른다.
  • 템플릿/콜백 패턴의 콜백은 보통 단일 메소드 인터페이스를 사용한다. 템플릿은 특정 기능을 위해 한 번만 노출되는 것이 일반적이기 때문이다.
  • 클라이언트가 템플릿 메소드를 호출하면서 콜백 오브젝트를 전달하는 것은 메소드 레벨의 DI이다.
  • DI작업이 클라이언트가 템플릿의 기능을 호출하는 것과 동시에 일어난다.
  • DI는 사용할 의존 오브젝트를 수정자 메소드로 받는 반면, 템플릿/콜백 방식은 매번 메소드 단위로 사용할 오브젝트를 전달받는 것이 특징이다.
  • 콜백 오브젝트가 내부 클래스로서 자신을 생성한 클라이언트 메소드 내의 정보를 직접 참조한다는 것도 특징이다.

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

콜백의 분리와 재활용

이번에는 익명 내부 클래스 코드를 분리해 보는 작업을 해보자. 변하는 부분과 변하지 않는 부분을 구분하여 리펙토링 해보자.

public void deleteAll() throws SQLException {
    executeSql("delete from users"); //변하는 sql 문장

}

private void excuteSql(final String query) throws SQLException {
    this.jdbcContext.workWithStatementStrategy(
        new StatementStrategy() { //변하지 않는 콜백 클래스 정의와 오브젝트 생성
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                return c.prepareStatement(query);
                }
        }
    );
}

한 단계 더 나아가 보자. excuteSql의 성격상 userDao에서만 사용하는 것이 아니라 이렇게 재사용 가능한 콜백 메소드는 DAO가 공유할 수 있는 템플릿 클래스 안으로 옮겨도 된다. 엄밀히 말하면 템플릿은 JdbcContext 클래스가 아니라 workWithStatementStrategy() 메소드이므로 JdbcContext 클래스로 excuteSql() 메소드를 옮겨도 문제 될건 없다.

public class JdbcContext {
    ...
    public void excuteSql(final String query) throws SQLException {
        this.jdbcContext.workWithStatementStrategy(
            new StatementStrategy() {
                public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                    return c.prepareStatement(query);
                    }
            }
        );
    }
}
public void deleteAll() throws SQLException {
    this.jdbcContext.excuteSql("delete from users");
}
  • deleteAll에서 jdbcContext의 excuteSql()을 호출해서 사용하는 구조가 되었다.
  • 결국 JdbcContext 안에 클라이언트와 템플릿, 콜백이 모두 공존하여 동작하는 구조가 됐다.
    • 일반적으로 성격이 다른 코드들은 가능한 분리하는게 좋지만, 이 경우에는 하나의 목적을 위해 긴밀하게 연관되며 응집력이 강한 코드는 모여 있는 것이 유리하다.
    • 구체적인 구현과 내부의 전략 패턴, DI, 익명 내부 클래스 등으로 기술은 최대한 감추고, 꼭 필요한 기능을 제공하는 단순한 메소드만 외부에 노출해 주는 것이다.

템플릿/콜백의 응용

스프링을 사용한다면 기본적으로 OCP를 지키고, 전략 패턴과 DI를 바탕에 깔고 확장력있게 코딩해야 한다. 자주 반복되는 코드는 메소드로 분리하고 그중 일부 작업을 필요에 따라 바꾸어 사용해야 한다면 인터페이스를 사이에 두고 분리해서 전략 패턴을 적용하고 DI로 의존관계를 관리하도록 만든다. 여기서 바뀌는 부분이 하나의 애플리케이션 안에서 동시에 일어난다면 템플릿/콜백 패턴을 고려해 볼 수 있다.

템플릿/콜백 패턴 적용 전략

  • 먼저 템플릿에 담을 반복되는 작업 흐름을 생각한다.
  • 템플릿이 콜백에게 전달해줄 내부의 정보는 무잇이고, 콜백이 템플릿에 반환해줄 내용은 무엇인지 생각한다.
  • 템플릿이 작업을 마친 뒤 클라이언트에게 전달해줘야 할 것도 생각해 본다.
  • 템플릿/콜백을 적용할 때 템플릿과 콜백의 경계를 정하고 각각 전달하는 내용이 무엇인지를 파악하는게 가장 중요하다.

텍스트 파일 읽기와 연산을 통한 예제
numbers.txt라는 파일 안에 '1 2 3 4'라는 숫자가 쓰여있고 이를 읽어서 더하기 연산을 수행한다고 해 보자. 내용은 계속 바뀔 수 있고 이를 위의 전략을 적용해 본다면 다음과 같이 수행할 수 있다.(나라면 어떻게 적용할지 생각해 본다.)

  • BufferedReader를 만들어서 콜백에게 전달해주고, 콜백이 라인을 읽어서 계산을 한 후 최종 결과를 템플릿에게 돌려주는 것을 인터페이스의 메소드로 표현하면 다음과 같다.
public interface BufferedReaderCallback {
    Integer doSomethingWithReader(BufferedReader br) throws IOException;
}
  • 템플릿을 메소드로 분리하고 BufferedReaderCallback를 적절한 시점에 실행해주면 된다. 콜백이 돌려준 결과는 최종적으로 모든 처리를 마친 후 클라이언트에 반환한다.
 //BufferedReaderCallback를 Autowired로 찾는게 일반적이지만 여기서는 파라미터를 통해 전달
//템플릿
public Integer fileReadTemplate(String filepath, BufferedReaderCallback callback) throws IOException {
    BufferedReader br = null;
    try {
        br = new BufferedReader(new FileReader(filepath));
        int ret = callback.doSomethingWithReader(br); //콜백 오브젝트 호출
        return ret;
    } 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()); }
        }
    }
}
  • 템플릿/콜백을 적용한 calcSum() 메소드이다.
public Integer calcSum(String filepath) throws IOException {
        BufferedReaderCallback sumCallback = new BufferedReaderCallback() {
            @Override
            public Integer doSomethingWithReader(BufferedReader br) throws IOException {
                Integer sum = 0;
                String line;
                while((line = br.readLine()) != null) {
                    sum += Integer.valueOf(line);
                }
                return sum;
            }
        };
        return fileReadTemplate(filepath, sumCallback);
    }
  • 곱을 계산하는 calcMultiply 메소드이다.
  • 람다식을 적용하였다.
public Integer calcMultiply(String filepath) throws IOException {
        BufferedReaderCallback sumCallback = br -> {
            Integer multiply = 1;
            String line;
            while((line = br.readLine()) != null) {
                multiply *= Integer.valueOf(line);
            }
            return multiply;
        };
        return fileReadTemplate(filepath, sumCallback);
    }
  • 이를 테스트하기 위한 코드이다.
@RunWith(SpringRunner.class)
@SpringBootTest
public class TemplateCallbackApplicationTests {
    Calculator calculator;
    String numFilepath;

    @Before
    public void init() {
        this.calculator = new Calculator();
        this.numFilepath = "C:/Users/Kim YJ/Documents/template-callback/numbers.txt";
    }

    @Test
    public void sumOfNumbers() throws IOException {
        assertThat(calculator.calcSum(this.numFilepath), is(10));
    }

    @Test
    public void multiplyOfNumbers() throws IOException {
        assertThat(calculator.calcMultiply(this.numFilepath), is(24));
    }

}

템플릿/콜백 리펙토링

위의 코드에서 calcSum()과 calcMultiply()는 연산자를 제외하고 모두 중복되는 코드이다. 콜백부분에는 연산에 관한 역할만 남겨놓고 템플릿 부분에 텍스트 라인작업까지 위임시킨다면 좀 더 깔끔한 코드가 될 것이다. LineCallback에는 파일의 각 라인과 현재까지 계산된 값을 넘겨주고 새로운 계산 결과를 리턴 값을 통해 받도록 한다. LineCallback 인터페이스를 경계로 콜백은 더 단순해지고 템플릿은 공통된 부분을 명확히 나타낼 것이다.

public interface LineCallback {
    Integer doSomethingWithLine(String line, Integer value);
}
public class Calculator {
    //템플릿
    //initVal: 계산결과를 저장할 변수의 초기값
    public Integer lineReadTemplate(String filepath, LineCallback callback, int initVal) throws IOException {
        BufferedReader br = null;
        try {
            br = new BufferedReader(new FileReader(filepath));
            Integer res = initVal;
            String line;
            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()); }
            }
        }
    }

    //덧셈을 계산하다는 콜백
    public Integer calcSum(String filepath) throws IOException {
        LineCallback sumCallback = new LineCallback() {
            @Override
            public Integer doSomethingWithLine(String line, Integer value) {
                return value + Integer.valueOf(line);
            }
        };
        return lineReadTemplate(filepath, sumCallback, 0);
    }

    //곱을 계산하는 콜백(람다형식)
    public Integer calcMultiply(String filepath) throws IOException {
        LineCallback multiplyCallback = (line, value) -> value * Integer.valueOf(line);
        return lineReadTemplate(filepath, multiplyCallback, 1);
    }
}

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

자바5에 추가된 제네릭스(Generics)를 이용하면 더 다양한 오브젝트 타입을 지원하는 인터페이스나 메소드를 정의할 수 있다. 콜백 인터페이스의 리턴 값과 파라미터 값의 타입을 제네릭 타입 파라미터 T로 선언한다.

public interface LineCallback<T> {
    T doSomethingWithLine(String line, T value);
}
  • lineReadTemplate() 메소드에서 LineCallback 오브젝트, initVal, res, 반환값을 T타입으로 정의하여 다양한 타입을 받을 수 있도록 수정한다.
public class Calculator3 {
    //템플릿
    public <T> T lineReadTemplate(String filepath, LineCallback<T> callback, T initVal) throws IOException {
        BufferedReader br = null;
        try {
            br = new BufferedReader(new FileReader(filepath));
            T res = initVal;
            String line;
            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()); }
            }
        }
    }

    public String concatenate(String filepath) throws IOException {
        LineCallback<String> concatenateCallback = (line, value) -> value + line;
        return lineReadTemplate(filepath, concatenateCallback, "");
    }
}
  • 앞에서 만든 코드보다 훨씬 깔끔하게 템플릿과 콜백이 분리되었다. 콜백은 순수한 로직만 남아 있기에 코드의 성격이 무엇인지 명확하게 나타난다.
  • 제네릭스 타입을 통해 다양한 타입의 값을 처리할 수 있게 되었다.
  • 코드에서 특성이 바뀌는 경계를 잘 살피고 그것을 인터페이스로 분리하는 것이 템플릿/콜백 패턴의 핵심이다.

위의 코드를 Github에서 받아서 테스트 하실 수 있습니다.


댓글0