Good code bad code 정리 - 2장: 추상화 계층


2장. 추상화 계층

코드 작성의 본질적인 목적은 ‘문제 해결’이다. 큰 문제는 하위 수준의 작은 문제들로 쪼개진다. 문제를 해결하기 위해 코드를 어떻게 구성하는가는 개발자에게 달린 문제다. 모든 기능을 하나의 거대한 함수나 클래스로 구성할지, 아니면 여러 개의 함수나 클래스로 나눌지, 나눈다면 어떤 기준으로 나눌지 매순간 고민해야 한다. 코드의 구성 방식은 코드 품질을 좌우하는 가장 기본적인 측면이며, 보통 이 문제는 추상화를 어떻게 잘 했느냐에 달려있다.

추상화가 필요한 이유

영화 데이터를 조회해야 하는 문제가 생겼다고 해보자. 언어에 따라 다르겠지만 대부분은 다음과 같이 간결하게 문제를 해결할 수 있다.

fetch("http://example.com/movies");

높은 수준에서 문제를 봤을 때는 fetch를 사용하는 간단한 한 줄 코드이다. 하지만 각 단계를 뜯어보면 단순한 문제는 아니다. 영화 json 데이터를 조회하기 위해서는 다음과 같은 작업이 뒤에서 일어난다.

  • HTTP 프로토콜의 복잡한 동작
  • TCP 연결
  • 사용자 장치가 인터넷에 연결되었는지 여부 확인
  • 요청 전송

상위 수준 문제를 해결하기 위해 따라오는 해결해야 할 하위문제가 많지만, 우리는 위의 복잡한 과정들은 알지 못해도 문제를 해결할 수 있다. 이것이 추상화의 힘이다. 하위 문제로 내려가면서 추상화 계층을 만들면 상위 문제는 하위 문제에 대해 자세히 알지 못해도 몇 가지 공개된 개념만 알면 문제를 해결할 수 있다.

추상화를 공개 API라고 생각해도 이해하기에 좋다.

추상화 계층 품질의 핵심 요소 네 가지

1장에서 고품질 코드를 작성하기 위한 방법과 일맥상통한 내용이다.

  1. 가독성 : 깔끔한 추상화는 다른 개발자가 코드를 이해하는 시간을 단축시켜 준다.
  2. 모듈화
  3. 재사용성 및 일반화성
  4. 테스트 용이성 : 깨끗하게 분할된 추상화 계층은 하위 문제에 대한 테스트를 쉽게 만들어 준다.

잘 작성된 함수

함수가 잘 구성되었는지 알려면 함수의 역할을 한 문장으로 표현해보는 게 좋다. 예를 들어 아래 코드를 보면,

const sendOwnerALetter = (vehicle, letter) => {
	// 1. 차량이 폐기된 경우 폐차장 주소로
	// 2. 아직 판매되지 않은 차량의 경우 전시장 주소로
	// 3. 그렇지 않으면 마지막 구매자의 주소로
	// 편지를 보낸다.
}

상위의 문제는 ‘차량 소유자의 주소를 조회하고 문자를 보낸다’이다.

하지만 하위에 해결해야 하는 조건이 세 개나 있음에도 이를 하나의 함수로 구현해 함수가 너무 많은 일을 하게 만들었다.

함수가 잘 구성되었는지 알기 위해서 다음 두가지를 충족하도록 하는 것이 좋은 방법이 될 수 있다.

  • 단일 업무 수행
  • 잘 명명된 다른 함수를 호출해 더 복잡한 동작 구성

위 코드의 경우 주소를 결정하는 1번과 2번 로직은 getOwnerAddress 라는 함수를 따로 만들어서 호출하도록 하는 것이 바람직한 방법이다. 그렇게 생성된 함수는 다른 곳에서 재사용하기도 쉽고 가독성도 올라간다.

잘 작성된 클래스

잘 작성된 클래스를 판별하는 기준을 여러가지가 있다.

  • 줄 수: 보통 300줄을 넘어가는 클래스는 항상 그런 것은 아니지만 어딘가 잘못되었을 가능성이 있다.
  • 응집력: 좋은 클래스는 응집력이 강하다.
    • 순차적 응집력
    • 기능적 응집력
  • 관심사의 분리: 서로 다른 관심사는 분리시키는 것이 좋다. 예를들어, 게임기와 TV는 별개이기 때문에 연결해서 사용하더라도 서로 다른 객체이므로 둘 중 한가지를 새로 사도 이용에 지장이 없다.

하나의 클래스는 한 가지 일만 관심을 가져야 한다. 단일 클래스에 담긴 개념이 많을 수록 가독성은 떨어진다. 또한 하위 문제에 대한 해결책을 커다란 클래스 내부에 묶어두면 이후에 다른 개발자가 재사용하기 어렵다.

너무 큰 클래스 코드의 개선 방법

이 경우에 DI(Dependency Injection) 의존성 주입 패턴을 사용해 코드를 개선할 수 있다.

예를 들어 이런 커다란 클래스를 작성했다고 해보자.

class TextSummarizer {
	summerizeText(text: string){
		return splitIntoParagraphs(text)
			.filter(paragraph => calculateImportance(paragraph) >= IMPORTANCE_THRESHOLD)
			.join("\n\n");
	}
	
	private calculateImportance(paragraph) {
	 // do something..
	 }
	 
	 private splitIntoParagraph(text) {
		 // do something..
	}
	
	private detectParagraphStartOffset() {
		 // do something..
	}
	
	private detectParagraphEndOffset() {
		 // do something..
	}
}

이 거대 클래스를 하위 클래스를 따로 만들어서 의존성 주입을 하면 크기를 줄일 수 있다.

class TextSummarizer {
private textImportanceScorer: TextImportanceScorer;
private paragraphFinder: ParagraphFinder
constructor(textImportanceScorer, paragraphFinder) {
	this.textImportanceScorer = textImportanceScorer;
	this.paragraphFinder = paragraphFinder;
}

	summerizeText(text: string){
		return splitIntoParagraphs(text)
			.filter(paragraph => calculateImportance(paragraph) >= IMPORTANCE_THRESHOLD)
			.join("\n\n");
	}
}

class TextImportanceScorer {
	// do something
}

class ParagraphFinder {
	// do something
	}

클래스의 수는 늘어났지만 클래스의 역할이 감소했기 때문에 내부 메서드 몇 개만 읽으면 각 클래스가 무슨 일을 하는지 파악하지 어렵지 않으므로 전체 가독성은 올라간다.

인터페이스

계층을 구분하고 구현 세부 사항이 계층 사이에 유출되지 않도록 하는 방법 중 하나이다. 인터페이스를 정의하고 이를 구현하는 클래스는 해당 계층에 대한 코드를 구현하면, 더 상위 계층에서는 인터페이스에만 의존하고 실제 로직을 구현하는 구체적 클래스에 의존하지 않아 클래스간 응집도는 줄어들게 된다.

인터페이스는 하나의 추상화 게층에 대해 두 가지 이상의 다른 방식으로 구현하는 경우에 사용하기 좋다. 또는 추후에 구현 방식이 추가되거나 변경될 가능성이 있는 경우에도 좋은 옵션이다.

예를 들어 위 예시 코드에서 본 TextImportanceScorer 가 현재는 내부에 작성된 알고리즘을 통해 계산하고 있지만 추후에 머신러닝을 이용해 중요도를 계산할 계획이 있다면 TextImportanceScorer 클래스에 직접 의존하는 것 보다 사이에 인터페이스를 두는 것이 확장성에 좋을 것이다. 인터페이스에 의존하는 상위 계층 코드는 수정하지 않으면서 머신 러닝을 사용하는 클래스가 텍스트 중요도 계산 인터페이스만 구현하게 한다면 쉽게 연결할 수 있다. 이런 구조는 설령 현재 구조를 잘못 설계했더라도 실수에 열려있으며 직접 의존하는 것보다 테스트하기에도 용이하다.

하지만 단점도 있다. 중간에 인터페이스를 추가하기 때문에 더 많은 코드를 작성해야 하고 결과적으로 코드가 복잡해질 수도 있다. 모든 클래스에 인터페이스를 붙이는 건 비효율적이다. 인터페이스를 사용할 경우를 구분할 줄 아는 능력을 기를 수 있도록 충분한 고민과 경험이 필요하다.

추상화의 단점

과한 추상화로 계층이 너무 얇아지면 불필요한 복잡성이 초래될 수 있다. 하지만 거대한 클래스나 함수보다는 잘게 쪼개진 구조가 나은 경우가 많다.