Skip to content

객체지향 리팩토링 가이드: 한글로 이해하는 SOLID 원칙과 디자인 패턴

Published:

개요

소프트웨어 개발에서 요구사항의 변화는 필연적으로 코드의 변경을 수반합니다. 이때 우리가 주목해야 할 중요한 점은 코드 수정이 필요한 지점들의 수입니다. 변경이 필요한 부분이 많아질수록 코드의 복잡도가 증가하고, 이는 자연스럽게 오류 발생 가능성도 높아지게 됩니다.

이 글에서는 제가 수학 교육 플랫폼 회사에서 경험한 실제 사례를 바탕으로 이야기를 풀어나가고자 합니다. 학생들의 수준과 학습 진도에 맞춘 맞춤형 문제 출제 시스템을 개발하면서 겪었던 코드 구조의 변화 과정을 공유하려고 합니다. 특히 교육 콘텐츠의 특성상 잦은 요구사항 변경이 있었고, 이를 효과적으로 대응하기 위한 리팩토링 과정이 매우 중요했습니다.

이 과정에서 특히 주목할 만한 점은 코드 변경이 필요한 지점이 하나인지 여러 개인지에 따라 발생하는 큰 차이입니다. 이는 단순한 수적 차이를 넘어서 코드의 유지보수성과 안정성에 직접적인 영향을 미치는 중요한 요소입니다. 실제 프로젝트에서 이러한 차이가 어떤 영향을 미치는지, 그리고 이를 어떻게 개선했는지를 함께 살펴보도록 하겠습니다.

코드를 한글로

아래 코드는 수학 문제 출제와 관련된 내용으로, 파라미터 중 레슨 타입과 커리큘럼 타입을 이용하여 문제를 생성하는 방식을 보여줍니다.

function getProblems(userId, curriculumType, lessonId, lessonType) {
    //...
    switch (lessonType) {
        case 'LESSON':
            //...
            break;
        case 'REVIEW':
            if (curriculumType === 'ELEMANTARY') {
                problems.push(writingProblemGenerate(lessonId));
                problems.push(writingProblemGenerate(lessonId));
                problems.push(writingProblemGenerate(lessonId));
                problems.push(mlProblemGenerate(lessonId));
                problems.push(graphProblemGenerate(lessonId));
                problems.push(graphProblemGenerate(lessonId));
                problems.push(randomProblemGenerate(lessonId));
                problems.push(randomProblemGenerate(lessonId));
            } else {
                //...
            }
        break;
    }
  
  return problems;
}

문제 생성과 관련하여 각 생성 함수의 구체적 내용은 다음과 같습니다:

이 코드를 한글로 표현하면 다음과 같습니다:

레슨타입이 리뷰이고 
초등 커리큘럼 일 때
서술형 3문제
머신러닝 1문제
그래프 2문제
랜덤문제 2문제 를 만들어

변화하는 부분, 변화하지 않는 부분

한글로 표현된 내용을 기준으로 다음과 같이 구분할 수 있습니다:

  1. 변화하는 부분: 레슨타입이 리뷰이고 초등 커리큘럼, 서술형 3문제 머신러닝 1문제 등 이러한 문장들은 내용이 추가되거나 삭제될 수 있습니다.

  2. 변화하지 않는 부분: “일 때”, “만들어”와 같은 연결어 이러한 부분은 추가되거나 삭제되는 상황이 발생하지 않습니다.

변화하는 부분을 자유롭게 수정할 수 있게 하려면, 변화하지 않는 부분을 별도의 공간으로 분리하여 연결해주는 것이 좋습니다:

레슨타입이 리뷰이고 
초등 커리큘럼            --------> 일 때

서술형 3문제
머신러닝 1문제
그래프 2개
랜덤 2개               --------> 만들어

이렇게 변화하지 않는 부분을 고정함으로써, 같은 개념에 대해서는 확장이 가능하면서도 다른 개념에 대해서는 닫혀있는 구조를 만들 수 있습니다.

이는 SOLID 원칙 중 OCP(Open-Closed Principle)를 따르는 것으로, 확장에는 열려있고 수정에는 닫혀있는 구조를 만드는 것입니다.

타입(유형) 나누기

여기서 주목할 점은 “레슨타입이 리뷰이고 초등 커리큘럼”과 “서술형 3문제 머신러닝 1문제”는 서로 다른 개념이라는 것입니다. 일때 앞부분은 조건을 나타내고, 만들어 앞부분은 문제출제 관련 내용을 나타냅니다.

이러한 분석을 통해 우리는 두 가지 독립적인 타입 계층이 존재함을 발견했습니다: 조건에 대한 타입과 문제생성 타입입니다. 이러한 발견은 단순한 구분 이상의 의미를 가집니다.

이는 SOLID 원칙 중 LSP(Liskov Substitution Principle)와 밀접하게 연관되어 있습니다. LSP는 subtyping이라는 개념을 통해 “타입의 계층 구조”를 만드는 것을 권장합니다. 예를 들어, 우리의 조건 타입에서는 ‘ReviewLessonTypeCondition’과 ‘ElementaryCurriculumCondition’이 모두 ‘Condition’이라는 상위 타입을 대체할 수 있어야 합니다. 마찬가지로 문제생성 타입에서도 ‘WritingProblemGenerator’, ‘MlProblemGenerator’ 등이 모두 ‘ProblemGenerator’라는 상위 타입을 대체할 수 있어야 합니다.

이러한 subtyping 구조는 나중에 보게 될 데코레이터 패턴이나 책임 연쇄 패턴을 적용할 때 핵심적인 기반이 됩니다. 각 타입들이 자신의 상위 타입을 완벽하게 대체할 수 있기 때문에, 우리는 이들을 유연하게 조합하고 확장할 수 있게 됩니다.

이렇게 해서 총 세 가지 관계를 나타내는 내용과 유형이 도출되었습니다:

  1. 일때, 만들어 (연결어)
  2. 레슨타입이 리뷰이고 초등 커리큘럼 (조건타입)
  3. 서술형 3문제 머신러닝 1문제… (문제생성타입)

한글에서 코드로

먼저 “일때, 만들어”를 다음과 같은 코드로 변환해보았습니다:

    if (~.isSatisfiedBy()) 
        ~.generate();

이는 “~조건이 만족될 때 ~문제를 생성한다”는 의미로 단순화할 수 있습니다.

조건타입에 해당하는 부분은 다음과 같이 구현할 수 있습니다:

    const condition1 = new ReviewLessonTypeCondition();
    const condition2 = new ElementaryCurriculumCondition();

이렇게 두 개의 타입으로 구현했을 때, 조건들이 두 개로 고정되어 있다면 다음과 같이 표현할 수 있습니다:

    if (condition1.isSatisfiedBy() && condition2.isSatisfiedBy())

하지만 조건의 수가 유동적일 수 있기 때문에, 데코레이터(decorator) 패턴을 활용하여 다음과 같이 구현하는 것이 더 유연합니다:

    new ReviewLessonTypeCondition(new ElementaryCurriculumCondition());

문제생성 타입에 해당하는 부분은 다음과 같이 구현할 수 있습니다:

    const writingProblemGenerator = new WritingProblemGenerator(3);
    const mlProblemGenerate = new MlProblemGenerator(1);
    const adaptiveProblemGenerator = new AdaptiveProblemGenerator(2);
    const randomProblemGenerator = new RandomProblemGenerator(2);

이러한 생성기들을 단순히 배열로 묶을 수도 있지만, 객체 간의 관계를 통해 책임을 더 명확하게 할 수 있습니다. 따라서 다음과 같이 책임 연쇄(Chain of Responsibility) 패턴을 적용했습니다:

    writingProblemGenerator.next(mlProblemGenerate);
    mlProblemGenerate.next(adaptiveProblemGenerator);
    adaptiveProblemGenerator.next(randomProblemGenerator);

마지막으로 클라이언트에서 호출하는 부분은 다음과 같이 구현되었습니다:

    problemAgency.addPolicy(
        new ProblemGeneratorPolicy(
            new ReviewLessonTypeCondition(
                new ElementaryCurriculumCondition()
            ),
            writingProblemGenerator
        )
    );

그리고 problemAgency 내부의 실행 로직은 다음과 같습니다:

    for (policy in policies) {
        if(policy.isSatisfiedBy(parameters) {
            policy.generate(parameters);
        }
    }

결론

이번 리팩토링 과정을 통해 우리는 복잡했던 문제 생성 로직을 더 체계적이고 유지보수하기 쉬운 구조로 개선했습니다. 처음에는 단순한 getProblems 함수 안에 모든 로직이 들어있었지만, 이제는 각각의 책임이 명확한 객체들로 분리되어 있습니다.

이러한 개선의 핵심은 ‘변경의 범위를 제한하는 것’에 있었습니다. 비즈니스 로직이 복잡해질수록 변경해야 할 부분을 정확히 파악하고 한정하는 것이 중요합니다. 특히 실무에서는 인프라나 프레임워크 같은 외부 요소들은 우리가 직접 제어하기 어렵기 때문에, 우리가 실제로 통제할 수 있는 비즈니스 로직에 집중하는 것이 현명한 전략입니다.

이 프로젝트를 통해 우리는 다음과 같은 중요한 교훈을 얻을 수 있었습니다:

  1. 코드의 책임을 명확히 분리하면 변경이 필요할 때 영향 범위를 최소화할 수 있습니다
  2. 객체 지향 원칙을 적절히 활용하면 코드의 유연성과 재사용성을 높일 수 있습니다
  3. 비즈니스 로직에 집중하여 변화율에 따른 코드 구조 조정이 가능합니다

앞으로도 코드를 작성할 때는 이러한 원칙들을 염두에 두고, 지속적으로 개선해 나가는 것이 좋겠습니다. 이를 통해 우리는 더 유지보수하기 쉽고, 확장 가능한 시스템을 만들어 나갈 수 있을 것입니다.


이전 글
디자인 패턴으로 실무 코드 개선하기: 수학 교육 플랫폼 리팩토링 경험기