들어가며
2주차 과제를 제출한 후 이제야 회고록을 작성해본다.
이번 2주차 과제는 “자동차 경주”였다. 이전 기수에서도 2주 차에 동일한 문제로 과제가 나왔고, 이번 7기에서도 같은 미션이 주어졌다.
지난번 미션을 구현할 때는 OCP(Open-Closed Principle)와 SRP(Single Responsibility Principle)를 지킨 도메인 설계에 초점을 맞췄었다. 이번에는 여기에 더해 원시값 포장과 일급 컬렉션을 적용하는 데 중점을 두고 미션을 진행하였다.
미션을 다 구현하고 나서는 스스로 나름 만족스러운 코드라고 생각했다. 그러나 스터디 팀원들의 코드 리뷰를 받고 나니, 내가 놓친 부분과 개선할 여지가 많았음을 깨달았다. 아쉬움이 남기도 했지만, 동시에 이런 피드백 덕분에 더 성장할 수 있다는 생각에 부원들에게 감사함을 느꼈다.
회고를 통해 개선할 부분을 정리하면서, 다음 미션에서는 더 나은 코드를 작성하고 싶다는 마음이 더욱 커진다.
이제 본격적으로 회고를 진행해보자!!
미션 링크 및 클린코드 스터디 링크
https://github.com/woowacourse-precourse/java-racingcar-7/pull/1284
[자동차 경주] 이동훈 미션 제출합니다. by hoonyworld · Pull Request #1284 · woowacourse-precourse/java-racingcar
감사합니다.
github.com
https://github.com/joconaco-study/read-joconaco
GitHub - joconaco-study/read-joconaco: 좋은 코드 나쁜 코드 탐구 스터디
좋은 코드 나쁜 코드 탐구 스터디. Contribute to joconaco-study/read-joconaco development by creating an account on GitHub.
github.com
배운 점
1️⃣ 원시 값 포장과 VO
VO(Value Object)란?
VO와 관련해서 수많은 글들을 보았는데, 별로 와닿는게 없었다.
이론은 알겠는데, 실제로 왜 사용하는지 감이 잘 오지 않았기 때문이다.
그러다가 어떤 블로그에서 본 예시 하나가 엄청난 힌트를 주었다.
“아, 이게 VO구나!“라는 생각이 단번에 들었었는데, 그 예시를 공유해보려 한다.
예시
계좌에 100원이라는 값이 있다고 치자.친구가 10원을 이체해줬으면 110원이 된다.
이건 100원이라는 값 자체가 110원이 된게 아니다.
내 계좌가 가지고 있는 값이 100원이라는 값에서 110원이라는 값으로 바뀐 것이다.
즉, 값 자체는 바뀌지 않는다. 값이 대체되는 것 뿐이다.
https://backendbrew.com/ko/docs/ddd/tactical/vo
이게 바로 VO의 핵심이다. VO는 ‘값’을 담고 있는 객체로, 그 값 자체는 변하지 않는다.
값을 변경하려는 경우, 기존 VO 객체를 수정하는 것이 아니라, 새로운 값을 가진 VO 객체를 생성하여 교체하는 방식으로 이루어진다. 다시 말해, VO는 불변성을 가진 객체로, 값 자체는 변하지 않고 대체될 뿐이다.
VO(Value Object)를 왜 사용해야 하는데?
VO를 사용하는 가장 큰 이유는 바로 불변성과 안전성 때문이다. VO는 값 그 자체를 표현하며, 값이 변경되는 것이 아니라 새로운 객체로 대체되는 방식으로 관리된다. 이러한 특성 덕분에 VO는 여러 가지 장점을 제공한다. 왜 VO를 사용해야 하는지, 그 이유를 몇 가지로 정리해보자.
1. 코드의 안정성 증가
VO는 값이 불변이라는 특징을 갖고 있어, 코드가 복잡해지더라도 특정 값이 의도치 않게 변경될 위험이 없다. 값이 한번 정해지면 변하지 않기 때문에, 다른 개발자가 VO를 사용할 때도 값이 수정될 우려가 없다. 예를 들어, Money라는 VO가 있다면, 이 값이 중간에 바뀌는 일이 없어서 거래 관련 로직에서 안정성을 보장할 수 있다.
2. 변경 추적이 용이함
VO는 값이 대체되는 방식으로 상태 변경을 관리하기 때문에, 변경 이력을 추적하기가 쉬워진다. 예를 들어, 계좌 잔액이 100원에서 110원으로 바뀌었을 때, 기존의 100원이라는 값은 그대로 존재하고, 110원이 새로운 VO 객체로 생성되어 대체되는 방식으로 진행된다. 덕분에 특정 시점의 값을 쉽게 추적할 수 있어, 이력을 관리하거나 오류를 디버깅할 때 유용하다.
3. 불변성을 통한 동시성 제어
멀티스레드 환경에서는 데이터의 일관성이 중요한데, VO의 불변성 덕분에 동시성 문제를 쉽게 해결할 수 있다. VO는 값이 변경될 일이 없기 때문에, 여러 스레드에서 동일한 VO를 참조하더라도 문제가 발생하지 않는다. 따라서 동시성 제어가 필요한 상황에서도 VO는 안정적인 선택이 된다.
4. 의미 있는 코드 표현
VO는 단순한 데이터 타입이 아니라 도메인의 의미를 담은 객체로, 값을 보다 명확하게 표현할 수 있다. 예를 들어, 단순히 int로 정의된 숫자보다는 Money, Distance, Weight 같은 VO가 값의 의미를 더 분명히 전달할 수 있다. 이러한 VO를 통해 코드의 가독성이 높아지고, 도메인 로직의 의도를 더욱 쉽게 이해할 수 있다.
5. 객체 간의 명확한 역할 구분
VO는 Entity와 다르게 고유 식별자가 없으며, 순수히 값만을 표현하는 역할을 담당한다. 이러한 특성 덕분에, VO와 Entity의 역할이 명확히 분리된다. Entity는 고유한 ID를 가지고 변경될 수 있는 상태를 가지지만, VO는 값 그 자체로서 변하지 않는 불변성을 지닌다. 이로 인해 코드 구조가 더욱 명확해지고, 객체 간의 역할과 책임이 명확히 구분된다.
여기서 4번 내용을 이용하기 위해서는 원시 값 포장(Primitive Wrapping)에 대해서 알아야 한다.
원시 값 포장 (Primitive Wrapping)
원시 값 포장은 말 그대로 원시 자료형(Primitive Type)을 객체로 감싸는 것을 의미한다. 코드를 작성할 때, 많은 경우 원시 자료형을 그대로 사용하는 경우가 있지만, 이를 조금 더 안전하고 의미 있는 방식으로 사용하기 위해 객체로 감싸는 방식을 사용한다. 예를 들어, int, double, String과 같은 원시 타입을 바로 사용하기보다는, 해당 값을 포장한 객체로 정의하는 것이다. 이를 통해 코드의 안정성과 가독성을 높일 수 있다.
원시 값을 그대로 쓰면 왜 문제가 되는데?
예를 들어, 돈을 표현하기 위해 Money라는 클래스를 만들고 싶다고 가정해보자. 보통 우리는 int 타입으로 돈의 금액을 표현하게 된다. 그런데 int를 사용하여 -100과 같은 음수 값도 허용하게 되면, 의미적으로 말이 안 되는 금액이 발생할 수 있다. 돈은 음수가 될 수 없으므로, 음수 값을 가지지 못하게 해야 한다.
원시 값만 사용할 경우, 이러한 유효성 검증을 매번 코드에서 수동으로 해야 하기 때문에, 코드가 지저분해지고 중복이 발생할 수 있다. 또한, 값의 의미가 명확히 드러나지 않아 코드를 읽는 사람도 이해하기 어려울 수 있다.
그래서 원시 값 포장을 통해 유효성 검증을 객체 내부에서 일관되게 처리하고, 값의 의미를 명확히 전달해야 한다!!
예시: Money 객체로 원시 값을 포장하기
public class Money {
private final int amount;
public Money(int amount) {
if (amount < 0) {
throw new IllegalArgumentException("금액은 음수가 될 수 없습니다.");
}
this.amount = amount;
}
public Money add(Money other) {
return new Money(this.amount + other.amount);
}
public Money subtract(Money other) {
int result = this.amount - other.amount;
if (result < 0) {
throw new IllegalArgumentException("금액은 음수가 될 수 없습니다.");
}
return new Money(result);
}
public int getAmount() {
return amount;
}
}
Money 객체를 생성할 때 음수 값을 허용하지 않도록 제한했기 때문에, 객체를 사용하는 코드에서 유효성 검증을 반복할 필요가 없다. 모든 Money 객체는 항상 양수라는 전제를 가질 수 있어 코드가 더 간결해지고 안전해진다. 또한 Money 클래스는 final로 선언된 필드를 통해 불변성을 가진다. 이를 통해 값이 예기치 않게 변경되는 것을 방지할 수 있으며, 특히 멀티스레드 환경에서도 안전하게 사용할 수 있다.
VO와 원시 값 포장은 같은 의미일까?
결론부터 말하자면, 아니다.
엄밀히 말하면, VO(Value Object)를 만들기 위해 원시 값을 포장하는 것이다. 원시 값을 포장했다고 해서 모두 VO가 되는 것은 아니다. VO로서의 조건을 만족하려면 원시 값 포장에 몇 가지 추가적인 요건이 필요하다.
VO로서의 조건을 만족하는 원시 값 포장의 방식은 다음과 같다.
1. equals와 hashCode 메서드를 재정의하여, VO 간의 동등성 비교가 가능해야 한다.
2. 불변 객체여야 한다.
위의 Money 클래스는 int amount라는 원시 값을 포장하고, final로 선언해 재할당을 방지하여 불변성을 보장했다. 그러나 equals와 hashCode 메서드를 재정의하지 않았기 때문에 동등성 비교가 제대로 이루어지지 않는다.
따라서, 이 Money 클래스는 VO로서의 조건을 충족하지 못하고 있다. VO가 되기 위해서는 두 객체가 같은 값을 가지는지를 비교할 수 있도록 equals와 hashCode 메서드를 재정의해야 한다.
왜 VO를 만들기 위해서 equals와 hashCode 메서드를 재정의 해야하는데?
1.equals 메서드: 값 기반의 동등성 비교
먼저, equals 메서드를 재정의하지 않으면, 자바의 기본 equals 메서드는 객체의 메모리 주소(참조) 기반으로 동등성을 비교한다. 즉, 동일한 값이라도 객체가 서로 다른 메모리 위치에 존재하면 equals 메서드가 false를 반환한다.
Money money1 = new Money(100);
Money money2 = new Money(100);
예를 들어, 다음과 같이 두 Money 객체가 동일한 amount 값을 가지고 있다고 가정해보자.
money1과 money2는 new 키워드로 각각 생성된 서로 다른 객체이기 때문에, 기본 equals 메서드를 사용하면 서로 다른 객체로 간주가 된다. 그러나 Money 클래스에서는 금액이라는 값을 기준으로 동등성을 판단하기 때문에 amount 값이 같다면 같은 금액으로 봐야한다.
이를 위해 equals 메서드를 재정의하여 amount 값이 동일한 경우 true를 반환하도록 해야한다!
@Override
public boolean equals(Object o) {
if (this == o) return true; // 같은 객체면 바로 true 반환
if (o == null || getClass() != o.getClass()) return false; // null 또는 다른 클래스면 false
Money money = (Money) o; // Money 객체로 형변환
return amount == money.amount; // amount 값이 동일하면 true 반환
}
2. hashCode 메서드: 해시 기반 자료구조에서의 동작 보장
자바의 규칙에 따르면, equals 메서드가 true를 반환하는 두 객체는 반드시 같은 hashCode 값을 가져야한다.
그렇지 않으면, 해시 기반 자료구조(예: HashMap, HashSet)에서 의도치 않은 오류가 발생할 수 있다.
HashSet은 내부적으로 객체의 hashCode를 사용하여 객체를 고유하게 저장한다. 만약 Money 클래스에서 hashCode를 재정의하지 않으면, 동일한 값을 가진 Money 객체라도 HashSet에서 서로 다른 객체로 취급이 되게 된다.
Money money1 = new Money(100);
Money money2 = new Money(100);
Set<Money> moneySet = new HashSet<>();
moneySet.add(money1);
moneySet.add(money2); // money2가 같은 값이라도 hashCode가 다르면 추가됨
이 경우, money1과 money2는 equals로는 같은 객체로 취급되지만, hashCode가 재정의되지 않으면 서로 다른 해시 코드를 가질 수 있어 moneySet에 두 번 들어가게 된다. 따라서 equals 메서드로 동등한 두 객체는 같은 hashCode 값을 가지도록 보장해야 한다!
@Override
public int hashCode() {
return Objects.hash(amount);
}
원시 값 포장을 통한 VO 만들기
import java.util.Objects;
public class Money {
private final int amount;
public Money(int amount) {
if (amount < 0) {
throw new IllegalArgumentException("금액은 음수가 될 수 없습니다.");
}
this.amount = amount;
}
public Money add(Money other) {
return new Money(this.amount + other.amount);
}
public Money subtract(Money other) {
int result = this.amount - other.amount;
if (result < 0) {
throw new IllegalArgumentException("금액은 음수가 될 수 없습니다.");
}
return new Money(result);
}
public int getAmount() {
return amount;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Money money = (Money) o;
return amount == money.amount;
}
@Override
public int hashCode() {
return Objects.hash(amount);
}
}
Money 클래스를 equals와 hashCode 메서드를 재정의하는 과정을 거쳐 VO를 만들었다.
이를 통해 동일한 값을 가진 두 Money 객체를 같은 객체로 인식할 수 있게 되었다!!
public class Main {
public static void main(String[] args) {
Money money1 = new Money(100);
Money money2 = new Money(100);
Money money3 = new Money(200);
// money1과 money2는 amount 값이 같으므로 equals가 true를 반환
System.out.println(money1.equals(money2)); // true
// money1과 money3는 amount 값이 다르므로 equals가 false를 반환
System.out.println(money1.equals(money3)); // false
}
}
"자동차 경주"에서 VO로 본 것
이번 자동차 경주 미션에서는 "자동차 위치", "자동차 이름", "시도 횟수"를 VO로 구현하였다.
그 이유는 다음과 같다.
1. 원시 값을 포장해 도메인 내 의미를 명확히 하기 위해
원시 값을 바로 사용하는 대신 VO로 포장함으로써 코드의 의미를 명확하게 전달할 수 있다. 예를 들어, int형 값을 사용하여 자동차의 위치나 시도 횟수를 표현할 수 있지만, Position라는 VO로 감싸면 각 값이 자동차의 위치나 시도 횟수임을 분명히 할 수 있다.
2. 값 기반 동등성 비교를 가능하게 하기 위해
두 Position 객체가 동일한 위치 값을 가지고 있다면, 이를 같은 위치로 간주할 수 있어야 한다.
마찬가지로 Count와 Name(Name은 이름이 같을 경우 유효성 검증을 하도록 해두긴 했지만, 도메인 내에서 이름으로서 의미를 부여하기 위해 VO로 사용) 각 객체 두 개가 동일한 값을 가진다면 이는 동일한 객체로 판단해야 한다.
3. 불변성을 보장해 값의 안정성을 높이기 위해
VO는 상태가 변하지 않아야 신뢰할 수 있는 도메인 요소로 기능할 수 있다. 따라서 Name, Count, Position을 VO로 생성하여 이후에 변경되지 않도록 설계하였다.
package racingcar.domain;
public record Position(
int position
) {
static Position newInstance() {
return new Position(0);
}
public Position increment() {
return new Position(position + 1);
}
}
package racingcar.domain;
import racingcar.exception.ErrorMessage;
import racingcar.exception.RacingCarGameException;
public record Name(
String carName
) {
private static final int MAX_NAME_LENGTH = 5;
static Name newInstance(String carName) {
validateCarNameNotEmpty(carName);
validateCarNameLength(carName);
return new Name(carName);
}
private static void validateCarNameNotEmpty(String carName) {
if (carName.trim().isEmpty()) {
throw RacingCarGameException.from(ErrorMessage.EMPTY_CAR_NAME_ERROR);
}
}
private static void validateCarNameLength(String carName) {
if (carName.length() > MAX_NAME_LENGTH) {
throw RacingCarGameException.from(ErrorMessage.NAME_TOO_LONG);
}
}
}
package racingcar.domain;
import racingcar.exception.ErrorMessage;
import racingcar.exception.RacingCarGameException;
public record Count(
int count
) {
private static final int MINIMUM_TRY_COUNT = 1;
static Count newInstance(int count) {
validatePositiveIntegerForTryCount(count);
return new Count(count);
}
public Count decrement() {
return new Count(count - 1);
}
public boolean isComplete() {
return count == 0;
}
private static void validatePositiveIntegerForTryCount(int count) {
if (count < MINIMUM_TRY_COUNT) {
throw RacingCarGameException.from(ErrorMessage.TRY_COUNT_INVALID);
}
}
}
특이한 점은 VO를 record로 선언했다는 점이다.
Java의 record는 자동으로 equals와 hashCode 메서드를 생성하여 VO가 필요로 하는 값 기반의 동등성을 자연스럽게 제공한다는 것을 어느 블로그를 통해 알게되어서 코드 수를 줄일 수 있어 도입을 해보았다.
record를 사용하게 되면, 사용자가 직접 equals와 hashcode를 정의해 주지 못하는 점에 대해 불안하다는 의견도 있었다.
하지만, 나는 아직은 단점을 느끼지 못해서 앞으로도 VO를 정의할 떄는 record를 사용할 것 같다.
아쉬운 점
1️⃣ VO의 특성을 제대로 활용하지 못함
package racingcar.domain;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import racingcar.exception.ErrorMessage;
import racingcar.exception.RacingCarGameException;
public class Cars {
private static final int MIN_CAR_COUNT = 2;
private final List<Car> raceCars = new ArrayList<>();
public Cars(List<String> carNames) {
validateCarNameUniqueness(carNames);
validateCarCount(carNames);
initializeCars(carNames);
}
public void moveAll(NumberGenerator numberGenerator) {
for (Car car : raceCars) {
car.move(numberGenerator);
}
}
public List<Car> determineWinners() {
int maxPosition = findMaxPosition();
return filterWinningCars(maxPosition);
}
public List<Car> getCars() {
return raceCars;
}
private void validateCarNameUniqueness(List<String> carNames) {
Set<String> uniqueNames = new HashSet<>(carNames);
if (uniqueNames.size() != carNames.size()) {
throw RacingCarGameException.from(ErrorMessage.DUPLICATE_CAR_NAMES);
}
}
private void validateCarCount(List<String> carNames) {
if (carNames.size() < MIN_CAR_COUNT) {
throw RacingCarGameException.from(ErrorMessage.INSUFFICIENT_CAR_COUNT);
}
}
private void initializeCars(List<String> carNames) {
for (String carName : carNames) {
raceCars.add(new Car(carName));
}
}
private int findMaxPosition() {
return raceCars.stream()
.mapToInt(car -> car.getPosition().position())
.max()
.orElse(0);
}
private List<Car> filterWinningCars(int maxPosition) {
return raceCars.stream()
.filter(car -> car.getPosition().position() == maxPosition)
.toList();
}
}
Cars 클래스는 게임에 참여하는 모든 자동차를 움직이고, 우승자를 결정하는 책임을 가지고 있다.
determineWinners() 메서드에서 findMaxPosition() 메서드를 통해 최대 위치를 찾고, filterWinningCars() 메서드를 통해 최대 위치에 존재하는 자동차들만 필터링하여 우승자로 선정하는 방식이다.
앞서 Position을 VO로 선언하였기 때문에 Position 객체의 값이 같으면 동일한 위치로 간주할 것이다.
하지만, 내 코드를 보면 findMaxPosition과 filterWinningCars 메서드에서 Position 객체의 값을 꺼내서 비교를 하고 있다.
즉 VO 객체끼리 직접 비교하는 것이 아닌 VO의 값 자체를 꺼내서 비교를 하고 있다!!
이는 VO의 활용을 제대로 하지 못한 코드라 볼 수 있다.
그래서 미션이 끝나고 다음과 같이 리팩토링을 진행했다.
package racingcar.domain;
public record Position(
int position
) {
static Position newInstance() {
return new Position(0);
}
public Position increment() {
return new Position(position + 1);
}
public boolean isSamePosition(Position other) {
return this.position == other.position;
}
public boolean isGreaterThan(Position other) {
return this.position > other.position;
}
}
package racingcar.domain;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import racingcar.exception.ErrorMessage;
import racingcar.exception.RacingCarGameException;
public class Cars {
private static final int MIN_CAR_COUNT = 2;
private final List<Car> raceCars = new ArrayList<>();
public Cars(List<String> carNames) {
validateCarNameUniqueness(carNames);
validateCarCount(carNames);
initializeCars(carNames);
}
public void moveAll(NumberGenerator numberGenerator) {
for (Car car : raceCars) {
car.move(numberGenerator);
}
}
public List<Car> determineWinners() {
Position maxPosition = findMaxPosition();
return filterWinningCars(maxPosition);
}
public List<Car> getCars() {
return raceCars;
}
private void validateCarNameUniqueness(List<String> carNames) {
Set<String> uniqueNames = new HashSet<>(carNames);
if (uniqueNames.size() != carNames.size()) {
throw RacingCarGameException.from(ErrorMessage.DUPLICATE_CAR_NAMES);
}
}
private void validateCarCount(List<String> carNames) {
if (carNames.size() < MIN_CAR_COUNT) {
throw RacingCarGameException.from(ErrorMessage.INSUFFICIENT_CAR_COUNT);
}
}
private void initializeCars(List<String> carNames) {
for (String carName : carNames) {
raceCars.add(new Car(carName));
}
}
private Position findMaxPosition() {
return raceCars.stream()
.map(Car::getPosition)
.max(Position::isGreaterThan) // Position 객체끼리 비교
.orElse(Position.newInstance()); // Position(0) 반환
}
private List<Car> filterWinningCars(Position maxPosition) {
return raceCars.stream()
.filter(car -> car.getPosition().isSamePosition(maxPosition)) // Position 객체끼리 비교
.toList();
}
}
변경사항은 다음과 같다.
Position 레코드: isSamePosition(Position other) 및 isGreaterThan(Position other) 메서드를 추가하여 VO 객체 간의 값 비교를 하도록 변경.
Cars 클래스: findMaxPosition()과 filterWinningCars() 메서드에서 Position 객체끼리 직접 비교하도록 변경.
이렇게 변경하는 과정을 거치면서 VO의 의미와 활용에 대해 명확하게 알게되었다.
고민한 점
“자동차 경주” 미션을 진행하면서 여러 가지 고민이 있었고, 그중 몇 가지는 나름의 결론을 내릴 수 있었다.
정답은 없지만, 코드를 작성할 때 어느 정도의 기준은 필요하다고 생각한다.
이에 따라 다음과 같은 기준을 세웠다.
1️⃣ 상수를 정의하는 클래스를 만들고자 할 때, 열거형(enum)과 final class 중 어느 것을 써야 하는가?
결론
단일 값을 나타낼 때는 final class가 적합하고, 여러 관련된 값을 하나의 의미로 묶어 표현할 때는 enum이 적합하다고 생각한다.
이유
코드 리뷰 후, 1주차 문자열 계산기 코드를 구분자를 enum으로 리팩토링을 진행했었다.
예를 들어, COMMA(",") 형식으로 상수를 선언했는데, 단순한 값을 관리하는 final class에 비해 별다른 장점이 느껴지지 않았다. 오히려 생성자와 getter가 추가되면서 기존의 final class가 더 간결하고 명확하게 보인다고 느꼈다.
타입 안정성은 좋은 기능이지만, 이런 경우에 굳이 enum을 사용해야 하는지 의문이 들었다.
그러다 한 블로그에서 enum으로 자동차 브랜드를 관리하면서 내부에 모델명을 값으로 정의하는 예시(예: AUDI(A4, A6, A8))를 보고 생각이 바뀌었다. 모델명이 달라도 동일한 브랜드라는 의미를 가지도록 관련된 값을 묶어 표현하면 가독성이 더 좋아지고,
연관된 로직을 enum 내에 캡슐화 가능하다는 것을 알게 되었다.
그래서 "관련된 값을 하나의 의미로 묶어야 하는 경우 enum, 단일 값을 나타낼 때는 final class를 사용하자"는 결론에 이르렀다.
2️⃣ for문과 stream 중 어느 것을 써야 하는가?
결론
필터링(filter)이 필요한 로직에서는 stream을 적극적으로 사용하고, 그 외의 경우에는 가독성을 비교하여 더 나은 것을 선택하기로 결정했다.
이유
stream API는 특히 filter를 사용할 때 조건에 맞는 데이터를 추출하기 쉽고, 코드의 가독성도 높아지는 장점이 있다고 느꼈다. 하지만 단순히 반복하거나 상태를 변경하는 경우에는 기존 for 문이 더 직관적인 경우가 많았다. 또한, 성능 측면에서는 for 문이 stream보다 빠르다는 의견이 있지만, 하드웨어 성능이 발전하고 있기 때문에 코드 가독성을 더 우선시해야 한다고 판단했다.
따라서 “조건에 따른 데이터 필터링이 필요한 경우에는 stream을 먼저 적용해보고, 반복만을 수행하는 간단한 로직에서는 for 문을 적용하자”는 결론에 이르렀다.
느낀점
프리코스를 진행하며 가장 크게 깨달은 점은 “코드에는 절대적인 정답이 없지만, 자신이 작성한 코드에 대해 다른 사람이 이해할 수 있도록 명확히 설명할 수 있어야 한다”는 것이었다.
처음에는 SOLID 원칙, 특히 SRP(단일 책임 원칙)를 엄격히 지키면 코드의 질이 무조건 향상된다고 생각했다. 그러나 유효성 검증을 구현하는 과정에서 모든 상황에서 SRP를 엄격히 지키기보다, 실용적 접근이 필요할 때가 있다는 것을 깨닫게 되었다.
특히 SRP를 지키지 않더라도, 도메인 내부에 검증 로직을 포함함으로써 오히려 변경 전파의 영향을 줄이는 효과가 있다는 점이 큰 깨달음이었다.
다음은 내가 생각했던 예시이다.
외부 Validator 사용
예: PasswordValidator라는 외부 클래스에서 비밀번호의 길이, 특수문자 포함 여부 등을 검증하여, 여러 도메인에서 재사용할 수 있도록 한다.
장점: SRP 원칙을 준수하면서 코드 중복을 줄일 수 있음.
단점: 특정 도메인별 요구사항이 다를 경우, 하나의 Validator로 모든 조건을 처리하려다 보니 규칙의 확장이 복잡해지고, 변경이 모든 도메인에 전파되는 문제가 발생할 수 있다. 예를 들어, Admin 사용자와 일반 사용자에게 다른 보안 규칙이 적용되면 이를 외부 Validator에 모두 포함하기 어렵다.
도메인 내부에 검증 로직 포함
예: Admin과 User 클래스에 각각 검증 로직을 포함하여 Admin은 엄격한 검증 규칙을, User는 간단한 검증 규칙을 갖도록 한다.
장점: 도메인별 맞춤 규칙을 적용할 수 있고, 각 도메인의 검증 로직 변경 시 다른 도메인에 영향을 주지 않으므로 유지보수성이 높다.
단점: SRP 원칙을 엄격히 지키지는 않으나, 도메인 특성에 맞춘 검증을 캡슐화하여 오히려 변경 전파 문제를 줄일 수 있다.
해당 예시를 통해 내린 결론은 다음과 같다.
💡 변경 가능성이 낮은 공통 검증 로직: 외부 Validator로 분리하여 여러 클래스에서 재사용하는 것이 좋다.
💡 변경 가능성이 높은 도메인 종속적 검증 로직: 도메인 내부에 두어 변경 범위를 한정하는 것이 유지보수 측면에서 유리하다.
이처럼, SRP를 엄격히 지키기보다 상황에 맞게 조정해 변경 전파를 최소화하는 것이 더 효율적임을 깨달았다. 코드에 정답이 있는 것이 아니라, 상황에 가장 적합한 방식을 선택하고 이를 명확히 설명할 수 있는 것이 중요하다는 점을 배운 주차였다.
'woowacourse-pre' 카테고리의 다른 글
[우테코 7기 프리코스] 3주차 회고 (0) | 2024.11.16 |
---|---|
[우테코 7기 프리코스] 1주차 회고 (0) | 2024.10.26 |