변화에 유연한 설계를 위한 고민 - 추상화란 무엇인가?

2023.11.19
22분
댓글

글을 시작하며

데브코스에 참여하여 매주 한 번씩 시니어 개발자이신 멘토님과 팀원분들과 함께
코드에 대한 의견을 나누는 커피챗 시간을 가졌습니다.

다양한 주제에 대해서 의견을 나눴지만 주제들을 하나의 큰 개념으로 모아보면
좋은 코드란 무엇일까?에 가까웠던 것 같습니다. (추상화 외에도 나눴던 개념들을 곧 포스팅해 보려고 합니다 🙌)

제가 생각하는 "좋은 코드"라는 개념에는 변화에 유연하게 대응할 수 있는 설계가 큰 비중을 차지하고 있기 때문에
이와 관련된 내용을 추상화 개념과 함께 풀어보고자 합니다.

이번 글에서는 추상화 기법이 프로그래밍에서 왜 필요한지와 어떻게 사용할 수 있는지
그리고 변화에 유연하게 대응할 수 있는 설계를 하기 위해 필요한 사고 과정과 선택 기준들에 대해 얘기해보겠습니다.


추상화란 무엇일까요?

추상화를 쉽게 풀어보자면, 핵심적인 특징만을 남기고 나머지를 제거하여 단순화한 것입니다.

혹시 이 추억의 졸라맨 캐릭터를 아시나요? ㅎㅎ
사실은 이 캐릭터도 사람의 핵심적인 특징(머리 1개, 팔 2개, 다리 2개)만을 남겨서 그린 추상화된 형태입니다.

사람의 표정, 목소리, 체형 등의 복잡한 특성에 집중하지 않고
대상이 가진 핵심적인 특징들로 단순하게 표현하는 것이 바로 추상화의 본질입니다.


추상화가 왜 필요한거죠?

그렇다면 프로그래밍에서는 추상화가 왜 필요한 것일까요? 🤔
그것은 바로 추상화를 통해 더 복잡하고 어려운 것들을 만들기 위해서입니다.

추상화를 이용하면 구체적이고 복잡한 구현을 감추어서 단순하게 표현할 수 있기 때문에
사용자는 내부 구현 원리를 모르더라도 기능을 사용할 수 있게 됩니다.

예를 들어서, 우리가 하루 중 가장 많은 시간을 함께 보내는 핸드폰을 살펴볼까요?

우리는 핸드폰 내부의 RAM이나 CPU가 어떻게 동작하는지 몰라도
간단한 터치 동작을 통해서 우리가 원하는 메세지를 보내고, 영상을 보고, 통화를 할 수 있습니다.

어떻게 이러한 것들이 가능하게 된 걸까요?

개발자들은 핸드폰 내부의 복잡한 기능들을 추상화하여
자신이 담당하는 통화 기능, 메세지 기능, 영상 기능 등에만 집중해서 분업을 할 수 있게 되었고
추상화된 기능들을 합성하여 복잡한 제품을 만들어낼 수 있던 것입니다.

React CRA

프론트엔드 개발자들에게 더 와닿을 예시를 들어본다면 React의 CRA가 있습니다.

우리는 React로 개발을 시작할 때 CRA와 같은 명령을 이용하여 Webpack, Babel 등의 설정을 하지 않아도 개발을 시작할 수 있습니다.

231119-190440

이는 CRA 자체적으로 Webpack Configuration을 만들어주고 이를 추상화를 통해 감춰버리기 때문
개발자들이 Webpack 설정에 대한 맥락을 몰라도 개발하는데 지장이 없던 것입니다.

실제로 node_modules -> react-scripts -> config -> webpack.config.js 경로로 감춰져있기 때문에 많은 개발자들이 webpack 설정에 사용하는 비용을 줄이고 다른 부분에 집중하여 개발할 수 있게 되었습니다.


00님, 이번 A 스펙에 00 기능이 추가됐어요 👀

만약 실무에서 이런 메세지를 받게 되었다고 가정해봅시다.

작성한 코드는 A 스펙의 요구사항을 모두 만족하기 때문에 문제가 없다고 생각해왔습니다.
하지만 갑자기 새로운 기능이 추가된다면 어떻게 될까요?

만약 변경에 유연하지 못한 코드였다면 간단한 수정사항임에도 많은 비용을 투자해야 될 수도 있습니다 🤯

사실 제품은 변화를 겪으면서 올바른 방향으로 성장하기 때문에 개발자는 변경에 유연하게 대응할 수 있는 능력이 필요하다고 생각합니다.

그렇다면 어떻게 해야 변경에 유연한 설계를 할 수 있을까요?

지금부터는 추상적인 것들을 합성해서 구체적인 것을 만드는 Bottom-Up 사고방식에 대해 소개하고자 합니다!


아이폰을 분해해봅시다.

아이폰이라는 제품은 마이크, 스피커, 디스플레이, 홈버튼 등으로 이루어져있습니다.

이제부터 각 제품들을 어떤 방식으로 조립하면 좋을지 생각해보겠습니다.
마이크나 스피커와 같은 요소들은 다른 버전이라고 해도 위치가 크게 변하지 않을 것입니다.

하지만 홈 버튼은 어떨까요?
아이폰 X를 기점으로 홈 버튼이 사라지긴 했지만, 여전히 그 이전 버전들에는 홈 버튼이 존재합니다.
만약 아이폰 20에서 아주 혁신적으로 Dial 형태로 돌리는 홈 버튼이 새로 등장하게 된다면 어떻게 처리하는게 좋을까요?

타입을 받아서 분기 처리

흠.. 홈 버튼 타입이 3가지이니까 타입을 외부에서 받아서 타입에 따라 각각 처리 해주면 되지 않을까요? 🤷‍♀️

type IPhoneHomeButtonType = 'dial' | 'gesture' | 'button';

interface Props {
  homeUIType: IPhoneHomeButtonType;
}

const IPhone = ({ homeUIType }: Props) => {
  const [isHomeScreen, setHomeScreen] = useState(false);
  const moveHome = () => setHomeScreen(true);

  switch (homeUIType) {
    case 'button':
      return <HomeButton onClick={moveHome} />;
    case 'gesture':
      return <HomeGesture onSwipeUp={moveHome} />;
    case 'dial':
      return <HomeDial onChange={moveHome} />;
  }
};

이 코드에서의 문제점을 같이 찾아보겠습니다.

  1. homeUIType이 변경될 때마다 IPhone 컴포넌트의 내부 로직을 수정해야 합니다.
  2. 홈으로 이동하는 moveHome 기능은 홈 버튼과 크게 관련이 없는 추상적인 기능이지만, 홈 버튼과 하나의 개념처럼 인식되기 쉬워 결합도가 높은 설계를 하게 될 수 있습니다.

여기서 결합도란, 모듈이 다른 모듈에 영향을 미치는 것을 뜻합니다.

모듈화를 할 때는 결합도와 응집도를 고려하여 각 상황에서의 장단점을 비교하는 것이 필요한데요, 결합도가 높아지게 된다면 하나의 모듈이 변화했을 때 연관된 모듈들도 변경해주는 것이 필요한 위험성이 있습니다.


IoC 패턴으로 합성하기

이와 달리 IoC 패턴을 이용하여 외부에서 버튼을 주입하는 방향으로 설계를 한다면
IPhone 컴포넌트 로직을 변경하지 않고도 변화에 유연하게 대응할 수 있습니다.

interface Props {
  renderHomeUI: (moveHome: () => void) => ReactNode;
}

const IPhone = ({ renderHomeUI }: Props) => {
  const [isHomeScreen, setHomeScreen] = useState();
  const moveHome = () => setHomeScreen(true);

  return renderHomeUI(moveHome);
}

<IPhone renderHomeUI={(moveHome) => <HomeButton onClick={moveHome} />} />
<IPhone renderHomeUI={(moveHome) => <HomeGesture onSwipeUp={moveHome} />} />
<IPhone renderHomeUI={(moveHome) => <HomeDialog onChange={moveHome} />} />

이처럼 제품을 작은 부품들로 쪼개서 이들을 어떻게 조립하면 좋을지 생각하는 방식을 Bottom-Up 사고방식이라고 합니다.

이러한 Bottom-Up 사고방식을 하기 위해서는 개념을 작은 부분으로 쪼개고 이를 어떠한 방식으로 조립할 수 있을지 생각해보는 것이 중요합니다.


Bottom-Up 사고방식의 장점

이러한 사고방식의 장점은, 아무래도 코드는 현재의 비즈니스 상황만을 반영하게 되기 때문에
대상들의 공통점을 뽑아내어 추상화를 하는 형식으로 개발하게 된다면 미래의 변경 가능성까지 고려하기가 어렵습니다.

하지만 추상적인 개념들을 조립하여서 구체적인 개념을 만드는 방식으로 사고하게 된다면
변경이 필요할 때 작은 개념들을 갈아끼워서 다시 조립하는 설계를 하기 수월해집니다.


좋은 추상화란 무엇일까요?

아하! 이제 추상화가 프로그래밍에서 왜 필요한지어떠한 사고 과정을 거쳐서 설계할 수 있을지에 대해 알게 되었습니다.

그렇다면 어떻게 하면 추상화를 잘할 수 있을까요?

저 또한 적절한 추상화란 무엇인지에 대해 고민이 많았는데요.. 🤔
멘토님의 블로그 글들과 커피챗 말씀들을 참고하여 그 기준을 정리해볼 수 있었습니다.


추상화가 필요한 근본적 이유

추상화가 필요한 근본적인 이유는
코드를 읽는 개발자가 코드의 동작을 이해할 때 과한 맥락에 노출되지 않도록 좁은 맥락의 스코프를 만들어서
이해하는 비용을 낮추기 위함입니다.

이해하기 어려운 코드는 가독성이 좋지 않다는 특징이 있습니다.

그렇다면 가독성이 좋지 않은 코드란 무엇일까요?
단순히 코드의 라인 수가 많다는 것보다는 동작을 이해하기 위해 분석해야 하는 코드의 양이 많다는 것에 가까울 것입니다.

따라서 추상화를 진행하는 사람은 다른 개발자가 나의 코드를 이해하기 위해 어떤 부분까지 알아야 하는지
어떤 부분은 몰라도 되는지에 대한 고민이 필요합니다.


맥락을 좁혀 이해하는 비용 낮추기

추상화가 필요한 근본적인 이유를 살펴보니 좋은 추상화에 대한 윤곽이 잡히는 것 같습니다 👀

좋은 추상화란 디테일한 코드를 숨겨서 개발자가 코드 내부를 보지 않더라도 동작을 이해할 수 있도록 설계하여
이해하는데 드는 비용을 줄여주는 것이라는 방향을 잡을 수 있었습니다.


그렇다면 언제 추상화 레벨을 높이는 것이 좋을까요?

추상화에도 단계가 있습니다. 추상화 레벨이 높을수록 자유도가 높아지게 됩니다.

배열 내부에 있는 문자열들의 앞에 I'am이라는 문자를 붙여달라는 요구사항을 마주하게 되었다고 가정해봅시다.
우리는 같은 기능이더라도 추상화 레벨에 따라서 다르게 구현할 수 있습니다.

이들 중 어떤 코드가 요구사항에 적합할까요?

const arr = ['Suhyeon', 'Anna', 'Jay'];

// 새로운 배열을 만드는 부분까지만 추상화
const newArray = arr.map((v) => `I am ${v}`);

// 템플릿 스트링으로 문자열을 합성하는 부분까지 추상화
const newArray = arr.map((v) => addPrefix(v, 'I am'));

// map을 사용한다는 사실까지 추상화
const newArray = addPrefixToItems(arr, 'I am');

// "I am"이라는 문자열을 합성하는 과정까지 추상화
const newArray = addIamToItems(arr);

사실 정해진 답은 없습니다 ㅎㅎ
중요한 것은 같은 동작이어도 추상화 레벨에 따라서 다양한 방식으로 구현할 수 있는 능력입니다.

우리는 추상 레벨에 따라서 코드를 나열해두고 그 방식들의 장단점을 생각하면서 모듈의 목적과 사용하는 집단의 특성에 따라 선택하는 것이 필요합니다.

입력의 자유도 제어하기

간단한 버튼 컴포넌트를 개발한다고 가정해보겠습니다.

const Button = ({ children }: PropsWithChildren<unknown>) => {
  return <button>{children}</button>;
};

위의 컴포넌트를 사용했을 때의 장단점은 무엇이 있을까요?

  • 장점: 외부에서 합성을 통해 다양한 형태의 children을 주입할 수 있게 되었습니다.
  • 단점: 버튼의 type을 변경하거나 클릭 핸들러 등의 기능을 추가하는 행위는 불가능합니다.

컴포넌트에서 담당하는 기능 자체가 적어졌기 때문에, 컴포넌트를 사용하는 개발자 입장에서는 사용법에 대해 크게 고민할 필요가 없습니다.

children 주입: 컴포넌트가 담당하는 기능이 적기 때문에 사용법에 대해 크게 고민할 필요가 없어짐


반면 아래의 컴포넌트는 어떤 장단점이 있을까요?

const Button = (props: ComponentProps<'button'>) => {
  return <button {...props} />;
};
  • 장점: button 컴포넌트의 모든 프로퍼티를 사용할 수 있게 되므로 자유도가 높습니다.
  • 단점: 사용하는 개발자 입장에서는 제공되는 프로퍼티가 많기 때문에 다소 어긋난 맥락들에 노출될 수 있습니다.

또한, 자유도가 높기 때문에 컴포넌트를 개발한 개발자의 의도와 벗어나는 사례가 생기는 리스크도 발생합니다.

button props 주입: 자유도는 높지만 설계한 개발자의 의도와 벗어난 방향으로 사용할 수도 있음


추상화 레벨 가이드

즉, 추상화된 모듈을 만드는 개발자는 모듈의 입력 범위를 조정하면서 사용자가 어떠한 방식으로 모듈을 사용하게 만들지 제어할 수 있게 됩니다.

앞서 각 방식의 장단점을 모두 확인했듯이 정해진 정답은 없지만
모듈의 목적과 사용하는 사람들의 특성을 고려하여 선택하는 것이 필요합니다.

지금부터는 선택에 도움이 될 수 있는 가이드라인에 대해 살펴보겠습니다.

1. 사용자들의 특성 고려하기

모듈의 원리를 알 필요 없는 상황

만약 원리를 알 필요 없는 상황에서 입력 범위를 넓게하여 높은 자유도를 제공한다면
사용자가 사용 방법에 대해 고민하는 비용이 발생할 것입니다.

따라서, 입력 범위를 좁게 설정하여 인지하는 맥락을 좁혀 사용자가 고민하는 비용을 줄여주는 방법이 권장됩니다.

모듈의 원리를 이해하고 다양한 사용에 재사용하는 상황

반대로 원리를 이해하고 재사용하는 것이 필요한 상황에서 좁은 범위의 입력을 구현한다면
사용처가 제한되어버릴 것입니다.

다양한 사용에 재사용을 해야한다면, 입력 범위를 넓게 하여 자유도를 높여주는 방법을 고려해보는 것이 권장됩니다.


2. 코드 이해에 필요한 맥락 파악하기

앞서 배웠듯이 추상화가 필요한 근본적인 이유는 코드에 대한 동작을 이해할 때 너무 과한 맥락에 노출되지 않도록 스코프를 설정하여 이해하는 비용을 낮추는 것에 있습니다.

하지만 그렇다고 이해에 핵심적인 부분까지 감춰버린다면
오히려 해당 코드를 한번 더 타고 들어가서 확인해야 하는 비용이 발생하게 됩니다.

따라서 과한 추상화를 하지 않기 위해서는 코드의 동작을 이해하기 위해 어떤 부분까지 알아야하는지 그리고 어떤 부분은 몰라도 되는지에 대한 깊은 고민을 해볼 필요가 있습니다.


글을 마치며

이상으로 프로그래밍에서의 추상화에 대한 개념과 변경에 유연한 설계를 하기 위해 필요한 사고방식들에 대해 알아보았습니다. "좋은 추상화란?" "적절한 추상화 레벨이란?" 과 같이 정답이 없는 주제가 많았던 만큼 고민하고 이해하는 시간이 유독 많이 들었던 글이었던 것 같습니다.

혹시 제가 다르게 이해한 부분이 있거나 새로운 의견이 있으시다면 댓글 또는 메일을 통해 공유해주시면 감사하겠습니다 🙌

좋은 코드를 설계하는 방법에 대해 고민할 수 있도록 이끌어주신 멘토님께 감사드리며 이번 글을 마무리 하도록 하겠습니다.

참고 문서