우테코 프리코스 백엔드 6기 (2023) 2주차 회고 (자동차 경주)
느낌
우테코 디스코드
2주차도 엄청 빠르게 지나갔습니다.
1주차를 완료한 이후, 우테코 디스코드에 참여하지 않았다는 사실을 알게되고 디스코드에 참가했습니다.
사람들 엄청 활발하더라구요.
지난 주차 과제를 서로 리뷰하는 활동들이 되게 활발하게 진행되고 있길래, 저도 참여했습니다
그리고.. 엄청나게 많은 리뷰를 하게 될 줄은 이 때는 몰랐습니다.
정말 메일이 엄청나게 왔습니다..
리뷰 한 명치 하고 나면 메일이 10개 와있고.. 10개 메일 다 확인하면 또 5개 와있고..
미친듯이 보느라 하루에 여유 시간이 없었네요.
하지만 그래도 이런 DM도 받아보고, 리뷰 받으신 분들이 좋은 말 해주셔서 좀 보람이 있었던 것 같습니다.
TDD
우테코의 미션들은 정말 의식적으로 TDD로 작업하려고 마음을 먹고 있습니다.
켄트 벡 아저씨는 TDD를 사용하면 설계상의 이점을 받을 수 있다 뭐 등등의 좋은 말들을 해주셨습니다.
하지만 뭔가 구현 기능 목록을 다 작성하고 구현을 해야 하다보니, 코딩 하기 전에 미리 설계를 다 끝내놓게 되더라구요.
그리고 남은 구현만 TDD로 하다보니 뭔가 TDD로 이득을 보고 있긴 한데, 한 절반만 보고 있다는 생각이 조금 들었습니다.
구현 기능 목록 작성 때엔 그냥 작성만 하고 설계는 맨땅부터 TDD와 함께 해야하는걸까요..
우테코 프리코스 2주차 미션
2주차 미션은 자동차 경주입니다.
1주차 때보다 개인적으로 더 쉬운 미션이라고 생각하고, 1주차를 하면서 낯선 환경에도 적응을 했기 때문에 정말 금방 뚝딱 끝냈습니다.
시도해본 것
빨리 끝냈다고 놀..았으면 좋았겠지만 역시 그럴 위인은 제가 못 됩니다.
1주차에서 위화감이 들었던 부분을 이것저것 고쳐보기도 했고, 리팩토링도 엄청 하면서 시간을 보내게 됐네요.
사용자 입력 받는 부분 추상화하기 (Hexagonal Architecture 스럽게)
우테코의 미션들은 콘솔로 하는 텍스트 게임이기 때문에, 통합 테스트를 하기 쉽지 않습니다.
이건 이전 글에서도 적은 적 있었죠.
이번에는 사용자 입력을 받는 부분을 port - adapter로 만들어서 추상화해봤습니다.
public interface RacingCarOutputPort {
List<String> getCarNames();
int getTryCount();
}
다음과 같이 사용자 입력을 받는 부분을 인터페이스로 추상화했습니다.
@Test
@DisplayName("play를 실행하면 자동차 경주 결과를 반환한다.")
void play() {
// given
RacingCarOutputPort racingCarOutputPort = new RacingCarOutputPort() {
public List<String> getCarNames() {
return List.of("pobi", "crong", "honux");
}
public int getTryCount() {
return 5;
}
};
// ....
}
이러면 테스트 코드에서는 이렇게 Stub 객체를 통해 사용자 입력을 모킹해주기가 매우 편하죠.
좋습니다.
하지만 딱 한 가지 고민되던 포인트가 있었는데, 사용자 입력을 받는 부분이니 input이란 단어가 어울리잖아요?
근데 헥사고날의 네이밍 규칙을 생각해보면 도메인 외부에서 값을 얻어오는 거다보니 output-port같은거에요.
(input은 사용자의 요청을 받는 부분이죠)
그래서 하.. 참 상황이 기묘하다.. 라고 생각을 했습니다.
최종 네이밍 선택은 output으로 가게 되었습니다.
부트스트랩을 담당하는 Framework 만들기
public class RandomNumberGenerator implements NumberGenerator {
private final int minInclusive;
private final int maxInclusive;
public RandomNumberGenerator(int minInclusive, int maxInclusive) {
this.minInclusive = minInclusive;
this.maxInclusive = maxInclusive;
}
@Override
public int generate() {
return Randoms.pickNumberInRange(minInclusive, maxInclusive);
}
}
제 RandomNumberGenerator 클래스는 생성자로 랜덤 최소값과 최대값을 받아요.
근데 이러면 어플리케이션을 실행할 때 이 RandomNumberGenerator를 생성하는 친구가 0, 9와 같은 숫자를 넣어줘야겠죠?
public class Application {
private static final int RANDOM_MIN_INCLUSIVE = 1;
private static final int RANDOM_MAX_INCLUSIVE = 9;
public static void main(String[] args) {
NumberGenerator generator = new RandomNumberGenerator(RANDOM_MIN_INCLUSIVE, RANDOM_MAX_INCLUSIVE);
// ...
}
}
그런데 그렇게 되면 이렇게 메인 클래스에서 0, 9라는 상수를 알아야하게 되더라구요.
이게 굉장히 이상하다 느꼈습니다. 그래서 이런 Bean들의 부트스트랩을 대신 해주는 역할을 가진 클래스가 있으면 좋겠다 생각했습니다.
public class Application {
public static void main(String[] args) {
RacingCarFramework instance = RacingCarFramework.getInstance();
RacingCarUseCase racingCarUseCase = instance.getBean(RacingCarUseCase.class);
RaceResult raceResult = racingCarUseCase.play();
}
}
짜잔. 확실히 좀 깔끔해보이지 않나요?
어떻게 구현했는가?
Bean이라는 이름만 봐도 느껴지시겠지만, 일단 스프링 프레임워크에서 영감을 많이 따왔습니다.
public class RacingCarConfiguration {
private NumberGenerator numberGenerator() {
int randomMinInclusive = 0;
int randomMaxInclusive = 9;
return new RandomNumberGenerator(randomMinInclusive, randomMaxInclusive);
}
private RaceChecker raceChecker() {
return new RaceChecker();
}
private CarService carService(NumberGenerator generator) {
return new CarService(generator);
}
private RacingCarGameService racingCarGameService(RaceChecker raceChecker) {
return new RacingCarGameService(raceChecker);
}
private RacingCarView racingCarView() {
return new RacingCarView();
}
private RacingCarOutputPort racingCarOutputPort() {
return new RacingCarOutputConsoleAdapter();
}
private RacingCarUseCase racingCarUseCase() {
return new RacingCarUseCase(
carService(numberGenerator()),
racingCarGameService(raceChecker()),
racingCarView(),
racingCarOutputPort()
);
}
}
이런 식으로, 스프링에서 @Bean
을 생성할 때와 비슷하게 Configuration 클래스에 각 인스턴스들을 생성하는 메소드를 등록했습니다.
public final class RacingCarFramework {
private static final RacingCarFramework instance = new RacingCarFramework(new RacingCarConfiguration());
private final RacingCarConfiguration configuration;
private RacingCarFramework(RacingCarConfiguration configuration) {
this.configuration = configuration;
}
public static RacingCarFramework getInstance() {
return instance;
}
public <T> T getBean(Class<T> clazz) {
Method beanCreateMethod = findBeanCreateMethod(clazz);
return createBean(beanCreateMethod);
}
private Method findBeanCreateMethod(Class<?> clazz) {
return Arrays.stream(RacingCarConfiguration.class.getDeclaredMethods())
.filter(method -> method.getReturnType().equals(clazz))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("해당하는 클래스의 빈이 없습니다."));
}
private <T> T createBean(Method beanCreateMethod) {
boolean accessible = beanCreateMethod.canAccess(configuration);
try {
beanCreateMethod.setAccessible(true);
return (T) beanCreateMethod.invoke(configuration);
} catch (Exception e) {
throw new IllegalArgumentException("해당하는 클래스의 빈을 생성할 수 없습니다.");
} finally {
beanCreateMethod.setAccessible(accessible);
}
}
}
그 후 이렇게 Framework라는 클래스를 구현했습니다.
스프링 느낌을 위해 정적 메소드를 썼고, 싱글톤을 이용해주었습니다. (단순함을 위해 Eager Initialization으로 구현했습니다)
getBean 메소드를 이용하면, Configuration 클래스의 메소드들을 뒤져서 빈을 생성해주는 형식으로 구현했습니다.
이점
private NumberGenerator numberGenerator() {
int randomMinInclusive = 0;
int randomMaxInclusive = 9;
return new RandomNumberGenerator(randomMinInclusive, randomMaxInclusive);
}
아까처럼 0과 9라는 상수를 Main이 아는 것이 아니라, Configuration 클래스가 알게 되어서 좀 더 역할 분담이 잘 되지 않았나 싶습니다.
또, Main에서 직접 서비스 객체 등을 생성하는 것이 아니라 로직을 호출하기만 하면 되기 때문에 더 보기 깔끔한 것 같습니다
전략 패턴을 활용해 랜덤을 추상화하고 테스트성 높이기
지난 주차에서 랜덤에 대한 테스트가 어려웠다고 회고했습니다.
도메인 객체에서 Randoms라는 클래스의 static 메소드를 그대로 사용했기 때문인데요.
이번에는 랜덤을 뽑는 방법을 전략 패턴을 이용해서 의존성을 주입받도록 수정해보았습니다.
public class NumberGeneratorEngine implements CarEngine {
private static final int MOVE_BOUNDARY = 4;
private final NumberGenerator generator;
public NumberGeneratorEngine(NumberGenerator generator) {
this.generator = generator;
}
@Override
public boolean move() {
return generator.generate() >= MOVE_BOUNDARY;
}
}
NumberGeneratorEngine
은 NumberGenerator
라는 녀석을 받아서 4 이상이면 move합니다.
이 NumberGenerator는 일종의 전략으로, 인터페이스를 통해 추상화해서 의존성을 주입받도록 구현했습니다.
@FunctionalInterface
public interface NumberGenerator {
int generate();
}
NumberGenerator는 아예 @FunctionalInterface
로 만들었습니다.
테스트 할 때에 그냥 람다로 쓱싹 stub 객체를 갖다 넣겠다는 마인드입니다.
public class RandomNumberGenerator implements NumberGenerator {
private final int minInclusive;
private final int maxInclusive;
public RandomNumberGenerator(int minInclusive, int maxInclusive) {
this.minInclusive = minInclusive;
this.maxInclusive = maxInclusive;
}
@Override
public int generate() {
return Randoms.pickNumberInRange(minInclusive, maxInclusive);
}
}
그래서 현실의 운영코드에서는 이 랜덤 생성기를 넣어서 랜덤으로 자동차가 움직이도록 하고,
@ParameterizedTest
@DisplayName("NumberGenerator 엔진은 generator에게 숫자를 뽑아서 4 이상이면 true를 반환한다.")
@ValueSource(ints = { 4, 5, 6, 7, 8, 9 })
void number_generator_engine_move_test(int generatedNumber) {
// given
NumberGenerator generator = () -> generatedNumber;
// when
CarEngine numberGeneratorEngine = new NumberGeneratorEngine(generator);
boolean moveResult = numberGeneratorEngine.move();
// then
assertThat(moveResult).isTrue();
}
테스트할 때에는 이렇게 람다를 통해서 아~주 간단하게 가짜 숫자 생성기를 만들어서 테스트하는거죠.
아쉬운 점
여전히 Output은 테스트하기 어렵다
input은 인터페이스를 통해 값을 얻어오도록 추상화하면 서비스 로직을 테스트하기 쉬웠지만, output은 여전히 테스트하기 어렵더라구요.
print 메소드들은 다 void이기 때문입니다.
흠.. 요 부분에 대해서는 계속 고민하고 있습니다.
여전히 Input은 테스트하기 어렵다
이게 왠 소리냐?
저는 input을 인터페이스를 통해 추상화해서 서비스 로직을 테스트하기 쉽도록 만들었습니다..
하지만 콘솔로 값을 입력받는 클래스에 대해서는 여전히 테스트하기 어려웠습니다.
이 클래스에선 여러 validation도 진행되고 있는 만큼, 테스트하고 싶다는 욕구가 강합니다만은 여전히 이 부분도 어렵네요.
정리하며
최근 학교 전공 수업으로 디자인 패턴을 배우고 있습니다.
수업 때는 그냥 멍 때리면서 들었는데 또 이렇게 활용해봄직한 예제가 나오니 재미가 있는 것 같네요
3주차도 기대됩니다