Post

프리코스 1주차를 진행하며

프리코스 1주차를 진행하며

들어가며

우아한테크코스 8기 프리코스 1주차 과제는 문자열 계산기 구현이었다. 간단해 보이는 과제였지만, 실제로 구현하면서 다양한 문제 상황을 마주했고 하나씩 해결해나가는 과정에서 많은 것을 배울 수 있었다. 이 글에서는 구현 중 만났던 여러 고민들과 해결 과정을 기록하고자 한다.



1. 커스텀 구분자 사용 시 기본 구분자도 함께 쓸까?

과제의 요구사항에는 아래와 같이 적혀있었다.

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

이 문장에서 “외에”라는 표현이 애매하다고 생각했다. 예를 들어 //?\n1?2:3이라는 입력이 들어왔을 때 두 가지 상황을 생각해볼 수 있었다.

  • Case 1: 커스텀 구분자 ? 추가?, ,, : 모두 구분자 → 결과: 6
  • Case 2: 커스텀 구분자 ? 대체?만 구분자 → 2:3은 파싱 불가


일반적으로 “커스텀 구분자를 지정한다”는 것은 기본값을 대체한다는 의미로 해석된다. 만약 기본 구분자와 함께 쓰려면 “추가로 지정할 수 있다” 같은 표현을 사용했을 것이다.

또한 추가 방식이면 의도하지 않은 동작이 발생할 수 있다.

1
2
3
"//?\n1?2:3"  // 커스텀 구분자 ?
// 만약 추가 방식이면: ?, ,, : 모두 구분자
// 사용자는 ?만 쓰고 싶었는데 ,도 구분자가 됨

해결방법

커스텀 구분자 지정 시 기본 구분자는 사용하지 않는 것으로 결정했다. 커스텀 구분자 하나만 사용한다는 의미로 해석한 것이다.

1
2
3
4
5
6
private static String findSeparator(String expression) {
    if (hasCustomSeparator(expression)) {
        return extractCustomSeparator(expression);  // 커스텀만 반환
    }
    return USE_DEFAULT_SEPARATOR;  // 기본 구분자 사용 신호
}


2. DEFAULT_SEPARATOR의 의미 혼재

기본 구분자를 사용할지 커스텀 구분자를 사용할지 판단하는 수정 전 코드는 아래와 같았다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static final String DEFAULT_SEPARATOR = ",:";

private static String findSeparator(String expression) {
    if (hasCustomSeparator(expression)) {
        return extractCustomSeparator(expression);
    }
    return DEFAULT_SEPARATOR;  // ",:" 반환
}

private static String[] split(String numbers, String separator) {
    if (separator.equals(DEFAULT_SEPARATOR)) {
        return numbers.split("[" + separator + "]");  // [,:]
    }
    return numbers.split(Pattern.quote(separator));
}

문제점은 DEFAULT_SEPARATOR가 두 가지 의미로 사용되고 있다는 것이었다.

  1. “기본 구분자를 사용한다”는 신호 (findSeparator에서)
  2. 실제 구분자 문자들 (split에서 [,:] 만들 때)

하나의 변수가 두 가지 역할을 하니 코드 의도가 불명확했다. 이걸 깔끔하게 분리시키고 싶었다.

해결방법

책임을 명확히 분리하자!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static final String DEFAULT_SEPARATORS = ",:";     // 실제 구분자 문자
private static final String USE_DEFAULT_SEPARATOR = "USE_DEFAULT_SEPARATOR";   // 기본 구분자 사용 신호

private static String findSeparator(String expression) {
    if (hasCustomSeparator(expression)) {
        return extractCustomSeparator(expression);
    }
    return USE_DEFAULT_SEPARATOR;  // 플래그로 사용
}

private static String[] split(String numbers, String separator) {
    if (separator.equals(USE_DEFAULT_SEPARATOR)) {
        return numbers.split("[" + DEFAULT_SEPARATORS + "]");  // 실제 문자 사용
    }
    return numbers.split(Pattern.quote(separator));
}


“변수 하나도 명확한 책임을 가져야 한다!”” 는 것을 느꼈다.

“이 변수는 정확히 무엇을 표현하는가?”를 항상 고민해야 한다.

이는 객체지향의 SRP(Single Responsibility Principle, 단일 책임 원칙) 와 맥락이 같다. SRP는 “하나의 클래스는 하나의 책임만 가져야 한다”는 원칙인데, 이를 변수 단위로 적용하면 “하나의 변수는 하나의 의미만 표현해야 한다”가 된다.

DEFAULT_SEPARATOR 하나가 “신호”와 “실제 값”이라는 두 가지 역할을 하면, 변경 사유도 두 가지가 된다. 이는 SRP 위반이며 코드의 응집도를 낮춘다.


3. hasCustomSeparator와 extractCustomSeparator의 중복

hasCustomSeparator 는 커스텀 구분자라 있나? 를 체크하고 extractCustomSeparator 는 그 커스텀 구분자를 추출해내는 메서드다.

이 메서드들을 작성하고 보니 커스텀 구분자 검증 로직이 두 메서드에 중복되었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private static boolean hasCustomSeparator(String expression) {
    if (!expression.startsWith("//")) return false;
    
    int endIndex = expression.indexOf("\n");
    if (endIndex == -1) return false;
    
    String separator = expression.substring(2, endIndex);
    return !separator.isEmpty();
}

private static String extractCustomSeparator(String expression) {
    int endIndex = expression.indexOf("\n");
    
    if (endIndex == -1) {
        throw new IllegalArgumentException("커스텀 구분자 형식이 잘못되었습니다.");
    }
    
    String separator = expression.substring(2, endIndex);
    
    if (separator.isEmpty()) {
        throw new IllegalArgumentException("커스텀 구분자가 비었습니다.");
    }
    
    return separator;
}

고민

DRY(Don’t Repeat Yourself) 원칙을 지키려면 중복을 제거해야 하지 않나?

옵션 1: hasCustomSeparator에서 완전 검증, extractCustomSeparator는 단순 추출만

  • 장점: 검증 로직 한 곳에 집중
  • 단점: extractCustomSeparatorhasCustomSeparator에 의존

옵션 2: 중복 허용, 각 메서드 독립적으로 동작

  • 장점: 메서드 간 의존성 없음, 각자 명확한 책임
  • 단점: 코드 중복


해결방법

약간의 중복을 허용하고 메서드 독립성 유지

왜냐하면 각 메서드별로 기능과 의의가 뚜렷하게 나뉘기 때문이다.

hasCustomSeparator 메서드는 커스텀 구분자를 사용할 수 있는가/아닌가 를 중점으로 바라본다. 그렇기 때문에 예/아니오만 수행해야하며, 이렇게 boolean은 오류를 던지는 것이 맞지 않다고 생각했다.

반면 extractCustomSeparator 메서드에서는 “그럼 커스텀 구분자 사용가능하니까 어떤 구분자인지 추출해봐” 라는 의미로 해석할 수 있다. 이를 문자열로 반환해야 하는 과정이기 때문에 반환이 불가능하다면 오류를 던지자! 로 결정했다.

다음과 같은 결정이 나름 이유가 있다고 결론 내렸다.

  • hasCustomSeparator는 boolean 반환에 집중
  • extractCustomSeparator는 구분자 추출 및 반환과 예외 처리에 집중


항상 중복 제거가 정답은 아니다!

중복을 제거하면서 메서드 간 결합도가 너무 높아진다면, 오히려 약간의 중복을 허용하며 의미를 뚜렷하게 부여하는게 나을 수 있음을 느꼈다.


4. 테스트에서 빈 문자열 입력이 실패하는 이유

빈 문자열 "" 입력시 0 이 나오는 테스트 코드를 작성하고 실행했는데 아래와 같은 에러가 발생했다.

1
2
3
4
5
6
7
@Test
void 빈_문자열_입력() {
    assertSimpleTest(() -> {
        run("");  // 빈 문자열 입력
        assertThat(output()).contains("결과 : 0");
    });
}

에러 로그는 다음과 같았다.

1
2
java.util.NoSuchElementException: No line found
at Console.readLine(Console.java:12)

camp.nextstep.edu.missionutils 에서 제공하는 Console API를 사용하여 입출력을 구현해야 했다.

테스트코드의 run 메서드도 camp.nextstep.edu.missionutils.test 라이브러리에서 제공하는 메서드였다.

woowacourse-projects/mission-utils 깃허브 를 직접 찾아보니 우테코에서 직접 만들어 제공하는 것 같다.

그래서 해당 라이브러리를 뜯어보았다.


NsTest의 내부 동작

NsTest command() 메서드

1
2
3
4
private void command(String... args) {
    byte[] buf = String.join("\n", args).getBytes();
    System.setIn(new ByteArrayInputStream(buf));
}

동작 과정

1
2
3
4
5
  run("")  호출
 command("") 
 String.join("\n", "")             // "" (빈 문자열)
 "".getBytes()                     // 빈 바이트 배열
 new ByteArrayInputStream( 배열)  // 빈 입력 스트림

이런 흐름으로 동작한다는 것을 파악했다.

문제점

빈 입력 스트림에서 readLine() 호출 → 읽을 줄이 없음 → NoSuchElementException

이를 해결하기 위해 뭐라도 입력을 해야했다.


해결방법

1
2
3
4
5
6
7
@Test
void 빈_문자열_입력() {
    assertSimpleTest(() -> {
        run("\n");  // ✅ 개행 문자 포함 (Enter만 친 것)
        assertThat(output()).contains("결과 : 0");
    });
}

동작 과정

1
2
3
4
5
6
run("\n")
 command("\n")
 String.join("\n", "\n")   // "\n" (개행 1개)
 "\n".getBytes()           // [10] (개행문자 바이트)
 ByteArrayInputStream에   하나 존재
 readLine() 반환값: ""      // ✅ 성공


배운점

테스트에서 “빈 입력”을 표현하는 방법에 대한 이해가 바뀌었다.

처음에는 run("")이 “아무것도 입력하지 않음”을 의미한다고 생각했지만, 실제로는 “입력 스트림 자체가 존재하지 않음” 을 의미했다.

내가 직접 Application을 실행하고 Enter만 누르는 상황은 run("\n")으로 표현해야 한다. 이는 String.join("\n", "\n")이 개행문자를 반환하여 ByteArrayInputStream 에 빈 줄 하나가 존재하는 상태를 만들기 때문이다.

결국 테스트 코드에서의 “빈 입력”은 입력 스트림이 없는 상태 ("") 가 아니라 빈 문자열이 입력된 상태 ("\n") 를 의미한다는 것을 찾아냈다.


5. 특수문자 이스케이프와 Pattern.quote()

구현 중 고민

커스텀 구분자를 split할 때, 사용자가 어떤 문자를 입력할지 알 수 없다. 만약 ?* 같은 정규식 특수문자가 들어온다면?

1
2
String text = "1?2?3";
String[] result = text.split("?");  // ❌ PatternSyntaxException 발생 가능!

split()과 정규식

split()정규식을 매개변수로 받는다.

1
public String[] split(String regex)  // regex = regular expression

정규식 특수문자: . ^ $ * + ? { } [ ] \ | ( )

이 문자들은 정규식에서 특별한 의미를 가진다

  • ?: “0개 또는 1개”
  • *: “0개 이상”
  • .: “모든 문자”


해결방법: Pattern.quote() 사용

1
2
3
4
5
6
7
8
9
private static String[] splitBySeparator(String numbers, String separator) {
    // 기본 구분자
    if (separator.equals(USE_DEFAULT_SEPARATOR)) {
        return numbers.split("[" + DEFAULT_SEPARATORS + "]");
    }

    // 커스텀 구분자(정규식 특수문자 이스케이프)
    return numbers.split(Pattern.quote(separator));
}

Pattern.quote() 동작 원리

사용자 입력을 \Q\E 로 감싸서 일반 문자로 만든다.

1
2
Pattern.quote("?")   // "\\Q?\\E"
Pattern.quote("**")  // "\\Q**\\E"

\Q\E 가 뭐지?

정규식에서 \Q\E 사이의 모든 문자는 메타문자가 아닌 리터럴로 취급된다.

  • ? → “0개 또는 1개” (정규식 메타문자)
  • \Q?\E → 물음표 문자 그대로 (일반 문자)

ex)

1
2
3
4
5
6
7
8
9
10
11
// 정규식에서 . 은 "임의의 한 문자"
"a.b".split(".")           // [] (모든 문자가 구분자로 인식되어 빈 배열)
"a.b".split("\\.")         // ["a", "b"] (이스케이프로 점 문자 그대로)
"a.b".split("\\Q.\\E")     // ["a", "b"] (\Q...\E로 점 문자 그대로)

// Pattern.quote 사용하면 내부적으로 문자열을 `\Q...\E`로 감싼다.
Pattern.quote(".")   // "\Q.\E" 반환
Pattern.quote("?")   // "\Q?\E" 반환

"a.b".split(Pattern.quote("."))    // ["a", "b"] 
"1?2?3".split(Pattern.quote("?"))  // ["1", "2", "3"]


Q. 만약 구분자 자체가 \E를 포함하면?

1
2
Pattern.quote("A\\EB")
// 결과: "\\QA\\E\\\\E\\QB\\E"

단순히 \Q…\E로 감싸면 중간의 \E가 리터럴 모드를 종료시켜버린다.

실제 quote() 메서드를 뜯어보며 이를 어떻게 해결하는지 알아보자.

Pattern.quote() 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static String quote(String s) {
    int slashEIndex = s.indexOf("\\E");
    if (slashEIndex == -1)
        return "\\Q" + s + "\\E"; // \\E가 없으면 간단히 감싸기

    // \\E가 있으면 이스케이프 처리
    int lenHint = s.length();
    lenHint = (lenHint < Integer.MAX_VALUE - 8 - lenHint) ?
            (lenHint << 1) : (Integer.MAX_VALUE - 8);

    StringBuilder sb = new StringBuilder(lenHint);
    sb.append("\\Q");
    int current = 0;
    do {
        sb.append(s, current, slashEIndex)
                .append("\\E\\\\E\\Q");
        current = slashEIndex + 2;
    } while ((slashEIndex = s.indexOf("\\E", current)) != -1);

    return sb.append(s, current, s.length())
            .append("\\E")
            .toString();
}

여기서 눈 여겨볼 점은 do-while 문 안에 있는 .append("\\E\\\\E\\Q") 부분이다.


\E\\E\Q 의 의미

\E를 만나면 \E\\E\Q를 삽입한다.

이걸 3개 파트로 쪼개보면

1
2
3
4
5
\\E\\\\E\\Q

= \\E      ( '\E' 로 리터럴 모드 종료 )
+ \\\\E    ( '\E' 문자를 일반 문자로 추가 : '\'를 위한\ + '\E'를 위한 \라서 \가 이렇게 많다)
+ \\Q      ( '\Q' 로 리터럴 모드 재시작 )

이걸 요약하면 : 리터럴 모드를 잠깐 끄고 → \E 를 문자로 넣고 → 다시 킴! 이 된다.

배운 점

  • 사용자 입력을 정규식으로 사용할 때는 반드시 이스케이프 처리가 필요하다.
  • Pattern.quote()는 단순히 \Q...\E로 감싸는 것이 아니라, 문자열 내의 \E까지 정교하게 처리한다는 점!

마치며

1주차 과제는 간단해 보였지만, 막상 구현하니 생각보다 고민할 것들이 많았다.

“이 변수는 정확히 무엇을 표현하는가?”, “이 중복은 제거해야 하나, 유지해야 하나?”, “테스트 환경과 실제 실행 환경은 어떻게 다를까?” 같은 질문들을 계속 던지면서 코드를 작성했다.

특히 요구사항이 애매할 때 어떻게 해석할지, 책임을 어떻게 분리할지 같은 부분에서 많이 고민했다. 정답이 정해져 있지 않은 문제들이라 더 고민이 깊어졌던 것 같다.

Java 표준 라이브러리의 내부 구현(Pattern.quote(), String.join() 등)을 들여다보면서 “이렇게까지 꼼꼼하게 예외 케이스를 처리하는구나”를 느꼈고, 좋은 코드란 무엇인가에 대해 다시 생각해보는 계기가 되었다.

1단계 과제였지만 생각보다 많은 걸 배웠다. 다음 단계가 기대된다.


References

This post is licensed under CC BY 4.0 by the author.