자바 클래스와 객체 - 필드 생성자 this 캡슐화 그리고 static 이 공유하는 것 (자바 초급 6단원)
이 단원에서 배우는 것
5단원까지의 코드에는 배열이 두 개 나란히 있었다. titles 와 overdueDays. 둘은 인덱스로만 연결돼 있다. titles[1] 과 overdueDays[1] 이 같은 책이라는 사실은 코드 어디에도 적혀 있지 않고, 오직 개발자의 머릿속에만 있다. 정렬 한 번만 잘못해도 제목과 연체일이 어긋난다.
이번 단원은 이 둘을 한 덩어리로 묶는다. Book 클래스를 만들고, 거기에 5단원의 calcLateFee 까지 끌어들인다.
- 클래스를 정의하고 필드·생성자·
this를 써서 객체를 만든다. - 필드를
private으로 감추고 메서드로만 여는 캡슐화가 실제로 무엇을 막아 주는지 설명한다. static이 붙은 것과 안 붙은 것이 각각 무엇에 속하는지 구분한다.
왜 필요한가
인덱스로 묶인 데이터는 반드시 어긋난다
4단원 코드에 정렬을 하나 넣어 본다. 연체일이 많은 순으로 보고 싶다고 하자.
String[] titles = {"자바의 정석", "이펙티브 자바", "클린 코드"};
int[] overdueDays = {0, 12, 3};
Arrays.sort(overdueDays); // [0, 3, 12] 로 정렬된다
// titles 는 그대로다. 이제 titles[1] 과 overdueDays[1] 은 다른 책이다.
컴파일도 되고 실행도 되고 예외도 없다. 그냥 영수증에 남의 책 연체료가 찍힌다. 배열 두 개로 한 개념을 표현하는 한 이 사고는 시간 문제다.
진짜 문제는 "제목과 연체일과 ISBN 이 한 권의 책을 이룬다"는 사실을 언어가 모른다는 점이다. 클래스는 그 사실을 코드에 적는 방법이다. 한 번 적어 두면 컴파일러가 대신 지켜 준다.
그리고 값이 언제 이상해졌는지 추적할 수 있어야 한다
overdueDays[1] = -5; 는 아무 데서나 쓸 수 있다. 음수 연체일이 들어와 계산이 이상해졌을 때, 어느 줄이 범인인지 찾으려면 배열을 건드리는 모든 코드를 뒤져야 한다. 값을 바꾸는 통로를 메서드 하나로 좁혀 두면 그 메서드만 보면 된다. 이게 캡슐화의 실용적 이유다. "정보 은닉"이라는 말보다 이쪽이 실무 감각에 가깝다.
문법과 예제
가장 단순한 형태부터
public class Book {
String title;
int overdueDays;
}
public class LoanDesk {
public static void main(String[] args) {
Book book = new Book();
book.title = "클린 코드";
book.overdueDays = 3;
System.out.println(book.title + " " + book.overdueDays + "일"); // 클린 코드 3일
}
}
여기서 Book 은 설계도이고 new Book() 이 만들어 낸 것이 객체다. book 변수 안에 들어 있는 것은 객체 자체가 아니라 객체가 놓인 곳의 주소다(2단원). 그래서 Book 은 참조형이다.
클래스 안에 선언한 변수를 필드라고 부르고, 메서드 안의 지역 변수와 두 가지가 다르다. 첫째, 필드는 초기화하지 않아도 4단원에서 본 타입별 기본값이 들어간다(int 는 0, 참조형은 null). 지역 변수는 초기화 없이 쓰면 컴파일 에러다. 둘째, 필드는 객체가 살아 있는 동안 유지된다.
생성자와 this
위 코드의 문제는 new Book() 직후의 객체가 제목이 null 인 반쯤 만들어진 상태라는 점이다. 그 사이에 누가 이 객체를 쓰면 NullPointerException 이 난다. 생성자는 "객체를 만들려면 이만큼은 반드시 줘야 한다"를 강제한다.
public class Book {
private final String title;
private final String isbn;
private int overdueDays;
public Book(String title, String isbn, int overdueDays) {
this.title = title;
this.isbn = isbn;
this.overdueDays = overdueDays;
}
}
생성자는 이름이 클래스명과 같고 반환 타입이 없다. void 도 안 쓴다. void Book(...) 이라고 적으면 그건 생성자가 아니라 Book 이라는 이름의 평범한 메서드가 되어 버린다. 컴파일은 되고 아무도 안 부른다. 이게 은근히 자주 나오는 실수다.
this 는 "지금 만들어지고 있는(또는 지금 메서드를 실행 중인) 그 객체"를 가리킨다. this.title 은 필드, 그냥 title 은 매개변수다. 이름을 다르게 지으면 this 없이도 되지만, 실무에서는 필드명과 매개변수명을 같게 두고 this 로 구분하는 쪽이 표준이다. 이름을 억지로 다르게 지으면 오히려 헷갈린다.
final 이 붙은 필드는 생성자에서 한 번 정해지면 바꿀 수 없다. 제목과 ISBN 은 책이 바뀌는 게 아니라 다른 책이 되는 것이므로 final 이 맞다. 반면 연체일은 반납·연장에 따라 변한다.
생성자 여러 개와 this(...)
생성자도 메서드처럼 오버로딩된다(5단원). 새로 등록하는 책은 연체일이 언제나 0이므로, 두 개만 받는 짧은 생성자를 하나 더 둔다.
public Book(String title, String isbn) {
this(title, isbn, 0); // 다른 생성자를 부른다. 반드시 첫 줄이어야 한다
}
this(...) 는 같은 클래스의 다른 생성자를 부르는 문법이다. 초기화 로직을 한 군데에 모으는 용도다. 생성자의 맨 첫 문장이어야 한다는 제약이 있다.
캡슐화 — private 과 검증
필드를 private 으로 막고 메서드로만 열어 준다. 읽는 메서드를 게터, 바꾸는 메서드를 세터라고 부른다. 중요한 건 이름이 아니라 세터 안에 검증을 넣을 수 있다는 점이다.
잘못된 값이 들어오면 throw new IllegalArgumentException(...) 으로 프로그램을 그 자리에서 멈춘다. 예외를 잡아서 처리하는 방법은 중급 단원에서 다루고, 여기서는 잘못된 값이 조용히 들어가는 것보다 요란하게 터지는 편이 낫다는 것만 받아들이면 된다.
public class Book {
private static int registeredCount = 0;
private static final int MAX_OVERDUE_DAYS = 365;
private final String title;
private final String isbn;
private int overdueDays;
public Book(String title, String isbn) {
this(title, isbn, 0);
}
public Book(String title, String isbn, int overdueDays) {
this.title = title;
this.isbn = isbn;
setOverdueDays(overdueDays); // 생성 시점에도 같은 검증을 태운다
registeredCount++;
}
public String getTitle() {
return title;
}
public String getIsbn() {
return isbn;
}
public int getOverdueDays() {
return overdueDays;
}
public void setOverdueDays(int overdueDays) {
if (overdueDays < 0 || overdueDays > MAX_OVERDUE_DAYS) {
throw new IllegalArgumentException("연체일이 범위를 벗어났다: " + overdueDays);
}
this.overdueDays = overdueDays;
}
public int lateFee() {
if (overdueDays <= 0) return 0;
if (overdueDays <= 7) return overdueDays * 100;
return 700 + (overdueDays - 7) * 200;
}
public static int getRegisteredCount() {
return registeredCount;
}
}
5단원의 calcLateFee(int overdueDays) 가 lateFee() 가 되면서 매개변수가 사라졌다. 계산에 필요한 값이 이미 객체 안에 있기 때문이다. 데이터와 그 데이터를 쓰는 코드가 한곳에 모이면 이런 일이 일어난다.
title 은 final 이라 세터가 아예 없다. "필드마다 게터와 세터를 기계적으로 만든다"는 관행이 있지만, 바꿀 이유가 없는 값에 세터를 다는 것은 캡슐화를 스스로 무너뜨리는 일이다. 세터는 필요할 때만 만든다.
써 보기
public class LoanDesk {
public static void main(String[] args) {
Book[] books = {
new Book("자바의 정석", "9788994492032"),
new Book("이펙티브 자바", "9788966262281", 12),
new Book("클린 코드", "9788966260959", 3)
};
int sum = 0;
for (Book book : books) {
System.out.println(book.getTitle() + " : " + book.lateFee() + "원");
sum += book.lateFee();
}
System.out.println("합계 : " + sum + "원");
System.out.println("등록된 도서 수 : " + Book.getRegisteredCount());
Book target = books[1];
target.setOverdueDays(3);
System.out.println(books[1].getTitle() + " : " + books[1].lateFee() + "원");
}
}
자바의 정석 : 0원
이펙티브 자바 : 1700원
클린 코드 : 300원
합계 : 2000원
등록된 도서 수 : 3
이펙티브 자바 : 300원
마지막 두 줄을 잘 본다. target 을 통해 바꿨는데 books[1] 도 바뀌었다. Book 이 참조형이라 target 과 books[1] 이 같은 객체를 가리키기 때문이다. 4단원의 배열 별칭, 5단원의 값 전달과 정확히 같은 이야기다.
잘못된 값을 넣으면 이렇게 된다.
book.setOverdueDays(-1);
// Exception in thread "main" java.lang.IllegalArgumentException: 연체일이 범위를 벗어났다: -1
// at Book.setOverdueDays(Book.java:34)
// at Reject.main(Reject.java:4)
스택 트레이스가 범인을 정확히 짚는다. 필드가 public 이었다면 이 정보는 얻지 못하고, 나중에 이상한 금액이 찍힌 뒤에야 알게 됐을 것이다.
static 이 공유하는 것
static 이 붙으면 객체가 아니라 클래스에 속한다. 객체를 몇 개 만들든 딱 하나만 존재한다.
| 인스턴스 멤버 (static 없음) | 정적 멤버 (static) | |
|---|---|---|
| 몇 개 있나 | 객체마다 하나씩 | 클래스당 하나 |
| 언제 생기나 | new 할 때 | 클래스가 처음 쓰일 때 |
| 부르는 법 | book.lateFee() | Book.getRegisteredCount() |
this | 있다 | 없다 |
registeredCount 가 딱 그 예다. "이 책의 등록 수"가 아니라 "전체 등록 수"이므로 객체마다 있으면 안 된다. 생성자에서 registeredCount++ 를 하면 어느 객체가 만들어지든 같은 카운터가 올라간다.
MAX_OVERDUE_DAYS 는 static final 이다. 모든 책에 공통이고 절대 안 바뀌는 값이므로 객체마다 복사해 둘 이유가 없다. 상수는 static final, 이름은 대문자와 밑줄이 자바의 관례다.
static 메서드는 this 가 없다. 그래서 인스턴스 필드를 볼 수 없다. 1단원에서 main 이 static 이어야 한다고 한 이유가 여기서 이어진다. 프로그램이 시작될 때는 아직 아무 객체도 없으므로, this 없이 부를 수 있어야 한다.
판단 기준은 하나다. "이 값/동작이 특정 객체 하나에 대한 것인가, 아니면 클래스 전체에 대한 것인가." 앞이면 인스턴스, 뒤면 static 이다. 편해 보인다고 전부 static 으로 만들면 객체를 만들 이유가 사라지고, 여러 곳에서 같은 값을 건드려 추적 불가능한 상태가 된다.
실무에서 자주 틀리는 것
1. 생성자에서 this 를 빠뜨린다
필드명과 매개변수명이 같을 때 this 를 안 쓰면, 가까운 쪽인 매개변수가 이긴다. 그래서 매개변수에 자기 자신을 대입하는 무의미한 문장이 된다.
public class SelfAssign {
private String title;
public SelfAssign(String title) {
title = title; // 매개변수에 매개변수를 대입. 필드는 그대로 null
}
public String getTitle() { return title; }
public static void main(String[] args) {
System.out.println(new SelfAssign("클린 코드").getTitle()); // null
}
}
컴파일 에러도 경고도 없이 null 이 나온다. 필드를 final 로 선언해 두면 이 실수가 컴파일 에러로 잡힌다. final 필드는 생성자가 끝날 때까지 반드시 대입돼야 하기 때문이다. 바뀌지 않는 필드에 final 을 붙이는 습관이 실제로 버그를 막는 순간이 여기다.
2. 생성자를 하나 만들었더니 new Book() 이 안 된다
생성자를 하나도 안 쓰면 컴파일러가 매개변수 없는 기본 생성자를 몰래 만들어 준다. 그런데 생성자를 하나라도 직접 쓰는 순간 그 기본 생성자는 사라진다.
Book x = new Book();
// error: no suitable constructor found for Book(no arguments)
// constructor Book.Book(String,String) is not applicable
// (actual and formal argument lists differ in length)
기본 생성자가 필요하면 직접 적어 준다. 다만 "필드가 전부 비어 있는 객체"를 허용해도 되는지 먼저 따져 본다. 대부분은 허용하지 않는 편이 낫다.
3. static 메서드에서 인스턴스 필드를 쓰려 한다
main 에 코드를 늘리다가 반드시 만나는 에러다.
public class Counter {
private int count = 0;
public static void main(String[] args) {
System.out.println(count);
// error: non-static variable count cannot be referenced from a static context
}
}
메시지가 정확하다. static 문맥에는 this 가 없으니 "어느 객체의 count"인지 정할 수 없다. 해법은 둘 중 하나다. (가) 그 값이 정말 클래스 전체 것이면 필드에 static 을 붙인다. (나) 객체마다 달라야 하는 값이면 main 에서 객체를 만들어 그 객체를 통해 접근한다. 습관적으로 static 을 붙여 에러만 없애는 것이 (나)를 (가)로 잘못 바꾸는 전형적인 경로다.
4. static 필드를 객체마다 다른 값이라고 생각한다
public class Wrong {
static int overdueDays; // static 이 붙으면 안 되는 자리
public static void main(String[] args) {
Wrong a = new Wrong();
Wrong b = new Wrong();
a.overdueDays = 12;
System.out.println(b.overdueDays); // 12
}
}
a 에 넣었는데 b 에서 나온다. 하나뿐인 값을 두 객체가 공유하기 때문이다. 게다가 a.overdueDays 처럼 인스턴스 변수로 정적 필드에 접근하는 문법이 허용되기 때문에, 코드만 봐서는 공유되고 있다는 사실이 드러나지 않는다. 정적 멤버는 항상 Wrong.overdueDays, Book.getRegisteredCount() 처럼 클래스 이름으로 접근한다.
5. 게터가 내부 배열을 그대로 돌려준다
5단원에서 배열은 주소가 복사된다고 했다. 게터가 배열 필드를 그대로 반환하면 private 은 이름뿐이고, 밖에서 내용을 마음대로 고칠 수 있다.
import java.util.Arrays;
public class Member {
private final String name;
private final int[] loanHistory;
public Member(String name, int[] loanHistory) {
this.name = name;
this.loanHistory = Arrays.copyOf(loanHistory, loanHistory.length); // 들어올 때 복사
}
public int[] getLoanHistory() {
return Arrays.copyOf(loanHistory, loanHistory.length); // 나갈 때 복사
}
public int[] getLoanHistoryUnsafe() {
return loanHistory; // 내부를 그대로 넘긴다
}
public static void main(String[] args) {
int[] history = {1, 2, 3};
Member m = new Member("김개발", history);
history[0] = 999; // 생성자에 넘긴 원본을 바꿔 본다
System.out.println(Arrays.toString(m.getLoanHistory())); // [1, 2, 3] 안전
m.getLoanHistoryUnsafe()[0] = 777; // 게터로 받은 배열을 바꿔 본다
System.out.println(Arrays.toString(m.getLoanHistory())); // [777, 2, 3] 뚫렸다
}
}
들어올 때와 나갈 때 양쪽 다 복사해야 막힌다. 이걸 방어적 복사라고 부른다. 배열뿐 아니라 뒤에 배울 컬렉션 필드에도 똑같이 적용된다.
참고로 Java 16 부터는 값만 담는 단순한 클래스를
record한 줄로 정의할 수 있다. 필드·생성자·게터·equals가 자동으로 만들어진다. 다만 위의 검증 로직이나 방어적 복사가 왜 필요한지 모르고record부터 쓰면 그것이 무엇을 대신해 주는지 판단할 수 없다. 중급 단원에서 다룬다.
스스로 확인하기
- 아래 클래스는
new Member("김개발")로 만들면getName()이null을 돌려준다. 원인을 찾고, 같은 실수가 컴파일 시점에 걸리도록 필드 선언을 고쳐라.public class Member { private String name; public Member(String name) { name = name; } public String getName() { return name; } } Book클래스에 "이 도서관 전체가 지금까지 받은 연체료 총액"을 누적하는 기능을 넣으려고 한다. 필드를static으로 해야 하는가, 아니면 인스턴스 필드로 해야 하는가? 이유와 함께 답하라.- 다음 코드의 출력을 예측하라.
Book a = new Book("클린 코드", "111", 3); Book b = a; b.setOverdueDays(12); System.out.println(a.lateFee()); System.out.println(Book.getRegisteredCount());
정답
- 생성자의
name = name;이 필드가 아니라 매개변수에 매개변수를 대입하고 있다.this.name = name;으로 고치면 된다. 컴파일 시점에 걸리게 하려면 필드를private final String name;으로 바꾼다.final필드는 생성자에서 반드시 대입돼야 하므로,this를 빠뜨리면variable name might not have been initialized로 컴파일이 거부된다. static이다. "도서관 전체 누적액"은 특정 책 한 권의 속성이 아니라 클래스 전체에 하나만 있어야 하는 값이기 때문이다. 반대로 "이 책이 지금까지 발생시킨 연체료"라면 책마다 달라야 하므로 인스턴스 필드가 맞다. 판단 기준은 언제나 "객체 하나에 대한 것인가, 클래스 전체에 대한 것인가"다.1700과1이다.b = a는 주소를 복사한 것이라a와b는 같은 객체를 가리킨다. 따라서b로 연체일을 12로 바꾸면a.lateFee()도700 + 5 * 200 = 1700이 된다. 그리고new는 한 번만 했으므로 등록 수는 1이다.b = a로는 객체가 새로 만들어지지 않는다.
다음 단원에서는 Book 의 title 과 isbn, 즉 String 을 제대로 파고든다. String 이 왜 불변인지, == 로 비교하면 왜 어떤 날은 맞고 어떤 날은 틀리는지, 영수증을 만들 때 StringBuilder 를 언제 써야 하는지를 다룬다.