Understand it step by step (한국어로 이해 → 영어로 말하기)
- 1
먼저 요구사항을 정리하고 핵심 엔티티를 찾습니다. 동전은 종류가 정해져 있으니 enum으로 만듭니다. 각 동전은 금액(cents)을 값으로 가집니다.
⚖️ Trade-off: 동전을 그냥 int 금액으로 받을지, enum으로 제한할지 고민됩니다. int로 받으면 자유롭지만 13원 같은 잘못된 값도 들어옵니다.
✅ Fix: 실제 자판기는 정해진 동전만 받으므로 enum으로 종류를 고정해 잘못된 입력을 컴파일 단계에서 막습니다.
🔧 도구:enumtype-safety
enum Coin { PENNY(1), NICKEL(5), DIME(10), QUARTER(25); final int cents; Coin(int c) { this.cents = c; } }🗣 영어로 말해“I model coins as an enum so only valid denominations can be inserted.”
checking microphone…
- 2
다음으로 상품(Product)을 만듭니다. 상품은 코드, 이름, 가격을 가집니다. 값이 바뀌지 않으니 불변(immutable) 객체로 final 필드를 씁니다.
⚖️ Trade-off: 가격을 double로 둘지 int cents로 둘지 고민됩니다. double은 부동소수점 오차로 거스름돈 계산이 틀어질 수 있습니다.
✅ Fix: 금액은 모두 정수 cents로 다뤄 부동소수점 오차를 피합니다.
🔧 도구:immutabilityvalue object
static final class Product { final String code; final String name; final int priceCents; Product(String code, String name, int priceCents) { this.code = code; this.name = name; this.priceCents = priceCents; } }🗣 영어로 말해“Product is an immutable value object and prices are stored as integer cents.”
checking microphone…
- 3
상품과 재고를 묶기 위해 Slot을 만듭니다. 한 슬롯은 하나의 상품과 남은 수량을 가집니다. 자판기는 슬롯 코드(A1 등)로 슬롯을 찾습니다.
⚖️ Trade-off: Product 안에 수량을 넣을지, 별도 Slot으로 분리할지 고민됩니다. Product에 넣으면 상품 정보와 재고가 섞입니다.
✅ Fix: 단일 책임 원칙(SRP)에 따라 상품 정보(Product)와 재고/위치(Slot)를 분리합니다. 컴포지션으로 Slot이 Product를 가집니다.
🔧 도구:composition-over-inheritanceSRP
static final class Slot { final Product product; int quantity; Slot(Product product, int quantity) { this.product = product; this.quantity = quantity; } }🗣 영어로 말해“A Slot composes a Product with its stock count, keeping product data and inventory separate.”
checking microphone…
- 4
자판기의 상태를 enum으로 표현합니다. 대기(IDLE), 동전 수금 중(COLLECTING), 배출 중(DISPENSING)으로 나눕니다. 상태에 따라 허용되는 동작이 달라집니다.
⚖️ Trade-off: 상태를 boolean 플래그 여러 개로 둘 수도 있지만, 플래그 조합이 모순될 수 있습니다(예: 수금 중인데 배출 중).
✅ Fix: 상태를 하나의 enum으로 모아 동시에 한 상태만 갖도록 강제하고, State 패턴의 단순한 형태로 전이를 관리합니다.
🔧 도구:Stateenum
enum VendingState { IDLE, COLLECTING, DISPENSING }🗣 영어로 말해“A single state enum guarantees the machine is in exactly one valid state at a time.”
checking microphone…
- 5
이제 VendingMachine 본체를 만듭니다. 슬롯들을 Map으로 보관하고 현재 투입 잔액과 상태를 가집니다. 동전을 넣으면 잔액이 올라가고 상태가 COLLECTING이 됩니다.
⚖️ Trade-off: 잔액(balance) 같은 거래 상태를 어디에 둘지 고민됩니다. 외부에 노출하면 누구나 잔액을 조작할 수 있습니다.
✅ Fix: 잔액과 상태를 private으로 캡슐화하고 insertCoin/select/refund 메서드로만 변경되게 합니다.
🔧 도구:encapsulationfacade
private final Map<String, Slot> slots = new HashMap<>(); private int balanceCents = 0; private VendingState state = VendingState.IDLE; void insertCoin(Coin coin) { if (state == VendingState.DISPENSING) throw new IllegalStateException("Dispensing in progress"); balanceCents += coin.cents; state = VendingState.COLLECTING; }🗣 영어로 말해“The machine encapsulates balance and state, mutated only through its public methods.”
checking microphone…
- 6
핵심 동작인 select(상품 선택)를 구현합니다. 슬롯 존재, 재고, 잔액을 검증한 뒤 거스름돈을 계산하고 재고를 줄이고 잔액을 0으로 만듭니다. 반환값으로 상품과 거스름돈을 함께 줍니다.
⚖️ Trade-off: 검증 실패 시 어떻게 알릴지 고민됩니다. null 반환은 호출자가 무시하기 쉽습니다.
✅ Fix: 품절/잔액 부족 같은 잘못된 상태는 IllegalStateException으로, 없는 슬롯은 NoSuchElementException으로 명확히 던집니다.
🔧 도구:fail-fastexception handling
Map.Entry<Product, Integer> select(String code) { Slot slot = slots.get(code); if (slot == null) throw new NoSuchElementException("No slot " + code); if (slot.quantity <= 0) throw new IllegalStateException("Sold out"); if (balanceCents < slot.product.priceCents) throw new IllegalStateException("Insufficient funds"); state = VendingState.DISPENSING; int change = balanceCents - slot.product.priceCents; slot.quantity--; balanceCents = 0; state = VendingState.IDLE; return Map.entry(slot.product, change); }🗣 영어로 말해“Select validates slot, stock and funds, then dispenses and returns the product with change.”
checking microphone…
- 7
엣지 케이스와 동시성을 다룹니다. 환불(refund)로 사용자가 잔액을 돌려받을 수 있게 하고, 여러 사람이 동시에 누르는 상황을 생각합니다.
⚖️ Trade-off: 동시성을 어디까지 막을지 고민됩니다. 메서드 전체에 synchronized를 걸면 안전하지만 처리량이 떨어집니다.
✅ Fix: 물리 자판기는 한 사람만 조작하니 단순 synchronized로 insertCoin/select/refund를 직렬화하거나, 분산이면 슬롯 단위 락으로 확장합니다. 우선 refund를 추가합니다.
🔧 도구:concurrencysynchronized
synchronized int refund() { int r = balanceCents; balanceCents = 0; state = VendingState.IDLE; return r; }🗣 영어로 말해“Refund returns the balance, and synchronizing the mutating methods makes operations atomic.”
checking microphone…
- 8
마지막으로 확장 지점을 설명합니다. 거스름돈을 실제 동전 개수로 계산하는 정책은 ChangeStrategy 인터페이스로 빼면 그리디/최적 알고리즘을 갈아끼울 수 있습니다.
⚖️ Trade-off: 거스름돈 로직을 VendingMachine 안에 그냥 둘지, 전략으로 뺄지 고민됩니다. 안에 두면 간단하지만 정책 교체가 어렵습니다.
✅ Fix: 거스름돈 정책을 Strategy 패턴으로 분리해 OCP(개방-폐쇄 원칙)를 지키고, 결제 수단 추가도 PaymentMethod 인터페이스로 열어둡니다.
🔧 도구:StrategyOCPinterface
interface ChangeStrategy { Map<Coin, Integer> makeChange(int amountCents, Map<Coin, Integer> available); }🗣 영어로 말해“I extract change-making as a Strategy interface so the policy can vary without touching the machine.”
checking microphone…
8단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.
Full Java (javac-verified)
import java.util.*;
public class Solution {
enum Coin {
PENNY(1), NICKEL(5), DIME(10), QUARTER(25);
final int cents;
Coin(int c) { this.cents = c; }
}
static final class Product {
final String code;
final String name;
final int priceCents;
Product(String code, String name, int priceCents) {
this.code = code; this.name = name; this.priceCents = priceCents;
}
}
static final class Slot {
final Product product;
int quantity;
Slot(Product product, int quantity) { this.product = product; this.quantity = quantity; }
}
enum VendingState { IDLE, COLLECTING, DISPENSING }
static final class VendingMachine {
private final Map<String, Slot> slots = new HashMap<>();
private int balanceCents = 0;
private VendingState state = VendingState.IDLE;
void loadSlot(String code, Product p, int qty) {
slots.put(code, new Slot(p, qty));
}
synchronized void insertCoin(Coin coin) {
if (state == VendingState.DISPENSING)
throw new IllegalStateException("Dispensing in progress");
balanceCents += coin.cents;
state = VendingState.COLLECTING;
}
// Returns the dispensed product plus change in cents.
synchronized Map.Entry<Product, Integer> select(String code) {
Slot slot = slots.get(code);
if (slot == null) throw new NoSuchElementException("No slot " + code);
if (slot.quantity <= 0) throw new IllegalStateException("Sold out");
if (balanceCents < slot.product.priceCents)
throw new IllegalStateException("Insufficient funds");
state = VendingState.DISPENSING;
int change = balanceCents - slot.product.priceCents;
slot.quantity--;
balanceCents = 0;
state = VendingState.IDLE;
return Map.entry(slot.product, change);
}
synchronized int refund() {
int r = balanceCents;
balanceCents = 0;
state = VendingState.IDLE;
return r;
}
}
public static void main(String[] args) {
VendingMachine vm = new VendingMachine();
vm.loadSlot("A1", new Product("A1", "Cola", 75), 2);
vm.insertCoin(Coin.QUARTER);
vm.insertCoin(Coin.QUARTER);
vm.insertCoin(Coin.QUARTER);
vm.insertCoin(Coin.QUARTER);
Map.Entry<Product, Integer> result = vm.select("A1");
System.out.println("Got " + result.getKey().name + ", change=" + result.getValue());
}
}