좋은 코드란 무엇인가

정재욱·2022년 7월 4일
3

Development_common_sense

목록 보기
1/4

Jbee 님의 좋은 코드를 왜 작성해야 하는지부터 고민했던 과정을 글을 읽고 약간의 각색을 추가하여 작성해봤습니다.
더 자세한 내용은 Jbee 님의 좋은 코드를 왜 작성해야 하는지부터 고민했던 과정을 글을 참고해주세요.

좋은 코드란?

‘좋은 코드란?‘이라고 구글링해보면 많은 검색 결과가 나온다. 나도 그렇고 다들 궁금했던듯하다. ‘좋은 코드’란 녀석은 정체도, 실체도 없이 이 세상에 떠돌고 있다. 모두가 ‘좋은 코드’의 기준이 조금씩 다르고 각각의 경험을 기반으로 좋은 코드를 정의하고 있다. 세간에 좋은 코드의 정의는 정말 많다.

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

읽기 쉬운 코드가 좋은 코드인가?

코드가 읽기 쉽도록 작성되어야 함은 너무나도 분명하다. 하지만 모든 개발자가 동일한 배경지식을 가진 것은 아니다. 각자 다른 환경에서 코드를 작성하고 읽기 때문에 ‘읽기 쉽다’는 것은 개개인에게 다르게 다가간다.

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

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

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

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

테스트가 용이한 코드가 좋은 코드인가?

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

중복이 없는 코드가 좋은 코드인가?

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

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


그렇다면 좋은 코드는 어떻게 작성해야 하는가?

1. 좋지 않은 코드를 줄이기

사실 우리는 코드를 작성할 시점에는 모두 좋은 코드를 작성하기 위해 노력한다. 그러나 이것을 놓치는 시점이 존재하고 좋지 않은 코드가 생산되는 것은 아닐까.

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

추출이 아닌, 추상화

  • 추출(extraction)
    ‘추출하다(extract)‘의 어원을 살펴보면 ex(밖으로)와 tract(끌어내기)의 합성어이다. 특별한 기준없이 단순히 밖으로 끌어내는 것을 의미한다.

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

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

우리가 별도 함수 또는 파일로 추출하는 이유는 여러 가지가 있다. 다른 곳에서 재사용하기 위해 분리하고 단지 파일이 커져서 분리한다. 단순히 중복된 코드 덩어리(chunk)에 대해 추출하면 재사용하기 어렵다. 오히려 함수 간 의존 관계를 파악하기 위해 비용이 들어가게 된다. 잘못 추출한 것이다.

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

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

Example - before

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를 만들게 된다.

Example - Bad

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의 세번째 인자로 무언가가 추가되거나 내부에서 어떤 분기를 추가하여 로직을 재구성해야할 것이다.

의존 관계가 있는 것들끼리 추상화를 해보자.

Example - After

// 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 ( ... );
}

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

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

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

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

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

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

아무리 깔끔한 코드를 작성해도 비즈니스 요구사항을 맞추다 보면 어쩔 수 없이 복잡한 코드가 작성된다. 사용하고 있는 라이브러리에서 발생한 버그일 수도 있고 여러 브라우저에 대응하다 탄생한 코드일 수 있다.

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

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

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

3. 일관성 있는 코드

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

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

1. Naming
변수명 네이밍도 중요하지만 작성되는 수많은 함수의 네이밍에도 규칙이 있으면 일관성을 지킬 수 있다. react hooks API도 hooks임을 네이밍에서부터 드러내기 위해 use-* prefix를 사용한다.

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

2. Directory
디렉터리 구조는 위에서 말한 ‘어디에 분리할지’와 관련이 있다.

우선 디렉터리 구조와 아키텍처는 비교 대상이 아니다. (아키텍처 !== 디렉터리 구조) 일관된 디렉터리 구조는 전체적인 구조를 파악하는 데 큰 도움이 되고 컴포넌트 간의 관계를 파악하는데에도 도움을 준다.

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

src
├── api
├── components
├── hooks
├── model
├── pages
│   ├── contract
│   └── docs
└── utils

components 디렉터리라면 디렉터리 구조가 컴포넌트 hierarchy를 드러낼 수 있고 pages 디렉터리라면 route path를 드러낼 수 있다. 코드가 어느 곳에 위치하는지 예상할 수 있도록 디렉터리 구조를 구성하면 코드를 파악하는데 도움이 된다.

다음과 같은 구조는 어떨까?

src
├── @shared
│   ├── components
│   ├── hooks
│   ├── models
│   └── utils
├── contract
│   ├── components
│   ├── hooks
│   ├── models
│   ├── utils
│   └── index.ts
├── docs
│   ├── components
│   ├── hooks
│   ├── models
│   ├── utils
│   └── index.ts
└── App.tsx

첫 번째 디렉터리 구조는 코드가 하는 역할에 따라 디렉터리를 분리했고, 두 번째 디렉터리 구조는 페이지(도메인 영역)에 따라 디렉터리를 분리했다. 애플리케이션이 커지고 복잡해지면 첫 번째 디렉터리 구조의 경우, 코드가 정의된 곳과 코드 사용되는 곳이 멀어지는 문제점이 발생한다.

두 번째 디렉터리 구조는 Featured Based라고 할 수 있는데 이렇게 구조를 가져간다면 문제를 해결할 수 있다. 애플리케이션 전반에서 공통으로 사용되는 것들만 Top Level의 @shared 디렉터리에서 관리하고 나머지는 응집도 높게 각각의 디렉터리에서 관리하는 것이다.

4. 확장성 있는 코드

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


결론

  • 코드 간의 의존성을 고민하자.
  • 합의된 규칙으로 일관성있게 작성하자.
  • 적절하게 확장 가능하도록 설계하자.
  • 어쩔 수 없는 코드는 주석과 함께 격리하자
profile
AI 서비스 엔지니어를 목표로 공부하고 있습니다.

0개의 댓글