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

안동민 개발노트

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

클래스와 데이터 묶음

평행 배열의 암묵적 인덱스 계약을 깨뜨려 보고 관련 데이터를 Member 클래스로 묶어야 하는 이유를 찾습니다.

회원가입 CLI가 처음에는 이메일 하나만 입력받았다면 변수 하나로 충분합니다.

회원 나이가 필요해지면 age를 더하고, 활성 여부가 필요해지면 active를 더하면 됩니다.

문제는 회원이 두 개, 세 개로 늘어날 때 시작됩니다.

한 회원을 설명하는 값들이 서로 다른 변수와 배열에 흩어지고, 개발자가 “같은 위치의 값은 같은 회원 정보”이라는 규칙을 머릿속으로만 지켜야 하기 때문입니다.

클래스는 함께 다룰 데이터의 구조를 이름 붙여 정의합니다.

new로 그 구조에 맞는 실제 값을 만들면 객체가 되고, 객체 변수에는 객체 자체를 복사한 값이 아니라 그 객체에 도달하는 참조가 들어갑니다.

클래스·객체·참조의 최소 예제
class Member {
    String email;
    int age;
}

Member member = new Member();
member.email = "hong@example.com";
member.age = 30;
System.out.println(member.email + "=" + member.age);

Member는 구조, new Member()는 객체 생성, member는 그 객체를 가리키는 참조 변수입니다.

이 최소 관계를 먼저 기억한 뒤 배열만으로 데이터를 관리할 때 무엇이 불편한지 확인합니다.


개별 변수의 코드 중복

다음 프로그램은 두 개의 회원 정보를 출력합니다.

실행할 수는 있지만 회원 한 개를 추가할 때마다 변수 선언, 값 대입, 출력문을 함께 늘려야 합니다.

src/members/ch04/v1/SeparateVariables.java
public class SeparateVariables {
    public static void main(String[] args) {
        String email1 = "kim@example.com";
        int age1 = 35;
        boolean active1 = true;

        String email2 = "lee@example.com";
        int age2 = 20;
        boolean active2 = false;

        System.out.println(email1 + " / " + age1 + "세 / " + active1);
        System.out.println(email2 + " / " + age2 + "세 / " + active2);
    }
}
실행
javac -d out src/members/ch04/v1/SeparateVariables.java
java -cp out SeparateVariables

kim@example.com / 35세 / true
lee@example.com / 20세 / false

출력 자체는 맞습니다.

하지만 email1, age1, active1이 하나의 회원이라는 사실은 이름 뒤의 숫자만 알려 줍니다.

컴파일러는 email1age2를 함께 출력해도 막지 못합니다.

모두 각자의 타입에는 맞는 코드이기 때문입니다.

데이터 사이의 관계가 변수 이름에만 숨어 있으면 오타가 곧 논리 오류가 됩니다.


배열의 관계 표현 한계

같은 종류의 값을 배열로 바꾸면 반복문을 사용할 수 있습니다.

회원을 추가할 때 출력문을 복제하지 않아도 된다는 점은 분명한 개선입니다.

src/members/ch04/v2/ParallelArrays.java
public class ParallelArrays {
    public static void main(String[] args) {
        String[] emails = {"kim@example.com", "lee@example.com", "park@example.com"};
        int[] age = {35, 20, 15};
        boolean[] active = {true, false, false};

        for (int i = 0; i < emails.length; i++) {
            System.out.printf(
                "%s / %d세 / %s%n",
                emails[i], age[i], active[i]
            );
        }
    }
}

이 구조에는 문서에 적히지 않은 계약이 하나 있습니다.

emails[i], age[i], active[i]는 반드시 같은 회원 정보를 나타낸다.

자바 타입 시스템은 이 계약을 모릅니다.

세 배열은 서로 독립된 객체이고 길이도 따로 정할 수 있습니다.

다음처럼 나이 배열에서 한 값을 빼먹으면 코드는 정상적으로 컴파일되지만 세 번째 회원을 출력하는 순간 실패합니다.

실패 사례: 배열 길이가 달라진 경우
String[] emails = {"kim@example.com", "lee@example.com", "park@example.com"};
int[] age = {35, 20};

for (int i = 0; i < emails.length; i++) {
    System.out.println(emails[i] + " / " + age[i]);
}
kim@example.com / 35
lee@example.com / 20
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 2 out of bounds for length 2

더 위험한 실패는 예외조차 나지 않는 경우입니다.

이메일 배열만 정렬하거나 한 배열에서만 값을 삭제하면 길이는 같아도 서로 다른 회원의 값이 결합됩니다.

프로그램은 그럴듯한 문장을 출력하므로 오류를 늦게 발견합니다.


함께 변하는 값

이메일, 회원 나이, 활성 여부는 “한 회원 정보”이 생성될 때 함께 정해지고, 화면에 표시되거나 파일에 저장될 때도 함께 이동합니다.

이런 값을 하나의 타입으로 묶으면 관계가 코드 구조에 드러납니다.

src/members/ch04/v3/Member.java
public class Member {
    String email;
    int age;
    boolean active;
}

Member는 지금 당장 실행되는 프로그램이 아닙니다.

한 회원 정보가 어떤 필드를 갖는지 선언한 새 타입입니다.

String이 문자열 값을 다루는 타입이고 int가 정수 값을 다루는 타입이듯, 이제 Member는 이메일·나이·활성 여부가 함께 있는 데이터를 다루는 타입이 됩니다.

클래스를 선언했다고 회원이 자동으로 생기지는 않습니다.

다음 절에서 new를 사용해 실제 인스턴스를 만들 것입니다.

지금은 클래스가 무엇을 해결하는지만 확인해 봅시다.

  • 한 회원의 필드가 한 타입 안에 모입니다.
  • age만 따로 정렬해 이메일과 어긋나게 만드는 구조를 피할 수 있습니다.
  • 메서드에 회원을 전달할 때 매개변수 세 개 대신 Member 하나를 전달할 수 있습니다.
  • 나중에 memo 같은 필드를 추가할 위치가 명확해집니다.

클래스의 응집도

값을 한 클래스에 넣는 기준은 “화면에 같이 보인다”가 아니라 “같은 개념을 설명하고 같은 이유로 바뀐다”입니다.

예를 들어 CLI의 현재 메뉴 번호나 출력 색상은 회원 정보 자체의 속성이 아닙니다.

그것까지 Member에 넣으면 입력 화면을 바꿀 때 도메인 데이터도 함께 수정해야 합니다.

다음 두 질문으로 경계를 점검할 수 있습니다.

  1. 이 값 없이도 회원 정보가라는 개념이 성립하는가?
  2. 이 값은 회원을 생성·저장·조회할 때 함께 이동하는가?

email, age, active는 두 번째 질문에 그렇다고 답할 수 있습니다.

반면 selectedMenu는 CLI의 상태이므로 별도 지역 변수에 두는 편이 낫습니다.


필드 추가의 변경 범위

사용자가 회원마다 짧은 메모를 남기고 싶다고 가정해 봅시다.

평행 배열 구조에서는 String[] notes를 추가하고, 초기값의 순서를 맞추고, 모든 출력·정렬·삭제 코드가 네 배열을 함께 움직이는지 확인해야 합니다.

클래스 구조에서는 Member에 필드를 추가하고 그 필드가 필요한 생성·출력 경계를 수정합니다.

src/members/ch04/v3/Member.java
public class Member {
    String email;
    int age;
    boolean active;
    String note;
}

클래스가 변경을 없앤 것은 아닙니다.

메모를 실제로 입력하고 출력하려면 사용처도 고쳐야 합니다.

중요한 차이는 관련 변경이 “회원 정보를 다루는 코드”라는 경계 안에 모인다는 점입니다.

구조의 목적은 수정할 줄 수를 무조건 최소화하는 데 있지 않고, 무엇을 함께 검토해야 하는지를 분명히 하는 데 있습니다.


회원 단위의 연산

데이터를 묶을 기준이 애매하다면 앞으로 필요한 연산을 상상해 보는 것도 도움이 됩니다.

회원 저장소에서는 “한 회원을 출력한다”, “한 회원의 활성 상태를 바꾼다”, “한 회원을 파일 한 줄로 저장한다” 같은 동작이 반복될 가능성이 큽니다.

평행 배열에서는 이 연산마다 이메일 배열의 인덱스, 나이 배열의 인덱스, 활성 배열의 인덱스를 함께 전달해야 합니다.

인덱스 하나만 잘못 전달해도 다른 사람의 값이 섞입니다.

반대로 Member가 있으면 연산의 입력을 Member 하나로 표현할 수 있습니다.

아직 메서드와 객체 책임을 옮기지는 않지만, 타입의 경계가 앞으로 생길 메서드의 경계까지 예고합니다.

단, 화면 전체를 그리거나 메뉴를 선택하는 연산은 한 회원만으로 수행할 수 없으므로 Member 안에 억지로 넣을 이유가 없습니다.

데이터 묶음은 이후 행동을 배치할 기준도 함께 제공해야 합니다.


연습 문제

다음 코드는 가입한 회원의 이메일, 이름, 활성 여부를 별도 배열로 관리합니다.

String[] emails = {"kim@example.com", "lee@example.com"};
String[] names = {"김민수", "이서연"};
boolean[] active = {true, false};

다음을 수행해 보세요.

  1. 세 값이 한 회원을 이루도록 MemberProfile 클래스를 선언합니다.
  2. 화면 테마나 현재 메뉴처럼 회원 자체의 속성이 아닌 값은 필드에서 제외합니다.
  3. 다음 요구사항 “가입 경로 추가”가 들어왔을 때 어느 파일과 사용처를 검토할지 적습니다.
  4. 아직 new를 배우지 않았으므로 객체 생성 코드는 작성하지 않아도 됩니다.
해설 보기
src/members/ch04/exercise/MemberProfile.java
public class MemberProfile {
    String email;
    String name;
    boolean active;
    String signupChannel;
}

email, name, active, signupChannel은 모두 한 회원과 함께 생성·저장될 값입니다.

현재 메뉴 번호나 콘솔 색상은 회원이 아니라 사용자 인터페이스의 상태이므로 넣지 않습니다.

가입 경로를 추가할 때는 MemberProfile의 필드, 회원을 만드는 입력 경계, 회원을 보여 주는 출력 경계를 함께 검토해야 합니다.

파일 저장 기능이 있다면 저장 형식도 영향을 받습니다.

필드 이름이 다르다고 정답이 틀리는 것은 아닙니다.

중요한 것은 타입이 한 명의 회원을 표현하고, 관련 없는 UI 상태가 섞이지 않는 것입니다.


평행 배열은 반복문을 가능하게 했지만 한 회원의 경계를 보장하지 못했습니다.

다음 절에서는 Member라는 설계로부터 실제 인스턴스를 만들고, 변수에 들어가는 것이 객체 자체가 아니라 참조값이라는 사실을 확인합니다.