내일배움캠프의 두 번째 자바 연습 과제로 매우 단순화된 키오스크를 구현하는 과제가 나왔다. 간단히 작동방식을 보면 다음과 같이 입출력을 처리하는 게 전부다.
[ MAIN MENU ]
1. Burgers
2. Drinks
3. Desserts
0. 종료 | 종료
1 <- // 1을 입력
[ BURGERS MENU ]
1. ShackBurger | W 6.9 | 토마토, 양상추, 쉑소스가 토핑된 치즈버거
2. SmokeShack | W 8.9 | 베이컨, 체리 페퍼에 쉑소스가 토핑된 치즈버거
3. Cheeseburger | W 6.9 | 포테이토 번과 비프패티, 치즈가 토핑된 치즈버거
4. Hamburger | W 5.4 | 비프패티를 기반으로 야채가 들어간 기본버거
0. 뒤로가기
2 <- // 2를 입력
선택한 메뉴: SmokeShack | W 8.9 | 베이컨, 체리 페퍼에 쉑소스가 토핑된 치즈버거
[ MAIN MENU ]
1. Burgers
2. Drinks
3. Desserts
0. 종료 | 종료
0 <- // 0을 입력
프로그램을 종료합니다.
자판기가 유한 상태 기계(FSM)로 표현될 수 있다는 예전 학습 내용이 떠올라, 키오스크도 비슷하게 될 수 있지 않을까 생각했다. 그래서 간단히 1) 메인 메뉴 상태, 2) 구체 메뉴 상태로 나누었다. 여기에 상태라는 말에서 GoF의 상태 패턴과 유사할 것 같다는 생각이 들어 상태 패턴을 적용해보고자 하였다.
상태 패턴은 내부 상태에 따라 객체가 다른 동작을 하게 만들고 싶을 때 사용한다. 즉 분기 처리를 객체에 위임하는 방식이다.

State는 인터페이스이며 객체가 할 수 있는 동작을 정의한다. Context 객체는 State를 가지고 있으며 실제 요청의 처리(Handle())를 State 구현체인 ConcreteState에 위임한다.
책에서는 상태 전이의 방법으로 세 가지 예시를 들었고, 장단점은 다음과 같다.
Context에서 직접 if-else나 switch로 분기한다. → 상태 개수가 적고 전이가 간단할 때 가장 빠르게 구현이 가능하다. 하지만 확장성이 떨어진다는 단점이 있다.
ConcreteState가 다른 ConcreteState를 생성하고, Context.setState()를 호출하거나 다음 State를 반환한다. → 동적으로 State 객체를 바꿀 수 있다. 하지만 ConcreteState들이 서로를 생성하게 되어 ConcreteState 간 의존성이 생긴다.
상태 전이 테이블을 생성하고 전이 시 참조한다. → 모든 상태 전이를 한 곳에서 볼 수 있다. 하지만 표로 나타내기 위해서는 가능한 모든 (상태, 입력 or 행동) 쌍에 대해 다음 상태를 만들어야 하기 때문에 입력을 추가하기 어려울 수 있다.

public class Kiosk {
private State state;
public Kiosk(State state) {
this.state = state;
}
public void start() {
Optional<State> optionalState = Optional.of(state);
while(optionalState.isPresent()) {
state = optionalState.get();
state.printOptions();
int input = state.getUserInput();
optionalState = state.processInput(input);
}
}
}
상태 패턴에서 Context에 해당하는 클래스이다. 초기 State를 DefaultState로 받은 뒤, 종료 상태(Optional.EMPTY)가 될 때까지 State의 동작을 반복적으로 지시한다.
State 인터페이스 public interface State {
void printOptions();
int getUserInput();
Optional<State> processInput(int userInput);
}
각각 메뉴 표시, 입력 받기, 입력 처리하기를 나타낸다.
State 클래스 예시 - CategoryChosenState public class CategoryChosenState implements State {
private final ItemStore itemStore;
private final InputHandler inputHandler;
private final Category chosenCategory;
public CategoryChosenState(ItemStore itemStore, InputHandler inputHandler, int userInput) {
this.itemStore = itemStore;
this.inputHandler = inputHandler;
this.chosenCategory = itemStore.getCategory(userInput - 1);
}
@Override
public void printOptions() {
chosenCategory.printItemsInCategory();
System.out.println("0. 뒤로가기");
}
@Override
public int getUserInput() {
int itemCount = chosenCategory.getSize();
return inputHandler.getIntegerInRange(0, itemCount);
}
@Override
public Optional<State> processInput(int userInput) {
if (userInput > 0) {
System.out.println(chosenCategory.getItem(userInput - 1));
}
return Optional.of(new DefaultState(itemStore, inputHandler));
}
}
Category를 선택한 상태를 나타내는 클래스이다. Category 내부 Item들을 불러오기 위해 ItemStore를 주입받았다.
다음 상태를 판단하여 Optional<State>로 반환한다.
Category와 Item)을 어떻게 넘겨줄까?처음에는 직접 생성자로 List<Category> 처럼 각 상태에 필요한 정보만 넘겨주는 걸 생각했다. 하지만 이 경우 CategoryChosenState가 List<Item>을 갖고 있어 다시 List<Category>를 넘겨줄 수가 없었다.
다음으로는 StateFactory와 같은 객체를 만들어 필요한 정보를 넘겨줄까 싶었다. 그래서 다음 클래스를 만들었다. 이것은 초기 버전이라 구조가 다르기 때문에, 대충 값 주입을 외부로 분리하려고 시도했다 정도로만 보면 되겠다.
public class StateFactory {
private final Kiosk context;
public StateFactory(Kiosk context) {
this.context = context;
}
public KioskState createHomeState() {
return new HomeState(context.getCategories());
}
public KioskState createCategoryChosenState(int selectedCategoryIndex) {
return new CategoryChosen(context.getMenus().get(selectedMenuIndex));
}
}
Context가 프로그램 상태를 들고 있어야 한다고 생각했기에 Context로부터 받아오는 방식을 썼는데, 막상 Context에서는 안 쓰므로, 여기에 직접 넣었어도 괜찮았을 거라는 생각이 든다. 하지만 그렇게 되면 팩토리가 데이터를 직접 갖는 구조가 되어 조금 이상했다.
마지막으로는 위의 구조대로 State에 직접 데이터를 들고 올 수 있는 클래스인 ItemStore를 넣어주었다.
public class ItemStore {
private final List<Category> categories;
public ItemStore(List<Category> categories) {
this.categories = categories;
}
public List<Category> getCategories() {
return categories.stream().toList();
}
public Category getCategory (int index) {
return categories.get(index);
}
이 구조를 사용한 이유는 Context로의 불필요한 의존성을 최대한 제거하기 위해서였다. 하지만 새로운 상태가 추가될 때 (e.g. Cart), 어떻게 추가해야 할지 애매하다. 해당 값은 단순 데이터이기에 읽기 전용으로 다뤄야 한다고 생각하여 stream().toList()로 불변 객체로 변환해 반환하였다. Category에는 세터가 없으므로 자동으로 읽기 전용이다.
위 3가지 방법 중 Context에서 분기로 처리하는 방법이 가장 단순하고 적합해보였으나, 일부러 복잡한 방법을 적용해보고자 했다. 하지만 전이 테이블은 List<Category>의 길이를 알 수 없어 적용하기가 까다로웠다. 그리고 Context를 참조하지 않게 만들기 위해 Context.setState() 대신 State를 반환하게 했다.
Cart (장바구니) 확장추가 요구사항으로 장바구니에 아이템 추가 구현이 있었다. 다음과 같은 방법을 생각해보았는데, 모두 마음에 들지 않아 적용하지 않았다. 실제 제품이었다면 어떻게든 완성해야겠지만, 아직은 설계를 연습하는 걸 중점으로 두고 싶었다.
별도의 CartStore를 만들고 상태 객체의 필드로 주입한다. → 이런 전역 상태가 추가될 때마다 상태에 추가해줘야 한다.
전체 프로그램 상태를 객체로 감싼다. → 아무데서나 상태를 변경할 수 있어 불안정성이 증가한다.
Context로 다시 정보들을 옮기고, 상태 객체들은 Context를 참조하게 바꾼다. → 전역 상태가 늘어나면 Context의 책임이 너무 거대해질 수 있다.
지금으로서는 다시 Context에 전역 상태값을 돌려놓고, 게터/세터로 열어두는 방식이 가장 간단해 보인다.
하나만 만들려면 객체가 stateless 해야 한다. 하지만 이번 경우에는 CategoryChosenState가 어떤 Category가 선택됐는지 알아야 했다. 그러면 생성자나 메소드 매개변수로 넘겨주어야 하는데 매개변수는 인터페이스를 바꿔야 하므로 생성자를 통해 주입해줄 수밖에 없었다. 다만 사용자가 시스템 부하가 커질만큼 빠르게 상태를 변경할 수 없기에 문제가 되지는 않을 것 같다.
개인적인 생각으론 상태 패턴을 적용한 것보다 단순 반복문과 분기가 더 나을 것 같다는 생각이 든다. 이유는 다음과 같다.
상태가 객체 지향적으로 깔끔하게 표현되지 않는다.
요구사항 중 Cart가 비어있지 않은 경우 추가 메뉴를 출력하는 것이 있다. 이러면 Cart가 비어있는 DefaultState와 그렇지 않은 상태를 분리하거나 적어도 boolean 필드를 갖도록 만들어야하는데 부자연스럽다.
상태가 데이터를 알아야 동작할 수 있는데, 전역 상태값들(Cart, selectedItem)등을 상태 객체에 주입하고 cart.addItem() 등으로 조작하는 것도 부자연스럽다.
List<Category>나 List<Item> 길이에 따라 올바른 입력 범위가 변하는데 이걸 전역 상태를 관리하지 않고 컴파일 타임에 결정하는 것이 불가능하다. 결과적으로 상태 객체가 데이터를 주입받아야 한다.
상태의 수가 적고, 확장 가능성도 낮다. = 별도의 계층을 도입하는 것은 오버 엔지니어링이다.
가장 큰 문제는 입출력의 범위가 가변적이고, 상태가 데이터에 의존한다는 점이었던 것 같다. 보통 FSM은 가능한 입력값 매우 한정적인데, 이 경우 Item과 Category 개수에 따라서 가변적이다. 그리고 상태가 데이터나 추가 상태(e.g. 고른 Category, Cart에 추가할 Item, Cart)를 필요로 한다는 점도 구현이 복잡해지고, 객체지향을 지키기 어려워지는 결과에 기여한 것 같다.
Drinks
"Blood",4.0,"피"
"Sweat",0.5,"땀"
"Tear",10.0,"눈물"
Desserts
...
의 형식을 가진 파일을 ./src/main/resources/menu.data 경로에 저장해두면, 자동으로 Category와 Item을 만들어준다. Json을 사용했으면 더 깔끔하지만 라이브러리에 의존하게 되기도 하고, 한 번 파싱 과정을 구현해보려고 직접 짜보았다. 다만 문자열 처리가 까다로워 java regex의 도움을 받았다.
public class Init {
public static List<Category> initCategories() {
try (BufferedReader br = new BufferedReader(
new FileReader(new File("./src/main/resources/menu.data")))) {
List<Category> categories = new ArrayList<>();
String currentCategory = null;
String line;
while ((line = br.readLine()) != null) {
currentCategory = line;
List<Item> items = new ArrayList<>();
while ((line = br.readLine()) != null && !line.isEmpty()) {
String pattern = "\"([^\"]+)\",([0-9.]+),\"([^\"]+)\"";
Pattern r = Pattern.compile(pattern);
Matcher m = r.matcher(line);
if (m.find()) {
String name = m.group(1);
double price = Double.parseDouble(m.group(2));
String description = m.group(3);
Item item = new Item(name, price, description);
items.add(item);
}
}
Category category = new Category(items, currentCategory);
categories.add(category);
}
return categories;
} catch (IOException e) {
System.out.println(e.getMessage());
throw new RuntimeException("Failed to initialize menu");
}
}
}