객체지향에서 핵심은 역할, 책임, 협력

객체지향 설계의 핵심은 협력을 구성하기 위해 적절한 객체를 찾고 적절한 책임을 할당하는 과정

기능을 구현하기 위해 협력을 결정하고 협력을 위해 역할과 책임을 고민하지 않은 채 구현에 초점을 맞추는 것은 변경하기 어렵고 유연하지 못한 코드를 낳는 원인이 됨

 

객체지향 원칙을 따르는 흐름은 하나의 객체에 의해 통제되지 않고 다양한 객체들 사이에 균형있게 분배되는 것이 일반적

 

역할은 객체들이 협력 안에서 수행하는 책임들이 모여 객체가 수행하는 역할

 

협력

협력은 객체들이 기능을 구현하기 위해 수행하는 상호작용

두 객체 사이의 협력은 하나의 객체가 다른 객체에게 도움을 요청할 때 시작되는데, 객체는 다른 객체의 상세한 내부 구현에 직접 접근할 수 없기 때문에 메시지 전송을 통해서만 자신의 요청을 전달할 수 있음

메시지를 수신한 객체는 스스로 메시지를 처리할 메서드를 선택하고 실행해 요청에 응답하는데, 이것이 객체가 메시지를 스스로 처리할 수 있는 자율적인 존재라는 것을 의미

 

자율적인 객체란 스스로의 결정에 따라 행동하고 행동의 결과로 자신의 상태를 직접 관리하는 객체

따라서 자율성을 보장하기 위해서는 필요한 상태와 행동을 다른 객체가 아닌 스스로 처리해야 함

그리고 자신이 할 수 없는 일을 다른 객체에게 요청하면 협력에 참여하는 객체들의 전체적인 자율성을 향상시킬 수 있음

결과적으로 객체를 자율적으로 만드는 가장 기본적인 방법은 내부 구현을 캡슐화하여 결합도를 느슨하게 유지하고 변경의 영향이 최소화될 수 있음

 

객체란 상태와 행동을 함께 캡슐화하는 실행 단위

어떤 객체가 필요한 이유는 협력에 필요한 적절한 행동을 보유하고 있기 때문이므로 협력이 바뀌면 객체의 행동이 변경될 수 있음

결과적으로 협력이 객체의 상태와 행동을 모두 결정하기 때문에 협력은 객체를 설계하는 데 필요한 문맥(Context)를 제공

 

책임

책임은 객체가 협력에 참여하기 위해 수행하는 행동

객체를 설계하기 위해 필요한 문맥인 협력이 갖춰졌을 때 다음으로 할 일은 협력에 필요한 행동을 수행할 수 있는 적절한 객체를 찾는 것

 

책임은 여러 개의 메시지로 분할되기도 하고, 여러 객체들이 협력해야하만 하는 책임일 수도 있음

 

객체지향 설계에서 가장 중요한 것은 책임이므로 얼마나 적절한 책임을 할당하느냐가 설계의 전체적인 품질을 결정

객체의 구현 방법은 상대적으로 책임보다는 덜 중요하며 책임을 결정한 다음에 고민해도 늦지 않음

 

자율적인 객체를 만드는 가장 기본적인 방법은 책임을 수행하는 데 필요한 정보를 가장 잘 알고 있는 객체에게 책임을 할당하는 방법

이를 책임 할당을 위한 INFORMATION EXPERT(정보 전문가) 패턴이라고 부름

일부 경우에는 응집도와 결합도의 관점에서 정보 전문가가 아닌 다른 객체에게 책임을 할당하는 것이 더 적절할 수 있음

 

책임을 갖고 책임을 수행할 적절한 객체를 찾아 책임을 할당하는 방식으로 협력을 설계하는 방법을 책임 주도 설계(Responsibility-Driven Design, RDD)라고 부름

책임 주도 설계 방법의 과정

  1. 시스템이 사용자에게 제공해야 하는 기능인 시스템 책임을 파악
  2. 시스템 책임을 더 작은 책임으로 분할
  3. 분할된 책임을 수행할 수 있는 적절한 객체 또는 역할을 찾아 책임을 할당
  4. 객체가 책임을 수행하는 도중 다른 객체의 도움이 필요한 경우 적절한 객체 또는 역할을 찾음
  5. 해당 객체 또는 역할에게 책임을 할당하고 메시지를 요청하여 두 객체가 협력하게 함

 

객체가 협력에 적합한지를 결정하는 것은 상태가 아니라 행동

먼저 객체에 필요한 상태가 무엇인지를 결정하고, 상태에 필요한 행동을 결정한다면 내부 구현이 퍼블릭 인터페이스에 노출될 수 있고 캡슐화를 저해할 수 있음

캡슐화를 저해한다면 내부 구현을 변경할 때 퍼블릭 인터페이스도 함께 변경되고 변경의 영향이 전파됨

이와 같은 객체의 내부 구현에 초점을 맞춘 설계 방법을 데이터-주도 설계(Data-Driven Design)이라고 부름

 

메시지가 객체를 선택해야 하는 이유

필요한 메시지를 수신할 때에만 public 인터페이스를 추가하기 때문에 객체가 최소한의 인터페이스를 가질 수 있게 됨

메시지로 인해 무엇을 수행할지에 초점을 맞추는 public 인터페이스를 선언하기 때문에 객체는 충분히 추상적인 인터페이스를 가질 수 있게 됨

  • 객체의 인터페이스는 무엇을 하는지는 표현해야 하지만 어떻게 수행하는지는 노출해서는 안됨

 

역할

객체가 특정한 협력 안에서 수행하는 책임의 집합을 역할이라고 부름

 

협력을 모델링할 때는 특정한 객체가 아니라 역할에게 책임을 할당한다고 생각하는 것이 좋음

메시지를 처리하기에 적합한 객체를 선택하는 과정에서는 메시지를 처리할 수 있는 적절한 역할을 찾은 후 역할을 수행할 객체를 선택하는 과정이 내포됨

 

역할이 중요한 이유는 역할을 통해 유연하고 재사용 가능한 협력을 얻을 수 있음

같은 행동을 수행하는 객체가 두 가지일때 각 객체가 참여하는 협력을 개별적으로 만든다면 대부분의 코드가 중복될 수 있기 때문에 피해야 함

이런 문제를 해결하기 위해서는 객체가 아닌 책임에 초점을 맞추고 두 객체는 동일한 책임을 수행하기 때문에 두 객체를 역할로 통합해야 함

역할은 두 종류의 구체적인 객체의 타입을 캡슐화하는 추상화이기 때문에 포괄할 수 있는 추상적인 이름을 부여해야 함

  • 추상화의 첫 번째 장점은 불필요한 세부 사항을 생략하고 핵심적인 개념을 강조할 수 있음
  • 추상화의 두 번째 장점은 설계가 유연해짐

 

같은 행동을 수행하는 객체를 추가하기 위해 새로운 협력을 추가할 필요 없이 역할에 포함되어 협력에 참여할 수 있게 되므로 협력이 유연해짐

역할은 프레임워크나 디자인 패턴과 같이 재사용 가능한 코드나 설계 아이디어를 구성하는 핵심적인 요소

 

역할을 구현하는 가장 일반적인 방법은 추상 클래스와 인터페이스를 사용하는 것

추상클래스와 인터페이스는 협력의 관점에서체 클래스들이 따라야 하는 책임의 집합을 서술한 것으로, 동일한 책임을 수행하는 다양한 종류의 클래스들을 협력에 참여시킬 수 있는 확장 포인트를 제공

 

객체에 관해 생각할 때 '이 객체가 무슨 역할을 수행해야 하는가?'라고 자문하는 것이 도움이 됨

위 질문은 객체가 어떤 형태를 띠어야 하는지, 어떤 동작을 해야하는지에 집중할 수 있게 도와줌

 

협력에 참여하는 후보가 여러 종류의 객체에 의해 수행될 필요가 있다면 후보는 역할이 되지만, 단지 한 종류의 각체만이 협력에 참여할 필요가 있다면 후보는 객체가 됨

저자는 설계 초반에는 적절한 책임과 협력을 탐색하는 것이 가장 중요한 목표이고, 역할과 객체를 명확하게 구분하는 것은 중요하지 않다고 함

따라서 애매하면 단순하게 객체로 시작하고 반복적으로 책임과 협력을 정제해가면서 필요한 순간에 객체로부터 역할을 분리해내는 것이 가장 좋은 방법

 

객체는 협력이 끝나면 협력의 역할이 아닌 원래 객체로 인식될 수 있으며, 동일한 역할을 수행하는 하나 이상의 객체들이 존재하여 서로 대체 가능하고, 여러 역할을 수행할 수 있지만 협력 안에서는 하나의 역할만이 보여짐

기본 생성자는 아무런 인자도 받지 않고 호출될 수 있는 생성자

생성자는 객체를 초기화하는 역할을 맡기 때문에, 기본 생성자는 객체 관련 정보를 갖지 않고도 객체를 초기화

 

다른 생성자를 선언했다면 컴파일러가 기본 생성자를 자동으로 선언하지 않기 때문에 기본 생성자 방법으로 사용할 경우 컴파일 에러 발생

class EquipmentPiece
{
public:
    EquipmentPiece(int IDNumber);
};

vector<EquipmentPiece> bestPieces(5); // error

vector<EquipmentPiece> bestPieces{EquipmentPiece(5)}; // 정상
vector<EquipmentPiece> bestPieces{5, 6}; // 정상

 

class EquipmentPiece
{
public:
    EquipmentPiece(int IDNumber = UNSPECIFIED);
    
private:
    static const int UNSPECIFIED = 0;
};

필요 없는 기본 생성자가 있는 클래스는 멤버 함수에서 데이터가 제대로 초기화되었는지 검사해야 하니 런타임 속도가 떨어지고, 코드 크기 증가, 검사 실패했을 때 처리하는 코드를 생각해야 하므로 코드 품질이 좋지 않음

프로그램은 하드디스크 같은 저장장치에 보관되어 있는 정적인 상태를 의미

프로세스는 프로그램이 실행되어 메모리에 올라온 동적인 상태를 의미

 

CPU가 타임 슬라이스를 여러 프로세스에 적당히 배분함으로써 동시에 실행하는 것처럼 보임

 

프로그램에서 프로세스로의 전환

프로세스는 컴퓨터 시스템의 작업 단위로 태스크라고도 부름

 

1. 운영체제는 프로그램을 메모리에 적재하고, 프로세스 제어 블록(PCB, Process Control Block)을 생성

  • PCB는 운영체제가 해당 프로세스를 관리하는 데이터 구조이기 때문에 메모리의 운영체제 영역에 만들어짐
  • 운영체제도 프로그램이므로 프로세스(커널 프로세스)로 실행되기 때문에 PCB가 있음

2. 프로세스가 종료되면 프로세스가 메모리에서 삭제되고 PCB도 폐기됨

 

프로세스의 상태

 

생성 상태

프로세스가 메모리에 올라와 실행 준비를 완료한 상태

운영체제로부터 PCB를 할당받은 상태

 

준비 상태

생성된 프로세스가 CPU를 얻을 때까지 기다리는 상태

CPU 스케줄러가 준비 상태의 프로세스 중 하나를 실행 상태로 바꿔 CPU에 PCB를 전달하는 작업을 디스패치라고 함

 

실행 상태

준비 상태에 있는 프로세스 중 하나가 CPU를 얻어 작업을 수행하는 상태

타임 슬라이스 동안 CPU를 사용할 권리를 갖고, 작업이 끝나지 않았다면 준비 상태로 돌아와 CPU를 얻기 위해 기다림(타임아웃)

CPU는 타임 슬라이스가 지난 뒤 클럭이 인터럽트를 전송

 

대기 상태

입출력을 요청한 프로세스가 입출력이 완료될 때까지 기다리는 상태

효율을 높이기 위해 입출력을 요청한 프로세스가 대기 상태가 되면 CPU 스케줄러는 준비 상태에 있는 프로세스를 실행함

입출력이 완료되면 입출력 관리자로부터 인터럽트를 받아 대기 상태로 전환, 만약 실행 상태로 전환한다면 현재 진행 중인 프로세스를 중단해야 하므로 복잡하기 때문에 대기 상태로 기다림

인터럽트가 들어왔을 때 효율적으로 프로세스를 찾기 위해 같은 입출력을 요구한 프로세스끼리 모아 놓음

 

완료 상태

실행 상태의 프로세스가 작업을 마친 상태

코드, 사용했던 데이터, PCB를 메모리에서 삭제

만약 오류나 다른 프로세스에 의해 비정상적으로 종료된다면 디버깅하기 위해 종료 직전의 메모리 상태를 저장장치로 옮기는데 이를 코어 덤프라고 함

PCB(프로세스 제어 블록)

프로세스를 실행하는 데 필요한 중요한 정보를 보관하는 자료 구조

보통 커널 내에 프로세스 테이블의 형태로 관리됨

모든 프로세스는 고유한 PCB를 가지며, 프로세스 생성 시 만들어지고 실행을 완료하면 폐기됨

 

PCB 구성

 

포인터PCB를 연결하여 프로세스의 준비 상태나 대기 상태의 큐를 구현할 때 포인터를 사용

프로세스 우선순위 : CPU 스케줄러가 준비 상태에 있는 프로세스 중 실행 상태로 옮겨야 할 프로세스를 선택할 때 프로세스 우선순위를 기준으로 삼음

각종 레지스터 정보 : 프로세스가 실행되는 중에 사용하던 레지스터 값들이 저장하여 다음에 실행할 때 저장된 레지스터 값을 사용

메모리 관리 정보 : 프로세스의 메모리 주소를 나타내는 메모리 위치 정보, 메모리 경계 레지스터 값과 한계 레지스터 값, Segmentation Table, Page Table 등 저장

할당된 자원 정보 : 프로세스를 실행하기 위해 사용하는 입출력 자원이나 오픈 파일 등에 대한 정보

계정 정보 : 계정 번호, CPU 할당 시간, CPU 사용 시간 등

PPID(Parent PID), CPID(Child PID)

 

Context Switch(문맥 교환)

기존 프로세스를 종료하고 새로운 프로세스의 실행을 준비하는 과정

기존 프로세스의 PCB에 작업 내용을 저장하고, 실행하는 프로세스의 PCB 내용으로 CPU를 설정

 

프로세스에 할당된 타임 슬라이스를 모두 사용한 경우, 인터럽트가 발생하여 인터럽트를 처리하는 경우 등에 컨텍스트 스위치가 일어남

잦은 컨텍스트 스위칭은 캐시 미스가 발생할 가능성이 높아져 메모리로부터 실행할 프로세스의 내용을 가져오는 작업이 빈번해져 큰 오버헤드 발생 가능

  • 시스템 콜을 사용하면 사용자 모드에서 커널 모드로 전환하는 과정에서 컨텍스트 스위칭이 발생

 

프로세스의 구조

프로세스는 코드 영역, 데이터 영역, 힙 영역, 스택 영역으로 구성됨

 

코드 영역

프로그래머가 작성한 프로그램은 코드 영역에 저장됨

읽기 전용(자기 자신을 수정하는 프로그램은 없기 때문)

 

데이터 영역

프로그램이 실행되는 동안 유지할 데이터(정적(static)/전역 변수)가 저장되는 공간

BSS(Block Started by Symbol) 영역 : 초기값이 없는(0으로 초기화) 데이터가 저장되는 공간

읽기/쓰기가 모두 가능한 영역과 읽기 전용 영역으로 나뉨

 

힙 영역

프로세스가 실행되는 동안 동적으로 할당되는 영역

 

스택 영역

프로세스를 실행하기 위해 일시적으로 사용할 값들이 저장되는 공간으로 지역 변수(매개 변수 포함), 함수 복귀 주소 등이 저장됨

 

프로세스가 실행되는 동안 만들어지는 동적 할당 영역은 레지스터 값, 스택, 힙 등

 

스레드

프로세스의 코드에 정의된 절차에 따라 CPU에 작업 요청을 하는 실행 단위

(운영체제 입장에서의 작업 단위는 프로세스, CPU 입장에서의 작업 단위는 스레드)

프로세스는 하나 이상의 스레드로 구성됨

 

하나의 스레드에는 스레드 ID와 프로그램 카운터, 레지스터 값, 스택 등으로 구성

스레드마다 다음에 실행할 주소와 연산 과정의 임시 저장 값을 가짐

 

프로세스와 스레드의 차이

프로세스는 다른 프로세스에 큰 영향을 미치지 않기 때문에 순서를 바꾸어도 문제 없음

  • 서로 독립적인 프로세스는 데이터를 주고받을 때 프로세스 간 통신(IPC, Inter Process Communication)을 이용

스레드는 다른 스레드에 큰 영향을 미칠 수 있기 때문에 순서를 바꾸면 문제가 될 수 있음

(예를 들면 B 스레드는 A 스레드가 끝난 뒤 실행해야 한다면 순서를 바꿀 때 문제 발생)

  • 멀티스레드는 변수나 파일 등을 공유할 수 있고, 전역 변수나 함수 호출 등의 방법으로 스레드 간 통신을 함

프로세스 내의 다른 스레드로 컨텍스트 스위칭할 때 다른 프로세스로 컨텍스트 스위칭하는 것보다 빠른 이유는 스레드는 독립적인 스택 영역만 있어 비교적 적은 메모리를 컨텍스트 스위칭하여 적은 캐시 미스, 적은 메모리 접근 횟수, 적은 레지스터 변경 때문

 

스레드 관련 용어

멀티스레드 : 프로세스 내 작업을 여러 개의 스레드로 분할함으로써 작업의 부담을 줄이는 프로세스 운영 기법

멀티태스킹(시분할 시스템) : 운영체제가 CPU에 작업을 줄 때(스레드에) 시간을 잘게 나누어 배분하는 기법

멀티프로세싱 : 독립적인 여러 개의 프로세스를 여러 개의 CPU 혹은 하나의 CPU 내 여러 개의 코어에 스레드를 배정하여 동시에 처리하는 작업 환경

  • 예시 : 네트워크로 연결된 여러 컴퓨터에 스레드를 나누어 협업하는 분산 시스템

(CPU) 멀티스레드 : 한 번에 하나씩 처리해야 하는 스레드를 파이프라인 기법을 이용하여 동시에 여러 스레드를 처리하도록 만든 병렬 처리 기법

 

멀티스레드 장점

한 스레드가 입출력으로 인해 작업이 진행되지 않더라도 다른 스레드가 작업을 계속하여 사용자의 작업 요구에 빨리 응답할 수 있음

한 프로세스 내에서 독립적인 스레드를 생성하면 프로세스가 가진 자원(코드, 데이터, 힙)을 모든 스레드가 공유

여러 개의 프로세스를 생성하는 것과 달리 불필요한 자원의 중복(중복되는 프로세스의 정적 영역)을 막음으로써 메모리 할당 시간 및 공간 감소

2개 이상의 CPU를 가진 컴퓨터에서 멀티스레드를 사용하면 다중 CPU가 멀티스레드를 동시에 처리하여 CPU 사용량이 증가하고 프로세스의 처리 시간이 단축

 

멀티스레드 단점

멀티스레드는 모든 스레드가 자원을 공유하기 때문에 한 스레드에 문제가 생기면 전체 프로세스에 영향을 미침

 

사용자 레벨 스레드

라이브러리에 의해 구현된 일반적인 스레드

운영체제가 멀티스레드를 지원하지 않을 때 사용하는 방법으로, 초기의 스레드 시스템에서 이용됨

사용자 레벨에서 스레드를 구현하기 때문에 관련 라이브러리를 사용하여 구현하며, 라이브러리는 커널이 지원하는 스케줄링이나 동기화 같은 기능을 대신 구현해주기 때문에 커널 입장에서 하나의 프로세스처럼 보임

따라서 사용자 프로세스 내의 여러 스레드가 존재하지만 커널의 스레드 하나와 연결되기 때문에 1 to N 모델이라고 부름

 

라이브러리가 직접 스케줄링을 하고 작업에 필요한 정보를 처리하기 때문에 문맥 교환이 필요 없어 속도가 빠름

(서로 다른 프로세스를 동시에 실행하지 않기 때문)

 

여러 개의 스레드가 하나의 커널 스레드와 연결되기 때문에 커널 스레드가 입출력 작업을 위해 대기 상태가 되면 모든 사용자 레벨 스레드가 같이 대기 상태가 되는 단점

한 프로세스의 타임 슬라이스를 여러 스레드가 공유하기 때문에 여러 개의 CPU를 동시에 사용할 수 없는 단점

기능을 커널이 아닌 라이브러리에 구현해야 하기 때문에 보안에 취약

 

커널 레벨 스레드

커널이 직접 생성하고 관리하는 스레드

커널이 멀티스레드를 지원하는 방식으로, 하나의 사용자 스레드가 하나의 커널 스레드와 연결되기 때문에 1 to 1 모델이라고 부름

 

독립적으로 스케줄링이 되므로 특정 스레드가 대기 상태가 되어도 다른 스레드는 작업을 계속할 수 있음

커널이 제공하는 보호 기능과 같은 모든 기능을 사용할 수 있음

커널 레벨에서 모든 작업을 지원하기 때문에 멀티 CPU를 사용할 수 있음

 

스레드(혹은 프로세스) 전환이 발생할 때 문맥 교환으로 인한 오버헤드 때문에 느리게 작동

 

멀티레벨(하이브리드) 스레드

사용자 레벨 스레드와 커널 레벨 스레드를 혼합한 방식으로 M to N 모델이라고 부름(M >= N)

 

하나의 커널 스레드가 대기 상태가 되면 다른 커널 스레드가 대신 작업하여 사용자 레벨 스레드보다 유연하게 작업을 처리할 수 있음

커널 레벨 스레드를 같이 사용하기 때문에 여전히 문맥 교환 오버헤드가 있어 사용자 레벨 스레드만큼 빠르지 않음

따라서 빨리 작업해야 하는 스레드는 사용자 레벨 스레드로 작동하고, 안정적으로 작업해야 하는 스레드는 커널 레벨 스레드로 작동함

상속의 장점은 기본 클래스 객체의 포인터나 참조자를 통해 파생 클래스 객체를 조작할 수 있는 점

 

class BST { ... };
class BalancedBST : public BST { ... };

void printBSTArray(ostream& s, const BST array[], int numElements)
{
    for(int index = 0; index < numElements; ++index)
    {
        s << array[index]; // BST 클래스에서 operator<<이 정의되어 있다고 가정
    }
}

BST BSTArray[10];
printBSTArray(cout, BSTArray, 10); // 정상

BalancedBST bBSTArray[10];
printBSTArray(cout, bSTArray, 10); // 미정의 동작

array[index]의 표현식은 *(array + index)이고, array+index가 가리키는 메모리 위치는 array + index * sizeof(BST)

따라서 컴파일러는 배열 내의 요소 위치를 정확히 지정하는 코드를 생성하기 위해 배열의 요소 객체 크기를 결정할 수 있어야 함

 

하지만 BalancedBST의 배열을 printBSTArray 함수에 전달하면 컴파일러는 배열의 요소 객체 크기를 BST라고 가정하지만 실제로는 BalancedBST의 크기를 갖고 있기 때문에 정확한 메모리 위치를 지정할 수 없어 미정의 동작 발생

 

결론적으로, 배열은 포인터의 산술 연산을 같이 할 뿐이므로 다형성을 갖고 있다는 생각은 하지 말자

C 스타일 캐스팅의 문제점

어떤 타입을 다른 타입으로 조건 없이 변경하는 것이 가능하기 때문에 버그가 발생할 수 있음

문법적으로 식별자를 괄호로 감쌌기 때문에 한 눈에 알아보기 어려움

int i = 1;
double d = (double)(i);

 

C++ 캐스트 연산자

C 스타일의 캐스팅의 문제점을 보완하기 위해 4개의 캐스팅을 도입

컴파일러가 캐스팅 에러를 찾아낼 수 있고, 한 눈에 알아보기도 쉬움

아래와 같은 문법으로 사용함

static_cast<타입>(표현식)

 

static_cast

C 스타일 캐스팅과 똑같은 캐스팅인 기본적인 캐스트 연산자

C 스타일 캐스팅과 똑같은 제약을 받아 struct를 int로 바꾸는 등의 작업을 하지 못함 

 

const_cast

상수성이나 휘발성(volatileness)를 없애는 데 사용

상수성이나 휘발성을 제거하는 것 이외의 용도로 const_cast를 사용하면 컴파일러 에러 발생

 

dynamic_cast

상속 계층 관계에 관련된 클래스 타입으로 안전하게 캐스팅할 때 사용

캐스팅의 실패는 nullptr이나 예외를 보고 판별할 수 있음

가상 함수가 없는 타입에는 적용할 수 없음

 

reinterpret_cast

포인터/정수 간의 변환 등 저수준 타입 재해석에 사용

거의 모든 포인터 변환을 허용하므로 매우 위험할 수 있음

 

dynamic_cast는 런타임에 실행되고 나머지는 컴파일 타임에 실행됨

진정한 객체지향 패러다임으로의 전환은 클래스가 아닌 객체에 초점을 맞출 때에만 얻을 수 있음

 

어떤 클래스가 필요한지를 고민하기 전에 어떤 객체들이 필요한지 고민하라
클래스는 공통적인 상태와 행동을 공유하는 객체들을 추상화한 것

따라서 클래스를 구현하기 위해서는 객체가 어떤 상태와 행동을 가지는지 먼저 결정해야 함

 

객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야 한다

객체를 협력하는 공동체의 일원으로 바라보는 것은 설계를 유연하고 확장 가능하게 함

 

도메인

문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야

객체지향 패러다임의 장점은 요구사항을 분석하는 초기부터 마지막까지 객체라는 동일한 추상화 기법을 사용할 수 있는 것

요구사항과 프로그램을 객체라는 동일한 관점으로 볼 수 있기 때문에 도메인을 구성하는 개념들이 객체와 클래스로 연결될 수 있음

 

예제 영화 도메인

 

자율적인 객체

객체는 상태행동을 함께 가지는 복합적인 존재이고, 스스로 판단하고 행동하는 자율적인 존재

객체지향은 객체 단위에 캡슐화(데이터와 기능을 객체 내부로 함께 묶는 것)를 통해 스스로 책임을 수행

 

대부분의 객체지향 프로그래밍 언어들은 상태와 행동을 캡슐화하는 것을 넘어 외부에서 접근을 통제할 수 있는 접근 수정자(public, protected, private)를 제공

객체 내부에 대한 접근을 통제하는 이유는 외부의 간섭을 최소화하여 상태와 행동을 변경하지 않고 객체를 자율적인 존재로 만들기 위함

 

캡슐화와 접근 수정자는 외부에서 접근 가능한 public 인터페이스와 내부에서만 접근 가능한 구현으로 나뉨

일반적으로 객체의 상태는 숨기고 행동만 외부에 공개해야 하므로 속성은 private로 선언해서 감추고, 외부에 제공해야 하는 일부 메서드만 public으로 선언해야 함

(어떤 메서드들이 서브 클래스나 내부에서만 접근 가능해야 한다면 protected, private로 지정해야 함)

public 인터페이스는 public으로 지정된 메서드만 포함되고, 구현은 private, protected 메서드, 속성이 포함됨

 

구현 은닉을 통해 클라이언트 프로그래머는 내부의 구현을 무시한 채 인터페이스만 알고 있어도 클래스를 사용할 수 있기 때문에 알고 있어야 하는 지식의 양을 줄일 수 있고, 클래스 작성자는 인터페이스를 바꾸지 않는 한 외부에 미치는 영향을 걱정하지 않고도 내부 구현을 변경할 수 있음

 

협력하는 객체들의 공동체

금액을 구현하기 위해 int와 같은 기본 타입을 사용하는 것보다 타입을 새로 정의한다면 저장하는 값이 금액과 관련돼 있다는 의미를 전달할 수 있고 금액과 관련된 로직이 서로 다른 곳에 중복되어 구현되는 것을 막을 수 있음

금액과 같은 개념이 하나의 인스턴스 변수만 포함하더라도 개념을 명시적으로 표현하는 것은 전체적인 설계의 명확성과 유연성을 높일 수 있음

 

협력

시스템의 기능을 구현하기 위해 객체들 사이에 이뤄지는 상호작용

 

객체지향 프로그램을 작성할 때는 먼저 협력의 관점에서 어떤 객체가 필요한지를 결정하고 객체들의 공통 상태와 행위를 구현하기 위한 클래스르 작성해야 함

객체는 다른 객체의 인터페이스에 공개된 행동을 수행하도록 요청할 수 있고, 요청을 받은 객체는 자율적인 방법에 따라 요청을 처리한 후 응답

객체가 다른 객체에게 요청할 수 있는 유일한 방법은 메시지를 전송하는 것 뿐이고, 요청이 도착할 때 해당 객체가 메시지를 수신

메시지를 수신한 객체는 자율적으로 메시지를 처리할 방법(메서드)을 결정

 

하나의 영화에 하나의 할인 정책만 설정할 수 있는 제약을 생성자에서 매개변수를 통해 적용

class Movie
{
public:
    Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy)
        : _discountPolicy(discountPolicy)
    {}
};

 

상속, 다형성, 추상화

부모 클래스인 DiscountPolicy 안에 중복 코드를 두고 AmountDiscountPolicy와 PercentDiscountPolicy가 상속받는 구조

DiscountPolicy는 인스턴스를 생성할 필요가 없기 때문에 추상(abstract) 클래스로 구현

(C++에서 추상 클래스는 순수 가상 함수(추상 메서드)가 있는 클래스로 구현)

 

class DiscountPolicy
{
public:
    Money calculateDiscountAmount(Screening screening)
    {
        for(DiscountCondition each : conditions)
        {
            if (each.isSatisfiedBy(screening))
            {
                return getDiscountAmount(screening);
            }
        }
    }
    
    return Money::Zero;
    
protected:
    Money getDiscountAmount(Screening Screening) = 0;
    
private:
    vector<DiscountCondition> conditions;
};

이처럼 부모 클래스에 기본적인 알고리즘의 흐름을 구현하고 중간에 필요한 처리를 자식 클래스에 위임하는 디자인 패턴을 Template Method 패턴이라고 부름

 

 

오버라이딩

부모 클래스에서 정의된 같은 이름, 같은 파라미터 목록을 가진 메서드를 자식 클래스에서 재정의하는 경우

자식 클래스의 메서드는 오버라이딩한 부모 클래서의 메서드를 가리기 때문에 외부에서는 부모 클래스의 메서드가 보이지 않음

 

오버로딩

메서드의 이름은 같지만 제공되는 파라미터의 목록이 다름

원래의 메서드를 가리지 않기 때문에 공존

Money plus(Money money)
{
    return Money(amount.add(money.amount));
}

Money plus(long money)
{
    return MMoney(amount.add(money));
}

 

어떤 클래스가 다른 클래스에 접근할 수 있는 경로를 가지거나 해당 클래스의 객체의 메서드를 호출할 경우 두 클래스 사이의 의존성이 존재

Movie는 코드 상에서는 DiscountPolicy에 의존하지만 런타임에는 AmountDiscountPolicy 혹은 PercentDiscountPolicy에 의존하게 됨

즉, 클래스 사이의 의존성과 객체 사이의 의존성이 동일하지 않을 수 있음

코드의 의존성과 런타임 의존성이 다를수록 객체를 생성하고 연결하는 부분을 찾아야하기 때문에 코드를 이해하기 어렵지만 유연해지고 확장 가능해짐

따라서 훌륭한 객체지향 설계자로 성장하기 위해서는 유연성과 가독성 사이에서 고민해야 함

 

상속

두 클래스 사이의 관계를 정의하는 방법

객체지향에서 코드를 재사용하는 방법으로, 클래스 사이에 관계를 설정하는 것만으로 부모 클래스가 가진 속성과 메서드를 자식 클래스에 포함됨

이처럼 부모 클래스와 다른 부분만을 추가해서 자식 클래스를 쉽고 빠르게 만드는 방법을 차이에 의한 프로그래밍이라고 부름

 

일반적으로 자식 클래스는 부모 클래스가 수신할 수 있는 모든 메시지를 수신할 수 있기 때문에 외부 객체는 자식 클래스를 부모 클래스와 동일한 타입으로 간주할 수 있음

class Movie
{
public:
    Money calculateMovieFee(Screening screening)
    {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
};

Movie는 협력 객체가 calculateDiscountAmount라는 메시지를 이해할 수 있다면 객체가 어떤 클래스의 인스턴스인지는 상관하지 않는다는 것을 의미

이처럼 자식 클래스가 부모 클래스를 대신하는 것을 업캐스팅(upcasting)이라고 부름

 

구현 상속(서브클래싱)은 순수하게 코드를 재사용하기 위한 목적으로 상속을 사용하는 것

인터페이스 상속(서브타이핑)은 다형적인 협력을 위해 부모 클래스와 자식 클래스가 인터페이스를 공유할 수 있도록 상속을 이용하는 것

 

인터페이스 클래스는 구현에 대한 고려 없이 다형적인 협력에 참여하는 클래스들이 공유 가능한 외부 인터페이스를 정의하는 것

C++에서는 추상 클래스를 통해 인터페이스 개념을 구현할 수 있음

 

상속의 가장 큰 문제점은 캡슐화를 위반하는 것

부모 클래스의 구현이 자식 클래스에게 노출되기 때문에 캡슐화가 약화되고 자식 클래스가 부모 클래스에 강하게 결합되고, 부모 크래스의 내부 구조를 잘 알고 있어야 함

결과적으로 상속을 과도하게 사용한 코드는 변경하기 어려워짐

 

상속의 두 번째 단점은 설계가 유연하지 않음

상속은 부모 클래스와 자식 클래스 사이의 관계를 컴파일 시점에 결정하므로 런타임에 객체의 종류를 변경하는 것이 불가능

따라서 해결하는 방법은 변경하려는 객체의 상태를 복사하는 방법이 유일

 

합성

다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법

상속과 다르게 인터페이스를 통해 약하게 결합되므로 인터페이스 정의된 메시지를 통해서만 코드를 재사용할 수 있음

 

상속이 가지는 두 가지 문제점을 모두 해결하여 인터페이스 정의된 메시지를 통해서만 코드를 재사용할 수 있기 대문에 구현을 효과적으로 캡슐화할 수 있고, 인스턴스를 교체하는 것이 비교적 쉽기 때문에 설계를 유연하게 만듦

 

대부분의 설계에서는 상속과 합성을 함께 사용해야 한다.

코드를 재사용하는 경우에는 상속보다 합성을 선호하는 것이 옳지만 DiscountPolicy의 관계와 같이 다형성을 위해 인터페이스를 재사용하는 경우에는 상속과 합성을 함께 조합해서 사용해야 함

 

다형성

위에서 코드 상에서는 Movie는 calculateDiscountAmount 메시지를 전송하고 런타임에 실행하는 메서드는 discountPolicy 객체의 클래스에 따라 달라지는 것을 다형성이라고 부름

즉, 동일한 메시지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력

컴파일 시간 의존성과 런타임 의존성을 다르게 만들 수 있는 객체지향의 특성을 이용해 서로 다른 메서드를 실행할 수 있게 함

 

실행될 메서드를 런타임에 결정하므로 지연 바인딩 또는 동적 바인딩이라고 부름

실행될 메서드를 컴파일 시점에 결정하는 것을 초기 바인딩 또는 정적 바인딩이라고 부름

 

다형성이란 추상적인 개념이며 구현할 수 있는 방법은 다양함

 

추상화

자식 클래스를 생략한 코드 구조를 표현한 그림을 통해 추상화를 사용할 경우 두 가지 장점을 보여줌

 

첫 번째 장점은 세부적인 내용을 무시한 채 상위 정책을 쉽고 간단하게 표현할 수 있음

그림을 한 문장으로 정리하면 영화 예매 요금은 최대 하나의 할인 정책과 다수의 할인 조건을 이용해 계산할 수 있다로 표현

따라서 필요에 따라 개념을 세부적인 내용으로 설명할 수도, 상위 정책만으로 설명할 수도 있음

 

추상화를 이용해 상위 정책을 기술하는 것은 기본적인 어플리케이션의 협력 흐름을 기술하는 것을 의미

새로운 자식 클래스들은 추상화를 이용해서 정의한 상위 정책을 그대로 따르게 됨

재사용 가능한 설계의 기본을 다루는 디자인 패턴이나 프레임워크 모두 추상화를 이용해 상위 정책을 정의하는 객체지향의 메커니즘을 활용

 

두 번째 장점은 추상화를 이용해 상위 정책을 표현하면 기존 구조를 수정하지 않고도 새로운 기능을 쉽게 추가하고 확장할 수 있음

설계가 구체적인 상황에 결합되는 것을 방지하기 때문에 추상화가 유연한 설계를 가능하게 함

class Movie
{
public:
    Money calculateMovieFee(Screening screening)
    {
        if(discountPolicy == nullptr)
        {
            return calculateMovieFee();
        }
        
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
};

위 코드의 문제점은 할인 정책이 없는 경우를 예외 케이스로 취급하기 때문에 일관성이 무너지고, 할인 정책이 없는 경우에는 할인 금액이 0원을 결정하는 책임이 DiscountPolicy가 아닌 Movie 쪽에 있기 때문

따라서 책임의 위치를 결정하기 위해 조건문을 사용하는 것은 협력 설계 측면에서 대부분 좋지 않은 선택이므로 항상 예외 케이스를 최소화하고 일관성을 유지할 수 있는 방법을 선택해야 함

 

Movie의 인스턴스에 NoneDiscountPolicy 인스턴스를 연결해서 일관성을 유지하는 방법

class NoneDiscountPolicy : public DiscountPolicy
{
protected:
    virutal Money calculateDiscountAmount(Screening Screening) override final
    {
        return Money.Zero;
    }
};

+ Recent posts

목차