HomeAboutMeBlogGuest
© 2025 Sejin Cha. All rights reserved.
Built with Next.js, deployed on Vercel
📖
공부한 책
/
Good Code, Bad Code
Good Code, Bad Code
/
8. 코드를 모듈화하라

8. 코드를 모듈화하라

개요1. 의존성 주입의 사용을 고려하라하드 코드화된 의존성은 문제가 될 수 있다.해결책 : 의존성 주입을 사용하라의존성 주입 프레임워크의존성 주입을 염두에 두고 코드를 설계하라2. 인터페이스에 의존하라구체적인 구현에 의존하면 적응성이 제한된다해결책 : 가능한 경우 인터페이스에 의존하라3. 클래스 상속을 주의하라클래스 상속은 문제가 될 수 있다상속은 추상화 계층에 방해가 될 수 있다상속은 적응성 높은 코드의 작성을 어렵게 만들 수 있다해결책 : 구성을 사용하라구성의 이점 1 : 더 간결한 추상화 계층구성의 이점 2: 적응성이 높은 코드진정한 is-a 관계는 어떤가?4. 클래스는 자신의 기능에만 집중해야 한다다른 클래스와 지나치게 연관되어 있으면 문제가 될 수 있다해결책 : 자신의 기능에만 충실한 클래스를 만들라5. 관련 있는 데이터는 함께 캡슐화하라캡슐화되지 않은 데이터는 취급하기 어려울 수 있다해결책: 관련된 데이터는 객체 또는 클래스로 그룹화하라6. 반환 유형에 구현 세부정보가 유출되지 않도록 주의하라반환 형식에 구현 세부사항이 유출될 경우 문제가 될 수 있다해결책: 추상화 계층에 적합한 유형을 반환하라7. 예외 처리 시 구현 세부사항이 유출되지 않도록 주의하라예외 처리 시 구현 세부사항이 유출되면 문제가 될 수 있다해결책: 추상화 계층에 적절한 예외를 만들라요약

개요

  • 1장에서는 소프트웨어 수명 주기 동안 요구사항이 어떻게 변하는지 논의 했다. 배포하기도 전에 요구사항이 바뀌는 경우가 많아서 코드를 작성하고 나서 몇 주 혹은 몇 개월 후에 이를 수정해야 하는 상황이 드물지 않게 볼 수 있다.
  • 요구사항이 어떻게 바뀔지 예측하는 것은 대개 시간 낭비이고 , 요구사항이 어떤식으로든 바뀐다는 점은 어느 정도 확신할 수 있다
  • 모듈화의 주된 목적 중 하나는 코드가 향후에 어떻게 변경되거나 재구성 될지 정확히 알지 못한 상태에서 변경과 재구성이 용이한 코드를 작성하는 것 ⇒ 각각의 기능(또는 요구사항)이 코드베이스의 서로 다른 부분에서 구현되어야 함
  • 이번 장의 내용은 주로 2장에서 논의한 간결한 추상화 계층이라는 개념을 기초로 함
    • 코드를 모듈화하는 것은 종종 하위 문제에 대한 해결책의 자세한 세부사항들이 독립적이고 서로 밀접하게 연관되지 않도록 하는 것으로 귀결됨
    • 모듈화된 코드는 재사용과 테스트에 더 적합하기 때문에 코드 모듈화는 많은 이점을 가지고 있다.

1. 의존성 주입의 사용을 고려하라

하위 문제에 대해 해결책이 항상 하나만 존재하는 것은 아니므로 하위 문제를 재구성할 수 있는 방식으로 코드를 작성하는 것이 유용할 수 있다.

하드 코드화된 의존성은 문제가 될 수 있다.

class RoutePlanner { private final RoadMap roadMap; RoutePlanner() { this.roadMap = new NorthAmericaRoadMap(); } ... }
  • RoadMap은 여러 개의 다른 구현체를 갖는 인터페이스.
  • 그러나 이 예에서 RoutePlanner 클래스는 생성자에서 NorthAmericaRoadMap 을 생성 ⇒ 의존성이 하드코딩 되어 있음
  • 또 다른 문제는 NorthAmericaRoadMap의 생성자에 파라미터가 추가되었다고 가정하면, RoutePlanner에서 NorthAmericaRoadMap 을 생성하기 위해 필요한, NorthAmericaRoadMap 클래스에만 적용되는 개념을 처리해야 함
class RoutePlanner { private const Boolean USE_ONLINE_MAP = true; private const Boolean INCLUDE_SEASONAL_ROADS = false; private final RoadMap roadMap; RoutePlanner() { this.roadMap = new NorthAmericaRoadMap( USE_ONLINE_MAP, INCLUDE_SEASONAL_ROADS); } }
단점
  • RoutePlanner 클래스를 다용도로 사용할 수가 없다.
  • 북미 외의 지역에 대해 사용하려는 경우가 있을 수 있고 오프라인 상태에서도 애플리케이션이 작동하기를 원할 수 있는데 위와 같이 하드코딩 되어있으면 그렇게 사용할 수가 없음

해결책 : 의존성 주입을 사용하라

class RoutePlanner { private final RoadMap roadMap; RoutePlanner(RoadMap roadMap) { this.roadMap = roadMap; }
  • 이렇게 로드맵을 주입하면 RoutePlanner 클래스의 생성자가 좀 더 복잡해진다는 단점이 있다. 몇 가지 팩토리 함수를 제공하면 이 과정이 훨씬 쉽게 될 수 있다.
  • 팩토리 함수를 직접 작성하는 것의 대안으로 의존성 주입 프레임워크를 사용할 수도 있다

의존성 주입 프레임워크

  • 중요한 점은 의존성 주입 프레임워크를 사용하면 팩토리 함수의 반복적인 코드를 작성하느라 허우적대지 않고 대신 매우 모듈화되고 다용도로 사용할 수 있는 코드를 만들 수 있다는 점임
  • 주의할 점은 의존성 주입을 좋아하는 개발자라도 의존성 주입 프레임워크를 항상 사용하는 것은 아니라는 점. 주의해서 사용하지 않으면 파악하기 어려운 코드가 만들어질 수 있다.

의존성 주입을 염두에 두고 코드를 설계하라

class RoutePlanner { Route planRoute(LatLong startPoint, LatLong endPoint) { List<Road> roads = NorthAmericaRoadMap.getRoads(); List<Junction> junctrions = NorthAmericaRoadMap.getJunctions(); .. } }
  • 위와 같이 구성하면 RoutePlanner 클래스는 RoadMap 인스턴스에 의존하지 않기 때문에 의존성 주입을 할 수 없다.
  • 하나의 해결책만 있는 아주 근본적인 하위 문제라면 이렇게 해도 일반적으로 문제가 없지만, 상위 코드 계층에서 하위 문제에 대해 설정을 달리하고자 한다면 문제가 될 수있다.
하위 문제에 대해 해결책이 두 가지 이상 가능한 경우 인터페이스를 정의하는 편이 낫다.
 

2. 인터페이스에 의존하라

인터페이스에 의존하게 되면 어떤 구현 클래스라도 사용할 수 있으므로 코드가 훨씬 더 모듈화되고 적응성이 높아진다.

구체적인 구현에 의존하면 적응성이 제한된다

class RoutePlanner { private final NorthAmericaRoadMap roadMap; RoutePlanner(NorthAmericaRoadMap roadMap) { this.roadMap = roadMap; } }
북미 외의 다른 어떤 지역에서도 작동하지 않는 RoutePlanner는 이상적인 클래스가 아니다. 이 클래스가 어떤 로드맵과도 동작하는 것이 더 바람직함
 

해결책 : 가능한 경우 인터페이스에 의존하라

구체적인 구현 클래스에 의존하면 인터페이스를 의존할 때보다 적응성이 제한되는 경우가 많다.
class RoutePlanner { private final RoadMap roadMap; RoutePlanner(RoadMap roadMap) { this.roadMap = roadMap; }
💡
보다 구체적인 구현보다는 추상화에 의존하는 것이 낫다는 생각은 의존성 역전원리의 핵심이다.

3. 클래스 상속을 주의하라

  • 두 가지 사물이 진정한 is-a 관계를 갖는다면 상속이 적절할 수 있다.
  • 상속은 강력한 도구지만, 몇가지 단점이 있고 상속이 야기하는 문제가 치명적일 수 있기 때문에 한 클래스가 다른 클래스를 상속하는 코드를 작성하는 것에 대해서는 신중하게 생각해봐야 한다.
  • 상속을 사용할 수 있는 상황에서 많은 경우 구성(composition)을 상속 대신 사용할 수 있다.

클래스 상속은 문제가 될 수 있다

interface FileValueReader { String? getNextValue(); void close(); } interface FileValueWriter { void writeValue(String value); void close(); } class CsvFileHandler implements FileValueReader, FileValueWriter { CsvFileHandler(File file) { ... } override String? getNextValue(); override void writeValue(String value); override void close(); } class IntFileHandler extends CsvFileHandler { Int? getNextInt() { ... } }
  • IntFileHandler 클래스는 슈퍼클래스인 CsvFileHandler의 함수를 마치 자신의 함수인 것처럼 액세스할 수 있으므로 IntFileReader 클래스 내에서 getNextValue()를 호출하면 슈퍼클래스의 함수가 호출된다

상속은 추상화 계층에 방해가 될 수 있다

한 클래스가 다른 클래스를 확장하면 슈퍼클래스의 모든 기능을 상속한다. 이 기능은 close() 함수의 경우처럼 유용할 때도 있지만, 원하는 것보다 더 많은 기능을 노출할 수도 있다.
위와 같이 상속시 IntFileHandler의 퍼블릭 API 는 아래와 같다
class IntFileHandler extends CsvFileHandler { Int? getNextInt() { ... } // 슈퍼클래스로부터 상속받은 함수 String? getNextValue() { ... } void writeValue(String value) { ... } void close() { ... } }
  • 클래스의 일부 기능을 외부로 개방하는 경우 적어도 그 기능을 사용하는 개발자가 있을 것이라고 예상할 수 있고, 코드 베이스에서 해당 함수들을 사용하기 시작하면 IntFileHandler 클래스를 변경하기가 매우 어려워진다.
  • IntFileHandler가 CsvFileHandler를 사용한다는 사실은 구현 세부사항이어야 하지만 상속을 통해 이 클래스의 함수들이 의도치 않게 외부에 공개된다.

상속은 적응성 높은 코드의 작성을 어렵게 만들 수 있다

IntFileReader 클래스를 통해 해결하려는 문제는 쉼표로 구분된 값을 가진 파일로부터 정수를 읽어들이는 것. 하지만 요구사항이 변경되어 쉼표뿐 아니라 세미콜론으로 구분된 값도 읽을 수 있어야 한다면.
 
  • 쉼표로 구분된 파일 내용을 처리하는 것에 더해서 세미콜론으로 구분된 내용도 처리해야 하기 때문에 단순히 IntFileReader가 CsvFileHnadler(쉼표로 구분된 파일 내용처리) 대신 SemicolonFileHandler(세미콜론 구분 내용 처리) 를 상속하도록 바꿀 수 없다.
  • 유일한 방법은 IntFileReader 클래스의 새 버전을 작성하고 이 클래스가 SemicolonFileHandler를 상속하도록 하는 것
class SemicolonIntFileReader extends SemicolonFileHandler { SemicolonIntFileReader(File file) { super(file); } Int? getNextInt()) { String? nextValue = getNextValue() .. } }
  • 새 클리스는 IntFileReader 클래스의 대부분을 그대로 가지고 있다. ⇒ 코드중복으로 인한 유지보수 비용 & 버그 발생 가능성 높임
  • FileValueReader 인터페이스는 파일 형식을 모르더라도 값을 읽을 수 있는 추상화 계층을 제공함. 하지만 상속을 사용했기 때문에 이러한 추상화 계층을 활용할 수 없게 되었다.

해결책 : 구성을 사용하라

상속을 사용한 원래 동기는 IntFileReader 클래스를 구현하는 데 도움이 되고자 CsvFileHandler 클래스의 일부 기능을 재사용하는 것
CsvFileHandler의 기능을 재사용하는 다른 방법으로는 구성을 사용하는 것
class IntFileReader { private final FileValueReader valueReader; void close() { valueReader.close(); // forwarding( 전달 ) } }
  • CsvFileHandler를 직접 사용하는 대신 FileValueReader 인터페이스 이용
  • IntFileReader 클래스는 CsvFileHandler를 확장하는 대신 FileValueReader의 인스턴스를 참조할 멤버 변수를 가짐
  • IntFileReader.close() 함수는 파일을 닫는 명령을 FileValueReader.close() 함수로 전달(forwarding)

구성의 이점 1 : 더 간결한 추상화 계층

  • 상속 사용 시, 서브 클래스는 슈퍼클래스의 모든 기능을 상속하고 외부로 제공
  • 상속 대신 구성을 사용하면 IntFileReader 클래스가 전달이나 위임을 사용하여 명시적으로 노출하지 않는 한 CsvFileHandler 클래스의 기능이 노출되지 않음
class IntFileReader { ... Int? getNextInt() { ... } void close() { ... } }
IntFileReader의 공개 API

구성의 이점 2: 적응성이 높은 코드

앞의 변경된 요구사항은 쉼표만이 아니라 세미콜론으로 구분된 값을 사용하는 파일도 지원해야 한다는 것
이제 IntFileReader 클래스는 FileValueReader 인터페이스에 의존하며 의존성 주입을 통해 이 요구사항을 쉽게 지원 가능
class IntFileReaderFactory { IntFileReader createCsvIntReader(File file) { return new IntFileReader(new CsvFileHandler(file)); } IntFileReader createSemicolonIntReader(File file) { return new IntFileReader(new SemicolonFileHandler(file)); } }

진정한 is-a 관계는 어떤가?

두 클래스가 진정으로 is-a 관계일 때조차 상속하는 것이 좋은 접근법인지에 대해서는 명확하지 않을 수 있다.
주의할 점
  • 취약한 베이스 클래스 문제 : 서브클래스가 슈퍼클래스에서 상속되고 슈퍼클래스가 나중에 수정되면 서브클래스가 작동하지 않을 수 있다.
  • 다이아몬드 문제 : 일부 언어에서 두개 이상의 슈퍼클래스를 확장할 수 있을 때, 여러 슈퍼클래스가 동일한 함수의 각각 다른 버전을 제공하는 경우 문제가 발생할 수 있음
  • 문제가 있는 계층 구조 : 많은 언어가 다중 상속을 지원하지 않으므로 클래스는 오직 하나의 클래스만 직접 확장할 수 있다. 이를 단일상속이라 하며 다른 유형의 문제가 발생할 수 있다.
    • 두 타입(Car, Aircraft)을 모두 갖는 타입(FlyingCar)가 발생 시, 계층 구조에 포함시킬 수 있는 합리적 방법이 없음
💡
클래스 상속에 숨어있는 많은 함정을 피하면서 계층구조를 달성하기 위해 다음과 같은 것들을 할 수 있다.
  • 인터페이스를 사용하여 계층 구조를 정의
  • 구성을 사용하여 코드를 재사용
 

4. 클래스는 자신의 기능에만 집중해야 한다

  • 모듈화의 핵심 목표 중 하나는 요구 사항이 변경되면 그 변경과 직접 관련된 코드만 수정한다는 것
  • 단일 개념이 단일 클래스 내에 완전히 포함된 경우라면 이 목표는 달성할 수 있다.
  • 어떤 개념과 관련된 요구사항이 변경되면 그 개념에 해당하는 단 하나의 클래스만 수정하면 된다.
  • 이것과 반대되는 상황은 하나의 개념이 여러 클래스에 분산되는 경우다. 해당 개념과 관련된 요구사항을 변경하려면 관련된 클래스를 모두 수정해야 한다.

다른 클래스와 지나치게 연관되어 있으면 문제가 될 수 있다

class Book { private final List<Chapter> chapters; Int wordCount() { return chapters.map(getChapterWordCount).sum(); } private static Int getChapterWordCount(Chapter chapter) { return chapter.getPrelud().wordCount() + chapter.getSections().map(section-> section.wordCount()).sum(); } }
Book 클래스에서 Chapter클래스에 대해 지나치게 많이 알고 있다.
 

해결책 : 자신의 기능에만 충실한 클래스를 만들라

코드 모듈화를 유지하고 한 가지 사항에 대한 변경 사항이 코드의 한 부분만 영향을 미치도록 하기 위해, Book과 Chapter 클래스는 가능한 한 자신의 기능에만 충실하도록 해야 한다.
class Book { private final List<Chapter> chapters; Int wordCount() { return chapters.map(chapter -> chapter.wordCount()).sum(); } } class Chapter { Int wordCount() { ... } }
💡
디미터의 법칙 한 객체가 다른 객체의 내용이나 구조에 대해 가능한 한 최대한으로 가정하지 않아야 한다는 소프트웨어 공학의 원칙. 이 원칙은 특히 한 객체는 직접 관련된 객체와만 상호작용 해야한다고 주장

5. 관련 있는 데이터는 함께 캡슐화하라

서로 다른 데이터가 서로 밀접하게 연관되어 있어 그것들이 항상 함께 움직여야 할 때가 있다. 이 경우에는 클래스(또는 유사한 구조)로 그룹화하는 것이 합리적이다.

캡슐화되지 않은 데이터는 취급하기 어려울 수 있다

class UiSettings { Font getFont() { ... } Double getFontSize() { ... } Double getLineHeight() { ... } Color getTextColor() { ... } } class TextBox { void renderText(String text, Font font, Double fontSize, Double lineHeight, Color textColor) { ... } } class UserInterface { private final TextBox messageBox; private final UiSettings uiSettings; void displayMessage(String message) { messageBox.renderText( message, uiSettings.getFont(), uiSettings.getFontSize(), uiSettings.getLineHeight(), uiSettings.getTextColor(), } }
  • 위 예제코드에서 displayMessage() 함수는 uiSettings 클래스의 일부 정보를 renderText() 함수로 전달하는 택배기사와 비슷하다. 실제 생활에서 택배기사는 종종 소포 안에 무엇이 들어있는지 정확히 신경쓰지 않을 것
  • 앞에서 살펴봤듯이 모듈화의 목적 중 하나는 요구사항의 변경이 있을 때 해당 요구사항과 직접 관련 있는 코드만 수정하고자 하는 것임. rednerText() 함수에 글꼴 스타일을 정의해야 하는 경우 이 새로운 정보를 전달하기 위해 displayMessage도 함께 수정해야 함
    • 예제에서 UiSettings와 TextBox 클래스만 실제로 텍스트 스타일을 처리하기 때문에 displayMessage() 함수까지 수정해야 하는 것은 바람직하지 않음

해결책: 관련된 데이터는 객체 또는 클래스로 그룹화하라

class UiSettings { TextOptions getTextStyle() { ... } } class UserInterface { private final TextBox messageBox; private final uiSettings uiSettings; void displayMessage(String message) { messageBox.renderText(message, uiSettings.getTextStyle()); // displayMessage 함수에는 텍스트 스타일의 세부 사항이 없음 } }
TextOptions 클래스에 텍스트 스타일 정보를 캡슐화해서 해당 인스턴스를 전달 ⇒ 박스 안에 뭐가 들어 있는지 신경쓰지 않고 부지런히 소포를 배달만 하는 택배 기사와 같음
 

6. 반환 유형에 구현 세부정보가 유출되지 않도록 주의하라

  • 구현 세부 정보가 유출되면 코드의 하위 계층에 대한 정보가 노출될 수 있으며, 향후 수정이나 재설정이 매우 어려워질 수 있다.
  • 코드에서 구현 세부정보를 유출하는 일반적인 형태 중 하나는 해당 세부 정보와 밀접하게 연결된 유형을 반환하는 것이다.

반환 형식에 구현 세부사항이 유출될 경우 문제가 될 수 있다

class ProfilePictureService { private final HttpFetcher httpFetcher; ProfilePictrueResult getProfilePicture(Int64 userId) { ... } } class ProfilePictureResult { HttpResponse.Status getStatus() { ... } HttpResponse.Payload? getImageData() { ... } }
  • Http 통신을 한다는 구현 세부사항이 유출됨
  • 다른 개발자가 위 클래스를 사용하려면 HttpResponse와 관련된 여러 개념을 처리해야 함. 프로피 사진 요청의 성공 여부와 실패한 이유를 이해하려면 HttpResponse.Status 열거 값을 확인해야 함
  • 또한 getProfilePicture 메서드를 호출하는 모든 코드는 이 함수의 반환값을 처리하기 위해 HttpResponse.Status와 HttpResponse.Payload를 다뤄야 함 → HttpResponse에 특정된 유형을 반환한다는 사실에 의존
    • 만약 웹소켓을 이용해 가져오는식으로 구현이 변경되면?? 너무 많은 코드가 변경되어야 함

해결책: 추상화 계층에 적합한 유형을 반환하라

ProfilePictureService 클래스가 해결하는 문제는 사용자의 프로필 사진을 가져오는 것임. 따라서 이 클래스를 통해 제공하고자 하는 이상적인 추상화 계층은 모든 반환 형식은 이 점을 반영해야 함
노출해야 할 최소한의 개념
  • 요청이 성공하거나 다음 이유중 하나로 실패할 수 있따
    • 사용자가 존재하지 않음
    • 서버가 연결할 수 없는 등의 일시적인 오류발생
  • 프로필 사진을 나타내는 데이터의 바이트 값
class ProfilePictureResult { enum Status { SUCCESS, USER_DOES_NOT_EXIST, OTHER_ERROR, } Status getStatus() { ... } List<Byte>? getImageData() { ... } }
  • HttpResponse.Status 와 HttpResponse.Payload 는 우리가 제공하는 추상화 계층에 적합하지 않다.
  • 외부로 노출할 개념을 최소화하는 유형을 새로 정의해 사용하면 좀 더 모듈화된 코드와 간결한 추상화 계층을 얻을 수 있다.

7. 예외 처리 시 구현 세부사항이 유출되지 않도록 주의하라

호출하고자 하는 쪽에서 복구하고자 하는 오류에 대해 비검사 예외를 사용하는 경우 예외 처리 시 구현 세부 정보를 유출하는 것은 특히 문제가 될 수 있다

예외 처리 시 구현 세부사항이 유출되면 문제가 될 수 있다

class TextSummerizer { private final TextImportanceScorer importanceScorer; String summerizeText(String text) { return paragraphFinder.find(text).filter(para -> importanceScorer.isImportant(para)) .join("\n\n"); } } interface TextImportanceScorer { Boolean isImportant(String text); } class ModelBasedScorer implements TextImportanceScorer { // @throws PredictionModelException 예측 모델 실행하는 동안 에러 발생 override Boolean isImportant(String text) { .. } }
  • 위와 같은 상황에서 TextSummerizer는 PredictionModelException이라는 모델 기반 예측을 사용한다는 세부 구현사항을 알리는 예외를 처리해야 하게 됨
  • ModelBasedScorer는 구현 클래스 중 하나이기에 TextImportanceScorer를 구현하는 다른 클래스에서 완전히 다른 유형의 예외를 발생시킬 수도 있다.

해결책: 추상화 계층에 적절한 예외를 만들라

class TextSummerizer{ String summerizeText(String text) throws TextSummerizeException { try { return paragraphFinder.find(text).filter(para => importanceScorer.isImportant(para)).join("\n\n"); } catch (TextImportanceScorerException e) { throw new TextSummerizerException(e); } } } interface TextImportanceScorer { Boolean isImportant(String text) throws TextImportanceScoreException; }
  • TextSummerizer 클래스를 사용하는 개발자는 이제 TextSummerizerException만 처리하면 됨.
  • TextImportanceScorer 클래스를 사용하는 곳에서도 TextImportanceScoreException 만 처리하면 됨
 

요약

  • 코드가 모듈화되어 있으면 변경된 요구사항을 적용하기 위한 코드를 작성하기 쉽다
  • 모듈화의 주요 목표 중 하나는 요구 사항의 변경이 해당 요구사항과 직접 관련된 코드에만 영향을 미치도록 하는 것
  • 코드를 모듈식으로 만드는 것은 간결한 추상화 계층을 만드는 것과 깊은 관련이 있다.
  • 다음의 기술을 사용하여 코드를 모듈화 할 수 있다
    • 의존성 주입
    • 구체적인 클래스가 아닌 인터페이스에 의존
    • 클래스 상속 대신 인터페이스 및 구성의 활용
    • 클래스는 자신의 기능만 처리
    • 관련된 데이터의 캡슐화
    • 반환 유형 및 예외 처리 시 구현 세부 정보 유출 방지