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

안동민 개발노트

본문 시작
4장 : 클래스와 참조

클래스·참조 종합 실습

입력에서 객체 생성, 배열 저장, 검색과 출력까지 값과 참조의 흐름을 하나의 프로그램으로 연결합니다.

지금까지는 참조의 한 장면을 보기 위해 작은 프로그램을 따로 실행했습니다. 이제 Study Log CLI에 세 개의 학습 기록을 입력하고, 객체 배열에 저장하고, 제목으로 검색하고, 전체 기록을 출력해 봅시다. 이 종합 예제의 목적은 기능을 많이 만드는 데 있지 않습니다. 입력 문자열과 숫자 값, 새 객체의 참조, 배열 칸의 참조가 어느 경계에서 복사되는지를 끝까지 추적하는 데 있습니다.

이번 버전의 StudyEntry는 여전히 필드만 가진 데이터 객체입니다. 입력·검증·출력 메서드는 StudyLogCli에 있습니다. 다음 객체 지향 장에서는 이 책임 중 무엇을 객체 안으로 옮겨야 하는지 판단합니다.

데이터 타입 설계

StudyEntry는 앞 문서와 같은 세 필드를 가집니다.

src/studylog/ch04/finalv/StudyEntry.java
public class StudyEntry {
    String title;
    int minutes;
    boolean completed;
}

CLI는 최대 세 개를 입력받는 고정 배열과 실제 기록 수 size를 관리합니다.

StudyEntry[] entries = new StudyEntry[3];
int size = 0;

배열 길이는 용량이고 size는 채운 칸의 수입니다. 기록 생성에 실패했는데 size부터 증가시키면 빈 슬롯이 데이터 범위에 포함됩니다. 따라서 다음 순서를 지킵니다.

  1. 입력을 읽고 검증합니다.
  2. 완성된 StudyEntry를 만듭니다.
  3. entries[size]에 참조를 저장합니다.
  4. 저장에 성공한 뒤 size를 증가시킵니다.

크기와 저장 순서

다음 코드는 빈 자리를 예약한다는 생각으로 size를 먼저 증가시킵니다.

실패 사례: 크기를 먼저 증가
int index = size;
size++;

StudyEntry entry = createEntry(title, minutes);
entries[index] = entry;

createEntry가 잘못된 시간 때문에 예외를 던지면 배열 칸은 null인데 size는 이미 증가한 상태가 됩니다. 이후 for (int i = 0; i < size; i++)가 그 슬롯을 정상 기록으로 보고 entries[i].title을 읽다가 NPE가 납니다. 오류가 난 입력 지점과 NPE가 난 출력 지점이 멀어져 원인을 찾기도 어렵습니다.

개선 버전은 객체가 성공적으로 만들어진 다음에만 상태를 갱신합니다.

StudyEntry entry = createEntry(title, minutes);
entries[size] = entry;
size++;

한 줄 차이지만 불변 조건이 생깁니다.

0 <= i < size인 모든 배열 칸에는 null이 아닌 StudyEntry가 있다.

입력 변환 경계

Scanner.nextInt()nextLine()이 빈 줄을 읽는 문제를 피하기 위해 이번 버전은 모든 입력을 nextLine()으로 받고 숫자만 Integer.parseInt로 변환합니다.

입력 경계
System.out.print("학습 시간(분): ");
String rawMinutes = scanner.nextLine();
int minutes = Integer.parseInt(rawMinutes);

숫자가 아닌 입력은 NumberFormatException을 일으킵니다. 대화형 프로그램이 종료되지 않도록 입력 메서드에서 처리하고 다시 묻습니다. 변환된 int가 양수인지도 별도로 검사해야 합니다. “숫자로 변환 가능함”과 “유효한 학습 시간임”은 다른 계약입니다.

전체 프로그램 구성

다음 두 파일은 같은 디렉터리에 둡니다.

src/studylog/ch04/finalv/StudyLogCli.java
import java.util.Scanner;

public class StudyLogCli {
    private static final int CAPACITY = 3;

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        StudyEntry[] entries = new StudyEntry[CAPACITY];
        int size = 0;

        while (size < entries.length) {
            System.out.println("\n[" + (size + 1) + "/" + entries.length + "]");
            String title = readTitle(scanner);
            int minutes = readPositiveMinutes(scanner);

            StudyEntry entry = createEntry(title, minutes, false);
            entries[size] = entry;
            size++;
        }

        printEntries(entries, size);

        System.out.print("\n찾을 제목: ");
        String keyword = scanner.nextLine().trim();
        StudyEntry found = findByTitle(entries, size, keyword);
        printSearchResult(found);
    }

    static String readTitle(Scanner scanner) {
        while (true) {
            System.out.print("제목: ");
            String title = scanner.nextLine().trim();
            if (!title.isEmpty()) {
                return title;
            }
            System.out.println("제목은 비워 둘 수 없습니다.");
        }
    }

    static int readPositiveMinutes(Scanner scanner) {
        while (true) {
            System.out.print("학습 시간(분): ");
            String input = scanner.nextLine().trim();
            try {
                int minutes = Integer.parseInt(input);
                if (minutes > 0) {
                    return minutes;
                }
                System.out.println("학습 시간은 1분 이상이어야 합니다.");
            } catch (NumberFormatException exception) {
                System.out.println("정수를 입력해 주세요.");
            }
        }
    }

    static StudyEntry createEntry(
        String title,
        int minutes,
        boolean completed
    ) {
        StudyEntry entry = new StudyEntry();
        entry.title = title;
        entry.minutes = minutes;
        entry.completed = completed;
        return entry;
    }

    static void printEntries(StudyEntry[] entries, int size) {
        System.out.println("\n== Study Log ==");
        for (int i = 0; i < size; i++) {
            StudyEntry entry = entries[i];
            System.out.printf(
                "%d. %s / %d분 / %s%n",
                i + 1,
                entry.title,
                entry.minutes,
                entry.completed ? "완료" : "진행 중"
            );
        }
    }

    static StudyEntry findByTitle(
        StudyEntry[] entries,
        int size,
        String title
    ) {
        for (int i = 0; i < size; i++) {
            StudyEntry entry = entries[i];
            if (entry.title.equalsIgnoreCase(title)) {
                return entry;
            }
        }
        return null;
    }

    static void printSearchResult(StudyEntry found) {
        if (found == null) {
            System.out.println("일치하는 기록이 없습니다.");
            return;
        }
        System.out.println(
            "검색 결과: " + found.title + " / " + found.minutes + "분"
        );
    }
}
컴파일과 실행
javac -d out src/studylog/ch04/finalv/StudyEntry.java src/studylog/ch04/finalv/StudyLogCli.java
java -cp out StudyLogCli
실행 예
[1/3]
제목: 클래스
학습 시간(분): 35

[2/3]
제목: 참조형
학습 시간(분): 삼십
정수를 입력해 주세요.
학습 시간(분): 30

[3/3]
제목: null
학습 시간(분): 20

== Study Log ==
1. 클래스 / 35분 / 진행 중
2. 참조형 / 30분 / 진행 중
3. null / 20분 / 진행 중

찾을 제목: 참조형
검색 결과: 참조형 / 30분

이 프로그램에는 지금까지 배운 값 복사가 모두 들어 있습니다.

  • titlekeyword에는 문자열 참조가 저장됩니다.
  • minutessize에는 정수 값이 저장됩니다.
  • createEntry는 새 객체 참조를 반환합니다.
  • entries[size] = entry는 그 참조를 배열 칸에 복사합니다.
  • printEntriesfindByTitle의 매개변수에는 배열 참조와 숫자 size가 각각 복사됩니다.
  • findByTitle은 찾은 객체의 참조 또는 null을 반환합니다.

객체 참조와 변경 전파

findByTitle이 반환하는 것은 복제된 기록이 아니라 배열이 가리키는 객체의 참조입니다. 따라서 검색 결과를 통해 필드를 바꾸면 전체 출력에서도 변경이 보입니다.

검색 결과로 완료 처리
StudyEntry found = findByTitle(entries, size, "참조형");
if (found != null) {
    found.completed = true;
}
printEntries(entries, size);

이 동작이 의도라면 검색 메서드는 편리합니다. 검색 결과를 수정본 초안으로 사용하려던 것이라면 공유 참조가 버그가 됩니다. 반환 타입이 같아도 호출자가 그 참조로 무엇을 할지에 따라 복사 정책이 달라집니다.

지금은 필드가 외부에 열려 있어 어느 메서드든 minutes = -1 같은 잘못된 상태를 만들 수 있습니다. 다음 장에서 접근 제어와 객체 메서드를 도입하면 “완료 처리”와 “시간 변경”의 규칙을 객체가 지키도록 개선할 수 있습니다.

연습 문제

종합 프로그램에 다음 기능을 추가하세요.

  1. findLongest(StudyEntry[] entries, int size) 메서드를 작성합니다.
  2. 기록이 하나 이상이라는 전제를 두지 말고 size == 0이면 null을 반환합니다.
  3. 가장 긴 시간이 같은 기록이 여러 개면 먼저 저장된 객체를 반환합니다.
  4. 반환된 객체의 시간을 바꾸면 배열에서도 변경이 보이는지 확인합니다.
  5. 독립적인 결과가 필요하다면 어떤 복사가 추가되어야 하는지 설명합니다.
해설 보기
src/studylog/ch04/exercise/LongestEntry.java
static StudyEntry findLongest(StudyEntry[] entries, int size) {
    if (size < 0 || size > entries.length) {
        throw new IllegalArgumentException("잘못된 기록 수: " + size);
    }
    if (size == 0) {
        return null;
    }

    StudyEntry longest = entries[0];
    for (int i = 1; i < size; i++) {
        StudyEntry candidate = entries[i];
        if (candidate.minutes > longest.minutes) {
            longest = candidate;
        }
    }
    return longest;
}
호출 예
StudyEntry longest = findLongest(entries, size);
if (longest == null) {
    System.out.println("기록이 없습니다.");
} else {
    System.out.println("최장 기록=" + longest.title);
    longest.minutes += 5;
}

비교 연산을 >로 두었기 때문에 같은 시간이 나오면 기존 longest를 유지하고 먼저 저장된 기록이 선택됩니다. 반환값은 배열 칸과 같은 객체를 가리키므로 longest.minutes += 5는 배열에서 읽는 값도 바꿉니다. 독립적인 결과가 필요하면 새 StudyEntry를 만들고 세 필드를 복사한 뒤 그 참조를 반환해야 합니다.

다음 단계의 설계 과제

프로그램은 실행되지만 설계상 불편이 남아 있습니다.

  • 필드 초기화 순서와 유효성 검사를 createEntry 호출자가 지켜야 합니다.
  • 모든 필드가 같은 패키지의 코드에 열려 있어 잘못된 값을 직접 대입할 수 있습니다.
  • 완료 처리와 출력 형식이 객체 밖의 여러 메서드에 흩어질 수 있습니다.
  • 고정 배열은 용량이 차면 더 이상 추가할 수 없습니다.
  • 검색 실패를 null로 표현해 호출자가 검사를 잊을 수 있습니다.

이 문제들은 한 번에 해결하지 않습니다. 생성자, 캡슐화, 컬렉션, Optional을 배울 때 같은 Study Log를 다시 열어 각 도구가 어느 경계를 개선하는지 비교합니다.


클래스와 참조를 배우는 목적은 문법을 늘리는 것이 아니라 데이터가 함께 움직이는 경계와 객체에 도달하는 경로를 코드에서 읽는 것입니다. 이제 Study Log는 한 기록을 객체로 표현하지만 행동과 유효성은 아직 바깥에 있습니다. 다음 객체 지향 장에서는 절차를 객체의 책임으로 옮겨 이 허술한 경계를 단단하게 만듭니다.