들어가면서
"클린 코드란 무엇일까" 이 주제는 개발자라면 한번쯤은 해봤을 고민이라고 생각합니다. 갑자기 이 고민을 하게 된 이유는 실무에서 분명 보기엔 깔끔한 코드인데, 수정하기에는 어려운 코드들을 몇 차례 경험하면서입니다. 이때부터 '클린 코드가 뭘까?'라는 고민을 다시 하게 되었습니다. 동시에 '그동안 생각해온 클린 코드의 기준이 뭐였을까?' 되돌아보게 되었습니다. 그저 교과서 개념처럼 '다른 사람이 읽기 쉬운 코드'라고 생각해왔던 거 같습니다. 이는 클린 코드의 수많은 조건 중 하나일 뿐 전부가 아니었다는 걸 깨달았습니다. 클린 코드란 무엇인지 정답을 알고 싶었지만 정답은 존재하지 않았고 사람마다, 팀마다 기준이 조금씩 다를 수 있다는 걸 배웠습니다. 개인적으로는 토스에서 발표한 '실무에서 바로 쓰는 Frontend Clean Code'라는 주제의 세미나를 보면서 설득력 있는 클린 코드의 기준이라는 생각을 했습니다. 토스에서 발표한 내용을 정리해보았습니다.
목차
- 실무에서 클린 코드의 의의
- 안일한 코드 추가의 함정
- 로직을 빠르게 찾을 수 있는 코드
- 실천 해봅시다
1. 실무에서 클린 코드의 의의
실무에서 클린 코드의 의미는 유지보수 시간을 단축할 수 있는 것입니다.
단순히 읽기 좋고 짧은 코드가 깨끗한 코드는 아닙니다. 코드가 조금 길더라도
빠르게 로직을 파악하고 수정하기 쉽게 작성한 코드가 실무에서는 클린한 코드라고 할 수 있습니다.
클린 코드에 정답이 있는 건 아닙니다.
덧붙이자면
리팩터링 2의 저자인 마틴 파울러도 좋은 코드의 기준을 '얼마나 수정하기 쉬운가'라고 말한적이 있습니다. 수정하기 쉬운 코드가 중요한 이유는 새로운 기능을 추가/수정할때도, 오류가 발생했을 때도, 협업할 때에도 많은 시간을 아껴주기 때문입니다. 또한 프로그램이 그 만큼 건강한 구조를 가졌다는 증거가 될 수 있습니다.
2. 안일한 코드 추가의 함정
[예시 상황]
기존 로직: 질문하기 버튼을 누르면 약관 동의 팝업을 띄우고 질문을 제출합니다.
수정할 로직: 질문하기 버튼을 누르고 이미 연결된 전문가가 있으면, 연결 전문가 팝업을 띄우고 해당 전문가에게 질문을 제출합니다.
[기존 코드]
function QuestionPage() {
const [popupOpened, setPopupOpened] = useState(false);
async function handleQuestionSubmit() {
const 약관동의 = await 약관동의_받아오기();
if (!약관동의) {
await 약관동의_팝업열기();
}
await 질문전송(questionValue);
alert('질문이 등록되었어요.')
}
return(
<main>
<form>
<textarea placeholer='어떤 내용이 궁금한가요?'/>
<Button onClick={handleQuestionSubmit}>질문하기</Button>
</form>
</main>
)
}
[요구사항 반영 코드]
function QuestionPage() {
const [popupOpened, setPopupOpened] = useState(false);
async function handleQuestionSubmit() {
const 연결전문가 = await 연결전문가_받아오기();
if (연결전문가 !== null) {
setPopupOpened(true);
} else {
const 약관동의 = await 약관동의_받아오기();
if (!약관동의) {
await 약관동의_팝업열기();
}
await 질문전송(questionValue);
alert("질문이 등록되었어요.");
}
}
async function handleMyExpertQuestionSubmit() {
await 연결전문가_질문전송(questionValue, 연결전문가.id);
alert(`${연결전문가.name}에게 질문이 등록되었어요.`);
}
return (
<main>
<form>
<textarea placeholer="어떤 내용이 궁금한가요?" />
<Button onClick={handleQuestionSubmit}>질문하기</Button>
</form>
{setPopupOpened && (
<연결전문가팝업 onSubmit={handleMyExpertQuestionSubmit}/>
)}
</main>
);
}
그냥 봤을 때는 기능들이 중간중간 잘 추가된 것처럼 보일 수 있습니다. 하지만 전체적인 그림을 보는 것이 중요합니다. 하지만 위 코드는 아래와 같은 문제점들을 가지고 있습니다.
- 하나의 목적인 코드가 흩뿌려져 있다. (연결 전문가 관련된 로직들이 여기저기 흩어져 있다.)
- 하나의 함수가 여러 가지 일을 하고 있다. (handleQuestionSubmit 함수는 연결 전문가 받아오기, 약관 동의받아오기, 질문 전송 3가지의 일을 한꺼번에 하고 있다.)
- 함수의 세부 구현 단계가 제각각이다. (handleQuestionSubmit과 handleMyExpertQuestionSubmit 함수는 비슷한 기능을 담당하고 있음에도 불구하고 내부 로직이 많이 다르다.)
[요구사항 리팩터링 코드]
function QuestionPage() {
const 연결전문가 = useFetch(연결전문가_받아오기);
async function handleNewExpertQuestionSubmit() {
await 질문전송(questionValue);
alert('질문이 등록되었어요.')
}
async function handleMyExpertQuestionSubmit() {
await 연결전문가_질문전송(questionValue, 연결전문가.id);
alert(`${연결전문가.name}에게 질문이 등록되었어요.`);
}
async function openPopupToNotAgreedUsers() {
const 약관동의 = await 약관동의_받아오기();
if(!약관동의) {
await 약관동의_팝업열기();
}
}
return (
<main>
<form>
<textarea placeholder='어떤 내용이 궁금한가요?' />
{연결전문가.connected ? (
<PopupTriggerButton
popup={(<연결전문가팝업 onButtonSubmit={handleMyExpertQuestionSubmit/>)}
>
질문하기 </PopupTriggerButton>
) : (
<Button onClick={async () => {
await openPopupToNotAgreedUsers();
await handleNewExpertQuestionSubmit();
}}>
질문하기
</Button>
)}
</form>
</main>
)
}
첫째로, PopupTriggerButton 컴포넌트를 만들어 연결 전문가 팝업 관련된 로직을 모았습니다. 기존 코드에서는 팝업을 띄우는 버튼과 팝업이 별개로 위치해 있었지만 이제는 버튼, 버튼 이름, 팝업이 PopupTriggerButton 컴포넌트 안에서 모두 해결되고 있습니다. 두 번째는 한 가지 역할만 하는 함수로 나누었습니다. 마지막 세 번째는 함수를 쪼개면서 각 함수 세부 구현 단계도 통일성 있게 작성했습니다.
코드는 좀 더 길어졌지만 관련된 코드가 한 곳에 모여있고, 각 함수들도 무엇을 담당하고 있는지 명확해졌습니다. 그리고 각각의 함수들의 세부사항이 비슷해서 코드를 파악하는데 더 용이해졌습니다. 앞서 말했다시피 클린 코드는 짧은 코드가 아니라 원하는 로직을 빠르게 파악하고 수정할 수 있는 코드입니다.
3. 로직을 빠르게 찾을 수 있는 코드
빠르게 로직을 찾을 수 있는 코드는 응집도, 단일 책임, 추상화 이 세 가지가 잘 조합된 코드입니다.
- 하나의 목적을 가진 코드가 응집해있어야 하고
- 하나의 함수는 하나의 역할을 맡아야 한다
- 함수의 세부 구현 단계가 제각각이지 않아야 한다. (핵심 개념을 필요한 만큼 노출?)
1) 응집도 - 무엇을 뭉쳐야 하나?
- 당장 몰라도 되는 디테일
[예시 코드]
function QuestionPage() {
const [popupOpened, setPopupOpened] = useState(false); // 디테일
async function handleClick() {
setPopupOpened(true);
}
function handlePopupSubmit() { // 팝업 버튼 클릭 시 액션
await 질문전송(연결전문가.id);
alert("질문을 전송했습니다.");
}
return(
<>
<button onClick={handleClick}>질문하기</button>
<Popup title='보험 질문하기' open={popupOpened}> // 제목 / 디테일: 컴포넌트 마크업
<div>전문가가 설명드려요</div> // 내용
<button onClick={handlePopupSubmit}>확인</button>
</Popup>
</>
)
}
[리팩터링 코드]
function QuestionPage() {
const [openPopup] = usePopup()
async function handleClick() {
const confirmed = await openPopup({
title: '보험 질문하기',
contents: <div>전문가가 설명드려요</div>
});
if (confirmed) {
await submitQuestion();
}
}
async function submitQuestion(연결전문가) { // 팝업 버튼 클릭 시 액션
await 질문전송(연결전문가.id);
alert("질문을 전송했습니다.");
}
return <button onClick={handleClick}>질문하기</button>
}
[설명]
openPopup이라는 커스텀 훅에 세부 구현을 숨겨 놓고 핵심 데이터들인 제목, 내용, 액션은 바깥에서 넘깁니다.
세부 구현은 마크업이나 팝업을 열고 닫을 때 사용하는 상태 값들을 말합니다. 바깥에서 넘겨주는 매개변수 없이 몽땅 커스텀 훅에 넣어놓고 훅만 불러와서 사용하는 방법도 있지만 이는 좋은 방법은 아닙니다. 세부 구현 사항을 읽어야만 커스텀 훅을 이해할 수 있기 때문입니다. 이는 커스텀 훅의 안티 패턴으로 여겨지고 있습니다. 반대로 위 예시에 있는 openPopup 커스텀 훅처럼 바깥에서 핵심 데이터들을 넘겨주면, 넘겨주는 데이터들만 봐도 이 커스텀 훅이 뭘 하는지 바로 파악할 수 있습니다. 이를 선언적 프로그래밍이라고 합니다.
잠시 선언적 프로그래밍과 명시적 프로그래밍에 대해 가볍게 짚고 넘어가겠습니다.
선언적 프로그래밍의 특징은 다음과 같습니다.
- 무엇을 하는 함수인지 빠르게 이해 가능
- 세부 구현은 내부에 뭉쳐 둠.
- 핵심 데이터만 바꿔서 재사용 가능.
[선언적 프로그래밍]
<Popup
onSubmit={회원가입}
onSuccess={프로필로이동}
/>
반대로 명령형 프로그래밍의 특징은 아래와 같습니다.
- 로직을 하나하나 명령하듯 작성합니다.
- 세부 구현이 모두 노출되어 있어서 커스텀하기 쉽습니다
- 하지만 코드를 읽는데 오래 걸리고 재사용이 어렵다는 단점이 있습니다.
[명령형 프로그래밍]
<Popup>
<button onClick={async () => {
const res = await 회원가입();
if(res.success) {
프로필로이동();
}
}}>전송</button>
</Popup>
2) 단일 책임
- 하나의 일을 하는 뚜렷한 이름의 함수 또는 기능성 컴포넌트를 만들어야 합니다.
1. 한 가지 일만 하는 함수
[예시 코드]
async function handle질문제출() {
const 약관동의 = await 약관동의_받아오기();
if(!약관동의) {
await 약관동의_팝업열기();
}
await 질문전송(questionValue);
alert('질문이 등록되었어요');
const 연결전문가 = await 연결전문가_받아오기();
if (연결저문가 !== null) {
await 연결전문가_질문전송(questionValue);
alert(`${연결전문가.name}에게 질문이 등록되었어요`);
}
}
[리팩터링 코드]
async function handle약관동의팝업() {
const 약관동의 = await 약관동의_받아오기();
if(!약관동의) {
await 약관동의_팝업열기();
}
}
async function handle새전문가질문제출() {
await 질문전송(questionValue);
alert('질문이 등록되었어요');
}
async function handle연결전문가질문제출() {
const 연결전문가 = await 연결전문가_받아오기();
if (연결저문가 !== null) {
await 연결전문가_질문전송(questionValue);
alert(`${연결전문가.name}에게 질문이 등록되었어요`);
}
}
2. 한 가지 일만 하는 기능성 컴포넌트
[예시 코드 1] - 아쉬운 점은 버튼 클릭 시, log를 기록하는 함수와 API 호출하는 함수 2개가 섞여 있다는 것입니다.
<button onClick={async () => {
log('제출 버튼 클릭');
await openConfirm();
}}
/>
[리팩터링] - 버튼 클릭 시, 로그를 찍는 컴포넌트를 따로 만들고 API를 호출하는 openConfirm 함수만 관리한다.
<LogClick message='제출 버튼 클릭'>
<button onClick={openConfirm} />
<LogClick/>
[예시 코드 2] - 아쉬운 점: 옵서버를 다는 코드 세부 구현과 API 콜을 하는 코드가 섞여 있다.
const targetRef = useRef(null);
useEffect(()=> {
const observer = new IntersectionObserver(([{isIntersecting}]) => {
if(isIntersecting) {
fetchCats(nextPage);
}
});
return () => {
observer.unobserve(targetRef.current);
};
})
return <div ref={targetRef}>더 보기</div>
[리팩터링 코드] - 상위 컴포넌트를 만들어 세부 구현을 숨겨놓고, 세부 구현과 API 콜과 영역을 분리합니다. 이제 API 콜만 신경 쓰면 됩니다.
<IntersectionArea onImpression={() => fetchCats(nextPage)}>
<div>더 보기</div>
</IntersectionArea>
3. 추상화
[예시 코드 1] - 컴포넌트의 추상화
<div style={팝업스타일}>
<button onClick={async () => {
const res = await 회원가입();
if (res.success) {
프로필로이동();
}
}}>전송</button>
</div>
[추상화 코드] - submit과 success 핵심 액션만 남기고 세부 구현 사항들은 모두 추상화합니다.
<Popup
onSubmit={회원가입}
onSuccess={프로필로이동}
/>
[예시 코드 2] - 함수의 추상화
const planner = await fetchPlanner(plannerId)
const label = planner.new ? '새로운 상담사' : '연결중인 상담사';
[추상화 코드]
const label = await getPlannerLabel(plannerId)
추상화 단계
'얼마나 추상화할 것인가?'에 대해 고민이 필요합니다.
추상화 단계가 일관성이 있어야 세부 구현 단계가 제각각이지 않습니다.
만약 각 컴포넌트들 마다 세부 구현들이 다르다면 코드를 읽을 때 혼란스러울 수 있습니다.
특정 메시지를 띄우는 코드를 예시로 단계별 추상화를 해보겠습니다.
Level 0 - 코드를 하나하나 적어주었습니다.
<Button onClick={showConfirm}>
전송
</Button>
{isShowConfirm && (
<Confirm onClick={() => {showMessage('성공')}} />
)}
Level 1 - 함수와 메시지를 보내 메시지를 띄울 수 있습니다.
<ConfirmButton onConfirm={() => {showMessage('성공')}}>
전송
</ConfirmButton>
Level 2 - 메시지만 보내 메시지를 띄울수 있습니다.
<ConfirmButton message='성공'>
전송
</ConfirmButton>
추상화를 하는 게 무조건 좋은 방법일까요?
그렇지 않습니다. 추상화 수준이 섞여있으면 코드를 읽을 때 혼란스러울 수 있지만 무조건 추상화를 하는 것이 좋은 방법은 아닙니다. 성급한 추상화는 오히려 코드의 유연성이 떨어져 유지 보수할 때 리소스가 더 많이 필요할 수 있습니다. 코드의 유연성을 생각했을 때 추상화를 신중하게 해야 합니다. 코드의 중복이 어느 수준인지, 추후 어떻게 변화될 가능성이 있는지 잘 따져 봐야 합니다. 신중하게 고려한 뒤 높은 추상화 혹은 낮은 추상화 단계로 상황에 따라 일관성 있게 코드를 정리해야 합니다.
4. 실천해봅시다
1) 담대하게 기존 코드 수정하기.
과감하게 구조를 뜯어야 클린한 코드를 유지할 수 있습니다.
2) 큰 그림 보는 연습하기.
'그때는 맞고 지금은 틀릴 수 있다' 항상 염두하기. 기능 추가 자체는 클린해도 전체적인 코드는 어지러울 수 있으니 큰 그림을 항상 검토해야 합니다.
3) 팀과 함께 공감대 형성하기.
개선할 부분을 공유하고 해결책을 찾아야 합니다. 해결책이 당장 나오지 않아도 됩니다. 문제만 공유하고 해결책을 찾는 시간을 서로 가지면 됩니다. 명시적으로 소통하는 시간이 필요합니다.
4) 문서로 적어 보기.
'향후 리스크가 무엇인지' / '어떻게 개선할 수 있는지'
'프론트엔드 > React' 카테고리의 다른 글
Next/image 컴포넌트의 내부 동작 원리를 알아보자! (0) | 2023.08.01 |
---|---|
리액트 image 어디에다 저장해야할까 (public vs src) (0) | 2022.09.25 |
리액트 불변성이란 무엇이고, 왜 지켜야 할까? (2) | 2021.06.24 |
리액트에서 cryptoJS 사용하는 법 (0) | 2021.04.15 |
간단하지만 명료하게 리액트 의존성 배열 정리 (0) | 2020.10.13 |