우테코 프리코스 1주차 미션이 종료되었다. 1주차는 너무 바빠서 코드 개선에 많은 신경을 쓰지 못했는데, 다른 분들의 제출물을 보니 초라해지는 내 과제...🥹 1주차에 가장 인상깊었던 부분을 잊어버리기 전에 빠르게 정리해봤다.
1. 첫 번째 설계 - 책에서 하란 대로 했는데🥹

이런 과제를 받아보는게 너무 오랜만이라 설레는 마음에 그만... 요구사항을 후다닥 정리하고 구현에 들어갔다. 객체지향의 사실과 오해에서 배운 방법을 적용해볼 날만 기다려왔던지라 마음만 앞서서 빠르게 설계를 시작했다.
- 프롬프트: 사용자와의 상호작용을 담당한다.
- 계산기: 계산을 담당한다.
- 문자열 파싱기: 문자열을 파싱한다.
👇그리고 이 내용을 코드로 구현한 결과

분명 책에서 하란대로 했는데
저자님꺼는 작고 소중한 클래스고 내꺼는 책임을 먹고 무럭무럭 자란 괴물 클래스가 되어버렸다...༼;´༎ຶ ༎ຶ༽
SRP(단일 책임 원칙)에 의하면, 클래스가 변경될 이유는 하나여야 한다. 하지만 프롬프트와 문자열 파싱기는 변경될 이유가 2개 이상이었다.
- 프롬프트가 변경되는 경우: 입력 방식 변경, 출력 형식 변경
- 문자열 파싱기가 변경되는 경우: 문자열 파싱 기준 변경, 구분자 추출 기준 변경, 유효성 검증
어디서부터 잘못된걸까 되짚어본 결과, 기능 목록을 대충 작성한 게 가장 큰 문제였다.
- 사용자로부터 문자열을 입력받아 결과를 출력한다 → Prompt 클래스
- 입력받은 문자열을 파싱한다 → StringParser 클래스
- 숫자들을 모두 더한다 → Calculator 클래스
빠르게 구현하고 싶은 마음에 헐레벌떡 작성한 게 이런 후폭풍으로 돌아올 줄이야🥲 결국 기능 명세서를 재정의해야 했다.
2. 두 번째 설계 - 작고 소중한 클래스 만들기

변경한 설계에서는 클래스 당 하나의 책임만 질 수 있도록 노력했다. 사용자와의 상호작용을 담당하던 Prompt 클래스는 입력값/출력값 핸들러로 쪼개고 아예 없애버렸다. 클래스명이 너무 모호했기 때문에...
출력 형식이 바뀌면 OutputHandler를, 입력된 값들을 모두 더하는 게 아니라 모두 빼야 한다면 Caculator를 수정해야 하는게 명확하게 보일 수 있도록! 내가 생각해도 클래스 진짜 잘 쪼갰다고 생각하고 매우 싱글벙글하고 있었는데... 코드리뷰를 받으며 생각치도 못한 문제점들을 알게 되었다😂
3. 코드리뷰 반영하기
(1) 객체 생성 담당할 클래스 찾기
코드리뷰 중 가장 기억에 남았던 부분은 InputHandler가 직접적으로 StringParser를 생성해서 사용하기보다는 Calculator가 의존성을 주입받고 이를 InputHandler에게 전달하는 방식으로 설계하면 유지보수성과 확장성이 높아질 것 같다는 의견이었다.
처음에는 왜 유지보수성이 높아지는지 이해하기 어려웠다. StringParser의 생성자 파라미터가 변하면 InputHandler의 코드도 변해야 한다는데, 그걸 main()으로 옮겨봤자 main()의 코드를 수정해야 하는 건 마찬가지 아닌가!? 하는 생각이 들었기 때문이다🥲
그치만 객체 생성을 모두 main()으로 옮기고 객체들은 의존성을 주입받아 사용하면 아래와 같은 장점이 있다.
- 객체 생성 책임을 main()에 모아두었기 때문에 생성자 파라미터가 변하면 main()을 수정해야 한다는 게 명확해진다.
- 객체를 직접 생성해서 사용하는 게 아니라 외부에서 주입받아 사용하므로 모킹을 통한 독립적인 단위 테스트가 가능해진다.
코드 복잡성이 증가한다는 단점이 있긴 하지만, 단점에 비하면 장점이 훨씬 크다고 생각해서 결론적으로는 아래와 같은 구조로 코드를 수정했다.
public class Application {
public static void main(String[] args) {
StringParser parser = new StringParser();
Calculator calculator = new Calculator(new InputHandler(), new OutputHandler(), parser);
calculator.run();
}
}
public class Calculator {
// 중략
public void run() {
outputHandler.printInitMessage();
int[] numbers = inputHandler.getInput(parser);
int result = calculate(numbers);
outputHandler.printResult(result);
}
}
(2) static 메서드를 어디에 사용해야 할까?
상태가 없고 행동만 존재하는 클래스의 경우 메서드를 static으로 관리하는 것도 방법이라는 피드백을 받았다. 그래서 보통 어떤 메서드들을 static으로 선언하는지를 찾아보았다. static 메서드는 인스턴스가 아닌 클래스에 속하는 메서드로, 인스턴스 생성 없이 호출할 수 있는 것이 특징이기 때문에 아래와 같은 케이스에 적합하다.
- 클래스의 상태에 의존하지 않고 입력값을 받아 처리하는 메서드
- 애플리케이션 전역에서 호출되는 메서드
- 인스턴스의 상태를 변경하지 않으면서 반복적으로 호출되는 메서드
Integer.parseInt()처럼 이곳저곳에서 호출될 수 있으면서도, 입력값을 받아 처리하는 메서드들이 static으로 선언되기에 적합하다. 나의 경우에는 구분자 추출기의 extractDelimiter() 메서드나, 애플리케이션 전역에서 호출되는 출력값 처리기의 printIllegalArgumentException() 메서드를 static으로 선언했다. 특히 예외 출력 메서드는 여기저기서 많이 호출되기 때문에 static으로 선언하기 적절했다.
(3) 출력 메세지는 Enum으로 관리하기
문자열은 하드코딩하기보다 별도 파일에서 관리하는게 좋다는 피드백을 받았다. 실제로 프로젝트를 진행할 땐 예외를 Enum으로 관리했던 것 같은데, 이번 과제는 시스템 규모가 작다보니 눈치채지 못했다😂 그래도 여기저기서 발생하는 예외를 좀 모아서 관리할 수 없을까? 아쉬웠던 찰나 좋은 피드백을 받아서 다행이었다.
public enum Message {
INIT_MESSAGE("덧셈할 문자열을 입력해 주세요."),
RESULT_PREFIX("결과 : "),
ERROR_PREFIX("에러 : ");
private final String message;
private Message(String message) {
this.message = message;
}
public String getMessage() {
return message;
}
}
public enum Exception {
INVALID_CUSTOM_DELIMITER("커스텀 구분자가 문자열입니다. 문자로 입력해 주세요."),
NOT_FOUND_CUSTOM_DELIMITER("커스텀 구분자가 입력되지 않았습니다. 문자를 입력해 주세요."),
INVALID_INPUT("문자열에 구분자, 양수 외 문자가 포함되어 있습니다.");
private final String exception;
private Exception(String exception) {
this.exception = exception;
}
public String getException() {
return exception;
}
}
(4) 반영하지 않은 리뷰
- InputHandler에서 조금 더 좁은 범위를 다루면 좋겠어요.
InputHandler에서 parseString() 메서드를 호출하는 부분이 책임이 과도하다고 보신 것 같다. 사실 입력을 받는 것과 입력을 처리하는 것은 다른 책임이 맞다. 하지만 클래스명이 InputReader가 아니라 InputHandler인데다가, 별도 클래스로 분리하기에는 시스템 규모에 비해 코드 복잡성이 너무 높아질 것 같다는 생각에 반영하지 않았다. - DTO의 필드에 직접 접근하기보다 getter를 사용하면 좋을것같아요.
나는 구분자를 추출하고 난 뒤 구분자 목록과 함께 남은 문자열을 반환하기 위해 DTO를 사용했다. StringParser에서 호출하는 용도라면 getter를 이용하는게 어떻겠냐는 피드백을 받았지만, getter를 사용해도 DTO 필드명이 변경되면 클라이언트(StringParser) 측 코드를 변경해야 하는 건 마찬가지기에 getter를 추가하지 않고 바로 필드에 접근했다.
직접 필드에 접근하는 게 좀 그런가? 싶어 createDelimiterRegex() 메서드를 DTO 내부로 옮기는 방법도 생각해봤지만, 비즈니스 로직을 포함하는 것은 데이터를 전달한다는 DTO의 목적에 맞지 않는것같아 이대로 두기로 했다. 대신 DTO를 동일 패키지로 옮기고, 필드 접근 제한자를 default로 변경했다.
public class DelimiterExtractionResult {
String input;
List<String> delimiters = new ArrayList<>(List.of(",", ":"));
DelimiterExtractionResult(String input, List<String> delimiters) {
this.input = input;
this.delimiters.addAll(delimiters);
}
}
public class StringParser {
// 중략
public int[] parseString(String input) {
DelimiterExtractionResult extractionResult = DelimiterExtractor.extractDelimiter(input);
Pattern pattern = createDelimiterRegex(extractionResult.delimiters);
String[] tokens = extractionResult.input.split(pattern.toString());
return Arrays.stream(tokens).map(InputValidator::validateToken)
.filter(token -> !token.isEmpty())
.mapToInt(Integer::parseInt)
.toArray();
}
}
4. 무엇을 배웠나요?
- 기능 명세서의 중요성
빠르게 구현하고 싶어서 기능 명세서를 대충 작성하고 들어갔다가 호되게 혼났다🥲 기능을 너무 큰 단위로 정의하는 바람에 클래스의 역할이 모호해져서 클래스 이름 짓기도 어려웠다. 하지만 기능 명세서를 다시 정의하고 설계에 들어갔을 때는 클래스의 역할이 명확해서 이름을 오래 고민하지 않아도 되었다. - 스프링 컨테이너의 소중함
스프링을 사용할 때는 인스턴스 생성과 주입을 모두 스프링 컨테이너가 담당해 주었는데, 순수 자바 코드로 작성하다 보니 이 책임을 어디에 둘 것인지를 결정하는 게 너무 어려웠다🤧 결국 main() 메서드에 몰아넣긴 했지만 main() 메서드가 지저분해지는 기분이라 영 맘에 들지 않았다. 예전의 개발자들도 나같은 고민을 하다가 스프링을 만들었을까? - 테스트 코드 작성 시점
나는 코드를 다 작성한 뒤에 테스트를 작성했다. 근데 이렇게 하니까 모호한 요구사항을 테스트 결과에 맞추고 싶은 유혹에 빠졌다😂 요구사항을 코드에 맞추면 코드를 수정할 필요가 없으니까. 이번에는 크게 복잡하지 않은 프로그램이라 유혹에 빠지지 않고 코드를 다 수정한 뒤 테스트를 커밋했지만, 테스트 코드를 먼저 작성하라는 말이 이런 의미였다는걸 깨달을 수 있었다. - 커밋 메세지 컨벤션의 목적
커밋 컨벤션은 적용해 본 적 있는데, subject line에 대해서만 알고 있을 뿐 message body나 footer에 대해서는 잘 몰랐다. 편의상 body와 footer는 생략하는 경우가 많았기 때문이다. 가끔식 커밋 내용을 한줄로 요약하기 위해 열심히 머리를 굴렸는데, subject line 한 줄 안에 우겨넣기 어렵다면 body를 활용할 수 있다고 하니 앞으로는 부담이 덜해질 것 같다 👀
'𝐑𝐞𝐯𝐢𝐞𝐰' 카테고리의 다른 글
프리코스 3주차 후기: getter 지양해야 하는거 알지만 너무 편리해요 (0) | 2024.11.11 |
---|---|
프리코스 2주차 후기: 랜덤값 테스트 그거 어떻게 하는건데 (1) | 2024.11.03 |
잇츠 스터디 크루 발표회 후기 (3) | 2024.09.21 |
[책] 객체지향의 사실과 오해 리뷰 (2) | 2024.09.14 |
[책] Clean Code(클린 코드) 리뷰 (0) | 2024.07.10 |