캡슐화 (Encapsulation)
캡슐화란 쉽게 말하면 변수나 메소드들을 캡슐로 감싸서 안보이게 하는 정보 은닉 개념중 하나이다.
우리가 먹는 알약을 생각해보자.
알약의 실제 약 내용은 가루약이지만, 이것을 캡슐로 감싸면서 맛을 은닉하여 사람이 먹기 편하게 하였으며, 또한 복잡한 재료들의 배합을 캡슐로 포장하여 다루기 편리하도록 하였다.
이처럼 캡슐화는 객체의 속성(Field)과 행위(Method)를 하나로 묶고, 외부로 부터 내부를 감싸 숨겨 은닉한다. 또한 외부의 잘못된 접근으로 값이 변하는 의도치 않는 동작을 방지하는 보호 효과도 누릴 수 있다.
자바에서는 대표적으로 protected , default , private의 접근제어자를 통해 구현이 가능하다.
class Time {
private int hour; // hour는 외부에서 접근하지 못하게 private으로 선언한다.
// Setter
public void setHour(int hour) {
if (hour < 0 || hour > 24) { // hour에 대한 유효성 검사
return;
} else {
this.hour = hour;
}
}
// Getter
public int getHour() {
return hour;
}
}
public class Main {
public static void main(String[] args) {
MyTime time = new MyTime();
// 유효하지 않은 parameter 입력
time.setHour(25);
System.out.println("Hour: " + time.getHour()); // 0
// 유효한 parameter 입력
time.setHour(13);
System.out.println("Hour: " + time.getHour()); // 13
}
}
위의 코드는 시간을 메서드로 입력받고, 출력하는 아주 간단한 캡슐화 예제이다.
여기서 클래스의 hour 멤버변수는 private으로 선언함으로써 다른 클래스에서의 접근을 제한해 두었다.
그리고 이 private hour 변수를 다루기 위해서는 setter method인 setHour(int hour)와 getter method인 getHour()의 호출을 통해서만 가능하다.
특히 이러한 메서드로 통한 접근 기법의 좋은 점은 유효하지 않은 숫자로 직접 변수 hour를 셋팅하려는 시도를 메서드 내부에서 유효성 체크 로직을 통해 거를 수 있다는 점이다
String 클래스의 캡슐화
실제 자바에서의 캡슐화의 대표적인 표본으로는 정말 자주 사용되는 String 클래스를 들 수 있다.
아래 사진은 실제로 자바 코드에 구현되어 있는 String 클래스의 내부 모습이다.
보다 시피 String 클래스 자체는 public으로 선언되어 있기 때문에 어디서든 접근을 할 수 있어 간편하게 사용이 가능하다. 그런데 final로 선언되어 있기 때문에 상속은 불가능하다. (String 클래스를 상속하여 사용해본 적은 없을 것이다)
String 클래스 내부의 필드들을 보면 다 private 제어자로 선언 되어 있는 것을 볼 수 있다. 즉, 직접적으로 문자열 값에 접근해서 변경할 수 없는 것이다.
실제로 String 데이터는 불변의 데이터라고 불리는데 그 이유가 바로 위와 같이 캡슐화가 되어 있기 때문이다.
이는 String 클래스를 통해 내부적으로 각종 password 및 encoding/decoding 작업이 이루어지기 때문에 보안적으로 굉장히 민감하기 때문이다.
좀더 String 클래스에 대해 왜 문자열 데이터가 불변인지 자세히 알고 싶다면 다음 포스팅을 참고하길 바란다.
정보 은닉 (OOP의 핵심)
정보 은닉을 구체적으로 말하면, 객체지향 언어적 요소를 활용하여 객체에 대한 구체적인 정보를 노출시키지 않도록 하는 기법을 말한다.
많은 개발자들은 객체 지향 파트에 들어서면 대게 캡슐화 == 정보 은닉 으로 주입식 암기를 한다. 이는 틀린 소리는 아니지만 정보 은닉 개념에 발가락만 담구고 넘어가는 꼴과 다름이 없다.
좀더 자세히 들어가자면, 자바 프로그래밍의 정보 은닉 기법은 대표적으로 3가지 정도가 있다.
- 객체의 구체적인 타입 은닉 ( = 업캐스팅)
- 객체의 필드 및 메소드 은닉 ( = 캡슐화)
- 구현 은닉 ( = 인터페이스 & 추상 클래스)
즉, 캡슐화 == 정보 은닉이 아니라, 정보 은닉 기법중 하나가 캡슐화 라는 것이다.
이밖에도 업캐스팅과 인터페이스 구현 역시 정보 은닉 측면에 포함 된다. 부모 클래스 타입으로 통합을 하거나 인터페이스 타입을 사용하는 것 역시 구체적인 클래스 타입을 은닉 함으로써 보다 다형하게 사용할수 있기 때문이다.
정보 은닉 이라는 단어를 보자면 무언가 정보를 은닉하여 보안적인 효과를 얻는 것 같다.
그러나 보안적인 측면 뿐만 아니라, 은닉되어 알필요가 없어 덜 알고 덜 공부하여도 간편하게 사용할 수 있게 해주는 의미도 내포한다.
우리가 자동차를 몰때 엔진이 어떻게 구동되어 바퀴가 굴러가는지에 대한 지식이 필요없이 페달만 밟으면 자동차가 굴러간다.
이것이 자동차 설계 정보를 은닉함으로서 경쟁사로부터 제품내부를 알지못하게 보안하는 효과도 있지만 소비자가 페달만 밟으면 작동하도록 단순화 한것도 포함한다. 이는 추상화 개념도 연결되는 대목이기도 하다.
객체 지향 언어를 통해 만들어진 디자인 패턴과 같은 설계들은, 사실 거슬러 올라가자면 정보 은닉 기법을 통해 얻는 이득을 극대화 하기 위해 만들어진 것들이다.
객체지향의 설계 원칙(SOLID) 과 각종 객체지향 설계에 관련된 격언들도 역시 정보 은닉에 기반을 두었다고 해도 과언이 아니다.
따라서 정보 은닉 개념을 잘 알고 있다면, 그에 파생된 oop 패턴 기법들에 대해 왜 이런 패턴이 생겨났는지 그로 인해 어떠한 이득을 얻을 수 있는지 자연스럽게 이해할 수 있게 된다.
객체의 타입 은닉 (업캐스팅)
사실 자식 객체의 타입을 부모 객체의 타입으로 형변환 하는 UpCasting도 일종의 정보 은닉이다.
왜냐하면 구체적인 자식 객체의 타입을 은닉함으로써 얻는 효과가 있기 때문이다. (다형성)
예를들어 아래와 같이 Rectangle 클래스를 직접 정의하여 메인 메소드에서 사용한다면, 결국 이것은 Rectangle 이라는 객체에 전적으로 의존하는 코드가 된다.
만일 Rectangle 클래스 구성에 무슨 문제가 생긴하면 메인 메소드도 영향을 받게 된다.
class Rectangle{
public void rectangle(){
System.out.println("rectangle");
}
}
class Myclass {
// ...
public void method() {
Rectangle rectangle = new Rectangle(); // Rectangle 객체 생성
rectangle.rectangle(); // Myclass 클래스는 Rectangle 클래스에 의존적인 코드
}
}
만약 Rectangle과 유사한 기능을 하는 객체 Square 나 Triangle을 추가로 구현한다고 가정하자.
그리고 기획이 바뀌어 Rectangle 대신 Triangle을 사용하도록 설계서가 바뀌었다고 하자.
현재 상태로서는 메인 메소드에 있는 초기화 코드부터 메서드 사용 코드를 통째로 바꾸어 주어야 한다.
따라서 Rectangle, Triangle .. 등 클래스 타입을 하나의 타입으로 통합 할 수 있는 Shape 라는 추상 클래스를 만들어 다형성의 효과를 이용해 구현해준다.
이때 눈여겨 보아야 할 것이 객체의 호환성을 위해 따로 추상 메소드 draw()를 구현하려고 명시한 것이다.
공개 메서드 draw() 를 통해 각 자식 객체의 내부 메소드 rectangle() , trinagle() 을 실행하도록 하면 마치 공통 분모를 묶어 호환이 된다.
abstract class Shape{
abstract public void draw(); // 하나의 공통 메소드로 각 자식 클래스 고유의 메서드 동작이 실행되도록 추상화
}
class Rectangle extends Shape{
public void draw(){
rectangle();
}
private void rectangle(){
System.out.println("rectangle");
}
}
class Triangle extends Shape{
public void draw(){
triangle();
}
private void triangle(){
System.out.println("triangle");
}
}
public class Myclass {
// ...
public void method() {
Shape shape = new Rectangle(); // 다형성
shape.draw(); // "rectangle" - 구체적인 Rectangle 객체 타입에 의존적이지 않게 되고 추상 통합 타입인 Shape 클래스에 의존적이게 되었다
}
}
처음 코드와 같이 new Rectangle() 통해 생성했지만, 상위 타입인 Shape 클래스 참조 변수인 shape으로 객체를 참조하게 되었다.
즉, 멤버 감소가 일어나 이후에 shape 참조 변수를 통해 사용할 수 있는 메소드는 Shape 클래스의 추상 메소드로만 제한되게 된다.
그래서 코드를 보면 생성 이후에는 Shape 클래스의 draw() 메소드만을 호출함으로써, Rectangle 클래스와 관련된 메소드 private void rectangle() 가 직접적으로 호출되지 않게 된다.
결국 객체 타입과 메서드를 은닉한 것이다.
이러한 정보 은닉을 통해서 얻을 수 있는 이점은 다음과 같다.
Rectangle의 생성 코드 이후에는 어떤 코드도 Rectangle 클래스에 의존하지 않는다. (Shape 클래스로 묶었으니까 Shape 클래스만 신경 쓰면 된다)
따라서 Rectangle 대신에 Triangle을 사용하고 싶어졌을 때에는 new Rectangle() 대신 new Triangle()을 생성하도록 변수 할당문을 수정 해주기만 하면 된다.
그러면 그 이후의 코드들은 전혀 수정될 필요가 없다. (결국 정보 은닉의 효과로 다형성의 효과를 누리게 되는 것과 같다)
public class Myclass {
// ...
public void method() {
// Shape shape = new Rectangle();
Shape shape = new Triangle(); // 변수 할당문만 바꿔주면 draw() 호출 부분 코드는 바꾸지 않아도 알아서 동작
shape.draw(); // "triangle"
}
}
만약에 완전히 객체를 Triangle로 변경하는게 아닌 Rectangle을 사용하다가 아주 잠시 메서드를 실행하기 위해 Triangle을 사용해야 할 경우도 발생할 수 있다.
이때는 다운캐스팅(downcasting)을 통해 동적으로 기능을 교체하면된다.
즉, 이미 선언되어 있는 shape 참조 변수에 (Triangle) 캐스팅만 해주면 기능 전환도 동적으로 쉽게 할 수 있다.
public class Myclass {
// ...
public void method() {
Shape shape = new Rectangle();
shape.draw(); // "rectangle"
// 다운캐스팅을 하고 메서드 실행
((Triangle) shape).draw(); // "triangle"
}
}
이처럼 업캐스팅 / 다운캐스팅이 단순히 객체 형변환 의미를 떠나서 객체 지향 프로그래밍 설계에 굉장히 중요하다라는 것을 볼 수 있다.
더욱 객체를 은닉하라 (디자인 패턴)
하지만 위의 코드도 완벽한 것은 아니다.
사실 자잘한 문제가 몇몇 있기 때문에 좀더 리팩토링을 해주어야 하는데, 그래서 나온게 한번쯤은 들어본 디자인 패턴들이 그것이다.
다음 코드는 간략하게 리팩토링 해본 디자인 패턴(팩토리 패턴) 코드의 간단한 예제이다.
abstract class Shape{
abstract public void draw();
}
class Rectangle extends Shape{
public void draw(){
rectangle();
}
private void rectangle(){
System.out.println("rectangle");
}
}
class Triangle extends Shape{
public void draw(){
triangle();
}
private void triangle(){
System.out.println("triangle");
}
}
// Shape에 연관된 자식 객체들을 찍어내는 팩토리라는 클래스를 새로 만든다.
class ShapeFactory{
public Shape create_R() { // 리턴 타입이 Shape
return new Rectangle();
}
public Shape create_T() { // 리턴 타입이 Shape
return new Triangle();
}
}
public class Myclass {
// ...
public void method() {
ShapeFactory factory = new ShapeFactory(); // 팩토리 객체를 만들어주고
Shape shape = factory.create_T(); // 팩토리 메소드로 Shape의 자식 객체를 만들도록 우회해준다. 리턴 타입이 Shape 이므로 변수의 타입도 Shape로 해준다.
shape.draw(); // "triangle"
}
}
Shape의 구체적인 자식 객체를 생성하는 책임을 담당하는 ShapeFactory 클래스를 새로 만들어 주었다.
그리고 이 ShapeFactory 클래스의 메서드를 보면 객체의 생성과 함께 Shape 타입으로 반환 하는 것을 볼 수 있다.
이렇게 하면 오로지 Shape 클래스와 ShapeFactory 클래스만으로 Rectangle, Triangle 클래스를 생성할 수 있게 된다.
즉, 완벽히 구체적인 객체에 대한 정보 은닉을 한 것이다.
이처럼 정보 은닉이 되면 될수록, 객체의 교체나 변경이 쉬워지게 되어 결과적으로 개발 생산성이 향상 되게 된다.
객체의 필드 & 메소드 은닉 (캡슐화)
캡슐화의 예제는 위에서 다뤘으니 클래스 필드의 private 화는 넘어가겠다.
다만 private 변수와 더불어 캡슐화에 있어서 중요한 점이 바로 private 메소드 이다.
은닉 메소드에 대해서는 상대적으로 강조가 적은 편이라 들어본적이 없을텐데 다음 코드를 봐보자.
class Process{
public void init(){}
public void process(){}
public void release(){}
}
위의 Process 클래스를 보면 동작 메서드가 모두 public 으로 지정 되어 있다.
이말은 외부에서 이 클래스의 객체를 사용하는 코드에 대해서는 모두 3개의 메소드에 의존하게 된다는 말이다.
다시 한번 말하자면, 이는 혹시라도 의존 되어있는 Process 객체를 수정하거나 제거를 하는 등의 수정이 발생했을 때, Process 클래스 자원을 사용하는 클래스에서 방대한 코드 수정을 해야 한다는 뜻이기도 하다.
또한 불필요하게 많은 수의 메소드를 노출시키되면, 호출 순서에 대해 연관관계를 유추하는데 어려움이 생기고 ,어느 메서드가 중요한지 중요도를 유추하는데 있어 어려움이 생긴다.
그래서 프로세스의 동작에 관한 메서드들을 모두 private화 하여 은닉시키고 work() 라는 public 메서드를 추가해 안에서 메서드 실행을 해줌으로써 극복 할 수 있다.
class Process{
private void init(){} // 은닉 메서드
private void process(){} // 은닉 메서드
private void release(){} // 은닉 메서드
public void work(){ // 공개 메서드
init(); // 은닉한 메서드 실행
process();
release();
}
}
이를 통해 적절한 수준에서 메소드들이 공개와 비공개로 나누어져 있기 때문에 어떤 메소드를 우선 살펴야 할지를 알 수 있으며, 또한 개별 메소드들의 호출 순서를 work() 메소드에서 정해주고 있기 때문에 Process 객체 사용에 대한 사용법을 적은 학습량으로 쉽게 사용이 가능해 진다.
그리고 메서드들을 은닉 함으로써 객체의 세부 내용을 덜 노출시키게 되어 보안에도 효과가 있게 된다.
이렇게 공개 메서드와 은닉 메서드를 구분하고 공개 메소드의 갯수를 최소화 시키는 이유가 바로 다음에 이어질 구현 은닉을 위한 설계이며 자바에서 인터페이스 라는 것을 사용하는 이유이기도 하다.
구현 은닉 (인터페이스)
위의 정보 은닉을 잘 받아들여, 변수를 private으로 선언하고 꼭 필요한 공개 메소드를 잘 구축한 좋은 클래스를 구성했다고 가정하자.
이렇게 클래스 구성을 해 놓으면 객체간에 의존성은 오직 공개 메소드에 의해서만 발생하게 된다.
그리고 이러한 공개 메소드를 은닉 메소드들과 구분하고 통합적으로 관리하기 위해서, 자바에서는 클래스와 유사하게 상속 가능한 타입이면서 구체적인 구현을 베제한 인터페이스(Interface)를 만들어 메소드 추상화를 통해 상속 시킬 공개 메서드를 통합적으로 관리하게 하였다.
이것이 왜 인터페이스(Interface)를 구현하고 추상 메소드를 정의하여 implements를 통해 재정의 규약을 설정하는, 우리가 지금까지 배우고 써먹었던 객체 지향적 프로그래밍의 이유가 바로 이것이다.
interface InterProcess {
public void work(); // 추상 메소드
}
class Process implements InterProcess {
private void init(){} // 은닉 메서드
private void process(){} // 은닉 메서드
private void release(){} // 은닉 메서드
public void work(){ // 공개 메서드 + 메소드 구체화
init();
process();
release();
}
}
public class Main {
public static void main(String[] args) {
InterProcess c = new Process(); // 인터페이스 역시 상위 클래스 타입 처럼 이용될 수 있다
c.work();
}
}
인터페이스 역시 자바 참조 타입의 다형성의 원리를 그대로 이용할 수 있다.
그래서 Process 타입의 객체를 InterProcess 인터페이스 타입으로 할당 할 수 있는 것이다.
그리고 캡슐화를 통해 최소한으로 줄인 공개 메서드를 인터페이스의 추상 메소드와 연동이 되면서, Process 클래스의 기능 동작을 하는데 있어 업캐스팅으로 인한 멤버 제한과 같은 제한 요소는 없어진다.
이밖에 인터페이스를 이용하면 실질적 클래스 간의 의존 관계가 없어지면서 기능 확장에 있어 제약이 줄어들게 된다.
추가적으로 자바의 인터페이스에 대해 자세하고 확실하게 알고 싶다면 다음 포스팅을 참고하길 바란다.
참고