최근에 토이 프로젝트를 진행하던 중, 아래와 같은 형태의 코드를 작성하게 되었습니다.
위 코드의 핵심 문제는 확장성 부족입니다. 새로운 엘릭서 타입이 추가할 때 마다 convert
메서드에 조건문을 추가해야 합니다.
이를테면 아래와 같습니다.
이러한 접근 방식은 객체지향의 개방-폐쇄 원칙(OCP)을 위반하고 코드의 복잡성을 증가시킵니다.
지금은 큰 문제가 되지 않겠지만 if/else 문이 수십-수백 개로 늘어난다면, 특정 타입을 수정하거나 찾는 일이 매우 어려워질 것입니다.
이번 포스팅에서는 간단한 예시 서비스를 리팩토링 하며 제가 토이 프로젝트에서 이 문제를 해결한 방법을 공유하고자 합니다.
모든 코드는 GitHub에서 확인하실 수 있습니다.
리그 오브 레전드라는 유명한 게임이 있습니다. 이 게임에는 140명이 넘는 챔피언이 존재하는데요.
이제부터 저는 이 챔피언들의 스킬 데미지를 계산하는 백엔드 개발자라고 가정해 보겠습니다.
클라이언트로부터 다음과 같은 요청을 받습니다.
{
"champion": "이즈리얼",
"attack": 400,
"defense": 50
}
서버는 다음과 같이 응답합니다.
{
"damage": 350.0
}
이제 구현을 해보겠습니다.
클라이언트 요청을 나타내는 ChampionRequest
:
서버의 응답을 나타내는 ChampionResponse
:
이제 스킬 데미지를 계산하는 핵심 비즈니스 로직을 구현할 차례입니다.
DamageCalculator
클래스를 정의하고, 비즈니스 로직을 구현한 뒤 ChampionResponse 객체를 반환합니다.
시간이 지나 새로운 챔피언 2개의 요청도 처리해야 한다면 어떻게 해야 될까요?
{
"champion": "레넥톤",
"attack": 300,
"defense": 200
}
{
"champion": "피즈",
"attack": 350,
"defense": 100
}
아래와 같이 if/else 문을 계속 반복하게 됩니다.
damage를 계산하는 로직은 단순하게 구현 했지만 무언가 복잡한 로직이 들어가는구나 정도로 이해해 주시면 감사하겠습니다.
리그 오브 레전드에는 140명이 넘는 챔피언이 있습니다.
그렇다면 요청사항이 추가될 때 마다 if/else 문을 140개 추가하는 것은 무언가 문제가 있어 보입니다.
이즈리얼, 레넥톤, 피즈에 대한 요청은 챔피언 이름에 따라 데미지 계산 로직만 다릅니다.
즉, 런타임 시 적절한 데미지 계산 로직을 선택할 수 있다면 if/else 문을 사용하지 않아도 되는데요.
이는 전략 패턴을 사용하기 좋은 상황입니다
말로하면 무슨 말인지 전혀 와닿지 않으므로 코드로 알아보겠습니다.
먼저, 데미지 계산 전략을 담당하는 인터페이스를 생성합니다.
이제 이즈리얼, 레넥톤, 피즈에 대한 전략을 구현합니다.
이제 이 전략들을, DamageCalculator에 등록하여 사용해 봅시다.
핵심은 전략을 등록한 부분입니다.
각 챔피언별 데미지 계산 로직은
해당 전략 클래스에 있으므로, Map에서 꺼내 사용하기만 하면 됩니다.
코드는 아래처럼 리팩터링 됩니다.
이제 시간이 흘러 아래의 챔피언 요청도 처리해야 한다면 어떻게 해야 될까요?
{
"champion": "리신",
"attack": 500,
"defense": 200
}
챔피언 리신에 대한 데미지 계산 전략을 정의한 뒤, 전략을 등록해 주면 됩니다.
getDamage
메서드는 건들일 필요가 없게 되었습니다.
if/else를 사용하던 방식보다는 확장성이 좋아진 것 같지만 이 방법도 역시 문제가 있습니다.
새로운 요청 사항이 추가될 때 마다 전략을 등록하기 위해 DamageCalaculator를 변경하고 있습니다.
새로운 전략을 등록할 때 마다 외부의 무언가가 자동으로 DamageCalaculator에도 전략을 등록해주면 좋겠습니다.
새 전략을 자동으로 DamageCalculator에 등록하려면 스프링 프레임워크의 의존성 주입 기능을 사용하면 됩니다.
그러기 위해선 먼저 스프링 컨테이너가 관리하는 빈으로 등록을 해야 합니다.
먼저, 전략들을 스프링 컨테이너가 관리하는 빈(Bean)으로 등록합니다.
@Component 어노테이션을 각 전략에 추가함으로써, 컴포넌트 스캔의 대상이 되고 스프링 빈으로 자동 등록됩니다.
스프링은 타입에 따라 빈을 주입합니다.
List<DamageCalculatorStrategy>
타입으로 선언하면 DamageCalculatorStrategy
인터페이스를 구현하는 모든 빈이 리스트 형태로 주입되는데요. 바로 아래의 네 가지 전략이 주입됩니다.
간단한 테스트로 알아보겠습니다.
이제 이를 이용하여 기존의 코드를 리팩터링 해보겠습니다.
Map의 경우에는 키를 이용하여 전략을 구분할 수 있었지만, List의 경우 키가 없으므로, 각 전략을 구분하기 위해 supports
메서드를 추가하였습니다. 이 메서드는 특정 챔피언을 지원하는지 여부를 판단하는 데 사용됩니다.
인터페이스가 변경됨에 따라, 전략들도 아래와 같이 수정됩니다.
최종적으로 리팩터링이 완료된 DamageCalculator 코드는 아래와 같습니다.
이제 아래와 같은 새로운 요청이 들어온다면 어떻게 하면 될까요?
{
"champion": "가렌",
"attack": 500,
"defense": 200
}
이제 새로운 챔피언이 추가될 때, 해당 챔피언의 전략 클래스만 만들고 스프링 빈으로 등록하면 됩니다. 이렇게 하면 기존의 DamageCalculator 코드를 수정할 필요가 없어져, 코드의 유지보수가 훨씬 쉬워졌습니다.
개발을 하다 보면, 기획이나 요구 사항의 변화에 빠르게 대응해야 하는 상황이 자주 발생합니다.
예를 들어,
기획과 연관된 부분이라 개발자 입장에서는 참 난처한 상황인데요.
이럴 때, 전략패턴과 의존성 주입을 활용하여 확장성 있는 코드를 설계한 뒤 변화가 필요한 부분만 변경한다면 조금 더 쉽고 빠르게 대처할 수 있지 않을까요?
이 글을 보시는 분들에게 조금이나마 도움이 되었으면 좋겠습니다. 감사합니다!