객체지향 생활 체조 원칙 (feat. 숫자 야구 게임)

0_0_yoon·2021년 11월 26일
1

TDD

목록 보기
2/3

📌 객체지향 생활 체조 원칙

우테코 프리코스 1주차 미션인 숫자 야구 게임을 구현하며 객체지향 생활 체조 원칙 9가지를 현재 내가 이해하고 있는 선에서 최대한 지키려고 노력했다. 동시에 TDD기반으로 구현하면서 테스트 케이스를 통과할 때 마다 리펙토링을 진행했다. 그 결과 위와 같은 클래스들이 생성됐다. 원칙의 순서대로 내가 느낀 점들을 중심으로 기술해봤다.

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

private static void checkValidation(String value) {
    checkLength(value);
    checkDuplicate(value);
}

private static void checkDuplicate(String value) {
    if (hasDuplicate(value)) {
        throw new IllegalArgumentException("중복된 숫자가 없도록 입력해주세요.");
    }
}

private static void checkLength(String value) {
    if (!isLimitedLength(value)) {
        throw new IllegalArgumentException("3자리 숫자를 입력하세요.");
    }
}

private static boolean isLimitedLength(String value) {
    return value.length() == LIMITED_LENGTH;
}

private static boolean hasDuplicate(String value) {
    return distinctCount(value) != LIMITED_LENGTH;
}

Balls의 객체 생성시 유효성을 검사하는 로직이다. 한 메서드에 오직 한 단계의 들여 쓰기를 통해 자연스럽게 메서드를 더 작게 나누게 돼서 가독성 좋은 코드를 짤 수 있게 됐다. 이 원칙은 바로 적용하기도 쉽고 그에 따른 이점도 쉽게 이해할 수 있었다.

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

public Score compare(Ball ball) {
    if (ball.isStrike(index, no)) {
        return Score.STRIKE;
    }
    if (ball.isBall(index, no)) {
        return Score.BALL;
    }
    return Score.NOTHING;
}

private boolean isStrike(Index index, No no) {
    return this.index.equals(index) && this.no.equals(no);
}

private boolean isBall(Index index, No no) {
    return !this.index.equals(index) && this.no.equals(no);
}

원칙중에 가장 지키기 쉬웠지만 존재 이유가 이해가지 않았다. 그저 조금 가독성이 좋아질 뿐 이라고 가볍게 생각하고 있다가 tecoble에서 관련된 포스팅을 읽고 난 뒤 이 원칙이 분기문을 최소화 하라는 의미로 해석할 수 있다고 생각했다.

public String report() {
    StringBuilder sb = new StringBuilder();
    if (hasBall()) {
        sb.append(getBallCount() + BALL_STRING + SPACE);
    }
    if (hasStrike()) {
        sb.append(getStrikeCount() + STRIKE_STRING);
    }
    if (isNothing()) {
        sb.append(NOTHING_STRING);
    }
    return sb.toString().trim();
}

위 처럼 분기문을 가지고 있는 메서드를 아래와 같이 리펙토링했다.

public String report() {
    StringBuilder report = new StringBuilder();
    report.append(reportBall());
    report.append(reportStrike());
    report.append(reportNothing());
    return report.toString().trim();
}

private String reportBall() {
    if (hasBall()) {
        return getBallCount() + BALL_STRING + SPACE;
    }
    return EMPTY_STRING;
 }

private String reportStrike() {
    if (hasStrike()) {
        return getStrikeCount() + STRIKE_STRING;
    }
    return EMPTY_STRING;
 }

private String reportNothing() {
    if (isNothing()) {
        return NOTHING_STRING;
    }
    return EMPTY_STRING;
}

그 결과 내 생각과 달리 더 작은 단위로 기능을 나눌수 있었으며 코드의 가독성이 좋아졌다. 앞으로는 분기문을 사용하기 전에 기능분리의 여지가 있는지 한번 더 생각해보고 코드를 짜야겠다.

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

처음에는 이 원칙의 필요성을 전혀 예상할 수 없었고 관련된 자료를 찾아봐도 이해하기 어려었다. 일단 지키며 구현했다가 이점을 가장 크게 느낀 원칙 중 하나이다.

public class Ball {
    private final Index index;
    private final No no;
}

public class No {
    public static final int MIN_NO = 1;
    public static final int MAX_NO = 9;

    private final int no;

    public No(int no) {
        checkValid(no);
        this.no = no;
    }

private void checkValid(int no) {
    if (!isValidNo(no)) {
        throw new IllegalArgumentException("1부터 9 사이의 숫자를 입력하세요.");
    }
}
private boolean isValidNo(int no) {
    return no >= MIN_NO && no <= MAX_NO;
}

그렇게 느낀 이유는 기존에 원시값 또는 문자열을 멤버필드로 가지고 있는 객체에서 유효성검사를 했었는데 위의 값들을 포장함으로써 유효성검사를 위임할 수 있다는 점이였다. 그 결과 기존 객체의 역할과 책임이 분산되고 볼륨이 작아지는 효과를 볼 수 있었다.

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

public class Result {
    private final List<Scores> result;
    
    int getStrikeCount() {
        return result.stream().mapToInt(Scores::getStrikeCount).sum();
    }

    int getBallCount() {
        return result.stream().mapToInt(Scores::getBallCount).sum();
    }
}

public class Scores {
    private final List<Score> Scores;
    
    public int getStrikeCount() {
        return (int)scores.stream().filter(Score::isStrike).count();
    }

    public int getBallCount() {
        return (int)scores.stream().filter(Score::isBall).count();
    }
}

협력 관계에 있는 객체들의 메서드들이 감춰져서 가독성이 좋아졌고 코드가 짧아졌다.

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

습관적으로 축약을 해서 의식적으로 중간중간 체크하며 진행했다.

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

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

이 원칙은 이번 미션에 적용하지 못 했다. 그 이유는 숫자 야구 게임이 복잡하고 거대한 기능을 요구하지 않는다고 판단했기 때문이다.
객체의 볼륨을 줄이고 패키지를 활용해서 역할 분리를 적극적으로 하라는 의미 정도로 이해하고 넘어갔다.

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

처음에 오해하고 멤버필드를 1개로 제한하라는 말인 줄 알았는데 잘 살펴보니 포장하지 않은 원시값, 문자열의 사용을 지양하라는 말이었다. 이는 이미 3번째 원칙인 "모든 원시 값과 문자열을 포장한다."와 연결되는 맥락이기 때문에 금방 의도를 이해할 수 있었다.

8. 일급 컬렉션을 쓴다.

public class Balls {
    public static final int ZERO = 0;
    public static final int LIMITED_LENGTH = 3;
    public static final String EMPTY_STRING = "";

    private final List<Ball> balls;
    
    private Balls(String value) {
        List<Integer> nos = FormatUtil.convert(value);
        this.balls = IntStream.range(ZERO, LIMITED_LENGTH)
			.mapToObj(i -> Ball.of(i, nos.get(i))).collect(Collectors.toList());
    }

    public static Balls from(String value) {
        checkValidation(value);
        return new Balls(value);
    }

    public Result compare(Balls comBalls) {
        return Result.from(balls.stream().map(comBalls::compare).collect(Collectors.toList()));
    }

    private Scores compare(Ball comBall) {
        return Scores.from(balls.stream().map(comBall::compare).collect(Collectors.toList()));
    }
}

이 원칙은 나에게 객체지향 프로그래밍이 무엇인지에 대해 느낄 수 있는 기회를 준것 같다. 일급 컬렉션으로 만듦으로써 도메인의 핵심 기능을 객체 내부에서 멤버필드의 상태 정보를 가지고 스스로 해결시킬 수 있다는 점을 깨달았다. 또한 3번째 원칙 "모든 원시 값과 문자열을 포장한다."에서 느낀 이점과 같이 유효성 검증을 포장된 객체 스스로 시킬 수 있다는 점을 금방 알 수 있었다.

9. getter/setter/property를 쓰지 않는다.

// 설명에 필요없는 코드 일부는 생략했습니다.
public class Computer {
    private Balls balls;
                    
    public Result matchBalls(String inputValue) {
        return balls.compare(Balls.from(inputValue));
    }
}

나는 이전까지 도메인 핵심 기능들을 구현할 때 각 객체에서 필요한 상태 값을 꺼낸 뒤 밖에서 로직을 짜왔다. 그러므로 이 규칙은 나로선 지킬 수 없었고 너무나 어려웠다. 일단은 이 원칙을 제외하고 나머지 원칙들을 지키려고 노력했다. 신기하게도 일급 컬렉션을 사용하고 문자열, 원시값을 포장하고 그 안에서 기능들을 잘게 나누는 과정을 거치면서 굳이 내가 객체에서 값을 꺼내 처리할 필요 없이 객체 내부에서 처리하면 된다는 점을 깨닫게 됐다. 그래서 객체의 상태 값 설정은 생성자를 통해 초기화하고 그 뒤에는 객체 자율적으로 상태 값을 처리하도록 설계할 수 있었다. 이번 미션을 하며 이 부분을 깨닫게 된 것이 가장 값진 성과라고 생각한다.

Message to me

  1. 분기문이 있다면 리펙토링을 한번 고민해보자
  2. 의식적으로 축약을 사용했는지 체크하자

나름대로 열심히 원칙들을 지켜가면서 구현했는데 결과물을 보니 객체 간의 결합도가 높은 건 아닌지 내 코드가 과연 수정이 필요한 상황에서 유연할 수 있을지 의구심이 생겼다. 앞으로 공부하면서 더 검증해보고 보완하며 의구심을 해소할 계획이다.

profile
꾸준하게 쌓아가자

0개의 댓글