Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 128 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,128 @@
# java-calculator-precourse
# 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 # 프로그램 실행
```
20 changes: 20 additions & 0 deletions src/main/java/calculator/Application.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
38 changes: 38 additions & 0 deletions src/main/java/calculator/DelimiterParser.java
Original file line number Diff line number Diff line change
@@ -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<String> DEFAULT_DELIMITERS = List.of(",", ":");

public Map.Entry<List<String>, 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<String> 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);
}
}
45 changes: 45 additions & 0 deletions src/main/java/calculator/InputValidator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package calculator;

import java.util.List;

public class InputValidator {

public static void validate(String input, List<String> 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("구분자가 연속으로 사용되었습니다.");
}
}
}
}
59 changes: 59 additions & 0 deletions src/main/java/calculator/NumberExtractor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package calculator;

import java.util.ArrayList;
import java.util.List;

public class NumberExtractor {
public List<Integer> splitToNumbers(String input, List<String> delimiters) {
List<Integer> 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<String> delimiters) {
for (String d : delimiters) {
if (d.charAt(0) == c) {
return true;
}
}
return false;
}

private void addCurrentNumber(List<Integer> numbers, StringBuilder current) {
if (!current.isEmpty()) {
numbers.add(Integer.parseInt(current.toString()));
}
}
}
22 changes: 22 additions & 0 deletions src/main/java/calculator/StringCalculator.java
Original file line number Diff line number Diff line change
@@ -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<String> DEFAULT_DELIMITERS = Arrays.asList(",", ":");

public int add(String input) {
DelimiterParser parser = new DelimiterParser();
Map.Entry<List<String>, String> parsed = parser.parse(input);

List<String> delimiters = parsed.getKey();
String numbersPart = parsed.getValue();

InputValidator.validate(numbersPart, delimiters);

List<Integer> numbers = new NumberExtractor().splitToNumbers(numbersPart, delimiters);
return numbers.stream().mapToInt(Integer::intValue).sum();
}
}
Loading