피드백 정리
2주차는 1주차 백엔드 공통 피드백과 코드 리뷰, 그리고 스스로 개선/학습하고 싶었던 부분까지 모두 정리했다. 피드백을 최대한 반영하고자 하는 것이 목표였다.
백엔드 공통 피드백
- 요구 사항을 정확하게 준수한다. ✅
- 기본적인 Git 명령어를 숙지한다. ✅
- Git으로 관리할 자원을 고려한다. ✅
- 커밋 메시지를 의미 있게 작성한다. ✅
- 커밋 메시지에 이슈 또는 풀 리퀘스트 번호를 포함하지 않는다. ✅
- 풀 리퀘스트를 만든 후에는 닫지 말고 추가 커밋을 한다. ✅
- 오류를 찾을 때 출력 함수 대신 디버거를 사용한다. ✅
- 이름을 통해 의도를 드러낸다. ✅
- 축약하지 않는다. ✅
- 공백도 코딩 컨벤션이다. ✅
- 공백 라인을 의미 있게 사용한다.
- 스페이스와 탭을 혼용하지 않는다. ✅
- 의미 없는 주석을 달지 않는다. ✅
- 코드 포매팅을 사용한다. ✅
- Java에서 제공하는 API를 적극 활용한다 ✅
- 배열 대신 컬렉션을 사용한다. ✅
코드 리뷰
- 기능 목록 관련
- 입력 / 로직 / 검증 / 계산 / 출력으로 나누어서 작성해보기 ✅
- git 커밋의 feature 단위와 유사하게 작성해보기 ✅
- try-finally 안에 Console.close() 선언을 통해 리소스 관리하기 ✅
- 조건문(if문)은 긍정문으로 표현하기 🔼
- 상수화 꼼꼼히 하기 ✅
- 에러 메시지가 많아질 경우, “[ERROR]”와 같은 부분은 상수로 분리하기 ✅
- 인스턴스/메서드가 각 클래스의 책임에 맞는지 생각해보기 ✅
- 일급컬렉션 활용하기 ✅
- 외부에서 호출하지 않는 생성자는 private 처리하기 ✅
- 메서드 네이밍과 변수 네이밍을 문제 해결 과정 속에서 의미있는 이름으로 짓기 ✅
- 정규표현식 사용 시, 그룹핑 활용해보기 🔼
- Pattern.quote()도 학습해보기
- Pattern.compile()의 비용
- Stream reduce(), peek()도 학습해보기 🔼
- 명시적으로 예외 던지기 ✅
- 와일드카드 임포트 지양하기 ✅
- 검증은 생성자에서 해야할까, 정적 팩토리 메서드에서 해야할까? ✅
- 유틸리티 클래스의 기준 ✅
- 클래스에 final 키워드 추가
- 생성자 private으로 제한
- 메서드에 static 키워드 추가
스스로 개선/학습하고 싶은 부분
- "할 수 있는 것만 하는 사람"이 아니라, "해본 적 없던 일에도 하나씩 도전하는 사람"이 되어보자. ✅
- 요구사항 더 꼼꼼하게 분석하기 ✅
- 도메인 규칙 고려하기 ✅
- 테스트케이스를 더 꼼꼼히 작성하기 ✅
- 객체에 메시지를 보내자 ✅
1주차가 종료되고, 하나씩 체크해보니 Pattern과 Stream에 대해 더 학습해보는 것이 부족했던 것 같다. 3주차 때는 이 부분을 보완할 예정이다! 또한 조금 더 깊이 있게 나만의 결론을 내릴 수 있도록, 학습에 매일 꾸준히 시간을 쏟아야한다는 생각이 들었다.
2주차에 고민했던 부분들
공통 피드백에서 네오님이 해주셨던 말씀과 같이 "내가 해본 적 없던 일은 무엇일까?"를 고민하며 2주차 미션에 집중했다. 고민했던 부분들은 아래와 같다.
요구사항 더 자세히 분석하기
요구사항을 살펴보다보면, 명시되지 않은 부분들이 존재한다. 1주차를 경험하며 이러한 부분들을 놓치지 않도록 더 꼼꼼하게 생각해봐야겠다는 생각이 들었고, 아래와 같이 정리했다.
입력 처리 관련
- 전체 입력에서 쉼표가 연속으로 여러번 입력된 경우 (ex - pobi,,woni,jun)
- 판단: 잘못된 형식의 입력으로 간주하고, 예외를 발생시킨다.
- 쉼표가 아닌 다른 구분자가 입력된 경우 (ex - pobi.woni.jun 또는 pobi,woni.jun)
- 판단: 잘못된 형식의 입력으로 간주하고, 예외를 발생시킨다.
- 전체 입력의 양 끝에 쉼표가 입력된 경우 (ex - ,pobi,woni,jun,,)
- 판단: 잘못된 형식의 입력으로 간주하고, 예외를 발생시킨다.
입력받은 자동차 관리 관련
- 자동차 이름의 양 끝에 공백이 입력되었을 경우 (ex - pobi , woni,jun)
- 판단: 공백을 제거하고 이름을 관리한다.
- 자동차 이름의 중간에 공백이 입력되었을 경우 (ex - po bi,woni,jun)
- 판단: 5자 이하 이름의 중간에 공백은 식별성을 저하할 수 있으니, 잘못된 형식의 입력으로 간주하고 예외를 발생시킨다.
- 자동차 이름은 숫자와 기호도 가능한가?
- 판단: 기호는 식별성을 저하할 수 있으니, 문자와 숫자만 허용한다.
- 이름이 중복되어 입력되었을 경우 (ex - pobi,pobi,woni)
- 판단: 중복은 허용하지 않는다. 중복된 이름으로 간주하고, 예외를 발생시킨다.
- 자동차 수의 최대 크기
- 판단: 최대 10명까지 경주에 참여할 수 있다.
- 시도할 횟수의 최대 크기
- 판단: 제한을 두지 않는다. 단, 0보다 커야 한다.
자동차의 전진/정지 처리 관련
- 각 자동차는 동시에 움직이는가, 순차적으로 움직이는가?
- 판단: 이름을 저장하고 있는 컬렉션을 순회하며 움직이고, 출력한다.
출력 처리 관련
- 우승자 출력 시, 순서는 입력순대로 하는가?
- 판단: 그렇다. 입력을 보장하고, 중복을 허용하지 않는 자료구조를 적용하는 것이 좋을 것 같다.
위와 같이 정리하니 각 도메인별로 해당하는 예외상황이 머릿속에 정리되었고, TDD를 하며 예외상황을 더 구체화 하고 정리할 수 있었다.
예외케이스도 하나의 커밋 단위로 봐야할까?
예외케이스 하나당, 하나의 커밋을 진행해야할지 고민되었다. 하지만 동작하지 않는 커밋은 기능이 아니라 판단했고, 요구사항을 최소한으로 분리하되 해당하는 예외 케이스를 함께 커밋하는 것으로 결정했다. 또한 입출력도 동작하는 하나의 기능에 포함된다고 판단되면 함께 커밋했다. 기능 목록은 아래와 같이 작성했다.
입력 처리
- 자동차 이름을 입력받는다.
- 자동차 이름은 쉼표(,)를 기준으로 구분된다.
- [예외상황] 전체 입력이 빈 값인 경우
- [예외상황] 쉼표(,)를 기준으로 올바르게 입력되지 않은 경우
- 전체 입력의 양 끝에 쉼표가 입력된 경우
- 쉼표가 아닌 다른 구분자가 입력된 경우
- 쉼표가 연속으로 입력된 경우
- 시도할 횟수를 입력받는다.
- [예외상황] 아무것도 입력되지 않은 경우
- [예외상황] 공백이 포함되어 입력된 경우
- [예외상황] 숫자가 아닌 경우
- [예외상황] 시도할 횟수가 0 이하일 경우
입력받은 자동차 관리
- 자동차에 유효한 이름을 부여받을 수 있다.
- [예외상황] 자동차 이름이 5자 이하가 아닌 경우
- [예외상황] 이름의 중간에 공백이 포함된 경우
- [예외상황] 문자와 숫자가 입력되지 않은 경우
- 입력받은 여러 자동차를 관리할 수 있다.
- [예외상황] 자동차 이름이 중복될 경우
- [예외상황] 자동차 수가 10대를 초과했을 경우
- 사용자가 잘못된 값을 입력할 경우 IllegalArgumentException을 발생시킨 후 애플리케이션은 종료되어야 한다.
무작위 값 처리
- 0에서 9 사이의 무작위 값을 구한다.
자동차의 전진/정지 처리
- 주어진 횟수 동안 n대의 자동차는 전진 또는 정지할 수 있다.
- 무작위 값이 4 이상 9 이하일 경우 전진한다.
- 무작위 값이 0 이상 3 이하일 경우 정지한다.
우승자 선발 처리
- 가장 많이 전진한 자동차가 우승한다.
- 우승자는 한 명 이상일 수 있다.
출력 처리
- 경주할 자동차 이름 입력 안내 메시지를 출력한다.
- 시도할 횟수 입력 안내 메시지를 출력한다.
- 전진하는 자동차를 출력할 때 자동차 이름을 같이 출력한다.
- 자동차 경주 게임을 완료한 후 누가 우승했는지를 알려준다.
- 우승자가 여러 명일 경우 쉼표(,)를 이용하여 구분한다.
자동차 이름 검증을 어떻게 해야할까?
입력값을 split(",")으로 나눈 후 검증 해야겠다고 생각했다. 하지만 split()은 모든 엣지 케이스를 처리해주지 않았다. 단순히 구분자로 나누기만 할 뿐, 빈 문자열이나 양 끝의 쉼표를 검증하지 않았다. 따라서 해당 엣지케이스를 모두 Cars 도메인 내에서 일일이 검증했다.
(1) 빈 값 검증
(2) 쉼표로 시작/끝나는지 검증
(3) 쉼표가 포함되어 있는지 검증
(4) split 후 빈 이름이 있는지 검증
리뷰를 통해 알게된 내용인데, isBlank()를 사용하면 trim() 없이도 검증할 수 있다. 자주 사용하는 String API에 대해 깊이 있게 학습해야겠다는 생각이 들었다.
// 입력이 비어있을 경우 (ex - "", " ")
private static void validateIsEmpty(String carNames) {
if (Objects.isNull(carNames) || carNames.isEmpty()) {
throw new IllegalArgumentException(CARS_INPUT_EMPTY.getMessage());
}
}
// 입력의 맨 앞 또는 맨 끝에 구분자가 있을 경우 (ex - ",pobi,woni,jun", "pobi,woni,jun,,")
private static void validateEdgeWithComma(String carNames) {
if (carNames.startsWith(",") || carNames.endsWith(",")) {
throw new IllegalArgumentException(CARS_INPUT_INVALID_COMMA.getMessage());
}
}
// 다른 구분자가 입력되었을 경우 (ex - "pobi:woni")
private static void validateOtherDelimiter(String carNames) {
if (!carNames.contains(",")) {
throw new IllegalArgumentException(CARS_INPUT_INVALID_COMMA.getMessage());
}
}
// 자동차 이름에 빈 값이 있을 경우 (ex - "pobi,,woni")
private static void validateNoEmptyName(String[] carNames) {
for (String name : carNames) {
if (name.trim().isEmpty()) {
throw new IllegalArgumentException(CARS_INPUT_INVALID_COMMA.getMessage());
}
}
}
위와 같이 순차적으로 검증하니, 어떤 입력이 잘못되었는지 명확하게 처리할 수 있었다. 하지만 Cars 객체의 책임이 너무 많다는 문제점이 있었다.
초기 Cars 클래스
// Cars.java
public class Cars {
private final List<Car> cars;
public Cars(String carNames) {
validate(carNames);
cars = addCar(carNames);
}
public void validate(String carNames) {
validateIsEmpty(carNames);
validateEdgeWithComma(carNames);
validateOtherDelimiter(carNames);
}
public List<Car> getCars() {
return cars;
}
public List<Car> addCar(String carNames) {
List<String> cars = parseCarNames(carNames.trim());
validateDuplicate(cars);
validateCarCount(cars);
return createCars(cars);
}
public void moveAll() {
for (Car car : cars) {
int randomNumber = RandomGenerator.generateNumber();
car.move(randomNumber);
}
}
public List<String> findWinners() {
int maxPosition = getMaxPosition();
return cars.stream()
.filter(car -> car.isMaxPosition(maxPosition))
.map(Car::getName)
.toList();
}
public int size() {
return cars.size();
}
private List<String> parseCarNames(String carNames) {
String[] extractCarNames = carNames.split(",");
validateNoEmptyName(extractCarNames);
return Arrays.stream(extractCarNames)
.map(String::trim)
.collect(Collectors.toList());
}
private List<Car> createCars(List<String> cars) {
return cars.stream()
.map(Car::new)
.toList();
}
private int getMaxPosition() {
return cars.stream()
.mapToInt(Car::getPosition)
.max()
.orElse(0);
}
private void validateIsEmpty(String carNames) {
if (Objects.isNull(carNames) || carNames.isEmpty()) {
throw new IllegalArgumentException("입력이 빈 값입니다.");
}
}
private void validateEdgeWithComma(String carNames) {
if (carNames.startsWith(",") || carNames.endsWith(",")) {
throw new IllegalArgumentException("쉼표(,)를 기준으로 올바르게 입력해주세요.");
}
}
private void validateOtherDelimiter(String carNames) {
if (!carNames.contains(",")) {
throw new IllegalArgumentException("쉼표(,)를 기준으로 올바르게 입력해주세요.");
}
}
private void validateNoEmptyName(String[] carNames) {
for (String name : carNames) {
if (name.trim().isEmpty()) {
throw new IllegalArgumentException("쉼표(,)를 기준으로 올바르게 입력해주세요.");
}
}
}
private void validateDuplicate(List<String> cars) {
Set<String> notDuplicateCars = new HashSet<>(cars);
if (cars.size() != notDuplicateCars.size()) {
throw new IllegalArgumentException("자동차 이름은 중복되지 않아야 합니다.");
}
}
private void validateCarCount(List<String> cars) {
if (cars.size() > 10) {
throw new IllegalArgumentException("자동차 수는 10대까지 가능합니다.");
}
}
}
따라서 CarNameParser 클래스에 파싱과 검증 로직을 위임했다. 또한 정적 팩토리 메서드를 도입해 해당 로직을 호출했다. 약 110라인에서 70라인으로 클래스를 작게 유지할 수 있었다. 이 때 검증 로직의 위치에 대한 고민도 함께 했는데 비즈니스 로직에 관련된 검증은 해당 도메인 내에서, 입력 로직과 관련된 검증은 Parser에서 진행했다. 이 부분에 대해서는 다른 프리코스 참여자분들/스터디원분들과 토론을 통해 더 얘기해보고 싶다.
수정 이후 Cars 클래스
// Cars.java
public class Cars {
private static final int MAX_CARS_SIZE = 10;
private static final String LF = "\n";
private final List<Car> cars;
private Cars(List<Car> cars) {
validate(cars);
this.cars = List.copyOf(cars);
}
public static Cars from(String carNames) {
List<Car> cars = CarNameParser.parse(carNames);
return new Cars(cars);
}
public List<Car> getCars() {
return cars;
}
public void moveAll() {
for (Car car : cars) {
int randomNumber = RandomGenerator.generateNumber();
car.move(randomNumber);
}
}
public List<String> getCarInfos() {
return cars.stream()
.map(Car::toString)
.toList();
}
public List<String> findWinners() {
int maxPosition = getMaxPosition();
return cars.stream()
.filter(car -> car.isMaxPosition(maxPosition))
.map(Car::getName)
.toList();
}
private int getMaxPosition() {
return cars.stream()
.mapToInt(Car::getPosition)
.max()
.orElse(0);
}
private void validate(List<Car> cars) {
validateDuplicate(cars);
validateCarCount(cars);
}
private void validateDuplicate(List<Car> cars) {
Set<Car> notDuplicateCars = new HashSet<>(cars);
if (cars.size() != notDuplicateCars.size()) {
throw new IllegalArgumentException(CARS_DUPLICATE_NAME.getMessage());
}
}
private void validateCarCount(List<Car> cars) {
if (cars.size() > MAX_CARS_SIZE) {
throw new IllegalArgumentException(CARS_EXCEED_LIMIT.getMessage());
}
}
@Override
public String toString() {
return cars.stream()
.map(Car::toString)
.collect(Collectors.joining(LF));
}
}
자동차의 전진/정지 처리는 어떻게 해야 할까?
초반에는 Cars와 TrialCount를 관리하는 Race 클래스를 만들어야 할지, 아니면 무작위 값이 4 이상일 때 Map에 자동차의 이름과 "-"를 추가하는 방식으로 구현해야 할지 고민되었다. 하지만 Map을 사용하니 remove()와 같은 불필요한 메서드까지 노출되었고 전진 판단 로직이 Car가 아닌 Race에 존재했다. 또한 Race 클래스 내에 3개 이상의 인스턴스 변수가 존재했다.
초기 Race 클래스
// Race.java
public class Race {
private final Cars cars;
private final TrialCount trialCount;
private final Map<Car, String> race;
public Race(Cars cars, TrialCount trialCount) {
this.cars = cars;
this.trialCount = trialCount;
race = new LinkedHashMap<>();
}
public void start() {
List<Car> carNames = cars.getCars();
registerCar(carNames);
for (int raceCount = 0; raceCount < trialCount.getTrialCount(); raceCount++) {
for (int carCount = 0; carCount < cars.size(); carCount++) {
int randomNumber = RandomGenerator.generateNumber();
if (randomNumber >= 4) {
race.compute(carNames.get(carCount), (car, raceState) -> raceState + "-");
}
}
for (Map.Entry<Car, String> state : race.entrySet()) {
System.out.println(state.getKey().getName() + " : " + state.getValue());
}
System.out.println();
}
}
private void registerCar(List<Car> carNames) {
for (Car carName : carNames) {
race.put(carName, race.getOrDefault(carName, ""));
}
}
}
초기 Car 클래스
// Car.java
public class Car {
private final String name;
public Car(String carName) {
validate(carName.trim());
this.name = carName;
}
public int getName() {
return name;
}
private void validate(String carName) {
validateNameLength(carName.length());
validateInsideWhiteSpace(carName);
validateNameFormat(carName);
}
private void validateNameLength(int carNameLength) {
if (carNameLength > 5) {
throw new IllegalArgumentException("자동차 이름은 5자 이하만 가능합니다.");
}
}
private void validateInsideWhiteSpace(String carName) {
if (carName.contains(" ")) {
throw new IllegalArgumentException("자동차 이름에 공백이 포함되어 있습니다.");
}
}
private void validateNameFormat(String carName) {
if (!carName.matches("^[a-zA-Z가-힣0-9]+$")) {
throw new IllegalArgumentException("자동차 이름은 문자와 숫자만 가능합니다.");
}
}
}
따라서 Car 클래스에 position 인스턴스를 추가해 Race가 맡은 책임을 분리했다. 이렇게 하니 랜덤 값에 대한 테스트도 용이해지고 위 문제들을 해결할 수 있었다.
수정 이후 Race 클래스
// Race.java
public class Race {
private final Cars cars;
private final TrialCount trialCount;
public Race(Cars cars, TrialCount trialCount) {
this.cars = cars;
this.trialCount = trialCount;
}
public Cars getCars() {
return cars;
}
public void playRound() {
for (Car car : cars.getCars()) {
int randomNumber = RandomGenerator.generateNumber();
car.move(randomNumber);
}
}
public List<String> judgeWinners() {
return cars.getCars().stream()
.filter(car -> car.getPosition() == cars.getMaxPosition())
.map(Car::getName)
.toList();
}
}
수정 이후 Car 클래스
// Car.java
public class Car {
private final String name;
private int position;
public Car(String carName) {
validate(carName.trim());
this.name = carName;
this.position = 0;
}
public String getName() {
return name;
}
public int getPosition() {
return position;
}
public void move(int randomNumber) {
if (randomNumber >= 4) {
this.position += 1;
}
}
private void validate(String carName) {
validateNameLength(carName.length());
validateInsideWhiteSpace(carName);
validateNameFormat(carName);
}
private void validateNameLength(int carNameLength) {
if (carNameLength > 5) {
throw new IllegalArgumentException("자동차 이름은 5자 이하만 가능합니다.");
}
}
private void validateInsideWhiteSpace(String carName) {
if (carName.contains(" ")) {
throw new IllegalArgumentException("자동차 이름에 공백이 포함되어 있습니다.");
}
}
private void validateNameFormat(String carName) {
if (!carName.matches("^[a-zA-Z가-힣0-9]+$")) {
throw new IllegalArgumentException("자동차 이름은 문자와 숫자만 가능합니다.");
}
}
@Override
public String toString() {
return name + " : " + "-".repeat(position);
}
}
파라미터에 final을 명시해주는 이유는?
코드 리뷰를 주고받으면서, 파라미터에 final을 붙인 코드를 볼 수 있었다. 아래는 간단한 예시이다.
public void move(final int randomNumber) {
if (randomNumber >= 4) {
this.position += 1;
}
}
이에 대해 나는 명시적으로 붙이지 않기로 했다. Java에서 파라미터는 기본적으로 값으로 전달되고, 재할당을 거의 하지 않기 때문에 final 없이도 충분히 의도가 명확하다고 판단했다. 대신 필드에는 불변성을 명확히 하기 위해 final을 붙였다. 또한 값이 변경되는 것을 막기 위해 1주차 때 학습했던 방어적 복사를 도입했다.
getter의 사용을 지양하라?
토론하기 커뮤니티에 'getter 메서드 지양'이라는 글이 올라왔다. 해당 자료를 들여보다보니 "getter 대신 객체에 메시지를 보내자"라는 문장이 눈에 띄었다. 쉽게 와닿지 않아 MVP를 구현한 이후 리팩토링 과정에서, 무분별하게 사용했던 getter를 수정하며 어떻게 객체에 메시지를 보내는지 학습했다.
기존에는 각 자동차의 최대 위치를 확인해 우승자를 판별할 때, getPosition()을 사용해 위치를 확인했다면 이를 isMaxPosition()으로 수정했다. 외부에서 데이터를 꺼내서 판단하는 것이 아니라, 객체에게 "너 최대 위치니?"라고 물어보는 것이 더 객체지향적이라는 점을 깨달았다. 출력용 getter는 허용하되 판단/비교에 사용되는 getter는 메시지로 대체하는 것이 실용적이라는 결론도 내렸다.
이에 대한 내용은 아래 블로그에서 따로 더 자세히 다루었다.
https://jihyun-devstory.tistory.com/61
랜덤 값 테스트를 어떻게 해야할까?
RandomGenerator를 테스트하려니 매번 다른 값이 나와 예측할 수 없었다. 이 때, 제공된 NsTest 내부를 살펴보니 assertRandomNumberInRangeTest라는 메서드가 존재했다. Mockito를 활용해 랜덤값을 고정시켜주는 테스트였다.
assertRandomNumberInRangeTest
public static void assertRandomNumberInRangeTest(
final Executable executable,
final Integer value,
final Integer... values
) {
assertRandomTest(
() -> Randoms.pickNumberInRange(anyInt(), anyInt()),
executable,
value,
values
);
}
RandomGenerator 클래스
public final class RandomGenerator {
private RandomGenerator() {}
public static int generateNumber() {
return Randoms.pickNumberInRange(0, 9);
}
}
RandomGenerator는 단순 wrapper라 별도 테스트가 불필요하다 판단했다. 대신 랜덤값에 의존하는 실제 비즈니스 로직을 위 테스트를 활용해 통합 테스트로 검증하는 것이 더 의미 있다는 결론을 내렸다. 통합 테스트를 처음으로 시도해보며 단위 테스트로는 발견하기 어려운 흐름상의 버그도 찾을 수 있었다.
class RaceIntegrationTest {
@Test
@DisplayName("랜덤값을 고정해 전체 경주 로직의 흐름을 검증한다.")
void 랜덤값_고정_통합_테스트() {
assertRandomNumberInRangeTest(
() -> {
Cars cars = Cars.from("pobi,woni,jun");
cars.moveAll();
assertThat(cars.getCars())
.extracting(Car::getName)
.containsExactly("pobi", "woni", "jun");
assertThat(cars.getCars().get(0).getPosition()).isEqualTo(1);
assertThat(cars.getCars().get(1).getPosition()).isEqualTo(0);
assertThat(cars.getCars().get(2).getPosition()).isEqualTo(1);
cars.moveAll();
assertThat(cars.getCars().get(0).getPosition()).isEqualTo(2);
assertThat(cars.getCars().get(1).getPosition()).isEqualTo(1);
assertThat(cars.getCars().get(2).getPosition()).isEqualTo(1);
assertThat(cars.findWinners()).containsExactly("pobi");
},
4, 3, 4,
5, 4, 3
);
}
@Test
@DisplayName("공동 우승을 검증한다.")
void 공동_우승_검증() {
assertRandomNumberInRangeTest(
() -> {
Cars cars = Cars.from("pobi,woni");
TrialCount trialCount = new TrialCount(3);
for (int i = 0; i < trialCount.trialCount(); i++) {
cars.moveAll();
}
assertThat(cars.findWinners()).containsExactly("pobi", "woni");
assertThat(cars.getCars().get(0).getPosition()).isEqualTo(3);
assertThat(cars.getCars().get(1).getPosition()).isEqualTo(3);
},
4, 4,
4, 4,
4, 4
);
}
}
입/출력 테스트
Console 클래스를 처음으로 테스트 해보고 싶었고, 표준 입출력을 제어하는 방법을 찾아야 했다. 따라서 제공된 NsTest 클래스를 분석하며 입출력을 테스트하는 방법을 학습했다.
(1) run("pobi,woni", "1"): 입력을 순서대로 주입
(2) output(): 출력된 내용을 String으로 가져옴
(3) runMain(): 실제 실행할 메인 로직 정의
public abstract class NsTest {
private PrintStream standardOut;
private OutputStream captor;
@BeforeEach
protected final void init() {
standardOut = System.out;
captor = new ByteArrayOutputStream();
System.setOut(new PrintStream(captor));
}
@AfterEach
protected final void printOutput() {
System.setOut(standardOut);
System.out.println(output());
}
protected final String output() {
return captor.toString().trim();
}
protected final void run(final String... args) {
try {
command(args);
runMain();
} finally {
Console.close();
}
}
protected final void runException(final String... args) {
try {
run(args);
} catch (final NoSuchElementException ignore) {
}
}
private void command(final String... args) {
final byte[] buf = String.join("\n", args).getBytes();
System.setIn(new ByteArrayInputStream(buf));
}
protected abstract void runMain();
}
위 메서드를 활용해 입출력 테스트를 구현했고, 입출력에 대한 전체 흐름이 내가 의도한대로 올바르게 작동하는지 확인할 수 있었다.
ApplicationTest
class ApplicationTest extends NsTest {
private static final int MOVING_FORWARD = 4;
private static final int STOP = 3;
@Test
void 기능_테스트() {
assertRandomNumberInRangeTest(
() -> {
run("pobi,woni", "1");
assertThat(output()).contains("pobi : -", "woni : ", "최종 우승자 : pobi");
},
MOVING_FORWARD, STOP
);
}
@Test
void 기능_테스트_공동_우승자() {
assertRandomNumberInRangeTest(
() -> {
run("pobi,woni,jun", "5");
assertThat(output()).contains(
"pobi : -",
"woni : ",
"jun : -",
"pobi : --",
"woni : -",
"jun : --",
"pobi : ---",
"woni : --",
"jun : ---",
"pobi : ----",
"woni : ---",
"jun : ----",
"pobi : -----",
"woni : ----",
"jun : -----",
"최종 우승자 : pobi, jun"
);
},
MOVING_FORWARD, STOP, MOVING_FORWARD,
MOVING_FORWARD, MOVING_FORWARD, MOVING_FORWARD,
MOVING_FORWARD, MOVING_FORWARD, MOVING_FORWARD,
MOVING_FORWARD, MOVING_FORWARD, MOVING_FORWARD,
MOVING_FORWARD, MOVING_FORWARD, MOVING_FORWARD
);
}
@Test
void 기능_테스트_단독_우승자() {
assertRandomNumberInRangeTest(
() -> {
run("pobi,woni", "3");
assertThat(output()).contains(
"pobi : -",
"woni : ",
"pobi : --",
"woni : ",
"pobi : ---",
"woni : -",
"최종 우승자 : pobi"
);
},
MOVING_FORWARD, STOP,
MOVING_FORWARD, STOP,
MOVING_FORWARD, MOVING_FORWARD
);
}
@Test
void 기능_테스트_모두_정지() {
assertRandomNumberInRangeTest(
() -> {
run("pobi,woni", "2");
assertThat(output()).contains(
"pobi : ",
"woni : ",
"최종 우승자 : pobi, woni"
);
},
STOP, STOP,
STOP, STOP
);
}
@Test
void 예외_테스트() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException("pobi,javaji", "1"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@Test
void 예외_테스트_자동차_이름_빈값() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException("pobi,,woni", "1"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@Test
void 예외_테스트_시도_횟수_0() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException("pobi,woni", "0"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@Test
void 예외_테스트_시도_횟수_음수() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException("pobi,woni", "-1"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@Test
void 예외_테스트_시도_횟수_문자열() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException("pobi,woni", "abc"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@Test
void 예외_테스트_자동차_이름_쉼표로_시작() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException(",pobi,woni", "1"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@Test
void 예외_테스트_자동차_이름_쉼표로_끝() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException("pobi,woni,", "1"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@Test
void 예외_테스트_자동차_이름_양끝에_쉼표() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException(",pobi,woni,jun,,", "1"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@Test
void 예외_테스트_자동차_이름_다른_구분자() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException("pobi.woni", "1"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@Test
void 예외_테스트_자동차_이름_공백만() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException(" ", "1"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@Test
void 예외_테스트_자동차_이름_중간에_공백() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException("po bi,woni", "1"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@Test
void 예외_테스트_중복된_자동차_이름() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException("pobi,woni,pobi", "1"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@Override
public void runMain() {
Application.main(new String[]{});
}
}
2주차 점검
1주차 때 재설정했던 목표가 성장할 수 있는 방향인지 돌아보기 위해, 2주차를 점검했다.
미션 시작 전과 제출 전 요구사항을 5번씩 읽고, 체크리스트로 꼼꼼히 확인하기
1주차가 종료되고, 요구사항 2가지를 빠뜨렸다는 점을 코드 리뷰를 통해 깨달았다. 재발생하지 않도록 2주차 때는 README에 기능/입출력/프로그래밍 요구사항 체크리스트를 만들고, 여러번 읽어가며 꼼꼼하게 확인했다.
매 주 최소 1가지 이상 학습하고, 블로깅하여 디스코드에 공유하기
1주차 미션에서 문자열 계산기 로직을 구현하는 과정에서 정규표현식에 대해 학습했다. 이를 블로그에 작성하고, 함께 나누기 커뮤니티에 공유했다. 학습한 내용을 다른 사람들과 공유하며 도움이 되었으면 하는 마음이 들었고, 뿌듯했다. 이번주는 'getter 없이 어떻게 테스트 하나요?'라는 블로그를 작성했다.
https://jihyun-devstory.tistory.com/59
[Java] 정규표현식(Regular Expression)
1. 정규표현식이 왜 필요할까?개발을 하다보면 사용자의 입력을 검증해야하는 순간이 온다. 그 형식은 다양하다. 전화번호 형식이라든지, 이메일 형식이라든지. 예를 들면 아래와 같다. 아래와
jihyun-devstory.tistory.com
https://jihyun-devstory.tistory.com/61
다른 프리코스 참여자분들과 상호 코드 리뷰 진행하기
1주차 미션이 종료되고 곧바로 커뮤니티에 PR 리뷰 요청 게시글을 기재했다. 스터디분들과도 리뷰를 진행했다. 생각한 것보다 이 과정이 재미있어 총 20분에게 코드 리뷰를 남겼고, 17명의 리뷰어와 128개의 코멘트를 주고받았다.리뷰를 남길 때 한 분당 약 20~30분의 시간이 소요되지만, 코드를 제대로 읽지 않고 리뷰를 남기면 깊게 고민할 수 없어 최대한 시간을 들이려고 한다.

혼자 학습을 할 때보다, 많은 사람들과 코드에 대한 다양한 견해와 자신만의 결론을 토론하는 과정이 뜻깊었다. 또한 새롭게 배운 부분들에 대해서는 나만의 결론을 내릴 수 있도록 의식적인 연습을 하고, 이미 알고있었던 것에 대해서는 다시 한번 생각해보는 계기가 되었다. 프리코스에서 코드 리뷰는 가장 중요한 활동 중 하나라는 생각이 들었다.
매 주 마주한 문제상황이나 고민을 기록하고, 회고 작성하여 공유하기
1주차 회고를 작성하고, 다시 돌아보기 커뮤니티에 공유했다. 2주차 역시 회고를 작성했다. 회고는 내가 구현하면서 고민했던 과정들을 더 자세하게 정리할 수 있어 학습했던 내용을 돌아보고, 점검하는데 도움이 되는 것 같다. 남은 3주간 꾸준히 올리며, 프리코스가 종료된 이후에도 지속적으로 블로깅 하는 습관을 길들이고 싶다.
https://jihyun-devstory.tistory.com/60
[우아한테크코스 8기] 1주차 회고
우아한테크코스 8기에 지원했다.약 5주간의 프리코스를 진행하며 고민했던 부분들을 적어나가려 한다. 1주차 미션은 문자열 계산기다. 기능 목록 작성과제 진행 요구 사항미션은 문자열 덧셈 계
jihyun-devstory.tistory.com
첫 시도 한 줄 챌린지 참여하기
해본 적 없던 일에 도전해보고 싶어 커뮤니티의 첫 시도 한 줄 챌린지에 참여했다. 2주차 때는 처음으로 기술 블로그도 공유해보고, 입/출력 및 통합 테스트를 구현해보고, 객체에 메시지를 보낼 수 있도록 직접 코드를 개선해보면서 방법을 조금씩 알게 되었다. 프리코스를 하며 이루고싶은 도전들에 대해 한 주에 1개 이상이라도 작성하니 동기 부여가 되었다. 3주차 때는 꼭 하드 스킬이 아니더라도, 소프트 스킬에 대한 도전도 올려보려고 한다!!



스터디
서울에서 진행된 오프라인 프리코스 스터디에 참여했다. TDD와 MVC 패턴 등에 대해 각자가 결론을 내린 부분들을 토론했다. 또한 코드 리뷰 할 때, 리뷰할 PR의 링크를 .com에서 .dev로 변경하거나 git에서 제공하는 ‘create codespace’를 사용하면 코드에 직접 리뷰를 달 수 있다는 사실도 알게 되었다. 다음주에는 짱감자라는 스터디명에 맞게 감자톡을 진행하기로 했다. 책 내용 또는 각자 주제 하나씩 발표 자료 준비해서 발표 하기로 했다. 또한 코드를 띄워놓고, 특정 부분에서 어떤 고민을 하며 구현했는지 라이브 코드 리뷰를 진행하기로 했다.
대전에서 서울까지 올라가야 했지만 함께 고민하고 토론하는 과정이 즐거워 스터디 하는 내내 시간 가는줄 몰랐다. 오히려 서울에 가야 할 이유가 생겼다는 게 설렘으로 다가왔다. 목표를 이루기 위해 열정적으로 프리코스에 임하는 분들과 함께하니, 혼자일 때보다 더욱 열심히 하게 되어 스터디에 참여하길 잘했다는 생각이 들었다.

회고
3주차 때는 오프라인 스터디에서 내가 고민했던 부분들을 더 자신있게 말하고 싶다. 6명의 스터디원들과 함께 애기를 나누다보면, 속으로 혼자 생각하는 나 자신을 보게 되었다. 매일 대전에서 혼자 학습하다 보니, 소프트스킬 역량이 낮아진 것이 원인인 것 같다. 따라서 3주차 오프라인 스터디에서는, 더 적극적으로 자신있게 생각을 말해보려 한다.
또한 '모던 자바 인 액션' 학습을 병행해보려 한다. 습관처럼 사용했던 Java API를 더 깊이있게 학습하고, 함수형 프로그래밍/람다/스트림과 같은 새로운 개념에 대해서도 학습해보고 싶기 때문이다.
시간 투자 부분에서는 생각보다 코드 리뷰에 많이 할애되고 있어, 프리코스에 더 많은 시간을 쏟아부어야겠다는 생각이 들었다. 회고와 기술 블로그 모두 주차가 종료되기 전에 기재하는 것이 목표이다. 프리코스가 종료되고 후회없도록 최선을 다하자.
구체화했던 프리코스 목표를 토대로 남은 3주동안 더 열심히 학습하고 공유하며, 프리코스 목표를 반드시 이루고 싶다. 열정적인 프리코스 참여자 분들과 함께 토론하며 서로의 성장에 큰 도움을 주고 있는 것 같아 이 시간이 즐겁다. 이런 프리코스가 우아한테크코스 체험판이라니 전보다 더욱 욕심이 커져간다.
2주차 PR 링크
https://github.com/woowacourse-precourse/java-racingcar-8/pull/294
[자동차 경주] 이지현 미션 제출합니다. by Jihyun3478 · Pull Request #294 · woowacourse-precourse/java-racingcar-
2주차도 고생하셨습니다!! 아래 고민한 부분을 중점적으로, 리뷰어분들은 어떤 고민과 선택을 하셨는지 함께 토론해보고 싶습니다! 꼭 아래에 해당되지 않아도 질문은 언제나 환영입니다!! 함께
github.com
'우아한테크코스 > 8기 프리코스' 카테고리의 다른 글
| [우아한테크코스 8기] 오픈미션 최종 회고 (0) | 2025.11.29 |
|---|---|
| [우아한테크코스 8기] 오픈 미션 중간 회고 (0) | 2025.11.17 |
| [우아한테크코스 8기] 프리코스 3주차 회고 (2) | 2025.11.04 |
| [우아한테크코스 8기] 프리코스 1주차 회고 (2) | 2025.10.20 |
