주요글: 도커 시작하기
반응형
많은 입문서에서 캡슐화(encapsulation)를 단순히 private을 사용해서 필드를 외부에 감추는 것 정도로 소개하고 넘어가는 경우가 많아, 많은 입문자들에게 오해를 심어주는 것 같다. 그래서 집필 중인 자바 프로그래밍 입문서에서 캡슐화와 관련된 부분을 블로그를 통해 공개하고자 한다. 아래 내용부터는 (아직 집필중인) 책의 캡슐화 부분을 발체한 내용이다.

* 본 문서는 책의 일부 내용을 발췌한 것으로서 온오프라인 상의 무단 배포를 금합니다.

캡슐화(encapsulation)

지금까지 자바에서 클래스를 만드는 방법을 살펴봤다. 필드를 이용해서 객체의 상태를 보관하고, 메서드를 이용해서 기능을 구현하고, 생성자를 이용해서 객체를 생성하는 방법을 공부 했다. 또한, public, protected, private을 이용한 접근 제어 방법도 살펴봤다.

그럼, 왜 클래스를 이용하는 걸까? C 언어의 구조체와 함수를 이용해도 자바의 클래스와 비슷한 구현이 가능할 것 같은데 말이다. 클래스를 사용하는 이유는 객체 지향 방식으로 구현하기 위해서이다. 자바는 클래스를 이용해서 객체를 표현하고 있으며, 자바 언어로 객체 지향을 잘 하기 위해서는 객체 지향 원칙에 따라 클래스를 작성해 주어야 한다.

그렇다면, 객체 지향의 가장 기본이 되는 원칙은 무엇일까? 바로 이 절의 제목인 '캡슐화(encapsulation)'가 객체 지향의 가장 기본이 되는 대원칙이다. 본 절에서는 완벽하게는 아니어도 조금이나마 독자가 캡슐화가 무엇이고 왜 캡슐화가 중요한 지 느낄 수 있도록 할 것이다.

흔히 객체 지향과 반대되는 개념으로 절차 지향을 언급하는데, 본 절에서는 아주 간단한 스톱워치 기능을 구현하는 과정을 통해서 절차 지향의 단점을 파악하고 캡슐화를 통해 어떻게 이 문제를 해결할 수 있는 지 살펴 볼 것이다.

절차 지향 방식의 구현

성능 측정 어플리케이션을 개발해서 고객에게 납품하기로 했다고 하자. 성능 측정을 위해 필요한 공통 기능은 실행 시간을 측정하는 것이다. 그래서 시간 데이터를 표현하기 위해 다음과 같이 밀리초(1/1000초) 단위로 시간을 보관할 수 있는 클래스를 작성하였다.

package ch03.util;

public class ProceduralStopWatch {

    public long startTime; // 밀리초(1/1000초) 단위
    public long stopTime; // 1/1000초 단위

    public long getElapsedTime() {
        return stopTime - startTime;
    }
}

ProceduralStopWatch 클래스는 시작 시간과 종료 시간을 저장할 수 있는 두 개의 public 필드를 제공하고 있다. 성능 측정 모듈에서 종료 시간과 시작 시간 사이의 차이를 구하는 코드가 많기 때문에, 흘러간 시간을 쉽게 구할 수 있도록 getElapsedTime() 메서드를 추가로 구현하였다.

이제 ProceduralStopWatch 클래스를 사용하는 코드는 다음과 같은 방식으로 작성될 것이다.

ProceduralStopWatch stopWatch = new ProceduralStopWatch();
stopWatch.startTime = System.currentTimeMillis(); // 시작 시간 설정

// 측정 대상 기능 실행

stopWatch.stopTime = System.currentTimeMillis(); // 종료 시간 설정
long elapsedTime = stopWatch.getElapsedTime(); // 시간 차이 구함

수 개월을 문제 없이 개발해 나갔다. 그런데, 늘 그렇듯이 고객으로부터 요구 사항을 변경하자는 연락이 왔다. 필요에 따라 밀리초(1/1000초) 보다 더 세밀한 단위로 측정할 필요가 있기 때문에 밀리초뿐만 아니라 더 세밀한 단위로도 측정할 수 있게 해 달라는 것이었다.

아주 간단한 방법은 다음과 같이 나노초 단위로 측정이 필요한 코드에서만 startTime 필드와 stopTime 필드에 나노초 단위로 시간을 저장하는 것이다.

ProceduralStopWatch stopWatch = new ProceduralStopWatch();
stopWatch.startTime = System.nanoTime(); // 시작 시간 나노초 단위 설정

// 측정 대상 기능 실행

stopWatch.stopTime = System.nanoTime(); // 종료 시간 나노초 단위 설정
long elapsedTime = stopWatch.getElapsedTime(); // 시간 차이 나노초 단위로 구함

하지만, startTime 필드에 어떤 경우에는 밀리초 단위의 값을 보관하고 어떤 경우에는 나노초 단위의 값을 보관하게 되면, 개발 과정뿐만 아니라 유지보수 과정에서 문제가 발생할 것 같다. 이미 개발자 중 몇 명은 기준 없이 밀리초와 나노초를 섞어서 사용하기 시작했다.

그래서, 나노초 단위를 저장할 수 있도록 ProceduralStopWatch 클래스에 startNanoTime, stopNanoTime의 두 필드와 getElapsedNanoTime() 메서드를 추가하기로 결심했다.

package ch03.util;

public class ProceduralStopWatch {

    public long startTime;
    public long stopTime;
   
    public long startNanoTime;
    public long stopNanoTime;

    public long getElapsedTime() {
        return stopTime - startTime;
    }
   
    public long getElapsedNanoTime() {
        return stopNanoTime - startNanoTime;
    }
}

이제 나노초 단위를 이용해서 시간을 측정해야 하는 코드를 다음과 같이 변경함으로써, 시간 단위가 나노초 임을 분명히 할 수 있게 되었다.

ProceduralStopWatch stopWatch = new ProceduralStopWatch();
stopWatch.startNanoTime = System.nanoTime(); // 시작 시간 나노초 단위 설정

// 측정 대상 기능 실행

stopWatch.stopNanoTime = System.nanoTime(); // 종료 시간 나노초 단위 설정
long elapsedTime = stopWatch.getElapsedNanoTime(); // 시간 차이 나노초 단위로 구함

일단, 급한 불은 껐다. 그런데, 여전히 불안함이 떠나질 않는다. 만약 고객이 초 단위로 값을 구해 달라는 요구가 추가되면 어떻게 해야 하나? 또는 같은 시간을 초 단위, 밀리초 단위, 나노초 단위로 표현해 달라고 하면 어떻게 해야 하나? 아마 코드는 점점 복잡해지고 요구사항이 추가되거나 변경될 때마다 함께 수정되는 코드도 많아질 것이다.

자, 그러면 왜 이런 일이 벌어졌을까? 가장 큰 이유는 데이터를 중심으로 개발했기 때문이다. (데이터를 중심으로 개발하는 것이 전형적인 절차 지향 방식 개발이다.)

[그림3.18] 데이터를 중심으로 개발되는 절차 지향 방식


ProceduralStopWatch 객체를 사용하는 코드는 객체의 필드에 직접 값을 할당하고 값을 가져올 수 있다. 편의상 getElapsedTime() 메서드를 만들었지만, 마음대로 필드의 값을 조작할 수 있다. ProceduralStopWatch 객체의 내부인 startTime 필드와 stopTime 필드를 마음대로 접근할 수 있기 때문에 많은 코드들이 직접 ProceduralStopWatch 객체의 필드 데이터를 조작할 수 있으며, 이로 인해 ProceduralStopWatch의 내부 코드를 변경하게 되면 많은 코드의 변화를 유발하게 된다.

[그림3.19] 절차 지향에서는 데이터의 변화가 많은 코드의 변화를 유발시킴

요구 사항의 변화가 없다면 또는 추가되지 않는다면, 데이터를 중심으로 개발하는 게 문제가 되지 않는다. 하지만, 거의 모든 프로젝트에서 요구사항은 추가되거나 변경되기 마련이며, 데이터를 중심으로 개발할 경우 데이터의 변화는 많은 코드에 수정을 발생시키는 요인이 된다.

그렇다면 어떻게 해야 ProceduralStopWatch 클래스를 사용하는 코드에 영향을 주지 않으면서 (또는 영향을 최소화하면서) ProceduralStopWatch 클래스의 내부 데이터 구조를 변경할 수 있을까? 정답은 바로 ProceduralStopWatch 클래스의 기능을 캡슐화하는 것이다.

객체 지향 방식의 구현

스톱워치 예를 객체 지향 방식으로 재구성해 보겠다. 객체 지향의 핵심 중의 핵심은 캡슐화에 있다. 캡슐화는 자세한 내부 구현을 외부에 드러내지 않고 숨기는 것이다. 캡슐화를 하게 되면 내부에 데이터를 어떻게 저장하는 지, 그 데이터를 어떻게 처리하는 지, 또는 특정 기능을 어떻게 제공하는 지에 대한 내용은 드러내지 않는다. 단지, 객체가 어떤 기능을 제공하는 지만 공유하게 된다.

예를 들어, 스톱워치 예의 경우 시간 데이터를 어떻게 구하는 지 또는 어떤 타입으로 저장하는 지 등의 구현은 외부로 드러내지 않고 다음의 기능만 외부에 제공하게 된다.
  • 스톱워치를 시작한다
  • 스톱워치를 중지한다.
  •  중지와 시작 사이의 시간 차이를 구한다.
스톱워치 기능을 제공하는 클래스를 캡슐화해서 다시 구현해 보자. 아래 코드는 객체 지향 방식으로 새롭게 구현한 스톱워치 클래스이다.

package ch03.util;

public class StopWatch {

    private long startTime;
    private long stopTime;

    public void start() {
        startTime = System.currentTimeMillis();
    }

    public void stop() {
        stopTime = System.currentTimeMillis();
    }

    public Time getElapsedTime() {
        return new Time(stopTime - startTime);
    }
}

StopWatch 클래스는 시작 시간과 종료 시간을 보관하기 위해 startTime 필드와 stopTime 필드를 사용하는데, 이 두 필드는 private 이다. 따라서, StopWatch 클래스를 제외한 다른 코드에서는 이 두 필드에 직접 접근할 수 없다.

start() 메서드는 startTime 필드에 현재 시간 값을 밀리초 단위로 저장한다. stop() 메서드도 유사하게 stopTime 필드에 밀리초 단위로 현재 시간 값을 저장한다. 즉, 스톱워치의 시작과 중지를 처리하려면 이 두 메서드를 호출해야 한다.

getElapsedTime()은 long이 아닌 Time이라는 클래스 타입을 리턴한다. Time은 시간을 표현하기 위해 만든 클래스로서 아래와 같다.

package ch03.util;

public class Time {

    private long t;
   
    public Time(long t) {
        this.t = t;
    }

    public long getMilliTime() {
        return t;
    }
}

StopWatch 클래스를 사용해서 시간 차를 구하는 코드는 다음과 같을 것이다.

StopWatch stopWatch = new StopWatch();
stopWatch.start(); // startTime 필드에 값을 할당하는 게 아닌, 기능 실행
// 코드
stopWatch.stop(); // stopTime 필드에 값을 할당하는 게 아닌, 기능 실행
Time time = stopWatch.getElapsedTime(); // long 타입이 아닌 시간을 표현하는 타입
// time.getMilliTime() 사용

앞서 절차 지향 방식에서는 필드에 직접 접근해서 필드 값을 변경했던 것에 반해 객체 지향 방식에서는 상세한 구현이 start() 메서드와 stop() 메서드로 캡슐화 되어 있다. StopWatch 클래스를 사용하는 코드는 start() 메서드가 내부적으로 어떻게 시작 시간을 저장하는 지에 대해서는 알 필요 없이, start() 메서드가 스톱워치의 시작 기능을 제공한다는 것만 알면 된다.

스톱워치를 중지한 뒤 흘러간 시간을 구할 때 사용된 리턴 타입은 long이 아닌 Time 클래스이다. long이 아닌 Time 클래스를 사용한 이유는 long은 시간을 표현하는 데 적합하지 않기 때문이다. 그래서 시간을 표현하기 위해 Time 클래스를 추가로 만들었고, Time 클래스로부터 필요한 값을 구하도록 했다.

이제 고객으로부터 새로운 요구사항을 받을 차례가 됐다. 절차 지향 방식을 설명할 때와 동일하게 나노초 단위로도 시간 차이 값을 구할 수 있어야 된다고 한다. 절차 지향 방식에서 ProceduralStopWatch 클래스를 사용하는 수 많은 코드를 변경해야 했던 기억이 새록 새록 떠오를 것이다. 하지만, 걱정하지 마라. 우리는 이미 스톱워치와 시간을 캡슐화하는 데 성공했고, StopWatch 클래스와 Time 클래스를 사용하는 코드에 어떤 영향도 주지 않고 이 두 클래스를 변경할 수 있다.

먼저, StopWatch 클래스가 내부적으로 나노초를 저장하도록 변경해보자. 다음과 같이 밀리초를 가져오는 코드를 나노초를 가져오는 코드로 변경했다.

package ch03.util;

public class StopWatch {

    private long startTime;
    private long stopTime;

    public void start() {
        startTime = System.nanoTime();
    }

    public void stop() {
        stopTime = System.nanoTime();
    }

    public Time getElapsedTime() {
        return new Time(stopTime - startTime);
    }
}

Time 클래스가 생성자에서 입력받는 값이 밀리초에서 나노초로 변경되었으므로 Time 클래스를 다음과 같이 변경하였다. t 필드가 저장하는 값의 단위가 나노초이므로 getMilliTime() 메서드의 구현이 일부 변경되었고, getNanoTime()메서드가 새로 추가되었다.

package ch03.util;

public class Time {

    private long t;
   
    public Time(long t) {
        this.t = t;
    }

    public long getMilliTime() {
        return t / 1000000L;
    }

    public long getNanoTime() {
        return t;
    }
}

나노초 단위를 수용할 수 있도록 StopWatch 클래스와 Time 클래스를 변경하였다. 이제 이두 클래스를 사용하던 코드가 나노초를 사용하도록 변경할 차례이다. 바꿔보자.

StopWatch stopWatch = new StopWatch();
stopWatch.start();
// 코드
stopWatch.stop();
Time time = stopWatch.getElapsedTime();
// time.getNanoTime() 사용

어떤가? 나노초 단위로 시간 차이를 구하기 위해서 변경한 코드는 Time 클래스의 getMilliTime() 메서드 대신 getNanoTime() 메서드를 사용하도록 변경한 것뿐이다. 나머지 코드는 하나도 변경되지 않았다.

고객으로부터 요구사항이 또 들어왔다. 초 단위로도 시간 차이를 표현하고 싶단다. 이건 아주 쉽다. 왜냐면 우리는 Time 클래스를 이용해서 시간을 표현하고 있기 때문이다. 다음과 같이 Time 클래스에 메서드 하나만 더 추가해주면 끝이다.

package ch03.util;

public class Time {
    ...
    public double getSecondTime() {
        return (double)t / (double)1000000000;
    }
}

자세한 내부 구현을 숨기고 외부에는 기능만을 제공하도록 StopWatch 클래스를 캡슐화 하였다. 그리고, 스톱워치의 요구 사항이 변경되었지만 StopWatch 클래스를 잘 캡슐화 한 덕분에 StopWatch 클래스를 사용하는 코드에 거의 영향을 주지 않고 StopWatch 클래스를 변경할 수 있었다.

이게 바로 캡슐화의 힘이다. 내부 구현(특히 데이터)을 외부에 노출하지 않고 기능을 잘 캡슐화하게 되면, 해당 기능을 변경해야 하는 상황이 발생할 경우 특정 클래스로만 변화가 수렴되는 특징을 갖게 된다. 변화가 여러 클래스로 확산되지 않기 때문에 그 만큼 캡슐화 된 클래스의 수정은 용이하게 된다.

[그림3.20] 캡슐화는 요구사항 변화에 따른 코드 수정 범위를 최소화 해 준다.

실제로 다수의 소프트웨어는 개발 과정뿐만 아니라 개발이 완료된 이후에도 요구사항의 추가, 버그 수정 등으로 코드를 수정하게 된다. 만약 절차 지향으로 개발했다면 이런 수정 과정에서 변경되는 코드의 범위는 점점 커지게 되고 시간이 흐를수록 변화의 폭은 더욱 증폭된다. 따라서 절차 지향 방식에서 무엇인가를 변경하는 것은 개발자 입장에서 많은 위험과 어려움이 따르는 작업이 된다.

반면에 객체 지향 방식으로 개발했다면 변화의 범위는 소수의 클래스로 한정되는 경향이 있으며, 따라서 새로운 요구 사항을 (절차 지향에 비해) 쉽고 빠르게 수용할 수 있게 된다. 우리는 이미 StopWatch 예제에서 이런 특징을 확인할 수 있었다.

+ Recent posts