diff --git a/README.md b/README.md index bd90ef0247..5c7885f2da 100644 --- a/README.md +++ b/README.md @@ -1 +1,129 @@ -# java-calculator-precourse \ No newline at end of file +# java-calculator-precourse +# 문자열 덧셈 계산기 + +입력된 문자열을 구분자를 기준으로 분리하여 합계를 계산하는 프로그램입니다. +절차지향적으로 기능을 완성한 뒤, 객체지향 원칙에 따라 리팩토링하며 구조를 개선했습니다. + +--- + +## 미션 개요 + +- 우아한테크코스 프리코스 1주차 과제 +- 문자열 입력을 받아 구분자 기준으로 숫자를 분리하고, 합을 계산하는 프로그램 +- 기본 구분자( `,` , `:` )와 커스텀 구분자(`//;\n`) 모두 지원 +- 잘못된 입력(음수, 문자 등)에 대해 예외 발생 + +--- + +## 기능 요구사항 + +- 입력된 문자열에서 숫자를 추출하여 덧셈 결과를 반환한다. +- 기본 구분자(쉼표 `,` , 콜론 `:`)를 사용한다. +- 커스텀 구분자를 지정할 수 있다. (예: `//;\n1;2;3`) +- 잘못된 입력(문자, 음수, 공백 등)은 `IllegalArgumentException`을 발생시킨다. + +--- + +## 구현 단계별 정리 + +이 프로젝트는 **절차지향적으로 구현한 뒤**, +**리팩토링을 통해 객체지향적으로 개선한 과정**을 담고 있습니다. +핵심 로직을 세분화하고, 역할에 따라 클래스를 분리하며 점진적으로 구조를 발전시켰습니다. + +--- + +### 1단계: 핵심 로직 구현 (절차지향 → 기능 분리) + +- 문자열을 입력받아 구분자 기준으로 분리하고, 숫자를 더하는 기본 기능 구현 +- 커스텀 구분자(`//;\n`) 처리 및 음수 검증 등 비즈니스 규칙을 절차적으로 구현 +- 기능 단위로 메서드를 분리하여 테스트 용이성 확보 + +--- + +### 2단계: 구조화 및 객체지향 리팩토링 + +Application / InputHandler: 사용자 I/O 처리 및 모든 객체를 조립하여 계산 흐름을 실행합니다. (조립 및 I/O 분리) + +Calculator (I): calculate(String input) 메서드로 계산 행위의 규약을 정의합니다. (최상위 규약) + +ValidationStrategy (I): 검증 로직의 규약을 정의하며, StringCalculator가 검증 방식을 유연하게 바꿀 수 있게 합니다. (전략 패턴 인터페이스) + +StringCalculator: 모든 구성 요소를 주입받아 순차적으로 호출하며 계산 흐름을 총괄하고, 오직 숫자 합산 책임만 가집니다. (지휘자/서비스) + +CollectAllValidator: ValidationStrategy를 구현하여, 모든 음수를 수집하고 한 번에 예외를 던지는 복잡한 검증 전략을 수행합니다. (검증 전략 구현체) + +DefaultDelimiterParser: 입력 문자열을 분석하여 **구분자 정보(DelimiterInfo)**와 숫자 문자열을 추출합니다. (\r 문자 정규화 처리 포함) (분석 전문가) + +DelimiterInfo (DTO): 구분자 정규식 및 숫자 문자열을 담는 데이터 전달 객체입니다. (데이터 홀더) + +NumberParser: 개별 토큰을 BigInteger로 변환하고 NumberFormatException을 처리합니다. (변환 전문가)의 + +--- +## 예외 상황 + +- 문자가 포함된 입력값 (예: `"a,2,3"`, `"1b:4"`) +- 음수 입력값 (예: `"-1,2,3"`) +- 구분자가 연속된 경우 (예: `"1,,2"`) +- 소수 입력 (예: `"1.5,2"`) +- 빈 문자열 또는 공백 입력 (예: `""`, `" "`) +- `null` 또는 입력이 존재하지 않는 경우 +- 숫자만 단독 입력된 경우 + +--- + +## 미션 진행 방향 + +이 미션은 **절차지향적으로 작동하는 코드를 먼저 작성한 뒤**, +객체지향 원칙(OOP)에 따라 **역할과 책임 중심으로 구조를 개선**하는 것을 목표로 합니다. + +1. 절차지향적으로 기능을 완성한다. +2. 역할과 책임을 분리하여 구조를 개선한다. +3. 테스트 가능한 구조로 리팩토링한다. + +--- + +## 커밋 컨벤션 + +AngularJS Commit Message 규칙을 참고했습니다. +커밋은 **기능 단위**로 나누어 작성합니다. (예: 기본 구분자 처리, 커스텀 구분자 추가, 음수 예외 처리 등) + +**Allowed ``** + +- feat: 새로운 기능 추가 +- fix: 버그 수정 +- docs: 문서 수정 +- style: 코드 포맷팅 +- refactor: 코드 리팩토링 +- test: 테스트 코드 추가 +- chore: 빌드, 설정 등 유지보수 작업 + +> 콜론(`:`) 뒤에는 반드시 공백 한 칸을 둡니다. + +--- + +## 어떤 점에 집중했는가 + +- 절차지향적 설계로 기본 동작 완성 능력 향상 +- 예외 처리와 입력 검증을 체계적으로 구현 +- 객체지향적 리팩토링을 통한 책임 분리 연습 +- 클린 코드 원칙을 적용하고 유지보수성을 고려한 설계 + +--- + +## 요약 + +이 프로젝트는 문자열 계산기를 절차지향적으로 구현한 뒤, +객체지향 설계 원칙에 따라 구조를 개선하며 **깨끗하고 확장 가능한 코드**로 발전시키는 과정을 담고 있습니다. + +--- +과제 소감 및 회고 + +이번 과제를 진행하며 처음에는 절차지향적으로 구현을 한 뒤, 점차 리팩토링을 통해 설계를 개선하는 과정을 보여주고 싶었습니다. 초기에는 차례대로 기능을 구현하는 데 집중했지만, 리드미에 추가된 새로운 모듈들이 등장하면서 예상치 못한 상황에 당황하기도 했습니다. 이를 통해 구현 과정에서 수정 가능성을 감안한 설계의 중요성을 깨달았습니다. + +특히 객체지향 설계 패턴을 적용하면서, StringCalculator가 모든 책임을 지는 구조에서 CollectAllValidator와 같은 전략 구현체로 책임을 위임하는 전략 패턴에 익숙해지는 데 시간이 필요했습니다. 이를 통해 인터페이스의 본질과 조립의 책임을 체감할 수 있었습니다. + +또한, 우테코 미션에서는 기능 구현만큼이나 요구사항에 명시된 출력 형식이 중요함을 배웠습니다. 최종 실패 원인이 논리적 오류가 아닌 띄어쓰기 불일치였다는 점에서 출력 형식의 중요성을 절실히 느꼈습니다. 이번 과제에서는 System.out.print()로 직접 확인하던 방식 대신 단위 테스트와 통합 테스트를 통해 안정성을 검증하며, 테스트 코드 작성의 중요성과 오류를 찾아가는 과정의 즐거움도 경험할 수 있었습니다. + +환경 독립적인 코드 작성의 중요성도 깨달았습니다. DefaultDelimiterParser에서 \r 문자를 제거하여 운영체제나 테스트 환경의 사소한 차이에도 코드가 흔들리지 않도록 입력을 정규화한 경험이 그 예입니다. + +마지막으로, 과제 수행 과정에서 겪은 고난과 역경은 코드 결과에는 그대로 드러나지 않았지만, 이를 커밋 메시지(feat, refactor, fix 등)로 기록하며 과정의 의미를 남기려 노력했습니다. 이번 경험을 통해 단순 구현을 넘어 설계, 테스트, 환경 고려, 기록까지 완성하는 과정을 배우게 되었으며, 다음 과제에서는 한층 더 발전된 나를 기대합니다. diff --git a/src/main/java/calculator/Application.java b/src/main/java/calculator/Application.java index 573580fb40..905173b38c 100644 --- a/src/main/java/calculator/Application.java +++ b/src/main/java/calculator/Application.java @@ -1,7 +1,32 @@ package calculator; +import java.math.BigInteger; + public class Application { + public static void main(String[] args) { - // TODO: 프로그램 구현 + InputHandler inputHandler = new InputHandler(); + String input = inputHandler.getUserInput(); + + try { + DelimiterParser delimiterParser = new DefaultDelimiterParser(); + NumberParser numberParser = new NumberParser(); + ValidationStrategy validationStrategy = new CollectAllValidator(); + + Calculator calculator = new StringCalculator(delimiterParser, numberParser, validationStrategy); + + BigInteger result = calculator.calculate(input); + + System.out.println("결과 : " + result); + + } catch (IllegalArgumentException e) { + System.out.println("오류: " + e.getMessage()); + throw e; // 예외를 다시 던져서 테스트에서 감지 가능하도록 처리 + } catch (Exception e) { + System.out.println("예상치 못한 오류가 발생했습니다."); + throw e; + } finally { + inputHandler.close(); + } } -} +} \ No newline at end of file diff --git a/src/main/java/calculator/Calculator.java b/src/main/java/calculator/Calculator.java new file mode 100644 index 0000000000..0cec60aeba --- /dev/null +++ b/src/main/java/calculator/Calculator.java @@ -0,0 +1,7 @@ +package calculator; +import java.math.BigInteger; + +public interface Calculator { + BigInteger calculate(String input); + +} diff --git a/src/main/java/calculator/CollectAllValidator.java b/src/main/java/calculator/CollectAllValidator.java new file mode 100644 index 0000000000..93f8910e79 --- /dev/null +++ b/src/main/java/calculator/CollectAllValidator.java @@ -0,0 +1,25 @@ +package calculator; + +import java.math.BigInteger; +import java.util.List; + +public class CollectAllValidator implements ValidationStrategy { + + @Override + public void validate(List numbers) { + StringBuilder negativeNumbers = new StringBuilder(); + + for (BigInteger number : numbers) { + if (number.compareTo(BigInteger.ZERO) < 0) { + if (negativeNumbers.length() > 0) { + negativeNumbers.append(", "); + } + negativeNumbers.append(number); + } + } + + if (negativeNumbers.length() > 0) { + throw new IllegalArgumentException("음수는 입력이 불가능합니다 :" + negativeNumbers.toString()); } + } + +} \ No newline at end of file diff --git a/src/main/java/calculator/DefaultDelimiterParser.java b/src/main/java/calculator/DefaultDelimiterParser.java new file mode 100644 index 0000000000..743a4df5a5 --- /dev/null +++ b/src/main/java/calculator/DefaultDelimiterParser.java @@ -0,0 +1,34 @@ +package calculator; + +import java.util.regex.Pattern; + +public class DefaultDelimiterParser implements DelimiterParser { + + private static final String BASIC_DELIMITER_REGEX = "[,:]"; + + @Override + public DelimiterInfo parse(String input) { + // 테스트 입력의 "\\n" 문자열을 실제 개행 문자 '\n'으로 변환 + String sanitizedInput = input.replace("\r", "").replace("\\n", "\n"); + + String finalDelimiterRegex = BASIC_DELIMITER_REGEX; + String numbersToSplit; + + if (sanitizedInput.startsWith("//")) { + int delimiterEndIndex = sanitizedInput.indexOf("\n"); + + if (delimiterEndIndex == -1) { + throw new IllegalArgumentException("커스텀 구분자 선언 후 반드시 줄 바꿈(\\n)을 해야합니다."); + } + + String customDelimiter = sanitizedInput.substring(2, delimiterEndIndex); + finalDelimiterRegex += "|" + Pattern.quote(customDelimiter); + + numbersToSplit = sanitizedInput.substring(delimiterEndIndex + 1); + } else { + numbersToSplit = sanitizedInput; + } + + return new DelimiterInfo(finalDelimiterRegex, numbersToSplit); + } +} diff --git a/src/main/java/calculator/DelimiterInfo.java b/src/main/java/calculator/DelimiterInfo.java new file mode 100644 index 0000000000..2ff2f02afa --- /dev/null +++ b/src/main/java/calculator/DelimiterInfo.java @@ -0,0 +1,21 @@ +package calculator; + + +public class DelimiterInfo { + + final private String delimiterRegex; + final private String numbersString; + + public DelimiterInfo(String delimiterRegex, String numbersString) { + this.delimiterRegex = delimiterRegex; + this.numbersString = numbersString; + } + + public String getDelimiterRegex() { + return delimiterRegex; + } + + public String getNumbersString() { + return numbersString; + } +} diff --git a/src/main/java/calculator/DelimiterParser.java b/src/main/java/calculator/DelimiterParser.java new file mode 100644 index 0000000000..3439096e93 --- /dev/null +++ b/src/main/java/calculator/DelimiterParser.java @@ -0,0 +1,8 @@ +package calculator; + +public interface DelimiterParser { + + DelimiterInfo parse(String input); + +} + diff --git a/src/main/java/calculator/InputHandler.java b/src/main/java/calculator/InputHandler.java new file mode 100644 index 0000000000..6e0c6a29e6 --- /dev/null +++ b/src/main/java/calculator/InputHandler.java @@ -0,0 +1,19 @@ +package calculator; + +import camp.nextstep.edu.missionutils.Console; + +public class InputHandler { + + private static final String INPUT_MESSAGE = "덧셈할 문자열을 입력해 주세요."; + + + public String getUserInput() { + System.out.println(INPUT_MESSAGE); + return Console.readLine(); + } + + + public void close() { + Console.close(); + } +} \ No newline at end of file diff --git a/src/main/java/calculator/NumberParser.java b/src/main/java/calculator/NumberParser.java new file mode 100644 index 0000000000..b76b28d0a2 --- /dev/null +++ b/src/main/java/calculator/NumberParser.java @@ -0,0 +1,20 @@ +package calculator; + +import java.math.BigInteger; + +public class NumberParser { + + public BigInteger parse(String token) { + String trimmedToken = token.trim(); + + if (trimmedToken.isEmpty()) { + return BigInteger.ZERO; + } + + try { + return new BigInteger(trimmedToken); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("숫자 이외의 문자열이 포함되어 변환할 수 없습니다: " + trimmedToken); + } + } +} diff --git a/src/main/java/calculator/StringCalculator.java b/src/main/java/calculator/StringCalculator.java new file mode 100644 index 0000000000..22e48aed07 --- /dev/null +++ b/src/main/java/calculator/StringCalculator.java @@ -0,0 +1,50 @@ +package calculator; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; + +public class StringCalculator implements Calculator { + + private final DelimiterParser delimiterParser; + private final NumberParser numberParser; + private final ValidationStrategy validationStrategy; // ⭐️ ValidationStrategy 필드 추가 + + public StringCalculator(DelimiterParser delimiterParser, NumberParser numberParser, ValidationStrategy validationStrategy) { + this.delimiterParser = delimiterParser; + this.numberParser = numberParser; + this.validationStrategy = validationStrategy; // ⭐️ Strategy 주입 + } + + @Override + public BigInteger calculate(String input) { + if (input == null || input.trim().isEmpty()) { + return BigInteger.ZERO; + } + + DelimiterInfo info = delimiterParser.parse(input); + + String[] parts = info.getNumbersString().split(info.getDelimiterRegex()); + + return sum(parts); + } + + private BigInteger sum(String[] parts) { + + List numbers = new ArrayList<>(); + for (String token : parts) { + numbers.add(numberParser.parse(token)); + } + + + validationStrategy.validate(numbers); + + BigInteger sum = BigInteger.ZERO; + for (BigInteger number : numbers) { + // 검증이 완료된 숫자만 합산합니다. + sum = sum.add(number); + } + + return sum; + } +} \ No newline at end of file diff --git a/src/main/java/calculator/ValidationStrategy.java b/src/main/java/calculator/ValidationStrategy.java new file mode 100644 index 0000000000..6543faa513 --- /dev/null +++ b/src/main/java/calculator/ValidationStrategy.java @@ -0,0 +1,9 @@ +package calculator; + +import java.math.BigInteger; +import java.util.List; + +public interface ValidationStrategy { + + void validate(List number); +} diff --git a/src/test/java/calculator/StringCalculatorTest.java b/src/test/java/calculator/StringCalculatorTest.java new file mode 100644 index 0000000000..e56d405772 --- /dev/null +++ b/src/test/java/calculator/StringCalculatorTest.java @@ -0,0 +1,39 @@ +package calculator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; // 예외 테스트를 위해 추가 필요 + +import java.math.BigInteger; // BigInteger 사용을 위해 추가 +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class StringCalculatorTest { + + private StringCalculator calculator; + + @BeforeEach + void setUp() { + DelimiterParser delimiterParser = new DefaultDelimiterParser(); + NumberParser numberParser = new NumberParser(); + + // ⭐️ 수정: ValidationStrategy 인터페이스 대신 구현체인 CollectAllValidator를 인스턴스화합니다. + ValidationStrategy validationStrategy = new CollectAllValidator(); + + // StringCalculator의 생성자는 이제 ValidationStrategy를 받습니다. + calculator = new StringCalculator(delimiterParser, numberParser, validationStrategy); + } + + @Test + @DisplayName("5. 커스텀 구분자를 사용하여 합계를 계산한다.") + void calculate_custom_delimiter() { + + + assertThat(calculator.calculate("//;\n1;2;3")).isEqualTo(BigInteger.valueOf(6)); + + assertThat(calculator.calculate("//#\n10#20#5")).isEqualTo(BigInteger.valueOf(35)); + + + assertThat(calculator.calculate("//!\n1!2,3")).isEqualTo(BigInteger.valueOf(6)); + } +} \ No newline at end of file