0. 개요
과제는 "문자열 덧셈 계산기"이다.
7기 프리코스 1주차와 같아서 놀랐다.
1주차 목표는 다음과 같다.
1. 우테코 기본 요구사항을 모두 만족한다.
2. 객체지향 프로그래밍을 적용하고자 노력한다.
3. 많이 고민하자.
이 글에서는 내가 어떤 흐름으로 프리코스 1주차를 진행했는 지 작성해보려고한다.
1. 초기 개발
잘못된 건데, 우선 git clone해서 연습삼아 여러 방식으로 프로그래밍을 진행했다.
그러고나서 어느정도 설계 방식을 정한 뒤, fork하여 코드를 작성했다.
이러면 커밋 기록이 너무 딱딱하고 내 고민의 흔적이 드러나지 않는 거 같다. 2주차는 fork한 뒤, 커밋 기록을 천천히 남겨보자.
초안 코드와 제출 코드에서 크게 달라진 점은 네 가지 이다.
첫번째는 Util 클래스이다.
두번째는 연산 로직이다.
세번째는 Pattern 사용 여부이다.
네번째는 객체 생성 방식이다.
처음에 어떻게 설계했는 지 하나하나 알아보고, 어떤 이유로 코드가 달라지게 됐는 지, 나의 생각을 읊어보자.
2. Util 클래스
초기에는 다음과 같이 NumberValidator라는 Util 클래스에 static 메서드를 만들어서 사용하려고 했다.
- Why? "검증이라는 건 핵심 비지니스 로직이 아니니까 Util 클래스로 만들어야겠다!"라고 생각했다.
- 실제 코드에 적어둔 나의 고민들도 같이 첨부했다.
// 입력받은 숫자 자체가 오버플로우인 경우 검증
// 입력받은 문자열이 숫자+문자 혼합되어 있는 경우를 검즘 -> 여기서 검증하는 게 이상한디
// 이친구가 StringParser로 들어가야할까
public class NumberValidator {
// split후 값에 대해서 올바르게 숫자 or 빈문자열만 있는 지 검증 역할.
// (0-9) 사이의 숫자를 입력해주세요.
// 설정한 구분자를 입력해주세요??
// 입력 받은 숫자 자체가 오버 플로우인 경우를 검증
// 입력받은 문자열이 숫자 + 문자가 혼합되어 있는 경우를 검증
public static void validateStringNumbers(List<String> stringNumbers) {
for(String stringNumber : stringNumbers) {
validateStringNumber(stringNumber);
}
}
public static void validateStringNumber(String stringNumber) {
if(stringNumber.isEmpty()){
return;
}
try{
validatePositiveNumber(stringNumber);
}catch (NumberFormatException e){
throw new IllegalArgumentException("입력 형식 오류");
}
}
public static void validatePositiveNumber(String stringNumber) {
int number = Integer.parseInt(stringNumber);
if(number < 0) {
throw new IllegalArgumentException("음수 입력하셈");
}
}
}
비지니스 로직은 문자열을 파싱하고, List<String>을 검증하는 순서로 이루어진다.
따라서 StringParser라는 객체에서 주로 사용할 것으로 예상되는 Util 클래스였다.
여기서 든 의문점 1. "StringParser에서만 NumberValidator를 사용된다는 점"이다.
Util 클래스와 static 메서드는 어디서든 접근이 가능하다는 장점이 있는데, StringParser에서만 사용할 거면 뭐하러 NumberValidator를 Util 클래스와 static 메서드로 작성해야하는 거지?
의문점 2. 어디서든 접근 가능하다는 것이 객체지향적인가?
위 의문에 대한 나의 답은 다음과 같다.
a. 어디서든 접근이 가능한 클래스 A가 있다.
b. 다수의 클래스에서 A를 사용한다.
c. A가 변경되면 다수의 클래스가 영향을 받는다.
d. 객체간의 결합도가 증가하므로 객체지향적이지 않다.
위 두가지 의문에 대해서 바로 떠오른 대안책은 NumberValidator의 static 메서드를 없애고, StringParser에 연관관계를 맺도록하는 것.
그러나 이런 의문이 들었다.
의문점 1. StringParser가 숫자 검증에 대한 책임이 있는가?
-> 고민을 해보니 아니라고 생각했다.
-> 문자열을 파싱(분석)하는 StringParser에서 숫자로 변환가능한 지 검증하는 책임은 없다고 생각했다.
그렇다면 숫자 검증은 누구의 책임일까?
고민 끝에 다음과 같은 의식의 흐름으로 해답을 냈다.
숫자로 변환 가능한 지 '왜' 검증을 하는 걸까
-> 연산 작업에서 문자에 사칙연산 할 순 없으니까
-> 좀 더 근본적으로
-> int형 타입(숫자)에 문자를 저장할 수 없으니까.
-> 그러고보니 Integer.parseInt()에서 문자열이 들어오면 ArithmeticException에러가 발생한다.
-> int라는 원시타입 대신 Integer라는 Wrapper 타입은 Integer 내부에 검증 로직을 두는 구나.
-> 그렇다면 나도 int를 감싼 나만의 Wrapper 타입을 만들고 거기에 검증 로직을 두는 게 어떤가?
-> 객체지향적이다.... 도메인 로직....
나의 해답은 Number라는 Class를 만들고, 해당 클래스에 검증에 대한 책임을 두는 것.
그래서 다음과 같이 코드를 작성했다.
// 첫번째 개선안.
public class Number {
private final int number;
private Number(String stringNumber) {
this.number = stringNumber.isEmpty() ?
0 :
Integer.parseInt(stringNumber);
}
public static Number from(String stringNumber) {
return new Number(stringNumber);
}
public int add(int sum) {
return this.number + sum;
}
public void validateOverflow(int value) {
if(isOverflow(value)) {
throw new IllegalArgumentException("합산 결과가 OverFlow");
}
}
public boolean isOverflow(int value) {
int sum = this.number + value;
return ((sum ^ number) & (sum ^ value)) < 0;
}
}
Number라는 Wrapper 클래스로 int를 포장했고 생성자에 검증 로직을 추가했다.
또한 private 생성자를 사용하고 정적 팩토리 메서드를 제작했다.
이렇게 Wrapper 클래스를 사용하면서 조금 더 객체지향적인 설계를 했다고 느꼈다.
그러나 또 다른 어려움이 생겼고 이는 다음 챕터에서 이야기하겠다.
3. 연산 로직
위 Number Class를 필두로 Calculator를 작성했다.
public class NumberCalculator {
private final List<Number> numbers;
public NumberCalculator(List<Number> numbers) {
this.numbers = numbers;
}
public int calculate() {
int prefix = 0;
for (Number number : numbers) {
number.validateOverflow(prefix); // 연산 중, 오버플로우 확인
prefix = number.add(prefix);
}
return prefix;
}
}
우선 getter 사용을 지양하고 있기에, Number 내부에 add() 메서드를 구현했다.
여기서 아쉬운 점은 Number와 int형으로 계산을 수행했다는 점 -> 이는 Number 간의 계산으로 해결할 수 있다.
그리고 가장 아쉬운 점은 Calculator라는 엔티티의 계산 방식이 정해져있다는 것이다.
즉, calculate() 메서드가 사칙 연산중에서 '덧셈'을 수행할 것으로 하드코딩 되어있다고 느꼈다. (초기값도 0으로 하드코딩되어있다. 곱셈이나 나눗셈은 초기값이 1이어야하는데.....)
이는 확장성있는 설계가 아니다. -> '뺄셈' 연산으로 바뀌면 Calculator 내부 코드를 수정해야한다.
이를 개선하기 위해 고민을 했는데, 나는 연산에 대한 행위를 추상화하기로 결정했다.
Operator라는 interface, 구현체 Adder를 만들었다.
public interface Operator {
Number getInitialAccumulator();
void operate(Number accumulator, Number operand);
}
public class Adder implements Operator{
@Override
public Number getInitialAccumulator() {
return Number.from(ZERO);
}
@Override
public void operate(Number accumulator, Number operand) {
accumulator.operate(operand, (x, y) -> x + y);
}
}
// Number Class 내부에 추가
public void operate(Number other, IntBinaryOperator operator) {
this.value = operator.applyAsInt(this.value, other.value);
}
추상화 과정에서 "연산 작업 자체를 넘겨주기 위한 방법"을 찾다가, 함수형 인터페이스를 사용할 수 있는 IntBinaryOperator라는 걸 알게되어 바로 적용했다.
이렇게 함으로써 덧셈이라는 연산 작업 요구사항에 대한 Adder를 추가해 Calculator의 생성자에 넘겨주면, 계산기는 덧셈만 수행한다.
요구사항이 '뺄셈'으로 바뀌더라도, Subtractor를 구현하고 생성자에 넘져두면된다. Calculator를 수정하지 않아도 된다! (OCP!)
또한 곱셈과 나눗셈을 잘 수행할 수 있도록 초기값을 설정하는 메서드도 추상화했다. (getInitialAccumulator())
상당히 객체지향적인 코드를 작성했다고 느꼈고 매우 뿌듯했다.
이러고 넘어가려고 했는데..... 깜빡할 뻔한 게 있다.
바로 Overflow다.
int 범위 밖의 Overflow인 값은 String -> Number로 변환할 때, 걸러진다.
그러나 연산 작업중에 Overflow가 발생한다면? 예외처리를 발생해야한다.
처음에는 opertae() 메서드 내부에 오버플로우 계산 방법을 직접 구현하려고 구글링을 했다.
그러던 중 Math.addExact()를 알게되었다.
public static int addExact(int x, int y) {
int r = x + y;
// HD 2-12 Overflow iff both arguments have the opposite sign of the result
if (((x ^ r) & (y ^ r)) < 0) {
throw new ArithmeticException("integer overflow");
}
return r;
}
Math 라이브러리에서 지원하는 메서드인데, 다음과 같이 덧셈 수행 중 Overflow가 발생하면 예외를 알아서 던져준다.
아주 고마운 녀석이다.
그리고 매개인자도 마침 int x, int y 이기때문에 IntBinaryOperator로 넘겨줄 수 있다.
따라서 다음과 같이 개선해냈다.
// Adder
@Override
public void operate(Number accumulator, Number operand) {
accumulator.operate(operand, Math::addExact); // Math::addExact를 넘겨줌
}
public void operate(Number other, IntBinaryOperator operator) {
try{
this.value = operator.applyAsInt(this.value, other.value);
}catch(ArithmeticException e){ // 연산 중 오버플로우 예외를 잡아 요구사항인 IllegalArgumentException을 다시 던진다.
throw new IllegalArgumentException(ARITHMETIC_RESULT_EXCEED_RANGE.getMessage());
}
}
이렇게 구현하니, Calculator 관련 코드는 객체지향적 프로그래밍을 적절히 사용하여, 구현했다고 생각했다!
4. Pattern
초안과 달라진 세번째는 Pattern의 사용 여부이다.
주어진 요구사항을 보면 '//'와 '\n'사이의 커스터 구분자를 추출하면 된다.
나는 이 요구사항을 보자마자 정규식으로 추출하면 매우 편할거라 생각했다.
그런데 막상 구현하고 보니 아쉬운 점이 있었다.
그것은 바로 책임과 예외처리이다.
다음은 실제로 내가 작성한 초안 코드이다.
public class DelimiterExtractor {
private final String message;
private final Matcher patternMatcher;
public final static String CUSTOM_DELIMITER_FORMAT = "^//(.)\\\\n.*";
public DelimiterExtractor(String message) {
this.message = message;
this.patternMatcher = Pattern.compile(CUSTOM_DELIMITER_FORMAT).matcher(message);
}
public boolean hasCustomDelimiter() {
return patternMatcher.find();
}
// TODO 정말 커스텀 구분자를 추출만 했으면 좋겠음.
public String extractCustomDelimiter() {
if(hasCustomDelimiter()){
String customDelimiter = patternMatcher.group(1);
DelimiterManager.addDelimiter(customDelimiter.charAt(0));
return message.substring(5);
}
return message;
}
}
아쉬운 점 첫번째는 extractCustomDelimiter()는 여러 책임을 가지고 있었다는 것이다.
TODO를 보면 알겠지만, 해당 메서드는 정말 커스텀 구분자를 추출만 했으면 좋겠다고 생각했다.
그런데 코드를 작성하다 보니 extractCustomDelimiter() 메서드에는 "커스텀 구분자"가 존재하는 지 확인하는 책임까지 존재했다.
또한, 기존의 문자열에서 커스텀 구분자 형식을 덜어내는 substring()까지 진행했다.
아쉬운 점 두번째는 patternMatcher.find()에서 커스텀 구분자 형식이 발견되지 않았다면, 그냥 넘어가게된다.
-> 사용자가 커스텀 구분자 지정 형식을 잘못 입력했으면?
-> //:\1,2,3 이라던지 /:\n1,2,3 이라던지
-> 누가봐도 커스텀 구분자 지정 형식 오류인데, 이에 대한 예외를 처리하지 않으면 Number의 검증로직에서 예외가 발생하게 된다.
-> 이는 올바른 예외 메세지가 아니다.
위 두가지 아쉬운 점 때문에, 커스텀 구분자 관련 로직은 Pattern 사용보다는 직접 "//"와 "\n"를 상수로 등록하고 index로 찾는 방식으로 변경했다.
우선 커스텀 구분자가 있는 지 검사하는 DelimiterInspector를 만들었다.
public class DelimiterInspector {
public boolean inspectDelimiter(String input) {
int prefixIndex = input.indexOf(CUSTOM_DELIMITER_PREFIX);
int suffixIndex = input.indexOf(CUSTOM_DELIMITER_SUFFIX);
if (checkDelimiter(prefixIndex, suffixIndex)) {
return true;
}
validateDelimiterFormat(prefixIndex, suffixIndex);
return false;
}
private boolean checkDelimiter(int prefixIndex, int suffixIndex) {
return prefixIndex == CUSTOM_DELIMITER_PREFIX_INDEX && suffixIndex == CUSTOM_DELIMITER_SUFFIX_INDEX;
}
private void validateDelimiterFormat(int prefixIndex, int suffixIndex) {
validateDelimiterPrefix(prefixIndex, suffixIndex);
validateDelimiterSuffix(prefixIndex, suffixIndex);
validateDelimiterValue(prefixIndex, suffixIndex);
validateDelimiterLength(prefixIndex, suffixIndex);
validateDelimiterAffixOrder(prefixIndex, suffixIndex);
validateDelimiterFormatPosition(prefixIndex, suffixIndex);
}
// 너무 길어서 생략
}
그리고 DelimiterParser라는 클래스를 만들어, 파싱(추출과 덜어냄)에 대한 역할과 책임을 부여했다.
public class DelimiterParser {
public Delimiter extractDelimiter(String input) {
char symbol = input.charAt(CUSTOM_DELIMITER_INDEX);
return Delimiter.from(symbol);
}
public String subtractDelimiter(String input) {
return input.substring(CUSTOM_DELIMITER_SUBTRACT_INDEX);
}
}
최종적으로 위 두 클래스와 Delimiter 저장소를 가지고 구분자 관련 로직을 총괄하는 DelimiterManager를 만들었다.
public class DelimiterManager{
private final DelimiterInspector inspector;
private final DelimiterParser parser;
private final DelimiterStorage storage;
public DelimiterManager(DelimiterInspector inspector, DelimiterParser parser, DelimiterStorage storage) {
this.inspector = inspector;
this.parser = parser;
this.storage = storage;
}
public boolean hasDelimiter(String input) {
return inspector.inspectDelimiter(input);
}
public String parseDelimiter(String input) {
Delimiter delimiter = parser.extractDelimiter(input);
storage.addDelimiter(delimiter);
return parser.subtractDelimiter(input);
}
}
이렇게 바꾸고 나니, 책임에 대한 역할 분리가 더 명확해졌다고 느껴졌다.
(물론 아쉬운 점도 있었다.)
5. 객체 생성
초안과 달라진 점 네번째는 객체 생성이다.
처음에는 new()를 통해서 어떤 객체가 필요한 시점에 사용했다.
// Rough한 예시
private List<Number> convertStringNumbers(List<String> stringNumbers) {
NumberConverter numberConverter = new NumberConverter(stringNumbers);
return numberConverter.convert();
}
private List<String> splitInput(String input) {
StringSplitter splitter = new StringSplitter(input);
return splitter.split();
}
이 부분을 점점 보다보니 Spring의 위대함을 느꼈다.
Spring에서는 객체를 생성하는 시점을 관리할 필요가 없기 때문이다.
객체를 생성하는 건 주요 비지니스 로직이 아니다. 따라서 위 코드는 핵심 로직에 가독성을 떨어트리는 좋지 않은 코드라 생각했다.
이를 개선하기 위해서 Config 파일 만들기로했다.
public class ViewConfig {
private static final ViewConfig INSTANCE = new ViewConfig();
private ViewConfig() {
}
public static ViewConfig getInstance() {
return INSTANCE;
}
public ApplicationView applicationView() {
return new ApplicationView(inputreader(), outputwriter());
}
private InputReader inputreader() {
return new InputReader();
}
private OutputWriter outputwriter() {
return new OutputWriter();
}
}
이렇게 Config 파일에 객체 생성을 정의해두고, 이를 Application이 실행될 때, 최상위 Config를 불러와 객체를 생성한다.
그리고 해당 객체를의 run 메서드를 통해 계산기 과정을 수행하도록 개선했다.
(방학 때, 무료로 들었던 Spring 핵심 원리가 크게 도움 됐다.)
6. 고민 내용.
6 - 1. int, long
Number Class에서 '수'를 뜻하는 value의 타입을 어떤걸로 선정할 지 고민했다.
고민 끝에 int를 사용하기로 결정했다.
고민해보니 int가 낫냐, long이 낫냐를 따질게 아니었다.
왜냐면 int이든, long이든 결국 오버플로우는 발생하기 때문이다. 따라서 "어떤 원시 타입을 사용하냐" 보다는 "Overflow가 발생했을 때, 어떻게 처리할 것인가"가 핵심이라고 생각했다.
우선 BigInteger는 고려하지 않았다. 오버플로우에 자유롭다는 장점이 있지만, 어플리케이션에서 숫자 계산을 위해 BigInteger를 사용한다는 게 매우 어색하게 느껴졌고, 메모리 기반이기 때문에 int나 long보다 연산속도가 느리다는 단점이 있다.
결정적으로는 불변객체라서 연산시에 새로운 객체를 생성해야한다는 점이다.
오버플로우는 발생하지 않지만, 계산할 숫자가 많다면, BigInteger는 계속 새로 생겨날 것이다. 매우 비효율적이다.
따라서 int냐 long이냐를 고민하기 보다는 어떻게 Overflow를 처리할 것인가에 집중했고 int를 사용하기로했다.
Overflow에 대한 처리는 Math.addExact와 try-catch로 처리했다.
6 - 2. 기본구분자를 커스텀구분자로 지정했다면 예외를 발생시켜야하는가?
기본 구분자는 , ; 인데 커스텀 구분자로 또 지정하면 어떻게 해야할까?
나는 예외를 발생시키지 않기로 결정했다.
예외란 "프로그램 실행 중에 발생하는 비정상적인 상황"이다.
1주차 과제에서 비정상적인 상황은 '분리'작업이나 '연산'작업에서 잘못된 값으로 인해 의도대로 동작하지 않을 때라고 생각했다.
그러나 기본 구분자를 중복하여 커스텀 구분자로 등록하는 건, 프로그램의 의도를 해칠만큼 비정상적인 상황이 아니라고 생각했다.
내부에서 검증만 잘 하면 '분리'작업이나 '연산'작업에는 큰 문제가 없기 때문이다.
그래서 Set 컬렉션을 사용하여, 구분자를 저장하여, 중복 문제를 해결했다.
6 - 3. 상수 관리 static final vs Enum
코드를 읽을 때, 하드 코딩된 숫자나, 문자열이 있다면, 해당 숫자나 문자열이 무엇을 의미하는 지 파악하기 힘들다.
이를 매직넘버, 매직리터럴이라고 하고, 이를 상수화한다.
이때, static final을 사용해야할까, Enum을 사용해야할까?
1주차에서는 다음과 같이 사용했다.
특정 도메인에서 사용되는 상수는 "도메인Constants"라는 이름의 클래스에 static final로 정의했다.
Integer, Character 같이 다양한 원시타입에는 static final로 상수를 정의했다.
public class DelimiterConstants {
public static final Integer ASCII_ZERO = 48;
public static final Integer ASCII_NINE = 57;
public static final Character COMMA = ',';
public static final Character COLON = ':';
// 생략
}
Enum은 에러 메세지를 상수화하는 데 사용했다.
에러 메세지는 어차피 String이기 때문에, Enum으로 사용해도 큰 문제가 되지 않는다.
또한 코드 가독성을 높이기 위해 String.format을 사용한다면 효과적일거라 생각했다.
public enum ViewMessage {
REQUEST_INPUT_MESSAGE("덧셈할 문자열을 입력해 주세요."),
RESULT_FORMAT("결과 : %s");
private final String message;
ViewMessage(String message) {
this.message = message;
}
public String getMessage() {
return this.message;
}
public String getMessage(String result) {
if(this.equals(RESULT_FORMAT)){
return String.format(this.message, result);
}
return this.getMessage();
}
}
7. 아쉬운 점
7 - 1. 객체지향인가?
Delimiter를 다루는 DelimiterInspector, DelimiterParser, DelimiterManager 등등
책임을 분리하기 위해 다음과 같이 객체를 인식하고 생성했다.
그런데 '상태'를 가지는 클래스가 없다는 게 객체지향적인지는 의문이다.
또한 String input에 대해서 많은 의존성을 띄고있다.
public class DelimiterParser {
public Delimiter extractDelimiter(String input) {
char symbol = input.charAt(CUSTOM_DELIMITER_INDEX);
return Delimiter.from(symbol);
}
public String subtractDelimiter(String input) {
return input.substring(CUSTOM_DELIMITER_SUBTRACT_INDEX);
}
}
예를 들어 위 DelimiterParser는 '상태'를 가지고 있지 않고 '행위'만 가지고있다.
(지금보니 책임이 SRP를 지키지 않은거 같기도하다... Parse = extract + subtract이라는 내의도가 잘 드러나는 코드일까?)
결국 각 행위가 input이라는 외부 입력에 의존한다는 것인데, 이게 과연 객체지향적인가? 라는 의문이 든다.
아직까지는 잘 모르겠다.... 객체지향 관련 책을 많이 읽어봐야겠다....
많은 메서드가 input이라는 문자열에 의존하고 있는데, 이런 의존을 해결하는 방법은 무엇일까??
input을 포장하는 것...? 흠...
7 - 2. 테스트 코드 작성
테스트 코드를 작성하는 방법에 대해서 깊게 공부해보지 못했습니다.
우선 Delimiter와 Number 같이 객체에 작성한 메서드가 잘 동작하는 지 확인하는 단위 테스트? (용어가 맞을까..)를 작성했습니다.
지금 생각해보니, 어플리케이션을 직접 실행하면서 여러 값을 넣어봤지, 정작 통합 테스트 코드는 작성하지 못했네요...
8. 느낀점
8 - 1. 재밌다.
학부 수업에서 OOP를 배울 때는, 주어진 프로그래밍 과제를 깊게 고민하지 않고 대충 만들어서 제출했다.
그런데 우아한 테크코스에서 1,000명이 넘는 사람들이 열심히하는 모습을 보니, 나도 대충할 수는 없겠더라.
마음 먹으면 제대로 하려는 내 성향이 1주차를 이끌었다.
확실히 재밌다.
나는 대기업에 취업하고자 하는 목표가 있었는데, 요즘따라 서비스기업이나 스타트업에서 즐겁게 개발하고 싶다는 생각이 든다.
8 - 2. 직관적인 네이밍은 어렵다.
직관적인 변수명, 메서드명을 짓는 게 너무 어려웠다.
특히 Delimiter 클래스의 symbol이라는 필드명은 떠오르기까지 꽤 긴 시간이 걸렸다.
또한, 직관적인 메서드명이 코드 가독성에 미치는 영향까지 이번에 알게되어서, 더더욱 나의 어휘력이 원망스러웟다.
9. 1주차 마무리
과제 제출 종료 후, 코드리뷰를 받아보고싶다.
내 코드에 대해서 부족한 부분을 피드백 받아서 2주차에 더 나은 코드를 작성하고싶다!
'우아한 테크 코스 8기' 카테고리의 다른 글
| 우아한 테크 코스 8기 Level 1 - 1주차 (0) | 2026.03.03 |
|---|---|
| [우아한 테크 코스 8기] 최종 합격 (0) | 2026.01.23 |
| [우아한 테크 코스 8기] 1차 합격 및 최종 코딩테스트 후기 (0) | 2025.12.30 |
| [우아한 테크 코스 8기] 프리코스 3주차 (0) | 2025.11.03 |
| [우아한 테크 코스 8기] 프리코스 2주차 (0) | 2025.10.27 |