diff --git a/README.md b/README.md index bd90ef0247..940eec1e12 100644 --- a/README.md +++ b/README.md @@ -1 +1,128 @@ -# java-calculator-precourse \ No newline at end of file +# java-calculator-precourse + +# 문자열 덧셈 계산기 + +## 과제 개요 +입력된 문자열에서 숫자를 추출하고, 지정된 구분자를 기준으로 분리해 합산하는 프로그램이다. +기본 구분자는 `,`(쉼표)와 `:`(콜론)이며, 사용자가 `//`와 `\n` 사이에 커스텀 구분자를 지정할 수도 있다. +잘못된 입력이 들어올 경우 `IllegalArgumentException`을 발생시킨다. + +--- + +## 실행 예시 +``` +1,2:3 +결과 : 6 + +//;\n1;2;3 +결과 : 6 +``` +--- + +## 개발 과정 및 사고의 흐름 + +### 1. 요구사항 분석 +문제의 핵심은 단순한 문자열 덧셈이 아니라 +**“문자열을 숫자로 해석하는 과정의 유효성”**을 명확히 정의하는 것이라고 판단했다. + +이에 따라 다음과 같은 질문으로 문제를 해석했다. + +- 문자열의 어디까지가 유효한 숫자일까? +- 구분자가 문장 앞뒤에 있어도 괜찮을까? +- 잘못된 입력은 어느 시점에서 검증해야 할까? + +이 과정을 통해 ‘구분자는 반드시 숫자 사이에 존재해야 한다’는 규칙을 직접 정의했다. +이는 명세에는 없었지만, 계산기의 일관된 동작을 위해 필요한 제약이라고 판단했다. + +--- + +### 2. 설계 방향 +기능을 단일 흐름으로 구현하지 않고, +책임을 역할 단위로 분리하여 유지보수성과 테스트 용이성을 확보했다. + +| 클래스 | 역할 | 설계 근거 | +|--------|------|------------| +| `Application` | 콘솔 입출력 관리 | 입력과 출력만 담당하도록 최소 책임 부여 | +| `StringCalculator` | 전체 로직 제어 | Parser → Validator → Extractor 순으로 흐름 고정 | +| `DelimiterParser` | 구분자 파싱 | 커스텀 구분자 인식 및 기본 구분자 병합 | +| `InputValidator` | 입력값 검증 | 음수, 공백, 연속 구분자, 시작/끝 구분자 검증 | +| `NumberExtractor` | 숫자 추출 및 변환 | 문자열을 숫자 리스트로 변환, 잘못된 문자 검출 | + +단일 책임 원칙(SRP)을 따르며, 각 클래스는 변경 이유가 하나만 있도록 설계했다. + +--- + +### 3. TDD 접근 방식 +기능을 한 번에 구현하지 않고, +테스트 케이스 작성 → 최소한의 구현 → 리팩토링 순으로 진행했다. + +#### 주요 테스트 시나리오 +- 빈 문자열 → 0 +- 기본 구분자(`,`, `:`)로 분리 → 합계 계산 +- 커스텀 구분자(`//;\n1;2;3`) → 합계 계산 +- 연속 구분자 → 예외 발생 +- 공백 포함 → 예외 발생 +- 시작 또는 끝이 구분자인 경우 → 예외 발생 +- 잘못된 커스텀 구분자 정의 → 예외 발생 + +예외 케이스를 먼저 정의함으로써 +“유효한 입력의 범위”를 스스로 정립하고 테스트를 통해 보장했다. + +--- + +### 4. 설계 결정 근거 + +| 항목 | 고민의 흐름 | 최종 결정 | +|------|-------------|-----------| +| 커스텀 구분자 범위 | 여러 문자 허용 시 파싱 모호성 증가 | 한 글자만 허용 | +| 구분자 위치 | 구분자는 숫자 사이에 존재해야 의미 있음 | 문자열 시작·끝 위치 불허 | +| 음수 입력 | 일반 계산기·금융 도메인 모두에서 음수는 예외로 처리 | 예외 발생 | +| 공백 처리 | 입력 형식의 명확성 유지 | 공백 포함 시 예외 발생 | +| 빈 문자열 | 입력이 없으면 0 반환이 직관적 | 0 반환 | + +--- + +### 5. 커밋 단위 기준 +커밋은 단순 변경이 아닌 “의도 단위”로 구분했다. +각 커밋 메시지만으로 어떤 문제를 해결하려 했는지 파악할 수 있도록 작성했다. + +| 커밋 메시지 | 의도 | +|--------------|------| +| `feat(DelimiterParser): 커스텀 구분자 파싱 기능 및 단위 테스트 추가` | 입력 파싱 로직 분리 | +| `feat(NumberExtractor): 문자열 숫자 추출 기능 구현 및 단위 테스트 추가` | 숫자 추출 및 예외 검증 | +| `feat(InputValidator): 입력값 검증 로직 추가` | 입력 규칙 검증 분리 | +| `refactor: 구분자 리스트 전달 방식으로 내부 흐름 정리` | 호출 구조 개선 | +| `test: 전체 테스트 코드 정리 및 예외 규칙 검증 추가` | 전체 시나리오 점검 | + +--- + +### 6. 리팩토링 포인트 +- 입력 검증 로직을 별도의 클래스(`InputValidator`)로 분리해 테스트 독립성 확보 +- `StringCalculator` 내부 흐름을 `Parser → Validator → Extractor` 순서로 고정 +- 단위 테스트(`DelimiterParserTest`, `NumberExtractorTest`)와 통합 테스트(`ApplicationTest`)를 분리하여 책임 구분 + +--- + +### 7. 테스트 구조 요약 + +| 테스트 파일 | 역할 | +|--------------|------| +| `ApplicationTest` | 전체 실행 흐름 검증 | +| `DelimiterParserTest` | 커스텀 구분자 파싱 테스트 | +| `NumberExtractorTest` | 숫자 추출 및 예외 상황 테스트 | + + +--- + +### 8. 회고 +이 과제를 통해 “구현보다 설계가 먼저”라는 점을 체감했다. +기능을 분리하고 예외를 정의하는 과정에서 단순히 동작하는 코드를 넘어서, +**왜 이렇게 만들어야 하는가**를 스스로 설명할 수 있게 되었다. + +--- + +## 실행 방법 +``` +./gradlew clean test # 전체 테스트 실행 +./gradlew run # 프로그램 실행 +``` diff --git a/src/main/java/calculator/Application.java b/src/main/java/calculator/Application.java index 573580fb40..aeb434246c 100644 --- a/src/main/java/calculator/Application.java +++ b/src/main/java/calculator/Application.java @@ -1,7 +1,27 @@ package calculator; +import camp.nextstep.edu.missionutils.Console; + public class Application { public static void main(String[] args) { // TODO: 프로그램 구현 + String input; + + try { + input = Console.readLine(); + } catch (Exception e) { + System.out.println("결과 : 0"); + return; + } + + if (input == null || input.isEmpty()) { + System.out.println("결과 : 0"); + return; + } + + StringCalculator calculator = new StringCalculator(); + + int result = calculator.add(input); + System.out.println("결과 : " + result); } } diff --git a/src/main/java/calculator/DelimiterParser.java b/src/main/java/calculator/DelimiterParser.java new file mode 100644 index 0000000000..bf4ff466c8 --- /dev/null +++ b/src/main/java/calculator/DelimiterParser.java @@ -0,0 +1,38 @@ +package calculator; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class DelimiterParser { + private static final List DEFAULT_DELIMITERS = List.of(",", ":"); + + public Map.Entry, String> parse(String input) { + if (input == null || input.isEmpty()) { + return Map.entry(DEFAULT_DELIMITERS, ""); + } + + input = input.replace("\\n", "\n"); + + if (input.startsWith("//")) { + int endIndex = input.indexOf("\n"); + if (endIndex == -1) { + throw new IllegalArgumentException("잘못된 커스텀 구분자 형식입니다."); + } + + String customDelimiter = input.substring(2, endIndex); + + if (customDelimiter.length() != 1) { + throw new IllegalArgumentException("커스텀 구분자는 한 글자여야 합니다."); + } + + List delimiters = new ArrayList<>(DEFAULT_DELIMITERS); + delimiters.add(customDelimiter); + + String numbers = input.substring(endIndex + 1); + return Map.entry(delimiters, numbers); + } + + return Map.entry(DEFAULT_DELIMITERS, input); + } +} diff --git a/src/main/java/calculator/InputValidator.java b/src/main/java/calculator/InputValidator.java new file mode 100644 index 0000000000..b355747813 --- /dev/null +++ b/src/main/java/calculator/InputValidator.java @@ -0,0 +1,45 @@ +package calculator; + +import java.util.List; + +public class InputValidator { + + public static void validate(String input, List delimiters) { + // 입력이 null이면 예외 (실행 자체가 잘못된 호출) + if (input == null) { + throw new IllegalArgumentException("입력이 null일 수 없습니다."); + } + + // 빈 문자열은 계산기에서 0 반환 + if (input.isEmpty()) { + return; + } + + if (input.contains(" ")) { + throw new IllegalArgumentException("공백은 허용되지 않습니다."); + } + + if (input.matches(".*-\\d+.*")) { + throw new IllegalArgumentException("음수는 허용되지 않습니다."); + } + + if (input.matches("^[^0-9]*$")) { + throw new IllegalArgumentException("숫자가 하나 이상 포함되어야 합니다."); + } + + char first = input.charAt(0); + char last = input.charAt(input.length() - 1); + if (!Character.isDigit(first)) { + throw new IllegalArgumentException("입력은 숫자로 시작해야 합니다."); + } + if (!Character.isDigit(last)) { + throw new IllegalArgumentException("입력은 숫자로 끝나야 합니다."); + } + + for (String d : delimiters) { + if (input.contains(d + d)) { + throw new IllegalArgumentException("구분자가 연속으로 사용되었습니다."); + } + } + } +} diff --git a/src/main/java/calculator/NumberExtractor.java b/src/main/java/calculator/NumberExtractor.java new file mode 100644 index 0000000000..734f656bf0 --- /dev/null +++ b/src/main/java/calculator/NumberExtractor.java @@ -0,0 +1,59 @@ +package calculator; + +import java.util.ArrayList; +import java.util.List; + +public class NumberExtractor { + public List splitToNumbers(String input, List delimiters) { + List numbers = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean previousIsDelimiter = false; + + input = input.replace("\\n", "\n"); + + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + + if (Character.isWhitespace(c)) { + throw new IllegalArgumentException("공백은 허용되지 않습니다."); + } + + if (isDelimiter(c, delimiters)) { + if (previousIsDelimiter && i != 0) { + throw new IllegalArgumentException("구분자가 연속으로 사용되었습니다."); + } + + addCurrentNumber(numbers, current); + current.setLength(0); + previousIsDelimiter = true; + continue; + } + + if (Character.isDigit(c)) { + current.append(c); + previousIsDelimiter = false; + continue; + } + + throw new IllegalArgumentException("유효하지 않은 문자가 포함되었습니다: " + c); + } + + addCurrentNumber(numbers, current); + return numbers; + } + + private boolean isDelimiter(char c, List delimiters) { + for (String d : delimiters) { + if (d.charAt(0) == c) { + return true; + } + } + return false; + } + + private void addCurrentNumber(List numbers, StringBuilder current) { + if (!current.isEmpty()) { + numbers.add(Integer.parseInt(current.toString())); + } + } +} diff --git a/src/main/java/calculator/StringCalculator.java b/src/main/java/calculator/StringCalculator.java new file mode 100644 index 0000000000..8ecc461f55 --- /dev/null +++ b/src/main/java/calculator/StringCalculator.java @@ -0,0 +1,22 @@ +package calculator; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +public class StringCalculator { + private static final List DEFAULT_DELIMITERS = Arrays.asList(",", ":"); + + public int add(String input) { + DelimiterParser parser = new DelimiterParser(); + Map.Entry, String> parsed = parser.parse(input); + + List delimiters = parsed.getKey(); + String numbersPart = parsed.getValue(); + + InputValidator.validate(numbersPart, delimiters); + + List numbers = new NumberExtractor().splitToNumbers(numbersPart, delimiters); + return numbers.stream().mapToInt(Integer::intValue).sum(); + } +} \ No newline at end of file diff --git a/src/test/java/calculator/ApplicationTest.java b/src/test/java/calculator/ApplicationTest.java index 93771fb011..0feb41a608 100644 --- a/src/test/java/calculator/ApplicationTest.java +++ b/src/test/java/calculator/ApplicationTest.java @@ -1,12 +1,12 @@ 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; +import camp.nextstep.edu.missionutils.test.NsTest; +import org.junit.jupiter.api.Test; + class ApplicationTest extends NsTest { @Test void 커스텀_구분자_사용() { @@ -16,14 +16,134 @@ class ApplicationTest extends NsTest { }); } + @Test + void 빈_문자열() { + assertSimpleTest(() -> { + run(""); + assertThat(output()).contains("결과 : 0"); + }); + } + + @Test + void 숫자_한개() { + assertSimpleTest(() -> { + run("1"); + assertThat(output()).contains("결과 : 1"); + }); + } + + @Test + void 쉼표_기본구분자() { + assertSimpleTest(() -> { + run("1,2"); + assertThat(output()).contains("결과 : 3"); + }); + } + + @Test + void 쉼표_콜론_혼합() { + assertSimpleTest(() -> { + run("1,2:3"); + assertThat(output()).contains("결과 : 6"); + }); + } + @Test void 예외_테스트() { assertSimpleTest(() -> - assertThatThrownBy(() -> runException("-1,2,3")) - .isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> runException("-1,2,3")) + .isInstanceOf(IllegalArgumentException.class) + ); + } + + @Test + void 연속_쉼표() { + assertSimpleTest(() -> + assertThatThrownBy(() -> runException("1,,2")) + .isInstanceOf(IllegalArgumentException.class) + ); + } + + @Test + void 연속_콜론() { + assertSimpleTest(() -> + assertThatThrownBy(() -> runException("1::2")) + .isInstanceOf(IllegalArgumentException.class) + ); + } + + @Test + void 공백_포함() { + assertSimpleTest(() -> + assertThatThrownBy(() -> runException("1, 2")) + .isInstanceOf(IllegalArgumentException.class) + ); + } + + @Test + void 숫자_아닌_문자() { + assertSimpleTest(() -> + assertThatThrownBy(() -> runException("1,a,3")) + .isInstanceOf(IllegalArgumentException.class) + ); + } + + @Test + void 커스텀_구분자_없음() { + assertSimpleTest(() -> + assertThatThrownBy(() -> runException("//\n1,2")) + .isInstanceOf(IllegalArgumentException.class) + ); + } + + @Test + void 커스텀_구분자_2글자() { + assertSimpleTest(() -> + assertThatThrownBy(() -> runException("//;;\n1;2")) + .isInstanceOf(IllegalArgumentException.class) + ); + } + + @Test + void 커스텀_구분자_위치_오류() { + assertSimpleTest(() -> + assertThatThrownBy(() -> runException("1;2//;\n3")) + .isInstanceOf(IllegalArgumentException.class) ); } + @Test + void 구분자_시작_또는_끝_위치_오류() { + assertSimpleTest(() -> + assertThatThrownBy(() -> runException(",1,2")) + .isInstanceOf(IllegalArgumentException.class) + ); + + assertSimpleTest(() -> + assertThatThrownBy(() -> runException("1,2,")) + .isInstanceOf(IllegalArgumentException.class) + ); + + assertSimpleTest(() -> + assertThatThrownBy(() -> runException("//;\n;1;2")) + .isInstanceOf(IllegalArgumentException.class) + ); + + assertSimpleTest(() -> + assertThatThrownBy(() -> runException("//;\n1;2;")) + .isInstanceOf(IllegalArgumentException.class) + ); + } + + @Test + void 숫자_없음() { + assertSimpleTest(() -> + assertThatThrownBy(() -> runException(",:")) + .isInstanceOf(IllegalArgumentException.class) + ); + } + + @Override public void runMain() { Application.main(new String[]{}); diff --git a/src/test/java/calculator/DelimiterParserTest.java b/src/test/java/calculator/DelimiterParserTest.java new file mode 100644 index 0000000000..16905b93b2 --- /dev/null +++ b/src/test/java/calculator/DelimiterParserTest.java @@ -0,0 +1,48 @@ +package calculator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class DelimiterParserTest { + + @Test + @DisplayName("커스텀 구분자가 없을 경우 기본 구분자(, :)만 반환한다") + void defaultDelimitersOnly() { + DelimiterParser parser = new DelimiterParser(); + Map.Entry, String> result = parser.parse("1,2:3"); + + assertThat(result.getKey()).containsExactly(",", ":"); + assertThat(result.getValue()).isEqualTo("1,2:3"); + } + + @Test + @DisplayName("커스텀 구분자를 포함하면 해당 구분자를 추가로 반환한다") + void customDelimiterAdded() { + DelimiterParser parser = new DelimiterParser(); + Map.Entry, String> result = parser.parse("//;\n1;2;3"); + + assertThat(result.getKey()).containsExactly(",", ":", ";"); + assertThat(result.getValue()).isEqualTo("1;2;3"); + } + + @Test + @DisplayName("커스텀 구분자가 2글자 이상이면 예외 발생") + void invalidCustomDelimiterLength() { + DelimiterParser parser = new DelimiterParser(); + assertThatThrownBy(() -> parser.parse("//;;\n1;2")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("개행 문자 없이 //로만 시작하면 예외 발생") + void invalidCustomDelimiterFormat() { + DelimiterParser parser = new DelimiterParser(); + assertThatThrownBy(() -> parser.parse("//;1;2")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/calculator/NumberExtractorTest.java b/src/test/java/calculator/NumberExtractorTest.java new file mode 100644 index 0000000000..e75f209bc7 --- /dev/null +++ b/src/test/java/calculator/NumberExtractorTest.java @@ -0,0 +1,48 @@ +package calculator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class NumberExtractorTest { + + @Test + @DisplayName("쉼표와 콜론으로 구분된 숫자를 추출한다") + void splitByDefaultDelimiters() { + NumberExtractor extractor = new NumberExtractor(); + List result = extractor.splitToNumbers("1,2:3", Arrays.asList(",", ":")); + + assertThat(result).containsExactly(1, 2, 3); + } + + @Test + @DisplayName("연속 구분자는 예외를 발생시킨다") + void consecutiveDelimitersThrowsException() { + NumberExtractor extractor = new NumberExtractor(); + assertThatThrownBy(() -> + extractor.splitToNumbers("1,,2", Arrays.asList(",", ":")) + ).isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("공백이 포함된 입력은 예외를 발생시킨다") + void spaceThrowsException() { + NumberExtractor extractor = new NumberExtractor(); + assertThatThrownBy(() -> + extractor.splitToNumbers("1, 2", Arrays.asList(",", ":")) + ).isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("시작과 끝 구분자는 허용된다") + void startAndEndDelimiterAllowed() { + NumberExtractor extractor = new NumberExtractor(); + List result = extractor.splitToNumbers(",1,2,", Arrays.asList(",", ":")); + + assertThat(result).containsExactly(1, 2); + } +} \ No newline at end of file