추상화에도 고민이 필요한 이유

추상화를 '잘'한다는 것이 무엇인지 고민해보자

추상화 지옥

주변 사람들에게 제가 항상 추상화를 고민하고, 잘 짜려고 한다는 말을 했었습니다. 실제로 고민을 많이 하는 것도 맞지만, 제가 추상화를 잘 해내는 사람이라고 줄곧 믿어왔습니다. 스스로 잘한다고 믿기까지 여러 자료를 찾아보고, 실무에서 적용해보려고도 노력했었습니다.
제가 처음으로 '이게 추상화라는 거구나'라고 아하! 했던 순간은 컴포넌트 설계가 중요한 이유 라는 글에서도 나타낸 바와 같이, Slash22: Effective Component 에 대한 영상을 접했을 때입니다. 그 전에는 단순히 잘 작동하는 지만 신경쓰면서 구현했다면(무언가를 작동하도록 만드는 것도 어려웠던 때이다보니, 작동만 하면 된다는 생각이 우선이었습니다) 이 영상을 보고, 다른 개발자들이 편하게 재사용할 수 있는 코드를 만들자는 생각을 꾸준히 하였습니다.
이를 실무에서도 적용하고자 노력하였는데, 잘 된 추상화가 동료들에게도 도움을 주는구나를 깨달았던 순간이 있었습니다. 전에 스타트업에서 일을 하면서 프론트엔드 개발자가 저를 포함해서 둘밖에 없었는데, 나머지 한 분이 프론트엔드를 이제 막 시작하시는 분이었습니다. 그가 편하게 일할 수 있도록 제가 짠 코드의 내부를 보지 않고도 코드의 목적을 정확하게 알 수 있게 하여, 사용하고 싶은 곳에 마음껏 사용하셨으면 좋겠다고 생각했습니다. 그 때 구현한 기능 중 하나가 redaction 하는 기능이었습니다. 개인정보를 유저가 직접 드래그하여 가릴 수 있도록 하는 것이었는데, 꽤나 까다로운 구현 작업이 필요했고, 어떻게 구현됐는지 동료 개발자가 제 코드를 파악하면서 짤 필요가 없도록 하고 싶었습니다. 그래서 useRedact를 사용하면 redaction의 주요 기능들을 꺼내 사용할 수 있고, RedactionWrapper에서 이를 사용하여 해당 컴포넌트로 감싸진 자식 컴포넌트들은 다 redaction이 가능하게 되었습니다.
Redaction을 어떻게 사용하는 지 궁금하다면 이 링크를 통해서 구현 사항을 확인할 수 있습니다.

const RedactionWrapper = ({ children }: { children: React.ReactNode }) => {
  const { selectText, replaceText } = useRedact() // redact와 관련된 다양한 기능들

  return (
    <div
      className="redaction-area"
      onMouseUp={(e: React.MouseEvent<HTMLDivElement, MouseEvent>) =>
        handleMouseUp(e)
      }
    >
      {/* <RedactionWrapper>로 감싸기만 하면 자식 컴포넌트를 redact 가능하도록 구현할 수 있음 */}
      {children}
    </div>
  )
}

이렇게 코드를 짜고 나니, 어느 순간 "UI와 비즈니스 로직은 무조건 분리해야해", "커스텀훅을 사용해서 꼭 내용물을 가려야해"하는 생각에 사로잡히기 시작했습니다. 어느 순간부터 Component에서 비즈니스 로직이 보이는 것은 지저분하다는 생각이 들었습니다. 무분별한 추상화 지옥에 빠졌고, 제가 하는 추상화가 잘한 추상화라고 생각했습니다.

이게 왜 여기 있어요?

좋은 추상화를 위해 이제는 한 단계 더 고민해야한다는 생각이 든 것은 최근에 본 면접이었습니다. 시험을 보면서 폼을 구현하였는데요, 현업에서 여러번 짜는 익숙한 형태의 폼입니다. 아래는 제가 과제 구현사항을 유추할 수 없도록 각색한 형태의 예시입니다.

const useUserForm = () => {
  const { userId } = useParams()
  const { data: initialUser } = useQueryGetUser(userId)
  const isEditMode = initialUser !== undefined
  const { mutate: createUser } = useMutationCreateUser()
  const { mutate: deleteUser } = useMutationDeleteUser()
  const { mutate: updateUser } = useMutationUpdateUser()
  const [user, setUser] = useState(SCHEDULE_TEMPLATE)
  const [errorMessage, setErrorMessage] = useState(undefined)

  // Handling functions...
}

면접에서 위와 같은 코드에 대해 이야기를 나누어볼 기회가 생겼는데, 전체적으로 '추상화'를 함께 고민하는 방향으로 흘러갔습니다. UI 로직에서의 가독성을 좋게 하기 위해서 커스텀훅을 만들었는데, 오히려 모든 로직을 몰아넣은 커스텀 훅의 가독성은 생각하지 못한 것이었습니다. 면접이 끝나갈 때쯤 제 코드를 다시 보니, useParams(URL의 파라미터를 가져오는 훅)가 form안에 들어가있어야 할 이유가 없었습니다. 정답이 있는 문제가 아니더라도 제가 근거를 가지고 판단하기 위해서는 뚜렷한 기준이 있어야겠다라는 생각이 들었습니다.

Coupling and Cohesion

결합과 응집은 단순히 프론트엔드를 넘어 소프트웨어 설계 전반에서 쓰이는 중요한 개념입니다. 결합은 소프트웨어 모듈 사이의 의존도를 의미합니다. 결합도가 높다는 것은, 모듈 간의 의존도가 높아서 현재 모듈을 변경했을 떄 다른 모듈에도 영향을 미칩니다. 응집은 모듈 내부의 끈끈함을 의미하는데요, 응집도가 높을수록 모듈 내의 요소들이 하나의 목적을 위해 밀접하게 관련되어 있음을 의미합니다.

결합

좋은 소프트웨어는 보통 결합도 낮은 경우를 의미합니다. 아래 6가지 결합 목록은 결합도를 판단하는 대표적인 기준이고 아래로 갈수록 결합도가 높은, 안티패턴의 소프트웨어라고 볼 수 있습니다.

  • 데이터 결합: 모듈 간에 데이터로 연결되어 있습니다.

  • 스탬프 결합: 모듈 간에 전체 데이터 덩어리를 주고 받으며 연결되어 있습니다.

  • 제어 결합: 모듈 간에 제어 정보를 전달하고 받은 정보로 인해 내부 동작 자체의 방향이 결정될 수 있습니다. 예를 들어 파이썬에서 정렬할 때 lambda 함수를 사용하는 것입니다.

  • 외부 결합: 다른 소프트웨어나 다른 하드웨어의 모듈에 의존합니다.

  • 공통 결합: 글로벌 데이터와 같이 다른 모듈들과 공유하는 데이터를 사용하고 있습니다.

  • 콘텐츠 결합: 다른 모듈의 데이터를 수정할 수 있고 제어 흐름이 한 모듈에서 다른 모듈로 전달됩니다.

응집

어떤 작업을 하는데 한 모듈안에 같은 목적을 가진 작업 요소들이 모여있다면, 이는 응집도가 높은, 좋은 형태로 설계한 소프트웨어라고 할 수 있습니다. 응집의 유형도 아래 7가지 기준이 대표적이면, 아래로 갈수록 응집도가 낮습니다.

  1. 기능적 응집력: 특정 목적에 필요한 모든 요소들이 포함되어 있는 형태입니다.

  2. 순차적 응집력: 한 모듈 내의 요소 중 한 쪽이 뱉은 데이터를 한 쪽에서 사용합니다.

  3. 커뮤니케이션 응집력: 요소들이 동일한 입력 혹은 출력 데이터에 기여합니다.

  4. 절차적 응집력: 요소들의 작업 실행 순서에 따라 모듈화 된 형태입니다.

  5. 시간적 응집력: 작업이 실행 타이밍이나 빈도를 기준으로 그룹핑됩니다.

  6. 논리적 응집력: 요소가 하는 작업이 논리적으로는 연결되어 있지만 기능은 관련이 없습니다.

  7. 우연적 응집력: 요소들 간의 관계가 없습니다.

선언형 프로그래밍 vs 명령형 프로그래밍

'추상화'라는 개념을 이야기할 때 심심치 않게 나오는 개념이 선언형(Declarative) 프로그래밍과 명령형(Imperative) 프로그래밍입니다. 선언형 프로그래밍은 명령형 프로그래밍을 더 추상화한 레벨이라고 볼 수 있습니다. 하나씩 설명해보겠습니다.

명령형 프로그래밍

명령형 프로그래밍은 어떻게 동작하는 지를 묘사하는 방식입니다. 영어로 표현할 때는 'HOW'를 나타낸다고 합니다. 목적을 달성하기 위해 '어떤 걸 해야하는 지' 차근차근 단계적으로 표현되어 있습니다. 예를 들면, '라면을 만들어라'가 아닌 '냄비를 꺼내서 물을 올리고 열을 가해서 물이 끓어오르면 스프와 면을 넣고...' 이런 식으로 모든 과정을 설명하는 것입니다.

선언형 프로그래밍

선언형 프로그래밍은 무엇을 하는 지를 묘사하는 방식입니다. 영어로는 'WHAT'을 나타냅니다. 달성할 목적이 '무엇'인지가 표현되어 있습니다. 함수형 프로그래밍이나 SQL도 일종의 선언적으로 짠 코드라고 할 수 있습니다. SQL문은 우리가 원하는 목적("어떤 데이터베이스에서 이런 조건을 만족하는 열을 선택하라")을 말하면, 내부에서 어떤 방식으로 해당되는 열을 찾고 수집하는 지 그 알고리즘은 모르고 있어도 됩니다.

iterationfor문으로 표현하여 명령적으로 코드를 짤 수도 있지만, 특정 순간에는 map이나 reduce같은 메소드를 써서 선언적으로 해결할 수 있습니다. 내부가 어떤 코드로 이루어져있는지를 알고 있지 않아도 각각이 어떤 목적을 위해 만들어진 메소드인지 알기 때문에 목적에 맞게 사용하면 따로 세세히 명령하지 않아도 예상했던 결과값을 도출할 수 있습니다.

리액트에서 에러를 핸들링하는 것에 대해도 생각해본다면 try-catch를 사용하는 것은 특정 로직에서 발생하는 에러에 대래 단계별로 대안책을 처리하기 때문에 명령형으로 볼 수 있습니다. 이에 반해 에러 바운더리는 에러가 발생했을 때 이를 throw하여 Error Boundary라는 추상화 레이어를 사용하고 이 내부에서 일괄적으로 에러를 받았을 때 어떻게 처리할 지를 나타내기 때문에 선언적으로 설계한 구조라고 볼 수 있습니다.

명령형이나 선언형 두 가지 방법을 모두 사용할 수 있는 언어라면 둘 중 더 나은 코드란 없고, 상황에 맞게 활용하여야 합니다. 선언적으로 코드를 짜게 된다면, 목적에 맞게 사용할 수 있을 때는 가독성이 더 좋게 느껴질 수 있지만, 디버깅이 더 어려워질 수 있습니다. 명령적으로 코드를 짜면 더 많은 코드를 읽어야할 수 있기 때문에 가독성이 떨어질 수 있지만, 개발자 입장에서 더 짜기 편할 수 있고 추상화하는 레이어로 감쌀 필요가 없기 때문에 더 디버깅이 쉬울 수도 있습니다. 순간순간마다 어떻게 코드를 짜야할 지 결정하는 것은 결국 개발자의 몫입니다.

다시 제 코드를 봅시다

제가 하는 추상화가 맞는지에 대한 의심이 들 때, 맹목적인 추상화는 좋지 않고 잘 짜야한다고 끊임없이 의심했지만 그 방법은 모르고 있었습니다. 하지만 제 판단이 맞는지 확인할 수 있는 꽤나 뚜렷한 기준들이 있다는 것을 알게 되었습니다. 제가 위해서 말한 코드의 결합도와 응집도를 생각해봅시다. useUserForm은 이름에서 그 목적이 유저를 대한 폼을 관리하는 훅입니다. 그 단일 목적에는 useParams 와 같이 URL에 관여하는 코드나 서버와 통신하는 useMutation 코드가 내부에 있는 것은 응집도를 낮춥니다. 이를 밖으로 빼내면 useUserForm 에서는 딱 유저폼을 수정하고 작성하는 코드만 남겨두고, params로 얻은 값은 밖에서부터 받아올 수 있습니다. 더 나아가, params의 값이 undefined일 수도 있기 때문에 params에서 알맞는 값을 가져오는 훅을 따로 만들면 두 훅은 서로 데이터만 전달받는 데이터 결합 형태로 구현할 수 있고, params hook은 다시 다른 로직에서도 재사용할 수 있게 됩니다. 서버와 통신하는 코드도 form으로부터 데이터를 받을 수는 있지만 UI와 더 응집되어야하는 로직이기 떄문에 밖으로 뺴내는 것이 더 가독성을 높입니다.

Conclusion: 맹목적 추상화를 짜지 않기 위한 고민

추상화에는 공식도 없고 정답도 없습니다. 어제는 잘 추상화되었다고 생각한 코드가 오늘 기획이 바뀌면서 Decoupling이 필요한 코드가 될 수도 있습니다. 그렇다고 해서 추상화에 대한 고민이 가치 없는 것은 아닙니다. 낮은 결합과 높은 응집을 위해 고민한 코드는 하나의 모듈이 한 가지 역할만 하고, 그렇게 목적성이 뚜렷한 독립적인 모듈을 만들어내고, 그 말은 즉슨 재사용할 수 있는 확률이 높아진다는 것입니다. 결합과 응집에 대한 기준들을 알고 추상화 강도를 판단한다면, '근거 있는 코드'를 완성할 수 있습니다. 기존에 짜여진 코드가 많은 프로젝트에도 충분히 반영할 수 있습니다. 현재 내가 짜고 있는 코드가 외부 코드와 dependency가 낮고, single responsibility를 잘 지키고 있는지 수시로 의심해보는 것이 그 방법입니다. 레거시를 고치는 것도 중요하지만, 내가 최대한 레거시를 만들어내지 않기 위해 노력하는 것도 중요합니다.