본문 바로가기
Concepts/SW Architecture

프로그래밍 패러다임 - 객체지향 프로그래밍

by ocwokocw 2021. 2. 10.

- 이 글은 로버트 C.마틴의 Clean Architecture를 기반으로 작성되었습니다. (가능하면 책을 읽어보는것을 추천한다.)

- 객체지향이란?

객체지향 (Object Oriented)은 프로그래머라면 당연히 들어봤을법한 너무도 유명한 개념이다. 객체지향이란 무엇인가?

 

어떤 사람들은 데이터와 함수의 조합이라고 정의한다. 하지만 이렇게 정의하기엔 너무 단순하고 객체지향 이전에도 데이터 구조를 함수와 조합해서 사용해왔다. 

 

또는 실제 세계를 모델링하는 방법이라고 정의한다. 이 정의는 앞의 정의보다 더 심오하긴 하지만 너무나 모호하다. 보통 OO의 정의를 설명하기 위해 캡슐화, 상속, 다형성을 언급하기도 하는데 이는 하나씩 자세히 살펴볼 필요가 있다.


- 캡슐화

OO정의에서 캡슐화를 언급하는 이유는 OO 언어에서 데이터와 함수의 캡슐화를 쉽게 제공하기 때문이다. 이런 특징은 클래스에서 private 접근자 데이터와 public 멤버 함수로 사상된다. 그런데 사실 C 언어에서도 캡슐화가 가능하다.

point.h

 

struct Point;
struct Point* makePoint(double x, double y);
double distance (struct Point *p1, Point *p2);

 

point.c

 

#include "point.h"
.....

struct Point{
    double x,y;
}

struct Point* makePoint(double x, double y){
    //....중략....
}

double distance(struct Point* p1, struct Point* p2){
    //....중략....
}

 

point.h 사용자측에서 Point 구조체에 접근하지 못하며 함수만 호출 가능하다. OO가 아닌 언어에서도 이처럼 충분히 캡슐화가 가능하다.

 

OO 언어에서 이를 어떻게 구현할까? Java 에서 이를 구현해보자.

 

public class Point {

	private double x;
	private double y;
	
	public Point(double x, double y) {
		super();
		this.x = x;
		this.y = y;
	}

	public double distance(Point p1) {
		
		//중략
	} 
}

 

Java 에서는 헤더와 구현체를 분리하는 방식이 아니라서 오히려 캡슐화가 더 심하게 회손되었다고 볼 수 있다. 멤버 변수를 사용하려고할 때 멤버 접근자에 의해 막히겠지만 사용하는 측에서 멤버 변수에 대해 알게된것이다. 오히려 C보다 캡슐화가 더 약해졌다. 이런 관점으로 볼 때, OO 의 정의를 언급할 때 캡슐화를 언급하기는 애매하다.


- 상속

상속이란 변수와 함수를 하나의 유효 범위로 묶어서 재정의하는 일이다. 이 역시 OO언어 이전에 C에서도 구현가능했었다. 앞서 사용한 C의 Point를 상속해보자.

namedPoint.h

 

struct NamedPoint;

struct NamedPoint* makeNmaedPoint(double x, double y, char* name);
void setName(struct NamedPoint* np, char* name);
char* getName(struct NamedPoint* np);

 

namedPoint.c

 

#include "namedPoint.h"
.....

struct NamedPoint{
    //중략
}

struct NamedPoint* makeNmaedPoint(double x, double y, char* name){
    //중략
}

void setName(struct NamedPoint* np, char* name){
    //중략
}

char* getName(struct NamedPoint* np){
    //중략
}

 

main.c

 

#include "point.h"
#include "namedPoint.h"
//중략

int main(int ac, char** av){
    struct NamedPoint* origin = makeNamedPoint(0.0, 0.0, "origin");
    struct NamedPoint* upperRight = makeNamedPoint(1.0, 1.0, "upperRight");
    double distanceBetweenPoints = distance((struct Point*) origin, (struct Point*) upperRight);
}

 

이렇게 OO 언어 이전에도 상속과 비슷한 기법이 사용됨을 알 수 있다. 하지만 OO 언어의 상속을 완벽하게 대체할 수 있는 기법은 아니다. Java 에서 상속을 이용하여 NamedPoint 를 구현해보자.

 

public class NamedPoint extends Point{

	String name;
	
	public NamedPoint(double x, double y) {
		super(x, y);
	}

	public NamedPoint(double x, double y, String name) {
		super(x, y);
		this.name = name;
	}
}

public static void main(String[] args){

	NamedPoint origin = new NamedPoint(0.0, 0.0, "origin");
	NamedPoint upperRight = new NamedPoint(0.0, 0.0, "upperRight");
	
	double distanceBetweenPoints = origin.distance(upperRight);
}

 

C와 비교했을 때 Java 에서의 상속이 일단 훨씬 편리하다. 또 거리를 구하는 과정에서 거리를 구할 때 강제로 Point로 업캐스팅을 해야했던 C와는 다르게 암묵적으로 캐스팅되었음을 알 수 있다.

OO정의 측면에서 상속 언급은 부분적으로 맞는말이라 할 수 있겠다


- 다형성

OO 언어 이전의 C언어에서도 다형성을 표현할 수 있었다.

 

#include <stdio.h>

void copy(){
    int c;
    while((c = getchar()) != EOF){
        putchar(c);
    }
}

 

putchar와 getchar 함수는 각각 STDOUT과 STDIN 에서 문자를 쓰고 읽는다. 이때 함수 즉 행동이 STDOUT과 STDIN 의 타입에 의존하니 다형적이라고 할 수 있다.

 

유닉스 운영체제는 5가지 함수를 구현해야 한다. (open, close, read, write, seek) FILE 데이터 구조는 이 함수들을 가리키는 포인터들을 포함한다.

 

struct FILE {
	void (*open)(char* name, int mode);
	void (*close)();
	int (*read)();
	void (*write)(char);
	int (*seek)(long index, int mode);
}

#include "file.h"
void open(char* name, int mode) {/*Impl*/}
void close() {/*Impl*/}
int read() {/*Impl*/}
void write(char) {/*Impl*/}
int seek(long index, int mode) {/*Impl*/}

struct FILE console = {open, close, read, write, seek};

extern struct FILE* STDIN;

int getchar(){
	return STDIN -> read();
}

 

getchar()는 STDIN 으로 참조되는 FILE 데이터구조의 read 포인터가 가리키는 함수를 단순히 호출하였다. 함수를 가리키는 포인터가 다형성의 근간이 되었다.

 

OO에서 전혀 나아진것이 없는것은 아니다. 이런 함수형 포인터 사용은 위험하다. 초기화 및 따라야할 관례들이 아주 많고 이를 지키지 않으면 버그 발생시 추적이 매우 어려워 진다. 하지만 OO에서는 이러한 점을 프로그래머가 신경쓰지 않아도 다형성을 제공해준다.


- 의존성 역전

다형성을 사용하기 전까지 일반적인 프로그램 구조는 아래와 같이 main이 고수준 함수를 호출하며, 고수준 함수는 저수준 함수를 호출한다. 즉 의존성과 제어의 흐름이 똑같게 된다.

하지만 다형성을 사용하면 상황이 달라진다.

Interface 없이 일반적으로 프로그래밍을 하면 ProductService가 ProductDataAccessImpl을 사용하는 대각선 방향 화살표의 의존성과 제어흐름을 갖게 된다.

 

이 두 구현체 사이의 Interface를 넣어보자. ProductService는 ProductDataAccess에 의존하고, ProductDataAccessImpl은 ProductDataAccess에 의존한다. 프로그램이 흘러가는 제어의 방향은 대각선 화살표 방향 그대로인데(결국에는 ProductDataAccessImpl의 메소드를 사용하므로), 인터페이스가 들어감으로 인해서 ProductDataAccessImpl의 소스코드 의존성이 변경되었다.

 

이것이 의미하는바는 단순히 UML의 화살표 방향을 변경했다는 것 이상이다. S/W 아키텍처는 소스코드의 의존성을 제어할 수 있게 된다. 그렇다면 소스코드의 의존성을 어떤 경우에 제어해야 하는가?

 

시스템을 구성하는 요소에는 UI, 비즈니스 업무규칙, 데이터베이스 등이 있다. 시스템의 핵심가치는 비즈니스 업무규칙에서 나온다. 데이터베이스에 대한 세부구현이 바뀌거나 UI의 세부구현이 바뀔때마다 비즈니스 업무규칙 소스코드가 영향을 받는다면 어떻게 될까?

 

UI 컴포넌트를 통해 특정 업무규칙을 적용한 뒤 데이터를 저장하는 과정을 떠올려보자. 의존관계가 아래와 같이 될 것이다. 이때 Database가 변경되면 업무규칙이 영향을 받는다.

핵심가치인 업무규칙이 세부사항은 Database에 영향을 받는다. 이런 영향도를 최소로 하려면 어떻게 해야할까?

DatabaseAccess 인터페이스를 끼워넣음으로 인해서 UI와 Database 같은 세부사항들이 핵심가치인 업무규칙에 의존할 수 있게 변경하였다.


- 결론

결국 OO란 아키텍처 관점에서 다형성을 이용하여 소스코드 의존성을 제어할 수 있는 권한을 가지는것이라 할 수 있다. 

소스코드의 의존성 방향을 저수준의 세부사항들(ex - UI, Database, Framework) 들이 고수준의 업무규칙에 의존할 수 있도록 하는것이 핵심이다.

댓글