좋은 코드란 무엇인가

김기훈·2023년 3월 2일

CS지식

목록 보기
7/10

좋은 코드란?

사람들마다 모두 조금씩 다르게 정의하고 있지만 대표적으로 보이는 것들은 다음과 같다.

  • 읽기 쉬운 코드
  • 중복이 없는 코드
  • 테스트가 용이한 코드

1. 읽기 쉬운 코드

주석 쓰면 편하지 않나?..

단순하게 생각해서 읽기 쉽도록 코드를 작성하려면 작성자에게 익숙한 언어를 사용하면 된다. 한국인이라면 영어로 복잡하게 작성된 코드보다 한국어가 익숙하지 않을까? 모든 코드에 한국어로 주석을 상세히 작성해두면 코드를 읽지 않아도 코드가 무엇을 하는지 빠르고 쉽게 파악할 수 있다.

물론, 주석만 읽어봐도 언제 사용해야 하며 무엇을 함수의 인자로 전달하고 무엇이 반환되는지 알 수는 있다.

하지만 주석은 관리하기 어렵다. 주석은 기본적으로 메타데이터이기 때문에 주석의 내용과 함수의 실제 동작이 일치한다고 보장할 수 없다. 수시로 변하는 요구사항을 반영하느라 함수가 수정될 때 주석이 함께 수정되지 않을 수도 있다. 오히려 한두 가지의 잘못된 주석으로 코드를 이해하는데 혼란을 야기할 수 있다.

나한텐 읽기 쉬운데?

모든 개발자가 동일한 배경지식을 가진 것은 아니다. 각자 다른 환경에서 코드를 작성하고 읽기 때문에 ‘읽기 쉽다’는 것은 개개인에게 다르다.

또한 변수명을 잘 짓는다는 것도, 영어를 모국어로 사용하고 있지 않은 사람들에겐 다르다. 어떤 누군가에겐 어색한 영어로 짓는 변수명이 잘 읽힐 수도 있기 때문에 잘 지은 변수명 또한 각자의 배경지식에 따라 기준이 모호하다.

또한 코드를 작성하는 시점에는 작성한 코드에 대한 이해가 100%이기 때문에 아무리 ‘읽기 쉽게’ 작성하고자 노력해도 한계가 있을 수밖에 없다.

읽기 쉬우면 끝인가?

너무 원초적이고 당연한 이야기이기에 와 닿지 않는다. 읽기 쉽다는 것은 단순히 코드를 읽는 것만 의미하는 것이 아니라 이해하는 것까지 포함하기 때문에 나무뿐만 아니라 숲을 볼 때도 쉬워야 한다.

그보다 더 근본적인 이유, 좋은 코드가 읽기 쉬운 코드라면 왜 읽기 쉽도록 작성해야 하는가? 어떻게 읽기 쉽도록 작성하는가? 에 대한 물음으로 좋은 코드에 대한 정의를 다시 한번 생각해 볼 수 있다.

2. 중복이 없는 코드

개발자라면 누구나 A와 B에서 동일한 로직을 수행하는 코드를 보는 순간, 추출하고 싶어 한다.
동일한 로직을 수행하는 코드가 여기에도 저기에도 있는 것은 굉장히 불편하기 때문이다.

오래전에 작성해둔 코드에서 A라는 코드 조각이 두 군데에 퍼져있다는 것을 기억하기 쉽지 않다. 때문에 한 곳의 A만 수정되었을 때 다른 한 곳에서 버그가 발생한다. 이러한 경험에 기반하여 동일한 로직을 수행하는 코드는 별도의 함수로 빼두고 재사용하려고 한다.

그때는 중복이었던 코드 조각을 필요에 의해 추출했지만, 지금은 아니라서 다시 합치거나 수정하는 경우도 있는데 이는 보통 하는 일은 동일하지만 그 목적이 다른 코드를 추출했을 때 많이 발생한다.
이것은 변경 사항을 반영하는 데 비용이 들어가게 한다.

때문에 중복 되는 코드가 있더라도 추출을 어떤 기준으로 할지도 명확히 정해야 한다.

3. 테스트가 용이한 코드

테스트가 용이하면 물론 좋다. 테스트 코드를 작성해두면 심리적 안정감도 생기고 리팩토링에 자신감도 생긴다. 그러나 중요한 것은 왜 테스트 코드를 작성하기 용이해야 하는가?이다. 그리고 어떻게 하면 테스트가 용이한 코드를 작성할 수 있을까? 에 대한 고민이 추가로 필요하다. 좀 더 근본적인 정의를 찾아 좀 더 깊게 들어가볼 수 있을 것 같다.


쓰이지 않는 코드

쓰지 않게 된 코드(Dead code)가 있는데, 이 코드가 어디에서 쓰일지도 모른다는 불안감으로 함부로 삭제를 하지 못하는 경우가 있다.

이 불안감은 어디에서 오는 것일까?

1. 거리

코드가 동작하는 곳과 코드가 정의된 곳 간의 물리적 거리가 멀면 파악하기 힘들어진다. 보고 있는 코드가 일정 범위에서만 사용된다는 확신이 있다면 그 범위만 확인 후 사용하지 않는 코드라는 확신을 얻을 수 있다.

2. 순수하지 않은 함수

함수 외부의 어떤 값을 기반으로 동작하는 함수는 그 side effect를 제어하기 어렵다.
함수의 입출력만 확인하여 함수 내부를 수정할 수 없다면 수정하기 까다롭다.

function 시급구하기(name) {
  return this.내임금(name) / this.총일한시간(new Date())
}

'시급구하기'라는 함수는 name이라는 입력 외에도 '내임금'이라는 입력으로 정의되지 않은 입력이 존재하며 new Date()라는 항상 변하는 값에 의존한다.

3. 응급처치를 한 코드

중복으로 빼뒀지만 사용하는 곳에서 다른 로직을 추가해줘야 할 때를 종종 마주한다. 이 때 수정으로 인한 Side effect가 두려워 함수에 입력을 추가하던가 옵션 값을 추가하여 내부에서 억지로 처리할 때 그 코드는 좋지 않은 코드가 된다.

기술부채

무조건 응급처치가 무조건 나쁘다는 말이 아니다. 현재 서비스에서 발생하는 버그를 잡아야 하는데, 전체적인 구조 리팩토링을 할 수 없기 때문이다. 다만 이런 응급처치한 코드들은 부채가 되고 상환의 의무를 져야 한다.

적절한 대출로 자금을 보다 융통성 있게 사용할 수 있듯이 잠시 기술 부채를 두고 문제를 해결하는 것도 중요하다. 하지만 기술 부채가 많아질수록 그 코드를 유지보수 하는데 더 큰 비용이 들어가기 때문에 적정선을 찾는 것 또한 중요하다.


좋지 않은 코드를 줄이기

좋은 코드를 작성하려면 우선 좋지 않은 코드를 줄여보자. 그리고 격리해보자. 작성된 모든 코드는 결국 유지 보수 비용이 필요하다. 궁극적으로 우리의 코드는 점진적으로 개선이 가능해야 한다.

1. 추출이 아닌, 추상화

추출과 추상화는 무엇이 다를까?

추출(extraction)

추출이란 특별한 기준없이 단순히 밖으로 끌어내는 것을 의미한다.

추상화(abstraction)

'추상화'의 어원은 조금 다르다. ab(먼 개념)와 tract(이끌어내기)의 합성어로, 어떤 대상의 중요한 요점들을 재해석하여 정리한 것이라고 해석할 수 있다.

의존성을 드러내기 위한 추상화

우리가 별도 함수 또는 파일로 추출하는 이유는 여러 가지가 있는데 단순히 중복된 코드 덩어리에 대해 추출하면 재사용하기 어렵다. 오히려 함수 간 의존 관계를 파악하기 위해 비용이 들어가게 된다.

함수를 분리할 때는 그 함수의 역할을 인지하고 하나의 역할만 하도록 정의해야 한다. 즉 의존성을 드러내기 위함이 추출의 목적이 되어야 한다.

한 파일에 여러 로직이 얽혀있을 때 각 코드 조각 중 서로 의존 관계에 있는 것들을 추출해야 한다. 이렇게 추상화가 된 함수는 하나의 목적(역할)을 갖게 되고 의미 있는 추출(추상화)이 이루어진다.

const Spagetti = () => {
  const [page, setPage] = useState(1);
  const [totalPage, setTotalPage] = useState(10);
  const [data, setData] = useState(null);
  const [isLoading, setLoading] = useState(false);

  const handlePage = () => {};
  const fetchData = async () => {
    fetch(page)
  };

  useEffect(() => {
    // data fetch
  }, [])

  return (
    <Component> ... </Componnet>
  )
}

pagination을 관리하는 두 state(page, totalPage)와
data의 상태를 관리하는 두 state(data, isLoading)가 섞여있다.
이 코드가 다른 곳에서 동일하게 사용된다고 가정해보자.

그대로 추출하면 다음과 같이 하나의 custom hooks를 만들게 된다.

function useSpagettiData(initialPage: number, fetchFunction: () => Promise<Data>) {
  const [page, setPage] = useState(1);
  const [totalPage, setTotalPage] = useState(10);
  const [data, setData] = useState(null);
  const [isLoading, setLoading] = useState(false);

  const fetchData = async () => {
    return await fetch(page);
  };

  const handlePage = () => { ... };

  useEffect(() => {
    // data fetch
  }, [])

  return {
    page, data, isLoading, isError, handlePage, ...
  }
}

const Spagetti = () => {
  const { data } = useSpagettiData(1, fetchData)

  return ( ... );
}

일단 기존 Spagetti 컴포넌트는 매우 간결해졌다.

그런데 이 custom hooks가 여러 가지의 일을 하고 있다보니 이름부터 정하기 어렵다. 그리고 인자로 받는 값과 반환하는 값도 많아졌다.

이 코드에 새로운 변경이 추가되면 어떻게 할 수 있을까? data의 초기값을 설정해줘야 한다면? API 응답값에 따라 total page가 달라지는 경우가 있을 수 있다면?

아마 hooks의 세번째 인자로 무언가가 추가되거나 내부에서 어떤 분기를 추가하여 로직을 재구성해야할 것이다.

이제 다시 의존 관계가 있는 것들끼리 추상화를 해보자

// pagination을 관리하는 hooks
function usePagination(initialPage: number) {
  const [page, setPage] = useState(initialPage);
  const [totalPage, setTotalPage] = useState(10);

  const handlePage = () => {};

  return { page, totalPage, handlePage };
}

// data fetch를 관리하는 hooks
function useFetch<T>(fetchFunction: () => Promise<T>, intialValue = null) {
  const [data, setData] = useState(intialValue);
  const [isLoading, setLoading] = useState(false);

  useEffect(() => {
    // data fetch
  }, [])

  return { data, isLoading, isError };
}

const Spagetti = () => {
  const { page, totalPage, handlePage } = usePagination(1);
  const { data } = useFetch(() => fetch(page), intialDadtdad);

  return ( ... );
}

위 코드처럼 새로운 요구사항이 들어왔을 때, 커다란 함수 내부에서 응급처치하지 않아도 된다.
의존성을 기반으로 분리된 여러 함수들은 각자의 역할을 수행하고 있기 때문에 각자의 영역을 침범할 필요가 없다.

분리된 여러 함수의 조합으로 코드를 구성하면 어떠한 변경 사항에 대해 필요한 부분의 함수만 수정하면 된다.

무엇을 분리할지 알았다면 이제 어디에 분리할지 결정해야 한다.

단순히 컴포넌트를 렌더링하는 함수 컴포넌트일 경우, 어느 정도의 레벨로 공유될지에 따라 정의되는 위치가 달라진다. 어떤 함수는 도메인과 강하게 결합해 있어서 특정 페이지에서만 재사용될 수 있고 어떤 함수는 의존 관계없이 어디에서든 사용할 수 있다.

때문에 목적에 따라 맞는 위치에 정의해주는 것 또한 중요하다.


삭제하기 쉬운 코드와 삭제하기 어려운 코드의 분리

아무리 깔끔한 코드를 작성해도 비즈니스 요구사항을 맞추다 보면 어쩔 수 없이 복잡한 코드가 작성된다.

이런 코드는 제3자 입장에서 읽기도 어렵고 섣불리 삭제하기도 어렵다. 복잡한 요구 사항을 담고 있는 코드는 변경에 유연하지 못하기 때문에 별도로 분리해둬야 한다.

좋지 않은 코드가 생산되는 것을 완전히 차단할 수 없다면 제대로 관리될 수 있도록 격리해야 한다.

위에서 코드의 맥락을 이해하는 데 방해가 될 수 있는 요소 중 하나로 주석을 이야기했지만, 이런 경우엔 주석과 함께 격리해두면 기존 코드의 흐름을 끊지 않고 복잡한 코드를 이해하는데 도움을 줄 수 있다.


 * @Note event type이 언제든 서버에서 추가될 수 있다. 하드코딩되어 있는 event name에 추가할 수 있도록 한다.
 */
const targetEventNameRegex = /click_|impression_|push_|scroll_|swipe_|background_/g

 * @Note eventName에 이전에 선택된 eventType_이 prefix로 붙어서 전달되어 이를 subtract 해준다.
 */
useEffect(() => {
  const refinedEventName = eventName?.replace(targetEventNameRegex, '')
  setValue(getName('eventName'), `${eventType}_${refinedEventName ?? ''}`)
}, [])

일관성 있는 코드

최소한의 가독성을 보장하는 방법은 일관성 있는 코드를 작성하는 것이다. 일관성은 합의된 규칙을 기반으로 만들어지며 이 합의된 규칙은 개개인에게 동일하게 다가간다.

코드에 일관성이 지켜진다면 예측이 가능하다. 예측이 가능하다는 것은 어느 곳에 어떤 코드가 위치하는지 예상할 수 있다는 것이다. 프로젝트 팀원 간의 그라운드 룰(Ground Rule)이 필요한 이유이다.

1. Naming

변수명 네이밍도 중요하지만 작성되는 수많은 함수의 네이밍에도 규칙이 있으면 일관성을 지킬 수 있다.

단순히 이벤트 핸들러를 정의할 때도 convention을 지켜서 정의한다면 해당하는 convention의 함수를 보면 이벤트 핸들러라는 예상이 가능해진다.

// prefix: handle
// target: button
// action: click
const handleButtonClick = () => {}

// prefix: on
// action: click
// target: button
const onClickButton = () => {}

위와 같이 prefix, action, target 이 일관된 규칙으로 작성되면 함수를 파악하는데 큰 도움이 된다.

2. Directory

디렉터리 구조는 위에서 말한 ‘어디에 분리할지’와 관련이 있다. 일관된 디렉터리 구조는 전체적인 구조를 파악하는 데 큰 도움이 되고 컴포넌트 간의 관계를 파악하는데에도 도움을 준다.

Top Level의 directory 구성에 따라 어느 곳에 어떤 모듈 또는 컴포넌트들이 위치할지 예측이 된다면 코드를 빠르게 이해할 수 있다.

//case1
src
├── api
├── components
├── hooks
├── model
├── pages
│   ├── contract
│   └── docs
└── utils
//case2
src
├── @shared
│   ├── components
│   ├── hooks
│   ├── models
│   └── utils
├── contract
│   ├── components
│   ├── hooks
│   ├── models
│   ├── utils
│   └── index.ts
├── docs
│   ├── components
│   ├── hooks
│   ├── models
│   ├── utils
│   └── index.ts
└── App.tsx

첫 번째 디렉터리 구조는 코드가 하는 역할에 따라 디렉터리를 분리했고 두 번째 디렉터리 구조는 페이지(도메인 영역)에 따라 디렉터리를 분리했다.

애플리케이션이 커지고 복잡해지면 첫 번째 디렉터리 구조의 경우, 코드가 정의된 곳과 코드 사용되는 곳이 멀어지는 문제점이 발생한다. 두 번째 디렉터리 구조는 이런 문제를 해결할 수 있다. 애플리케이션 전반에서 공통으로 사용되는 것들만 Top Level의 @shared 디렉터리에서 관리하고 나머지는 응집도 높게 각각의 디렉터리에서 관리하는 것이다.

참고자료 : 지역성의 원칙을 고려한 패키지 구조 : 기능별로 나누기


확장성 있는 코드

확장이 어려운 코드는 내부에서 많은 변경이 발생하며 이것은 코드를 읽기 어렵게 만든다. 자연스럽게 생산성도 떨어진다.

스타일링만 되어있는 가장 단순한 Input 컴포넌트를 만든다고 가정해보자.

const Input = styled.input`
  // styling
`;

const Input = ({
  type,
  value,
  onChange,
}) => {
  return (
    <Input type={type} value={value} onChange={onChange}>
  )
}

이 컴포넌트는 확장에 닫혀있다. 확장에 닫혀있다는 뜻은 이 Input 컴포넌트에 새로운 이벤트 핸들러나 값을 전달하기 어려운 구조라는 뜻이다. ref를 전달할 수도 있고 focus, blur 이벤트에 핸들러를 추가할 수도 있다. 이러한 변경이 발생할 때마다 props를 추가해줘야 하기 때문이다.

이 Input 컴포넌트 목적에 따라 달라지겠지만 정말 (굳이) 스타일링만 추가된 Input 컴포넌트를 별도로 만든다면 다음과 같이 만들어야 한다.

const Input = styled.input`
  // styling
`;

const Input = (props: HTMLAttributes<HTMLInputElement>) => {
  return (
    <Input {...props}>
  )
}

기존 input HTML Element가 받을 수 있는 attribute들을 그대로 받을 수 있어야 하는 것이다. 이 컴포넌트가 의미있는 컴포넌트가 되려면 validator에 대한 처리가 추가되면 좀 더 의미있어진다.

const Input = styled.input<{ isValid?: boolean }>`
  // styling
  // error styling
`;

interface Props extends HTMLAttributes<HTMLInputElement> {
  isValid?: boolean;
}


const Input = ({ isValid, ...props }: Props) => {
  return (
    <Input {...props} isValid={isValid}>
  )
}

간단한 예시로 살펴봤다. 이 컴포넌트는 validate를 외부에 위임하고 있는데, 상황에 따라 validator에 대한 부분을 함께 추상화한다면 좀 더 응집도가 높은 컴포넌트를 만들 수도 있다.

하지만 확장에 너무 많이 열려있으면 내부 API를 수정하기 어려워지는 문제도 발생할 수 있기 때문에 이 또한 적정선을 찾아가는 것이 중요하다.


결론

- 코드 간의 의존성을 고민하자.

- 합의된 규칙으로 일관성있게 작성하자.

- 적절하게 확장 가능하도록 설계하자.

- 어쩔 수 없는 코드는 주석과 함께 격리하자.

profile
평생 공부하기

0개의 댓글