diff --git a/README.md b/README.md
index 8d7e8aee..45018dbb 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,47 @@
-# java-baseball-precourse
\ No newline at end of file
+# java-baseball-precourse
+
+1. 게임 시작 및 초기화
+- 애플리케이션 실행 시 숫자 야구 게임을 시작한다.
+- 컴퓨터의 정답 숫자 3자리를 생성한다.
+- 1~9 사이의 숫자만 사용한다.
+- 중복되지 않는 서로 다른 숫자 3개로 구성한다.
+- 3자리 수(문자열 또는 리스트 등)로 관리한다.
+
+---
+2. 사용자 입력 처리
+- 사용자로부터 3자리 숫자를 입력받는다.
+- 입력값 검증을 수행한다.
+- 길이가 3인지 확인한다.
+- 모두 숫자인지 확인한다.
+- 각 자리가 1~9인지 확인한다.
+- 중복 숫자가 없는지 확인한다.
+- 잘못된 입력이면 예외 처리 후 에러 메시지를 출력한다.
+- [ERROR]로 시작하는 메시지를 출력한다.
+- 에러 이후에도 게임은 종료되지 않고 다시 입력을 받는다.
+
+---
+3. 결과(힌트) 판정
+- 사용자 입력과 컴퓨터 정답을 비교하여 스트라이크/볼/낫싱을 계산한다.
+- 같은 숫자가 같은 자리면 스트라이크
+- 같은 숫자가 다른 자리면 볼
+- 공통 숫자가 하나도 없으면 낫싱
+- 판정 결과를 출력한다.
+- 스트라이크/볼이 존재하면 조합 형태로 출력한다. (예: 1스트라이크 1볼)
+- 아무것도 없으면 낫싱을 출력한다.
+
+---
+4. 게임 진행 루프
+- 사용자가 정답 3자리를 모두 맞힐 때까지(3스트라이크) 입력 → 판정 → 출력 과정을 반복한다.
+- 3스트라이크가 되면 게임 종료 메시지를 출력한다.
+
+---
+5. 게임 종료 후 재시작/종료
+- 게임 종료 후 사용자에게 재시작/종료 입력을 받는다.
+- 1 입력 시 게임을 다시 시작한다 (정답 숫자 재생성).
+- 2 입력 시 프로그램을 완전히 종료한다.
+- 재시작/종료 입력값이 잘못된 경우에도 예외 처리한다.
+- [ERROR]로 시작하는 메시지를 출력한다.
+- 다시 입력을 받는다.
+
+## UML
+
diff --git a/build.gradle b/build.gradle
index 20a92c9e..c3026c4c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -18,6 +18,8 @@ repositories {
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
testImplementation 'org.assertj:assertj-core:3.25.3'
+ compileOnly 'org.projectlombok:lombok:1.18.30'
+ annotationProcessor 'org.projectlombok:lombok:1.18.30'
}
test {
diff --git a/src/main/java/.gitkeep b/src/main/java/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/main/java/Application.java b/src/main/java/Application.java
new file mode 100644
index 00000000..a77514f2
--- /dev/null
+++ b/src/main/java/Application.java
@@ -0,0 +1,12 @@
+import baseball.controller.BaseballGame;
+import baseball.model.ComputerNumber;
+import baseball.view.InputView;
+import baseball.view.OutputView;
+
+public class Application {
+
+ public static void main(String[] args) {
+ BaseballGame controller = new BaseballGame(new InputView(), new OutputView(), ComputerNumber::createRandom);
+ controller.run();
+ }
+}
diff --git a/src/main/java/baseball/controller/BaseballGame.java b/src/main/java/baseball/controller/BaseballGame.java
new file mode 100644
index 00000000..b0bfa40a
--- /dev/null
+++ b/src/main/java/baseball/controller/BaseballGame.java
@@ -0,0 +1,72 @@
+package baseball.controller;
+
+import baseball.exception.CommonErrorCode;
+import baseball.exception.InvalidInputException;
+import lombok.RequiredArgsConstructor;
+import baseball.model.ComputerNumber;
+import baseball.model.Judge;
+import baseball.model.Result;
+import baseball.model.UserGuess;
+import baseball.view.InputView;
+import baseball.view.OutputView;
+
+import java.util.function.Supplier;
+
+@RequiredArgsConstructor
+public class BaseballGame {
+ private final InputView inputView;
+ private final OutputView outputView;
+ private final Supplier computerNumberSupplier;
+
+ public void run() {
+ while (true) {
+ playSingleGame();
+ if (!askRestart()) {
+ return;
+ }
+ }
+ }
+
+ private void playSingleGame() {
+ ComputerNumber targetNum = computerNumberSupplier.get();
+
+ outputView.printStartMessage();
+
+ while (true) {
+ try {
+ String raw = inputView.readGuess();
+ UserGuess guess = UserGuess.from(raw);
+
+ Result result = Judge.judge(targetNum, guess);
+ outputView.printHint(result);
+
+ if (result.isThreeStrike()) {
+ outputView.printGameEndMessage();
+ break;
+ }
+ } catch (InvalidInputException e) {
+ outputView.printError(e.getMessage());
+ }
+
+ }
+ }
+
+ private boolean askRestart() {
+ while (true) {
+ try {
+ String cmd = inputView.readRestartCommand();
+ validateRestartCommand(cmd);
+
+ return "1".equals(cmd);
+ } catch (InvalidInputException e) {
+ outputView.printError(e.getMessage());
+ }
+ }
+ }
+
+ private void validateRestartCommand(String cmd) {
+ if (!"1".equals(cmd) && !"2".equals(cmd)) {
+ throw new InvalidInputException(CommonErrorCode.INVALID_COMMAND);
+ }
+ }
+}
diff --git a/src/main/java/baseball/exception/CommonErrorCode.java b/src/main/java/baseball/exception/CommonErrorCode.java
new file mode 100644
index 00000000..a1464d2b
--- /dev/null
+++ b/src/main/java/baseball/exception/CommonErrorCode.java
@@ -0,0 +1,19 @@
+package baseball.exception;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+@Getter
+public enum CommonErrorCode implements ErrorCode {
+
+ EMPTY_INPUT("EMPTY_INPUT", "입력값이 비어있습니다."),
+ INVALID_LENGTH("INVALID_LENGTH", "3자리 숫자를 입력해야 합니다."),
+ NOT_A_NUMBER("NOT_A_NUMBER", "숫자만 입력해야 합니다."),
+ NUMBER_OUT_OF_RANGE("NUMBER_OUT_OF_RANGE", "각 자리는 1~9 사이여야 합니다."),
+ DUPLICATED_NUMBER("DUPLICATED_NUMBER", "중복되지 않는 숫자 3개여야 합니다."),
+ INVALID_COMMAND("INVALID_COMMAND", "1 또는 2를 입력해야 합니다.");
+
+ private final String code;
+ private final String message;
+}
diff --git a/src/main/java/baseball/exception/ErrorCode.java b/src/main/java/baseball/exception/ErrorCode.java
new file mode 100644
index 00000000..ae2738c7
--- /dev/null
+++ b/src/main/java/baseball/exception/ErrorCode.java
@@ -0,0 +1,6 @@
+package baseball.exception;
+
+public interface ErrorCode {
+ String getCode();
+ String getMessage();
+}
diff --git a/src/main/java/baseball/exception/InvalidInputException.java b/src/main/java/baseball/exception/InvalidInputException.java
new file mode 100644
index 00000000..5bf7f61c
--- /dev/null
+++ b/src/main/java/baseball/exception/InvalidInputException.java
@@ -0,0 +1,13 @@
+package baseball.exception;
+
+import lombok.Getter;
+
+@Getter
+public class InvalidInputException extends IllegalArgumentException {
+ private final CommonErrorCode commonErrorCode;
+
+ public InvalidInputException(CommonErrorCode commonErrorCode) {
+ super(commonErrorCode.getMessage());
+ this.commonErrorCode = commonErrorCode;
+ }
+}
diff --git a/src/main/java/baseball/model/ComputerNumber.java b/src/main/java/baseball/model/ComputerNumber.java
new file mode 100644
index 00000000..48089a25
--- /dev/null
+++ b/src/main/java/baseball/model/ComputerNumber.java
@@ -0,0 +1,40 @@
+package baseball.model;
+
+import lombok.RequiredArgsConstructor;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Random;
+import java.util.Set;
+
+@RequiredArgsConstructor
+public final class ComputerNumber {
+ private static final Random RANDOM = new Random();
+ private final List digits;
+
+ public static ComputerNumber createRandom() {
+ Set set = new LinkedHashSet<>();
+ while (set.size() < 3) {
+ int n = RANDOM.nextInt(9) + 1; // 1~9
+ set.add(n); // 중복 자동 제거
+ }
+ return new ComputerNumber(new ArrayList<>(set));
+ }
+
+ public int digitAt(int index) {
+ return digits.get(index);
+ }
+
+ public boolean contains(int digit) {
+ return digits.contains(digit);
+ }
+
+ public int size() {
+ return digits.size();
+ }
+
+ public static ComputerNumber of(List digits) {
+ return new ComputerNumber(digits);
+ }
+}
diff --git a/src/main/java/baseball/model/Judge.java b/src/main/java/baseball/model/Judge.java
new file mode 100644
index 00000000..a2fa3749
--- /dev/null
+++ b/src/main/java/baseball/model/Judge.java
@@ -0,0 +1,21 @@
+package baseball.model;
+
+public final class Judge {
+ private Judge() { }
+
+ public static Result judge(ComputerNumber answer, UserGuess guess) {
+ int strike = 0;
+ int ball = 0;
+
+ for (int i = 0; i < answer.size(); i++) {
+ int g = guess.digitAt(i);
+
+ if (answer.digitAt(i) == g) {
+ strike++;
+ } else if (answer.contains(g)) {
+ ball++;
+ }
+ }
+ return new Result(strike, ball);
+ }
+}
diff --git a/src/main/java/baseball/model/Result.java b/src/main/java/baseball/model/Result.java
new file mode 100644
index 00000000..eb1dc971
--- /dev/null
+++ b/src/main/java/baseball/model/Result.java
@@ -0,0 +1,9 @@
+package baseball.model;
+
+public record Result(int strike, int ball) {
+ private static final int DIGIT_COUNT = 3;
+
+ public boolean isThreeStrike() {
+ return strike == DIGIT_COUNT;
+ }
+}
diff --git a/src/main/java/baseball/model/UserGuess.java b/src/main/java/baseball/model/UserGuess.java
new file mode 100644
index 00000000..ef283ee4
--- /dev/null
+++ b/src/main/java/baseball/model/UserGuess.java
@@ -0,0 +1,53 @@
+package baseball.model;
+
+import baseball.exception.CommonErrorCode;
+import baseball.exception.InvalidInputException;
+import lombok.AllArgsConstructor;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+@AllArgsConstructor
+public class UserGuess {
+ private final List digits;
+
+ private static final int DIGIT_COUNT = 3;
+ private static final int MIN_NUMBER = 1;
+ private static final int MAX_NUMBER = 9;
+
+ public static UserGuess from(String input) {
+ if (input == null) {
+ throw new InvalidInputException(CommonErrorCode.EMPTY_INPUT);
+ }
+ if (input.length() != DIGIT_COUNT) {
+ throw new InvalidInputException(CommonErrorCode.INVALID_LENGTH);
+ }
+
+ List digits = new ArrayList<>(DIGIT_COUNT);
+ Set dupCheck = new HashSet<>();
+
+ for (int i = 0; i < DIGIT_COUNT; i++) {
+ char c = input.charAt(i);
+ if (!Character.isDigit(c)) {
+ throw new InvalidInputException(CommonErrorCode.NOT_A_NUMBER);
+ }
+
+ int d = c - '0';
+ if (d < MIN_NUMBER || d > MAX_NUMBER) {
+ throw new InvalidInputException(CommonErrorCode.NUMBER_OUT_OF_RANGE);
+ }
+ if (!dupCheck.add(d)) {
+ throw new InvalidInputException(CommonErrorCode.DUPLICATED_NUMBER);
+ }
+ digits.add(d);
+ }
+
+ return new UserGuess(digits);
+ }
+
+ public int digitAt(int index) {
+ return digits.get(index);
+ }
+}
diff --git a/src/main/java/baseball/view/InputView.java b/src/main/java/baseball/view/InputView.java
new file mode 100644
index 00000000..a3ee55ac
--- /dev/null
+++ b/src/main/java/baseball/view/InputView.java
@@ -0,0 +1,17 @@
+package baseball.view;
+
+import java.util.Scanner;
+
+public class InputView {
+ private final Scanner scanner = new Scanner(System.in);
+
+ public String readGuess() {
+ System.out.print("숫자를 입력해주세요: ");
+ return scanner.nextLine();
+ }
+
+ public String readRestartCommand() {
+ System.out.println("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.");
+ return scanner.nextLine();
+ }
+}
diff --git a/src/main/java/baseball/view/OutputView.java b/src/main/java/baseball/view/OutputView.java
new file mode 100644
index 00000000..1dfd4579
--- /dev/null
+++ b/src/main/java/baseball/view/OutputView.java
@@ -0,0 +1,32 @@
+package baseball.view;
+
+import baseball.model.Result;
+
+public class OutputView {
+ public void printStartMessage() {
+ System.out.println("숫자 야구 게임을 시작합니다.");
+ }
+
+ public void printHint(Result result) {
+ int strike = result.strike();
+ int ball = result.ball();
+
+ if (strike == 0 && ball == 0) {
+ System.out.println("낫싱");
+ return;
+ }
+
+ StringBuilder sb = new StringBuilder();
+ if (ball > 0) sb.append(ball).append("볼 ");
+ if (strike > 0) sb.append(strike).append("스트라이크");
+ System.out.println(sb.toString().trim());
+ }
+
+ public void printGameEndMessage() {
+ System.out.println("3개의 숫자를 모두 맞히셨습니다! 게임 종료");
+ }
+
+ public void printError(String message) {
+ System.out.println("[ERROR] " + message);
+ }
+}
diff --git a/src/test/java/.gitkeep b/src/test/java/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/test/java/baseball/model/ComputerNumberTest.java b/src/test/java/baseball/model/ComputerNumberTest.java
new file mode 100644
index 00000000..677214f6
--- /dev/null
+++ b/src/test/java/baseball/model/ComputerNumberTest.java
@@ -0,0 +1,55 @@
+package baseball.model;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("ComputerNumber 생성 테스트")
+class ComputerNumberTest {
+
+ @DisplayName("컴퓨터 숫자는 항상 3자리로 생성된다")
+ @Test
+ void createRandom_createsThreeDigits() {
+ ComputerNumber number = ComputerNumber.createRandom();
+
+ assertEquals(3, number.size());
+ }
+
+ @DisplayName("컴퓨터 숫자는 1~9 범위의 숫자로만 구성된다")
+ @Test
+ void createRandom_digitsAreBetweenOneAndNine() {
+ ComputerNumber number = ComputerNumber.createRandom();
+
+ for (int i = 0; i < number.size(); i++) {
+ int digit = number.digitAt(i);
+ assertTrue(digit >= 1 && digit <= 9);
+ }
+ }
+
+ @DisplayName("컴퓨터 숫자는 중복되지 않는다")
+ @Test
+ void createRandom_digitsAreUnique() {
+ ComputerNumber number = ComputerNumber.createRandom();
+
+ Set unique = new HashSet<>();
+ for (int i = 0; i < number.size(); i++) {
+ unique.add(number.digitAt(i));
+ }
+
+ assertEquals(3, unique.size());
+ }
+
+ @DisplayName("contains는 포함된 숫자에 대해 true를 반환한다")
+ @Test
+ void contains_returnsTrueForExistingDigit() {
+ ComputerNumber number = ComputerNumber.createRandom();
+
+ int digit = number.digitAt(0);
+
+ assertTrue(number.contains(digit));
+ }
+}
diff --git a/src/test/java/baseball/model/JudgeTest.java b/src/test/java/baseball/model/JudgeTest.java
new file mode 100644
index 00000000..db30eb1b
--- /dev/null
+++ b/src/test/java/baseball/model/JudgeTest.java
@@ -0,0 +1,73 @@
+package baseball.model;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("Judge 스트라이크/볼 판정 테스트")
+class JudgeTest {
+
+ @DisplayName("모든 숫자와 자리가 같으면 3스트라이크이다")
+ @Test
+ void judge_allMatch_returnsThreeStrike() {
+ ComputerNumber answer = ComputerNumber.of(List.of(1, 3, 7));
+ UserGuess guess = UserGuess.from("137");
+
+ Result result = Judge.judge(answer, guess);
+
+ assertEquals(3, result.strike());
+ assertEquals(0, result.ball());
+ assertTrue(result.isThreeStrike());
+ }
+
+ @DisplayName("숫자는 같지만 자리가 다르면 볼로 판정한다")
+ @Test
+ void judge_sameDigitsDifferentPosition_returnsBalls() {
+ ComputerNumber answer = ComputerNumber.of(List.of(1, 3, 7));
+ UserGuess guess = UserGuess.from("731");
+
+ Result result = Judge.judge(answer, guess);
+
+ assertEquals(0, result.strike());
+ assertEquals(3, result.ball());
+ }
+
+ @DisplayName("스트라이크와 볼이 함께 존재할 수 있다")
+ @Test
+ void judge_strikeAndBallMixed() {
+ ComputerNumber answer = ComputerNumber.of(List.of(1, 3, 7));
+ UserGuess guess = UserGuess.from("173");
+
+ Result result = Judge.judge(answer, guess);
+
+ assertEquals(1, result.strike()); // 1
+ assertEquals(2, result.ball()); // 7, 3
+ }
+
+ @DisplayName("공통 숫자가 없으면 스트라이크와 볼은 0이다")
+ @Test
+ void judge_noMatchingDigits_returnsNothing() {
+ ComputerNumber answer = ComputerNumber.of(List.of(1, 3, 7));
+ UserGuess guess = UserGuess.from("258");
+
+ Result result = Judge.judge(answer, guess);
+
+ assertEquals(0, result.strike());
+ assertEquals(0, result.ball());
+ }
+
+ @DisplayName("일부만 같은 경우 스트라이크만 판정될 수 있다")
+ @Test
+ void judge_onlyStrike() {
+ ComputerNumber answer = ComputerNumber.of(List.of(1, 3, 7));
+ UserGuess guess = UserGuess.from("128");
+
+ Result result = Judge.judge(answer, guess);
+
+ assertEquals(1, result.strike()); // 1
+ assertEquals(0, result.ball());
+ }
+}
diff --git a/src/test/java/baseball/model/ResultTest.java b/src/test/java/baseball/model/ResultTest.java
new file mode 100644
index 00000000..4002f625
--- /dev/null
+++ b/src/test/java/baseball/model/ResultTest.java
@@ -0,0 +1,26 @@
+package baseball.model;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("Result 결과 상태 테스트")
+class ResultTest {
+
+ @DisplayName("스트라이크가 3이면 isThreeStrike는 true를 반환한다")
+ @Test
+ void isThreeStrike_whenStrikeIsThree_returnsTrue() {
+ Result result = new Result(3, 0);
+
+ assertTrue(result.isThreeStrike());
+ }
+
+ @DisplayName("스트라이크가 3이 아니면 isThreeStrike는 false를 반환한다")
+ @Test
+ void isThreeStrike_whenStrikeIsNotThree_returnsFalse() {
+ Result result = new Result(2, 1);
+
+ assertFalse(result.isThreeStrike());
+ }
+}
diff --git a/src/test/java/baseball/model/UserGuessTest.java b/src/test/java/baseball/model/UserGuessTest.java
new file mode 100644
index 00000000..f2098b67
--- /dev/null
+++ b/src/test/java/baseball/model/UserGuessTest.java
@@ -0,0 +1,77 @@
+package baseball.model;
+
+import baseball.exception.CommonErrorCode;
+import baseball.exception.InvalidInputException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class UserGuessTest {
+
+ @DisplayName("정상적인 3자리 숫자 입력이면 UserGuess가 생성된다")
+ @Test
+ void from_validInput_createsUserGuess() {
+ UserGuess guess = UserGuess.from("135");
+
+ assertEquals(1, guess.digitAt(0));
+ assertEquals(3, guess.digitAt(1));
+ assertEquals(5, guess.digitAt(2));
+ }
+
+ @DisplayName("입력이 null이면 EMPTY_INPUT 예외가 발생한다")
+ @Test
+ void from_nullInput_throwsException() {
+ InvalidInputException e = assertThrows(
+ InvalidInputException.class,
+ () -> UserGuess.from(null)
+ );
+
+ assertEquals(CommonErrorCode.EMPTY_INPUT, e.getCommonErrorCode());
+ }
+
+ @DisplayName("입력 길이가 3이 아니면 INVALID_LENGTH 예외가 발생한다")
+ @Test
+ void from_invalidLength_throwsException() {
+ InvalidInputException e = assertThrows(
+ InvalidInputException.class,
+ () -> UserGuess.from("12")
+ );
+
+ assertEquals(CommonErrorCode.INVALID_LENGTH, e.getCommonErrorCode());
+ }
+
+ @DisplayName("숫자가 아닌 문자가 포함되면 NOT_A_NUMBER 예외가 발생한다")
+ @Test
+ void from_nonDigit_throwsException() {
+ InvalidInputException e = assertThrows(
+ InvalidInputException.class,
+ () -> UserGuess.from("1a3")
+ );
+
+ assertEquals(CommonErrorCode.NOT_A_NUMBER, e.getCommonErrorCode());
+ }
+
+ @DisplayName("숫자가 1~9 범위를 벗어나면 NUMBER_OUT_OF_RANGE 예외가 발생한다")
+ @Test
+ void from_outOfRange_throwsException() {
+ InvalidInputException e = assertThrows(
+ InvalidInputException.class,
+ () -> UserGuess.from("109")
+ );
+
+ assertEquals(CommonErrorCode.NUMBER_OUT_OF_RANGE, e.getCommonErrorCode());
+ }
+
+ @DisplayName("중복된 숫자가 있으면 DUPLICATED_NUMBER 예외가 발생한다")
+ @Test
+ void from_duplicatedDigits_throwsException() {
+ InvalidInputException e = assertThrows(
+ InvalidInputException.class,
+ () -> UserGuess.from("113")
+ );
+
+ assertEquals(CommonErrorCode.DUPLICATED_NUMBER, e.getCommonErrorCode());
+ }
+}