소스는 java-src/project/stage1/에 있다. 패키지 선언 없이 4개 파일이고, 폴더에서 javac -encoding UTF-8 *.java && java -Dstdout.encoding=UTF-8 Main으로 실행한다.
/** 1단계 회원. 필드 + 생성자 + getter만 있는 단순 데이터 클래스. */
public class Member {
private final int id;
private final String name;
private int point; // 보유 포인트 (원 단위)
public Member(int id, String name, int point) {
this.id = id;
this.name = name;
this.point = point;
}
public int getId() { return id; }
public String getName() { return name; }
public int getPoint() { return point; }
public void addPoint(int amount) { point += amount; }
}id와 name은 final이라 생성 후 바뀌지 않는다. point만 addPoint로 증가한다. 포인트를 차감하는 메서드는 없다 — 1단계에는 포인트 결제가 없기 때문이다. "필요한 것만 만든다"는 원칙은 모든 단계에 적용된다.
/** 1단계 상품. 재고는 int 필드를 직접 증감한다. */
public class Product {
private final int id;
private final String name;
private final String category;
private final int price; // 단가 (원)
private int stock; // 재고 수량
public Product(int id, String name, String category, int price, int stock) {
this.id = id;
this.name = name;
this.category = category;
this.price = price;
this.stock = stock;
}
public int getId() { return id; }
public String getName() { return name; }
public String getCategory() { return category; }
public int getPrice() { return price; }
public int getStock() { return stock; }
/** 재고 차감. 검증은 호출자(Main)가 한다 — 이것이 1단계의 약점 중 하나. */
public void decreaseStock(int qty) { stock -= qty; }
}decreaseStock은 재고가 부족해도 그냥 뺀다. 음수 재고를 막는 책임이 Main.createOrder에 있다. 지금은 호출자가 한 곳이라 문제없지만, 호출자가 둘이 되는 순간 한쪽이 검증을 빼먹을 수 있다.
/**
* 1단계 주문. 주문 항목(상품 ID, 수량)을 "병렬 배열" 두 개로 보관한다.
* productIds[i]와 quantities[i]가 한 쌍이며, 최대 MAX_LINES개까지만 담을 수 있다.
*/
public class Order {
public static final int MAX_LINES = 3;
private final int id;
private final int memberId;
private final int[] productIds = new int[MAX_LINES];
private final int[] quantities = new int[MAX_LINES];
private int lineCount; // 실제로 채워진 항목 수
public Order(int id, int memberId) {
this.id = id;
this.memberId = memberId;
}
/** 항목 추가. 배열이 가득 차면 false. */
public boolean addLine(int productId, int qty) {
if (lineCount >= MAX_LINES) return false;
productIds[lineCount] = productId;
quantities[lineCount] = qty;
lineCount++;
return true;
}
public int getId() { return id; }
public int getMemberId() { return memberId; }
public int getLineCount() { return lineCount; }
public int getProductId(int index) { return productIds[index]; }
public int getQuantity(int index) { return quantities[index]; }
}핵심 줄은 productIds[lineCount] = productId; quantities[lineCount] = qty; lineCount++;다. 세 줄이 항상 함께 실행되어야 두 배열이 동기화된다. 주문은 회원 객체가 아니라 memberId(int)를 갖는다. 그래서 주문에서 회원 이름을 알려면 다시 findMember로 찾아야 한다.
Main은 길어서 네 조각으로 나눠 읽는다. 실제 파일은 하나다.
/**
* 1단계: 배열 + 기본 클래스 + if/for + 메서드 분리로 만든 콘솔 주문 관리.
* javac -encoding UTF-8 *.java && java -Dstdout.encoding=UTF-8 Main
*
* 회원 등록 / 상품 등록 / 주문 생성 / 주문 목록 / 총액 계산
*/
public class Main {
// ---- 저장소: 크기가 고정된 배열 + "채워진 개수" 카운터 ----
static final int MAX_MEMBERS = 3;
static final int MAX_PRODUCTS = 4;
static final int MAX_ORDERS = 3;
static Member[] members = new Member[MAX_MEMBERS];
static int memberCount = 0;
static Product[] products = new Product[MAX_PRODUCTS];
static int productCount = 0;
static Order[] orders = new Order[MAX_ORDERS];
static int orderCount = 0;
static int nextOrderId = 1;모든 저장소가 static 필드다. 프로그램 하나에 저장소 하나라는 가정이 코드에 박혀 있다. 테스트를 위해 "빈 저장소를 하나 더" 만드는 것이 불가능하다.
// ---- 등록 ----
static void registerMember(int id, String name, int point) {
if (memberCount >= MAX_MEMBERS) {
System.out.println("[실패] 회원 저장 공간이 가득 찼습니다: " + name);
return;
}
members[memberCount] = new Member(id, name, point);
memberCount++;
System.out.println("회원 등록: " + id + " " + name + " (포인트 " + format(point) + ")");
}
static void registerProduct(int id, String name, String category, int price, int stock) {
if (productCount >= MAX_PRODUCTS) {
System.out.println("[실패] 상품 저장 공간이 가득 찼습니다: " + name);
return;
}
products[productCount] = new Product(id, name, category, price, stock);
productCount++;
System.out.println("상품 등록: " + id + " " + name + " " + format(price) + "원 (재고 " + stock + ")");
}두 메서드가 구조적으로 똑같다. "가득 찼는지 확인 → 배열에 넣기 → 카운터 증가 → 메시지". 타입만 다르다. 이 중복은 제네릭 없이는 없앨 수 없다(3단계).
같은 ID로 두 번 등록해도 막지 않는다. 중복 검사를 넣으려면 findMember를 먼저 호출해야 하는데, 그러면 등록이 O(n)이 된다. 지금은 "호출자가 알아서"로 두었다.
// ---- 조회: 선형 탐색. 데이터가 늘수록 느려진다 ----
static Member findMember(int id) {
for (int i = 0; i < memberCount; i++) {
if (members[i].getId() == id) return members[i];
}
return null; // 못 찾으면 null — 호출자가 매번 확인해야 함
}
static Product findProduct(int id) {
for (int i = 0; i < productCount; i++) {
if (products[i].getId() == id) return products[i];
}
return null;
}return null이 이 단계의 실패 신호다. 호출자는 반환값을 받을 때마다 if (x == null)을 써야 하고, 한 번이라도 빼먹으면 NullPointerException이다.
// ---- 주문 생성 ----
static void createOrder(int memberId, int[] productIds, int[] quantities) {
Member member = findMember(memberId);
if (member == null) {
System.out.println("[실패] 존재하지 않는 회원: " + memberId);
return;
}
if (orderCount >= MAX_ORDERS) {
System.out.println("[실패] 주문 저장 공간이 가득 찼습니다 (회원 " + member.getName() + ")");
return;
}
// 1차 검증: 모든 상품이 존재하고 재고가 충분한지 먼저 확인
for (int i = 0; i < productIds.length; i++) {
Product p = findProduct(productIds[i]);
if (p == null) {
System.out.println("[실패] 존재하지 않는 상품: " + productIds[i]);
return;
}
if (p.getStock() < quantities[i]) {
System.out.println("[실패] 재고 부족: " + p.getName()
+ " (재고 " + p.getStock() + ", 요청 " + quantities[i] + ")");
return;
}
}
// 2차: 검증을 통과했으니 재고 차감 + 주문 저장
Order order = new Order(nextOrderId, memberId);
int total = 0;
for (int i = 0; i < productIds.length; i++) {
Product p = findProduct(productIds[i]); // 같은 탐색을 또 한다 (중복)
p.decreaseStock(quantities[i]);
order.addLine(productIds[i], quantities[i]);
total += p.getPrice() * quantities[i];
}
orders[orderCount] = order;
orderCount++;
nextOrderId++;
member.addPoint(total / 100); // 결제액의 1% 적립
System.out.println("주문 생성: #" + order.getId() + " " + member.getName()
+ " " + format(total) + "원 (적립 " + format(total / 100) + ")");
}주문 생성이 두 번 순회하는 이유가 중요하다. 첫 순회에서 검증만 하고 둘째 순회에서 차감한다. 만약 한 번에 "검증하고 바로 차감"하면, 두 번째 상품에서 실패했을 때 첫 번째 상품의 재고는 이미 깎여 있다. 이 "전부 성공 아니면 전부 안 함"이 트랜잭션의 원형이고, 4단계에서 정식으로 다룬다.
order.addLine(...)의 반환값(boolean)을 무시하고 있다. 항목이 4개 이상이면 조용히 버려진다. 검증에 구멍이 있는 것이다.
// ---- 목록 / 집계 ----
static int orderTotal(Order order) {
int total = 0;
for (int i = 0; i < order.getLineCount(); i++) {
Product p = findProduct(order.getProductId(i));
total += p.getPrice() * order.getQuantity(i);
}
return total;
}
static void printOrders() {
for (int i = 0; i < orderCount; i++) {
Order o = orders[i];
Member m = findMember(o.getMemberId());
System.out.println("#" + o.getId() + " " + m.getName() + " / " + format(orderTotal(o)) + "원");
for (int j = 0; j < o.getLineCount(); j++) {
Product p = findProduct(o.getProductId(j));
System.out.println(" - " + p.getName() + " x" + o.getQuantity(j)
+ " = " + format(p.getPrice() * o.getQuantity(j)) + "원");
}
}
}
static int totalSales() {
int sum = 0;
for (int i = 0; i < orderCount; i++) sum += orderTotal(orders[i]);
return sum;
}
static void printSalesByMember() {
for (int i = 0; i < memberCount; i++) { // 회원 × 주문 이중 루프
int sum = 0;
for (int j = 0; j < orderCount; j++) {
if (orders[j].getMemberId() == members[i].getId()) sum += orderTotal(orders[j]);
}
System.out.println("회원별 매출: " + members[i].getName() + " " + format(sum)
+ "원 (포인트 " + format(members[i].getPoint()) + ")");
}
}
static void printStock() {
StringBuilder sb = new StringBuilder("남은 재고:");
for (int i = 0; i < productCount; i++) {
sb.append(" ").append(products[i].getName()).append("=").append(products[i].getStock());
}
System.out.println(sb);
}
/** 12345 → "12,345". 자릿수 구분은 문자열 처리로 직접 구현. */
static String format(int amount) {
return String.format("%,d", amount);
}
}orderTotal은 주문 합계를 계산할 때마다 상품을 다시 탐색한다. 주문이 상품 객체 대신 ID만 갖기 때문이다. printOrders는 orderTotal을 부르고 나서 항목별로 또 findProduct를 부르므로, 주문 하나 출력에 탐색이 2번 × 항목 수만큼 일어난다. 데이터가 작아서 느끼지 못할 뿐이다.
printSalesByMember는 회원 수 × 주문 수의 이중 루프다. 회원 1만 명, 주문 10만 건이면 10억 번 비교한다. Map<회원, 합계>가 있으면 주문 수만큼만 돌면 된다(2단계).
public static void main(String[] args) {
System.out.println("=== 1. 회원 등록 ===");
registerMember(1, "김철수", 5_000);
registerMember(2, "이영희", 12_000);
registerMember(3, "박민수", 0);
registerMember(4, "최지우", 3_000); // 배열 가득 → 실패
System.out.println();
System.out.println("=== 2. 상품 등록 ===");
registerProduct(101, "무선 마우스", "전자", 25_000, 10);
registerProduct(102, "USB-C 케이블", "전자", 8_000, 30);
registerProduct(103, "자바 입문서", "도서", 32_000, 3);
registerProduct(104, "텀블러", "생활", 15_000, 20);
System.out.println();
System.out.println("=== 3. 주문 생성 ===");
createOrder(1, new int[]{101, 102}, new int[]{2, 1}); // 정상
createOrder(2, new int[]{103}, new int[]{5}); // 재고 부족
createOrder(9, new int[]{101}, new int[]{1}); // 없는 회원
createOrder(2, new int[]{103, 104}, new int[]{1, 3}); // 정상
createOrder(3, new int[]{101, 999}, new int[]{1, 1}); // 없는 상품
createOrder(3, new int[]{101}, new int[]{1}); // 정상
createOrder(1, new int[]{104}, new int[]{1}); // 주문 배열 가득
System.out.println();
System.out.println("=== 4. 주문 목록 ===");
printOrders();
System.out.println();
System.out.println("=== 5. 집계 ===");
System.out.println("총 매출: " + format(totalSales()) + "원");
printSalesByMember();
printStock();
}시나리오는 "정상 경로 3개 + 실패 경로 4개"로 짰다. 실패 경로가 정상 경로보다 많은 것은 의도적이다 — 실무 코드의 절반은 실패 처리이고, 1단계의 실패 처리 방식(return + 메시지)이 얼마나 산만한지 보여 주기 위해서다.
// 출력:
=== 1. 회원 등록 ===
회원 등록: 1 김철수 (포인트 5,000)
회원 등록: 2 이영희 (포인트 12,000)
회원 등록: 3 박민수 (포인트 0)
[실패] 회원 저장 공간이 가득 찼습니다: 최지우
=== 2. 상품 등록 ===
상품 등록: 101 무선 마우스 25,000원 (재고 10)
상품 등록: 102 USB-C 케이블 8,000원 (재고 30)
상품 등록: 103 자바 입문서 32,000원 (재고 3)
상품 등록: 104 텀블러 15,000원 (재고 20)
=== 3. 주문 생성 ===
주문 생성: #1 김철수 58,000원 (적립 580)
[실패] 재고 부족: 자바 입문서 (재고 3, 요청 5)
[실패] 존재하지 않는 회원: 9
주문 생성: #2 이영희 77,000원 (적립 770)
[실패] 존재하지 않는 상품: 999
주문 생성: #3 박민수 25,000원 (적립 250)
[실패] 주문 저장 공간이 가득 찼습니다 (회원 김철수)
=== 4. 주문 목록 ===
#1 김철수 / 58,000원
- 무선 마우스 x2 = 50,000원
- USB-C 케이블 x1 = 8,000원
#2 이영희 / 77,000원
- 자바 입문서 x1 = 32,000원
- 텀블러 x3 = 45,000원
#3 박민수 / 25,000원
- 무선 마우스 x1 = 25,000원
=== 5. 집계 ===
총 매출: 160,000원
회원별 매출: 김철수 58,000원 (포인트 5,580)
회원별 매출: 이영희 77,000원 (포인트 12,770)
회원별 매출: 박민수 25,000원 (포인트 250)
남은 재고: 무선 마우스=7 USB-C 케이블=29 자바 입문서=2 텀블러=17