본문 바로가기

객체에 데이터와 행동을 함께 두기

회원 한 명의 규칙은 Member에, 회원 목록의 규칙은 MemberRegistry에 두면서 어떤 행동을 어느 객체에 맡길지 정하는 기준을 배웁니다.

앞 글에서 본 절차 지향 코드는 데이터를 Member와 main에 두고, 그 데이터를 다루는 절차를 바깥의 static 메서드에 흩어 놓았습니다. 그래서 나이 규칙 하나를 바꾸려 해도 여러 곳을 찾아야 했고, 배열과 size를 넘기는 쪽이 한 번만 실수해도 상태가 어긋났습니다.

객체 지향 설계는 여기서 질문을 바꿉니다. '어떤 클래스를 만들까'보다 '이 상태를 누가 알고 있고, 그 규칙을 누가 지켜야 할까'를 먼저 묻는 것입니다. 이렇게 객체가 알고 있어야 할 정보와 해야 할 일을 묶어 책임(responsibility)이라고 부릅니다. 이 글에서는 회원 한 명에 관한 일은 Member에게, 회원 목록에 관한 일은 새로 만들 MemberRegistry에게 맡겨 봅니다. 그리고 반대로 무엇은 맡기지 말아야 하는지도 살펴봅니다.

필드를 누구나 바꿀 수 있으면

먼저 지금의 Member가 왜 규칙을 지킬 수 없는지 확인하겠습니다. 다음 프로그램은 이메일과 나이를 모두 채워 정상적인 회원을 만든 뒤, 바깥 코드에서 이메일 필드에 null을 대입합니다. 그런 다음 회원 자신의 메서드인 describe를 호출합니다.

lab/PublicFieldInvariantFailure.java
public final class PublicFieldInvariantFailure {
    public static void main(String[] args) {
        Member member = new Member();
        member.email = "[email protected]";
        member.age = 40;

        member.email = null;

        System.out.println(member.describe());
    }

    private static final class Member {
        String email;
        int age;

        String describe() {
            return email.toUpperCase() + "=" + age;
        }
    }
}
실행 결과
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because "this.email" is null
	at PublicFieldInvariantFailure$Member.describe(PublicFieldInvariantFailure.java:17)
	at PublicFieldInvariantFailure.main(PublicFieldInvariantFailure.java:9)

예외 메시지는 this.email이 null이어서 toUpperCase()를 호출할 수 없었다고 알려 줍니다. 스택 트레이스를 위에서부터 읽으면, 예외는 describe 메서드의 17번 줄에서 났고 그 메서드를 부른 곳은 main의 9번 줄입니다. PublicFieldInvariantFailure$Member는 중첩 클래스 Member를 바깥 클래스 이름과 $로 이어 표시한 것입니다.

원인은 예외가 난 자리가 아니라 소스 7번 줄의 member.email = null;에 있습니다. describe는 '이메일은 null이 아니다'라는 조건을 믿고 작성되었는데, 필드가 누구에게나 열려 있어서 바깥 코드가 그 조건을 아무 때나 깰 수 있었습니다. 앞 글에서 이렇게 객체가 늘 지켜야 하는 조건을 불변식이라고 했습니다. 필드를 직접 바꾸는 길이 열려 있는 한, Member는 자기 불변식을 지킬 방법이 없습니다. 게다가 원인과 증상이 떨어져 있어서, 실제 프로그램이었다면 null을 넣은 코드를 찾기가 훨씬 어려웠을 것입니다.

필드를 바깥에서 아예 못 건드리게 막는 방법은 이 장의 접근 제어자와 정보 은닉에서 자세히 다룹니다. 이 글에서는 그보다 먼저, 행동을 객체 안으로 옮기면 규칙이 어디에 모이는지를 보겠습니다.

회원 한 명에 관한 일은 회원에게

다음 Member는 앞의 것과 세 가지가 다릅니다. 첫째, 필드에 private을 붙여 이 클래스의 코드만 다루겠다는 뜻을 밝혔습니다. 둘째, 생성자가 값을 검사해서 이메일이 비었거나 나이가 14세부터 120세까지의 범위를 벗어나면 예외를 던집니다. 예외가 나면 new가 객체를 돌려주지 않으므로, 이 생성자를 거쳐 만들어진 회원은 처음부터 규칙을 지키는 상태입니다. 생성자의 검사는 다음 글에서 자세히 다룹니다. 셋째, 생일 축하, 성인 여부 판단, 문자열 표현이라는 세 가지 행동을 메서드로 가집니다.

src/MemberResponsibility.java
public final class MemberResponsibility {
    public static void main(String[] args) {
        Member member = new Member("[email protected]", 40);

        member.celebrateBirthday();
        System.out.println(member.describe());
        System.out.println("adult=" + member.isAdult());
    }

    private static final class Member {
        private String email;
        private int age;

        Member(String email, int age) {
            if (email == null || email.isBlank() || age < 14 || age > 120) {
                throw new IllegalArgumentException("invalid member");
            }
            this.email = email;
            this.age = age;
        }

        void celebrateBirthday() {
            if (age >= 120) {
                throw new IllegalStateException("maximum age");
            }
            age++;
        }

        boolean isAdult() {
            return age >= 18;
        }

        String describe() {
            return email + "=" + age;
        }
    }
}
[email protected]=41
adult=true

40세로 만든 회원에게 celebrateBirthday()를 요청하면, 메서드는 나이가 120 미만인지 확인한 뒤 41로 올립니다. 그래서 describe()는 [email protected]=41을 돌려주고, isAdult()는 41이 18 이상이므로 true를 돌려줍니다.

생일 처리가 Member 안에 있는 이유는 그 일에 필요한 정보가 모두 Member에 있기 때문입니다. 지금 나이와 허용 상한 120을 둘 다 아는 것은 회원 자신입니다. 호출하는 쪽은 나이를 읽어 1을 더하고 다시 대입하는 절차를 몰라도 되고, 120세를 넘기면 안 된다는 규칙을 기억할 필요도 없습니다. 나이를 늘리는 길이 이 메서드 하나뿐이라면 '나이는 120을 넘지 않는다'는 규칙도 이 한곳에서만 지키면 됩니다. 이미 120세인 회원이라면 나이를 올리기 전에 IllegalStateException이 나므로 규칙을 어긴 상태가 만들어지지 않습니다.

isAdult()와 describe()도 같은 이유로 Member에 있습니다. isAdult()는 '18세 이상이면 성인'이라는 분류 규칙에 이름을 붙인 것이어서, 호출하는 쪽이 age >= 18을 여기저기 반복해 쓰지 않아도 됩니다. 나중에 기준이 바뀌어도 이 메서드 하나만 고치면 됩니다.

아래 그림은 앞의 두 프로그램을 나란히 놓고 비교합니다. 왼쪽은 필드에 직접 null을 넣어 describe()가 실패하는 경로이고, 오른쪽은 celebrateBirthday()가 조건을 확인한 뒤 나이를 바꾸는 경로입니다. 두 실행은 서로 다른 프로그램이며, 같은 객체를 고친 과정이 아닙니다.

필드 직접 대입의 실패와 생일 메서드의 조건을 구분한다

직접 email=null 대입 뒤 describe가 실패하는 경로와 birthday가 상한 검사 후 age 40→41을 허용하는 경로입니다. 두 독립 실행을 비교하여 행동이 규칙을 지키는 전환을 보여 줄 수 있습니다.

필드 직접 대입의 실패와 생일 메서드의 조건을 구분한다

서로 다른 두 프로그램에서 직접 email을 null로 바꾸면 describe가 실패하고, MemberResponsibility의 정상 생일 호출은 나이40을41로 바꾼다. 후자는 나이120 이상이면 증가 전 예외를 던진다.

상태 변경은 조건을 확인하는 행동으로 요청한다

직접 대입의 실패와 생일 메서드의 정상 동작을 두 독립 프로그램으로 비교합니다.

서로 다른 프로그램 · 같은 객체의 복구 과정이 아님

서로 다른 프로그램 · 같은 객체의 복구 과정이 아님
시점PublicFieldInvariantFailureMemberResponsibility
처음 상태email = [email protected], age = 40new Member("[email protected]", 40)
변경 요청member.email = null;member.celebrateBirthday();
관찰 결과describe()의 email.toUpperCase()에서 NullPointerException[email protected]=41와 adult=true 출력
상태 변경은 조건을 확인하는 행동으로 요청한다 서로독립실행. 직접emailnull대입뒤toUpperCase NPE와Kim40의birthday40<120통과뒤41/adulttrue. 같은객체복구아님;120이상별도거절본문보존. 독립 · 직접 대입 처음 Member [email protected] age=40 email = null 조건 없이 필드 변경 email: null age: 40 유지 describe() 호출 email.toUpperCase() NullPointer Exception 독립 · 행동 요청 처음 Member [email protected] age=40 celebrateBirthday() 40 < 120 확인 age++ → 41 상태 변경 허용 호출 후 관찰 [email protected]=41 adult=true 생일 요청이 규칙 유지 두 그림 사이에는 실행 연결 없음 120세 이상: 증가 전에 예외
상태 변경은 조건을 확인하는 행동으로 요청한다 서로독립실행. 직접emailnull대입뒤toUpperCase NPE와Kim40의birthday40<120통과뒤41/adulttrue. 같은객체복구아님;120이상별도거절본문보존. 독립 · 직접 대입 처음 Member [email protected] age=40 email = null 조건 없이 필드 변경 email: null age: 40 유지 describe() 호출 email.toUpperCase() NullPointer Exception 독립 · 행동 요청 처음 Member [email protected] age=40 celebrateBirthday() 40 < 120 확인 age++ → 41 상태 변경 허용 호출 후 관찰 [email protected]=41 adult=true 생일 요청이 규칙 유지 두 그림 사이에는 실행 연결 없음 120세 이상: 증가 전에 예외
PublicFieldInvariantFailure
처음에는 [email protected], age=40이다.
member.email = null 뒤 describe()의 email.toUpperCase()가 NullPointerException을 일으킨다.
MemberResponsibility
new Member("[email protected]", 40) 뒤 celebrateBirthday()를 호출한다.
[email protected]=41와 adult=true가 출력된다.

celebrateBirthday()는 age >= 120이면 age++ 전에 IllegalStateException("maximum age")를 던집니다. 위 정상 호출의 나이는 40이므로 이 거절 분기를 실행하지 않습니다.

이 예의 조건

  • 두 실행은 독립이며 같은 객체 복구가 아닙니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2서로 독립된 실행이다. 직접 email에 null을 대입한 뒤 toUpperCase에서 NPE가 발생하는 경로와 Kim 40의 생일 메서드가 40<120 검사를 통과한 뒤 41/adult true가 되는 경로를 비교한다. 같은 객체의 복구가 아니며 120 이상을 별도로 거절하는 본문을 보존한다.
원 도표의 설명과 주석
구분내용
설명서로 독립된 실행이다. 직접 email에 null을 대입한 뒤 toUpperCase에서 NPE가 발생하는 경로와 Kim 40의 생일 메서드가 40<120 검사를 통과한 뒤 41/adult true가 되는 경로를 비교한다. 같은 객체의 복구가 아니며 120 이상을 별도로 거절하는 본문을 보존한다.
전체 읽기 정보필드 직접 대입의 실패와 생일 메서드의 조건을 구분한다 필드 직접 대입의 실패와 생일 메서드의 조건을 구분한다 서로 다른 두 프로그램에서 직접 email을 null로 바꾸면 describe가 실패하고, MemberResponsibility의 정상 생일 호출은 나이 40을 41로 바꾼다. 후자는 나이 120 이상이면 증가 전 예외를 던진다. 상태 변경은 조건을 확인하는 행동으로 요청한다 직접 대입의 실패와 생일 메서드의 정상 동작을 두 독립 프로그램으로 비교합니다. 서로 다른 프로그램 · 같은 객체의 복구 과정이 아님 서로 다른 프로그램 · 같은 객체의 복구 과정이 아님 시점 PublicFieldInvariantFailure MemberResponsibility 처음 상태 email = [email protected] , age = 40 new Member("[email protected]", 40) 변경 요청 member.email = null; member.celebrateBirthday(); 관찰 결과 describe() 의 email.toUpperCase() 에서 NullPointerException [email protected]=41과 adult=true 출력 PublicFieldInvariantFailure 처음에는 [email protected], age=40 이다. member.email = null 뒤 describe() 의 email.toUpperCase() 가 NullPointerException 을 일으킨다. MemberResponsibility new Member("[email protected]", 40) 뒤 celebrateBirthday() 를 호출한다. [email protected]=41과 adult=true 가 출력된다. celebrateBirthday() 는 age >= 120 이면 age++ 전에 IllegalStateException("maximum age") 를 던집니다. 위 정상 호출의 나이는 40이므로 이 거절 분기를 실행하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
초기 상태

각 Member가 Kim 40부터 시작합니다.

  • 두 경로의 상태 변경 규칙을 비교합니다.
직접 쓰기 경로

email=null 대입 뒤 describe가 toUpperCase를 호출합니다.

  • 결과: NPE
  • 조건 없는 필드 쓰기가 후속 행동의 전제를 깨뜨립니다.
행동 요청 경로

birthday가 40<120을 확인하고 41로 늘립니다.

  • 검사하는 행동이 변경 규칙을 유지합니다.

필드 직접 대입의 실패와 생일 메서드의 조건을 구분한다

직접 email=null 대입 뒤 describe가 실패하는 경로와 birthday가 상한 검사 후 age 40→41을 허용하는 경로입니다. 두 독립 실행을 비교하여 행동이 규칙을 지키는 전환을 보여 줄 수 있습니다.

필드 직접 대입의 실패와 생일 메서드의 조건을 구분한다

서로 다른 두 프로그램에서 직접 email을 null로 바꾸면 describe가 실패하고, MemberResponsibility의 정상 생일 호출은 나이40을41로 바꾼다. 후자는 나이120 이상이면 증가 전 예외를 던진다.

상태 변경은 조건을 확인하는 행동으로 요청한다

직접 대입의 실패와 생일 메서드의 정상 동작을 두 독립 프로그램으로 비교합니다.

서로 다른 프로그램 · 같은 객체의 복구 과정이 아님

서로 다른 프로그램 · 같은 객체의 복구 과정이 아님
시점PublicFieldInvariantFailureMemberResponsibility
처음 상태email = [email protected], age = 40new Member("[email protected]", 40)
변경 요청member.email = null;member.celebrateBirthday();
관찰 결과describe()의 email.toUpperCase()에서 NullPointerException[email protected]=41와 adult=true 출력
상태 변경은 조건을 확인하는 행동으로 요청한다 서로독립실행. 직접emailnull대입뒤toUpperCase NPE와Kim40의birthday40<120통과뒤41/adulttrue. 같은객체복구아님;120이상별도거절본문보존. 독립 · 직접 대입 처음 Member [email protected] age=40 email = null 조건 없이 필드 변경 email: null age: 40 유지 describe() 호출 email.toUpperCase() NullPointer Exception 독립 · 행동 요청 처음 Member [email protected] age=40 celebrateBirthday() 40 < 120 확인 age++ → 41 상태 변경 허용 호출 후 관찰 [email protected]=41 adult=true 생일 요청이 규칙 유지 두 그림 사이에는 실행 연결 없음 120세 이상: 증가 전에 예외
상태 변경은 조건을 확인하는 행동으로 요청한다 서로독립실행. 직접emailnull대입뒤toUpperCase NPE와Kim40의birthday40<120통과뒤41/adulttrue. 같은객체복구아님;120이상별도거절본문보존. 독립 · 직접 대입 처음 Member [email protected] age=40 email = null 조건 없이 필드 변경 email: null age: 40 유지 describe() 호출 email.toUpperCase() NullPointer Exception 독립 · 행동 요청 처음 Member [email protected] age=40 celebrateBirthday() 40 < 120 확인 age++ → 41 상태 변경 허용 호출 후 관찰 [email protected]=41 adult=true 생일 요청이 규칙 유지 두 그림 사이에는 실행 연결 없음 120세 이상: 증가 전에 예외
PublicFieldInvariantFailure
처음에는 [email protected], age=40이다.
member.email = null 뒤 describe()의 email.toUpperCase()가 NullPointerException을 일으킨다.
MemberResponsibility
new Member("[email protected]", 40) 뒤 celebrateBirthday()를 호출한다.
[email protected]=41와 adult=true가 출력된다.

celebrateBirthday()는 age >= 120이면 age++ 전에 IllegalStateException("maximum age")를 던집니다. 위 정상 호출의 나이는 40이므로 이 거절 분기를 실행하지 않습니다.

이 예의 조건

  • 두 실행은 독립이며 같은 객체 복구가 아닙니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2서로 독립된 실행이다. 직접 email에 null을 대입한 뒤 toUpperCase에서 NPE가 발생하는 경로와 Kim 40의 생일 메서드가 40<120 검사를 통과한 뒤 41/adult true가 되는 경로를 비교한다. 같은 객체의 복구가 아니며 120 이상을 별도로 거절하는 본문을 보존한다.
원 도표의 설명과 주석
구분내용
설명서로 독립된 실행이다. 직접 email에 null을 대입한 뒤 toUpperCase에서 NPE가 발생하는 경로와 Kim 40의 생일 메서드가 40<120 검사를 통과한 뒤 41/adult true가 되는 경로를 비교한다. 같은 객체의 복구가 아니며 120 이상을 별도로 거절하는 본문을 보존한다.
전체 읽기 정보필드 직접 대입의 실패와 생일 메서드의 조건을 구분한다 필드 직접 대입의 실패와 생일 메서드의 조건을 구분한다 서로 다른 두 프로그램에서 직접 email을 null로 바꾸면 describe가 실패하고, MemberResponsibility의 정상 생일 호출은 나이 40을 41로 바꾼다. 후자는 나이 120 이상이면 증가 전 예외를 던진다. 상태 변경은 조건을 확인하는 행동으로 요청한다 직접 대입의 실패와 생일 메서드의 정상 동작을 두 독립 프로그램으로 비교합니다. 서로 다른 프로그램 · 같은 객체의 복구 과정이 아님 서로 다른 프로그램 · 같은 객체의 복구 과정이 아님 시점 PublicFieldInvariantFailure MemberResponsibility 처음 상태 email = [email protected] , age = 40 new Member("[email protected]", 40) 변경 요청 member.email = null; member.celebrateBirthday(); 관찰 결과 describe() 의 email.toUpperCase() 에서 NullPointerException [email protected]=41과 adult=true 출력 PublicFieldInvariantFailure 처음에는 [email protected], age=40 이다. member.email = null 뒤 describe() 의 email.toUpperCase() 가 NullPointerException 을 일으킨다. MemberResponsibility new Member("[email protected]", 40) 뒤 celebrateBirthday() 를 호출한다. [email protected]=41과 adult=true 가 출력된다. celebrateBirthday() 는 age >= 120 이면 age++ 전에 IllegalStateException("maximum age") 를 던집니다. 위 정상 호출의 나이는 40이므로 이 거절 분기를 실행하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
초기 상태

각 Member가 Kim 40부터 시작합니다.

  • 두 경로의 상태 변경 규칙을 비교합니다.
직접 쓰기 경로

email=null 대입 뒤 describe가 toUpperCase를 호출합니다.

  • 결과: NPE
  • 조건 없는 필드 쓰기가 후속 행동의 전제를 깨뜨립니다.
행동 요청 경로

birthday가 40<120을 확인하고 41로 늘립니다.

  • 검사하는 행동이 변경 규칙을 유지합니다.

그렇다고 회원과 관련된 행동을 모두 Member에 넣어야 하는 것은 아닙니다. 회원 목록을 화면에 어떤 색으로 보여 줄지, 회원 정보를 어느 파일 경로에 저장할지는 회원과 관련이 있어도 회원 자체의 규칙은 아닙니다. 이런 일은 화면이나 파일을 맡은 다른 객체가 하는 편이 낫습니다. 행동을 어디에 둘지는 두 가지로 판단합니다. 그 행동에 필요한 정보를 누가 가지고 있는가, 그리고 그 행동이 바뀌는 이유가 그 객체가 바뀌는 이유와 같은가입니다.

회원 목록을 관리하는 객체 만들기

앞 글에서는 회원 배열과 size를 main이 들고 다니며 메서드마다 넘겼습니다. 이번에는 그 둘을 필드로 가진 MemberRegistry 클래스를 만들고, 회원 추가와 집계를 이 객체의 메서드로 옮깁니다. 저장소(registry)라는 이름처럼 이 객체는 회원들을 보관하고, 몇 명이 들어 있는지, 몇 명까지 담을 수 있는지를 스스로 압니다.

Member에는 값이 올바른지 검사하는 static 메서드 isValid를 두었습니다. static 메서드라서 객체를 만들기 전에도 Member.isValid(...)로 부를 수 있습니다. 저장소는 이것으로 미리 검사해 잘못된 값이면 false를 돌려주고, 생성자도 같은 메서드로 한 번 더 검사합니다. 생성자의 검사는 누군가 저장소를 거치지 않고 new Member(...)를 직접 호출하더라도 잘못된 회원이 만들어지지 않게 하는 마지막 방어선입니다.

src/ObjectOrientedMemberRegistry.java
public final class ObjectOrientedMemberRegistry {
    public static void main(String[] args) {
        MemberRegistry registry = new MemberRegistry(3);

        System.out.println(registry.add("[email protected]", 40));
        System.out.println(registry.add("", 30));
        System.out.println(registry.add("[email protected]", 50));

        registry.printEntries();
        System.out.println("count=" + registry.count());
        System.out.println("total=" + registry.totalAge());
    }

    private static final class MemberRegistry {
        private final Member[] members;
        private int size;

        MemberRegistry(int capacity) {
            if (capacity <= 0) {
                throw new IllegalArgumentException("capacity must be positive");
            }
            members = new Member[capacity];
        }

        boolean add(String email, int age) {
            if (size == members.length || !Member.isValid(email, age)) {
                return false;
            }
            members[size] = new Member(email, age);
            size++;
            return true;
        }

        int count() {
            return size;
        }

        int totalAge() {
            int total = 0;
            for (int index = 0; index < size; index++) {
                total += members[index].age();
            }
            return total;
        }

        void printEntries() {
            for (int index = 0; index < size; index++) {
                System.out.println((index + 1) + ". " + members[index].describe());
            }
        }
    }

    private static final class Member {
        private final String email;
        private final int age;

        Member(String email, int age) {
            if (!isValid(email, age)) {
                throw new IllegalArgumentException("invalid member");
            }
            this.email = email;
            this.age = age;
        }

        static boolean isValid(String email, int age) {
            return email != null && !email.isBlank() && age >= 14 && age <= 120;
        }

        int age() {
            return age;
        }

        String describe() {
            return email + "=" + age;
        }
    }
}
true
false
true
1. [email protected]=40
2. [email protected]=50
count=2
total=90

필드 앞의 final은 그 필드에 값을 한 번만 넣을 수 있다는 표시입니다. 6장의 final 변수·필드·상수에서 자세히 배우며, 여기서는 회원의 이메일과 나이, 저장소의 배열이 만들어진 뒤로 다른 값으로 바뀌지 않는다는 뜻으로 읽으면 됩니다.

출력을 한 줄씩 따라가 보겠습니다. 첫 add는 용량이 남아 있고 값도 올바르므로 [email protected] 회원을 0번 칸에 저장하고, size를 1로 늘린 뒤 true를 돌려줍니다. 둘째 add는 이메일이 비어 있어 isValid가 false이므로 아무것도 저장하지 않고 false를 돌려줍니다. size는 1 그대로입니다. 셋째 add는 [email protected] 회원을 1번 칸에 저장하고 size를 2로 늘립니다. printEntries()는 앞의 두 칸을 1부터 번호를 붙여 출력하고, count()는 2를, totalAge()는 40과 50을 더한 90을 돌려줍니다.

main을 앞 글의 코드와 비교해 보면, 배열도 size도 더는 보이지 않습니다. main은 registry.add(...)로 회원을 추가해 달라고, registry.totalAge()로 합계를 알려 달라고 요청할 뿐입니다. size를 바꾸는 코드가 add 안에만 있으니, 앞 글처럼 돌려받은 size를 저장하지 않아 회원을 덮어쓰는 실수도 일어날 수 없습니다. 일이 나뉜 모양을 정리하면, MemberRegistry는 몇 칸까지 채웠는지와 용량을 관리하고 Member는 한 회원의 값과 그 표현을 맡습니다. 저장소의 불변식인 'size는 0 이상 배열 길이 이하이고, 앞의 size칸에는 회원이 들어 있다'도 add 한곳에서만 지키면 됩니다.

데이터를 넘기는 호출과 객체에 보내는 요청

두 방식의 호출을 나란히 놓아 보겠습니다. 절차 지향 코드는 다룰 데이터를 인수로 넘깁니다.

size = add(members, size, email, age);
int total = total(members, size);
print(members, size);

객체 지향 코드는 상태를 가진 객체를 먼저 적고, 그 객체에게 할 일을 요청합니다. 같은 세 작업이지만 인수 목록에서 members와 size가 사라졌고, size = add(...)처럼 결과를 다시 대입하는 줄도 없습니다.

registry.add(email, age);
int total = registry.totalAge();
registry.printEntries();

두 코드 모두 메서드를 호출한다는 점은 같습니다. 차이는 메서드에 static이 붙었는지보다, 상태와 그 상태를 다루는 행동이 어디에 함께 있는지에 있습니다. 객체 지향 버전에서 members와 size는 MemberRegistry의 내부 사정이어서 호출하는 쪽이 알 필요가 없습니다. 객체 지향에서는 이렇게 객체의 메서드를 호출하는 것을 그 객체에 메시지(message)를 보낸다고 표현하고, 메시지를 받는 객체를 수신 객체(receiver)라고 부릅니다. 아래 그림은 세 번의 add가 끝난 뒤 main이 무엇을 호출하고, 배열과 size가 어디에 있는지 보여 줍니다.

호출자는 registry에 요청하고 배열과 size는 내부에 둔다

ObjectOrientedMemberRegistry의 세 add 호출 뒤 registry는 길이3 배열과 size2를 가진다. 두 슬롯은 kim40과lee50 Member를 참조하고 마지막 슬롯은null이다. main은 메서드를 호출해 저장여부와count2,total90을 얻는다.

호출자는 요청하고 Registry는 배열과 size를 관리한다

세 번의 추가 요청 중 빈 이메일은 거절되어 두 회원만 저장됩니다.

ObjectOrientedMemberRegistry · 세 add 호출 뒤의 상태

main의 호출과 MemberRegistry 내부의 배열·size main은 registry의 메서드를 호출한다. registry의 size는2이고 길이3 배열의0,1번 참조는 각각 kim40과lee50 Member에 도달한다.2번은null이다. 점선은 호출이고 실선은 객체 참조이며 두 회원은 배열 칸 자체가 아니다. mainregistry.add(...)registry.count()registry.totalAge()true / false / truecount=2, total=90 MemberRegistrysize = 2 · members.length = 3사용 구간: [0, size) = [0, 2)Member[] members [0]참조[1]참조[2]null Memberemail: [email protected]age: 40 Memberemail: [email protected]age: 50
  • 메서드 호출
  • 객체 참조
호출자는 요청하고 Registry는 배열과 size를 관리한다 main점선호출,Registry가length3 size2 소유. slot0→Kim40,slot1→Lee50,slot2null. 객체는별도참조대상이며main이배열·size를인수로넘기지않음. true/false/true/count2/total90. main · 요청하는 쪽 add 반환 true / false / true count=2 totalAge=90 메서드 호출 MemberRegistry 내부 length=3 · size=2 사용 범위 [0, 2) [0] Member 참조 [1] Member 참조 [2] null · 미사용 첫 Member 객체 [email protected] age=40 둘째 Member 객체 [email protected] age=50 점선 호출 · 실선 객체 참조 배열·size를 인수로 주지 않음
호출자 main
registry.add(...)의 반환은 true / false / true. registry.count()는 2, registry.totalAge()는90을 반환한다.
MemberRegistry 내부
members.length = 3, size = 2. 사용 구간은 [0, size) = [0, 2)이다.
members[0]의 참조 대상
Member: email = [email protected], age = 40.
members[1]의 참조 대상
Member: email = [email protected], age = 50.
사용하지 않은 members[2]
null. 배열 칸은 Member 자체가 아니라 참조값을 저장한다.

빈 이메일의 add는 false를 반환해 저장·증가를 건너뜁니다. 이 프로그램에서 집계와 출력은 Member.age()와 Member.describe()를 사용하며, 호출자는 배열과 size를 인수로 넘기지 않습니다.

인스턴스 메서드 호출을 static 메서드의 첫 인수로 객체를 넘기는 것으로 바꿔 읽어 볼 수도 있습니다.

registry.add(email, age)
≈ add(registry, email, age)

실제로 인스턴스 메서드는 점 앞에 적은 수신 객체를 this라는 이름으로 받습니다. add 안에서 size라고만 쓰면 this.size, 즉 registry가 가리키는 객체의 size 필드를 뜻합니다. 같은 add 메서드라도 어느 저장소 객체에 보냈는지에 따라 다른 객체의 필드를 바꾸는 것은 이 때문입니다. 다만 이 비유는 두 가지 점에서 맞지 않습니다. 하나는 private 필드를 클래스 안의 코드만 다룰 수 있다는 점이고, 다른 하나는 7장에서 배울 다형성처럼 수신 객체의 실제 클래스에 따라 실행될 메서드가 달라질 수 있다는 점입니다.

여기서 한 가지 짚어 둘 것이 있습니다. 이 글의 예제처럼 한 파일의 바깥 클래스 안에 함께 선언한 중첩 클래스끼리는 private 멤버에도 접근할 수 있습니다. 그래서 main에 registry.size = 100;이라고 써도 컴파일이 됩니다. 이 예제들은 그런 직접 대입 대신 행동 메서드만 쓰기로 하고 작성한 것입니다. 클래스를 서로 다른 파일로 나누면 컴파일러가 이런 접근을 막아 주는데, 그 규칙은 접근 제어자와 정보 은닉에서 다룹니다.

더 깊이: this는 숨은 첫 번째 매개변수

앞의 비유는 JVM 수준에서는 거의 그대로 사실입니다. JVM 명세(JVMS §2.6.1)에 따르면 인스턴스 메서드를 호출할 때 수신 객체의 참조는 0번 지역 변수로 전달되고, 자바 코드의 this가 바로 그 값입니다. static 메서드에는 이 자리가 없으므로 this도 없고, 그래서 static 메서드 안에서는 인스턴스 필드를 이름만으로 쓸 수 없습니다. 자바 8부터는 이 숨은 매개변수를 boolean add(MemberRegistry this, String email, int age)처럼 수신자 매개변수(receiver parameter)로 직접 적을 수도 있습니다. Java 언어 명세(JLS §8.4)는 이 문법이 수신 객체의 타입을 소스 코드에 적을 수 있게 하려고, 즉 그 타입에 애노테이션을 붙일 수 있게 하려고 존재할 뿐 실행에는 아무 영향이 없다고 설명합니다. 적더라도 호출하는 방법은 registry.add(email, age) 그대로입니다.

한 객체에 모든 일을 맡기지 않기

행동을 데이터 가까이에 두라는 말을 끝까지 밀고 가면, MemberRegistry가 Scanner 입력, 파일 저장, 날짜 계산, 화면 색상, 네트워크 전송까지 모두 맡게 될 수도 있습니다. 그러면 모든 코드가 한 클래스에 모여 결국 처음의 거대한 절차 덩어리로 돌아갑니다. 어떤 행동을 다른 객체로 떼어 낼지 고민될 때는 다음 질문이 도움이 됩니다.

  • 이 행동이 바뀌는 이유가 회원 저장 규칙이 바뀌는 이유와 같은가?
  • 이 행동에 필요한 데이터 대부분을 이 객체가 이미 가지고 있는가?
  • 다른 객체의 내부 값을 여러 번 물어본 뒤에야 계산할 수 있는가?
  • 콘솔, 파일, 네트워크 같은 외부 입출력과 핵심 규칙이 한 메서드에 섞여 있는가?

이 질문으로 회원 관리 프로그램을 나누면, MemberRegistry는 회원의 추가, 삭제, 조회, 집계를 맡고, 콘솔 명령을 처리하는 CLI 코드는 문자열 입력과 안내 메시지 출력을 맡는 구성이 자연스럽습니다. CLI는 입력한 줄을 해석해 registry.add(...)를 호출하고, 돌려받은 결과에 따라 사용자에게 안내합니다. 입력 형식이 바뀌어도 저장 규칙은 그대로이고, 저장 규칙이 바뀌어도 입력 코드는 그대로입니다.

더 깊이: 정보 전문가와 단일 책임 원칙

이 글에서 쓴 두 가지 판단 기준에는 널리 알려진 이름이 있습니다. '그 일에 필요한 정보를 가진 객체에게 맡긴다'는 크레이그 라만(Craig Larman)이 정리한 GRASP 원칙의 정보 전문가(Information Expert)이고, '바뀌는 이유가 하나뿐이도록 나눈다'는 로버트 C. 마틴(Robert C. Martin)의 단일 책임 원칙(Single Responsibility Principle, SRP)입니다. 두 원칙은 서로 다른 쪽을 가리킬 때가 있습니다. 회원 목록을 표 모양으로 출력하는 코드는 회원 데이터를 가진 Member나 MemberRegistry에 두고 싶어지지만, 표 모양은 화면 요구 때문에 바뀌므로 저장 규칙과 바뀌는 이유가 다릅니다. 이 글의 예제는 describe()처럼 짧은 기본 표현만 Member에 두었습니다. 화면에 맞춘 꾸밈은 출력을 맡은 쪽으로 보내는 타협을 택한 것이고, 프로그램이 커질수록 이런 분리가 더 중요해집니다. 단일 책임 원칙이 말하는 '바뀌는 이유'를 모듈 설계 전반에서 어떻게 판단하는지는 소프트웨어공학 교재의 책임을 나누는 모듈에서 다룹니다.

입력은 CLI가, 저장 규칙은 저장소가

이제 두 객체가 일을 나눠 맡는 작은 CLI를 만들어 보겠습니다. 결과를 매번 똑같이 재현할 수 있도록, Scanner는 키보드 대신 코드에 적은 문자열을 읽습니다. 3장에서 본 것처럼 new Scanner("...")는 주어진 문자열을 입력처럼 한 줄씩 읽어 줍니다. 명령은 add 이메일 나이, summary, quit 세 가지입니다.

src/MemberRegistryCollaboration.java
import java.util.Scanner;

public final class MemberRegistryCollaboration {
    public static void main(String[] args) {
        Scanner scanner = new Scanner("add [email protected] 40\nadd [email protected] 50\nsummary\nquit\n");
        MemberRegistry registry = new MemberRegistry(2);

        while (scanner.hasNextLine()) {
            String[] parts = scanner.nextLine().trim().split("\\s+");
            if (parts[0].equals("quit")) {
                break;
            }
            if (parts[0].equals("summary")) {
                System.out.println(registry.summary());
                continue;
            }
            if (parts.length == 3 && parts[0].equals("add")) {
                boolean saved = registry.add(parts[1], Integer.parseInt(parts[2]));
                System.out.println(saved ? "saved" : "rejected");
            }
        }
    }

    private static final class MemberRegistry {
        private final Member[] members;
        private int size;

        MemberRegistry(int capacity) {
            members = new Member[capacity];
        }

        boolean add(String email, int age) {
            if (size == members.length || email.isBlank() || age < 14 || age > 120) {
                return false;
            }
            members[size++] = new Member(email, age);
            return true;
        }

        String summary() {
            int total = 0;
            for (int index = 0; index < size; index++) {
                total += members[index].age();
            }
            return "count=" + size + ", total=" + total;
        }
    }

    private static final class Member {
        private final String email;
        private final int age;

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

        int age() {
            return age;
        }
    }
}
saved
saved
count=2, total=90

main은 한 줄을 읽을 때마다 앞뒤 공백을 지우고 공백을 기준으로 나눠 parts 배열을 만듭니다. 첫 줄 add [email protected] 40은 조각이 세 개이고 첫 조각이 add이므로, 나이 문자열을 Integer.parseInt로 바꿔 registry.add를 호출합니다. 저장에 성공했으니 saved가 나옵니다. 둘째 줄도 같은 과정을 거쳐 saved를 출력하고, 셋째 줄 summary는 저장소가 만든 count=2, total=90을 출력합니다. 넷째 줄 quit에서 반복문을 빠져나오며 프로그램이 끝납니다.

이 협력에서 main이 아는 것은 Scanner와 명령의 형식뿐이고, 용량과 나이 범위, 합계 계산은 MemberRegistry가 압니다. main은 회원이 배열에 어떻게 저장되는지 모르고, MemberRegistry는 명령이 어떤 문자열로 들어오는지 모릅니다. 서로 상대의 내부를 몰라도 add와 summary라는 요청만으로 함께 일할 수 있습니다. 이 프로그램에서는 값 검사도 MemberRegistry.add가 맡고, Member 생성자는 받은 값을 필드에 넣기만 합니다. 아래 그림은 세 부분이 실제로 하는 일을 정리한 것입니다.

고정 입력 CLI에서 실제 코드가 맡는 책임을 나눈다

MemberRegistryCollaboration의 main은 문자열을 파싱하고 결과를 출력한다. MemberRegistry는 값검사·저장·size·합계와summary문자열을 맡고 Member 생성자는 최종필드 대입만 한다. 고정 입력의 결과는saved두번과count2,total90이다.

파싱·저장·출력 책임을 실제 코드에 맞춰 나눈다

이 고정 입력 예제에서 검증은 Registry가, Member 생성자는 필드 대입을 맡습니다.

MemberRegistryCollaboration · 고정 문자열 입력 예제

MemberRegistryCollaboration · 고정 문자열 입력 예제
담당 코드직접 다루는 값실제 행동
mainScanner, 명령 토큰, 이메일·나이 문자열nextLine().trim().split(...)와 Integer.parseInt로 해석한다. add/summary를 호출하고 saved/rejected 또는 요약을 출력한다.
MemberRegistrymembers, size, 용량과 나이 범위add에서 용량·빈 이메일·14~120세를 검사해 저장한다. summary가 나이를 합산하고 count=..., total=... 문자열도 만든다.
Memberfinal email, final age생성자가 전달된 값을 필드에 대입한다. 이 프로그램의 Member 생성자에는 검증 코드가 없다.
파싱·저장·출력 책임을 실제 코드에 맞춰 나눈다 main고정입력파싱/출력,Registry용량·이메일·14~120검사와저장/요약문자열,유효add에서만Member생성·필드대입. Member자체검증없음. 두saved/count2total90,summary는Member생성없음. main · 고정 입력 줄·토큰·parseInt 해석과 출력 담당 MemberRegistry 책임 members · size 보관 add: 용량·이메일·14~120세 검사·저장 summary: 합산·요약 문자열 생성 유효 add: 생성 요청 Member 생성자 final email / final age 전달값 대입만 · 검증 없음 main에서 출력 saved 두 번 count=2, total=90 점선 요청 · 실선 반환 · summary에는 Member 생성이 필요하지 않음
파싱·저장·출력 책임을 실제 코드에 맞춰 나눈다 main고정입력파싱/출력,Registry용량·이메일·14~120검사와저장/요약문자열,유효add에서만Member생성·필드대입. Member자체검증없음. 두saved/count2total90,summary는Member생성없음. main · 고정 입력 줄·토큰 분리 parseInt로 나이 변환 add / summary 호출 MemberRegistry 책임 members · size 보관 add: 용량·이메일· 14~120세 검사·저장 summary: 합산· 요약 문자열 생성 유효 add: 생성 요청 Member 생성자 final email / age 전달값 대입만 수행 Registry가 결과 반환 main에서 출력 saved 두 번 count=2, total=90 점선 요청 · 실선 결과 반환 Member는 검증하지 않음
main
Scanner의 고정 문자열을 줄·토큰으로 나누고 Integer.parseInt로 나이를 해석한다.
add와 summary를 호출하고 저장 여부의 saved/rejected 또는 요약 문자열을 출력한다.
MemberRegistry
members, size, 용량과14~120세 범위를 다룬다.
add가 용량·빈 이메일·나이를 검사해 저장한다. summary는 합산뿐 아니라 count=..., total=... 문자열 조립도 맡는다.
Member
final email, final age를 보관한다. 이 예제의 생성자는 대입만 하며 검증하지 않는다.

입력 순서: add [email protected] 40, add [email protected] 50, summary, quit. 출력은 saved 두 번과 count=2, total=90입니다.

앞의 ObjectOrientedMemberRegistry와 다른 프로그램입니다. 별도 View는 없으며, 잘못된 숫자의 예외 복구나 실제 콘솔 대화까지 구현한 예제로 해석하지 않습니다.

이 CLI는 정해진 문자열만 읽으므로 잘못된 입력을 처리하는 코드가 없습니다. 실제 콘솔에서 add [email protected]처럼 나이를 빠뜨리면 조각이 두 개라서 아무 안내 없이 무시되고, add [email protected] forty처럼 나이에 글자를 넣으면 Integer.parseInt가 NumberFormatException을 던져 프로그램이 끝납니다. 조각 수를 확인하고 숫자 변환 예외를 잡는 일은 3장의 입력 검증에서 배운 방법으로 CLI 쪽에 추가해야 합니다. '숫자로 바꿀 수 있는 입력인가'는 입력 형식의 문제이고, '14세부터 120세까지인가'는 저장 규칙의 문제이므로 두 검사는 서로 다른 객체에 있습니다.

정리

  • 객체의 책임은 그 객체가 알아야 할 정보와 해야 할 일입니다. 필드를 누구나 바꿀 수 있으면 객체는 자기 불변식을 지킬 수 없고, 원인과 증상이 멀리 떨어진 버그가 생깁니다.
  • 행동은 그 일에 필요한 정보를 가진 객체에 둡니다. 생일 처리와 성인 판단은 Member에, 저장과 집계는 배열과 size를 가진 MemberRegistry에 두었습니다.
  • 객체에 메시지를 보내는 코드에는 내부 상태가 드러나지 않습니다. 상태를 바꾸는 길이 객체의 메서드뿐이면 규칙도 그 메서드에서만 지키면 됩니다.
  • 모든 일을 한 객체에 몰지 않습니다. 바뀌는 이유가 다른 일, 특히 입력과 출력은 핵심 규칙과 다른 객체가 맡게 나눕니다.

연습 문제

  1. 목표 가입자 수와 현재까지의 가입자 수를 가진 SignupQuota 클래스를 만드세요. 목표는 생성자에서 받으며 양수여야 합니다. register(int count)는 양수만 받아 가입자 수에 더하고, progressPercent()는 목표 대비 달성률을 정수 백분율로 돌려주되 100을 넘지 않게 합니다. main에서 목표 100명에 40명과 70명을 차례로 등록한 뒤 합계와 달성률을 출력하고, 실행하기 전에 결과를 먼저 예상해 보세요.
  2. 1번에서 만든 클래스로 목표 100명에 30,000,000명을 한 번에 등록하면 progressPercent()가 무엇을 돌려줄까요? 결과가 이상하다면 원인을 찾고, 합계가 int 범위를 넘는 등록은 상태를 바꾸지 않고 거절하도록 고치세요.
  3. 다음 기능을 Member, MemberRegistry, CLI 가운데 어디에 두는 것이 좋을지 정하고 이유를 쓰세요.
    • 회원의 나이를 1 늘리기
    • 전체 회원의 평균 나이 구하기
    • 회원 목록을 표 모양으로 콘솔에 출력하기
    • 이메일로 회원 찾기
해설 보기

1번은 다음처럼 작성할 수 있습니다.

src/SignupQuotaResponsibility.java
public final class SignupQuotaResponsibility {
    public static void main(String[] args) {
        SignupQuota goal = new SignupQuota(100);
        goal.register(40);
        goal.register(70);

        System.out.println("total=" + goal.totalCount());
        System.out.println("progress=" + goal.progressPercent());
    }

    private static final class SignupQuota {
        private final int targetCount;
        private int totalCount;

        SignupQuota(int targetCount) {
            if (targetCount <= 0) {
                throw new IllegalArgumentException("invalid target");
            }
            this.targetCount = targetCount;
        }

        void register(int count) {
            if (count <= 0) {
                throw new IllegalArgumentException("invalid count");
            }
            totalCount += count;
        }

        int totalCount() {
            return totalCount;
        }

        int progressPercent() {
            int percent = totalCount * 100 / targetCount;
            return Math.min(percent, 100);
        }
    }
}
total=110
progress=100

두 번 등록하면 합계는 40, 110으로 늘어납니다. 달성률은 110 * 100 / 100으로 110이지만, Math.min(110, 100)이 더 작은 100을 고르므로 progress=100이 나옵니다. 목표와 현재 합계가 같은 객체에 있으니 달성률을 계산하는 규칙도 그 객체에 두었습니다. 호출하는 쪽은 계산식이나 100으로 자르는 처리를 반복해 쓰지 않습니다.

2번에서는 totalCount * 100이 문제입니다. 30,000,000 × 100은 3,000,000,000인데, int에 담을 수 있는 최댓값은 2,147,483,647입니다. 변수 스코프와 숫자 형변환에서 본 오버플로가 일어나 곱셈 결과는 4,294,967,296을 뺀 -1,294,967,296이 되고, 100으로 나누면 -12,949,672입니다. Math.min은 이 음수와 100 가운데 작은 쪽을 고르므로 progressPercent()는 -12949672를 돌려줍니다. 이미 넘쳐 버린 값은 Math.min으로 되돌릴 수 없습니다. 게다가 등록을 계속하면 합계 totalCount 자체도 int 범위를 넘을 수 있습니다.

1번 클래스의 두 메서드를 다음처럼 바꾸면 두 문제가 모두 해결됩니다.

SignupQuota의 수치 범위 보완
void register(int count) {
    if (count <= 0) {
        throw new IllegalArgumentException("invalid count");
    }
    totalCount = Math.addExact(totalCount, count);
}

int progressPercent() {
    long percent = (long) totalCount * 100 / targetCount;
    return (int) Math.min(percent, 100L);
}

Math.addExact는 합이 int 범위를 넘으면 ArithmeticException을 던집니다. 대입은 오른쪽 계산이 성공한 뒤에야 일어나므로, 예외가 나면 totalCount에는 이전 합계가 그대로 남습니다. 달성률은 곱하기 전에 long으로 바꿔 계산합니다. 합계가 가장 클 때도 2,147,483,647 × 100은 214,748,364,700이어서 long 범위 안에 넉넉히 들어갑니다. 목표가 양수이고 합계가 0 이상이므로 결과는 0부터 100까지의 정수입니다.

고친 버전으로 목표 100명에 30,000,000명을 등록하면 달성률은 100입니다. 목표 100명에 Integer.MAX_VALUE명을 등록한 뒤 한 명을 더 등록하면, 두 번째 등록에서 integer overflow 메시지의 ArithmeticException이 나고 합계는 2,147,483,647로 남습니다.

3번에서 나이를 1 늘리는 일은 Member에 둡니다. 지금 나이와 상한 120을 회원 자신이 알고 있기 때문입니다. 평균 나이는 모든 회원과 size가 필요하므로 totalAge()처럼 MemberRegistry에 둡니다. 회원이 한 명도 없을 때 0으로 나누지 않게 하는 처리도 거기서 합니다. 표 모양 출력은 CLI나 출력을 맡는 별도 클래스가 할 일입니다. 표 모양은 화면 요구 때문에 바뀌며 저장 규칙과는 바뀌는 이유가 다르므로, 저장소는 출력에 필요한 값만 제공합니다. 이메일로 찾는 일은 배열과 size를 가진 MemberRegistry에 둡니다. 다만 '이 회원의 이메일이 주어진 값과 같은가'라는 비교는 Member에 hasEmail(String email) 같은 메서드로 두면, 이메일을 비교하는 방식이 바뀌어도 Member만 고치면 됩니다.

다음 글

이 글의 Member는 생성자에서 값을 검사해 처음부터 올바른 상태로 만들어집니다. 다음 글에서는 생성자가 없을 때 어떤 문제가 생기는지부터 시작해, 생성자와 this가 정확히 어떻게 동작하는지, 생성자에서 값을 검사하면 무엇을 믿을 수 있게 되는지를 살펴봅니다.