객체지향 생활체조 원칙

안녕하세요 트렌비 Garden 서비스팀 준입니다.

우리는 멋지고 예쁜 몸을 만들기 위해 헬스장을 찾지만 무엇부터 운동을 해야 할지 잘 모를 때가 많습니다.
가장 먼저 해야할 것은 바로 준비운동입니다. 준비운동을 하면 근육의 가동 범위를 높여주고 부상을 예방할 수 있습니다.

객체지향 생활체조 원칙도 객체지향적으로 사고하고 개발하기 위해 지키면 좋을 준비운동이라고 생각하면 좀 더 쉽게 다가갈 수 있습니다. 객체지향 생활체조 원칙이라는 용어는 마틴 파울러의 책 중에 <소트웍스 앤솔러지> 6장에서 소개하고 있습니다.

그럼 객체지향적으로 코드를 잘 만들기 위한 아래의 9가지 동작을 하나씩 알아보겠습니다.

9가지 원칙은 다음과 같다.

  1. 한 메서드에 오직 한 단계의 들여쓰기만 한다.
  2. else 예약어를 쓰지 않는다.
  3. 모든 원시값과 문자열을 포장한다.
  4. 일급(first-class) 콜렉션을 쓴다.
  5. 한 줄에 점을 하나만 찍는다.
  6. 줄여쓰지 않는다.
  7. 모든 엔티티(entity)를 작게 유지한다.
  8. 2개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.
  9. 게터(getter)/세터(setter)/프로퍼티(property)를 쓰지 않는다.

펭수_감사


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

이 동작은 한 메소드의 들여쓰기를 2단계 이상하지 말라는 것입니다.
들여쓰기가 많은 메소드일수록 하나의 메소드에서 여러 가지 일들을 처리하고 있다고 볼 수 있습니다.

(Before)

입력받은 문자열의 합을 구하는 예제입니다. 현재 sumTotal 메소드에는 문자열을 COMMA로 자르는 일과 합을 구하는 두 가지 일을 하고 있습니다.

public int sumTotal(List<String> inputs) {
    int total = 0;
    for (int i = 0; i < inputs.size(); i++) {
        String input = inputs.get(i);
        String[] texts = input.split(COMMA);
        for (String text : texts) {
            total += Integer.parseInt(text);
        }
    }
        return total;
}

(After)

하나의 메소드에서 처리하던 두 가지 일을 각 메소드로 분리한 후의 코드를 보면 훨씬 간결해진 것을 볼 수 있습니다.
오직 한 단계의 들여쓰기만 하도록 메소드를 작성한다면 메소드 내에서 한가지 일에만 집중할 수 있고 그에 따라 코드가 읽기 쉬워질 뿐만 아니라 재사용이 가능해집니다. 이것은 테스트를 작성하기 좋고 디버깅이 쉽도록 만들어 줍니다.

public int sumTotal(List<String> inputs) {
        int total = 0;
        for (int i = 0; i < inputs.size(); i++) {
            total += sumLine(splitText(inputs.get(i)));
        }
        return total;
}

public List<Integer> splitText(String text) {
        String[] texts = text.split(COMMA);
        return Stream.of(texts)
                .map(Integer::parseInt)
                .collect(Collectors.toList());
}

public int sumLine(List<Integer> numbers) {
        int sum = 0;
        for (Integer number : numbers) {
            sum += number;
        }
        return sum;
}

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

대부분 여러 겹으로 중첩된 조건문을 파악하느라 고생한 경험이 있을 것입니다.
이런 경우 if 조건을 만족하지 않는 경우를 생각해야 하기 때문에 코드를 읽을 때 양쪽을 다 생각해야 합니다. 따라서 코드의 가독성이 떨어질 뿐 아니라 오류가 발생할 확률이 높아집니다. 이 동작은 switch/case 문을 사용하는 것도 허용하지 않습니다.

(Before)

예제를 살펴보겠습니다. 코드의 길이가 길지 않아 가독성이 나쁘지 않다고 느낄 수 있습니다.
그러나 실무에서는 훨씬 복잡한 코드를 작성하는 경우가 많습니다. 이 메소드는 statusName 변수를 마지막에 리턴하고 있습니다. 모든 조건문 분기를 다 확인하기 전까지는 메소드가 어떻게 동작하는지 파악하기가 어렵습니다.

public String getOrderStatus(String statusCode) {
	String statusName = "";

	if (statusCode.equals("1")) {
		statusName = "집화";
	} else if (statusCode.equals("2")) {
		statusName = "출고";
	} else if (statusCode.equals("3")) {
		statusName = "배송중";
	} else {
		statusName = "배송완료";
	}
	
	return statusName;
}

(After 1)

빠르게 적용해 볼 수 있는 것 중 하나는 Early Return을 사용하는 것입니다.

public String getOrderStatus(String statusCode) {
	if (statusCode.equals("1")) {
		return "집화";
	}
	if (statusCode.equals("2")) {
		return "출고";
	}
	if (statusCode.equals("3")) {
		return "배송중";
	}
	if (statusCode.equals("4")){
		return "배송완료";
	}
	throw new IllegalArgumentException("존재하지 않는 상태입니다.");
}

Early Return을 적용하면 조건을 만족하는 경우 메소드는 자신의 역할을 수행하고 리턴합니다.
코드를 읽을 때 메소드를 끝까지 확인하지 않아도 되기 때문에 가독성을 높일 수 있습니다.

추가로 불필요한 지역변수의 생성을 막을 수 있습니다. Before 코드에서 statusName이라는 지역변수를 선언하고 코드를 작성했는데 이 경우에 중간에 지역변수 값이 변경될 수 있는 가능성이 언제든지 존재할 수 있게 됩니다.
결국 분석해야 할 코드의 양이 늘어나게 됩니다.

(After 2)

public enum OrderStatus {

    PICKUP("1", "집화"),
    RELEASE("2", "출고"),
    DELIVERING("3", "배송중"),
    COMPLETED("4", "배송완료")
    ;

    private String statusCode;
    private String statusName;

    OrderStatus(String statusCode, String statusName) {
        this.statusCode = statusCode;
        this.statusName = statusName;
    }

    public static String getStatusName(String statusCode) {
        return Arrays.stream(values())
                .filter(orderStatus -> orderStatus.statusCode.equals(statusCode))
                .findFirst()
                .orElseThrow( () -> new IllegalArgumentException("상태가 존재하지 않습니다."))
                .name();
    }
}
public String getOrderStatus(String statusCode) {
	return OrderStatus.getStatusName(statusCode);
}

조금 더 객체지향적으로 바꿔본다면 Enum을 활용해볼 수 있습니다.
이제 주문 상태가 추가되거나 변경되더라도 Enum 객체 내부에서 모두 처리가 가능해졌습니다.

객체지향의 다형성을 활용한 설계는 명확하게 코드로 의도를 표현하여 읽기 쉽고 유지 보수하기 좋은 코드를 만들어 줍니다. 이를 위해 else를 사용하지 않도록 권장하는 것입니다.


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

원시 타입의 변수는 값을 나타내지만 객체 내에서 상태로 쓰이면서 비즈니스 의미를 나타내기도 합니다.
이런 원시 타입 변수를 객체로 포장하면 얻는 이점이 많습니다.

(Before)

아래와 같이 지하철 라인을 나타내는 Entity가 있을 때 Line 클래스 내에 요금을 나타내는 fare 변수가 있다면 fare와 관련된 여러가지 행위를 Line 클래스 내에 작성하게 됩니다. 또는 여타 다른 클래스(예: FareCalculator) 내에서 Line의 getFare() 메소드로 요금을 가져와 로직을 수행할 것입니다.

@Entity
public class Line extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String name;

    private String color;

    private int fare;
    
    ...
    
    public int plus(int otherFare) {
        return this.fare += otherFare;
    }
}

(After)

아래처럼 원시값을 객체로 포장했을 때 요금과 관련된 검증과 비지니스 로직을 Fare 객체 내에서 스스로 관리할 수 있게 됩니다. 이것은 코드를 유지보수하는데 도움이 됩니다.

@Embeddable
public class Fare {
 
    private int fare;

    public Fare() {
    }

    private Fare(int fare) {
        validate(fare);
        this.fare = fare;
    }

    public static Fare from(int fare) {
        return new Fare(fare);
    }

    private void validate(int fare) {
        if (fare < 0) {
            throw new IllegalArgumentException("요금은 음수가 될 수 없습니다.");
        }
    }

    public Fare plus(Fare amount) {
        return new Fare(fare + amount.fare);
    }
    ...

4. 일급(first-class) 콜렉션을 쓴다.

컬렉션 외에 다른 필드를 가지고 있지 않은 클래스를 일급 컬렉션이라고 합니다.
컬렉션을 객체로 포장하면 컬렉션과 관련된 코드 중복을 막을 수 있고 데이터를 캡슐화할 수 있다는 점에서 좀 더 객체지향적인 코드를 작성할 수 있습니다

(Before)

아래처럼 RacingGame 객체가 racingCars 라는 경기에 참가할 자동차 List를 가지고 있을 떄 RacingGame 내에서 자동차를 참가시키고 각 자동차들의 레이싱을 진행하는 로직들을 작성하게 될 것입니다.

여기서 만약 경기에 참가할 자동차의 수를 10대로 제한한다는 내용이 추가되거나 예: List<MotoCycle> 오토바이 레이싱도 개최를 한다면 RacingGame 코드는 가독성이 떨어지고 유지보수하기 어렵게 됩니다.

public class RacingGame {

    private int countOfRound;
    private List<RacingCar> racingCars;

    public RacingGame(int countOfCar, int countOfRound) {
        this.racingCars = new ArrayList<>();
        this.countOfRound = countOfRound;
        participate(countOfCar);
    }

    private void participate(int countOfCar) {
        for (int i = 0; i < countOfCar; i++) {
            racingCars.add(new RacingCar());
        }
    }
    
    public RacingRound racing(MoveRule moveRule) {
        return new RacintRound(racingCars.stream()
                .map(racingCar -> racingCar.run(moveRule))
                .collect(Collectors.toList()));
    }
    ...
}

(After)

아래처럼 List<RacingCar> 를 Wrapping 한 일급 컬렉션을 만들면 해당 클래스 내에서 레이싱에 참가한 자동차들의 상태와 행위를 관리할 수 있게 됩니다.

또한 List<RacingCar> 와 관련된 검증 로직을 RacingCars 한곳에만 작성하면 되기 때문에 코드의 중복을 줄일 수 있게 되었습니다. 이외에 일급 컬렉션은 컬렉션의 불변을 보장할 수 있습니다.

public class RacingCars {

    private final List<RacingCar> racingCars;

    public RacingCars(int countOfCar) {
        this.racingCars = new ArrayList<>();
        participate(countOfCar);
    }

    private List<RacingCar> participate(int countOfCar) {
        for (int i = 0; i < countOfCar; i++) {
            racingCars.add(new RacingCar());
        }
        return this.racingCars;
    }

    public RacingRound racing(MoveRule moveRule) {
        return new RacingRound(racingCars.stream()
                .map(racingCar -> racingCar.run(moveRule))
                .collect(Collectors.toList()));
    }

    public List<RacingCar> getParticipatingCars() {
        return Collections.unmodifiableList(racingCars);
    }
}

5. 한 줄에 점을 하나만 찍는다.

이 동작은 디미터 법칙과 연관이 있습니다. 여러 객체들과 협력을 통해 프로그램을 완성해나가는 객체지향 프로그래밍에서 객체들 사이의 협력 경로를 제한하면 결합도를 낮출 수 있다는 것입니다. 즉, 한줄에 점을 하나만 찍는 다는 것은 내가 모르는 객체까지 불러와서 사용을 하지 말라는 것입니다.

(Before)

아래 예제는 QnAService의 인스턴스 변수 questions에서 get() 메소드를 통해 멀리 있는 낯선 객체 Answer를 추가하는 코드입니다. 예제처럼 get() 메소드가 기차처럼 이어지는 형태가 디미터 법칙을 위반한 코드입니다.

Q.왜 멀리있는 객체에 메시지를 보내는 것을 피해야 할까요?

public class Question {
    private final List<Answer> answers;

    public Question(List<Answer> answers) {
        this.answers = answers;
    }

    public List<Answer> getAnswers() {
        return answers;
    }
}

public class QnaService {
    private final List<Question> questions;

    public QnaService(List<Question> questions) {
        this.questions = questions;
    

    public void addAnswer(int questionId, String text) {
        questions.get(questionId).getAnswers().add(new Answer(text));
    }
    ...
}

(After)

만약 Question 객체내의 List<Answer>가 아래처럼 일급 컬렉션을 사용하도록 수정된다면 QnAService 내의 addAnswer() 까지 그 수정이 필요하기 때문입니다. 따라서 아래처럼 Question 객체에 Answer를 추가하는 메소드를 만들고 메시지를 보내도록 리팩토링하면 변경에 좀 더 유연한 코드가 됩니다.

public class Question {
    private final Answers answers;

    public Question(Answers answers) {
        this.answers = answers;
    }

    public void addAnswer(Answer answer) {
        return answers.add(answer);
    }
}

...

public void addAnswer(int questionId, String text) {
        questions.get(questionId).addAnwer(new Anwer(text));
}

6. 줄여쓰지 않는다.

Q.왜 줄여쓰지 말라는 것일까? 라고 궁금증이 생길 수 있습니다. 줄여쓰려는 이유는 간단합니다. 이름이 길고 복잡하기 때문입니다. 하지만 과도한 축약은 코드의 가독성을 떨어뜨립니다. 동료 개발자를 위해서라도 한두 단어 정도 길이는 줄여쓰지 않는 것이 의미를 전달하는데 좋습니다.

또한 이름이 길어진다는 것은 어떤 변수나 메소드 또는 클래스에 많은 책임이 부여되어 있다는 것을 의미할 수 있습니다. 따라서 이번 동작은 이름이 길어지면 객체지향적인 설계를 고민해볼 필요가 있다는 의미도 담고 있습니다.

(Before)
public class WinningLotto {

    private LottoTicket wLottoTicket;
    private LottoNumber bNumber;

...


(After)
public class WinningLotto {

    private LottoTicket winningLottoTicket;
    private LottoNumber bonusBall;

...

7. 모든 엔티티(entity)를 작게 유지한다.

책에서는 50줄 이상 되는 클래스와 10개 파일 이상 되는 패키지는 없어야 한다고 이야기하고 있습니다.
공감은 하지만 현업에서 가능한가? 라고 했을 때 쉽게 답을 내기는 어려울 것 같습니다.

따라서 숫자에 집중하기보다는 엔티티를 작성할 때 되도록 한 가지 일만 하도록 설계하라는 의미로 받아들이면 좋을 것 같습니다. 클래스의 크기를 줄이기 위해 분리하다 보면 작은 역할을 하는 여러 클래스들이 생겨납니다.

그리고 이런 작은 클래스들이 모여 커다란 하나의 기능을 수행할 수 있습니다. 그 기능을 위해 클래스들을 모아 패키지로 구성하면 됩니다.

엔티티


8. 2개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.

인스턴스 변수를 기존 클래스에 계속 추가하게 되면 그 클래스의 응집도는 떨어지게 됩니다. 즉, 많은 인스턴스 변수를 가진 클래스는 단일 작업을 하기 힘들다는 것입니다. 이 원칙은 위에서 이야기한 3. 모든 원시값과 문자열을 포장한다. 4. 일급 컬렉션을 사용한다. 의 동작들과 연관이 있습니다.

(Before)

public class Member {
    private String firstName;
    private String LastName;
    private int age;
   
    ...
}

(After)

public class Member {
    private Name name;
    private Age age;
}

public class Name {
    private String firstName;
    private String lastName;
}

public class Age {
    private int age;
}

Member 클래스가 인스턴스 변수로 firstName, lastName, age를 모두 가지고 있는 형태에서 객체로 포장한 NameAge를 가지는 구조가 됨으로써 관리할 인스턴스 변수의 수가 줄어들었습니다. 각 객체에서 내에서 스스로 상태에 대한 검증이나 비즈니스 로직을 수행하기 때문에 응집도를 높일 수 있습니다.


9. 게터(getter)/세터(setter)/프로퍼티(property)를 쓰지 않는다.

이 원칙은 객체의 값을 외부에서 변경하기 보다는 객체에 메시지를 보내 객체내에서 스스로 상태를 관리하도록 하여 캡슐화를 지키자는 의미입니다.

(Before)

아래 예제는 질문 삭제 시 로그인한 유저가 질문 작성자인지 체크하는 로직입니다. 이 코드는 Question 객체의 상태를 getWriter() 메소드를 통해 외부로 가져와 처리를 하고 있습니다. 만약 작성자인지 판단하는 기준이 변경되었다면 이를 체크하는 모든 소스코드를 수정해야 합니다. 이는 중복된 코드를 만들게 된다는 것을 알 수 있습니다.

public class QnaService {

    public void deleteQuestion(LoginUser login) {
        Question question = findQuestionById(questionId);
				
        if (!question.getWriter().equals(loginUser)) {
            throw new CannotDeleteException("질문을 삭제할 권한이 없습니다.");
        }
   
        ...
    }
}

(After)

getter의 경우 개발을 하다 보면 표현 계층 쪽에 값을 전달하는 경우처럼 필요한 순간이 있기 때문에 제한적으로 사용하는 게 좋다는 생각이 듭니다.

setter 경우는 객체의 값을 외부에서 변경하도록 열어두어 캡슐화를 위반하는 것이기 때문에 사용을 지양해야 합니다.
필요한 순간이 온다면 setter보다는 change-, update- 같은 의미 있는 네이밍을 주면 동료 개발자가 로직을 파악하는데 더 좋을 것 같습니다.

public class QnaService {

    public void deleteQuestion(LoginUser login) {
        Question question = findQuestionById(questionId);
        question.checkIsOwner(loginUser);
        ...
    }
}
클래스 분리를 두려워하지 말자!

설계의 신이 아닌 이상 처음부터 완벽한 객체지향적인 설계가 나올 수는 없습니다.
위 원칙들을 의식적으로 지키면서 코드를 짜도록 연습한다면 미래의 수백 라인의 메소드에서 발생하는 오류를 찾느라 밤새는 일은 줄어들 것입니다.

*9가지 동작들을 좀 더 자세히 익히기를 원한다면 각 주제별로 다룬 내용들을 참고해보시길 바랍니다.

펭수_감사