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

안동민 개발노트

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

클래스와 인스턴스

new가 Member 객체를 만들고 참조값을 반환한 뒤 필드에 접근하는 순서를 메모리 흐름과 함께 추적합니다.

앞 절에서 Member라는 타입을 선언했지만 아직 회원 정보는 하나도 존재하지 않습니다.

클래스는 필드의 구성만 설명합니다.

실제 이메일과 나이가 들어갈 저장 공간은 new Member()를 실행해야 생깁니다.

이 차이를 놓치면 “클래스를 만들었는데 왜 값이 저장되지 않지?” 또는 “변수에 객체 전체가 들어 있나?” 같은 혼란이 이어집니다.

이번 절의 핵심은 문법 한 줄을 네 단계로 풀어 읽는 것입니다.

Member member = new Member();

오른쪽의 new가 인스턴스를 만들고, JVM이 그 인스턴스에 도달할 수 있는 참조값을 돌려주며, 왼쪽 변수는 그 참조값을 보관합니다.


클래스와 인스턴스

먼저 클래스 파일과 실행 파일을 분리합니다.

src/members/ch04/v4/Member.java
public class Member {
    String email;
    int age;
    boolean active;
}
src/members/ch04/v4/MemberMain.java
public class MemberMain {
    public static void main(String[] args) {
        Member member = new Member();

        member.email = "member@example.com";
        member.age = 45;
        member.active = false;

        System.out.printf(
            "%s / %d세 / %s%n",
            member.email, member.age, member.active
        );
    }
}

두 파일을 함께 컴파일하고 main이 있는 클래스를 실행합니다.

실행
javac -d out src/members/ch04/v4/Member.java src/members/ch04/v4/MemberMain.java
java -cp out MemberMain

member@example.com / 45세 / false

Member.java만 컴파일해도 클래스 파일은 만들 수 있지만 실행 시작점인 main이 없습니다.

반대로 MemberMainMember 타입을 사용하므로 두 소스가 모두 컴파일 대상에 있어야 합니다.

클래스 선언과 프로그램 실행 역할을 분리해 두면 이후 여러 프로그램이 같은 타입을 재사용할 수 있습니다.


new의 실행 순서

Member member = new Member();를 한 덩어리로 외우지 말고 다음 순서로 읽어 봅시다.

  1. JVM이 Member 인스턴스를 저장할 공간을 힙에 확보합니다.
  2. 각 필드를 타입의 기본값으로 초기화합니다. emailnull, age0, activefalse입니다.
  3. 새 인스턴스에 도달할 수 있는 참조값을 결과로 돌려줍니다.
  4. 지역 변수 member에 그 참조값을 복사합니다.

실제 참조값의 숫자나 주소 표현은 JVM 구현에 맡겨집니다.

교재에서는 관계를 설명하려고 x101 같은 기호를 쓰지만 애플리케이션이 그 값을 계산하거나 바꾸지는 않습니다.

다음 프로그램으로 필드를 대입하기 전의 기본값을 확인할 수 있습니다.

src/members/ch04/v4/DefaultFieldValues.java
public class DefaultFieldValues {
    public static void main(String[] args) {
        Member member = new Member();

        System.out.println("email=" + member.email);
        System.out.println("age=" + member.age);
        System.out.println("active=" + member.active);
    }
}
email=null
age=0
active=false

기본값이 있다는 사실과 그 값이 업무적으로 올바르다는 사실은 다릅니다.

이메일 없는 회원 정보를 허용하지 않을 예정이라면 객체를 만든 직후 유효한 값을 넣어야 합니다.

생성 시점에 유효성을 강제하는 방법은 생성자를 다루는 장에서 개선합니다.


점 연산자와 참조

member.email을 “변수 안에 있는 email”이라고만 이해하면 참조형이 어려워집니다.

실제로는 다음 두 동작이 결합됩니다.

  1. member 변수에서 참조값을 읽어 인스턴스에 도달합니다.
  2. 그 인스턴스의 email 필드를 선택합니다.
member.email = "member@example.com";
int copiedAge = member.age;

첫 줄은 참조를 따라가 email 필드에 문자열 참조를 저장합니다.

둘째 줄은 같은 방식으로 age 필드에서 숫자 45를 읽어 copiedAge에 복사합니다.

점 왼쪽에 객체가 아니라 null이 있으면 첫 단계에서 경로가 끊깁니다.

그 실패는 뒤의 null 절에서 자세히 다룹니다.


참조 변수와 객체 생성

다음 코드는 컴파일되지 않습니다.

실패 사례: 초기화하지 않은 지역 변수
public class UninitializedReference {
    public static void main(String[] args) {
        Member member;
        System.out.println(member.email);
    }
}
error: variable member might not have been initialized

Member member;는 참조값을 담을 지역 변수만 선언합니다.

객체도 만들지 않았고 변수에 null조차 대입하지 않았습니다.

자바는 초기화되지 않은 지역 변수를 읽는 것을 컴파일 단계에서 막습니다.

아래 코드는 컴파일되지만 실행 중 실패합니다.

실패 사례: null을 저장한 참조 변수
Member member = null;
System.out.println(member.email);

이번에는 변수에 null이라는 값이 분명히 들어 있으므로 컴파일러 규칙은 통과합니다.

그러나 실행할 때 따라갈 인스턴스가 없어 NullPointerException이 발생합니다.

“변수 선언”, “참조값 대입”, “객체 생성”을 같은 동작으로 보지 않아야 두 오류의 시점을 구분할 수 있습니다.


다중 인스턴스

클래스는 한 번 선언하고 new는 필요한 만큼 호출할 수 있습니다.

src/members/ch04/v4/TwoMembers.java
public class TwoMembers {
    public static void main(String[] args) {
        Member firstMember = new Member();
        firstMember.email = "kim@example.com";
        firstMember.age = 25;

        Member secondMember = new Member();
        secondMember.email = "lee@example.com";
        secondMember.age = 40;

        System.out.println(firstMember.email + " / " + firstMember.age);
        System.out.println(secondMember.email + " / " + secondMember.age);
    }
}

두 변수의 타입과 두 인스턴스의 필드 구조는 같지만 저장된 상태는 독립적입니다.

firstMember.age를 30으로 바꿔도 secondMember.age는 40으로 남습니다.

각각의 new가 다른 인스턴스를 만들고 다른 참조값을 반환했기 때문입니다.

디버깅할 때는 클래스 이름보다 참조 관계를 먼저 봅니다.

같은 Member 타입이라고 같은 객체는 아닙니다.

반대로 변수 이름이 다르더라도 같은 참조값이 복사되면 같은 객체를 바라볼 수 있습니다.

그 공유 문제는 기본형과 참조형을 비교할 때 직접 재현합니다.


연습 문제

다음 조건을 만족하는 프로그램을 작성하세요.

  1. Member 인스턴스를 두 개 만듭니다.
  2. 첫 회원은 이메일 "kim@example.com", 나이 25세, 활성 상태 true로 설정합니다.
  3. 둘째 회원은 이메일 "lee@example.com", 나이 40세, 활성 상태 false로 설정합니다.
  4. 두 인스턴스의 필드가 서로 영향을 주지 않는지 확인하기 위해 첫 회원의 나이를 30세로 바꾸고 둘 다 다시 출력합니다.
  5. Member shared = first;를 추가했을 때 shared.age = 99가 어느 인스턴스를 바꾸는지 실행 전에 예측합니다.
해설 보기
src/members/ch04/exercise/TwoMemberExercise.java
public class TwoMemberExercise {
    public static void main(String[] args) {
        Member first = new Member();
        first.email = "kim@example.com";
        first.age = 25;
        first.active = true;

        Member second = new Member();
        second.email = "lee@example.com";
        second.age = 40;
        second.active = false;

        first.age = 30;
        System.out.println(first.email + "=" + first.age);
        System.out.println(second.email + "=" + second.age);

        Member shared = first;
        shared.age = 99;
        System.out.println("first=" + first.age);
        System.out.println("second=" + second.age);
    }
}
kim@example.com=30
lee@example.com=40
first=99
second=40

첫 번째와 두 번째 new는 서로 다른 객체를 만들었습니다.

shared = first에서는 새 객체를 만들지 않고 첫 객체의 참조값을 복사했으므로 sharedfirst가 같은 필드를 읽고 씁니다.

이 결과를 아직 완전히 설명하기 어렵다면 정상입니다.

다음 두 절에서 객체 배열과 참조값 복사를 차례로 분해합니다.


클래스는 필드 구조이고 인스턴스는 new가 만든 실제 상태입니다.

변수에는 인스턴스 전체가 아니라 그 상태에 도달하는 참조값이 들어갑니다.

다음 절에서는 여러 참조를 배열에 넣을 때 메모리 구조가 어떻게 한 층 더 생기는지 살펴봅니다.