복잡한 기능 리팩토링, 절차 지향을 벗어나자

김동하·2026년 1월 13일

업무

목록 보기
1/6
post-thumbnail

개요

실무에서 자주 겪는 패턴이 있다. 처음엔 단순했던 기능이, 요구사항이 조금씩 추가되면서 특정 지점부터 급격히 복잡해지는 순간이다. 테이블 행 위치 이동 기능 기능이 딱 그랬다.

상품 리스트나 이벤트, 기획 리스트가 있는 대부분 어드민 메뉴에서 사용자가 노출 순서나 중요도를 변경하기 위해 추가된 기능이었다.

처음엔 간단했다. table의 row를 하나 선택해서

위, 아래 버튼을 클릭하면 row의 위치가 변한다.

당시 테이블 라이브러리로 react-table을 사용했는데 사용 버전에는 행 이동 기능이 없었고, 기획에서 몇 가지 더 기능이 추가될 수 있다고 하여 비즈니스 로직에 맞춰 라이브러리를 커스텀하기 보다 그냥 개발하기로 했다.

버튼을 누르면 row의 index를 가져와서 [arr[1], arr[2]] = [arr[2], arr[1]] 구조 분해 swap으로 배열을 변경하고 다시 상태를 업데이트 하는 식으로 개발했다.

문제

문제는 해당 기능이 시간차를 두고 조금씩 추가 되었다는 것이다.

  • 맨 위, 맨 아래로 이동시키는 버튼이 추가
  • 어드민 메뉴에 따라 No가 부여될 수 있는 row 개수 제한
  • row의 특정 값(전시 여부 등등)에 따라 up 이동 제한
  • 어드민에서 row의 특정 값 변경 시 노출 불가 상태면 자동으로 down
  • 페이지네이션 된 것도 고려해야함
  • row 다중 선택 후 이동 가능
  • 다중 선택 후 이동 제한이 걸린 row가 있다면 해당 row만 이동 못함

등등...

그렇게 기능이 추가될 때마다 기획에 맞게 수정했는데 어느 순간부터 코드가 복잡해짐을 깨달았다.

대부분 실무에서 개발이 그렇지만 '리팩토링 해야지' 생각만 하다가 기능을 계속 추가하다보니 더 이상 손 쓰기 어려울 정도로 복잡도가 높아졌다.

사실, 기능을 추가하는 건 큰 문제가 되지 않았다. 코드를 읽기는 어려워도 어떻게 잘 끼어 넣으면 잘 돌아갔다.

진짜 문제는 디버깅이었다. 워낙 많은 비즈니스 로직이 뒤섞여 있다보니 버그가 생기면 어느 부분에서 문제가 되는지 추측이 불가능했다.

예를들어 위 버튼을 눌렀는데 row가 이동하지 않았다면 이게 row의 제한 조건이 잘못된 건지, 노출 조건이 문제인지, 아니면 순서 이동 로직의 문제인지 유추가 안 되어 로그를 하나씩 다 찍어봐야 했다(굉장한 고통이었다)

그래서 무엇이 문제일까 고민해보니 가장 큰 문제는 절차 지향으로 작성한 코드가 문제였다. 어떤 문제였는지 처음부터 살펴보자

구조

  위로 이동 버튼 클릭 -> 
  변경 가능한 인덱스 찾기 -> 
  스왑 가능한지 찾고 스왑하기 -> 
  상태 업데이트

의 프로세스를 위해 useSwapRow 라는 훅을 만들어서 기능에 필요한 로직을 모았다. useSwapRow은 크게 3가지 일을 했다.

  • UI 이벤트 처리(이동 버튼 클릭 시)
  • 도메인 로직(타겟 인덱스를 계산하고 swap 조건 보고 가능한지 판단)
  • row 이동 시 테이블 상태 업데이트
function useSwapRow<T extends SwapItem = SwapItem>({
  //...
}: SwapRow<T>) {
  
  const { ..., getSwap, getTargetIndex } = swap();
  const { ..., updateSelectionState } = useSwapSelectionState({
  //...
  });
  
  //...

실제 swap을 하는 swap()과 리액트 라이프 사이클과 관련있는 useSwapSelectionState()를 분리하였다.

const onChangePosition = (e: MouseEvent<HTMLButtonElement>) => {
    // ...
    const targetIndexes = calculateTargetIndexes(pos, isMoreThanFirstPage);
    const { swappedTableList } = performSwap(pos, targetIndexes);

    updateSelectionState(targetIndexes.swappedTableList);
    // ...
  };

이동 버튼을 클릭하게 되면 calculateTargetIndexes()로 이동하게될 index를 계산하고

performSwap()에서 인덱스를 넘겨 swap을 하고

swap된 테이블을 updateSelectionState()로 업데이트 하는 식이었다.

swap() 내부를 간단히 보자면

const swap = (...) => {
  //...
  const getTargetIndex = (...) => {
    const newCurrentIndexes = [];
    // ...
    for (let i = 0; i < currentIndexes.length; i++) {
      // 비즈니스 로직 기준 이동 가능할지에 대한 조건들..
      newCurrentIndexes.push(willMoveIndex);
    }
    return newCurrentIndexes;
  }
 
  const getSwap = (...) => {
    // ...
    (let i = 0; i < currentIndexes.length; i++) {
      // 인덱스 0인지 마지막 인덱스인지, 이미 앞 인덱스들이 막혔는지..
      
      [list[currentIndex], list[nextIndex]] = [list[nextIndex], list[currentIndex]];
    }
 
    return list
  }
  //...
}

getTargetIndex()으로 변경될 index들을 구하고 getSwap()에서 실제 리스트를 변경하면 순서 변경이 마무리 된다.

원인

문제가 커진 핵심은, swap 로직이 아래 형태로 계속 확장되었다는 점이다.

for (let i = 0; i < currentIndexes.length; i++) {
  // ...
  if (조건1) continue;
  if (조건2) continue;
  if (조건3) break;

  [list[currentIndex], list[nextIndex]] = [list[nextIndex], list[currentIndex]];
}

루프 한 번에 그 안에서 온갖 조건 분기 만족하면 swap, 아니면 continue 혹은 break 구조는

  • 스왑 가능한 상태가 조건으로만 존재해 의도가 드러나지 않는다.
  • 루프 안에서 배열을 직접 변경해, 매 단계가 부수효과가 발생한다.
  • continue와 break로 흐름이 분기되며, 왜 멈췄는지 추적이 어렵다.

라는 문제를 발생시켰고 이 로직을 해제하기로 했다.

절차 vs 선언

디버깅이 어려웠던 점은 순서 변경 여정이 모두 절차지향으로 짜였기 때문이었다.

프로그래밍의 과정을 개발자가 다 지정해줘야 하기에 내가 아닌 다른 누군가가 코드를 수정한다고 하면 그 과정을 모두 알아야 한다. 다른 사람 뿐만 아니라 개발을 직접 한 나조차도 저렇게 쭉 짜놓고 시간이 지나 기능을 추가해야하는 상황에서 다시 코드를 읽고 이해하는 리소스가 많이 들었다. 그래서 절차 지향(명령형)이 아닌 선언 혹은 함수형으로 변경을 고민하게 되었다.

선언형으로 변경하면 도대체 무엇이 좋을까. 예전부터 관심만 있었지 제대로 공부한 적이 없어 이번 기회에 여러 아티클을 읽었다.

기존의 사고 방식을 깨부수는 함수형 사고이 블로그에 선언형의 좋은 예시가 있어 잠깐만 정리해보자면

아래와 같은 문제가 있다.

const arr = ['aa', 'bb', 'cc', ''];

배열을 순회하며 빈 문자열을 걸러내고, 각 원소의 첫 글자를 대문자로 변경해라.

이 문제에 대해 절차 지향은 어떻게 작업을 수행할 지에 대해 말한다.

const newArr = [];
for (let i = 0; i < arr.length; i++) {
  if (arr[i].length !== 0) {
    newArr.push(arr[i].charAt(0).toUpperCase() + arr[i].substring(1));
  }
}

이런 식으로 개발자는 i라는 상태를 직접 관리해야하며, 매 루프 때마다 i에 1을 더 해가면서 배열의 어느 인덱스까지 탐색했는지도 신경써줘야 하는 상황인 것이다. 게다가 배열의 원소에 접근할 때도 i를 사용하여 직접 접근 명령을 내려야하고 이 원소가 빈 문자열인지 아닌지 여부도 검사해줘야한다.

절차 지향은 보통 컴퓨터의 사고 방식이다. 그렇기 때문에 간단한 문제가 아닌 복잡한 로직을 절차형으로 작성하게 되면 처음부터 끝까지 흐름을 다 따라가야만 이해할 수가 있다.

반면 선언형은 컴퓨터에게 내가 무엇을 할지만 알려준다. 반복적인 잡다한 일은 컴퓨터에게 위임하고 개발자는 문제 해결에 집중한다

function convert(s) {
  return s.charAt(0).toUpperCase() + s.substring(1);
}

const newArr2 = arr.filter(v => v.length !== 0).map(v => convert(v));
  1. 인자로 받은 문자열의 첫 글자만 대문자로 변경하는 함수를 선언
  2. arr 배열에서 원소의 길이가 0이 아닌 것들을 걸러냄
  3. 걸러진 배열을 순회하면서 1번에서 선언한 함수를 사용하여 원소의 첫글자를 대문자로 변경

선언형으로 코드를 작성하면 같은 결과의 코드지만 개발자의 사고의 흐름이 달라진다. 개발자는 디테일한 인덱스 변수의 선언이나 흐름에 대해서 생각할 필요는 없어졌고 좀 더 본질적인 문제에 집중할 수 있다.

위 예시가 쉬워서 와닿지 않는다면 좀 더 어려운 예시를 보자

const m = [
  [1,2,3],
  [4,5,6],      
  [7,8,9],
];

// 위 2차원 배열을 아래처럼 180도 회전해보자

[
  [9,8,7],
  [6,5,4],
  [3,2,1],
]

위 문제를 해결하기 위해 익숙한 절차형으로 먼저 코딩을 한다고 해보자. 빈 베열을 만들고 이중 루프를 순회하면서 하나씩 cell을 새로 채우는 로직을 생각할 수 있다.

function rotate180(matrix) {
  const n = matrix.length;
  const m = matrix[0].length;
  const result = Array.from({ length: n }, () => Array(m));

  for (let i = 0; i < n; i++) {
    for (let j = 0; j < m; j++) {
      result[n - 1 - i][m - 1 - j] = matrix[i][j];
    }
  }
  return result;
}

n, m이 무엇인지, 이중 루프가 어디부터 어디까지 도는지, result 배열을 어떻게 변경하는지 전부 알아야 한다.로직이 한눈에 들어오지 않고 for문을 하나씩 따라가야 하기에 복잡도가 조금만 올라가도 코드를 이해하는데 피로도가 생긴다.

반면 선언형의 경우 무엇을 할지를 정하고 그에 필요한 함수를 만들면 된다.

const reverseRows = matrix =>
  matrix.slice().reverse()

const reverseEachRow = matrix =>
  matrix.map(row => row.slice().reverse())

const rotate180 = matrix =>
  reverseEachRow(reverseRows(matrix))

180도 회전이란 1.행 순서를 뒤집고 2.각 행을 뒤집는다 로 나눌 수 있다. 그럼 먼저 전체 행 순서를 뒤집는 reverseRows()를 떠올리고 각각의 행의 순서를 뒤집는 reverseEachRow()를 떠올려내면 위 처럼 rotate180이라는 간단한 함수로 만들 수 있다.

함수를 나눠서 할 일을 정의하고 그 함수를 그냥 실행하면 된다. 상태를 변경하거나 디테일에 신경 쓸 필요 없이 무엇을 할지만 고민하면 된다.

어떻게 변경할까

나의 경우 swap 로직은 아래와 같았다.

4 종류의 버튼에 따라 움직일 수 있는 방향이 결정되는데, 특정 조건에 따라 움직일 수도, 움직이지 못할 수도 있다.

그래서 내가 절차 지향으로 작성한 코드를 간략하게 쓰면

for (let i = 0; i < currentIndexes.length; i++) {
      const currentIndex = currentIndexes[i];
      const nextIndex = currentIndex + direction;
      const currentItem = list[currentIndex];
      // 기타 변수들...

      // 배열 범위 체크
      if (!list[nextIndex] || !list[currentIndex]) {
        continue;
      }
  
      //  상태 조건에 때문에 더 못감
      if (isItemStuck(...)) {
        stuckIndexes.push(currentIndex);
        continue;
      }

      // 스왑 가능 여부 체크
      if (!canSwapItem(currentIndex, nextIndex, lastIndex, isUp, isDown)) {
        continue;
      }

      // 순차적으로 앞 row가 stuck인지 체크 
      if (isAllSequential(currentIndexes, i, direction, limitIndex)) {
        continue;
      }

      [list[currentIndex], list[nextIndex]] = [
        list[nextIndex],
        list[currentIndex],
      ];
    }

    return list;
  }

내가 인지한 문제인

1. 로직의 흐름을 따라가기 힘듦
2. 유지보수 어려움

의 관점에서 문제점을 찾자면

// 문제 1: 스왑 가능한 상태가 명시적이지 않음 -> 그냥 절차만 보여줌
// 문제 2: continue, break로 제어 흐름이 복잡함

if (조건1) continue;
if (조건2) continue;
if (조건3) break;
// 문제 3: 루프 안에서 배열 직접 변경 
[list[currentIndex], list[nextIndex]] = [
  list[nextIndex],
  list[currentIndex],
];

이었고 먼저 조건을 상태로 변경하기로 했다.

swap 상태 명시적으로 변경

선언형의 기본은 무엇을 하고 싶은지에 좀 더 집중하면 조건들을 상태로 취급해보자

type SwapState =
  | { 
      type: 'can-swap';
      shouldSwap: true;
      swapPair: [number, number]; 
    }
  | { 
      type: 'skip';
      shouldSwap: false;
      reason: 
        | 'out-of-bounds' 
        | 'stuck' 
        | 'cannot-swap' 
        | 'sequential' 
        | 'limit-reached';
    };

먼저 조건이 아닌 상태로 보여줄 것이다. 스왑 가능 여부를 상태로 표현하도록 하여 가독성과 명시성을 높이고

type SwapContext = {
  currentIndex: number;
  previousIndex?: number;
  nextIndex: number;
  direction: number;
  limitIndex: number;
  currentItem?: SwapItem;
  isFixed: boolean;
  ...
};

흩어져있는 변수를 한곳에서 관리하기 위해 SwapContext라는 객체를 만든다.

이제 SwapContext를 기준으로 swap이 가능한지 판단하는 함수를 만들것이다. 함수명은 evaluateSwapState라고 짓자

const evaluateSwapState = (
    context: SwapContext,
    ...
  ): SwapState => {
    // 1. 배열 범위 체크
    if (!isValidIndex(context, nextIndex)) {
      return {
        type: 'skip',
        shouldSwap: false,
        reason: 'out-of-bounds',
      };
    }

    // 2. Stuck 체크
    if (isItemStuck(context)) {
      return {
        type: 'skip',
        shouldSwap: false,
        reason: 'stuck',
      };
    }

    // 스왑 가능 여부 체크
    if (!canSwapItem(context)) {
      return {
        type: 'skip',
        shouldSwap: false,
        reason: 'cannot-swap',
      };
    }

    // ...
  
    return {
      type: 'can-swap',
      shouldSwap: true,
      swapPair: [context.currentIndex, nextIndex],
    };
  };

조건문을 나열되었을 때 보다 훨씬 의도가 잘 담겨있어 코드 읽기가 편한다. 이제 getSwap을 다시 작성해보자.

const getSwap = <T extends SwapItem>(params: GetSwap<T>): T[] => {
  const base = buildBaseSwapContext(params);
  let stuckIndexes: number[] = [];
  
  return params.currentIndexes.reduce(
    (accList, currentIndex, index) => {
     // ... 
  };

이제 변수는 이제 context에서 관리한다. for문을 순회하면서 조건을 검사했던 방식은 reduce로 변경한다,

const getSwap = <T extends SwapItem>(params: GetSwap<T>): T[] => {
  const base = buildBaseSwapContext(params);
  let stuckIndexes: number[] = [];
  
  return params.currentIndexes.reduce(
    (accList, currentIndex, index) => {
     // ... 
  };

이제 reduce를 돌면서 스왑 상태만 평가 해주면 된다.

const getSwap = <T extends SwapItem>(params: GetSwap<T>): T[] => {
  const base = buildBaseSwapContext(params);
  let stuckIndexes: number[] = [];

  return params.currentIndexes.reduce((accList, currentIndex, index) => {
    const context = buildSwapContext(base, currentIndex); // 루프마다 변경되는 context 
    const currenSwapState = evaluateSwapState(context);

    if (currenSwapState.type === 'skip' && currenSwapState.reason === 'stuck') {
        stuckIndexes = [...stuckIndexes, currentIndex];
        base = { ...base, stuckIndexes };
      }
}, listCopy);

평가가 끝나고 swap이 가능한 상태면 리듀스 내부에서 스왑한 리스트를 다음 평가로 넘긴다.

 return applySwap(accList, actionState); // 구조분해로 배열 스왑

처음 내가 작성했던 코드와 비교하면 굉장히 의도가 잘 보이고, 절차보다는 무엇을 하는지에 좀 더 집중되었음을 알 수 있다(내가 보기엔 그렇다..)

// 초기 코드
const getSwap = <T extends SwapItem>({
    list = [],
    currentIndexes,
    direction,
    lastIndex,
    firstIndex,
    isLimit,
    lastDisplayYIndex,
    isFixed,
  }: GetSwap<T>): T[] => {
    const isUp = direction < 0;
    const isDown = direction > 0;
    const stuckIndexes: number[] = [];
    const limitIndex = isUp ? firstIndex : lastIndex;

    for (let i = 0; i < currentIndexes.length; i++) {
      const currentIndex = currentIndexes[i];
      const nextIndex = currentIndex + direction;
      const currentItem = list[currentIndex];

      // 배열 범위 체크
      if (!list[nextIndex] || !list[currentIndex]) {
        continue;
      }

      const isAlreadyStuck =
        stuckIndexes.length > 0 &&
        stuckIndexes[stuckIndexes.length - 1] + 1 === currentIndex;

      if (
        isItemStuck(
          currentItem,
          currentIndex,
          isUp,
          isDown,
          isFixed ?? false,
          lastDisplayYIndex,
          isAlreadyStuck
        )
      ) {
        stuckIndexes.push(currentIndex);
        continue;
      }

      // 스왑 가능 여부 체크
      if (!canSwapItem(currentIndex, nextIndex, lastIndex, isUp, isDown)) {
        continue;
      }

      //순차적인 배열인지 체크
      if (isAllSequential(currentIndexes, i, direction, limitIndex)) {
        continue;
      }

      if (isLimit) {
        break;
      }

      [list[currentIndex], list[nextIndex]] = [
        list[nextIndex],
        list[currentIndex],
      ];
    }

    return list;
  };

이렇게 절차지향에서 벗어나게 되면 개인적인 가장 큰 장점으로는 무엇을 하고 싶은지가 코드에서 보인다는 점이다.

아직 뭔진 모르겠지만 reudce를 사용해 배열을 누적해서 변화시키는데 각 평가마다 currentIndex를 기반하여 context가 변경되고, 변경된 context로 SwapState가 결정되는구나. 그리고 skip이나 stuck 상태가 아니라면 배열을 변경하는군!

이라고 한 눈에 의도가 파악 가능하다. 그리고 유지 보수 측면에서도 reduce 내부에서 하는 일이 단계 별로 명확해져서 기능을 추가하기도 제거하기도 편하다.

[1, 2, 4]를 선택하여 위로 올리면

리팩토링 후에도 문제 없이 잘 작동한다!

State Machine

선언형으로 바꾼다고 바꿨지만, 뭔가 찝찝하다. 정확히 말하면 선언형이라기 보다 절차형을 함수로 분리하여 읽기 좋게 만든 느낌에 가깝다. 어떻게 하면 찐 선언형으로 작성할 수 있을까 지피티와 함께 고민하다가 state machine 이라는 좋은 아이디어를 발견했다

상태 머신이란 지금 이 시스템은 어떤 상태에 있고, 이 상태에서만 허용되는 행동만 할 수 있게 강제로 구조를 짜는 것이다. 상태머신에는 전이 함수라는 심플한 개념이 있다.

// 전이 함수
(state, event, env) => nextState

상태머신은 event에 따라 어떻게 상태가 변하는지만을 중심에 둔다. 나의 비즈니스 로직과도 잘 맞아 떨어져 상태 머신을 중심으로 코드를 변경하였다.

루프는 거들뿐

수정된 버전의 getSwap은 reduce라는 고급스러운 방법을 사용하지만 여전히 루프에 의존적이다.

// getSwap.ts
// ...
return params.currentIndexes.reduce((accList, currentIndex) => {
      const context = // 무언가 변화
      const actionState = // 무언가 변화

      if (조건) {
        // 무언가 변화
      }
      return applySwapAction(accList, actionState);
    }, listCopy);

즉,현재 getSwap이란 함수의 실행은 루프 중심이다. 루프를 돌면서 아래의 액션을 실행한다.

  • 인덱스들을 순회한다
  • 매 스텝마다 조건을 평가한다
  • stuckIndexes를 즉시 갱신한다
  • swap을 적용한다

그렇다면 함수의 실행을 상태 중심으로 변경해보자.

상태

getSwap에서 추적해야 하는 상태는 딱 2가지다. 막힌 인덱스들 그리고 누적 결과 리스트.

// 상태
type SwapMachineState<T extends SwapItem> = {       
  list: T[];               
  stuckIndexes: number[]; 
};

그리고 매 swap때마다 처리해야할 이벤트는 currentIndex다.

// 이벤트
type SwapMachineEvent = {
  currentIndex: number;
};

상태 변이와 상관없이 정해진 값들은 환경 변수라 칭하자.

type SwapEnv = {
  currentIndexes: number[];
  direction: number;
  lastIndex: number;
  firstIndex: number;
  lastDisplayYIndex?: number;
  isFixed: boolean;
  isLimit: boolean;

  isMovingUp: boolean;
  isMovingDown: boolean;
};

이제 getSwap()을 변경해보면

 const getSwap = <T extends SwapItem>(params: GetSwap<T>): T[] => {
    //...
   
    // 초기 상태
    let state = {
      list: list.slice(), 
      stuckIndexes: [],
    };

    const transition = (
      state: SwapMachineState<T>,
      event: SwapMachineEvent
    ): SwapMachineState<T> => {
      const currentIndex = event.currentIndex;
      const nextIndex = currentIndex + env.direction;

      const context = {
        list: state.list, 
        currentIndexes: env.currentIndexes,
        direction: env.direction,
        lastIndex: env.lastIndex,
        firstIndex: env.firstIndex,
        lastDisplayYIndex: env.lastDisplayYIndex,
        isFixed: env.isFixed,

        //...
      };

      const actionState = evaluateSwapState(
        context,
        nextIndex,
        env.lastIndex,
        env.isLimit
      );

      // stuck이면 stuckIndexes 업데이트
      const nextStuck =
        actionState.type === 'skip' && actionState.reason === 'stuck'
          ? [...state.stuckIndexes, currentIndex]
          : state.stuckIndexes;

      // swap이면 list 업데이트
      const nextList = applySwapAction(state.list, actionState);

      return {
        list: nextList,
        stuckIndexes: nextStuck,
      };
    };
   
    // 전이 함수 실행 
    for (let i = 0; i < currentIndexes.length; i++) {
      state = transition(state, { currentIndex: currentIndexes[i] });
    }

    return state.list;
  };

초기 상태를 두고 transition이 발생할 때마다 상태를 변경하는 식으로 흐름이 바뀐다. 즉, 개발자는 상태를 두고 어떻게 변화하는지를 알 수 있게 된다.

상태 머신으로 변경하면서 로직의 구성도 굉장히 심플해진다

  • evaluateSwapState (규칙이나 조건)
  • applySwapAction (부수효과)
  • transition (상태 전이)

다른 것 볼 필요도 없이 transition 만 보면 어떻게 돌아가는지 파악이 된다.

트레이드오프

사실, 엄청 복잡한 상태를 요구하는 기능이 아니라 상태머신으로 개발하는 것이 추상화 비용만 증가하는 느낌도 없지 않아 있지만, 절차 지향 개발에 익숙해져 있어서 다른 방법을 모색하고 싶어서 리팩토링을 진행했다. 동일한 결과를 내는 로직을 여러 방법으로 풀어볼 수 있어 좋은 경험이었다!

profile
프론트엔드 개발

0개의 댓글