Vending Machine

LLD

Understand it step by step (한국어로 이해 → 영어로 말하기)

자판기(Vending Machine)를 객체지향으로 설계하는 문제입니다. 사용자는 동전을 넣고, 상품을 고르고, 상품과 거스름돈을 받습니다. 핵심 요구사항은 동전 투입, 상품 선택, 거스름돈 계산, 재고 관리, 환불, 그리고 자판기 상태(대기/수금/배출) 관리입니다.
  1. 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. 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. 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. 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. 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. 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. 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. 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());
    }
}
🎙 이 카드 AI랑 음성 대화 · 기록 저장