Five Lines of Code - 6. 데이터 보호

주제: 데이터와 기능에 대한 접근을 제한하는 캡슐화로 불변속성이 지역에만 영향을 주게 만들기

🔖 6.1 getter 없이 캡슐화 하기

책의 내용

(1) getter와 setter를 사용하지 말 것

클래스는 동일한 데이터에 대한 기능을 결합해서 불변속성을 더 가깝게 모아 지역화 합니다. boolean이 아닌 필드에 getter나 setter를 사용하지 마십시오.

객체의 필드에 getter가 존재하는 순간, 캡슐화를 해제하고 불변속성을 전역적으로 만듭니다. 객체를 반환한 후, 이를 반환받은 곳에서는 이 객체를 더 많은 곳에 전달할 수 있으며, 이는 우리가 제어할 수 없습니다. 객체를 얻은 어느 곳에서나 public 메서드를 호출할 수 있으며, 예상치 못한 방식으로 객체를 수정할 수 있습니다.

(2) 푸시 기반 아키텍처 장려

풀 기반(pull-based) 아키텍처에서는 데이터를 가져와 중앙에서 연산을 수행하며, 수많은 '수동적인' 데이터 클래스와 여기저기서 데이터를 혼합해서 모든 작업을 수행하는 소수의 '관리자' 클래스로 이어집니다. 이 접근 방식은 데이터와 관리자 사이, 그리고 암묵적으로 데이터 클래스 간에도 밀 결합을 가져옵니다.

class Website {
  constructor (private url: string) { }
  getUrl() { return this.url; }
}

class User {
  constructor (private username: string) { }
  getUsername() { return this.username; }
}

class BlogPost {
  constructor (private id: string, private author: User) { }
  getId() { return this.id; }
  getAuthor() { return this.author; }
}

function generatePostLink(website: Website, post: BlogPost)
{
  let url = website.getUrl();
  let user = post.getAuthor();
  let name = user.getUsername();
  let postId = post.getId();
  
  return url + name + postId;
}

푸시 기반(push-based) 아키텍처에서는 가능한 한 데이터에 가깝게 연산을 이관하고, 데이터를 가져오는 대신 인자로 데이터를 전달합니다. 결과적으로 모든 클래스가 자신의 기능을 가지고 있으며, 코드는 그 효용에 따라 분산됩니다.

class Website {
  constructor (private url: string) { }
  generateLink(name: string, id: string) { 
    return this.url + name + id; 
  }
}

class User {
  constructor (private username: string) { }
  generateLink(website: Website, id: string)
  {
    return website.generateLink(
      this.username,
      id);
  }
}

class BlogPost {
  constructor (private id: string, private author: User) { }
  generateLink(website: Website)
  { 
    return this.author.generateLink(
      website, this.id
    );
  }
}

function generatePostLink(website: Website, post: BlogPost)
{
  return post.generateLink(website);
}

나의 생각

(1) 메서드를 인라인화 하는 것의 장점은 무엇일까?

일단 '인라인화' 라는 것은 간단히 말해서 함수나 메서드 호출을 그 호출 부분에 해당하는 코드로 대체하는 것이다. 즉, 함수 호출을 그 함수가 포함된 곳에 있는 코드로 대체하여 코드를 단순화하는 것이라고 할 수 있다.

// 기존 코드
function add(a: number, b: number): number {
  return a + b;
}

function calculateSum(x: number, y: number): number {
  return add(x, y);
}

// 인라인화된 코드
function calculateSum(x: number, y: number): number {
  return x + y; // add 함수 호출을 해당 함수의 내용인 덧셈 연산으로 대체하여 함수 호출을 없앰
}

다시 본론으로 돌아와, 위의 푸시 기반 예제 코드에서는 덧붙여진 정보 없이 한 줄에 불과하기 때문에 generatePostLink를 인라인화할 가능성이 높다. 이 경우, generatePostLinkgenerateLink 메서드를 호출하는 일만 한다. 이렇게 되면 함수 호출을 통해 전달되는 인자들을 더 직접적으로 이해할 수 있으며, 코드의 의도를 더 명확하게 전달할 수 있는 것이다.

따라서 이러한 인라인화를 통해 함수 호출의 오버헤드를 제거하고 코드를 단순화하여 더 직관적인 코드가 되며, 결국 유지보수를 더 쉽게 만들 수 있다. 다만, 과도한 인라인화는 코드의 가독성을 저하시킬 수 있으므로 적절한 상황에서 사용해야겠다.

(2) 왜 boolean이 아닌 필드에 getter나 setter를 사용하지 말라는 것일까?

boolean 필드는 주로 ‘객체의 상태’를 나타내는 데 사용된다고 했다. 예를 들어, 아래와 같이 활성화된 상태를 나타내는 isActive 와 스위치 상태를 나타내는 isSwitchedOn 가 있다고 치자. 이러한 경우에는 boolean 값을 반환하는 getter와 boolean 값을 설정하는 setter를 사용하는 것이 일반적으로 괜찮을 것 같다.

class Example {
  private isActive: boolean;

  constructor(isActive: boolean) {
    this.isActive = isActive;
  }

  public getIsActive(): boolean { // getter
    return this.isActive;
  }

  public setIsActive(active: boolean): void { // setter
    this.isActive = active;
  }
}

위의 코드에서 isActive 필드를 private으로 설정한 이유는 이 필드를 클래스 외부에서 직접 접근하거나 변경하는 것을 방지하기 위함이다.

반면, getIsActive 메서드를 public으로 설정한 이유는, 클래스의 사용자가 isActive 필드의 상태를 읽을 수 있도록 하기 위해서다. 만약 getIsActive 메서드도 private으로 설정되어 있다면, 클래스 외부의 코드에서는 이 필드의 값을 전혀 알 수 없게 되어, 이 필드가 갖는 정보를 활용할 수 없을 것이다. → 즉, boolean 값이 return되는 메서드는 public으로!

그러나 다른 유형의 필드, 특히 객체의 내부 상태를 직접 노출하는 것은 일반적으로 좋지 않다. 왜냐하면 이는 캡슐화 원칙을 위반하고, 객체의 내부 구현에 대한 의존성을 높일 수 있기 때문이다. getter와 setter는 주로 필드에 대한 접근을 제한하고, 필요한 경우 해당 필드를 읽거나 수정하는 데 사용된다.

예를 들어, 아래와 같이 사용자의 나이를 나타내는 age 필드가 있다고 가정해 보자.

class User {
  private age: number;

  constructor(age: number) {
    this.age = age;
  }

  public getAge(): number {
    return this.age;
  }

  public setAge(age: number): void {
    this.age = age;
  }
}

위의 예시 코드에서 age 필드에 대한 getter와 setter가 있다. 이렇게 하면 외부에서 age 값을 직접 읽고 수정할 수 있으므로 객체의 내부 구현이 노출된다. 대신에 객체의 상태를 변경하는 메서드를 제공하여 객체의 내부 상태에 대한 캡슐화를 유지하는 것이 더 바람직할 것이다.

(3) 밀 결합(tight coupling)이란 무엇일까?

밀 결합 (tight coupling)은 소프트웨어 컴포넌트가 서로 강하게 의존하고 있는 상태를 나타낸다. 이는 한 컴포넌트가 다른 컴포넌트의 내부 구현에 대해 너무 많이 알고 있거나, 다른 컴포넌트의 변경이 해당 컴포넌트에 영향을 미치는 상황을 의미한다. 객체 지향 프로그래밍에서 많이 나타나는 밀 결합은 주로 아래와 같은 형태로 나타난다.

  • 직접 의존성(Direct Dependency): 한 클래스가 다른 클래스의 메서드를 직접 호출하거나, 다른 클래스의 인스턴스를 직접 생성하는 경우에 발생합니다. 이는 클래스 간의 의존성을 높이고, 변경에 취약한 코드를 만들 수 있다.

  • 인터페이스의 무시: 클래스가 다른 클래스의 내부 상태에 직접 접근하거나, 다른 클래스의 내부 구현에 의존하는 경우에 발생한다. 이는 유연성을 감소시키고, 코드의 재사용을 어렵게 만든다.

  • 강력한 결합: 두 클래스가 서로의 내부 상태를 수정하거나, 서로에게 직접적으로 메시지를 보내는 경우에 발생한다. 이는 변경의 파급 효과를 증가시키고, 코드를 이해하고 유지보수하는 데 어려움을 줄 수 있다.

따라서 getter와 setter를 사용하는 것은 종종 밀 결합을 초래할 수 있다. 외부에서 클래스의 내부 상태를 직접적으로 접근하고 수정하는 것은 클래스의 캡슐화를 깨뜨리고, 의존성을 높일 수 있기 때문이다.

(4) 아래 내용을 더 잘 이해해보자

(boolean이 아닌 필드에 getter나 setter를 사용하지 말라는 이야기, 밀결합 문제 발생 이야기..) 이런 문제는 가변(mutable) 객체에서만 발생하는 문제입니다. 그러나 이 규칙은 불변(immutable) 필드에도 비공개 필드를 적용해서 얻는 또 다른 효과, 즉 제안하는 아키텍처 때문에 부울(Boolean) 만을 예외로 하고 있습니다.

이 부분을 읽으면서 이해가 되지 않아 몇 번을 읽었는데, 내 언어로 풀어서 정리해보겠다.

[요약] 결론적으로, 불변 객체에서는 일반적으로 내부 상태를 직접 노출하는 것을 피하나, Boolean 필드의 경우에는 getter와 setter를 사용하여 외부로 노출하는 것이 허용된다.

우선, 용어 하나하나를 먼저 짚고 가보겠다. 책에서 말하는 “결합(coupling)“이란 객체 간의 상호 의존성을 나타낸다. 결합이 강하면 한 객체의 변경이 다른 객체에 큰 영향을 줄 수 있다. 이러한 상황을 피하기 위해 부분적으로 변경되는 객체를 다른 객체와 결합하지 않는 것이 바람직하다.

그리고 “가변(mutable) 객체“란, 객체의 상태가 변경될 수 있는 객체를 의미한다. 반면에 “불변 객체“는 생성된 후에는 상태가 변경되지 않는 객체를 의미한다.

위의 설명에서 “Boolean을 예외로 한다“는 말은 일반적으로 불변 객체에서는 내부 상태를 외부로 직접 노출시키는 것을 피하는 것이 좋지만, Boolean 필드의 경우에는 예외를 허용한다는 것을 의미한다. 그 이유는 무엇일까?

Boolean 필드는 종종 “객체의 현재 상태를 표현하는 플래그“로 사용되므로, 외부에서 이 상태를 확인할 수 있는 것이 필요할 때가 많다. 이러한 필드는 주로 getter와 setter를 사용하여 외부에서 접근하고 변경할 수 있어야 한다. 예를 들어, 어떤 객체가 ‘활성화 상태‘인지 아닌지를 외부에서 판단할 수 있어야 하는 로직이 있을 수 있다. 이러한 경우, 상태 정보를 읽을 수 있는 getIsActive 메서드는 public으로 설정하는 것이 적절할 것 같다.

더불어, Boolean 필드는 대개 변경되지 않는 경우가 많으며, 변경되더라도 그 영향이 다른 객체에 크게 미치지 않는 경우가 많다. 따라서 이러한 Boolean 필드에 대해서는 getter와 setter를 사용하여 외부로 노출하는 것이 허용되고, 이러한 필드를 비공개(private)로 만들어 결합을 완화하는 것이 적절할듯 하다.

🔖 6.2 간단한 데이터 캡슐화하기

책의 내용

코드에는 공통 접두사나 접미사가 있는 메서드나 변수가 없어야 합니다. 여러 요소가 동일한 접사를 가지면 해당 요소들의 긴밀성을 나타내지만, 더 좋은 방법으로는 '클래스'가 있습니다.

클래스를 사용해서 이런 메서드와 변수를 그룹화하는 장점은 외부 인터페이스를 완전히 제어할 수 있다는 것입니다. 도우미 메서드를 숨겨 전역 범위를 오염시키지 않을 수 있습니다.

class Account {
  // deposit() 비공개로 직접 호출 X
  private deposit(to: string, amount: number)
  {
    let accountId = database.find(to);
    database.updateOne(
      accountId,
      { $inc: { balance: amount } });
  }

  // 대신 transfer()를 통해 deposit()가 호출
  transfer(amount: number,
    from: string, to: string)
  {
    this.deposit(from, -amount);
    this.deposit(to, amount);
  }
}

위의 예제 코드에서 deposit 메서드와 관련된 동작을 Account 클래스 내에서 캡슐화하여 외부에서의 접근을 제어합니다. 이를 통해 외부 인터페이스를 완전히 제어하고, 도우미 메서드를 숨겨 전역 범위를 오염시키지 않을 수 있습니다.

이 규칙이 파생된 스멜을 '단일 책임 원칙'이라고 하며, 클래스에는 단 하나의 책임만 있어야 합니다. 공통 접사가 암시하는 구조는 해당 메서드와 변수가 공통 접사의 책임을 공유한다는 것을 의미합니다. 따라서 이런 메서드는 이 공통 책임을 전담하는 별도의 클래스가 있어야 합니다.

나의 생각

(1) 단일 책임 원칙을 준수하는 클래스와 그렇지 않은 클래스 수정하기

우아한테크코스 마지막 미션 때 쯤이었나, MVC 중, Model에서 각 클래스의 책임을 어떻게 가져갈 것인가에 대한 고민이 많았다. 주요 로직에서 각 클래스별로 담당해야 하는 가지들을 그려가면서 책임을 분리했는데, 하나의 클래스에서 변경이 발생할 경우 다른 클래스에 영향을 미치지 않게 하고자 했다.

예를 들어, 아래의 조건에 맞게 배지를 부여하는 클래스가 있었다.

import { BADGES, OUTPUT } from '../common/constants.js';

class Badge {

  #totalBenefit;

  constructor(totalBenefit) {
    this.#totalBenefit = totalBenefit;
  }

  assignBadge() {
    for (const { badge, amount } of BADGES) {
      if (this.#totalBenefit >= amount) {
        return badge;
      }
    }
    return OUTPUT.none;
  }
};

export default Badge;

이 클래스에는 assignBadge() 즉, 배지를 부여한다는 한 가지 명확한 책임을 가지고 있다. 또한 외부에 정의된 BADGESOUTPUT 상수를 사용하며 이 클래스가 외부 의존성을 이해하고 다른 책임으로 분리되도록 했다.

반면, 아래의 날짜 관련 코드를 보면 단일 책임 원칙을 잘 준수하고 있지 않은 것으로 보인다.

import { ERROR, EVENT } from '../common/constants.js';
import { isInRange, isNumeric } from '../common/validator.js';
import { throwError } from '../common/utils.js';

class EventDate {

  #date;

  constructor(date) {
    this.#validate(date);
    this.#date = Number(date); 
  }

  getEventDate() {
    return this.#date; 
  }

  #validate(date) {
    this.#validateNumber(date);
    this.#validateRange(date);
  }

  
  #validateNumber(date) {
    if (!isNumeric(date)) {
      throwError(ERROR.date);
    }
  }

  #validateRange(date) {
    if (!isInRange(date, EVENT.min_date, EVENT.max_date)) {
      throwError(ERROR.date);
    }
  }
};

export default EventDate;

위의 클래스에서는 #validate 메서드를 통해 숫자 여부와 범위를 검사하고, 생성자에서 주어진 날짜를 숫자로 변환하여 저장한다.

만약 위 코드를 다시 작성한다면, 유효성 검사와 변환을 담당하는 유틸리티 클래스나 함수를 따로 분리하여 단일 책임 원칙을 준수하는 클래스로 변경할 것 같다. 예를 들어, EventDate 클래스는 단순히 날짜를 저장하고 제공하는 역할만을 수행하도록 설계하고, 유효성 검사 및 변환은 별도의 유틸리티 함수나 클래스로 분리할 것 같다.

import { ERROR, EVENT } from '../common/constants.js';
import { isInRange, isNumeric } from '../common/validator.js';
import { throwError } from '../common/utils.js';

// 날짜 유효성 검사 및 변환을 담당하는 유틸리티 클래스로 분리
class DateValidator {
  static validate(date) {
    if (!this.isNumeric(date) || !this.isInRange(date)) {
      throwError(ERROR.date);
    }
  }

  static isNumeric(date) {
    return !isNaN(date);
  }

  static isInRange(date) {
    return date >= EVENT.min_date && date <= EVENT.max_date;
  }
}

// 날짜를 저장하고 제공하는 하나의 역할만 수행하는 클래스
class EventDate {
  #date;

  constructor(date) {
    DateValidator.validate(date);
    this.#date = Number(date);
  }

  getEventDate() {
    return this.#date;
  }
}

export default EventDate;

(2) 그렇다면 하나의 클래스에는 하나의 메서드만 있어야 할까?

단일 책임 원칙은 클래스가 한 가지 목적을 수행한다는 것을 의미하지, 클래스가 반드시 하나의 메서드만 가져야 한다는 것은 아니라고 한다.

실제로 클래스는 여러 메서드를 가질 수 있다. 하지만 이러한 메서드들은 모두 클래스의 주요 목적에 대한 다양한 측면을 처리해야 하며, 결국은 모두 클래스의 주요 목적을 달성하기 위한 부분이어야 할 것이다.

예를 들어, 자동차 클래스는 주행하는 데 필요한 여러 메서드를 가질 수 있다. 주행을 시작하고 멈추는 메서드, 속도를 조절하는 메서드, 방향을 바꾸는 메서드 등이 있을 수 있다. 이 여러 개의 메서드들은 모두 자동차의 주행에 관련된 작업을 처리하므로, 이러한 클래스는 단일 책임 원칙을 준수할 수 있겠다.

따라서 클래스의 메서드 수는 중요하지 않겠다. 중요한 것은 클래스가 한 가지 목적을 수행하고, 그 목적을 달성하기 위해 필요한 메서드들을 적절하게 포함하고 있는지일 것이다.

🔖 6.4 순서에 존재하는 불변속성 제거하기

책의 내용

무언가가 다른 것보다 먼저 호출되어야 할 때, 그것을 순서 불변속성(sequence invariant)라고 합니다. 이 기법은 항상 특정 순서로 일이 발생하게 하는데 활용할 수 있습니다. 이 리팩토링 기법을 '순서 강제화' 라고 합니다.

가장 멋진 유형의 리팩토링은 컴파일러에게 프로그램이 어떻게 실행되기를 바라는지 가르쳐줄 수 있을 때입니다. 그래야 원하는대로 실행되는지 확인하는 데에 도움이 됩니다. 객체지향 언어에서는 생성자가 항상 객체의 메서드보다 먼저 호출되어야 합니다.

생성자를 사용해 일부 코드가 실행되게 하면 클래스의 인스턴스가 코드가 실행되었다는 증거가 됩니다. 생성자를 성공적으로 실행하지 않고 인스턴스를 얻을 수는 없기 때문입니다.

나의 생각

책보다 더 쉬운 예제를 써봤다. 아래 예시에서는 User 클래스를 정의하고 생성자에서 사용자 이름을 초기화한다. 또한 greet 메서드를 통해 인사말을 출력하는 기능을 포함하고 있다. 생성자가 먼저 호출되어 이름을 설정한 후, 메서드를 통해 해당 이름을 사용할 수 있다.

class User {
  private name: string; // 사용자 이름

  constructor(name: string) {
    console.log("Constructor is called.");
    this.name = name; // 생성자에서 이름 초기화
  }

  greet() {
    console.log(`Hello, ${this.name}!`); // 인사말을 출력하는 메서드
  }
}

// 객체 생성
const user = new User("Ella");

// 메서드 호출
user.greet();

이 코드를 실행하면 다음과 같은 순서로 출력된다.

  1. "Constructor is called." - User 객체의 생성자가 호출되면서 출력된다.

  2. "Hello, Ella!" - greet 메서드가 호출되면서 사용자 이름과 함께 인사말이 출력된다.

위의 예제를 통해 객체의 생성자가 객체의 다른 메서드보다 먼저 실행되어 (순서 강제화) 필요한 초기화 작업을 수행하는 것을 볼 수 있다. 생성자를 통해 설정된 값은 그 후 호출되는 메서드에서 활용될 것이다.

🔖 6.5 열거형을 제거하는 또 다른 방법

책의 내용

타입스크립트를 포함한 많은 언어에서 열거형은 메서드를 가질 수 없습니다. 이때는 private 생성자를 사용해 우회할 수 있습니다. 모든 객체는 생성자를 호출해 생성해야 합니다. 생성자를 private으로 만들면 클래스 내부에서만 객체를 생성할 수 있습니다. 특히 존재하는 인스턴스 수를 제어할 수 있습니다. 이런 인스턴스를 public 상수에 넣으면 열거형으로 사용할 수 있습니다.

나의 생각

(1) 타입스크립트에서 열거형은 메서드를 가질 수 없다?

열거형(Enums)은 특정 값들의 집합을 쉽고 안전하게 사용할 수 있도록 도와주는 기능이다. 열거형은 기본적으로 숫자나 문자열 값을 담은 고정된 상수들의 집합을 정의하는데 사용되는데, 열거형이 메서드를 가질 수 없다는 말은, 열거형 자체가 메서드를 포함할 수 있는 객체와는 다르다는 것을 의미한다고 한다. 또한, Tree-Shaking이 되지 않는다고 한다.

정리하자면, 일반적인 클래스에서는 데이터(속성)와 함께 행동(메서드)를 정의할 수 있는 반면, 열거형은 단순히 데이터(특정 값)만을 나열하는 데 사용되고, 이 데이터들에 대해 직접적으로 메서드를 추가할 수는 없다.

📚 참고 자료

Last updated