CLEAN CODE

Tasker·2023년 10월 4일
0

개발

목록 보기
1/4

1. 책을 읽게 된 경위

7년 전부터 미루고 미루다 읽은 책..

왜 읽게 되었나?

  처음에는 학교 선배가 추천해준 책이었는데, 대학생때 워낙 바쁘게 살다 보니 사놓고 읽지는 못했던 그런 책 중에 하나였다. 예전부터 깔끔한 코드에 관심이 있기도 했고 오픈채팅방이랑 스터디에서 지속적으로 언급되는 책이어서 호기심이 커져서, 구매 7년 만에 표지를 넘길 수 있었다.


2. N회독 후기

주워들은 지식으로 알고 있었던, 하지만 알고만 있었던(?) 내용

당연한 것에 진리가 있다.

  기술 블로그나 코딩컨벤션을 정할 때 생각하던 내용들을 왜 그런지, 예시와 함께 설명해주는 책이었다. 학문적인 부분은 대학생때 배운 내용들이 대부분을 차지했다. SOLID, DRY, KISS, Critical Section, Dead Lock, Live Lock 등등..

JAVA 기반으로된 설명

  자바는 대학 다닐때 4년 동안 제일 좋아했던 언어였지만, 첫 취업을 JS계열로 커리어를 쌓게 되면서, 졸업 이후에는 자바 코드를 본 적이 없었지만 언어는 언어일 뿐이라서 막 이해가 안 될 정도는 아니었다.


3. 톺아보기

서론

  • 클린 코드는 "팀에서 서로 동의하는 원칙"을 세우기 위한 소통에서 시작된다.
  • 개발 생태계에서 라이브러리와 프레임워크가 빠르게 발전하지만, 개발에 대한 원칙과 기본은 변하지 않는다.

보이스카우트 규칙

  The boy scout rule. 떠날 때는 찾을 때보다 더 깨끗하게 해야 한다. 이는 개발에서도 적용된다. > git pull 로 체크아웃한 코드보다 > git push로 체크인 한 코드가 더 깨끗해야 한다.

개발 역량을 제고하는 방법

  가장 좋은 건, 프로그램을 잘 짜는 사람과 같이 일하는 것이다. 그 사람이 생각하는 방식을 배운다면 개발 실력이 순식간에 껑충 뛴다. 개발 실력은 계단식으로 성장하는데, 아무런 자극이 없으면 그 단계를 벗어나지 못한다.

유지보수의 중요성

  SW는 80% 이상이 유지보수다. 건축 업계의 수리공이나 자동차 업계의 수리공처럼 소프트웨어 개발자를 생각해야 한다.

가독성의 중요성

  읽기 좋은 코드는 돌아가는 코드만큼이나 중요하다. 코드를 인정사정없이 리팩토링하라.

변수 네이밍

  아이 이름을 짓듯이 심사숙고해서 지어야 한다.

설계

  설계는 과정이지, 고착된 종착점이 아니다.


1장, 깨끗한 코드

기계가 실행할 정도로 상세하게 요구사항을 명시하는 작업. 바로 이것이 프로그래밍이다.

르블랑의 법칙

  Leblanc's Law. 나중은 결코 오지 않는다. 돌아가는 쓰레기에 대한 리팩토링을 미루지 마라.

클린코드의 효과

  시간을 들여 깨끗한 코드를 만드는 노력이 장기적인 비용을 절감하는 방법이며, 전문가로서 살아남는 길이다.

관리자의 오더와 프로그래머의 태도

  대다수 관리자는 좋은 코드를 원한다. 그러면서도 관리자가 일정과 요구사항을 강력하게 밀어 붙이는 이유는, 그것이 관리자의 책임이기 때문이다. 개발자의 책임은 좋은 코드를 사수하는 일이다.

기한을 맞추는 방법

  빠르게 가는 유일한 방법은 언제나 코드를 최대한 깨끗하게 유지하는 습관이다.

유명한 프로그래머들이 말하는 클린 코드란?

  • 논리적으로 간단하다.
  • 의존성을 최대한 줄여, 유지보수가 쉽다.
  • 한 가지 기능만 책임진다.
  • 추측이 아니라, 사실에 기반해야 한다.
  • 나쁜 코드는 나쁜 코드를 유혹한다.
  • Test Case가 없는 코드는 깨끗한 코드가 아니다.
  • 중복이 없다.
  • 정말 빠르게 개발해야 한다면, 중복과 추상화만 잡은 뒤에 추후에 구현단을 바꿔라.

개발자는 저자다.

  저자는 독자들과 소통할 책임이 있으며, 한 줄의 문장을 글 중간에 삽입하려면 끊임없이 기존 글을 읽는다. 뒤로 갈수록 쓰는 시간보다 읽는 시간이 많아질 것이기 때문에 술술 읽혀야 한다.


2장, 의미있는 이름

변수명은 의도가 분명해야 한다.

그릇된 단서를 남기지 말라.

  개발자에게 List는 특수한 의미다. 실제 List 추상 자료형이 아니라면, accountList처럼 명명하지 않는다.

불용어

  Product라는 클래스가 있는데, ProductInfo나 ProductData라는 클래스를 다루면 안 된다. 다 같은 의미로 인지하기 때문이다.

발음하기 쉬운 이름

  프로그래밍은 사회 활동이기 때문에 발음하기 쉬운 이름은 중요하다. 명료하고 간결하게 지어라.

기억력을 자랑하지 마라.

  i, j 같은 변수가 아닌 realDaysPerIdealDay, WORK_DAYS_PER_WEEK 같은 변수명을 사용하라.

변수명 인코딩을 피하라.

  접두어 등의 인코딩을 피하라. 헝가리안 표기법에서의 데이터 타입을 앞에 적는 방식이나 인터페이스의 경우 'I'를 붙이는 방식은 피하라. 이는 불필요한 부담이 된다.

클래스 이름

  클래스 이름과 객체 이름은 명사나 명사구가 적합하다. Customer, WikiPage, Account, AddressParser등이 좋은 예이며, 피해야 할 단어로 Manager, Processor, Data, Info 등이 있다.

메서드 이름

  메서드 이름은 동사나 동사구가 적합하다. postPayment, deletePage, save등이 좋은 예이며, 접근자(Accessor) • 변경자(Mutator) • 조건자(Predicate)는 get, set, is를 사용한다.

일관성있는 어휘를 사용하라.

  동일 코드 기반인데 어떤 것은 Controller, 어떤 것은 Manager로 짓지 마라. A와 B를 더하는 add로 써오다가 집합의 요소를 추가하는 add 메서드를 추가하지 마라.

네이밍 규칙 우선 순위

  1. 익숙한 고유 의미
  2. 해법영역: Singleton, Stack, Factory, Queue, ...
  3. 도메인: RotateSpeed, AttackDelay
  4. 접두어

3장, 함수

함수를 만드는 첫째 규칙은 '작게!'다. 둘째 규칙은 '더 작게!'다.

함수의 크기

  함수는 작을 수록 좋다. 20줄도 길다. if/else/while문 등에 들어가는 블록은 한 줄이어야 하고, 대게 거기서 함수를 호출한다.

SRP(Single Responsibility Principle)

  함수는 한 가지를 해야 한다. 그 한 가지를 잘 해야 한다. 그 한 가지만을 해야 한다.

함수의 추상화 수준

  함수 내 모든 문장의 추상화 수준이 동일해야 한다. '내려가기 규칙'을 적용해서 논리적으로 아래와 같은 형태로 표현 되어야 읽는 사람이 헷갈리지 않는다.

- 제목1
    - 소제목1
    	- 본문1
        - 본문2
    - 소제목2
    - 소제목3
    	- 본문1

OCP(Open Closed Principle)

  확장에 열려 있어야 하고, 수정에 닫혀 있어야 한다.

서술적인 이름을 사용하라.

  함수는 이름이 길어도 괜찮다. 겁먹을 필요 없다. 길고 서술적인 이름이 짧고 어려운 이름보다 좋다. 이름을 정하느라 시간을 들여도 괜찮다.

인수의 개수

  가장 이상적인 인수의 수는 0개다. 적으면 적을 수록 좋다. 3개는 피하는 편이 좋고, 4개 이상은 특별한 이유가 필요하다.

단항 인수 함수

  단항 인수 함수는 동사/명사 쌍을 이루면 의도를 명확히 표현할 수 있다. writeFile("name") 처럼 말이다. 아래는 좋은 예다.

- boolean fileExists("MyFile")
- InputStream fileOpen("MyFile")
- passwordAttemptFailedNtimes(int attempts)

아래는 안 좋은 예다.

- void includeSetupPageInfo(StringBuffer pageText)

변환 함수에서 출력 인수를 사용했다는 점, 혹은 객체 설정을 외부에 맡겼다는 점에서 혼란을 일으킨다.

Flag 인수는 추하다.

  함수로 부울 값을 넘기는 관례는 정말로 끔찍하다. SRP를 어긴다고 대놓고 공표하는 셈이다.

이항 인수 함수

  Point p = new Point(0,0)는 좋은 예이지만, func(A,B)A.method(B)로 리팩토링하는 게 좋다.

삼항 인수 함수

  이 경우는 대게 일부를 독자적인 클래스로 선언할 수 있다. Circle makeCircle(double x, double y, double radius);Circle makeCircle(Point center, double radius);로 바꿀 수 있다.

구현부를 찾게 하지 마라.

  함수 선언부를 찾아보는 행위는 코드를 보다가 주춤하고 인지적으로 거슬린다는 뜻이므로 피해야 한다. 추상화된 이름으로 유추할 수 있도록 한다.

명령과 조회를 분리하라.

  함수는 뭔가를 수행하거나 뭔가에 답하거나 둘 중 하나만 해야 한다. if (deletePage(page) == E_OK)는 좋은 코드가 아니다. 오류 코드 대신 Try/Catch 예외 처리를 사용하라.

DRY(Don't Repeat Yourself)

  중복은 소프으웨어에서 모든 악의 근원이다. AOP(Aspect Oriented Programming), COP(Component Oriented Programming) 등 모든 개발 방식이 중복 제거에 기반해 있다.


4장, 주석

주석은 필요 없다.

주석을 작성하는 이유

  코드로 의도를 표현하는 데에 실패하여 주석을 사용한다. 우리가 작성한 코드가 지저분한 모듈이라는 사실을 자각해서 "주석을 달아야겠다!" 라고 생각한다. 주석을 되도록 안 쓰는 코드가 클린코드다.

주석은 코드의 버전을 따라가지 못한다.

  주석은 시간이 지날수록 코드에서 멀어진다. 부정확한 주석은 아예 없는 주석보다 훨씬 나쁘다.

그럼에도 필요한 주석

  1. 법적인 주석 - 파일 첫머리의 저작권 정보와 소유권 정보
  2. 의도를 설명하는 주석 - 정규식의 해석, 테스트 스레드를 25000개로 설정한 이유
  3. 결과를 경고하는 주석 - 여유 시간이 충분하지 않다면, 이 테스트는 실행하지 마십시오.
  4. TODO - 앞으로 해야할 정보. TODO를 떡칠하지는 않는다.
  5. Javadocs - 공개 API를 구현할 때 유용하게 사용한다. 코드의 버전과 일치시키도록 신경쓴다. 그러면서도 의무적으로 모든 함수나 변수에 Javadocs를 다는 것은 어리석다.

VCS(Version Control System) 활용

  지금은 Git 같은 VCS가 있기 때문에 옛날 코드처럼 변경 이력을 주석으로 기록하고 관리하지 않아도 된다. 완전히 제거하는 편이 좋다. 주석처리된 코드뭉치 또한 과감히 삭제하라.


5장, 형식 맞추기

오늘 구현한 기능이 다음 버전에서 바뀔 확률은 아주 높다.

가독성의 중요성

  코드 형식은 의시소통의 일환이다. 따라서 가독성은 앞으로 바뀔 코드의 품질과 속도에 지대한 영향을 미친다.

연관성

  서로 밀접한 개념은 한 파일에 속해야 마땅하다. protected 변수를 피해야 하는 이유 중 하나다. 연관성이 깊은 두 개념이 멀리 떨어져 있으면 코드를 읽는 사람이 소스 파일을 여기저기 뒤지게 된다.

변수 선언

  변수는 사용하는 위치에 최대한 가까이 선언한다. 지역번수는 각 함수 맨 처음에 선언한다. 루프를 제어하는 변수는 루프 문 내부에서 선언한다.

인스턴스 선언

  인스턴스 변수는 클래스 맨 처음에 선언한다.

함수 선언

  한 함수가 다른 함수를 호출한다면 두 함수는 세로로 가까이 배치한다. 가능하다면 호출하는 함수를 호출되는 함수보다 먼저 배치한다. 그러면 프로그램이 자연스럽게 읽힌다.

가로줄 제한

  행 길이는 100~120자 정도로 제한하는 게 좋다.

팀 규칙

  팀은 한 가지 규칙에 합의해야 한다. 그리고 모든 팀원은 그 규칙을 따라야 한다. 개개인이 따로국밥처럼 맘대로 짜대는 코드는 피해야 한다. 어떤 개발자가 와도 한 소스파일에서 봤던 형식이 다른 소스 파일에도 쓰이리라는 신뢰감을 줘야 한다.


6장, 객체와 자료 구조

추상 인터페이스를 제공해 사용자가 구현을 모른 채 자료의 핵심을 조작할 수 있어야 진정한 의미의 클래스다.

추상 인터페이스

  인터페이스는 자료 구조를 명백하게 표현해야 한다. 자료를 세세하게 공개하기보다는 추상적인 개념으로 표현해야 한다.

객체 지향과 절차 지향

  새로운 자료 타입이 필요한 경우 객체 지향 기법이 적합하고, 새로운 함수가 필요한 경우 절차 지향 기법이 적합하다.

디미터 법칙

  디미터 법칙은 잘 알려진 휴리스틱이다. 모듈은 자신이 조작하는 객체의 속사정을 몰라야 한다. 메서드가 반환하는 객체의 메서드는 호출하면 안 된다. 아래는 '기차 충돌'이라는 안 좋은 예시다.
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();
그리고 아래는 좋은 예시다.
final String outputDir = ctxt.options.scratchDir.absolutePath;

DTO(Data Transfer Object)

  DTO는 공개 변수만 있고, 함수가 없는 자료 구조체다.

활성 레코드

  활성 레코드는 객체가 아닌, 자료 구조체다. DTO의 특수한 형태로, 데이터베이스 테이블이나 다른 소스에서 자료를 직접 변환한 결과다. 내부 자료를 숨기는 객체는 따로 생성하되, 이는 활성 레코드의 인스턴스일 가능성이 높다.


7장, 오류 처리

오류가 발생하면 예외를 던지는 게 좋다. 그러면 호출자 코드가 더 깔끔해진다.

Try-Catch-Finally

  예외가 발생할 코드를 짤 때는 try-catch-finally 문으로 시작하는 편이 좋다. 그러면 try 블록에서 무슨 일이 생기든지 호출자가 기대하는 상태를 정의하기 쉬워진다.

TDD(Test Driven Development)

  먼저 강제로 예외를 일으키는 테스트 케이스를 작성한 후 코드를 작성하는 방법을 권장한다. 그러면 트랜잭션 본질을 유지하기 쉬워진다.

예외에 의미를 제공하라

  예외를 던질 때는 전후 상황을 충분히 덧붙인다. 그러면 오류가 발생한 원인과 위치를 찾기가 쉬워진다. Catch 블록에서 오류를 기록하도록 충분한 정보를 넘겨 주고 로깅한 뒤, 프로그램을 계속 수행해도 좋은지 확인한다.

외부 API 사용시 오류 처리

  외부 API를 사용할 때는 감싸기 기법이 최선이다. 의존성이 낮아서 나중에 다른 라이브러리로 갈아타도 비용이 적으며, 특정 업체가 API를 설계한 방식에 발목 잡히지 않는다.

특수 사례 패턴

  때로는 외부 API를 감싸는 게 적합하지 않은 때도 있다. 클래스나 객체가 예외적인 상황을 캡슐화해서 처리하여 클라이언트 코드가 예외적인 상황을 처리할 필요가 없어진다. 이를, 특수 사례 패턴이라고 부른다.

null을 반환하지 마라.

  null을 반환하는 코드는 일거리를 늘릴 뿐만 아니라 호출자에게 문제를 떠넘기고, null을 호출자가 처리하게 되면 종속성이 강해진다. null을 반환하고픈 유혹이 든다면 그 대신 예외를 던지거나 특수 사례 객체를 반환한다. 만약 사용하려는 외부 API가 null을 반환한다면 감싸기 메서드를 구현해 예외를 던지거나 특수 사례 객체를 반환하는 방식을 고려한다.

null을 전달하지 마라.

  null을 반환하는 방식도 나쁘지만 메서드로 null을 전달하는 방식은 더 나쁘다. 대다수 프로그래밍 언어는 null을 적절히 처리하는 방법이 없다. 그렇다면 애초에 null을 넘기지 못하도록 금지하는 정책이 합리적이다. 즉, 인수로 null이 넘어오면 코드에 문제가 있다는 말이다.


8장, 경계

시스템에 들어가는 모든 소프트웨어를 직접 개발하는 경우는 드물다. 우리는 오픈소스, 패키지, 컴포넌트를 깔끔하게 통합해야 한다.

학습 테스트

  외부 패키지 테스트는 우리 책임이 아니다. 하지만, 우리쪽 코드를 작성해 외부 코드를 호출하는 대신 간단한 테스트 케이스를 작성해 외부 코드를 익힐 수 있다. 이를 학습 테스트라고 한다. API를 사용하려는 목적에 초점을 맞춰서 진행한다. 패키지의 새 버전에도 대응이 가능하며, 이전이 쉬워 진다. 그렇지 않다면

외부 패키지와 우리 소프트웨어의 경계

  새로운 클래스로 경계를 감싸거나 아니면 Adapter pattern을 사용해 우리가 원하는 인터페이스를 패키지가 제공하는 인터페이스로 변환하자. Typeorm이나 Prisma 처럼 말이다.


9장, 단위 테스트

실제 코드와 맞먹을 정도로 방대한 테스트 코드는 심각한 관리 문제를 유발하기도 한다.

TDD 법칙 세 가지

  1. 첫째 법칙: 실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다.
  2. 둘째 법칙: 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
  3. 셋째 법칙: 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.

단위 테스트가 주는 이점

  1. 코드의 유연성
  2. 코드의 유지보수성
  3. 코드의 재사용성

  테스트 케이스가 있으면 변경이 두렵지 않다.

assert는 최소한으로 작업하라.

  테스트당 하나의 개념을 테스트하며 assert문 개수는 최대한 줄여야 이해하기 쉽고, 어디서의 오류인지 빠르게 파악할 수 있다.

F.I.R.S.T 규칙

  • F(Fast): 테스트는 빨라야 한다. 테스트가 느리면 자주 돌릴 엄두를 못 낸다.
  • I(Independent): 각 테스트는 서로 의존하면 안 된다. 어떤 순서로 실행해도 괜찮아야 한다.
  • R(Repeatable): 어떤 환경에서도 반복 가능해야 한다. Dev, QA, Prod, Local에서 결과가 동일해야 한다.
  • S(Self-Validating): bool(success/fail) 로 결과를 볼 수 있어야 한다. 로그 파일을 읽게 해서는 안 된다.
  • T(Timely): 실제 코드를 구현하기 직전에 구현한다. 실제 코드를 구현한 다음에 테스트 코드를 만들면 실제 코드가 테스트하기 어렵게 작성될 수 있다. 애초에 테스트가 불가능하도록 설계할지도 모른다.

DSL(Domain Specific Langauge)

  테스트 코드는 실제 코드만큼이나 중요하다. 테스트 API를 구현해 도메인 특화 언어를 만들자. 그러면 그만큼 테스트 코드를 짜기가 쉬워 진다.


10장, 클래스

클래스를 설계할 때도, 함수와 마찬가지로, '작게'가 기본 규칙이다.

클래스 체계

  멤버 변수나 상수는 static public final -> static private -> private 순서로 작성되어 내려오며, public 변수가 필요한 경우는 거의 없다. private method는 public method에서 호출된 직후에 기술된다.

캡슐화

  캡슐화를 풀어주는 결정은 언제나 최후의 수단이다.

SRP(Single Responsibility Principle)

  하나의 책임을 진다. 클래스나 모듈을 변경할 이유가 단 하나뿐이어야 한다는 말이다. Processor, Manager 등과 같이 모호한 단어가 있다면 클래스에다 여러 책임을 떠안겼다는 증거다.

응집도를 높여라.

  응집도가 높다는 말은 논리적인 단위로 묶인다는 의미다. 응집도가 높아지도록 변수와 메서드를 적절히 분리해 새로운 클래스 여러 개로 쪼개준다.

OCP(Open Closed Principle)

  클래스는 확장에 개방적이고 수정에 폐쇄적이어야 한다.

결합도를 낮춰라.

  API를 직접 호출하는 대신 인터페이스를 생성한 후 메서드 하나를 선언하면, 클래스를 흉내내는 테스트용 클래스도 만들 수 있다. 이렇게 결합도를 낮추면 유연성과 재사용성도 더욱 높아진다. 변경으로부터 잘 격리되어 있다는 의미다.

DIP(Dependency Inversion Principle)

  결합도를 최소로 줄이면 나오는 설계 원칙이다. 상세한 구현이 아니라 추상화에 의존해야 한다는 원칙이다.


11장, 시스템

프로그램 제작은 사용과 아주 다르다는 사실을 명심한다.

초기화 지연과 계산 지연 기법

  실제로 필요할 때까지 객체를 생성하지 않으면, 불필요한 부하가 걸리지 않는다.

public Service getService() {
	if (service == null) {
    	service = new MyServiceImpl(...); // 인수에 의존하지 않을때만 가능
    }
    return service;
}

Abstract Factory Pattern

  때로는 객체가 생성되는 시점을 애플리케이션이 결정할 필요도 생긴다. 이 때 사용하는 패턴.

DI(Dependency Injection)

  인터페이스에 의존하는 기법이며, IoC(Inversion of Control)기법을 의존성 관리에 적용한 메커니즘이다. 프레임워크는 DI의 인터페이스 기반이다. 스프링 프레임워크를 예로 들면, 자바 DI 컨테이너를 제공하며, 객체 사이 의존성은 XML 파일에 정의한다. 대다수의 DI 컨테이너는 필요할 때까지는 객체를 생성하지 않고, 대부분은 계산 지연이나 비슷한 최적화에 쓸 수 있도록 팩토리를 호출하거나 프록시를 생성하는 방법을 제공한다.

Proxy

  개별 객체나 클래스에서 메서드 호출을 감싸서 대리로 수행하는 경우를 뜻한다. JDK에서 제공하는 동적 프록시는 인터페이스만 지원한다. Has-a 관계가 Proxy API 기반이라고 보면 된다. Spring AOP, JBoss AOP등과 같은 여러 자바 프레임워크는 내부적으로 프록시를 사용한다.

POJO(Plain Old Java Object)

  POJO는 순수하게 도메인에 초점을 맞춘다. 프레임워크에 의존하지 않는 독립적인 객체라고 보면 된다. 학교 수업에서 배운 그 객체다. 따라서 테스트가 개념적으로 더 쉽고 간단하다.

프레임워크

  프레임워크는 사용자가 모르게 프록시나 바이트코드 라이브러리를 사용해 영속성, 트랜잭션, 보안, 캐시, 장애조치 등과 같은 설정을 구현한다. 주요 객체를 생성하고 서로 연결하는 등 DI 컨테이너의 구체적인 동작또한 제어한다.

EJB3 스프링

  모든 정보가 애너테이션 속에 있으므로 코드 자체가 깔끔하고 깨끗하다. 즉, 그만큼 코드를 테스트하고 개선하고 보수하기 쉬워졌다. 이것도 자동화와 재사용의 일종이다.

AspectJ 관점

  관심사를 관점으로 분리하는 가장 강력한 도구는 AspectJ언어다. AspectJ는 언어 차원에서 관점을 모듈화 구성으로 지원하는 자바 언어 확장이다. 스프링 프레임워크는 AspectJ에 미숙한 팀들이 애너테이션 기반 관점을 쉽게 사용하도록 다양한 기능을 제공한다.

관심사 분리

  관심사를 분리하는 방식은 그 위력이 막강하다. 도메인 논리를 POJO로 작성하여, 코드 수준에서 아키텍처 관심사를 분리할 수 있다면, 진정한 테스트 주도 아키텍처 구축이 가능해지고, 단순한 아키텍처를 복잡한 아키텍처로 키워갈 수도 있다.

Agile

  아주 단순하하면서도 멋지게 분리된 아키텍처로 소프트웨어 프로젝트를 진행해 결과물을 재빨리 출시한 후, 기반 구조를 추가하며 조금씩 확장해나가도 좋다. 아무 방향 없이 프로젝트에 뛰어들라는 소리는 아니다. 범위, 목표, 일정은 물론이고 결과로 내놓을 시스템의 일반적인 구조도 생각해야 한다. 변하는 환경에 대처해 진로를 변경할 능력도 반드시 유지해야 한다.

표준을 사용하라.

  표준을 사용하면 아이디어와 컴포넌트를 재사용하기 쉽고, 적절한 경험을 가진 사람을 구하기 쉬우며, 좋은 아이디어를 캡슐화하기 쉽고, 컴포넌트를 엮기 쉽다. 하지만 때로는 표준을 만드는 시간이 너무 오래 걸려 업계가 기다리지 못하거나 원래 표준을 제정한 목적을 잊어버리기도 한다. 그러니, 공식문서와 친해져라. 바로 만들지 않고 검색으로 찾아보고 확실하게 작업하라. 라이브러리를 쓸 때도 마찬가지다.

시스템은 도메인 특화 언어가 필요하다.

  DSL은 간단한 스크립트 언어나 표준 언어로 구현한 API를 가리킨다. 좋은 DSL은 도메인 개념과 그 개념을 구현한 코드 사이에 존재하는 의사소통 간극을 줄여준다. 애자일 기법이 팀과 프로젝트 이해관계자 사이에 의사소통 간극을 줄여주듯이 말이다. DSL을 사용하면 고차원 정책에서 저차원 세부사항에 이르기까지 모든 추상화 수준과 모든 도메인을 POJO로 표현할 수 있다.


12장, 창발성(創發性)

모든 테스트를 실행하라.
중복을 없애라.
의도를 표현하라.
클래스와 메서드의 수를 최소로 줄여라.

모든 테스트를 실행하라.

  검증이 불가능한 시스템은 절대 출시하면 안 된다. SRP를 준수하면 테스트가 쉽고, 결합도가 높으면 테스트 케이스를 작성하기 어렵다. DIP와 같은 원칙을 적용하고 DI, 인터페이스, 추상화 등과 같은 도구를 사용해 결합도를 낮춰라.

리팩토링하라.

  코드를 점진적으로 리팩토링 해나간다. 시스템이 깨질까 걱정할 필요는 없다. 테스트 케이스가 있으니까. 응집도를 높이고, 결합도를 낮추고, 관심사를 분리하고, 시스템 관심사를 모듈로 나누고, 함수와 클래스 크기를 줄이고, 더 나은 이름을 선택하고, 중복을 제거하고, 의도를 표현하고, 클래스와 메서드 수를 최소로 줄여라.

Template Method Pattern을 활용하라.

  작업을 처리하는 일부분을 서브 클래스로 캡슐화해 전체 기능은 그대로 두면서 특정 단계 내역을 바꾸는 패턴이다.


13장, 동시성

동시성은 책 하나를 할당할 정도로 복잡한 주제다.

동시성이 필요한 이유

  동시성은 결합을 없애는 전략이다. '무엇'과 '언제'를 분리하는 전략이다. Servlet을 예로 들면, Servlet은 EJB Container에서 돌아가는데 이 컨테이너는 동시성을 부분적으로 관리해주어, 웹 요청이 들어오면 웹 서버는 비동기식으로 Servlet을 실행한다. 구조적 개선 뿐 아니라, 응답 시간과 작업 처리량 개선을 위해서 동시성 구현이 불가피하다.

동시성에 대한 오해

  • 동시성이 항상 성능을 높여주는 것은 아니다. 대기 시간이 아주 길어 여러 스레드가 프로세서를 공유할 수 있거나, 여러 프로세서가 동시에 처리할 독립적인 계산이 충분히 많은 경우에만 성능이 높아진다.
  • 동시성을 구현하면 설계가 변하지 않는다는 것은 거짓이다. 시스템 구조가 크게 달라진다.
  • 웹 또는 EJB 컨테이너를 사용하면 동시성을 이해할 필요가 없다는 것도 거짓이다. 물론, 멀티스레드를 관리해주긴 하나, 동작은 알아야 한다.

동시성의 특징

  • 동시성은 부하를 유발한다. 코드도 더 짜야 한다.
  • 간단한 문제라도 동시성은 복잡하다.
  • 동시성 버그는 재현하기 어렵다. 일회성 문제로 여겨 무시하기 쉽다.
  • 동시성을 구현하려면 흔히 근본적인 설계 전략을 재고해야 한다.

동시성 방어 원칙

  • SRP: 복잡성 하나만으로도 따로 분리할 이유가 충분하다.
  • 자료 범위의 제한: 공유자원을 최대한 줄이고, 자료를 캡슐화하라. Critical Section은 synchronized 키워드로 보호한다.
  • 자료 사본 사용: 객체를 복사해 읽기 전용으로 사용한다. 이 방식으로 내부 잠금을 없애 절약한 수행 시간은 사본 생성과 가비지 컬렉션 부하 시간을 상쇄한다.
  • 스레드의 독립적 구현: 자신만의 세상에 존재하는 스레드를 구현한다. 다른 스레드와 자료를 공유하지 않고, 각 스레드는 클라이언트 요청 하나를 처리한다. 모든 정보는 비공유 출처에서 가져오며 로컬 변수에 저장한다. 물론, Servlet은 대다수 DB Connection과 같이 자원을 공유하는 상황에 처한다.

라이브러리를 이해하라.

  동시성에 좋은 라이브러리들이 많다. 지속적으로 오픈소스를 공부하라. 아래는 동시성 설계 클래스 일부다.

  • ReentrantLock: 한 메서드에서 잠그고 다른 메서드에서 푸는 락이다.
  • Semaphore: 전형적인 세마포다. 개수(Count)가 있는 락이다.
  • CountDownLatch: 지정한 수만큼 이벤트가 발생하고 나서야 대기 중인 스레드를 모두 해제하는 락이다. 모든 스레드가 공평하게 시작할 기회를 준다.

실행모델을 이해하라.

  • 한정된 자원(Bound Resource): 크기나 숫자가 제한적이다. DB Connection, 길이가 일정한 읽기/쓰기 버퍼 등이 그 예다.
  • 상호 배제(Mutual Exclusion): 한 번에 한 스레드만 공유 자원을 사용할 수 있다.
  • 기아(Starvation): 굉장히 오랫동안 혹은 영원히 자원을 기다린다.
  • 데드락(Deadlock): 여러 스레드가 서로가 끝나기를 기다린다.
  • 라이브락(Livelock): 락을 거는 단계에서 각 스레드가 서로를 방해한다.자원을 점유했다 내려놨다를 반복.

공유 객체 하나에 여러 메서드가 필요한 상황

  • 클라이언트에서 잠금: 첫 번째 메서드를 호출하기 전에 서버를 잠근다. 마지막 메서드를 호출할 때까지 잠금을 유지한다.
  • 서버에서 잠금: 서버에다 "서버를 잠그고 모든 메서드를 호출한 후 잠금을 해제하는" 메서드를 구현한다. 그리고 이 메서드를 클라이언트에서 호출한다.
  • 연결(Adapter) 서버: 잠금을 수행하는 중간 단계를 생성한다. "서버에서 잠금" 단계와 유사하지만 원래 서버는 변경하지 않는다.

스레드 코드 테스트하기

  코드가 올바르다고 증명하기에는 현실적으로 불가능하다. 그럼에도 충분한 테스트는 위험을 낮출 수 있다. 문제를 노출하는 테스트 케이스를 작성하고, 프로그램 설정과 시스템 설정, 그리고 부하를 바꿔가며 자주 돌려라. 테스트가 실패하면 원인을 추적하라. 다시 돌렸더니 통과하더라는 이유로 그냥 넘어가면 절대로 안 된다.

  • 테스트 코드를 천천히, 빨리, 다양한 속도로 돌려본다. Object.wait() 나 yield()를 이용한다. 코드가 실패한다면, yield때문이 아니라, 원래 잘못된 코드일 확률이 높다.
  • 실행 중 스레드의 수를 바꿔본다.
  • 프로세서 수보다 많은 스레드를 돌리면서, Swapping을 유도해본다. 스와핑이 잦을수록 임계영역을 빼먹은 코드나 데드락을 일으키는 코드를 찾기 쉬워진다.
  • 코드에 보조 코드를 넣어서 강제로 실패를 일으키게 해본다.
  • 스레드 환경 밖에서 생기는 버그와 스레드 환경에서 생기는 버그를 따로 디버깅하한다. 밖에서 생기는 버그는 POJO를 만들어서 따로 테스트한다.

14장, 점진적인 개선

프로그래밍은 과학보다 공예(Craft)에 가깝다.

코드를 개선하는 과정

  한 방에 깨끗하고 우아한 프로그램이 나오지는 않는다. 프로그래밍은 공예에 가깝기 때문에 깨끗한 코드를 짜려면 지저분한 코드를 짠 뒤에 정리해야 한다. 그리고 개선이라는 이름 아래 구조를 크게 뒤집는 행위는 프로그램을 망치기 때문에, 개선 전과 똑같이 프로그램을 돌리면서 개선하는 게 좋으며, TDD기법을 사용해서 변경을 가한 후에도 변경 전과 똑같이 모든 테스트가 돌아가게 해야 한다. 변경 중간 중간의 과정에도 말이다.

리팩토링을 할 때에는 코드를 넣었다 뺐다 하는 사례가 아주 흔하다. 단계적으로 조금씩 변경하며 매번 테스트를 돌려야 하므로 여기저기 옮길 일이 많아진다. 큰 목표를 이루기 위해 자잘한 단계를 수없이 거친다.

개선을 쉽게 하는 방법

  소프트웨어 설계는 분할만 잘해도 품질이 크게 높아진다. 적절한 장소를 만들어 코드만 분리해도 설계가 좋아진다. 관심사를 분리하면 코드를 이해하고 보수하기 훨씬 더 쉬워진다. 코드는 처음부터 깨끗하게 유지해야 하며, 아침에 엉망으로 만든 코드를 오후에 정리하기는 어렵지 않다.


15장, JUnit 들여다보기

코드를 리팩토링 하다 보면 원래 했던 변경을 되돌리는 경우가 흔하다. 리팩토링은 코드가 어느 수준에 이를 때까지 수많은 시행착오를 반복하는 작업이기 때문이다.

변수 앞에 접두어를 넣을 필요가 없다.

  이전에는 접두어 f, I, ... 를 넣었지만, 지금은 IDE에서 잡아 주기 때문에 필요없다.

의도를 명확히 표현하라.

  조건문은 캡슐화해서 표현해야 한다. if (shouldNotCompact()) 부정문은 긍정문보다 이해하기 어렵기 때문에 if (canBeCompacted()) ... else 로 쓰는 게 더 좋다.

위상 정렬

  위상적으로 정렬하여 각 함수가 사용된 직후에 정의되도록 한다. 분석 함수가 먼저 나오고 조합 함수가 그 뒤를 이어서 나온다.


16장, SerialDate 리팩토링

분석은 악의나 자만과는 거리가 멀다. 전문가 입장에서 수행하는 검토, 그 이상도 그 이하도 아니다. 우리 모두가 편안하게 여겨야 할 활동이다. 남이 내게 해준다면 감사히 반겨야 할 활동이다.

클래스 재설계

  일반적으로 부모 클래스는 자식 클래스를 몰라야 바람직하다.

변수 위치 변경

  변수가 사용되는 위치에 가깝게 옮긴다.

사소하지 않은 코드

  테스트 커버리지는 높을 수록 좋으나, 너무 사소해 테스트할 필요도 없는 경우도 있다.


17장, 냄새와 휴리스틱

프로그램을 수정할 때마다 "왜?"라고 자문하라.

잘못된 주석

  • 부적절한 정보
  • 쓸모 없는 주석
  • 중복된 주석: 구구절절 설명하거나, 함수 서명만 달랑 기술하는 Javadoc.
  • 성의 없는 주석: 간결하고 명료하게 작성해야 한다.
  • 주석 처리된 코드: 소스 코드 관리 시스템에 맡기고 지워야 한다.

잘못된 개발 환경

  • 여러 단계로 빌드: 빌드는 한 명령, 한 단계로 끝내야 한다.
  • 여러 단계로 테스트: 모든 단위 테스트는 한 명령으로 돌려야 한다. IDE에서 버튼 하나로 모든 테스트를 돌린다면 가장 이상적이다.

잘못된 함수

  • 너무 많은 인수: 인수 개수는 적을수록 좋다.
  • 출력 인수: 출력 인수는 사용하지 않는다. 뭔가의 상태를 변경해야 한다면 함수가 속한 객체의 상태를 변경한다.
  • 플래그 인수: boolean 인수는 SRP를 대놓고 위반한다.
  • 호출되지 않는 함수

일반적인 휴리스틱

  • 한 소스 파일에 여러 언어를 사용한다.
  • 당연하지 않은 동작과 기능
  • 올바르지 않은 경계 처리: 스스로의 직관에 의존하지 마라. 모든 경계 조건을 찾아내고, 모든 경계 조건을 테스트하는 테스트 케이스를 작성하라.
  • 안전 절차 무시
  • 중복: 어디서든 중복을 발견하면 없애라. Template Method Pattern이나 Strategy Pattern으로 중복을 제거한다.
  • 올바르지 못한 추상화 수준: 고차원 개념과 저차원 개념을 섞어서는 안 된다. 한 함수 내에서 추상화 수준은 동일해야 하며, 함수 이름이 의미하는 작업보다 한 단계만 낮아야 한다.
  • 부모클래스가 자식클래스에 의존: 독립성을 보장해야 한다. 일반적으로 부모클래스는 자식클래스를 아예 몰라야 마땅하다. FSM(Finite State Machine) 구현 같은 경우는 같은 개수가 확실히 고정되어 있어서 예외적으로 자식클래스를 선택하는 코드가 들어가기 때문에 같은 JAR 파일로 배포하지만, 일반적으로는 다른 JAR파일로 배포하는 편이 좋다.
  • 과도한 정보: 결합도를 낮추고, 인터페이스에 너무 많은 함수를 제공하지 않는다.
  • 일관성 부족: 어떤 개념을 특정 방식으로 구현했다면 유사한 개념도 같은 방식으로 구현하여, 최소 놀람의 원칙을 부합시켜라.
  • 인위적 결합: 함수를 당장 편한 위치에 넣으려는 게으르고 부주의한 행동이다.
  • 기능 욕심: 클래스는 자기 클래스의 변수와 함수에 관심을 가져야지 다른 클래스의 변수와 함수에 관심을 가져서는 안 된다.
  • 모호한 의도
  • 부적절한 static 함수: 조금이라도 의심스럽다면 인스턴스 함수로 정의한다.
  • 어려운 로직: 서술적 변수 이름은 많이 써도 괜찮다. 일반적으로 많을수록 더 좋다. 해독하기 어렵던 모듈들이 순식간에 읽기 쉬운 모듈로 탈바꿈한다.
  • 알고리즘 이해 부족: 구현이 끝났다고 선언하기 전에 함수가 돌아가는 방식을 확실히 이해하는지 확인하라. 테스트 케이스를 모두 통과한다는 사실만으로 부족하다. 작성자가 알고리즘이 올바르다는 사실을 알아야 한다.
  • if/else, switch/case: if/else, switch/case보다는 다형성 객체를 사용하는 게 좋다.
  • 표준 표기법을 따르라: 팀이 정한 표준을 따르라. 모두가 동의하는 것이 중요하다.
  • 매직 숫자는 명명된 상수로 교체하라: 7 -> DAYS_IN_WEEKS
  • 정확히 결정하라
  • 구조로 강제하라: IDE나 Lint로 강제하라.
  • 숨겨진 시간적인 결합: 연결 소자를 생성해서 다음 함수의 인자로 넣는다. 시간적인 결합을 노출한다.
  • 설정 정보는 최상위 단계에 둬라: 설정 관련 상수는 최상위 단계에 둔다. 그래야 변경하기도 쉽다.

잘못된 자바

  • 와일드카드를 사용하라: 명시적인 import 문은 강한 의존성을 생성하지만 와일드카드는 그렇지 않다. 명시적으로 클래스를 import하면 그 클래스가 반드시 존재해야 하지만, 와일드카드로 패키지를 지정하면 특정 클래스가 존재할 필요는 없다.
  • 상수의 상속: 상수는 상속하지 않는다. 대신 static import를 사용한다.
  • 상수 대 Enum: enum은 이름이 부여된 열거체에 속하기 때문에 enum이 좋다.

잘못된 이름

  • 서술적인 이름을 사용하라: 이름은 성급하게 정하지 않는다. 가독성의 90%는 이름이 결정한다.
  • 표준 명명법을 사용하라: Decorator Pattern을 활용한다면 장식하는 클래스 이름에 Decorator라는 단어를 사용해야 한다.

잘못된 테스트

  • 불충분한 테스트: 잠재적으로 깨질 만한 부분을 모두 테스트해야 한다.
  • 커버리지 도구를 사용하라.
  • 사소한 테스트를 건너뛰지 마라.
  • 경계 조건을 테스트하라: 경계 조건은 각별히 신경 써서 테스트한다.
  • 버그 주변은 철저히 테스트하라: 버그가 터지는 곳은 제대로 설계되지 않았다는 뜻이다.
  • 실패 패턴을 살펴라.
  • 테스트는 빨라야 한다.

부록, 동시성II

애플리케이션이 어디서 시간을 보내는지 알아야 한다.

애플리케이션이 시간을 보내는 곳

  • I/O - Socket 사용, DB Connection, 가상 메모리 스와핑 기다리기 등...
  • Processor - 수치 계산, 정규 표현식 처리, 가비지 컬렉션 등...

프로세서 연산과 동시성

  프로세서 연산에 시간을 보내는 프로그램을 스레드를 늘린다고 빨라지지 않는다. CPU Cycle은 한계가 있기 때문이다.

I/O 연산과 동시성

  주로 I/O 연산에 시간을 보낸다면 동시성이 성능을 높여주기도 한다.

서버가 만드는 스레드

  서버가 만드는 스레드 수는 이론상 JVM(Java Virtual Machine)이 허용하는 수까지 가능하다. 공용 네트워크에 연결된 수많은 사용자를 지원하는 시스템이라면 시스템 동작이 멈출지도 모른다.

서버 process가 지는 책임

  1. 소켓 연결 관리
  2. 클라이언트 처리
  3. 스레드 정책
  4. 서버 종료 정책

스레드를 관리하는 코드는 스레드만 관리해야 한다. 비동시성 문제까지 뒤섞지 않더라도 동시성 문제는 그 자체만으로도 추적하기 어렵다.

스레드의 작업

  return ++lastIdUsed; 에 들어있는 바이트 코드 명령은 8개다. 만약 바이트 코드의 명령 단계가 N개이고 스레드가 T개라면 총 단계는 N*T개이며, 스레드 중 하나를 선택하는 Context Switch가 일어난다. 가능한 순열 수는 (NT)! / N!^T 가 된다.

만약 synchronized 키워드를 사용하면, 가능한 경우의 수는 T로 줄어즌다. 스레드가 2개일 때, A->B, B->A 두 경우만 존재하기 때문이다.

JVM 명세에 따르면 long 타입처럼 64비트 값을 할당하는 연산은 32비트 값을 할당하는 연산 두 개로 나눠진다. 연산 중간에 다른 스레드가 끼어들어 두 32비트 값 중 하나를 변경할 수 있다는 말이다.

연산에 대한 정의

  • 프레임(Frame): Call Stack을 정의할 때 사용하는 표준 기법이다. 모든 메서드 호출에는 프레임이 필요하다. 반환 주소, 메서드로 넘어온 매개변수, 메서드가 정의하는 지역변수를 포함한다.
  • 지역 변수: 메서드 범위 내에 정의되는 모든 변수. this는 현재 객체.
  • 피연산자 스택(Operand Stack): 매개변수를 저장하는 장소.

Lock의 대가

  현대 프로세서는 흔히 CAS(Compare and Swap)라 불리는 연산을 지원한다. DB에서의 Optimistic Locking이라는 개념과 유사하고 동기화 버전은 Pessimistic Locking이라는 개념과 유사하다.

락의 대가는 매우 비싸다. 그래서 현대에는 동시성을 위해 Atomic Operation을 지원하는 라이브러리들이 많이 생겨났다.

Lock을 거는 주체

  클라이언트 기반 잠금 메커니즘은 변경과 추적이 너무 어렵다. 일반적으로 서버-기반 잠금이 더 바람직하다. 이유는 아래와 같다.

  • 코드 중복이 줄어든다. 클라이언트에 잠금 코드를 추가할 필요도 없어진다.
  • 성능이 좋아진다. 단일스레드 환경으로 시스템을 배치하면 서버만 교체해도 오버헤드가 줄어든다.
  • 오류가 발생할 가능성이 줄어든다.
  • 스레드 정책이 하나다. 서버에서만 정책을 구현한다.
  • 공유 변수 범위가 줄어든다. 모두 서버에 숨겨진다.
  • 서버 코드에 손을 대지 못한다면 Adapter Pattern을 사용해 API를 변경한 후 잠금을 추가한다.

DeadLock

  서로가 서로의 자원을 필요로 하여 진행이 되지 않는다. 데드락은 아래 네 가지 조건을 모두 만족해야 한다.

  1. 상호 배제(Mutual Exclusion)
  2. 잠금 & 대기(Lock & Wait)
  3. 선점 불가(No Preemption)
  4. 순환 대기(Circular Wait)

상호 배제

  여러 스레드가 한 자원을 공유하지만 동시에 사용하지 못하고 갯수가 제한적인 상황 아래는 예시다.

  • DB Connection
  • FileSystem 쓰기용 파일 열기
  • Record Lock: 테이블 레코드 전체를 잠그는 락
  • Semaphore: 공유자원에 한정된 프로세스나 스레드만 접근 가능하도록 하는 방식

예방법은 아래와 같다.

  • 동시에 사용해도 괜찮은 자원을 사용한다. AtomicInterger 같은 CAS를 사용한다.
  • 스레드 수 이상으로 자원 수를 늘린다.
  • 자원을 점유하기 전에 필요한 자원이 모두 있는지 확인한다.

잠금 & 대기

  일단 스레드가 자원을 점유하면 필요한 나머지 자원까지 모두 점유해 작업을 마칠 때까지 이미 점유한 자원을 내놓지 않는다.

예방법은 아래와 같다.

  • 어느 하나라도 점유하지 못한다면 지금까지 점유한 자원을 몽땅 내놓고 처음부터 다시 시작한다.

위 방식은 잠재적인 문제가 두 가지 있다. 아래와 같다.

  • 기아(Starvation): 한 스레드가 계속해서 필요한 자원을 점유하지 못한다.
  • 라이브락(Livelock): 여러 스레드가 계속해서 자원을 점유했다 내놨다를 반복한다.

기아는 CPU 효율을 저하시키는 반면 라이브락은 쓸데 없이 CPU만 많이 사용한다.

선점 불가

  한 스레드가 다른 스레드로부터 자원을 빼앗지 못한다.

예방법은 아래와 같다.

  • 필요한 자원이 잠겼다면 자원을 소유한 스레드에게 풀어달라 요청한다.

순환 대기

  T1이 R1을 점유하고 T2가 R2를 점유한다. 또한 T1은 R2가 필요하고 T2도 R2가 필요하다. 이 조건을 예방하는 것이 데드락을 방지하는 가장 흔한 전략이다.

예방법은 아래와 같다.

  • 둘 다 첫 번째는 T1을 확보도록 설계한다. T1과 T2가 자원을 똑같은 순서로 할당하게 만들면 순환 대기는 불가능해진다.

하지만 위 방식도 잠재적인 문제가 있다. 아래와 같다.

  • 맨처음 할당한 자원이 아주 나중에야 쓰일지도 모른다. 즉, 자원을 꼭 필요한 이상으로 오랫동안 점유한다.
  • 때로는 순서에 따라 자원을 할당하기 어렵다. 첫 자원을 사용한 후에야 둘째 자원의 ID를 얻는다면 순서대로 할당하기란 불가능하다.

동시성 테스트

  몬테 카를로 테스트. 임의로 값을 조율하면서 반복해 돌린다. 테스트가 실패한 조건은 신중하게 기록한다. 또한, 시스템을 배치할 플랫폼 전부에서 테스트를 돌린다. 실제 환경과 비슷하게 부하를 걸어 줄 수 있다면 그렇게 한다. 스레드 코드 테스트를 도와주는 ConTest같은 도구들을 적극 활용한다.

profile
..ㅁ

0개의 댓글