캡슐화와 추상화 타입, 내가 캡슐화를 구현하는 노하우

WIZ·2023년 10월 9일
5

OOP

목록 보기
2/3

저번 포스팅에서 우리는 타입계층에 대해서 이해했고, 타입계층을 이용해 다형성을 구현하는 방법까지 살펴봤다.

https://velog.io/@slolee/객체지향에서-타입계층이-중요한-이유

위 포스팅에서 중요하게 말했던 내용은, 상속과 인터페이스는 타입계층을 만들어내기 위한 도구이고 이러한 타입계층은 다형성을 구현하는데 문법적 기반이 된다는 것이었다.
이번 포스팅에서는 내가 객체지향에서 가장 중요하게 생각하는 캡슐화에 대해서 설명하려고 한다.


캡슐화


객체지향 프로그래밍을 처음 공부할 때 나는 캡슐화에 대해서 크게 깊이있게 다뤄본적이 없었다. 사실.. 크게 중요한 개념이 아니라고 생각했던것 같기도하다.
그때의 생각을 돌이켜보면 단순히 은닉성에 대해서만 이해하고 위와같이 생각했던 것 같다.

지금의 나는 과장을 조금 보태 객체지향 프로그래밍에서 다형성보다 캡슐화가 더 중요한 개념이라고 생각한다.


캡슐화에 대한 기본적인 개념은 클래스 안에 서로 연관있는 속성과 행위를 하나로 묶고, 실제 구현 내용의 일부를 감추어 은닉한다는 것이다.
(참조: https://ko.wikipedia.org/wiki/%EC%BA%A1%EC%8A%90%ED%99%94)

캡슐화도 다른 객체지향의 특징들과 마찬가지로 변경에 유연한 구조를 만들기 위한 개념이다.
이를 위해 자주 변경되는 구체적인 것들을 분리해 외부로부터 감추는게 캡슐화의 근본적인 목표다.

위 두 문장이 내가 이 포스팅에서 설명할 캡슐화에 대한 요약이니 기억하고 계속해서 읽어보도록 하자!

자주 변경되는 구체적인 것들을 외부로 감춤으로써 변경에 유연한 구조를 만들 수 있을까?
이유는 명확하다.

의존성은 변경을 전파하기 때문이다.
외부에서 의존하는 Client 객체가 구체적인 것을 의존하게되면, 그 구체적인게 변경될 때마다 그 변경이 Client 객체로 전파된다.

의존성이 존재하는한 변경의 전파를 완벽하게 막을 수는 없다.
하지만, 캡슐화를 통해 자주 변경되는 것과 자주 변경되지 않는 것을 분리하고 외부에서는 자주 변경되지 않는 것에만 의존하게 함으로써 변경의 전파를 최소화할 수는 있다.

캡슐화를 구현했을 때 얻을 수 있는 장점을 조금 더 구체적으로 살펴보자.

구체적인 것에 대한 변경이 전파되지 않는다.

여기서 말하는 구체적인 것은 무엇이든 될 수 있다.
특정 행위가 어떻게(How) 처리되는지가 될 수도 있고, 객체 내부의 특정 상태가 될 수도 있다.
자주 변경되는 모든 것을 구체적인 것이라고 이해하고 넘어가면 될 것 같다.

캡슐화가 잘 이뤄진 상태에서 변경의 전파는 두 가지 관점에서의 장점이 있다.

  1. 해당 객체는 구체적인 것을 수정해도 외부로 전파되지 않기 때문에 쉽게 변경할 수 있다.
    (객체의 자율성과 관련이 있다)
  2. 해당 객체를 의존하는 객체는, 의존하고 있는 객체가 변경되더라도 자기한테까지 전파되지 않기 때문에 유지보수가 쉬워진다.

외부(Client 객체)에서 바라봤을 때의 복잡도가 낮아진다.

Client 객체 입장에서 구체적인 것들을 제외하고 협력에 꼭 필요한 추상화된 정보만을 의존하는 것이기 때문에 인지과부화를 줄이고 조금 더 높은 수준에서 협력을 설명할 수 있게된다.
높은 수준의 설명을"회사에서 집에 어떻게 가나요?" 라는 질문을 예시로 설명해보자.

  1. 판교역에서 신분당선을 타고 신사역까지 갑니다.
  2. 신사역에서 3호선으로 환승해 연신내역까지 갑니다.
  3. 연신내역에서 내려 집까지 도보로 10분정도 걸어갑니다.

회사에서 집에 어떻게 가냐는 질문에 위와 같이 답했다고 해보자.
이 답은 충분히 높은 수준의 설명이다. 즉, 높은 수준의 설명이라는 것은 구체적인 것들을 제외하고 추상화된 정보만을 제공했다고 말할 수 있다.
지하철을 어떻게 타러가고, 어떤 결제수단을 이용했으며, 어느 길을 따라서 도보로 이동했는지 등의 구체적인 정보는 모두 제외되었다.

TODO: 예제가 하나 추가되면 좋을 것 같다.


만약 Client 객체가 자주 변경되는 구체적인 것을 의존하면 그 변경이 Client 객체로 전파됨으로써 유지보수하기 굉장히 힘든 코드가 탄생하게 될 것이다.

우리는 이러한 상태를 Client 객체와 해당 객체의 결합도가 높다고 표현한다.
반대로 말해서 결합도를 낮추기 위해서 캡슐화를 한다고 이해해도 좋을 것 같다.


그래서 캡슐화는 어떻게하는데?


우리가 쉽게 캡슐화를 떠올렸을 때 가장 먼저 떠오르는건 데이터를 private 으로 숨기는 데이터의 은닉이다.
캡슐화란 단순히 데이터를 숨기는 행위가 아니다.
변경될 수 있는 모든 것을 숨기는게 캡슐화의 본질이다.

캡슐화의 시작은 자주 변경되는 것과 자주 변경되지 않는 것을 분리하는 것으로부터 시작된다.
그리고 자주 변경되는 것을 외부 Client 객체가 알지 못하도록 하는것이 캡슐화의 기본적인 방법이다.

여기서 알지 못해야한다는 것은 직접적으로 구체적인 것을 의존하지 말아야 한다는 것을 의미하기도 하지만, 의존성 외에도 정보를 알지 못하게 하는 것 또한 의미가 있다.

내가 실제 개발할 때 캡슐화를 위해 활용하는 몇 가지 노하우를 소개해볼까한다..!!

호출하는쪽 코드를 먼저 작성하자.

안그래도 복잡한 내용에 복잡한 예제를 사용하면 더 혼란이 가중될 것 같아서 아주 간단한 예제를 통해 설명을 해볼까한다.
Spring Boot 를 이용해 어플리케이션을 개발해본 개발자라면 3-Layered Architecture 에 대해서 알고 있을 것이다. (Controller - Service - Repository)

@RestController
class MemberController(
	private val memberService: MemberService  // Compile Error!
) {

	@PostMapping("/api/member")
	fun register(@RequestBody req: RegisterRequest) {
    	memberService.register(req)  // Compile Error!
    }
}

나는 코드를 작성할 때 Controller 의 코드를 먼저 작성한다.
그러면 당연히 아직 Service 가 개발되지 않았기 때문에 에러가 발생한다.
그 시점에 이제 Service 를 추가해 개발한다.

마찬가지로 Service 에서 Repository 를 의존하고 있다면 에러가 발생한다.
그 시점에 이제 Repository 를 추가해 개발한다.

이렇게 하는 이유가 뭘까?
이러한 순서로 개발하는 것은 협력에 있어서 데이터보다 행위에 초점을 맞출 수 있도록 돕는다.
Service 가 아직 개발되지 않았기 때문에 해당 클래스에 어떤 구체적인 것들이 존재하는지 결정되기 전이라는 사실이 중요하다.
그 시점에 ControllerService 에 요청을 보내는 코드가 먼저 작성된다.
즉, 아무런 구체적인 정보가 없는 상태에서 협력에 있어서의 행위가 가장 먼저 결정되는 것이다.
그리고 이러한 행위는 협력 자체가 변경되지 않는한 잘 변경되지 않는다.

이러한 방식의 개발이 캡슐화 구현하는데 가장 쉽게 시도해볼 수 있는 방법이다.

반대로 객체의 행위보다 데이터가 먼저 결정된다면 어떻게 될까?
이는 너무 이른시기에 구체적인 것이 결정되는 것이고, 의도하지 않더라도 이 구체적인 것들이 외부에 노출될 가능성이 높아진다.
(물론 꼭 노출된다는 것은 아니다. 가능성에 대한 이야기다.)

뿐만 아니라 데이터를 중심으로 객체가 설계된다면 협력에 어울리지 않는 객체가 만들어질 가능성도 높아진다는 문제도 있다.

데이터보다 행위에 초점을 맞춰 객체를 만들어야하는데, 호출하는쪽 코드를 먼저 작성하는게 굉장히 큰 도움이 된다고 생각한다.

간단하게 설계하되 변경될만한 가능성이 있는 코드를 찾아내자.

객체지향적인 설계를 코드에 녹이는 모든 순간순간은 Trade-Off 를 하는 과정이라고 생각한다.
조금 더 변경에 유연한 코드를 작성하면 구조적으로 복잡해질 수밖에 없다. 따라서 모든 부분에 대해서 복잡한 설계를 가져가는게 정답이 아니다.

따라서 바닥부터 복잡한 구조를 잡아나가는건 오히려 역효과가 날 수 있다.
그래서 나는 우선 협력을 설계하고, 그 설계를 바탕으로 가장 간단한 구조의 코드를 먼저 작성한다.

이렇게 코드를 작성하다보면 어떤 부분들에 대해서, "이 부분이 다르게 변경되면 어떡하지?" 라는 의문이 드는 순간들이 있다. (만약 없다면 그런 질문들을 의도적으로 던져보자)
그럼 그 의문에 답을 찾기 위해 캡슐화, 추상화, 다형성 등의 방법을 이용해 변경에 유연한 코드를 만들어나가기 시작한다.

사실 이 방법은 캡슐화에 특정되는 방법은 아니지만 이런 습관이 개인적으로 객체지향 설계에 있어서 Trade-Off 하는 좋은 방법이라고 느끼고있다.


더 높은 수준의 캡슐화


자주 변경되는 것과 자주 변경되지 않는 것을 분리하고 자주 변경되는 구체적인 것들을 외부에서 의존하지 못하도록 막는게 캡슐화의 목표라고 했다.
캡슐화를 구현하는 방법은 다양하다.

앞서 소개한 캡슐화는 특정 객체의 구체적인 변경으로부터 자유로워지기 위한 과정들을 소개했다.
하지만 객체의 구체적인 부분이 변경되는게 아니라 객체 자체가 다른 객체도 대체되거나 확장되어야하면 어떡할까?

여기서는 추상화 타입에 대한 이야기를 해보려한다.

추상화 타입을 만들고 이를 의존하게 하는 것은 캡슐화의 일환이다.
단순히 객체의 변경이 아니라 객체 자체의 대체, 확장에 유연한 구조를 만들기 위한 더 높은 수준의 캡슐화라고 생각한다.

추상화 타입은 자주 변경되는 것과 자주 변경되지 않는 것을 분리하고, 자주 변경되지 않은 것을 추상화해 하나의 상위 타입으로 만든 결과물이다.
기본적으로 추상화 타입에 들어가는 요소들은 자주 변경되지 않는 것들이어야한다.
대표적으로 객체의 행위가 여기에 속한다.

추상화 타입에 담기는 내용은 자주 변경되지 않는 것들어야 하며, 이러한 것을 결정하는 일은 매우 신중해야하고 어려운 일이다.
그래서 처음부터 추상화 타입의 도입을 결정하지 않는 것을 추천한 것이다!!


추상화 타입을 정의하는 방법으로는 추상클래스와 인터페이스가 있다.
템플릿 메소드 패턴과 같이 추상화 타입에 공통적으로 추상화 시킬 수 있는 로직이나 상태가 존재한다면 추상클래스를, 그게 아니라 단순히 책임에 대해서만 나열하고 싶으면 인터페이스를 선택하면된다.

사실 추상화 타입이 단지 추상클래스와 인터페이스를 의미한다고 생각하지는 않는다.
그냥 자주 변하지 않는 것들이 추상화되어서 상위 타입으로써 역할을 하고 있다면 그게 바로 추상화 타입이라고 생각한다.


추상화 타입을 의존하면 생기는 일


결국 추상화 타입에 담기는건 협력에서의 메시지(행동)에 대한 것이다. (물론 자주 변경되지 않는거라면 다른 것들이 담겨도 된다!!)
당연히 행동에 대한 How(어떻게) 가 아니라 What(무엇) 이 담겨야한다.

Client 객체가 추상화 타입을 의존하게되면, 자주 변경되지 않는 것을 의존하게된다.
추상화 타입을 의존하면 생기는 장점들을 살펴보자.

변경뿐만 아니라 대체, 확장에 유연한 구조를 만들 수 있다.

추상화 타입을 의존하고 있는 Client 객체는 보낸 요청을 처리할 객체에 대해서 전혀 알지 못한다.
그 객체와의 의존성은 런타임 시점이 되어서야 바인딩되기 때문이다.

따라서 그 요청을 처리해주는 객체가 다른 객체로 대체되거나 확장되어도 Client 객체로의 영향은 전혀 없다.
이게 바로 OCP 인데, Client 객체의 수정 없이 확장에 열려있기 때문이다.

캡슐화가 구현된 것이기 때문에 당연히 위에서 소개한 객체의 구체적인 것이 변경되는 것 또한 Client 에게 전파되지 않는다.

관심사를 명확히 분리할 수 있다.

추상화 타입에는 오직 자주 변경되지 않는 정보들만 담겨있다.
따라서, Client 객체는 이외의 정보는 알 수가 없다.
그냥 그 객체가 해당 요청에 대한 처리를 잘해서 응답해줄거라는 믿음만 가질 뿐이다.

이러한 믿음이 있기에 Client 객체와 요청을 처리하는 객체 사이의 관심사가 명확히 분리된다.
관심사의 분리는 아키텍처 관점에서 굉장히 중요한 개념인데, 여기서 너무 깊이 설명은 하지 않을 예정이다.

관심사의 분리가 잘 되어 있는 구조에서는 개발하기가 굉장히 쉽고, 나중에 코드를 보기도 쉽고, 협력을 이해하기도 굉장히 쉬워진다.

Client 를 먼저 개발할 수 있다.

Client 객체가 의존해야할 객체가 완성되지 않아도 협력에 참여할 정도의 행위만 결정되었다면 Client 객체를 컴파일 에러없이 먼저 개발할 수 있다.
별거 아닌 장점이지만.. 그냥 관심사의 분리에서 파생되는 장점이라고 생각하고 넘어가면 될 것 같다.

다형성을 구현할 수 있다.

다형성에 대해서는 하나의 포스팅으로 다뤄도 될 정도로 굉장히 많은 내용의 설명이 필요하다.
여기서 설명하고 싶은건 Client 객체는 추상화 타입만 의존하고 있을 뿐, 실제로 그 요청을 처리할 구체적인 구현 객체에 대해서 모른다는 것이다.
(컴파일 시점의 의존성과 런타임 시점의 의존성이 다른것임을 이해해야한다.)

실제로 Client 객체가 보낸 요청을 처리할 구현 객체를 모르기 때문에 갑자기 그 객체가 다른 객체로 대체되어도 Client 객체는 모른다.
모른다는 말은 Client 객체와 그 요청을 처리하는 구현 객체의 결합도가 굉장히 낮아지고, 그 객체 변경 및 확장에 영향을 전혀 받지 않는다는 것을 의미한다.

추상화 타입이 있기 때문에 이러한 다형성을 구현할 수 있는 것이다.


추상화 타입을 의존하기 위해서는 DI(Dependency Injection) 이 반드시 필요하다. 그 이유는 다음 포스팅에서 소개할 예정인데 그 전에 스스로 한번 고민해보는 것도 캡슐화를 명확히 이해했는지 확인하는 좋은 경험이 될 것 같다.

다음 포스팅에서는 IoC 와 DI 에 대해서 깊이있게 다뤄볼 예정이다.

0개의 댓글