728x90
서론
- 이전 장에서는 숫자야구게임의 정책문서를 작성하며, 테크스펙을 작성해 개발시 문제가 될 영역들을 체크해봤다.
- Step2에서 필요한 도메인 로직을 추가하고, Game이라는 도메인을 만들어서 하나의 Game에 도메인 관리를 하는 것을 목표로 리팩토링 및 구현을 해보겠습니다.
- 이번 미션부터는 인자, 멤버 변수, 지역 변수 등에 최대한 final을 붙여 불변을 유지하는 것이 목표입니다.
- 또한 Value Object를 record로 변경합니다.
- Java17부터는 record를 지원합니다. Value Object인 경우엔 record가 어울리는 형태이기 때문에 record로 변경해서 진행합니다.
Game
- Game의 모든 정보를 가지고 있습니다.
- 유니크한 값으로 구분할 PK, 게임에 할당된 Computer, Player가 시도한 기록인 PlayerRecords, 시간 및 종료 일자 및 시간을 상태로 가지고 있습니다.
- 이번 요구사항에서의 핵심 메서드는 addPlayerRecord 입니다.
public void addPlayerRecord(final PlayerRecord playerRecord) {
if (endAt != null) {
throw new IllegalArgumentException("게임이 종료되었습니다.");
}
playerNumbers.add(playerRecord);
if (playerRecord.isSuccess()) {
endAt = LocalDateTime.now();
}
}
- 게임을 진행하면, Player는 정답을 맞출때 까지 계속 시도하게 됩니다.
- 일단, 게임이 종료되었다면 Player는 시도할 수 없도록 해야 합니다. 그 부분에 대한 Validate를 추가합니다.
- 아울러 PlayerRecord를 일급컬렉션에 추가합니다.
- 마지막으로 이번 시도에서 Player가 정답을 맞춰 성공했다면 endAt의 시간을 현재 시간으로 넣어서 종료되도록 합니다.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class GameTest {
private Game generalGame;
@BeforeEach
void setUp() {
generalGame = new Game(new Computer(List.of(new Number(1), new Number(2), new Number(3))));
}
@DisplayName("player가 성공하지 못하면 게임이 종료되지 않는다.")
@Test
void AddPlayerRecordTest() {
final PlayerRecord playerRecord = new PlayerRecord(new BaseBallNumbers(List.of(new Number(1), new Number(2), new Number(4))), 2, 0);
assertThat(generalGame.getEndAt()).isNull();
generalGame.addPlayerRecord(playerRecord);
assertThat(generalGame.getEndAt()).isNull();
}
@DisplayName("player가 성공하면 게임이 종료된다.")
@Test
void endAfterAddPlayerRecordTest() {
final PlayerRecord playerRecord = new PlayerRecord(new BaseBallNumbers(List.of(new Number(1), new Number(2), new Number(3))), 3, 0);
assertThat(generalGame.getEndAt()).isNull();
generalGame.addPlayerRecord(playerRecord);
assertThat(generalGame.getEndAt()).isNotNull();
}
@DisplayName("게임이 이미 종료되었다면 playerRecord를 추가할 수 없다.")
@Test
void addPlayerRecordAfterEndExcpetionTest() {
final PlayerRecord playerRecord = new PlayerRecord(new BaseBallNumbers(List.of(new Number(1), new Number(2), new Number(3))), 3, 0);
generalGame.addPlayerRecord(playerRecord);
assertThat(generalGame.getEndAt()).isNotNull();
assertThatThrownBy(() -> generalGame.addPlayerRecord(playerRecord))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("게임이 종료되었습니다.");
}
}
- 위에서의 조건 및 상황들이 모두 잘 동작하는지 단위테스트를 꼼꼼히 넣어두고 정책을 확인해봅니다.
- 전체적인 도메인 코드를 살펴보겠습니다.
package baseball.domain;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
public class Game {
private int id;
private final Computer computer;
private final List<PlayerRecord> playerNumbers = new ArrayList<>();
private final LocalDateTime startAt = LocalDateTime.now();
private LocalDateTime endAt;
public Game(final Computer computer) {
this.computer = computer;
}
public void addPlayerRecord(final PlayerRecord playerRecord) {
if (endAt != null) {
throw new IllegalArgumentException("게임이 종료되었습니다.");
}
playerNumbers.add(playerRecord);
if (playerRecord.isSuccess()) {
endAt = LocalDateTime.now();
}
}
public int getId() {
return id;
}
public Computer getComputer() {
return computer;
}
public LocalDateTime getStartAt() {
return startAt;
}
public LocalDateTime getEndAt() {
return endAt;
}
public int getPlayerTimes() {
return playerNumbers.size();
}
public void setId(final int id) {
this.id = id;
}
public List<PlayerRecord> getPlayerNumbers() {
return playerNumbers;
}
}
- setId가 있는 경우는 이번 애플리케이션에 특이한 부분으로 저희는 현재 InMemory에 저장되는 Collection을 사용하고 있습니다. PK 격인 Id는 해당 Collection에 저장될때 알수 있는것이기 때문에 특이하게 id만 setter를 만들었습니다.
Count
- 스트라이크, 볼의 갯수를 의미하는 Count Value Object를 이번 Step2에서는 만들어 보도록 하겠습니다.
- Value Object로 하는 이유는 일단 기본적인 Validate를 추가하기 위함입니다.
- 0개~3개 사이의 값이 되어야 하는 조건을 좀더 Value Object를 통해 처리할 예정입니다.
- 아울러 record로 처리해서 getter, final과 같은 기본 메서드들을 Value Object가 기본적으로 가져야 하는 것들을 나타낼 예정이빈다.
public record Count(int value) {
private static final int MIN_VALUE = 0;
private static final int MAX_VALUE = 3;
public Count {
validateCount(value);
}
private void validateCount(int count) {
if (count < MIN_VALUE || count > MAX_VALUE) {
throw new IllegalArgumentException("0~3개만 가능합니다.");
}
}
public boolean isMinValue() {
return this.value == MIN_VALUE;
}
public boolean isMaxValue() {
return this.value == MAX_VALUE;
}
}
- 최대, 최소값을 상수로 빼서 최대한 가독성과 의미 전달에 신경을 쓰는것이 좋습니다.
- isMinValue와 isMaxValue는 낫싱 / 성공 여부를 의미하는 수들이기 때문에 메서드로 빼서 자기 자신 스스로 상태를 검증할 수 있도록 합니다.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class CountTest {
@DisplayName("최소 수인 경우에는 true를 반환한다.")
@Test
void isMinValueTest() {
Count count = new Count(0);
assertThat(count.isMinValue()).isTrue();
}
@DisplayName("최소 수가 아닌 경우에는 false를 반환한다.")
@ParameterizedTest
@ValueSource(ints = {1, 2, 3})
void isNotMinValueTest(int value) {
Count count = new Count(value);
assertThat(count.isMinValue()).isFalse();
}
@DisplayName("최대 수인 경우에는 true를 반환한다.")
@Test
void isMaxValueTest() {
Count count = new Count(3);
assertThat(count.isMaxValue()).isTrue();
}
@DisplayName("최대 수가 아닌 경우에는 false를 반환한다.")
@ParameterizedTest
@ValueSource(ints = {0, 1, 2})
void isNotMaxValueTest(int value) {
Count count = new Count(value);
assertThat(count.isMaxValue()).isFalse();
}
@DisplayName("0~3개만 가능합니다. 에러 메시지를 반환한다.")
@ParameterizedTest
@ValueSource(ints = {-1, 4})
void validateCountTest(int value) {
assertThatThrownBy(() -> new Count(value))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("0~3개만 가능합니다.");
}
}
Number
- Number도 record로 변경하여 좀더 Value Object에 필요한 메서드들을 쉽게 지원하도록 합니다.
package baseball.domain;
public record Number(int value) {
public static final int MIN_VALUE = 1;
public static final int MAX_VALUE = 9;
public Number {
validateRange(value);
}
public void validateRange(final int value) {
if (value < MIN_VALUE || value > MAX_VALUE) {
throw new IllegalArgumentException("1부터 9까지의 숫자만 입력 가능합니다.");
}
}
public boolean equals(final int value) {
return this.value == value;
}
}
Commend
- 이번에 기록을 조회하는 명령어를 추가하게 됩니다.
- Enum에 추가함으로써 이전에 COMMEND_VALUES를 통해 알아서 캐싱이 될것이기 때문에 우리는 Enum 값만 하나 추가하면 됩니다.
- 그러나 이번에 추가하면서 of 메서드에 validate 메서드는 하드코딩이기때문에 또 변경해줘야 하는 귀찮음이 있습니다.
- 이부분도 동적으로 할수 있게 해보겠습니다.
import java.util.Arrays;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
public enum Commend {
START(1),
GAME_RECORD(2),
END(9);
private static final String DELIMITER = ", ";
private static final Map<Integer, Commend> COMMEND_VALUES = Arrays.stream(values())
.collect(Collectors.toMap(commend -> commend.value, Function.identity()));
private static final String VALUE_STRING = COMMEND_VALUES.keySet()
.stream()
.map(String::valueOf)
.collect(Collectors.joining(DELIMITER));
private final int value;
Commend(final int value) {
this.value = value;
}
public static Commend of(final int value) {
return Optional.ofNullable(COMMEND_VALUES.get(value))
.orElseThrow(() -> new IllegalArgumentException(VALUE_STRING + "만 입력 가능합니다."));
}
}
- 이미 있는 COMMEND_VALUES를 통해 stream을 통해 String으로 value를 만들어서 joining을 통해 값을 만들면 차후에 추가되어도 자동으로 ExceptionMessage까지 만들어지기 때문에 더욱더 유동성 있게 되었습니다.
- of의 테스트 코드도 provideOfTestFixtures에 값을 추가하면 하면 테스트 코드도 통과합니다.
- Exception인 경우엔 하드코딩을 해두어야 차후 엣지케이스가 생기지 않아 테스트 코드는 예외 메시지를 하드코딩으로 처리합니다.
package baseball.domain;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class CommendTest {
private static Stream<Arguments> provideOfTestFixtures() {
return Stream.of(
Arguments.of(1, Commend.START),
Arguments.of(2, Commend.GAME_RECORD),
Arguments.of(9, Commend.END)
);
}
@DisplayName("명령어 찾기 테스트")
@ParameterizedTest
@MethodSource("provideOfTestFixtures")
void ofTest(final int value, final Commend expected) {
final Commend actual = Commend.of(value);
assertThat(actual).isEqualTo(expected);
}
@DisplayName("존재하지 않는 명령어를 입력하면 예외가 발생한다.")
@Test
void ofExceptionTest() {
assertThatThrownBy(() -> Commend.of(3))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("1, 2, 9만 입력 가능합니다.");
}
}
BaseBallNumbers
- 이것도 역시 record로 변경하였습니다.
- 아울러 getValueNumber라고 하는 메서드를 새롭게 만들었습니다.
public List<Integer> getValueNumbers() {
return numbers.stream()
.map(Number::value)
.toList();
}
- 해당 메서드는 실제 numbers에 있는 원시값의 List를 반환합니다.
- 불변으로 반환하며 원시값을 반환하는 이유는 차후 원시값을 View를 내려줄때 써야 하는데, 굳이 외부에서 getter를 사용해서 추출하기 보단 내부에서 변환 이후에 하는 것이 훨씬 더 안전하다고 판단하였습니다.
- 마지막으로 record로 변경하면서 실제 validate를 할때 인자를 받아서 처리하도록 리팩토링 하였습니다.
import java.util.Collections;
import java.util.List;
public record BaseBallNumbers(List<Number> numbers) {
public static final int TOTAL_COUNT = 3;
public BaseBallNumbers {
validateDuplicateNumbers(numbers);
validateSize(numbers);
}
public boolean isContains(final Number number) {
return numbers.contains(number);
}
public boolean isSameIndexOf(final Number number, final int index) {
return numbers.get(index).equals(number);
}
public int indexOf(final Number number) {
return numbers.indexOf(number);
}
@Override
public List<Number> numbers() {
return Collections.unmodifiableList(numbers);
}
public List<Integer> getValueNumbers() {
return numbers.stream()
.map(Number::value)
.toList();
}
private boolean isTotalSize(final List<Number> numbers) {
return numbers.size() == TOTAL_COUNT;
}
private boolean isDuplication(final List<Number> numbers) {
return numbers.stream()
.distinct()
.count() != numbers.size();
}
private void validateDuplicateNumbers(final List<Number> numbers) {
if (isDuplication(numbers)) {
throw new IllegalArgumentException("중복된 숫자가 있습니다.");
}
}
private void validateSize(final List<Number> numbers) {
if (!isTotalSize(numbers)) {
throw new IllegalArgumentException("3개의 숫자를 입력해주세요.");
}
}
}
- 기존 Step1과 테스트 코드나 정책은 변경된게 없으니 테스트 코드는 같이 첨부를 하지 않습니다.
PlayerRecord
- 실제 플레이어의 각각의 시도에 대한 정보들이 들어있는 Domain입니다.
- 각각의 시도에서 숫자, 스트라이크 갯수, 볼 갯수들을 상태로 가지고 있습니다.
package baseball.domain;
import java.util.List;
public class PlayerRecord {
private final BaseBallNumbers numbers;
private final Count strikeCount;
private final Count ballCount;
public PlayerRecord(BaseBallNumbers numbers, int strikeCount, int ballCount) {
this.numbers = numbers;
this.strikeCount = new Count(strikeCount);
this.ballCount = new Count(ballCount);
}
public List<Integer> getValueNumbers() {
return numbers.getValueNumbers();
}
public int getStrikeCount() {
return strikeCount.value();
}
public int getBallCount() {
return ballCount.value();
}
public boolean isNotting() {
return ballCount.isMinValue() && strikeCount.isMinValue();
}
public boolean isSuccess() {
return strikeCount.isMaxValue();
}
}
- 플레이어가 입력하는 숫자에 대해서는 BaseBallNumbers에서 validate로 처리하고 있으며, 스트라이크, 볼의 갯수에 대해서는 Count의 Value Object에서 처리하고 있으므로 해당 객체에선 실제 플레이어의 시도에 대한 정보들만 잘 리턴하도록 처리합니다.
package baseball.domain;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class PlayerRecordTest {
@DisplayName("스트라이트와 볼 갯수가 0개면 낫싱이다.")
@Test
void isNottingTest() {
BaseBallNumbers plyerNumbers = new BaseBallNumbers(List.of(
new Number(1),
new Number(2),
new Number(3)));
PlayerRecord playerRecord = new PlayerRecord(plyerNumbers, 0, 0);
assertThat(playerRecord.isNotting()).isTrue();
}
@DisplayName("스트라이트와 볼 갯수가 0개가 아니면 낫싱이 아니다.")
@ParameterizedTest
@CsvSource(value = {"1,0", "0,1", "1,1"})
void isNottingTest2(int ballCount, int strikeCount) {
BaseBallNumbers plyerNumbers = new BaseBallNumbers(List.of(
new Number(1),
new Number(2),
new Number(3)));
PlayerRecord playerRecord = new PlayerRecord(plyerNumbers, strikeCount, ballCount);
assertThat(playerRecord.isNotting()).isFalse();
}
@Test
@DisplayName("스트라이크 갯수가 3개면 성공이다.")
void isSuccessTest() {
BaseBallNumbers plyerNumbers = new BaseBallNumbers(List.of(
new Number(1),
new Number(2),
new Number(3)));
PlayerRecord playerRecord = new PlayerRecord(plyerNumbers, 3, 0);
assertThat(playerRecord.isSuccess()).isTrue();
}
@DisplayName("스트라이크 갯수가 3개가 아니면 성공이 아니다.")
@ParameterizedTest
@CsvSource(value = {"1,1", "0,2"})
void isSuccessTest2(int ballCount, int strikeCount) {
BaseBallNumbers plyerNumbers = new BaseBallNumbers(List.of(
new Number(1),
new Number(2),
new Number(3)));
PlayerRecord playerRecord = new PlayerRecord(plyerNumbers, strikeCount, ballCount);
assertThat(playerRecord.isSuccess()).isFalse();
}
}
클린코드&객체지향을 지향하자.
- 하드코딩된 값들을 상수화를 진행합니다.
- 실제 해당 내부 메서드에서만 사용하는 메서드 및 상태인 경우엔 private의 접근제어자로 외부에서 사용하지 못하도록 합니다
- getter를 지양합니다.
- Collection을 부득히 하게 getter를 써야 하는 경우엔 unmodifiableList 를 이용해 불변을 유지하여 외부에서 해당 데이터를 변경하지 못하도록 합니다.
- 어떤 행동을 해야 한다면, 객체에게 메시지를 던져 응답을 받을수 있도록 합니다.
- final을 통해서 각 값들에 대해서 최대한 불변을 유지합니다.
- 인스턴스 변수, 매개변수, 지역 변수 등에 final을 사용합니다.
- valueObject를 사용 할 시, record를 사용해서 좀더 간편하게 사용하도록 합니다.
결론
- Step1을 끝내고 Step2로 넘어와서 Step1에 대한 코드들을 한번 읽어보고, 정책 문서 및 테크스펙을 작성하면서 리팩토링을 해야 하는 부분들이 많이 보이게 된거 같습니다.
- 리팩토링이란, 계속 해야 하는 것이라고 생각이 드는데 이번 Step2에서 아키텍쳐적인 리팩토링 및 객체지향에서의 메서드에 대한 구조를 리팩토링 한 부분에 대해서는 최대한 만족스러운거 같습니다.
- 요구사항이 추가되고 분석하면서 해당 도메인 및 문제에 대해서 객체지향적으로 어떻게 아키텍처를 잡는것이 훨씬 좋을지도 조금씩 보이게 되는거 같습니다.
- 아직 ValueObject나 일급컬렉션, 메서드를 작성하는 것이 조금 미흡하다고 생각이 들지만, 아직 Step3, 4가 남아 있기 때문에 계속해서 리팩토링을 하면서 구조를 변경해 나가면서 시도해보는것이 가장 중요하다고 생각이 듭니다.
- 다음 편에선 비지니스 로직을 어떻게 리팩토링하였는지, 실제 기록을 조회하는 비지니스 로직, Repository 로직을 같이 살펴보겠습니다.
언제든 해당 글에 대해서 피드백은 환영입니다.
728x90
728x90
'Backend > Java' 카테고리의 다른 글
[숫자야구게임 Step2] 실제 애플리케이션이 동작할 코드를 작성하자 (0) | 2024.04.26 |
---|---|
[숫자야구게임 Step2] 비지니스 로직을 작성하자. (0) | 2024.04.24 |
Five API Performance Optimization Tricks that Every Java Developer Must Know(모든 자바 개발자가 알아야 할 다섯 가지 API 성능 최적화 팁) (0) | 2024.04.20 |
[숫자야구게임 Step2] 정책을 정의내리고 테크스펙을 작성해보자. (0) | 2024.04.20 |
[숫자야구게임 Step1] 실제 애플리케이션이 동작할 코드를 작성하자 & 마무리 (2) | 2024.04.20 |