본문 바로가기
카테고리 없음

[우테코] 8기 프리코스 1주차 회고

by haeyoon 2025. 10. 20.

우아한테크코스 8기 프리코스에 대한 회고를 매주 올릴 예정이다!
각 회고에서는 어떤 부분에 집중했는지, 그리고 어떤 키워드를 학습했는지를 중심으로 정리할 계획이다.

 

1주차 미션은 [문자열 덧셈 계산기]였다.

PR 링크: https://github.com/woowacourse-precourse/java-calculator-8/pull/167

 

먼저 본격적인 구현 전 간단히 메모로 구조를 잡아보자.

// \n 사이에 오는 구분자 → 구분자 리스트에 추가

도메인 구조: mvc 패턴 따라서

- controller
- model
    input으로 받은 문자열을 숫자 리스트 형태로 가공 → calculate
    - numbers
    - calculate
- view
    - input 문자열 입력하는 메서드
    - output 결과: 보여주는 메서드

 

구조를 잡은 뒤, 1차적으로 전반적인 코드를 작동만 하게 구현 후, 수차례의 리팩토링을 통해 코드를 다듬었다.


1. 학습 키워드 📚

이번 미션에서 신경쓴 포인트들이다.

1) 정규표현식

이번 미션에서는 일정한 포맷을 요구하는 부분이 많아, 정규표현식을 도입했다. 이를 통해 입력값의 유효성 검사를 간결하게 처리하고, 코드의 일관성을 유지할 수 있도록 했다.

 

정규 표현식이 적용된 부분의 코드

// 커스텀 문자열 추출
private String extractCustomSeparator(String inputString) {
        Pattern pattern = Pattern.compile("^//(.+)\\\\n(.*)$");
        Matcher matcher = pattern.matcher(inputString);

        if (matcher.find()) {
            return matcher.group(1);
        }
        return null;
    }
// 입력 문자열에 구분자 외 문자가 포함되어있는지 검증하는 메서드
private static void validInput(String inputString, Separator separators) {
        String allowedCharsRegex = separators.stream() // 정수와 구분자들로 이루어진 정규식
            .map(Pattern::quote)
            .collect(Collectors.joining("|", "[0-9", "]"));

        String pattern = "^(" + allowedCharsRegex + ")+$";

        if (!inputString.matches(pattern)) {
            throw new IllegalArgumentException("입력 문자열에 구분자 외의 허용되지 않은 문자가 포함되어 있습니다.");
        }

    }

 

- .map(Pattern::quote)

정규식 메타문자를 안전하게 이스케이프(escape) 시켜주는 역할
-> Pattern.quote: 이 문자열을 정규식으로 해석하지 말고 문자 그대로 인식해라!

Pattern.quote("|") → "\\\\|"
Pattern.quote(".") → "\\\\."
Pattern.quote("*") → "\\\\*"

 

String.split()은 내부적으로 정규식을 인자로 받기 때문에, "|", ".", "*" 같은 기호를 구분자로 쓰면 오작동합니다.

-> map(Pattern::quote)를 통해 각 구분자를 정규식 안전한 형태로 바꿔줌

 

문자열에서 커스텀 구분자 추출하는 메서드 리팩토링 - 정규식 적용 전/후

1. 기존 코드

private String extractCustomSeparator(String inputString) {
        List<String> customSeparatorList = new ArrayList<>();

        for (int i = 2; i < inputString.length(); i++) { // '//' 이후의 String 전체 순회
        	// \n이 있는 부분이 나오면 break
            if (inputString.charAt(i) == '\\\\' && i + 1 < inputString.length() && inputString.charAt(i + 1) == 'n') {
                break;
            }
            // 커스텀 문자열 부분의 문자는 customSeparatorList에 add
            customSeparatorList.add(String.valueOf(inputString.charAt(i)));
        }

        return String.join("", customSeparatorList); // 리스트 -> 문자열로 변환
    }

 

2. 적용 후

훨씬 코드가 간결하고 보기 쉬워진 보습을 볼 수 있다.

private String extractCustomSeparator(String inputString) {
        Pattern pattern = Pattern.compile("^//(.+)\\\\\\\\n(.*)$"); // 정규표현식 생성
        Matcher matcher = pattern.matcher(inputString); // 입력 문자열이 해당 정규식 형태를 따르는지 검증

        if (matcher.find()) { // 만족하면
            String customSeparator = matcher.group(1); // 구분자 추출
            separators.add(customSeparator);

            return customSeparator;
        }
        return null;
    }

 

 

추가적인 정규표현식 문법

- 문자열 전체가 허용된 문자(allowedCharsRegex)로만 이루어져 있는지를 검사

String pattern = "^(" + allowedCharsRegex + ")+$";
^ 문자열의 시작 문자열의 맨 앞부터 검사
( + allowedCharsRegex + ) 허용된 문자 그룹 괄호 안의 패턴을 하나의 단위로 묶음
+ 1개 이상 반복 괄호 안의 문자가 하나 이상 나와야 한다
$ 문자열의 끝 문자열의 맨 끝까지 검사

 

2) 원시값 포장

원시값 포장이란 기본 데이터 타입을  래퍼클래스객체로 바꾸는 방법이다. 

List<String> numbers = List.of("1", "2", "3"); // 원시타입으로 표현

NumberList numbers = new NumberList(List.of("1", "2", "3")); // 원시값 포장

 

원시값 포장을 하면 클래스 내부에서 유효성 검증을 한 번만 수행해, 모든 사용 지점에서 안전하게 보장할 수 있다는 장점이 있다.

 

원시값 포장이 필요한 경우

- 비즈니스 규칙이나 특별한 의미를 가지는 값

 

원시값 포장을 남용한다면?

- 불필요한 객체 많아짐 → 관리 포인트 늘어남
- 코드 가독성이 떨어짐

원시값 포장 적용

이번 미션에서 문자열 중 구분자 리스트를 포장하는 Separator 클래스

양수를 포장하는 NumberList 클래스를 구현했다.

-> 이를 통해 원시값을 객체로 분리하여, 책임을 명확히 하고 코드의 가독성과 유지보수성을 높였다.

public class NumberList {

    private final List<Integer> numbers;

    public NumberList(List<Integer> numbers) {
        validateNumberPositive(numbers);
        this.numbers = numbers;
    }

    private void validateNumberPositive(List<Integer> numbers) {
        ...
    }

    ...
}
public class Separator {

    private List<String> separators = new ArrayList<>(List.of(",", ":"));

    public void addCustomSeparator(String inputString) {
        String customSeparator = extractCustomSeparator(inputString);
        ...
    }

    private String extractCustomSeparator(String inputString) {
        ...
    }
    ...
}

3) README 작성

마지막으로 리드미 작성이다.

README를 작성하는 일이 익숙하지 않아, 먼저 “왜 README를 작성해야 하는가?”, “작성하면 어떤 장점이 있을까?” 를 고민해보았다.

- 프로젝트의 설명서를 직접 작성해 봄으로써 구현할 기능의 전체적인 틀을 잡을 수 있기 때문

- 자신의 코드를 볼 사람들의 이해를 돕기 위함

 

이 중에서도 두 번째 이유가 가장 크다고 생각했다.
그렇다면 README는 누구를 위한 문서인지  = 비개발자를 위한 문서인지, 개발자를 위한 문서인지에 따라 내용 구성에 차이가 있을 것이라 판단했다.

 

지금까지의 프로젝트들에서 내가 작성한 README는 프로젝트 소개, 기술 스택, 시스템 아키텍처 등을 중심으로 이루어져 있었고, 특히 프로젝트 소개에 많은 비중을 두었다. 그렇기에 이전의 README는 비개발자를 위한 문서에 더 가까웠다고 볼 수 있다.

 

하지만 이번 미션에서 내 코드를 읽을 사람들은 대부분 개발자이기 때문에, 이번에는 개발자 중심의 README를 작성하고자 했다.
이에 따라 프로젝트 소개와 게임 규칙뿐 아니라 코드 구조, 기술 스택, 예외 처리 방식 등을 함께 담아, 보다 기술적인 관점에서 이해할 수 있는 문서를 구성했다.


2. 고민 사항에 대한 코드리뷰 💻

PR에서 해당 고민사항에 대해, 다른 분들과 의견을 나눠보았다.

🙇‍♀️🙇‍♀️혹시 해당 질문에 대해 의견이 있으신 분은 댓글로 달아주셔도 감사하겠습니다🙇‍♀️🙇‍♀️

1) 3. 정규표현식 관련 가독성

처음에 코드를 구현할 때는 정규표현식을 사용하지 않고 문자열(inputString)을 끊어서 각각 확인했습니다.
if (inputString.charAt(i) == '\\' && i + 1 < inputString.length() && inputString.charAt(i + 1) == 'n') {...}​

하지만 이렇게 구현하게 되니 코드가 길어지고, 지저분해지는 것 같아, 아래와 같이 정규표현식으로 코드를 간소화했습니다.
Pattern pattern = Pattern.compile("^//(.+)\\\\n(.*)$"); Matcher matcher = pattern.matcher(inputString);​

코드가 짧아졌다는 장점은 있으나, 실제 프로젝트 등에서 정규표현식을 잘 사용하지 않으니(혹시 제가 잘못알고있는 것이라면 알려주세요!) 다른 사람들이 봤을 때 이러한 표현이 많아질수록 오히려 가독성을 해칠 수 있다는 우려가드네요.
이에 대해 리뷰어님의 의견이 궁금합니다!

답변: 

(추후 작성 예정)

2) 입력 문자열 관련 예외 처리의 세분화 부족

내 코드에서의 예외문은 두 가지이다.
- 입력 문자열에 구분자 외의 문자가 포함된 경우: "입력 문자열에 구분자 외의 허용되지 않은 문자가 포함되어 있습니다."
- 문자열에 숫자 0이 포함된 경우: "모든 숫자는 양수여야합니다."

현재 나의 코드에서는 대부분의 에러가 첫번째 에러메세지로 처리되고 있다.

[입력 요구사항] 문자열은 구분자와 양수로 구분되어있습니다.
- 0이 입력된 경우: 두번째 에러 메세지를 던진다.
- 음수가 입력된 경우 ([예시1] -1 [예시2] -1:-3 [예시3] //?\n-1?1)
   : 음수의 마이너스(-)를 부호로 인식하지 않고 문자 '-'와 숫자'1'로 각각 인식하여, 구분자가 아닌 문자 라고 인식 ->  첫번째 에러 메세지를 던진다

-> 하지만 음수가 입력되었을 때는 이를 문자가 아닌 부호로 인식하여 두번째 예외문("모든 숫자는 양수여야합니다.")을 던지는 것이 더 자연스럽다고 생각이 되는데, 지금의 코드를 유지해도 음수일때 항상 에러를 던지는 것은 맞아서요... 이 경우에는 두번째 예외를 던지는 경우로 수정을 하는 것이 더 자연스러운 흐름일까요? 의견이 궁금합니다!

답변: 

(추후 작성 예정)

 

3) 예외 케이스 부족

1번에서 말했듯 예외케이스가 부족한가 하는 생각이 들었다. 다양한 케이스들을 고민해보았지만 각 케이스들은 모두 예외를 던질 필요 없다고 판단되어 던지지 않았다.

[고민했지만 예외를 던지지 않은 케이스]

- 구분자만 입력된 경우(숫자는 문자열에 포함 되지 않은 경우):

   기능 요구사항의 제출 예시에 "" => 0가 있었는데, 이는 아무 숫자가 입력되지 않은 경우에는 합이 0이라고 판단한다고 이해했다. 따라서 기본 구분자, 또는 커스텀 구분자만 입력된 경우 결과값을 정상 출력할 수 있도록 했다.

- 커스텀 구분자가 숫자인 경우: //3\n과 같이 커스텀 구분자를 지정하는 부분에 숫자가 들어왔을 때, 숫자도 구분자로 사용되지 못할 이유가 없다 판단하여 정상 작동하도록 구현했다.

- 커스텀 구분자의 길이가 1 이상인 경우: 커스텀 구분자의 길이가 길어도, 정상 작동해야한다고 판단하였다.

- 커스텀 구분자가 기본 구분자와 같을 경우: 커스텀 구분자가 , 와 :인 경우, 커스텀 구분자를 따로 추가하지 않고 기본 구분자로 split하도록 구현했다.

답변: 

(추후 작성 예정)


3. 트러블 슈팅🔫 - 테스트 코드 실행 오류

전체 테스트를 돌릴 때 두번째 테스트가 통과하지 않았다.

 

하지만 Application으로 “-1,2,3”를 입력했을 때 에러를 던지는 것이 확인된다. 

 

뭐가 문제일까 🤯

 

테스트코드는 아래와 같았다

package calculator;

import camp.nextstep.edu.missionutils.test.NsTest;
import org.junit.jupiter.api.Test;

import static camp.nextstep.edu.missionutils.test.Assertions.assertSimpleTest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class ApplicationTest extends NsTest {
    @Test
    void 커스텀_구분자_사용() {
        assertSimpleTest(() -> {
            run("//;\\n1");
            assertThat(output()).contains("결과 : 1");
        });
    }

    @Test
    void 예외_테스트() {
        assertSimpleTest(() ->
            assertThatThrownBy(() -> runException("-1,2,3"))
                .isInstanceOf(IllegalArgumentException.class)
        );
    }

    @Override
    public void runMain() {
        Application.main(new String[]{});
    }
}

 

테스트코드가 Nstest를 의존받고있는데 해당 로직을 분석해보자!

NsTest의 입력 방식

NsTest 로직

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

→ System.in을 진짜 키보드 대신 ByteArrayInputStream (메모리 안에 있는 가짜 입력) 으로 교체한다!

 

run("1,2,3"); 를 작성하면 내부에서는

"1,2,3\n" 이 들어있는 ByteArrayInputStream 형태로 바뀐다

 

요구사항

Console API 라이브러리
- camp.nextstep.edu.missionutils에서 제공하는 Console API를 사용하여 구현해야 한다.
   - 사용자가 입력하는 값은 camp.nextstep.edu.missionutils.Console의 readLine()을 활용한다. 

 

이러한 요구사항을 간과하고 코드를 짰는데, Console 클래스를 보자.

- readLine 메서드

- 콘솔 입력 받아서 한줄 입력
- getInstance 메서드: Scanner(System.in)을 한 번만 생성해서 재사용성보장

- close 메서드

- Scanner를 닫는다

 

이로써 문제점을 찾았다.

문제점

(feat. Scanner와 Console의 차이, 그리고 테스트에서 Scanner가 터지는 이유)

 

1) Scanner에서의 입력

System.in은 운영체제가 제공하는 실제 콘솔 입력 스트림

Scanner sc = new Scanner(System.in);
String input = sc.nextLine(); // 사용자 입력 기다림

→ 프로그램이 실행될 때 사람이 직접 입력하고 엔터를 누르는 입력을 기다린다.

 

Scanner가 두 번째로 입력을 읽을때

Scanner 특징: 내부적으로 입력을 버퍼에 저장해두고nextLine()을 호출할 때마다 한 줄씩 꺼낸다

→ but 테스트 입력은 딱 한 줄만 존재하니까 한 번 읽은 뒤에는 버퍼가 비어있음

→ 그 상태에서 다시 nextLine() 하면 읽을 게 없으니까 예외(NoSuchElementException) 발생!!

String a = sc.nextLine(); // OK
String b = sc.nextLine(); // ❌ 더 이상 읽을 게 없음 -> 예외 발생

입력 스트림이 이미 끝났는데 또 읽으려 해서 에러 발생

 

2) 테스트 환경(NsTest)에서의 입력

NsTest는 자동 테스트를 위해 “입력”을 사람이 치는 게 아니라 가짜 입력을 미리 넣어둠

System.setIn(new ByteArrayInputStream("1,2,3\\n".getBytes()));

→ 메모리에 들어 있는 한 줄짜리 데이터("1,2,3\\n")로 바꿔치기

 

Console에서의 Scanner

public static String readLine() {
    return getInstance().nextLine();
}

private static Scanner getInstance() {
    if (scanner == null) { // 중요!
        scanner = new Scanner(System.in);
    }
    return scanner;
}

Console 내부에서도 Scanner를 쓰지만,

  • 한 번만 초기화하고
  • 테스트가 끝날 때(finally) 자동으로 Console.close()로 정리

→ 입력이 한 번만 정확히 읽히고, 테스트 입력 스트림(System.in)이 바뀌어도 안전하게 동작

 

3) 오류가 난 이유

= Console이 안전한 이유

public class Console {
    private static Scanner scanner;
	...
    public static void close() {
        if (scanner != null) {
            scanner.close();
            scanner = null; // 중요!!!!!!!!!
        }
    }
    ...
}

Console은 내부 Scanner를 닫고 버린 다음(scanner = null;) 다음번에 호출될 때 다시 새로 만드는 구조이기 때문

= 한 테스트가 끝나고 System.in이 닫혀도, 다음 테스트 시작 시 다시 getInstance()에서 새 Scanner를 만들어 쓰니까 EOF 상태❌

 

🔍 정리해보면

  Scanner 직접 사용 Console 사용
Scanner 생성 시점 직접 new Scanner(System.in) 처음 readLine() 호출 시 내부에서 생성
Scanner 닫을 때 sc.close()
→ System.in 닫힘
Console.close()
→ System.in 닫힘 + scanner = null
닫힌 후 재사용
이미 닫힌 System.in 계속 참조 → EOF
⭕️
다음 readLine() 시점에 new Scanner(System.in) 다시 생성 가능
테스트 연속 실행 시 2번째 테스트에서 EOF 발생 NsTest가 각 테스트마다 Console.close() 후 다시 만들어줌
→ 안전하게 초기화됨

 

해결

즉 요구사항에 나온 방식으로 입력 방식만 바꾸면 해결되는 문제였다.

😂 오늘의 결론: 요구사항을 지키자!

 

- 기존 scanner

Scanner scanner = new Scanner(System.in);
String input = scanner.nextLine();

 

- 변경후 console

String input = Console.readLine();