본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
3장 : 입력·배열·메서드

Study Log 메서드 훈련

입출금 예제의 상태 변경 패턴을 재구성해 검증·저장·조회·집계를 분리한 Study Log CLI를 완성합니다.

입력, 배열, 반복, 조건, 메서드를 따로 이해했다면 이제 하나의 프로그램에서 경계를 연결해야 합니다. 종합 문제의 핵심은 기능 수가 아니라 상태를 바꾸는 순서와 책임 배치입니다. 잘못된 입력이 배열과 size를 어긋나게 하지 않고, 목록과 요약이 같은 저장 범위를 사용하도록 Study Log를 단계별로 구성합니다.

size 증가 순서의 결함

lab/IncrementBeforeValidation.java
public final class IncrementBeforeValidation {
    public static void main(String[] args) {
        String[] topics = new String[3];
        int[] minutes = new int[3];
        int size = 0;

        int index = size++;
        String topic = "";
        int value = 30;
        if (topic.isBlank() || value <= 0) {
            System.out.println("invalid");
        } else {
            topics[index] = topic;
            minutes[index] = value;
        }

        index = size++;
        topics[index] = "methods";
        minutes[index] = 50;

        for (int current = 0; current < size; current++) {
            System.out.println(topics[current] + "=" + minutes[current]);
        }
    }
}
잘못된 결과
invalid
null=0
methods=50

첫 입력은 거절됐지만 size는 이미 1이 되었습니다. 다음 정상 기록이 index 1에 저장되어 index 0에 구멍이 남습니다. 상태 변경은 용량 검사 → 값 검증 → 배열 쓰기 → size 증가 순서여야 합니다. 검증 실패 경로에서는 어떤 저장 상태도 바뀌면 안 됩니다.

if (size < topics.length && isValid(topic, value)) {
    topics[size] = topic;
    minutes[size] = value;
    size++;
}

한 번의 추가가 모두 적용되거나 전혀 적용되지 않는 성질을 원자성이라고 볼 수 있습니다. 아직 데이터베이스 트랜잭션을 배우지 않았더라도 작은 메모리 상태에서도 같은 사고가 필요합니다.

상태 변경 메서드

입금과 출금 예제는 현재 잔액을 입력으로 받고 새 잔액을 반환합니다. 기본형 balance는 메서드 안에서 바꿔도 호출자 변수에 반영되지 않으므로 반환값을 다시 대입합니다.

src/BalanceMethods.java
public final class BalanceMethods {
    public static void main(String[] args) {
        int balance = 0;

        balance = deposit(balance, 10_000);
        balance = withdraw(balance, 3_000);
        balance = withdraw(balance, 8_000);

        System.out.println("balance=" + balance);
    }

    private static int deposit(int balance, int amount) {
        if (amount <= 0) {
            System.out.println("invalid deposit");
            return balance;
        }
        int next = balance + amount;
        System.out.println("deposit=" + amount);
        return next;
    }

    private static int withdraw(int balance, int amount) {
        if (amount <= 0 || amount > balance) {
            System.out.println("rejected withdrawal=" + amount);
            return balance;
        }
        int next = balance - amount;
        System.out.println("withdraw=" + amount);
        return next;
    }
}
deposit=10000
withdraw=3000
rejected withdrawal=8000
balance=7000

실패한 출금은 기존 balance를 반환하므로 상태가 유지됩니다. Study Log의 add도 같은 패턴으로 기존 size 또는 새 size를 반환할 수 있습니다.

입출금 프로그램Study Log공통 질문
balancesize와 배열 원소현재 상태는 무엇인가
amount 검증topic·minutes 검증요청을 받아도 되는가
balance 반환size 반환성공 뒤 상태는 무엇인가
잔액 부족용량 부족실패 시 기존 상태가 유지되는가

도메인은 다르지만 입력을 검사하고 다음 상태를 계산한 뒤 호출자가 결과를 채택하는 구조는 같습니다. 이 대응을 이해하면 문제 문구가 바뀌어도 메서드 설계 원리를 옮길 수 있습니다.

책임 단위의 문제 분해

코드를 쓰기 전에 필요한 질문을 메서드 후보로 바꿉니다.

  • 입력 한 줄을 안전하게 읽는가: readLine
  • 시간 문자열을 양의 정수로 바꿀 수 있는가: parsePositiveMinutes
  • 새 기록을 저장할 수 있는가: add
  • 저장된 기록을 모두 보여 주는가: printEntries
  • 합계와 평균을 계산하는가: total, average
  • 명령 반복을 끝낼 것인가: mainrunning

모든 것을 메서드로 만들 필요는 없습니다. 메뉴 반복과 명령 분기는 프로그램의 전체 흐름이므로 main에 남겨도 좋습니다. 반대로 입력 검증과 배열 갱신이 switch의 각 분기에 복제되면 별도 메서드로 모읍니다.

메서드 사이의 계약도 적어 둡니다.

add 입력: 같은 용량의 두 배열, 현재 size, 검증할 주제와 시간
add 성공: 두 배열의 index size에 기록, size + 1 반환
add 실패: 배열 불변, 기존 size 반환
total 입력: minutes와 유효 범위 size
total 결과: index 0부터 size - 1까지의 합

이 계약이 있으면 구현을 읽을 때 모든 문장을 다시 추론하지 않아도 됩니다.

메서드 기반 CLI

다음 프로그램은 재현 가능한 실행을 위해 입력 문자열을 사용합니다. 실제 사용 시 Scanner 생성만 new Scanner(System.in)으로 바꾸면 메뉴와 메서드는 그대로입니다.

src/StudyLogCli.java
import java.util.Scanner;

public final class StudyLogCli {
    public static void main(String[] args) {
        String input = String.join("\n",
                "add", "arrays", "40",
                "add", "", "30",
                "add", "methods", "50",
                "list",
                "summary",
                "quit") + "\n";
        Scanner scanner = new Scanner(input);

        String[] topics = new String[3];
        int[] minutes = new int[3];
        int size = 0;
        boolean running = true;

        while (running && scanner.hasNextLine()) {
            String command = scanner.nextLine().trim();

            switch (command) {
                case "add" -> {
                    String topic = readLine(scanner, "topic");
                    String rawMinutes = readLine(scanner, "minutes");
                    Integer value = parsePositiveMinutes(rawMinutes);

                    if (value == null) {
                        System.out.println("invalid minutes");
                    } else {
                        size = add(topics, minutes, size, topic, value);
                    }
                }
                case "list" -> printEntries(topics, minutes, size);
                case "summary" -> printSummary(minutes, size);
                case "quit" -> running = false;
                default -> System.out.println("unknown command=" + command);
            }
        }

        System.out.println("bye size=" + size);
    }

    private static String readLine(Scanner scanner, String field) {
        if (!scanner.hasNextLine()) {
            throw new IllegalStateException("missing " + field);
        }
        return scanner.nextLine().trim();
    }

    private static Integer parsePositiveMinutes(String raw) {
        try {
            int value = Integer.parseInt(raw);
            return value > 0 ? value : null;
        } catch (NumberFormatException exception) {
            return null;
        }
    }

    private static int add(
            String[] topics,
            int[] minutes,
            int size,
            String topic,
            int value
    ) {
        if (size < 0 || size > topics.length || size > minutes.length) {
            throw new IllegalStateException("broken size");
        }
        if (size == topics.length || size == minutes.length) {
            System.out.println("full");
            return size;
        }
        if (topic == null || topic.isBlank()) {
            System.out.println("invalid topic");
            return size;
        }

        topics[size] = topic;
        minutes[size] = value;
        System.out.println("added=" + topic);
        return size + 1;
    }

    private static void printEntries(String[] topics, int[] minutes, int size) {
        if (size == 0) {
            System.out.println("no entries");
            return;
        }
        for (int index = 0; index < size; index++) {
            System.out.println((index + 1) + ". " + topics[index] + "=" + minutes[index]);
        }
    }

    private static void printSummary(int[] minutes, int size) {
        if (size == 0) {
            System.out.println("summary=none");
            return;
        }
        int total = total(minutes, size);
        double average = (double) total / size;
        System.out.println("total=" + total + ", average=" + average);
    }

    private static int total(int[] minutes, int size) {
        int result = 0;
        for (int index = 0; index < size; index++) {
            result += minutes[index];
        }
        return result;
    }
}
added=arrays
invalid topic
added=methods
1. arrays=40
2. methods=50
total=90, average=45.0
bye size=2

빈 주제 요청은 add에서 기존 size 1을 반환합니다. 다음 정상 요청도 index 1에 저장되어 구멍이 생기지 않습니다. 숫자 변환 실패는 parse 메서드가 null로 표현하고, 배열 저장 메서드는 이미 양수로 변환된 int만 받습니다. 각 경계가 맡는 검증이 다릅니다.

Integernull을 쓰는 방식은 현재 배운 범위에서 간단하지만, null 의미가 넓어질 수 있습니다. 이후에는 OptionalInt나 결과 타입으로 성공 값과 실패 이유를 명시할 수 있습니다. 지금 중요한 것은 예외가 입력 반복 전체를 중단하지 않고, 실패가 저장 상태를 바꾸지 않는다는 점입니다.

변경 관점의 모듈성

메서드 수가 많다고 모듈성이 높은 것은 아닙니다. 다음 변경을 가정해 어느 코드만 바뀌는지 확인합니다.

최소 학습 시간을 5분으로 바꾸기

현재는 parsePositiveMinutesvalue > 0을 검사합니다. 5분 규칙으로 바뀌면 그 메서드 한 곳의 조건을 value >= 5로 바꿉니다. 배열 저장과 출력은 바뀌지 않습니다.

목록 형식을 topic (minutes min)으로 바꾸기

printEntries의 출력 한 줄만 바뀝니다. 입력 파싱과 합계 계산은 영향을 받지 않습니다.

평균을 소수점 한 자리로 표시하기

printSummary의 표현 책임만 바뀝니다. total 계산은 정수 합계를 그대로 반환합니다.

최대 기록 수를 늘리기

main의 배열 생성 용량만 바꿔도 add는 전달받은 배열 길이로 공간을 판단합니다. add 내부에 숫자 3을 하드코딩했다면 변경 지점이 흩어졌을 것입니다.

좋은 모듈 경계는 함께 바뀌는 코드를 모으고, 다른 이유로 바뀌는 코드를 분리합니다. 입력 방식, 저장 규칙, 계산 규칙, 출력 표현은 서로 다른 변경 이유입니다.

종합 검토 항목

실행 성공만 확인하지 말고 다음 경계를 직접 넣어 봅니다.

  1. 첫 명령이 list 또는 summary인 빈 상태
  2. 빈 주제, 공백 주제, 0분, 음수, 숫자가 아닌 시간
  3. 배열 용량과 정확히 같은 수의 정상 추가
  4. 용량을 한 번 넘는 추가
  5. 잘못된 입력 뒤 정상 입력을 추가했을 때 빈 인덱스가 없는지
  6. 합계와 평균이 list에 보이는 기록만 대상으로 하는지
  7. quit 뒤 입력이 남아 있어도 더 처리하지 않는지

테스트 코드를 별도로 만들지 않더라도 이런 입력 시나리오를 실행하고 결과를 기록하면 상태 경계의 결함을 발견할 수 있습니다. 정상 경로 한 번만 보는 것보다 실패 뒤 복구를 확인하는 편이 중요합니다.

다음 단계의 설계 부채

지금 구조는 입문 범위의 문법을 종합하는 데 적합하지만, 기능이 늘면 한계가 분명해집니다. topicminutes를 두 배열로 나눠 두었기 때문에 추가·삭제·정렬마다 두 배열을 같은 인덱스로 움직여야 합니다. 메서드마다 배열 두 개와 size를 반복 전달하는 것도 호출 계약을 길게 만듭니다. 고정 길이 때문에 용량이 차면 새 배열을 만들고 복사해야 합니다.

이 한계를 감추려고 더 많은 static 전역 변수를 만들지는 않습니다. 다음 단계에서는 한 기록의 주제와 시간을 객체 하나로 묶고, Study Log가 기록 배열과 size를 소유하게 합니다. 그러면 add, remove, summary는 흩어진 매개변수 대신 자신의 상태에 작동할 수 있습니다. 이후 컬렉션을 배우면 고정 용량과 직접 이동 코드도 제거할 수 있습니다.

중요한 것은 현재 코드가 쓸모없어지는 것이 아닙니다. 이번 장에서 확립한 입력 경계, 검증 후 상태 변경, 유효 범위 집계, 실패 시 기존 상태 보존이라는 계약은 객체와 컬렉션으로 표현이 바뀌어도 그대로 남습니다. 다음 문법은 이 계약을 더 안전하게 담는 수단입니다.

연습 문제

remove 메서드를 작성하세요. 주제 배열, 시간 배열, size, 삭제할 index를 받고, 유효한 index이면 뒤의 원소를 한 칸씩 앞으로 당긴 뒤 새 size를 반환합니다. 마지막 사용 칸은 null0으로 비웁니다. 잘못된 index이면 기존 size를 그대로 반환합니다.

해설 보기
src/RemoveStudyEntry.java
public final class RemoveStudyEntry {
    public static void main(String[] args) {
        String[] topics = {"arrays", "input", "methods", null};
        int[] minutes = {40, 30, 50, 0};
        int size = 3;

        size = remove(topics, minutes, size, 1);
        print(topics, minutes, size);
        System.out.println("size=" + size);
    }

    private static int remove(
            String[] topics,
            int[] minutes,
            int size,
            int target
    ) {
        if (target < 0 || target >= size) {
            return size;
        }

        for (int index = target; index < size - 1; index++) {
            topics[index] = topics[index + 1];
            minutes[index] = minutes[index + 1];
        }

        int last = size - 1;
        topics[last] = null;
        minutes[last] = 0;
        return size - 1;
    }

    private static void print(String[] topics, int[] minutes, int size) {
        for (int index = 0; index < size; index++) {
            System.out.println(topics[index] + "=" + minutes[index]);
        }
    }
}
arrays=40
methods=50
size=2

병렬 배열 두 개를 같은 반복문에서 함께 이동해야 주제와 시간이 어긋나지 않습니다. 반복 조건은 index < size - 1입니다. 마지막 사용 원소는 다음 원소가 없으므로 이동 대상이 아니며, 이동이 끝난 뒤 명시적으로 비웁니다. 이 삭제 메서드도 성공하면 size - 1, 실패하면 기존 size라는 상태 전이 계약을 가집니다.