Skip to content

Latest commit

 

History

History
583 lines (352 loc) · 18.2 KB

객체지향_생활체조.md

File metadata and controls

583 lines (352 loc) · 18.2 KB

♻ 객체지향 생활체조


객체지향 생활체조 원칙은 마틴 파울러의 책 소트웍스 앤솔러지에 나온 9가지 원칙이다.

9가지 원칙 그 자체에 매몰되지 말고, 원칙이 해결하고자 하는 문제점(WHY)에 집중해 보자.



원칙 1, 한 메서드에 오직 한 단계의 들여 쓰기만 한다.


WHY

"함수는 한 가지를 해야 한다. 그 한 가지를 잘 해야 한다." 클린 코드


SOLUTION

이런 함수의 구조를 개선하기 위해서는 마틴 파울러의 책 리팩토링에서 소개된 메서드 추출(Extract Method) 기법을 사용할 수 있다. 메서드 추출은 코드의 일부분을 메서드로 분리하여, 코드의 복잡도를 낮추는 기법이다.


class Board {
   // ...
   String board() {
      StringBuffer buf = new StringBuffer();
      collectRows(buf);
      return buf.toString();
   }

   void collectRows(StringBuffer buf) {
      for (int i = 0; i < 10; i++)
         collectRow(buf, i);
   }

   void collectRow(StringBuffer buf, int row) {
      for (int i = 0; i < 10; i++)
         buf.append(data[row][i]);
      buf.append("\n");
   }
}




원칙 2, else 예약어를 쓰지 않는다.


SOLUTION

바로 Early Return 패턴을 사용하는 것이다.

Ealry Return 이란, 조건문 조건이 일치하면 그 즉시 반환을 하는 디자인 패턴이다.

가독성이 훨씬 증가하여, 읽기 쉬운 코드가 된 것을 확인할 수 있다.
이때, 보호 절(gaurd clause)을 사용해볼 수도 있다. 보호 절을 사용하여 Fast-Fail 처리를 할 수도 있겠다.


또는 객체지향의 주요 특징 중 하나인 다형성을 사용하는 방법도 있다.
다형성을 사용하는 예로는 가장 대표적으로 전략 패턴(strategy pattern)이 있다.
또는 널 객체 패턴(null object pattern)을 사용해 볼 수도 있다.




원칙 3, 모든 원시 값과 문자열을 포장한다.


WHY

"int 값 하나 자체는 그냥 아무 의미 없는 스칼라 값일 뿐이다. 어떤 메서드가 int 값을 매개변수로 받는다면 그 메서드 이름은 해당 매개변수의 의도를 나타내기 위해 모든 수단과 방법을 가리지 않아야 한다." ― 소트웍스 앤솔러지


원시 타입 데이터는 아무런 의미를 가지고 있지 않다. 원시 값의 의미는 변수명으로 추론할 수밖에 없다.


1️⃣ 예시로 아래와 같은 Person 클래스가 있다고 해보자.

public class Person {

    private final int id;
    private final int age;
    private final int money;

    public Person(final int id, final int age, final int money) {
        this.id = id;
        this.age = age;
        this.money = money;
    }
}

Personid , age , money 필드를 가지고 있으며, 모두 int 타입이다.

이 셋을 구분할 수 있는 것은 오직 변수명뿐이다. 이런 상황에서는 아래와 같은 실수를 막을 수 없다.

int age = 15;
int money = 5000;
int id = 150;

Person person = new Person(age, money, id);

위 코드는 컴파일 에러가 발생하지 않는다. age , money , id 모두 같은 타입이기 때문이다.

하지만, 우리 의도대로 Person 객체가 생성되지 않는다. 파라미터를 전달하는 순서가 의도와 맞지 않기 때문이다.

실제로 생성된 Person 객체는 ID가 15이고, 나이가 5000살이며, 가진 돈은 150원 일 것이다.


2️⃣ 또, 아래와 같이 거리를 미터 단위로 나타내는 원시 값이 있다고 하자.

int distanceInMeter = 1000; // 1000m

이 데이터가 우리의 코드 베이스 전반에 흩어져 있다고 가정하자.

그런데, 몇몇 부분에서는 미터 단위를 킬로미터 단위로 환산하여 사용해야 한다고 하자.

환산이 필요한 부분에서는 아래와 같은 연산이 코드 베이스 전반적으로 중복되어 흩어질 것이다.

int distanceInKilometer = distanceInMeter / 1000; // 1km

생각해 보니 거리는 음수가 될 수 없다. 거리 값을 사용하는 모든 코드에서 아래와 같이 거리 값의 무결성을 검증해야 한다.

if (distanceInMeter < 0) {
    throw new IllegalArgumentException("잘못된 거리 값 입니다.");
}

이렇게 점점 특정 데이터에 대한 작업이 여기저기 중복되어 흩어지게 된다.

이렇게 코드가 흩어진 경우 요구사항이 변경되었을 때 변경해야 하는 코드의 지점도 많아진다. 유지 보수가 힘들어질 것이다.


위 케이스 모두 원시 타입을 사용하여 도메인 개념을 표현할 때 발생하는 문제점들이다.

이를 원시 타입에 대한 집착 (Primitive Obsession) 안티 패턴이라고 부른다.


SOLUTION

위 문제를 해결하기 위해서는 원시 값을 의미를 부여할 수 있는 객체로 만드는 것이다.


첫 번째 케이스부터 개선해 보자. 우선 아래와 같이 각각의 원시 타입을 포장할 클래스를 정의한다.

public class Id {
    private int value;
    // 생성자 생략
}

public class Age {
    private int value;
    // 생성자 생략
}

public class Money {
    private int value;
    // 생성자 생략
}

Person 클래스도 아래와 같이 변경한다.

public class Person {

    private final Id id;
    private final Age age;
    private final Money money;

    public Person(final Id id, final Age age, final Money money) {
        this.id = id;
        this.age = age;
        this.money = money;
    }
}

개선된 구조에서는 각각의 도메인 개념이 다른 값과 구분된 고유의 타입을 갖게 되었으므로, 더 이상 혼란스럽지 않게 값을 사용할 수 있다.


또한 잘못된 타입을 전달하면 컴파일 에러가 발생하므로 항상 올바른 값을 전달할 수 있다.

Age age = new Age(15);
Money money = new Money(5000);
Id id = new Id(10);

Person person = new Person(id, age, money);

두 번째 케이스를 개선해 보자. 두 번째 케이스는 데이터가 변환, 유효성 검사 등의 행위를 가지지 않아 발생하는 문제이다.

객체지향을 객체지향답게 사용하려면, 책임과 역할을 부여받고 능동적으로 행동하는 객체를 만들어야 한다.

도메인 개념을 캡슐화하고, 행위를 부여하자.

public class Distance {

    private final int meter;

    public Distance(final int meter) {
        if (meter < 0) {
            throw new IllegalArgumentException("잘못된 거리 값 입니다.");
        }
        this.meter = meter;
    }

    public int toKilometer() {
        return meter / 1000;
    }
}

**Distance**라는 클래스를 정의하여 원시 값을 포장하였다.

Distance 객체는 생성 시점에 값의 유효성을 검증해 유효한 객체만 생성될 수 있다.

또한 **toKilometer()**라는 변환 로직을 객체 내부에 구현하였다. 코드 베이스 전반에 흩어진 비즈니스 로직이 하나의 객체로 응집되었고, 그로 인하여 중복 코드도 줄어들었다. 또한 객체가 능동적인 행위를 갖게 되어 더 객체지향적으로 코드를 작성할 수 있게 되었다.


이런 형태를 **값 객체 (Value Object)**라고 한다.

값 객체에 대해서는 해당 아티클을 참고하자. 👉 원시 타입 대신 VO(Value Object)를 사용하자




원칙 4, 한 줄에 점을 하나만 찍는다.


여기서 점이란 객체 멤버에 접근하기 위한 점(.)을 의미한다.


WHY

방금 전 원칙 3에서 개선한 Person 클래스를 가져오자.

이번엔 **Person**이 일정 금액 이상 돈을 보유하고 있는지 검사해 볼 것이다.


if (person.getMoney().getValue() > 10000) {
    System.out.println("당신은 부자 💰");
}

Getter를 사용하여 구현하면, 위와 같이 한 줄에 점이 2개 이상 찍힐 것이다.

점이 여러 개 찍혔다는 것의 의미는 무엇일까?


일단 위 코드는 점을 두 개 이상 찍으면서, 결합도가 높아졌다. 위 코드를 사용하는 클래스는 Person 뿐 아니라 **Money**에 대한 의존성을 추가로 갖게 된다.


SOLUTION

*"한 줄에 점을 하나만 찍으라"*는 원칙은 사실 디미터 법칙을 직관적으로 이해하기 위한 원칙이다.

디미터 법칙은 낯선 이와 이야기하지 말라(Don't Talk to Strangers) 또는 **최소 지식 원칙(Principle of least knwoledge)**으로도 알려져 있다.


디미터 법칙의 핵심은 객체 그래프를 따라 멀리 떨어진 객체에게 메시지를 보내는 설계를 피하라는 것이다.

이런 설계는 객체 간의 결합도를 높이는 지름길이다. 많은 점이 찍혀있다는 것은, 객체가 다른 객체에 깊숙이 관여하고 있음을 의미한다. 이는 캡슐화가 깨져있다는 방증이기도 하다.


if (person.hasMoneyMoreThan(10000)) {
    System.out.println("당신은 부자 💰");
}

위와 같이 점을 하나만 사용하도록 코드를 개선하였다.

**Person**의 **Money**의 메서드를 호출하는 것이 아니라 **Person**에게 물어보는 방식으로 변경되었다.

Person 은 조금 더 능동적으로 자신이 가지고 있는 상태를 활용해 행위를 수행할 수 있게 되었다.

또한 위 코드를 사용하는 객체도 **Money**라는 객체에 대한 의존성을 끊어내었다. 이는 원칙 9에서 이야기할 Tell, don't ask 원칙이기도 하다.


단, DTO, 자료구조와 같은 경우에는 내부 구조를 외부에 노출하는 것이 당연하므로 디미터 법칙을 적용하지 않는다. 또한 Java Stream API처럼 메서드 체이닝(method chaining)을 사용하는 경우는 디미터 법칙을 위반하지 않는다. 디미터 법칙은 결합도와 관련된 이야기이므로, 본질을 잊고 '한 줄에 점 하나'에 매몰되지 말자.




원칙 5, 줄여 쓰지 않는다. (축약 금지)


"의도가 분명하게 이름을 지으라" - 클린 코드


WHY

클래스, 메서드, 변수 이름을 축약하면 읽는 이로 하여금 혼란을 야기할 수 있다.

이름이 왜 길까? 해당 클래스, 메서드가 너무 많은 일을 하고 있다는 신호 아닐까?

특히 클래스의 경우 단일 책임 원칙(SRP)을 위반하고 있을 수 있다.


SOLUTION

클래스와 메서드의 역할을 적절하게 분리하고, 각각의 책임을 다른 객체와 메서드에 위임해 보자.

역할과 책임이 줄어들어 이름도 짧게 만들 수 있을 것이다.


클래스와 메서드의 이름을 한두단어 정도로 짧게 유지하고, 문맥을 중복하는 이름을 자제하자.

주문을 나타내는 Order 클래스의 주문 메서드를 **shipOrder()**로 명명할 필요가 있을까? 짧게 **ship()**으로 하여도 의미는 통할 것이다.




원칙 6 모든 엔티티를 작게 유지한다.


WHY

50줄이 넘는 클래스와, 파일이 10개 이상인 패키지를 지양하자는 원칙이다.

보통 50줄이 넘는 클래스는 한 가지 일을 하고 있지 않으며, 코드의 이해와 재사용을 어렵게 만든다. SRP 원칙은 SOLID 중 가장 지키기 쉬우면서, 효과가 좋은 원칙이다.

이를 항상 기억하자. 또한 50줄이 넘지 않은 클래스는 한눈에 보기 쉽다는 부가적인 효과도 존재한다.

패키지 내의 파일의 수도 줄여야지 하나의 목적을 달성하기 위한 연관된 클래스의 집합임이 드러나게 된다. 이는 높은 응집도를 위함이다.


SOLUTION

클래스가 50줄이 넘어가지 않게 작성하고, 패키지의 파일이 10개 이상이 되지 않도록 유지하자.




원칙 7, 2개를 초과하는 인스턴스 변수를 가진 클래스를 쓰지 않는다.


WHY

인스턴스 변수가 많아질수록 클래스의 응집도는 낮아진다는 것을 의미한다.

마틴 파울러는 대부분의 클래스가 인스턴스 변수 하나만으로 일을 하는 것이 마땅하다고 한다. 하지만, 몇몇 경우에는 두 개의 변수가 필요할 때가 있다고 한다.

클래스는 하나의 상태(인스턴스 변수)를 유지하고 관리하는 것과 두 개의 독립된 변수를 조율하는 두가지 종류로 나뉜다고 한다.


이 원칙은 클래스의 인스턴스 변수를 최대 2개로 제한하는 것은 굉장히 엄격하지만, 최대한 클래스를 많이 분리하게 강제함으로써 높은 응집도를 유지할 수 있게 하는 원칙이다.


SOLUTION

덩치가 큰 객체를 이해하는 과정은 쉽지 않다. 객체를 협력 객체 간의 계층 구조로 바라보자.

이 원칙을 재귀적으로 클래스에 적용하여, 덩치가 큰 객체를 작은 크기의 여러 객체로 분해하면, 자연스럽게 인스턴스 변수들을 적절한 곳에 위치시킬 수 있다.




원칙 8, 일급 컬렉션을 쓴다.


WHY

원칙 3, 모든 원시 값과 문자열을 포장한다. 원칙과 비슷한 원칙이라고 생각한다.

컬렉션 또한 클래스로 포장하지 않으면, 의미 없는 객체의 모음일 뿐이다.

다이소는 5000원이 넘는 물건을 판매하지 않는 정책이 있다고 한다. 다이소의 상품 진열대를 List 컬렉션으로 구현해 보자.


List<Item> daisoItems = new ArrayList<>();
if (item.isPriceHigherThan(5000)) {
    throw new IllegalArgumentException("5000원이 넘는 물건은 진열할 수 없습니다.");
}

5000원이 넘는 상품은 진열할 수 없으므로, 위와 같이 리스트에 Item 객체를 추가하는 곳마다 유효성 검증 코드를 추가해야 한다. 원칙 3에서의 문제가 똑같이 발생한다. 여기저기 비즈니스 로직이 흩어지고, 이로 인해 중복 코드가 발생한다.


SOLUTION

컬렉션을 클래스로 한번 감싸 일급 컬렉션으로 만들어 이 문제를 해결해 보자.

public class DaisoItems {

    private final List<Item> items;

    public DaisoItems() {
        this.items = new ArrayList<>();
    }

    public void addItem(final Item item) {
        if (item.isPriceHigherThan(5000)) {
            throw new IllegalArgumentException("5000원이 넘는 물건은 진열할 수 없습니다.");
        }
        items.add(item);
    }
}

사용은 아래와 같이 한다.

DaisoItems daisoItems = new DaisoItems();
daisoItems.addItem(item);

여기저기 흩어져있는 비즈니스 로직이 일급 컬렉션으로 응집되고, 중복 코드가 낮아지게 되었다.

또한 여러 역할과 책임을 일급 컬렉션에게 위임하여 좀 더 능동적인 객체로 만들어볼 수 있게 되었다.




원칙 9, Getter, Setter, Property를 사용하지 않는다.


WHY

객체를 조금 더 객체답게 사용하기 위해서는, 객체가 할 수 있는 작업은 객체에게 믿고 맡겨야 한다.

즉, 객체에게 책임이 있는 작업은 객체에게 직접 시켜야 한다.

이런 원칙을 묻지 말고, 시켜라(Tell, don't ask) 원칙이라고 한다.


위에서 만든 Distance 객체에 Getter만 추가하여 다시 가져와보았다.


public class Distance {

    private final int meter;

    public Distance(final int meter) {
        if (meter < 0) {
            throw new IllegalArgumentException("잘못된 거리 값 입니다.");
        }
        this.meter = meter;
    }

    public int toKilometer() {
        return meter / 1000;
    }

		public int getMeter() {
				return meter;
		}
}

어딘가에서 Distance 객체를 사용하는데, 이번엔 미터를 센티미터 단위로 환산하여 작업해야 한다. 아래와 같이 **getMeter()**를 사용하여 값을 가져오고 100을 곱하여 센티미터로 환산했다.


Distance distance = new Distance(10);
int cm = distance.getMeter() * 100;

위는 바람직한 코드인가? 그렇지 않다. 위와 같은 코드는 객체의 역할과 책임, 자율성을 무시한 코드이다.

위와 같이 코드를 작성하면, 다시 비즈니스 로직이 코드 베이스 전반으로 흩어지고 중복 코드가 발생한다.


SOLUTION

단위 환산이라는 행위는 Distance 객체의 책임이다. 아래와 같이 개선하자.

public class Distance {

    // ...

    public int toCentimeter() {
        return meter * 100;
    }

    // ...
}

센티미터로 단위를 환산하는데 Getter가 필요한가? Getter와 Property도 마찬가지이다.

객체 상태에 기반한 모든 결정과 행동은 외부가 아닌 객체 내부에서 이루어져야 한다. 그것이 바람직한 객체이다.


Getter/Setter/Property를 남발하면, 불필요한 객체 내부 구현의 노출로 이어지며 이는 곧 응집도 하락과, 캡슐화의 위반으로 이어진다.

계속 강조하지만 객체의 자율성을 보장하고 능동적으로 행동할 수 있도록 설계하자.



마치며

프로그래밍은 복잡도를 다루는 기술이다.

복잡도를 이겨내기 위해서는 최대한 작은 단위로, 읽기 쉽고 예측할 수 있는 코드를 작성해야 한다.


객체지향 생활체조 원칙은 불편하다. 때때로는 가혹하다. 하지만 더 좋은 코드를 위해 의식적인 연습을 해보자.