우테코 프리코스 1주차 미션인 숫자 야구 게임을 구현하며 객체지향 생활 체조 원칙 9가지를 현재 내가 이해하고 있는 선에서 최대한 지키려고 노력했다. 동시에 TDD기반으로 구현하면서 테스트 케이스를 통과할 때 마다 리펙토링을 진행했다. 그 결과 위와 같은 클래스들이 생성됐다. 원칙의 순서대로 내가 느낀 점들을 중심으로 기술해봤다.
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의 객체 생성시 유효성을 검사하는 로직이다. 한 메서드에 오직 한 단계의 들여 쓰기를 통해 자연스럽게 메서드를 더 작게 나누게 돼서 가독성 좋은 코드를 짤 수 있게 됐다. 이 원칙은 바로 적용하기도 쉽고 그에 따른 이점도 쉽게 이해할 수 있었다.
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;
}
그 결과 내 생각과 달리 더 작은 단위로 기능을 나눌수 있었으며 코드의 가독성이 좋아졌다. 앞으로는 분기문을 사용하기 전에 기능분리의 여지가 있는지 한번 더 생각해보고 코드를 짜야겠다.
처음에는 이 원칙의 필요성을 전혀 예상할 수 없었고 관련된 자료를 찾아봐도 이해하기 어려었다. 일단 지키며 구현했다가 이점을 가장 크게 느낀 원칙 중 하나이다.
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;
}
그렇게 느낀 이유는 기존에 원시값 또는 문자열을 멤버필드로 가지고 있는 객체에서 유효성검사를 했었는데 위의 값들을 포장함으로써 유효성검사를 위임할 수 있다는 점이였다. 그 결과 기존 객체의 역할과 책임이 분산되고 볼륨이 작아지는 효과를 볼 수 있었다.
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();
}
}
협력 관계에 있는 객체들의 메서드들이 감춰져서 가독성이 좋아졌고 코드가 짧아졌다.
습관적으로 축약을 해서 의식적으로 중간중간 체크하며 진행했다.
이 말은 50줄 이상 되는 클래스와 파일이 10개 이상인 패키지는 없어야 한다는 뜻이다.
이 원칙은 이번 미션에 적용하지 못 했다. 그 이유는 숫자 야구 게임이 복잡하고 거대한 기능을 요구하지 않는다고 판단했기 때문이다.
객체의 볼륨을 줄이고 패키지를 활용해서 역할 분리를 적극적으로 하라는 의미 정도로 이해하고 넘어갔다.
처음에 오해하고 멤버필드를 1개로 제한하라는 말인 줄 알았는데 잘 살펴보니 포장하지 않은 원시값, 문자열의 사용을 지양하라는 말이었다. 이는 이미 3번째 원칙인 "모든 원시 값과 문자열을 포장한다."와 연결되는 맥락이기 때문에 금방 의도를 이해할 수 있었다.
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번째 원칙 "모든 원시 값과 문자열을 포장한다."에서 느낀 이점과 같이 유효성 검증을 포장된 객체 스스로 시킬 수 있다는 점을 금방 알 수 있었다.
// 설명에 필요없는 코드 일부는 생략했습니다.
public class Computer {
private Balls balls;
public Result matchBalls(String inputValue) {
return balls.compare(Balls.from(inputValue));
}
}
나는 이전까지 도메인 핵심 기능들을 구현할 때 각 객체에서 필요한 상태 값을 꺼낸 뒤 밖에서 로직을 짜왔다. 그러므로 이 규칙은 나로선 지킬 수 없었고 너무나 어려웠다. 일단은 이 원칙을 제외하고 나머지 원칙들을 지키려고 노력했다. 신기하게도 일급 컬렉션을 사용하고 문자열, 원시값을 포장하고 그 안에서 기능들을 잘게 나누는 과정을 거치면서 굳이 내가 객체에서 값을 꺼내 처리할 필요 없이 객체 내부에서 처리하면 된다는 점을 깨닫게 됐다. 그래서 객체의 상태 값 설정은 생성자를 통해 초기화하고 그 뒤에는 객체 자율적으로 상태 값을 처리하도록 설계할 수 있었다. 이번 미션을 하며 이 부분을 깨닫게 된 것이 가장 값진 성과라고 생각한다.
나름대로 열심히 원칙들을 지켜가면서 구현했는데 결과물을 보니 객체 간의 결합도가 높은 건 아닌지 내 코드가 과연 수정이 필요한 상황에서 유연할 수 있을지 의구심이 생겼다. 앞으로 공부하면서 더 검증해보고 보완하며 의구심을 해소할 계획이다.