Devin.KR

도서 객체와 참조

110분 안팎

학습 목표

필드·생성자·접근 제어와 참조 공유를 설명합니다.

개념

왜 도서를 객체로 만드나요

제목과 대여 상태를 각각 따로 들고 다니면 어느 상태가 어느 책의 것인지 호출자가 계속 맞춰야 합니다. 한 권의 정보와 변경 규칙을 Book 안에 모으면 제목이 없는 도서를 만들거나 다른 책의 상태를 우연히 바꾸는 일을 줄일 수 있습니다. 이번 레슨의 Book은 제목과 대여 중 여부를 가집니다. 실제 프로젝트의 회원, 대여 기록, 데이터베이스 저장은 아직 없습니다. 이 작은 객체에서 생성 시점의 유효성, 상태 변경, 참조 공유를 직접 확인한 뒤 다음 모듈의 도서 목록으로 이어 갑니다.

클래스와 인스턴스의 역할

class Book은 도서가 가질 정보와 동작을 정의합니다. new Book("A")는 그 정의로 한 객체를 만들고 Book a 변수는 객체를 가리키는 참조를 보관합니다. new Book("B")를 다시 실행하면 별도 객체가 생깁니다. title과 borrowed를 인스턴스 필드로 선언하면 객체별로 값이 보관됩니다. borrowed를 static으로 선언하면 책마다 상태가 생기는 대신 클래스가 공유하는 상태가 됩니다. 책 A를 대여했는데 B도 대여 중으로 보이는 오류가 여기서 생깁니다. 컴파일이 된다는 사실과 업무 의미가 맞다는 사실은 다릅니다.

생성자가 유효한 출발점을 만듭니다

private final String title;
private boolean borrowed;
public Book(String title) {
    if (title == null || title.isBlank()) {
        throw new IllegalArgumentException("제목은 비어 있을 수 없습니다");
    }
    this.title = title;
}

생성자는 클래스와 이름이 같고 반환 타입을 쓰지 않습니다. new로 객체를 만들 때 실행되어 필요한 값을 받습니다. title이 null이면 제목 값 자체가 없는 상태입니다. 빈 문자열은 길이가 0인 문자열이고 공백 문자열은 문자는 있지만 눈에 보이는 제목이 없는 상태입니다. 이번 계약은 null, 빈 문자열, isBlank가 참인 공백 문자열을 거부합니다. 일반 제목의 앞뒤 공백을 제거하는 요구는 없으므로 자동으로 trim하거나 strip하지 않습니다. 요구되지 않은 정규화를 넣으면 저장할 값이 호출자의 기대와 달라질 수 있습니다.

null 검사를 앞에 놓는 이유는 ||의 단락 평가입니다. null이면 앞 조건이 참이므로 title.isBlank()를 호출하지 않습니다. 순서를 뒤집으면 null에서 메서드를 호출해 NullPointerException이 날 수 있습니다. 우리가 원하는 것은 입력 계약을 설명하는 IllegalArgumentException입니다. throw는 현재 처리를 예외로 끝냅니다. 검증을 모두 통과한 뒤 this.title에 값을 대입합니다. this는 현재 객체이고 같은 이름의 매개변수와 필드를 구분합니다. title = title만 적으면 매개변수 자신에게 대입하여 필드는 초기화되지 않습니다.

읽는 통로와 바꾸는 통로를 정합니다

private은 다른 클래스가 필드를 직접 고치지 못하게 합니다. public String getTitle()은 읽는 통로입니다. 이번 레슨의 제목은 생성 후 바꾸지 않으므로 final이며 제목 세터를 제공하지 않습니다. String은 불변이므로 반환한 문자열 내용을 고쳐 Book의 제목을 바꿀 수 없습니다. 다만 final은 모든 참조 대상의 내부까지 불변으로 만드는 문법은 아닙니다. 나중에 배열이나 리스트를 필드로 넣을 때는 외부에서 내용을 바꿀 수 있는지 따로 판단해야 합니다.

public String getTitle() { return title; }
public boolean isBorrowed() { return borrowed; }
public void borrow() {
    if (borrowed) throw new IllegalStateException("이미 대여 중입니다");
    borrowed = true;
}
public void returnBook() { borrowed = false; }

borrow는 대여 상태를 true로 바꿉니다. 이미 대여 중이면 IllegalStateException을 발생시켜 중복 대여를 거부합니다. 잘못된 제목은 입력값 문제이고 중복 대여는 객체의 현재 상태 문제이므로 예외 종류를 구분합니다. returnBook은 상태를 false로 바꾸며 이 단계에서는 이미 반납된 책을 다시 반납해도 false를 유지합니다. 이 동작은 실습 계약입니다. 모든 실서비스가 같은 반납 규칙을 가져야 한다는 뜻은 아니며, 이후 요구가 바뀌면 상태 전이와 테스트도 함께 조정합니다.

별칭 공유와 객체 독립성을 나눕니다

Book a = new Book("A");
Book alias = a;
Book b = new Book("B");
alias.borrow();
System.out.println(a.isBorrowed());
System.out.println(b.isBorrowed());

alias = a는 새 책을 만들지 않습니다. a의 참조 값을 복사하므로 두 변수가 같은 객체를 가리킵니다. alias로 대여하면 a로 읽어도 true가 나옵니다. b는 별도의 new 호출로 만들었으므로 false입니다. 같은 객체를 두 이름으로 보는 상황과 서로 다른 객체가 잘못 상태를 공유하는 상황을 구분해야 합니다. 전자는 정상 참조 동작이고 후자는 borrowed를 static으로 선언한 오류일 수 있습니다. 독립성 테스트를 피하려고 별칭이 같은 상태를 보지 않게 만들면 참조 의미를 바꾼 셈입니다.

참조형을 메서드에 넘길 때도 참조 값이 복사됩니다. 메서드가 복사한 참조로 객체의 borrow를 호출하면 원래 변수가 보는 객체 상태도 바뀝니다. 그러나 매개변수에 new Book을 다시 대입하는 일은 호출자의 변수를 다른 객체로 바꾸지 않습니다. 객체 변경과 참조 변수 재대입이 서로 다른 동작이기 때문입니다. 이런 구분이 있어야 테스트에서 도서를 새로 만들어야 하는 이유와 같은 객체를 공유해도 되는 범위를 판단할 수 있습니다.

실패 메시지를 계약과 연결합니다

starter는 제목 검증을 비워 두고 대여 상태를 정적 필드로 공유합니다. 먼저 생성자에서 null과 빈 제목을 거부하게 고칩니다. 그 다음 borrowed에서 static을 제거해 객체별 상태를 만듭니다. 이전 LoanPolicy와 CatalogApp의 계약은 유지합니다. Expected IllegalArgumentException to be thrown, but nothing was thrown이라는 메시지는 잘못된 제목을 받아도 생성자가 정상 종료했다는 뜻입니다. 제목 검증의 조건을 확인합니다. independentBooks에서 expected false but was true가 나오면 별도 객체가 같은 상태를 공유하는지 확인합니다.

NullPointerException이 나오면 값이 없는 참조에서 어떤 메서드를 호출했는지 스택의 본인 코드 줄을 봅니다. 모든 예외를 catch해서 무시하면 검증이 성공한 듯 보이지만 잘못된 객체가 남을 수 있습니다. title has private access라는 컴파일 메시지는 외부 코드가 숨긴 필드에 직접 접근했다는 뜻입니다. 공개 getTitle을 사용하며 필드를 public으로 바꿔 해결하지 않습니다. final 필드의 초기화 오류는 생성자에서 this.title 대입을 빠뜨렸는지 확인할 단서입니다.

변경 정책을 이어갈 준비

지금은 외부에서 제목을 변경할 수 없다는 계약으로 연습합니다. 모듈 미션에서는 담당자가 제목 오타를 고칠 수 있도록 rename이라는 검증된 변경 통로를 추가합니다. 그때 final을 제거하는 이유는 새 업무 요구이며 private 접근 제어와 제목 유효성은 유지합니다. 변경을 도입하면서 별도 객체가 영향을 받지 않고 실패 시 기존 값이 유지되는 테스트를 추가합니다. 객체 모델은 영원히 고정된 틀이 아니라 요구를 안전하게 반영할 수 있는 경계를 만드는 도구입니다. 서재에서 생성자와 참조의 추가 사례를 읽되 이번 실습의 계약은 테스트로 직접 설명합니다.

따라하기

객체별 상태와 생성 조건 확인

BookTest에서 null·빈 제목 거부와 independentBooks 검사를 읽습니다. Book의 title과 borrowed 선언을 찾아 생성자 검증과 객체별 상태가 각각 어디에서 보장되어야 하는지 적습니다.

시작 코드의 TODO 수정

src/main/java/lab/Book.java에서 생성자 검증과 borrowed 필드 선언을 고칩니다. 나머지 공개 메서드는 유지합니다. 코드 조각을 해당 선언과 메서드 안에 적용한 뒤 ./mvnw test를 실행해 컴파일하고 규칙을 확인합니다.

private boolean borrowed;
// 생성자의 첫 검사
if (title == null || title.isBlank()) {
    throw new IllegalArgumentException("제목은 비어 있을 수 없습니다");
}

정상과 경계를 직접 실행

앞 단계의 빌드가 끝나 target/classes가 만들어진 뒤 아래 명령을 실행합니다. Demo.java는 실습 루트에 만드는 관찰용 파일입니다. 제공 테스트와는 별도이며 다른 레슨으로 이동해 실행하지 않습니다.

cat > Demo.java <<'JAVA'
public class Demo {
    public static void main(String[] args) {
        lab.Book a = new lab.Book("A");
        lab.Book alias = a;
        lab.Book b = new lab.Book("B");
        alias.borrow();
        System.out.println("A 대여 중: " + a.isBorrowed());
        System.out.println("B 대여 중: " + b.isBorrowed());
        a.returnBook();
        System.out.println("A 반납 후: " + a.isBorrowed());
        try { new lab.Book(null); }
        catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }
    }
}
JAVA
javac --release 17 -encoding UTF-8 -cp target/classes -d target/classes Demo.java
java -cp target/classes Demo

실행 결과

A 대여 중: true
B 대여 중: false
A 반납 후: false
제목은 비어 있을 수 없습니다

테스트와 종료 상태 확인

아래 명령으로 전체 테스트를 다시 실행하고 바로 이어 종료 상태를 읽습니다. -q는 정상 로그를 줄입니다. 아래 0은 echo가 보여 준 테스트 명령의 종료 상태입니다. target/surefire-reports의 XML 또는 txt에서 제공 테스트 22개가 실행되었고 실패·오류·건너뜀이 0인지 함께 확인합니다.

./mvnw -q test
echo $?

실행 결과

0

확인 문제

실습

Book 생성자는 null·빈 문자열·공백 제목을 IllegalArgumentException으로 거부합니다. private final 제목과 객체별 borrowed를 사용하고 공개 메서드로 상태를 변경합니다. 별칭은 같은 객체를 보며 별도로 만든 두 책은 독립적입니다. 중복 대여를 거부하고 반납 후 false를 확인합니다.

시작 코드·테스트 내려받기

실행 명령

./mvnw test

기대 결과

제공 테스트 22개 실행, Failures 0, Errors 0, Skipped 0, 종료 코드 0입니다.

모범 답안모범 답안 내려받기

더 읽기

면접 질문

  • 인스턴스 필드와 static 필드의 차이는 무엇인가요?
  • 참조 복사와 새 객체 생성은 어떻게 다른가요?
  • private과 final은 각각 어떤 역할을 하나요?