클린 코드에서 말하는 것들

내가 이해한 클린 코드 핵심 내용

Lifthus
10 min readSep 17, 2023

이 글에서는 아주 유명한 개발 서적인 “Clean Code”의 핵심 내용에 대해 알아보도록 하자.

Clean Code
Clean Code

이름

각 변수, 클래스, 메소드 등을 위한 식별자 이름은 가능한 서술적이면서 직관적으로 이해하고 발음하기 쉽게 짓는다. 이상한 줄임말을 쓰지 않도록 하고 나중에 검색하기도 쉬운 이름을 선택한다. 또한 하나의 개념에 대해서는 하나의 단어로 통일해 혼란을 방지한다.

클래스, 객체 같은 것들을 위해서는 명사나 명사구를 쓰되, Manager, Processor, Data 같이 누가봐도 SRP를 위반할 것 같은, 혹은 포괄적인 이름은 되도록 피한다.

메서드, 함수 같은 것들을 위해서는 동사나 동사구를 사용한다. 메서드의 이름에는 인수에 관해 추론할 수 있는 내용을 포함하면 좋다.

모든 이름은 사용되는 맥락을 충분히 표현할 수 있어야하고 쓸데없는 맥락은 절대 포함하면 안된다.

함수

함수는 여러 단계의 추상화를 거쳐 가능한 작게 만들고, 하나의 일만 하도록 엄격히 제한한다. 한 함수에서 작업하는 내용들의 추상화 단계는 같아야 이해하기 쉽다.

함수의 인수 개수는 0을 포함해 작으면 작을수록 좋다. 3개가 넘어가는 인수는 최대한 피하도록 하자. 인수의 개수가 늘어나면 그 중 일부가 독자적인 클래스를 형성할 수 있는 가능성이 높아진다. 입력 받은 인수에 결과를 저장해주는 것보다는 직관적으로 눈에 들어오는 결과를 리턴하는 형태의 함수를 추구하자.

플래그 인수는 절대 사용하지말고 함수를 분리하도록 하고, 다른 선택자들도 마찬가지다.

함수에서 맡은 책임 외의 부수 효과를 절대 일으키지 않도록 한다.

명령과 조회는 분리하도록 한다. 예컨대 어떤 명령을 실행하는 함수의 성공 여부를 불리언 값으로 반환하면 함수 이름만 봐서는 그 모든 기능들을 예측하기 어렵다.

언어에 따라 구현이 달라지겠지만 가능하면 오류 코드를 반환하기 보다는 예외를 발생시키도록 하자.

함수의 핵심은 반복을 줄이는 것.

주석

주석으로 코드를 설명하려 하지말고 충분히 의도가 표현되는 코드를 작성하자. 알고리즘이 복잡하다든지 꼭 필요한 경우를 제외하고는 주석은 최소화 한다. 코드에다 주석 처리해놓은 것은 그냥 싹 지우고 필요하면 버전 시스템을 활용해 복구하도록 한다.

무엇보다 주석은 코드와 달리 실행되지 않아서 잘 관리되지 않는 경향이 있기 때문에, 시간이 지날수록 점점 낡아지고 실제 코드의 내용을 전혀 반영하지 못하는 방향으로 가게 된다. 그렇기 때문에 코드 그 자체로 의도를 충분히 표현할 수 있도록 하자.

부적절하고 성의 없는 주석, 주석 처리된 코드는 모두 지워버리고, 코드만 봐도 충분히 이해가는데 굳이 주절주절 달아놓은 그런 주석들도 다 제거한다. 가능한 주석보다 코드 자체로 의도를 표현하되, 꼭 써야한다면 공들여서 깔끔하게 작성한다.

객체와 자료구조

객체는 자료에 대한 접근을 추상화해야 한다. 필요하다면 사용해야 겠지만 무지성으로 Getter와 Setter를 남발하는 것은 정보은닉과 캡슐화의 의미를 완전히 저해한다. 가능하면 어떤 연산 같은 것들을 통해 간접적으로 접근하도록 객체를 잘 캡슐화하도록 하자.

자료구조는 모든 필드가 공개되어 있다. 그저 자료를 전달하기만 하면 되기 때문에 정보 은닉 같은 것은 필요 없다.

객체지향적인 관점에서 객체를 사용하면 기존 메소드들을 변경하지 않으면서 새로운 형태의 타입을 추가하기가 쉽다. 반면 절차지향적인 관점에서 자료구조 클래스들을 구성하면 기존 자료구조를 변경하지 않으면서 새로운 함수를 추가하기가 쉽다. 예컨대 각 도형 형태 클래스 별로 넓이를 구하는 메소드를 구현하는 것과 도형 객체를 받아 넓이를 구하는 함수 하나를 구현하는 것을 생각해보자.

에러 핸들링

상술했듯이 오류 코드를 반환하기 보다는 예외를 발생시킬 수 있다면 그렇게 하도록 한다. try-catch 문을 지원하는 언어라면 catch 문에서 프로그램의 상태를 일관성있게 유지하도록 처리하자.

가능한 모든 예외 타입을 정의하고 처리하려고 하기보다는 포괄적인 unchecked 예외를 사용한다. 하위 계층의 모든 예외 타입을 정의하는건 매우 비효율적일 것이다. 예외의 이름과 오류 메시지에는 적절한 정보를 제공해야 한다.

예외가 발생하면 그 내용을 기록하고 프로그램을 계속 수행해도 좋은지 확인한다. 외부 API를 사용하는 경우 거기서 던지는 예외들을 잡아내는 wrapper 클래스를 사용한다.

null 값을 반환하거나 전달받아서 그 내용을 if문으로 확인하기 보다는 애초에 null이 활용되는 상황을 극도로 통제하여 애초에 null 값이 들어오지 않는 상황을 가정하고 조성해서 코드를 깔끔하게 만들자. null 대신 예외를 던지는 것도 하나의 방법이다.

외부 API

외부 API를 학습할 때 테스트 코드를 활용해보자. 이때 생성되는 테스트는 당연히 추후의 유지보수에도 도움이 될 것이다.

아직 명세 조차 안나온 다른 팀의 API를 사용해야 할 때는 adapter 패턴을 적용해 중간 추상화 계층과 가짜 API를 두고 임시로 사용할 수 있도록 하자. 이렇게 하면 단위 테스트도 쉬워진다.

이미 존재하는 외부 API도 어댑터, wrapper로 한번 감싸면 유지보수에 도움이 될 수 있다.

TDD

  • 실패하는 단위 테스트를 작성하고 나서야 실제 코드 작성하기
  • 컴파일은 성공하면서 실행은 실패하는 정도로만 단위 테스트 작성하기
  • 당장 실패하는 테스트를 통과할 정도까지만 실제 코드 작성하기

위 내용은 TDD가 주창하는 방식이다.

테스트 코드를 작성하는 비용은 꽤 크지만 시스템이 커질수록 코드 변경을 마음 놓고 할 수 있다는 장점이 매우 커진다. 잘못된 미묘한 수정 사항들이 모두 잘 작성된 테스트에 반영될 것이기 때문이다. 즉 테스트 코드는 코드에 유연성, 유지보수성, 재사용성을 제공한다.

테스트 코드도 가능한 하나의 작업만 테스트하도록 하면서(즉 assert를 한번만 사용하고 하나의 개념만 다루면서), 적절한 추상화를 통해 가독성을 높인다. 이렇게 잘 구성하면 테스트 코드 자체가 훌륭한 문서가 된다.

테스트 코드의 실행은 빨라야한다(그렇지 않으면 점점 실행되지 않게 될 것이다). 각 테스트는 서로 독립적이어야 한다. 테스트는 모든 환경에서 반복가능해야 한다(OS 등). 테스트는 스스로 검증하여 불리언 값을 반환해야 한다(개발자가 결과를 분석하지 않아도 되도록). 테스트는 적시에 작성해야 한다(실제 코드를 구현하기 직전에).

테스트는 잠재적으로 깨질만한 모든 부분을 테스트하고, 커버리지 도구를 활용하도록 한다, 사소한 테스트도 건너뛰지 않도록 하고 경계 조건과 버그 주변을 철저히 테스트 한다.

클래스

  • Single responsibility
  • Open-closed
  • Liskov-substitution principle
  • Interface segregation
  • Dependency inversion

클래스도 함수와 마찬가지로 작을수록 좋다. 그리고 SRP를 매우 엄격하게 지켜 응집도를 극대화하고 결합도는 최소화해야 한다.

수퍼클래스가 서브클래스에 의존하는 경우는 일부 예외를 제외하고 제거 한다. 비어있는 생성자 같은 쓸데 없는 코드들도 모두 제거 한다. 무관한 개념들을 인위적으로 결합하려 하지 말고 무엇보다 클래스의 기능을 쓸데 없이 늘리려 하지 말자.

시스템

Construction과 use를 분리한다. Main에서 모든 의존성을 초기화하고 세부적인 구현들에서는 각 의존성이 모두 연결되었다고 가정하고 코드를 작성한다(Lazy-loading 기법을 사용하지 않는다면). Dependency injection 컨테이너를 두고 별도의 의존성 주입 메커니즘을 사용할 수도 있다.

추상 팩토리 패턴을 사용하여 복잡한 객체의 생성을 비즈니스 로직과 분리한다. 복잡한 생성 방식은 실제로 객체의 생명주기 첫단계 이후에는 쓸모 없는 경우가 많다.

Cross-cutting 관심사는, 예를 들어 영속성 같은 관심사는, 객체의 경계를 넘나드는(cross-cutting) 관심사다. Aspect-Oriented Programming은 이러한 횡단 관심사에 대한 모듈성을 확보하는 방법론이다. 개발자는 영속적으로 저장할 객체를 선언하고 영속성과 관련한 모든 책임은 관련 프레임워크 등에 위임한다.

동시성

하나의 프로세서에서 동시성은, 여러 스레드가 처리해야할 독립적인 계산이 충분히 많은 경우에 평균 응답 시간에 성능 향상을 가져온다. 다만 컨텍스트 스위칭 등 트레이드 오프도 발생한다.

공유 자원은 관리하기 힘들고 제대로 관리하려면 성능에도 오버헤드를 일으키기 때문에 가능하면 최대한 사용하지 않도록 하고, 대신 자원의 사본을 사용한다(가능하면).

공유 자원을 사용해야 한다면 관련된 의존성을 잘 이해하고, 동기화, 락되는 부분을 최소한의 범위로 줄여야 한다.

다중 스레드의 테스트는 매우 어렵다. 가능한 문제를 노출하는 테스트 케이스를 작성하고, 여러 설정과 환경을 바꿔가며 테스트를 자주 돌려야 한다.

동시성을 다루는 작업은 그 자체로 매우 복잡하기 때문에 별도의 클래스로 분리해서 처리해주는게 좋다.

동시성을 사용하는 쪽보다 구현하는 쪽에서 잠금을 실행해 코드 중복과 실수를 줄이도록 한다.

데드락

데드락은 다음 네 요소가 모두 충족될 때 발생할 수 있다.

  • Mutual exclusion: 개수가 제한적인 자원을 여러 스레드가 동시에 사용하지 못함.
  • Lock & Wait: 스레드가 자원을 점유하면 나머지는 대기해야 함.
  • No preemption: 점유된 자원을 다른 스레드가 빼앗아 오지 못함.
  • Circular wait: 순환적으로 서로가 점유한 자원을 서로가 기다림.

이 중 단 하나라도 깨주면 데드락은 오지 않는다. 애초에 동시에 사용할 수 있는 자원을 사용하거나, 대기하지 않도록 하거나, 남의 자원을 선점할 수 있게 하거나, 순환적인 구조를 피해서 코딩하거나.

다만 모든 자원을 확보할 수 없으면 가진 자원 다 내놓고 대기하지 않도록 하면 어떤 스레드는 한꺼번에 확보하기 어려운 자원들로 인해 Starvation 문제에 빠질 수 있고, 여러 스레드가 한번에 락에 진입해서 점유했다 내놨다 반복하는 라이브락 문제에 빠질 수도 있다. 남의 자원을 빼앗는 선점 알고리즘은 관리하기 까다롭고, 순환적인 구조는 개발자 마음대로 안될 때가 많다.

많은 경우에서 그냥 데드락 발생을 용인하기도 한다.

환경

하나의 명령어나 버튼을 통해 간단하게 빌드와 테스트를 할 수 있는 환경을 구축하자.

관례를 정하기 보다는 가능하면 애초에 위반할 수 없게 언어의 구조 자체를 활용한다.

코드

가능한 한 소스 파일에 하나의 언어만 사용하고, 코드를 봤을 때 누구나 당연하게 예측 가능한 행동만 하도록 구현한다. 그리고 알고리즘의 경계 지점은 항상 주의하고, 모든 중복은 최소화하자(똑같은 코드든 비슷한 알고리즘이든). 추상화 수준도 적절히 조절하자.

논리적인 의존성은 반드시 물리적인 의존성으로 직접 드러낸다. 리터럴 상수들은 검색, 유지보수를 위해 명명된 상수로 변경한다.

조건문에는 복잡한 부정 조건 보다는 바로 이해되는 긍정 조건을 사용하도록 하고, 조건을 반환하는 함수로 캡슐화해 가독성을 높이자. 어떤 함수들이 순서대로 실행되야 한다면 이전 함수의 결과값을 인수로 받도록 하는 방식을 사용해 그 결합을 드러낸다.

알고리즘의 경계조건을 +1 같은 식으로 표현하지 말고 별도의 변수로 의미를 드러내자.

설정에 관한 정보는 추상화 최상위 단계에 둔다. 중요한 설정 정보가 저 하위 계층 클래스에 있다면 누가 그걸 찾을 수 있겠는가.

Conclusion

요즘 프로젝트를 하며 아키텍처나 구체적인 코드 관행에 대해서 고민이 많아져서 읽어 봤는데, 막 “와!” 까지는 아니지만 나름대로 생각이 정리되고 좋은 것 같다. 이미 가독성과 유지보수성이 훌륭한 수준의 코드를 짜고 있는 고수들은 굳이 시간내서 읽을 필요는 없겠다. 가끔 오픈 소스 프로젝트 코드들을 보면 왜 이렇게 코드를 짰을까 했는데 클린 코드의 내용과 비슷해서 무릎을 탁 치기도 했다. 하여튼 결론은 코드 관행에 대한 고민이 많은 사람은 한번 읽어 보고 생각을 정리하는데 도움을 얻도록 하자.

--

--