
값을 감싸서 값 객체로 표현해보자
ds_chanin
·2025. 3. 17. 00:52
들어가며
원시 값들은 그 자체로 의미가 있긴한다. int는 숫자, String은 문자열 등등..
그런데 int와 String을 단순한 숫자와 문자열로 사용하지 않는 경우 불편한 상황이 생긴다.
여기서 불편한 상황이라면 int와 String을 특정한 맥락에서 사용할 때 최소한의 방어장치들이 없다는 것이다.
더 나아가 이러한 값들을 활용한 특별한 비즈니스 로직을 표현하기 불편한 상황이 있다.
구체적으로 어떠한 상황이 있을지 살펴보고
이러한 문제를 해결하기 위해 값을 감싸서 값 객체로 표현해보도록 하자.
예시
문자열로 식을 입력받아 계산을 하고 다음과 같은 제약 조건이 있다고 가정하자.
정책
- 문자열로 식을 전달받는다.
- 식의 구성은 숫자와 연산자로 이루어져 있으며 연산자는 덧셈, 뺄셈, 나눗셈, 곱셈만 지원한다.
- 연산 순서는 일반적인 사칙연산의 숫자가 아닌 왼쪽부터 순차적으로 적용한다.
- 숫자와 연산자는 한칸의 공백으로 구분한다.
입력 예시: 1 + 2 * 4 / 6
출력 예시: 2
간단하지 않은가?
예시 코드
public class Application {
public int run(String input) {
List<String> tokens = tokenize(input); // 1, +, 2, *, 3
Queue<Integer> numbers = new LinkedList<>();
Queue<String> operators = new LinkedList<>();
for (int index = 0; index < tokens.size(); index++) {
if (index % 2 == 0) {
numbers.offer(convertToNumber(tokens.get(index)));
} else {
operators.offer(tokens.get(index));
}
}
int result = numbers.poll();
while (numbers.isEmpty()) {
String operator = operators.poll();
int number = numbers.poll();
result += operate(result, number, operator);
}
return result;
}
private static List<String> tokenize(String input) {
return Arrays.stream(input.split(" "))
.collect(Collectors.toList());
}
private Integer convertToNumber(String token) {
try {
return Integer.parseInt(token);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("숫자가 아닌 값이 입력되었습니다 : " + token);
}
}
private int operate(int number1, int number2, String operator) {
if (operator.equals("+")) {
return number1 + number2;
}
if (operator.equals("-")) {
return number1 - number2;
}
if (operator.equals("*")) {
return number1 * number2;
}
if (operator.equals("/")) {
return number1 / number2;
}
throw new IllegalArgumentException("지원하지 않는 연산자 : " + operator);
}
}
더 검증할 예외 케이스가 많이 있으나 전부 작성하면 집중할 부분에 집중을 못하게 되니
호로록 코드를 작성하면 위와 같이 작성할 수 있겠다.
이제 문제점을 살펴보자
public Integer convertToNumber(String token)를 보면!
피 연산자로 숫자가 와야한다. 그러나 우리가 입력받는 값은 문자열이다.
따라서 문자열로 입력된 값이 숫자로 입력되었는지 검증은 필수적일 것이다.
그렇기에 convertToNumber를 통해 Integer로 String을 parse할 때 NumberFormatException이 발생하는 지 확인하고 이를 알려주도록 되어 있다.
이 부분은 굉장히 중요한 부분이라는데에는 반대 의견이 없을 것이라 생각한다.
중요한 부분이라는 것은 신뢰가 있어야하는 것이고 다시말해 테스트 코드를 통한 검증이 필요하다는 말이다.
그러나 이 부분을 테스트하려면 어떻게 해야하는가?
input에 올바른 식을 입력해야한다. 그것도 올바른 식을.
물론 지금은 식을 입력했을때 별다른 검증을 하는 부분이 없지만 중요한 것은 우리가 관심있는 사항을 테스트하기 위해
테스트 하고자 하는 부분과 관련 없는 부분에 대해서 알고 테스트를 수행해야 한다는 것이다.
마치 컵을 만들고 컵에 물이 세지 않는지 확인하기 위해,
정수기 급수대 아래에 있는 컵에 물을 잘 전달하는지 확인하며 시작하는 모양이랄까?
그냥 물은 아무렇게나 넣어도 상관없는데..?
우리는 관심사에 대해서만 알고 싶고 검증하고 싶다.
우리가 관심있는건 "String으로 이루어진 하나의 token이 숫자를 잘 표현하는지"이다.
특히 다른 부분에 의존하지 않고 영향받지 않고 독립적으로 테스트를 하고 싶다.
그럼 어떻게?
String으로 된 token을 하나의 클래스로 감싸자. 단일 값을 가진 클래스로!
public class NumberToken {
private final int number;
public NumberToken(int number) {
this.number = number;
}
public NumberToken(String token) {
this(convertToNumber(token));
}
private static int convertToNumber(String token) {
try {
return Integer.parseInt(token);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("숫자가 아닌 값이 입력되었습니다 : " + token);
}
}
}
위와 같이 설계를 하면 String으로 표현된 token을
숫자로 바꿀수 있는지에 대한 책임을 NumberToken이라는 클래스에게 일임할 수 있다.
이렇게 만든 NumberToken을 이용하여 기존 Application을 고쳐보면 다음과 같이 된다.
public class Application {
public int run(String input) {
List<String> tokens = tokenize(input); // 1, +, 2, *, 3
Queue<NumberToken> numbers = new LinkedList<>();
Queue<String> operators = new LinkedList<>();
for (int index = 0; index < tokens.size(); index++) {
if (index % 2 == 0) {
numbers.offer(new NumberToken(tokens.get(index)));
} else {
operators.offer(tokens.get(index));
}
}
NumberToken result = numbers.poll();
while (numbers.isEmpty()) {
String operator = operators.poll();
NumberToken number = numbers.poll();
result = operate(result, number, operator);
}
return result.getNumber();
}
private static List<String> tokenize(String input) {
return Arrays.stream(input.split(" "))
.collect(Collectors.toList());
}
private NumberToken operate(NumberToken number1, NumberToken number2, String operator) {
if (operator.equals("+")) {
return new NumberToken(number1.getNumber() + number2.getNumber());
}
if (operator.equals("-")) {
return new NumberToken(number1.getNumber() - number2.getNumber());
}
if (operator.equals("*")) {
return new NumberToken(number1.getNumber() * number2.getNumber());
}
if (operator.equals("/")) {
return new NumberToken(number1.getNumber() / number2.getNumber());
}
throw new IllegalArgumentException("지원하지 않는 연산자 : " + operator);
}
}
private 메서드였던 convertToNumber가 사라졌지만 여전히 아쉽다.
우리에겐 제거하고 싶은 그리고 검증해야하나는 private 메서드가 아직 한가지 더 남아있다.
매개변수가 3개나 되는 operate를 어떻게 바꿔보면 좋을까?
너무나도 쉬운 예제이다보니 쉽게 예상하리라 싶다.
"operate는 NumberToken이 수행해줄 수 있지 않을까?"
바로 적용해보자.
NumberToken에게 operate를 적절하게 이식하면 다음과 같이 될것이다.
public class NumberToken {
private final int number;
... 생략
public NumberToken operate(String operator, NumberToken numberToken) {
int result = doOperate(operator, numberToken);
return new NumberToken(result);
}
private int doOperate(String operator, NumberToken numberToken) {
if (operator.equals("+")) {
return this.number + numberToken.number;
}
if (operator.equals("-")) {
return this.number - numberToken.number;
}
if (operator.equals("*")) {
return this.number * numberToken.number;
}
if (operator.equals("/")) {
return this.number / numberToken.number;
}
throw new IllegalArgumentException("지원하지 않는 연산자 : " + operator);
}
}
operate의 매개변수가 3개였던게 NumberToken에게 이식되며 2개로 줄었다!
또한 NumberToken을 이용해 연산에 대한 테스트까지 가능해졌다!
그리고 이렇게 옮겨진 operate로 인해 Application은 조금 더 단순하게 표현이 되었다.
public class Application {
public int run(String input) {
List<String> tokens = tokenize(input); // 1, +, 2, *, 3
Queue<NumberToken> numbers = new LinkedList<>();
Queue<String> operators = new LinkedList<>();
for (int index = 0; index < tokens.size(); index++) {
if (index % 2 == 0) {
numbers.offer(new NumberToken(tokens.get(index)));
} else {
operators.offer(tokens.get(index));
}
}
NumberToken result = numbers.poll();
while (numbers.isEmpty()) {
String operator = operators.poll();
NumberToken number = numbers.poll();
result = result.operate(operator, number);
}
return result.getNumber();
}
private static List<String> tokenize(String input) {
return Arrays.stream(input.split(" "))
.collect(Collectors.toList());
}
}
Application에는 더 이상 테스트하기 힘든 private 메서드는 사라졌고 public 메서드만 남게되었다. 이제 중요한 부분은 NumberToken을 통해 신뢰성을 보장받게 되었다.
여기까지 하고 만족할 수 있으나 아마 한번 더 개선하고 싶은 부분이 보였을 것이다.
NumberToken도 만들었는데 String operator라고 위와 같이 표현을 못할까?
혹시 NumberToken의 operate의 문제점이 있다고 생각하지 못했다면
잠시 스크롤을 멈추고 위로 올라가 코드를 살펴보고 다시 내려오자.
operate의 문제점은 다음과 같다.
if절에서 +를 검사했으면 식에서도 +를 이용해주어야 하고, -를 검사했다면 -를 이용해야한다.
사람이 실수할 수 있는 부분이다.
"NumberToken 이용해서 operate를 검증하는 단위테스트를 작성하면 되지 않나?"
맞는 말인데 조금 아쉬운 면이 있다.
지금 상태에서 단위테스트를 작성한다면 아래와 같이 작성해야 할 것이다.
class NumberTokenTest {
@DisplayName("연산자에 따라 계산이 수행된다.")
@ParameterizedTest
@CsvSource(value = {
"3,+,3,6",
"3,-,3,0",
"3,*,3,9",
"3,/,3,1"
})
void test_11(int number1, String operator, int number2, int expected) {
//given
NumberToken numberToken1 = new NumberToken(number1);
NumberToken numberToken2 = new NumberToken(number2);
//when
NumberToken result = numberToken1.operate(operator, numberToken2);
//then
assertThat(result.getNumber()).isEqualTo(expected);
}
@DisplayName("지원하지 않는 연산자를 입력하면 exception이 발생한다.")
@Test
void test_23() {
NumberToken numberToken1 = new NumberToken(3);
NumberToken numberToken2 = new NumberToken(3);
assertThatThrownBy(() -> numberToken1.operate("!", numberToken2))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("지원하지 않는 연산자 : !");
}
}
두가지 아쉬운 점이 있다고 본다.
- 연산이 올바르게 동작하는지 테스트를 위해 NumberToken을 생성해주어야 한다.
- 올바르지 않은 연산자가 입력되었는지 확인하기 위해 NumberToken이 필요하다.
연산이 올바르게 되는지는 int값과 operator만 필요한데 꼭 NumberToken까지 필요한걸까?
그리고 NumberToken때와 마찬가지로 지원하지 않는 연산자를 확인하기 위해 NumberToken의 operate까지 수행해야만 할까?
그리 좋은 모습은 아니라는데 동의해주리라 믿는다~
그럼 이번에도 동일하게 OperatorToken을 만들어보자!
지원하는 operator의 종류만 사용할 수 있도록 보증하고 연산에 대한 책임을 NumberToken에서 OperatorToken으로 위임하자!
public class OperatorToken {
private static final Set<String> OPERATORS = new HashSet<>(Arrays.asList("+", "-", "*", "/"));
private final String operator;
public OperatorToken(String operator) {
this.operator = operator;
this.validate();
}
private void validate() {
if (!OPERATORS.contains(operator)) {
throw new IllegalArgumentException("지원하지 않는 연산자 : " + operator);
}
}
public int operate(int number1, int number2) {
switch (operator) {
case "+":
return number1 + number2;
case "-":
return number1 - number2;
case "*":
return number1 * number2;
case "/":
return number1 / number2;
default:
throw new IllegalArgumentException("지원하지 않는 연산자 : " + operator);
}
}
}
OperatorToken을 생성하게 되면서 지원하는 연산자인지 생성자에서 검증하면서 신뢰성을 얻게 되었고
연산에 대한 부분도 더이상 NumberToken이 필요하지 않다.
이제 테스트 코드는 아래와 같이 간결해질 수 있다.
class OperatorTokenTest {
@DisplayName("연산자에 따라 계산이 수행된다.")
@ParameterizedTest
@CsvSource(value = {
"3,+,3,6",
"3,-,3,0",
"3,*,3,9",
"3,/,3,1"
})
void test_11(int number1, String operator, int number2, int expected) {
//given
OperatorToken operatorToken = new OperatorToken(operator);
//when
int result = operatorToken.operate(number1, number2);
//then
assertThat(result).isEqualTo(expected);
}
@DisplayName("지원하지 않는 연산자를 입력하면 exception이 발생한다.")
@Test
void test_23() {
assertThatThrownBy(() -> new OperatorToken("!"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("지원하지 않는 연산자 : !");
}
}
NumberToken을 의존하지 않는 깔끔한 테스트 코드가 되었다!
그리고 NumberToken 또한 연산에 대한 책임에서 벗어나 한결 가벼운 코드가 되었다.
public class NumberToken {
private final int number;
... 생략
public NumberToken operate(OperatorToken operator, NumberToken numberToken) {
int result = operator.operate(this.number, numberToken.number);
return new NumberToken(result);
}
}
물론 다른 부분도 더 개선할 여지가 많이 있으나
이번에 이야기 하고자 한 값을 감싸서 표현하는 이유에 대해서는 충분히 전달되지 않았을까?
마치며
이와 같이 String 자체가 가지고 있는 의미는 문자열이지만 우리가 생성하는 애플리케이션의 맥락에 따라 String은 여러가지 의미를 가질 수 있다.
지금처럼 값을 감싸 값 객체로 표현을 하면 애플리케이션의 특정 맥락에서 String만으로는 의미를 파악하기 힘들었을 값을 명시적인 역할과 책임을 가진 객체로서 구분이 가능해진다.
또한 코드를 파악하기 더 쉬워지는 것은 당연하고 비슷한 요구사항이 있을때 동일 코드를 반복적으로 작성하지 않고 활용할 수 있을것이다.
마지막에 OperatorToken을 개선했는데, 아직 이 부분에 대해서는 더 개선할 여지가 남아있다.
이에 대해서는 다음 글에서 이야기해보도록 하자~!
'스터디 > 코드리뷰' 카테고리의 다른 글
테스트 코드에는 일련의 로직을 넣지 말자 (0) | 2025.02.23 |
---|---|
하드코딩한 매직넘버는 상수로써 표현하자 (0) | 2025.02.21 |
테스트도 단일책임원칙을 지키자 (0) | 2025.02.19 |
생성자 내부에서 너무 많은 일을 하지 말자 (0) | 2025.02.19 |
Exception을 테스트 할 때 거짓 음성을 주의하자 (0) | 2025.02.18 |