[Java][OOP] 객체지향의 특성들
개요 - 천리길도 한걸음부터!
웹 프로그래밍을 위해 공부하고, 팀플도 하면서 느끼게 되는 것은 “역시 기초가 탄탄해야한다”라는 것이다. 오늘자 기준으로 이제 나는 Spring이라는 나로서는 처음 접해보는 기술을 공부하게 될텐데, 처음에 DI(Dependency Injection)와 IOC(Inversion of Control)에 대해 배우게 되었다. 그런데 이 개념들을 자세히 살펴보고 곰곰히 생각해보니 아무래도 이 개념들도 객체 지향의 특성들을 잘 활용한 것 같다는 생각이 들었다. 또한, 강의 선생님께서 말씀하시길 스프링은 다형성 천지라고 하셨다. 다형성도 객체 지향의 특성 중 하나이다.
또한, 팀플을 할 때에도, 규모가 조금만 더 커지더라도 코드가 복잡해져서 제대로 리팩토링을 하지 않으면 가독성도 저하될 뿐만 아니라, 유지보수에도 어려움을 겪고, 심지어는 나중에 기능이 확장되면 어떻게 기능을 추가해야할지 감도 안오기 마련이었다.
MVC 패턴을 배울 때도 코드의 양이 조금만 늘어나도 구조가 복잡해지는 것을 느꼈는데, 이 때 Command Pattern, Factory Pattern이라는 디자인 패턴을 공부하고 적용해보니 한층 더 코드 구조가 깔끔해졌었다. 또한, 이러한 디자인 패턴들도 사실은 다형성, 캡슐화 등 객체 지향 프로그래밍의 여러 특성들이 사용되었다는 것도 알게 되었다.
이러한 이유로, 오히려 기초가 탄탄해야 한다는 것을 요즘 절실히 느끼고 있다. 따라서 비록 강의 시간에는 진도를 빠르게 나가고 있지만, 앞으로 배울 내용들을 제대로 이해하기 위해선 역설적으로 기초를 더 다지는 게 좋을 것 같다는 생각이 들었다. 그래서 이 페이지에서는 객체 지향 프로그래밍과 그 특성들에 대해 정리해보겠다. 원래 객체와 객체 지향 프로그래밍이란 개념은 특정 언어에 종속적인 개념이 아니지만 나는 개인적으로 자바를 중점적으로 공부하고 있기에, 객체와 객체지향에 관한 글들에서 작성할 예제들은 모두 자바로 작성할 예정이니 참고!
객체와 객체지향
객체(Object)는 현실 세계에 존재하며, 다른 대상들과 구별되는 독립된 실체를 의미한다. 자동차, 사람, 스마트폰 등 우리 주변에 있는 모든 것들이 다 객체이다.
객체 지향 프로그래밍(OOP, Object Oriented Programming)은 프로그램을 구성하는 여러 요소들을 각각 객체로 구현한 후, 이러한 서로 독립된 객체들을 조합하여 하나의 프로그램을 만들도록 하는 패러다임이다. 즉, 마치 서로 독립적인 부품들을 조합하여 자동차, 포크레인 등 하나의 기계로 만드는 것과 같다고 보면 된다.
현실 세계에 존재하는 대상을 소프트웨어의 객체로 설계하는 것을 객체 모델링(Object Modeling)이라 한다. 보통 현실 세계에서의 객체에는 크게 속성(state)과 동작(behavior)으로 구성되어 있다. 스마트폰을 예로 들면, 스마트폰에 기록된 전화번호, 모델명 등의 “정보”가 속성이 되며, 전화하기, 메시지 전송, 앱 사용하기 등의 “행위”가 동작이 된다. 소프트웨어에서의 객체에서는 속성을 필드(field)로, 동작을 메서드(method)로 정의한다.
// 의사코드
public class SmartPhone {
// 속성 (필드)
String brandName = "샘숭";
String phoneNumber = "000-1111-2222";
// 동작 (메서드)
void makeACall(String toWho) {
System.out.println(toWho + "님께 전화를 합니다.");
}
}
에제 1-1
객체지향 프로그래밍의 주요 특징들
객체지향 프로그래밍의 주요 특징으로는 크게 4가지로 알려져 있다.
- 캡슐화 (Encapsulation)
- 상속 (Inheritance)
- 다형성 (Polymorphism)
- 추상화 (Abstraction)
각각의 특징들에 대해 하나씩 자세히 살펴보겠다.
캡슐화 (Encapsulation)
캡슐약을 떠올려보자. 캡슐이 안의 내용물을 감싸는 형태로 되어 있을 것이다. 이 약을 복용하는 사람들은 이 약의 내부를 보지 못한다. 그럼에도 사실 이 약의 내부를 볼 필요도 없고, 캡슐이 없었다면 내용물이 이곳저곳 흩어져서 불편했을 것이다. 또한 캡슐이 없었다면 외부의 오염물질을 막을 방법이 없어 내용물이 금세 오염될 것이다.
객체 지향에서의 캡슐화도 이와 비슷한 맥락을 가지고 있다. 캡슐화는 서로 관련있는 필드와 메서드들. 즉 데이터와 기능들을 하나의 클래스라는 캡슐로 묶어 이들을 외부로부터 보호하는 것을 의미한다.
나는 캡슐화에 대해 접할 때 흔히 자동차, 커피머신 등의 기계로 예를 드는 것을 많이 보았다. 운전자는 자동차의 내부 부품과 어떻게 작동하는지 그 원리를 알 필요가 없다. 그래서 자동차의 내부 구조는 운전자로부터 노출되지 않도록 가리는 것이 좋다. 만약 운전자가 자동차 내부 부품에 손이 닿도록 설계되어 있다면 조금만 잘못 건드려도 자동차를 망가트릴 수 있기에 캡슐화는 아주 중요하다고 볼 수 있다.
소프트웨어에서의 캡슐화도 마찬가지이다. 본디 객체지향에서의 객체는 객체 설계 5원칙인 SOLID에서 “단일 책임 원칙”을 말하는 SRP에서도 알 수 있듯, 하나의 객체는 하나의 기능만 맡도록 하는 것이 원칙이다. 쉽게 말하자면 앞서 객체 지향은 여러 아주 작은 부품들을 만들고 그 부품들을 조립하는 것이라고 하였다. 그렇다면 부품에 해당하는 객체는 당연히 하나의 기능만 맡도록 하는 것이 좋을 것이다. 캡슐화는 이러한 객체의 독립성을 보장해준다. 외부로부터 접근하여 객체 내부의 속성이나 기능이 변하게 된다면 의도한 대로 프로그램이 작동되지 않을 수 있기에 캡슐화를 잘해야한다.
캡슐화를 통해 얻게 되는 이점들을 정리하면 다음과 같다.
- 객체의 독립성을 보장한다. 이를 통해 외부로부터의 데이터 및 기능 오염을 방지할 수 있다.
- 객체의 독립성이 보장되므로, 둘 이상의 객체 간의 의존도를 최소화할 수 있다. 만약 둘 이상의 객체 간의 의존도가 높으면 한 객체의 코드를 수정하면 다른 객체의 코드도 수정해야할 수 있으므로, 이 경우 유지보수적인 관점에서도 좋지 못하다.
자바에서는 캡슐화를 구현하기 위해 2가지 방법을 제공한다.
- 접근 제한자(Access Modifiers)
- public, protected, private, default
- Getter, Setter 메서드
접근 제한자의 경우, 패키지의 외부로부터도 접근 가능하게 할지, 다른 클래스로부터의 접근을 가능하게 할지를 접근 제한자를 이용하여 결정할 수 있다. 같은 클래스 내부에서만 접근 가능하도록 하는 private부터 모든 접근을 제한하지 않는 public까지 접근 제한의 강도를 원하는 대로 조절할 수 있어 어느 정도까지 캡슐화를 할 것인지도 개발자가 조절할 수 있다.
Getter, Setter 메서드의 경우, 클래스 내 필드값을 외부로부터 직접 접근하는 것을 막아 잘못된 데이터가 입력되는 것을 방지해준다. 이것도 클래스 내 데이터에 대한 캡슐화라 볼 수 있겠다. 어떤 특정 필드에 대해 getter 메서드만 사용하면 그 필드값은 read only, 즉 읽기 전용이 되어 외부에서 해당 필드의 값을 수정하는 것을 방지할 수 있다. 또한, setter 메서드를 이용하여 외부로부터 새로운 값을 전달받을 때 해당 값이 올바른 값인지 그 유효성 검사를 먼저 할 수 있다는 장점도 있다.
class MyCar {
private int fuel = 100;
private String brandName = "현다이";
// getter 메서드만 정의하여 해당 필드값을 읽기 전용으로 만들 수 있다.
public String getBrandName() {
return brandName;
}
public void setFuel(int fuel) {
if (fuel < 0 || fuel > 100) {
System.out.println("연료량이 0 미만 100 이상일 수 없습니다.");
return; // 잘못된 범위의 값 입력 방지.
}
this.fuel = fuel;
}
}
예제 2-1
상속 (Inheritance)
현실 세계에서 부모가 자식에게 여러 유산들을 상속해주듯이, 프로그래밍에서의 상속도 이와 마찬가지의 의미를 가진다. 상속은 부모 클래스의 모든 기능들을 자식 클래스에게 물려주는 것을 의미한다. 상속을 이용하면 부모 클래스의 기능을 그대로 가져다 쓸 수 있기에 코드의 재사용성을 높일 수 있다. 또한, 서로 똑같은 기능이 별도의 여러 클래스에 흩어져 있을 경우, 코드가 중복될 뿐만 아니라 일괄적으로 해당 기능에 수정이 필요할 때 일일이 각 클래스에 찾아가 해당 코드를 고쳐야할 것이다. 하지만 공통의 기능을 부모 클래스 내부에 정의한 후, 이를 여러 클래스들이 상속받도록 하면 코드의 중복성도 제거할 수 있고, 공통의 기능을 하는 메서드에 수정이 필요하면 부모 클래스에서만 수정하면 되기에 편리하다는 장점이 있다.
앞서 언급한 점들에 더해 상속의 장점들을 정리해보면 다음과 같다.
- 코드 재사용성 (Code Reusablility) : 부모 클래스와 중복되는 코드를 또 작성할 필요없이 그저 부모 클래스를 상속하기만 하면 기능 그대로 사용할 수 있다. 부모 클래스의 기능이 필요하되 자식 클래스에서 이를 조금 다르게 커스텀해야할 때에도 이미 부모 클래스에 기반 코드가 있으니 이를 오버라이딩하여 일부만 커스텀해주면 되는 편리함도 있다.
- 유지보수성 확보 : 만약 중복되는 코드들을 이곳저곳에 흩어져서 작성한 경우, 해당 기능에 수정이 필요할 때 일일히 이들을 찾아 모두 똑같이 수정해줘야한다는 번거로움이 있다. 또한 사람은 항상 실수를 하기 마련이기에 중복되는 코드들에서 모두 똑같은 부분을 수정한다는 보장도 없다. 실수로 전혀 엉뚱한 곳을 건드릴 수도 있기 때문. 하지만 하나의 부모 클래스를 여러 자식 클래스들이 상속받도록 하는 구조로 만든다면 부모 클래스에 기능 수정 필요 시 부모 클래스 내 코드만 수정해도 모든 자식 클래스들에서도 같은 수정 사항을 반영받아 일관적인 유지보수성을 확보할 수 있다.
- 유연성 확보 : 하나의 부모 클래스를 상속받는 자식 클래스는 그 수가 여러 개일 수 있다. 이 경우
Parent parent = new ChildOne();
과 같이 부모 타입 객체 참조 변수에 자식 객체를 담는 것이 가능해진다. 이것이 중요한 이유는, 다른 자식 객체로 바꿔 다른 기능을 사용해야할 일이 생길 때 그저Parent parent = new ChildTwo();
와 같이 새로운 자식 객체로 바꾸기만 하면 되고, 그 외에는 다른 코드들을 건드릴 필요가 없게 된다. 이는 최소한의 코드 수정만으로도 다른 기능으로 유연하게 교체할 수 있음을 시사한다.
하지만 상속에는 단점들도 많다고 한다.
- 강한 결합도 (Tight Coupling) : 부모 클래스 내 일부 기능을 수정하면 자식 클래스에도 바로 영향이 간다. 즉, 두 클래스 간 각자의 독립성이 떨어져 한 쪽의 변화가 다른 한 쪽에게 예기치 않은 변화를 줘 예상 밖의 버그, 오류가 발생할 수도 있다. 또한, 런타임 환경, 즉 앱 실행 도중에 하나의 기존 자식 클래스가 다른 부모 클래스를 상속받도록 할 수 없다. 이미 코드 상에서 특정 부모 클래스만을 상속받도록 고정시켰기 때문이다. 반면, 상속 대신 하나의 인스턴스를 메서드를 통해 외부로부터 주입받아 사용하는 composition(합성)의 방식을 사용한다면 최소한의 코드 수정만으로도 런타임에 다른 기능을 하는 인스턴스로 바꿀 수 있기 때문에 상속보다 훨씬 더 유연한 개발이 가능해진다.
- 다중 상속에 의한 죽음의 다이아몬드 문제 (Diamond problem) : 다중 상속이 가능한 언어의 경우, 하나의 조상 클래스로부터 두 개의 자식 클래스들이 상속받을 수 있고, 이 두 클래스들을 동시에 상속받는 또 하나의 자손 클래스가 존재하는 소위 다이아몬드 형태의 상속 관계가 가능해진다. 이러한 다중 상속의 문제점은, 둘 이상의 다중 상속을 하는 부모 클래스들에서 같은 메서드명이 존재한다면 자식 클래스에서는 어떤 클래스의 메서드를 택해 오버라이딩 해야할지 모호해진다는 모호성이 발생하게 된다. 그래서 자바에서는 단일 상속만을 허용한다.
- 클래스 폭발 문제 (class explosion) : 상속은 부모 - 자식 간의 결합도가 높고, 클래스 간 관계가 컴파일 단계에서 이미 결정되었기 때문에 유연한 개발이 쉽지 않다. 이러한 이유로 어떤 경우의 수들을 모두 충족하기 위해 하나의 부모 클래스를 상속받는 자식 클래스들의 수가 그만큼 늘어날 것이다. 약간의 기능 추가 및 수정을 위해 또 상속을 해서 새로 작성해야하기 때문이다. 이렇게 필요 이상의 상속 받는 클래스들을 생성하는 것을 클래스 폭발 또는 조합 폭발(combinational explosion) 문제라고 부른다. 이러한 문제는 새로운 기능 추가 및 기존 기능 수정이 필요할 때 발생할 수 있다. 또한 자바처럼 단일 상속만을 제공하는 언어에서도 단일 상속의 한계를 피하기 위해 A가 B를, 또 B가 C를 상속받는 꼬리에 꼬리를 무는 구조가 발생할 수 있어 단일 상속 언어에 대해서도 클래스 폭발 문제는 여전히 존재한다. 한 편, composition 방식을 사용한다면 실행 도중에 언제든지 다른 의존 인스턴스로 바꿔 주입받아 사용할 수 있어 조금 더 유연한 개발이 가능하므로, 상속에 의한 클래스 폭발보다는 더 적은 클래스들만 가지고도 똑같은 기능을 구현할 수 있게 된다.
- 불필요한 기능 상속 : 예를 들어 마피아 게임을 만든다고 해보자. 마피아 게임의 캐릭터인 마피아, 시민, 경찰, 의사 등등 모두는 다 “사람”이라는 공통점이 있어 이를 Person이라는 부모 클래스로 만들고, 각 캐릭터들은 이 부모 클래스를 상속받아 구현한다고 해보자. 이에 따르면 Person 클래스에는 투표 기능인
vote()
, 채팅 기능인chat()
기능들을 정의해두면 모든 캐릭터들은 별도 구현없이도 해당 기능들을 그대로 사용할 수 있다는 이점이 있다. 하지만 만약 Person에 다른 사람을 죽이는 기능인kill()
을 추가한다면 이는 마피아에게는 필수 기능이지만 시민에게는 불필요한(있어서는 안되는) 기능을 상속받게 된다. 이처럼 상속은 특정 자식 클래스에게는 불필요한 기능까지 물려준다는 단점이 있다. - 캡슐화 위반: 상속은 캡슐화를 위반할 수 있다. 자식 클래스가 부모 클래스의 특정 메서드를 오버라이딩할 때, 부모 클래스에 속한 해당 메서드의 작동 방법을 모른 채 오버라이딩하면 예기치 않은 동작을 마주할 수 있다. 다음은 이에 대해 잘 알려져 있는 예제이다. 자바의 HashSet을 상속받아 기존 HashSet에 요소를 추가할 때마다 총 몇 번 요소가 추가되었는지 세어주는 기능을 추가한다고 가정한다.
package inheritance;
import java.util.Collection;
import java.util.HashSet;
public class MySet<E> extends HashSet<E> {
private int addCount = 0;
public int getAddCount() {
return addCount;
}
@Override
public boolean add(E e) {
++addCount;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
}
예시 3-1.
import inheritance.MySet;
import lombok.extern.slf4j.Slf4j;
import java.util.Arrays;
@Slf4j
public class Main {
public static void main(String[] args) {
MySet<Integer> mySet = new MySet<>();
mySet.addAll(Arrays.asList(1, 2, 3));
mySet.add(10);
log.info(mySet.toString());
log.info(String.valueOf(mySet.getAddCount()));
// 예상 출력 결과: 4, 실제 출력 결과: 7
}
}
예시 3-2.
위 코드를 실행해보면 출력 결과는 당연히 4가 나와야 하는데 실제로는 7이 출력된다. 왜 이런 걸까? 사실 HashSet의 부모의 부모 클래스인 AbstractCollection 클래스에는 addAll()
메서드가 다음과 같은 로직을 가지고 있다.
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
예시 3-3. AbstractCollection.addAll()
즉, addAll()
메서드 내부에 add()
메서드를 호출하는 구조로 되어있으며, 앞선 코드 3-1에서는 실제 요소를 추가하는 기능을 super
키워드를 통해 부모 클래스가 가지고 있는 기존 메서드를 호출하는 것으로 해결했기 때문에 이러한 문제가 발생하는 것이다.
이렇듯, 상속의 문제점은 오버라이딩할 때 간혹 부모 클래스의 코드를 알아야만 오류, 버그 없이 코드를 작성할 수 있기에 개발 경험을 해친다. 이러한 문제점은 자식 클래스가 부모 클래스의 코드를 알아야만 하는 문제에서 비롯되며, 이는 곧 부모 클래스의 캡슐화가 제대로 되어 있지 않았기에 발생하는 문제이다. 이로 인해 객체의 독립성을 해쳐 자식 클래스에도 영향을 끼치게 되는 문제가 발생하는 것이다.
이렇듯 사실 상속에는 이점보다는 단점이 많다. 그래서인지 자바의 창시자 제임스 고슬링(James Gosling)이 상속이란 개념을 만든 게 후회가 되었다고 말할 정도였다. 그래서 상속의 문제점을 해결하기 위해 “Favor composition over inheritance” 즉 “상속보단 합성을 선호하라”란 말이 자주 인용된다. 상속의 대부분의 문제점들을 composition이 대신 해결해줄 수 있기 때문이다. 이러한 상속의 문제점으로 인해 자바에서는 실무에서는 상속보다는 인터페이스를 더 자주 활용한다고 한다. 인터페이스는 추상 메서드들을 포함하고 있고, 이를 구현체가 구현하는 방식이기에 호출하는 쪽에서는 해당 기능이 어떻게 구현되어있는지 몰라도 문제없이 해당 기능을 사용할 수 있다는 이점이 있다. 즉, 인터페이스를 활용하면 바로 다음에 소개할 추상화 및 다형성을 이용할 수 있게 되고, 객체 간 결합도를 낮게 만들어 유연한 코딩이 가능하게 되기 때문에 인터페이스를 실무에서 많이 활용한다.
자바에서 상속을 이용하는 방법에 대해서는 “[Java] 상속 (Inheritance)” 글을 참고.
참고) Composition과 Aggregation link
하나의 객체 내에서 다른 객체를 참조하는 방법에는 두 가지가 있다. 하나는 객체 내 필드에 직접 new 키워드를 이용하여 다른 객체를 생성 및 참조하도록 하는 것이고, 또 다른 방법은 생성자 메서드나 setter 메서드를 이용하여 외부로부터 객체를 입력받아 필드에 저장하는 것이다. 전자를 composition, 후자를 aggregation이라 한다. 사실 중간에 다른 인스턴스로 교체한다든가 하는 유연성을 위해서는 후자가 좋다.
그러나 필자가 상속에 대해 조사해봤을 떄 상속의 대체제로 보통 composition만을 언급하였다. 여기서는 composition과 aggregation을 뭉뚱그려 composition이라고 부르는 것 같았다. 그래서 사실상 “상속 대신 aggregation을 사용하라“가 더 정확한 문구라 생각하지만 필자가 참고한 자료들에서는 모두 composition 용어만 사용했기에 이 글에서도 composition 용어만 사용하였다.
추상화 (Abstraction)
추상화는 어떤 객체들의 공통된 특성들 또는 본질만을 추출하는 것을 의미한다. 예를 들어 마피아 게임을 만든다고 할 때 마피아, 시민, 의사, 경찰들의 공통적인 특징은 “사람”이라는 것이다. 모두 낮에는 한 표씩 투표할 수 있으며, 각자 의견을 표출할 수 있다는 공통적인 특징을 가지고 있다.
// 의사 코드
public interface Person {
void vote(String toWhoName); // 누구에게 투표할지.
void speak(String say); // 낮에 자신의 의견을 표출.
}
예제 4-1
class Mafia implements Person {
private String role;
public Mafia() {
role = "mafia";
}
public void printMyRole() {
System.out.println(role);
}
}
class Citizen implements Person {
private String role;
public Citizen() {
role = "citizen";
}
public void printMyRole() {
System.out.println(role);
}
}
예제 4-2
자바에서는 추상화를 구현하는 요소로 추상 클래스(abstract class)와 인터페이스(interface)가 있는데, 주로 인터페이스가 자주 쓰인다고 한다. 위 코드는 마피아 게임에 등장하는 캐릭터들의 공통적인 특성들을 인터페이스를 이용하여 추상화한 것이다.
추상화라는 말 자체에도 내포되어 있듯, 추상화를 구현하는 인터페이스에서는 메서드의 구체적인 구현을 명시하지 않는다. 이러한 추상 메서드의 정의를 통해 다른 구상 클래스들이 이를 구현하도록 강제하여 구현을 위임하는 형태이다. 즉, 객체 지향 프로그래밍에서는 어떤 개념을 설명하는 “역할”과 이를 구현하는 “구현” 조차도 분리하고 있는 것을 볼 수 있다. 이러한 추상화의 특징은 다형성과 맞물려 유연한 코드와 변경 및 기능 확장에 유리한 프로그램 설계에 도움을 준다.
다형성 (Polymorphism)
다형성은 객체의 속성이나 기능의 사용 방법은 동일하나 상황, 맥락에 따라 그 실행 결과가 다르게 나오는 성질을 의미한다.
메서드 수준에서의 다형성에는 메서드 오버라이딩과 메서드 오버로딩이 있다.
class Person {
public void introduceMySelf() {
System.out.println("안녕하세요, 저는 사람입니다.");
}
}
class Mafia extends Person {
@Override
public void introduceMySelf() {
System.out.println("안녕하세요, 저는 선량한 시민입니다.");
}
}
class Citizen extends Person {
@Override
public void introduceMySelf() {
System.out.println("안녕하세요, 저야말로 진정한 시민입니다.");
}
}
// 사용 시 코드
Person person = new Mafia();
person.introduceMySelf(); // !
Person person2 = new Citizen();
person2.introducMySelf(); // !
// 메서드 오버라이딩을 이용하면 같은 메서드명을 호출해도 서로 다른
// 기능을 수행하도록 할 수 있다. 이것이 다형성의 한 예이다.
예제 5-1. 메서드 오버라이딩의 예.
class MyCalculator {
void printAddResult(int a, int b) {
System.out.println(a + " + " + b + " = " + (a + b));
}
void printAddResult(int a, int b, int c) {
System.out.println("입력 받은 세 개의 수");
System.out.println(a + ", " + b + ", " + c);
System.out.println("세 수의 합 : " + (a + b + c));
}
void printAddResult(int... args) {
int total = 0;
for (int i = 0; i < args.length; i++) {
total += args[i];
}
System.out.println("덧셈 결과: " + total);
}
}
// 호출 코드
MyCalculator calc = new MyCalculator();
calc.printAddResult(1, 2);
calc.printAddResult(1, 2, 3);
calc.printAddResult(1, 2, 3, 4, 5);
// 매개변수의 개수가 달라짐에 따라 몇 개의 수를 더할 수 있을지 그 기능이 달라진다.
// 그럼에도 똑같은 메서드명을 호출한다. 이것이 다형성의 한 예라 볼 수 있겠다.
예제 5-2. 메서드 오버로딩의 예
위와 같이 똑같은 메서드명을 사용하여 호출하나 메서드의 매개변수에 따라, 또는 어느 클래스의 메서드이냐에 따라 그 기능과 결과가 달라지는 것을 알 수 있다. 이것이 메서드 수준에서의 다형성이라 볼 수 있겠다.
메서드말고도 자료형에 대해서도 다형성이 존재한다. 한 자료형의 참조변수가 여러 자료형의 객체를 참조할 수 있는 것을 의미한다. 앞선 예제 5-1을 다시 보면 부모-자식 관계에 놓인 두 클래스들에 대해, 부모 객체 참조 변수에 자식 객체의 참조값을 할당할 수 있음을 볼 수 있다.
Person person = new Mafia();
person.introduceMySelf();
Person person2 = new Citizen();
person2.introducMySelf();
이로 인해 부모 객체 참조 변수를 이용하여 여러 타입의 자식 객체들을 참조할 수 있으며, 똑같은 메서드를 호출하여 사용할 수 있다.
참고로, 추상화와 다형성을 이용하면 객체 설계 원칙 중 하나인 OCP(Open-Close Principle)을 만족시킬 수 있다. OCP는 한 마디로 말해 “기능의 확장에는 열려있되 기존 코드의 수정에는 닫혀있어야 한다”는 원칙으로, 확장 시 기존 코드에 수정, 삭제, 추가가 되는 일이 최소화되어야 한다는 뜻이다. 만약 기능 확장으로 인해 기존 코드에도 수정(또는 삭제 및 추가)이 필요해진다면 프로젝트 규모가 크고 복잡할 때 수많은 코드들을 일일이 찾아서 수정해야 할 수도 있다. 이 경우 프로그래밍이 매우 피곤한 일이 될 것이며, 기존 코드를 잘못 건드려 의도치 않은 오류 및 버그를 발생시킬 수도 있는 것이다. 기능 확장에 따라 기존 코드를 수정한다는 것은 기능 확장을 위해 새로 생성된 클래스와 기존 클래스 간의 결합도가 높다는 방증이기도 하다.
이에 대한 설명을 위해 다음의 예제를 준비하였다.
package mafiaGame.characters;
/**
* 마피아 클래스.
*/
public class Mafia {
private String role = "mafia";
private String nickname = "Javas";
public String getNickname() {
return nickname;
}
/**
* 마을에서 방출할 사람을 투표한다.
* @param targetName 투표할 대상의 닉네임
*/
public void vote(String targetName) {
System.out.println(nickname + "님이 " + targetName + "님에게 투표하였습니다.");
}
/**
* 자신의 정체를 공개한다.
*/
public void revealMySelf() {
System.out.println(nickname + "님의 정체는 " + role + "이었습니다!");
}
}
예제 5-3. Mafia.java
package mafiaGame.characters;
/**
* 시민 클래스
*/
public class Citizen {
private String role = "citizen";
private String nickname = "kimquel";
public String getNickname() {
return nickname;
}
/**
* 마을에서 방출할 사람을 투표한다.
* @param targetName 투표할 대상의 닉네임
*/
public void vote(String targetName) {
System.out.println(nickname + "님이 " + targetName + "님에게 투표하였습니다.");
}
/**
* 자신의 정체를 공개한다.
*/
public void revealMySelf() {
System.out.println(nickname + "님의 정체는 " + role + "이었습니다!");
}
}
예제 5-4. Citizen.java
package mafiaGame.logic;
import mafiaGame.characters.Citizen;
import mafiaGame.characters.Mafia;
/**
* 마피아 게임을 실행하는 간단한 로직
*/
public class MafiaTool {
void executeGame(Mafia mafia, Citizen citizen) {
mafia.vote(citizen.getNickname());
mafia.revealMySelf();
}
void executeGame(Citizen citizen, Mafia mafia) {
citizen.vote(mafia.getNickname());
citizen.revealMySelf();
}
}
예제 5-5. MafiaToo.java
package mafiaGame.logic;
import mafiaGame.characters.Citizen;
import mafiaGame.characters.Mafia;
public class Main {
public static void main(String[] args) {
MafiaTool mTool = new MafiaTool();
Mafia mafia = new Mafia();
Citizen citizen = new Citizen();
mTool.executeGame(mafia, citizen);
mTool.executeGame(citizen, mafia);
}
}
예제 5-6. Main.java
위 코드들을 보면, MafiaTool 클래스는 Mafia와 Citizen 객체를 받고, 내부에서 해당 객체들의 메서드를 호출하는 형식을 띄고 있다. 즉, MafiaTool 클래스는 Mafia 및 Citizen 객체 없이는 실행될 수 없기에 MafiaTool 클래스는 Mafia와 Citizen 클래스에 대한 결합도가 높은 상황이다. 이렇게 결합도가 높으면 마피아 개임에 새 캐릭터를 추가할 때마다 기존 MafiaTool 클래스 내부의 코드량이 많아진다는 단점이 있다. 즉, OCP 원칙을 위배한다. (게다가 Mafia, Citizen 클래스 내부에는 서로 중복되는 코드들이 발생하고 있다) 다음은 객체 간 결합도가 높음에 따라 기존 코드량이 증가하는 것을 보여주는 예제이다.
package mafiaGame.logic;
import mafiaGame.characters.Citizen;
import mafiaGame.characters.Doctor;
import mafiaGame.characters.Mafia;
import mafiaGame.characters.Police;
/**
* 마피아 게임을 실행하는 간단한 로직
*/
public class MafiaTool {
void executeGame(Mafia mafia, Citizen citizen) {
mafia.vote(citizen.getNickname());
mafia.revealMySelf();
}
void executeGame(Citizen citizen, Mafia mafia) {
citizen.vote(mafia.getNickname());
citizen.revealMySelf();
}
// 신 캐릭터 추가로 인해 추가된 코드들
void executeGame(Doctor doctor, Mafia mafia) {
doctor.vote(mafia.getNickname());
doctor.revealMySelf();
}
void executeGame(Police police, Mafia mafia) {
police.vote(mafia.getNickname());
police.revealMySelf();
}
}
예제 5-7. MafiaTool.java
package mafiaGame.logic;
import mafiaGame.characters.Citizen;
import mafiaGame.characters.Doctor;
import mafiaGame.characters.Mafia;
import mafiaGame.characters.Police;
public class Main {
public static void main(String[] args) {
MafiaTool mTool = new MafiaTool();
Mafia mafia = new Mafia();
Citizen citizen = new Citizen();
// 추가된 코드
Doctor doctor = new Doctor();
Police police = new Police();
mTool.executeGame(mafia, citizen);
mTool.executeGame(citizen, mafia);
// 추가된 코드
mTool.executeGame(doctor, mafia);
mTool.executeGame(police, mafia);
}
}
예제 5-8. Main.java
이렇게 코드량이 증가하면 가독성과 유지보수성이 떨어진다. 위 문제를 해결하기 위해선 추상화와 다형성을 이용하면 된다.
먼저 마피아 게임 캐릭터들의 공통된 특성들을 인터페이스로 정의한다.
package mafiaGame.characters;
public interface Person {
String getNickname();
void vote(String targetName);
void revealMySelf();
}
예제 5-9. Person.java
그리고 Mafia, Citizen 등의 클래스들은 해당 인터페이스를 구현하도록 한다.
package mafiaGame.characters;
/**
* 마피아 클래스.
*/
public class Mafia implements Person {
private String role = "mafia";
private String nickname = "Javas";
@Override
public String getNickname() {
return nickname;
}
/**
* 마을에서 방출할 사람을 투표한다.
* @param targetName 투표할 대상의 닉네임
*/
@Override
public void vote(String targetName) {
System.out.println(nickname + "님이 " + targetName + "님에게 투표하였습니다.");
}
/**
* 자신의 정체를 공개한다.
*/
@Override
public void revealMySelf() {
System.out.println(nickname + "님의 정체는 " + role + "이었습니다!");
}
}
예제 5-10. Mafia.java
그 후 MafiaTool.java 에서는 다음의 코드로만 변경하면 그만이다.
package mafiaGame.logic;
import mafiaGame.characters.Citizen;
import mafiaGame.characters.Doctor;
import mafiaGame.characters.Mafia;
import mafiaGame.characters.Person;
import mafiaGame.characters.Police;
/**
* 마피아 게임을 실행하는 간단한 로직
*/
public class MafiaTool {
void executeGame(Person votePerson, Person targetPerson) {
votePerson.vote(targetPerson.getNickname());
votePerson.revealMySelf();
}
}
MafiaTool.java
이렇게 되면 아무리 많은 새로운 캐릭터 클래스들이 증가하더라도 MafiaTool 클래스 내부 코드는 전혀 수정되거나 추가되지 않고 고정된 상태를 유지한다. 이는 인터페이스를 이용한 추상화와 더불어, executeGame 메서드에서 사용되는 매개변수 타입을 인터페이스 Person으로 하여 구상 클래스들의 객체 참조를 할당받는 구조로 이루어져 있어 다형성을 이용하였기에 가능한 일이다. 이제 MafiaTool 클래스에 대해서는 OCP 원칙을 지킬 수 있게 되었다.
이와 같이 객체지향 프로그래밍의 4가지 특성들을 이용하여 유지보수성을 확보할 수 있다.
객체지향 프로그래밍의 다른 특징들
정보 은닉화 (Information hiding)
사실 객체지향의 특징 4가지에는 정보 은닉화란 개념이 들어가 있지 않으나 이 특징도 매우 중요하게 다뤄지는 것 같아서 여기서도 다뤄보고자 한다.
객체지향 프로그래밍에서의 정보 은닉은 객체지향 언어적 요소를 활용하여 객체의 구체적인 정보를 외부에 노출시키지 않도록 하는 기법을 의미한다.
사실 앞선 4가지 특징들을 보면 공통점이 있다. 유연한 개발을 위해 객체들 간의 독립성이 보장되어야 한다는 점이다. 물론 현실에서는 하나의 객체가 다른 객체를 가져와 사용할 수 밖에 없어 100% 독립성은 없다고 볼 수 있다. 다만 궁극적으로 유연한 개발, 가독성과 쉬운 유지보수성을 확보하기 위해 서로 연관되는 두 객체는 그 결합도가 최소가 되어야한다는 것이다. 각 객체들의 독립성이 보장되지 않을 때 예기치 않은 버그까지 날 수 있음을 앞서 상속의 단점 파트에서 살펴보았다. 이러한 객체의 독립성은 곧 “어떤 객체의 내부 내용은 외부의 다른 객체가 알게 해선 안된다”는 정보 은닉으로 이어진다. 즉, 서로가 서로를 몰라야 객체 간 결합도를 낮추고, 기능 교체, 변경을 쉽게 할 수 있다. 사실 앞선 4가지 특징들은 전부 정보 은닉을 전제로 하고 있다는 것이다. 그래서 필자가 조사했을 때 OOP의 가장 핵심적인 특징을 “정보 은닉”으로 뽑는 곳들이 많았다.
캡슐화가 곧 정보 은닉이라는 말이 있는데, 이는 틀린 말이라고 한다. 정보 은닉에는 캡슐화를 포함한 여러 요소들이 있는, 집합으로 따지면 포함 관계에 있는 것이다. 정보 은닉의 종류에는 캡슐화를 포함하여 다음의 요소들이 있다고 한다.
- 객체의 구체적 타입 은닉 (upcasting)
- 객체의 필드, 메서드 은닉 (캡슐화)
- 구현 은닉 (인터페이스 또는 추상 클래스)
객체 타입 은닉 (Upcasting)
자식 객체의 타입을 부모 객체의 타입으로 형변환하는 것을 upcasting이라 한다. 부모 객체 타입 참조 변수에 자식 객체 인스턴스를 대입하는 것도 동일하다. 보통 하나의 부모 객체 타입의 참조 변수에는 수많은 자식 객체 인스턴스들 중 하나를 골라 할당받는게 가능하다. 이 말은, 기능 변경 또는 추가가 필요할 때마다 다른 자식 객체로 갈아끼워 변경하는게 수월하다는 것을 내포하고 있다. 즉, 다형성을 의미하게 된다. 이러한 upcasting도 부모 객체 타입을 이용하여 조금 더 구체적인 자식 객체 타입의 정보를 은닉하는 것이기에 이것도 정보 은닉의 일종이다. 객체 타입 은닉을 통해 다형성이란 이점을 얻을 수 있고, 이로 인해 자식 객체만 변경해도 기능을 쉽게 변경할 수 있다는 유연성을 보장 받을 수 있다.
만약 구현체의 인스턴스를 그대로 생성하여 사용한다고 해보자. 아래 예시 코드처럼 말이다.
@Slf4j
public class Dog {
public void speak() {
log.info("개가 짖습니다. 월월!");
}
}
@Slf4j
public class Cat {
public void speak() {
log.info("고양이가 웁니다. 냐옹!");
}
}
public class Zoo {
public void execute() {
//Dog dog = new Dog();
//dog.speak();
Cat cat = new Cat();
cat.speak();
}
}
예제 6-1.
위 코드에서 Zoo 객체 내 메서드 내부에 Cat 객체를 직접 생성, 사용하고 있다. 이는 두 객체 간 결합도가 높음을 의미한다. 그래서 만약 Cat이 아닌 Dog 객체로 변경해야하거나 아니면 아예 다른 동물 객체로 변경해야 하는 경우 위 코드에서 볼 수 있듯 기존 코드들을 대폭 수정해야한다는 번거로움이 존재한다.
만약 이를 공통의 인터페이스로 추상화를 거친 다음, 이 인터페이스로 대표되는 부모 객체 타입 참조 변수에 구현체 인스턴스를 대입하는 방법으로 변경하면 다음과 같을 것이다.
public interface Animal {
void speak();
}
@Slf4j
public class DogImpl implements Animal {
@Override
public void speak() {
log.info("개가 짖습니다. 월월!");
}
}
@Slf4j
public class CatImpl implements Animal {
@Override
public void speak() {
log.info("고양이가 웁니다. 냐옹!");
}
}
public class Zoo {
public void execute() {
//Animal animal = new DogImpl();
Animal animal = new CatImpl();
animal.speak(); // 자식 객체를 바꿔도 이 호출문은 변경하지 않아도 잘 작동한다.
}
}
예제 6-2.
위와 같이 각 구현체들의 공통점들을 하나로 모아 추상화한 인터페이스를 부모 타입으로 하는 변수에 구현체 인스턴스를 대입하는 upcasting 방식을 취함으로써, 기능 변경을 원하면 그에 맞는 다른 자식 구현체로 바꿔 생성하기만 하면 되기에 수정해야하는 기존 코드의 양이 줄어들고 조금 더 유연한 기능 변경이 가능함을 볼 수 있다.
이렇게 구체적인 코드가 들어있는 자식 객체 타입을 추상화된 부모 객체 타입으로 업캐스팅하여 객체의 타입을 외부로부터 은닉하면 이 코드를 사용하는 클라이언트와의 결합도가 낮아져 수정해야 하는 기존 코드의 양이 줄어들고 기능을 유연하게 변경할 수 있게 된다.
한 편 위 예제 코드들에서는 Cat, Dog 객체들을 Zoo 객체 내에서 직접 생성하는 패턴을 보였는데, 사실 이렇게 객체 생성을 직접하는 행위도 두 객체 간 결합도를 높이는 행위라고 한다. 따라서 객체 생성 자체도 별도의 모듈이 담당하여 대신 생성하도록 하기도 한다. 디자인 패턴 중 팩토리 패턴이 이를 수행한다. 다음은 앞선 코드들에 팩토리 패턴을 적용한 모습이다.
public class AnimalFactory {
private static final AnimalFactory animalFactory = new AnimalFactory();
public static AnimalFactory getInstance() {
return animalFactory;
}
private AnimalFactory() {}
public Animal getDog() {
// 필요한 경우, 객체 생성 후 설정을 하거나 다른 작업을 한 후 해당 객체를 반환하게 할 수도 있음.
return new DogImpl();
}
public Animal getCat() {
return new CatImpl();
}
}
public class Zoo {
public void execute() {
AnimalFactory animalFactory = AnimalFactory.getInstance();
//Animal animal = animalFactory.getDog();
Animal animal = animalFactory.getCat();
animal.speak();
}
}
예제 6-3.
팩토리 패턴을 적용하니 객체 생성 자체도 클라이언트인 Zoo 입장에서는 더더욱 은닉화되었기 때문에 두 객체 간 결합도가 더 감소한다. 이러한 팩토리 패턴은 특히 경우에 따라 여러 객체들을 준비해 그에 맞는 객체를 생성, 반환해야 할 때, 객체 생성자 메서드 자체에 변화를 주어야 할 때, 경우에 따라 객체 생성에 긴 로직이 필요할 때 사용하면 팩토리 객체가 객체 생성만을 담당하기 때문에 단일 책임 원칙과 관심사 분리의 이점을 얻을 수 있다.
객체의 필드, 메서드 은닉 (캡슐화)
객체 필드에 private, protected 등 적절한 접근 제한자를 사용하고 getter, setter를 이용하면 의도치 않은 값 변경을 방지하고 오로지 개발자가 원할 때에만 setter 메서드를 호출하여 변경하도록 제한시킬 수 있다. 따라서 객체 필드의 은닉화도 중요하다.
한 편, 이러한 접근 제한자는 메서드에도 적용될 수 있는데, 이러한 메서드에도 은닉화는 중요하다.
하나의 클래스 내부에 여러 메서드들이 있는데, 이 메서드들이 하나로 그룹 지어 순차적으로 호출되어야만 해야할 때 특히 메서드의 은닉화가 중요하다.
@Slf4j
public class MyServiceWrong {
public void init() {
log.info("서비스 제공을 위한 시스템 초기화.");
}
public void createProduct() {
log.info("상품 제작 중...");
}
public void release() {
log.info("제작된 상품을 고객에게 공개합니다.");
}
}
public class MyServiceExecuter {
public void execute() {
MyServiceWrong myServiceWrong = new MyServiceWrong();
myServiceWrong.init();
myServiceWrong.createProduct();
myServiceWrong.release();
}
}
예제 7-1.
예를 들어 위 코드에서 init()
→ createProduct()
→ release()
순서대로 호출되어야만 의도한 대로 작동된다고 해보자. 그런데 이 메서드들이 모두 public으로 공개되어 있는 바람에 클라이언트 입장에서는 어떤 메서드를 먼저 호출해야하는지 판단할 단서가 하나도 없다. 심지어는 메서드 일부를 호출하는 것 자체를 모르고 생략할 수도 있는 것이다.
이럴 땐 각각의 메서드들을 private로 전환하여 은닉하고, 이들을 특정 순서로 호출하는 공개 메서드를 별도로 정의하여 노출시키면 된다.
@Slf4j
public class MyServiceEnCapsulated {
private void init() {
log.info("서비스 제공을 위한 시스템 초기화.");
}
private void createProduct() {
log.info("상품 제작 중...");
}
private void release() {
log.info("제작된 상품을 고객에게 공개합니다.");
}
public void getService() {
log.info("서비스 제공 프로세스 시작.");
init();
createProduct();
release();
log.info("서비스 제공 프로세스 완료.");
}
}
public class MyServiceExecuter {
public void execute() {
MyServiceEnCapsulated myServiceEnCapsulated = new MyServiceEnCapsulated();
myServiceEnCapsulated.getService();
}
}
예제 7-2.
클라이언트 입장에서는 공개 메서드가 단 하나뿐이니 이 메서드만 사용해도 그 어떤 혼란을 겪지 않아도 원하는 결과를 얻을 수 있다. 또한 private와 public 메서드들의 구분으로 public 메서드가 private가 더 중요하다는 것을 암시할 수도 있다. 그리고 세부 내용을 private 메서드로 은닉화함으로써 보안을 지킬 수도 있다.
구현 은닉 (인터페이스 또는 추상 클래스)
자바를 비롯한 여러 언어들에는 인터페이스라는 존재가 있다. 이 인터페이스에는 오로지 추상 메서드들만을 정의할 수 있어 어떤 기능이 어떻게 작동해야할지는(how) 전혀 신경쓰지 않고 어떤 기능(What)을 사용할 수 있는지만을 알 수 있게끔 한다. 그리고 실제 구현은 구현 클래스가 이 인터페이스를 상속받아 구현하는 방식을 취한다.
앞서 언급했었던 객체 지향의 큰 장점은 두 객체 간의 독립성을 확보하고 결합도를 낮춰 유연한 개발 및 쉬운 유지보수성을 얻는데에 있다고 할 수 있다. 그리고 그 결합도를 낮추는, 즉 두 객체 간 독립성을 확보하는 방법에는 두 객체가 서로의 상세한 정보, 로직들을 모르게 하는 것이다. 즉, 구현 자체를 은닉화하는 것이다. 이를 위해 인터페이스를 사용한다.
앞서 메서드의 은닉화를 통해 여러 메서드들을 단 하나의 공개 메서드에서 순차적으로 호출하도록 하여 호출 순서를 지키도록 할 수 있음을 보았다. 이러한 공개 메서드도 구상 메서드이기에 다른 객체에서 이를 사용하면서도 결합도를 낮추려면 이 공개 메서드도 추상화시킨 인터페이스를 통해 우회적으로 사용하도록 하는 게 좋다.
public interface ServiceSystem {
void getService();
}
@Slf4j
public class MyServiceWithSystem implements ServiceSystem {
private void init() {
log.info("서비스 제공을 위한 시스템 초기화.");
}
private void createProduct() {
log.info("상품 제작 중...");
}
private void release() {
log.info("제작된 상품을 고객에게 공개합니다.");
}
@Override
public void getService() {
log.info("===== 구현 은닉 적용 =====");
log.info("서비스 제공 프로세스 시작.");
init();
createProduct();
release();
log.info("서비스 제공 프로세스 완료.");
}
}
public class MyServiceExecuter {
public void execute() {
ServiceSystem serviceSystem = new MyServiceWithSystem();
serviceSystem.getService();
}
}
예제 8-1.
MyServiceWithSysmtem()
객체를 사용하는 MyServiceExecuter
객체의 입장에서는 ServiceSystem
이라는 인터페이스의 getService()
추상 메서드를 대신 호출하여 사용하고 있다. 이로 인해 MyServiceExecuter
에서는 언제든지 ServiceSystem
의 다른 구현체로 변경하여 그 기능을 사용할 수 있다. 즉, 인터페이스를 사용하도록 함으로써 상대 객체의 구체적인 구현 내용을 은닉하는, 즉 구현 은닉을 통해 유연한 개발, 유지보수성 등의 이점들을 얻을 수 있는 것이다.
글을 마치며…
지금까지 이 글에서는 객체 지향 프로그래밍이 무엇인지 그리고 이를 사용하면 어떤 이점이 있는지를 객체 지향의 특징들과 함께 살펴보았다. 객체 지향의 주요 특징 4가지와 정보 은닉까지 모든 특징들은 서로 연관되어 있음을 알 수 있다. 상속 또는 추상화를 이용하여 다형성을 확보할 수 있다는 점이 그 예시이다. 이러한 객체 지향의 궁극적인 목표는 코드 재사용성, 적은 코드 변경으로 기능 확장, 동적으로 언제든 기능을 변경할 수 있는 유연한 개발, 유지보수성 등에 있다고 볼 수 있다.
이 글에서 언급한 객체 지향의 특징들에 더 나아가서 객체 지향 설게를 위한 5가지 기본 원칙인 SOLID 원칙이 존재하는데, 이에 대해서는 이 글이 길어진 관계로 다른 글에서 다뤄보겠다.
References
[1] 객체 지향 프로그래밍의 4가지 특징ㅣ추상화, 상속, 다형성, 캡슐화 -
[2] [Java] 객체지향 프로그래밍의 특징(캡슐화, 상속, 다형성)
[4] https://namu.wiki/w/객체 지향 프로그래밍
[5] 정보 은닉화
[6] 정보 은닉화
객체지향의 올바른 이해 : 5. 정보 은닉(information hiding)
[7] 상속 개념과 문제점
💠 상속을 자제하고 합성(Composition)을 이용하자
[8] 상속
[9] 상속의 장단점
[10] 참고) 팩토리 메서드 패턴
💠 팩토리 메서드(Factory Method) 패턴 - 완벽 마스터하기
This content is licensed under
CC BY-NC 4.0
댓글남기기