thenullpage.com

회원 한 명을 프로그램에 담으려면 이름과 나이와 이메일이 한 덩어리로 붙어 있어야 한다. 변수를 세 개 따로 두면 누가 누구 것인지 금방 헷갈리고, 회원이 백 명이면 손을 못 댄다. 게다가 그 덩어리에는 나이를 한 살 더한다든가 자기소개를 출력한다든가 하는 동작도 딸려 온다. 자바는 데이터와 동작을 한 묶음으로 만드는 수단으로 클래스를 쓴다. 자바 코드를 짠다는 건 사실상 클래스를 정의하고 그 클래스로 객체를 찍어내는 일의 반복이다.


클래스는 설계도, 객체는 찍어낸 실물. 클래스(class)는 어떤 값을 담고 어떤 동작을 할지 적어 둔 설계도다. 설계도만 있어서는 아무 일도 안 일어나고, new로 실물을 하나 만들어야 값을 담고 기능을 부를 수 있다. 이 실물을 객체(object) 또는 인스턴스(instance)라 부른다. 클래스 안에 선언한 변수는 필드(field), 동작은 메서드(method)다.


public class Member {
String name;
int age;

void introduce() {
System.out.println(name + " (" + age + "세)");
}
}


이 설계도로 객체를 두 개 만들어 본다.


Member m1 = new Member();
m1.name = "김민수";
m1.age = 30;
m1.introduce(); // 김민수 (30세)

Member m2 = new Member();
m2.introduce(); // null (0세)


m1과 m2는 같은 설계도에서 나왔지만 값은 각자 따로 들고 있다. m2처럼 아무것도 안 채운 객체가 에러 없이 출력되는 건 필드에 기본값이 자동으로 들어가기 때문이다. 참조 타입은 null, int는 0, boolean은 false다. 메서드 안에서 선언한 지역 변수는 이 규칙이 적용되지 않아 값을 넣지 않고 쓰면 컴파일 단계에서 걸린다. 필드와 지역 변수의 차이가 여기서 갈린다.


생성자는 객체가 만들어지는 순간에 실행된다. 위처럼 필드를 하나씩 채우는 방식은 빠뜨리기 쉽다. 생성자(constructor)를 두면 만들 때 값을 아예 강제로 받을 수 있다. 생성자는 이름이 클래스 이름과 똑같고 반환 타입을 적지 않는다.


public class Member {
String name;
int age;

Member(String name, int age) {
this.name = name;
this.age = age;
}
}

Member m = new Member("이서연", 27);
m.introduce(); // 이서연 (27세)


생성자를 하나도 안 적으면 컴파일러가 인자 없는 기본 생성자를 몰래 만들어 준다. 앞에서 new Member()가 통했던 이유다. 그런데 생성자를 직접 하나라도 적는 순간 그 기본 생성자는 사라진다. 위 코드에서 new Member()를 부르면 컴파일 에러가 난다. 인자 없이도 만들고 싶다면 Member() {}를 하나 더 적어 두면 된다. 이렇게 이름이 같고 인자 목록만 다른 메서드를 여러 개 두는 걸 오버로딩(overloading)이라 한다.


this는 지금 이 객체를 가리킨다. 생성자 파라미터 이름과 필드 이름이 같으면 안쪽에서는 파라미터가 필드를 가린다. this.name은 이 객체의 필드, 그냥 name은 파라미터다.


Member(String name, int age) {
name = name; // 파라미터에 파라미터를 대입, 필드는 계속 null
age = age;
}


this를 빠뜨린 이 코드는 컴파일도 되고 실행도 되는데 필드에는 아무것도 안 들어간다. 생성자로 값을 넘겼는데 출력이 null과 0으로 나온다면 거의 이 실수다.


접근 제어자는 문을 어디까지 열지 정한다. 필드나 메서드 앞에 붙는 private, public 같은 키워드다. private은 같은 클래스 안에서만, 아무것도 안 적으면 같은 패키지 안에서만, protected는 같은 패키지와 상속받은 클래스까지, public은 어디서든 접근할 수 있다. 지금까지처럼 필드를 열어 두면 밖에서 아무 값이나 밀어 넣는 걸 막을 방법이 없다.


m.age = -5; // 그대로 들어간다


캡슐화는 필드를 잠그고 통로만 내주는 것. 필드를 private으로 닫고 읽고 쓰는 메서드를 따로 여는 방식이다.


public class Member {
private String name;
private int age;

public String getName() {
return name;
}

public void setAge(int age) {
if (age < 0) {
System.out.println("나이는 음수가 될 수 없다");
return;
}
this.age = age;
}
}


Member m = new Member();
m.setAge(-5); // 나이는 음수가 될 수 없다
m.age = -5; // 컴파일 에러: age has private access in Member


값을 검사하는 코드가 setAge 한 자리에만 있으면 되고, 필드가 닫혀 있으니 그 검사를 우회할 길이 없다. 값을 꺼내는 메서드를 게터(getter), 넣는 메서드를 세터(setter)라 부른다. 다만 모든 필드에 게터와 세터를 기계적으로 다 만드는 건 캡슐화가 아니라 필드를 그냥 열어 둔 것과 다를 게 없다. 만든 뒤에 바뀔 일이 없는 값이라면 세터를 아예 만들지 않는 쪽이 낫다.


static은 객체가 아니라 클래스에 붙는다. 필드 앞에 static을 붙이면 객체마다 하나씩 생기는 게 아니라 클래스 전체에 하나만 존재하고 모든 객체가 그것을 공유한다.


public class Member {
static int count = 0;
private String name;

Member(String name) {
this.name = name;
count++;
}
}

new Member("김민수");
new Member("이서연");
System.out.println(Member.count); // 2


count는 객체를 몇 개 만들든 하나뿐이라 생성자가 돌 때마다 값이 쌓인다. 꺼낼 때도 특정 객체가 아니라 Member.count처럼 클래스 이름으로 꺼낸다. 프로그램 시작점인 main에 static이 붙는 것도 같은 이유다. 아직 객체가 하나도 없는 시점이라 클래스 이름만으로 부를 수 있어야 한다. 반대로 static 메서드 안에는 this가 없어서 인스턴스 필드를 바로 쓰지 못한다.


객체를 그냥 출력하면 이상한 값이 나온다. println에 객체를 넣으면 Member@1b6d3586 같은 게 찍힌다. 클래스 이름과 해시값일 뿐 안에 든 값이 아니다. 사람이 읽을 형태로 바꾸려면 toString을 재정의한다.


@Override
public String toString() {
return "Member{name=" + name + ", age=" + age + "}";
}

System.out.println(m); // Member{name=이서연, age=27}


println은 객체를 받으면 내부적으로 그 객체의 toString()을 부른다. 위에 붙은 @Override는 부모 쪽 메서드를 제대로 덮어썼는지 컴파일러에게 확인시키는 표시다. toSting처럼 이름을 잘못 적으면 그 줄에서 바로 에러가 나 조용히 무시되는 일을 막아 준다.


이번 편에서 손에 쥐어야 할 감각은 세 가지다. 클래스는 설계도일 뿐이고 new로 만든 객체마다 필드 값은 따로 논다는 것, 생성자를 직접 하나라도 적으면 인자 없는 기본 생성자는 사라진다는 것, 그리고 필드는 private으로 닫고 필요한 통로만 메서드로 열어 값 검사를 한 자리에 모은다는 것이다. Member 클래스를 직접 쳐 놓고 필드를 열었다 닫았다 하면서 어디서 컴파일 에러가 나는지 확인해 보면 감이 빨리 잡힌다.