우아한테크코스 7기 프리코스 1주차 회고

송선권·2024년 10월 21일
3
post-thumbnail

과제 - 문자열 덧셈 계산기

입력한 문자열에서 숫자를 추출하여 더하는 계산기를 구현한다.

  • 쉼표(,) 또는 콜론(:)을 구분자로 가지는 문자열을 전달하는 경우 구분자를 기준으로 분리한 각 숫자의 합을 반환한다.
    • 예: "" => 0, "1,2" => 3, "1,2,3" => 6, "1,2:3" => 6
  • 앞의 기본 구분자(쉼표, 콜론) 외에 커스텀 구분자를 지정할 수 있다. 커스텀 구분자는 문자열 앞부분의 "//"와 "\n" 사이에 위치하는 문자를 커스텀 구분자로 사용한다.
    • 예를 들어 "//;\n1;2;3"과 같이 값을 입력할 경우 커스텀 구분자는 세미콜론(;)이며, 결과 값은 6이 반환되어야 한다.
  • 사용자가 잘못된 값을 입력할 경우 IllegalArgumentException을 발생시킨 후 애플리케이션은 종료되어야 한다.

입출력 요구 사항

입력

  • 구분자와 양수로 구성된 문자열

출력

  • 덧셈 결과

실행 결과 예시

덧셈할 문자열을 입력해 주세요.
1,2:3
결과 : 6

요구사항 정의

과제 해결에 앞서 요구사항을 명확히 정의해보자.

숫자와 구분자로 이루어진 문자열이 주어지면 문자열에서 숫자를 인식하여 합을 반환해야 한다. 구분자는 ,:이지만 원하는 경우 특정 맷의 문자열을 통해 커스텀 구분자를 정의하여 사용할 수 있다. 조건을 만족하지 못하거나 입력 포맷이 잘못된 경우 IllegalArgumentException를 발생시킨 후 종료한다.

위 내용만으로는 애매한 부분이 몇 가지 있다.

빈 문자열("")이 들어오면?

이 경우는 사실 문제를 잘 읽어보면 명시되어 있다.

"" => 0

비어있는 문자열이 들어오면 숫자가 들어오지 않은 것으로 가정하고 합을 0으로 반환한다.

구분자만 연속으로 2개이상 들어오면?

그렇다면 구분자만 2개이상 연속으로 들어오면 어떻게 할까?

2,,3 => 2+0+3 => 5

앞서 빈 문자열이 들어오면 0으로 취급하기로 했다. 그리고 구분자가 연속으로 들어온다는 건 다시말해 숫자칸이 비어있다는 뜻이다. 그래서 나는 특정 위치의 숫자가 빈 문자열("")로 들어온 것으로 판단하고 숫자 0으로써 사용하기로 했다.

커스텀 구분자는 1글자여야 하는가?

커스텀 구분자가 반드시 1글자여야만 할 이유가 있을까? 여러 문자로 이루어진 구분자도 가능하지 않을까? 요구사항에는 아래와 같은 문장이 있다.

문자열 앞부분의 "//"와 "\n" 사이에 위치하는 문자를 커스텀 구분로 사용한다.

커스텀 구분는 정확히 문자를 사용한다고 명시되어 있다. 1글자여야 한다는 별도의 명시는 없었지만 문맥상 1글자만 허용하는 방향이 바람직하다고 결론내렸다.

음수나 0은 숫자로 인식해야 하는가?

입력 요구사항에는 아래와 같은 문장이 있다.

구분자와 양수로 구성된 문자열

입력 문자열에 들어오는 숫자는 양수여야만 한다. 즉 음수는 올바른 입력 숫자가 될 수 없다. 물론 0도 양수는 아니기 때문에 올바른 입력 숫자가 될 수 없다.

커스텀 구분자와 기본 구분자는 혼용될 수 있는가?

요구사항에는 아래와 같은 문장이 있다.

기본 구분자(쉼표, 콜론) 외에 커스텀 구분자를 지정할 수 있다.

기본 구분자와 커스텀 구분자를 혼용할 수 있는가에 대해서는 별도 언급이 없다. 제한한다는 내용은 없었기 때문에 낙관적으로 가능한 방향으로 진행하기로 했다.

숫자는 커스텀 구분자가 될 수 있는가?

이 케이스는 요구사항에 간접적으로라도 명시된 부분이 없다. 고민 끝에 내가 내린 결론은 안될 것 없지 않나?이다.

//1\n21314 => 2+3+4 => 9

숫자를 입력으로 넘긴다면 그 사용자에게는 해당 숫자를 "수"로써 사용할 의향이 없다는 의미를 내포한다고 가정했다. 그렇기에 별도로 숫자 커스텀 구분자를 제한할 필요는 없다고 생각했다.

테스트 추가

이렇게 애매한 케이스들을 명확히 했는데, 코드만 봐서는 특정 케이스가 들어올 경우 이렇게 반환하는 게 의도된 것이 맞는지 알기 힘들다. 그래서 검증과 명시의 목적으로 테스트에 관련 케이스를 추가했다.

@Test  
void 정상_케이스() {  
    assertSimpleTest(() -> {  
        // 일반적인 입력  
        run("1,2,3");  
        assertThat(output()).contains("결과 : 6");  
  
        // 구분자가 연속으로 주어지는 경우  
        run("1::2::3");  
        assertThat(output()).contains("결과 : 6");  
  
        // 숫자 없이 구분자만 연속으로 주어지는 경우  
        run(",,,,");  
        assertThat(output()).contains("결과 : 0");  
  
        // 커스텀 구분자와 기본 구분자를 혼용하는 경우  
        run("//;\\n1,2:3;4");  
        assertThat(output()).contains("결과 : 10");  
  
        // 숫자를 커스텀 구분자로 사용하는 경우  
        run("//1\\n21212");  
        assertThat(output()).contains("결과 : 6");  
    });  
}  
  
@Test  
void 예외_케이스() {  
    assertSimpleTest(() -> {  
            // 커스텀 구분자가 2글자 이상인 경우  
            assertThatThrownBy(() -> runException("//;;\\n1;2;3"))  
                .isInstanceOf(IllegalArgumentException.class);  
  
            // 숫자로 0이 들어오는 경우  
            assertThatThrownBy(() -> runException("0,1,2"))  
                .isInstanceOf(IllegalArgumentException.class);  
        }  
    );  
}

숫자 추출 방법

1주차 과제를 구현하면서 가장 신경쓴 부분은 숫자 추출 방법이다.

문자 단위 추출

처음에는 단순히 for문으로 입력 문자열을 문자 단위로 순회하는 방법으로 구현했다. 하지만 작성하다보니 처리해야 할 예외 케이스가 많았고, 코드가 점점 지저분해지는 것이 눈에 보이기 시작했다. 이 방향은 절대 최선의 방법이 될 수 없다고 생각해 문자열에서 특정 패턴을 추출하는 새로운 방법을 찾아보았다.

정규표현식

정규표현식을 활용하면 복잡한 패턴 속에서도 특정 문자열을 추출할 수 있다고 한다. 그래서 정규표현식에 대해 학습하고 적용해보니 훨씬 깔끔해진 코드를 확인할 수 있었다.

정규표현식 사용 부분은 다음과 같이 구현했다.

Matcher matcher = Pattern.compile("//(.)\n(.*)", Pattern.DOTALL).matcher(input);  
  
String delimiters = DEFAULT_DELIMITERS;  
String inputNumbers = input;  
  
if (matcher.matches()) {  
    delimiters += "|" + Pattern.quote(matcher.group(1));  
    inputNumbers = matcher.group(2);  
}

학습한 내용을 간단하게 정리해두었으니 관심이 있다면 읽어보자.
정규표현식

startsWith(), indexOf()

정규표현식을 사용해서 간결하게 구현하니 뿌듯했다. 하지만 이게 정말 최선이 맞는지 의구심이 들었다. 좀 더 고민해보니 정규표현식 외에도 String.startsWith()String.indexOf()를 활용해서 문제를 해결할 수 있었다.

정규표현식의 경우와 비교해보기 위해 직접 구현해보았다.

String delimiters = ",|:";  
String inputNumbers = input;  
  
if (input.startsWith("//")) {  
    int delimiterIndex = input.indexOf("\n");  
    if (delimiterIndex == -1 || delimiterIndex - input.indexOf("//") != 3) {  
        throw new IllegalArgumentException();  
    }  
    delimiters += "|" + Pattern.quote(input.substring(2, delimiterIndex));  
    inputNumbers = input.substring(delimiterIndex + 1);  
}

의도가 불분명한 매직 넘버가 많이 생겼고, 무엇보다 코드가 너무 정적이라는 느낌이 들었다. 정규표현식의 경우는 과제에서 요구하는 문자열 형식이 바뀌어도 유연하게 대응할 수 있지만 이 코드는 그렇지 않다. 확장성과 유연성에 대해 상대적으로 닫혀있기에 좋게 보이지 않는다.

그렇다면 성능은 어떨까? 정규표현식은 Matcher나 Pattern 객체를 생성하여 동작하기 때문에 성능이 조금 떨어질 수 있다고 한다.


  • startsWith(), indexOf()
    • 테스트 시간 60~70ms
    • 5회 테스트 평균 60.6ms
  • 정규표현식
    • 테스트 시간 60~70ms
    • 5회 테스트 평균 63.6ms

테스트 결과 유의미한 차이를 확인하기 힘들었다. 테스트 횟수가 적고 그 내용도 간단한 수준이어서 큰 차이가 나지 않은 걸수도 있다. 그래도 과제 수준에서는 성능 상 큰 차이가 나지 않는다는 것을 보여주는 지표라고 생각한다.

둘의 성능이 비슷한 시점에서 코드의 확장성과 유연함을 챙기기 위해 최종적으로 정규표현식을 사용하기로 결정했다.

회고

1주차에는 문제에 대한 요구사항을 명확히 하고 이에 대한 내 의도를 테스트를 통해 명확히 명시하고자 했다. 또한 핵심 로직을 유연성있게 구현하기 위해 고민하고 정규표현식을 추가로 학습하며 이 과정을 공유하기 위해 블로깅도 해보았다. 여기에 그치지 않고 정규표현식이 정말 최선의 선택이었는지 다시 한 번 의심하고 테스트해보며 객관적인 지표를 얻고자 했다. 여러 고민끝에 만족스러운 결과가 나왔다고 생각한다.

다음주에는 2주차 미션을 진행하며 다른 사람들과 1주차 코드리뷰를 진행하려고 한다. 스스로 만족스러운 1주차였지만 다른 사람들은 내 코드를 어떻게 바라볼지, 어떤 인사이트를 가지고 이번 과제를 진행했는지 궁금하다. 혼자 고민하는 시간도 중요하지만 이번에는 다른 사람들과의 상호작용을 통해 더욱 성장할 수 있는 기회를 가져보자.

0개의 댓글