코드
https://github.com/rkwhr0010/clean_code/tree/main/src
변경 사항은 git history 참고
프로그램의 가장 기본 단위가 함수다. (자바 기준 메서드)
원본 코드
public class HtmlUtil { public static String testableHtml(PageData pageData, boolean includeSuiteSetup) throws Exception { WikiPage wikiPage = pageData.getWikiPage(); StringBuffer buffer = new StringBuffer(); if (pageData.hasAttribute("Test")) { if(includeSuiteSetup) { WikiPage suiteSetup = PageCrawlerImpl.getInheritedPage(SuiteResponder.SUITE_SETUP_NAME, wikiPage); if(suiteSetup != null) { WikiPagePath pagePath = suiteSetup.getPageCrawler().getFullPath(suiteSetup); String pagePathName = PathParser.render(pagePath); buffer.append("!include -setup .") .append(pagePathName) .append("\n"); } } } WikiPage setUp = PageCrawlerImpl.getInheritedPage("SetUp", wikiPage); if(setUp != null) { WikiPagePath setUpPath = wikiPage.getPageCrawler().getFullPath(setUp); String setupPathName = PathParser.render(setUpPath); buffer.append("!include -setup .") .append(setupPathName) .append("\n"); } buffer.append(pageData.getContent()); if(pageData.hasAttribute("Test")) { WikiPage teardown = PageCrawlerImpl.getInheritedPage("TearDown", wikiPage); if(teardown != null) { WikiPagePath tearDownPath = wikiPage.getPageCrawler().getFullPath(teardown); String tearDownPathName = PathParser.render(tearDownPath); buffer.append("\n") .append("!include -teardown .") .append(tearDownPathName) .append("\n"); } if(includeSuiteSetup) { WikiPage suiteTearDown = PageCrawlerImpl.getInheritedPage(SuiteResponder.SUITE_TEARDOWN_NAME, wikiPage); if(suiteTearDown != null) { WikiPagePath pagePath = wikiPage.getPageCrawler().getFullPath(teardown); String pagePathName = PathParser.render(pagePath); buffer.append("\n") .append("!include -teardown .") .append(pagePathName) .append("\n"); } } } pageData.setContent(buffer.toString()); return pageData.getHtml(); } } |
리팩터링 코드
public static String renderPageWithSetupsAndTeardowns( PageData pageData, boolean includeSuiteSetup) throws Exception { boolean isTestPage = pageData.hasAttribute("Test"); if (isTestPage) { WikiPage wikiPage = pageData.getWikiPage(); StringBuffer newPageContent = new StringBuffer(); includeSetupPages(includeSuiteSetup, wikiPage, newPageContent); newPageContent.append(pageData.getContent()); includeTeardownPages(includeSuiteSetup, wikiPage, newPageContent); pageData.setContent(newPageContent.toString()); } return pageData.getHtml(); } |
작게 만들어라!
함수를 만드는 규칙은 하나도 작게, 둘도 작게다.
명확한 근거는 없지만, 저자 경험에서 나온 규칙이다.
작은 함수 기준
가로 0 ~ 150
세로 2 ~ 4
들여쓰기 수준 1 ~ 2 단
public static String renderPageWithSetupsAndTeardowns( PageData pageData, boolean includeSuiteSetup) throws Exception { if (isTestPage(pageData)) { includeSetupAndTeardownPages(pageData, includeSuiteSetup); } return pageData.getHtml(); } |
블록과 들여쓰기
제어문과 반복문 속에 들어가는 블록은 원래 한 줄이어야 한다는 뜻이다.
내부 코드를 함수로 추출하여 이름을 짓는다면, 코드를 이해하기 쉬워ㅣ진다.
한 가지만 해라!
함수는 한 가지를 해야 한다. 그 한 가지를 잘 해야 한다. 그 한 가지만을 해야 한다.
위 함수는 함수 이름 아래에서 추상화 수준이 하나인 단계만 수행한다면 그 함수는 한 가지 작업만 한다.
함수를 만드는 이유는 큰 개념을 다음 추상화 수준에서 여러 단계로 나누기 위해서다.
기존 함수에서 다른 의미를 가지는 함수를 추출할 수 있다면 그 함수는 여러 작업을 하는 것이다.
함수 내 섹션
한 함수에서 섹션이 나눠진다면, 그 함수는 여러가지 일을 한다는 뜻이다.
한 가지만 잘 하는 함수는 섹션을 나누기 힘들다.
함수 당 추상화 수준은 하나로!
함수가 한 가지만 잘하려면 모든 문장의 추상화 수준이 동일해야 한다.
한 함수 내에 추상화 수준이 섞이면, 읽는 사람이 헷갈린다.
특히 근본 개념과 세부사항이 섞여있다면, 깨진 창문 처럼 그 함수는 사람들이 세부사항을 더 추가하기 시작한다.
위에서 아래로 코드 읽기: 내려가기 규칙
좋은 코드는 좋은 책을 읽는 것과 같아야 한다.
높은 추상화 수준 코드에서 점점 낮아지는 추상화 수준으로 코드는 구성돼야 한다.
Switch 문(if-else 포함)
스위치문은 근본적으로 N 가지 일을 처리한다. 그리고 구조적으로 작게 만들기 어렵다.
개발 시 제어문은 사용할 수 밖에 없다.
스위치문은 다형성을 이용해 분리할 수 있다.
예시
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); } } |
문제점,
함수가 길다.
신규 유형을 추가하기 힘들다.
SRP 위반한다. 코드를 변경할 이유가 하나가 아니다
OCP 위반한다. 신규 유형을 추가 마다 기존 코드가 영향을 받는다.
가장 큰 문제
똑같은 유해한 구조가 무한정 반복될 수 있다.
/* * 페이지 48, 가장 큰 문제 예시 * 똑같은 유해한 구조가 무한정 반복될 수 있다. */ void isPayDay(Employee e, Date date) throws InvalidEmployeeType { switch (e.type) { case COMMISSIONED: /* 뭔가 하는 코드 */ return ; case HOURLY: /* 뭔가 하는 코드 */ return ; case SALARIED: /* 뭔가 하는 코드 */ return ; default: throw new InvalidEmployeeType(e.type); } } void deliverPay(Employee e, Money money) throws InvalidEmployeeType{ switch (e.type) { case COMMISSIONED: /* 뭔가 하는 코드 */ return ; case HOURLY: /* 뭔가 하는 코드 */ return ; case SALARIED: /* 뭔가 하는 코드 */ return ; default: throw new InvalidEmployeeType(e.type); } } |
개선
public interface EmployeeFactory { Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType; } |
public class EmployeeFactoryImpl implements EmployeeFactory { @Override 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 SalariedEmployee(r); default: throw new InvalidEmployeeType(r.type); } } } |
public abstract class Employee { EmployeeRecord r; Employee(EmployeeRecord r) { this.r = r; } public abstract boolean isPayDay() ; public abstract Money calculatePay() ; public abstract void deliverPay(Money pay) ; } |
추상 팩터리로 객체를 생성하는 switch 문을 외부로 부터 숨긴다.
팩터리 구현체는 switch 문을 구현해 Employee 파생 클래스의 인스턴스를 생성한다.
호출자는 Employee라는 인터페이스를 통해 구현부는 모른체 호출하게 된다.
언어 차원에서 다형성으로 인해 실제 파생 클래스 메서드가 호출된다.
코드 안에서 다형성을 이용한 switch 문은 한 번만 사용한다.
서술적인 이름을 사용하라!
단편적인 이름은, 그 함수가 무엇을 하는지 알기 힘들다.
renderHtml() 함수는 무엇을 하는지 정확히 알 수 없다.
서술적인 이름을 지어 함수가 하는 일을 잘 표현해야 한다.
함수 이름이 너무 길거나, 짓기 어렵다면, 함수가 한 가지 일을 하는지 확인해야 한다.
한 가지만 잘 하는 함수는 이름을 짓기 쉽다.
요즘 IDE는 이름 변경이 매우 쉬우니, 좋은 이름이 떠올랐으면 즉시 바꾸자
함수 인수
가장 좋은 인수는 0 항
그 다음 1 항
그 다음 2 항이다. 2 항 부터는 인수 순서에 의존성이 생긴다.
3 항 부터는 피하는 게 좋고,
4 항은 특별한 이유 없이는 사용하면 안된다. (특별한 이유 예시 _ 배열 복사 같은 것은 인수가 많을 수 밖에 없다)
많은 인수는 테스트 코드 작성도 어렵게 한다. 경우의 수가 늘어나기 때문
되도록 인수는 0 ~ 1 개 유지해야 한다.
많이 쓰는 단항 형식
같이 유념하고 볼 개념, 명령과 질의 분리
질의와 찾기가 많다
질의
Boolean isExists("123")
찾기
User findById("123")
자주 사용하지 않지만, 이벤트 형식도 있다.
이벤트 함수는 입력 인수만 있다. 출력 인수는 없다. (명령, command)
함수 호출을 이벤트로 해석해 입력 인수로 시스템 상태를 바꾼다.
passwordAttemptFailedNtimes(int attempts)
이벤트 함수는 시스템 상태를 바꾸기에 이름에서 이벤트 함수임을 명확히 해야 한다.
위 이 외 경우는 가급적 단항 함수를 피해야 한다.
안 좋은 예시
Void includeSetupPageInto(StringBuffer pageText)
StringBuffer includeSetupPageInto(StringBuffer pageText)
항등 함수처럼 자기 자신을 리턴하는 게 났다.
플래그 인수
함수에 Boolean 값을 넣는 것은 않 좋다.
이 함수는 여러 가지 일을 하는 함수라는 것을 드러내는 꼴이다.
이항 함수, 삼항 함수
이함 함수부터는 인수 간 순서를 신경써야 한다.
즉, 고려 사항이 기하급수적으로 증가한다.
인수 객체
인수가 2 ~ 3 개 이상 필요 시 독자적인 클래스 변수를 고려한다.
//이것 보다. Circle makeCircle(double x, double y, double radius){ return new Circle(x, y, radius); } //이것이 더 명료하다. 연관있는 인수를 하나로 묶었기 때문이다. Circle makeCircle(Point center, double radius){ return new Circle(center, radius); } |
단순한 눈속임처럼 보이지만, 아니다. 서로 연관있는 인수를 하나로 묶었다는 것에서 가치가 있다.
인수 목록
가변 인수가 필요한 경우가 있다.
이 경우는 사실상 이항 함수로 봐야한다.
동사와 키워드
함수 의도나 인수의 순서와 의도를 제대로 표현하려면 좋은 함수 이름이 필수다.
함수 동사, 인수 명사 이렇게 쌍을 이뤄야 한다.
writeField(String name)
위 경우 누가봐도 이름을 필드에 쓴다는 것을 알 수 있다.
인수의 순서를 함수 이름에 적시하는 것은 좋은 방법이다.
assertExpectedEqualsActual(expected, actual)
부수 효과를 일으키지 마라!
부수 효과는 함수에서 한 가지를 하겠다고 해놓고 남 몰래(부수효과) 다른 짓도 하는 행위다.
public class UserValidator { private Crytograher crytograher; public boolean checkPassword(String username, String password) { User user = UserGateway.findByName(username); if(user != User.NULL) { String codePhrase = user.getPhraseEncodeByPassword(); String phrase = crytograher.decrpt(codePhrase, password); if("Valid Password".equals(phrase)) { Session.initialize(); return true; } } return false; } } |
패스워드가 일치하면 true, 아니면 false를 리턴하는 함수다.
위 예시에서 부수효과는 Session.initialize(); 이다
함수 이름만 봐선 로그인 성공 시 세션을 초기화 한다는 것을 알 수 없다.
즉, 사용자가 의도치 않게 인증 후 세션을 지울 가능성이 있다.
또한 시간적 결합이 존재한다.
checkPassword() 함수는 세션을 초기화해도 되는 상황(시간적 결합)에서만 호출 가능하다.
세션을 지울 수도 있기 때문이다.
이 경우 함수 이름에서 이를 명시해 줘야 한다.
checkPasswordAndInitializeSession
참고로 이 함수는 두 가지 일을 한다
명령과 조회를 분리하라!
함수는 뭔가를 수행하거나 뭔가에 답하는 두 가지 행동만 해야한다.
같이 하면 안된다.
public boolean set(String attrbute, String value) { //저장에 성공하면 true를 실패하면 false를 반환한다고 가정 return false; } public void doSomething() { /* 이상한 코드 형식 */ if(set("하나", "둘")) { } } |
명령과 질의를 같이 수행하면 위와 같은 괴상한 코드가 나올 수 있다.
추가로 위 함수는 이름이 불명확하다.
set()이 무슨 동작을 하는지 예상이 안된다.
//명령 public void set(String attribute, String value) { } //질의 public boolean attributeExists(String string) { return false; } public void doSomething() { if(attributeExists("하나")) { set("하나", "둘"); } } |
명령과 질의를 분리하고, 이름을 명확히해 주석 같은 정보가 없어도 뭘 하는지 알 수 있다.
오류 코드보다 예외를 사용하라!
명령 함수에서 오류 코드를 반환하는 방식은 명령/조회 분리에 위반한다.
오류 코드는 중첩 코드를 야기한다.
반환되는 오류코드를 처리하기 위해 제어문이 필요하기 때문이다.
String something(Page page) { if(deletePage(page) == E_OK) { if(registry.deleteReference(page.name) == E_OK) { if(configKeys.deleteKey(page.name.makeKey()) == E_OK){ logger.info("page deleted"); } else { logger.info("configKey not deleted"); } } else { logger.info("deleteReference from registry failed"); } } else { logger.info("delete failed"); return E_ERROR; } return E_OK; } |
오류 코드 대신 예외 사용
void something(Page page) { try { deletePage(page); registry.deleteReference(page.name); configKeys.deleteKey(page.name.makeKey()); } catch (Exception e) { logger.info(e.getMessage()); } } |
try/catch 블록 뽑기
Try/catch 블록은 그 구조가 추하다.
정상 처리 코드와 오류 처리 코드를 뒤섞기 때문이다.
여기서 뒤섞인 다는 것은 하나의 함수는 하나만 잘해야 하는데,
오류 처리 코드와 정상 처리 코드가 함께 있다는 것을 의미하는 것 같다.
별도 함수를 뽑아 이 문제를 제거하는 편이 옳다.
void delete(Page page) { try { deletePageAndAllReferences(page); } catch (Exception e) { logError(e); } } private void logError(Exception e) { logger.info(e.getMessage()); } private void deletePageAndAllReferences(Page page) throws Exception { deletePage(page); registry.deleteReference(page.name); configKeys.deleteKey(page.name.makeKey()); } |
이렇게 오류 처리 코드와 정상 동작 코드를 분리하면 이해하고 수정하기 쉬워 진다.
오류 처리도 한 가지 작업이다.
오류 처리도 한 가지 작업이기 때문에 이전 코드에서 별도 함수로 추출해서 정상 코드와 분리한 것이다.
Error.java 의존성 자석
에러 코드를 반환한다는 것은 어딘 가에 오류 코드를 반환하기 위한 코드를 정의한 다는 뜻이다.
public enum Error { OK, INVALID, NO_SUCH, LOCKED, OUT_OF_RESOURCES, WATTING_FOR_EVENT; } interface OtherError { String OK = "OK"; String INVALID = "INVALID"; String NO_SUCH = "NO_SUCH"; String LOCKED = "LOCKED"; String OUT_OF_RESOURCES = "OUT_OF_RESOURCES"; String WATTING_FOR_EVENT = "WATTING_FOR_EVENT"; } |
위와 같은 클래스가 의존성 자석이다.
열거형 Error의 경우, 새로운 오류를 정의해야 하는 경우 Error를 사용하는 모든 클래스를 재 컴파일 해야 한다.
이 문제로 Error 열거형은 변경이 쉽지 않다.
재컴파일을 피하려고 기존 에러 코드를 재사용하게 된다.
오류 코드 대신 예외를 사용하면, 새로운 예외는 Exception 클래스에서 파생되어 기존 코드는 전혀 재컴파일이 필요 없다.
반복하지 마라!(Don’t Repeat Yourself : DRY 원칙)
중복의 문제는 변경에 취약하다.
중복이 10 개 라면 하나의 변경에도 10 번 수정 해야 한다.
10 번의 수정 과정에서 개발자가 실수를 하지 않는 다는 보장도 없다.
현대 소프트웨어에서 가장 문제되는 코드는 중복이다.
그래서 이를 극복하기 위한 다양한 방법들이 존재한다.
데이터 베이스도 이런 중복 문제로 정규화를 수행한다.
AOP도 중복 제거를 위함이다.
구조적 프로그래밍
에츠허르 데이크스트라 구조적 프로그래밍 원칙에 따르면, 모든 함수는 입구와 출구가 하나만 존재해야 한다.
단일입구/단일출구 원칙
Break, continue, goto(절대)를 사용하면 안된다.
이 규율은 함수가 클 때만 이익을 제공한다.
작은 함수에선 효과가 없다.
작은 함수에선 여러 출구가 오히려 함수 표현에 도움될 때도 있다.
함수를 어떻게 짜죠?
글쓰기라 생각하면 된다.
일단 생각한 바를 기록한 후 다듬고, 다듬는다.
이를 함수에 대입하면,
일단 동작을 하는 로직을 만든다. 이땐 들여쓰기가 아무리 많아도 괜찮다.
다만, 초안 작성에도 테스트 코드는 만들면서 진행해야 한다.
함수를 쪼개거나 이름을 짓는 등, 이후 리팩터링을 한다.
결론
대가 프로그래머는 시스템을 구현할 프로그램이 아닌 풀어갈 이야기로 여긴다.
프로그래밍 언어라는 수단을 사용해 표현려기 강한 언어를 만들어 이야기를 풀어간다.
함수가 이를 표현하는 언어에 속한다.
좋은 함수는 길이가 짧고, 이름이 좋고, 체계가 잡힌 함수다.
좋은 함수의 궁극적 목표는 시스템 이야기를 풀어가는 것이다.
'IT책, 강의 > 클린코드(Clean Code)' 카테고리의 다른 글
5장 형식 맞추기 (0) | 2023.12.11 |
---|---|
4장 주석 (1) | 2023.12.04 |
2장 의미 있는 이름 (1) | 2023.11.20 |
1장 깨끗한 코드 (0) | 2023.11.13 |
다시 시작 (0) | 2023.11.10 |