클래스는 설계도이고 객체는 그 설계도로 찍어 낸 실체다. 지금까지는 자바가 만들어 둔 타입만 썼지만 이제 우리만의 타입을 직접 만든다. 붕어빵 틀이 클래스라면 그 틀로 구워 낸 붕어빵 하나하나가 객체, 곧 인스턴스다. 틀은 하나여도 객체는 여럿 만들 수 있고 각자 다른 상태를 가진다. 클래스와 객체의 이 관계가 객체지향의 출발점이다.


클래스는 필드와 메서드로 이뤄진다. 필드는 객체가 지니는 데이터, 곧 상태다. 메서드는 객체가 할 수 있는 일, 곧 행동이다. 은행 계좌라면 잔액은 필드, 입금과 출금은 메서드다. 데이터와 그것을 다루는 동작을 한 덩어리로 묶는 것이 클래스의 본질이다.


public class Account {
  String owner;
  long balance;
  void deposit(long amount) { balance += amount; }
}


객체는 new로 힙에 만들어 낸다. 클래스는 설계도라 그 자체로는 아무 일도 못 한다. new로 실제 객체를 만들어야 쓸 수 있고, 점 기호로 그 객체의 필드와 메서드에 접근한다. 같은 클래스로 만든 객체는 각자 독립된 상태를 가져, 한 계좌의 잔액을 바꿔도 다른 계좌는 그대로다.


Account a = new Account();
a.deposit(1000);
System.out.println(a.balance); // 1000


생성자는 객체가 태어나는 순간을 책임진다. 만든 뒤 필드를 하나씩 채우면 필수 값을 빠뜨린 미완성 객체를 쓸 위험이 있다. 생성자는 클래스와 같은 이름에 반환 타입이 없는 특별한 메서드로, 객체를 만들면서 초기값을 강제한다. 안의 this는 지금 이 객체 자신을 가리켜, 매개변수와 필드 이름이 같을 때 필드 쪽을 구분한다. 생성자를 하나라도 직접 적으면 자바가 몰래 넣어 주던 기본 생성자가 사라지니, 빈 생성자가 필요하면 직접 적어야 한다.


Account(String owner, long balance) {
  this.owner = owner;
  this.balance = balance;
}
Account a = new Account("kim", 1000);


생성자도 여러 개 둘 수 있다. 매개변수의 종류나 개수가 다른 생성자를 여럿 두는 것을 오버로딩이라 한다. 잔액을 안 주면 0으로 시작하는 생성자와 잔액까지 받는 생성자를 함께 둘 수 있다. 이때 한 생성자에서 다른 생성자를 this(...)로 불러 중복을 줄이는데, 이 호출은 반드시 생성자의 첫 줄에 와야 한다.


Account(String owner) { this(owner, 0); }


캡슐화는 데이터를 감추고 검증된 통로만 여는 것이다. 잔액을 public으로 열면 누구나 a.balance = -9999 같은 값을 넣는다. 그래서 필드는 private으로 감추고 값을 다루는 통로를 메서드로 열어, 그 안에서 규칙을 검사한다. 읽는 메서드는 get, 바꾸는 메서드는 set으로 시작하는 게 자바의 오랜 관례라 온갖 프레임워크가 이를 전제로 돈다. 다만 모든 필드에 기계적으로 setter를 다는 건 캡슐화를 무너뜨리니, 바뀌면 안 되는 값은 읽기만 열어 둔다.


public class Account {
  private long balance;
  public long getBalance() { return balance; }
  public void withdraw(long amount) {
    if (amount > balance)
      throw new IllegalArgumentException("잔액 부족");
    balance -= amount;
  }
}


static은 객체가 아니라 클래스에 속한다. 보통 필드와 메서드는 객체마다 따로 있지만, static을 붙이면 클래스 자체에 하나만 존재한다. 만든 계좌의 총개수처럼 모든 객체가 공유하는 값이나, Integer.parseInt처럼 객체 없이 부르는 기능에 쓴다. 다만 static 필드는 전역 상태라 여러 곳에서 몰래 바뀌면 추적이 어렵고 여러 스레드에 안전하지 않다. 상수나 순수 계산 도우미에만 쓰고 상태는 객체에 담는 게 원칙이다.


static int count = 0;
Account() { count++; }
System.out.println(Account.count);


final 필드와 생성자 검증으로 객체를 올바르게 지킨다. 필드에 final을 붙이면 생성자에서 한 번 정한 뒤 못 바꾼다. 모든 필드를 final로 두고 setter를 안 열면 불변 객체가 되어, 여러 곳에서 공유해도 안전하고 여러 스레드에 강하다. 자바가 뒤에 record를 들인 것도 이 불변 데이터를 쉽게 만들기 위해서다. 또 좋은 클래스는 잘못된 상태의 객체가 아예 안 만들어지게, 생성자에서 값을 검사해 어기면 예외를 던진다. 이 관문에서 한 번 걸러 두면 그 객체를 쓰는 수많은 곳에서 매번 확인할 필요가 없다.


Account(String owner, long balance) {
  if (owner == null || owner.isBlank())
    throw new IllegalArgumentException("주인 필요");
  if (balance < 0)
    throw new IllegalArgumentException("잔액 음수 불가");
  this.owner = owner;
  this.balance = balance;
}


객체 변수는 null일 수 있고 그게 흔한 사고의 원인이다. 참조 타입 변수는 아직 아무 객체도 안 가리키는 null 상태일 수 있다. 이 상태에서 a.deposit(100)처럼 점을 찍어 접근하면 그 유명한 NullPointerException이 터진다. 자바에서 가장 자주 만나는 예외가 이것이다. 밖에서 온 객체는 null인지 먼저 확인하거나, 애초에 null 대신 빈 객체를 돌려주도록 설계하는 습관이 중요하다.


다른 언어와 견주면 자바의 선택이 도드라진다. Go에는 클래스가 없다. struct로 데이터를 묶고 함수에 리시버를 붙여 메서드를 만든다. 러스트도 struct로 데이터를, 별도의 impl 블록으로 메서드를 정의한다. 데이터와 동작을 한 블록에 넣는 자바와 달리 둘을 분리하는 것이다. 캡슐화도 Go는 이름 첫 글자가 대문자면 공개라는 규칙을 쓰는 반면 자바는 접근제어자 키워드를 명시한다.


다음 편에서는 상속과 다형성을 다룬다. 클래스로 타입을 만드는 법을 익혔으니, 이제 이미 있는 클래스를 물려받아 확장하는 상속과, 같은 이름의 동작이 객체마다 다르게 작동하는 다형성으로 나아간다. extends로 부모를 물려받고 @Override로 동작을 바꿔 끼우며 super로 부모를 부르는 방법을 코드로 세운다.