Chapter 9. 코드를 재사용하고 일반화할 수 있도록 하라

w-beom·2023년 4월 28일
0
post-thumbnail

Chapter 9. 코드를 재사용하고 일반화할 수 있도록 하라

■ 안전하게 재사용할 수 있는 코드 작성 방법
■ 다양한 문제를 해결하기 위해 일반화된 코드를 작성하는 방법

9.1 가정을 주의하라

코드 작성 시 가정을 하면 코드가 더 단순해지거나, 더 효율적으로 되거나, 둘 다일 수도 있다. 그러나 이러한 가정으로 인해 코드가 더 취약해지고 활용도가 낮아져 재사용하기에 안전하지 않을 수 있다.

9.1.1 가정은 코드 재사용 시 버그를 초래할 수 있다.

class Article {
  private List <Section> sections;
  List<Image> getAllImages() {
    for (Section section in sections) {
      if (section.containsImages()) { // 기사 내에 이미지를 포함하는 섹션은 최대 하나만 있다. 
        return section.getImages();
      }
    }
    return new ArrayList<>();
  }
}

Article 클래스는 사용자가 읽을 수 있는 뉴스 웹 사이트의 기사를 나타낸다.
getAllImages() 함수는 기사에 포함된 모든 이미지를 반환한다. 이 코드는 이미지가 포함된 섹션이 하나만 있을 것이라고 가정한다. 이러한 가정을 함으로써 이미지가 있는 섹션이 두 개 이상인 기사에 getAllImages() 함수가 사용될 경우 모든 이미지를 반환하지 않는다. 이런 일은 일어날 가능성이 매우 높고, 그로 인해 버그를 초래할 가능성이 있다.

9.1.2 해결책 : 불필요한 가정을 피하라

책에서는 불필요한 가정을 피하기 위해 비용-이익 상충관계를 고려하고 섣부른 최적화를 하지 말라고 한다.

섣부른 최적화
코드 최적화는 일반적으로 비용이 든다. 즉, 최적화된 해결책을 구현하는 데 더 많은 시간과 노력이 필요하며 그 결과 코드는 종종 가독성이 떨어지고, 유지 관리가 더 어려워지며, 가정을 하게 되면 견고함이 떨어질 가능성이 있다. 게다가 최적화는 보통 프로그램 내에서 수천 번 혹은 수백만 번 실행되는 코드 부분에 대해 이루어질 때 상당한 이점이 있다.

따라서 대부분의 경우에는 큰 효과 없는 코드 최적화를 하느라고 애쓰기보다는 코드를 읽을 수 있고, 유지보수 가능하며, 견고하게 만드는 데 집중하는 것이 좋다. 코드의 어떤 부분이 여러 번 실행되고 그 부분을 최적화하는 것이 성능 향상에 큰 효과를 볼 수 있다는 점이 명백해질 때에라야 최적화 작업을 해도 무방하다.

9.1.3 해결책 : 가정이 필요하면 강제적으로 하라

때로는 가정이 필요하거나 가정으로 얻는 이득이 비용을 초과할 정도로 코드가 단순해질 수 있다. 코드에 가정이 있을 때, 다른 개발자들이 그것을 여전히 모를 수 있다는 사실을 염두에 두어야 한다.

  1. 가정이 '꺠지지 않게' 만들라 : 가정이 깨지면 컴파일되지 않는 방식으로 코드를 작성할 수 있다면 가정이 항상 유지될 수 있다.
  2. 오류 전달 기술을 사용하라 : 가정을 깨는 것이 불가능하게 만들 수 없는 경우에는 오류를 감지하고 오류 신호 전달 기술을 사용하여 신속하게 실패하도록 코드를 작성할 수 있다.

9.2 전역 상태를 주의하라

전역 상태 또는 전역변수는 실행되는 프로그램 내의 모든 콘텍스트 사이에 공유된다. 전역변수를 정의하는 일반적인 방법은 다음과 같다.

  • 자바나 C# 같은 언어에서 변수를 정적static으로 표시(이 책의 의사코드에서 사용되는 방식이다)
  • C++와 같은 언어에서 클래스나 함수의 외부 즉 파일 수준의 변수 정의
  • 자바스크립트 기반 언어에서 전역 윈도 객체의 속성으로 정의

전역변수는 프로그램 내의 모든 콘텍스트에 영향을 미치기 때문에 전역변수를 사용할 때는 누구도 해당 코드를 다른 목적으로 재사용하지 않을 것이라는 암묵적인 가정을 전제한다. 전역 상태는 코드를 매우 취약하게 만들고 재사용하기도 안전하지 않기 때문에 일반적으로 이점보다 비용이 더 크다.

9.2.1 전역 상태를 갖는 코드는 재사용하기에 안전하지 않을 수 있다.

어떤 상태에 대해 프로그램의 여러 부분이 공유하고 접근할 필요가 있을 때 이것을 전역변수에 넣고 싶은 마음이 들 수 있다. 이렇게 하면 코드의 어느 부분이라도 그 상태에 접근하기가 아주 쉽다. 이렇게 하면 코드를 재사용하는 것이 안전하지 않을 때가 있다.

온라인 쇼핑 애플리케이션을 만들고 있다고 가정해보자.
사용자의 장바구니 항목은 아이템을 추가하는 코드, 사용자가 장바구니의 항목을 검토하는 화면, 체크아웃을 처리하는 코드와 같이 애플리케이션의 많은 다른 부분으로부터 접근하기 위해 아래와 같이 static변수로 선언했다.

class ShoppingBasket {
  private static List <Item> items = new ArrayList<>();
  static void addItem(Item item) {
    items.add(item);
  }
  static void List <Item> getItems() {
    return List.copyOf(items);
  }
}
class ViewItemWidget {
  private final Item item;
  
  ViewItemWidget(Item item) {
    this.item = item;
  }
  void addItemToBasket() {
    ShoppingBasket.addItem(item);
  }
}
class ViewBasketWidget {
  void displayItems() {
    List<Item> items = ShoppingBasket.getItems();
  }
}

누군가 이 코드를 재사용하려고 하면 어떻게 되는가?
알았든 몰랐든 간에 이 코드를 작성할 때 암묵적인 가정이 이루어졌는데, 그것은 이 소프트웨어를 실행하는 인스턴스당 하나의 장바구니만 필요하다는 것이었다. 이 가정이 맞지 않게 될 상황이 많은데 이것은 이 가정이 상당히 취약하다는 것을 의미한다. 이 가정은 다음과 같은 이유로 깨질 수 있다.

  • 사용자의 장바구니 내용을 서버에 저장하기로 결정하고 서버단 코드에서 ShoppingBasket 클래스를 사용하기 시작한다. 서버의 한 인스턴스는 다른 많은 사용자로부터의 요청을 처리하기 때문에 이제 소프트웨어를 실행하는 인스턴스당 (이 경우 서버) 장바구니는 많이 존재한다.
  • 사용자가 나중을 위해 장바구니 항목을 저장할 수 있는 기능을 추가한다. 즉, 클라이언트 측 응용 프로그램은 현재 활성 장바구니뿐만 아니라 나중을 위해 저장되는 장바구니까지 처리해야 한다.
  • 정상적인 재고 외에 신선한 농산물을 판매하기 시작한다. 이것은 완전히 다른 공급자와 배송 메커니즘을 사용하기 때문에 별도의 장바구니로 처리해야 한다.

원래의 가정이 깨지면 소프트웨어에 문제가 발생한다. 만약 두 개의 다른 코드에서 모두 ShoppingBasket 클래스를 사용하고 있다면 그것들은 서로 간섭할 것이다 이들 중 하나가 항목을 추가하면 이 항목은 장바구니를 사용하는 다른 모든 코드에도 추가되어 보일 것이다. 위에서 예로 든 상황에서는 버그가 발생할 수 있으므로 ShoppingBasket 클래스는 안전한 방법으로 재사용하기가 불가능하다.

9.2.2 해결책: 공유 상태에 의존성 주입하라

수정된 ShoppingBasket

class ShoppingBasket {
  private final List <Item> items = new ArrayList<>(); // static이 아닌 인스턴스 변수
  
  void addItem(Item item) {
    items.add(item);
  }
  
  void List <Item> getItems() {
    return List.copyOf(items);
  }
}

의존성 주입된 ShoppingBasket

class ViewItemWidget {
  private final Item item;
  private final ShoppingBasket basket;
  
  publicViewItemWidget(Item item, ShoppingBasket basket) { // 의존성이 주입된 ShoppingBasket
    this.item = item;
    this.basket = basket;
  }
  public void addItemToBasket() {
    basket.addItem(item);
  }
}

class ViewBasketWidget {
  private final ShoppingBasket basket;
  
  public ViewBasketWidget(ShoppingBasket basket) { // 의존성이 주입된 ShoppingBasket
    this.basket = basket;
  }
  public void displayItems() {
    List <Item> items = basket.getItems();
  }
}

9.3 기본 반환값을 적절하게 사용하라

합리적인 기본값은 사용자 친화적인 소프트웨어를 만들기 위한 좋은 방법이다. 워드프로세서 프로그램을 열 때마다 단어를 입력하기 전에 항상 원하는 글꼴, 텍스트 크기, 텍스트 색, 배경색, 줄 간격 및 줄 높이를 정확하게 선택해야 한다고 상상해보라. 그 소프트웨어는 사용하기에 매우 짜증 날 것이고, 아마 다른 워드프로세서로 바꿀 것이다.

기본값을 제공하려면 종종 다음과 같은 두 가지 가정이 필요하다.

  • 어떤 기본값이 합리적인지
  • 더 상위 계층의 코드는 기본값을 받든지 명시적으로 설정된 값을 받든지 상관하지 않는다.

9.3.1 낮은 층위의 코드의 기본 반환값은 재사용성을 해칠 수 있다.

class UserDocumentSettings {
  private final Font font;
  
  public Font getPreferredFont() {
    if (font != null) {
      return font;
    }
    return Font.ARIAL;
  }
}

워드프로세서를 개발하고 있다고 가정해보자.
UserDocumentSettings 클래스는 특정 문서에 대한 사용자의 환경설정을 저장하며, 그중에 한 가지가 사용할 글꼴이다. 글꼴을 지정하지 않은 경우 getPreferredFont() 함수는 기본값인 Font.ARIAL을 반환한다.

이렇게 하면 방금 언급한 요구 사항은 충족되지만, 기본 글꼴로 Arial을 원하지 않는 경우에 UserDocumentSettings 클래스를 다시 사용하고자 하는 경우 어려움을 겪을 수 있다. 사용자가 특별히 Arial을 선택한 것인지 아니면 선호하는 폰트를 설정하지 않아 기본값이 반환된 것인지 구분하는 것이 불가능하다.

9.3.2 해결책: 상위 수준의 코드에서 기본값을 제공하라.

기본값에 대한 결정을 UserDocumentSettings 클래스에서 하지 않도록 하기 위한 가장 간단한 방법은 사용자가 제공한 값이 없을 때 널값을 반환하는 것이다.

class UserDocumentSettings {
  private final Font font;
  public Font getPreferredFont() {
    return font;
  }
}

따라서 기본값 제공은 사용자 설정 처리와는 다른 별개의 하위 문제가 된다. 이는 호출하는 쪽에서 원하는 방식으로 이 하위 문제를 해결할 수 있다는 것을 의미하며, 코드의 재사용성을 높여준다.

기본값을 캡슐화하기 위한 클래스

class DefaultDocumentSettings {
  Font getDefaultFont() {
    return Font.ARIAL;
  }
}
class DocumentSettings {
  private final UserDocumentSettings userSettings;
  private final DefaultDocumentSettings defaultSettings;
  
  public DocumentSettings(UserDocumentSettings userSettings, DefaultDocumentSettings defaultSettings) {
    this.userSettings = userSettings;
    this.defaultSettings = defaultSettings;
  }
  
  public Font getFont() {
    public Font userFont = userSettings.getPreferredFont();
    if (userFont != null) {
      return userFont;
    }
    return defaultSettings.getFont();
  }
}

상위 수준의 코드에서 사용할 설정을 결정할 때 DocumentSettings 클래스는 간결한 추상화 계층을 제공한다. 기본값과 사용자가 제공한 값에 대한 모든 구현 세부 정보를 숨기지만, 동시에 의존성 주입을 사용하여 이러한 구현 세부 정보를 재설정할 수도 있다. 이를 통해 코드의 재사용성과 적응성이 보장된다.

9.4 함수의 매개변수를 주목하라

9.4.1 필요 이상으로 매개변수를 받는 함수는 재사용하기 어려울 수 있다.

필요 이상의 매개변수를 받는 함수

class TextBox {
  private final Element textContainer;
  void setTextStyle(TextOptions options) {
    setFont(...);
    setFontSize(...);
    setLineHight(...);
    setTextColor(options);
  }
  void setTextColor(TextOptions options) {
    textContainer.setStyleProperty("color", options.getTextColor().asHexRgb());
  }
}

경고 메시지에 대한 스타일을 TextBox에 적용하는 함수를 구현한다고 가정해보자. 이 함수의 요구 사항은 텍스트 색상만 빨간색으로 설정하고 그 외의 모든 스타일 정보는 그대로 유지한다. 이를 위해 TextBox.setTextColor() 함수를 재사용하려 하겠지만 이 함수는 TextOptions의 인스턴스를 매개변수로 사용하기 때문에 간단치가 않다.

텍스트 색상을 빨간색으로 설정하는 것만을 원하지만 이를 위해 그 외의 다른 관련 없는 값으로 전체 TextOptions 인스턴스를 생성해야만 한다.

9.4.2 해결책: 함수는 필요한 것만 매개변수로 받도록 하라.

class TextBox {
  private final Element textElement;
  
  void setTextStyle(TextOptions options) {
    setFont(...);
    setFontSize(...);
    setLineHight(...);
    setTextColor(options.getTextColor());
  }
  
  void setTextColor(Color color) {
    textElement.setStyleProperty("color", color.asHexRgb());
  }
}

TextBox.setTextColor() 함수가 TextOptions에서 유일하게 사용하는 것은 텍스트 색상이다. 따라서 함수는 전체 TextOptions 인스턴스를 사용하는 대신 Color 인스턴스를 매개변수로 사용할 수 있다.

9.5 제네릭의 사용을 고려하라

클래스는 종종 다른 유형 혹은 클래스의 인스턴스나 참조를 갖는다. 이것의 대표적인 예는 리스트 클래스다. 문자열 리스트가 있으면 리스트 클래스는 문자열 클래스의 인스턴스를 갖는다. 리스트에 항목을 저장하는 것은 매우 일반적인 하위 문제다. 어떤 경우에는 문자열 리스트가 필요하지만 다른 경우에는 정수 리스트가 필요할 수 있다. 문자열과 정수를 저장하기 위해 완전히 다른 별개의 리스트 클래스가 필요하다면 상당히 번거로울 것이다.

9.5.1 특정 유형에 의존하면 일반화를 제한한다.

문자열 유형을 하드 코딩해서 사용

class RandomizedQueue {
  private final List<String> values = new ArrayList<>();
  public void add(String value) {
    values.add(value);
  } 
  
  /** 
  * 큐로부터 무작위로 한 항목을 삭제하고 그 항목을 반환한다. 
  */
  public String getNext() {
    if (values.isEmpty()) {
      return null;
    }
    Integer randomIndex = Math.randomInt(0, values.size());
    values.swap(randomIndex, values.size() - 1);
    return values.removeLast();
  }
}

RandomizedQueue 클래스는 String에 대한 의존도가 높기 때문에 다른 유형을 저장하는 데는 사용할 수 없다. 이 코드에서와 같이 RandomizedQueue를 구현하면 문자열로 표현될 수 있는 단어를 저장하는 특정한 유형의 문제는 해결하지만 다른 유형의 동일한 하위 문제를 해결할 수 있을 만큼 일반화되어 있지는 않다.

9.5.2 해결책: 제네릭을 사용하라

제네릭 사용

class RandomizedQueue <T> {
  private final List<T> values = new ArrayList<>();
  void add(T value) {
    values.add(value);
  } 
  
  /** 
  * 큐에서 무작위로 한 항목을 삭제한 후에 그 항목을 반환한다. 
  */
  public T getNext() {
    if (values.isEmpty()) {
      return null;
    }
    Integer randomIndex = Math.randomInt(0, values.size());
    values.swap(randomIndex, values.size() - 1);
    return values.removeLast();
  }
}

RandomizedQueue 클래스는 이제 원하는 어떤 것이라도 저장할 수 있으므로 단어를 사용하는 게임 버전에서는 다음과 같이 문자열을 저장하는 클래스로 정의할 수 있다.

RandomizedQueue<String> words = new RandomizedQueue<String>();

RandomizedQueue<Picture> pictures = new RandomizedQueue<Picture>();

요약

  • 동일한 하위 문제가 자주 발생하므로 코드를 재사용하면 미래의 자신과 팀 동료의 시간과 노력을 절약할 수 있다.

  • 다른 개발자가 여러분이 해결하려는 문제와는 다른 상위 수준의 문제를 해결하더라도 특정 하위 문제에 대해서는 자신이 작성한 해결책을 재사용할 수 있도록 근본적인 하위 문제를 식별하고 코드를 구성하도록 노력해야 한다.

  • 간결한 추상화 계층을 만들고 코드를 모듈식으로 만들면 코드를 재사용하고 일반화하기가 훨씬 쉽고 안전해진다.

  • 가정을 하게 되면 코드는 종종 더 취약해지고 재사용하기 어렵다는 측면에서 비용이 발생한다.

    • 가정을 하는 경우의 이점이 비용보다 큰지 확인하라.
    • 가정을 해야 할 경우 그 가정이 코드의 적절한 계층에 대해 이루어지는 것인지 확인하고 가능하다면 가정을 강제적으로 적용하라.
  • 전역 상태를 사용하면 특히 비용이 많이 발생하는 가정을 하는 것이 되고 재사용하기에 전혀 안전하지 않은 코드가 된다. 대부분의 경우 전역 상태를 피하는 것이 가장 바람직하다.

profile
습득한 지식과 경험을 나누며 다른 사람들과 문제를 함께 해결해 나가는 과정에서 서로가 성장할 수 있는 기회를 만들고자 노력합니다.

0개의 댓글