텍스트 어드벤처라는 게임을 구현해보고 유지보수 어려운 코드를 설계 원칙을 적용하여 리펙토링 해보자!
게임 설명
2-1 텍스트 어드벤처
텍스트 어드벤처 게임
텍스트 어드벤처
사용자가 키보드를 이용해서 명령을 입력하고 실행 결과가 텍스트로 출력되는 게임
게임 구성 요소
지도 : 지도는 여러개의 방으로 이우러져 있고 방들은 동서남북 4방향으로 연결될 수 있음
플레이어 : 사용자가 조작하는 캐릭터 - 명령어를 이용해서 자유롭게 이동함
상호 작용 결과 콘솔 출력
게임은 명령어를 해석하고 적절한 방식으로 처리한 후에 실행 결과를 터미널에 출력한다.

방 : 지도는 여러개의 방으로 구성되는데 방에는 이름과 설명을 지정할 수 있다. 통로를 통해 연결된 방들끼리는 서로 이동이 가능하다.
방에는 위치를 나타내는 좌표가 할당된다. 이 좌표는 x, y 두 숫자의 조합으로 표현된다.


플레이어의 위치는 플레이어가 있는 방의 좌표로 표현한다. 화면에서 플레이어는 좌표가 0, 2 인 언덕에 위치한다

이제 이 지도를 코드로 구현해보자
방을 구현하는 Room 클래스
먼저 지도를 구성하는 각각의 방은 Room 클래스로 구현한다. 방에는 좌표와 이름 , 설명이 포함된다.

지도는 여러개의 룸들을 조합해서 만든다.
먼저 여러개의 룸들을 저장할 자료 구조를 만들어야 한다.
예제에서는 1차원 배열을 이용해서 룸들을 관리
가로 길이가 2 세로 길이가 3인 지도를 만들려면 6개의 룸을 저장할 수 있는 1차원 배열이 필요하다.
각각의 룸이 위치할 슬롯을 매핑하기 위해서 룸의 좌표를 배열의 인덱스로 변환한다.

좌표 (x, y)계산식인덱스
| (0, 0) | 0 + 0 * 2 | 0 |
| (1, 0) | 1 + 0 * 2 | 1 |
| (0, 1) | 0 + 1 * 2 | 2 |
| (1, 1) | 1 + 1 * 2 | 3 |
| (0, 2) | 0 + 2 * 2 | 4 |
| (1, 2) | 1 + 2 * 2 | 5 |
좌표를 배열의 인덱스로 변경하려면 y 좌표의 가로 길이를 곱한 값에 x 좌표를 더해주면 된다.
좌표 1,0 처럼 방이 없을 경우 룸대신 null 저장
게임 전체를 구현하는 게임 클래스
게임의 전체적인 로직은 게임 클래스 안에 구현한다.
게임에서 가로 길이는 인스턴스 변수 width , 세로 길이는 인스턴스 변수 height에 저장한다.
지도를 구성하는 방들은 룸 타입의 배열 rooms에 저장한다.
그리고 플레이어가 위치한 현재 위치는 x, y 에 저장한다.
이 좌표를 배열 인덱스로 변환하면 플레이어가 위치한 룸을 찾을 수 있다.
생성자를 추가하여 인스턴스 변수들은 적절한 값으로 초기화해보자.
먼저 가로 길이가 2이고 세로 길이가 3인 지도를 만들기 위해서 width에 2 , height 는 3을 할당
다음으로 5개의 Room 인스턴스들을 생성해서 ArrangeRooms 메소드에 전달한다.
ArrangeRooms 메소드는 Room을 적절한 인덱스에 할당한다.
먼저 width*height 크기를 가지는 배열을 생성하고 코어 루프를 돌면서
앞에서 설명한 공식에 따라서 Room을 배열에 배치한다.
1, 0 에는 할당할 룸이 없이 때문에 초기값인 null이 유지됨.
플레이어의 초기 위치도 0, 2 로설정.
이렇게 하면 플레이어는 0,2 에 있는 언덕에서 출발한다.
마지막으로 게임 실행 여부를 저장하는 러닝 변수를 추가한다.
게임 시작시 true 로 초기화 , 게임 종료시 false 로 바뀜.
게임 실행
게임 클래스의 run 메서드 살펴보기
이 메서드는 게임의 전체적인 실행을 책임진다.
run 메서드 실행시 환영합니다 문구 출력 후 다음에 x, y 를 인덱스로 변환해서 얻은 이름과 설명을 출력한다.
그 다음은 게임에서 입력할 수 있는 명령어들을 출력
플레이어가 사용할 수 있는 두가지 명령어
다른 방으로 이동할 때 사용할 수 있는 go
go 명령어 사용시 이동할 방향을 함께 지정해줘야함
게임을 종료할 때 사용할 수 있는 quit
러닝 변수를 true 로 설정하고 러닝이 false가 될때까지 루프를 반복한다.
게임 클래스는 루프를 돌면서 사용자로부터 명령을 입력받는데 이때 키보드 입력을 받기 위해
Scanner 클래스 사용
While Loop 안에서 Scanner로 입력 받은 다음에 문자열을 공백을 기준으로 분리 후
분리도니 문자열을 commands 배열에 담는다.
switch문은 배열을 이용해서 입력받은 명령어를 분석한다.
go 명령어 사용시 방사이를 이동할 수 있다.
go 명령어 두에 오는 문자열이 어떤 값인지에 따라 이동 로직이 달라진다.
이동 범위를 초과하면 이동이 불가능하다는 메시지를 출력하고 명령어 파싱을 종료한다.
이동이 가능한 경우 좌표값 갱신 후 이동한 방의 이름과 설명을 출력한다.
게임 클래스 전체 코드
게임 클래스에는 4개의 인스턴스 변수와 1개의 생성자, 2개의 메소드가 포함되어 있다.
게임 클래스의 문제점
- run 메서드가 너무 길어서 한눈에 파악하기 어렵다.
- 코드를 이해하기 어려우면 요구사항이 변경될 때 코드를 수정하기도 어려워진다.
- 테스트 하기도 어렵다.
- 메서드의 길이가 길면 버그가 발생하기도 쉽다.
- 클래스의 한 부분을 수정할 때 수정과 상관이 없는 다른 부분이 쉽게 영향을 받기 때문이다.
따라서 게임 클래스의 설계를 개선해보자.
설계 원칙을 이용해 설계를 개선해보자.
package hello.objectprinciple;
import java.util.Scanner;
public class Game {
private int width, height;
private Room[] rooms;
private int x, y;
private boolean running = false;
public Game() {
this.x = 0;
this.y = 2;
this.width = 2;
this.height = 3;
this.rooms = arrangeRoom(
new Room(0, 0, "샘", "아름다운 샘물이 흐르는 곳입니다. 이곳에서 휴식을 취할 수 있습니다."),
new Room(0, 1, "다리", "큰 강ㅁ 위에 돌로 만든 커다란 다리가 있습니다."),
new Room(1, 1, "성", "용왕이 살고 있는 성에 도착했습니다."),
new Room(0, 2, "언덕", "저 멀리 성이 보이고 언덕 아래로 좁은 길이 나 있습니다."),
new Room(1, 2, "동굴", "어둠에 잠긴 동굴 안에 작은 화톳불이 피어 있습니다.")
);
}
private Room[] arrangeRoom(Room ... rooms) {
Room[] result = new Room[width * height];
for (var room : rooms) {
result[room.x() + room.y()* width] = room;
}
return result;
}
public void run() {
System.out.println("> ");
System.out.print("당신은 ["+ rooms[x + y* width].name() + "] 에 있습니다. ");
System.out.println(rooms[x + y* width].description());
System.out.println("다음 명령어를 사용할 수 있습니다.");
System.out.println("go {north|east|south|west} - 이동, quit - 게임 종료");
Scanner scanner = new Scanner(System.in);
running = true;
while (running) {
String[] commands = scanner.nextLine().toLowerCase().trim().split("\\s+");
switch (commands[0]) {
case "go" -> {
switch (commands[1]) {
case "north" -> {
if(y-1 < 0 || rooms[x + (y -1)* width] == null) {
System.out.println("이동할 수 없습니다.");
} else {
y -=1;
System.out.print("당신은 ["+ rooms[x + y* width].name() + "] 에 있습니다. ");
System.out.println(rooms[x + y* width].description());
}
}
case "south" -> {
if (y + 1 >= height || rooms[x + (y + 1) * width] == null) {
System.out.println("이동할 수 없습니다.");
} else {
y += 1;
System.out.println("당신은 [" + rooms[x + y * width].name() + "]에 있습니다.");
System.out.println(rooms[x + y * width].description());
}
}
case "east" -> {
if (x + 1 >= width || rooms[(x + 1 ) + y * width] == null) {
System.out.println("이동할 수 없습니다.");
} else {
x += 1;
System.out.println("당신은 [" + rooms[x + y * width].name() + "]에 있습니다.");
System.out.println(rooms[x + y * width].description());
}
}
case "west" -> {
if (x - 1 < 0 || rooms[(x - 1) + y * width] == null) {
System.out.println("이동할 수 없습니다.");
} else {
x -= 1;
System.out.println("당신은 [" + rooms[x + y * width].name() + "]에 있습니다.");
System.out.println(rooms[x + y * width].description());
}
}
default -> System.out.println("이해할 수 없는 명령어입니다.");
}
}
case "quit" -> running = false;
default -> System.out.println("이해할 수 없는 명령어입니다.");
}
}
System.out.println("\n 게임을 종료합니다.");
}
}
메서드 추출 리펙터링 : 독립적인 메서드도 추출한 후 의도를 드러내는 이름을 붙여라
긴 메서드를 작은 메서드로 분리하자
- 읽고 이해하기 쉬운 코드 -> 버그 수정, 성능 최적화, 메서드 추가 용이
- 작은 메서드를 조합하면 일련의 주석을 읽는 것 같은 코드 작성 가능
- 메서드 재사용성 향상(한가지의 명확한 작업만 수행하기 때문에)
- 메서드 오버라이딩 용이
메서드 추출 시 중요사항
- 의도를 드러내는 이름
- 메서드 이름은 메서드 호출자의 의도를 반영하도록 명명 (어떤 일을 하는지 표현)
- 메서드 호출자의 목표(what)를 설명하고 이를 달성하는 메커니즘이나 알고리즘(how)을 드러내지 말것
메서드 추출 이유
가독성 향상, 중복코드 제거, 불안정한 의존성 고립
단일 추상화 수준 원칙 : 메서드 내의 모든 코드는 동일한 추상화 수준에 위치해야한다.
조합 메서드 패턴 - 동일한 추상화 수준으로 유지
단일 추상화 수준 원칙을 따르는 메서드는 조합 메서드 형태를 뛴다.
how - 저수준
what - 고수준
추상화 수준이 섞여 있다면 로직을 이해하기 어렵다.
추상화 수준이 동일하다면 세부 사항을 모르더라도 메서드 목적을 이해할 수 있다.
단일 추상화 수준 원칙 적용하기
긴 메서드를 조합 메서드로 만들기
1. run 메서드에서 비슷한 목적을 가진 코드들을 함께 묶고 주석 달기
메서드 추출 리펙토링
긴 메서드 -> 조합 메서드
1.run 메서드에 비슷한 목적을 가진 코드들에 주석 달기
-> 주석들만 떼 읽어보면 run메서드가 하는 일을 한눈에 알 수 있음
2. 동일한 추상화 수준을 유지하면서 의도를 드러내는 이름 붙이기
메서드 이름: 메서드를 호출하는 클라이언트의 의도를 표현하는 이름
package hello.objectprinciple;
import java.util.Scanner;
public class Game {
private int width, height;
private Room[] rooms;
private int x, y;
private boolean running = false;
public Game() {
this.x = 0;
this.y = 2;
this.width = 2;
this.height = 3;
this.rooms = arrangeRoom(
new Room(0, 0, "샘", "아름다운 샘물이 흐르는 곳입니다. 이곳에서 휴식을 취할 수 있습니다."),
new Room(0, 1, "다리", "큰 강 위에 돌로 만든 커다란 다리가 있습니다."),
new Room(1, 1, "성", "용왕이 살고 있는 성에 도착했습니다."),
new Room(0, 2, "언덕", "저 멀리 성이 보이고 언덕 아래로 좁은 길이 나 있습니다."),
new Room(1, 2, "동굴", "어둠에 잠긴 동굴 안에 작은 화톳불이 피어 있습니다.")
);
}
private Room[] arrangeRoom(Room ... rooms) {
Room[] result = new Room[width * height];
for (var room : rooms) {
result[room.x() + room.y()* width] = room;
}
return result;
}
public void run() {
welcome();
play();
farewell();
}
private void welcome() {
// 환영 문구 출력
System.out.println("환영합니다!");
System.out.println("당신은 ["+ rooms[x + y* width].name() + "]에 있습니다.");
System.out.println(rooms[x + y* width].description());
System.out.println("다음 명령어를 사용할 수 있습니다.");
System.out.println("go {north|east|south|west} - 이동, quit - 게임 종료");
}
private void play() {
// 게임 플레이
Scanner scanner = new Scanner(System.in);
running = true;
while (running) {
System.out.print("> ");
String[] commands = scanner.nextLine().toLowerCase().trim().split("\\s+");
switch (commands[0]) {
case "go" -> {
switch (commands[1]) {
case "north" -> {
if(y-1 < 0 || rooms[x + (y -1)* width] == null) {
System.out.println("이동할 수 없습니다.");
} else {
y -=1;
System.out.println("당신은 ["+ rooms[x + y* width].name() + "]에 있습니다.");
System.out.println(rooms[x + y* width].description());
}
}
case "south" -> {
if (y + 1 >= height || rooms[x + (y + 1) * width] == null) {
System.out.println("이동할 수 없습니다.");
} else {
y += 1;
System.out.println("당신은 [" + rooms[x + y * width].name() + "]에 있습니다.");
System.out.println(rooms[x + y * width].description());
}
}
case "east" -> {
if (x + 1 >= width || rooms[(x + 1 ) + y * width] == null) {
System.out.println("이동할 수 없습니다.");
} else {
x += 1;
System.out.println("당신은 [" + rooms[x + y * width].name() + "]에 있습니다.");
System.out.println(rooms[x + y * width].description());
}
}
case "west" -> {
if (x - 1 < 0 || rooms[(x - 1) + y * width] == null) {
System.out.println("이동할 수 없습니다.");
} else {
x -= 1;
System.out.println("당신은 [" + rooms[x + y * width].name() + "]에 있습니다.");
System.out.println(rooms[x + y * width].description());
}
}
default -> System.out.println("이해할 수 없는 명령어입니다.");
}
}
case "quit" -> running = false;
default -> System.out.println("이해할 수 없는 명령어입니다.");
}
}
}
private void farewell() {
// 작별 문구 출력
System.out.println("\n게임을 종료합니다.");
}
}
run 메서드는 추상화 수준이 동일한 코드로 구성된 조합 메서드가 되었다.
-> 이해하고 수정하고 확장하기 쉬워졌다.
welcome 메서드를 조합 메서드로 구현해보자.
private void welcome() {
showGreeting();
showRoom();
showHelp();
}
private void showHelp() {
System.out.println("다음 명령어를 사용할 수 있습니다.");
System.out.println("go {north|east|south|west} - 이동, quit - 게임 종료");
}
private void showRoom() {
System.out.println("당신은 ["+ rooms[x + y* width].name() + "]에 있습니다.");
System.out.println(rooms[x + y* width].description());
}
private void showGreeting() {
System.out.println("환영합니다!");
}
동일한 추상화 수준으로 메서드를 조합하면 복잡성이 낮아지기 때문에 이해하기 쉬운 코드가 된다.
public void run() {
welcome();
play();
farewell();
}
메서드의 목적과 그 목적을 달성하기 위해 필요한 단계들을 한눈에 파악할 수 있다.
게임을 실행(run)하기 위해서
환영 메시지(welcome)를 출력하고
게임을 플레이(play) 한후
작별 메시지(farewell)를 출력한다.
메서드 수준의 응집도
메서드가 여러가지 이유로 변경된다면 낮은 응집도이다.
한가지 이유로 변경된다면 응집도가 높은 응집도이다.
play 메서드는 여전히 복잡하다.
작은 메서드로 나눠서 코드 가독성을 향상시켜보자.
각 방향별로 플레이어를 움직이는 로직이 숨겨져 있다.
의도를 명확히 표현하는 이름을 붙여서 메서드를 추출하면 play()를 이해하기 쉽게 개선할 수 있다.
개선 후 -> 플레이어가 동서남북 네 방향으로 이동한다는 사실을 쉽게 이해할 수 있다.
private void play() {
// 게임 플레이
Scanner scanner = new Scanner(System.in);
running = true;
while (running) {
System.out.print("> ");
String[] commands = scanner.nextLine().toLowerCase().trim().split("\\s+");
switch (commands[0]) {
case "go" -> {
switch (commands[1]) {
case "north" -> moveNorth();
case "south" -> moveSouth();
case "east" -> moveEast();
case "west" -> moveWest();
default -> System.out.println("이해할 수 없는 명령어입니다.");
}
}
case "quit" -> running = false;
default -> System.out.println("이해할 수 없는 명령어입니다.");
}
}
}
private void moveWest() {
if (x - 1 < 0 || rooms[(x - 1) + y * width] == null) {
System.out.println("이동할 수 없습니다.");
} else {
x -= 1;
showRoom();
}
}
private void moveEast() {
if (x + 1 >= width || rooms[(x + 1 ) + y * width] == null) {
System.out.println("이동할 수 없습니다.");
} else {
x += 1;
showRoom();
}
}
private void moveSouth() {
if (y + 1 >= height || rooms[x + (y + 1) * width] == null) {
System.out.println("이동할 수 없습니다.");
} else {
y += 1;
showRoom();
}
}
private void moveNorth() {
if(y-1 < 0 || rooms[x + (y -1)* width] == null) {
System.out.println("이동할 수 없습니다.");
} else {
y -=1;
showRoom();
}
}
의도가 명확하지 않은 인스턴스 변수 변경 로직 -> 추상화 수준을 맞추기 위해 메서드 추출
인스턴스 변수 running = true 로 초기화
while 문에서 running 체크하고 quit 일때 false 로 초기화 하는 부분이 이해하기 어렵다.
인스턴스 변수 running = true 로 초기화 -> 게임을 시작하기 위해서
while 문에서 running 체크 -> 게임이 현재 실행중인지 여부 판단을 위해서
quit 일때 false 로 초기화 하는 -> 게임 시작을 종료 시키기 위해
-> 이 의도를 메서드로 추출하자 (시작, 판단, 종료) : 코드가 한결 이해하기 쉬워졌다.
private void play() {
// 게임 플레이
Scanner scanner = new Scanner(System.in);
start();
while (isRunning()) {
System.out.print("> ");
String[] commands = scanner.nextLine().toLowerCase().trim().split("\\s+");
switch (commands[0]) {
case "go" -> {
switch (commands[1]) {
case "north" -> moveNorth();
case "south" -> moveSouth();
case "east" -> moveEast();
case "west" -> moveWest();
default -> System.out.println("이해할 수 없는 명령어입니다.");
}
}
case "quit" -> stop();
default -> System.out.println("이해할 수 없는 명령어입니다.");
}
}
}
private void stop() {
running = false;
}
private boolean isRunning() {
return running;
}
private void start() {
running = true;
}'JAVA' 카테고리의 다른 글
| 스프링 데이터 JPA는 인터페이스만 정의했는데 어떻게 동작할까? (0) | 2025.10.07 |
|---|---|
| 외부 연동 방식 변경과 어댑터 패턴 적용 (3) | 2025.08.03 |
| 5-1 단일 책임 원칙 (1) | 2025.05.29 |
| 동시성 문제와 해결 방안 (0) | 2025.04.09 |
| Java 예외 처리, 제대로 알고 쓰자 (1) | 2025.03.26 |