프리코스 2주차를 진행하며
들어가며
우아한테크코스 8기 프리코스 2주차 과제는 자동차 경주 게임 구현이었다. 1주차 문자열 계산기보다 훨씬 고민할 거리가 많았다. “생성자를 쓸까, 정적 팩터리 메서드를 쓸까?”, “검증은 어디서 해야 하지?”, “Cars 클래스를 굳이 만들어야 하나?”… 코딩하면서 수없이 고민했던 것들이다. 이 글에서는 그런 설계 고민들을 하나씩 정리해보려고 한다. 혹시 비슷한 고민을 하고 있다면 도움이 되길 바란다!
1. 생성자 vs 정적 팩터리 메서드
Car 객체를 어떻게 만들지 고민이 시작되었다.
“그냥 new Car("pobi") 하면 되는 거 아닌가?”
처음엔 이렇게 생각했다. 근데 코드를 작성하다 보니 뭔가 찝찝했다.
방법 1: public 생성자로 만들기
1
2
3
4
5
public Car(String name) {
validateName(name);
this.name = name;
this.position = INITIAL_POSITION;
}
간단하다. 근데 이게 최선일까?
방법 2: 정적 팩터리 메서드로 만들기
1
2
3
4
5
6
7
8
9
private Car(String name) { // 생성자는 private
this.name = name;
this.position = INITIAL_POSITION;
}
public static Car createCar(String name) {
validateName(name);
return new Car(name);
}
뭔가 더 길어 보이는데? 왜 이렇게 하는 걸까?
정적 팩터리 메서드의 장점
처음엔 “생성자로 충분한데 굳이 정적 팩터리 메서드가 필요할까?”라고 생각했다. 하지만 코드를 작성하다 보니 정적 팩터리 메서드의 장점들이 눈에 들어왔다.
1. 이름으로 의도를 명확하게 표현할 수 있다
1
2
Car car = new Car("pobi"); // 뭘 하는 건지 애매함
Car car = Car.createCar("pobi"); // "자동차를 생성한다!" 명확함
createCar 이라는 이름만 봐도 “아, 자동차를 만드는구나!”를 바로 알 수 있다.
2. 생성 과정을 완전히 제어할 수 있다
1
2
3
4
private Car(String name) { // private이라 외부에서 직접 생성 불가
this.name = name;
this.position = INITIAL_POSITION;
}
생성자를 private으로 막아버리면 외부에서 new Car()를 못 쓴다. 무조건 createCar()를 거쳐야 하니까 검증을 빼먹을 일이 없다!
3. 확장하기 쉽다
나중에 다른 방식으로 자동차를 만들고 싶으면?
1
2
3
4
5
6
public static Car createCarWithPosition(String name, int position) {
validateName(name);
Car car = new Car(name);
car.position = position; // 위치 지정
return car;
}
이렇게 메서드만 추가하면 된다. 생성자 오버로딩보다 의미가 훨씬 명확하다!
해결방법 : 생성자 대신 정적 팩터리 메서드를 고려하라
이펙티브 자바 아이템 1에 따르면 다음과 같은 정적 팩터리를 사용하는게 유리한 경우가 더 많기 때문에 무작정 public 생성자를 제공하던 습관을 고치라 추천한다. 이에 정적 팩터리 메서드 패턴으로 작성해보았다.
정적 팩터리 메서드 패턴 채택
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Car {
private final String name;
private int position;
private Car(String name) { // private 생성자
this.name = name;
this.position = INITIAL_POSITION;
}
public static Car createCar(String name) { // 정적 팩터리 메서드
validateName(name);
return new Car(name);
}
private static void validateName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("자동차 이름은 빈 값이 불가능합니다.");
}
if (name.length() > MAX_NAME_LENGTH) {
throw new IllegalArgumentException("자동차 이름은 5글자를 초과할 수 없습니다.");
}
}
}
단순해 보이는 객체 생성도, 어떻게 만드느냐에 따라 코드의 명확성이 확 달라진다는 걸 느꼈다.
특히 createCar라는 이름이 주는 명확함이 코드 가독성에 큰 도움이 되었다.
2. 검증은 어디서 할까? - Car 내부 vs 외부 검증 클래스
자동차 이름 검증을 어디서 할지 고민이 되었다.
일반적으로 개발할 때는 비즈니스 로직이 많아서 validator 클래스를 유틸리티 클래스로 제공하여 검증 작업을 제공했었는데, 이번 과제는 좀 느낌이 달랐다.
방법 1: Car 내부에서 검증
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Car {
public static Car createCar(String name) {
validateName(name); // Car 내부 private 메서드
return new Car(name);
}
private static void validateName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("자동차 이름은 빈 값이 불가능합니다.");
}
if (name.length() > MAX_NAME_LENGTH) {
throw new IllegalArgumentException("자동차 이름은 5글자를 초과할 수 없습니다.");
}
}
}
방법 2: 외부 검증 클래스
1
2
3
4
5
6
7
8
9
10
public class RacingCarNameValidator {
public static void validate(List<String> names) {
if (names.stream().anyMatch(name -> name == null || name.isBlank())) {
throw new IllegalArgumentException("이름이 비어있습니다.");
}
if (names.stream().anyMatch(name -> name.length() > MAX_LEN)) {
throw new IllegalArgumentException("이름은 " + MAX_LEN + "자 이하만 가능합니다.");
}
}
}
검증 로직이 한 곳에 모여서 깔끔해 보인다. 근데…
고민 - 외부 Validator의 함정
외부 검증 클래스를 만들면 검증 로직이 한곳에 모여 관리하기 좋을 것 같았다. 하지만 문제가 있었다.
외부 검증 클래스의 문제점
1. 검증 규칙이 두 곳에 흩어진다
1
2
3
4
5
// RacingCarNameValidator에도 있고
validate(names);
// Car.validateName()에도 있다
validateName(name);
검증 규칙을 바꾸려면 두 곳을 다 수정해야 해서 귀찮다.
2. 검증을 깜빡할 수 있다
1
2
// 실수로 검증 안 하고 생성해버리면?
Car car = Car.createCar("toolongname"); // 위험!
외부 검증 클래스는 호출을 까먹으면 그대로 버그가 된다.
실제 서비스였다면? 중간에 API를 직접 호출해서 객체를 만들어버리면 오류 데이터가 들어간다. 상상만 해도 끔찍하다…
3. Car 이 스스로를 책임지지 못한다
1
2
// Car의 규칙이 Car 밖에 있다?
RacingCarNameValidator.validate(names);
Car의 생성 규칙인데 Car 이 모른다. 이건 진짜 이상한 상황이다.
해결방법 - 객체는 스스로를 검증해야 한다!
Car 내부에서 검증하는 것으로 결정했다. 코드는 아래와 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Car {
private static final int MAX_NAME_LENGTH = 5;
public static Car createCar(String name) {
validateName(name); // 생성 시 무조건 검증
return new Car(name);
}
private static void validateName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("자동차 이름은 빈 값이 불가능합니다.");
}
if (name.length() > MAX_NAME_LENGTH) {
throw new IllegalArgumentException("자동차 이름은 5글자를 초과할 수 없습니다.");
}
}
}
왜 이렇게 결정했나?
- 캡슐화 :
Car이 스스로의 유효성을 보장할 수 있다. - 단일 책임 : 검증 규칙이 한 곳에만 존재한다.
- 안전성 : 객체 생성 시 무조건 검증을 거친다.
예외는 있다. 물론 복잡한 비즈니스 로직은 외부 Validator가 낫다.
예를 들어 쿠폰 발급 검증에서 이미 발급받았는지, 발급 대상인지, 발급 기간인지, 재고가 있는지 등의 체크할 때는 DB조회도 필요하고 여러 도메인이 엮여 있어서 별도 Validator클래스가 맞다.
하지만 단순한 자기 자신의 규칙은 자기가 지켜야 한다!
배운 점
“검증 로직을 별도 클래스로 분리하면 깔끔하다”는 생각이 들 수 있지만, 실제로는 도메인 객체가 스스로를 검증 하는 것이 더 안전하고 명확하다는 것을 깨달았다. 객체는 자기 자신에 대한 규칙을 스스로 관리해야 한다. 다만 더 복잡한 비즈니스 로직의 경우 외부 validator 클래스로 검증하는게 훨씬 좋다고 생각한다.
3. isEmpty() vs isBlank(): 빈 값 검증은 어떻게 해야 유리할까
자동차 이름이 비어있는지 확인하는 코드를 짰다.
1
2
3
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException("자동차 이름은 빈 값이 불가능합니다.");
}
완벽해 보였다. 근데…
테스트를 돌려보니
1
2
3
4
5
@Test
void 공백만_있는_이름은_생성_불가능_예외() {
assertThatThrownBy(() -> Car.createCar(" "))
.isInstanceOf(IllegalArgumentException.class);
}
테스트가 통과 안 된다!
" " (공백 문자열)을 넣었는데 예외가 안 던져졌다.
isEmpty()의 정체
isEmpty()는 길이만 체크 한다.
1
2
3
4
5
6
7
String name1 = "";
String name2 = " "; // 공백
String name3 = " pobi ";
name1.isEmpty() // true
name2.isEmpty() // false - 공백은 문자로 취급!
name3.isEmpty() // false
공백도 문자니까 길이가 0이 아니다. 그래서 isEmpty()를 통과해버린다!
isBlank()의 등장
Java 11부터 isBlank()가 추가됐다. 이건 의미 있는 내용 을 체크한다.
1
2
3
name1.isBlank() // true
name2.isBlank() // true - 공백도 비어있다고 판단!
name3.isBlank() // false
공백만 있어도 “빈 값”으로 취급한다. 이게 우리가 원하는 것!
수정된 코드
1
2
3
4
5
private static void validateName(String name) {
if (name == null || name.isBlank()) { // - isBlank() 사용
throw new IllegalArgumentException("자동차 이름은 빈 값이 불가능합니다.");
}
}
이제 공백 테스트도 통과한다!
isEmpty() vs isBlank() 비교
| 메서드 | 빈 문자열 "" |
공백 " " |
null | 용도 |
|---|---|---|---|---|
isEmpty() |
true | false | NPE | 길이가 0인지만 확인 |
isBlank() |
true | true | NPE | 공백 포함 빈 값 확인 |
배운 점
사용자 입력을 검증할 때는 의미 없는 공백까지 걸러내는 isBlank() 가 더 적절하다.
isEmpty()는 단순히 길이만 체크하지만, isBlank()는 “의미 있는 내용이 있는가?”를 체크한다.
4. 일급 컬렉션이 필요한 이유 - Cars 클래스
처음에는 “자동차 여러 대를 List로 관리하면 되는데 굳이 Cars 클래스가 필요할까?”라고 생각했다.
방법 1: List 직접 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class RacingGame {
private List<Car> cars; // 그냥 List 사용
public void moveAll() {
for (Car car : cars) {
car.move();
}
}
public List<Car> getWinners() {
int maxPosition = cars.stream()
.mapToInt(Car::getPosition)
.max()
.orElse(0);
return cars.stream()
.filter(car -> car.getPosition() == maxPosition)
.collect(Collectors.toList());
}
}
이러한 로직이 필요한 곳에서 매번 List를 생성해야한다. 되긴 하는데 뭔가 RacingGame이 너무 바쁘고 복잡해 보였다.
고민
“List만으로도 충분한데 굳이 클래스로 감싸야 하나?” 하지만 코드를 작성하다 보니 몇몇 문제점이 보였다.
문제 1: RacingGame이 너무 많은 일을 한다
1
2
3
// RacingGame이 직접 스트림 처리
int maxPosition = cars.stream().mapToInt(Car::getPosition).max().orElse(0);
return cars.stream().filter(car -> car.getPosition() == maxPosition)...
자동차들을 관리하는 건 RacingGame의 일일까? 아니면 자동차들 스스로의 일일까?
문제 2: 중복 코드가 생긴다
우승자를 찾는 로직을 다른 곳에서도 쓰려면?
1
2
3
// 또 이 긴 코드를 복붙해야 한다
int maxPosition = cars.stream().mapToInt(Car::getPosition).max().orElse(0);
return cars.stream().filter(car -> car.getPosition() == maxPosition)...
문제 3: 책임이 불명확하다
“모든 자동차를 이동시킨다”는 로직이 어디 있어야 할까?
RacingGame? 아니면- 자동차들 자체?
이런 고민들을 명확히 해결하고자 했으며 일급 컬렉션을 도입해보았다.
일급 컬렉션 Cars 채택
일급 컬렉션이란? 컬렉션을 포함한 클래스인데, 그 컬렉션 외에 다른 멤버 변수가 없는 클래스다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class Cars {
private final List cars; // 이것만 있다!
private Cars(List cars) {
this.cars = cars;
}
public static Cars from(List names) {
List cars = names.stream()
.map(Car::createCar)
.toList();
return new Cars(cars);
}
public void moveAll() {
for (Car car : cars) {
car.move();
}
}
public List<Car> getWinners() {
int maxPosition = findMaxPosition();
return cars.stream()
.filter(car -> car.getPosition() == maxPosition)
.collect(Collectors.toList());
}
private int findMaxPosition() {
return cars.stream()
.mapToInt(Car::getPosition)
.max()
.orElse(0);
}
public List<Car> getCars() {
return Collections.unmodifiableList(cars);
}
}
뭐가 좋아졌을까?
1. RacingGame이 가벼워졌다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 이전: RacingGame이 직접 처리
public class RacingGame {
public List<Car> getWinners() {
int max = cars.stream().mapToInt(Car::getPosition).max().orElse(0);
return cars.stream().filter(car -> car.getPosition() == max)
...
}
}
// 개선 후: Cars에게 위임
public class RacingGame {
public List<Car> getWinners() {
return cars.getWinners(); // 간단!
}
}
RacingGame은 게임 진행만 신경 쓰면 된다. 자동차들 관리는 Cars가 알아서!
2. 책임이 명확해졌다
- “모든 자동차 이동” →
cars.moveAll() - “우승자 찾기” →
cars.getWinners()
어디에 뭐가 있는지 한눈에 보인다!
3. 중복이 사라졌다
1
2
3
4
5
// Cars 없으면 여러 곳에서 반복
int maxPosition = cars.stream().mapToInt(Car::getPosition).max().orElse(0);
// Cars 있으면 한 번만 작성
private int findMaxPosition() { ... }
4. 불변성을 보장할 수 있다
1
2
3
public List<Car> getCars() {
return Collections.unmodifiableList(cars); // 외부에서 수정 불가!
}
외부에서 getCars()로 리스트를 받아도 수정할 수 없다.
일급 컬렉션은 단순히 “List를 클래스로 감싼 것”이 아니다. 컬렉션 관련 로직을 한 곳에 모아서 관리하는 것이다!
5. getter 사용 지양하기
처음 코드를 짤 때 습관적으로 이렇게 짰다.
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Car {
public String getName() {
return name;
}
public int getPosition() {
return position;
}
}
// 사용하는 쪽
Car car = Car.createCar("pobi");
System.out.println(car.getName() + " : " + "-".repeat(car.getPosition()));
그런데 여러 자료와 토론게시판을 보니 “getter 사용을 지양한다 “는 내용이 있었다.
왜일까?
getter의 문제점
문제 1: 캡슐화가 깨진다
1
2
// Car의 내부 데이터를 밖으로 꺼낸다
String output = car.getName() + " : " + "-".repeat(car.getPosition());
자동차의 데이터를 외부로 꺼내서 외부에서 조립한다. Car는 그냥 데이터 저장소일 뿐이다.
문제 2: 객체가 수동적이 된다
1
2
3
4
// 자동차는 데이터만 제공, 판단은 외부에서
if (car.getPosition() >= 3) {
System.out.println(car.getName() + "이(가) 앞서가고 있습니다!");
}
자동차가 앞서가는지는 자동차 스스로 알 수 있는데, 외부에서 데이터 뽑아서 판단한다.
이러면 Car는 그냥 데이터 덩어리다.
Tell, Don’t Ask 원칙을 적용하라
“데이터를 요청하지 말고, 객체에게 시켜라”
1
2
3
4
5
6
7
8
9
10
// 피해야 할 패턴 - Ask: 데이터 달라고 요청
int position = car.getPosition();
if (position >= 3) {
System.out.println("앞서감!");
}
// 적용할 패턴 - Tell: 객체에게 시킴
if (car.isLeading()) {
System.out.println("앞서감!");
}
개선 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Car {
private final String name;
private int position;
// getter 대신
// public int getPosition() { return position; }
// 행동 메서드 제공
public String toResultString() {
return name + " : " + "-".repeat(position);
}
public boolean isAt(int targetPosition) {
return this.position == targetPosition;
}
public boolean isLeadingWith(int maxPosition) {
return this.position == maxPosition;
}
}
사용 예시
1
2
3
4
5
6
7
8
9
// getter로 데이터 꺼내서 조립하지 말고
for (Car car : cars) {
System.out.println(car.getName() + " : " + "-".repeat(car.getPosition()));
}
// 행동 메서드를 써서 객체에게 시키자
for (Car car : cars) {
System.out.println(car.toResultString());
}
훨씬 깔끔하고 의미도 명확하다!
예외: 꼭 필요한 getter는?
모든 getter를 없앨 수는 없다. 예를 들어 우승자 이름을 출력할 때는
getName()이 필요하다.
1 2 3 public String getName() { return name; }하지만 이런 경우에도 최소한으로 제공하고, 가능하면 행동 메서드로 대체하는 것이 좋다.
getter는 편리하지만 남용하면 객체가 데이터 덩어리 가 된다. 객체는 데이터를 제공하는 것이 아니라 스스로 책임을 수행 해야 한다는 것을 깨달았다.
6. 정적 상수는 private static final로
코드를 작성하다 보면 매직 넘버가 많이 생긴다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Car {
public static Car createCar(String name) {
if (name.length() > 5) { // 5가 뭐지?
throw new IllegalArgumentException("...");
}
return new Car(name);
}
public void move() {
int randomNumber = Randoms.pickNumberInRange(0, 9); // 0, 9가 뭐지?
if (randomNumber >= 4) { // 4가 뭐지?
position++;
}
}
}
나는 알지만 남이 보면 모르는 상수들이 많이 생기는데 이걸 명확히 해주는게 좋다고 한다.
매직 넘버의 문제점
문제 1: 의미가 불명확하다
1
if (name.length() > 5) { // 왜 5?
이 5가 어떤 의미의 조건인지 명확히 보여줄 수 있으면 더 좋지 않을까?
문제 2: 변경하기 어렵다
1
2
3
4
// 이름 최대 길이를 6으로 바꾼다면?
if (name.length() > 5) { // 여기도 바꾸고
validateName(name, 5); // 여기도 바꾸고
checkLength(name, 5); // 여기도 바꾸고...
저 정적 상수가 여기저기 흩어져 있으면 다 찾아서 바꿔야 한다. 하나라도 놓치면 버그가 발생한다.
문제 3. 실수 가능성이 있다.
1
2
// 최대 길이 5를 의미하는 건데 실수로 4를 쓴다면?
if (name.length() > 4) { // 버그 발생
해결방법
의미 있는 상수로 정의하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Car {
private static final int INITIAL_POSITION = 0; // 시작 위치
private static final int MAX_NAME_LENGTH = 5; // 최대 이름 길이
private static final int RANDOM_MIN = 0; // 랜덤 최소값
private static final int RANDOM_MAX = 9; // 랜덤 최대값
private static final int MOVE_THRESHOLD = 4; // 이동 기준값
private final String name;
private int position;
private Car(String name) {
this.name = name;
this.position = INITIAL_POSITION; // 의미 명확함
}
public static Car createCar(String name) {
if (name.length() > MAX_NAME_LENGTH) { // 의미 명확함
throw new IllegalArgumentException("자동차 이름은 5글자를 초과할 수 없습니다.");
}
return new Car(name);
}
public void move() {
int randomNumber = Randoms.pickNumberInRange(RANDOM_MIN, RANDOM_MAX); // 의미 명확함
if (randomNumber >= MOVE_THRESHOLD) { // 의미 명확함
position++;
}
}
}
이제 코드가 스스로 설명한다!
왜 private static final인가?
private - 외부에서 접근 불가
1
Car.MAX_NAME_LENGTH // 컴파일 에러
Car 내부에서만 쓰는 값이니까 밖으로 노출할 필요 없다.
static - 모든 인스턴스가 공유
1
2
// 자동차 100대를 만들어도
// MAX_NAME_LENGTH는 메모리에 딱 1개만 존재
인스턴스마다 따로 가질 필요 없으니 static으로 만든다.
final - 값 변경 불가
1
MAX_NAME_LENGTH = 10; // 컴파일 에러!
상수니까 바뀌면 안 된다!
네이밍 컨벤션
1
2
3
4
5
6
7
// 예시 : 대문자 + 언더스코어
private static final int MAX_NAME_LENGTH = 5;
private static final int MOVE_THRESHOLD = 4;
// 나쁜 예시
private static final int maxNameLength = 5; // 카멜케이스 x
private static final int MAXNAMELENGTH = 5; // 언더스코어 없음 x
배운 점
5, 4 같은 매직 넘버는 코드를 읽는 사람에게 “이게 뭐지?”라는 의문을 남긴다. 의미 있는 이름의 상수 로 만들면 코드가 스스로 설명하게 된다.
마치며
2주차 과제는 1주차보다 규모가 커지면서 객체 설계에 대한 고민이 많아졌다.
가장 크게 느낀 건, “객체는 데이터가 아니라 책임을 가진 존재다” 라는 것이었다.
Car는 단순히 이름과 위치를 담는 그릇이 아니다. 스스로 검증하고, 이동하고, 결과를 표현하는 책임을 가진 객체다.
- 생성자 대신 정적 팩터리 메서드로 의도를 명확히 하고
- 검증은 객체 스스로 하게 만들고
- 일급 컬렉션으로 컬렉션 관련 로직을 모아서 관리하고
- getter 대신 행동 메서드로 객체에게 책임을 주고
- 상수로 코드가 스스로 설명하게 만들고
이 모든 것들이 “좋은 객체 설계”라는 하나의 목표를 향하고 있었다.
특히 “객체는 데이터가 아니라 책임을 가진 존재” 라는 것을 깨닫는 과정이었다. Car는 단순히 이름과 위치 데이터를 담는 것이 아니라, 스스로 검증하고, 이동하고, 결과를 표현하는 책임을 가진 객체다.
isEmpty()와 isBlank() 같은 작은 차이도, private static final 같은 기본적인 것도, 실제로 코드를 작성하며 그 필요성을 체감하니 더 깊이 이해할 수 있었다.
1주차보다 고민도 많았고 배운 것도 많았다.
References
- Effective Java - 3/E
- String (Java SE 11 Documentation)
- 일급 컬렉션을 사용하는 이유
- Tell, Don’t Ask
