Structural Typing과 객체지향 설계
필자는 이전 회사에서 처음으로 Go 라는 언어를 접했습니다. 프로그래밍 언어라는 주제에 큰 흥미를 가진 사람으로서 Go라는 언어는 매우 흥미로운 주제라고 할 수 있는데, 재미있게도 합류하기로 한 회사에서 Go를 사용하기 때문에 억지로 배운 케이스라 할 수 있습니다.
Go를 좋아하지 않았던 이유는 매우 단순한데, Go 관련 문서에 항상 등장하는 이친구, Gopher 때문이었습니다.
사실 특별한 이유는 없고, 못생긴 캐릭터가 문서에 크게 자리 잡고 있으니 괜히 보기 싫어서 얼마 못 가 튜토리얼 문서를 닫았을 뿐입니다.
하지만 Go와 함께한 2년동안 새롭게 배운것들이 너무나 많았고 Go라는 언어를 좋아하게 되었습니다. 오늘은 이렇게 배우게된 주제 중 하나에 대해 이야기를 해보려 합니다.
본격적으로 시작하기에 앞서, Go에 대한 이야기로 시작한 글이기에 Go에 대한 내용이 많을것이라고 생각하실 수 있지만 언어를 뛰어넘은 내용을 이야기하기에 Java, Python등 다양한 언어가 등장할 예정입니다.
Go의 Interface
Go에는 상속의 개념이 없습니다. 굳이 따지자면 has-a 상속을 지향한다고 할 수 있는데, 이 또한 객체지향의 관점에서 다른 언어의 속성을 억지로 분류하는 느낌이라 좋아하는 표현은 아닙니다.
Go에 상속이 없지만 interface라는 개념은 존재하는데, 사실 Java와 같은 객체지향 언어의 interface랑 매우 유사합니다.
다음 Go에서의 interface의 예시를 봅시다.
type Flyable interface {
Fly()
}
위 예시는 Flyable
이라는 interface를 정의하고 있고, 이 interface를 구현하는 구현체는 Fly()라는 함수를 구현해야 한다고 명시하고 있습니다. 그렇다면 이 interface를 구현하는 구현체는 어떻게 정의할 수 있을까요?
type Bird struct {
Name string
}
func (b Bird) Fly() {
fmt.Printf("%s is flying", b.Name)
}
위는 간단한 Flyable
의 구현체입니다. 위에서 이야기한것과 같이 그리고 코드를 보다 싶이 Bird
라는 struct는 Flyable
이라는 struct에 대한 명시적인 구현 선언을 가지고 있지 않습니다. 이것이 바로 Go의 interface의 정의입니다.
Go에서의 interface는 위와 같이 interface에 정의된 메소드만 정확한 시그니처로 구현하고 있다면 해당 interface의 타입으로 다룰 수 있는것 입니다.
var f Flyable = Bird{
Name: "Chicken",
}
f.Fly()
이러한 특징으로 Go에서는 이미 존재하는 타입에 대한 인터페이스를 정의할 수도 있습니다.
사실 이러한 특징 때문에 Go에선 특정 struct가 해당 interface를 구현하고 있는지 확인하기 까다롭습니다. 만약 Bird
가 Flyable
을 제대로 구현하고 있는지 확인하려면, Flyable
타입 변수에 Bird
인스턴스를 할당하려는 코드가 있을 때 컴파일 에러가 나는지 간접적으로 확인해야 하는 것 입니다.
type Insect struct {
}
func (i Insect) Fly(distance int) { // 다른 Signature
// ...
}
var f2 Flyable = Insect{} // 컴파일 에러!
이는 기존 객체지향 언어들의 접근 방식과 매우 다른점이라 할 수 있습니다. 일반적으로 Class를 설계하기 전에 interface를 먼저 정의하고, 해당 class를 사용하고자 하는 코드에는 interface로 타입의 선언하고, class가 interface를 구현하도록 만드는 것이 일반적인 객체지향 프로그래밍의 작업 방식이지요.
interface Flyable() {
void fly();
}
class Bird implements Flyable {
private String name;
public Bird(String name) {
this.name = name;
}
@Override
public void fly() {
system.out.println(name + " is flying");
}
}
Flyable f = new Bird("Chicken");
f.fly();
Go의 interface의 동작과 같이 특정 타입(Class, Struct 등)이 interface를 명시적으로 구현 하는것이 아닌 interface를 만족하는지 타입 체크시에 확인하는 Typing을 Structural Typing이라고 합니다.
반면 Java와 같이 다형성에 명시적인 구현 및 상속을 필요로하는 Typing은 Nominal Typing 이라고 합니다.
Structural Typing
Structural Typing의 개념이 낯설수도 있지만 사실 다른 프로그래밍 언어에서도 생각보다 흔히 적용하고 있는 개념입니다. 예를 들어 TypeScript도 Structural Typing을 지원하는 대표적인 언어이죠.
class Bird {
name: string;
constructor(name: string) {
this.name = name;
}
fly() {
console.log(`${this.name} is flying`);
}
}
interface Flyable {
fly(): void;
}
let f: Flyable = new Bird("Chicken");
f.fly();
// interface와 class가 아닌 object type과 object를 사용해도 동일한 시맨틱을 나타낼 수 있습니다.
또한 Python도 Protocol이라는 이름으로 Structural Typing을 지원하고 있습니다.
from typing import Protocol
class Bird(object):
def __init__(self, name: str) -> None:
super().__init__()
self.name = name
def fly(self) -> None:
print(f"{self.name} is flying")
class Flyable(Protocol):
def fly(self) -> None:
pass
f: Flyable = Bird("Chicken")
f.fly()
Duck Typing과의 차이점
Structural Typing은 흔히 Duck Typing과 비교되기도 합니다.
Duck Typing을 소개할 때 많이 인용하는 다음과 같은 문장이 있습니다.
만약 어떤 새가 오리처럼 걷고, 헤엄치고, 꽥꽥거리는 소리를 낸다면 나는 그 새를 오리라고 부를 것이다.
이를 위 코드에 비유해 다시 말해보면
만약 어떤 객체가 fly() 할 수 있으면 Flyable이라 하겠다.
이렇게 말 할 수 있죠. 이를 Python 코드로 다시 써보면
class Bird(object):
def __init__(self, name):
super().__init__()
self.name = name
def fly(self):
print(f"{self.name} is flying")
flyable = Bird("Chicken")
flyable.fly()
위와 같이 표현할 수 있습니다. 눈치 채신 분들도 있겠지만, 위 코드는 Structural Typing에서 소개한 코드에서 타입 힌트만 제외한 것 입니다.
즉 Structural Typing은 Duck Typing을 정적 타입으로 표현한 것이라고 이야기할 수도 있습니다.
왜 Structural Typing을 사용하는가?
이제 Structural Typing이 무엇인가에 대해서는 이해가 되었을텐데요. 그렇다면 Nominal Typing이라는 대중적인 Typing이 있는데 Go나 TypeScript, Python같은 언어는 대체 왜 Structural Typing을 사용하고 도입하는 것 일까요?
먼저 Record나 Structure과 같은 데이터 타입을 더 유연하게 다룰 수 있다는점이나 int
, float
등의 Scalar 타입들을 다루기 편하다는 장점들이 있습니다.
이러한 장점들 때문에 사실 많은 언어들이 이미 Structural Typing을 지원하고 있다고 볼수도 있습니다. Java, C와 같은 언어도 엄밀히 따지면 암시적 형 변환이라는 이름으로 Scalar 레벨에서의 Structural Typing을 지원하고 있는 것 입니다.
하지만 Java와 C같은 언어를 “Structural Typing을 지향한다” 라고 하지는 않죠. Structural Typing을 지향한다고 말하는 Go와 같은 언어는 엄밀히 말하면 “다형성(Polymorphism)에 Structural Typing을 사용했다” 라고 말할 수 있습니다.
이러한 다형성에서 Strucral Typing은 강력한 장점을 가지는데요. 여기서 더 깊게 들어가기 전에 이 글의 두번째 주제인 객체지향 설계에 대해 먼저 이야기 해보겠습니다.
의존성 역전
객체지향 프로그래밍에서 중요성을 몇번이나 강조해도 모자란것이 바로 의존성 역전의 원칙입니다. 사실 이는 객체지향 프로그래밍을 벗어나더라도 어떤 프로그래밍 언어에서도 서로 다른 표현법으로 중요성을 강조하고 있는 내용입니다.
의존성 역전 원칙은 다음과 같습니다.
High level modules should not depend upon low level modules. Both should depend upon abstractions.
고수준 모듈은 저수준 모듈에 의존하지 않아야 하고, 각 모듈은 추상화에 의존해야한다.
여기에서 고수준과 저수준은 컴퓨터공학에서 일반적으로 이야기하는 추상화 레벨을 의미합니다. 만약 특정 모듈이 의존하고 있는 다른 모듈에 더 높은 추상화레벨을 가진 모듈이 있다면 해당 추상화 레벨에 의존해야 한다는 의미가 됩니다. 이를 코드로 보면 이해하기 쉬울 것 입니다
다음 덧셈을 하고 결과를 출력하는 Java 모듈을 봅시다.
@AllArgsContructor
class AddAndPrintModule {
private final ConsoleWriter writer;
public void addAndWrite(int a, int b) {
int c = a + b;
this.writer.write(c);
}
}
// ...
AddAndPrintModule module = new AddAndPrintModule(new ConsoleWriter(...));
module.addAndWrite(1, 2);
위 모듈은 의존성 역전의 원칙을 위배한다고 볼 수 있습니다. AddAndWriteModule
은 ConsoleWriter
의 write()
라는 동작에만 의존함에도 불구하고, ConsoleWriter
라는 구체적인 타입을 요구하고 있습니다. ConsoleWriter
라는 이름에 볼 수 유추할 수 있듯이 FileWriter
, StringWriter
등 다양한 또다른 Writer
들이 존재할 수 있다는것을 예상할 수도 있습니다.
즉, ConsoleWriter
에는 Writer
라는 더 높은 레벨의 추상화 모듈이 존재함에도 Writer
가 아닌 ConsoleWriter
에 의존하고 있는것은 의존성 역전의 원칙에 위배된다고 할 수 있죠.
이러한 맥락을 가지고 위 모듈을 다시 작성해 본다면 다음과 같이 작성할 수 있죠.
@AllArgsContructor
class AddAndPrintModule {
private final Writer writer;
public void addAndWrite(int a, int b) {
int c = a + b;
this.writer.write(c);
}
}
// ...
AddAndPrintModule module = new AddAndPrintModule(new ConsoleWriter(...));
module.addAndWrite(1, 2);
// Writer는 다음과 같이 정의되어 있을 것 입니다.
interface Writer {
void write(int i);
}
그렇다면 의존성 역전 원칙을 지키기 위해서는 항상 interface를 만들고 그를 구현하는 방식으로 코드를 작성해야 하는 것 일까요? 물론 그렇지 않습니다.
좀 더 현실에 가까운 다음 예시를 보겠습니다. 다음은 게시글을 조회하는 API 엔드포인트를 정의하기 위한 Controller 코드입니다.
@AllArgsContructor
class PostReadController {
private final PostReadService postReadService;
@GetMapping("/posts/{postId}")
public PostResponse getPost(@PathParameter long postId) {
Optional<Post> post = this.postReadService.get(postId);
return toResponse(post);
}
}
위 PostReadService
에 억지로 interface를 추가한다면 어떻게 될까요?
interface PostReadService {
Optional<Post> get(long postId);
}
@AllArgsConstructor
class PostReadServiceImpl implements PostReadService {
private final PostRepository postRepository;
// ...
@Override
public Optional<Post> get(long postId) {
// ...
}
}
위 코드는 전혀 합리적으로 보이지 않습니다. 하지만 의존성 역전의 원칙을 지키기 위해서는 어쩔 수 없다구요? 다음 내용을 살펴보면 생각이 달라질 수 있을겁니다.
먼저, 왜 위와 같은 구조를 만들게 되었는지 사고 과정을 따라가보면 좋습니다.
추상화에 대한 오해
먼저 interface를 만들어야겠다고 생각하게되는 이유는, 추상화를 해야한다 = interface를 만들어야 한다 라는 생각에서 시작되었을 것 이라고 예상됩니다.
추상화는 interface를 만들거나, 추상 클래스를 만드는 것을 의미하지 않습니다. 추상화는 복잡한 모듈에서 핵심적인 개념을 간추려 내는 것을 의미합니다. interface나 추상 클래스는 간추려낸 것을 표현하는 방법 중 하나일 뿐이죠.
위 interface PostReadService
가 정의하고 있는 메소드는 get()
하나 뿐입니다. 또한 구현인 PostReadServiceImpl
이 정의하고 있는 메소드 또한 get()
하나 뿐입니다. PostReadService
가 과연 PostReadServiceImpl
의 핵심적인 개념을 잘 간추리고 있나요? 전혀 아닌 상황입니다. 물론 PostReadService
에 대한 다른 구현체가 존재한다면 이는 “구현 방법" 이라는 암시적 개념을 제외함으로써 추상화를 이루어 냈다고 할 수도 있겠죠. 하지만 우리는 PostReadService
에 대한 구현이 하나만 있다는 사실을 알고 있죠. (구현이 늘어날 일도 없을겁니다!) 이러한 상황에서 interface는 더 높은 추상화 레벨을 제공하지 못할 뿐더러, 불필요합니다. 즉, 이런 interface를 추가한다고 해서 저수준 의존성을 고수준 의존성으로 바꾼 것이 아니라는 의미죠.
물론 항상 단일 구현을 가진 interface가 틀렸다는것은 아닙니다. 시작할때부터 단일한 구현체가 예상되지 않는 모듈을 작성할 때 interface를 먼저 작성하는고 단일 구현체로 시작하는 것은 좋은 접근이죠. 아직 구현체가 추가되지 않았을 뿐이지 유일한 구현체는 아니니까요! 데이터에 접근하는 Repository가 대표적인 예시입니다. 위 예시에선 PostRepository
의 경우에 “RDB를 저장소로 사용한다" 라는 구현 디테일을 숨기고 있을 수 있습니다. 즉, PostRepository
가 interface로 따로 있고 실제로는 RDBPostRepository
가 구현하고 있는게 이상하지 않다는 것이죠. 또한 저장소 백엔드가 교체될 가능성이 없다 해도 테스트 더블 (Test Double)으로 대체될 가능성이 높기도 하죠.
물론 반대로 Repository는 항상 interface가 있어야 하는것도 아닙니다. 그 이유는 다음 섹션의 내용이 알려줄 수 있을것 같네요.
Class에 대한 오해
위에서 interface와 class의 추상화 레벨이 같음에도 class를 의존성으로 정의하는데에 거부감이 드는 사람이 있을 수 있습니다. 이는 class가 구현을 의미한다는 점에서 들 수 있는 거부감이라고 생각합니다. 이를 잘 뜯어 살펴봅시다.
Class라는 것은 선언과 구현으로 나뉘어집니다. 선언이라는것은 이 class가 제공하는 인터페이스가 무엇인가에 대한 정의입니다. 즉, 인터페이스와 구현이 합쳐진것이 Class이고 이를 분리해서 생각할 수 있다는 것을 의미합니다.
특정 class와 상속관계에 있지 않은 모듈에서, 해당 class가 제공하는 인터페이스란 무엇일까요? 바로 Public 메소드와 필드입니다. 일반적으로 Java에서 필드는 캡슐화를 통해 숨기기에 쉽게 말해 Public 메소드만을 의미한다고 말할 수 있죠. (물론 필드도 인터페이스입니다. 특히 Property를 지원하는 Python, Kotlin등 에서는 필드, Property도 메소드와 동등하게 생각하는 것이 중요합니다.)
Java에서 Public 메소드의 모음, 즉 인터페이스를 따로 분리한다면 무엇이 될까요? 바로 interface입니다. 즉, 해당 class와 상속관계에 있지 않다면 interface나 class나 의존하는 모듈 입장에서는 동등하다는 의미이죠.
결국 해당 class가 암시적인 다른 의미(RDB를 백엔드로 사용한다, 콘솔에 출력한다 등)를 지니지만 않는다면 interface를 억지로 만들어 의존하지 않아도 된다는 의미가 됩니다.
비교를 위한 빌드업을 위해 Java 이야기를 길게 했는데, 이제 Go에서의 이야기를 해본다면 Go에서 또한 의존성 역전의 원칙을 중요하게 생각합니다.
type PostRoute struct {
postReadService *PostReadService,
}
func New(postReadService *PostReadService) *PostRoute {
return &PostRoute{
postReadService: postReadService,
}
}
func (r *PostRoute) GetPost(postId int64) PostResponse {
post := r.postReadService.get(postId)
return toResponse(post)
}
Go에서도 위와 같이 PostRoute가 PostReadService에 의존하는것이 가능하지만 Java와 같은 이유로 PostReadService가 interface일 이유가 없습니다.
물론 다음과 같은 경우엔 interface가 도움이 되겠지요.
type PostReadService {
postRepository PostRepository
}
func New(postRepository PostRepository) {
return &PostReadService{
postRepository: postRepository,
}
}
// ...
인터페이스 분리의 원칙
Java에서 SOLID 원칙 중 가장 지키기 어려운 원칙은 무엇일까요? 저는 개인적으로 인터페이스 분리의 원칙 이라고 생각합니다.
Clients should not be forced to depend upon interfaces that they do not use.
클라이언트가 사용하지 않는 인터페이스에 의존하도록 강요해서는 안된다.
인터페이스 분리의 원칙에 따르면 위 Java 예시에서 나온 PostRepository의 경우엔 사실 다음과 같이 분리 되었어야 할 것 입니다.
interface PostReadRepository {
Optional<Post> get(long postId);
}
interface PostCreateRepository {
Post create(PostCreateForm post);
}
interface PostUpdateRepository {
Post update(Post post);
}
interface PostDeleteRepository {
void delete(long postId);
}
class PostRepositoryImpl implements PostReadRepository, PostCreateRepository, ... {
...
}
너무 과하지 않냐고 생각할 수 있는데, PostReadService
는 읽기만 하는 서비스입니다. 사용하지 않는 create / update / delete에 의존하지 않으려면 이렇게 인터페이스가 분리되어 있어야 한다는 의미입니다.
하지만 이렇게 인터페이스를 잘게 쪼개는것은 무리일 뿐더러, 의존하는 모듈이 팀 내에서 작성된 모듈이 아니라면 일은 더욱 어려워집니다. Java에서 인터페이스 분리의 원칙을 지키기란 매우 어려운 일이죠.
반면 Go에서는 이를 지키는것이 훨씬 쉽습니다. 그냥 의존하는 쪽에서 필요한 기능만 interface로 작성하는것이죠.
type PostReadRepository interface {
Get(postId int64) *Post
}
type PostReadService struct {
postReadRepository PostReadRepository,
...
}
...
Go에서는 이렇게 자신이 의존하고 있는 기능을 직접 interface로 작성하고 의존성에 명시하는것이 권장됩니다. 구현체를 작성하기 전에 interface를 작성하는것이 아니라요!
거대한 기능을 제공하는 외부 라이브러리도 걱정 문제 없습니다.
type FooAPI interface {
Foo() Bar
}
var service FooAPI = someModuleThatHasOver100Methods.New()
이는 우리가 테스트 더블을 만들어야할 범위를 줄여줌으로써 더 나은 테스트를 작성하기 쉽게 만들어주기도 하죠
type FakeFooAPI struct {}
// SomeModuleThatHasOver100Methods에 있는 모든 메소드를 구현할 필요가 없음
func (f *FakeFooAPI) Foo() Bar {
...
}
SomeModuleThatHasOver100Methods
에 대한 Fake를 구현한다고 하면 100개 이상의 메소드를 구현해야 했을겁니다. 하지만 Go의 interface로 인터페이스 분리의 원칙을 지킨다면 실제로 의존하는 하나의 메소드만 구현해도 테스트 더블로 충분히 사용할 수 있죠.
그리고 물론 이러한 특징은 Structural Typing의 특징이지 Go만이 가진 특징이 아니기에 Python에서도 동일한 접근을 할 수 있습니다.
from typing import Protocol
class PostReadRepository(Protocol):
def get(post_id: int) -> Post | None:
...
class PostReadService(object):
def __init__(self, repo: PostReadRepository) -> None:
super().__init__()
self.repo = repo
...
service = PostReadService(PostRepository(db_connection, ...))
...
이렇게 Go에서 의존성을 정의할 때 객체지향 언어 설계에서 사용되는 원칙을 위한 접근 방식들을 알아보았습니다. 정리해보자면 다음과 같이 할 수 있겠네요.
- 의존성을 정의할 때 단일 구현이라면 해당 구현에, 높은 추상화 모듈이 있다면 해당 모듈에 의존한다.
- 원하는 수준으로 책임이 나뉘어진 interface가 존재하지 않는다면 의존하는쪽에서 정의한다.
이러한 특징들 덕분에 Go에서 다형성은 매우 단순해지고 여러 장점을 가지게 됩니다.
먼저 의존 대상이될 모듈은 다음과 같은 장점을 가지게 되죠.
모듈을 제공하는 입장에서 어디까지 추상화 레벨을 높여 제공하지는 어려운 고민입니다.
- 단일 메소드인 경우 인터페이스를 제공하는게 맞는지
- 여러 메소드를 제공할 경우 인터페이스는 어디까지 분리해야 하는지
등등 구현 외에도 고민할 게 너무나 많죠.
하지만 Go에서는 모듈을 제공하는쪽에선 구현에 집중하고 의존하는 쪽에서 해당 모듈에 직접 의존을 하던가, 스스로 interface를 정의해 더 높은 추상화 레벨에서 의존 하던가 선택할 수 있습니다.
또한 의존을 하는 모듈쪽에선 해당 구현체가 어떤 레벨의 추상화를 제공하던, 우리가 직접 interface를 정의해 추가적인 추상화 레이어를 추가하여 의존할 수 있게 되죠. 이로 인해 복잡한 인터페이스를 가진 모듈도 쉽게 Pluggable하게 인터페이스를 재정의할 수 있으며 테스트 더블을 만들기도 쉬워집니다.
물론 의존하는쪽에서 interface를 정의할지는 선택입니다! 구현체에 의존한다고 해서 실제로 의존성 역전의 원칙을 위배하는것은 아니니까요.
하고싶은 이야기가 많아 장황한 글이 되었는데 여기까지 읽으셨다면 Go 뿐만 아니라 객체지향 언어에서 혹은 다른 Structural Typing에서도 활용하실 수 있는 인사이트를 얻으셨을거라 생각합니다.
Go에서 배운 지식이 다른 언어에서도 확장 되었듯이 이렇게 다양한 언어를 접하는것은 프로그래머로서 매우 즐거운 일이라는 이야기를 마지막으로 글을 마칩니다.
여담으로 엄밀히 말하면 Go는 객체지향 언어가 아닙니다. 하지만 SOLID 원칙과 같은 객체지향에서 시작된 많은 설계 원칙들은 다른 사상을 가진 언어에서도 비슷한 시맨틱으로 중요하게 나타난다고 생각하기에 객체지향 설계라는 제목을 붙여보았습니다.