지나가던 개발(zigae)

프론트엔드 클린코드 - Swich Case 회피

2022년 9월 13일 • ☕️ 6 min read

clean code

switch-case는 코드가 장황하고 반복되는 보일러 플레이트 코드가 많기 때문에 필자를 포함해 많은 개발자들의 싫어한다. 이는 동작에 문제가 있거나 어렵기 때문이 아니다.

문제는 해당 코드 블록의 읽기 위해 다른 코드 블록 읽기를 중단함으로써 스위칭 비용이 발생한다는 것이다. 간단한 switch-case 조차 20줄 정도는 쉽게 발생한다. 또한 개발자의 비즈니스 로직을 보여주는 데 있어서 switch-case는 매우 개방적이다. 이는 개발자가 switch-case를 사용할 때마다 비즈니스 로직을 더 추가할 수 있음을 의미한다. 곧 코드가 복잡해지고 유지 보수하기 어려워진다는 것을 의미하고, 모두에게 인지 부하가 발생한다.

필자는 swich-case를 개선하는 두 가지 함수에 대해 설명한다.

match 함수

많은 구문들이 불필요하거나 더 읽기 쉬운 방식으로 다시 작성할 수 있다.

반복되고 있는 break 문을 다음과 같은 코드는 switch 문에 대부분 필수적이다.

let color = "grey"
switch(tag) {
  case "warning":
    color = "red";
    break;
  case "success":
    color = "green";
    break;
  default:
    color = "grey";
}

코드를 함수로 감싸면 간단하게 해결할 수 있다.

const getTagColor = (tag) => {
  switch(tag) {
    case "warning":
      return "red";
    case "success":
      return "green";
    default:
      return "grey";
  }
};
const color = getTagColor(tag);

반복되는 break를 제거했다. 그러나 다음 문제가 여전히 존재한다.

  1. 여전히 반복되는 코드가 존재한다.
  2. 이 패턴을 적용할 때마다 switch 문을 위해 함수로 감싸는 작업이 요구된다.

개발자는 명령형 코드(switch) 보다 선언적인 코드를 원한다. 바로 살펴보자.

const matched = x => ({
  on: () => matched(x),
  otherwise: () => x,
})
const match = x => ({  
  on: (pred, fn) => (pred(x) ? matched(fn(x)) : match(x)),
  otherwise: fn => fn(x),
})

match 함수는 우리가 전달하는 값에 대한 컨텍스트를 만든다. 이 컨텍스트를 사용하면 특정 조건이 충족되지 않을 경우 실행을 생략하고 다음 조건 함수를 사용하여 컨텍스트의 값을 매핑할 수 있다. 또한 otherwise 함수는 어떤 조건 함수도 일치 함수를 호출하지 못하게 됐을 때 값을 가져온다.

설명이 장황하여 복잡해 보일 수 있지만 어떻게 동작하는지 이해를 돕기 위해 예제를 단계별로 설명한다.

일치하는 조건이 없는 경우

// match 컨텍스트를 생성한다.
match(50)
// 50은 0보다 작지 않으므로 match(50) 컨텍스트를 유지한다.
.on(x => x < 0, () => 0)
// 50은 0과 1 사이가 아니므로 match(50) 컨텍스트를 유지한다.
.on(x => x >= 0 && x <= 1, () => 1)
// 아직 match(50) 상태이므로 그렇지 않으면
// callback이 호출되고 500을 반환한다.
.otherwise(x => x * 10)

일치하는 조건이 존재하는 경우

// match 컨텍스트를 생성한다.
match(0)
// 0은 0보다 작지 않으므로 match(0) 컨텍스트를 유지합니다.
.on(x => x < 0, () => 0)
// 0은 아래 조건을 만족 하기에 함수의 반환 값으로
// 일치하는 컨텍스트에 넣어 matching(1) 컨텍스트를 생성한다.
.on(x => x >= 0 && x <= 1, () => 1)
// 일치하는 컨텍스트가 있으므로 콜백은 무시 되고 대신 1을 얻는다.
.otherwise(x => x * 10)

다음과 같은 문제를 해결한다.

  • 재사용 가능한 functional switch
  • 조건이 일치할 때만 실행된다.(스위치의 의미가 훼손되지 않았다)
  • 단순한 표현식 대신 더 복잡한 조건(함수)을 사용할 수 있다.
  • 함수이기에 각 고유한 스코프를 가지게 된다.

이제 switch 기능과 if else, else 모두의 기능을 캡슐화 및 추상화했고, 표현식의 이점을 모두 가지게 되었다. 선언적인 코드에도 우호적이며 깨끗한 코드임이 분명하다. 더 나아가 리턴 값으로 또 다른 match 함수를 호출하여 anti arrow 패턴을 개선하는 유틸 생성도 가능하다.

응용

어떻게 활용할 수 있을까? 예를 들어, 다음과 같은 코드가 있다고 가정해보자.

const done = x => ({
  attempt: () => done(x),
  finally: fn => fn(x),
})
const until = (pred, x) => ({
  attempt: fn => {
    const y = fn(x)
    return pred(y) ? done(y) : until(pred, x)
  },
  finally: (_, fn) => fn(x),
})

until 함수는 결과가 특정 조건을 만족시킬 때까지 값을 다른 값에 매핑하는 패턴을 캡슐화한다. 다소 복잡할 수 있지만 until() 함수가 동작 원리는 독자들에게 맡긴다. 위 동작원리와 함께 한다면 이해할 수 있을 것이다.

With Typescript

포스팅 편의상 Javascript로 작성했지만 최근 타입이 없는 코드는 Javascript 개발자들에게 끔찍할 수 있어 Typescript 코드를 제공한다.

interface MatchType<X, Y> {
  on: (
    pred: (x: X) => boolean,
    fn: (x: X) => Y
  ) => MatchType<X, Y>;
  otherwise: (fn: (x: X) => Y) => Y;
}

function matched<X>(x: X) {
  return {
    on: () => matched(x),
    otherwise: () => x,
  };
}

function match<X, Y>(x: X): MatchType<X, Y> {
  return {
    on: (pred: (x: X) => boolean, fn: (x: X) => Y) =>
      pred(x) ? matched(fn(x)) : match(x),
    otherwise: (fn: (x: X) => Y) => fn(x),
  };
}

multi 함수

switch-case의 문제는 이미 언급하였다. multi 함수는 그중 가독성에 대한 문제를 해결한다.

multi 함수는 로직을 내부에 숨기고, 개발자가 알아야 하는 것은 multi 함수를 사용하고 있고 이 함수가 어떻게든 작동한다는 것뿐이다. 실제 구현보다 기능에 집중할 수 있게 된다. 이를 “선언적 프로그래밍”이라고 하며 개발자의 인지 부하를 낮추면서 코드의 가독성을 높이는 데 도움이 된다. 선언적 코드는 로직에 추상화 레이어를 더하여, 인간의 언어와 더 가깝게 표현할 수 있다. 이는 가독성을 높이고, 코드를 이해하기 쉽게 만들어 준다.

또 다른 장점은 바로 확장성이다.

switch-case에 다른 옵션을 추가해야 하는 경우 코드로 돌아가서 switch-case를 수정해야 한다. 하지만 multi 함수는 multi 함수를 호출하는 곳에서 새로운 옵션을 추가할 수 있다. 이는 코드의 유지 보수성을 높여준다.

구현

우선 동작을 도와주는 라이브러리가 이미 있으며, 본 글 역시 @arrow/multimethod 라이브러리를 참고하여 작성하였다.

multi 함수를 사용할 계획이라면 직접 구현하기보다 기존 라이브러리를 사용하는 것이 좋다. 하지만 이러한 구현을 이해하는 것은 매우 중요하다. 구현을 이해한다면 다른 라이브러리를 사용할 때 더 많은 것을 이해할 수 있을 것이다.

키포인트는 사용할 실제 값을 제공하는 디스패처 기능이 필요하다는 것과 옵션 수가 고정되어 있지 않다는 것을 이해하는 것이 중요하다. 직접 리버스엔지니어링을 해보자.

라고 말하면서 구현을 제공한다.

function method(value, fn) {
    return {value, fn}
}

function multi(dispatcher, ...methods) {
    return (originalFn) => {
        return (elem) => {
            let key = dispatcher(elem)
            let method = methods.find( m => m.value === key)
            if(!method) {
                if(originalFn) {
                    return originalFn(elem)
                } else {
                    throw new Error("해당 옵션을 사용하여 수행할 작업이 아닙니다.")
                }
            }
            return method.fn(elem)
        }
    }
}

method 함수의 기능은 단지 키를 실제 로직과 결합하는 것일 뿐, 그 이상의 기능은 없다. 여기서 흥미로운 코드는 multi 함수 내부에 있으며, 이는 원래 함수를 활용하는 익명 함수를 반환하고 디스패처 코드(첫 번째 인수)에 의해 리턴 한 값을 기반으로 다른 로직을 실행할 수 있는 컨텍스트를 반환한다.

multi 함수는 비교적 간단하기에 응용 및 Typescript 파트는 생략하고, 기회가 되면 추가로 다루도록 하겠다. 사용법 참고

마무리

이번 포스팅은 이게 전부다 그렇게 복잡하진 않았다고 생각한다. matchmulti 함수 모두 필자는 원하지 않는 switch-case를 제거하는 데 도움이 되었다. 이러한 패턴은 더 많은 곳에서 사용될 수 있으며, switch-case를 사용하기로 결정하기 전에 대안으로 사용할 수 있는지 고려해 보길 바란다.

필자는 최근 프로젝트에서 matchmulti를 활용했고 만족스러웠지만, 팀 내에 동작 방식을 설명하는 비용이 발생 했고, 또는 switch-case를 사용하는 것이 더 좋다 생각하는 사람들도 있었다. switch-case를 사용하는 것이 좋은지, 또는 둘 다 사용하는 것이 좋은지에 대한 결론을 내리는 것은 팀 구성원의 몫이다.

개발자라면 문제를 마주쳤을 때 비판적으로 생각하고, 그 문제를 해결하기 위해 여러 가지 방법을 시도하는 것이 바람직하다. 누군가는 제시한 방법을 사용하지 않는다고 말할 수 있지만, 그 사람이 그 방법을 사용하지 않았을 뿐이고, 그렇다고 해서 그 방법이 틀린 것은 아니다. 반대 의견도 경청하여 더 나은 방법을 고민해 보는 것이 좋다.

혹시 과거에 switch-case를 사용하며 불편함을 느껴 다른 방법을 시도해 보았다면 생각을 공유해 주길 바란다.

차일피일 하다보니 글을 쓰는 시간이 오래 걸려 포스팅이 완성이 늦어졌는데 이점 양해를 구한다. 다음 포스팅은 짧은 호흡으로 작성해보겠다.