
이 글은 개발 전공 서적을 읽고, 책의 전반적인 내용과 인상 깊었던 개념을 중심으로 개인적인 이해와 함께 정리한 글입니다.
1장. 깨끗한 코드
르블랑의 법칙 (leblanc's Law)
Later equals never 나중은 결코 오지 않는다
사람이 짜는 코드는 항상 완벽할 수는 없습니다. 상황에 맞게 개선 및 리팩토링을 해야하고, 더 좋은 코드가 있다면 교체할 수 있어야 합니다. 책의 내용처럼 나쁜 코드는 쌓일수록 팀 전체의 생산성을 점차 떨어뜨리고, 이를 해결하기 위해 추가적인 인력이 필요하기 때문에 일단 완성부터하고 나중에 고쳐야지라는 마음가짐보다는 처음부터 깨끗한 코드를 짜는 것이 중요한 것 같습니다.
이 책에서는 나쁜 코드의 위험성을 크게 세 가지로 들고 있습니다.
- 원대한 재설계의 꿈
현재 시스템의 코드들이 엉망이라 새로 시스템을 만들면 해결될 것이라고 착각하지만, 기존 시스템의 복잡성과 비즈니스 요구사항을 완벽하게 반영하기 어렵다. - 태도
나쁜 코드의 위험을 이해하지 못하는 관리자의 말을 그대로 따르는 것은 전문가답지 못하고, "시간이 부족해서", "상사가 시켜서"와 같은 이유들로 발생된 나쁜 코드는 프로젝트를 실패로 이끄는 큰 위험이다. 결정적으로 그 실패의 원인이 나쁜 코드라면 가장 큰 책임은 전적으로 프로그래머에게 존재한다. - 원초적 난제
나쁜 코드는 엉망진창의 코드로 이루어져있기 때문에, 오히려 깨끗한 코드보다 프로젝트의 기한을 맞추기가 어렵다.
깨끗한 코드란?
깨끗한 코드에 대해 개발 분야의 유명인사(?)들의 정의를 살펴보겠습니다.
비야네 스트롭스트룹 - C++ 창시자
나는 우아하고 효율적인 코드를 좋아한다. 논리가 간단해야 버그가 숨어들지 못한다. 의존성을 최대한 줄여야 유지보수가 쉬워진다.
오류는 명백한 전략에 의거해 철저히 처리한다. 성능을 최적으로 유지해야 사람들이 원칙없는 최적화로 코드를 망치려는 유혹에 빠지지 않는다. 깨끗한 코드는 한 가지를 제대로 한다.
데이브 토마스, 앤디 헌트 - 실용주의 프로그래머
깨진 창문이론을 기술적 부채를 관리하는 은유적 표현으로 사용
► 건물의 깨진 창문이 있다면 사람들은 다른 창문이 깨져도 상관하지 않는다. 즉, 깨진 창문 하나가 쇠퇴의 과정을 시작하게 만드는 원인이다. 따라서, 나쁜 코드가 있다면 프로세스를 멈추고 그 자리에서 해결해야 한다.
그레디 부치 - Object Oriented Analysis and Design with application 저자
깨끗한 코드는 단순하고 직접적이다. 깨끗한 코드는 잘 쓴 문장처럼 읽힌다. 깨끗한 코드는 결코 설계자의 의도를 숨기지 않는다. 오히려 명쾌한 추상화와 단순한 제어문으로 가득하다.
'big' 데이브 토마스 - OTI 창립자이자 이클립스 전략의 대부
깨끗한 코드는 작성자가 아닌 사람도 읽기 쉽고 고치기 쉽다. 단위 테스트 케이스와 인수 테스트 케이스가 존재한다. 깨끗한 코드에는 의미있는 이름이 붙는다. 특정 목적을 달성하는 방법은(여러 가지가 아니라) 하나만 제공한다. 의존성은 최소이며 각 의존성을 명확히 정의한다. API는 명확하며 최소로 줄였다. 언어에 따라 필요한 모든 정보를 코드만으로 명확히 표현할 수 없기에 코드는 문학적으로 표현해야 마땅하다.
마이클 페더스 - Working Effectively with Legacy Code의 저자
깨끗한 코드의 특징은 많지만 그 중에서도 모두를 아우르는 특징이 하나 있다. 깨끗한 코드는 언제나 누군가 주의 깊게 짰다는 느낌을 준다. 고치려고 살펴봐도 딱히 손 댈 곳이 없다. 작성자가 이미 모든 사항을 고려했으므로. 고칠 궁리를 하다보면 언제나 제자리로 돌아온다. 그리고는 누군가 남겨준 코드, 누군가 주의 깊게 짜놓은 작품에 감사를 느낀다.
► 이 책의 저자인 bob 아저씨는 '주의'가 클린코드의 주제라고 했습니다. 부제를 붙이라면 '코드를 주의 깊게 짜는 방법'이라고 합니다.
론 제프리스 - Extreme Programming Installed와 Extreme Programming Advventure in C# 저자
- 중복을 피하라
- 한 기능만 수행하라
- 제대로 표현하라
- 작게 추상화하라
워드 커닝햄 - Wiki, Fit 창시자, Extreme Programming 공동 창시자 등등... (대단하신...분...)
코드를 읽으면서 짐작했던 기능을 각 루틴이 그대로 수행한다면 깨끗한 코드라 불러도 되겠다.
코드가 그 문제를 풀기 위한 언어처럼 보인다면 아름다운 코드라 불러도 되겠다.
► 깨끗한 코드는 읽으면서 놀랄 일이 없어야 한다고 합니다.
보이스카우트 규칙 (Boy scout Rule)
캠프장은 처음 왔을 때보다 더 깨끗하게 해놓고 떠나라
깨끗하게 작성된 코드는 시간이 지나면서 언젠가는 엉망이 될 수 있습니다. 그래서 한 번 잘 짰다고 끝이 아니라, 코드의 지속적 개선이 이루어져야 합니다. 책에서도 반복되듯이, 코드는 지속적으로 개선되어야만 하기에 처음부터 코드를 깨끗하게 짜는 것이 중요한 것 같습니다.
2장. 의미 있는 이름
2장에서는 변수나 함수따위에 이름을 붙일 때, 이름을 잘 지을 수 있는 몇 가지 규칙을 소개해줍니다. 제가 요약해놓은 내용을 같이 살펴보겠습니다.
의도를 분명히 밝혀라
변수(또는 함수나 클래스)의 이름은 존재 이유, 수행하는 기능, 사용 방법이 명확하게 드러나도록 지어야 합니다.
예를들어,
아래와 같이 변수의 이름을 d로 지어도 d라는 변수 명은 아무 의미도 드러나지 않습니다.
int d; // 경과 시간(단위: 날짜)
따라서, 아래와 같이 변수의 의도가 분명히 드러나는 이름을 사용해야 합니다.
int elapsedTimeInDays;
int dyasSinceCreation;
int daysSinceModification;
int fileAgeInDays;
이렇게 의도가 분명히 드러나게 된다면 변수(또는 함수나 클래스)가 하는 일을 이해하기 쉽게 됩니다.
그릇된 정보를 피하라
이름은 실제 구현과 어긋나는 정보를 담아서는 안됩니다.
예를들어,
여러 계정을 그룹으로 묶을 때, 실제 List가 아닌데도 accountList라고 부르는 것은 코드의 의도를 오해하게 만들 수 있습니다. 따라서 accountsGroup, bunchOfAccounts, accounts와 같이 명명하는 것이 코드의 가독성을 높입니다.
또한, 서로 흡사한 이름을 사용하지 않도록 주의하고, 유사한 개념이나 역할을 가진 요소들은 일관된 표기법으로 이름을 지어야 합니다.
▶︎ 여러 계정이 List로 묶여있지 않다면, accountList는 잘못된 변수명!
의미 있게 구분하라
연속된 숫자를 덧붙이거나 불용어를 추가하는 방식은 적절하지 못합니다.
예를들어, Product라는 클래스가 이미 존재한다면, ProductInfo나 ProductData와 같은 이름을 사용하는 것은 의미 없는 차이에 불과합니다. 이러한 이름은 실제 역할의 차이를 드러내지 못하며, 코드의 의도를 흐리게 만듭니다.
▶︎ Product ↔ ProductInfo 둘의 차이가 무엇인지 불분명!
발음하기 쉬운 이름을 사용하라
정의된 변수 명은 토론과 같이 읽기만 하는 것이 아니라, 말로 전달되기에도 편해야합니다.
private Date genymdhms; // generate year, month, day, hour, minute, second...
"젠 와이 엠 디 에이취 엠 에스"...
private Date generationTimestamp;
"제너레이션 타임스탬프"... 훨씬 발음하기 쉽다.
검색하기 쉬운 이름을 사용하라
숫자가 포함된 변수를 찾기위한 숫자 검색이나 한 글자로 이루어진 변수 명은 검색으로 찾기 어렵습니다.
따라서,
int MAX_CLASSES_PER_STUDENT;
와 같이 의미가 분명하고 검색하기 쉬운 변수를 사는 것이 좋습니다.
인코딩을 피하라
이름에 타입이나 구현 방식을 드러내는 인코딩 정보를 포함시키는 것은 코드의 유지보수성과 가독성을 떨어뜨립니다.
가령,
String phoneString; // 헝가리안 표기법
private String m_dsc; // 멤버 변수에 m_이라는 접두어 붙이기
- 헝가리안 표기법 : 변수 이름에 타입이나 성격을 인코딩하는 방식
- 변수에 접두어 붙이기 : 변수나 함수 이름 앞에 의미나 정보를 붙이는 방식
위와 같이 만든다면, 변수의 타입이 변경되면 이름이 실제 의미와 어긋나게 되어 변수의 의도를 왜곡할 수 있게 됩니다.
또한, 인터페이스 클래스와 구현 클래스를 구분지을 때도 IClass명/ Class명 으로 구분하는 것보다 인터페이스 클래스의 이름을 건드리지 않고, Class명/ Class명Imp 나 Class명/ CClass명과 같이 구현 클래스에서 차이를 드러내는 편이 훨씬 좋습니다.
아래 제가 진행한 프로젝트의 코드중 클래스 명을 보겠습니다.

위 클래스의 역할은 OAuth2 사용자의 정보를 어플리케이션(서비스)에서 쓰기 좋은 형태로 변환하는 서비스입니다.
하지만, CustomOAuth2UserService라는 이름에서는 무엇을 커스텀했는지, 클래스명을 보고 의도를 제대로 파악할 수 없습니다.
클래스 명을 OAuth2UserServiceImpl이나 OAuth2UserMapper로 바꾼다면 클래스의 책임과 역할이 명확할 것 같습니다.
따라서, 이름에는 구현 정보가 아닌, 무엇을 의미하고 어떤 역할을 하는지에 집중하는 것이 바람직합니다.
자신의 기억력을 자랑하지 마라
이름을 지을 때는 다른 사람들까지 보고 단번에 이해할 수 있는 명료한 이름이 최고입니다. 자신만이 아는 변수명으로 명명하는 것은 바람직하지 못합니다.
클래스 이름
클래스 이름과 객체 이름은 동사가 아닌 명사나 명사구가 적합합니다.
클래스와 객체는 행위를 표현하기보다는 개념이나 대상을 표현하기 때문입니다.
Manager, Processor, Data, Info와 같이 역할이 모호한 불용어보다는
Customer, WikiPage, Account, AddressParser와 같이 의미 해석이 잘 되는 명사가 좋습니다.
메서드 이름
오히려, 메서드 이름은 동사나 동사구가 적합합니다.
메서드는 객체가 수행하는 행위를 표현하기 때문에, 내부 구현을 보지 않더라도
이름만 보아도 어떤 동작을 하는지, 어떤 비즈니스 로직을 담고 있는지 알 수 있어야 합니다.
- 접근자(Accessor)는 get, 변경자(Mutator)는 set, 조건자(Predicate)는 is를 값 앞에 붙여줍니다.
- 생성자를 오버로딩하기 위해서는 정적 팩토리 메서드를 사용하여 객체 생성의 의도와 규칙을 이름으로 드러내야 합니다.
// 정적 팩토리 메서드 사용 X
new User("kim", 20); 대신
// 정적 팩토리 메서드 사용 O
User.createIfAdult("kim", 20);
기발한 이름은 피하라
기발한 이름을 피하라는 이유도 당연히 변수(또는 함수나 클래스)의 이름의 의도를 분명하게 전달하기 위함입니다.
그런 이유로, 특정 문화에서만 사용되는 농담은 피하는 것이 좋습니다.
한 개념에 한 단어를 사용하라
같은 개념이라면 통일된 단어를 사용해야 합니다.
예를 들어, get/fetch/retrieve는 모두 값을 가져온다는 의미를 담고 있지만, 이를 혼용하면 메서드간 어떤 차이를 가지는지 혼란을 줄 수 있습니다.
따라서, 동일한 유형의 동작이나 책임을 수행한다면 하나의 표현만을 선택해 일관되게 사용하는 것이 바람직합니다.
말장난을 하지마라
바로 전, 동일한 개념에 일관된 표현을 위해 동일한 단어를 사용하라고 했습니다.
반대로 서로 다른 개념에 같은 단어를 재사용하는 것 또한 피해야 합니다. 이는 일관성이 아닌 단어의 의미를 불분명하게 만듭니다.
예를 들어, A클래스에서 add()라는 메서드는 컬렉션에 요소를 추가하는 의미로 사용되고, B클래스에서는 add()라는 메서드가 두 값을 더한 결과를 반환하는 의미로 사용된다면, 메서드 이름만 보고서는 실제 동작을 예측하기 어려워집니다.
따라서, 같은 단어는 항상 같은 의미로 사용하고, 의미가 다르다면 이름 또한 달라야 합니다. 이것이 말장난을 피하는 가장 중요한 기준입니다.
해법 영역에서 가져온 이름을 사용하라
이 책에서 언급하는 문제 영역과 해법 영역의 차이는 간단합니다.
문제 영역(Problem Domain)에서 가져온 이름이란 도메인 지식이나 비즈니스 용어이고, 해법 영역(Solution Domain)에서 가져온 이름이란, 기술적 개념이나 개발자에게 익숙한 용어를 그대로 사용하는 것을 의미합니다.
쉽게 말해, 문제 영역의 용어는 기획자나 실제 사용자들이 사용하는 Order, Payment, Delivery와 같은 용어이고, 해법 영역의 용어는 Repository, Controller, Service와 같이 코드의 구조와 역할을 설명하는 기술적인 표현입니다. (이해가 번쩍...!)
해법 영역에서 가져온 이름을 사용해, 코드의 역할과 구조를 명확히 드러낼 수 있습니다.
문제 영역에서 가져온 이름을 사용하라
모든 이름을 해법 영역에서 가져올 수는 없습니다. 특히 비즈니스 규칙이나 도메인 개념을 표현해야 하는 경우에는 기술적인 용어만으로 해당 의미를 정확하게 담아내기 어렵기 때문에, 이런 경우에는 억지로 프로그래밍 용어를 만들어내기보다는 실제로 사용되는 비즈니스 용어를 사용하는 것이 바람직합니다.
의미 있는 맥락을 추가하라
String street;
String city;
String state;
String zip;
위의 변수들을 각각 떼어놓고 보면, 이들이 주소를 구성하는 요소라는 사실을 한눈에 파악하기 어렵습니다. 결국 여러 변수를 함께 보며 의미를 추측해야 합니다.
그렇다면 아래 코드와 비교해 보겠습니다.
class Address {
String street;
String city;
String state;
String zip;
}
이번에는 Address라는 클래스 이름이 맥락을 제공해 주기 때문에, Address.street, Address.city와 같이 사용되며 각 변수의 역할과 의도를 훨씬 쉽게 이해할 수 있습니다.
이렇게 의미 있는 맥락을 이름이나 구조에 담아내면, 코드를 읽는 사람은 추가 설명 없이도 해당 값이 어떤 목적을 가지는지 자연스럽게 파악할 수 있습니다.
불필요한 맥락을 없애라
예를 들어, 사용자의 집 주소를 저장하기 위해 클래스 명을 UserAddress라고 지었다고 가정해보겠습니다.
class UserAddress {
String street;
String city;
String state;
String zip;
}
그런데 서비스에 새로운 기능이 추가되어, '사용자의 회사 주소'를 저장해야하는 경우가 발생했다고 한다면,
이 경우 UserAddress라는 클래스를 사용하는 것은 부적절합니다. 클래스 이름에 구체적 맥락이 포함되어 있기 때문에, 다른 용도의 주소를 저장하려면 새로운 클래스를 만들어야 하고, 코드 구조가 불필요하게 복잡해집니다.
대신 클래스 이름에는 Address라는 클래스 명을 사용하고, 객체(인스턴스) 이름에서 구체적 맥락을 드러내면 확장성이 높습니다.
아래 코드로 비교해보겠습니다.
Address homeAddress; // 사용자의 집 주소
Address companyAddress; // 사용자의 회사 주소
즉, 클래스 이름은 개념 자체를 표현하고, 변수 이름에서 구체적 맥락을 제공하는 것이 깨끗한 코드라고 할 수 있겠습니다.
3장. 함수
public static String renderPageWithSetupsAndTeardowns(PageData pageData, boolean isSuite)
throws Exception {
boolean isTestPage = pageData.hasAttribute("Test");
if (isTestPage) {
WikiPage testPage = pageData.getWikiPage();
StringBuffer newPageContent = new StringBuffer();
includeSetupPages(testPage, newPageContent, isSuite);
newPageContent.append(pageData.getContent());
includeTeardownPages(testPage, newPageContent, isSuite);
pageData.setContent(newPageContent.toString());
}
return pageData.getHtml();
}
위 코드는 설정페이지와 해제페이지를 테스트 페이지에 넣은 후 해당 테스트를 HTML로 렌더링하는 코드임을 알 수 있습니다.
하지만, 클린 코드에서는 해당 코드도 클린하지 못하다고 이야기 합니다.
public static String renderPageWithSetupsAndTeardowns(PageData pageData, boolean isSuite)
throws Exception {
if (isTestPage(pageData))
includeSetupAndTeardownPages(pageData, isSuite);
return pageData.getHtml();
}
위와 같이 코드를 고쳐야하는 이유에 대해서 알아보겠습니다.
작게 만들어라
책에서 말하는 함수를 작게 만들어야 하는 요인에 대한 근거는 시각적인 부분(가독성)이 큰 것 같습니다.
if 문 / else 문 / while 문 등에 들어가는 블록은 한 줄이어야 합니다. (블록의 크기를 줄여라...!)
함수가 짧을수록 한눈에 들어옵니다.
한 가지만 해라
"함수는 한 가지를 해야 한다. 그 한 가지를 잘 해야 한다. 그 한 가지만을 해야 한다."
먼저 renderPageWithSetupsAndTeardowns() 함수는 하나의 함수가 너무 많은 일을 하고 있습니다.
이 함수는 표면적으로는 “페이지를 렌더링”하는 것처럼 보이지만, 실제로는 다음과 같은 여러 책임을 동시에 수행합니다.
- 테스트 페이지인지 판단하는 책임
- 설정(setup) / 해제(teardown) 페이지를 조합하는 책임
- PageData의 내용을 변경하는 책임
- 변경된 페이지를 HTML로 변환해 반환하는 책임
즉, 하나의 함수가 서로 다른 변경 이유를 동시에 품고 있는 상태입니다. 이는 클린 코드에서 가장 경계하는 구조입니다.
따라서, 함수 하나당 하나의 작업을 하도록 쪼개야 합니다.
함수 당 추상화 수준은 하나로
함수가 하나의 작업만을 수행하기 위해서는 한 함수내에서 추상화 수준을 섞지않아야 합니다. (추상화 ↔ 구체화)
renderPageWithSetupsAndTeardowns()도 추상화 수준이 섞여있습니다.
includeSetupPages(testPage, newPageContent, isSuite); // 의도 중심의 높은 추상화
newPageContent.append(pageData.getContent()); // 구현 세부사항에 가까운 낮은 추상화
includeTeardownPages(testPage, newPageContent, isSuite); // 의도 중심의 높은 추상화
pageData.setContent(newPageContent.toString()); // pageData 객체의 구체적인 저장방식을 변경하는 낮은 추상화
하지만, 실제로 함수의 추상화 수준을 일정하게 유지하기란 어렵습니다. 따라서, 코드를 위에서 아래로 이야기처럼 이어지도록 작성해야합니다. 클린코드에서는 이를 "내려가기 규칙"이라고 설명합니다.
핵심은, 한 함수안에서 추상화 수준을 완벽하게 동일하게 맞추려 애쓰기보다, 코드를 위에서 아래로 읽을 때 점점 더 구체적인 이야기로 자연스럽게 내려가도록 구성하라는 것입니다.
Switch 문
클린 코드에서 switch 문은 추상화 수준을 무너뜨리는 대표적인 예로 설명됩니다. 아래 코드를 통해 살펴보겠습니다.
public Money calculatePay(Employee e) throws InvalidEmployeeType {
switch (e.type) {
case COMMISSIONED:
return calculateCommissionedPay(e);
case HOURLY:
return calculateHourlyPay(e);
case SALARIED:
return calculateSalariedPay(e);
default:
throw new InvalidEmployeeType(e.type);
}
}
- 새로운 case가 추가될 때마다 수정이 필요하고
- 각 case가 서로 다른 일을 하게 되며
- 결국 하나의 함수가 여러 정책을 동시에 담당하게 됩니다
위와 같은 이유에서 클린 코드에서는 switch 문을 가능하면 드러내지 말고 인터페이스 뒤에 꽁꽁 숨기라고 합니다.
숨기는 방법에는 다형성을 사용하여 숨길 수 있습니다.
다형성을 사용하는 방법 중 하나인 추상 팩토리 메서드를 활용하는 방식이 있습니다.
public abstract class Employee {
public abstract boolean isPayday();
public abstract Money calculatePay();
public abstract void deliverPay(Money pay);
}
public interface EmployeeFactory {
public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType;
}
public class EmployeeFactoryImpl implements EmployeeFactory {
public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType {
switch (r.type) {
case COMMISSIONED:
return new CommissionedEmployee(r) ;
case HOURLY:
return new HourlyEmployee(r);
case SALARIED:
return new SalariedEmploye(r);
default:
throw new InvalidEmployeeType(r.type);
}
}
}
위와 같이 수정하여 추상 팩토리 메서드는 구체 클래스 생성 로직을 내부에 감추고, 클라이언트는 구현 클래스가 아닌 인터페이스만을 의존하도록 만듭니다.
그 결과, 새로운 case가 추가되더라도 기존 클라이언트 코드를 수정하지 않고 새로운 구현 클래스만 추가하면 됩니다.
이는 변경에는 닫혀 있고 확장에는 열려 있는 구조를 만들어 줍니다.(OCP 준수)
서술적인 이름을 사용하라
- 길고 서술적인 이름이 짧고 어려운 이름이나 길고 서술적인 주석보다 좋습니다.
- 함수 이름을 정할 때는 여러 단어가 쉽게 읽히는 명명법을 사용하는 것을 권장합니다.
- 그런 다음, 여러 단어를 사용해 함수 기능을 잘 표현하는 이름을 선택하는 것이 좋습니다.
코드를 읽으면서 짐작했던 기능을 각 루틴이 그대로 수행하는 것을 깨끗한 코드라고 했습니다. 따라서, 함수가 작고 단순할수록 서술적인 이름을 고르기도 쉬워집니다.
함수 인수
함수의 인수는 없는 것이 가장 이상적이라고 할 수 있습니다. 하지만, 함수의 인수가 필요한 경우도 있습니다. 이 때는 최대한 함수의 인수를 3개를 넘기지 말아야 합니다. 인수의 개수가 많아질수록 함수의 개념을 이해하기 어렵게 만들기 때문입니다.
함수에 인수 1개를 넘기는 가장 흔한(적절한) 경우는 아래와 같습니다.
1. boolean fileExists("MyFile") // 인수에 질문을 던지는 경우
2. InputStream fileOpen("MyFile") // 인수를 뭔가로 변환해 결과를 반환하는 경우
3. passwordAttemptFailedNtimes(int attempts) // 이벤트 함수 (입력 인수로 시스템의 상태를 바꾸는 함수)
위의 경우가 아니라면 단항 함수는 가급적 피하는 것이 좋습니다. 대표적인 예시를 코드를 통해 살펴보겠습니다.
1. void includeSetupPageInto(StringBuffer PageText) // 변환 함수에서 출력 인수를 사용
2. render(boolean isSuite) // 플래그 인수
1번과 같은 경우는 SetupPage를 변환하는 함수인데 함수의 의도와 달리 어떤 값으로 변환되는 지를 알 수 없습니다.
2번과 같은 경우는 인수 값(true/false)에 따라 서로 다른 일을 하도록 강제하기 때문입니다. 이는 곧 함수가 한 가지 책임만 수행하지 않는다는 신호가 됩니다.
이항 함수는 인수가 1개인 함수보다 이해하기 어렵습니다. 하지만 Point p = new Point(0, 0)와 같이 2개의 인수가 한 값을 표현하는 두 요소는 괜찮습니다.
중요한 것은 인수 간의 자연적인 순서(x, y 따위의 순서가 존재하는 개념)가 있지 않다면 인위적으로 기억해야 하므로 주의하여 사용해야 한다는 것 입니다.
삼항 함수는 당연히... 2개인 함수보다 훨씬더 이해하기 어렵습니다. 이와 같이 함수의 인수가 늘어나는 경우에는 필요시 클래스 변수를 활용하여 인수를 선언하도록 하는 것이 클린한 코드라고 할 수 있습니다.
예를 들어,
1. Circle makeCircle(double x, double y, double radius);
2. Circle makeCircle(Point center, double radius);
1번의 x, y좌표를 Point라는 클래스 변수를 활용해 묶어 표현하는 것처럼 말입니다.
가변 인수도 의미에 따라 단항, 이항, 삼항으로 취급할 수 있습니다. 하지만 인수 3개를 넘어서는 인수를 사용할 경우에는 문제가 있습니다.
핵심은 인수의 개수가 아니라 해당 인수가 명확한 역할을 가진 인수들로 이루어져있고, 각 인수의 의미가 분명한 경우에 사용해야 한다는 것입니다.
마지막으로, 함수의 의도와 인수의 순서와 의도를 제대로 표현하려면 좋은 함수 이름이 필수적입니다.
아래와 같이 함수이름을 적절하게 선언한다면 코드의 의도가 짐작가는 클린한 코드라고 할 수 있겠습니다.
write(name) : 이름이 무엇이든 쓴다.
writeField(name) : name이 Field라는 사실이 드러난다.
assertExpectedEqualsActual(expected, actual) : 인수의 순서를 기억할 필요가 없어진다.
부수 효과를 일으키지 마라
먼저, 아래 함수를 살펴보겠습니다.
public class UserValidator {
private Cryptographer cryptographer;
public boolean checkPassword(String userName, String password) {
User user = UserGateway.findByName(userName);
if (user != User.NULL) {
String codedPhrase = user.getPhraseEncodedByPassword();
String phrase = cryptographer.decrypt(codedPhrase, password);
if ("Valid Password".equals(phrase)) {
Session.initialize();
return true;
}
}
return false;
}
}
위 함수의 이름과 반환값만 보면 “비밀번호가 올바른지 확인하는 역할” 을 수행하는 것처럼 보입니다.
그러나 실제로는 비밀번호 검증 외에도 사용자 세션을 초기화하는 부수 효과(side effect) 를 가지고 있습니다.
이러한 부수 효과는 함수의 이름과 의도에서 드러나지 않으며,
결과적으로 함수가 검증(validation)과 상태 변경(state change) 이라는 두 가지 책임을 동시에 떠안게 만듭니다.
특히 이 코드는 인증 성공 시 반드시 Session.initialize() 가 먼저 호출되어야 한다는 시간적인 결합(temporal coupling) 을 만들어냅니다. 이로 인해 해당 함수를 사용하는 호출부에서는 “이 함수가 내부에서 어떤 상태를 변경하는지”를 알고 있어야만 안전하게 사용할 수 있습니다.
또한 테스트 코드 관점에서도 문제가 됩니다. 비밀번호 검증만 테스트하고 싶은 경우에도 세션 초기화가 함께 일어나기 때문에,
세션 환경을 함께 구성해야 하고, 이는 테스트의 범위를 불필요하게 확장시키며 테스트 코드의 복잡성을 증가시키는 원인이 됩니다.
클린 코드에서는 이러한 이유로
함수는 자신의 이름이 약속한 일만 수행하고, 예상치 못한 부수 효과를 일으키지 않아야 한다고 강조합니다.
다음으로, 부수효과를 막기위해서는 출력 인수를 사용을 지양해야 합니다.
appendFooter(s);
위 함수는 s를 Footer로 놓아야하는지, s에 Footer를 놓아야하는지, s가 입력인지, 출력인지가 분명하지 않습니다.
아래 함수 선언부를 보아야만 비로소 s가 출력인수라는 것을 알 수 있습니다.(report에 footer를 붙임)
public void appendFooter(StringBuffer report)
하지만, 함수 선언부를 찾아보는 행위가 존재하므로 클린한 코드라고 할 수 없습니다. 클린한 코드는 함수 호출만 보고도 의도와 책임이 드러나야하기 때문입니다.
따라서, 아래와 같이 출력 인수를 피하고 객체가 자신의 상태를 변경하도록 호출되는 것이 권장됩니다.
report.appendFooter();
💡출력 인수 (output argument)
입력처럼 보이지만, 함수가 결과를 써넣는 대상
- 함수 선언부 -
// void addFooter(StringBuffer report) {
// report.append("FOOTER");
// }
- 함수 호출부 -
// StringBuffer report = new StringBuffer();
// addFooter(report);
addFooter(report)는 마치 report를 footer에 입력해야하는 것처럼 보이지만, report에 footer를 추가해야합니다.
명령과 조회를 분리하라(CQRS 패턴)
if (set("username", "unclebob"))...
위의 코드에서 set이라는 메서드 이름만으로는 메서드의 의도를 명확히 알 수 없습니다. 즉, 호출자는 메서드 이름과 인자만 보고 의도를 추론해야하는 번거로움이 있습니다.
문제의 핵심은 set이
- 값을 변경하는 명령(command)이면서
- 동시에 성공 여부를 반환하는 조회(query) 역할까지 수행한다는 점입니다.
클린 코드에서는 하나의 함수가 두 역할을 동시에 수행해서는 안 된다고 말했습니다. 따라서 기존 코드는 아래와 같이 명령과 조회가 분리되도록 수정되어야 합니다.
if (attributeExists("username")) { // 조회
setAttribute("username", "unclebob"); // 명령
}
오류 코드보다 예외를 사용하라
if (deletePage(page) == E_OK) {
if (registry.deleteReference(page.name) == E_OK) {
if (configKeys.deleteKey(page.name.makeKey()) == E_OK) {
logger.log("page deleted");
} else {
logger.log("configKey not deleted");
}
} else {
logger.log("deleteReference from registry failed");
}
} else {
logger.log("delete failed"); return E_ERROR;
}
위와 같이 오류 코드를 사용한다면 호출부는 각 단계마다 반환값을 확인하고 분기처리를 해야합니다.
따라서, 아래 코드와 같이 예외를 사용하여 정상 흐름과 예외 흐름을 명확히 분리하여야 합니다.
try {
deletePage(page); // 페이지 삭제
registry.deleteReference(page.name); // 레지스트리 참조 삭제
configKeys.deleteKey(page.name.makeKey()); // 설정 키 삭제
} catch (Exception e) {
logger.log(e.getMessage());
}
하지만, 책에서는 try/catch문을 한 번더 정제를 합니다.
저는 위 코드도 괜찮지 않나... 라고 생각을 했지만 좀 더 찾아보니
- 세부 구현(삭제 절차의 세부사항)을 호출부가 알고 있다는 점에서 추상화 수준이 낮다라는 문제와
- 페이지 삭제, 레지스트리 참조 삭제, 설정 키 삭제는 사실상 "페이지 삭제"라는 하나의 유스케이스에 속해있다라는 문제입니다.
따라서, 페이지 삭제라는 사실상 하나의 유스케이스를 묶어 세부 구현을 감추고 페이지를 삭제한다라는 의도만 남겨 추상화 수준을 높이는 것이 바람직하다고 할 수 있습니다.
다음으로, 오류 처리도 한 가지 작업입니다. 여전히, 함수는 한 가지 일만을 해야합니다.
다음으로, 에러코드와 관련된 내용입니다. Error enum(열거형 변수)만을 사용하는 경우, 내용이 수정되거나 추가 및 삭제 된다면, Error 클래스를 사용하는 모든 클래스를 다시 컴파일 해야 합니다.
이처럼 공통 Error enum은 여러 모듈에서 동시에 참조되기 쉬워 의존성 자석(Dependency Magnet)이 되며, 변경에 매우 취약한 구조를 만듭니다.
REST API에서는 외부로 노출되는 예외 타입은 단순하게 유지하고, 도메인 내부에서는 Error를 도메인별로 분리하여 에러의 의미가 코드에 자연스럽게 드러나도록 설계해야 합니다. (예: Error → AuthError, ChatRoomError, ChatMessageError …)
이와 같은 설계는 Error클래스의 의존성을 줄이고 각 도메인 별 Error 클래스의 책임을 명확하게 만듭니다.
반복하지 마라
RDB에서는 정규화, OOP(객체 지향)에서는 부모 클래스로 코드를 몰아서 중복을 없애야 합니다.
구조적 프로그래밍, AOP(관점 지향), COP(컴포넌트 지향) 모두 중복 제거 전략을 사용합니다.
코드의 중복을 줄이는 것은 모듈의 가독성이 크게 높인다는 것을 알 수 있습니다.
구조적 프로그래밍
구조적 프로그래밍에서는 모든 함수와 함수 내부의 모든 블록이 하나의 입구와 하나의 출구만을 가져야 한다고 말합니다.
즉, return 문은 하나만 존재해야 하고, 반복문 내부에서 break나 continue를 사용하는 것도 지양해야 한다는 의미입니다.
다만 이 규칙은 함수가 크고 복잡할 때 특히 의미가 있습니다.
함수가 충분히 작고 책임이 명확하다면, 여러 개의 return, break, continue를 사용하는 것이 오히려 가독성을 높이는 경우도 많습니다.
코딩 테스트에 익숙한 저로서는
“loop 안에서 continue를 쓰지 말라고?”라는 생각이 먼저 들었습니다.
하지만 코딩 테스트는 예외적으로 프로덕트 코드를 만드는 것이 아니라, 성능과 일정 수준의 가독성을 빠르게 확보하는 것이 목적이기 때문에
관례적으로도 반복문 안에서 continue를 자주 사용하는 것이 자연스럽다고 생각했습니다. 허허
1 ~ 3장 결론 및 느낀 점
클린 코드는 단순히 "잘 동작하는 코드"가 아니라 의도를 명확히 전달하고 변경에 강한 코드라는 점을 강조합니다.
의미가 분명하고, 의도가 쉽게 짐작가는, 함수가 크지 않으며, 단일 책임을 준수하고, 중복을 제거하는 원칙들이 코드의 품질과 유지보수성까지 좌우한다고 말합니다.
저도 여러 프로젝트를 진행해오면서 코드를 클린하게 유지하여 진행하기위해 코드 컨벤션을 문서화했었습니다. 하지만, 이후에 성능 개선을 위해 리팩토링을 하고, 최적화를 거치면서 점점 코드의 품질을 가독성이나 의도보다는 성능 위주로만 판단하게 되는 순간들이 많았던 것 같습니다.
그 결과, 제가 작성한 코드임에도 불구하고 시간이 지나서 봤을 때, 왜 이렇게 작성된 코드인지 불분명해 시간이 불필요하게 길게 소요된 경험도 있었습니다.
따라서, 제가 작성한 코드가 제가 작성한 것 같은 코드 스멜이 나지 않고 시간이 지나 누군가 보더라도, 맥락이 빠르게 이해되는 코드를 작성하는 것이 클린한 코드라는 것을 다시 한 번 느끼게 된 것 같습니다.
이번 1~3장을 다시 읽으면서, 성능 이전에 코드가 명확한 구조 및 네이밍과 책임을 유지하고 있는지가 장기적으로 더 큰 생산성을 만든다는 점을 유념하여 클린한 코드를 꾸준히 고민하여 작성해 나가고자 합니다.
