Claire's Study Note

[TDD, 클린 코드 with Java] 생성자 활용

by Hi.Claire
반응형

🐣 TDD, 클린 코드 with Java 19기 (박재성, 넥스트스텝)

 

생성자 활용

엘레강트 선인장

세 번째 라이브 강의의 마지막 내용이다.

마지막 내용은 <엘레강트 오브젝트> 책의 '생성자 하나를 주 생성자로 만드세요'와 관련한 내용이다.

그동안 생성자를 활용하는 것에 대해 많이 다루었는데 이번에는 좀더 다양한 예제를 통해 연습해본다.

 

<엘레강트 오브젝트>

생성자 하나를 주 생성자로 만드세요.

생성자를 적극적으로 사용하자.

생각보다 많은 사람들이 생성자를 잘 활용하지 못한다.

기본 생성자만 사용하면서 Setter 메서드를 남용한다거나, 정적 팩토리 메서드를 남용하는 경우가 많다.

이번 과정에서는 박재성님을 믿고 생성자를 적극적으로 사용해보자!

 

생성자 하나를 주 생성자로 만드세요.

  • 주(primary) 생성자프로퍼티를 초기화하는 일, 이런 초기화를 주 생성자만 담당하도록 한다.
  • 부(secondary) 생성자주 생성자를 호출하도록 한다.

 

Car.java

public class Car {

    private final String name;
    private RacingNumber position;

    public Car(final String name) {
        this(name, 0);
    }

    public Car(final String name, final int position) {
        this(name, new RacingNumber(position));
    }

    // 주생성자
    public Car(final String name, final RacingNumber position) {
        if (name.isBlank()) {
            throw new IllegalArgumentException("자동차 이름의 값이 존재해야 합니다.");
        }
        this.name = name;
        this.position = position;
    }
    
    //생략
}

주 생성자만 인스턴스 변수와 동일한 타입의 매개변수를 가지고, 주 생성자만 인스턴스 변수에 값을 할당한다.

나머지 생성자들은 부 생성자인데, 직접 인스턴스 변수에 값을 할당하지는 않고 주 생성자나 부 생성자를 호출하기만 한다.

나머지 부 생성자들은 결국 주 생성자를 호출하게 되므로 주 생성자에만 유효성 체크 로직을 넣어두면 돼서 중복 코드를 방지할 수 있다.

그리고 주 생성자를 가장 마지막에 쓰는 것이 컨벤션이다.

수많은 생성자들 중에서 어느 것이 주 생성자인지 일일이 찾을 필요 없이 맨 마지막 생성자를 찾아가면 그것이 주 생성자이기 때문에 유지보수하기가 더 편하다.

 

<엘레강트 오브젝트> 중에서

클래스를 잘 설계한다면 클래스는 많은 수의 생성자와 적은 수의 메서드를 포함할 것이다.

2, 3개의 메서드와 5~10개의 생성자를 포함하는 것이 적당하다.

이런 기준을 두는 핵심은 응집도가 높고, 견고한 클래스에는 적은 수의 메서드와 상대적으로 더 많은 수의 생성자가 존재한다는 점이다.

생성자의 개수가 더 많을수록 클래스는 더 개선되고, 사용자 입장에서 클래스를 더 편하게 사용할 수 있다.

메서드가 많아지면 클래스의 초점이 흐려지고, 단일 책임 원칙(SRP)을 위반하기 때문이다.

이에 비해 생성자가 많아지면 유연성이 향상된다.

하나의 주 생성자와 다수의 부 생성자(one primary, many secondary) 원칙의 핵심은 중복 코드를 방지하고 설계를 더 간결하게 만들어 유지보수성이 향상된다는 것이다.

 

로또 미션을 통해 생성자를 활용하는 연습을 해보자.

 

LottoGame.java

    public static int match(List<Integer> userLotto, WinningLotto winningLotto) {
        int matchCount = winningLotto.matchCount(userLotto);
        boolean matchBonusNo = winningLotto.hasBonusNo(userLotto);
        return rank(matchCount, matchBonusNo);
    }

사용자가 생성한 로또를 객체가 아닌 List<Integer>로 그대로 들고다닐 경우 로또 번호의 유효성 체크에 대한 고민을 항상 해야 한다.

그래서 List<Integer>를 일급 컬렉션을 사용해서 포장해보겠다.

6개의 숫자를 가지는 로또 한 장을 Lotto라는 객체로 포장한다.

당연히 테스트 코드부터 작성하는데, 이번에는 생성자를 잘 활용하는 연습을 하는 중이므로 다양한 타입의 매개변수를 갖는 생성자를 만들어보겠다.

 

LottoTest.java

public class LottoTest {
    @Test
    void create() {
        Lotto lotto = new Lotto(Arrays.asList(1, 2, 3, 4, 5, 6));
        assertThat(lotto).isEqualTo(new Lotto("1,2,3,4,5,6"));
    }
}

List<Integer> 타입과 String 타입을 매개변수로 받는 생성자들을 만들어보자.

 

Lotto.java

package javajigi;

import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

public class Lotto {

    public static final String DELIMITER = ",";
    
    private final List<Integer> numbers;

    public Lotto(String textNumbers) {
        this(toIntNumbers(textNumbers));
    }

    // 주생성자
    public Lotto(List<Integer> numbers) {
        this.numbers = numbers;
    }

    private static List<Integer> toIntNumbers(String textNumbers) {
        String[] strings = textNumbers.split(DELIMITER);
        return Arrays.stream(strings)
                .map(Integer::parseInt)
                .collect(Collectors.toList());
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Lotto lotto = (Lotto) o;
        return Objects.equals(numbers, lotto.numbers);
    }

    @Override
    public int hashCode() {
        return Objects.hash(numbers);
    }
}

 

더 다양한 생성자를 추가해보자.

가변인자를 매개변수로 받는 생성자도 추가하면 사용자가 Lotto 객체를 더 편하게 사용할 수 있을 것 같다.

 

LottoTest.java

    @Test
    void create() {
        // 생략
        
        Lotto lotto2 = new Lotto(1, 2, 3, 4, 5, 6);
        assertThat(lotto2).isEqualTo(new Lotto("1,2,3,4,5,6"));
    }

 

Lotto.java

    public Lotto(Integer... numbers) {
        this(Arrays.asList(numbers));
    }

 

이제 로또 번호에 대한 유효성 체크를 해보겠다.

로또 객체는 numbers라는 이름의 List<Integer> 프로퍼티를, 즉 로또 번호들을 캡슐화하고 있다.

로또 객체의 주 생성자가 이 프로퍼티를 초기화하는 역할을 담당한다.

따라서 로또 번호의 유효성을 체크하는 로직을 추가해야 한다면 바로 이 로또 객체의 주 생성자에 추가해야 한다.

 

로또 번호 유효성 체크

  • 로또 번호는 6자리여야 한다.
  • 로또 번호는 1부터 45 사이의 값이어야 한다.
  • 로또 번호는 중복된 값이 있으면 안된다.

 

먼저 로또 크기에 대한 유효성 체크를 한다.

 

LottoTest.java

    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Lotto(Arrays.asList(1, 2, 3, 4, 5));
        }).isInstanceOf(IllegalArgumentException.class);
    }

 

Lotto.java

    public Lotto(List<Integer> numbers) {
        if (numbers.size() != 6) {
            throw new IllegalArgumentException("로또 크기는 6이어야 합니다.");
        }
        this.numbers = numbers;
    }

 

다음으로 중복값에 대한 유효성 체크를 한다.

 

LottoTest.java

    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Lotto(Arrays.asList(1, 2, 3, 4, 5));
        }).isInstanceOf(IllegalArgumentException.class);

        assertThatThrownBy(() -> {
            new Lotto(Arrays.asList(1, 2, 3, 4, 5, 5));
        }).isInstanceOf(IllegalArgumentException.class);
    }

Set을 사용하면 중복값이 제거된다.

 

Lotto.java

public class Lotto {
    private static final String DELIMITER = ",";
    private static final int LOTTO_SIZE = 6;

    private final List<Integer> numbers;

	// 생략

    public Lotto(List<Integer> numbers) {
        if (Set.copyOf(numbers).size() != LOTTO_SIZE) {
            throw new IllegalArgumentException("로또는 6개의 번호로 구성되어야 합니다.");
        }
        this.numbers = numbers;
    }
    // 생략
}

 

마지막으로 로또 번호의 값이 1부터 45 사이의 숫자인지 검증해야 한다.

그런데 List<Integer>의 Integer 역시 원시값이다.

이 상태에서 유효성 체크를 하려면 numbers를 loop를 돌면서 하나씩 확인해야 한다.

이 원시값을 LottoNumber 객체로 포장하고 로또 번호에 대한 유효성 체크 로직을 위임한다. (숙제로 내주신 부분을 구현해봤다.)

 

LottoNumberTest.java

public class LottoNumberTest {

    @Test
    void create() {
        LottoNumber lottoNumber = new LottoNumber(1);
        assertThat(lottoNumber).isEqualTo(new LottoNumber(1));
    }

    @Test
    void throwIfInvalidNumber() {
        assertThatThrownBy(() -> {
            new LottoNumber(0);
        }).isInstanceOf(IllegalArgumentException.class);

        assertThatThrownBy(() -> {
            new LottoNumber(46);
        }).isInstanceOf(IllegalArgumentException.class);
    }
}

 

LottoNumber.java

public class LottoNumber {
    private final int MIN_VALUE = 1;
    private final int MAX_VALUE = 45;

    private final int value;

    public LottoNumber(int value) {
        if (value < MIN_VALUE || value > MAX_VALUE) {
            throw new IllegalArgumentException("로또 번호는 1부터 45 사이의 값이어야 합니다.");
        }
        this.value = value;
    }
    //생략
}

원시값 Integer를 포장하는 LottoNumber 객체를 만들고, LottoNumber의 주 생성자에서 로또 번호의 최솟값과 최댓값에 대한 유효성 체크를 한다.

로또 번호의 유효성 체크 로직을 LottoNumber 객체로 위임함으로써 Lotto는 더이상 로또 번호의 유효성에 대해 고민할 필요가 없다.

 

이번 시간에는 생성자를 활용하는 연습을 해보았다.

생성자를 잘 활용하면 사용자가 객체를 더 유연하게 사용할 수 있고, 코드의 복잡성을 줄이고, 중복 코드를 방지하고, 설계를 더 간결하게 만들어 유지보수성이 향상된다.

 

Q&A

  • 네이밍 규칙이 고민이 됩니다. 또 클래스 개수가 많아지면 그 특징들에 따라서 패키지도 구분을 해줘야 하나 싶기도 합니다. 그러면서 이런 부분을 팀 수준에서 만들어 가려면 클래스 이름, 패키지 구조에 일관성을 갖춰야 할 거 같은데 어째야 하나 생각이 듭니다.
    • 패키지 관련 답변 : 지금 패키지 구조는 racinggame > domain / utils / view 와 같은 방식인데 점점 클래스 분리를 하다보면 domain 패키지 아래에 서브패키지를 두어 분리하게 되는 경우가 생긴다. 팀/조직 내에 당연히 이와 관련한 컨벤션이 필요하다.
    • 네이밍 규칙 관련 답변 : 챗GPT를 활용한다.
  • 빌더 패턴도 많이 사용하고 있는데, 포비님은 빌더 패턴과 생성자를 사용하는 기준이 있으신가요?
    • 인스턴스 변수의 수가 많아질 때에는 빌더 패턴을 사용하는 것을 추천한다. 마지막 미션 때 인스턴스 변수의 수가 많아진다.
  • 식별성이 있는 객체와 상태가 있는 객체를 같은 의미라고 볼 수 있을까요? 이 질문을 하는 이유는 식별성이 있는 객체에 대해서 실무에서 생성자를 통해서 생성하지 않고 있어서입니다.
    • 둘은 일치하는 개념은 아니다. 모든 상태가 있는 객체를 식별할 수 있는 것은 아니기 때문이다. 마지막 미션에서 데이터베이스가 나오는데 상태가 있는 객체를 어떻게 식별할 것인지 고민하게 될 것이다.
반응형

블로그의 정보

Claire's Study Note

Hi.Claire

활동하기