Search

Go는 OOP일까?

주제
Backend
날짜
2023/08/21

Go는 객체 지향 언어일까?

Is Go an object-oriented language?
Yes and no. Although Go has types and methods and allows an object-oriented style of programming, there is no type hierarchy. The concept of “interface” in Go provides a different approach that we believe is easy to use and in some ways more general. There are also ways to embed types in other types to provide something analogous—but not identical—to subclassing. Moreover, methods in Go are more general than in C++ or Java: they can be defined for any sort of data, even built-in types such as plain, “unboxed” integers. They are not restricted to structs (classes).
Also, the lack of a type hierarchy makes “objects” in Go feel much more lightweight than in languages such as C++ or Java.
공식 문서에 따르면
Go는 typemethod가 있고 객체 지향 스타일의 프로그래밍을 허용하지만 type 계층이 없다.
Go의 interface 개념은 우리가 사용하기 쉽고 어떤 면에서는 더 일반적이라고 믿는 다른 접근법을 제공한다.
하위 class화와 유사하게(그러나 동일하지 않게) 다른 타입에 타입을 내장하는 방법도 있다.
Go의 method는 C++이나 Java보다 더 일반적이다.
struct(class)에 국한되지 않고, “unboxed” 정수와 같은 평범한 내장 타입을 포함하여 어떠한 종류의 데이터에 대해서도 정의될 수 있다.
또한, type 계층의 부재는 Go에서의 객체(object)를 C++이나 Java와 같은 언어보다 휠씬 더 가볍게 느끼게 합니다.

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

Go가 객체 지향인지 아닌지를 논하기전에 먼저 객체 지향 언어에 대해 정의해야한다.
위키피디아를 기준으로 아래와 같이 OOP에 대해 정의해봤다.
"객체 지향 프로그래밍"은 “객체”라는 개념을 기반으로 하는 프로그래밍 패러다임이다. 이 객체는 종종 속성(attribute)이라고 알려진 필드의 형태로 데이터를 포함할 수 있으며, 종종 메소드라고 알려진 procedure의 형태로 코드를 포함할 수 있다.
객체의 procedure는 연관된 객체의 속성(attribute)에 접근하고, 종종 해당 속성(attribute)을 수정할 수 있다.
객체의 내부 상태는 속성(attribute)과 메소드의 private/protected/public을 활용하여 외부로부터 보호(캡슐화)된다.
객체는 종종 객체 지향 언어에서 클래스의 인스턴스로 정의된다.

OOP 메커니즘

위의 개념 특성들은 가장 일반적인 객체 지향 언어인 Java와 C++에서 다음과 같은 메커니즘으로 구현된다.
Encapsulation
Go에서 패키지 레벨에서 가능
Composition
Go에서는 임베딩을 통해 가능
Polymorphism
Go에서는 인터페이스 만족을 통해 가능
타입은 모든 인터페이스 메소드를 정의하면 수동으로 구현하지 않고도 인터페이스를 만족시킨다. 거의 모든 것이 메소드를 붙일 수 있기 때문에, int와 같은 기본 타입조차도, 거의 모든 것이 인터페이스를 만족시킬 수 있다.
Inheritance
Go는 Composition보다 취약하며 나쁜 실천으로 간주되는 전형적인 타입 주도의 하위 클래스화 개념(상속)을 제공하지 않는다.

OOP의 시초

사실 Object의 개념이 극도로 복잡하고 주관적이다.
놀라운 사실은 “객체 지향”이라는 용어의 창시자인 Alan Kay가 이전에 언급한 메커니즘(캡슐화, 구성, 다형성, 상속)을 기반으로 방법론을 정립하지 않았다는 사실이다. 이러한 메커니즘들은 부수적으로 더 발전해 나갔다.
Alan Kay의 원래 개념은 다음 특성을 기반으로 정의됐다.
Messaging
Go에서는 Channel을 통해 가능
객체 간의 통신 측면에서 모듈, 객체가 어떻게 소통하는지 설계되어야 하며, 그들의 내부 속성과 행동이 어떻게 되어야 하는지보다 중요하다.
Local retention, protection, and hiding of state-process
Go에서는 public/private 속성 및 메소드를 정의함으로써 가능
Extreme late-binding of all things
Go에서는 고차 함수와 인터페이스를 통해 가능
고차 함수란 함수를 인수로 받거나 함수를 반환하는 함수이다.

Local retention, protection, and hiding of state-process (Encapsulation)

Encapsulation는 객체지향 프로그래밍의 개념으로, 데이터와 데이터를 조작하는 함수를 결합하고, 둘 다 외부의 간섭과 오용으로부터 보호한다.
Encapsulation는 객체를 유효한 상태로 유지하고 데이터를 숨기는 것에 관한 것이다. 대부분의 객체 지향 코드 베이스는 단일 대형 시스템(monoliths)이다. 적어도 Microservice가 유행하기 전까지는…
Monoliths 앱들은 보통 하나의 큰 상태를 가지며, 공유 메모리와 상태 접근은 private/protected/public 속성/메소드를 사용하여 제어된다.
이를 이용해 OOP의 장점을 이끌어낼 수 있다.
사실 DDD(Domain Driven Design)를 따르고 적절한 캡슐화를 달성한다면 광범위한 응용 프로그램에 매우 잘 작동한다.
OOP 장점: Individualism, Encapsulation, Shared memory, Mutable State

Go를 특별하게 만드는 것들

멀티코어 CPU의 출현은 언어가 병렬성 또는 동시성에 대한 완벽한 지원을 제공해야 한다는 주장이 있다.
그리고 거대한 동시성 프로그램에서 리소스 관리를 가능하게 하려면, garbage collection 또는 적어도 안전한 자동 메모리 관리를 해주는 무언가가 필요했다.
이 모든 것은 Java와 같은 객체 지향 언어에서 달성하기 매우 어렵다. 그래서 Go는 다른 접근법을 선택했다.
메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라
Go의 첫 번째 특징은 OOP가 대표하는 정의의 정확히 반대다. 이것만으로도 다른 구현/사고 방식이 적용되어야 함을 제안하는 충분한 단서가 되어야 한다.
위에서 말한 효율적인 통신은 다음을 사용해서 달성된다.
Action/Reaction, Request/Response function call design
Chain/Impulses/Stream of events

Struct vs Object

Struct의 역할

포인터를 통해 구현된 경우 유효한 상태 유지하기
서로 다른 타입의 필드들을 함께 그룹화하기

Object의 역할

외부로부터 데이터 숨기기
행동을 정의하기
메시징 수행하기
유효한 상태 유지하기
Go의 struct는 타입화된 필드의 모음이다. 데이터와 함께 그룹화하여 기록을 형성하는 데 유용하다.
Go가 객체 지향 언어처럼 보이는 가장 큰 이유는 struct와 object가 매우 비슷해 보이기 때문이라 생각된다. 따라서 객체 지향 언어에서 오는 개발자들이 Go를 그런 방식으로 작성하는 것은 자연스럽지만 사실은 그렇지 않다.
아래는 클래스 방식(receiver)으로 struct를 활용한 예시다.
type MyFakeClass struct { attribute1 string }
Go
복사
func (mc MyFakeClass) printMyAttribute() { fmt.Println(mc.attribute1) }
Go
복사
하지만 위의 Receiver 인자를 가진 메서드는 아래와 같이 일반 함수로도 작성할 수 있다.
func printMyAttribute(mc MyFakeClass) { fmt.Println(mc.attribute1) }
Go
복사
해당 방식은 Go 컴파일러가 실제로 내부에서 사용하는 방식이다.

Struct과 Object의 작동 방식 차이점

아래와 같이 구현할 경우 객체 지향적인 작동 방식을 달성할 수 없다.
// Copyright 2018 Lukas Lukac <https://lukaslukac.io>; All rights reserved. // Play: https://play.golang.org/p/FQa09CzdtRh package main import ( "fmt" ) type illusion struct { magicianCount int } func (i illusion) increaseMagicianCount() { i.magicianCount++ } func main() { myIllusion := illusion{} myIllusion.increaseMagicianCount() fmt.Println(myIllusion.magicianCount) // Expected: 1, Surprisingly: 0 // ↟ equivalent to ↡ myIllusion = illusion{} increaseMagicianCount(myIllusion) fmt.Println(myIllusion.magicianCount) // Expected: 0, Not surprisingly: 0 } func increaseMagicianCount(i illusion) { i.magicianCount++ }
Go
복사
하지만 위와 같은 상황에서 Pointer Receiver Function을 통해 원하는 객체 지향적인 작동 방식을 달성할 수 있다.
// Copyright 2018 Lukas Lukac <https://lukaslukac.io>; All rights reserved. // Play: https://play.golang.org/p/E34Y0SfAyK9 package main import ( "fmt" ) type illusion struct { magicianCount int } func (i *illusion) increaseMagicianCount() { i.magicianCount++ } func main() { myIllusion := &illusion{} myIllusion.increaseMagicianCount() fmt.Println(myIllusion.magicianCount) // Expected: 1, Actual: 1 // ↟ equivalent to ↡ myIllusion = &illusion{} increaseMagicianCount(myIllusion) fmt.Println(myIllusion.magicianCount) // Expected: 1, Actual: 1 increaseMagicianCount(nil) // I am evil developer fmt.Println(myIllusion.magicianCount) // Expected: 1, Actual: 1 } func increaseMagicianCount(i *illusion) { // with a value receiver you don't have to add unnecessary IF conditions and worry about panic attacks because // is possible to receive NIL on a pointer receiver if i == nil { return } i.magicianCount++ }
Go
복사
Pointer Receiver Function
이것이 일반적은 패턴이라고 볼 수 있다.
기술적으로, 새 인스턴스를 생성하지 않고 struct를 업데이트할 수 있는 유일한 방법이며, struct를 업데이트하는 권장된 방법이다.
다만, pointer는 보다 주의 깊게 사용해야한다. (불변성의 이유)

Value vs Pointer Receiver

Value Receiver를 사용해야 할 때

receiver가 map, func, chan일 경우.
receiver가 slice이고 메서드가 slice를 다시 자르거나 재할당하지 않는 경우.
receiver가 상태를 변경하지 않는 경우. (immutable)
receiver가 순수한 타입을 가진 작은/중간 크기의 배열 또는 구조체인 경우, 하나의 ValueObject라고 할 수 있다.
예: int, string, time.Time, XY 좌표 등 필드들의 집합을 나타내는 모든 것.

Value Receiver 장점

동시성에 안전하며 Goroutine과 호환성이 좋다.
Value Receiver의 값 복사 비용은 대다수의 응용 프로그램에서 성능 병목 부분이 아니다.
오히려 실제로 생성될 수 있는 garbage 양을 줄일 수 있다.
값이 value receiver인 메서드에 전달되면 heap에 할당하는 대신 stack에 있는(on-stack) 복사본을 사용할 수 있다.
stack은 access pattern으로 인해 메모리 할당 및 해제가 간단해기 때문에 더 빠르다.
pointer/integer는 단순하게 증가 또는 감소된다.
반면 heap은 할당 및 해제에 더 복잡한 관리가 필요하다.
작은 struct를 설계하도록 직접적으로, 의식적으로 강요한다.
캡슐화, 책임에 대해 다루기 쉽다.
단위 테스트가 간편하다.
NIL condition이 없다.
Pointer Receiver는 NIL이 전달될 수 있고, 이는 panic을 일으킬 수 있다.

Pointer Receiver를 사용해야 할 때

큰 데이터셋을 처리할 경우.
NewRelic 또는 Blockchain DataStore DB와 같은 고성능 분석 어플리케이션을 개발할 경우.
receiver가 sync.Mutex 또는 비슷한 동기화 필드를 포함하는 구조체인 경우, receiver는 복사를 피하기 위해 pointer여야 한다.
하지만 굳이 mutex을 사용해야하는 상황이 아니라면 channel을 사용하면 상관없다.
receiver가 변형을 수행하는 경우.
하지만 명확한 의도와 명백한 I/O를 가진 순수 함수를 설계함으로써 대부분 피할 수 있다.
struct가 상태를 유지하는 경우.
예) TokenCache
type TokenCache struct { cache map[string]map[string]bool } func (c *TokenCache) Add(contract string, token string, authorized bool) { tokens := c.cache[contract] if tokens == nil { tokens = make(map[string]bool) } tokens[token] = authorized c.cache[contract] = tokens }
Go
복사

Pointer Receiver 장점

CPU와 메모리 측면에서 할당된 후 더 효율적이다.
하지만 요즘 컴퓨터 하드웨어상으론 성능 병목 부분이 아니며, 몇 밀리초가 비즈니스 요구사항인지 고민해봐야한다.
상태를 유지하고 변경할 수 있다.
Receiver 요약 receiver의 value가 아주 큰 경우가 아닌 이상 value receiver가 여러 이점이 있습니다. 일반적으로 value receiver를 사용하는 것을 권장하는거 같다.
go 권장 사항 중 struct에 대한 receiver를 통일하는 것이 좋다. 특성 메서드에서 value receiver를 사용할 경우 다른 모든 메서드들 또한 value receiver로 변경하는 것이 좋다.