공공부하자개발 · 영어 학습 노트
자바
미니 프로젝트쇼핑몰 정산 시스템 4단계0/4 완료
  • 011단계: 콘솔 주문 관리 (초급 기술)
  • 022단계: 객체지향 리팩터링 (중급 기술)
  • 033단계: 모던 자바 적용 (고급 기술)
  • 044단계: 일마감 정산 배치 (배치 기술)
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 미니 프로젝트 › 01 / 4

1단계: 콘솔 주문 관리 (초급 기술)

섹션 6진행 0 / 4
1이번 단계의 목표2설계3구현 따라가기4실행 결과와 해설5확장 과제6다음 단계로‹ 이전다음 ›

3. 구현 따라가기

소스는 java-src/project/stage1/에 있다. 패키지 선언 없이 4개 파일이고, 폴더에서 javac -encoding UTF-8 *.java && java -Dstdout.encoding=UTF-8 Main으로 실행한다.

3.1: Member.java

java
/** 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단계에는 포인트 결제가 없기 때문이다. "필요한 것만 만든다"는 원칙은 모든 단계에 적용된다.

3.2: Product.java

java
/** 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에 있다. 지금은 호출자가 한 곳이라 문제없지만, 호출자가 둘이 되는 순간 한쪽이 검증을 빼먹을 수 있다.

3.3: Order.java

java
/**
 * 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로 찾아야 한다.

3.4: Main.java — 저장소와 등록

Main은 길어서 네 조각으로 나눠 읽는다. 실제 파일은 하나다.

java
/**
 * 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 필드다. 프로그램 하나에 저장소 하나라는 가정이 코드에 박혀 있다. 테스트를 위해 "빈 저장소를 하나 더" 만드는 것이 불가능하다.

java
    // ---- 등록 ----

    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)이 된다. 지금은 "호출자가 알아서"로 두었다.

3.5: Main.java — 조회와 주문 생성

java
    // ---- 조회: 선형 탐색. 데이터가 늘수록 느려진다 ----

    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이다.

java
    // ---- 주문 생성 ----

    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개 이상이면 조용히 버려진다. 검증에 구멍이 있는 것이다.

3.6: Main.java — 목록과 집계

java
    // ---- 목록 / 집계 ----

    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단계).

3.7: main 실행 흐름과 전체 출력

java
    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 + 메시지)이 얼마나 산만한지 보여 주기 위해서다.

text
// 출력:
=== 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
구현 따라가기
  • 3.1: Member.java
  • 3.2: Product.java
  • 3.3: Order.java
  • 3.4: Main.java — 저장소와 등록
  • 3.5: Main.java — 조회와 주문 생성
  • 3.6: Main.java — 목록과 집계
  • 3.7: main 실행 흐름과 전체 출력
이전 섹션2 설계3 / 6다음 섹션4 실행 결과와 해설