최종 실습: 게임 기록 관리 콘솔 프로그램 완성하기
22강. 최종 실습: 게임 기록 관리 콘솔 프로그램 완성하기
1. 이번 강의에서 해결할 문제
문법 예제가 흩어져 있으면 상태, 입력, 조회와 파일 책임이 뒤섞입니다. 요구 기능을 클래스별 책임으로 나누고 종료 후에도 기록이 남는 하나의 프로그램으로 연결해야 합니다.
2. 학습 목표
3. 핵심 개념
최종 프로그램은 외부 서버나 데이터베이스 없이 로컬 data/records.tsv만 사용합니다.
| 클래스 | 책임 |
|---|---|
GameMode | 허용하는 게임 모드를 enum 값으로 제한 |
GameRecord | 플레이어·모드·점수·플레이 시간 검증과 파일 한 줄 변환 |
GameRecordService | List 저장, 전체·플레이어별·최고 점수 조회, Map·Set 요약 |
GameRecordStore | UTF-8 파일 불러오기와 저장 |
GameRecordApp | 콘솔 메뉴, 입력 검증, 서비스와 저장소 연결 |
상속이나 인터페이스는 구현이 하나뿐인 현재 요구에 이점이 없어 억지로 추가하지 않습니다. 이후 저장 형식이나 UI가 실제로 교체될 때 계약을 도입합니다.
4. 단계별 실습
1. src/GameMode.java 작성
실행 환경: 코드 편집기
실행 위치: C:\dev\game-records
실행 전 확인: 같은 이름의 기존 파일이 있다면 덮어쓰기 전에 Git diff와 이번 강의 변경 범위를 확인합니다.
대상 파일: src/GameMode.java
public enum GameMode {
CASUAL, RANKED, TRAINING
}
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 + "분"; }
}
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;
}
}
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);
}
}
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()); }
}
}
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가 저장되며 다음 실행에 복원됩니다.
5. 코드가 동작하는 이유
App은 입력과 메뉴만, Service는 메모리 조회 규칙만, Store는 파일만, GameRecord는 유효 데이터만 책임집니다. List는 전체 기록과 중복을 보존하고 Map은 플레이어별 건수, Set은 사용한 모드의 고유값을 제공합니다. 임시 파일을 완전히 쓴 뒤 대상 파일로 옮겨 저장 도중 기존 파일을 직접 비우는 위험을 줄입니다.
6. 자주 하는 실수와 해결법
7. 직접 실습
8. 이해 점검 질문 3개
9. 핵심 요약
최종 실습: 게임 기록 관리 콘솔 프로그램 완성하기 미니 퀴즈
선택 즉시 정답과 해설을 확인할 수 있습니다. 결과는 이 브라우저에만 저장됩니다.
학습을 마쳤나요?
직접 실습과 점검 질문까지 확인한 뒤 완료로 표시하세요.