객체지향 프로그래밍 (OOP)
클래스의 메서드와 같은 행동을 프로세스, 클래스의 변수와 같은 상태나 정보를 데이터라고 칭한다.
절차적 프로그래밍에서는 프로세스와 데이터를 별도의 모듈에서 관리하는 것, 객체지향 프로그래밍은 데이터와 프로세스를 하나의 모듈에 위치하도록 프로그래밍하는 방식이다.
하지만 단순히 변수와 메서드만 클래스에 넣었다고해서 절차적 프로그래밍이 객체지향 프로그래밍이 되는 것이 아니다. 객체지향 프로그래밍에서는 모든 객체들이 자율적으로 협력에 참가해야한다.
응집도와 결합도
소프트웨어의 품질을 측정하는 기준은 응집도, 결합도가 있다.
응집도란 모듈에 포함된 요소들이 연관된 정도, 즉 변경이 발생할 때 모듈 내부에서 발생하는 변경의 정도이다. 만약 한 개의 모듈에서 변경사항이 생겨 코드를 수정할 경우, 모듈 전체에서 수정사항이 많다면 응집도가 높은 것이고, 수정사항은 많으나 여러 개의 모듈에서 수정을 진행해야한다면 응집도가 낮은 것이다. 그러니까 하나의 모듈에 얼마나 비슷한 기능들이 뭉쳐있는가를 나타낸다.
결합도란 의존성의 정도, 즉 한 개의 모듈에서의 변경사항이 다른 모듈의 변경을 요구하는 정도이다. 만약 한 개의 모듈에서 수정사항이 발생했으나 이 변경 때문에 다른 여러 모듈들을 수정해야한다면 결합도가 낮은 것이다. 단, String과 같은 표준 라이브러리는 변경될 가능성이 매우 낮으므로 결합도가 높아도 상관없으나 커스텀 클래스에서는 반드시 결합도를 낮추기위해야한다.
따라서 객체지향 프로그래밍이 등장한 이유이자 사용하는 이유는 바로 응집도를 높이고 결합도를 낮춰 코드에 변경사항이 발생하더라도 다른 코드에 미치는 영향력을 줄이기 위함이다.
캡슐화
그래서 응집도를 높이고 결합도를 낮추는 가장 유명한 방식은 캡슐화다.
캡슐화는 정보를 숨기고 게터세터 등의 접근자 메서드를 제공하는 방식으로도 진행할 수 있으나, 캡슐화의 본질은 내부 인스턴스의 상태를 수정해야할 때, 외부클래스에서 직접 접근하게하지 않고 내부에서 수정로직을 수행하는 것이다.
"외부클래스에서 직접 내부 인스턴스의 상태를 변경한다"라는 뜻은 결국, 외부클래스가 변경하려는 객체의 내부 인스턴스를 알고 있다는 것을 뜻한다.
class Person {
private Dog dog;
private int age;
private String name;
public Person(Dog dog) {
this.dog = dog;
}
public Dog getDog(){
return dog;
}
public int getAge(){
return age;
}
public String getName(){
return name;
}
public void setDog(Dog dog){
this.dog = dog;
}
public void setAge(int age){
this.age = age;
}
public void setName(String name){
this.name = name;
}
}
class Dog {
private int age;
private String name;
public int getAge() {
return age;
}
public String getName() {
return name;
}
public void setAge(int age) {
this.age = age;
}
public void setName(String name){
this.name = name;
}
}
내가 애용하는 개와 사람 객체. 간단한 개념설명은 개와 사람 객체로 거의 다 설명될 듯 한데..
만약 1년이 지나고 AgeChanger 클래스에서 개와 사람의 나이를 1살 증가시켜야한다면
class AgeChanger {
public void setDogAge(Person person) {
preson.setAge(person.getAge() + 1);
person.getDog.setAge(person.getDog.getAge() + 1);
}
}
이렇게 Person 객체에는 Dog 객체가 있으며, Dog 객체에는 setAge 메서드가 존재한다는 것을 알고 있어야한다.
문제는 이렇게 외부에서 내부클래스의 구현정도를 상세히 알고 있다면 결합도가 높아지고 응집도가 낮아지며, 코드의 변경에 있어서 큰 문제가 발생한다. 만약 Dog 객체에서 age 변수와 그의 게터세터에서 수정이 일어났다면, AgeChanger 객체에서도 수정이 일어나야한다. 또한 비단 AgeChanger 뿐 아니라 Dog 객체의 setAge 메서드를 사용하는 모든 메서드에 대해서 수정이 일어나야한다.
class Person {
private Dog dog;
private int age;
private String name;
public void activateAging() {
age++;
dog.activateAging();
}
}
class Dog {
private int age;
private String name;
public void activateAging() {
age++;
}
}
class AgeChanger {
public void setDogAge(Person person) {
person.activateAging();
}
}
이렇게 바꿀 수 있다.
즉, 내부의 상태를 변경할 때, 외부에서 직접 수정하는게 아니라, 외부에서는 객체로 메시지를 보내고, 객체는 이 메시지를 받아서 상태 변경을 스스로 해내도록 하는 과정, 따라서 이를 위해 내부상태를 모조리 숨기고 메시지를 받아서 상태변경을 수행할 메서드만 외부에 제공하는 것을 캡슐화라고한다.
위의 예시에서보면 기존에는 AgeChanger 객체에서 게터세터를 통해 person 객체와 Dog 객체의 직접 접근했지만, 캡슐화를 통해서 Person 객체와 Dog 객체의 나이를 1씩 증가시키는 접근자메서드만 제공하는 것이다.
그래서 외부에서는 코드 내부는 잘 모르지만, 어쨌든 메시지를 보내면 내가 원하는 동작을 수행한다 정도로만 알게하는 것이 캡슐화의 목표이자 객체지향 프로그래밍의 꽃이다.
이것은 캡슐화의 예시를 드는 간단한 사례일 뿐이고, 더 복잡하며 정확한 예시는 오브젝트 책 전반에 걸쳐서 나오기 때문에 그 부분을 읽으면 된다.
역할-책임-협력
이렇게 객체지향 프로그래밍의 핵심은 변경이 용이한 코드를 만드는 것이다. 따라서 이를 위한 핵심 요소들이 있는데, 역할, 책임, 협력이다.
객체지향 프로그래밍에서 객체들은 하나의 서비스를 동작시키기 위해 서로 메시지를 보내며 로직을 수행한다. 이런 상호작용을 협력이라고 칭한다. 또한 협력에 참여하기 위해 객체가 수행하는 일을 책임이라고한다. 객체들은 책임을 위해서 협력을 수행한다. 마지막으로 객체들은 책임을 위해 협력하고, 따라서 협력 내부에서 특정한 역할을 가진다.
기차표를 예매한다고할 때, 표값을 계산하거나 남은 좌석여부를 확인하는 작업은 기차(Train) 객체가 잘 알고, 외부에서는 Train 객체의 인스턴스에 접근하지 않는한 모른다.
만약 기차표 예매를 담당하는 Reserving 객체가 있다면 Train 객체의 내부 데이터를 사용해서 Train 객체 대신 기차의 좌석현황, 운임요금 등을 계산해서는 안된다. 오로지 기차표에 대한 정보를 가장 잘 아는 Train 객체가 내부 인스턴스를 통해 값을 반환할 것을 Reserving 객체가 요청하는 것이 바람직하다.
이 과정에서 Reserving 객체와 Train 객체를 "기차표 예매"라는 목적을 위해 협력하고 있고, Reserving 객체는 "기차표 예매"라는 책임을, Train 객체는 "기차 운임요금 계산"이라는 책임을 가지고 있다. 이처럼 각 객체는 목적을 가지고 서로 협력하며, 협력을 위해 책임을 수행한다.
객체지향 프로그래밍에서 가장 중요한 것이 책임이다. 또한 책임은 특정 로직을 수행해야할 때 필요한 정보를 가장 잘 알고 있는 객체에 할당하는 것이 좋다. 기차표 계산, 좌석확인 등의 책임은 Train 객체에게, 기차표 예매의 책임은 Reserving 객체에게 할당한 것은 모두 그 책임을 수행하는데 가장 정보를 많이 알고 있기 때문이다. 이를 정보 전문가 패턴 (Information expert)이라고 칭한다.
따라서 어느 객체에게 책임을 할당해야하는지가 중요한데, 시스템에서 최종적으로 해결하고자하는 책임을 찾은 후, 더 작은 책임으로 쪼개나가면서 객체에게 할당하는 과정을 거친다.
예를들어 "기차표 예매"라는 책임은 Reserving 객체가 담당하고, 보다 세부적인 책임인 "기차표 운임측정"이라는 책임은 출발역과 도착역 간 운임에 대한 정보가 있는 Train 객체가 담당할 수 있다. 이렇게 시스템이 제공할 책임을 찾고 작게 쪼개나가며 적절한 객체에게 이를 할당하는 전략이 중요하다. 또 여기서는 "기차표 예매"라는 책임도 등장하지만 "기차표를 예매하라"라는 메시지도 같이 등장한다. 결국 협력에 참가하기 위해 책임이 등장하고, 협력은 메시지를 통해 이루어지기 때문이다.
이를 통해 객체가 자율성을 보장받게된다. 자신의 상태를 직접 관리하고 스스로 행동하는 객체를 자율적인 객체라고 칭한다. 그리고 이를 위한 방법은 외부에서 특정 메시지를 받으면 적절히 내부 값을 토대로 로직을 수행한 후 값을 반환하는 캡슐화를 통해 이룰 수 있다. 또한 그 과정에서 모르는 정보가 생긴다면 다시 다른 객체에게 메시지를 보내야한다.
기차표 예매 사이트에서는 기차가 한 개만 다니는게 아니라 KTX, 새마을호, 무궁화호 열차도 다니기 때문에 운임을 여러 개로 계산할 수 있다. 하지만 공통점은 모두 외부에서는 운임요금을 계산할 것을 요청하면 무슨 일이 일어나는지는 모르겠지만 운임요금을 반환받을 것이다라는 점이다.
따라서 공통적인 "운임요금 계산" 메서드만 뽑아서 인터페이스로 제공할 수 있다.
public interface Train {
int calculateTrainFee(ReservationInfo reservationInfo);
}
public class KTXTrain implements Train {
public int calculateTrainFee(ReservationInfo reservationInfo) {
// ...
// 계산
// ...
}
}
public class SaemaulTrain implements Train {
public int calculateTrainFee(ReservationInfo reservationInfo) {
// ...
// 계산
// ...
}
}
그런 다음에 이 인터페이스의 구현체를 만들면 된다.
만약 좀 더 구체화하려면..
public interface Train {
int calculateTrainFee(ReservationInfo reservationInfo);
}
public abstract class AbstractTrain implements Train {
String departure;
String arrival;
int trainNumber;
protected AbstractTrain (String departure, String arrival, int trainNumber){
this.departure = departure;
this.arrival = arrival;
this.trainNumber = trainNumber;
}
int calculateTrainFee(ReservationInfo reservationInfo);
}
public class KTXTrain extends AbstractTrain {
public KTXTrain (String arrival, String destination, int trainNumber){
super(arrival, destination, trainNumber);
}
int calculateTrainFee(ReservationInfo reservationInfo){
//KTX 운임계산
}
}
...
KTXTrain ktxtrain = new KTXTrain("서울", "부산", 1);
int fee = ktxtrain.calculateTrainFee(reservationInfo);
...
뭐 이렇게 추상화 계층을 만들 수 있겠다.
암튼 중요한 것은 책임에 집중하여 한 책임을 수행할 객체를 할당한다. 그리고 할당받은 객체는 하나의 추상화된 인터페이스로 동작하게 된다.
이처럼 KTXTrain, SaemaulTrain 등, 하나의 책임을 수행하는 여러 가지 객체들이 존재하는데, 이 객체들은 공통적으로 "운임계산"이라는 책임을 수행한다. 따라서 이 책임의 목적을 역할이라고 칭할 수 있다. "운임계산"이라는 역할을 KTXTrain 객체나 SaemaulTrain 객체 등 여러 객체들이 수행할 수 있다.
협력에 참여하는 객체는 특정한 역할을 수행한다. 하나의 역할을 여러 객체가 구현할 수도있고, 하나의 객체가 역할을 수행할 수도 있다. 전자는 인터페이스나 추상클래스가, 후자는 객체가 기본적인 역할을 담당한다.
이렇게 내부 구현을 전혀 모르지만 추상클래스와 인터페이스만으로 코드를 재사용하는 방법을 합성이라고한다. 합성 이외의 방식으로는 상속이 있으나, 상속은 상위클래스와 하위클래스가 강하게 결합된다는, 반(反) 객체지향적이라는 단점이 있다. 따라서 상속보다는 합성을 주로 사용하는 것이 바람직하다.
다만 위의 추상클래스처럼 다형성의 이점을 위해 인터페이스를 재사용하려면 상속과 합성을 같이 사용해야한다.
암튼 그래서 중요한 것은 책임이다. 객체지향적 애플리케이션은 객체의 상호작용인 협력에 의해서 수행되며, 협력에 참여하기위해 객체들이 수행하는 로직이 책임이다. 따라서 책임이 가장 중요하며, 설계 시 시스템이 궁극적으로 제공할 책임을 생각해내고, 이를 잘게 분리해가며 적절한 객체에게 할당하는 방식으로 시스템 설계가 이루어져야한다. 그리고 이 방식을 RDD(Responsibility-Driven-Design, 책임지향 프로그래밍)라고 칭한다.
RDD나 객체지향 프로그래밍의 가장 큰 목적은 바로 낮은 결합도와 높은 응집도, 그로인한 수정이 용이한 코드설계에 있다. 따라서 책임 우선설계와 수정이 용이한 코드설계를 위해서는 캡슐화가 필수적이다.
캡슐화는 내부 상태를 모두 숨기고 외부에서 접근가능한 접근자 메서드만 제공하는 방식으로, 변수만 private로 선언하고 게터세터만 제공하는 것이 캡슐화가 아니라 내부 인스턴스 변수를 변경해야할 경우, 이 로직을 객체, 클래스 내부에 작성하고, 이에대한 접근자메서드를 제공하는 것을 캡슐화라고 한다.
이는 정보은닉의 목적 뿐 아니라, 이전에는 "xx객체 내부에는 oo변수가 있으니 게터를 이용해서 정보를 찾은 다음, 로직을 수행한 뒤에 다시 세터로 넣어주어야겠다"였지만, 캡슐화를 통해서 "xx객체 내부에 oo변수에 특정한 로직을 수행하고 싶은데, xx객체에 메시지를 보내면 응답하겠지"로 바뀐다. 따라서 외부클래스는 객체의 세부사항에 대해서 크게 알지 못해도 되며, 어떤 메서드를 통해 객체에 메시지를 보내야하는지만 알면 된다.
따라서 이런 캡슐화는 객체들끼리 협력에 참여하게하지만 서로 제대로된 구현은 모르게할 수 있다. 또한, 여러 객체들이 한 메시지에 응답할 수 있다면 이를 인터페이스나 추상클래스로 뽑아서 합성(composition)을 사용할 수도 있다.
여담으로 책임에 집중한 RDD가 아닌, 데이터에 집중한 데이터 기반 개발도 있다. RDD가 "이 책임은 누가 담당해야하는가?"에 집중했다면 데이터 중심 개발에서는 "이 객체는 어떤 데이터를 담아야하는가?"이다.
따라서 RDD에서는 다른 객체와의 협력을 위한 메서드를 작성하고, 이 메서드를 기반으로 필요한 상태를 변수에 저장한다. 데이터 중심에서는 객체가 담아야할 데이터를 저장하고, 이를 조작하는 메서드를 작성한다. 즉 인과관계가 반대라는 소리.
그래서 데이터 중심 설계에서는 게터세터가 필수적이다. 왜냐하면 RDD에서는 다른 객체와의 협력을 예상하고 게터세터가 아니더라도 객체의 책임을 수행하는 메서드만 제공하면되는데, 데이터 중심 설계에서는 책임과 협력을 모르기 때문에 우선은 게터세터만 제공하고 외부에서 내부 변경을 수행하는 로직을 맡긴다.
또한 아무리 게터세터를 많이 만들었어도 정보를 은닉하고 게터세터만 제공하는 기초적인 캡슐화는 캡슐화가 아니다.
저서의 내용을 인용해보자면,
"...이 때 (인스턴스 변수) fee의 타입을 변경한다고 가정해보자. 이를 위해서는 getFee 메서드의 반환 타입도 함께 수정해야할 것이다. 그리고 getFee 메서드를 호출하는 ReservationAgency의 구현도 변경된 타입에 맞게 함께 수정해야 할 것이다. fee의 타입 변경으로 인해 협력하는 클래스가 변경되기 때문에 getFee 메서드는 fee를 정상적으로 캡슐화하지 못한다. 사실 getFee 메서드를 사용하는 것은 인스턴스 변수 fee의 가시성을 private에서 public으로 변경하는 것과 거의 동일하다."
따라서 단순히 정보를 private로 바꾸고 게터세터만 제공하는 것은 진정한 캡슐화가 아니며, 객체에게 필수적인 메서드도 아니다. 객체에게 필수적인 메서드는 객체의 책임져야하는 무언가를 수행하는 메서드이다. 변수가 private이더라도 접근자와 수정자로 외부에 필드가 노출되면 캡슐화가 아니다.
객체는 자신이 어떤 데이터를 가지고있는지를 내부에 캡슐화하고 외부에 절대 공개해서는 안된다.
여기까지가 4장의 내용이다.
중요한 것들은 책임, 그리고 캡슐화, 그리고 책임은 오직 객체가 스스로 수행하여 자율적인 객체를 만들자. 그리고 책임에 대한 수행은 외부에서 메시지를 보내야한다.
내용이 다소 추상적이고 어려운 편인데, 오브젝트 1 ~ 4장을 읽어보면 쉽게 읽을 수 있을 것이다.
