우테코

[우테코 프리코스] 1주차 미션 회고록

nkdev 2025. 10. 21. 14:28

PR 주소 :

https://github.com/woowacourse-precourse/java-calculator-8/pull/1032

✍🏻 회고

기능 요구사항이 간단해보였으나, 테스트 코드를 짜면서 추가/수정할 부분이 많이 발견되었다.

최대한 다양한 예외 케이스를 선정하고 테스트하는 과정을 통해 코드를 더 견고하게 만들 수 있었다.

 

코드를 타인이 보았을 때 이해가 잘 되도록 번역기를 활용해 변수명, 함수명을 용도에 맞게 짓고 주석을 추가하였다.

코드 리뷰를 통해 가독성이 좋은 코드인지 평가 받고 피드백을 적용해나가고 싶다.

 

예외 처리의 경우 모두 일관되게 IllegalArgumentException를 던지도록 구현하였는데, 다음 주차때 부터는 경우를 나누어 각 상황에 맞는 메시지를 리턴하도록 만들어보고 싶다.

 

1주차 미션을 통해 '객체 지향의 사실과 오해' 책에서 공부한 이론들을 실제 코드로 적용해볼 수 있어서 의미 있는 시간이었다.

코드 리뷰를 통해 받은 피드백이 어떨지 궁금하기도 하고, 다른 분들의 코드를 보면서 많이 배워가고 싶다.

 

🛠️ 코드 개선 및 디버깅 내용

1) 구현 기능 목록 선정 + 전체적인 설계

지난 달에 next step 과제를 하면서 배운 내용에서 착안하여 기능 목록을 리스트업했다.

최대한 객체지향적인 짜임새가 드러나도록 노력했다.

 

기능 요구사항을 만족하기 위해 '값 입력받기 -> 구분자 찾기 -> 구분자 기준으로 숫자 분리 -> 합 계산 -> 출력' 이라는 행동이 필요하다고 판단했고 각 행동을 수행하는 객체를 만들어 책임을 할당했다.

책임은 객체에 맞게 응집도 있게 할당했다.

- [ ] 값 입력받기 (InputHandler)
    - [ ] 아무것도 입력받지 않은 경우 0을 출력 후 애플리케이션을 종료한다


- [ ] 구분자 찾기 (DelimiterFinder)
    - [ ] 0번째 문자가 정수이면 문자열 끝까지 확인하여 구분자가 , : 밖에 없거나 존재하지 않는지 확인한다
    - [ ] 0번째 문자가 정수가 아니면 //커스텀구분자\n 형태인지 확인 후 문자열 끝까지 확인하여 구분자가 커스텀 구분자 밖에 없는지 확인한다
    - [ ] 두 경우에 해당되지 않는다면 IllegalArgumentException 예외를 발생시킨 후 애플리케이션을 종료한다


- [ ] 구분자 기준으로 숫자 분리 (NumberSeparator)
    - [ ] 구분자가 없는 경우 문자열을 바로 숫자로 취급한다
    - [ ] 찾은 구분자를 기준으로 숫자를 분리한다


- [ ] 값의 합을 계산 (SumCalculator)
    - [ ] 유효성 검증
        - [ ] 분리된 값 중 하나라도 정수가 아닌 경우
        - [ ] IllegalArgumentException 예외를 발생시킨 후 애플리케이션을 종료한다
    - [ ] 분리된 숫자를 받아 합을 계산하고 출력한 후 애플리케이션을 종료한다

 

2) 설계에서 보완할 점

이번 설계에서 보완할 점은, 각 객체들이 다른 객체들에게 책임을 위임하지 않고 로직만 수행한 후 바로 값을 리턴하게 했다는 점이다.

그래서 객체를 호출하는 곳(Application.java)에서 리턴값을 직접 받아서 다른 객체에게 넘겨주면서 동작이 수행되고 있다.

이러한 방법은 '객체 간의 협력'을 고려하지 않은 설계인 것 같다. 다음 주차때 보완해야겠다.

 

3) DelimiterFinder 로직 구현 과정 + 예외처리에 대한 어려움

이번 과제에서 가장 시간을 많이 쓴 부분이다.

문자열을 받아 구분자를 추출하도록 해야 한다.

package calculator;

import java.util.Arrays;

public class DelimiterFinder {

    public String getDelimiter(String input){
        char[] inputArr = input.toCharArray();
        String ans = "";
        boolean isValid = true;

        if('0' <= inputArr[0] && inputArr[0] <= '9'){ //0번째 문자가 정수
            boolean haveDelimiter = false;

            for(int i=0; i<inputArr.length; i++){
                isValid = (inputArr[i] == ',') ||
                        (inputArr[i] == ':') ||
                        ('0' <= inputArr[i] && inputArr[i] <= '9');
                if(!isValid)
                    throw new IllegalArgumentException("유효하지 않은 입력입니다.");
                if((inputArr[i] == ',') || (inputArr[i] == ':'))
                    haveDelimiter = true;
            }
            if(haveDelimiter)
                ans = ",";
            else
                ans = "";

        } else { //0번째 문자가 정수가 아님
            if(inputArr[0] == '/' && inputArr[1] == '/'){

                int k = 2;
                StringBuffer sb = new StringBuffer();

                while(true){
                    if(k > inputArr.length || k+1 > inputArr.length){
                        isValid = false;
                        break;
                    }
                    if(inputArr[k] == '\\' && inputArr[k+1] == 'n')
                        break;
                    sb.append(inputArr[k]);
                    k++;
                }

                if(sb.isEmpty() || !(inputArr[k] == '\\' && inputArr[k+1] == 'n'))
                    isValid = false;

                ans = sb.toString();
            } else isValid = false;

            if(!isValid)
                throw new IllegalArgumentException("유효하지 않은 입력입니다.");
        }
        return ans;
    }
}

 

로직을 아래와 같이 짜고 다양한 예외 케이스를 만든 후, 각각 통과되도록 테스트코드를 짰다.

    • 0번째 문자가 정수인 경우
      • 모든 문자를 하나씩 방문하면서 각 문자가 '정수 , : ' 셋 중 하나에 해당되지 않으면 예외처리
      • , 또는 :가 연속으로 발견되면 예외처리
      • 문자(, 또는 :)가 하나라도 발견되면 ","를 리턴, 문자가 발견되지 않으면 ""(빈 문자열)를 리턴
    • 0번째 문자가 정수가 아닌 경우
      • 0번째, 1번째 문자가 '//'인지 확인
        • 무한 루프 -> 2번째 문자부터 하나씩 방문하면서 StringBuffer에 문자 저장
        • 종료 조건 -> '\n'를 만난 경우, 문자열을 벗어난 인덱스를 검사하려고 하는 경우
      • '\n'를 만난 경우 StringBuilder가 empty이면 예외처리
      • '\n'를 만나지 않은 경우 다시 한번 '\n'가 존재하는지 확인한 후 없다면 예외처리

 

하나의 조건이 충족되지 못했을 때마다 따로 예외 처리를 하고 예외 메시지를 다르게 구현해야 할지 아니면 내가 구현한 방법처럼 isValid로 묶어서 러프하게 던져도 괜찮을지 고민하다가 일괄적으로 예외처리를 했다.

 

4) split()함수의 정규식 처리 때문에 발생한 에러 해결 과정

문제 : split() 사용 시 커스텀 구분자(*, ^, % 등)가 정규식 특수 문자로 해석되어 PatternSyntaxException 발생
해결 : replace()를 통한 표준화 방식(:, 커스텀 구분자를 ,로 변환)으로 해결하여 정규식 위험 제거

 

로직 :

    public int[] separateStringIntoNumberByDelimiter(String delimiter, String input){
        if(!delimiter.equals(",")){ //커스텀 문자인 경우 '//[커스텀문자]\n' 이후의 문자열을 input값으로 치환
            input = input.substring(3 + delimiter.length());
        }
        String[] tmp = input.split(delimiter);
        return Arrays.stream(tmp)
                .mapToInt(Integer::parseInt)
                .toArray();
    }

테스트 코드 :

    @Test
    @DisplayName("커스텀 구분자를 기준으로 문자열을 분리한다")
    void return_comma(){
        //given
        String delimiter = "^%";
        String input = "//^%\n11^%22^%";

        //when
        int[] result = numberSeparator.separateStringIntoNumberByDelimiter(delimiter, input);
        int[] expected = {11, 22};

        //then
        assertThat(result).isEqualTo(expected);
    }

에러 :

For input string: "11^%22^%"
java.lang.NumberFormatException: For input string: "11^%22^%"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:662)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at java.base/java.util.stream.ReferencePipeline$4$1.accept(ReferencePipeline.java:214)
	at java.base/java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:1024)

NumberFormatException 발생

 

참고 :

https://jamesdreaming.tistory.com/126

 

[ 자바 코딩 ] NumberFormatException 원인과 해결방법

안녕하세요. 제임스 입니다. 이번에는 개발 중 종종 발생하는 오류중 하나인 NumbreFormatException 에 대해 정리 해보겠습니다. NumbreFormatException 은 왜 발생 하는 것일 까요? 단어를 잘 보시면 이유를

jamesdreaming.tistory.com

 

이 에러는 String str = "012ab"처럼 정수로 변환하고 싶은 문자열에 정수가 아닌 값이 포함되어 있을 경우 발생하는 에러이다.

Integer.parseInt(str) -> NumberFormatException 에러

 

String[] tmp를 출력해보니 {"11", "22"}가 아니라 {"11^%22^%"}로 확인되었다. 

문자열이 구분자 기준으로 split이 안 된 채 String 배열에 저장되었고 이를 int로 바꾸는 과정에서 기호가 포함되어 있기 때문이었다.

 

split() 사용 시 커스텀 구분자(*, ^, % 등)가 정규식 특수 문자로 해석되어 발생한 문제이므로, 모든 구분자를 replace()를 통해 ','로 바꾼 후 split()을 사용하도록 변경하였다.

 

변경 후 코드 :

public int[] separateStringIntoNumberByDelimiter(String delimiter, String input){
        if(delimiter.isEmpty() && input.isEmpty()){
            return new int[]{0};
        }

        if(!delimiter.equals(",")){ //커스텀 문자인 경우 '//[커스텀문자]\n' 이후의 문자열을 input값으로 치환
            input = input.substring(4 + delimiter.length());
        }

        String replacedInput = input.replace(delimiter, ",");
        replacedInput = replacedInput.replace(":", ",");
        String[] tmp = replacedInput.split(",");
        return Arrays.stream(tmp)
                .mapToInt(Integer::parseInt)
                .toArray();

    }

 

5) Scanner.nextLine()와 run()의 의 동작 원리

문제 : scanner.nextLine()으로 빈 문자열을 받았을 때 '결과 : 0'이 출력되도록 하는 테스트 코드가 실패함
해결 : 테스트할 빈 문자열을 ""이 아니라 "\n"으로 변경

 

시도한 테스트 코드 :

@Test
@DisplayName("빈 문자열이 입력되면 0을 반환한다")
void return_zero() {
	assertSimpleTest(() -> {
		run("");
		assertThat(output()).contains("결과 : 0");
	});
}

 

입력값 없이 엔터만 친 경우 "결과 : 0"이 출력되는지 테스트하기 위해 짠 코드이다.

그런데 NoSuchElementException이 발생하면서 테스트가 실패했다.

 

왜 실패하지? 테스트는 실패하는데 직접 Application.java를 실행시켜서 콘솔에 엔터만 쳐보면 NoSuchElementException도 발생하지 않고 "결과 : 0"이 출력된다.

 

입력 받는 로직 :

public String getInputString(){
        System.out.println("덧셈할 문자열을 입력해 주세요.");
        return scanner.nextLine();
    }

 

그냥 Scanner.nextLine()으로 입력을 받고 있다.

Scanner의 nextLine()은 개행문자(\n)를 구분자로 인식하고 종료하니까, 아무것도 입력하지 않고 엔터(\n)만 쳤을 때 nextLine()은 \n를 포함한 줄 전체를 읽어들이고 개행문자는 버린다.

 

따라서 사용자가 아무것도 입력하지 않고 엔터만 쳤다면 빈 문자열 ("")이 입력된 것 아닌가?

그래서 테스트 코드는 잘못된 부분이 없다고 생각했다.

 

이유를 못 찾겠어서 일단 아래와 같이 조치했다.

그러니 테스트 코드는 일단 잘 돌아간다.

public String getInputString(){
        System.out.println("덧셈할 문자열을 입력해 주세요.");
        try {
            return scanner.nextLine();
        } catch(NoSuchElementException e){
            return "";
        }
    }

 

제출하고 다시 공부해보니 run()은 사용자의 콘솔 입력을 시뮬레이션하는 함수였다.

따라서 run("")이 아니라 run("\n")으로 테스트 코드를 수정하는 것이 올바른 방법이다.

    @Test
    @DisplayName("빈 문자열이 입력되면 0을 반환한다")
    void return_zero() {
        assertSimpleTest(() -> {
            run("\n");
            assertThat(output()).contains("결과 : 0");
        });
    }

 

6) Pattern Matcher

나는 구분자를 기준으로 문자열을 분리할 때, 글자 하나 하나를 검사했는데

코드리뷰에서 java.util.regex라는 패키지에 Pattern, Matcher라는 클래스를 제공한다는 것을 알려주셨다.

 

예제 1

Pattern CUSTOM_DELIMITER_PATTERN = Pattern.compile("//(.*)\n(.*)");

Pattern은 문자열을 정규식 패턴에 따라 매칭할 때 사용되는 클래스이다.

(.*)은 어떤 문자든 0개 이상 존재해야 한다는 뜻이다.

String input = "//;\n1;2;3";
Matcher matcher = CUSTOM_DELIMITER_PATTERN.matcher(input);

검사하고 싶은 문자열을 Pattern 객체의 matcher()에 전달하면 Matcher 객체를 얻을 수 있다.

이 Matcher 객체로 대상 문자열이 패턴과 일치하는지 확인거나 (matches(), find())

매칭된 부분을 반환할 수 있다. (group())

if (matcher.matches()) {
    String delimiter = matcher.group(1);  // ";"
    String numbers = matcher.group(2);    // "1;2;3"
}

group()으로 첫 번째 그룹, 두 번째 그룹을 나누어 문자열을 반환받는 모습이다. 

 

예제 2

Pattern BASIC_DELIMITER_PATTERN = Pattern.compile("[,:]");

대괄호로 구분자를 정의한 후, 구분자 기준으로 나누는 패턴도 있다.

문자열을 ,또는 : 기준으로 나누도록 정의해보았다.

String input = "1,2:3";
String[] tokens = BASIC_DELIMITER_PATTERN.split(input);

그냥 구분자 기준으로 split하려면 Matcher을 안 만들고 Pattern 객체의 split()메서드를 써도 된다.

for (String token : tokens) {
    System.out.println(token);
}

 

이렇게 Pattern, Matcher을 사용하면 구분자를 추출하고, 패턴에 맞는지 유효성 검사하는 동작까지 제공된 함수로 처리할 수 있다.

 

7) 오버플로우

 

자바의 intlong은 크기가 제한되어 있어서, 큰 수를 계산해야 하는 경우에는 오버플로우가 발생할 수 있다.

  • 오버플로우는 변수에 담을 수 있는 범위를 넘어서는 값을 저장하려고 할 때 발생하는 문제
  • 자바에서 int는 32비트 정수, 표현 가능한 범위는 -2,147,483,648 ~ 2,147,483,647

BigInteger, BigDecimal은 크기에 제한 없는 정수를 다룰 수 있는 클래스이다.