이 글은 GOF 중에서 의도가 거의 유사하지만, 구현 방식이 조금씩 다른 3 가지 패턴을 알아본다. 템플릿 메서드 패턴, 전략 패턴, 템플릿 콜백 패턴이다. 이 3가지 패턴은 변경이 자주 발생하는 코드와 그렇지 않은 코드를 분리하는 것이 핵심이다. 여기에 객체지향의 다형성(또는 상속)을 이용하여 변경이 자주 발생하는 코드를 유연하게 대처하도록 한다.

패턴을 설명하기 위해 한 가지 예제를 바탕으로 각각의 패턴이 어떻게 구현되는지 살펴보자.

전체 예제 코드는 이 링크에서 볼 수 있습니다.

예제 - 문자 알림 전송 기능

개발자 파커는 애플리케이션에 문자 알림을 전송하는 기능을 추가하는 작업을 맡게 되었다. 이 기능의 요구사항은 다음과 같았다.

  • 문자 전송은 A 외부 서비스 API를 사용한다.
    • API 요청 정보는 휴대폰 번호와 문자 내용 두 가지가 존재한다.
    • 응답은 문자 발송에 성공하면 true, 문자 발송에 실패하면 false를 반환한다.
  • 문자 전송 전후로 시작과 결과 로그를 남긴다.
  • 결과 로그에는 외부 서비스 API의 요청에서 응답까지 소요된 시간을 측정한 결과를 보여줘야한다.

작업을 맡은 파커는 빠르게 개발하여 아래의 코드를 작성하였고, 배포하였다.

public void sendSms(String cellphoneNumber, String smsContents) {
	log.info("문자 발송 시작");	
	long startTime = System.currentTimeMillis();
	
	ASmsSendingService aService = new ASmsSendingService();
	// 중간 로직 생략...
	boolean result = aService.send(cellphoneNumber, smsContents);

	long endTime = System.currentTimeMillis();
	long resultTime = endTime - startTime;
	log.info("문자 발송 결과: 성공 여부 = {}, 소요 시간 = {}ms", result, resultTime);
}

시간이 지나고 서비스가 성장하면서 문자 알림 전송의 양도 크게 증가하였다. 그 결과, A 문자 서비스를 사용하는 비용이 너무 부담스러워졌다. 이 비용을 줄이기 위해 가격이 더 저렴한 B 문자 서비스로 변경해달라는 요청이 왔다.

public void sendSms(String cellphoneNumber, String smsContents) {
	log.info("문자 발송 시작");	
	long startTime = System.currentTimeMillis();
	
	BSmsSendingService bService = new BSmsSendingService();
	// 중간 로직 생략...
	boolean result = bService.send(cellphoneNumber, smsContents);

	long endTime = System.currentTimeMillis();
	long resultTime = endTime - startTime;
	log.info("문자 발송 결과: 성공 여부 = {}, 소요 시간 = {}ms", result, resultTime);
}

개발자 파커는 B 문자 서비스를 사용하기 위해 내부 로직을 모두 바꾸고 테스트 후 배포하였다. 한시름 돌린 찰라에 개발 환경을 분리한다는 이야기가 나왔고, 운영 환경이 아닌 개발 환경에서는 문자를 보내면 안된다는 요구사항이 생겼다.

파커는 고민에 빠졌다. sendSms() 메서드가 외부 서비스가 변경될 때마다 수정이 되어야 하는지 의문이었다. 그리고 외부 서비스를 변경하는 코드 외에 로그를 출력하는 것과 시간을 측정하는 부분은 변하지 않는데, sendSms() 메서드가 계속 변경되는 것도 마음에 들지 않았다.

문자 외부 서비스가 변경되거나 심지어 실제로는 보내지 않게 하는 로직으로 변경되어도 sendSms() 메서드에는 전혀 변경이 일어나지 않게 하려면 어떻게 해야 할까? 객체지향의 원칙 중 SRP 관점에서도 sendSms() 메서드는 문자 외부 서비스 사용에 대한 역할과 문자 전송의 진행과정을 로그로 남겨야하는 두 가지 역할로 인해서 변경되는 이유가 2 가지 이상이 되어버린다. 이는 객체지향적이지 않은 코드로 보인다.

이를 해결하기위해 처음에 언급한 디자인 패턴을 적용해보며, 문자 외부 서비스가 바뀌어도 어떻게 변경없이 sendSms() 메서드를 재사용할 수 있는지 살펴보자.

템플릿 메서드 패턴(Template Method Pattern)

먼저, 템플릿 메서드 패턴이 무엇인지 알아보자. GOF 디자인 패턴 책에서는 다음과 같이 정의하고 있다.

Defines the skeleton of an algorithm in a method, deferring some steps to subclasses. Template Method lets subclasses redefine certain steps of an algorithm without changing the algorithms structure. - GOF -

Template Method Pattern

위 그림과 함께 설명하면, 부모 클래스는 어떠한 기능(알고리즘)의 템플릿을 정의하고, templateMethod() (변경되는 로직)는 자식 클래스에서 재정의한다. 이는 객체지향의 특징인 상속과 다형성을 통해 변경되는 부분을 관리하는 것이다.

위 예제에서 변경되는 부분은 문자 외부 서비스 API를 호출하는 부분이다.

ASmsSendingService aService = new ASmsSendingService();
// ...
boolean result = aService.send(cellphoneNumber, smsContents);
BSmsSendingService bService = new BSmsSendingService();
// ...
boolean result = bService.send(cellphoneNumber, smsContents);

위 두 코드를 보면 요구사항이 변경되었을 때 sendSms() 메서드가 변경된 부분을 비교한 모습이다. 이 부분을 위 그림에서 templateMethod() 로 분리한다면, 어떤 외부 서비스가 오더라도 sendSms() 메서드 자체는 변경될 일이 없을 것이다.

@Slf4j
public abstract class SmsTemplateService {

    public void sendSms(String cellphoneNumber, String smsContents) {
        log.info("문자 발송 시작");
        long startTime = System.currentTimeMillis();

        boolean result = requestAPI(cellphoneNumber, smsContents);

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("문자 발송 결과: 성공 여부 = {}, 소요 시간 = {}ms", result, resultTime);
    }

    public abstract boolean requestAPI(String cellphoneNumber, String smsContents);

}

먼저, 그림의 Abstratct Class 부분인 부모 클래스를 보자. 변경되는 부분인 templateMethod()requestAPI() 메서드이다. 이는 자식 클래스에서 재정의해야 한다. A 외부 서비스와 B 외부 서비스를 사용하는 자식클래스는 다음과 같이 구현할 수 있다.

public class ASmsService extends SmsTemplateService {

    @Override
    public boolean requestAPI(String cellphoneNumber, String smsContents) {
        ASmsSendingService aService = new ASmsSendingService();
        return aService.send(cellphoneNumber, smsContents);
    }
}
public class BSmsService extends SmsTemplateService {

    @Override
    public boolean requestAPI(String cellphoneNumber, String smsContents) {
        BSmsSendingService bService = new BSmsSendingService();
        return bService.send(cellphoneNumber, smsContents);
    }
}

이를 사용하는 클라이언트를 간단히 테스트 코드로 작성해보면 다음과 같다.

@Test
void send_sms_using_a_service() {
    String cellphoneNumber = "010-1111-2222";
    String smsContents = "Hello!";
    SmsTemplateService service = new ASmsService();

    service.sendSms(cellphoneNumber, smsContents);
}
  • A 문자 서비스를 사용하기 위해서 구체 클래스는 ASmsService 로 생성하였다.
문자 발송 시작
A 외부 서비스 사용: [010-1111-2222]에게 'Hello!' 내용 문자 보내기
문자 발송 결과: 성공 여부 = true, 소요 시간 = 1ms
@Test
void send_sms_using_b_service() {
    String cellphoneNumber = "010-1111-2222";
    String smsContents = "Hello!";
    SmsTemplateService service = new BSmsService();

    service.sendSms(cellphoneNumber, smsContents);
}
문자 발송 시작
B 외부 서비스 사용: [010-1111-2222]에게 'Hello!' 내용 문자 보내기
문자 발송 결과: 성공 여부 = true, 소요 시간 = 1ms

템플릿 메서드 패턴의 단점은 상속을 사용한다는 점이다. 상속을 사용하게 되면, 자식 클래스들은 부모 클래스에게 강하게 결합된다. 위 코드는 부모 클래스에 로직이나 필드가 없지만, 이러한 코드들이 추가되면 자식 클래스도 이를 똑같이 가져가게 된다. 만약 부모 클래스가 변경된다면 자식 클래스 모두를 변경해야 되는 상황도 올 수 있다.

(그 외 단점인 코드양이 많아지고 구조가 복잡해지는 것은 디자인 패턴을 사용할 때 공통적인 단점이다. 사실 변경에 유연한 코드를 위해서 이러한 단점은 불가피한 상황이다. 따라서 간단하고 변경이 될 여지가 거의 없는 코드라면 디자인 패턴을 사용하지 않는 것이 더 좋을 때도 있다.)

이러한 상속에 대한 단점을 없애고, 템플릿 메서드 패턴과 유사한 장점을 가질 수 있는 패턴이 전략 패턴이다.

전략 패턴(Strategy Pattern)

전략 패턴은 GOF에서 다음과 같이 정의한다.

Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it - GOF -

Strategy Pattern

전략 패턴은 말그대로 여러 전략을 쉽게 교환할 수 있도록 만드는 패턴이다. 템플릿 메서드 패턴은 변경되는 부분을 부모 클래스의 추상 메서드(템플릿 메서드)로 만들었지만, 전략 패턴은 이를 인터페이스로 완전히 분리하였다. 그리고 변경되지 않는 부분은 Context에 존재한다.

먼저, Context에 해당하는 코드를 보자.

@Slf4j
public class SmsService {

    private final SmsSendingStrategy smsSendingStrategy;

    public SmsService(SmsSendingStrategy smsSendingStrategy) {
        this.smsSendingStrategy = smsSendingStrategy;
    }

    public void sendSms(String cellphoneNumber, String smsContents) {
        log.info("문자 발송 시작");
        long startTime = System.currentTimeMillis();

        boolean result = smsSendingStrategy.execute(cellphoneNumber, smsContents);

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("문자 발송 결과: 성공 여부 = {}, 소요 시간 = {}ms", result, resultTime);
    }
}

sendSms() 메서드가 존재하는 클래스 SmsService가 Context에 해당하는 클래스이다. 여기서는 전략을 필드에 위치시켰다. 전략에 해당하는 인터페이스는 다음과 같다.

public interface SmsSendingStrategy {

    boolean execute(String cellphoneNumber, String smsContents);
}

Context는 전략에 해당하는 인터페이스에만 의존하고 있고, 이 전략이 변경된다고 해서 Context는 전혀 변경되지 않는다.

구체적인 전략은 이 전략 인터페이스를 구현하는 클래스로 선언할 수 있다.

@Slf4j
public class SmsSendingStrategyA implements SmsSendingStrategy {

    @Override
    public boolean execute(String cellphoneNumber, String smsContents) {
        log.info("A 외부 서비스 사용: [{}]에게 '{}' 내용 문자 보내기", cellphoneNumber, smsContents);
        return true;
    }
}

이를 사용하는 클라이언트는 다음과 같이 선언할 수 있다.

@Test
void send_sms_using_a_service() {
    String cellphoneNumber = "010-1111-2222";
    String smsContents = "Hello! Strategy Pattern.";
    SmsSendingStrategy strategyA = new SmsSendingStrategyA();
    SmsService service = new SmsService(strategyA);

    service.sendSms(cellphoneNumber, smsContents);
}
문자 발송 시작
A 외부 서비스 사용: [010-1111-2222]에게 'Hello! Strategy Pattern.' 내용 문자 보내기
문자 발송 결과: 성공 여부 = true, 소요 시간 = 2ms

여기서, 전략 패턴 구현 방식에 대해 몇 가지 추가적으로 살펴볼 것이 있다. 먼저 전략의 위치에 대해 살펴보자. 위는 전략을 Context의 필드에 선언하여, Context 클래스가 생성될 때 초기화되도록 하였다. 이를 ‘선 조립, 후 실행’ 이라고도 부르는데, 이는 필요한 전략이 실행 시점에 반드시 존재한다는 것을 보장받을 수 있다. 스프링 프레임워크가 실행 시점에 모든 빈 의존관계를 설정하는 것과 같은 이치이다. 하지만 단점으로는 전략을 변경하는데 드는 비용이 크다는 점이다. 전략을 바꾸기 위해서는 Context 객체를 새로 생성해야 하기 때문이다.

위 단점을 해결하기 위해서는 전략의 위치를 변경해줄 수 있다. Context의 필드가 아닌, 실제로 사용하는 메서드의 매개변수로 받을 수 있다.

@Slf4j
public class SmsService {
    public void sendSms(String cellphoneNumber, String smsContents,
                        SmsSendingStrategy smsSendingStrategy) {
        log.info("문자 발송 시작");
        long startTime = System.currentTimeMillis();

        boolean result = smsSendingStrategy.execute(cellphoneNumber, smsContents);

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("문자 발송 결과: 성공 여부 = {}, 소요 시간 = {}ms", result, resultTime);
    }
}

위 Context 클래스는 전략 인터페이스를 필드가 아닌 실제 사용하는 sendSms() 메서드의 매개변수로 받고 있다. 이렇게 구현하면, ‘선 조립, 후 실행’에서의 단점은 해결할 수 있지만, 실행 시점에 이 전략이 존재하는지에 대해서 보장받을 수는 없다.

전략 위치를 어디에 둘 지는 답이 없고 서로 장단점이 명확히 구분된다. 따라서 상황에 따라 선택해서 사용하는 것을 권장한다.

한 가지 떠 살펴볼 점은 전략을 구현하는 방식이다. 전략 인터페이스의 구현체를 꼭 클래스 파일로 만들 필요가 있을까? 한 전략의 구현체가 여러 곳에서 사용하는 것이 아닌 딱 한 곳에서만 필요한 경우라면 클래스 파일로 따로 분리해서 선언하는 것보다 익명 클래스 또는 람다를 사용해서도 구현할 수 있다.

클라이언트에서 전략 구현체를 직접 구현해서 사용하는 코드를 보자.

@Test
void send_sms_using_a_service_anonymous_class() {
    String cellphoneNumber = "010-1111-2222";
    String smsContents = "Hello! Strategy Pattern.";
    SmsService service = new SmsService(new SmsSendingStrategy() {
        @Override
        public boolean execute(String cellphoneNumber, String smsContents) {
            log.info("A 외부 서비스 사용: [{}]에게 '{}' 내용 문자 보내기", cellphoneNumber, smsContents);
            return true;
        }
    });

    service.sendSms(cellphoneNumber, smsContents);
}

익명 클래스를 사용하면 위와 같이 구현할 수 있다. 여기에 Java8 이상 버전을 사용하고, 전략 인터페이스가 FuntionalInterface라면, 람다 표현식으로 더욱 깔끔하게 구현할 수도 있다.

@Test
void send_sms_using_a_service_lambda() {
    String cellphoneNumber = "010-1111-2222";
    String smsContents = "Hello! Strategy Pattern.";
    SmsService service = new SmsService((cellphoneNumber1, smsContents1) -> {
        log.info("A 외부 서비스 사용: [{}]에게 '{}' 내용 문자 보내기", cellphoneNumber1, smsContents1);
        return true;
    });

    service.sendSms(cellphoneNumber, smsContents);
}

지금까지는 전략 패턴을 살펴봤다. 템플릿 메서드 패턴에 비해서 상속을 사용하지 않고 인터페이스를 사용하므로, 좀 더 코드를 유연하게 사용할 수 있다. 전략은 더이상 Context(또는 템플릿)에 의존하지 않으므로 자유롭게 필요에 따라 구체적인 전략을 만들고, 또는 새로운 전략 인터페이스를 사용할 수도 있다.

템플릿 콜백 패턴(Template Callback Pattern)

마지막으로, 템플릿 콜백 패턴을 살펴보자. 템플릿 콜백 패턴은 사실 전략 패턴을 몇 가지 방식으로 구현하면서 이미 사용되었다. 전략 패턴의 전략이 콜백으로 변경된 것 뿐이다

프로그래밍에서 콜백(callback) 또는 콜애프터 함수(call-after function)는 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 말한다. 콜백을 넘겨받는 코드는 이 콜백을 필요에 따라 즉시 실행할 수도 있고, 아니면 나중에 실행할 수도 있다. - 위키백과 -

정리하면 콜백 코드는 호출(call)한 곳에서 실행되지 않고, 호출을 요청한 곳(back or callee)에서 실행되는 코드를 말한다.

이 패턴은 GOF에서 나온 패턴이 아닌, 스프링 프레임워크에서 부르는 패턴이다. 토비의 스프링 책에서 그림을 보면 좀 더 이해하기 쉬울 듯 하다. (토비 스프링 책을 참고하여 예제에 맞게 살짝 수정하였다.)

Template Callback Pattern

위 그림처럼 동작하려면 콜백 코드를 템플릿 메서드에게 넘기는 방법이 필요하다. 자바 8부터는 이러한 코드를 람다 형태로 매개변수로 전달할 수 있다. (자바 8 이전에는 익명 클래스 사용)

위 그림을 자세히 보면 앞서 전략 패턴을 구현하던 방식 중 람다를 사용한 흐름과 같다.

@Test
void send_sms_using_a_service_lambda() {
    String cellphoneNumber = "010-1111-2222";
    String smsContents = "Hello! Strategy Pattern.";
    SmsService service = new SmsService((cellphoneNumber1, smsContents1) -> {
        log.info("A 외부 서비스 사용: [{}]에게 '{}' 내용 문자 보내기", cellphoneNumber1, smsContents1);
        return true;
    });

    service.sendSms(cellphoneNumber, smsContents);
}

람다를 사용한 전략패턴을 다시 한 번 보자. 사실 살짝 다른 지점이 보인다. 위는 전략을 필드로 삽입하여, 그림과 매칭하기가 조금 헷갈릴 수가 있다. 전략의 위치를 매개변수로 바꾸고 이를 람다로 하면 완전히 위 그림과 같아질 것이다.

@Test
void send_sms_using_a_service_lambda() {
    String cellphoneNumber = "010-1111-2222";
    String smsContents = "Hello! Strategy Pattern.";
    SmsService service = new SmsService();

    service.sendSms(cellphoneNumber, smsContents, (cellphoneNumber1, smsContents1) -> {
        log.info("A 외부 서비스 사용: [{}]에게 '{}' 내용 문자 보내기", cellphoneNumber1, smsContents1);
        return true;
    });
}

위 코드를 보자. sendSms() 메서드의 매개변수로 람다식을 사용했고, 이 코드가 콜백 코드이다. 그림과 매칭해보면, 클라이언트는 테스트 코드 메서드이고, 콜백은 매개변수의 람다식이다. 그리고 템플릿은 sendSms() 메서드이다.

이를 보면 템플릿 콜백 패턴은 전략 패턴의 용어 중 일부만 살짝 바꾸면 바로 같아진다.

  • Strategy Pattern → Template Callback Pattern
  • Strategy → Callback
  • Context → Template

정리

지금까지 문자 전송 기능 예제를 통해 디자인 패턴 중 템플릿 메서드 패턴, 전략 패턴, 템플릿 콜백 패턴 이렇게 3가지를 살펴보았다. 이 3 가지 디자인 패턴의 의도는 같다. 변경되기 쉬운 코드와 그렇지 않은 코드를 분리하고, 변경되기 쉬운 코드를 추상 메서드 또는 인터페이스로 추상화하여 변경 지점을 추출하였다. 그 결과, 변경을 하더라도 템플릿(또는 컨텍스트) 코드는 그대로이고 이를 어디서든 재사용 및 확장할 수 있게 되었다.

이는 객체지향 5대 원칙 중에서도 OCP를 만족시키는 대표적인 사례이다. 템플릿(또는 컨텍스트) 코드 기준으로 확장에는 열려있지만, 변경에는 닫혀있다. (사실, 패턴에 더해서 클라이언트라는 제 3자의 위치에서 구체 클래스의 주입까지 더해져서 OCP가 온전히 완성되었음을 인지하고 있어야 한다.)

참고자료