최종 실습: 게임 기록 관리 콘솔 프로그램 완성하기
22강. 최종 실습: 게임 기록 관리 콘솔 프로그램 완성하기
1. 이번 강의에서 해결할 문제
문법 예제가 흩어져 있으면 상태, 입력, 조회와 파일 책임이 뒤섞입니다. 요구 기능을 클래스별 책임으로 나누고 종료 후에도 기록이 남는 하나의 프로그램으로 연결해야 합니다.
2. 학습 목표
3. 핵심 개념
최종 프로그램은 외부 서버나 데이터베이스 없이 로컬 data/records.tsv만 사용합니다.
최종 실습은 긴 코드를 한 파일에 붙여 넣는 과제가 아닙니다. 유효한 기록 한 건 → 메모리 목록 → 파일 저장 → 콘솔 메뉴 순서로 작은 단위를 확인한 뒤 연결합니다. 앞 단계가 동작하지 않으면 다음 클래스를 추가하지 않습니다.
| 클래스 | 책임 |
|---|---|
GameMode | 허용하는 게임 모드를 enum 값으로 제한 |
GameRecord | 플레이어·모드·점수·플레이 시간 검증과 파일 한 줄 변환 |
GameRecordService | List 저장, 전체·플레이어별·최고 점수 조회, Map·Set 요약 |
GameRecordStore | UTF-8 파일 불러오기와 저장 |
GameRecordApp | 콘솔 메뉴, 입력 검증, 서비스와 저장소 연결 |
상속이나 인터페이스는 구현이 하나뿐인 현재 요구에 이점이 없어 억지로 추가하지 않습니다. 이후 저장 형식이나 UI가 실제로 교체될 때 계약을 도입합니다.
먼저 알아야 할 용어
- 도메인 객체: 이 프로그램이 다루는 핵심 데이터와 규칙을 가진 객체입니다. 여기서는
GameRecord입니다. - 불변 조건: 객체가 정상 상태라면 항상 지켜야 하는 규칙입니다. 빈 이름, 음수 점수,
null게임 모드는 허용하지 않습니다. - 서비스: 여러 기록을 모아 조회·집계하는 유스케이스 규칙을 담당합니다.
- 영속화: 프로그램이 종료되어도 데이터를 남기도록 파일 같은 저장소에 기록하는 일입니다.
- 경계: 사용자 입력이나 파일처럼 프로그램 밖의 데이터가 들어오는 지점입니다. 실패 가능성이 크므로 검증과 오류 안내가 필요합니다.
- TSV: 한 행이 기록 하나이고 열을 탭(
\t)으로 나누는 텍스트 형식입니다.
실행 전에 데이터 흐름을 그립니다
| 사용자 행동 | 호출 흐름 | 확인할 결과 |
|---|---|---|
| 기록 추가 | App → new GameRecord → Service | 유효한 기록만 메모리 목록에 추가 |
| 전체 조회 | App → Service | 입력 순서의 복사 불가능한 목록 출력 |
| 최고 점수 | App → Service | 기록이 없으면 빈 Optional, 있으면 최고 기록 |
| 저장 후 종료 | App → Store | 임시 파일 작성 뒤 records.tsv 교체 |
| 다음 실행 | App → Store → Service | 유효한 행은 복원하고 깨진 행은 번호와 함께 건너뜀 |
4. 단계별 실습
아래 파일을 한꺼번에 만들지 마세요. 각 단계마다 컴파일하고, 가장 작은 호출로 결과를 확인한 뒤 다음 파일로 이동합니다.
1. src/GameMode.java 작성
실행 환경: 코드 편집기
실행 위치: C:\dev\game-records
실행 전 확인: 같은 이름의 기존 파일이 있다면 덮어쓰기 전에 Git diff와 이번 강의 변경 범위를 확인합니다.
대상 파일: src/GameMode.java
public enum GameMode {
CASUAL, RANKED, TRAINING
}
enum은 게임 모드를 임의 문자열이 아니라 세 값 중 하나로 제한합니다. "rankd" 같은 오타를 프로그램 내부 상태로 저장하지 못하게 하고 switch에서도 가능한 경우를 컴파일러가 추적할 수 있습니다.
2. src/GameRecord.java 작성
실행 환경: 코드 편집기
실행 위치: C:\dev\game-records
실행 전 확인: 같은 이름의 기존 파일이 있다면 덮어쓰기 전에 Git diff와 이번 강의 변경 범위를 확인합니다.
대상 파일: src/GameRecord.java
import java.util.Locale;
public final class GameRecord {
private final String playerName;
private final GameMode gameMode;
private final int score;
private final int playMinutes;
public GameRecord(String playerName, GameMode gameMode, int score, int playMinutes) {
if (playerName == null || playerName.isBlank()) throw new IllegalArgumentException("플레이어 이름이 비었습니다.");
if (playerName.contains("\t") || playerName.contains("\n")) throw new IllegalArgumentException("이름에 탭이나 줄바꿈을 사용할 수 없습니다.");
if (gameMode == null) throw new IllegalArgumentException("게임 모드가 필요합니다.");
if (score < 0 || playMinutes < 0) throw new IllegalArgumentException("점수와 플레이 시간은 0 이상이어야 합니다.");
this.playerName = playerName.trim();
this.gameMode = gameMode;
this.score = score;
this.playMinutes = playMinutes;
}
public String playerName() { return playerName; }
public GameMode gameMode() { return gameMode; }
public int score() { return score; }
public int playMinutes() { return playMinutes; }
public String toDataLine() { return String.join("\t", playerName, gameMode.name(), String.valueOf(score), String.valueOf(playMinutes)); }
public static GameRecord fromDataLine(String line) {
String[] parts = line.split("\t", -1);
if (parts.length != 4) throw new IllegalArgumentException("필드가 4개가 아닙니다.");
return new GameRecord(parts[0], GameMode.valueOf(parts[1].toUpperCase(Locale.ROOT)), Integer.parseInt(parts[2]), Integer.parseInt(parts[3]));
}
@Override public String toString() { return playerName + " | " + gameMode + " | " + score + "점 | " + playMinutes + "분"; }
}
GameRecord의 중요한 줄
- 네 필드는
private final이므로 생성이 끝난 뒤 다른 값으로 대입할 수 없습니다. - 생성자는 필드에 넣기 전에 빈 이름, 구분 문자를 깨뜨리는 탭·줄바꿈, 음수 숫자를 거부합니다.
trim()은 앞뒤 공백을 제거하지만 빈 값 검사는 그보다 먼저isBlank()로 수행합니다.toDataLine()은 객체를 TSV 한 행으로,fromDataLine()은 한 행을 다시 검증된 객체로 바꿉니다.split("\t", -1)의-1은 마지막 빈 열도 보존하므로 “필드가 빠진 행”을 정확히 발견하는 데 필요합니다.
먼저 new GameRecord("Alice", GameMode.RANKED, 1200, 15)가 성공하는지 확인하고, 이름을 빈 문자열이나 점수를 -1로 바꿨을 때 이해할 수 있는 예외가 나는지 비교합니다.
3. src/GameRecordService.java 작성
실행 환경: 코드 편집기
실행 위치: C:\dev\game-records
실행 전 확인: 같은 이름의 기존 파일이 있다면 덮어쓰기 전에 Git diff와 이번 강의 변경 범위를 확인합니다.
대상 파일: src/GameRecordService.java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
public final class GameRecordService {
private final List<GameRecord> records = new ArrayList<>();
public GameRecordService(List<GameRecord> initialRecords) { records.addAll(initialRecords); }
public void add(GameRecord record) { records.add(record); }
public List<GameRecord> all() { return List.copyOf(records); }
public List<GameRecord> findByPlayer(String name) {
return records.stream().filter(record -> record.playerName().equalsIgnoreCase(name.trim())).toList();
}
public Optional<GameRecord> highestScore() {
return records.stream().max(Comparator.comparingInt(GameRecord::score));
}
public Map<String, Integer> countByPlayer() {
Map<String, Integer> counts = new LinkedHashMap<>();
for (GameRecord record : records) counts.merge(record.playerName(), 1, Integer::sum);
return counts;
}
public Set<GameMode> usedModes() {
Set<GameMode> modes = new LinkedHashSet<>();
for (GameRecord record : records) modes.add(record.gameMode());
return modes;
}
}
Service는 파일 경로와 Scanner를 모릅니다. List는 중복 기록과 입력 순서를 보존하고, Map은 플레이어 이름별 횟수, Set은 중복 없는 모드 목록을 표현합니다. all()이 records 자체가 아니라 List.copyOf(records)를 반환하므로 호출자가 clear()로 내부 목록을 지울 수 없습니다.
highestScore()가 GameRecord 대신 Optional<GameRecord>를 반환하는 이유는 기록 0건도 정상 상태이기 때문입니다. 호출자는 “없음”을 예외가 아닌 분기로 처리합니다.
4. src/GameRecordStore.java 작성
실행 환경: 코드 편집기
실행 위치: C:\dev\game-records
실행 전 확인: 같은 이름의 기존 파일이 있다면 덮어쓰기 전에 Git diff와 이번 강의 변경 범위를 확인합니다.
대상 파일: src/GameRecordStore.java
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
public final class GameRecordStore {
private final Path file;
public GameRecordStore(Path file) { this.file = file; }
public List<GameRecord> load() throws IOException {
if (Files.notExists(file)) return List.of();
List<GameRecord> records = new ArrayList<>();
int lineNumber = 0;
for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) {
lineNumber++;
if (line.isBlank()) continue;
try { records.add(GameRecord.fromDataLine(line)); }
catch (IllegalArgumentException e) { System.err.println(lineNumber + "행 건너뜀: " + e.getMessage()); }
}
return records;
}
public void save(List<GameRecord> records) throws IOException {
Files.createDirectories(file.getParent());
Path temporary = file.resolveSibling(file.getFileName() + ".tmp");
Files.write(temporary, records.stream().map(GameRecord::toDataLine).toList(), StandardCharsets.UTF_8);
Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING);
}
}
Store는 먼저 .tmp 파일을 완전히 쓰고 마지막에 대상 파일로 옮깁니다. 기존 파일을 직접 열어 비운 뒤 쓰다가 실패하면 이전 데이터까지 잃을 수 있기 때문입니다. 다만 운영체제·파일 시스템에 따라 이 이동이 완전한 원자적 교체를 보장하는 것은 아니므로 중요한 데이터라면 백업과 더 강한 저장 전략이 필요합니다.
로드 중 깨진 한 행은 행 번호와 이유를 출력하고 나머지 유효 행을 계속 읽습니다. 이 정책은 “한 줄 오류 때문에 모든 기록을 잃지 않는다”는 선택이며, 오류 행을 조용히 무시하지는 않습니다.
5. src/GameRecordApp.java 작성
실행 환경: 코드 편집기
실행 위치: C:\dev\game-records
실행 전 확인: 같은 이름의 기존 파일이 있다면 덮어쓰기 전에 Git diff와 이번 강의 변경 범위를 확인합니다.
대상 파일: src/GameRecordApp.java
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Scanner;
public class GameRecordApp {
private final Scanner scanner = new Scanner(System.in);
private final GameRecordStore store = new GameRecordStore(Path.of("data", "records.tsv"));
private GameRecordService service;
public static void main(String[] args) { new GameRecordApp().run(); }
void run() {
try { service = new GameRecordService(store.load()); }
catch (IOException e) { System.err.println("불러오기 실패, 빈 목록으로 시작합니다: " + e.getMessage()); service = new GameRecordService(List.of()); }
while (true) {
System.out.println("\n1. 기록 추가 2. 전체 조회 3. 플레이어 조회 4. 최고 점수 0. 저장 후 종료");
switch (scanner.nextLine().trim()) {
case "1" -> addRecord();
case "2" -> printRecords(service.all());
case "3" -> findPlayer();
case "4" -> service.highestScore().ifPresentOrElse(System.out::println, () -> System.out.println("기록이 없습니다."));
case "0" -> { saveAndExit(); return; }
default -> System.out.println("메뉴 번호를 다시 입력하세요.");
}
}
}
void addRecord() {
try {
System.out.print("플레이어 이름: "); String name = scanner.nextLine();
System.out.print("모드(CASUAL/RANKED/TRAINING): "); GameMode mode = GameMode.valueOf(scanner.nextLine().trim().toUpperCase(Locale.ROOT));
int score = readNonNegativeInt("점수: ");
int minutes = readNonNegativeInt("플레이 시간(분): ");
service.add(new GameRecord(name, mode, score, minutes));
System.out.println("기록을 추가했습니다.");
} catch (IllegalArgumentException e) { System.out.println("입력 오류: " + e.getMessage()); }
}
int readNonNegativeInt(String prompt) {
System.out.print(prompt);
int value = Integer.parseInt(scanner.nextLine().trim());
if (value < 0) throw new IllegalArgumentException("0 이상의 정수를 입력하세요.");
return value;
}
void findPlayer() {
System.out.print("조회할 플레이어: ");
printRecords(service.findByPlayer(scanner.nextLine()));
}
void printRecords(List<GameRecord> records) {
if (records.isEmpty()) { System.out.println("기록이 없습니다."); return; }
records.forEach(System.out::println);
System.out.println("플레이어별 건수: " + service.countByPlayer());
System.out.println("사용 모드: " + service.usedModes());
}
void saveAndExit() {
try { store.save(service.all()); System.out.println("저장했습니다."); }
catch (IOException e) { System.err.println("저장 실패: " + e.getMessage()); }
}
}
메뉴 코드의 실행 흐름
run()은 파일 복원을 한 번 시도하고 실패하면 빈 목록으로 명시적으로 시작합니다.- 메뉴 입력은
nextLine()으로 통일해 숫자 입력 뒤 줄바꿈이 남는 문제를 피합니다. addRecord()는 문자열을 enum과 정수로 변환하는 경계이며 잘못된 입력을 메뉴 밖으로 퍼뜨리지 않습니다.case "0"은 저장을 시도한 뒤return해 반복을 끝냅니다.- 조회 결과가 비어 있어도 오류가 아니라 “기록이 없습니다”라는 정상 메시지를 보여 줍니다.
현재 구현은 저장 실패를 알리지만 다시 시도할 기회를 주지 않고 종료합니다. 학습용 최소 버전의 한계이므로, 확장 실습에서는 저장 성공 여부에 따라 종료할지 묻도록 개선할 수 있습니다.
6. PowerShell에서 컴파일·실행
실행 환경: Windows PowerShell
실행 위치: C:\dev\game-records
실행 전 확인: java -version과 javac -version이 선택한 LTS JDK를 가리키고, 소스 파일명이 public 클래스명과 일치하는지 확인합니다.
대상: 이 강의의 .java 파일, out의 class 파일과 명시된 출력 파일
Set-Location C:\dev\game-records
Remove-Item .\out\*.class -ErrorAction SilentlyContinue
javac -Xlint:all -d out .\src\GameMode.java .\src\GameRecord.java .\src\GameRecordService.java .\src\GameRecordStore.java .\src\GameRecordApp.java
java -cp out GameRecordApp
Get-Content .\data\records.tsv -ErrorAction SilentlyContinue
예상 결과: 콘솔에서 기록 추가·전체/플레이어별 조회·최고 점수를 실행하고 종료하면 records.tsv가 저장되며 다음 실행에 복원됩니다.
다음은 Alice 기록 한 건을 추가한 예시입니다. 프롬프트 배치는 입력에 따라 이어서 보일 수 있지만 핵심 값은 같아야 합니다.
1. 기록 추가 2. 전체 조회 3. 플레이어 조회 4. 최고 점수 0. 저장 후 종료
1
플레이어 이름: Alice
모드(CASUAL/RANKED/TRAINING): ranked
점수: 1200
플레이 시간(분): 15
기록을 추가했습니다.
Alice | RANKED | 1200점 | 15분
플레이어별 건수: {Alice=1}
사용 모드: [RANKED]
저장했습니다.
종료 뒤 data/records.tsv의 한 줄은 탭으로 구분된 Alice, RANKED, 1200, 15입니다. 프로그램을 다시 실행해 전체 조회했을 때 같은 기록이 보이면 저장과 복원이 연결된 것입니다.
5. 코드가 동작하는 이유
App은 입력과 메뉴만, Service는 메모리 조회 규칙만, Store는 파일만, GameRecord는 유효 데이터만 책임집니다. List는 전체 기록과 중복을 보존하고 Map은 플레이어별 건수, Set은 사용한 모드의 고유값을 제공합니다. 임시 파일을 완전히 쓴 뒤 대상 파일로 옮겨 저장 도중 기존 파일을 직접 비우는 위험을 줄입니다.
6. 자주 하는 실수와 해결법
7. 직접 실습
8. 이해 점검 질문 3개
9. 핵심 요약
10. 과정 마무리와 다음 적용
이 프로그램은 Java의 변수·조건·메서드·객체·컬렉션·예외·파일 처리를 하나의 데이터 흐름으로 연결합니다. 다음 확장에서는 정렬·검색·우선순위 자료구조를 비교하되, 먼저 현재 요구·실행 명령·실제 출력·오류 정책을 README에 기록해 재현 가능한 기준선을 남기세요.
최종 실습: 게임 기록 관리 콘솔 프로그램 완성하기 미니 퀴즈
선택 즉시 정답과 해설을 확인할 수 있습니다. 결과는 이 브라우저에만 저장됩니다.
학습을 마쳤나요?
직접 실습과 점검 질문까지 확인한 뒤 완료로 표시하세요.