스타트업 특성상 소비자 반응을 확인하고자 서비스 앱 메인 페이지의 섹션 및 구성 요소를 자주 변경해야 하는 요구사항이 발생했습니다.
하나의 엔드포인트에서 여러 비즈니스 로직을 조합하여 응답하는 방식으로 구현되어 있었습니다.
이는 코드의 복잡도를 증가시키고 유지보수를 어렵게 만드는 원인이 되었습니다.
아래와 같은 문제들로 인해 앱 메인 코드 로직의 복잡도는 높으며 객체 지향 원칙을 위배하고 있어 저 포함 동료 개발자들은 로직을 수정할 때 불안한 마음을 갖고 있었습니다.
700라인에 육박하는 절차지향적 코드가 존재합니다.
대략적인 코드 품질을 확인할 수 있는 CodeMetrics 플러그인 기준으로 저희 앱 메인 페이지의 코드 복잡도는 65 Bloody hell..입니다. 😭
다양한 비즈니스 로직이 하나의 객체에 집중되어 있어 코드가 복잡하고 가독성이 떨어졌습니다.
신규 섹션 추가나 변경 시 기존 코드의 많은 부분을 수정하거나 코드 탐색에 오랜 시간이 걸렸습니다.
또한, 사이드 이펙트는 없는지 불안함이 동반되었습니다.
public class AppMainService {
for(MainPageItem item : mainPageIteams()) {
swich(type) {
case ASection:
if() {
...
} else {
...
}
break;
case BSection:
if() {
...
} else if() {
...
} else if() {
...
} else {
...
}
break;
...
}
}
}
위 코드를 확인해보시면 AppMainService 객체는 너무 많은 책임을 갖고 있어 특정 섹션 수정, 추가 요구사항이 발생하면 어떠한 파급 효과가 발생할지 예상되지 않을 뿐더라 유지보수, 확장성은 떨어지고 코드의 복잡도는 높아지기 마련입니다.
새로운 섹션을 추가할 때마다 기존 로직을 수정해야 하므로 확장성이 제한되었습니다.
비즈니스 로직이 한 곳에 집중되어 있어 단위 테스트 작성에 어려움을 겪었습니다.
우선 하나의 엔드포인트에서 데이터를 조합하여 클라이언트에게 응답을 제공하는 설계는 변경하지 않았습니다.(성능 개선이 아닌 구조 개선이었기에)
구조를 개선하기 위해서 전략 패턴을 적극적으로 이용하였습니다.
특정 타입에 맞는 전략을 선택 후 실행하는전략 패턴
을 적용하였습니다.
코드 구조를 개선하면서 유지보수성과 코드 가독성이 높아진 것을 확인할 수 있습니다.
또한, 새로운 요구사항이 발생하여도 SRP 원칙과 OCP 원칙을 준수할 수 있게 되었습니다.
새로운 SectionStrategy 구현체를 추가할 때 기존 코드를 수정할 필요 없이, 단순히 새로운 구현체를 추가하고 DI 컨테이너에 등록하기만 하면 됩니다.
즉, 시스템의 확장성을 크게 향상시킬 수 있게 되었습니다.
public class AppMainService {
private final List<SectionStrategy<SectionType>> sectionStrategies;
public SectionResponse retrieveSection() {
List<SectionData<?>> sectionResponseList = new ArrayList<>();
SectionComponent sections = sectionCacheService.getSectionComponents();
for(SectionComponent component : sections) {
// 요청 객체 생성
SectionRequest request = SectionReqeust.of();
// type에 해당하는 strategy 조회
SectionStrategy<SectionType> sectionStrategy = findStrategy(SectionType.valueOfType(request.getType()))
if(sectionStrategy == UNDEFINED) {
return;
}
// 섹션 조회 및 조합
sectionStrategy.execute(request, sectionResponseList)
}
}
private SectionStrategy<SectionType> findMyHomeStrategy(MyHomeSectionType type) {
return sectionStrategies.stream()
.filter(myHomeStrategy -> myHomeStrategy.isMatchingRole(type))
.findFirst()
.orElse(UNDEFINED);
}
}
개별 전략 알고리즘에서는 공용 인터페이스에 정의된 메소드를 구현하고 있습니다.
isMatchingRole()
: 자신의 전략에 해당하는지 확인합니다.execute()
: 비즈니스 로직을 수행합니다.public class ASectionStrategy implements SectionStrategy<SectionType> {
private final AService aService;
@Override
public boolean isMatchingRole(SectionType type) {
return ASectionType == type;
}
@Override
public void execute(SectionRequest request, List<SectionData<?>> sectionResponseList) {
SectionData<AResponse> sectionData = createSectionData(new SectionData(), request);
// execute business logic
...
sectionResponseList.add(sectionData);
}
@Override
public <T> MenuSectionData<T> createSectionData(T t, MenuSectionRequest request) {
return new MenuSectionData<>(request.getMenuSection());
}
}
전략 알고리즘 인터페이스에는 함수들이 정의되어있습니다.
createSectionData()
함수를 오버로딩을 통해 구현하였습니다.public interface MenuSectionStrategy<E1 extends Enum<E1>> {
boolean isMatchingRole(E1 type);
void execute(SectionRequest request, List<SectionData<?>> sectionList);
/**
* 섹션의 기본 데이터를 생성합니다.
*
* @param t
* @param request
* @param <T>
* @return
*/
<T> SectionData<T> createSectionData(T t, SectionRequest request) {
return new SectionData<>(request.getSection());
}
/**
* 기본 데이터 생성과, Supplier 로 제공 받은 함수를 수행하여 데이터까지 세팅합니다.
*
* @param request
* @param dataFactory
* @param <T>
* @return
*/
<T> SectionData<T> createSectionData(SectionRequest request, Supplier<T> dataFactory) {
SectionData<T> sectionData = new SectionData<>(request.getSection());
T data = dataFactory.get();
if (data == null || (data instanceof Collection && ((Collection<?>) data).isEmpty())) {
return sectionData;
}
sectionData.setData(data);
return sectionData;
}
deafult AMethod() {
...
}
deafult BMethod() {
...
}
}
이번 구조 개선을 통해 절자지향적으로 작성된 하나의 거대한 객체를 전략 패턴을 이용해 OOP 원칙을 준수할 수 있게 되었고, 동료 개발자들은 조금 더 유지보수하기 용이해졌습니다.