[디자인패턴] 템플릿 메소드 패턴(Template Method Pattern)
💡 코드가 보이지 않으시다면 드래그 혹은 오른쪽 아래 🌜 아이콘을 눌러 테마 색을 변경해주세요.
안녕하세요!
키크니 개발자 입니다. 🦒
김영한님 강의인 스프링 핵심원리 - 고급편을 보면서 템플릿 메소드 패턴에 대해 다시 복습하고자
'얄팍한 코딩사전 : 객체지향 디자인패턴 2'을 참고하였습니다.
템플릿 메소드 패턴이란?
어떤 같은 형식을 지닌 특정 작업들의 세부 방식을 다양하고자 할 때 사용하는 패턴입니다.
변하지 않는 것은 추상클래스의 메서드로 서언하고, 변하는 부분은 추상 메서드로 선언하여 자식 클래스가 오버라이딩 하도록 처리합니다.
이렇듯 특정 작업을 처리하는 일부분을 서브 클래스로 캡슐화하여 전체적인 구조는 바뀌지 않으면서,
특정 단계에서는 수행하는 내용을 바꾸는 패턴입니다.
예를 들어 대대로 전통 약과를 만드는 가문이 있다고 가정하면,
약과를 만드는데에는 반죽을 만드는 과정, 반죽을 기름에 튀겨내는 과정, 그리고 시럽을 바르는 즙청 과정이 있습니다.
이 세 과정을 어떻게 하느냐에 따라 다양한 약과들이 만들어질 수 있습니다.
이전의 전략 패턴(Strategy Pattern)은 이 방식들을 각각 갈아 끼워넣을 수 있는 모듈화된 형식으로 따로 만들었다고 하면
(템플릿 메서드 패턴과 비슷한 역할을 하면서 상속의 단점을 제거할 수 있는 디자인 패턴입니다.),
템플릿 메소드 패턴은 이와 달리 다양화된 방식을 각각 자식 클래스들에서 오버라이딩하는 방식으로 구현하는 것이라고 할 수 있습니다.
그러면 객체지향의 단순한 상속일 뿐인데 왜 패턴으로 구분한건지 의아하실 수 있습니다.
템플릿 메소드에서의 상속은 일정한 형식이 있습니다.
부모 클래스에 전반 과정을 수행하는 메인 메소드(아래의 예시인 MapView class의 initMap() 참고)가 있고,
그 과정 가운데 세부 메소드가 있습니다.
(아래의 예시인 MapView class에 connectMapServer(), showMapOnScreen(), moveToCurrentLocation() 참고)
메인 메소드를 호출하면 실행중에 이 세부 메소드들이 호출되는 형태입니다.
자식 과정에서는 그 세부 메소드를 오버라이딩 하는 것입니다.
(아래의 예시인 NaverMapView, KakaoMapView class 참고)
다시 약과 이야기로 돌아와서
약과 가문의 부모님이 약과를 만드는 과정을 반죽, 튀기기, 즙청으로 정해놨다고 하면
이 전체 과정은 자식들이 바꿀 수가 없습니다.
(아래의 예시를 참고하면 MapView class의 initMap() 내부의 내용들을
connectMapServer(), showMapOnScreen(), moveToCurrentLocation()로 정했으면,
이 내용들을 바꿀 수 없다고 이해하면 됩니다.)
왜냐하면 이게 달라지면 약과과 아니게 되기 때문입니다.
하지만 이 세부 과정 하나하나는 자식들이 자기만의 스타일로 개발해서 다양한 약과들을 발전시켜 나갈 수 있습니다.
템플릿 메서드 패턴의 장점은?
- 전체적으로 동일하면서 부분적으로는 다른 구문으로 구성 된 메서드의 코드 중복을 최소화 시킬 수 있습니다.
- 자식 클래스의 역할을 줄여 핵심 로직의 관리가 용이합니다.
- 코드를 객체지향적으로 구성할 수 있습니다.
템플릿 메서드 패턴의 단점은?
- 상속을 사용하기 때문에 자식 클래스가 부모 클래스와 컴파일 시점에 강하게 결합됩니다.
- 추상 메소드가 많아지면서 클래스 관리가 복잡해집니다.
- 클래스간의 관계와 코드가 꼬여버릴 염려가 있습니다.
상황
앱에서 위치 정보를 제공하는 기능에 네이버와 카카오지도를 탭으로 구분되어 기능을 사용하려고 합니다.
템플릿 메소드 패턴은 부모 클래스의 메인메소드와 세부메소드, 그것을 overriding한 자식 클래스에 초점을 둡니다.
실행 : TemplateExample class
부모 클래스 : MapView class
부모 클래스의 메인메소드 : initMap()
부모 클래스의 세부메소드 : connectMapServer(), showMapOnScreen(), moveToCurrentLocation()
자식 클래스 : NaverMapView, KaKaoMapView
public class TemplateExample {
public static void main(String[] args) {
new NaverMapView().initMap();
new KakaoMApView().initMap();
}
}
자식 클래스의 (부모클래스의)메인메소드를 실행시킵니다.
public abstract class MapView {
protected abstract void connectMapServer(); // (1)
protected abstract void showMapOnScreen(); // (2)
protected abstract void moveToCurrentLocation(); // (3)
public void initMap() {
connectMapServer();
showMapOnScreen();
moveToCurrentLocation();
}
}
각각 탭을 누르면,
(1) : 해당 서버에 접속해서
(2) : 받아온 지도를 화면에 보여주고
(3) : 지도에서 현재 위치로 이동하는 상황입니다.
어떤 지도를 사용하든 이 세 과정은 이 순서대로 모두 반드시 실행해야 합니다.
public class NaverMapView extends MapView {
@Override
protected void connectMapServer() {
System.out.println("네이버 지도 서버에 연결");
}
@Override
protected void showMapOnScreen() {
System.out.println("네이버 지도를 보여줌");
}
@Override
protected void moveToCurrentLocation() {
System.out.println("네이버 지도에서 현 좌표로 이동");
}
}
public class KakaoMapView extends MapView {
@Override
protected void connectMapServer() {
System.out.println("카카오 지도 서버에 연결");
}
@Override
protected void showMapOnScreen() {
System.out.println("카카오 지도를 보여줌");
}
@Override
protected void moveToCurrentLocation() {
System.out.println("카카오 지도에서 현 좌표로 이동");
}
}
네이버지도와 카카오지도는 서로 완전 다른 API이기 때문에 위의 기능들을 구현하는 코드도 상이합니다.
(MapView의 추상메소드를 override한 connectMapServer(), showMapOnScreen(), moveToCurrentLocation()
내부의 코드들은 상이합니다.)
이 두 탭의 기능을 MapView 추상 클래스에서 상속받은 NaverMapView와 KakaoMapView 클래스로 각각 구현합니다.
MapView에서의 추상메소드인 connectMapServer(), showMapOnScreen(), moveToCurrentLocation()는
MapView에서 직접 구현하진 않기 때문에 이 메소드들은 추상으로 선언만 해두고
이 메소드들을 차례대로 실행할 initMap()메소드만 구체적으로 구현을 해놓을 것입니다.
initMap() 메소드는 이미 구현을 한 상태이기 때문에 자식들(NaverMapView, KakaoMapView)이 건들여서는 안됩니다.
MapView의 추상메소드들을 상속받은 NaverMapView, KakaoMapView에서 구현한 후
완성 된 클래스로 객체를 생성해서 위와 같이 initMap()인 메인 메소드를 실행해주면 됩니다.
템플릿 메소드 패턴은 이처럼 어떤 일을 수행하는 몇 가지 방법이 있는데
그 전반적 과정에 공통된 절차가 있을 때 코드를 효율적으로 짜기 위해 만들어진 패턴 입니다.
마지막으로 소심하게 위 예시로 나온 약과를 템플릿 메서드 패턴을 사용해서 코드로 작성하면 어떤 결과가 나올까 생각을 해봤습니다. 😲
RestAPI 형식에 맞춰 약과를 만드는 것을 호출해보면 어떨까 생각하면서 작성하였습니다.
DB에 저장한다고 가정합니다. (해당 코드는 작성하지 않았습니다.)
(추후에 테스트 코드로도 변경해봐야겠습니다. 🫣)
@RequiredArgsConstructor
@RestController
public class YakgwaController {
private final YakgwaService yakgwaService;
@PostMapping("/yakgwas")
public ResponseEntity<String> makeYakgwas(@RequestBody YakgwasRequestBody body) {
String yakgwa = yakgwaService.makeYakgwas(body);
return ResponseEntity.ok(yakgwa);
}
}
요청에 따라 BIG약과인지, MINI약과인지 응답하고 싶어 아래와 같이 작성하였습니다.
@Service
public class YakgwaService {
public String makeYakgwas(YakgwasRequestBody body) {
String yakgwa;
if (body.getYakgwaSize() == YakgwaSize.BIG) {
yakgwa = new BigYakgwa().makeYakqwa();
} else {
yakgwa = new MiniWakgwa().makeYakqwa();
}
return yakgwa;
}
}
아래는 사이즈를 받을 수 있는 requestBody class 입니다.
@Getter
public class YakgwasRequestBody {
private YakgwaSize yakgwaSize;
}
약과의 사이즈는 enum type으로 구별됩니다.
@Getter
public enum YakgwaSize {
BIG, MINI
}
실행 : YakgwaController
부모 클래스 : Yakgwa
부모 클래스의 메인메서드 : makeYakqwa()
부모 클래스의 서브메서드 : workDough(), fryDough(), applySyrup()
자식 클래스 : BigWakwa, MiniWakwa
응답 값을 따로 받고 싶어 마지막 applySyrup()에는 어떤 약과인지에 대해 string으로 return 하도록 작성하였습니다.
public abstract class Yakgwa {
protected abstract void workDough();
protected abstract void fryDough();
protected abstract String applySyrup();
public String makeYakqwa() {
workDough();
fryDough();
return applySyrup();
}
}
약과를 상속받은 큰사이즈의 약과입니다.
public class BigYakgwa extends Yakgwa {
@Override
protected void workDough() {
System.out.println("쌀가루 1kg를 반죽한다.");
}
@Override
protected void fryDough() {
System.out.println("식용유 1L를 사용하여 튀긴다.");
}
@Override
protected String applySyrup() {
System.out.println("조청 1L, 물 1L를 모두 섞어 졸인다.");
return "BIG 약과 완성";
}
}
약과를 상속받은 작은 사이즈의 약과입니다.
public class MiniWakgwa extends Yakgwa{
@Override
protected void workDough() {
System.out.println("쌀가루 100g를 반죽한다.");
}
@Override
protected void fryDough() {
System.out.println("식용유 100ml를 사용하여 튀긴다.");
}
@Override
protected String applySyrup() {
System.out.println("조청 100ml, 물 100ml를 모두 섞어 졸인다.");
return "MINI 약과 완성";
}
}
YakgwaSize를 'BIG'으로 요청했을 때
쌀가루 1kg를 반죽한다.
식용유 1L를 사용하여 튀긴다.
조청 1L, 물 1L를 모두 섞어 졸인다.
response는 아래와 같습니다.
YakgwaSize를 'MINI'으로 요청했을 때
쌀가루 100g를 반죽한다.
식용유 100ml를 사용하여 튀긴다.
조청 100ml, 물 100ml를 모두 섞어 졸인다.
response는 아래와 같습니다.
⭐️ 참고한 곳
김영한님 인프런 - 스프링 핵심원리 - 고급편
배워야 할 것이 더 많은 주니어 개발자입니다. 🐣
내용 전달보다는 정리를 목적으로 포스팅을 하고 있습니다.
잘못 된 내용이나 부족한 부분은 댓글로 주시면 감사드리겠습니다.