우아한테크코스 6기

객체지향 생활체조 원칙

스키(ski) 2023. 10. 25. 21:36

우테코 프리코스에 참가하고 디스코드에서 여러 참가자들이 공유해주는 것들중에 [객체지향 생활체조 원칙]이라는 말을 처음 들어봤다.

몇몇 사람들은 이미 알고 있었는지 이에 대해 심도깊은 이야기를 하는것을 보았다.

처음 들었을때 내 심정

이에 대해 몰랐던 나는 이 정보를 목록을 보기 시작했다.

 

'한 메서드에 들여쓰기는 한단계만.. else를 쓰지 않는다.. getter를 쓰지말자.. 응..?'

 

뭔가 처음보는데 익숙한 느낌이 들었다. 그래서 어디서 봤지 곰곰히 생각해보니 난 이걸 본적이 있다는걸 알게되었다.

작년 프리코스 4주차(다리 건너기)의 요구사항중 일부

바로 작년 우테코 프리코스 미션의 제약조건이었다! 그때는 뭣도 모르고 했었는데 사실 이게 원칙이라는 이름으로 있던거라니.. 

 

이번에는 이 객체지향 생활 체조에 대해 서술해보려고 한다.

 

그래서 이게 뭔데?

객체지향 생활원칙의 개념이 나온 책

객체지향 생활원칙은 마틴 파울러가 작성한 '소트웍스 앤솔러지'에 나오는 개념이다. 이는 코드를 객체지향적으로 작성하기위해 준수하면 좋을 9가지의 원칙에 대해 설명하고 있다. 

정확하게는 좋은 설계의 바탕이 되는 7가지의 코드 품질 항목인 응집력(cohesion), 느슨한 결합(loose coupling), 무중복(zero duplication), 캡슐화(encapsulation), 테스트 가능성(testability), 가독성(readability), 초점(focus)를 실제 코드에 실현하기 위한 훈련방법이라고 볼수 있다.

 

https://developerfarm.wordpress.com/2012/01/26/object_calisthenics_1/ (소트웍스 앤솔러지의 일부 발췌 내용)

 

더 나은 소프트웨어를 향한 9단계: 객체지향 생활 체조(1)

오늘날 더 나은 소프트웨어를 향한 9단계 알아먹기도, 테스트하기도, 그리고 유지보수하기도 어렵도록 형편없이 짠 코드를 우리 모두 보아왔다. 소프트웨어를 점진적으로 작성하고 그에 따라

developerfarm.wordpress.com

 

9가지 원칙에 대해 코드에 적용하는 훈련을 통해 객체지향이 되도록 하는 코드를 만들수 있게하고, 이런 과정에서 코드를 바라보는 새로운 관점을 만드는것이 목표로 보인다.

 

객체지향 생활체조 9원칙

 

1. 한 메서드에서 한 단계 들여쓰기만 사용하자

Use only one level of indentation per method

 

한단계의 들여쓰기를 한다고 어떻게 변화하는지 궁금할수도 있다.

이에 관련된 예시는 본인의 다른 포스트에 연관 되어있기에 그 링크를 걸어둔다.

https://skianything.tistory.com/20

 

Indent Depth를 줄여보자!

우테코 프리코스에 참가하면서 많은 참가자들이 객체지향 생활체조 9원칙을 지키려고 노력하는 것을 보았다. (본인 포함) 그중에서 아래 원칙을 지키는것이 생각보다 어려웠다. 한 메서드에서

skianything.tistory.com

Indent를 줄이게 되면 자연스럽게 한개의 메서드에서 할수 있는 일이 적어질수 밖에없게 된다. 

void filterAndPrint(int[] arr){ //메서드의 일:배열을 순회/배열값이 0 이상일때 출력
    for(int x=0;x<ROW;x++){
        if(arr[x]>0){
            System.out.println(arr[x]);
        }
    }
}
void circuitAndPrint(int[] arr){ //메서드의 일을 분리, 순회
    for(int x=0;x<ROW;x++){
        print(arr[x]);
    }
}

void print(int x){ //출력
    if(x>0){
        System.out.println(x);
    }
}

이렇게 한개의 메서드는 자연스럽게 하나의 일만 하게되고, 이를 통해 메서드의 재사용률이 올라갈수 있다.

(만약 print 메서드의 기능을 다른 메서드에서 사용해야 할때 쉽게 적용할수 있다.)

이와 관련되서 리팩토링과 관련된 다른 도서인 '클린 코드'에서는 메서드의 역할에 대해 언급한 문장이 있다

함수는 한 가지를 해야 한다. 그 한 가지를 잘해야 한다. 그 한 가지만을 잘해야 한다.

 

 

2. else 예약어를 쓰지 말자

Don’t use the else keyword

 

else문을 빈번하게 사용하는 코드의 예시를 보여보겠다.

void printNumByRange(int number){
    if(number>100){
    	Sytem.out.println("it over 100");
    }
    else{
    	if(number<30){
            System.out.println("it smaller than 30");
        }
        else{
        	if(number>70){
            	System.out.println("is between 30 and 70");
            }
            ...
        }
    }
}

인자인 number의 값에 따라 결과를 출력하는 기능을 가진 메서드가 있다고 가정하자. 그리고 우리는 이 코드를 분석하고 수정하는 역할을 맡았다고 생각해보자. 이 코드를 한눈에 파악할수 있겠는가?

number의 값에 따라 분기가 계속일어나고 그 안에서 세부 분기가 일어나고 이것이 반복되기에 한눈에 파악하기는 쉽지 않을것이다.

이는 else 예약어의 사용으로 인해 일어나는 현상일 것이다. else는 if문에서 건 조건, 그 외의 모든 것 이라는 분기를 가지는것이다. 그 안에서 또 분기가 일어나고 또 else를 쓰게되면 세부적인 분기가 몇번이고 반복되는것이다.

 

그럼 위의 코드에서 else 예약어를 안쓴다는 제약을 두고 코드를 적어보자

void printNumByRange(int number){
    if(number>100){
    	Sytem.out.println("it over 100");
        return;
    }
    
    if(number<30){
        System.out.println("it smaller than 30");
        return;
    }
    
    if(number>70){
        System.out.println("is between 30 and 70");
        return;
    }
    
    ....
}

이전 코드보다는 편히 해석이 가능해 진것이 보일것이다. 이는 앞에서 언급된 1번 원칙인 '한 메서드에서 한 단계의 들여쓰기만 사용하자'와 밀접한 연관이 있는 원칙이다. 

 

3. 모든 원시값과 문자열을 포장하자

Wrap all primitives and strings

 

해당 원칙과 관련해서 '소트웍스 앤솔러지'에 나온 문장을 인용해보려고 한다.

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

간단하게 설명하면 원시값을 인자로 넘겨버리면 다른 원시값과 어떻게 구분할건데? 이를 보여주려면 메서드명에 잘 설명해야할텐데?  간단한 예시를 보여보겠다.

int find(int row,int column){
    ....
}

int형 변수인 row와 column을 인자로 받는 find라는 메서드가 있다. 헤당 메서드에서 두 인자를 구분하는것은 인자명밖에 없다. 만약 다른 메서드에서 해당 메서드를 사용하려고 할때 다음과 같은 문제가 생길수도 있다.

 

"앞쪽이 row였나.. 뒷쪽이 row였나..?"

 

물론 이때마다 해당 메서드의 구현문으로 보고 파악하면 되겠지만 만약, 이런 것이 반복된다면..? 아니면 귀찮다고 안보고 인자를 거꾸로 넣게 된다면? 우리는 에러문을 마주하게 되고 이것때문에 밤샘을 할지도 모른다(?)

 

또한 1번 원칙과 연관된 문제점도 발생할수도 있다.

만약 row,column에 의도치 않는 값이 들어온다면 (ex.음수) 문제가 발생하기에 이를 처리하기 위해 해당 메서드 안에 기느을 추가할것이다.

int find(int row,int column){
    if(row<0){
        throw new Exception("row가 0 이하입니다.");
    }
    
    if(column<0){
        throw new Exception("column가 0 이하입니다.");
    }
    ....
}

이는 앞에서 언급된 '한 메서드는 한 가지일만 수행해야 한다.' 라는 클린코드의 문장과 상반되는 코드를 가지게 된다.

따라서 이를 위해 이러한 값들을 포장하는 것이다.

int find(Row row,Column column){
    ....
}

class Row{
    int row;
    
    public Row(int row){
        if(row<0){
            throw new Exception("0 이하의 row가 들어왔습니다.");
        }
        
        this.row=row;
    }
}

class Column{
    int column;
    
    public Column(int column){
        if(column<0){
            throw new Exception("0 이하의 row가 들어왔습니다.");
        }
        
        this.column=column;
    }
}

위처럼 구성하게 되면 앞에서 언급된 문제들인 인자를 헷갈릴 고민도 없게되고 메서드가 여러 일을 수행하지 않을수 있게된다. (이미 객체 생성시에 예외처리를 끝내기 때문에)

 

 

4. 한 줄에서 한개의 점만 사용하자

Use only one dot per line

 

점(.)을 여러개 사용하는 예시에 대해 보여보겠다.

Result result=game.play().find().calculate();//이는 예시코드

그리고 메서드의 구조가 다음과 같다고 하자.

이렇게 한 코드에서 점이 여러개가 있다는것은 한 객체에 다른 객체에 대한 정보가 드러나 있다는 증거이다.

즉 한 객체가 다른 객체에 깊이 관여하고 있다는 의미이고 이는 곧, 캡슐화를 어기고 있다는 것과  같은 의미를 가진다. 이와 관련된 법칙으로는 디미터(Demeter)의 법칙이 있다.

친구하고만 대화하라  -디미터(Demeter)의 법칙

 

즉 해당 객체랑만 관련있는 것과만 대화하라는 것이다.

 

5. 축약하지 말자

Don’t abbreviate

 

대부분의 개발자는 변수명이나 메서드명을 간략하게 작성해본적이 있을것이다. 이는 이름을 간단하게 표현하고 싶다는 욕구에서 온것이다. 하지만 왜 이런 욕구가 드는건가를 생각해볼 필요가 있다. 

왜 우리는 이름을 줄일려고 하는걸까? 이유는 이름이 길거나, 같은 걸 반복해서 적기 때문일것이다. 각각의 이유에 대해 깊게 파고 들어가보자

 

1. 그럼 왜 이름이 긴걸까?

이는 표현해야 할게 많은것이고 표현해야할게 많다는것은 수행하고 있는 기능이 많다는것을 의미한다. 이는 첫번째 원칙에 위배되는것이다. 따라서 기능을 분리시킬 필요가 있는것이다.

에를 들면 OrderAndFindAndCalculate()라는 메서드의 이름을 보면 3가지 기능을 가진 메서드로보인다. 이를 각각 분리해서 Order(),Find(),Calculate() 이런식으로 분리하면 이름이 짧아지는것이다.

 

2. 왜 이름을 반복해서 적는걸까?

해당 기능을 반복적으로 사용되기 때문이다. 즉,중복적으로 사용이 된다는 뜻인데, 중복된 기능을 제대로 처리하지 못하고 있다는 뜻일지도 모른다.

 

즉, 축약을 하고싶다는 생각이 든다는것은 분리를 제대로 마무리 짓지 못했을 가능성이 높다!

 

 

6. 모든 엔티티를  작게 유지하자

Keep all entities small

 

이것에 대한 상세한 정의는 '소트웍스 앤솔러지' 책에 나와있다.

이 말은 50줄 이상 되는 클래스와 파일이 10개 이상인 패키지는 없어야 한다는 뜻이다.

보통 50줄 이상의 클래스는 한가지 이상의 일을 하고있을 가능성이 높다고 한다. 추가적으로 50줄 짜리의 클래스는 한 화면에 다 잡히기에 스크롤 없이 볼수 있다는 이점도 가진다.

 

패키지 내부의 파일의 제한을 둔 이유는 클래스간의 연관성을 보이게 하기 위해서이다.

메서드를 분리하고 클래스를 분리하게 되면 자연스럽게 클래스의 개수가 많아진다. 하지만 이는 특정 기능에서 분리된 클래스이기에 몇몇 클래스들이 모여서 하나의 목적을 이루게 된다.

패키지는 그런 목적을 가진 클래스들을 모음으로서 패키지의 정체성을 지닐수 있게 된다.

 

 

7. 클래스는 인스턴스 변수 두 개를 넘지 않게 하자

Don’t use any classes with more than two instance variables

 

앞에서부터 반복해서 이야기하는것이 있다. 

바로 '한 클래스는 한 가지의 일만을 수행해야 한다.' 라는 점이다.

클래스는 하나의 상태(인스턴스 변수)를 유지,관리 하는것을 목표로 하는것이 좋다.

만약 여기서 인스턴스 변수가 하나 추가가 된다면 클래스는 두 가지의 상태를 유지하고 관리해야 하기에 앞에서 언급되는 '한 가지 일만을 수행한다'를 지키기 어려워 진다.

따라서 인스턴스 변수를 두개 이상을 만들지 않는것을 원칙으로 내세운다.

(여기서 인스턴스 변수는 원시타입과 컬렉션과 같이 기본적인 형태 타입의 변수를 의미한다)

 

이 규칙 하에서 클래스를 생성하게 되면 클래스는 크게 두가지 종류로 나뉘게 된다.

 

1. 한가지 상태(인스턴스 변수)를 유지,관리 하는 클래스

2, 두개의 독립된 변수를 조율하는 클래스 

 

이렇게 두가지로 나뉘게 된다. '사용자의 정보(User)'를 예시로 들어보겠다.

 

사용자의 정보를 세부하게 분류

이렇게 사용자라는 큰 틀에서 이름이라는 세부정보 그중에서도 성과 이름으로 다시 나뉠수 있다. 

앞에서의 분류에 따르면 성,이름,아이디,회원번호가 1번 클래스의 분류되고 이름,사용자가 2번클래스로 분류가 되어진다.

 

8. 일급 컬렉션을 사용하자

Use first-class collections

 

3번 원칙인 모든 원시값과 문자열을 포장하자와 맥락을 같이하는 원칙이다.

컬렉션 또한 그 자체로 사용하게 되면 의미를 파악하기 힘든 객체이기에 이를 클래스로 묶어서 사용하는 '일급 컬렉션'으로서 사용하라는 것이다.

 

class Lotto{
    List<Numbers> lotto;
    
    ...
}

이런식으로 컬렉션을 일급 컬렉션으로 묶어서 사용하게 되면 이와 관련된 기능을 한곳에 모을수 있는 효과를 가질수 있게된다. 해당 예시에서는 Numbers를 분류하고 순회등 여러가지 유용한 기능들을 같이 사용할수 있게 된다. 

 

9. Getter / Setter / Properties를 사용하지 말자

Don’t use any getters/setters/properties

 

이는 캡슐화를 유지하면서 객체의 상태를 노출하지 않고 내부에서 작업을 수행해 그 결과를 반환하라는 의미를 지닌다.

class Rectangle{
    private int width;
    private int height;
    
    public void setWidth(int width){
        this.width=width;
    }
    
    public void setHeight(int height){
        this.height=height;
    }
    
    public int getWidth(){
        return width
    }
    
    public int setWidth(){
        return height
    }   
}

아래와 같이 밑변과 높이를 가진 직사각형을 나타내는 Rectangle이라는 클래스가 있다고 하자. 개발자는 이 직사각형의 넓이를 구하는 프로그램을 만든다고 했을때 다음과 같이 간단하게 구할수 있을것이다.

rectangle.getWidth()*rectangle.getHeight(); //Rectangle rectangle=new Rectangle();

간단하지만 이는 객체의 상태를 직접적으로 가져오기 때문에 캡슐화를 지키지 못한 경우라고 볼수 있다. 

따라서 클래스의 상태를 직접적으로 가져오지 않고 넓이를 구해야한다. 

이에 관련한 문장이 책에 언급되어진다.

묻지 말고, 시켜 (Tell, don't ask)

 

즉,해당 속성에 대한 책임을 가진 클래스에게 행위를 시키라는 뜻이 된다. 

예시로 표현하면 넓이를 구하는것을 외부에서 하는것이 아닌 클래스 내부에서 시킨뒤 그 답만을 가져오면 되는것이다.

 

class Rectangle{
    private int width;
    private int height;
    
    public void setWidth(int width){
        this.width=width;
    }
    
    public void setHeight(int height){
        this.height=height;
    }
    
    public int getArea(){
        return width*height;
    }
}

이런 식으로 행위를 시키면 되는것이다. 어차피 밖에서 요구하는건 과정이 아니라 답이기 때문!

 

Q. getter를 없애는건 알겠는데 setter는 어떻게 없애죠?

이는 간단하다. 생성자를 사용하면 된다.

class Rectangle{
    private int width;
    private int height;
    
    public Rectangle(int width,int height){
        this.width=width;
        this.height=height;
    }
    
    public int getArea(){
        return width*height;
    }
}

이런 식으로 하면 setter를 없앨수 있게된다. 

 

Q. 어? 이러면 width,height의 값을 다시는 못바꾸는 거 아닌가요?

맞다! 못바꾼다. 그게 당연한거다. 

클래스는 한가지 상태의 유지,관리를 하는 목적을 가지는데, 이를 외부의 행위를 통해 상태를 바꿀수 있다는것은 캡슐화를 지키지 못하는것이 된다.

혹시 값을 바꾸고 싶다면 객체를 새로 만들면 된다.

 

PS. 해당 법칙은 데이터 전달을 목적으로 하는 DTO는 대상이 되지 않는다.

 

맺으며

처음 이 규칙을 보고 바로 적용하기는 정말 어려울것이다. (나도 어려웠고 아직도 어렵고 앞으로도 어려울것같다.)

그럴때는 모든 규칙을 한번에 적용하려 하지 말고 한개씩 적용해보자.

그렇게 하나씩 하다보면 어느새 몬생긴 코드에서 아름다운 코드를 맞이하고 자뻑을 시전하는 본인을 볼수 있을것이다.

이뻐진 코드를 보고 천재라고 느끼는 자신

하지만 다음날 코드를 보면 다시 더러워보인다는게 학계의 점심