Understand it step by step (한국어로 이해 → 영어로 말하기)
- 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
이제 주차 자리를 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
입차 증거로 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
요금 계산을 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
이제 핵심인 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
동시성을 처리합니다. 두 차가 동시에 같은 빈 자리를 차지하는 경쟁 상태를 막아야 합니다. 자리를 찾고 점유하는 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
엣지 케이스를 다룹니다. 만차일 때 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
마지막으로 확장 지점을 짚습니다. 새 요금 정책은 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));
}
}