드디어 이번 주차 코드 리뷰를 모두 마쳤다!
시험과 개인적인 사정으로 인해 이번 주차는 조금 늦게 시작했고, 진행하는 데도 시간이 꽤 걸렸다. 그래도 하나하나 리뷰를 남기고, 다른 사람들의 코드를 보면서 배우는 과정이 정말 재미있었다. 다만 생각보다 많은 시간을 쏟아서 그런지, 2주차 미션은 조금 촉박하게 느껴질 것 같다.
앞으로 레벨이 올라갈수록 공부할 시간은 점점 줄어들 테니, 다음부터는 코드 리뷰를 조금 더 효율적으로, 그리고 적절한 양으로 진행해야겠다는 생각이 들었다. 그래도 이번 리뷰를 통해 정말 많이 배우고 성장할 수 있어서 뿌듯하다.
이번에 느낀 점 중 하나는, 정말 잘하는 사람들이 너무 많다는 것!
DI를 복잡하게 설계한 사람도 있었고, implements를 두 번 연달아 쓰거나 T 제네릭을 적극적으로 활용하는 사람들도 있었다. 나도 T를 공부하긴 했지만, 실제로 복잡하게 적용된 코드를 보니 이해하는 데 꽤 시간이 걸렸다.
이런 코드를 보면서 “나도 더 깊이 공부해야겠다”는 자극을 많이 받았다.
내 목표는 과제를 진행하면서 필요한 지식을 직접 배우는 것이었는데, 이번 코드 리뷰를 통해서 ‘리뷰를 받는 것’뿐만 아니라 다른 사람들의 코드를 보는 것도 정말 좋은 공부가 된다는 걸 다시 한 번 느꼈다. 앞으로도 계속 열심히 따라가며 성장하고 싶다.
다른 사람들의 리뷰에서 배운 점
다른 사람들의 코드를 보면서 정말 많은 것을 배웠다. 특히 “이 부분의 의도는 무엇이었을까?” 하고 물어보는 과정이 정말 흥미로웠다.
코드 리뷰를 하고 받는 것뿐만 아니라, 다른 사람들의 리뷰 과정을 지켜보거나 직접 질문을 던지고 답을 받고 하는 것이 특히 재밌었다.
“왜 이렇게 생각하셨을까?”, “이 부분은 코드를 어떻게 바꾸면 좋을까요?” 같이 의견을 주고받는 시간이 정말 유익했다. 몰랐던 것도 알고 나와 다른 사람들의 의견을 들을 수 있었다는 점도 좋앗다.
이렇게 여러 사람의 시각에서 코드를 바라보며 배운 점이 많아 정리해두고 싶다는 생각으로 이번 글을 작성하게 되었다!
다이어그램
함수의 흐름을 한 눈에 보기 쉽게 다이어그램으로 정리한 분들이 꽤 있었다.
나도 사람들이 보기 편했으면 좋겠다는 생각으로 PR 메시지에 글로 흐름을 정리해두기도 했다.

그런데 코드 리뷰를 하면서 본 다른 분들의 다이어그램이 훨씬 보기 좋았고, 어떤 도구를 사용했는지 물어보니 Visual Paradigm과 Mermaid란 툴을 사용했다고 하셨는데, 나는 그 중에서 Mermaid가 좀 더 나아보였다.
이 툴을 이용하면 아래와 같이 흐름을 간단하게 시가고하할 수 있다. 아래 예시는 단순하지만, 실제 과제 코드 흐름을 Mermaid로 표현한 다이어그램들을 보니 확실히 구조를 한 눈에 파악할 수 있어서 보기 편했다.

처음에는 IntellilJ의 다이어그램 기능을 사용해볼가 고민했지만, Mermaid가 훨씬 더 직관적이고 깔끔하게 보여서 앞으로 이쪽을 써볼 생각이다.
DI(Dependency Injection)
코드 리뷰를 하면서 DI(의존성 주입)와 관련된 이야기를 가장 많이 보았다. 특히 `Controller`가 `InputView`나 `OutputView` 같은 객체를 직접 생성할지, 아니면 외부에서 주입받을지에 대한 논의가 활발했다.
나 같은 경우 처음에는 이 부분을 깊게 고민하지 않았다. `Controller`는 입력을 받고 `Service`는 로직을 수행하는 구조라고만 생각했기 때문에 `InputView`와 `OutputView`를 전부 `static` 메서드로 만들어 `Service` 내부에서 바로 호출하는 방식으로 구현했었다.
그런데 다른 사람들의 코드를 보면서 생각이 달라졌다. `Controller`가 `View`를 생성자 주입(constructor injection) 으로 받는 구조를 사용하면 훨씬 유연하다는 것을 알게 되었다.
이 방식은 테스트 용이성 측면에서 특히 강점이 있다. 실제 콘솔 입력 대신 `가짜 InputView(Mock)`를 주입할 수 있고, 나중에 입력 방식을 파일 입력으로, 출력 방식을 GUI나 웹으로 바꾸더라도 `Controller` 코드는 수정할 필요가 없다. 즉, 클래스 간 결합도를 낮추고 책임을 명확히 나눌 수 있다.
반대로 `Controller` 내부에서 객체를 직접 생성하면 테스트가 어렵고 결합도가 높아진다. 이 경우 `View`의 구현체가 바뀌면 `Controller`까지 수정해야 하므로 OCP(개방-폐쇄 원칙) 을 위반하게 된다. 또한 객체 생성 시점에 의존성이 고정되기 때문에 재사용성과 확장성도 떨어진다.
그래서 입력과 출력처럼 외부 리소스에 접근하는 객체는 `Controller`가 직접 만들기보다 외부에서 주입받아 관리하는 구조가 훨씬 낫다는 결론에 도달했다.
이 과정에서 `AppConfig`를 이용해 객체를 미리 생성해두고 `Controller`에 주입하는 방식도 자주 보였다. 내가 본 코드에서는 AppConfig 안에 `InputView`와 `OutputView`가 필드로 선언되어 있었고, 이 인스턴스들을 그대로 Controller 생성자에 전달하는 형태였다.
public class AppConfig {
private final InputView inputView = new InputView();
private final OutputView outputView = new OutputView();
}
이렇게 구성하면 `Controller`나 `Service`는 어떤 `View`를 사용하는지 몰라도 된다. 입출력 방식을 바꾸더라도 `AppConfig`의 필드만 수정하면 되기 때문에 훨씬 유연하다.
또한 테스트 시에는 `InputView` 대신 `Mock` 객체를 쉽게 교체할 수도 있다. 이 개념은 `Spring`의 `@Configuration`과 `@Bean`과도 같다.
즉, 객체 생성의 제어권을 외부로 옮겨 의존 관계를 명확히 하고 결합도를 낮추는 방식이다. 결국 DI는 단순히 객체를 외부에서 주입하는 기술이 아니라, 변경에 유연하고 관리하기 쉬운 구조를 만드는 설계 방식이라는 점을 느꼈다.
isBlank
if (expression == null || expression.isBlank()) {
return 0;
}
위 코드에 아래처럼 코드 리뷰가 달라서 의아했었다. isBlank가 또 있다고?라는 생각이 들었다.
org.junit.platform.commons.util.StringUtils 클래스에 이와 유사한 동작을 하는 isBlank 메서드가 정의되어 있어요. 참고하셔도 좋을 것 같아요 😊


확인해보니 두 메서드는 모두 문자열이 비어 있거나 공백으로만 이루어져 있는지를 판별한다는 점에서는 같았지만, 동작 방식에는 차이가 있었다.
`StringUtils.isBlank(String str)`은 `static` 메서드로, 매개변수가 `null`이거나 `trim()` 결과가 빈 문자열일 경우 `true`를 반환한다. 즉 null 값까지 함께 처리하기 때문에 null-safe하다는 특징이 있다.
반면 `String.isBlank()`는 인스턴스 메서드로, 내부적으로 `indexOfNonWhitespace()`를 호출하여 문자열 내 공백이 아닌 문자가 하나라도 존재하는지를 검사한다. 이 방식은 `trim()`처럼 문자열을 새로 생성하지 않아 더 효율적이지만, `null`인 경우에는 `NullPointerException`이 발생하기 때문에 null-safe하지 않다.
결국 두 메서드는 공백 문자열 판별이라는 목적은 같지만 아래와 같은 차이가 있다.
- `StringUtils.isBlank()`: null까지 포함해 안전하게 처리
- `String.isBlank()`: null이 아닐 때 더 빠르고 간결
즉, 입력값이 `null`일 가능성이 있다면 `StringUtils.isBlank()`를, 그렇지 않다면 `String.isBlank()`를 사용하는 것이 더 적절하다고 느꼈다.
그래서 기존의 코드는 아래처럼 바꿀 수 있다.
import org.junit.platform.commons.util.StringUtils;
if (StringUtils.isBlank(expression)) {
return 0;
}
`StringUtils.isBlank()`를 사용하면 `null` 체크와 공백 체크를 한 줄로 합칠 수 있어 코드가 더 간결해지고, 실수로 `null`을 놓칠 가능성도 줄어든다.
출력 책임 분리와 확장 가능성
이번 과제에서는 결과를 출력할 때 `"결과 : "`라는 접두사를 붙이는 것이 요구사항이었다.
한 코드 리뷰에서 출력 메서드 구현 방식에 대한 흥미로운 논의가 있었다. 처음 코드는 단순히 콘솔에 문자열을 출력하는 형태였다.
@Override
public void print(String message) {
System.out.println(message);
}
이에 `"결과 : "` 형식의 출력을 따로 담당하는 메서드를 만들어, 과제 요구사항에 맞는 출력 형태를 명확히 표현하자는 의견이 나왔다. 하지만 코드 작성자는 별도의 메서드를 만들지 않고, 아래처럼 필요한 곳에서 직접 `"결과 : "`를 붙여 출력하는 방식을 택했다.
printer.print("결과 : " + {결과});
댓글 내용을 보면, 단순히 결과 출력을 분리하기보다 출력 책임을 한곳으로 집중시켜 변경에 유연한 구조를 만들고자 한 것으로 보였다.
이 방식이라면 출력 형식이 변경되거나, 출력 수단이 콘솔에서 `Logger`나 `FileWriter` 등으로 교체되더라도 `Printer` 구현체만 수정하면 된다.
즉, `"결과 : "`라는 요구사항을 유지하면서도 출력 로직을 인터페이스 단위로 추상화해 OCP를 구현한 방식이다.
오버플로우
이번 과제에서 합계를 계산하는 과정에서 나는 단순히 `long` 타입을 사용하여 가능한 범위를 넓히는 방식으로 구현했었다.
그런데 코드 리뷰를 하던 중 아래와 같은 코드를 보고 눈이 갔다.
public long sum() {
try {
long initValue = 0L;
return numbers.stream()
.reduce(initValue, Math::addExact);
} catch (ArithmeticException arithmeticException) {
throw new IllegalArgumentException("[ERROR] 덧셈 가능한 범위를 초과하였습니다");
}
}
처음엔 왜 `Long::sum` 대신 `Math.addExact`를 썼는지 궁금해서 찾아봤다. 알고 보니 `Math.addExact()`는 덧셈 중 오버플로우가 발생하면 `ArithmeticException`을 던져 잘못된 계산을 방지하는 메서드였다.
자바의 `일반 덧셈(+)`이나 `Long::sum`은 범위를 초과해도 예외를 던지지 않고, `Long.MAX_VALUE + 1` 같은 경우 단순히 `Long.MIN_VALUE`로 순환되어 버린다. 반면 `Math.addExact()`는 이런 상황을 감지해 예외를 발생시키므로 명확하게 에러를 처리할 수 있다.
그동안 나는 단순히 `long`이면 충분하다고 생각했는데 오류를 감지하고 처리하는 방식이 더 적절한 것 같다는 생각이 들었다.
Facade
최근 우테코 프리코스에서 코드 리뷰를 진행하면서 Facade 패턴이라는 개념을 다시 접하게 되었다.
예전에 정보처리기사 공부를 하면서 한 번쯤 들어봤던 개념이었지만, 그 당시에는 단순히 용어만 외웠을 뿐 깊이 이해하진 못했다.
이번에 다른 분 코드에서 `CalculatorFacade`라는 클래스를 보고 "왜 굳이 `Facade`를 붙였을까"하는 궁금증이 생겼다. 그래서 정의와 예제 코드를 찾아보면서 Facade가 어떤 의도로 사용되는지 다시 공부해보았다.

처음에는 `"복잡한 비즈니스 로직을 하나의 간단한 메서드로 캡슐화한다"`라는 정의를 보았는데, 솔직히 그 문장만으로는 잘 와닿지 않았다. 그래서 사람들이 실제로 어떻게 Facade를 활용하는지 궁금해 여러 자료를 찾아보았고, 그 내용을 블로그에 정리해보았다.
Facade Pattern
최근 우테코 프리코스에서 코드 리뷰를 진행하면서 Facade 패턴이라는 개념을 다시 접하게 되었다. 예전에 정보처리기사 공부를 할 때 한 번쯤 들어봤던 개념이지만, 그 당시에는 단순히 용어 정
best11gh.tistory.com
블로그에도 적어두었지만, 처음에는 단순한 예시 코드만 보고 아래처럼 생각했다.
이건 그냥 `Service`나 `Domain`으로도 만들 수 있지 않을까?
하지만 다른 사람들이 작성한 조금 더 구체적인 예시 코드들을 보면서, `Facade`는 단순히 기능을 제공하는 클래스가 아니라 여러 `Service`의 협력을 조율하는 조립자 역할을 한다는 것을 이해하게 되었다.
그렇게 이해하고 난 직후에는 더 궁금증이 생겼다. 코드 리뷰 대상자 분의 폴더 구조는 아래와 같다.
calculator
├── Application.java
├── controller
│ └── CalculatorController.java
├── domain
│ ├── Delimiter.java
│ ├── DelimiterExtractor.java
│ ├── ExpressionParser.java
│ ├── NumberExtractor.java
│ └── Numbers.java
├── facade
│ └── CalculatorFacade.java
└── view
├── InputView.java
└── OutputView.java
처음에는 "그냥 `Calculator`로 해도 되는 거 아닌가" 싶었다. 겉보기에는 `domain`처럼 보였기 때문이다. 사실 나는 `service` 쪽에 더 가깝다고 생각했지만, 이번 과제의 주제가 "문자열 덧셈 계산기"였기 때문에 "계산기"라는 개념 자체가 핵심 도메인 로직에 더 가깝다고 느꼈다. 그래서 나의 경우에는 `StringCalculator`를 domain 계층에 배치하는 것이 자연스럽다고 판단했다.
그러던 중 이런 문장을 보았다.
퍼사드는 전략 패턴이나 팩토리 패턴 같은 다른 디자인 패턴과 다르게 특정 형태의 클래스 구조를 강제하지 않는다.
이 말을 보고 나니 "내가 본 코드나 내가 한 선택 모두 틀린 건 아니구나"라는 생각이 들었다. 내가 본 예시들은 주로 여러 `Service`의 협력을 조율하는 형태였지만, Facade의 본질적인 의미는 복잡한 시스템을 감추고 단순한 인터페이스를 제공하는 것이다. 그렇다면 `StringCalculator`나 `CalculatorFacade` 역시 그 의미에 충분히 부합한다고 느꼈다.
peek
public int calculate(String input) {
String[] numbers = delimiterParser.parse(input);
return Arrays.stream(numbers)
.peek(NumberValidator::validate)
.mapToInt(Integer::parseInt)
.sum();
}

처음에는 코드를 보고 "오 `peek`이란 게 있구나 다음에 써봐야겠다"라고 생각했다. 근데 바로 아래 코드 리뷰에서 `peek`이 디버깅이나 로깅용 중간 연산이라는 걸 보고 "아 다음에도 안 써야겠다..."라는 생각이 들었다.
그래도 예전에는 이 값들을 하나씩 어떻게 확인하지 싶었는데 생각해보니 디버깅 할 때 딱 정말 유용한 메서드인 것 같다.
StringJoiner
코드 리뷰를 하던 중 `StringBuilder`를 쓴 코드에 아래처럼 다른 방식을 소개하는 피드백을 봤다. 나도 StringBuilder를 썼고 저건 몰랐기 떄문에 찾아봤다.
StringJoiner 사용해보시면 좋을 것 같아요. 해당 메서드를 더 간편하게 같은 결과로 만들어줍니다.미세하게 더 느린데 걱정 할 정도는 아닙니다.
기존 코드는 다음과 같이 `StringBuilder`를 사용해 문자열을 이어붙이고 있었다.
public String getAllDelimiters() {
StringBuilder stringBuilder = new StringBuilder("[");
for (Character character : delimiters) {
stringBuilder.append(character);
}
stringBuilder.append("]");
return stringBuilder.toString();
}
`StringJoiner`를 사용하면 아래와 같이 더 간결한 코드로 동일한 결과를 얻을 수 있다.
public String getAllDelimiters() {
StringJoiner joiner = new StringJoiner("", "[", "]");
for (Character character : delimiters) {
joiner.add(String.valueOf(character));
}
return joiner.toString();
}
- delimiter : 각 문자열 사이에 들어갈 구분자
- prefix : 전체 문자열의 시작 부분에 붙는 문자
- suffix : 전체 문자열의 끝부분에 붙는 문자
내부적으로 `StringBuilder`를 사용하기 때문에 성능 차이도 거의 없다.
null 반환
코드 리뷰를 하던 중 아래 코드에 대한 피드백을 보고 궁금증이 생겼다.
private String checkCustomHeader(String rawExpression) {
Matcher matcher = CUSTOM_HEADER_PATTERN.matcher(rawExpression);
return matcher.matches() ? matcher.group(1) : null;
}

나는 개인적으로 `null`을 반환해도 괜찮다고 생각했기 때문에, 아래와 같은 질문을 남겼다.

이에 대한 답변은 다음과 같았다.
- 메서드 반환 타입이 `String`으로 명시되어 있는데 예상치 못한 `null`이 반환되면 `NullPointerException`이 발생할 수 있다.
- `null`이 반환될 수 있다면 호출하는 쪽은 항상 `null` 체크를 해야 하고, 이런 코드는 가독성도 떨어지고 실수하기 쉽다.
- 따라서 `null` 대신 `""`을 반환하거나, 더 나아가 상수로 관리하는 것이 좋다.
답변을 듣고 생각해보니 정말 맞는 말이었다. 아무리 `String`이 `null`을 가질 수 있는 타입이라 하더라도, 매번 `null` 체크를 하는 건 번거롭고 그걸 한 번이라도 놓친다면 의도치 않은 예외가 발생할 수 있다.
값 불변
이번 과제에서 나는 불변(Immutable) 구조를 따로 만들지 않았다. 그런데 다른 사람의 코드 리뷰에서 아래와 같은 코드를 보고 눈에 띄는 피드백을 발견했다.
public List<BigDecimal> getValue() {
return value;
}

피드백의 핵심은 리스트를 그대로 반환하면 불변이 깨질 수 있다는 점이었다. 이렇게 작성하면 외부에서 `getValue()`로 리스트를 받은 뒤
`add()`나 `clear()` 같은 메서드로 내부 상태를 변경할 수 있기 때문이다.
즉, final만으로는 완전한 불변이 보장되지 않는다. 리스트를 외부에 노출할 때는 `Collections.unmodifiableList()`로 감싸서 읽기 전용으로 만들거나, `List.copyOf()`를 사용해 새로운 불변 리스트를 반환해야 진짜로 변경 불가능한 컬렉션이 된다.
조금 더 알아보니 두 메서드는 비슷해 보이지만, 동작 방식이 다르다.
| 구분 | `Collections.unmodifiableList()` | `List.copyOf()` |
| 방식 | 기존 리스트를 포장(wrapper)만 함 | 새로운 리스트로 복사(copy)함 |
| 원본 변경 영향 | 원본이 바뀌면 같이 바뀜 | 원본이 바뀌어도 영향 없음 |
| Java 버전 | Java 1.2부터 | Java 10부터 |
| null 허용 여부 | 허용 | null 원소 있으면 예외 발생 |
| 성능 | 복사 안 하므로 빠름 | 복사 수행하므로 약간 느림 |
| 불변성 수준 | 읽기 전용 수준 | 완전한 불변 (Immutable) |
| 대표적 사용 시점 | 기존 리스트를 그대로 노출해야 할 때 | 완전히 새로운 불변 컬렉션을 만들 때 |
요약하자면 아래와 같다.
- `unmodifiableList()`: 원본을 감싸서 수정만 막는 읽기 전용 뷰
- `List.copyOf()`: 원본을 복사해 완전히 불변으로 만드는 진짜 복사본
만약 리스트 내부의 요소까지 완전한 불변을 보장하고 싶다면, 단순히 두 메서드를 사용하는 것만으로는 부족하다. 이때는 내부 데이터를 불변 객체로 만들거나, 원본 리스트의 각 요소를 복사해 새로운 리스트를 생성해 반환해야 한다.
정적 팩토리 메서드
다른 사람의 코드 리뷰를 보던 중, 정적 팩토리 메서드를 사용한 이유를 잘 정리한 댓글을 보게 되었다.
그 내용은 읽고 나도 정적 팩토리 메서드의 장점을 한 번 더 생각해볼 수 있었다.

나는 기본 생성자 대신 정적 팩토리 메서드를 사용한 이유가 객체 생성의 의도를 더 명확히 드러내기 위해서라고 생각한다.
생성자를 그대로 사용하면 단순히 "객체를 만든다"는 의미에 그치지만, 정적 팩토리 메서드는 메서드명으로 이 객체가 어떤 맥락에서, 어떤 방식으로 만들어지는 가를 표현할 수 있다.
예를 들어, 아래처럼 이름을 붙이면 해당 객체가 주어진 입력으로부터 생성되는 도메인 객체라는 의미가 훨씬 명확해진다.
Numbers.of(tokens);
Delimiters.from(custom);
또 다른 이유는 캡슐화이다. 객체 생성 과정을 외부에 노출하지 않기 때문에 내부 구현이 바뀌더라도 외부(`Service`) 코드는 수정할 필요가 없다.
즉, 이번 시도는 도메인 객체의 생성 책임을 도메인 내부로 옮겼다는 점에서 의미가 있었다고 생각한다. 이로써 역할 분리가 더 명확해지고, 테스트나 유지보수 측면에서도 훨씬 유연한 구조가 되었다.
헥사고날
코드를 살펴보던 중 대부분의 사람들은 익숙한 MVC 패턴을 사용했하고 있었다. 그런데 한 사람의 프로젝트에서 조금 특이한 폴더 구조를 발견했다.
src
└── main
└── java
└── calculator
├── Application.java
├── application
│ ├── port
│ │ └── inbound
│ │ ├── CalculateUseCase.java
│ │ ├── DelimiterParserUseCase.java
│ │ └── NumericDataMapperUseCase.java
│ └── service
│ ├── CalculatorService.java
│ ├── DelimiterParseDecisionService.java
│ ├── DelimiterParserService.java
│ └── NumericDataMapperService.java
├── bootstrap
│ └── AppConfig.java
├── domain
│ └── Delimiter.java
└── interfaces
└── adapter
├── inbound
│ ├── CalculatorController.java
│ ├── ConsoleRequestReader.java
│ └── RequestReader.java
└── outbound
├── CalculatorView.java
├── ErrorMessages.java
└── OutputMessages.java
└── test
이 구조를 보는 순간 "이건 무슨 구조지"하는 궁금증이 생겼다. 그래서 작성자에게 물어봤고 이 구조가 헥사고날 아키텍처라는 것을 알게 되었다.

조금 더 찾아보니, 이 아키텍처는 의존성의 방향을 안쪽으로 고정시키는 구조를 기반으로 하고 있었다.
- 가장 안쪽의 domain이 애플리케이션의 핵심 비즈니스 로직을 담당하고,
- 그 바깥의 application이 도메인과 외부를 연결하는 포트를 정의하며,
- 가장 바깥의 interfaces/adapter가 실제 구현체(DB, Web, CLI 등)를 포함하는 어댑터(Adapters) 역할을 한다.
솔직히 이전까지는 헥사고날 아키텍처라는 말을 단순히 용어로만 알고 있었다. 그런데 이번에 실제 코드를 보고 구조를 직접 찾아보니 MVC와 완전히 다른 관점에서 설계된 패턴이라는 걸 느꼈다.
예외 메시지 enum
예외 메시지를 관리할 때 나는 `ErrorMessage`라는 클래스를 만들어 정적 변수로 한 곳에서 관리하고 있었다. 그런데 코드 리뷰를 보던 중, `enum`으로 예외 메시지를 관리한 사람들이 꽤 많다는 것을 알게 되었고 그렇게 하면 어떤 이점이 있는지 궁금증이 생겼다. 그래서 `enum`을 사용한 사람들에게 물어보고 다녔다.


그 과정에서 특히 눈에 들어온 부분이 있었다. 두 명의 코드 리뷰에서 모두 `Object... obj`를 사용하고 있었던 것이다. 처음 본 거여서 찾아보니 이 문법은 자바의 가변 인자(varargs) 기능이었다.
public String format(Object... obj) {
return template.formatted(obj);
}
즉, 인자의 개수를 자유롭게 넘길 수 있고, 컴파일러가 자동으로 `Object[]` 배열로 변환해준다. 예를 들어 아래 두 코드는 완전히 동일하게 동작한다.
getFormatObject(1, 2, 3);
getFormatObject(new Object[]{1, 2, 3});
가변 인자를 사용하면
- 인자 개수가 달라도 하나의 메서드로 처리할 수 있고,
- 포맷 유무에 상관없이 일관된 방식으로 메시지를 구성할 수 있다는 장점이 있다.
다만 포맷 지정자(`%d`, `%s` 등)와 인자 개수나 타입이 맞지 않으면 `MissingFormatArgumentException`이나 `IllegalFormatConversionException`이 발생할 수 있으므로 주의해야 한다.

이전 방식(`static final 상수`)은 단순히 문자열 메시지만 다뤘지만, enum을 사용하면 코드, 템플릿, 상태 코드, 로그 레벨 등 다양한 정보를 함께 관리할 수 있다. 아래는 그런 방식을 보여주는 예시 코드이다.
public enum ErrorCode {
INVALID_INPUT("C001", "입력값이 잘못되었습니다: %s", 400, LogLevel.WARN),
MISSING_DELIMITER("C002", "커스텀 구분자 선언이 누락되었습니다", 400, LogLevel.WARN),
NEGATIVE_NUMBER("C003", "음수는 허용되지 않습니다: %s", 400, LogLevel.WARN),
INTERNAL_ERROR("S000", "알 수 없는 서버 오류가 발생했습니다", 500, LogLevel.ERROR);
private final String code; // 안정적인 식별자 (대시보드/문서화/클라이언트 핸들링용)
private final String template; // 메시지 템플릿(포맷)
private final int httpStatus; // 웹이라면 응답 코드 매핑
private final LogLevel logLevel; // 로깅 심각도
ErrorCode(String code, String template, int httpStatus, LogLevel logLevel) {
this.code = code;
this.template = template;
this.httpStatus = httpStatus;
this.logLevel = logLevel;
}
public String code() { return code; }
public String template() { return template; }
public int httpStatus() { return httpStatus; }
public LogLevel logLevel() { return logLevel; }
public String format(Object... args) {
return template.formatted(args);
}
public enum LogLevel { TRACE, DEBUG, INFO, WARN, ERROR }
}
이처럼 enum을 사용하면 아래와 같은 이점이 있다.
- 에러 코드 식별자와 메시지의 일관성 유지,
- 다양한 부가 정보(HTTP 상태, 로그 레벨 등) 통합 관리,
- 템플릿 기반 포맷팅 지원
즉, 단순히 상수를 모으는 것을 넘어, 하나의 에러를 “객체”로 모델링하는 구조라는 점이 가장 큰 차이였다.
의존성 주입 받지 않는 추가적인 필드
public class CalculatorService {
private final Input input;
private final Delimiters delimiters;
private final Numbers numbers;
public CalculatorService(String origin) {
this.input = new Input(origin);
this.delimiters = new Delimiters();
this.numbers = new Numbers();
}
}

저 코멘트를 보고도 처음에는 명확히 이해되지 않아 추가로 질문을 남겼다.
당시에는 "어차피 `origin`만 생성자를 통해 주입받고 있으니, 나머지는 그냥 내부에서 생성해도 되는 거 아닌가?라는 생각이 들었지만, 이후 의존성 주입 개념을 공부하면서 이제는 그 이유를 확실히 이해하게 되었다.
아마 아래처럼 코드를 수정하라는 의미였던 것 같다.
public class CalculatorService {
private final Input input;
private final Delimiters delimiters;
private final Numbers numbers;
public CalculatorService(String origin, Delimiters delimiters, Numbers numbers) {
this.input = new Input(origin); // 값 객체는 내부에서 조립
this.delimiters = delimiters; // 주입
this.numbers = numbers; // 주입
}
}
메서드 명 관례
평소에는 메서드 이름을 그냥 감으로 짓곤 했는데, 코드 리뷰 중 아래와 같은 글을 보고 나서 메서드 네이밍에도 세세한 관례가 있다는 것 알게 되어 한 번 찾아보게 되었다.

메서드명에도 일종의 관례가 있다. 보통 의미에 따라 프리픽스(prefix)를 다르게 사용한다.
- `check`: 조건을 검사하고 `true/false` 값을 반환할 때 사용한다.
- `validate`: 조건이 맞지 않으면 예외를 던질 때 사용한다. (void 메서드)
- `is`: 상태를 확인할 때, `boolean`을 반환한다.
- `has`: 무언가를 보유했는지 여부를 나타낸다.
- `to`: 형 변환을 할 때 사용한다..
- `from`: 정적 팩토리 메세더(static factory)를 정의할 때 자주 사용한다.
내 코드 리뷰
view의 역할
이번 과제에서 내 코드 중에서도 특히 View의 역할을 어디까지 둘 것인지에 대한 이야기가 많이 나왔다. 즉, View가 단순히 출력만 담당해야 하는지, 아니면 입력까지 책임져야 하는지에 대한 부분이었다.
사실 처음 구현할 때는 `InputView`에서 입력까지 받도록 했다. 사용자의 입력을 `View`에서 직접 읽어오고, 그 결과를 `Controller`에 전달하는 구조였다. 하지만 나중에 코드를 정리하면서 `View`는 “출력만 담당하는 게 더 낫지 않을까?” 하는 생각이 들어, 입력 부분을 `Controller`로 옮기고 `readInput()` 메서드를 새로 추가했다.
private String readInput() {
InputView.printInputGuide();
try {
String input = Console.readLine().trim();
InputValidator.validateNotNull(input);
return input;
} catch (NoSuchElementException e) {
return EMPTY_INPUT;
}
}
이후 코드 리뷰에서 입력까지 `View`에서 담당하면 `Controller`가 더 단순해질 것 같다는 의견이 많았다. 입력과 출력을 모두 `View`의 책임으로 두면, 사용자와의 상호작용이 한곳에 모여 코드의 흐름이 더 명확해진다는 관점이었다.
나는 이번 과제에서는 `View`는 출력만 담당한다는 기준으로 구현했지만, 리뷰를 보며 “입력까지 `View`가 맡는 쪽이 더 자연스럽고 Controller도 단순해질 수 있겠다”는 생각이 들었다.
그래서 다음 과제에서는 View의 책임을 조금 더 확장하는 방향으로 시도해볼 예정이다.
Numbers 클래스 설계 고민
이번 과제에서 `Numbers` 클래스의 내부 구현 방식에 대한 피드백이 많았다. 특히 숫자를 어떤 자료형으로 관리할지에 초점이 맞춰졌다.
처음 구현에서는 `List<String>` 형태로 숫자들을 관리했다. 입력으로 받은 문자열 배열을 그대로 리스트로 변환해 필드에 저장하고, `validateAllPositive()`에서는 이 문자열 리스트를 그대로 순회하면서 양수인지 검사했다. `sum()`에서는 그 필드를 변경하지 않고, 합을 구하는 순간에만 각 문자열을 `double`로 파싱해 더하는 방식이었다.
public class Numbers {
private final List<String> numbers;
private Numbers(List<String> numbers) {
this.numbers = numbers.stream()
.map(String::strip)
.toList();
validateAllPositive();
}
static Numbers of(String[] tokens) {
return new Numbers(List.of(tokens));
}
private void validateAllPositive() {
boolean allPositive = numbers.stream().allMatch(NumberUtils::isPositive);
if (!allPositive) {
throw new IllegalArgumentException(POSITIVE_NUMBER_ONLY);
}
}
public double sum() {
return numbers.stream()
.mapToDouble(Double::parseDouble)
.sum();
}
}
리뷰에서는 “문자열이 아닌 숫자 자료형으로 관리하면 더 명확하다”는 의견이 있었다. 처음에는 왜 굳이 그렇게 해야 하는지 고민이 됐다. 숫자형으로 관리하려면 변환 전에 검증을 어떻게 처리해야 할지가 애매했기 때문이다.
그런데 답변을 작성하면서 꼭 둘 중 하나로만 선택할 필요는 없다는 걸 깨달았다. 입력값은 문자열로 받고, 내부 필드는 숫자형으로 관리하면 검증과 변환 모두 자연스럽게 해결된다. 입력 단계에서는 문자열 그대로 검증을 수행하고, 이후 숫자 객체로 변환해 저장하는 구조도 충분히 가능했다.
또 `validateAllPositive()`처럼 리스트 전체를 한 번에 검증하기보다는 각 숫자 객체가 스스로 유효성을 검사하도록 하는 방식이 더 깔끔하겠다는 생각이 들었다. 이렇게 하면 검증 책임이 분리되고, `Numbers` 클래스가 불필요하게 많은 역할을 하지 않아도 된다.
public class Number {
private final double value;
public Number(String raw) {
this.value = parseAndValidate(raw);
}
private double parseAndValidate(String raw) {
double parsed = Double.parseDouble(raw);
if (parsed < 0) {
throw new IllegalArgumentException(POSITIVE_NUMBER_ONLY);
}
return parsed;
}
public double getValue() {
return value;
}
}
이렇게 바꾸면 `Numbers`는 단순히 `List<Number>`를 가지고, 합산 시에는 각 숫자의 값을 꺼내 더하기만 하면 된다.
public double sum() {
return numbers.stream()
.mapToDouble(Number::getValue)
.sum();
}
결국 처음에는 “검증을 하려면 문자열로 들고 있어야 한다”고 생각했지만, 입력은 문자열로 받고 내부에서는 숫자형으로 관리하며 검증은 각 숫자 객체가 맡는 구조가 훨씬 자연스럽고 명확한 설계인 것 같다.
double 사용
문제에서 "양수만 입력 가능하다”는 조건이 있었기에, 처음엔 실수도 포함된다고 생각하고 `double`을 사용했다.
그런데 코드 리뷰에서 이런 피드백을 받았다.

그제서야 "아… 구분자 자체가 `.`일 수도 있구나" 하는 걸 뒤늦게 깨달았다. `double`로 처리하면 소수점 구분자와 커스텀 구분자가 충돌할 수 있다는 걸 미처 생각하지 못했던 것이다.
만약 이걸 미리 알았다면, 실수 연산을 완전히 포기하기보다는 “.이 구분자로 사용될 경우 실수 입력은 허용되지 않습니다.” 처럼 명확히 제한을 두는 쪽으로 설계했을 것 같다.
메서드의 책임 분리
처음에 `sum()` 메서드를 작성했을 때, 이 안에 너무 많은 역할이 들어 있다는 생각이 들었다. 하지만 당시에는 어떤 기준으로 분리해야 할지 감이 잘 잡히지 않아 그대로 두었다.
public double sum() {
String custom = InputParser.extractDelimiter(input);
String body = InputParser.extractBody(input);
if (body.isBlank()) {
return ZERO;
}
validateBodyEdge(body, custom);
Delimiters delimiters = createDelimiters(custom);
String[] tokens = delimiters.splitBody(body);
Numbers numbers = Numbers.of(tokens);
return numbers.sum();
}
그런데 코드 리뷰에서 어떤 분이 아래처럼 정리된 코드를 보여주셨다. 핵심은 `sum()`이 단순히 “합을 구한다”는 이름과 다르게 너무 많은 단계(파싱, 검증, 계산 등)를 포함하고 있다는 점이었다.
public double sum() {
String custom = InputParser.extractDelimiter(input);
String body = InputParser.extractBody(input);
if (body.isBlank()) {
return ZERO;
}
validateBodyEdge(body, custom);
return parseAndCalculate();
}
이런 피드백을 보면서, 메서드 이름이 너무 포괄적이면 실제 역할이 흐려질 수 있다는 걸 느꼈다. 앞으로는 한 메서드 안에 여러 단계를 담기보다 이름과 책임이 일치하도록 더 세분화해야겠다고 생각했다.
EOF
깃허브에 코드를 올릴 때, 파일 끝에 아래처럼 빨간 금지 마크(⛔️)가 뜨는 걸 본 적이 있었다. 그땐 그냥 “뭐지?” 하고 넘겼는데, 이번에 리뷰를 통해 이유를 알게 되었다. 바로 EOF(End Of File), 즉 파일 끝의 개행문자를 챙겨야 한다는 피드백이었다.

이 부분은 GitHub 파일 끝에 개행이 필요한 이유 (no newline at end of file)에 잘 정리되어 있었다.
요약하자면, POSIX 표준에 따라 파일의 마지막 줄은 개행 문자로 끝나야 하며, 이게 없으면 Git에서 diff 표시가 이상하게 뜨거나, 일부 환경에서 예기치 않은 오류가 발생할 수 있다고 한다.
이 내용을 보고 나서 인텔리제이에도 자동 개행 설정을 추가했다. 이제는 파일을 저장할 때마다 자동으로 EOF가 들어가도록 세팅 완료했다.
공통 피드백
1. 요구 사항을 정확하게 준수한다.
과제를 제출하기 전에 과제 진행 요구 사항, 기능 요구 사항, 프로그래밍 요구 사항을 모두 충족하였는지 다시 한번 확인한다. 이러한 요구 사항들은 미션마다 다르므로 항상 주의 깊게 읽어 본다.
2. 기본적인 Git 명령어를 숙지한다.
Git은 개발자 간의 협업을 위한 가장 기본적인 프로그램으로, 컴퓨터 파일의 변경 사항을 추적하고 여러 사용자 간의 해당 파일에 대한 작업을 조정한다. 지금은 add, commit, push 등의 간단한 명령어만 배워도 충분하지만, Git에 대해 미리 알아두어도 나쁠 것은 없다.
- [10분 깃코톡] 와일더의 Git Commands
- [10분 테코톡] 주노의 git commands
- [10분 테코톡] 망쵸의 유용한 Git 명령어
- [10분 테코톡] 해시, 다르의 깃 명령어 동작 원리
공부한 내용
[Git] Git Commands
브랜치란?정의사람들이 동일한 소스 코드를 기반으로 하되, 서로 다른 기능이나 수정 작업을 동시에 진행할 수 있게 해주는 Git의 기능이다. 각자 독립적인 작업 공간(브랜치)를 만들어 작업한
best11gh.tistory.com
- "해시, 다르의 깃 명령어 동작 원리" 제외 공부 완료
3. Git으로 관리할 자원을 고려한다.
Java 코드만 있으면 .class 파일을 생성할 수 있다. 따라서 Git을 통해 .class 파일을 관리할 필요가 없다. IntelliJ IDEA의 .idea 폴더와 Eclipse의 .metadata 폴더도 IDE에서 자동으로 생성하는 폴더이므로 Git으로 관리할 필요가 없다. Git에 코드를 추가할 때는 Git을 통해 형상 관리해야 하는 코드인지 고려하는 것이 좋다. 또한 .gitignore에 대해서도 알아본다.
4. 커밋 메시지를 의미있게 작성한다.
해당 커밋에서 수행된 작업을 이해할 수 있도록 커밋 메시지를 작성한다. 또한 팀의 좋은 코드 리뷰 문화는 작은 커밋을 작성하는 것에서 비롯된다.
5. 커밋 메시지에 이슈 또는 풀 리퀘스트 번호를 포함하지 않는다.
일부 프로젝트에서는 작업을 이슈 또는 풀 리퀘스트와 연결하기 위해 커밋 메시지에 이슈 또는 풀 리퀘스트 번호를 포함하기도 한다. 그러나 이 접근 방식은 원본 저장소의 관련 없는 이슈 또는 풀 리퀘스트에 영향을 미칠 수 있다. 따라서 이 과정에서는 커밋 메시지에 이슈 또는 풀 리퀘스트 번호를 포함하지 않는다.
6. 풀 리퀘스트를 만든 후에는 닫지 말고 추가 커밋을 한다.
이미 풀 리퀘스트를 생성하면 변경을 위해 새 풀 리퀘스트를 만들 필요가 없다. 변경이 필요한 경우 추가 커밋을 하면 자동으로 반영된다. 그러므로 미션 제출 기간 이후에는 추가 커밋을 금지한다.
7. 오류를 찾을 때 출력 함수 대신 디버거를 사용한다.
디버깅은 프로그램 오류를 감지하고 수정하는 과정이다. 문법 오류와 같이 컴파일러가 처리하기 때문에 쉽게 발견할 수 있는 오류도 있지만, 어느 지점에서 오류가 발생했는지 파악하기 어려운 경우도 있다. 이때 코드 중간에 System.out.println()를 사용하여 매번 코드를 실행하여 문제를 파악할 수 있으나, 이는 비효율적이며 불필요한 코드가 남을 수 있다. 하지만 디버거를 이용하면 코드 내부의 상태 값이 어떻게 변하는지, 어떤 흐름으로 프로그램이 실행되는지 이해할 수 있다. 현재 사용 중인 IDE에서 애플리케이션을 디버깅하는 방법을 학습한다.
- [10분 테코톡] 웨지의 인텔리제이 디버깅
- [10분 테코톡] 오리의 Intellij Debugging
- [10분 테코톡] 몰리의 디버깅
- Debugging in Visual Studio Code
8. 이름을 통해 의도를 드러낸다.
나 자신, 다른 개발자와의 소통을 위해 중요한 활동 중의 하나가 좋은 이름 짓기이다. 변수 이름, 함수(메서드) 이름, 클래스 이름을 짓는데 시간을 투자하라. 이름을 통해 변수의 역할, 함수의 역할, 클래스의 역할에 대한 의도를 드러내기 위해 노력하라. 연속된 숫자를 덧붙이거나(a1, a2, ..., aN), 불용어(Info, Data, a, an, the)를 추가하는 방식은 적절하지 못하다.
9. 축약하지 않는다.
의도를 드러낼 수 있다면 이름이 길어져도 괜찮다.
누구나 실은 클래스, 메서드, 또는 변수의 이름을 줄이려는 유혹에 곧잘 빠지곤 한다. 그런 유혹을 뿌리쳐라. 축약은 혼란을 야기하며, 더 큰 문제를 숨기는 경향이 있다. 클래스와 메서드 이름을 한 두 단어로 유지하려고 노력하고 문맥을 중복하는 이름을 자제하자. 클래스 이름이 Order라면 shipOrder라고 메서드 이름을 지을 필요가 없다. 짧게 ship이라고 하면 클라이언트에서는 order.ship()라고 호출하며, 간결한 호출의 표현이 된다.
객체 지향 생활 체조 원칙 5: 줄여쓰지 않는다(축약 금지)
10. 공백도 코딩 컨벤션이다.
if, for, while문 사이의 공백도 코딩 컨벤션이다.
11. 공백 라인을 의미있게 사용한다.
공백 라인을 의미 있게 사용하는 것이 좋아 보이며, 문맥을 분리하는 부분에 사용하는 것이 좋다. 과도한 공백은 다른 개발자에게 의문을 줄 수 있다.
12. 스페이스와 탭을 혼용하지 않는다.
들여쓰기에 스페이스(space)와 탭(tab)을 혼용하지 않는다. 둘 중의 하나만 사용한다. 확신이 서지 않으면 풀 리퀘스트를 작성한 후 들여쓰기가 잘 되어 있는지 확인하는 습관을 들인다.
13. 의미없는 주석을 달지 않는다.
변수 이름, 함수(메서드) 이름을 통해 어떤 의도인지가 드러난다면 굳이 주석을 달지 않는다. 모든 변수와 함수에 주석을 달기보다 가능하면 이름을 통해 의도를 드러내고, 의도를 드러내기 힘든 경우 주석을 다는 연습을 한다.
14. 코드 포맷팅을 사용한다.
코드 포매팅과 구조화는 클린 코드를 위한 최소한의 요구 사항이다. IDE의 코드 자동 정렬 기능을 사용하면 더 깔끔한 코드를 볼 수 있다.
- IntelliJ IDEA: ⌥⌘L, Ctrl+Alt+L
- Eclipse: ⇧⌘F, Ctrl+Shift+F
- Visual Studio Code: ⇧⌥F, Shift+Alt+F
15. Java에서 제공하는 API를 적극 활용한다.
함수(메서드)를 직접 구현하기 전에 API에서 해당 함수를 제공하는지 확인한다. 예를 들어 사용자를 출력할 때 사용자가 둘 이상인 경우 쉼표(,) 기반 문자열을 출력하도록 다음과 같이 구현할 수 있다.
var members = List.of("pobi", "jason");
var result = String.join(",", members); // pobi,jason
16. 배열 대신 컬렉션을 사용한다.
컬렉션(List, Set, Map 등)을 사용하면 다양한 API를 사용하여 데이터를 조작할 수 있다. 예를 들어 List<String>에 "pobi" 값이 있는지 다음과 같이 확인할 수 있다.
var members = List.of("pobi", "jason");
var result = members.contains("pobi"); // true'우아한테크코스' 카테고리의 다른 글
| [우아한 테크코스] 프리코스 2주차 코드 리뷰 후기 (0) | 2025.11.03 |
|---|---|
| [우아한 테크코스] 테스트 함수 (0) | 2025.11.02 |
| [우아한 테크코스] 프리코스 2주차 회고 (0) | 2025.10.28 |
| [우아한 테크코스] 프리코스 1주차 회고 (1) | 2025.10.26 |
| [우아한 테크코스] 클린코드 (0) | 2025.10.20 |