객체 지향의 특징과 객체 지향 프로그래밍
객체 지향의 특징에는 4가지가 있다고 학습했다.
- 추상화
- 캡슐화
- 상속
- 다형성
추상화는 현실 세계에 존재하는 객체를 소포트웨어적으로 표현하되, 원하는 역할(책임)에 맞게 표현한다. 현실 세계에 존재하는 정보여도 원하는 역할이 아니라면 표현하지 않는다.
캡슐화는 클라이언트에게 모든 정보를 제공하지 않고, 사용하기 편리하게 필요한 정보만을 제공하여 사용성과 보안을 향상시키는 특징이다.
상속은 중첩되는 코드를 상속으로 코드 반복을 줄이고, 객체를 체계적으로 표현할 수 있는 특징이다.
다형성은 위 상속을 사용하여 하나의 객체가 여러 타입의 객체로 취급받을 수 있는 특징을 말한다.
그렇다면 이 객체 지향을 사용하는 객체 지향 프로그래밍은 무엇인가?
명령어의 목록으로 바라보는 절차 지향 프로그래밍과 달리, 하나의 단위인 ‘객체’들의 모임으로 프로그램을 파악하는 관점이다. 그래서 하나의 객체 안에 객체의 속성과 기능 정보가 모아져 있다. 객체의 이 정보들을 사용해서 다른 객체와 협력한다.
위 설명에서 중요한 부분이 ‘객체’들의 모임으로 프로그램을 파악한다는 것이다. 즉 객체들끼리 협력하여 의도한 프로그램 목표를 수행한다. 그러면 각 객체의 역할을 잘 정의하는게 중요하다.
이 역할을 정의하여 수행하는 객체를 정의할 때 사용되는 게 ‘다형성’이기 때문에 객체 지향 프로그래밍에서 제일 중요한 특성이 다형성이다. 현실 세계에서 다형성이 어떻게 적용되는지 예시를 찾아보자.
다형성의 예시를 실세계에서 찾아보자
지난 포스팅에서 다형성에 대해 알아보았고, 인터페이스를 받아 구현해봤다. 이 인터페이스를 사용하여 역할 을 정의한다. 인터페이스이므로 구현체가 필요하다고 지난 포스팅에서 학습했으므로, 이 인터페이스를 받아서 만든 실체를 구현 이라 하자.
현실 세계에서 역할과 구현의 예시를 찾아보자. 더 나아가 이 역할을 사용하는 클라이언트는 누구인지 생각해보자.
자동차
첫 번째 예로는 자동차가 있다. 이 자동차를 직접적으로 사용하는 건 운전자다. 각 자동차마다 제공하는 자동차의 역할이 동일하기 때문에, 운전자는 여러 종류의 자동차를 운전할 수 있다.
이 역할에는 정해진 규칙이 있다. 브레이크, 엑셀, 운전대, 변속기 등을 반드시 제공한다는 규칙이다. 여기서 운전자는 자동차의 내부 원리는 알지 못해도 사용할 수 있다. 이 부분이 바로 캡슐화다. 숨길 부분은 숨기고, 드러낼 부분만 드러내서 사용자가 쉽게 사용할 수 있는 인터페이스를 만든다.
이 자동차의 종류에는 자동차 제조사가 만든 모든 차량들이 해당된다. 차량의 제조사 종류들은 다 다르지만, 운전자는 이 자동차를 다 사용할 수 있다. 왜냐하면 차량의 역할 인터페이스가 다 동일하기 때문이다. 그래서 현재 사용하는 자동차가 망가져도 다른 자동차로 대체할 수 있다.
자동차의 클라이언트, 역할, 구현은 무엇일까?
- 클라이언트: 사용자
- 역할: 자동차
- 구현: 자동차 제조사가 만든 모든 차량들
연극
연극을 보면 관람객들이 보고 있고, 여러 개의 역할이 있으며, 하나의 역할을 여러 배우들이 돌아가면서 연기한다. 이게 객체 지향 프로그래밍과 많이 유사하다고 생각한다.
- 클라이언트: 관람객
- 역할:남자 주인공, 여자 주인공, 조연들
- 구현: 각 배역을 맡은 여러 배우들
항공사
항공사의 비행기도 그렇다. 항공사들은 여러 종류의 여객기를 사용하여 여행을 제공한다.
- 클라이언트: 항공사
- 역할: 여객기
- 구현: 보잉과 에어버스에서 만든 여러 항공기들
컴퓨터 장치들
- 클라이언트: 사용자
- 역할: 키보드, 마우스, 모니터
- 구현: 각 역할들을 구현하는 실제 장치들의 종류가 많다.
역할, 구현 그리고 협력
클라이언트 관점에서 역할과 구현을 분리하지 않으면 생기는 단점
역할과 구현을 분리하는 이유는 분리하지 않을 때 생기는 문제점을 생각해보면 된다. 가장 마지막에 본 연극인 ‘앙리할아버지와 나’를 예시로 든다.
여기서 여주인공의 역할로는 박소담, 채수빈, 권유리님이 맡으셨다. 할아버지 역할로는 이순재, 신구 선생님이 맡으셨다.
만약 역할과 구현을 구분하지 않았다면 하나의 역할에 한 명의 배우만 가능하여, 이 배우에게 문제가 발생했을 경우 다른 배우가 대체하지 못한다. 즉, 유연하지 못하다. 그래서 요즘 주연 배우들이 치명적인 이슈를 가지고 있는지가 중요하다. 중도 하차할 경우 매우 비용이 많이 발생하기 때문이다.
이처럼 역할과 구현을 구분하지 않으면 유연하지 않아 변경이 어렵다.
클라이언트 관점에서 역할과 구현을 분리하면 생기는 장점
다음으로 사용자(클라이언트)의 관점에서 생각해보자.
역할과 구현을 분리하면 클라이언트는 이 역할을 무엇이 또는 누가 구현하는지 몰라도 된다. 역할(인터페이스)만 알면 되기 때문이다. 왜냐하면 우리는 이 역할이 제공하는 서비스를 이용하는 클라이언트이기 때문이다.
연극을 보는데, 자동차를 타는데, 여행을 가는데 있어서 이 구현체(배우, 차 종류, 항공기 종류)가 바껴도 클라이언트에게는 아무런 영향이 없다.
자동차나 비행기의 작동원리를 몰라도 자동차와 비행기의 역할을 충분히 이용할 수 있다.
새로운 자동차와 비행기가 나오면서 내부 구조가 변경되어도 자동차와 비행기의 역할을 충분히 이용할 수 있다. 자동차의 동력이 석유에서 전기로 바껴도 충분히 운전할 수 있다.
그러면 클라이언트 관점에서 생각해본 장점들 을 정리해보면 다음과 같다.
- 클라이언트는 대상의 역할(인터페이스)만 알면 된다.
- 다른 말로 말하자면 클라이언트는 구현 대상의 내부 구조를 몰라도 된다.
- 그래서 클라이언트는 구현 대상의 내부 구조가 변경되어도 영향을 받지 않는다.
- 클라이언트는 동일한 역할만 제공하면 구현 대상 자체를 변경해도 영향을 받지 않는다.
위 장점들로 인해 설계가 유연해지고 변경이 편리하다.
자바에서는 interface
키워드를 사용한 인터페이스를 통해 역할을 정의하고, 이 interface
를 받아서 implements
키워드를 사용하여 구현한 클래스를 구현체로 사용한다.
❗️ 역할을 정의할 때 반드시 인터페이스를 사용하지 않아도 되지만, 여태 학습한 것처럼 인터페이스를 권장한다.
그러면 역할과 구현을 분리하여 생기는 단점은?
클라이언트와 서비스 제공자가 인터페이스(역할)를 중심으로 연결되어 있기 때문에, 이 인터페이스를 바꾼다면 서비스를 이용하는 클라이언트와 서비스를 제공하는 서버 모두에 큰 변경이 발생한다!
- 자동차 이용 방법이 바뀐다면?
- 컴퓨터의 입출력 장치들의 연결 및 사용 방법이 바뀐다면?
- 대본 자체가 변경된다면?
그래서 실제 세계에 있는 것을 역할과 구현 관점에서 분석한 후, 다형성을 사용해서 소프트웨어 세계에 추상화한다. 이때 인터페이스 변경이 일어나지 않도록 안정적으로 잘 설계하는 게 중요하다❗️❗️
협렵이라는 관점에서
이전 포스팅과 위 내용에서는 클라이언트와 하나의 역할 간 관계에 대해서만 고려했다. 이번에는 여러 개의 역할 간 관계에 대해서 고려해보자.
제공하는 서비스를 이용하는 역할(요청하는 역할)에 속하면 클라이언트, 서비스를 제공하는 역할(응답하는 역할)에 속하면 서버라 생각할 수 있다. 그런데, 이 서버 또한 다른 서버로부터 제공하는 서비스를 이용할 수 있기 때문에 서버이자 클라이언트가 된다.
클라이언트 -> 서버
클라이언트 -> 서버 + 클라이언트 -> 서버
객체 지향 프로그래밍은 프로그램을 객체들 간 협력으로 파악하는 관점이다. 서버가 클라이언트가 될 수 있는 것처럼 객체들 간에도 서비스를 제공하는 객체가 또 다른 서비스를 사용하는 객체가 된다.
코드 예시
그러면 이제 코드를 통해 이해해보자. 처음에는 역할과 구현을 나누지 않고 코드를 작성해본 후, 다형성을 활용하여 역할과 구현을 분리해본다.
클라이언트가 역할이 아닌 구현에 의존한다면?
포테이토 피자와 주문자(클라이언트) 클래스를 만든다.
Order
클래스1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
package pizza; public class OrderClient { private PotatoPizza potatoPizza; public void order(PotatoPizza potatoPizza) { System.out.println("포테이토 피자를 주문합니다."); this.potatoPizza = potatoPizza; } public void delivery() { potatoPizza.startDelivery(); potatoPizza.onDelivery(); potatoPizza.arrive(); } }
PotatoPizza
클래스1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
package pizza; public class PotatoPizza { public void addIngredient() { System.out.println("감자 재료를 추가합니다."); } public void startDelivery() { System.out.println("배달을 시작합니다."); } public void onDelivery() { System.out.println("배달 중입니다."); } public void arrive() { System.out.println("배달이 도착했습니다."); System.out.println("주문자가 피자를 받았습니다"); } }
OrderMain
클래스1 2 3 4 5 6 7 8 9 10 11
package pizza; public class OrderMain { public static void main(String[] args) { OrderClient orderClient = new OrderClient(); PotatoPizza potatoPizza = new PotatoPizza(); orderClient.order(potatoPizza); orderClient.delivery(); } }
실행 결과
1 2 3 4 5
포테이토 피자를 주문합니다. 배달을 시작합니다. 배달 중입니다. 배달이 도착했습니다. 주문자가 피자를 받았습니다
위와 같이 설계를 한다면 PotatoPizza
외 피자를 주문할 수가 없다. 유연한 설계가 불가능하다.
다형성을 적용한다면?
이제 다형성을 적용한다면 코드를 어떻게 변경해야할지 생각해보자.
먼저 다형성을 적용하기 위해 역할과 구현 단계를 나눠야 한다. 역할은 자바에서 인터페이스를 통해 만들고, 구현은 이 인터페이스를 받아 만든다.
인터페이스로
Pizza
인터페이스를 만든다.위
PotatoPizza
클래스에서 만들었던 메서드들을Pizza
인터페이스 내부에 추상 메서드로 각각 선언한다.1 2 3 4 5 6 7 8 9 10
package pizza; public interface Pizza { void startDelivery(); void onDelivery() ; void arrive(); }
이 인터페이스를 상속받아
PotatoPizza
를 만든다.PotatoPizza
내부에 인터페이스 내부의 추상 메서드를 오버라이딩한다.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
package pizza; public class PotatoPizza implements Pizza{ public void startDelivery() { System.out.println("배달을 시작합니다."); } public void onDelivery() { System.out.println("배달 중입니다."); } public void arrive() { System.out.println("배달이 도착했습니다."); System.out.println("주문자가 피자를 받았습니다"); } }
orderClient
클래스의 인스턴스 변수와 인스턴스 메서드의 타입을PotatoPizza
에서Pizza
인터페이스로 수정한다.OrderMain
에서PotatoPizza
구현체의 인스턴스를 생성하고, 이 인스턴스의 타입은Pizza
인터페이스로 한다.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
package pizza; public class OrderClient { private Pizza pizza; public void order(Pizza pizza) { System.out.println("포테이토 피자를 주문합니다."); this.pizza = pizza; } public void delivery() { pizza.startDelivery(); pizza.onDelivery(); pizza.arrive(); } }
이후에 생성하는 피자 클래스들의 인스턴스 타입도
Pizza
인터페이스로 한다. 그러면order
메서드에서 매개변수로 받을 때 타입에러가 발생하지 않고, 메서드 오버라이딩에 의해서 인스턴스의 메서드가 실행된다.Pizza
타입의 인스턴스 변수에 담겨진 참조값을 통해 heap 영역에 생성된 인스턴스를 참조한다. 이 인스턴스의 참조값에 접근하여 인스턴스 메서드의 메모리 위치를 확인 후, 인스턴스 메서드를 실행한다.1 2 3 4 5 6 7 8 9 10 11
package pizza; public class OrderMain { public static void main(String[] args) { OrderClient order = new OrderClient(); Pizza potatoPizza = new PotatoPizza(); order.order(potatoPizza); order.delivery(); } }
위 수정들을 통해 ‘역할’과 ‘구현’이 분리되어 유연한 설계가 가능해진다.
OCP(Open-Closed Principle) 원칙
강의를 들으면서 OCP 원칙에 대해 처음 들었는데, 객체 지향 설계 원칙 중 하나다.
- Open for extension: 새로운 기능의 추가나 변경 사항이 생겼을 때, 기존 코드는 확장할 수 있어야 한다.
- Closed for modification: 기존의 코드는 수정되지 않아야 한다.
위 두 가지 내용을 상충되는 걸로 보인다. 이렇게 상충되는 내용을 발견하면 관점이 다르다는 걸 기억하자.
첫 번째 ‘Open for extenstion’은 새로운 객체가 추가될 수 있어야 한다는 걸 의미한다. 위에 pizza 예시처럼 새로운 피자 종류를 언제든지 추가할 수 있어야 한다. 어떻게? 바로 인터페이스를 통해서 추가한다는 의미다.
그 다음으로 두 번째 ‘Closed for modification’은 첫 번째 원칙에 따라 기존 코드는 확장할 수 있지만, 이 기존 코드에 클라이언트 코드는 포함되어 있지 않아서 클라이언트 코드는 수정하지 않는다. 단, 새로운 객체를 생성하여 클라이언트에게 전달하는 부분은 수정될 수 밖에 없다.
위 두 가지 모두 ‘다형성’을 활용하여 ‘역할’과 ‘구현’을 잘 분리하면 위 원칙을 준수할 수 있다. 대부분의 코드를 유지할 수 있고, 새로운 객체가 필요하면 구현 부분을 늘리면 된다.