디자인 패턴 - 전략 패턴(Strategy pattern)과 디자인 패턴에 대한 고찰_feat(우테코 - 자동차 경주)
우테코 1단계 level 1 의 자동차 경주 미션 리팩토링을 진행하고, 미션 리뷰어에게 전략 패턴에 대한 이야기를 들었다.
나는 사실 전략 패턴에 대해 잘 알지 못했다. 그런데 전략 패턴을 이미 구현했다는 피드백을 보고 신기함을 느꼈다.
어떻게 제대로 모르는 것을 구현할 수 있지?
이 궁금증을 시작으로 전략 패턴을 더 자세히 공부하기로 했다.
전략 패턴(Strategy Pattern)?
'알렉산더 슈베츠' 저자의 '디자인 패턴에 뛰어들기' 라는 책에서는 이렇게 설명하고 있다.
(이 글은 위 링크 글을 보고 공부하여 정리한 내용으로 원문을 참조하시는 것을 추천한다.)
전략 패턴은 알고리즘들의 패밀리를 정의하고, 각 패밀리를 별도의 클래스에 넣은 후 그들의 객체들을 상호교환할 수 있도록 하는 행동 디자인 패턴입니다.
... ? 🤔
글만 봐서는 정확히 어떤 패턴인지 이해하기 어렵다.
구체적인 문제 상황을 예로 들면서 공부해보자.
전략 패턴을 문제 상황과 해결로 알아보기
내비게이션(경로 탐색) 앱
내비: 사용자들이 어느 도시에서든 빠르게 방향을 잡도록!
자동 경로 계획 기능: 목적지로 가는 가장 빠른 경로를 보기
버전1: 도로로된 경로만 만들기
버전2: 도보 경로 옵션 추가
버전 3: 대중 교통 경로 옵션 추가
추후 버전: 자전거 경로 추가 및 도시의 모든 관광 명소를 지나는 경로 옵션 추가
내비게이션 앱 문제점
새 경로 구축 알고리즘을 추가할 때마다 내비게이터(경로 탐색) 클래스의 크기가 두 배로 늘어남.
--> 유지 보수가 어려워짐, 팀워크가 비효율적이게 됨
내비게이션 앱 해결책
전략 패턴을 사용하여 특정 역할을 다양한 방식으로 수행하는 클래스를 고르고, 모든 알고리즘을 전략들(strategies)이라는 별도의 클래스들로 추출할 수 있다.
원래 클래스에는 전략 중 하나에 대한 참조를 저장하기 위한 필드가 있어야 한다. 이는 흔히 context(문맥)으로 표현된다.
context 가 특정 알고리즘(특정 경로 탐색 기능)을 선택할 책임이 없다. 대신, 클라이언트가 원하는 전략을 context 에 전달한다.
context(위에서는 Navigator 의 routeStrategy)는 실제 RouteStrategy의 구현체가 무엇인지 알지 못한다.
단지 무엇인지 모르는 RouteStrategy 에게 경로를 만들라는 메시지를 전달할 뿐이다.
RouteStrategy 의 buildRoute 는 출발지, 목적지를 받아서 경로의 체크 포인트들의 컬렉션을 리턴한다.
내비게이터의 책임은 지도에 체크 포인트들의 집합을 렌더링하는 것이다.
어떤 경로 구축 알고리즘을 사용할 지는 주요한 책임이 아니다. 즉, 어떤 경로 구축 알고리즘이 되었는지는 몰라도 된다!
그래서 Navigator 가 구현체인 RoadStrategy, WalkingStrategy, PublicTransportStrategy 에 대해서는 의존성을 갖지 않고, 그 인터페이스인 RouteStrategy 에만 의존성을 가질 수 있다.
Strategy 패턴 구조
이제 위에서 본 Strategy 패턴의 해결은 일반화하여 구조를 알아보자.
위 그림에서는 Context 에 strategy 를 외부에서 setStrategy 메서드를 사용하여 주입해주고 있다.
물론 상황에 따라 setter 가 아닌 생성자를 사용해서 주입할 수도 있다.
이제 글 초반에 이해하기 어려웠던 전략 패턴 설명이 이해가 될 것이다.
전략 패턴은 알고리즘들의 패밀리를 정의하고, 각 패밀리를 별도의 클래스에 넣은 후 그들의 객체들을 상호교환할 수 있도록 하는 행동 디자인 패턴입니다.
추가로 이 글 에서 전자상거래 앱의 결제 메서드에 관련하여 전략 패턴을 사용한 예시 자바 코드를 볼 수 있다.
자동차 경주 미션 중 Strategy 패턴 적용
먼저 미션은 이 깃허브 레포지토리 에서 볼 수 있다.
(사실 이는 프리코스 레포지토리이기는 한데 큰 영향은 없다. 기능 요구사항이 더 잘 명시되어 있다.)
step 1(페어 프로그래밍)에서의 구현
미션에서는 자동차가 랜덤 값에 따라 전진하므로, 1차 구현 시에 Car 클래스를 아래처럼 만들었다.
data class Car(val name: String, val numberGenerator: NumberGenerator) {
var position: Int = 0
private set
// ...
fun moveIfPossible() {
if (canMove()) position++
}
private fun canMove(): Boolean = numberGenerator.generate() >= MOVE_LOWER_BOUND
// ...
}
여기서 NumberGenerator 는 숫자를 생성하는 역할의 인터페이스이다.
interface NumberGenerator {
fun generate(): Int
}
// 프로덕션에 사용(랜덤으로 수 생성)
object RandomNumberGenerator : NumberGenerator {
override fun generate() = Random.nextInt(UPPER_BOUND)
}
// 테스트에 사용(생성자로 넘긴 수 생성)
class ExplicitNumberGenerator(private val input: Int): NumberGenerator {
override fun generate() = input
}
랜덤 수를 생성하므로 위처럼 NumberGenerator 라는 인터페이스를 만들고 프로덕션 코드에서는 RandomNumberGenerator 를, 테스트 코드에서는 ExplicitNumberGenerator 를 사용하도록 했다.
Car 의 인스턴스를 생성하는 클라이언트 코드에서 NumberGenerator 의 구현체를 생성자의 파라미터로 주입 하는 구조이다.
여기서도 전략 패턴의 사용을 볼 수 있다.
그림으로 그리면 아래와 같다.
이미 페어 프로그래밍에서 전략 패턴을 사용했던 것이다!
이 글을 읽는 사람들 중, 전략 패턴을 잘 몰랐던 사람들도 이미 전략 패턴을 사용해보았을 가능성이 높다.
그렇다면, 리뷰어께서 남긴 '본래 step2 에서 전략 패턴을 피드백 드리려고 했는데' 는 어디 부분일까?
위 코드에서 전략패턴을 사용하여 객체를 더 분리할 수 있다.
step 2(리팩토링)에서의 구현
나는 전략 패턴에 대해서 자세히 알지 못했다고 했었다.
나는 step 2 에서 특정한 디자인 패턴을 사용해야 겠다는 생각을 한 것이 아닌, 객체의 책임을 분리해야 겠다는 의도로 리팩토링을 시작했었다.
data class Car(val name: String, val numberGenerator: NumberGenerator) {
var position: Int = 0
private set
// ...
fun moveIfPossible() {
if (canMove()) position++
}
private fun canMove(): Boolean = numberGenerator.generate() >= MOVE_LOWER_BOUND
// ...
}
나는 위 Car 클래스에서 불편함을 느꼈다.
현재는 Car 가 생성자로 NumberGenerator 를 가지고 있다. 과연 Car 가 NumberGenerator 를 알고 있어야 할까?
나는 아니라고 생각했다. 자동차는 전진 혹은 멈춤만 하는 것이지, 어떤 조건에서 움직일지를 결정하지 않아도 된다고 생각이 들었다.그래서 조건을 판별해주는 책임을 다른 객체로 분리하기로 했다. (메서드로 치면 canMove 였다.)
객체를 적절히 분리한 결과는 아래와 같다.
data class Car(val name: String) {
var position: Int = DEFAULT_POSITION
private set
fun move() {
position++
}
// ...
}
// 전진 전략에 대한 책임을 가짐
interface MoveStrategy {
fun move(car: Car)
}
// 숫자를 사용하여 전진 전략을 판단함 (프로덕션에서 사용)
class MoveStrategyUsingNumber(private val numberGenerator: NumberGenerator) : MoveStrategy {
override fun move(car: Car) {
if (numberGenerator.generate() >= MOVE_LOWER_BOUND) {
car.move()
}
}
// ...
}
// 전진/멈춤에 대해 명시적으로 결정함 (테스트에서 사용)
class MoveStrategyIsAlways(private val boolean: Boolean) : MoveStrategy {
override fun move(car: Car) {
if (boolean) {
car.move()
}
}
}
interface NumberGenerator {
fun generate(): Int
}
// 프로덕션에 사용(랜덤으로 수 생성)
object RandomNumberGenerator : NumberGenerator {
override fun generate() = Random.nextInt(UPPER_BOUND)
}
DI 를 수행하는 클라이언트 코드 `Application.kt`
fun main() {
RacingGame(
InputView(),
OutputView(),
MoveStrategyUsingNumber(RandomNumberGenerator),
).start()
}
디자인 패턴에 대한 생각
반복해서 말하지만 나는 전략 패턴에 대해 잘 알지 못했다.
이것을 계속해서 강조하는 이유는 여기에 서술한 디자인 패턴에 대한 고찰 때문이다.
디자인 패턴에 대한 정의를 먼저 알아보자.
디자인 패턴은 소프트웨어 개발에서 자주 발생하는 문제들을 해결하기 위한 재사용 가능한 솔루션입니다.
이러한 패턴들은 공통된 설계 문제에 대한 해결책을 제공하여 코드의 유연성, 확장성 및 유지 보수성을 향상시킵니다.
디자인 패턴은 프로그래밍 언어나 플랫폼에 독립적이며, 주로 객체지향 소프트웨어 개발에 적용됩니다.
디자인 패턴은 결국 선배 개발자들이 계속해서 마주친 문제들의 객체 지향적인 해결 방법을 노하우로 정리해 놓은 것이다.
즉, 객체 지향 프로그래밍 패러다임을 잘 이해하면, 디자인 패턴을 알지 못해도 어느 순간, 디자인 패턴을 적용하고 있을 수도 있다는 것이다.
디자인 패턴 적용의 어려움?
정형와된 디자인 패턴을 암기해서 나중에 내가 처한 문제 상황에서 그 패턴을 적용하려고 해도, 내가 처한 현재 상황이 디자인 패턴을 적용하여 해결해도 되는 문제인지를 알기 쉽지 않다.
하지만 시험보듯이 패턴을 암기하는 것보다, 객체 지향적으로 생각하는 능력과 습관을 가지게 된다면, 필요한 디자인 패턴을 필요한 상황에 적용할 수 있을 것이다.
그래서 나는 객체지향 프로그래밍 패러다임을 정확히 이해하고 객체지향적인 설계를 많이 연습한 후에, 디자인 패턴을 공부하는 것이 순서에 맞다고 생각한다.
관련해서 조영호 저자의 객체지향 관련 서적 '객체지향의 사실과 오해' 와 '오브젝트: 코드로 이해하는 객체 지향 프로그래밍' 을 추천하고 글을 마무리 하겠다.
출처:
https://refactoring.guru/ko/design-patterns/strategy
https://refactoring.guru/ko/design-patterns/strategy/java/example#example-0