- Published on
좋은 코드란 무엇일까?
- Authors
- Name
- 성기동
이 글은 좋은 코드란 무엇일까?의 내용을 가져왔습니다.
모든 개발자는 좋은 코드를 작성하고 싶으리라 생각한다. 누구나 관심 있어 하는 주제인 만큼 나 또한 여러 고민을 거듭해왔고 여태까지의 생각을 정리해보려고 한다. 어떤 프로그램을 구성하는 코드냐에 따라 좋다는 기준이 달라질 수 있을 것 같다. 이 글은 웹 개발, 그중에서도 웹 프런트엔드 개발 관점임을 짚고 넘어간다.
Table of Contents
- 좋은 코드가 도대체 뭐지?
- 왜 좋지 않은 코드가 생산되는가?
- 좋지 않은 코드를 줄이기
좋은 코드가 도대체 뭐지?
‘좋은 코드란?‘이라고 구글링해보면 많은 검색 결과가 나온다. 나도 그렇고 다들 궁금했던듯하다. ‘좋은 코드’란 녀석은 정체도, 실체도 없이 이 세상에 떠돌고 있다. 모두가 ‘좋은 코드’의 기준이 조금씩 다르고 각각의 경험을 기반으로 좋은 코드를 정의하고 있다. 세간에 좋은 코드의 정의는 정말 많다.
- 읽기 쉬운 코드
- 중복이 없는 코드
- 테스트가 용이한 코드
- 등등…
읽기 쉬운 코드가 좋은 코드?
이 제목의 책도 있다. 제목 그대로의 내용을 담고 있고 읽기 쉬운 코드를 작성하기 위해서 지켜야 할 몇 가지를 제안하고 있다.
읽기 쉬우려면 주석을 쓰면 되지 않나?
단순하게 생각해서 읽기 쉽도록 코드를 작성하려면 작성자에게 익숙한 언어를 사용하면 된다. 한국인이라면 영어로 복잡하게 작성된 코드보다 한국어가 익숙하지 않을까? 모든 코드에 한국어로 주석을 상세히 작성해두면 코드를 읽지 않아도 코드가 무엇을 하는지 빠르고 쉽게 파악할 수 있다.
❌
// 이름: 원리금상환반환값
// 설명: 원리금상환을 시도했을 때, 요청자의 신용도에 따라 보여줄 텍스트를 3초 뒤에 반환해주는 함수
// 인자1: m: 요청자에 대한 정보 (신용도 함수로 전달되는 데이터 객체)
// 인자2: pe: 3초가 되기 전에 우선 보여줘야 하는 텍스트
// 반환: a: 보여줘야 하는 값
function a(m, pe) {
let a
const t = s(pe)
const b = dc(m)
setTimeout(() => {
if (b) {
a = dd()
} else {
a = t(b)
}
}, 3000)
if (a) {
return pe
} else {
return a
}
}
(코드는 이해하기 힘들지만) 주석만 읽어봐도 언제 사용해야 하며 무엇을 함수의 인자로 전달하고 무엇이 반환되는지 알 수는 있다.
하지만 주석은 관리하기 어렵다. 주석은 기본적으로 메타데이터이기 때문에 주석의 내용과 함수의 실제 동작이 일치한다고 보장할 수 없다. 수시로 변하는 요구사항을 반영하느라 함수가 수정될 때 주석이 함께 수정되지 않을 수도 있다. 오히려 한두 가지의 잘못된 주석으로 코드를 이해하는데 혼란을 야기할 수 있다.
나한텐 읽기 쉬운데?
코드가 읽기 쉽도록 작성되어야 함은 너무나도 분명하다. 하지만 모든 개발자가 동일한 배경지식을 가진 것은 아니다. 각자 다른 환경에서 코드를 작성하고 읽기 때문에 ‘읽기 쉽다’는 것은 개개인에게 다르게 다가간다.
변수명을 잘 짓는다는 것도, 영어를 모국어로 사용하고 있지 않은 사람들에겐 다르게 다가간다. 어떤 누군가에겐 어색한 영어로 짓는 변수명이 잘 읽힐 수도 있기 때문에 잘 지은 변수명 또한 각자의 배경지식에 따라 기준이 모호하다.
또한 코드를 작성하는 시점에는 작성한 코드에 대한 이해가 100%이기 때문에 아무리 ‘읽기 쉽게’ 작성하고자 노력해도 한계가 있을 수밖에 없다.
읽기 쉬우면 끝인가?
읽기만 쉬우면 될까? 너무 원초적이고 당연한 이야기이기에 와 닿지 않는다. 읽기 쉽다는 것은 단순히 코드를 읽는 것만 의미하는 것이 아니라 이해하는 것까지 포함하기 때문에 나무뿐만 아니라 숲을 볼 때도 쉬워야 한다.
그보다 더 근본적인 이유, 좋은 코드가 읽기 쉬운 코드라면 왜 읽기 쉽도록 작성해야 하는가? 어떻게 읽기 쉽도록 작성하는가? 에 대한 물음으로 좋은 코드에 대한 정의를 다시 한번 생각해 볼 수 있다.
왜 읽기 쉬워야 하지?
테스트가 용이한 코드?
테스트가 용이하면 물론 좋다. 테스트 코드를 작성해두면 심리적 안정감도 생기고 리팩토링에 자신감도 생긴다. 그러나 왜 읽기 쉬운가?에 대한 의문처럼 왜 테스트 코드를 작성하기 용이해야 하는가?이다. 그리고 어떻게 하면 테스트가 용이한 코드를 작성할 수 있을까?에 대한 고민이 추가로 필요하다. 좀 더 근본적인 정의를 찾아 좀 더 깊게 들어가볼 수 있을 것 같다.
‘테스트가 용이하다’는 것은 무엇을 의미하는 것일까?
중복이 없는 코드?
개발자라면 누구나 A와 B에서 동일한 로직을 수행하는 코드를 보는 순간, 추출(extraction)하고 싶어 한다. 동일한 로직을 수행하는 코드가 여기에도 저기에도 있는 것은 굉장히 불편하기 때문이다.
이 불편함은 어디에서 오는 걸까?
오래전에 작성해둔 코드에서 A라는 코드 조각이 두 군데에 퍼져있다는 것을 기억하기 쉽지 않다. 때문에 한 곳의 A만 수정되었을 때 다른 한 곳에서 버그가 발생한다. 이러한 경험에 기반하여 동일한 로직을 수행하는 코드는 별도의 함수로 빼두고 재사용하려고 한다.
그 땐 같았지만 지금은 같지 않은 하지만 이렇게 공통으로 사용하고 있는 코드를 수정하는 건 어렵다. 분명 A라는 코드 조각을 동일한 로직을 수행하여 별도로 추출했지만, 시간이 지나고 A와의 조금 다른 A* 역할로 수정이 필요해졌다.
function task({ isFromA, isFromB, isFromC }) {
const a = foo();
const b = bar();
// 🥶 OMG
const option = isFromA ? "a" : isFromB ? "b" : isFromC ? "c" : "";
return zar(a, b, option);
}
그때는 중복이었던 코드 조각을 필요에 의해 추출했지만, 지금은 아니라서 다시 합치거나 수정하고 있다. 보통 하는 일은 동일하지만, 그 목적이 다른 코드를 추출했을 때, 많이 발생하는데 이것은 변경 사항을 반영하는 데 비용이 들어가게 한다.
추출은 어떤 ‘기준’으로 해야할까?
왜 좋지 않은 코드가 생산되는가?
사실 우리는 코드를 작성할 시점에는 모두 좋은 코드를 작성하기 위해 노력한다. 그러나 이것을 놓치는 시점이 존재하고 좋지 않은 코드가 생산되는 것은 아닐까.
어쩌다가 코드가 상하게 되는걸까?
우리는 어떤 상황들을 마주하게 되는가?
개발자는 새로운 프로젝트를 만들기도 하지만 기존의 코드를 수정하기도 하고 다시 만들기도 한다. 그리고 새롭게 만든 프로젝트는 세상의 빛을 보기도 전에 수정될 가능성이 (매우) 많다.
- 이미 운영 중인 서비스의 CS 대응하며 수정하기
- 변경된 요구사항, 인터페이스를 반영하기
- 어제 또는 방금 작성한 코드 수정하기
- …
쓰이지 않는 코드
쓰지 않게 된 코드(Dead code)가 있는데, 이 코드가 어디에서 쓰일지도 모른다는 불안감이 있다면 감히 삭제하지 못할 것이고 이 코드는 깨진 유리창 중 하나가 된다.
이 불안감은 어디에서 오는 것일까?
거리 코드가 동작하는 곳과 코드가 정의된 곳 간의 물리적 거리가 멀면 파악하기 힘들어진다. 보고 있는 코드가 일정 범위에서만 사용된다는 확신이 있다면 그 범위만 확인 후 사용하지 않는 코드라는 확신을 얻을 수 있다.
순수하지 않은 함수 함수 외부의 어떤 값을 기반으로 동작하는 함수는 그 side effect를 제어하기 어렵다. 함수의 입출력만 확인하여 함수 내부를 수정할 수 없다면 수정하기 까다롭다.
function getWage(name) {
return this.getMySalary(name) / this.getTotalTime(new Date());
}
getWage
란 함수는 name
이라는 입력 외에도 getMySalary
라는 입력으로 정의되지 않은 입력이 존재하며 new Date()
라는 항상 변하는 값에 의존한다.
응급처치를 한 코드
중복으로 빼뒀지만 사용하는 곳에서 다른 로직을 추가해줘야 할 때를 종종 마주한다. 이 때 수정으로 인한 Side effect가 두려워 함수에 입력을 추가하던가 옵션 값을 추가하여 내부에서 억지로 처리할 때 그 코드는 좋지 않은 코드가 된다.
이렇게 응급처치가 몇 번 이뤄지면 누구도 알아보기 어려운 함수가 탄생한다. 당연히 이런 함수는 우리가 고민하는 좋은 코드라고 할 수 없다.
기술부채 응급처치가 무조건 나쁘다는 말이 아니다. 당장 문제 해결을 하는 데에 이만큼 빠른 대응이 없다. 현재 서비스에서 발생하는 버그를 잡아야 하는데, 전체적인 구조 리팩토링을 할 수 없기 때문이다. 다만 이런 응급처치한 코드들은 부채가 되고 상환의 의무를 져야 한다.
자금이 필요하여 은행에서 대출을 받았다면 대출받은 금액을 언젠가 상환해야 하는 것과 동일한 이치이다. 적절한 대출로 자금을 보다 융통성 있게 사용할 수 있다. 마찬가지로 잠시 기술 부채를 두고 문제를 해결하는 것도 중요하다. 과도한 대출은 이자의 부담이 심한 것처럼 기술 부채가 많아질수록 그 코드를 유지보수 하는데 더 큰 비용이 들어가기 때문에 적정선을 찾는 것 또한 중요하다.
좋지 않은 코드를 줄이기
좋지 않다고 생각되는 코드들을 살펴봤다. 좋은 코드란 좋지 않은 코드가 없는 코드를 말한다. 좋은 코드를 작성하려고 노력하는 대신 좋지 않은 코드를 줄여보자. 그리고 격리해보자. 작성된 모든 코드는 결국 유지 보수 비용이 필요하다. 궁극적으로 우리의 코드는 점진적으로 개선이 가능해야 한다.
추출이 아닌, 추상화
추출과 추상화는 무엇이 다를까?
추출(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 ( ... );
}
위 상황처럼 새로운 요구사항이 들어왔을 때, 커다란 함수 내부에서 응급처치하지 않아도 된다. 의존성을 기반으로 분리된 여러 함수들은 각자의 역할을 수행하고 있기 때문에 각자의 영역을 침범할 필요가 없다.
분리된 여러 함수의 조합으로 코드를 구성하면 어떠한 변경 사항에 대해 필요한 부분의 함수만 다른 함수로 수정하면 된다.
Where 무엇을 분리할지 알았다면 이제 어디에 분리할지 결정해야 한다.
단순히 컴포넌트를 렌더링하는 함수 컴포넌트일 경우, 어느 정도의 레벨로 공유될지에 따라 정의되는 위치가 달라진다. 어떤 함수는 도메인과 강하게 결합해 있어서 특정 페이지에서만 재사용될 수 있고 어떤 함수는 의존 관계없이 어디에서든 사용할 수 있다.
목적에 따라 맞는 위치에 정의해주는 것 또한 중요하다. (아래 계속)
삭제하기 쉬운 코드와 삭제하기 어려운 코드의 분리
아무리 깔끔한 코드를 작성해도 비즈니스 요구사항을 맞추다 보면 어쩔 수 없이 복잡한 코드가 작성된다. 사용하고 있는 라이브러리에서 발생한 버그일 수도 있고 여러 브라우저에 대응하다 탄생한 코드일 수 있다.
이런 코드는 제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 ?? ""}`);
}, []);
하드코딩이 불가피한 경우, 비즈니스 요구사항이 복잡하여 hacky한 코드를 작성해야 하는 경우 등 어쩔 수 없이 좋지 않게 작성되는 코드들은 주석과 함께 격리시켜 관리할 수 있다.
일관성 있는 코드
최소한의 가독성을 보장하는 방법은 일관성 있는 코드를 작성하는 것이다. 일관성은 합의된 규칙을 기반으로 만들어지며 이 합의된 규칙은 개개인에게 동일하게 다가간다.
코드에 일관성이 지켜진다면 예측이 가능하다. 예측이 가능하다는 것은 어느 곳에 어떤 코드가 위치하는지 예상할 수 있다는 것이다. 프로젝트 팀원 간의 그라운드 룰(Ground Rule)이 필요한 이유이다.
- Naming 변수명 네이밍도 중요하지만 작성되는 수많은 함수의 네이밍에도 규칙이 있으면 일관성을 지킬 수 있다. react hooks API도 hooks임을 네이밍에서부터 드러내기 위해 use-* prefix를 사용한다.
단순히 이벤트 핸들러를 정의할 때도 convention을 지켜서 정의한다면 해당하는 convention의 함수를 보면 이벤트 핸들러라는 예상이 가능해진다.
// Case 1.
// prefix: handle
// target: button
// action: click
const handleButtonClick = () => {};
// Case 2.
// prefix: on
// action: click
// target: button
const onClickButton = () => {};
위와 같이 prefix
, action
, target
이 일관된 규칙으로 작성되면 함수를 파악하는데 큰 도움이 된다.
- 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
디렉터리에서 관리하고 나머지는 응집도 높게 각각의 디렉터리에서 관리하는 것이다.
이런 디렉터리 구조에 대한 고민이 있던 와중에 안희종님의 글 - 지역성의 원칙을 고려한 패키지 구조: 기능별로 나누기을 읽었는데, 참고하면 좋을 것 같다.
확장성 있는 코드
확장이 어려운 코드는 내부에서 많은 변경이 발생하며 이것은 코드를 읽기 어렵게 만든다. 자연스럽게 생산성도 떨어진다.
스타일링만 되어있는 가장 단순한 Input 컴포넌트를 만든다고 가정해보자.
const Input = styled.input`
// styling
`;
const Input = ({
type,
value,
onChange,
}) => {
return (
<Input type={type} value={value} onChange={onChange}>
)
}
이 컴포넌트는 확장에 닫혀있다. (다행히 type
은 props로 받았다.) 확장에 닫혀있다는 뜻은 이 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를 수정하기 어려워지는 문제도 발생할 수 있다. 라이브러리의 경우 확장성이 높으면 많은 use-case를 대응할 수 있겠지만 내부적으로 큰 변화가 있을 때, Breaking Change가 생길 가능성이 높아진다. 이 또한 적정선을 찾아가는 것이 무엇보다 중요하다.
결론
So What?
좋은 코드를 왜 작성해야 하는지부터 고민했던 과정을 글로 정리해보았다.
- 코드 간의 의존성을 고민하자.
- 합의된 규칙으로 일관성있게 작성하자.
- 적절하게 확장 가능하도록 설계하자.
- 어쩔 수 없는 코드는 주석과 함께 격리하자.
결론없다. 결국 투입되는 비용 대비 결과물이 있어야 하는 엔지니어링의 영역이기 때문에 상황에 따라 트레이드 오프를 고민하여 코드를 관리해나가는 것이 무엇보다 중요하다고 생각한다.