객체지향 생활체조 원칙


“어떤 멍청이라도 컴퓨터가 이해할 수 있느 코드는 작성할 수 있다. 좋은 프로그래머는 사람이 이해할 수 있는 코드를 작성한다. (Any fool can write code that a computer can understand. Good programmers write code that humans can understand)” ― 마틴 파울러

객체지향 생활 체조 원칙은 마틴 파울러의 책에서 나온 9가지 원칙이다. 좋은 품질의 소프트웨어를 만들기 위한 응집도, 느슨한 결합, 무중복 , 캡슐화, 테스트 가능성 , 가독성, 초점을 실현하기 위한 훈련 지침이다.

필자는 항상 개발을 하면서 “이렇게 해도 되는건가?” , “잘하고 있는건가?” 라는 생각이 들 때가 많았다. 특정 기능을 수행하는 로직이 잘 돌아가긴 하는데.. 좋은 코드 인가? 에 대한 확신이 없었다.

그래서 처음 개발을 시작했을 때 배웠던 객체 지향을 되돌아보고 정리해보고자 한다.

시작하기 전에 중요한 점은 모든 원칙이 정답이 아니라는 것이다. 즉 , 원칙 자체에 매몰되지 않고 이 원칙이 어떤 문제를 해결하려 했는가? , 원칙이 해결하고자 하는 문제점에 중심을 두고 공부해보자.



첫번째 원칙 - 한 메서드에 오직 한 단계의 들여 쓰기만 하라.

이유

class Board {
//    ...
   String board() {
      StringBuffer buf = new StringBuffer();
      for (int i = 0; i < 10; i++) {
         for (int j = 0; j < 10; j++)
            buf.append(data[i][j]);
         buf.append("\n");
      }
      return buf.toString();
   }
}

코드를 한눈에 봤을 때 이 코드가 무슨 일을 하는지 명확히 판단할 수 있는가? 코드의 들여쓰기 깊이가 깊어지면 깊어질수록 가독성이 하락한다. 조건문 안에 조건문, 그 조건문 안에는 또 반복문, 등등.. 이런 구조의 코드는 무슨 일을 하는지 알 수가 없다.

또한 , 중첩이 많다는 건 하는 일도 많다는 것과 다르지 않다.

“함수는 한 가지를 해야한다. 그 한가지를 잘하고. 그 한가지만 해야한다.”

해결책

이런 함수의 구조를 개선하기 위해서는 메소드 추출 기법을 사용할 수 있다. 메소드 추출은 코드의 일부분을 메소드로 분리하여, 코드의 복잡도를 낮추는 방법이다.

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");
   }
}

맨 처음에 하나의 함수에 집중되어 있던 일들을 각각의 함수로 분리하여 3개의 함수가 만들어졌다.


두번째 원칙 - else 예약어를 쓰지 않는다.

이유

기본 언어 스펙인 else 예약어를 쓰지 말라는 원칙이다. 우리는 else 예약어를 전혀 사용하지 않고도 프로그램을 작성할 수 있다. else 예약어를 무분별하게 사용하면 조건 분기에 대한 depth 가 깊어질 수 있기 때문이다.

public String getAgeCategory(int age) {
    if (age < 5) {
        return "infant";
    } else {
        if (age < 12) {
            return "child";
        } else {
            if (age < 19) {
                return "teenager";
            } else {
                return "adult";
            }
        }
    }
}

딱 보면 코드가 읽기 불편한 것을 느낄 수 있다. 분기안에 분기 그리고 또 다른 분기, 이러한 방식은 흐름을 따라가기가 어려워진다. else 예약어를 사용하지 않는 원칙은 ‘원칙1. 한 메서드에 오직 한 단계의 들여 쓰기만 한다.’ 와 밀접하게 맞닿아있다. 깊은 단계의 들여쓰기는 곧 읽기 어려운 코드를 낳는다.

해결책

바로 Early Return 패턴을 사용하는 것이다. Ealry Return이란, 조건문 조건이 일치하면 그 즉시 반환을 하는 디자인 패턴이다. Ealry Return 을 사용하여 위 예시 코드를 개선하면 아래와 같다.

public String getAgeCategory(int age) {
    if (age < 5) {
        return "infant";
    }

    if (age < 12) {
        return "child";
    }

    if (age < 19) {
        return "teenager";
    }

    return "adult";
}

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

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

하지만 필자의 개인적인 생각으로는 else 문 자체를 쓰지말란 것에는 완벽히 동의 하진 않는다. 복잡하지 않은 분기 처리 시에는 오히려 else 문이 주는 가독성이 더 좋다고 생각하고 있다.

다만, 복잡한 분기처리 , 깊은 중첩 구조가 생길 위험이 있다면 else 문은 쓰지 않는게 좋겠다.


세번째 원칙 - 모든 원시값과 문자열을 포장한다.

이유

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

원시 타입 데이터는 아무런 의미를 가지고 있지 않다. 원시 값의 의미는 변수명으로 추론 할 수 밖에 없다. 예시로 아래와 같은 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;
    }
}

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

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

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

Person person = new Person(age, money, id);
// Person 의 생성자는 id , age ,money 순으로 파라미터를 입력해야한다.

근데 파라미터를 순서에 맞게 넣지 않았는데도 불구하고 위 코드는 컴파일 에러가 발생하지 않고 잘 생성된다.

age, money , id 가 모두 같은 타입이기 때문이다.

그리고 이렇게 생성된 객체는 우리가 원하는 의도대로 데이터의 값이 들어가지 않았다.

id 에는 age 값 , age 에는 money 값 money 에는 id 값이 들어갔기 때문이다.

즉, id = 15 , age = 5000 , money = 15 인 객체가 생성되었다.

또, 다른 예로 아래와 같이 거리를 미터 단위로 나타내는 원시값이 있다고 가정해보자.

int distanceInMeter = 1000;

이 데이터가 우리의 코드 베이스 전반에 흩어져 있다고 가정하자. 그런데, 몇몇 부분에서는 미터 단위를 킬로미터 단위로 환산하여 사용해야 한다고 하자. 환산이 필요한 부분에서는 아래와 같은 연산이 코드 베이스 전반적으로 중복되어 흩어질 것이다.

int distanceInKilometer = distanceInMeter / 1000; //1km

그리고 거리는 음수가 될 수 없으므로 거리 값을 사용하는 모든 코드에서 아래와 같이 거리 값의 무결성 검증을 한다면 아래와 같은 코드를 작성해야한다.

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

이렇게 점점 특정 데이터에 대한 작업이 여러곳에서 필요로 할 때 사용하다보면 중복되어 흩어지게 된다. 이렇게 코드가 흩어져 있을 경우 요구사항이 변경되었을 때 변경해야하는 코드의 지점도 많아진다.

위 케이스 모두 원시 타입을 사용하여 도메인 개념을 표현할 때 발생하는 문제점들이다. 이를 원시타입에 대한 집착 안티패턴 이라고 한다.

해결책

위 문제를 해결하기 위해서는 원시값을 의미를 부여할 수 있는 객체로 만드는 것이다. 첫번째 케이스부터 개선해보자. 우선 아래와 같이 각각의 원시타입을 포장할 클래스를 정의한다.


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);

또한 변경된 후에는 앞으로 잘못된 타입을 전달하면 컴파일 에러가 발생하기 때문에 항상 올바른 값을 전달할 수 있다.

이번엔 두번째 케이스를 개선해보자.

publicpublic 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() 라는 변환 로직을 객체 내부에 구현함으로써 코드 베이스 전역에 흩어져있던 중복된 로직들이 하나의 객체로 응집되었다. 그로 인해 중복 코드를 줄이고 객체가 능동적인 행위를 갖게되어 더 객체 지향적으로 코드를 작성할 수 있게 되었다.

이런 형태를 값 객체 라고 한다. 값 객체에 대해서는 이전에 원시 타입 대신 VO 를 사용하자 라는 포스팅으로 다뤄본 바 있다.


네번째 원칙 - 한줄에 점을 하나만 찍는다.

여기서 점이란 객체 멤버에 접근하기 위한 점 . 을 의미한다. PHP 의 경우엔 -> 로 이해하면 된다.

이유

방금전 원칙3에서 개선한 Person 클래스를 가져오겠다. 이번엔 Person 이 일정 금액 이상 돈을 보유하고 있는지 검사해볼 것이다.

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

Getter 를 사용하여 구현하면, 위와 같이 한 줄에 점이 2개 이상 찍힐 것이다. 점이 여러개 찍혓다는 것의 의미는 무엇일까?

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

해결책

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

디미터 법칙은 낯선 이와 이야기하지 말라 또는 최소 지식 원칙으로도 알려져있다.

디미터 법칙은 핵심은 객체 그래프를 따라 멀리 떨어진 객체에게 메시지를 보내는 설계를 피하라는 것이다. 이런 설계는 객체간의 결합도를 높이는 지름길이다. 많은 점이 찍혀있다는 것은, 객체가 다른 객체에 깊숙이 관여하고 있음을 의미한다. 이는 캡슐화가 깨져있다는 방증이기도 하다.

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

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

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

즉, 직접 Person 에 접근하여 값을 가져와 계산하는 것이 아니라 Person 에게 부탁하는 것이다.

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


다섯번째 원칙 - 줄여쓰지 않는다 (축약 하지말라)

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

이유

클래스 , 메소드 , 변수 이름을 축약하면 읽는 이로 하여금 혼란을 줄 수 있다. 왜 축약하고 싶을까? 이름이 너무 길기 때문아닐까? 이름은 또 왜 길까? 해당 클래스, 메소드가 너무 많은 일을 하고 있는 것은 아닐까 생각해보자.

해결책

클래스와 메소드의 역할을 적절하게 분리하고 , 각각의 책임을 다른 객체와 메소드에 위임해보자. 역할과 책임이 줄어들어 이름도 짧게 만들 수 있을 것이다.

클래스와 메소드의 이름을 한두단어 정도로 짧게 유지하고, 문맥을 중복하는 이름을 자제하자. 주문을 나타내는 Order 클래스의 주문 메소드를 shipOrder() 로 명명할 필요가 있을까? 그냥 짧게 ship() 으로 해도 의미는 통할 것이다.


여섯번 째 원칙 - 모든 엔티티를 작게 유지한다.

이유

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

또한 50줄이 넘지 않는 클래스는 한눈에 보기 쉽다는 부가적인 효과도 있다.

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

해결책

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

일곱번 째 원칙 - 2개를 초과하는 인스턴스 변수를 가진 클래스를 쓰지 않는다.

이유

인스턴스 변수가 많아질수록 클래스의 응집도는 낮아진다는 것을 의미한다. 마틴 파울러는 대부분의 클래스가 인스턴스 변수 하나만으로 일을 하는 것이 마땅하다고 한다. 하지만, 몇몇 경우에는 두개의 변수가 필요할 때가 있다고 한다. 클래스는 하나의 상태(인스턴스 변수)를 유지하고 관리하는 것과 두개의 독립된 변수를 조율하는 두 가지 종류로 나뉜다고 한다.

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

해결책

object-rule-7

코드가 많고 덩치가 큰 클래스, 객체를 보았을 때 한번에 이해하기란 쉽지 않다. 객체를 협력 객체간의 계층 구조로 바라보자. 이 원칙을 재귀적으로 클래스에 적용하여, 덩치가 큰 객체를 작은 크기의 여러 객체로 분해하면, 자연스럽게 인스턴스 변수들을 적절한 곳에 위치시킬 수 있다.


여덟번 째 원칙 - 일급 컬렉션을 쓴다.

이유

‘세번째 원칙 - 모든 원시값과 문자열을 포장한다’ 원칙과 비슷한 원칙인 듯 하다. 컬렉션 또한 클래스로 포장하지 않으면, 의미없는 객체의 모음일 뿐이다.

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


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

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

해결책

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


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);

여기저기 흩어져있는 비즈니스 로직이 일급컬렉션으로 응집되고, 중복 코드가 낮아지게 되었다. 또한 여러 역할과 책임을 일급 컬렉션에게 위임하여 좀 더 능동적인 객체로 만들어볼 수 있게 되었다.

매번 List 를 선언하고 조건을 확인하는 로직들이 전역적으로 흩어져 있었으나, 컬렉션을 클래스로 감싸 일급 컬렉션을 만든다면 중복을 줄이고 응집도를 높일 수 있다.


아홉번 째 원칙 - Getter, Setter, Property 를 사용 하지 않는다.

이유

객체를 조금 더 객체답게 사용하기 위해서는 , 객체가 할 수 있는 작업은 객체에게 믿고 맡겨야한다. 즉 , 객체에게 책임이 있는 작업은 객체에게 직접 시켜야한다. 이런 원칙을 “묻지 말고 , 시켜라” 원칙이라고 한다.

위에서 만든 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;

위 코드는 바람직 하지 않다. 위와 같은 코드는 객체의 역할과 책임, 자율성을 무시한 코드다. 위와 같이 코드를 작성하면, 다시 비즈니스 로직이 코드 베이스 전반으로 흩어지고 중복 코드가 발생한다.

해결책

‘단위 환산’ 이라는 행동은 Distance 객체의 책임이다.

public class Distance {

// ...

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

// ...

}

센터미터로 단위를 환산하는데 왜 Getter 가 필요한가? 왜 Getter 를 통해 직접 값을 가져와서 센티미터를 구하는가? 객체 상태에 기반한 모든 결정과 행동은 외부가 아닌 객체 내부에서 이루어져야한다. 그것이 바람직한 객체이다.

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


마치며

아홉가지 원칙들을 살펴보며 조금 과하다 싶은 원칙도 있었다. 그리고 몇몇 원칙은 오히려 더 복잡성이 증가할 것 같은데? 라는 생각이 들기도 했다.

해당 내용을 공부하면서 필자가 생각한 느낀 점은 “최대한 나누고, 최대한 분리하며 역할을 명확히 하라” 였던 것 같다.


Reference Blog