클래스로 관련 데이터 묶기
흩어진 변수와 평행 배열로 회원 정보를 다룰 때 생기는 문제를 확인하고, 함께 다니는 값을 Member 클래스 하나로 묶는 이유와 기준을 배웁니다.
3장에서 만든 회원가입 명령줄 프로그램(CLI, command-line interface)은 이메일과 나이를 서로 다른 배열에 담고, 같은 인덱스에 있는 값끼리 한 회원의 정보라고 약속하고 썼습니다. 회원 수가 적고 기능이 단순할 때는 이 방식으로도 충분했습니다. 하지만 회원마다 저장할 값이 늘고 정렬이나 삭제 같은 기능이 붙으면, 그 약속을 지키는 일이 모두 개발자의 기억에 맡겨집니다. 한 곳만 빠뜨려도 다른 회원의 값이 섞이는데, 컴파일러는 이를 알려 주지 못합니다.
이 글에서는 회원 정보를 개별 변수와 배열로 관리할 때 무엇이 어긋나는지 직접 실행해 보고, 해결책으로 클래스(class)를 소개합니다. 클래스는 함께 다루는 값들을 하나로 묶어 새 타입으로 이름 붙이는 문법입니다. 이 장의 다음 글들에서는 클래스로 실제 객체를 만들고, 변수에 무엇이 들어가는지를 차례로 살펴봅니다.
회원마다 변수를 따로 만들면
가장 단순한 방법부터 보겠습니다. 다음 프로그램은 회원 두 명의 이메일, 나이, 활성 여부를 각각 변수에 담아 출력합니다.
public class SeparateVariables {
public static void main(String[] args) {
String email1 = "[email protected]";
int age1 = 35;
boolean active1 = true;
String email2 = "[email protected]";
int age2 = 20;
boolean active2 = false;
System.out.println(email1 + " / " + age1 + "세 / " + active1);
System.out.println(email2 + " / " + age2 + "세 / " + active2);
}
}1장에서 만든 작업 폴더에서 다음처럼 컴파일하고 실행합니다. 소스 파일이 src/members/ch04/v1 폴더 안에 있으므로 javac에는 그 경로를 그대로 적고, java에는 클래스 이름 SeparateVariables만 적습니다.
javac -d out src/members/ch04/v1/SeparateVariables.java
java -cp out SeparateVariables[email protected] / 35세 / true
[email protected] / 20세 / false출력은 맞습니다. 하지만 회원을 한 명 추가하려면 변수 세 개를 새로 선언하고 출력문도 한 줄 늘려야 합니다. 회원이 열 명이면 변수는 서른 개가 됩니다.
더 큰 문제는 email1, age1, active1이 한 회원이라는 사실을 이름 끝의 숫자만 알려 준다는 점입니다. 출력문에서 age1 대신 age2를 잘못 쓴 경우를 생각해 보세요.
System.out.println(email1 + " / " + age2 + "세 / " + active1);[email protected] / 20세 / true[email protected] 회원 옆에 [email protected] 회원의 나이 20이 출력됐습니다. 그런데 컴파일러는 이 실수를 막지 못합니다. age2도 int이므로 타입으로 보면 아무 문제가 없기 때문입니다. 값들 사이의 관계가 변수 이름에만 담겨 있으면, 이름 하나를 잘못 쓴 오타가 그대로 잘못된 결과가 됩니다.
배열로 바꾸면 반복문을 쓸 수 있습니다
같은 종류의 값을 배열에 모으면 회원 수와 상관없이 반복문 하나로 처리할 수 있습니다. 3장에서 이메일과 나이를 두 배열에 나눠 담았던 것처럼, 이번에는 활성 여부까지 세 배열에 나눠 담습니다. 이렇게 같은 인덱스끼리 짝을 이루는 배열 여러 개를 평행 배열(parallel arrays)이라고 부릅니다. 3장에서 병렬 배열이라고 부른 구조와 같은 것입니다.
public class ParallelArrays {
public static void main(String[] args) {
String[] emails = {"[email protected]", "[email protected]", "[email protected]"};
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]
);
}
}
}이 예제에서 처음 쓰는 System.out.printf는 형식 문자열 안의 자리 표시자를 뒤에 오는 값으로 차례로 채워 출력합니다. %s는 값을 문자열로, %d는 정수로 바꿔 넣는 자리이고, %n은 줄바꿈입니다. 그래서 emails[i]가 첫 번째 %s에, age[i]가 %d에, active[i]가 두 번째 %s에 들어갑니다.
[email protected] / 35세 / true
[email protected] / 20세 / false
[email protected] / 15세 / false회원을 추가할 때 출력문을 복사하지 않아도 되니 분명히 나아졌습니다. 하지만 이 구조는 코드 어디에도 적혀 있지 않은 약속 하나에 기대고 있습니다.
emails[i],age[i],active[i]는 언제나 같은 회원의 정보다.
자바의 타입 시스템은 이 약속을 알지 못합니다. 세 배열은 서로 독립된 객체이고, 길이도 순서도 따로 정해집니다. 그래서 약속이 깨져도 컴파일은 그대로 성공합니다.
같은 인덱스라는 약속이 깨지는 두 가지 경우
첫째는 배열의 길이가 서로 달라지는 경우입니다. ParallelArrays의 main 안을 다음처럼 바꿔 나이 배열에서 값 하나를 빠뜨리면, 코드는 문제없이 컴파일되지만 세 번째 회원을 출력하려는 순간 실행이 멈춥니다.
String[] emails = {"[email protected]", "[email protected]", "[email protected]"};
int[] age = {35, 20};
for (int i = 0; i < emails.length; i++) {
System.out.println(emails[i] + " / " + age[i]);
}[email protected] / 35
[email protected] / 20
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 2 out of bounds for length 2emails.length는 3이지만 age의 길이는 2라서, i가 2가 되면 age[2]는 없는 칸이 됩니다. 실제 출력에는 이 아래에 예외가 난 위치를 알려 주는 at ... 줄이 이어집니다. 그래도 이 경우는 예외가 나기 때문에 문제가 있다는 것을 바로 알 수 있습니다.
둘째는 길이는 같은데 순서가 어긋나는 경우로, 이쪽이 더 위험합니다. 회원 목록을 이메일 순으로 보여 주려고 이메일 배열만 정렬했다고 해 봅시다. java.util.Arrays.sort는 배열의 원소를 오름차순으로 정렬하는 표준 라이브러리 메서드입니다. 3장에서는 import java.util.Arrays;를 적고 Arrays.copyOf처럼 짧게 불렀는데, 이 예제처럼 패키지 이름까지 붙여 쓰면 import 문 없이도 호출할 수 있습니다.
public class SortOneArray {
public static void main(String[] args) {
String[] emails = {"[email protected]", "[email protected]", "[email protected]"};
int[] age = {15, 35, 20};
java.util.Arrays.sort(emails); // 이메일 배열만 사전 순으로 정렬
for (int i = 0; i < emails.length; i++) {
System.out.println(emails[i] + " / " + age[i] + "세");
}
}
}[email protected] / 15세
[email protected] / 35세
[email protected] / 20세이메일은 사전 순으로 정렬됐지만 나이 배열은 그대로이므로, 세 회원 모두 다른 사람의 나이를 갖게 되었습니다. 원래 35세인 [email protected]은 15세로, 15세인 [email protected]은 20세로 출력됩니다. 예외도 경고도 없고 출력 형식도 그럴듯하기 때문에, 누군가 데이터를 직접 대조해 보기 전까지는 잘못됐다는 것을 알기 어렵습니다. 한 배열에서만 값을 지우거나 끼워 넣을 때도 똑같은 일이 생깁니다.
두 경우 모두 원인은 같습니다. '같은 인덱스의 값은 같은 회원'이라는 관계가 코드 구조에는 없고 개발자의 머릿속에만 있기 때문입니다. 그렇다면 해결 방향도 분명합니다. 한 회원의 값들을 처음부터 하나로 묶어서 따로 움직일 수 없게 만들면 됩니다.
Member 클래스로 한 회원의 값 묶기
이메일, 나이, 활성 여부는 회원 한 명이 가입할 때 함께 정해지고, 화면에 보이거나 파일에 저장될 때도 함께 움직입니다. 이렇게 늘 함께 다니는 값들을 묶어 새 타입으로 선언하는 문법이 클래스입니다.
public class Member {
String email;
int age;
boolean active;
}class 키워드 뒤의 Member가 새 타입의 이름이고, 중괄호 안에 선언한 변수 email, age, active를 필드(field)라고 부릅니다. 필드는 이 타입의 값 하나가 갖는 데이터 항목입니다. String이 문자열을 다루는 타입이고 int가 정수를 다루는 타입이듯, 이제 Member는 이메일, 나이, 활성 여부가 한데 묶인 회원 정보를 다루는 타입이 됩니다.
Member.java에는 main 메서드가 없으므로 이 파일만으로는 실행할 수 있는 프로그램이 아닙니다. 회원 정보가 어떤 필드로 이루어지는지를 선언했을 뿐입니다. 필드 앞에 private 같은 접근 제어자를 붙이지 않았다는 점도 눈여겨보세요. 이렇게 선언한 필드는 같은 패키지에 속한 다른 클래스가 직접 읽고 쓸 수 있는데, package 선언 없이 작성한 이 장의 예제들은 모두 이름 없는 패키지에 함께 속하므로 실행용 클래스에서 member.email처럼 필드에 바로 접근할 수 있습니다. 패키지와 접근 제어는 5장에서 배웁니다.
아래 그림은 평행 배열의 1번 인덱스에 흩어져 있던 세 값이 Member의 어느 필드에 대응하는지 보여 줍니다. 점선은 대응 관계를 나타낼 뿐입니다. 클래스를 선언한다고 해서 배열의 값이 저절로 옮겨지거나 회원 객체가 생기지는 않습니다.
ParallelArrays의 실제 index 1 값인 [email protected], 20, false를 Member의 email, age, active 필드 선언에 대응한다. 데이터 구조 비교이며 객체 생성이나 자동 변환 실행을 뜻하지 않는다.
Member는 한 회원의 필드 경계를 선언합니다
세 배열의 같은 index 1 값을 한 타입의 필드에 대응합니다. 클래스 선언만으로 객체가 생기거나 값이 자동 저장되지는 않습니다.
| 회원 정보 | 독립된 배열의 같은 index | 한 Member 안의 필드 |
|---|---|---|
| 이메일 | emails[1] · "[email protected]" | String email; |
| 나이 | age[1] = 20 | int age; |
| 활성 여부 | active[1] = false | boolean active; |
ParallelArrays · index 1
- 나이
age[1]=20- Member에 묶을 필드:
int age; - 활성 여부
active[1]=false- Member에 묶을 필드:
boolean active;
- 배열의 관계는 index에 의존
- 세 배열의 길이와 순서를 따로 맞춰야 같은 회원의 값이 됩니다.
- Member는 필드의 경계를 선언
- 세 필드를 한 타입 안에 모읍니다. 선언만으로 객체가 생기거나 위 값이 자동 저장되지는 않습니다.
실제 회원 정보는 new로 만듭니다. 자세한 동작은 다음 글에서 다루지만, 쓰는 모양을 미리 보면 다음과 같습니다. 이 코드는 실행용 클래스의 main 안에 들어갈 부분입니다.
Member member = new Member();
member.email = "[email protected]";
member.age = 30;
System.out.println(member.email + "=" + member.age); // [email protected]=30new Member()는 Member의 구조대로 실제 값을 담을 공간, 즉 객체(object)를 만듭니다. 그다음 member.email처럼 점(.)을 찍어 그 객체의 필드에 값을 넣거나 읽습니다. 이때 변수 member에는 객체 자체가 아니라 객체를 찾아갈 수 있는 참조(reference)가 들어가는데, 이것이 이 장 전체를 꿰뚫는 주제입니다.
클래스로 묶으면 평행 배열에서 겪은 문제가 다음처럼 달라집니다.
- 한 회원의 필드가 한 객체 안에 모이므로 이메일만 따로 정렬되어 나이와 어긋나는 일이 생기지 않습니다. 정렬하더라도 회원 객체 단위로 움직입니다.
- 길이를 맞춰야 할 배열이 여러 개 있지 않습니다. 회원 목록은
Member배열 하나로 관리합니다. - 메서드에 회원을 넘길 때 값 세 개 대신
Member하나를 넘길 수 있습니다. - 회원에 새 정보를 추가할 때 어디에 선언해야 하는지가 분명합니다.
어떤 값을 같은 클래스에 넣을까
클래스를 쓰기 시작하면 '이 값도 Member에 넣어도 될까?'라는 질문을 자주 하게 됩니다. 기준은 '화면에 같이 보이는가'가 아니라 '같은 대상을 설명하고, 같은 이유로 바뀌는가'입니다. 예를 들어 CLI에서 지금 선택된 메뉴 번호나 출력 색상은 회원 목록 화면에 함께 보일 수는 있어도 회원 자체의 속성은 아닙니다. 이런 값까지 Member에 넣으면 메뉴 구성을 바꿀 때마다 회원 데이터 구조도 함께 고쳐야 합니다.
다음 두 질문으로 판단하면 대부분 정리됩니다.
- 이 값은 회원 자체를 설명하는가, 아니면 회원을 보여 주는 화면이나 메뉴의 상태를 설명하는가?
- 이 값은 회원을 만들고, 저장하고, 조회할 때 함께 움직이는가?
email, age, active는 두 질문 모두에 '회원 자체를 설명하고, 함께 움직인다'고 답할 수 있습니다. 반면 현재 선택한 메뉴를 뜻하는 selectedMenu는 CLI의 상태이므로 main의 지역 변수로 두는 편이 맞습니다. 이처럼 한 클래스 안의 요소들이 하나의 목적을 위해 얼마나 밀접하게 모여 있는지를 응집도(cohesion)라고 합니다. 관련 있는 값만 모은 클래스는 응집도가 높고, 이해하기도 고치기도 쉽습니다.
필드를 하나 더 추가할 때 고칠 곳
요구사항이 바뀌어 회원마다 짧은 메모를 남기게 되었다고 해 봅시다. 평행 배열 구조라면 String[] notes 배열을 새로 만들고, 초깃값을 다른 배열과 같은 순서로 맞추고, 출력·정렬·삭제 코드가 모두 네 배열을 함께 움직이는지 하나하나 확인해야 합니다. 하나라도 빠뜨리면 앞에서 본 어긋남이 다시 생깁니다.
클래스 구조에서는 먼저 Member에 필드를 하나 추가합니다.
public class Member {
String email;
int age;
boolean active;
String note;
}물론 클래스가 변경 자체를 없애 주지는 않습니다. 메모를 실제로 입력받고 출력하려면 회원을 만드는 코드와 보여 주는 코드도 고쳐야 합니다. 달라지는 점은 검토할 범위입니다. 메모가 회원의 일부라는 사실이 Member 선언에 드러나므로, Member를 만들거나 출력하는 곳을 찾아보면 고칠 곳이 모두 나옵니다. 구조를 잘 잡는 목적은 고칠 줄 수를 무조건 줄이는 것이 아니라, 무엇을 함께 고쳐야 하는지 분명하게 만드는 데 있습니다.
회원 한 명을 작업 단위로 보기
어떤 값을 묶을지 애매하다면 앞으로 그 데이터로 할 작업을 떠올려 보는 것도 도움이 됩니다. 회원 관리 프로그램에서는 '회원 한 명을 출력한다', '회원 한 명의 활성 상태를 바꾼다', '회원 한 명을 파일의 한 줄로 저장한다' 같은 작업이 반복해서 나옵니다. 평행 배열에서는 이런 작업마다 배열 여러 개와 인덱스를 함께 넘겨야 하고, 인덱스를 하나만 잘못 넘겨도 다른 회원의 값이 섞입니다.
// 평행 배열: 배열 세 개와 인덱스를 함께 넘겨야 합니다
static void printMember(String[] emails, int[] age, boolean[] active, int index) { ... }
// 클래스: 회원 하나만 넘기면 됩니다
static void printMember(Member member) { ... }Member가 있으면 작업의 입력을 회원 하나로 표현할 수 있습니다. 아직은 이런 메서드를 Member 밖에 두지만, 5장에서는 회원과 관련된 동작을 Member 클래스 안으로 옮기는 방법을 배웁니다. 반대로 화면 전체를 그리거나 메뉴를 고르는 작업은 회원 한 명만으로 할 수 없으므로 Member에 넣을 이유가 없습니다. 데이터를 묶는 기준이 나중에는 동작을 어디에 둘지 정하는 기준으로도 이어집니다.
더 깊이: 값만 묶을 때 쓰는 record
Java 16부터는 값을 묶는 용도의 클래스를 더 짧게 선언하는 레코드(record)가 정식 기능이 되었습니다. record MemberRecord(String email, int age, boolean active) {} 한 줄만 쓰면 컴파일러가 세 값을 받는 생성자, email() 같은 읽기 메서드, 내용을 비교하는 equals와 hashCode, 읽기 좋은 toString을 만들어 줍니다. 예를 들어 new MemberRecord("[email protected]", 35, true)를 출력하면 MemberRecord[[email protected], age=35, active=true]가 나옵니다.
그런데 레코드의 필드는 모두 final이라서, 한 번 만든 뒤에는 member.age = 30처럼 값을 바꿀 수 없고 이렇게 쓰면 컴파일 오류가 납니다. 이 장은 필드를 바꿔 가며 여러 변수가 같은 객체를 공유할 때 무슨 일이 생기는지 보여 주어야 하므로, 일부러 필드를 직접 바꿀 수 있는 평범한 클래스를 씁니다. 이 교재는 5장의 캡슐화된 회원 저장소를 만들 때부터 값 중심의 작은 타입에 레코드를 쓰고, 불변 객체는 9장에서, 레코드의 세부 사항은 36장에서 더 다룹니다.
정리
- 관련 값을 개별 변수나 평행 배열로 관리하면 값들 사이의 관계가 이름이나 인덱스에만 남아, 컴파일러가 실수를 잡아 주지 못합니다.
- 평행 배열은 길이가 달라지면 예외가 나고, 한 배열만 정렬하거나 지우면 예외 없이 다른 회원의 값이 섞입니다.
- 클래스는 함께 다니는 값을 필드로 묶어 새 타입을 선언합니다. 선언만으로는 객체가 생기지 않습니다.
- 같은 클래스에 넣을 값은 '같은 대상을 설명하고 같은 이유로 바뀌는가'로 판단합니다.
연습 문제
다음 코드는 가입한 회원의 이메일, 이름, 활성 여부를 별도 배열로 관리합니다. 세 배열에서 같은 인덱스의 칸이 한 회원을 이룹니다. 예를 들어 0번 칸끼리 모은 [email protected], 김민수, true가 한 사람의 정보입니다.
String[] emails = {"[email protected]", "[email protected]"};
String[] names = {"김민수", "이서연"};
boolean[] active = {true, false};- 세 값이 한 회원을 이루도록
MemberProfile클래스를 선언하세요. 화면 테마나 현재 메뉴처럼 회원 자체의 속성이 아닌 값은 넣지 않습니다. - 다음 요구사항으로 '가입 경로'를 기록해야 한다면, 클래스에 무엇을 추가하고 어떤 코드를 함께 검토해야 할지 적어 보세요.
- 가입일, 휴대폰 번호, 목록 화면의 현재 페이지 번호, 마지막으로 입력한 메뉴 명령 가운데
MemberProfile에 넣을 것과 넣지 않을 것을 고르고 이유를 쓰세요.
아직 new를 자세히 배우지 않았으므로 객체를 만드는 코드는 작성하지 않아도 됩니다.
해설 보기
public class MemberProfile {
String email;
String name;
boolean active;
String signupChannel;
}1번과 2번을 함께 보겠습니다. email, name, active, signupChannel은 모두 한 회원과 함께 만들어지고 저장될 값입니다. 현재 메뉴 번호나 콘솔 색상은 회원이 아니라 사용자 인터페이스의 상태이므로 넣지 않습니다. 가입 경로를 추가할 때는 MemberProfile의 필드뿐 아니라 회원을 만드는 입력 코드와 회원을 보여 주는 출력 코드를 함께 검토해야 하고, 파일로 저장하는 기능이 있다면 저장 형식도 바뀝니다. 필드 이름은 달라도 괜찮습니다. 중요한 것은 이 타입이 회원 한 명을 표현하고, 관련 없는 화면 상태가 섞이지 않는 것입니다.
3번에서 가입일과 휴대폰 번호는 회원 자체를 설명하고 회원과 함께 저장되므로 넣습니다. 목록 화면의 페이지 번호와 마지막 메뉴 명령은 프로그램 화면의 상태이고, 회원을 저장하거나 불러올 때 함께 움직이지 않으므로 넣지 않습니다. 같은 회원을 보고 있어도 페이지 번호는 사용자가 화면을 넘길 때마다 바뀌는데, 이것은 회원 정보가 바뀌는 이유와 다릅니다.
다음 글
클래스와 인스턴스에서는 new로 Member 객체를 실제로 만들고, 변수에 들어가는 것이 객체 자체가 아니라 참조라는 사실을 메모리 그림과 함께 확인합니다.