Parking Lot

LLD

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

주차장을 객체지향으로 설계한다 — 차량·주차 자리·티켓·요금 정책. 작은 차는 큰 자리에 들어갈 수 있고, 요금 정책은 나중에 갈아끼울 수 있어야 한다.
  1. 1

    먼저 요구사항을 정리하고 핵심 개념을 enum과 추상 클래스로 나눕니다. 차량 크기를 enum VehicleSize로 만들고, 차량은 공통 속성(번호판, 크기)을 가지므로 추상 클래스 Vehicle로 두고 Motorcycle/Car/Truck이 상속하게 합니다.

    ⚖️ Trade-off: 차량 종류를 enum 하나로 둘지, 클래스 상속으로 둘지 고민됩니다. enum은 간단하지만 종류마다 다른 동작을 넣기 어렵습니다.

    ✅ Fix: 크기는 값이라 enum이 맞고, 차량 자체는 나중에 종류별 동작이 생길 수 있으니 추상 클래스로 둡니다. 둘을 섞어서 씁니다.

    🔧 도구:enum vs classinheritanceSRP

    enum VehicleSize { MOTORCYCLE, COMPACT, LARGE }
    
    abstract class Vehicle {
        final String plate;
        final VehicleSize size;
        Vehicle(String plate, VehicleSize size) { this.plate = plate; this.size = size; }
    }
    class Car extends Vehicle { Car(String p) { super(p, VehicleSize.COMPACT); } }
    class Truck extends Vehicle { Truck(String p) { super(p, VehicleSize.LARGE); } }
    🗣 영어로 말해

    I model vehicle size as an enum and the vehicle itself as an abstract class with subtypes.

    checking microphone…

  2. 2

    이제 주차 자리를 ParkingSpot 클래스로 만듭니다. 각 자리는 고유 id, 수용 가능한 크기, 그리고 현재 주차된 차량을 가집니다. 자리가 비었는지(isFree), 이 차가 들어갈 수 있는지(fits) 확인하는 메서드를 둡니다.

    ⚖️ Trade-off: fits 판단을 어디에 둘지가 문제입니다. ParkingLot에서 if문으로 비교할 수도 있습니다.

    ✅ Fix: '이 자리에 이 차가 맞는가'는 자리의 책임이므로 ParkingSpot.fits 안에 둡니다. 작은 차는 큰 자리에 들어갈 수 있으므로 enum의 ordinal로 크기를 비교합니다.

    🔧 도구:encapsulationInformation ExpertSRP

    class ParkingSpot {
        final String id;
        final VehicleSize size;
        private Vehicle vehicle;
        ParkingSpot(String id, VehicleSize size) { this.id = id; this.size = size; }
        boolean isFree() { return vehicle == null; }
        boolean fits(Vehicle v) { return v.size.ordinal() <= size.ordinal(); }
        void park(Vehicle v) { this.vehicle = v; }
        Vehicle remove() { Vehicle v = vehicle; vehicle = null; return v; }
    }
    🗣 영어로 말해

    Each spot knows whether it's free and whether a vehicle fits, keeping that logic local.

    checking microphone…

  3. 3

    입차 증거로 Ticket 클래스를 만듭니다. 티켓은 티켓 id, 차량 번호판, 어느 자리에 주차했는지(spotId), 그리고 입차 시각을 담습니다. 출차할 때 이 입차 시각으로 요금을 계산합니다.

    ⚖️ Trade-off: 입차 시각을 Date.now()로 잡을지 인자로 받을지 고민됩니다.

    ✅ Fix: 테스트 가능성과 결정성을 위해 시각을 직접 호출하지 않고 nowMillis를 인자로 주입받습니다. 티켓은 불변(final 필드)으로 둡니다.

    🔧 도구:immutabilitydependency injection (time)

    class Ticket {
        final String id;
        final String plate;
        final String spotId;
        final long entryMillis;
        Ticket(String id, String plate, String spotId, long entryMillis) {
            this.id = id; this.plate = plate; this.spotId = spotId; this.entryMillis = entryMillis;
        }
    }
    🗣 영어로 말해

    A ticket records the spot and entry time, and I inject time instead of calling the clock.

    checking microphone…

  4. 4

    요금 계산을 FeeStrategy 인터페이스로 분리합니다. 시간당 정액 요금(FlatHourlyFee) 구현을 하나 만듭니다. 나중에 주말 할인, 첫 30분 무료 같은 다른 정책이 와도 ParkingLot 코드를 바꾸지 않고 전략만 갈아끼웁니다.

    ⚖️ Trade-off: 요금 계산을 ParkingLot 안에 직접 계산식으로 넣으면 간단하지만, 요금 정책이 바뀔 때마다 ParkingLot을 수정해야 합니다.

    ✅ Fix: 요금 정책은 자주 바뀌는 부분이므로 Strategy 패턴으로 뽑아내 OCP(개방-폐쇄 원칙)를 지킵니다.

    🔧 도구:Strategy patternOCPcomposition-over-inheritance

    interface FeeStrategy { long fee(long minutes); }
    
    class FlatHourlyFee implements FeeStrategy {
        private final long ratePerHour;
        FlatHourlyFee(long ratePerHour) { this.ratePerHour = ratePerHour; }
        public long fee(long minutes) {
            return ((minutes + 59) / 60) * ratePerHour; // 올림 처리
        }
    }
    🗣 영어로 말해

    I extract pricing behind a strategy interface so new fee rules don't touch the lot.

    checking microphone…

  5. 5

    이제 핵심인 ParkingLot을 조립합니다. 자리 목록, 발급된 티켓 맵, 요금 전략을 가집니다. park()는 차에 맞는 첫 빈 자리를 찾아 주차하고 티켓을 발급합니다. unpark()는 티켓으로 자리를 비우고 요금을 돌려줍니다.

    ⚖️ Trade-off: 빈 자리를 찾는 방식을 매번 전체 순회로 할지, 크기별 큐를 따로 둘지 고민됩니다.

    ✅ Fix: v1은 단순함을 위해 리스트 순회로 첫 자리(first-fit)를 찾습니다. 자리 수가 많아지면 크기별 사용 가능 자리 큐로 O(1) 배정으로 확장할 수 있습니다.

    🔧 도구:compositionFacade-like coordinatorSRP

    class ParkingLot {
        private final List<ParkingSpot> spots;
        private final Map<String, Ticket> active = new ConcurrentHashMap<>();
        private final FeeStrategy feeStrategy;
        private long ticketSeq = 0;
        ParkingLot(List<ParkingSpot> spots, FeeStrategy fee) {
            this.spots = spots; this.feeStrategy = fee;
        }
    }
    🗣 영어로 말해

    The lot composes spots, tickets, and a fee strategy, and coordinates park and unpark.

    checking microphone…

  6. 6

    동시성을 처리합니다. 두 차가 동시에 같은 빈 자리를 차지하는 경쟁 상태를 막아야 합니다. 자리를 찾고 점유하는 park()와 출차하는 unpark()를 ReentrantLock으로 감싸 한 번에 한 스레드만 자리 상태를 바꾸게 합니다.

    ⚖️ Trade-off: 전체 lot에 하나의 락을 걸면 안전하지만, 차가 몰리면 처리량이 떨어집니다.

    ✅ Fix: v1은 정확성이 우선이라 lot 단위 단일 락으로 갑니다. 확장 시 크기별/구역별로 락을 쪼개 경쟁을 줄일 수 있습니다.

    🔧 도구:ReentrantLockconcurrencyrace condition

    Optional<Ticket> park(Vehicle v, long nowMillis) {
        lock.lock();
        try {
            for (ParkingSpot s : spots) {
                if (s.isFree() && s.fits(v)) {
                    s.park(v);
                    Ticket t = new Ticket("T" + (++ticketSeq), v.plate, s.id, nowMillis);
                    active.put(t.id, t);
                    return Optional.of(t);
                }
            }
            return Optional.empty(); // 만차
        } finally { lock.unlock(); }
    }
    🗣 영어로 말해

    I guard park and unpark with a lock so two cars never grab the same spot.

    checking microphone…

  7. 7

    엣지 케이스를 다룹니다. 만차일 때 park()는 예외 대신 Optional.empty()를 돌려줘 호출자가 자연스럽게 처리하게 합니다. 잘못된 티켓으로 출차하면 IllegalArgumentException을 던집니다. 최소 1분 요금을 보장해 0원이 나오지 않게 합니다.

    ✅ Fix: 정상 흐름(만차)은 Optional로, 비정상 입력(가짜 티켓)은 예외로 구분합니다. 시간은 분 단위로 내림하되 최소 1분으로 막습니다.

    🔧 도구:Optionalfail-fast validationdefensive programming

    long unpark(String ticketId, long nowMillis) {
        lock.lock();
        try {
            Ticket t = active.remove(ticketId);
            if (t == null) throw new IllegalArgumentException("invalid ticket");
            spots.stream().filter(s -> s.id.equals(t.spotId))
                 .forEach(ParkingSpot::remove);
            long minutes = Math.max(1, (nowMillis - t.entryMillis) / 60000);
            return feeStrategy.fee(minutes);
        } finally { lock.unlock(); }
    }
    🗣 영어로 말해

    Full lot returns empty, a bad ticket throws, and I always charge at least one minute.

    checking microphone…

  8. 8

    마지막으로 확장 지점을 짚습니다. 새 요금 정책은 FeeStrategy 구현만 추가하면 됩니다. 새 차량 종류는 Vehicle을 상속하고 크기를 정하면 됩니다. 자리 배정 알고리즘(first-fit -> 크기별 큐)이나 다층 구조(여러 층, 출입구)는 ParkingLot 내부만 바꾸면 외부 인터페이스는 그대로입니다.

    ⚖️ Trade-off: 지금 모든 확장을 미리 추상화하면 복잡도만 늘어납니다(오버엔지니어링).

    ✅ Fix: 실제로 두 번째 구현이 필요해진 지점(요금)만 인터페이스로 뽑고, 나머지는 명확한 확장 지점만 남겨둡니다.

    🔧 도구:OCPYAGNIStrategy

    // 확장 예시: 첫 30분 무료 정책
    class FreeFirstHalfHour implements FeeStrategy {
        private final long ratePerHour;
        FreeFirstHalfHour(long r) { this.ratePerHour = r; }
        public long fee(long minutes) {
            long billable = Math.max(0, minutes - 30);
            return ((billable + 59) / 60) * ratePerHour;
        }
    }
    🗣 영어로 말해

    New fee rules or vehicle types plug in without changing the parking lot core.

    checking microphone…

8단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.

Full Java (javac-verified)

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;

enum VehicleSize { MOTORCYCLE, COMPACT, LARGE }

abstract class Vehicle {
    final String plate;
    final VehicleSize size;
    Vehicle(String plate, VehicleSize size) { this.plate = plate; this.size = size; }
}
class Motorcycle extends Vehicle { Motorcycle(String p) { super(p, VehicleSize.MOTORCYCLE); } }
class Car extends Vehicle { Car(String p) { super(p, VehicleSize.COMPACT); } }
class Truck extends Vehicle { Truck(String p) { super(p, VehicleSize.LARGE); } }

class ParkingSpot {
    final String id;
    final VehicleSize size;
    private Vehicle vehicle;
    ParkingSpot(String id, VehicleSize size) { this.id = id; this.size = size; }
    boolean isFree() { return vehicle == null; }
    boolean fits(Vehicle v) { return v.size.ordinal() <= size.ordinal(); }
    void park(Vehicle v) { this.vehicle = v; }
    Vehicle remove() { Vehicle v = vehicle; vehicle = null; return v; }
}

class Ticket {
    final String id;
    final String plate;
    final String spotId;
    final long entryMillis;
    Ticket(String id, String plate, String spotId, long entryMillis) {
        this.id = id; this.plate = plate; this.spotId = spotId; this.entryMillis = entryMillis;
    }
}

interface FeeStrategy { long fee(long minutes); }
class FlatHourlyFee implements FeeStrategy {
    private final long ratePerHour;
    FlatHourlyFee(long ratePerHour) { this.ratePerHour = ratePerHour; }
    public long fee(long minutes) { return ((minutes + 59) / 60) * ratePerHour; }
}

class ParkingLot {
    private final List<ParkingSpot> spots;
    private final Map<String, Ticket> active = new ConcurrentHashMap<>();
    private final FeeStrategy feeStrategy;
    private final ReentrantLock lock = new ReentrantLock();
    private long ticketSeq = 0;

    ParkingLot(List<ParkingSpot> spots, FeeStrategy feeStrategy) {
        this.spots = spots; this.feeStrategy = feeStrategy;
    }

    Optional<Ticket> park(Vehicle v, long nowMillis) {
        lock.lock();
        try {
            for (ParkingSpot s : spots) {
                if (s.isFree() && s.fits(v)) {
                    s.park(v);
                    Ticket t = new Ticket("T" + (++ticketSeq), v.plate, s.id, nowMillis);
                    active.put(t.id, t);
                    return Optional.of(t);
                }
            }
            return Optional.empty();
        } finally { lock.unlock(); }
    }

    long unpark(String ticketId, long nowMillis) {
        lock.lock();
        try {
            Ticket t = active.remove(ticketId);
            if (t == null) throw new IllegalArgumentException("invalid ticket");
            spots.stream().filter(s -> s.id.equals(t.spotId)).forEach(ParkingSpot::remove);
            long minutes = Math.max(1, (nowMillis - t.entryMillis) / 60000);
            return feeStrategy.fee(minutes);
        } finally { lock.unlock(); }
    }
}

public class Solution {
    public static void main(String[] args) {
        List<ParkingSpot> spots = List.of(
            new ParkingSpot("M1", VehicleSize.MOTORCYCLE),
            new ParkingSpot("C1", VehicleSize.COMPACT),
            new ParkingSpot("L1", VehicleSize.LARGE)
        );
        ParkingLot lot = new ParkingLot(spots, new FlatHourlyFee(2));
        Ticket t = lot.park(new Car("KOR-1"), 0).orElseThrow();
        System.out.println("Parked at " + t.spotId);
        System.out.println("Fee = " + lot.unpark(t.id, 90 * 60000));
    }
}
🎙 이 카드 AI랑 음성 대화 · 기록 저장