객체 지향 프로그래밍의 진짜 본질은 무엇인가?

객체 지향 프로그래밍의 진짜 본질은 무엇인가?

학부 2학년 시절, Java 언어 중심의 객체 지향 프로그래밍 강의를 수강했다. 객체 지향 프로그래밍은 현실 세계의 복잡성을 객체라는 관점에서 관찰하여 코드에 투영하는 것이라고 배웠다. 또한 객체 지향의 요소로 캡슐화, 다형성, 추상화, 상속이 있다고 했다. 이 네 가지 개념이 객체 지향을 이루는 근간이라고 배웠다. 잘 이해가 가지 않았고, 개인적으로 학습하며 흔히 알려진 ‘붕어빵-붕어빵 틀’ 비유를 보며 객체와 클래스가 무엇인지 이해하는 듯 했다. 본 수업에서 3-Match Game 게임을 개발하는 프로젝트를 하며 GameBoard, Tile, TileGrid, Timer, MenuPanel, OptionPanel 등 다양한 클래스를 설계했고, 객체 지향 프로그래밍을 잘 수행했다고 착각했다. 이후에도 몇년 동안이나 백엔드 개발을 하면서도 관례적인 Layered Architecture의 Controller, Service, Entity 클래스들을 만들고, DI Framework로 결합도를 낮추기도 하며 좋은 객체 지향 프로그래밍을 하고 있다고 착각했다.

최근에 객체 지향의 요소가 캡슐화, 다형성, 추상화, 상속이 아니라는 글을 보았다. 곧바로 객체 지향 프로그래밍의 요소가 뭔지, 아니 객체 지향 프로그래밍이 뭔지 찾아봤다. 대부분의 결과는 캡상추다, SOLID 원칙, DI 등을 개념적으로만 설명하고 있을 뿐 객체 지향 프로그래밍의 진짜 의미를 포함하지 않았다. 이를 계기로 나는 객체 지향 프로그래밍의 본질과 창시자가 해결하고자 했던 문제점 등을 찾으며 깊게 고민했고, 진짜 객체 지향 프로그래밍이 무엇인지 점점 이해할 수 있었다.

Alan Kay의 이야기를 마음 속에 새기며…

“I’m sorry that I long ago coined the term “objects” for this topic because it gets many people to focus on the lesser idea. The big idea is messaging.”

“OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things.”

2003년 email exchange에서 Alan Kay의 발언 인용

객체 지향 프로그래밍이란?

객체 지향을 이해하기 위해 과거로 돌아갔다. OOP의 창시자 Alan Kay는 생물학 전공이었다. 그는 복잡한 생물체를 구성하는 세포로부터 영감 받아 소프트웨어를 구조화하는 방법을 창안했다. 행위를 기준으로 데이터를 조작하는 프로시저 프로그래밍하는 방식인 절차 지향 프로그래밍이 거대한 시스템을 만들기에 적합하지 않다고 생각하여 데이터, 행위를 독립 개체로 엮어 시스템을 구조화하는 방법을 생각해낸 것이다.

객체 지향 프로그래밍은 데이터와 행위를 하나의 객체라는 것으로 묶어, 모듈 간 협력을 통해 시스템을 구성한다. 이런 프로그래밍 방식은 다음과 같은 장점이 존재한다.

  • 어떤 객체는 자신에게 속한 데이터만 조작하므로 데이터의 안정성을 높인다.
  • 거대한 시스템이 객체와 객체 간의 협력이라는 단위를 통해 조직적으로 구성되어 단순화된다.
  • 협력에 참여하는 객체들을 쉽게, 추가, 교체, 확장하여 소프트웨어의 유연성을 높인다.
  • 객체라는 개념이 비교적 현실 세계를 반영하기 쉽기에 직관적으로 시스템을 이해할 수 있다.

회사의 조직 체계에 비유해보자. 개발 부서는 개발을, 디자인 부서는 디자인을, 경영진은 경영을 담당한다. 개발 부서는 개발 과정에서 필요한 디자인 관련 자료를 디자인 부서에 요청할 수도 있다. 하지만 디자인 작업 중인 자료를 직접 찾아서 확인하거나 수정하지는 않는다. 또 회사라는 거대한 시스템을 작은 단위인 부서 혹은 팀로 구성되고, 이들의 협력으로 회사가 운영된다. 이 회사에 마케팅 부서가 추가될 수도 있다. 그리고 그들은 그들끼리 마케팅 업무를 수행할 것이고, 디자인 부서와 새롭게 협력할 수도 있을 것이다. 하지만 이런 부서의 추가가 전체적인 회사의 시스템을 뒤엎지는 않을 것이다. 심지어는 개발 팀 전체를 같은 기술 스택을 가진 다른 사람들로 구성된 팀으로 교체하더라도 문제 없다. 위의 장점과 비교하면서 예시를 보면 OOP의 장점을 쉽게 이해할 수 있다.

Alan Kay는 OOP의 핵심을 메시징, 캡슐화, 동적 바인딩이라고 얘기했다. 메시징은 부서, 팀 간의 협력을 대응된다. 캡슐화는 각 부서의 업무 과정과 자료가 노출되지 않는 것에 대응된다. 동적 바인딩은 회사 운영 중에 팀 혹은 부서가 변경될 수도 있다는 것에 대응된다. 이들을 더 자세히 살펴보자.

메시징, 캡슐화, 동적 바인딩

Alan Kay는 OOP에서 핵심은 Object나 Class 같은 것들이 아니라 메시징이라고 얘기한 바 있다. 그 이전에 객체가 무엇인지 명확하게 알아야 한다.

한 SNS의 Object를 객체로 번역하는 것이 어색하고 잘못되었다는 글을 보았다. 해당 글에서는 우스갯 소리로 객체의 가장 적합한 표현은 ‘거시기’라고 말하기도 했다.

그 거시기 말고 이 거시기요...

다행히 객체가 무엇인가라는 고민을 종종 해보았기에 그 말을 보고 바로 이해할 수 있었다. OOP를 처음 접할 때 가장 많이 드는 예시인 붕어빵, Animal, Person과 같은 것이 객체라는 것은 직관적이지만, 실제로 개발을 하다 보면 추상적인 개념들을 객체로 투영하는 것은 쉽지 않다. 오브젝트(위키북스, 조영호 저) 서적에서는 객체를 다음과 같이 정의내린다.

  • 상태와 행동을 함께 갖는 복합적인 것
  • 스스로 판단하고, 행동할 수 있는 자율적인 것

이제 객체의 정의를 어느 정도 알았으니 Alan Kay가 중요하다고 말한 개념들을 이해해보자.

메시징(Messaging)

객체들이 협력하여 하나의 시스템을 구성한다는 관점에서 바라보면 메시징은 직관적으로 이해할 수 있다. 객체 A, B와 각각 수행할 수 있는 작업 X, Y가 있다고 가정해보자. 객체 지향의 관점에서 만약 객체 A가 작업 X을 수행하던 도중, 작업 Y이 필요하다면 객체 A는 작업 Y를 직접 수행하는 것이 아니라 객체 B에게 작업 Y를 수행해달라고 요청한다. 객체 B가 객체 A에게 작업 Y 수행 요청을 받으면, 작업을 수행한 후 적절하게 응답한다. 이 과정에서 객체 A가 객체 B에게 작업 수행을 요청하는 것을 메시징이라고 하고, 객체 지향 시스템에서 어떤 객체가 다른 객체와 상호 작용하는 방법은 메시징이 유일하다.

이때, 외부에서 접근 가능한 부분을 공개 인터페이스(public interface)라고 한다. 즉, 메시징이란 한 객체가 다른 객체에게 상호 작용하는 과정은 공개 인터페이스를 통해 메시지를 전송하고, 응답 메시지를 수신하는 과정이다. 객체 지향 시스템에서는 하나의 시스템을 하나의 구성 요소로 해결하는 것이 아니라 작은 책임을 갖고, 자율적으로 행동하는 객체들의 협력으로 이루어지는 것이 핵심이므로 메시징이 Big Idea인 것이다.

객체들을 설계할 때에도 메시징이 중요하게 작용한다. **책임 주도 설계(RDD)**는 시스템 구성 객체들이 수행해야 할 책임들로부터 책임을 수행할 적절한 객체를 찾아 할당하는 방식으로 객체 간 협력을 설계하는 방법이다. 이때, 책임을 할당하는 과정에서는 필요한 메시지를 먼저 식별하고, 메시지를 할당할 객체를 선택한다. 이런 과정을 통해 객체는 충분히 추상적인 최소한의 인터페이스를 갖게 되고, 깔끔하게 캡슐화된다.

캡슐화(Encapsulation)

캡슐화는 개념적이나 물리적으로 객체 내부의 세부적인 사항을 감추는 것을 의미한다. 풀어 말하자면 메서드의 구현 내용이나 객체가 포함한 데이터를 볼 수 없도록 하는 것이다. 객체 지향 언어에서 메서드를 구현하는 것과 데이터의 접근 범위를 접근 제어자로 제한함으로써 구현할 수 있다.

중요한 것은 메시징에 관점에서 봤을 때, 한 객체가 다른 객체의 데이터에 접근할 수 없어야 한다는 점이다. 만약 다른 객체의 데이터가 필요하다면 메시지를 보내어 요청해야 한다. 캡슐화를 잘 수행함으로써 객체의 응집도를 높일 수 있다. 자기가 포함한 데이터에 대한 작업은 자신만이 할 수 있고, 다른 객체는 메시징을 통해 협력하는 구조이기 때문이다. 에서 사용한 예시에서 객체 A가 작업 Y를 수행할 수 없는 구조도 캡슐화라고 할 수 있다.

즉, 캡슐화가 잘 이루어지면 객체 간의 결합도를 낮추고, 응집도를 높여 변경하기 쉬운 코드를 작성할 수 있다. 결합도와 응집도가 무엇인지는 뒤에서 더 자세히 설명한다.

동적 바인딩(Dynamic Binding)

동적 바인딩은 지연 바인딩(Late Binding)이라고도 부른다. Alan Kay는 OOP의 의미 중 하나가 Extreme Late-Binding이라고 했다. 이 의미 역시 메시징과 관련 된다. 동적 바인딩은 메시지를 받는 객체가 컴파일 타임 시점의 정적인 코드 기반으로 결정되는 것이 아니라 런타임 시점에 결정되어야 한다는 의미이다. 흔히 객체 지향의 요소라고 잘못 알려져 있는 다형성이 구현하고자 하는 개념 중 하나이다. 아래 코드를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
interface Animal {
eat(food: string): void;
}

class Dog extends Animal {
public eat(food: string): void {
console.log(`cat eats ${food}`);
}
}

class Cat extends Animal {
public eat(food: string): void {
console.log(`cat eats ${food}`);
}
}

class AnimalHotel {
private animals: Animal = [];

void addDog(dog: Dog) {
this.animals.push(dog);
}

void addCat(cat: Cat) {
this.animals.push(cat);
}

void feedToAllAniaml(food: string) {
this.animals.forEach(animal => animal.eat(food));
}
}

어떤 어플리케이션에서 AniamlHotel 객체가 실행 중 요청에 따라 동적으로 Dog, Cat을 추가한다고 가정하자. 어느 시점에 feedToAllAnimal 메서드를 실행하면 메서드 구현 내부 코드에서 실행되는 eat 메서드는 어떤 객체의 eat 메서드를 호출할까? 이러한 개념이 동적 바인딩이다. 동적 바인딩이 이뤄지면, 런타임에 어느 객체가 메시지를 수신하는지 결정되므로 유연한 객체 지향 시스템을 만들 수 있다.

올바른 객체 지향 설계 방향

소프트웨어 공학 프로세스는 다른 공학 프로세스와는 다르다. 하나의 시스템을 구성하는 데에 많은 리소스가 필요하지만 이름에서 알 수 있듯 다른 공학에 비해 비교적 변경이 용이한 편이다. 실제로 대부분의 소프트웨어는 지속적으로 발전에, 잦은 변경 사항에 대응해야 한다. 따라서 이들은 잦은 변경에 유연하게 대처할 수 있어야 하고, 이러한 방향으로 설계되는 것이 올바르다. 좋은 객체 지향 설계는 크게 두 가지 관점에서 살펴볼 수 있다.

결합도(Coupling)

객체 지향 시스템은 객체들이 유기적으로 연관되어 협력한다. 하지만 이때 객체 사이의 의존성을 주의해야 한다. 객체 간 의존성이 크다면 하나의 객체를 변경할 때, 관련된 다른 객체들도 함께 변경해야 하기 때문이다. 따라서 객체를 설계할 때에는 결합도를 낮추어 변경이 용이하도록 해야 한다.

느슨한 결합(Loose Coupling)은 변경에 용이하다.

객체들은 메시징을 통해 협력한다. 이때, 메시지와 메소드를 명확하게 구분해야 한다. 메시지는 한 객체가 다른 객체에게 요청하는 것으로 메소드를 호출하는 부분에 대응된다. 메소드는 객체가 메시지를 받는 부분으로 실제로 메소드가 구현된 영역이다. 우리는 메소드가 없더라도 메시지를 발신하는 방법을 알고 있다.

객체 지향 언어에서는 인터페이스를 통해 구현부 없이 메시지 수신부를 정의할 수 있다. 인터페이스를 정의하여 메시지를 수신, 발신하도록 구성하면 실제 객체 간의 결합도를 낮출 수 있다. 동적 바인딩 개념에 따라 어떤 객체와 협력하는지는 알 수 없지만 인터페이스를 구현하고 있는 객체와 메시지를 주고 받는다고 가정하여 코드를 작성하면 객체의 자율성이 높아져 결합도가 낮아진다. 따라서 변경에 대응하기 쉬운 코드를 작성하기 위해 결합도를 낮추는 방향으로 설계하는 것이 좋은 방법이다.

일반적으로 잘 알려진 의존성 주입(Dependency Injection) 기법을 통해 결합도를 낮출 수 있다. 물론 DI 말고도 객체 간 결합도를 낮추는 방법은 다양하지만 본 포스팅에서는 다루지 않는다. DI를 통해 객체 간의 결합도를 낮추는 예시를 살펴보자. 예시에 사용되는 코드는 TypeScript, Nest.js로 작성되었다. 다음은 어떤 블로그에서 Article을 발행하는 API를 구성하는 클래스들이다.

  • Article을 발행하는 기능을 수행하는 책임이 있는 ArticlePublisher 클래스
1
2
3
4
5
6
7
8
9
@Injectable()
export class ArticlePublisherV1 {
constructor(private readonly aritcleRepository: ArticleRepository) {}

async publish({ title, content }: PublishArticleDto) {
const article = new Article(uuid(), title, content);
await this.aritcleRepository.save(article);
}
}

  • Article을 발행 API의 요청과 응답을 처리하는 책임이 있는 ArticlePublishApi 클래스
1
2
3
4
5
6
7
8
9
@Controller('articles')
export class PublishArticleApi {
constructor(private readonly articlePublisher: ArticlePublisherV1) {}

@Post()
async publishArticle(@Body() publishArticleDto: PublishArticleDto) {
await this.articlePublisher.publish(publishArticleDto);
}
}

  • Article 도메인의 Dependency를 관리하고, 캡슐화하는 ArticleModule 클래스
1
2
3
4
5
@Module({
providers: [ArticlePublisherV1, ArticleRepository],
controllers: [PublishArticleApi],
})
export class ArticleModule {}

위 예시의 코드는 Nest.js의 DI를 적극적으로 활용한다. 그런데 사실 위 코드는 결합도가 높다. 만약 ArticlePublisherV1을 ArticlePublisherV2로 변경한다면, PublishArticleApi 클래스의 코드도 수정해야 한다. 이 상황에서 인터페이스를 활용해서 결합도를 낮출 수 있다. 먼저 ArticlePublisher 인터페이스를 만들고, 기존 ArticlePublisherV1 클래스에 implements 코드를 추가한다.

  • ArticlePublisher 인터페이스
1
2
3
export interface ArticlePublisher {
publish(publishArticleDto: PublishArticleDto): void;
}

  • 인터페이스 구현을 추가한 ArticlePublisherV1 클래스
1
2
3
4
5
6
7
8
9
@Injectable()
export class ArticlePublisherV1 implements ArticlePublisher {
constructor(private readonly aritcleRepository: ArticleRepository) {}

async publish({ title, content }: PublishArticleDto) {
const article = new Article(uuid(), title, content);
await this.aritcleRepository.save(article);
}
}

  • 인터페이스를 통한 DI를 위해 수정한 ArticleModule 클래스
1
2
3
4
5
6
7
8
9
10
11
@Module({
providers: [
{
provide: 'ARTICLE/ARTICLE_PUBLISHER',
useClass: ArticlePublisherV1,
},
ArticleRepository,
],
controllers: [PublishArticleApi],
})
export class ArticleModule {}

이제 ArticlePublisherApi에서는 ArticlePublisherV1을 직접적으로 참조하지 않기 때문에 ArticlePublisherV2와 같은 같은 기능의 새로운 클래스를 만들더라도 쉽게 교체할 수 있다. 마찬가지로 ArticleRepository도 수정해주어 느슨한 결합을 만들어낼 수 있다.

물론 모든 클래스에 대응하는 인터페이스를 정의하는 것은 오히려 코딩해야 할 양만 늘고 오히려 좋지 않을 수 있다. 하지만 두 객체 간 결합도를 낮추어 확장성을 높인다는 측면에서는 훌륭한 설계이다. AWS SMS라는 외부 서비스를 이용해 문자 발송을 담당하는 AwsSmsNotifier 클래스가 있다고 가정해보자. 인터페이스를 활용한 느슨한 결합의 객체 관계에서는 AWS SMS에서 Twilio로 외부 서비스를 변경하더라도 기존 코드의 수정 없이 TwiloSmsNotifier 클래스를 만들고, DI해주면 간단하게 클래스를 교체 가능하다.

응집도(Cohesion)

객체 지향 시스템에서 객체는 자신들의 고유한 책임이 있다. 앞선 회사의 예시에서 각 부서들은 개발, 디자인, 경영, 마케팅 등 각 부서만의 고유한 책임이 있었다. 물론 이 세상에는 개발과 다지인도 병행할 수 있는 멀티 플레이어 능력자들이 있겠지만 OOP에서는 그 책임 범위가 명확하게 분리되어 있는 것이 좋다. 자신이 처리할 책임이 없는, 처리할 수 없는 작업은 다른 객체에게 메시지를 보내 작업을 위임한다.

이때, 어떤 객체의 책임이 뭉쳐있는 정도를 응집도라고 한다. 응집도가 높은 객체일 수록 자신의 책임이 명확하고, 관련 작업만을 수행하며, 다른 객체와 긴밀하게 협력할 수 있는 구조를 만들어낸다. 이렇게 자율성이 높은 객체는 시스템을 이해하기 쉽게 만들고, 변경이 발생해도 수정해야 할 객체와 범위가 명확해진다. 이는 Robert.C.Martin이 제시한 설계 원칙인 SOLID 원칙의 SRP(Single Responsibility Principle), 프로그래머들 사이에서 통용되는 격언인 KISS(Keep It Simple, Stupid)과도 맞닿아 있다.

위에서 사용한 블로그 코드의 예시를 다시 살펴보자.

1
2
3
4
5
6
7
8
9
@Injectable()
export class ArticlePublisherV1 implements ArticlePublisher {
constructor(private readonly aritcleRepository: ArticleRepository) {}

async publish({ title, content }: PublishArticleDto) {
const article = new Article(uuid(), title, content);
await this.aritcleRepository.save(article);
}
}

이 클래스는 ‘Article을 발행한다’라는 책임을 갖고, 실제로 작업 수행을 하는 publish 메서드가 구현되어 있다. 이제 ‘발행한 Article을 삭제한다’는 기능을 추가해달라는 요구사항이 생겼다. 이때 ArticlePublisherV1 클래스에 remove 메서드를 추가하여 기능을 구현하더라도 프로그램 작동에는 문제가 없다. 하지만 나중에 또 다른 개발자가 합류하여 Article 삭제 관련 코드를 수정해야 할 일이 생긴다면 어떤 클래스의 어떤 메서드를 수정해야 하는지 혼란스러워 할 것이다.

따라서 객체는 응집도가 높도록 설계하여, 객체 책임과 변경 범위가 명확하도록 하는 것이 좋은 설계이다.

객체 지향을 이해하다

더 깊은 이해를 위해서는 더 많은 학습과 노력이 필요하다. 하지만 우리의 수고를 덜어줄 앞선 세대의 수 많은 연구 결과들이 존재한다. 기원은 확실치 않지만 DI라는 개념은 객체 간의 결합도를 낮출 수 있는 방법으로 널리 알려져 있다. Rod Johnson은 객체 간 의존도, EJB의 불필요한 코드 작성을 줄이기 위해 비침투적인 DI 프레임워크인 Spring 을 개발했다. Robert.C.Martin은 객체 지향 설계에 도움이 될만한 SOLID 원칙을 소개했다. 이외에도 OOP에 근간이 되는 여러 아키텍처, 개발 방법론, 디자인 패턴들이 이미 존재한다. 이들은 어떤 관점에서는 학습해야 할 대상일 수도 있지만, 또 다른 관점에서는 오히려 개발의 수고를 덜어주기 위한 이전 세대의 산물이다. 하지만 OOP를 잘 이해하고 있지 않다면 이들은 독립적인 대상으로 인식되어져 오히려 어떤 문제를 해결하기 위한 목적으로 사용되는지조차 이해하지 못 하고 어려움만 가중될 것이다. 본 포스팅은 객체 지향 프로그래밍이 해결하고자 하는 본질적인 문제와 올바른 설계 방향을 이해하고, 좋은 방향으로 나아가는 시작점이 될 수 있을 것이다.

글을 마무리 하며

현대에는 대부분 객체 지향적으로 개발한다. 그러니 정말 이들을 잘 이해하고 있더라도 한번 정도는 깊게 생각해보는 것이 큰 도움이 될 거라고 생각한다. 만약 본 포스팅을 보고 “당연히 그렇게 생각해야 하는 거 아니야?”, “당연히 객체 지향 프로그래밍이 시스템을 객체로 작게 구성하는 거지”와 같은 생각이 든다면 정말 객체 지향 프로그래밍을 잘 이해하고 있거나 객체 지향 프로그래밍을 전혀 이해하지 못 하고 있거나 둘 중 하나라고 생각한다.

인터넷은 누구든 자료를 쉽게 올리고, 접할 수 있지만 그만큼 잘못된 정보들도 많다. 객체 지향 프로그래밍과 관련된 수많은 자료들이 그렇다. 심지어 이 포스팅을 작성하는 필자조차도 정말 내가 올바른 정보를 전달하는 것인지 계속해서 의심하고 있다. 하지만 확실한 건 본문의 내용은 객체 지향의 창시자 Alan Kay을 주장을 시작으로 진짜 객체 지향을 잘 이해하고 있다고 생각하는 수 많은 개발자들의 생각을 근거로 작성했다. 글의 내용이 정말 옳다면 언젠가 캡상추다 같은 것이 객체 지향의 요소, 장점이라고 주장하는 잘못된 자료들 말고, 이런 자료들이 많아졌으면 하는 바람이다.

3줄 요약

  • 객체 지향 시스템은 객체 간의 메시징을 통해 유기적으로 구성된다. 이는 시스템을 이해하기 쉽게 하고, 소프트웨어의 유연성을 높인다.
  • 변경에 대응하기 쉬운 객체 설계를 위해서는 결합도(Coupling)를 낮추고, 응집도(Cohesion)를 높여야 한다.
  • 결합도가 낮은 설계는 코드 수정 전파 가능성을 낮추고, 응집도가 높은 설계는 객체들의 책임이 명확하여 수정 범위를 좁힌다. 이는 자연스럽게 코드의 유지보수, 확장성을 높인다.

객체 지향 프로그래밍의 진짜 본질은 무엇인가?

https://notjustmoney.github.io/2022/02/21/what-is-essence-of-oop/

Author

Jaeyun Cha

Posted on

2022-02-21

Updated on

2023-04-11

Licensed under

댓글