Logochar-yb

소프트웨어 디자인도 전략적으로, 전략 패턴에 대해

전략 패턴에 대해 알아보며 예시를 통해 어떻게 적용할 수 있는지 알아보았습니다.

서론

이번에는 전략 패턴에 대해 이야기해 보려고 합니다. 이전 DynamoDB 관련하여 현 회사에서 촬영 도메인이 존재하는데, 해당 촬영 도메인에서 저희는 AI 영상 분석을 위한 람다 호출을 진행합니다.

그러나 사용자의 촬영 종료에 의한 람다 호출인지, 이전에 진행했던 분석이 사용자에게는 원치 않는 결과가 제공되어 재검수를 요청한 것인지, 그에 따른 조건에 따라 분석 알고리즘이 결정되어 요청되는데요, 이 알고리즘을 결정하는 요소는 서버 애플리케이션에서 결정을 하고 해당 전략에 대해서 여러 분기 처리를 진행하기에는 유지보수 측에서 어려운 점을 마주했었습니다. 각 알고리즘마다 임계 정확도가 다르기에 임계값 변경이 이뤄지면 여러 클래스에서도 변동사항이 생겨 어려운 점이 있었습니다.

그래서 이 점을 보다 완화시키고자 디자인 패턴 중 전략 패턴을 도입하는 것입니다.


전략 패턴이란?

실행(런타임) 중에 알고리즘 전략을 선택하여 객체 동작을 실시간으로 바뀌도록 할 수 있게 하는 행위 디자인 패턴입니다. 전략이라는 것이 앞서 이야기한 알고리즘이 될 수도 있고, 어떠한 Type이 되어 기능이나 동작이 될 수도 있는 특정 목표 수행 계획을 의미합니다.

어떤 컴포넌트가 비즈니스 로직을 수행하는 데에 있어서 알고리즘 or Type 즉, 전략이 여러 가지일 때, 이러한 것들을 일련의 전략으로 정의하고 손쉽게 교체할 수 있는 디자인 패턴이 전략 패턴입니다.

컨텍스트는 원래 특정 데이터를 구분하기 위해 참조를 저장하는 필드를 포함해야 하지만, 직접 실행하는 대신 추상화된 전략에 위임합니다.

컨텍스트는 작업에 적합한 알고리즘을 선택할 책임이 없습니다. 대신 클라이언트가 원하는 전략을 컨텍스트에 전달합니다. 사실, 컨텍스트는 전략들에 대해 많이 알지 못합니다. 컨텍스트는 같은 일반 인터페이스(추상화)를 통해 모든 전략과 함께 작동하며, 이 일반 인터페이스는 선택된 전략 내에 캡슐화된 알고리즘을 작동시킬 단일 메서드만 노출합니다.

이렇게 하면 컨텍스트가 구상 전략들에 의존하지 않게 되므로 컨텍스트 또는 다른 전략들의 코드를 변경하지 않고도 새 알고리즘들을 추가하거나 기존 알고리즘들을 수정할 수 있습니다.

위 그림과 같은 구조로 전략 패턴은 여러 OOP의 집합체로 볼 수 있는데, 이 구조에 맞춰 코드를 완성하기 위한 순서를 정리해 보겠습니다.

순서 정의

  1. 동일 계열의 알고리즘 군을 정의
  2. 각각의 알고리즘을 캡슐화
  3. 상호 교환이 가능하도록 운용
  4. 알고리즘을 사용하는 클라이언트와 의존성이 없도록 독립성 유지
  5. 알고리즘을 다양하게 변형이 가능하도록

전략 패턴의 흐름

전략 패턴을 활용하여 촬영 분석 프로세스를 구현하였는데요, 전략 패턴을 적용하여 다양한 분석 알고리즘을 동적으로 선택할 수 있어 유지보수성과 확장성이 높아집니다. 이번에는 정의한 순서에 따라 구성도와 예시 코드를 통해 전략 패턴에 좀 더 와닿도록 이야기 해보겠습니다.

전략 패턴은 행동(전략)을 인터페이스로 정의하고, 이를 구현하는 여러 클래스를 만들어 실행 시점에서 적절한 전략을 선택할 수 있도록 하는 디자인 패턴입니다.

예를 들어, 촬영 데이터를 분석하는 방식이 여러 개 존재한다고 가정해 보겠습니다.

예시 알고리즘 SCENE (장면 분석) ACTION (액션 분석) FACE (얼굴 분석)

각각의 분석 방식이 다르기 때문에 하나의 클래스에서 모든 로직을 처리하는 것은 유지보수성이 떨어집니다. 따라서, 각 분석 방식마다 별도의 클래스를 생성하고, 전략 패턴을 적용하면 보다 유연한 설계가 가능합니다.

@RestController
@RequestMapping("/filming")
class FilmingController(
    private val filmingService: FilmingService,
){
    @PostMapping("/analysis")
    fun analysis(
        @RequestParam("algorithm") algorithm: String,
    ): String = filmingService.analysisFilming(FilmingAnalysisRequest.to(algorithm))
}
@Service
class FilmingService(
    private val filmingProcessor: FilmingProcessor,
) {
    fun analysisFilming(request: FilmingAnalysisRequest): String =
        filmingProcessor.analysisProcess(request)
}

우선 앞에서 이야기한 촬영에 관한 예시를 설명으로 FilmingController, FilmingService와 같은 기본적인 레이어 코드를 구성을 먼저 해보았습니다.

@Component
class FilmingProcessor(
    private val filmingRepository: FilmingRepository,
    private val filmingPolicyCondition: MutableList<FilmingPolicyCondition>,
){
    fun analysisProcess(request: FilmingAnalysisRequest): String {
        val filmings = filmingRepository.findAllById(request.algorithm)
 
        // 전략 패턴 사용
        val analyzedUseAlgorithm = routeFilmingAnalysisStrategy(request.algorithm)
            .analysisAlgorithm(filmings)
 
        return "$analyzedUseAlgorithm analysis completed"
    }
 
    // 전략 라우팅
    private fun routeFilmingAnalysisStrategy(
        algorithm: FilmingAlgorithm
    ): FilmingPolicyCondition {
        return filmingPolicyCondition.firstOrNull {
            it.support(algorithm)
        } ?: throw IllegalArgumentException("Unsupported algorithm")
    }
}

이후 FilmingProcessor라는 촬영할 알고리즘을 요청받아 처리하는 컴포넌트를 선언하여 알고리즘에 따른 전략 패턴을 적용했습니다.

FilmingProcessor 코드 설명

  1. filmingRepository.findAllById(request.algorithm): 요청된 알고리즘(request.algorithm)에 해당하는 Filming 데이터를 조회합니다.
  2. routeFilmingAnalysisStrategy(request.algorithm): 요청된 알고리즘에 맞는 전략을 찾아 실행합니다. 등록된 전략 리스트 중 support() 메서드를 통해 적절한 전략을 선택합니다.
  3. analysisAlgorithm(filmings): 선택된 전략(Policy)의 analysisAlgorithm() 메서드를 실행하여 분석을 수행합니다.

위 코드를 보면서 private val filmingPolicyCondition: MutableList<FilmingPolicyCondition> 구문에서 FilmingPolicyCondition는 어떻게 구성되어 있느냐? 각 분석 방식을 전략 패턴으로 구현하기 위해 인터페이스를 생성합니다.

interface FilmingPolicyCondition {
    fun support(filmingAlgorithm: FilmingAlgorithm): Boolean
 
    fun analysisAlgorithm(
        filmings: List<Filming>,
    ): String
}

FilmingPolicyCondition 코드 설명

  1. support(filmingAlgorithm: FilmingAlgorithm): 특정 FilmingAlgorithm을 지원하는지 여부를 반환합니다.
  2. analysisAlgorithm(filmings: List<Filming>): 선택된 알고리즘을 적용하여 촬영 데이터를 분석합니다.

그렇다면, 해당 PolicyCondition이 정책에 따른 인터페이스일 것이고, 그에 대한 구현체는 요구사항에 따라 적합하게 구현하면 되겠습니다.

@Component
class FilmingAnalysisActionPolicyCondition: FilmingPolicyCondition {
    override fun support(filmingAlgorithm: FilmingAlgorithm): Boolean {
        return filmingAlgorithm == FilmingAlgorithm.ACTION
    }
 
    override fun analysisAlgorithm(
        filmings: List<Filming>,
    ): String {
        // ... 요구사항 구현
    }
 
    private fun verifyAlgorithmAction(it: Filming) {
        if (it.algorithm != FilmingAlgorithm.ACTION) {
            throw IllegalArgumentException("Algorithm is not Action")
        }
    }
 
    private fun verifyAlgorithmFrameUrl(it: Filming) {
        if (!it.frameUrl.contains(it.algorithm.name.lowercase())) {
            throw IllegalArgumentException("Frame URL is unmatched Algorithm")
        }
    }
}

코드 설명

  1. support(): 이 클래스는 FilmingAlgorithm.ACTION 타입만 지원되도록 처리합니다.
  2. analysisAlgorithm(): 분석 알고리즘 혹은 분석 처리 등등 요구사항에 적합하게 처리하면 되겠습니다.
  3. verifyAlgorithmAction(): 데이터의 알고리즘 값이 ACTION인지 확인합니다.
  4. verifyAlgorithmFrameUrl(): frameUrl이 올바르게 설정되었는지 검증합니다.

🔍 전략 패턴 적용의 장점

1, OCP (Open-Closed Principle) 원칙 준수 새로운 FilmingAlgorithm이 추가될 경우, 기존 코드를 수정하지 않고 새로운 FilmingPolicyCondition 클래스만 추가하면 됩니다. 2. 유지보수성 향상 FilmingProcessor 내부에서 직접 분석 로직을 처리하지 않기 때문에 코드가 깔끔하게 유지됩니다. 3. 유연한 확장 가능 SCENE, ACTION, FACE 등 추가적인 분석 알고리즘이 필요할 경우 새로운 클래스를 추가하여 쉽게 확장할 수 있습니다.

현재는 SCENE, ACTION, FACE 세 가지 알고리즘만 추가했지만, 이후 새로운 알고리즘이 필요할 경우 기존 코드를 수정하지 않고 쉽게 확장할 수 있습니다.

전략 패턴은 객체 내에서 한 알고리즘의 다양한 변형들을 사용하고 싶을 때, 그리고 런타임 중에 한 알고리즘에서 다른 알고리즘으로 전환하고 싶을 때 사용하는 게 유용합니다, 또 전략 패턴은 객체의 행동들을 특정 하위 행동들을 다양한 방식으로 수행할 수 있는 다른 하위 객체들과 연관시켜 객체의 행동들을 런타임에 간접적으로 변경할 수 있게 해줍니다.

전략 패턴은 일부 행동을 실행하는 방식에서만 차이가 있는 유사한 클래스들이 많은 경우, 이번 포스팅에서 예시로 들었던 알고리즘 혹은 타입 등등에 사용하면 유용할 듯합니다!

전략 패턴은 코드의 나머지 부분에서 해당 코드, 내부 데이터, 그리고 다양한 알고리즘들의 의존 관계들을 고립시킬 수 있습니다. 다양한 클라이언트들이 알고리즘들을 실행하고 런타임에 전환하기 위한 간단한 인터페이스를 구성할 수 있습니다.


🚀 간단한 확장 예시: FACE 분석 정책 추가

새로운 분석 알고리즘 FACE 분석 을 추가한다고 가정해 보겠습니다. 단순히 새로운 FilmingPolicyCondition 구현체를 추가하면 됩니다.

@Component
class FilmingAnalysisFacePolicyCondition: FilmingPolicyCondition {
    override fun support(filmingAlgorithm: FilmingAlgorithm): Boolean {
        return filmingAlgorithm == FilmingAlgorithm.FACE
    }
 
    override fun analysisAlgorithm(
        filmings: List<Filming>,
    ): String {
        return "Face"
    }
}

기존 FilmingProcessor 코드는 수정할 필요 없이, 새로운 정책 클래스만 추가하면 FACE 분석 이 자동으로 동작하게 됩니다.


그럼 Enum Type마다 전략 패턴을 사용해 구현해?

지금까지 전략 패턴(Strategy Pattern)의 장점 에 대해서 이야기했지만, 어떤 패턴이든 단점이 존재합니다. 전략 패턴이 항상 최선의 선택은 아닙니다. 오히려 잘못 사용하면 코드의 복잡도가 증가하고 유지보수가 어려워질 수도 있습니다. 그렇다면, 전략 패턴을 적용할 때 고려해야 할 단점과 적절한 사용 타이밍에 대해서 살펴보겠습니다.

1. 불필요한 코드 증가 전략 패턴을 적용하려면 기본적으로 인터페이스 + 여러 개의 구현 클래스를 만들어야 합니다. 만약 분석 알고리즘이 많지 않다면, 굳이 인터페이스와 클래스를 분리하는 것이 오히려 코드 복잡도를 증가시킬 수 있습니다.

예를 들어, FilmingAlgorithm 이 2~3개밖에 되지 않고 앞으로도 크게 변경될 일이 없다면, 전략 패턴을 사용하지 않고 단순한 when 문으로 처리하는 것이 더 간결할 수도 있습니다.

fun analysisProcess(request: FilmingAnalysisRequest): String {
    val filmings = filmingRepository.findAllById(request.algorithm)
 
    return when (request.algorithm) {
        FilmingAlgorithm.ACTION -> analyzeAction(filmings)
        FilmingAlgorithm.SCENE -> analyzeScene(filmings)
        FilmingAlgorithm.FACE -> analyzeFace(filmings)
    }
}

위처럼 간단한 분기 처리만으로 충분할 경우, 굳이 전략 패턴을 사용할 필요가 없습니다. 즉, 패턴을 위한 패턴은 피해야 한다는 것입니다!

2. 전략 패턴을 이해하는 학습 비용 전략 패턴을 사용하면 클래스가 분리되면서 코드의 흐름을 한눈에 파악하기 어려울 수 있습니다. 즉, 개발자들이 기존 코드를 유지보수하려면 어떤 전략이 사용되는지 먼저 파악해야 하는 학습 비용이 발생할 수 있습니다.

이 점에서는 저도 맨처음 간단하게 추상화하여 구현체를 구성했구나라고 생각했었다만, enum에 따른 전략적 설계가 보였습니다.

val analyzedUseAlgorithm = routeFilmingAnalysisStrategy(request.algorithm)
    .analysisAlgorithm(filmings)

위 코드만 보면, 실제로 어떤 분석 로직이 수행되는지 바로 이해하기 어렵습니다. 해당 메서드가 어떤 전략을 선택하는지 확인하기 위해 인터페이스와 여러 클래스를 찾아가야 하는 번거로움이 있습니다.

3. 전략 간 차이를 이해해야 하는 부담 클라이언트 코드 (FilmingProcessor) 가 적절한 전략을 사용하려면, 각 전략이 어떤 차이가 있는지 명확히 이해하고 있어야 합니다. 즉, 전략 패턴을 사용하면 적절한 전략을 선택하는 책임이 클라이언트 코드에 남게 된다는 점을 고려해야 합니다.

private fun routeFilmingAnalysisStrategy(
    algorithm: FilmingAlgorithm
): FilmingPolicyCondition {
    return filmingPolicyCondition.firstOrNull {
        it.support(algorithm)
    } ?: throw IllegalArgumentException("Unsupported algorithm")
}

위 코드에서는 FilmingAlgorithm에 맞는 전략을 찾아야 하는데, 새로운 알고리즘이 추가될 경우 클라이언트 코드가 이 변경 사항을 알고 있어야 하는 부담 이 발생할 수 있습니다.


전략 패턴이 적절한 경우

그렇다면 객체 지향 프로그래밍에서 사용되는 디자인 패턴, 전략 패턴을 언제 적용하는 것이 좋을까요?

✅ 알고리즘이 자주 변경되거나 확장될 가능성이 있는 경우 전략 패턴을 사용하면 새로운 알고리즘을 추가할 때 기존 코드를 수정하지 않고 확장할 수 있습니다. 예를 들어, FilmingAlgorithm 에 새로운 유형이 추가될 가능성이 높다면 전략 패턴을 사용하는 것이 좋습니다.

✅ 여러 개의 비슷한 조건문이 중복되는 경우 여러 곳에서 when 문을 반복해서 사용해야 한다면, 전략 패턴을 통해 조건문을 제거하고 역할을 분리하면 코드의 가독성이 좋아집니다.

✅ 실행 중에 알고리즘을 동적으로 변경해야 하는 경우 전략 패턴을 사용하면 동적으로 실행 전략을 변경 할 수 있습니다. 예를 들어, 사용자의 선택에 따라 분석 방식을 변경 해야 한다면 전략 패턴이 적절한 해결책이 될 수 있습니다.

이처럼 전략 패턴을 적용하기 전에 고려할 사항으로는 알고리즘이 자주 변경될 가능성이 있는지, 만약 변경될 가능성이 높다면 전략 패턴이 적합하며, 분기 처리 시 if-else, when 같은 문법이 효율적일 지, 새로운 전략이 추가될 때 기존 코드를 수정하지 않고 확장 가능한 지 여부를 개인 혹은 팀원들과 상의해서 좋은 코드 스멜이 나도록 개발할 수 있을 거 같습니다.

이번 글에서는 전략 패턴을 활용하여 요구사항에 맞춰 유연하게 설계하는 방법을 살펴보았습니다. 처음에는 저도 생소했지만, 사내에서 왜 이 패턴을 사용했는지, 이를 통해 어떤 장점을 얻을 수 있는지 고민하며 정리해 보았습니다:)


실습한 Github Repo

참고