1. 읽기 좋은 코드 작성하기 - 클린코드

해당 글은 NEXTSTEP의 초록스터디 학습 자료를 바탕으로 작성한 글입니다.

https://edu.nextstep.camp/s/0DWD8BIx

 

학습 테스트로 배우는 자바 기초

 

edu.nextstep.camp

 

클린코드에는 누구나 중요시하는 기준이 있을 것이다. 그러나 유지보수를 높이고 버그를 줄이기 위해 가독성 높은 코드를 작성하는 클린코드에 대해선 다들 동의할 것이다.

 

1. 객체에 역할을 부여함으로써 코드 상에서 의미를 파악할 수 있도록 한다.

@Test
    @DisplayName("코드를 통해 객체의 역할을 명확하게 드러내는 방법은 없을까")
    void 코드를_통해_객체의_역할을_명확하게_드러내는_방법은_없을까() {
        class Car {
        
            private int position;

            void forward() {
                if (position > 5) {
                    throw new IllegalStateException("최대 5까지만 움직일 수 있습니다.");
                }
                position += 1;
            }
        }
        final var car = new Car();
        car.forward();
        assertThat(car.position).isEqualTo(1);
    }

 

해당 코드는 Car라는 클래스가 forward()라는 메시지를 받았을 때, 최대 5까지만 움직일 수 있는 코드이다.

즉, Car가 전진할 때, 위치가 변하는 것이다. Car라는 자동차 클래스는 자신의 위치를 변경하는 역할을 갖고 있다.

하지만 우리가 생각했을 때, Car란 자동차 내부의 부품들이 동작하면서 움직이는 것이 주된 역할이며 위치가 변경되는 것은 그 다음에 따라오는 것이다. 따라서 위치 값을 조정하는 역할을 갖는 클래스를 분리시킨다면 Car의 역할이 코드 상에서 좀 더 명확하게 구분될 수 있다.

 

@Test
    @DisplayName("코드 자체로 설명이 되도록 코드를 작성하면 유지 및 관리의 비용이 줄어든다")
    void 코드_자체로_설명이_되도록_코드를_작성하면_유지_및_관리의_비용이_줄어든다() {
        class Position {
            private final int value;

            public Position() {
                this(0);
            }

            public Position(final int value) {
                if (value > 5) {
                    throw new IllegalStateException("최대 5까지만 움직일 수 있습니다.");
                }

                this.value = value;
            }

            Position forward() {
                return new Position(value + 1);
            }
        }

        class Car {
            private Position position = new Position();

            void forward() {
                position = position.forward();
            }
        }

        final var car = new Car();

        car.forward();
        assertThat(car.position).isEqualTo(new Position(1));
    }

 

개선된 코드를 보면 자동차는 위치 값을 조절하는 역할을 갖는 Position과 연관관계를 맺는다. 또한 위치 값을 조절하는 상태 값과 로직은 Car에 있는 것이 아니라 Position에 존재한다. 이처럼 클래스를 분리함으로써 각 클래스들의 역할을 보기 쉽게 구분할 수 있다.

 

실제 현업에서 사용되는 코드들은 이것과는 비교가 안될 정도로 복잡하다. 각 클래스들의 역할과 책임이 명확하지 않다면 코드 상에서 이들의 역할과 협력 관계를 파악하기 쉽지 않을 것이다. 물론 무작정 클래스로 나눠버리면 의미없는 클래스들이 많아져 유지보수성이 떨어질 수도 있다. 적절한 추상화 수준을 관리하는 것이 중요하다.

 

2. 일관된 코드 스타일을 유지한다.

@Test
    @DisplayName("일관된 코드 스타일을 가져간다")
    void 일관된_코드_스타일을_가져간다() {
        class Car {
        
            private String name;
            private int position;

            public String getName()
            {
                return name;
            }

            void Forward()  {
                position += 1;
            }

            public int position() {
                return position;
            }

            void minusPosition()
            {
                position--;
            }
        }

        final var car = new Car();

        car.Forward();
        assertThat(car.position()).isEqualTo(1);
    }

 

하나의 코드에 여러 명의 손길이 닿은 것이 느껴질 정도로 일관성이 없다.

  1. 각 메서드마다 여는 중괄호의 위치가 다 다르고 메서드 시그니처와의 공백 크기도 조금씩 다르다.
  2. 값을 변경하는 메서드와 단순히 반환하는 메서드들이 뒤죽박죽 섞여 있다. 각 메서드별 기능들을 한눈에 구분하기 어렵다.
  3. 메서드 이름에 일관성이 없다. getName()과 position()는 특정 값을 반환하는 메서드이다. 이들의 공통점이 한 눈에 보이지 않는다. Forward()와 minusPosition()도 상태를 변경하는 공통점을 가졌음에도 한눈에 알기 어렵다.

이럴 때는 코드를 작성하는 규칙을 정하는 것이 좋다. 특히, 팀 프로젝트의 경우, 개발자마다 작성하는 코드 스타일이 다를 수 있다. 사전에 소통하며 컨벤션을 잘 정립해놓는다면 이후 유지보수성을 높일 수 있을 것이다.

 

3. 하나의 메서드는 한 가지의 일만 하도록 한다.

class LottoGame {
            int calculatePrize(
                    final List<Integer> numbers,
                    final List<Integer> winningNumbers
            ) {
                for (int number : numbers) {
                    if (number < 1 || number > 45) {
                        throw new IllegalArgumentException("로또 번호는 1부터 45까지의 숫자여야 합니다.");
                    }
                }
                for (int winningNumber : winningNumbers) {
                    if (winningNumber < 1 || winningNumber > 45) {
                        throw new IllegalArgumentException("로또 번호는 1부터 45까지의 숫자여야 합니다.");
                    }
                }

                int count = 0;
                for (int number : numbers) {
                    for (int winningNumber : winningNumbers) {
                        if (number == winningNumber) {
                            count++;
                        }
                    }
                }

                return switch (count) {
                    case 6 -> 1_000_000_000;
                    case 5 -> 50_000_000;
                    case 4 -> 500_000;
                    case 3 -> 5_000;
                    default -> 0;
                };
            }
        }

 

해당 메서드(calculatePrize())는 여러 가지 일(값 검증, 결과 값 계산, 결과 값에 따른 분기 처리)을 수행하고 있다.

이렇게 되면 정확히 이 메서드가 어떠한 역할을 하고 있는지 딱 잘라서 말할 수도 없고, 이해하기에도 어렵다.

각각의 역할에 맞는 메서드 이름을 부여하여 분리시킨다면 코드를 이해하기에도 쉽고, 적절한 추상화를 통해 이후 재사용성도 높아진다.

 

class LottoGame {
            int calculatePrize(
                    final List<Integer> numbers,
                    final List<Integer> winningNumbers
            ) {
                validateNumbers(numbers);
                validateNumbers(winningNumbers);

                final int count = countMatchNumbers(numbers, winningNumbers);
                return calculatePrizeByCount(count);
            }

            private void validateNumbers(final List<Integer> lottoNumbers) {
                for (int number : lottoNumbers) {
                    validateNumber(number);
                }
                if (new HashSet<>(lottoNumbers).size() != 6) {
                    throw new IllegalArgumentException("로또 번호는 6개여야 합니다.");
                }
            }

            private int countMatchNumbers(
                    final List<Integer> numbers,
                    final List<Integer> winningNumbers
            ) {
                int count = 0;
                for (int number : numbers) {
                    for (int winningNumber : winningNumbers) {
                        if (number == winningNumber) {
                            count++;
                        }
                    }
                }

                return count;
            }

            private int calculatePrizeByCount(final int count) {
                return switch (count) {
                    case 6 -> 1_000_000_000;
                    case 5 -> 50_000_000;
                    case 4 -> 500_000;
                    case 3 -> 5_000;
                    default -> 0;
                };
            }
        }

 

메서드가 한 가지 일만 수행하도록 코드를 변경하였다. calculatePrize()를 확인했을 때, 분리된 메서드의 이름을 통해 해당 로직의 흐름을 쉽게 파악할 수 있다. 만약, 검증과 관련된 로직이 궁금하다면 validate~ 메서드만 확인하면 된다. 이는 코드 리뷰어의 리뷰 비용을 낮추는데 있어서 효과적인 방법이다.

 

4. 객체의 역할을 명확하게 드러내어 의도를 분명하게 전달한다.

바로 직전 코드를 봤을 때, LottoGame이라는 객체가 어떠한 역할을 하고 있는지 하나의 문장으로 정리할 수 있을까?

필자가 생각했을 때, LottoGame은 다음과 같은 역할을 갖고 있다.

  1. 로또 번호의 범위가 1부터 45까지인지 검증한다.
  2. 로또 번호의 총 개수가 6개인지 검증한다.
  3. 로또 번호와 당첨번호를 비교하여 일치하는 개수를 계산한다.
  4. 일치하는 개수에 따라 상금을 반환한다.

LottoGame이라는 이름을 들었을 때는, 로또 게임 실행을 담당하는 역할로 인식할 수 있다. 하지만 실제 코드 상에서는 로또 번호의 검증부터 로또 당첨개수 계산 등 여러 역할을 수행하고 있다.

 

코드의 일부분을 예시로 사용하겠다.

enum LottoRank {
            FIRST(6, 1_000_000_000),
            SECOND(5, 50_000_000),
            THIRD(4, 500_000),
            FOURTH(3, 5_000),
            NONE(0, 0);

            private final int count;
            private final int prize;

            LottoRank(final int count, final int prize) {
                this.count = count;
                this.prize = prize;
            }

            public static LottoRank of(final int count) {
                return Arrays.stream(values())
                        .filter(prize -> prize.count == count)
                        .findFirst()
                        .orElse(NONE);
            }

            public int getPrize() {
                return prize;
            }
        }

 

이는 로또의 일치 개수에 따른 상금을 갖고 있는 Enum 클래스이다. LottoRank라는 이름을 통해 로또의 결과에 해당하는 등수와 관련된 로직을 수행한다는걸 쉽게 파악할 수 있다. 이런 방식으로 객체의 역할을 명확하게 드러낼 수 있도록 코드를 작성한다면 마찬가지로 코드 리뷰어가 의도를 쉽게 파악할 수 있다.