[우테코 6기 도전기] 프리코스 1주차 후기

Hyunjoon Choi·2023년 10월 25일
0

우테코 6기 도전기

목록 보기
4/4
post-thumbnail

예상치 못한 숫자 야구..

이전 기수의 문제를 봤을 때, 간단한 문제였던 온보딩이었기에 이번에도 1주차에는 비슷한 문제가 나올 것으로 기대했었다. 하지만 자동차 경주 미션과 같이 실제 미션이 바로 1주차에 나와서 처음에는 약간 당황스러웠다.

그럼에도 다행인 점은, 숫자 야구의 전반적인 룰을 이미 백준 문제에서 풀었기에 어떤 식으로 볼과 스트라이크를 계산하면 되는지 이미 파악을 했다는 점이다. 따라서 구현 시간에도 그렇게 오래 걸리지는 않은 것 같다. 다만 리팩터링을 많이 해야 할 뿐...

본 글에서는 프리코스 기간 동안 어떤 것들을 느꼈고 배웠는지를 작성하려고 한다.

1. 클린 코드 책 내용만이 무조건 정답이라고 생각하지 말자

다소 과감한 말일 수도 있지만, 애초에 클린 코드에도 다음과 같은 글이 작성되어 있다.

실제로도 이 책에서 주장하는 기법 다수는 논쟁의 여지가 있다. 여러분도 모든 기법에 동의하지 않으리라. 어떤 기법은 격렬히 반대하리라. 그래도 괜찮다. 우리 생각이 무조건 옳다고 주장할 의도는 없으니까. 하지만 다른 한편으로 이 책은 우리가 오랫동안 고민하고 숙고한 교훈과 기법을 권고한다. - 클린 코드, 17p

진정 성장하기 위해서는 클린 코드를 성서처럼 오점이 없다고 생각하며 무조건적으로 받아들이기보다는, 꼭 이게 정답인 사고일까라고 되뇌어보고 자신의 판단 하에 적용을 취사선택하는 방향이 더 나을 것 같다.

생각이 달랐던 점은 아래 부분들이었다.

1-1. 지나친 함수 분할은 오히려 가독성을 해친다

클린 코드에서는 다음과 같은 경우에서도 함수를 분할하라고 한다.

private String render(boolean isSuite) throws Exception {
    this.isSuite = isSuite;
    if (isTestPage())
    includeSetupAndTeardownPages();
    return pageData.getHtml();
}

private boolean isTestPage() throws Exception {
    return pageData.hasAttribute("Test");
}

물론 이해는 된다. 각 함수의 추상화 수준을 맞추기 위해서다.

이와 비슷한 방식으로, 나는 이번 미션을 하면서 처음에는 아래와 같이 작성했었다.

private void play() {
    int computerNumber = RandomNumber.pickNumber();
    while (true) {
        ...
        int strike = countGameStrike();
        ...
    }
    printGameEnd();
    askResumeInput();
}

private int countGameStrike() {
    return umpire.countStrike();
}

private static void printGameEnd() {
    EndView.end();
}

private static void askResumeInput() {
    AskController.askResumeInput();
}

하지만 이런 방식으로 하니, 오히려 클래스에 너무 많은 함수가 존재함에 따라 함수가 필요 이상으로 많아지는 것 같은 느낌이 들었다.

따라서 이렇게 단순히 다른 클래스에게 메서드를 요청하는 것은, 함수로 감싸지 않아도 될 것 같다는 생각이 들었다. 이후 앞으로는 이러한 방식으로 작성하려고 한다.

private void play() {
    int computerNumber = RandomNumber.pickNumber();
    while (true) {
        ...
        int strike = umpire.countStrike();
        ...
    }
    EndView.end();
    AskController.askResumeInput();
}

정확히 말하면, 다른 객체에게 메서드를 실행해달라고 요청하는 부분은 그 메서드의 이름으로 충분히 유추 가능하다면 굳이 별도의 함수로 감싸지 않아도 될 것 같다.

단, 함수화를 통해 쉽게 코드 표현을 줄일 수 있다면 그 때는 함수화를 진행해도 될 것 같다. 아래 클린 코드 예시처럼 말이다.

// includeSetupAndTeardownPages 함수를 통해 아래 네 개의 작업이 단축된다
private void includeSetupAndTeardownPages() throws Exception {
    includeSetupPages();
    includePageContent();
    includeTeardownPages();
    updatePageContent();
}

또 다른 예는 다음이다.

// BEFORE
public static void assertNumberValue(final String input) {
    if (!isInputValidPositiveNumber(input)) {
        throw new IllegalArgumentException();
    }
}

private static boolean isInputInvalidPositiveNumber(final String input) {
    return input.matches("^[1-9]+$");
}

// AFTER
public static void assertNumberValue(final String input) {
    if (!input.matches("^[1-9]+$")) {
        throw new IllegalArgumentException();
    }
}

하지만 아래 경우는 분리할 필요가 있어 보인다. 함수 호출을 넘어 특정 작업을 실행할 경우다.

public static int pickNumber() {
    StringBuilder numberBuilder = new StringBuilder();

    while (!isBuilderEnoughPicked(numberBuilder)) {
        saveNewNumber(numberBuilder);
    }
    ...
}

// 값이 같은지 "비교" 행위를 한다.
private static boolean isBuilderEnoughPicked(final StringBuilder numberBuilder) {
    return numberBuilder.length() == PLAY_NUMBER_DIGIT.getValue();
}

1-2. 지나친 클래스 분할을 하지 말자

클린 코드를 잘못 해석해서인지, 여러 converter 들을 만들곤 했었다. 형 변환과 같은 것들조차 하나의 기능으로 바라본 것이다.

public class StringInputConverter {

    public static String[] toArray(final String input) {
        return input.split("");
    }
}

public class IntegerInputConverter {

    public static toString(final int number) {
        return String.valueOf(number);
    }
}

이러다보니 아래와 같은 괴상한 코드가 나오기도 했다.

public class BallRule implements GameRule {

    @Override
    public int calculate(final int hitter, final int pitcher) {
        String[] origin = StringInputConverter.toArray(IntegerInputConverter.toString(hitter));
        String[] test = StringInputConverter.toArray(IntegerInputConverter.toString(pitcher));
        
        boolean[] match = recordMatchedPositions(origin, test);
        ...
    }
}

도대체 이것을 따로 만들어놓은 게 무슨 의미가 있나? 어떤 이점이 있나? (그리고 쓸데없이 의존성이 늘어난다)

따지고 보면 String을 String[] 배열로 만드는 것은 String 객체에게 기본적으로 요청할 수 있는 것이며, int를 String으로 만드는 것 또한 가능하다. 따라서, 이는 이상하게 개발한 것이므로 지양하도록 해야겠다고 생각했다.

public class BallRule implements GameRule {

    @Override
    public int calculate(final int hitter, final int pitcher) {
        // 표현이 조금 비효율적일수도 있다. 말하고 싶은 것은 기본 메서드로 절약할 수 있는 것들을 절약하자는 것이다.
        String hitterValue = String.valueOf(hitter);
        String pitcherValue = String.valueOf(pitcher);

        String[] hitterNumbers = hitterValue.split("");
        String[] pitcherNumbers = pitcherValue.split("");
        
        boolean[] match = recordMatchedPositions(hitterNumbers, pitcherNumbers);
        ...
    }
}

2. 원시값 포장을 제대로 써먹자

2-1. 지나친 책임 분할

이전에 미션을 연습삼아 미리 해 보며 원시값 포장에 대해 알아봤었다.

원시값 포장의 장점 중 하나는 일반적인 원시값으로 썼을 때 보다 예외처리를 해당 객체에서 해 주어 사용되는 클래스 (예: Name과 User가 있다면 User)에서는 원시값에 대한 예외처리를 해 주지 않아도 된다는 것이다.

그런데 나는 책임을 분할해야 한다는 것에 집착해서 처음에는 Validator를 만들곤 했었다.

public class NumberValidator {

    public static void assertInputNumberWithLength(final String input, final int length) {
        assertNumberValue(input);
        aasertDigitLength(input, length);
        assertEachNumberUnique(input);
    }
    
    private static void assertNumberValue(final String input) {
        ...
    }
    ...
}

실제 사용은 이렇다. 이 때는 원시값을 포장하지 않았으며 (만들었어도 검증을 Validator에게 맡겼을 것 같다.), 직접적으로 입력을 받았을 때 매번 검증이 일어나도록 설정했다.

public class ConsoleInputView implements InputView {

    @Override
    public int readPlayNumber() {
        String number = Console.readLine();
        NumberValidator.assertInputNumberWithLength(number, PLAY_NUMBER_DIGIT.getValue());
        return Integer.parseInt(number);
    }
}

하지만 PlayNumber를 원시값 포장 객체로 만들고, 예외처리를 PlayNumber에서 직접 하도록 바꿨다.

public class PlayNumber {

    private final int number;
    
    private PlayNumber(final String number) {
        validateNumber(number);
        this.number = Integer.parseInt(number);
    }
}

NumberValidator를 사용하지 않고 직접 원시값 포장 및 해당 포장 객체에서 예외처리를 하면 응집도가 올라간다는 장점이 있다고도 느꼈다.

오브젝트 (Object) 책에도 다음과 같은 내용이 있다.

밀접하게 연관된 작업만을 수행하고 연관성 없는 작업은 다른 객체에게 위임하는 객체를 가리켜 응집도(cohension)가 높다고 말한다. 자신의 데이터를 스스로 처리하는 자율적인 객체를 만들면 결합도를 낮출 수 있을뿐더러 응집도를 높일 수 있다.

객체의 응집도를 높이기 위해서는 객체 스스로 자신의 데이터를 책임져야 한다. ... 외부의 간섭을 최대한 배제하고 메시지를 통해서만 협력하는 자율적인 객체들의 공동체를 만드는 것이 훌륭한 객체지향 설계를 얻을 수 있는 지름길인 것이다. - 오브젝트 (Object), 26p

만약 굳이 NumberValidator를 만들었다면 PlayNumber 객체 입장에서는 자신의 데이터에 관해 다른 객체가 관여하게 되는 것이다.

PlayNumber에서 예외 처리를 하게 하니, 테스트 코드도 더 쉽게 작성할 수 있었다. (만약 예외 처리를 NumberValidator에서 했다면 각 코드 아래에 NumberValidator 메서드 호출을 매번 했을 것이다.)

@Test
void 플레이_숫자는_세자리여야만_한다() {
    Assertions.assertThrows(IllegalArgumentException.class, () -> {
        PlayNumber playNumber = PlayNumber.from("1234");
    });
}

@Test
void 플레이_숫자는_숫자여야만_한다() {
    Assertions.assertThrows(IllegalArgumentException.class, () -> {
        PlayNumber playNumber = PlayNumber.from("12a");
    });
}

@Test
void 플레이_숫자는_중복되면_안된다() {
    Assertions.assertThrows(IllegalArgumentException.class, () -> {
        PlayNumber playNumber = PlayNumber.from("111");
    });
}

@Test
void 세자리_모두_정상이라면_문제없이_생성된다() {
    PlayNumber playNumber = PlayNumber.from("123");
}

결론: 원시값 포장은 불변 객체이면서 해당 원시값으로 예외처리 등 뭔가 작업이 필요할 때 만들도록 하며, 이 때 예외처리에 대해서는 원시값 포장 객체 스스로 수행할 수 있도록 하자!

2-2. 원시값 포장을 할 때는 VO처럼 내부 속성에 불변을 고려하자

사실 원시값 포장 객체는 내부의 속성이 반드시 불변일 필요는 없다고 한다. VO야 값을 표현하는 데 집중하기 때문에 불변이어야 하고 Equals & hashcode를 재정의하는 등의 조건이 있었지만, 원시값 포장은 책임을 할당하는 것에 그치기 때문이다.

그래서 처음에는 ResumeNumber를 다음과 같이 작성했었다.

public class ResumeNumber {

    private static final int RESUME_ANSWER_LENGTH = 1;
    
    private int resumeNumber;
    
    private ResumeNumber(final int number) {
        this.resumeNumber = number;
    }

    public static ResumeNumber createDefault() {
        return new ResumeNumber(PLAY_WANT.getValue());
    }
    
    public void updateNumber(final String answer) {
        validateAnswer(answer);
        this.resumeNumber = Integer.parseInt(answer);
    }
}

ResumeNumber 같은 경우 처음에 생성됐을 때 PLAY_WANT 값으로 resumeNumber가 저장되기에 생성자 코드에 validate가 없었지만, PlayNumber와 같이 생성자 시점에 validate를 하는 경우도 있다.

이 때 updateNumber와 같은 속성 변경 메서드가 있다면, 그럴 때 마다 또 validate를 해야 하는 부담이 있다. 때문에 생성자 시점에만 검증을 하도록 아예 속성값을 불변으로 설정하는 게 나아보인다.

원시값 포장 객체 생성 방법 중 VO가 있고, VO 또한 원시값만 포장하는 경우가 아닌 경우도 있음을 인지하자. (도움된 블로그)

그래서 이 ResumeNumber를 업데이트하며 사용해왔던 GameController도 바뀌었다.

// BEFORE
public class GameController {

    private final ResumeNumber resumeNumber;
    
    ...
    
    String resumeAnswer = inputView.readMoreAnswer();
    resumeNumber.updateNumber(resumeAnswer);
}
// AFTER
public class GameController {

    private ResumeNumber resumeNumber;
    
    ...
    
    String resumeAnswer = inputView.readMoreAnswer();
    resumeNumber = ResumeNumber.from(resumeAnswer);
}

다만 때에 따라서는 불변으로 만들지 않는 것이 더 좋은 때가 있을 것 같기도 하다. 일단은 불변을 적용해본 뒤, 불변을 해제해야 할 지 그대로 둘 지 비교해봐야 정확히 구분할 수 있을 듯 하다.

3. 공용 상수는 전용 클래스에 담기보다는 Enum으로 쓰자

객체에 private하게 사용되는 상수는 제외하고, 여러 곳에서 사용되는 상수들은 상수 전용 클래스 (예: Constants 등)에 담기보다는 Enum을 쓰는 것이 더 좋음을 알게 되었다. 이는 이펙티브 자바에도 있는 내용이다.

  • 정수 상수는 문자열로 출력해도 의미가 아닌 단지 숫자로만 보인다.
  • 같은 정수 열거 그룹에 속한 모든 상수를 순회할 수도 없다. 갯수 또한 파악할 수 없다.
  • 열거 타입으로 하면 타입 안전성이 보장된다. 특정 열거 타입의 인스턴스를 의존하는 메서드는 그와 다른 열거 타입의 인스턴스를 받았을 시 컴파일 오류를 던진다.
  • 열거 타입으로 하면 임의의 메서드나 필드를 추가할 수 있으며, 임의의 인터페이스를 구현할수도 있다.

따라서 이전에는 아래와 같이 사용했었지만,

public class Constants {

    public static final int PLAY_NUMBER_DIGIT = 3;
    public static final int RESTART = 1;
    public static final int END = 2;
}

이후 다음과 같이 바꾸었다.

public enum Constant {

    PLAY_NUMBER_DIGIT(3),
    PLAY_WANT(1),
    END_WANT(2);

    private final int value;

    Constant(final int value) {
        this.value = value;
    }

    public int getValue() {
        return this.value;
    }
}

Enum에 대해서는 우아한형제들 기술블로그 등을 보면서 더 익혀야 할 것 같다.

4. 다른 사람이 쉽게 이해할 수 있는 이름을 쓰자.

이름 짓기.. 진짜 어려운 것 같다.

처음에는 숫자 야구와 관련하여 야구다보니, 심판 객체 이름을 야구 + 심판을 나타내는 Umpire를 썼었다.

하지만 Referee는 들어봤어도, Umpire에 대해서는 처음 들었다. 그럼에도 사전적 의미에 더 집중하기 위해 Umpire로 사용했었다.

블로그를 더 찾아보니, 다음과 같이 적혀있었다.

한국에서는 referee와 umpire를 구분하지 않고 심판이라는 뜻으로 쓰지만, 사실 어떤 스포츠인지에 따라 표현이 달라집니다. 대체로 Referee는 경기장 안에 들어가 있는 심판(결정자)을 뜻하고, umpire는 경기장 밖에 있는 심판(중재자)을 뜻합니다. 하지만 크리켓처럼 referee와 umpire를 다 쓰는 스포츠도 있으니 이 기준을 100% 믿어선 안 됩니다. 궁금할 때마다 검색해 보는 편이 좋죠. 대표적으로 referee를 쓰는 스포츠로는 축구가 있고, umpire를 쓰는 스포츠로는 야구가 있습니다.

아무래도 대부분의 경우에 Referee를 더 많이 쓰니, 만약 다른 사람이 내 코드를 리뷰한다면 쉽게 의미가 전달될 수 있도록 Umpire보다는 Referee를 쓰는 게 낫지 않을까? 라는 생각이 들어 바꾸게 되었다.

정작 클린 코드 내용을 정리한 글에서는 아래와 같이 작성했었다 🥲

이 생각을 바꾸게 되었다. 프로젝트를 함께 개발하는 사람들이라면 충분한 이해를 갖추었을 확률이 높겠지만, 만약 비슷한 의미를 가진 단어가 더 보편적으로 알려져 있다면 의미 전달을 더 쉽게 할 수 있을 것 같다.

5. 커밋 컨벤션 준수

커밋 컨벤션을 정리하면서 그동안과 다르게 의식적으로 커밋 컨벤션을 지키려는 노력을 할 수 있었다.

뿐만 아니라 무조건적으로 따르기보다는, 내 생각에 맞춰 일부 수정한 부분이 있기도 하다. (자연스럽게 읽힐 수 있도록 하는 것 등)

물론 팀 컨벤션이 있다면 그것을 지키는 게 최우선이지만, 일단은 많은 사람들이 보편적으로 알고 있는 커밋 컨벤션이 습관에 배이게 된 것 같아 좋다.

결론

공통적으로 느낀 것은, 진짜 미션을 스스로 하니 내 주관에 맞게 이 지식이 왜 그런지 탐구해보는 훈련을 할 수 있었다는 것이다. 1주차만 해도 이렇게 배우고 느낀 게 많았는데, 2~4주차에는 또 어떤 것들을 배울 수 있을까? 그런 점에 있어서도 기대가 된다.

한편으로는 새로운 미션이 주어졌을 때 또 똑같은 실수를 초반에 하게 되는 건 아닌지 걱정이 들기도 한다. 그럴 때 마다 이 때 느끼고 배운 점들을 복기해보고, 같은 실수를 반복하지 않도록 더 점검해봐야겠다.

향후 있을 공통 피드백에서 내가 어떤 점을 놓쳤었는지 점검해보기도 해 보자!

배운 점 요약

  • 지나친 함수화는 오히려 가독성을 저하시킨다. 이렇듯, 어떤 개념을 공부하든지간에 뚜렷한 주관을 가지도록 하자.
  • 형 변환 등 기본적으로 실행할 수 있는 것들은 별도의 클래스를 만들지 말자.
  • 원시값을 저장할 때 예외처리 등의 책임이 주어질 시 원시값 포장을 적극 활용하자.
  • 공통적으로 사용할 상수가 있다면 Enum으로 관리하자.
  • 다른 사람이 쉽게 이해할 수 있는 이름을 사용하자. (향후 있을 코드 리뷰 등에 대해서도 쉽게 파악하기 위해!)
  • 커밋 컨벤션 의식적으로 지키자!

최종 제출

이곳에서 확인하실 수 있습니다.

profile
개발을 좋아하는 워커홀릭

0개의 댓글