토비의 스프링 정리 프로젝트 #3.5 템플릿과 콜백

Jake Seo·2021년 8월 1일
0

토비의 스프링

목록 보기
21/29

템플릿과 콜백

전략 패턴의 기본 구조에 익명 내부 클래스를 활용한 방식은 복잡하지만 바뀌지 않는 일정한 패턴을 갖는 작업 흐름이 존재하고, 그 중 일부만 자주 바꿔서 사용하는 경우에 적합한 구조다.

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

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

템플릿

템플릿은 어떤 목적을 위해 미리 만들어둔 모양이 있는 틀을 가리킨다. 고정된 틀 안에 바꿀 수 있는 부분을 넣어서 사용하는 경우 템플릿이라고 부른다. 이를테면 JSP는 HTML이라는 고정된 부분에 EL과 스크립릿이라는 변하는 부분을 넣은 일종의 템플릿 파일이다. 템플릿 메소드 패턴은 고정된 틀의 로직을 가진 템플릿 메소드를 슈퍼 클래스에 두고, 바뀌는 부분을 서브 클래스의 메소드에 두는 구조로 이뤄진다.

콜백

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

템플릿/콜백의 동작원리

템플릿/콜백의 특징

여러 메소드를 가질 수 있는 전략 패턴의 전략과 달리 템플릿/콜백 패턴의 콜백은 보통 단일 메소드 인터페이스를 사용한다. 템플릿의 작업 흐름 중 특정 기능을 위해 한 번 호출되는 경우가 일반적이기 때문이다. 물론 하나 이상의 콜백 오브젝트를 사용하는 것도 가능하다. 콜백은 일반적으로는 하나의 메소드를 가진 인터페이스를 익명 내부 클래스로 구현한다.

위 그림은 템플릿/콜백의 작업 흐름이다.

  • 클라이언트: 콜백 오브젝트를 만들고, 템플릿에 전달 및 호출 (1, 2)
  • 템플릿:참조정보 생성 및 콜백의 오브젝트 메소드 호출 (3, 4, 5)
  • 콜백: 클라이언트 메소드에 있는 정보와 템플릿이 가진 참조 정보를 이용하여 작업 수행 후 템플릿에 결과 반환 (6, 7, 8)
  • 템플릿: 콜백이 돌려준 정보를 이용하여 나머지 작업 수행 후 경우에 따라 최종 결과를 다시 클라이언트에게 반환 (9, 10, 11)

클라이언트가 콜백 오브젝트를 만들고 템플릿에 전달하는 것은 메소드 레벨 DI이다. 일반적인 DI라면, 템플릿에 인스턴스 변수를 만들어두고 사용할 의존 오브젝트를 수정자 메소드로 받아서 사용한다. 반면, 템플릿/콜백 방식에서는 매번 메소드 단위로 사용할 오브젝트를 새롭게 전달받는다.

콜백 오브젝트가 내부 클래스로 자신을 생성한 클라이언트 메소드 내의 정보를 직접 참조하여 클라이언트와 콜백이 강하게 결합된다는 면에서도 일반적인 DI와 조금 다르다.

전략 패턴과 수동 DI를 이용한다.

JdbcContext에 적용된 템플릿/콜백

템플릿의 작업 흐름이 복잡해지면, 한 번 이상의 콜백을 호출하기도 하고, 여러개의 콜백을 클라이언트로부터 받아서 사용하기도 한다.

편리한 콜백의 재활용

기존 방식은 템플릿에 담긴 코드를 여기저기 반복적으로 사용해야 하는 단점이 있었다. 템플릿/콜백 방식에서는 이러한 단점이 해결됐다. JdbcContext만 이용해도, 기존의 커넥션을 맺고 끊는 템플릿 코드를 매번 재작성 할 필요가 사라지고, 비즈니스 로직에만 집중할 수 있게 되었다. 많이 개선된 지금의 코드에서 한가지 아쉬운 점이 있는데, 익명 내부 클래스를 사용하여 익숙하지 않은 스타일 때문에 가독성이 떨어진다는 것이다.

콜백의 분리와 재활용

위에서 언급했던 문제인 복잡한 익명 내부 클래스의 사용을 최소화해보자. 코드를 깔끔하게 만들기 위해 가장 첫번째로 생각해보아야 할 것은 분리를 통해 재사용이 가능한 부분을 찾아내는 것이다. 즉, 변화할 수 있는 부분과 변화하지 않을 부분을 구분하는 것이다.

기존의 deleteAll() 메소드

    public void deleteAll() throws SQLException {
        StatementStrategy strategy = c -> c.prepareStatement("delete from users"); // 선정한 전략 클래스의 오브젝트 생성
        jdbcContext.workWithStatementStrategy(strategy); // 컨텍스트 호출, 전략 오브젝트 전달
    }

여기서 변화할 수 있는 부분은 "delete from users" 뿐이다. 나머지는 '템플릿'으로 볼 수 있다.

변경 후 deleteAll() 메소드

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

    public void executeSql(String sql) throws SQLException {
        StatementStrategy strategy = c -> c.prepareStatement(sql); // 선정한 전략 클래스의 오브젝트 생성
        jdbcContext.workWithStatementStrategy(strategy); // 컨텍스트 호출, 전략 오브젝트 전달
    }

변화하지 않는 템플릿을 executeSql()이라는 메소드로 만들어주었다. 이로 인해, .preparedStatement()를 사용하여 수행되는 쿼리는 전부 .executeSql()을 재활용할 수 있게 되었다. 이제는 콜백 익명 클래스 구현도 필요 없어졌다.

콜백과 템플릿의 결합

executeSql()은 위에서 언급한대로, deleteAll() 메소드에서만 사용되긴 아까우니, JdbcContext 클래스로 옮겨서 필요한 곳에 여기저기 쓰일 수 있도록 만들어주자.

JdbcContext 클래스

public class JdbcContext {
    private DataSource dataSource;

    public void executeSql(String sql) throws SQLException {
        StatementStrategy strategy = c -> c.prepareStatement(sql); // 선정한 전략 클래스의 오브젝트 생성
        this.workWithStatementStrategy(strategy); // 컨텍스트 호출, 전략 오브젝트 전달
    }
...

UserDao 클래스

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

객체지향 원칙 중 결합도가 낮고 응집도가 높아야 이상적이라는 원칙이 있다. 위 JdbcContext의 경우, 쿼리를 날리기 위한 JDBC 템플릿 코드가 뭉쳐있으니 응집도가 높은 경우의 예시이다. 또한, 외부에는 구체적인 구현을 감추고 필요한 기능을 제공하는 단순 메소드만 공개하였기 때문에 캡슐화(은닉화)가 잘되어 있다.

비단 .deleteAll() 뿐만 아니라, UserDao.add()도 위와 같이 템플릿을 쪼갤 수 있다. 다만, 바인딩할 파라미터 타입을 살펴서 적절한 설정 메소드를 호출해주는 작업이 조금 복잡해질 수는 있다. 그래도 한번 만들어놓으면 매우 편리하게 재사용 가능하니 도전해볼만 하다.

파라미터가 있는 .executeSql()을 추가한 JdbcContext 클래스

    public void executeSql(String sql, Object ...parameters) throws SQLException {
        StatementStrategy stmt = c -> {
            PreparedStatement ps = c.prepareStatement(sql);

            for(int i=1; i<=parameters.length; i++) {
                ps.setObject(i, parameters[i-1]);
            }

            return ps;
        };

        this.workWithStatementStrategy(stmt);
    }

add()를 변경한 UserDao 클래스

    public void add(User user) throws SQLException {
        this.jdbcContext.executeSql("insert into users(id, name, password) values (?, ?, ?)"
                , user.getId()
                , user.getName()
                , user.getPassword()
        );
    }

책에 없는 내용인데, 직접 만들어보고 테스트를 돌려보니 잘 작동한다. 모든 타입에 대해서 .setObject()가 잘 적용되는지 테스트해보진 않았지만, 웬만한 타입은 커버가 될 것 같다.

자세한 내용을 알아보기 위해 주석을 읽어봤는데, non-typed Null에 대한 처리를 모든 DB가 해줄 수 있는 건 아니어서, setObject() 중에 파라미터 3개로 sqlType까지 주면 DB 밴더에 더욱 무관하게 사용할 수 있다는 내용 같다.

템플릿/콜백의 응용

스프링이 제공하는 디자인 패턴을 이해하고 사용하는 것과 이해하지 못하고 사용하는 것에는 큰 차이가 있다. 스프링이 제공하는 기술의 구조를 이해하면 손쉽게 확장해서 쓸 수 있다. 전략 패턴, DI, 템플릿/콜백 패턴 모두 중요하다.

코드에서 고정된 흐름을 찾았는데, 여기저기 반복되는 것을 보았다면 중복되는 코드를 분리할 방법을 생각해보는 습관을 길러야 한다. 먼저 메소드로 분리해볼 수 있고, 일부 작업을 필요에 따라 바꾸어야 한다면, 인터페이스를 사이에 두고 분리하여 전략패턴을 적용하여 DI로 의존관계를 관리하도록 만들 수 있다. 바뀌는 부분이 한 애플리케이션 안에서 동시에 여러 종류가 만들어질 수 있다면 템플릿/콜백 패턴을 적용하는 것을 고려해볼 수 있다.

테스트와 try/catch/finally

간단한 템플릿/콜백 예제를 하나 작성해보자. 파일을 열어서 모든 라인의 숫자를 더한 합을 돌려주는 코드를 만드는 게 목표이다.

numbers.txt 생성

1
2
3
4
5
6
7
8
9
10

숫자가 담긴 파일이다.

Calculator.java 생성

public class Calculator {
    public Integer calcSum(String filepath) throws IOException {
        BufferedReader br = new BufferedReader(new FileReader(filepath));
        Integer sum = 0;
        String line = null;

        while((line = br.readLine()) != null) {
            sum += Integer.valueOf(line);
        }

        br.close();
        return sum;
    }
}

CalcSumTest.java 생성

public class CalcSumTest {
    @Test
    public void sumOfNumbers() throws IOException {
        Calculator calculator = new Calculator();
        String path = getClass().getResource("/numbers.txt").getPath();
        Integer calcSum = calculator.calcSum(path);
        Assertions.assertEquals(calcSum, 55);
    }
}

간단하게 파일을 열고 모든 라인의 숫자를 합해주고 반환하는 코드를 작성했다. 그런데 파일 입출력도 DB 커넥션과 같이 끝났을 때는 항상 리소스를 반납해주어야 한다.

Calculator.java - 예외 처리 적용

public class Calculator {
    public Integer calcSum(String filepath) throws IOException {
        BufferedReader br = null;
        
        try {
            br = new BufferedReader(new FileReader(filepath));
            Integer sum = 0;
            String line;

            while ((line = br.readLine()) != null) {
                sum += Integer.valueOf(line);
            }

            br.close();
            return sum;
        } catch(IOException e) {
            System.out.println("e.getMessage() = " + e.getMessage());
            throw e;
        }
        finally {
            if (br != null) {
                try {
                    br.close();
                } catch (IOException e) {
                    System.out.println("e.getMessage() = " + e.getMessage());
                }
            }
        }
    }
}

예외 처리를 적용했더니, 어마무시하게 비대한 클래스로 변했다. 어떠한 경우에라도 파일이 열렸으면 반드시 닫아주도록 만들었다.

중복 제거와 템플릿 설계

문제 제기

클라이언트가 모든 숫자의 곱도 얻을 수 있으면 좋겠다는 요구를 했다면, 그리고 그 후에 또 여러 연산을 더하고 싶다는 이갸기가 들려온다면, 어떻게 해야 할까?

가장 간단히는 소스코드를 계속 복사 붙여넣기 하고, calcMultiply, calcDivide, calc...의 메소드를 계속 생성해나갈 수 있다. 그리고 우리가 고치는 부분은 그냥 +=*=정도로 고칠 것이다. 하지만 그 이후에 고객의 요구사항으로 예외가 났을 때, 예외처리 로그를 남겨달라는 요구사항이 또 생긴다면? 복사 붙여넣기한 각각의 코드의 catch 블록에 일일이 로그를 남기는 부분을 추가해야 할 것이다.

이렇게 작성하면 실수하기도 쉽고 무엇보다 객체지향 언어를 쓰는 이점이 하나도 없다.

문제 해결

템플릿/콜백 패턴을 적용하여 조금 더 효율적인 문제 해결을 해보자. 늘 그렇듯, 반복되는 코드 흐름이 어떤 것인지 확인해보자. 반복되는 코드 흐름은 템플릿에 담길 것이다. 그리고 반복되지 않는 코드 흐름이 어떤 것인지도 확인해보자. 반복되지 않는 코드 흐름은 콜백에 담길 것이다. 템플릿과 콜백에 담길 내용을 생각해보았다면, 템플릿이 콜백에게 전달해줄 내부 정보는 무엇이고, 콜백이 템플릿에게 돌려줄 내용은 무엇인지 생각해보자.

템플릿과 콜백의 메세징 내용이 중요하다.

템플릿/콜백 패턴을 적용할 때는, 템플릿과 콜백의 경계를 정하고, 템플릿이 콜백에게, 콜백이 템플릿에게 각각 전달하는 내용이 무엇인지 파악하는 게 가장 중요하다. 그에 따라 콜백의 인터페이스를 정의해야 하기 때문이다.

1단계로 다음과 같이 코드 흐름을 분리해보자.

  • 템플릿: 파일을 열고, 각 라인을 읽어올 수 있는 BufferedReader를 만들어 콜백에게 전달해준다.
  • 콜백: BufferedReader를 받아 각 라인을 처리하고 최종 결과를 반환한다.

이것을 인터페이스처럼 표현하면 아래와 같은 코드가 나온다.

콜백 인터페이스 정의하기: BufferedReaderCallback

public interface BufferedReaderCallback {
  Integer doSomethingWithReader(BufferedReader br) throws IOException;
}

템플릿 메소드 정의하기: fileReadTemplate

    public Integer fileReadTemplate(String filepath, BufferedReaderCallback callback) throws IOException {
        BufferedReader br = null;

        try {
            br = new BufferedReader(new FileReader(filepath));
            Integer result = callback.doSomethingWithReader(br);

            return result;
        } catch(IOException e) {
            System.out.println("e.getMessage() = " + e.getMessage());
            throw e;
        }
        finally {
            if (br != null) {
                try {
                    br.close();
                } catch (IOException e) {
                    System.out.println("e.getMessage() = " + e.getMessage());
                }
            }
        }
    }

    public Integer calcSum(String filePath) throws IOException {
        BufferedReaderCallback callback = br -> {
            Integer sum = 0;
            String line = null;

            while((line = br.readLine()) != null) {
                sum += Integer.valueOf(line);
            }

            return sum;
        };


        return this.fileReadTemplate(filePath, callback);
    }

템플릿/콜백 정의하기: CalcSumTest (클라이언트)

public class CalcSumTest {
    Calculator calculator;
    String filePath;

    @BeforeEach
    public void setUp() {
        this.calculator = new Calculator();
        this.filePath = getClass().getResource("/numbers.txt").getPath();
    }

    @Test
    public void sumOfNumbers() throws IOException {
        Integer sum = calculator.calcSum(filePath);
        Assertions.assertEquals(sum, 55);
    }
}

개선된 점 정리

이제 읽어들인 숫자에 다른 연산이 필요하더라도 BufferedReaderCallback의 구현만 바꿔주면 파일 입출력 코드는 전혀 신경쓰지 않고, 프로그램에 변화를 줄 수 있다.

또한, +, -, *, / 등의 연산이 다 만들어진 이후에, 예외시 로그를 남기라는 추가 요청사항이 들어와도 변경해야 할 지점은 단 한군데이다.

곱셈 연산기 만들어보기

이번에는 불러들인 숫자를 전부 곱하는 버전을 만들어보자. 1부터 10까지 곱하면 3628800이라는 결과가 나온다.

    @Test
    public void multiplyOfNumbers() throws IOException {
        Integer multiply = calculator.calcMultiply(filePath);
        Assertions.assertEquals(multiply, 3628800);
    }

위 테스트가 성립하도록 코드를 작성해보자.

calcMultiply()

    public Integer calcMultiply(String filePath) throws IOException {
        BufferedReaderCallback callback = br -> {
            Integer multiply = 1;
            String line = null;

            while((line = br.readLine()) != null) {
                multiply *= Integer.valueOf(line);
            }

            return multiply;
        };

        return this.fileReadTemplate(filePath, callback);
    }

잘 작성 되어서 테스트도 무사히 통과한다.

템플릿/콜백의 재설계

사실 calcMultiply() 메소드는 이전의 calcSum() 메소드와 많은 부분이 공통된다. 단, 1부터 시작하며, 곱해나간다는 점만 다르다. 공통적인 패턴은 또 템플릿으로 변화시킬 수 있다.

템플릿/콜백을 찾아낼 때는 변화하는 경계를 찾고 그 경계에서 어떤 정보를 주고받는지 확인하면 된다.

위에서 바뀌었던 코드는 2가지로 볼 수 있다.

  • 초기값이 1로 시작됐다.
  • +=*=이 됐다.

fileReadTemplate -> lineReadTemplate

    public Integer lineReadTemplate(String filepath, LineCallback callback, int initValue) throws IOException {
        BufferedReader br = null;

        try {
            String line = null;
            Integer result = initValue;
            br = new BufferedReader(new FileReader(filepath));

            while((line = br.readLine()) != null) {
                result = callback.doSomethingWithLine(line, result);
            }

            return result;
        } catch(IOException e) {
            System.out.println("e.getMessage() = " + e.getMessage());
            throw e;
        }
        finally {
            if (br != null) {
                try {
                    br.close();
                } catch (IOException e) {
                    System.out.println("e.getMessage() = " + e.getMessage());
                }
            }
        }
    }

기존의 fileReadTemplate() 메소드를 지우고 lineReadTemplate()이라는 메소드를 생성해주었다. 이전에서 우리가 반복되는 부분을 callback에 넣었던 실수를 인지하고, 다시 반복되는 부분을 template로 자연스럽게 올려주었다.

나머지 calcSum()calcMultiply()도 이에 맞게 변경해주자.

    public Integer calcSum(String filePath) throws IOException {
        LineCallback callback = ((line, value) -> Integer.valueOf(line) + value);
        return this.lineReadTemplate(filePath, callback, 0);
    }

    public Integer calcMultiply(String filePath) throws IOException {
        LineCallback callback = ((line, value) -> Integer.valueOf(line) * value);
        return this.lineReadTemplate(filePath, callback, 1);
    }

람다 함수를 사용했더니 매우 적은 수의 라인으로 표현이 가능해졌다.

테스트 코드를 작동시켜보면, 정상적으로 작동한다.

개선된 점

로우 레벨의 파일처리 코드가 템플릿으로 분리되고 순수한 계산로직만 남게 되었다. 덕분에 해당 코드의 관심사가 명확하게 보인다. Calculator 클래스와 메소드는 데이터를 가져와 계산한다는 핵심기능에 충실한 코드만 갖고 있게 됐다.

코드의 특성이 바뀌는 경계를 잘 살피고 그것을 인터페이스를 사용해 분리한다는 가장 기본적인 객체지향 원칙에만 충실하면 어렵지 않게 템플릿/콜백 패턴을 만들어 활용할 수 있다.

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

현재는 결과가 Integer로 고정되어 있지만, 나눗셈을 하다보면 소수점도 나오고 다른 타입이 필요할 수 있다. 이럴 때는 제네릭스를 이용하면 된다.

LineCallback 인터페이스에 타입 파라미터 적용하기

public interface LineCallback<T> {
    T doSomethingWithLine(String line, T value);
}

Calculator 클래스에 타입 파라미터 적용하기

public class Calculator {
    public <T> T lineReadTemplate(String filepath, LineCallback<T> callback, T initValue) throws IOException {
        BufferedReader br = null;

        try {
            String line = null;
            T result = initValue;
            br = new BufferedReader(new FileReader(filepath));

            while((line = br.readLine()) != null) {
                result = callback.doSomethingWithLine(line, result);
            }

            return result;
        } catch(IOException e) {
            System.out.println("e.getMessage() = " + e.getMessage());
            throw e;
        }
        finally {
            if (br != null) {
                try {
                    br.close();
                } catch (IOException e) {
                    System.out.println("e.getMessage() = " + e.getMessage());
                }
            }
        }
    }

    public Integer calcSum(String filePath) throws IOException {
        LineCallback<Integer> callback = ((line, value) -> Integer.valueOf(line) + value);
        return this.lineReadTemplate(filePath, callback, 0);
    }

    public Integer calcMultiply(String filePath) throws IOException {
        LineCallback<Integer> callback = ((line, value) -> Integer.valueOf(line) * value);
        return this.lineReadTemplate(filePath, callback, 1);
    }

    public String concatenate(String filePath) throws IOException {
        LineCallback<String> callback = ((line, value) ->  value + line);
        return this.lineReadTemplate(filePath, callback, "");
    }

    public Double calcDivide(String filePath, Double initValue) throws IOException {
        LineCallback<Double> callback = ((line, value) -> Double.valueOf(line) * value);
        return this.lineReadTemplate(filePath, callback, initValue);
    }
}

위와 같이, 파라미터에서 받은 타입을 이용하여 제네릭스를 적용하는 방식을 썼는데, 위와 같이 설정하면 메소드에서는 LineCallback<T> callback 파라미터에서 받은 타입으로 T 타입을 설정하게 된다.

파라미터 중, callbackinitValue 모두 같은 T 타입을 사용하므로 두 파라미터의 타입은 일치해야 한다.

참고: 클래스를 만들 때, 타입 파라미터의 타입을 설정하고 싶다면, Calculator<T>와 같이 작성하면 된다.

제네릭 정리 포스팅의 제네릭 메소드 항목에서 자세히 설명하고 있다.

테스트해보기

public class CalcSumTest {
    Calculator calculator;
    String filePath;

    @BeforeEach
    public void setUp() {
        this.calculator = new Calculator();
        this.filePath = getClass().getResource("/numbers.txt").getPath();
    }

    @Test
    @DisplayName("합 확인")
    public void sumOfNumbers() throws IOException {
        Integer sum = calculator.calcSum(filePath);
        Assertions.assertEquals(sum, 55);
    }

    @Test
    @DisplayName("곱 확인")
    public void multiplyOfNumbers() throws IOException {
        Integer multiply = calculator.calcMultiply(filePath);
        Assertions.assertEquals(multiply, 3628800);
    }

    @Test
    @DisplayName("그냥 문자열로 더하기")
    public void sumOfString() throws IOException {
        String sum = calculator.concatenate(filePath);
        Assertions.assertEquals(sum, "12345678910");
    }

    @Test
    @DisplayName("1부터 10까지로 나눠보기")
    public void divideOfNumbers() throws IOException {
        Double divide = calculator.calcDivide(filePath, (double) 10000);
        Assertions.assertEquals(divide, 3.6288E10);
    }
}

전부 이상없이 테스트를 잘 통과했다.

새롭게 살펴본 리턴 값을 갖는 템플릿이나 템플릿 내에서 여러번 호출되는 콜백 오브젝트, 또 제네릭스 타입을 갖는 메소드나 콜백 인터페이스 등의 기법은 스프링 템플릿/콜백 패턴이 적용된 곳에서 종종 사용되고 있다. 이러한 사용법을 모르면 스프링의 코드를 이해하기 어렵다.

profile
풀스택 웹개발자로 일하고 있는 Jake Seo입니다. 주로 Jake Seo라는 닉네임을 많이 씁니다. 프론트엔드: Javascript, React 백엔드: Spring Framework에 관심이 있습니다.

0개의 댓글