Claire's Study Note

[TDD, 클린 코드 with Java] 자바 스트림(Stream), 경계값 테스트, 클래스 분리, 원시값과 문자열 포장

by Hi.Claire
반응형

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

 

자바 스트림(Stream), 경계값 테스트, 클래스 분리, 원시값과 문자열 포장

두 번째 라이브 강의 내용을 정리하다 보니 생각보다 분량이 많아서 두 번에 걸쳐서 업로드하게 되었다.
지난 1편에서는 TDD와 관련한 내용을 주로 다루었다.
객체 지향 설계와 TDD cycle에 익숙하지 않은 경우 어떤 식으로 TDD를 연습할 수 있는지 배웠다.
Test Fixture를 만들 때 불변성을 해치는 setter의 사용을 지양하고 대신 생성자를 적극 활용하는 것이 좋다.
그리고 getter를 사용하는 대신 객체에게 직접 메시지를 보내서 물어보라는 내용도 있었다.
마지막으로 테스트하기 어려운 구조를 테스트가 가능한 구조로 바꾸는 연습도 해보았다.
이번 2편에서는 리팩터링과 관련한 내용이 많다.
그 중에서도 클래스 분리와 관련된 내용이 어렵지만 중요한 내용인 것 같다.
이번 2편의 목표는 객체지향 생활 체조 원칙 중 다음 두 가지 내용을 연습해보며 이해하는 것이다.

  • 규칙 3: 모든 원시값과 문자열을 포장한다.
  • 규칙 8: 일급 콜렉션을 쓴다.

원시값 포장, 일급컬렉션,,, 어려워,,, 그렇지만 쉽게만 살아가면 재미없어~ 빙고!

 

자동차 경주 피드백 라이브 강의

6. 메서드 분리 : 자바 Stream 사용

indent가 1 이하로 줄어들지 않는 경우 자바 스트림(Stream)을 사용한다.
 
Winners.java

    private static List<Car> findWinners(List<Car> cars, int maxPosition) {
        List<Car> winners = new ArrayList<>();
        for (Car car : cars) {
            if (car.isSame(maxPosition)) {
                winners.add(car);
            }
        }
        return winners;
    }
    private static List<Car> findWinners(List<Car> cars, int maxPosition) {
        return cars.stream()
                .filter(car -> car.isSame(maxPosition))
                .collect(Collectors.toList());
    }

 

7. 경계값 테스트

테스트 코드도 유지보수해야 하는 코드다.
모든 값을 테스트하는 것은 지양하고, 최소한의 케이스로 모든 테스트를 커버하는 것이 중요하다.
경계값을 기준으로 테스트하는 것이 좋다.
move() 메서드는 랜덤값이 4 이상일 때 이동하고, 미만일 때에는 정지하므로 경계값인 4, 3으로 테스트한다.
 
CarTest.java

    @Test
    void 이동() {
        car = new Car("pobi");
        car.move(4);
        assertThat(car.getPosition()).isEqualTo(1);
    }

    @Test
    void 정지() {
        car = new Car("pobi");
        car.move(3);
        assertThat(car.getPosition()).isEqualTo(0);
    }

 

8. 클래스 분리 : 모든 원시값과 문자열을 포장한다.

리팩터링할 때 어떤 기준으로 클래스를 분리해야 할지 모르겠으면 다음 두 가지 기준으로 클래스를 분리해보자.

  • 규칙 3. 모든 원시값과 문자열을 포장한다.
  • 규칙 8. 일급 콜렉션을 쓰자.

 
Car 객체의 원시값 position을 포장(Wrapping)한다.
Position 클래스를 분리하여 원시값을 하나만 가지는 클래스를 만든다. 
 
1. equals(), Hashcode() 메서드 구현
Position.java

package javajigi;

public class Position {
    private final int value;

    public Position(int value) {
        this.value = value;
    }

    public int getPosition() {
        return value;
    }
}

 
PositionTest.java

package javajigi;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;

class PositionTest {

    @Test
    void create() {
        Position position = new Position(4);
        assertThat(position.getPosition()).isEqualTo(4);
    }
}

 
Getter()로 값을 꺼내려하지 말고 객체 단위로 비교하는 것이 좋다.

    @Test
    void create() {
        Position position = new Position(4);
        assertThat(position).isEqualTo(new Position(4));
    }

 
그런데 위의 테스트는 실패한다.

> Task :test FAILED

Expected :javajigi.Position@fe9b692
Actual   :javajigi.Position@4b0a2c09

 
OOP에서는 클래스에서 equals()와 HashCode() 메서드를 수기로 구현해야 한다.
 
Position.java

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Position position = (Position) o;
        return value == position.value;
    }

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

다시 테스트를 돌리면 성공한다.
 
2. 생성자를 통한 유효성 체크
Position의 허용 범위는 0 이상이다.
Position 생성자에 검증 로직을 두어 유효성 체크를 할 수 있다.
 
PositionTest.java

    @Test
    void 음수_체크() {
        assertThatThrownBy(() -> {
            new Position(-1);
        }).isInstanceOf(IllegalArgumentException.class);
    }

 
Position.java

    public Position(int value) {
        if (value < 0) {
            throw new IllegalArgumentException("음수는 허용하지 않습니다.");
        }
        this.value = value;
    }

만약 position이 Car 객체 안에 int형의 필드로 존재한다면 복잡한 로직들 속에서 position의 허용 범위는 int의 허용 범위가 될 것이다.
Position 객체로 포장함으로써 0 이상의 값이 보장된다.
 
3. 기존 코드에서 원시값(int)을 클래스(Position)로 타입 변경하는 문제 역시 생성자를 사용하여 해결한다. 

public class Car {

	// 생략
    
    private final String name;
    private Position position;

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

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

    public Car(final String name, final Position position) {
        this.name = name;
        this.position = position;
    }
    
    // 생략
}

 
4. Position 객체로 메시지를 보내라.
Position 클래스를 분리하면서 Car 객체에서 position 원시값을 사용하던 모든 로직들이 Position 객체 안으로 들어가야 한다.
Car에서는 Position 객체에게 메시지를 보내서 해당 로직을 위임한다.
이제 Car에는 아무것도 하지 않으면서 Position에게 로직을 위임하는 메서드들이 남아 있게 되는데, 이러한 메서드에 대한 테스트는 Position과 중복되므로 수행하지 않아도 된다. (예 : isSame(), max())
 
Car.java

package javajigi;

public class Car {

    public static final int MOVE_THRESHOLD = 4;
    private final String name;
    private Position position;

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

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

    public Car(final String name, final Position position) {
        this.name = name;
        this.position = position;
    }

    public boolean isSame(int otherPosition) {
        return position.isSame(otherPosition);
    }

    public int max(int maxPosition) {
        return position.max(maxPosition);
    }

    public void move(int randomNo) {
        if (randomNo >= MOVE_THRESHOLD) {
            position = position.increase();
        }
    }
}

이제 Car에는 position 값을 구하거나 변경하거나 비교하는 로직이 없다. 모두 Position에게 위임했다.
Car의 유일한 로직은 자동차의 이동 유무를 결정하는 move()이므로 해당 로직만 테스트하면 된다.
 
CarTest.java

package javajigi;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class CarTest {

    @Test
    void 이동() {
        Car car = new Car("pobi");
        car.move(4);
        assertThat(car.isSame(1)).isTrue();
        assertThat(car.isSame(0)).isFalse();
    }

    @Test
    void 정지() {
        Car car = new Car("pobi");
        car.move(3);
        assertThat(car.isSame(0)).isTrue();
        assertThat(car.isSame(1)).isFalse();
    }
}

 
Position.java

package javajigi;

import java.util.Objects;

public class Position {
    private int value;

    public Position(int value) {
        if (value < 0) {
            throw new IllegalArgumentException("음수는 허용하지 않습니다.");
        }
        this.value = value;
    }

    public boolean isSame(int otherPosition) {
        return value == otherPosition;
    }

    public int max(int maxPosition) {
        return Math.max(maxPosition, value);
    }

    public Position increase() {
        value++;
        return this;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Position position = (Position) o;
        return value == position.value;
    }

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

OOP에서 가장 좋은 객체는 응집도가 높고 단일책임원칙을 지키는 객체이다.
Position 객체는 인스턴스 변수로 value 하나만 가지고, 객체 내부에서 모든 메서드가 value 인스턴스 변수와 의존관계를 맺고 있다.
따라서 Position 객체는 응집도가 높고 단일책임원칙을 지킬 가능성이 높다.
 
PositionTest.java

package javajigi;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class PositionTest {

    @Test
    void create() {
        Position position = new Position(4);
        assertThat(position).isEqualTo(new Position(4));
    }

    @Test
    void 음수_체크() {
        assertThatThrownBy(() -> {
            new Position(-1);
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void isSame() {
        Position position = new Position(4);
        assertThat(position.isSame(4)).isTrue();
        assertThat(position.isSame(5)).isFalse();
    }

    @Test
    void max() {
        Position position = new Position(4);
        assertThat(position.max(5)).isEqualTo(5);
    }

    @Test
    void increase() {
        Position position = new Position(4);
        assertThat(position.increase()).isEqualTo(new Position(5));
    }
}

 
다음으로 Car 객체의 원시값 randomNo를 포장한다.
RandomNo의 생성자를 통해 유효성을 체크한다.
Car 객체 내부에서 randomNo를 사용하던 로직은 RandomNo에게 메시지를 보내서 위임한다.
 
RandomNo.java

package javajigi;

public class RandomNo {

    private static final int MOVE_THRESHOLD = 4;
    private final int value;

    public RandomNo(int value) {
        if (value < 0 || value > 9) {
            throw new IllegalArgumentException("0에서 9 사이의 값만 허용합니다.");
        }
        this.value = value;
    }

    public boolean isMovable() {
        return value >= MOVE_THRESHOLD;
    }
}

 
RandomNoTest.java

package javajigi;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class RandomNoTest {

    @Test
    void 범위_0이상_9이하_체크() {
        assertThatThrownBy(() -> {
            new RandomNo(-1);
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void 이동_가능() {
        RandomNo randomNo = new RandomNo(4);
        assertThat(randomNo.isMovable()).isTrue();
    }

    @Test
    void 이동_불가() {
        RandomNo randomNo = new RandomNo(3);
        assertThat(randomNo.isMovable()).isFalse();
    }
}

 
Car.java

    public void move(RandomNo randomNo) {
        if (randomNo.isMovable()) {
            position = position.increase();
        }
    }

 
CarTest.java

    @Test
    void 이동() {
        Car car = new Car("pobi");
        car.move(new RandomNo(4));
        assertThat(car.isSame(1)).isTrue();
        assertThat(car.isSame(0)).isFalse();
    }

    @Test
    void 정지() {
        Car car = new Car("pobi");
        car.move(new RandomNo(3));
        assertThat(car.isSame(0)).isTrue();
        assertThat(car.isSame(1)).isFalse();
    }

 

9. 클래스 분리 : 일급 컬렉션

일급 컬렉션(First Class Collection)은 컬렉션을 포장(Wrapping)한다.
클래스 안에 인스턴스 변수로 해당 컬렉션 하나만을 가진다.
지난번 미션1 3단계 코드 리뷰에서도 일급 컬렉션을 사용하라는 피드백을 받았는데 그때 공부했던 내용은 다음 글을 참고하면 된다.
https://moominie.tistory.com/92#head6

 

[TDD, 클린 코드 with Java] 241006~ 미션1. 3단계 피드백 반영 : 테스트 코드, 전략패턴, 일급컬렉션

🐣 TDD, 클린 코드 with Java 19기 (박재성, 넥스트스텝) 241006 미션1. 3단계 피드백 반영미션1. 3단계 피드백 반영3단계 - 자동차 경주 요구사항https://edu.nextstep.camp/s/KIXpLZ6i/ls/3Vlw7LtF NEXTSTEP edu.nextstep.c

moominie.tistory.com

 
클래스 분리 리팩터링 규칙

  • 규칙 3: 모든 원시값과 문자열을 포장한다.
  • 규칙 8: 일급 컬렉션을 쓴다.
  • 클린코드 책 : 함수 인수
    • 함수에서 이상적인 인수 개수는 0개(무항)이다. 다음은 1개이고, 그 다음은 2개이다.
    • 3개는 가능한 피하는 편이 좋다.
    • 4개 이상은 특별한 이유가 있어도 사용하면 안된다.

 
Q&A

  • 인스턴스 변수를 2개까지만 허용할 경우, 실무에서 인스턴스 변수 2개까지만 유지하기가 쉽지 않을거 같은데요. 예를 들어 DB persist 객체의 내용에 대응하는 도메인 객체가 필요할 경우 인스턴스 변수 2개 제약을 이유로 많이 분리하게 될 거 같은 상황이 예상됩니다. 이런 경우에도 분리해서 관리하는게 좋을까요?
    • 맞다. DB 테이블의 컬럼과 도메인 객체의 인스턴스 변수가 1:1 매핑되는 것은 아니다. 객체 지향 설계를 잘했다면 클래스의 수가 훨씬 더 많아야 한다. 앞으로 미션들을 진행하면서 가능한 인스턴스 변수 개수를 최대한 줄여보자. 작은 단위의 클래스로 분리하면 클래스의 크기가 작아지고, 단일책임원칙도 지키면서, TDD도 수월하고, 유지보수성이 좋아지는 코드를 구현할 수 있다.
  • 가끔 클래스를 분리하다보면 전부 다 위임하게 되는 경우가 있더라구요. 이런경우는 어떻게 하시나요?
    • 두 개의 객체가 거의 같은 책임을 갖게 되는 경우는 너무 과도하게 분리한 경우다. 한 객체가 하는 일이 하나도 없이 전부 다 다른 객체에게 위임해버리는 경우라면 너무 과도하게 분리한 것이다. 이런 경우에는 둘을 합치는게 맞다.
    • 위의 예시에서 Car 객체는 Position 객체에게 특정 로직들을 위임했음에도 여전히 name 필드를 가지고, move() 기능을 갖고 있으므로 객체로서 의미가 있다.
  • D {
        A();
        r=mock();
        B(r); 
    }
    위와 같은 코드를 테스트한다면, A랑 B만 테스트하고 D는 테스트하지 않아도 되는 것인가요? 아니면 이럴 때는 mock을 쓰는 게 한 방법인가요?
    • 위의 예시는 우리가 현업에서 흔히 만나게 되는 레거시 코드의 예시이다. mock() 부분이 데이터베이스 혹은 외부 API와 관련되어 테스트하기 어려운 코드일 가능성이 높다. 이럴 경우 완전히 설계를 바꿔서 A와 B의 로직을 다른 객체로 위임해서 테스트 가능한 구조로 바꿀 수 있다. 절차지향 프로그래밍에서 위와 같은 코드들이 많이 생성된다. 우리의 목표 중 하나는 객체지향 설계를 통해 mock을 쓰지 않고도 테스트 가능한 코드를 만드는 것이다.
  • 실무에서는 구현중심을 아직 선호하고 있고, 설계에 들어가는 시간을 크게 주지는 않는 편인데요, 주니어 시절에 설계기간을 어떻게 가져가셨을까요?(ex: 정량적 데드라인 적용)
    • 2000년대 초중반에 OOP, 디자인 패턴, UML 등이 유행하면서 설계에 많은 시간을 투자했다. UML을 기반으로 클래스 다이어그램, 시퀀스 다이어그램 등 설계 문서를 만들고 많은 시간을 투자한 후에 구현에 들어가니까 설계와 구현이 완벽히 맞지 않았다. 또 구현이 끝난 후 사용자 데모를 하면 그때서야 요구사항이 바뀌는 경우도 많았다. 그럼 다시 설계부터 해야 한다.
    • 최근의 흐름은 초반에 너무 과도한 설계를 하기보다는 최소한의 설계를 하고, 빠르게 기능 구현을 완료하고, 사용자 피드백을 받은 후에, 요구사항이 바뀌면 이를 반영하여 또다시 빠르게 설계해서 구현하고 피드백 받는 방식으로 진행한다. 스프린트 혹은 반복 주기라는 용어를 사용한다.
    • TDD는 초반에 과도한 설계를 하지 않고 점진적인 리팩터링을 지향한다. 구현하고 빠르게 설계(리팩터링)하는 방식이다. 작은 설계를 자주 한다. 또한 TDD에서의 설계는 최소한의 설계로 도메인에 대한 설계, 즉, 핵심 비즈니스 로직을 가지는 부분, 상태값을 가지는 부분만 설계한다.
  • 모든 원시값과 문자열을 포장하는 규칙을 적용하는 부분에서, 너무 좋은 장점을 보아서 미션에서는 물론이고 현업에서도 적용하고 싶은 욕구가 뿜뿜하는데요..!! 근데 실무에서 해당 원칙을 그대로 적용하면, 오버엔지니어링이 될 것 같은데 그럼에도 불구하고 저 원칙에 따라 모든 원시값과 문자열을 포장하는게 좋을까요? 만약 포장을 결정하는 팁이나 조건같은게 있다면 궁금합니다!!
    • 지금은 연습 단계다. 연습 단계에서는 무작정 가이드를 따라서 해본다. 그러면서 길러야 하는 능력은 "이것은 포장했을 때 효과가 상당할 것 같다. 포장함으로써 많은 로직들이 의미가 생긴다." 혹은 "이것은 포장했을 때 별로 의미가 없다. 너무 과한 포장이다. 그대로 있어도 유지보수 측면에서 충분히 좋을 것 같다." 라는 판단을 할 수 있는 능력이다. 처음에는 원칙을 지키다가 경험이 쌓이면서 어느 순간 그것을 깨뜨릴 수 있어야 한다. 어떤 것은 원칙을 적용하는 것이 의미가 있고, 어떤 것은 지키지 않는 것이 의미가 있다는 것을 빠르게 판단할 수 있게 된다. 이를 통해 설계 역량이 쌓인다. 이 교육 과정을 통해 그런것을 연습하는 거다.
  • 혹시 유틸클래스와 도메인클래스를 나누는 기준이 있을까요? 처음에는 Car만 상태를 가진다고 생각하고 도메인클래스로 만들었는데, RacingGame은 유틸클래스로 만들었지만 Car를 여러개 가지니까 도메인클래스가 되어야 하는지 궁금해서요!
    • 클래스 메서드로 구현해도 된다. 다만 Car의 목록과 tryNo에 대한 상태 관리를 해야 하기 때문에 지금처럼 상태값을 갖도록 구현하는게 더 낫다고 생각한다. 

 
 

반응형

블로그의 정보

Claire's Study Note

Hi.Claire

활동하기