🏁 서론
이번에는 초간단 애플리케이션인 자동차 경주를 구현해 보았다. 총 4단계에 걸쳐 요구사항에 맞게 점진적으로 개발하고 리팩토링을 진행하였다. 이전에 진행한 계산기 프로젝트보다 난이도가 올라갔고, 특히 설계와 객체 분리에 대한 고민이 많았다.
그동안의 프로젝트는 ‘일단 동작하게 만드는 것’에 급급해 설계적인 부분에 깊이 다가가지 못했다는 점을 이번 스터디를 통해 확실히 느꼈다. 특히 계산기 프로젝트는 학습 순서나 리팩토링 타이밍이 다소 뒤죽박죽이었다면, 이번 미션은 단계별 학습 → 구현 → 리팩토링의 사이클을 비교적 체계적으로 따라가 보려 노력했다. 덕분에 마지막 리팩토링에서는 코드 구조를 전반적으로 개선하면서도 여러 시행착오를 겪었고, 그 과정을 통해 많은 걸 배울 수 있었다.
📚 이번 주 학습 내용
https://github.com/izzy80/java-racingcar-simple-playground/tree/step4
GitHub - izzy80/java-racingcar-simple-playground: 초록 스터디 자바 기초 과정 자동차 경주 미션을 위한 저장
초록 스터디 자바 기초 과정 자동차 경주 미션을 위한 저장소. Contribute to izzy80/java-racingcar-simple-playground development by creating an account on GitHub.
github.com
🛠️ 단계별 PR 회고
📌 1단계
1. 매직 넘버(Magic Number)와 상수
정의
소스 코드에서 의미를 가진 숫자나 문자열을 별도의 상수 없이 하드코딩한 값
예)
private boolean isMovable(int value) {
return value >= 4;
}
위와 같이 4가 어떤 의미인지 외부에서 코드를 읽는 사람은 바로 알 수 없다.
매직넘버를 왜 피해야할까?
- 의도를 알 수 없다.
- 중복될 가능성
- 유지보수 어렵다
- 테스트 코드와의 연결성 약화
그래서 아래와 같이 매직넘버를 상수화 하는 것이다.
private static final int MOVE_THRESHOLD = 4;
private boolean isMovable(int value){
return value >= MOVE_THRESHOLD;
}
위와 같이 `MOVE_THRESHOLD`라는 이름을 붙이면, 이 값이 “움직일 수 있는 기준값”임을 바로 이해할 수 있다.
결론적으로 코드의 의도를 파악하기 쉽게 만들어 가독성을 높이고, 유지 관리를 더 쉽게 만들어준다.
2. 예외처리는 항상 신경 쓰자 !!
종종 예외처리에 신경을 못 쓰는 경우가 있는데 항상 주의하는 것이 좋을 것 같다.
이번 구현에서는 Car 클래스에 name이라는 필드가 있었는데, 생성자에서 ""(빈 문자열)이나 null이 들어올 경우를 깜빡했다.
작은 실수라도 프로그램 전체 흐름에 영향을 줄 수 있으므로, 예외 상황을 먼저 생각하고 방어적인 코드를 작성하는 습관을 갖는 것이 중요하다는 점을 다시금 느꼈다.
3. `Math` vs `Random`
이 둘은 모두 자바에서 랜덤 값을 생성할 때 흔히 사용되는 클래스다.
간단한 로직은 Math.random(), 테스트 및 다양한 타입 지원이 필요한 경우는 Random 클래스 사용하자
Math.random()
- 반환값:0.0 이상 1.0 미만의double 타입
-seed값으로 현재시간을 사용하므로 매번 실행시킬때마다 다른 숫자가 출력 -> 테스트에 분리
Random 클래스
- 다양한 타입 반환 가능 (`nextInt()`, `nextBoolean()`, `nextDouble()` 등)
-seed값 설정을 통해예측 가능한 랜덤값생성 가능 →테스트에 유리
//0 <= value <= 9 랜덤값을 가져오게 하자
import java.util.Random;
//1. Math 이용
(int)Math.floor((Math.random() * 10));
//2. random 이용
Random random = new Random();
//Random random = new Random(5); //seed값 고정
random.nextInt(10);
📌 2단계
1. 일급 컬렉션
- 컬렉션 (List, Set, Map 등) 을 Wrapping한 클래스
- 멤버 변수로 컬렉션만 갖고 있어야 함
예)
기존에는 단순히 배열이나 리스트로 자동차들을 관리하고 있었다.
public class CarWinner {
private Car[] cars; //참가한 자동차들
}
이를 일급 컬렉션으로 리팩토링하면 다음과 같이 도메인 의미가 드러난다:
public class Cars {
//자동차 경주에 참가하는 자동차들의 집합
private List<Car> cars;
public Cars(List<Car> cars) {
this.cars = cars;
}
}
장점은?
- 도메인 의미가 명확해진다.
- 컬렉션 관련 로직이 분산되지 않고 응집된다 (도메인의 상태 및 행위를 한 곳에서 관리)
→ 리스트에 대한 검증, 정렬, 이동 등 비즈니스 로직을 Cars 클래스에 모아둘 수 있다.
- 불변성을 유지할 수 있다. 외부에서 컬렉션을 직접 수정하지 못하게 해서 객체 상태 보호에 유리
+) 추가 코멘트
List<Car>를 반환하는 getCars 메소드를 만들었는데 관련되어서 리뷰를 받았다.
기존 코드는 아래와 같았다.
public List<Car> getCars() {
return new ArrayList<>(cars); //캡슐화
}
하지만 리뷰어분께서 이 방식은 "진짜 불변이라 보기 어렵다"고 피드백을 주셨다.
그 이유는 다음과 같다
var car = cars.getCars().get(0);
car.move(); // Car 객체는 여전히 외부에서 변경 가능
즉, 리스트 구조는 변경할 수 없더라도, 리스트 내부의 객체(Car)는 여전히 상태 변경이 가능한 구조였던 것이다.
외부에 상태를 노출하지 않도록 하기 위해, getCars() 대신
불변 객체인 CarInfo(또는 CarDTO)를 만들어 리스트를 변환해서 반환하는 방식으로 수정했다
public List<CarInfo> getCarInfos() {
return cars.stream()
.map(car -> new CarInfo(car.getName(), car.getPosition()))
.toList();
}
이렇게 하면 외부에서는 Car의 내부 상태를 변경할 수 없게 되어,
도메인 객체의 불변성이 더 강하게 보장된다.
여러 블로그 글을 보면 리스트의 불변성을 지키기 위해 아래와 같은 코드를 사용한다.
`Collections.unmodifiableList(cars)`, `new ArrayList<>(cars)`, `List.copyOf(cars)`
하지만 이 역시 List 자체는 불변이지만 List 내부에 있는 Car에는 접근이 가능하다.
2. 외부 주입 없이 객체 내부에서 초기화할 때, 필드 vs 생성자
두 방식 모두 문법적으로는 문제가 없음
객체의 생성 시점과 값을 연결해서 명확히 표현하고 싶다면 생성자에서 초기화
반면, 상수처럼 절대 바뀌지 않는 값이라면 필드에서 바로 초기화해도 무방
3. this 키워드는 꼭 붙여야 할까?
이번 PR을 올리고 나서, 내가 작성한 코드 중 this 키워드가 생략된 부분이 있다는 점을 알게 되었다.
평소에는 너무 자연스럽게 this를 써왔기 때문에,“생략해도 괜찮은 걸까?”라는 의문이 생겼다.
자바에서는 필드와 지역 변수(또는 매개변수)의 이름이 겹칠 때만 this를 사용하는 것이 일반적인 규칙이라고 한다.
그 외의 경우에는 생략해도 문제가 없다.
하지만this를 명시하는 것이가독성과 코드의 일관성을 높여주는 데 도움이 될 수 있다. 그러니 팀의 컨벤션에 맞게 사용하면 될 것 같다.
4. JCF( Java Collections Framework )
JCF
List, Set, Map 등을 포함하는 표준 라이브러리
4.1. List vs 배열
List를 사용하자.
List는
- 크기 제한이 없는 동적 자료구조
- 인터페이스 기반으로 설계되어 유연성이 높다
4.2. List로 선언할까, ArrayList로 선언할까?
//1. List 선언
List<String> list = new ArrayList<>();
//2. ArrayList 선언
ArrayList<String> list = new ArrayList<>();
둘 다 컴파일 에러 없이 잘 동작하지만, 인터페이스인 List로 선언하는 것이 훨씬 유연하다
예를 들어, 나중에 LinkedList나 다른 구현체로 바꾸더라도
→ List 타입으로 선언돼 있으면 코드 변경이 최소화되고,
→ 테스트나 확장 시에도 의존성을 줄일 수 있다는 장점이 있다.
단, ArrayList 고유의 메서드(ensureCapacity() 등)를 써야 할 경우엔 다운캐스팅이 필요할 수 있지만,
일반적인 상황에선구현체보다는 인터페이스를 기준으로 설계하는 습관이 더 바람직하다
5. 축약된 변수명은 지양
우승한 자동차 수를 저장하는 변수명을 cnt로 작성했는데, 리뷰어께서 축약된 변수명은 지양하는 것이 좋다는 피드백을 주셨다.
사실 개발을 하다 보면 cnt, tmp, res 같은 변수명을 습관처럼 쓰게 되는데,
이런 변수명은 변수의 의미가 코드 흐름 안에서 명확하게 드러나지 않아 가독성을 떨어뜨린다.
6. 메서드명 역시 함축해서 쓰지 말 것
whichWinner()라는 메서드명을 썼는데, 리뷰어님께서 이 이름이 무엇을 하는 메서드인지 명확하지 않다는 피드백을 주셨다.
실제로 해당 메서드는
- 자동차들을 이동시키고
- 우승자를 판별하고
- 우승자 이름을 저장하는 역할까지 하고 있었는데,
이 모든 과정을 whichWinner라는 이름으로 표현하기엔 너무 축약적이었다.
메서드 역시 이름만으로 역할이 드러나야 한다!!
7. 책임 주도 설계
객체 지향 설계에서 객체의 책임을 중심으로 설계를 진행하는 방법론
객체가 자율적으로 자신의 상태와 행동을 관리하며, 외부에서는 객체의 내부 구현에 신경 쓰지 않고 메시지를 통해 상호작용하도록 설계하는 것을 목표
https://f-lab.kr/insight/responsibility-driven-design-20250114
이 개념은 아직 익숙하지 않기때문에, 실제 프로젝트나 코딩 경험을 많이 쌓아보면서 감을 잡아야 할 것 같다.
관련 키워드로는 다음이 있다
`도메인 주도 설계 (DDD)`, `데이터 중심 설계`
📌 3단계
1. strip() vs trim()
trim()은 문자열 앞뒤의 공백을 제거하는 데 많이 쓰이는 메서드다.
하지만 Java 11부터 등장한 strip()은 유니코드 기준으로 더 다양한 공백 문자까지 제거할 수 있도록 개선되었다.
둘의 차이를 간단히 정리하면 다음과 같다:
- trim() → ASCII 기반의 전통적인 공백만 제거 (스페이스, 탭 등)
- strip() → 유니코드 공백까지 포함한 더 넓은 범위의 공백 제거 가능
기능적으로는 비슷하지만, 국제화나 다양한 문자열 입력을 고려할 때는 strip()을 사용하는 것이 더 안전하다.
2. 문자열 연산
이번 미션 중 car.getName() + " : " + "-".repeat(...)와 같이 문자열을 + 연산자로 계속 이어붙이는 코드를 작성했는데,
리뷰어님께서 문자열 연산의 성능 이슈에 대해 짚어주셨다.
Java에서 문자열은 불변(immutable) 객체이기 때문에, + 연산을 반복할수록 매번 새로운 문자열 객체가 생성된다.
이로 인해 메모리 낭비는 물론, 연산 성능도 떨어질 수 있다.
이 문제를 해결하기 위한 대표적인 방법이 바로 `StringBuilder`이다.
- StringBuilder는 내부적으로 문자열을 변경 가능한 배열로 다루며,
- 문자열을 반복해서 더해도 객체를 새로 생성하지 않기 때문에 성능적으로 유리하다.
StringBuffer는 StringBuilder와 비슷하지만 동기화를 지원하기 때문에 멀티스레드 환경에서 사용된다.
단일 스레드 환경이라면 StringBuilder만으로도 충분하다.
3. toList()
int max = getMaxPosition();
return cars.getCars().stream()
.filter(car -> car.getPosition() == max)
.collect(Collectors.toList());
이번 코드에서는 스트림의 결과를 리스트로 바꾸기 위해 `.collect(Collectors.toList())`를 사용했는데,
리뷰어님께서 Java 16 이상에서는 .toList() 메서드를 직접 사용할 수 있다는 피드백을 주셨다.
찾아보니, Stream.toList()는 Java 16부터 새로 도입된 기능이고, 그 이전 버전(Java 11 등)에서는 Collectors.toList()를 사용했다.
4. toString()
처음엔 Car 객체에서 "이름 : ---"처럼 결과를 출력해야 하니, toString()으로 문자열을 조합해주는 것도 괜찮지 않을까? 하는 생각이 들었다.
하지만 리뷰어님의 피드백을 듣고 보니, toString()은 본래 객체의 전체 상태를 요약해주는 용도이고,
특정 출력 형식이 필요한 경우에는 그 책임을 외부에 두는 것이 더 적절하다는 걸 알게 되었다.
즉, toString()은 디버깅/로깅용 요약 문자열 반환 용도
특정 출력 형식이 있다면 외부 책임으로 분리
📌 4단계
1. DTO vs VO (+ record)
DTO (Data Transfer Object)
목적
계층 간 데이터 전달을 위한 객체
→ 예: Controller → Service → Repository 사이에서 요청/응답 데이터 전달
특징
- 보통 불변일 필요 없음 (setter 사용 가능)
- 순수 데이터 컨테이너 (로직 거의 없음)
VO (Value Object)
목적
‘값’ 자체를 표현하는 객체
특징
- 불변(setter 사용 불가능)
- equals(), hashCode() 재정의해서 값 기준 비교
- 로직 포함 가능 (예: 유효성 검사)
record (Java 14+)
record로 VO를 간단하게 만들 수 있음
자동으로 생성되는 것
- private final 필드
- public 생성자
- equals(), hashCode(), toString()
주의점
- 불변 객체지만 내부 필드가 참조형이면 완전한 불변은 아님 (얕은 불변성)
- 클래스 상속 불가, 인터페이스는 구현 가능
- 로직이 복잡하거나 필드가 많아지면 일반 클래스가 더 적합
2. assertAll vs assertSoftly
assertAll은 실패한 테스트 코드의 예상값, 실제값을 출력을 해주지만 정확히 어떤 테스트에서 실패했는지 알기는 힘듦
assertSoftly는 이 메소드는 실패한 코드의 라인위치까지 알려줌
3. 일급컬렉션 중첩 구조
import java.util.*;
public class RaceHistory{
private final List<List<CarInfo>> rounds = new ArrayList<>();
처음에는 각 라운드별 자동차 상태를 저장하려고 List<List<CarInfo>> 형태로 작성했는데,
이를 리뷰어님께서 “다소 번거롭고 다루기 복잡할 수 있으니 일급 컬렉션으로 감싸보면 어떨까” 하는 피드백을 주셨다.
그래서 위 코드를
List<CarInfo>는 RoundSnapshot이라는 클래스로,
List<RoundSnapshot>는 RaceHistory라는 클래스로 감싸면,
단순히 리스트를 다루는 코드가 아니라 "라운드의 상태", "경기의 전체 히스토리"처럼 의도를 명확하게 표현했다.
📝 마무리 회고
이번 자동차 경주 미션은 단순한 게임 로직 이상의 의미가 있었다. 객체의 역할을 명확히 하고, "테스트 가능하고 변경에 유연한 구조"를 고민한 프로젝트였다. 특히 일급 컬렉션, DTO/VO 분리, 그리고 View와 Domain 간의 책임 분리가 왜 중요한지를 실습을 통해 체감할 수 있었다.
다음 프로젝트에서는 처음부터 테스트와 설계를 함께 고려하는 습관을 유지하고, 의미 있는 리팩토링을 더 가볍고 빠르게 해내는 것을 목표로 삼고 싶다.
🔗 Pull Request 기록
'🌤️일상 > 초록 스터디' 카테고리의 다른 글
1주차) 계산기 회고 (0) | 2025.05.03 |
---|