본문 바로가기

절차 지향 코드가 커질 때 생기는 문제

데이터를 클래스로 묶고 메서드로 나눠도 규칙이 여러 곳에 흩어지는 이유를 확인하고, 절차 지향과 객체 지향이 각각 어울리는 상황을 구분합니다.

4장에서는 회원의 이메일과 나이를 Member 클래스 하나로 묶었습니다. 그런데 회원을 추가하고, 값을 검사하고, 목록을 출력하고, 합계를 내는 일은 여전히 main과 static 메서드가 모두 맡고 있습니다. Member는 값을 담아 나르기만 하고, '나이는 14세 이상이어야 한다' 같은 규칙은 그 값을 쓰는 메서드마다 따로 흩어져 있는 셈입니다.

이처럼 프로그램을 '무엇을 어떤 순서로 할지'라는 절차(procedure)의 묶음으로 나누고, 데이터는 그 절차에 인수로 넘겨 다루는 방식을 절차 지향 프로그래밍(procedural programming)이라고 합니다. 절차 지향이 잘못된 방식은 아닙니다. 입력을 받아 계산하고 출력하면 끝나는 작은 프로그램이라면 실행 순서가 그대로 드러나서 오히려 읽기 쉽습니다. 문제는 같은 데이터를 바꾸는 절차가 늘어날 때 생깁니다. 이 글에서는 회원 목록 코드를 평행 배열에서 객체 배열로, 다시 여러 메서드로 나눈 형태로 바꿔 가며 무엇이 좋아지고 무엇이 그대로 남는지 확인합니다.

평행 배열에서 회원 한 명을 지우면

이메일과 나이를 서로 다른 배열에 두고 같은 인덱스끼리 한 회원으로 쓰는 구조를 평행 배열(parallel arrays)이라고 했습니다. 다음 프로그램은 회원 세 명 가운데 인덱스 1의 [email protected]을 지우려고, 그 뒤의 칸을 한 칸씩 앞으로 당긴 다음 회원 수 size를 줄입니다. 그런데 실수로 이메일 배열만 당깁니다.

lab/ParallelDeleteBug.java
public final class ParallelDeleteBug {
    public static void main(String[] args) {
        String[] emails = {"[email protected]", "[email protected]", "[email protected]"};
        int[] age = {40, 30, 50};
        int size = 3;
        int target = 1;

        for (int index = target; index < size - 1; index++) {
            emails[index] = emails[index + 1];
        }
        size--;

        for (int index = 0; index < size; index++) {
            System.out.println(emails[index] + "=" + age[index]);
        }
    }
}
실행 결과

당기는 반복문은 index가 1일 때 한 번만 돌아 emails[1]에 emails[2]의 [email protected]을 복사합니다. 이어서 size가 2로 줄어 출력 반복문은 앞의 두 칸만 읽습니다. 첫 줄의 [email protected]은 그대로지만, 둘째 줄에는 [email protected]과 지워졌어야 할 [email protected]의 나이 30이 짝지어 나왔습니다. age 배열은 한 칸도 움직이지 않았기 때문입니다. 아래 그림은 삭제 전, 이메일만 복사한 직후, size를 줄인 뒤의 세 단계를 차례로 보여 줍니다.

이메일만 당기면 같은 index의 회원 정보가 어긋난다

index 1 삭제에서 이메일만 이동하여 lee가 나이 30과 잘못 결합하고 size=2가 됩니다. 병렬 배열의 동일 인덱스 불변식이 언제 깨지는지 전후가 분명합니다.

이메일만 당기면 같은 index의 회원 정보가 어긋난다

ParallelDeleteBug에서 target 1을 삭제할 때 이메일만 이동해 index 1의 [email protected]과 기존 나이30이 잘못 결합한다. size가2가 되어 남은 index2는 읽지 않는다.

이메일만 이동하면 회원의 이메일과 나이가 어긋난다

target 1을 삭제한 뒤 lee의 이메일에 park의 나이 30이 남습니다.

ParallelDeleteBug · target = 1 삭제 전후

ParallelDeleteBug · target = 1 삭제 전후
index삭제 전 · size = 3이메일만 이동한 뒤 · size = 2
0[email protected] / 40세[email protected] / 40세
1[email protected] / 30세[email protected] / 30세 · 잘못 결합
2[email protected] / 50세[email protected] / 50세 · 사용 범위 밖
이메일만 이동하면 회원의 이메일과 나이가 어긋난다 target1삭제 전 Kim40/Park30/Lee50. emails[2]만emails[1]로복사해 결과Lee30,age[1]30유지;size2이후index2미사용. 실선이메일복사/점선나이대응. 삭제 전 · size=3 emails age [0] kim 사용 중 40 [1] park 사용 중 30 [2] lee 사용 중 50 index 1: park / 30 이메일만 이동 · size=2 emails age [0] kim 사용 중 40 [1] lee 사용 중 30 [2] lee 사용 밖 50 index 1: lee / 30 · 잘못 결합 이메일만 복사 age[1]=30을 옮기지 않아 Lee의 나이 50과 어긋남 kim / park / lee는 본문 이메일의 약칭 · index 2는 이후 사용 밖
이메일만 이동하면 회원의 이메일과 나이가 어긋난다 target1삭제 전 Kim40/Park30/Lee50. emails[2]만emails[1]로복사해 결과Lee30,age[1]30유지;size2이후index2미사용. 실선이메일복사/점선나이대응. 삭제 전 · size=3 emails age [0] kim 사용 중 40 [1] park 사용 중 30 [2] lee 사용 중 50 index 1: park / 30 이메일만 이동 · size=2 emails age [0] kim 사용 중 40 [1] lee 사용 중 30 [2] lee 사용 밖 50 index 1: lee / 30 · 잘못 결합 emails[2] → emails[1] age[1]은 30 유지 kim / park / lee는 본문 이메일 실선 이메일 복사 · 점선 age 대응
index 0
삭제 전과 후: [email protected] / 40세.
index 1
삭제 전: [email protected] / 30세.
이메일만 이동한 뒤: [email protected] / 30세. 잘못 결합한 값이다.
index 2
삭제 전과 후: [email protected] / 50세. 삭제 후에는 size = 2여서 읽지 않는다.

잘못된 결합: emails[1]만 바뀌고 age[1]은 30으로 남습니다.

별도의 Member[] 선언에서는 new Member("[email protected]", 50)가 이메일과 나이를 한 객체로 묶습니다. 참조 하나를 옮기면 같은 필드 쌍에 도달하지만, 이 선언 자체가 삭제를 실행한 것은 아닙니다.

이 예의 조건

  • Kim/Park/Lee는 본문 이메일의 약칭입니다. target 1 삭제입니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2target 1 삭제 전 Kim 40/Park 30/Lee 50이다. emails[2]만 emails[1]로 복사해 결과는 Lee 30이 되고 age[1]은 30을 유지한다. size 2 이후 index 2는 미사용이다. 실선은 이메일 복사이고 점선은 나이 대응이다.
15index 1: park
29index 1: lee
3030 · 잘못 결합
33kim
34park
35lee는 본문 이메일의 약칭 · index 2는 이후 사용 밖
원 도표의 설명과 주석
구분내용
설명target 1 삭제 전 Kim 40/Park 30/Lee 50이다. emails[2]만 emails[1]로 복사해 결과는 Lee 30이 되고 age[1]은 30을 유지한다. size 2 이후 index 2는 미사용이다. 실선은 이메일 복사이고 점선은 나이 대응이다.
전체 읽기 정보이메일만 당기면 같은 index의 회원 정보가 어긋난다 이메일만 당기면 같은 index의 회원 정보가 어긋난다 ParallelDeleteBug에서 target 1을 삭제할 때 이메일만 이동해 index 1의 [email protected]과 기존 나이 30이 잘못 결합한다. size가 2가 되어 남은 index 2는 읽지 않는다. 이메일만 이동하면 회원의 이메일과 나이가 어긋난다 target 1을 삭제한 뒤 lee의 이메일에 park의 나이 30이 남습니다. ParallelDeleteBug · target = 1 삭제 전후 ParallelDeleteBug · target = 1 삭제 전후 index 삭제 전 · size = 3 이메일만 이동한 뒤 · size = 2 0 [email protected] / 40세 [email protected] / 40세 1 [email protected] / 30세 [email protected] / 30세 · 잘못 결합 2 [email protected] / 50세 [email protected] / 50세 · 사용 범위 밖 index 0 삭제 전과 후: [email protected] / 40세. index 1 삭제 전: [email protected] / 30세. 이메일만 이동한 뒤: [email protected] / 30세. 잘못 결합한 값이다. index 2 삭제 전과 후: [email protected] / 50세. 삭제 후에는 size = 2 여서 읽지 않는다. 잘못된 결합: emails[1] 만 바뀌고 age[1] 은 30으로 남습니다. 별도의 Member[] 선언에서는 new Member("[email protected]", 50) 가 이메일과 나이를 한 객체로 묶습니다. 참조 하나를 옮기면 같은 필드 쌍에 도달하지만, 이 선언 자체가 삭제를 실행한 것은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
삭제 전 대응

세 칸의 email과 age가 정상 대응합니다.

  • index0/1: Kim40/Park30
  • index 2: Lee50,size3
  • 같은 인덱스가 회원 한 명의 두 값을 연결합니다.
이메일만 복사

emails[2]를 emails[1]에 복사합니다.

  • emails1: Lee
  • age[1]: 30 유지
  • 나이 배열을 같이 이동하지 않아 회원 대응이 깨집니다.
사용 영역 감소

size 2 뒤 index 2는 미사용입니다.

  • index 1: Lee 30, 잘못 결합
  • index 2: Lee 50, 사용 밖
  • size 감소가 이동 누락을 복구하지 않습니다.

이메일만 당기면 같은 index의 회원 정보가 어긋난다

index 1 삭제에서 이메일만 이동하여 lee가 나이 30과 잘못 결합하고 size=2가 됩니다. 병렬 배열의 동일 인덱스 불변식이 언제 깨지는지 전후가 분명합니다.

이메일만 당기면 같은 index의 회원 정보가 어긋난다

ParallelDeleteBug에서 target 1을 삭제할 때 이메일만 이동해 index 1의 [email protected]과 기존 나이30이 잘못 결합한다. size가2가 되어 남은 index2는 읽지 않는다.

이메일만 이동하면 회원의 이메일과 나이가 어긋난다

target 1을 삭제한 뒤 lee의 이메일에 park의 나이 30이 남습니다.

ParallelDeleteBug · target = 1 삭제 전후

ParallelDeleteBug · target = 1 삭제 전후
index삭제 전 · size = 3이메일만 이동한 뒤 · size = 2
0[email protected] / 40세[email protected] / 40세
1[email protected] / 30세[email protected] / 30세 · 잘못 결합
2[email protected] / 50세[email protected] / 50세 · 사용 범위 밖
이메일만 이동하면 회원의 이메일과 나이가 어긋난다 target1삭제 전 Kim40/Park30/Lee50. emails[2]만emails[1]로복사해 결과Lee30,age[1]30유지;size2이후index2미사용. 실선이메일복사/점선나이대응. 삭제 전 · size=3 emails age [0] kim 사용 중 40 [1] park 사용 중 30 [2] lee 사용 중 50 index 1: park / 30 이메일만 이동 · size=2 emails age [0] kim 사용 중 40 [1] lee 사용 중 30 [2] lee 사용 밖 50 index 1: lee / 30 · 잘못 결합 이메일만 복사 age[1]=30을 옮기지 않아 Lee의 나이 50과 어긋남 kim / park / lee는 본문 이메일의 약칭 · index 2는 이후 사용 밖
이메일만 이동하면 회원의 이메일과 나이가 어긋난다 target1삭제 전 Kim40/Park30/Lee50. emails[2]만emails[1]로복사해 결과Lee30,age[1]30유지;size2이후index2미사용. 실선이메일복사/점선나이대응. 삭제 전 · size=3 emails age [0] kim 사용 중 40 [1] park 사용 중 30 [2] lee 사용 중 50 index 1: park / 30 이메일만 이동 · size=2 emails age [0] kim 사용 중 40 [1] lee 사용 중 30 [2] lee 사용 밖 50 index 1: lee / 30 · 잘못 결합 emails[2] → emails[1] age[1]은 30 유지 kim / park / lee는 본문 이메일 실선 이메일 복사 · 점선 age 대응
index 0
삭제 전과 후: [email protected] / 40세.
index 1
삭제 전: [email protected] / 30세.
이메일만 이동한 뒤: [email protected] / 30세. 잘못 결합한 값이다.
index 2
삭제 전과 후: [email protected] / 50세. 삭제 후에는 size = 2여서 읽지 않는다.

잘못된 결합: emails[1]만 바뀌고 age[1]은 30으로 남습니다.

별도의 Member[] 선언에서는 new Member("[email protected]", 50)가 이메일과 나이를 한 객체로 묶습니다. 참조 하나를 옮기면 같은 필드 쌍에 도달하지만, 이 선언 자체가 삭제를 실행한 것은 아닙니다.

이 예의 조건

  • Kim/Park/Lee는 본문 이메일의 약칭입니다. target 1 삭제입니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2target 1 삭제 전 Kim 40/Park 30/Lee 50이다. emails[2]만 emails[1]로 복사해 결과는 Lee 30이 되고 age[1]은 30을 유지한다. size 2 이후 index 2는 미사용이다. 실선은 이메일 복사이고 점선은 나이 대응이다.
15index 1: park
29index 1: lee
3030 · 잘못 결합
33kim
34park
35lee는 본문 이메일의 약칭 · index 2는 이후 사용 밖
원 도표의 설명과 주석
구분내용
설명target 1 삭제 전 Kim 40/Park 30/Lee 50이다. emails[2]만 emails[1]로 복사해 결과는 Lee 30이 되고 age[1]은 30을 유지한다. size 2 이후 index 2는 미사용이다. 실선은 이메일 복사이고 점선은 나이 대응이다.
전체 읽기 정보이메일만 당기면 같은 index의 회원 정보가 어긋난다 이메일만 당기면 같은 index의 회원 정보가 어긋난다 ParallelDeleteBug에서 target 1을 삭제할 때 이메일만 이동해 index 1의 [email protected]과 기존 나이 30이 잘못 결합한다. size가 2가 되어 남은 index 2는 읽지 않는다. 이메일만 이동하면 회원의 이메일과 나이가 어긋난다 target 1을 삭제한 뒤 lee의 이메일에 park의 나이 30이 남습니다. ParallelDeleteBug · target = 1 삭제 전후 ParallelDeleteBug · target = 1 삭제 전후 index 삭제 전 · size = 3 이메일만 이동한 뒤 · size = 2 0 [email protected] / 40세 [email protected] / 40세 1 [email protected] / 30세 [email protected] / 30세 · 잘못 결합 2 [email protected] / 50세 [email protected] / 50세 · 사용 범위 밖 index 0 삭제 전과 후: [email protected] / 40세. index 1 삭제 전: [email protected] / 30세. 이메일만 이동한 뒤: [email protected] / 30세. 잘못 결합한 값이다. index 2 삭제 전과 후: [email protected] / 50세. 삭제 후에는 size = 2 여서 읽지 않는다. 잘못된 결합: emails[1] 만 바뀌고 age[1] 은 30으로 남습니다. 별도의 Member[] 선언에서는 new Member("[email protected]", 50) 가 이메일과 나이를 한 객체로 묶습니다. 참조 하나를 옮기면 같은 필드 쌍에 도달하지만, 이 선언 자체가 삭제를 실행한 것은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
삭제 전 대응

세 칸의 email과 age가 정상 대응합니다.

  • index0/1: Kim40/Park30
  • index 2: Lee50,size3
  • 같은 인덱스가 회원 한 명의 두 값을 연결합니다.
이메일만 복사

emails[2]를 emails[1]에 복사합니다.

  • emails1: Lee
  • age[1]: 30 유지
  • 나이 배열을 같이 이동하지 않아 회원 대응이 깨집니다.
사용 영역 감소

size 2 뒤 index 2는 미사용입니다.

  • index 1: Lee 30, 잘못 결합
  • index 2: Lee 50, 사용 밖
  • size 감소가 이동 누락을 복구하지 않습니다.

이 버그가 까다로운 이유는 프로그램이 아무 오류 없이 끝난다는 데 있습니다. '같은 인덱스는 같은 회원'이라는 규칙은 개발자의 머릿속에만 있고 코드 어디에도 적혀 있지 않습니다. 그래서 컴파일러도 JVM도 이 규칙이 깨진 것을 알아채지 못하고, 그림의 마지막 단계에서 보듯 size를 줄여도 어긋난 짝은 바로잡히지 않습니다. 추가, 삭제, 정렬처럼 배열의 순서를 바꾸는 절차를 만들 때마다 모든 배열을 함께 움직였는지 사람이 직접 확인해야 합니다.

객체로 묶으면 이메일과 나이가 함께 움직인다

4장에서 한 것처럼 이메일과 나이를 Member 객체 하나에 담으면 이 실수는 일어날 수 없습니다. 이 장의 예제는 객체를 만들면서 값을 바로 넣을 수 있도록 Member에 생성자(constructor)를 둡니다. 생성자는 new로 객체를 만들 때 실행되는 초기화 코드로, 이름이 클래스 이름과 같고 반환 타입을 적지 않습니다.

생성자의 최소 예제
class Member {
    String email;
    int age;

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

Member member = new Member("[email protected]", 40);

new Member("[email protected]", 40)을 실행하면 객체가 만들어지는 동시에 두 값이 필드에 들어갑니다. 생성자 안의 this.email은 지금 만들어지는 객체의 필드이고, 오른쪽의 email은 생성자가 받은 값입니다. 위 코드는 클래스 선언과 사용을 나란히 놓은 조각이라 이대로는 실행되지 않습니다. 생성자를 쓰는 이유와 this의 정확한 의미는 이 장의 생성자와 this에서 자세히 다루므로, 지금은 '객체를 만들면서 필드 값을 채운다'는 정도로 읽으면 충분합니다.

이제 회원 세 명을 Member 배열에 담고, 앞과 똑같이 인덱스 1을 지워 보겠습니다. 당기는 반복문의 모양은 같지만, 옮기는 대상이 이메일 문자열이 아니라 회원 객체의 참조입니다.

Member[] members = {
        new Member("[email protected]", 40),
        new Member("[email protected]", 30),
        new Member("[email protected]", 50)
};
int size = 3;
int target = 1;

for (int index = target; index < size - 1; index++) {
    members[index] = members[index + 1];
}
size--;
members[size] = null;

이 코드 뒤에 앞의 size칸을 차례로 출력하는 반복문을 붙여 실행하면 다음과 같이 나옵니다. 인덱스 1에 있던 park 회원이 빠지고, 뒤에 있던 lee 회원이 이메일과 나이를 모두 지닌 채 한 칸 앞으로 당겨졌는지 보세요.

배열 칸에 들어 있는 것은 회원 객체를 가리키는 참조이므로, 참조 하나를 옮기면 그 객체의 이메일과 나이가 함께 따라옵니다. 둘을 따로 옮기다 하나를 빠뜨릴 여지가 처음부터 없는 것입니다.

하지만 삭제 절차 자체는 여전히 바깥 코드의 몫입니다. 어디부터 당길지, size를 언제 줄일지, 비게 된 마지막 칸의 참조를 null로 지울지는 이 배열을 쓰는 코드가 모두 알고 있어야 합니다. 마지막 줄을 빠뜨리면 members[2]에 [email protected] 회원의 참조가 하나 더 남는데, size 밖의 칸이라 당장은 아무 문제도 드러나지 않습니다.

회원 목록을 정적 메서드로 처리하기

객체로 묶은 회원을 출력하고 나이 합계를 구하는 프로그램을 4장의 방식 그대로 작성하면 다음과 같습니다. main이 배열과 size를 만들어 두고, 출력과 합계는 static 메서드가 맡습니다.

src/ProceduralMemberArray.java
public final class ProceduralMemberArray {
    public static void main(String[] args) {
        Member[] members = new Member[3];
        int size = 0;

        members[size++] = new Member("[email protected]", 40);
        members[size++] = new Member("[email protected]", 50);

        printMembers(members, size);
        System.out.println("total=" + total(members, size));
    }

    private static void printMembers(Member[] members, int size) {
        for (int index = 0; index < size; index++) {
            Member member = members[index];
            System.out.println(member.email + "=" + member.age);
        }
    }

    private static int total(Member[] members, int size) {
        int result = 0;
        for (int index = 0; index < size; index++) {
            result += members[index].age;
        }
        return result;
    }

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

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

Member를 ProceduralMemberArray 안에 선언한 것은 예제를 파일 하나로 실행하기 위해서입니다. 이렇게 클래스 안에 선언한 클래스를 중첩 클래스(nested class)라고 하며, 자세한 문법은 11장의 중첩 클래스의 종류와 정적 중첩 클래스에서 배웁니다. 지금은 Member라는 클래스를 같은 파일에 하나 더 선언한 것으로 읽으면 됩니다. 앞에 붙은 private, static, final은 뒤에서 하나씩 배우므로 지금은 신경 쓰지 않아도 됩니다. 실행 방법은 지금까지와 같아서, javac -d out src/ProceduralMemberArray.java로 컴파일하고 java -cp out ProceduralMemberArray로 실행합니다.

main은 두 회원을 members[0]과 members[1]에 넣으면서 size를 2로 늘립니다. printMembers는 앞의 두 칸을 돌며 한 줄씩 출력하고, total은 같은 두 칸의 나이 40과 50을 더해 90을 돌려줍니다. 이메일과 나이가 한 객체 안에 있으니, 정렬하든 삭제하든 참조 하나만 옮기면 됩니다. 이것이 데이터를 묶어서 얻는 효과입니다.

그렇다고 회원에 관한 규칙까지 한곳에 모인 것은 아닙니다. Member의 필드는 누구나 바꿀 수 있어서 members[0].age = -10; 같은 대입을 막을 방법이 없습니다. 출력 형식은 printMembers가, 합계 계산은 total이 각자 Member의 필드를 꺼내 해석합니다. 그리고 두 메서드 모두 배열과 size를 짝으로 받아야 제대로 동작합니다.

메서드로 나눠도 남는 문제

main 안에 추가, 목록 출력, 합계를 모두 적으면 실행 순서는 한눈에 보이지만 어떤 규칙이 어디에 있는지는 흐려집니다. 그래서 다음 프로그램은 회원을 추가하고 값을 검사하는 일까지 add 메서드로 뽑아냈습니다. 이제 main은 '회원을 추가하고 목록과 요약을 출력한다'는 큰 흐름만 보여 주고, 세부 작업은 각 메서드가 맡습니다. 둘째 add에는 일부러 빈 이메일을 넘깁니다.

src/ExtractedMemberProcedures.java
public final class ExtractedMemberProcedures {
    public static void main(String[] args) {
        Member[] members = new Member[3];
        int size = 0;

        size = add(members, size, "[email protected]", 40);
        size = add(members, size, "", 30);
        size = add(members, size, "[email protected]", 50);

        print(members, size);
        printSummary(members, size);
    }

    private static int add(Member[] members, int size, String email, int age) {
        if (size >= members.length) {
            System.out.println("full");
            return size;
        }
        if (email == null || email.isBlank() || age < 14 || age > 120) {
            System.out.println("invalid");
            return size;
        }
        members[size] = new Member(email, age);
        return size + 1;
    }

    private static void print(Member[] members, int size) {
        for (int index = 0; index < size; index++) {
            System.out.println(members[index].email + "=" + members[index].age);
        }
    }

    private static void printSummary(Member[] members, int size) {
        int total = 0;
        for (int index = 0; index < size; index++) {
            total += members[index].age;
        }
        System.out.println("count=" + size + ", total=" + total);
    }

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

        Member(String email, int age) {
            this.email = email;
            this.age = age;
        }
    }
}
invalid
[email protected]=40
[email protected]=50
count=2, total=90

invalid가 맨 앞에 나오는 것은 출력보다 추가가 먼저 실행되기 때문입니다. 둘째 add는 빈 이메일을 거절하며 invalid를 출력하고, 회원을 저장하지 않은 채 받은 size 1을 그대로 돌려줍니다. 그래서 셋째 회원 [email protected]은 1번 칸에 들어가고, 목록에는 두 회원만 나오며 요약도 count=2, total=90이 됩니다.

메서드로 나눈 덕분에 검사 규칙은 add 한곳에 모였습니다. 그런데 호출하는 코드를 보면 size = add(members, size, ...)처럼 배열과 size를 매번 함께 넘기고, 돌려받은 값을 다시 size에 저장합니다. 메서드 호출과 값 전달에서 본 것처럼 int 인수는 값이 복사되어 넘어가므로, add는 main의 size를 직접 바꿀 수 없기 때문입니다. 결국 '회원이 몇 명 저장되어 있는가'라는 상태는 main이 들고 다니고, add는 새 값을 계산해 줄 뿐입니다.

이 구조에서는 호출하는 쪽이 한 번만 실수해도 상태가 어긋납니다. 첫 add 줄을 add(members, size, "[email protected]", 40);처럼 돌려준 값을 받지 않게 바꾸면 실행 결과가 이렇게 달라집니다.

invalid
[email protected]=50
count=1, total=50

[email protected]은 0번 칸에 저장되었지만 size가 0에 머물러 있었기 때문에, 셋째 add가 같은 0번 칸에 [email protected]을 덮어썼습니다. 컴파일러는 돌려준 값을 버리는 호출을 문제 삼지 않으므로 이번에도 오류 없이 틀린 결과가 나옵니다. printSummary가 Member의 age 필드를 직접 꺼내 더하는 것도 같은 문제를 보여 줍니다. 데이터는 Member와 main에 있는데, 그 데이터를 다루는 행동은 바깥의 static 메서드에 흩어져 있습니다.

아래 그림은 앞의 두 프로그램을 나란히 놓고, 어느 쪽이든 배열과 size를 main이 보관하면서 메서드를 부를 때마다 인수로 넘긴다는 점을 보여 줍니다. 절차를 메서드로 나눈 것이지, 상태를 누가 가지는지가 바뀐 것은 아닙니다.

메서드를 나눠도 배열과 size의 계약은 호출자에게 남는다

ProceduralMemberArray와 ExtractedMemberProcedures는 별도 프로그램이다. 둘 다 길이3 배열에 kim40과 lee50을 두고 size2를 사용하며 추출 후에도 배열과 size를 외부 메서드에 전달한다.

메서드를 나눠도 배열과 size의 계약은 호출자에게 남는다

두 독립 프로그램 모두 사용 구간을 외부 절차에 전달합니다.

두 독립 프로그램 · 각 main에서 추가가 끝난 뒤

두 독립 프로그램 · 각 main에서 추가가 끝난 뒤
비교 항목ProceduralMemberArrayExtractedMemberProcedures
저장 상태length = 3, size = 2 · kim=40, lee=50 · 마지막 칸 nulllength = 3, size = 2 · kim=40, lee=50 · 마지막 칸 null
추가와 거절main이 객체를 만들고 members[size++]에 저장add가 빈 이메일을 거절하며 기존 size를 반환
호출 시 전달printMembers(members, size)와 total(members, size)size = add(members, size, ...), print(members, size), printSummary(members, size)
출력·집계[email protected]=40, [email protected]=50, total=90invalid 뒤 두 회원 출력, count=2, total=90
메서드를 나눠도 배열과 size의 계약은 호출자에게 남는다 독립 ProceduralMemberArray와ExtractedMemberProcedures에서 main이 length3/size2/Kim40/Lee50/null 보관. 외부 static메서드에 배열참조와size를 전달하며 extracted add의 반환size를 호출자가 받음. 절차분리가상태소유이동은아님. 독립 프로그램 · 1 main이 보관 members · length=3 [0] kim / 40 [1] lee / 50 [2] null size = 2 사용 범위 [0, 2) 인수: 배열 참조·size 외부 static 메서드 printMembers / total 배열·size로 순회 집계 90 호출자가 계약 유지 합계 반환값 받기 count=2 · total=90 독립 프로그램 · 2 main이 보관 members · length=3 [0] kim / 40 [1] lee / 50 [2] null size = 2 사용 범위 [0, 2) 인수: 배열 참조·size 외부 static 메서드 add / print printSummary 빈 이메일 → invalid 호출자가 계약 유지 add 반환 size 받기 count=2 · total=90 kim / lee는 본문 이메일의 약칭 점선 호출 · 실선 반환
메서드를 나눠도 배열과 size의 계약은 호출자에게 남는다 독립 ProceduralMemberArray와ExtractedMemberProcedures에서 main이 length3/size2/Kim40/Lee50/null 보관. 외부 static메서드에 배열참조와size를 전달하며 extracted add의 반환size를 호출자가 받음. 절차분리가상태소유이동은아님. 독립 프로그램 · 1 main이 보관 members · length=3 [0] kim / 40 [1] lee / 50 [2] null size = 2 사용 범위 [0, 2) 인수: 배열 참조·size 외부 static 메서드 printMembers / total 배열·size로 순회 집계 90 호출자가 계약 유지 합계 반환값 받기 count=2 · total=90 독립 프로그램 · 2 main이 보관 members · length=3 [0] kim / 40 [1] lee / 50 [2] null size = 2 사용 범위 [0, 2) 인수: 배열 참조·size 외부 static 메서드 add / print printSummary 빈 이메일 → invalid 호출자가 계약 유지 add 반환 size 받기 count=2 · total=90 kim / lee는 본문 이메일의 약칭 점선 호출 · 실선 반환
저장 상태
두 프로그램 각각 members.length = 3, size = 2. 사용한 두 칸은 [email protected]=40, [email protected]=50이고 마지막 칸은 null이다.
ProceduralMemberArray
main이 객체를 만들고 members[size++]에 저장한다.
printMembers(members, size)와 total(members, size)를 호출한다. 두 회원과 total=90을 출력한다.
ExtractedMemberProcedures
add가 빈 이메일을 거절해 invalid를 출력하고 기존 size를 반환한다.
size = add(members, size, ...)로 반환값을 받으며 print와 printSummary에도 배열·size를 전달한다. 두 회원과 count=2, total=90을 출력한다.

데이터 묶음은 이메일·나이를 함께 다루게 하고 메서드 추출은 작업을 나눕니다. 그러나 순회 구간 [0, size)과 필드 직접 읽기·쓰기의 책임은 이 예제의 외부 절차에 남습니다.

더 깊이: 다른 객체의 데이터만 들여다보는 메서드

마틴 파울러(Martin Fowler)의 《리팩터링》은 printSummary 같은 코드를 기능 편애(feature envy)라는 이름의 코드 냄새로 분류합니다. 자기가 속한 모듈 안에서보다 다른 모듈의 함수나 데이터와 더 많이 주고받는 함수를 가리키는 말로, 흔한 처방은 그 함수를 데이터가 있는 쪽으로 옮기는 함수 옮기기(Move Function, 초판에서는 Move Method)입니다. 나이를 더하는 반복문을 회원 배열을 가진 저장소 객체의 메서드로 옮기면 바로 다음 글의 totalAge가 됩니다. 다만 코드 냄새는 살펴볼 신호일 뿐 반드시 고쳐야 하는 규칙은 아닙니다. 화면 출력처럼 데이터와 다른 이유로 바뀌는 코드는 데이터와 떨어져 있는 편이 나을 때도 있고, 이 판단 기준은 다음 글에서 다시 다룹니다.

요구사항이 바뀌면 어디를 고쳐야 할까

절차 지향 코드의 비용은 처음 작성할 때보다 요구사항이 바뀔 때 드러납니다. 앞의 ExtractedMemberProcedures를 쓰는 회원 관리 프로그램에 다음 세 가지 요청이 들어왔다고 해 봅시다.

나이는 14세부터 120세까지만 허용한다

회원을 추가하는 경로가 add 하나뿐이라면 add의 조건만 고치면 됩니다. 하지만 파일에서 회원을 불러오는 기능이나 나이를 수정하는 기능이 생겨 member.age에 직접 값을 넣는다면, 그 경로마다 같은 검사를 복사해야 합니다. 하나라도 빠뜨리면 규칙을 어긴 회원이 저장됩니다. 필드가 누구에게나 열려 있는 한 '저장된 모든 회원은 나이 규칙을 지킨다'고 말할 수 없습니다.

연령대별 회원 수와 전체 회원 수를 출력한다

집계 메서드가 늘어나는 만큼 메서드마다 배열과 size를 받아, 0번 칸부터 size - 1번 칸까지만 도는 반복문을 다시 씁니다. 누군가 실수로 members.length까지 돌면 비어 있는 칸의 null을 만나 NullPointerException이 납니다. 배열은 몇 칸까지 채워졌는지 스스로 알려 주지 못하므로, 그 정보는 언제나 호출하는 쪽이 size로 들고 다녀야 합니다.

최대 회원 수를 3명에서 10명으로 늘린다

배열을 만드는 new Member[3] 한 곳만 고치면 될 것 같지만, 다른 메서드가 index < 3처럼 숫자 3을 직접 썼다면 그곳도 찾아서 고쳐야 합니다. 반면 add처럼 members.length로 용량을 확인하는 코드는 고칠 필요가 없습니다. 용량이라는 정보를 배열 자신에게 물어보기 때문입니다.

세 경우 모두 한 가지를 바꾸려면 여러 곳을 함께 살펴야 했습니다. 이처럼 코드의 한 부분이 다른 부분의 내부 사정에 기대는 정도를 결합도(coupling)라고 하며, 결합도를 짝 개념인 응집도와 함께 모듈 설계의 기준으로 정리한 글은 소프트웨어공학 교재의 결합도와 응집도입니다. 결합도가 높으면 변경 비용은 고친 줄 수만으로 정해지지 않습니다. 규칙을 지키려고 찾아봐야 하는 위치의 수, 잘못된 상태를 만들 수 있는 경로의 수, 호출하는 쪽이 기억해야 하는 상태 조각의 수가 모두 비용입니다.

절차 지향과 객체 지향은 언제 어울릴까

절차 지향 코드는 작업의 순서를 중심으로 읽힙니다. 회원 한 명을 등록하는 과정을 순서대로 적으면 다음과 같고, 이때 배열과 size는 각 단계가 주고받는 재료입니다.

입력을 읽는다 → 검증한다 → 배열에 넣는다 → size를 늘린다 → 출력한다

객체 지향 코드는 데이터와 그 데이터를 다루는 행동을 함께 가진 객체에게 일을 요청하는 형태로 읽힙니다. 다음은 다음 글에서 만들 회원 저장소 객체 registry를 쓰는 모습입니다.

registry.add(email, age)
registry.printEntries()
registry.totalAge()

두 번째 코드에는 배열도 size도 보이지 않습니다. 회원을 어떻게 저장하는지, 몇 명까지 담을 수 있는지는 registry 객체가 알고 있고, 호출하는 쪽은 '추가해 달라', '합계를 알려 달라'고 요청만 합니다.

둘 중 어느 쪽이 언제나 낫지는 않습니다. 섭씨를 화씨로 바꾸거나 두 수 중 큰 값을 고르는 기능처럼, 입력을 받아 결과를 돌려주면 끝나고 기억해 둘 상태가 없다면 Math.max 같은 static 메서드 하나로 두는 편이 자연스럽습니다. 반대로 회원 저장소처럼 계속 유지해야 하는 상태가 있고, 여러 행동이 그 상태를 함께 바꾸고, 언제나 지켜야 하는 조건이 있다면 객체로 묶는 편이 낫습니다. 이렇게 객체가 존재하는 동안 늘 참이어야 하는 조건을 불변식(invariant)이라고 합니다. 회원 저장소라면 'size는 0 이상 배열 길이 이하이고, 앞의 size칸에는 null이 아닌 회원이 들어 있다'가 불변식입니다.

객체 지향은 클래스를 많이 만드는 문법이 아닙니다. 상태와 그 상태를 다루는 행동을 어느 객체에 함께 둘지 정하는 설계 방식입니다.

정리

  • 절차 지향은 프로그램을 실행 순서대로 절차로 나누고 데이터를 인수로 넘겨 다룹니다. 상태가 없는 작은 작업에는 명확하고 충분합니다.
  • 평행 배열은 '같은 인덱스는 같은 회원'이라는 규칙을 코드 밖에 두기 때문에, 한 배열만 움직이는 실수가 오류 없이 틀린 결과로 이어집니다. 데이터를 객체로 묶으면 이 실수는 사라집니다.
  • 데이터를 묶고 메서드로 나눠도 배열과 size 같은 상태는 호출하는 쪽이 들고 다니고, 필드는 누구나 바꿀 수 있습니다. 그래서 규칙을 지키는 코드가 여러 곳에 흩어집니다.
  • 한 부분을 바꿀 때 함께 살펴야 하는 곳이 많을수록 결합도가 높고 변경 비용이 큽니다. 유지할 상태와 불변식이 있다면 상태와 행동을 객체 안에 함께 두는 편이 낫습니다.

연습 문제

  1. Member[]와 size를 받아 회원 나이의 합계와 가장 나이가 많은 회원의 이메일을 출력하는 printReport를 작성하세요. 배열은 null이 아니고, size는 0 이상 배열 길이 이하이며, 앞의 size칸에는 회원이 들어 있다고 가정합니다. size가 0이면 report=none만 출력합니다. 최댓값은 임의의 숫자가 아니라 실제 첫 회원으로 시작하고, 나이가 같으면 먼저 저장된 회원을 고릅니다. 코드를 쓰기 전에 [email protected] 40세, [email protected] 55세, [email protected] 55세 순서로 저장된 배열의 결과를 먼저 예상해 보세요.
  2. 다음 기능을 static 메서드 하나로 두는 편이 나은 것과 상태를 가진 객체로 만드는 편이 나은 것으로 나누고, 이유를 쓰세요.
    • 섭씨 온도를 화씨로 바꾸기
    • 장바구니에 상품을 담고 빼며 총액 구하기
    • 문자열의 앞뒤 공백을 지우고 소문자로 바꾸기
    • 도서관에서 책을 빌려주고 돌려받으며 대출 중인 책 목록 관리하기
해설 보기

1번은 다음처럼 작성할 수 있습니다. main은 문제의 세 회원으로 한 번, size를 0으로 한 번 호출합니다.

src/ProceduralReport.java
public final class ProceduralReport {
    public static void main(String[] args) {
        Member[] members = {
                new Member("[email protected]", 40),
                new Member("[email protected]", 55),
                new Member("[email protected]", 55)
        };
        printReport(members, 3);
        printReport(members, 0);
    }

    private static void printReport(Member[] members, int size) {
        if (size == 0) {
            System.out.println("report=none");
            return;
        }

        int total = 0;
        int maxIndex = 0;
        for (int index = 0; index < size; index++) {
            total += members[index].age;
            if (members[index].age > members[maxIndex].age) {
                maxIndex = index;
            }
        }
        System.out.println("total=" + total);
        System.out.println("oldest=" + members[maxIndex].email);
    }

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

        Member(String email, int age) {
            this.email = email;
            this.age = age;
        }
    }
}
total=150
[email protected]
report=none

첫 호출에서 합계는 40, 95, 150으로 늘어납니다. 가장 나이 많은 회원의 위치 maxIndex는 0번 칸의 [email protected]에서 시작해, 1번 칸의 55가 40보다 크므로 1로 바뀝니다. 2번 칸의 [email protected]도 55세지만 55 > 55는 거짓이라 후보가 바뀌지 않고, 그래서 먼저 저장된 [email protected]이 남습니다. 조건을 >=로 썼다면 [email protected]이 골라졌을 것입니다.

둘째 호출에서는 첫 if가 바로 return하므로 배열 칸을 하나도 읽지 않습니다. 이 검사가 없으면 반복문은 한 번도 돌지 않지만 마지막 줄의 members[maxIndex]가 0번 칸을 읽습니다. 이 예제의 둘째 호출처럼 0번 칸에 회원이 남아 있으면 예외 없이 total=0과 [email protected]이 출력되어, 회원이 없는데도 가장 나이 많은 회원이 있다고 잘못 알립니다. 길이 0인 배열이라면 ArrayIndexOutOfBoundsException이, 0번 칸이 비어 있다면 NullPointerException이 납니다.

절차를 메서드 하나로 깔끔하게 모았지만, 배열과 size가 서로 맞는지는 여전히 호출하는 쪽이 지켜야 합니다. 다음 글에서는 저장소 객체가 이 둘을 직접 가지게 됩니다.

2번에서 섭씨를 화씨로 바꾸는 기능과 문자열을 정리하는 기능은 static 메서드가 어울립니다. 결과가 입력만으로 정해지고, 호출과 호출 사이에 기억해 둘 상태가 없기 때문입니다. 반대로 장바구니는 담긴 상품 목록이라는 상태가 있고 담기, 빼기, 총액 계산이 모두 그 상태를 함께 씁니다. 수량은 0보다 커야 한다는 불변식도 장바구니 객체 안에서 한 번만 지키면 됩니다. 도서관 대출도 대출 중인 책 목록을 빌려주기와 돌려받기가 함께 바꾸고, 이미 빌려준 책은 다시 빌려줄 수 없다는 조건을 지켜야 하므로 객체로 만드는 편이 낫습니다.

다음 글

지금의 Member는 값을 담는 상자이고, add와 total은 그 바깥에 있는 절차입니다. 다음 글에서는 질문을 바꿉니다. 회원 한 명이 자기 값이 올바른지 스스로 지킬 수 있는지, 회원 저장소가 배열과 size를 직접 가질 수 있는지, 호출하는 쪽이 내부 인덱스 대신 add 같은 행동만 알게 할 수 있는지를 차례로 확인합니다.