OOP를 하면서, 무심코 지나쳤던 OOP의 특징들
들어가기전
일반적으로 OOP를 하는 개발자라면 패러다임에 맞게 객체를 독립적인 모듈로 바라보고 재사용해야 한다는 점은 누구나 알 것이다. 하지만 여러 요인들로 인해 객체를 재사용하여 프로그래밍을 하는 것보다는 잘 돌아가는 프로그램을 추구할 것이다.
무엇보다 독립적인 모듈화에 대한 부분은 OOP의 특성을 이해하고 정리가 되었다면 많은 부분을 해소할 수 있을거라 생각한다. 본 포스팅은 OOP를 하면서 무심코 지나쳤던 OOP의 특징들, 흔히 “캡추다산정”이라 불리는 캡슐화, 추상화, 다형성, 상속성, 정보은닉의 개념에 대해 정리하고 생각하는 시간을 가지려 한다. 객체지향, OOP의 개념적인 부분은 아래 링크를 참고하기 바란다.
학습 목표
- 정보은닉에 대한 이해
- 캡슐화에 대한 이해
- 추상화에 대한 이해
- 상속성에 대한 이해
- 다형성에 대한 이해
Information hiding(정보은닉) - 보호하다.
정보은닉은 외부에서 데이터 접근을 제한한다는 개념이다.
언어적 측면에서 정보은닉은 접근 제한자를 통해 은닉의 정도를 구현할 수 있다. 따라서 클래스의 데이터 또는 메소드에 접근 제한자를 기술하여 외부에서 데이터 접근을 제한할 수 있다.
- private : 자기 클래스 내부의 메소드에서만 접근 허용
- default : 같은 패키지에 있는 객체만 접근 허용
- protected : 같은 패키지에 있는 객체 또는 상속받은 자식 클래스에서 접근 허용
- public : 모든 접근을 허용
프로그래밍에 있어 이러한 접근 제한자들은 클래스 외부로부터 제한된 접근 권한을 제공하며 원하지 않는 외부의 접근에 대해 내부를 보호하는 작용을 한다. 이렇게 함으로써 이들 부분이 프로그램의 다른 부분들에 영향을 미치지 않고 변경될 수 있다.
예를 들면, 클래스를 선언하고 그 클래스를 구성하는 객체에 대하여 “public” 또는 “private” 등으로 정의한다고 가정하자. 이 경우 “public”으로 정의된 함수와 데이터는 외부에서 사용할 수 있으며, “private”로 선언된 경우는 외부에서 제어할 수 없고 오로지 클래스 내부에서만 사용된다.
따라서 정보은닉은 클래스의 핵심적인 데이터를 외부로부터 접근을 제한하여 데이터의 무결성을 보장해주고, 더 나아가 프로그램의 기능에 대한 신뢰성이 향상된다.
- 데이터 무결성 보장
- 기능의 신뢰성 향상
*데이터 무결성(Data integrity)은 컴퓨팅 분야에서 완전한 수명 주기를 거치며 데이터의 정확성과 일관성을 유지하고 보증하는 것을 가리키며 데이터베이스나 RDBMS 시스템의 중요한 기능이다.
사실상 정보은닉은 은닉을 통해 직접 데이터를 제어한다는 개념으로, 가장 단순하고 기초적인 메커니즘이지만 이 메커니즘을 잘 이해하여 객체에 활용한다면 독립적인 모듈화를 실현할 수 있다. 무엇보다 이 객체는 OOP에서 가장 중요한 단위로써, 어떻게 객체를 잘 정의하여 독립적인 모듈화를 실현할 수 있는지에 대한 부분은 캡슐화를 살펴보면 알 수 있다.
Encapsulation(캡슐화) - 묶고, 숨기다.
캡슐화의 정의는 언어 측면과 기술 측면으로 정의할 수 있다.
- 언어 측면 : 객체의 속성(data fields)과 행위(methods)를 하나로 묶어져 있는 언어적 구조
- 기술 측면 : 일부 구현 내용을 외부에 감추어 은닉
이처럼 두 가지 측면은 다른 의미를 다루고 있지만, 모듈의 재사용성 향상이라는 하나의 방향성을 지향하고 있다.
먼저 언어적 측면으로는 흔히 클래스를 정의하고 내부에 데이터와 메소드를 정의하고, 정의된 클래스를 프로그램의 독립적인 단일 단위로 바라본다는 개념이다. 이는 클래스 기반 언어의 언어 구조 특징으로, 이를 캡슐화라 정의한다.
하지만 단순히 클래스 기반 언어의 구조 특징을 캡슐화라 정의할 수 없다. 캡슐화란 클래스 기반 언어의 구조적인 개념에 국한되어있지 않고, 다른 프로그래밍 언어에서도 캡슐화란 개념이 나오기 때문이다. 이러한 이유는 캡슐화란 일부 구현 기능의 은닉 정도를 제어한다는 측면으로 바라보는 개념이 있기 때문이다.
정보은닉 그리고 모듈화
이러한 캡슐화의 기술적 측면에선 정보은닉 개념을 통해 기능의 단순화라는 방향성에 초점을 두고 있다.
다음 그림을 보면 Alam
클래스 내부에 일부 데이터들을 접근 제한자를 지정하여 외부로부터 직접적인 데이터 접근을 제한하고 있다. 이처럼 클래스 내부에서 실제적으로 데이터를 조작하는 메소드 또는 핵심 데이터를 은닉하고, 직접 호출하지 않는 대신, 필요에 따라 사용할 수 있는 기능을 외부에 공개한다.
결과적으로 개발자는 필요한 메소드만 호출하여 데이터를 얻을 수 있고, 동시에 다른 기능, 데이터에 영향을 미치지 않고 변경될 수 있다. 무엇보다 데이터를 조작하기 위한 세부적인 구현 기능을 외부의 접근으로부터 제한하고 클래스 내부 안에서 동작하기 때문에, 객체라는 단위를 보다 하나의 독립적인 단위로 바라보고 결과적으로 객체의 기능을 재사용성이 향상된다.
이처럼 독립적인 모듈화를 기본 메커니즘을 따르고, 동시에 객체의 기능을 적용하는 과정에 더 쉽게 접근하기 위해 일부 데이터 접근을 제한하는 정보은닉이라는 개념을 사용한다.
흔히 캡슐화와 정보은닉(Information Hiding)에 대해서 같은 개념으로 정의하는데, 캡슐화는 모듈화(modularity)의 중점으로 둔 개념이고, 정보은닉은 직접적인 데이터 접근에 대한 개념을 중점으로 둔 개념이다. 따라서 캡슐화의 독립적인 모듈화를 실현하기 위해 정보은닉을 사용하기 때문에 잘 된 캡슐화엔 정보은닉의 개념이 포함하고 있다.
무엇보다 캡슐화는 객체의 독립적인 모듈화를 통해 프로그래밍을 유연하게 만들었다면, 설계 측면에서 객체를 분리라는 개념을 통해 객체와 객체의 유연한 관계를 형성하여 유연한 프로그래밍을 할 수 있는데 이를 추상화라 한다.
Abstract(추상화) - 분리하다.
추상화란 대상을 하나의 공통된 기준으로 분리한다는 개념이다.
설계적 측면에서의 추상화란 여러 모듈의 공통적인 성향을 묶고, 세부적으로 분리하여 관리를 쉽게 만들고 더 나아가 공통 특징을 띈 객체의 확장성을 향상한다는 방향성을 제시하고 있다.
이 추상화 설계의 첫 단계는 객체들의 공통된 특성을 띤 기능을 파악하는 데 있다. 그다음 추상적인 목적에 맞는 공통 기능은 묶고 비공통 기능은 제거하여, 추상화 객체에 추상화 메소드로 정의한다. 이를 구현하는 실제 객체에선 객체에 특성에 맞게 추상화된 기능에 대해 상세히 정의하여 구현한다.
결과적으로 추상화 클래스는 특정 목적을 띄고 있고 공통 기능을 포함하고 있는 집약체이기 때문에, 프로그램의 기능 명세의 역할을 하고 있다. 추상화 클래스를 구현하는 실제 객체는 이 기능 명세에 따라 프로그래밍을하게 되므로, 초기 설계에 대한 고민을 덜게 됨으로 프로그래밍의 복잡성은 감소한다.
또한, 설계 구조는 추상화 객체와 관련된 실제 구현 객체들이 묶여 있으므로 자연스레 응집도가 높아지게 되고 객체 관리가 쉬워진다. 이러한 추상화 설계 방식은 실제 구동되는 객체의 세부 기능을 외부로부터 감추고 추상화 대상에 의존하는 프로그래밍은 관계를 느슨하게 만들기 때문에 결합도를 낮춰지게 되고 궁극적으로 프로그래밍을 유연하게 할 수 있게 된다.
- 기능 명세의 역할
- 프로그래밍의 복잡성 감소
- 외부로부터 세부 기능 은닉
추상화 vs 캡슐화
유연한 프로그래밍을 실현할 수 있는 가장 큰 이유는 하나의 공통 대상으로 추출하고 세부 기능을 은닉한다는 기본 메커니즘을 따르기 때문이다. 이 추상화의 메커니즘은 다소 캡슐화와 유사하므로 혼동을 빚어낼 수 있는 요지가 다분하다. 따라서 각 개념의 차이를 명확히 짚고 넘어가야 한다.
이 차이는 은닉 레벨, 수준, 초점에 따라 살펴볼 수 있다.
이 차이점들은 객체를 바라보는 관점의 차이에서 비롯된다. 캡슐화는 객체의 단위에 초점을 맞췄다면, 추상화는 설계를 초점으로 객체 간 구조를 바라보았기 때문이다. 결과적으로 추상화와 캡슐화는 다르지만, 최종적으로 응집도를 높이고 결합도를 낮춰 유연한 프로그래밍을 지향한다는 방향성은 같다.
이 유연한 프로그래밍은 구체적인 대상에 의존된 코드와 추상적인 대상에 의존한 코드를 비교해보면 그 이유에 대해 명확히 알 수 있다.
구체적인 대상 vs 추상적인 대상
class MyPet{
AnimalAction service = new AnimalAction();
public void bark(){
service.bark("cat"); // 구체적인 대상을 코드에 명시
}
}
class AnimalAction{
private Cat cat = new Cat();
private Dog dog = new Dog();
public void bark(String animal){
// 구체적인 대상에 따라 데이터 조작
switch(animal){
case : "cat"
this.cat.bark(); // 고양이 울음 소리 mew!
break;
case: "dog"
this.dog.bark(); // 개 울음 소리 bowwow!
break;
}
}
}
다음 코드를 보면 bark()
이라는 메소드를 구현하기 위해 스위치 문을 통해 각 동물의 이름을 전달받고 해당 이름에 맞는 동물 클래스의 bark()
메소드를 호출했다. 이처럼 추상적인 대상이 아닌 구체적인 대상에 의존하여 데이터 조작을 하게 된다면 여러 문제가 생긴다.
// 새로운 동물 "새" 데이터 조작 추가
case: "brid"
this.brid.bark(); // 새 울음 소리 chatter!
break;
단편적인 예를 들어 Bird
클래스를 추가한다고 치자. 이때 기능이 제대로 동작하기 위해선 bark()
메소드 뿐만 아니라 데이터 조작에 관련된 모든 기능에 직접 추가해줘야 한다. 결과적으로 코드가 복잡해질뿐더러 객체간 강한 결합도가 형성되어 기능의 확장성이 어려워진다.
구체적인 대상 → 추상적인 대상
이 문제들은 결과적으로 구체적인 대상이 아닌 추상적인 대상에 의존해야 한다는 결론에 다다르게 된다.
class MyPet{
AnimalAction service = new AnimalAction();
Animal animal = new Bird(); // 추상 클래스 인스턴스화
public void bark(){
service.bark(animal); // 추상 메소드 전달
}
}
class AnimalAction{
public void bark(Animal animal){
// 추상 메소드 호출
animal.bark(); // 새 울음 소리 chatter!
}
}
다음 코드를 보면 AnimalAction
클래스에서 Animal
이라는 추상 클래스를 전달받아 bark()
메소드를 호출하였다. 이전보다 코드가 깔끔해지고 명확해졌고 더 놀라운 사실은 추상화를 통해 별도의 기능의 코드 수정 없이 새로운 동물인 Bird
를 추가했다는 것이다. 이 결과는 JVM(Java Virtual Machine)에 의해 최종적으로 호출될 메소드가 동적으로 정해졌다는 걸 알 수 있다.
자세히 살펴보자면 컴파일 시점엔 bark()
메소드는 추상화 메소드 Animal.bark()
를 바라보고 있지만, 실제 프로그램이 호출되는 런타임 시점에는 Bird.bark()
를 바라보게 된다. 따라서 실제 구현되는 Bird.bark()
메소드는 컴파일 시점엔 은닉되고 실제 데이터 처리되는 코드에 별도의 추가, 수정 작업 없이 우아하게 해결할 수 있게 된다.
Compile time : 고급 언어(프로그래밍 언어)를 기계어로 변경하는 과정의 시간
Runtime : 어떤 프로그램이 실행되는 동안의 시간
정리하자면 추상화는 분리라는 개념을 통해 추상적 대상을 추출하고, 이 대상에 의존하는 방식은 기존 기능의 수정 없이 클래스를 확장할 수 있다. 이 분리의 개념과는 반대로 OOP에선 객체를 전달받아 객체를 확장해 프로그래밍을 좀 더 쉽게 할 수 있는데 이 개념을 상속이라 한다.
Inheritance(상속성) - 관계를 맺다.
상속이란 기존의 클래스를 전달받아서 새로운 클래스를 생성한다는 개념이다.
언어적인 측면에선 클래스의 속성과 행위를 하위 클래스에 물려주거나, 상위 클래스에서 물려받는 것을 뜻하고, 이 과정에서 기존의 객체와 새로운 객체 간의 관계가 형성된다. 관계가 형성된 하위 클래스는 상위 클래스가 가지고 있는 모든 데이터와 메소드를 사용할뿐더러 자신만의 데이터와 메소드를 추가로 덧붙임으로써 새로운 형태의 클래스로 발전할 수 있다.
이 때문에 비슷한 기능을 구현할 필요가 없고, 상속이라는 개념을 통해 기능을 재사용함으로써 별도의 코드를 추가할 필요가 없이 기능을 구현할 수 있다. 이는 결과적으로 중복되는 코드가 줄어들게 되고 객체를 좀 더 범용성 있게 사용할 수 있다.
class Object{}
class String extends Object{}
class int extends Object{}
예를 들자면 Object로 정의한 데이터에 String, int 객체가 쓰여도 문제가 되지 않는다. 이 말인즉슨 Object 객체를 상속받은 String과 int는 각기 다른 새로운 클래스이지만 근본적으로 Object 클래스를 상속받아 확장된 클래스이기 때문에 Object 객체의 기본적인 기능을 포함하고 있다는 의미와 같다.
여담이지만 상위 클래스에 하위 클래스의 데이터를 정의하는 데 있어 데이터형을 맞춰주는 캐스팅(Castting) 작업이 필요가 없고, 자동으로 오토박싱(Auto Boxing) 되어 데이터 조작을 수행할 수 있다는 점을 알 수 있는 대목이다.
본론으로 돌아와서 이 메커니즘은 객체 간의 관계의 개념이 중요하다. 따라서 상속은 이 관계를 만드는 설계 방식을 배제할 수 없다. 일반적으로 설계 방식은 객체들의 공통점을 추출하여 상위 객체에 정의하는 방식으로써 이러한 과정에서 자연스레 의존 관계가 형성된다.
이처럼 기존 클래스에 상위 클래스를 추출하는 설계 방식을 소프트웨어 디자인 측면에선 일반화라 하고 이와 상반된 개념을 특수화(상세화)라 한다.
일반화와 특수화를 통한 분리와 포함
이 일반화와 특수화는 설계 관점으로 객체를 분리, 포함에 따라 객체 구조를 정의하는 설계 방식이다.
- Generalization(일반화)
- Specialization(특수화, 상세화)
먼저 일반화는 기존 클래스들의 공통점을 추출하여 상위 클래스를 만드는 설계 방식이다. 설계 방식은 다음과 같다.
먼저 일반화할 클래스 Dog
, Bird
의 공통된 데이터를 추출하고, 이 공통된 데이터를 새로운 클래스 Pet
에 정의한다. 그다음 Pet
에 정의된 공통된 데이터는 Dog
, Bird
에서 제거하고 Pet
을 상속받는다. 자연스레 상속관계에 의해 상위 클래스(Pet
)와 하위 클래스(Dog
, Bird
) 관계가 형성된다.
이 때문에 상위 클래스엔 필요한 데이터와 메소드만 포함하고 있고, 이 일반화 관계에 참여하는 클래스는 서로 밀접하게 결합한 형태를 띠고 있다. 설계 순서는 하위 클래스를 먼저 정의하고 정의된 클래스들의 공통점을 추출하는 상향식(Bottom-Up) 방법을 통해 설계된다.
반면에 특수화란 일반화와 반대되는 설계 방식으로써, 하향식(Top-Down) 방법으로 접근하여 기존 클래스를 세부화하여 하위 클래스를 만드는 것이다.
기존의 Pet
클래스에 정의된 데이터들이 하위 클래스들에 무엇이 필요하고 필요 없는지 판단한다. 이에 따라 식별된 공통 데이터를 Pet
에 정의하고 비공통 데이터는 제거한다. 하위 클래스로 정의할 Dog
, Bird
클래스는 Pet
클래스를 상속받고 Bird
클래스엔 제거되었던 fly()
메소드를 정의한다.
따라서 각각의 하위 클래스는 상위 클래스의 모든 데이터를 포함하고 있고 필요에 따라서 기능은 재정의하거나 추가하여 구현할 수 있다. 일반적으로 상위 클래스의 내부 복잡성이 하위 클래스에서 구현된다.
근본적으로 이러한 설계 방식을 통해 형성된 하위 클래스는 private로 정의된 데이터 이외의 모든 메소드와 특성을 상위 클래스로부터 상속받게 된다. 상속의 본질적 특성은 객체 간 강한 결합성을 띄게 되므로 접근 제한자를 지정하여 상속 정도를 제어해야 한다.
특히 상속 과정에서 하위 클래스는 상위 클래스의 메소드를 재정의할 수 있고, 이 때문에 상속받은 메소드는 각 하위 클래스들에서 각기 다른 처리 방식을 취할 수 있다. 이러한 다양한 처리 작업을 할 수 있는 개념을 다형성이라 한다.
Polymorphism(다형성) - 다양한 형태를 제공한다.
다형성이란 하나의 이름으로 다양한 처리 작업을 할 수 있는 개념이다.
본래 Polymorphism의 단어는 many(많은) + form(형태)의 기원으로 “다양한 형태”의 뜻을 지니고 있고, 프로그래밍에서도 이와 같은 의미로 정의한다.
프로그래밍 측면에서 다형성이란 동일한 이름으로 상이한 기능을 구현하여 하나의 메시지가 객체에 따라 다르게 응답이 가능하다는 메커니즘으로써, 이는 실행되는 시점에 따라 정적일 수 있고 동적일 수 있고 종류에는 오버로딩과 오버라이딩이 있다.
- Overloading(Compile time)
- = Static Polymorphism
- = Compile Polymorphism
- Overriding(Runtime)
- = Dynamic Polymorphism
- = Runtime Polymorphism
오버로딩은 컴파일 시점에 호출할 메소드가 미리 결정되고, 동시에 컴파일 시점에 메모리가 할당된다. 때문에 오버로딩을 정적 다형성 또는 컴파일 다형성이라 한다.
하지만 오버라이딩은 런타임 시점에 호출될 메소드가 정해지기 때문에, 런타임 시점에 메모리가 할당된다. 이로 인해 동적 다형성 또는 런타임 다형성이라 한다.
다형성의 규칙
- Overloading
- = Same Name + Different Parameter
- Overriding
- = Same Name + Same Paramter
정적 다형성인 오버로딩은 같은 이름, 다른 매개 변수 타입과 개수를 정의하여 구현한다. 특히 오버로딩은 같은 클래스에서 같은 이름을 가진 메소드를 구현해야 한다. 이러한 특징을 두고 오버로딩을 수평적 다형성이라 한다.
- 클래스 내부에서 메소드 재정의
- 다른 매개 변수 타입
- 다른 매개 변수 개수
- 다른 매개 변수 순서
이와 달리 오버라이딩은 상위 클래스로부터 상속받은 메소드를 하위 클래스에서 재정의한다는 개념으로써 반드시 상위 클래스의 메소드와 같은 이름, 매개 변수여야 한다. 이처럼 위에서 상속받아 아래에서 재정의하는 방식을 빗대어 수직적 다형성이라고도 한다.
- 상속에 의한 메소드 재정의
- 재정의할 메소드와 같은 메소드
오버로딩 vs 오버라이딩
앞에 설명에 따르면 오버로딩은 컴파일 시점에 메모리를 할당된다는 단점은 오버라이딩보다 비교적 우아하지 않아 보일 수 있지만 이와 같은 생각은 옳지 않다. 두 다형성이 지향하는 방향성과 장단점이 있으므로 상황에 맞게 이를 구분하고 사용해야 한다.
먼저 오버로딩은 일반적으로 인라인 코드로 구현하기 때문에 오버라이딩보다 잠재적으로 빠르고, 공통 기반의 상위 클래스를 고려하지 않아도 되므로 쉽게 구현할 수 있고, 부분적으로 메소드의 재정의가 필요할 때 적절하다.
반면 오버라이딩은 상속에 의한 재정의이라는 개념은 여러 객체를 우아하게 처리할 수 있다는 장점이 있다. 또한, 잠재적 실행 코드가 비교적 적다. 이 이유는 오버라이딩은 오직 하나의 다형적 메소드만 필요한 데 반해 오버로딩은 적어도 두 개 이상의 다형적 메소드가 필요하다.
이처럼 두 다향성은 지향하고 있는 방향성이 자체가 다르다. 이 때문에 상황에 따라 판단하고, 오버로딩과 오버라이딩을 적절히 사용하여 개발해야 한다.
마무리
OOP의 특징의 개념들은 각각의 개념이 아닌 상호 작용하고 있는 개념이다. 물론 각 특징은 객체를 바라보는 관점과 방식은 달리 보일 수 있지만, 원초적으로 독립적인 모듈화에 초점을 두고 객체를 좀 더 독립적인 모듈로 바라보고 이를 재사용하기 위한 원칙들을 제시하고 있다.
따라서 이 원칙들을 준수하며 프로그래밍을 해야 하고, 하나의 모듈을 만드는 데 있어 어떻게 하면 독립적인 모듈로 만들 수 있는지 고심해야 한다.
마지막으로 OOP의 전반적인 방향성을 잘 담고 있는 마틴 파울러가 작성한 TellDontAsk 글을 소개하며 글을 마친다.
참고
difference-between-runtime-polymorphism-and-compile-time-polymorphism
generalization-specialization-and-inheritance
Software Design : Inheritance, Generalization, Specialization, Association, Aggregation, Composition, Abstraction