🚗 자동차 경주 게임 프로그래밍 소감문
이번 자동차 경주 게임을 구현하면서 가장 많이 고민했던 부분은 입력 문자열을 어떻게 정확하게 분리할 것인가, 그리고 이름과 점수, 회차 정보를 어떤 자료구조로 저장할 것인가였습니다. 단순히 문자열을 split(",")으로 나누는 것이 아니라, 연속된 구분자(,,), 빈 문자열, 정규표현식의 한계, limit 값의 차이 등 세밀한 상황까지 고려해야 했기 때문입니다.
🧩 1. String.split()의 한계를 직접 파악하다
처음에는 String.split(",")만 사용하면 된다고 생각했습니다.
하지만 테스트 중 "a,,b,c" 같은 입력에서 예상치 못한 결과가 나왔습니다.
이유를 찾기 위해 String 클래스의 실제 구현을 분석해보니,
split() 메서드는 내부적으로 다음과 같이 동작합니다.
public String[] split(String regex, int limit) {
return split(regex, limit, false);
}
여기서 limit 값에 따라 결과가 달라집니다.
limit > 0→ 패턴은(limit - 1)번까지만 적용limit == 0→ 가능한 한 많이 적용하되 마지막 빈 문자열은 제거limit < 0→ 가능한 한 많이 적용하며 빈 문자열도 모두 유지
즉, "a,,b"에서 빈 문자열을 유지하려면 limit을 음수로 설정해야 했습니다.
이해를 돕기 위해 간단한 코드 예시를 살펴보겠습니다.
String input = "a,,b,c";
// limit = -1: 모든 빈 문자열 유지
String[] result = input.split(",", -1); // {"a", "", "b", "c"}
System.out.println("limit = -1: " + Arrays.toString(result));
String inputWithTrailingComma = "a,b,c,,";
// limit = 0: 마지막 빈 문자열 제거
String[] resultNoTrailingEmpty = inputWithTrailingComma.split(",", 0); // {"a", "b", "c"}
System.out.println("trailing, limit = 0: " + Arrays.toString(resultNoTrailingEmpty));
// limit = -1: 모든 빈 문자열 유지
String[] resultWithTrailingEmpty = inputWithTrailingComma.split(",", -1); // {"a", "b", "c", "", ""}
System.out.println("trailing, limit = -1: " + Arrays.toString(resultWithTrailingEmpty));
그 결과, 쉼표가 연속되는 모든 상황에서 예외 처리를 정확히 수행할 수 있었습니다.
이 과정에서 splitWithDelimiters() 메서드도 함께 학습했습니다.
이 메서드는 단순히 문자열을 나누는 것뿐 아니라 구분자 자체도 배열에 포함시키기 때문에,
패턴의 반복이나 구분자 누락 여부를 더 정밀하게 탐지할 수 있었습니다.
예를 들어 "a,,b,c"를 splitWithDelimiters(",", -1)로 처리하면,
구분자가 연속될 때 빈 문자열이 생성된다는 점을 명확히 확인할 수 있었습니다.
// Apache Commons Lang의 StringUtils.splitPreserveAllTokens()는 이와 유사한 기능을 제공합니다.
// 예시: String[] parts = StringUtils.splitPreserveAllTokens("a,,b,c", ',');
// 결과: {"a", "", "b", "c"} 와 같이 모든 토큰을 유지합니다.
// 여기서는 `YourStringUtils`라는 가상의 유틸리티 클래스를 사용했다고 가정합니다.
String text = "a,,b,c";
String[] partsWithDelimiters = YourStringUtils.splitWithDelimiters(text, ",");
// 예상 결과는 사용된 라이브러리/구현에 따라 다를 수 있으나,
// 구분자 인근의 빈 문자열을 유지한다는 점에서 `split(regex, -1)`과 유사한 효과를 얻을 수 있습니다.
System.out.println("splitWithDelimiters 예시: " + Arrays.toString(partsWithDelimiters));
이 덕분에,
“쉼표가 반복되는 모든 입력을 예외로 처리한다”는 요구사항을 안정적으로 구현할 수 있었습니다.
⚙️ 2. 자료구조 설계의 고민: 이름, 회차, 점수의 저장 방식
두 번째로 가장 어려웠던 부분은 세 가지 정보를 한 번에 저장하는 구조를 설계하는 것이었습니다.
처음에는 Map<String, List<Integer>> 형태로 단순히 이름에 점수 리스트를 매핑하려 했지만,
“각 회차별 점수”라는 정보가 분리되지 않아 불편했습니다.
그래서 여러 대안을 비교했습니다.
| 방식 | 장점 | 단점 |
|---|---|---|
LinkedHashMap |
입력 순서 보장 | 중첩 구조 표현 어려움 |
TreeMap |
자동 정렬 | Comparable 필요, 복잡성 증가 |
Map<Map, Integer> |
구조적 표현 가능 | 가독성 떨어짐 |
✅ Map<CarKey, Integer> |
각 회차를 객체 키로 관리 | 명확한 책임 분리 가능 |
결국 객체 기반 키(CarKey)를 사용하는 HashMap 구조를 택했습니다. 이 방식은 name과 round를 합친 복합 키를 사용해 “누가 몇 번째 시도에서 몇 점을 얻었는지”를 명확하게 표현할 수 있었습니다.
CarKey는 다음과 같이 구현하여 name과 round를 조합한 유일한 키를 제공합니다. equals()와 hashCode()를 올바르게 오버라이드하여 HashMap에서 정확한 키 역할을 하도록 했습니다.
public class CarKey {
private final String name;
private final int round;
public CarKey(String name, int round) {
this.name = name;
this.round = round;
}
// 회차 수와 차 이름이 같은가
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CarKey carKey = (CarKey) o;
return round == carKey.round && Objects.equals(name, carKey.name);
}
@Override
public int hashCode() {
return Objects.hash(name, round);
}
}
이 CarKey를 활용하여 HashMap에 각 차의 특정 회차별 전진 점수를 저장하는 RacingPlayer 클래스의 일부입니다.
public class RacingPlayer {
private final HashMap<CarKey, Integer> records = new HashMap<>(); // CarKey를 키로, 전진 점수를 값으로 저장
private final List<String> names;
private final int times; // 총 시도 횟수
public RacingPlayer(List<String> names, int times) {
this.names = names;
this.times = times;
}
// 랜덤 전진 결과를 저장하는 메서드
public HashMap<CarKey, Integer> raceAndSaveResult() {
for (String name : this.names) {
int currentScore = 0; // 각 이름별 누적 점수
for (int round = 1; round <= this.times; round++) {
// 이 미션에서는 "각 회차별 전진 여부"를 기록하므로, score를 매번 초기화합니다.
int scoreForThisRound = 0; // 현재 회차의 전진 여부 (0 또는 1)
// 주어진 조건에 따라 0~9 사이의 난수를 생성
int randomNumber = Randoms.pickNumberInRange(0, 9);
if (randomNumber >= 4) { // 4 이상일 때 전진 (+1)
scoreForThisRound = 1;
}
// CarKey(자동차 이름, 현재 회차)를 키로 현재 회차의 전진 점수를 저장합니다.
this.records.put(new CarKey(name, round), scoreForThisRound);
}
}
return this.records;
}
// 이름과 최종 총 점수를 계산하고 우승자를 반환하는 메서드
public List<String> calculateTotalScores() {
HashMap<String, Integer> totalScores = new HashMap<>(); // 이름별 총점을 저장할 Map
// 각 자동차 이름별로 모든 회차의 점수를 합산합니다.
for (String name : names) {
int total = IntStream.rangeClosed(1, times) // 1부터 총 시도 횟수(times)까지의 IntStream을 생성
// getOrDefault를 사용하여 해당 CarKey가 없는 경우 0을 반환하여 NullPointerException을 방지합니다.
.map(round -> records.getOrDefault(new CarKey(name, round), 0))
.sum(); // 조회된 모든 회차의 점수를 합산
totalScores.put(name, total); // 자동차 이름과 총점을 totalScores 맵에 저장
}
int maxScore = getMaxScore(totalScores); // 최고 점수 계산
return getWinnersList(totalScores, maxScore); // 최고 점수와 동일한 모든 우승자 리스트 반환
}
private int getMaxScore(HashMap<String, Integer> totalScores) {
return Collections.max(totalScores.values()); // totalScores 맵에서 가장 높은 점수를 찾습니다.
}
// 최고 점수와 동일한 점수를 가진 자동차 이름들을 필터링하여 리스트로 반환합니다.
private List<String> getWinnersList(HashMap<String, Integer> totalScores, int maxScore) {
return totalScores.entrySet()
.stream()
.filter(entry -> entry.getValue() == maxScore)
.map(Map.Entry::getKey) // 필터링된 Entry에서 키(자동차 이름)만 추출
.toList(); // 추출된 키들을 List로 수집
}
}
위의 raceAndSaveResult() 메서드는 각 차가 회차별로 전진한 정보를 records 맵에 저장합니다. 이후 calculateTotalScores() 메서드에서는 IntStream.rangeClosed를 활용하여 1회차부터 times회차까지 반복하며 각 회차의 점수를 records에서 가져와 합산합니다. 여기서 getOrDefault를 사용하여 키가 존재하지 않을 경우 기본값 0을 반환하도록 처리하여 안정성을 높였습니다. 이렇게 수집된 총점들을 바탕으로 getMaxScore와 getWinnersList를 통해 공동 우승자를 효과적으로 찾을 수 있었습니다.
🧮 3. 랜덤 값 로직과 점수 합산
주어진 조건에 따라 0~9 사이의 난수를 반복 생성하고,
4 이상일 때만 전진(+1)하도록 구현했습니다.
이 로직을 단순 반복이 아니라,
회차를 기준으로 점진적으로 확장되는 구조로 짰다는 점이 핵심이었습니다.
예를 들어 시도 횟수가 3이라면,
각 이름에 대해 (1회차, 2회차, 3회차)의 누적 점수를 저장합니다.
이렇게 하면 모든 중간 결과를 검증하기 쉬운 구조가 됩니다.
🏁 4. 테스트와 예외 처리의 중요성
입력값 검증에서도 다양한 케이스를 고려했습니다.
- 이름이 5자를 초과하는 경우
- 쉼표 없이 입력된 경우
- 시도 횟수가 0이거나 음수, 너무 큰 경우
- 이름 목록이 지나치게 긴 경우 - 저의 경우 임의로 100글자로 설정했습니다
모든 입력이 유효하지 않은 경우 명시적인 예외를 발생시키고,
각 케이스에 대한 테스트 코드를 작성했습니다.
이를 통해 단순히 “동작하는 코드”가 아니라
“잘못된 입력에도 안정적으로 반응하는 코드”를 만들 수 있었습니다.
다음은 예외 처리를 위한 테스트 코드 중 일부입니다.
java
public class RacingCarTest extends NsTest {
@Test
void 다섯글자_이상의_이름에_예외를_던진다() {
//given
String[] longNames = new String[3];
for (int i = 0; i < 3; i++) {
longNames[i] = "qwerty"; // 6자 이름
}
assertThatThrownBy(() -> StringParser.parseCarNames(longNames))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Name Is Longer Than 5"); // 5자를 초과하면 예외 발생
}
@Test
void 정해진_수_이상의_차_이름에_예외를_던진다() {
// given
String[] tooManyNames = new String[101]; // 100개 초과
for (int i = 0; i < 101; i++) {
tooManyNames[i] = "qwert";
}
// when & then
assertThatThrownBy(() -> StringParser.parseCarNames(tooManyNames))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Too Much Input Names(More Than 100)"); // 100개 초과 시 예외 발생
}
@Test
void 시도횟수가_음수인_경우_예외를_던진다() {
assertThatThrownBy(() -> StringParser.parseRoundNumber("-4"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid Number"); // 음수 입력 시 예외 발생
}
@Test
void 시도횟수가_Integer_최대치보다_크거나_같을_경우_예외를_던진다() {
assertThatThrownBy(() -> StringParser.parseRoundNumber(String.valueOf(Integer.MAX_VALUE)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid Number"); // 최대치를 초과(혹은 같은 값까지도)하면 예외 발생
}
}
🧠 5. 배운 점
이번 미션을 통해 표준 라이브러리의 메서드 한 줄조차 깊게 이해하는 것의 중요성을 느꼈습니다.
String.split()은 단순히 문자열을 나누는 메서드로만 생각했지만,
직접 내부 구현을 읽어보니 성능 최적화(fast path), 정규식 처리 분기, limit의 영향 등
많은 설계 의도가 담겨 있었습니다.
또한, 자료구조 설계를 단순한 저장소 선택이 아니라
“데이터의 관계를 어떻게 표현할 것인가”의 문제로 바라보게 되었습니다. 특히 HashMap에 CarKey와 같은 객체를 키로 활용하여 복잡한 데이터를 명확하고 효율적으로 관리하는 방식은 인상 깊은 경험이었습니다.
✨ 마무리하며
이번 자동차 경주 미션은 단순한 문자열 분리나 반복문 과제가 아니라,
정규표현식의 실제 동작 원리, 자료구조의 적합성 판단, 예외 처리의 세밀함을 동시에 요구하는 프로젝트였습니다.
특히 split()과 splitWithDelimiters()의 차이를 명확히 이해하면서,
“API를 사용하는 것과 이해하고 사용하는 것의 차이”를 체감할 수 있었습니다. 그리고 HashMap과 스트림 API를 활용하여 데이터를 효과적으로 처리하는 방법을 체득한 것도 큰 수확입니다.
'[우아한테크코스] 8기 프리코스' 카테고리의 다른 글
| [우아한테크코스] 8기 프리코스 - 오픈 미션 회고록 (0) | 2025.11.25 |
|---|---|
| [우아한테크코스] 8기 프리코스 - 3차 미션 회고록 (0) | 2025.11.25 |
| [우아한테크코스] 8기 프리코스 - 1차 미션 회고록 (0) | 2025.11.25 |