Go는 객체지향언어인가?
Go는 전통적인 의미의 객체지향 언어는 아니지만, OOP의 개념을 일부 다른 형태로 지원한다.
class -> struct & method
상속 -> 임베딩
다형성, 추상화 -> 인터페이스
...
Go에는 그럼 객체가 없나?
있다. 인스턴스 정도로 부르기도 하지만 '객체'로 칭하는 편이 통상의 대화에서 무리가 없다.
OOP(Object-oriented Programming)라 하면 '데이터와 그 데이터의 상태를 조작하는 동작을 하나의 클래스(객체)로 묶어서 설계하는 패러다임' 인데
여기서 '클래스'가 하나의 틀이라면 클래스로부터 실제 사용 가능한 무언가로 실체화 한 것을 '객체'로 부른다.
Go에는 클래스는 없지만 구조체와 메서드를 통해 '객체'를 만들어 사용한다고 볼 수 있다.
그럼 객체지향 아니냐?
객체지향의 4가지 특성 중 하나인 '상속'은 안한다.
그런데 '상속'으로 할 수 있는 것 중에 불필요한 것 빼고는 다 한다.
이 점이 내가 go를 선호하는 이유이자 대학다닐때 동기,선후배 사이에서 유일하게 java를 싫어했던 이유이다.
개인적으로 상속은 반복되는 코드를 줄여 보일러플레이트를 없애는데 광적으로 집착하다 탄생한 괴랄한 결과물이라고 생각함.
부모 클래스에 정의된 코드는 자식 클래스에 또 작성하지 않고 상속시키면 되니까.
기존 java식 상속 예시에서 보면 부모 클래스가 가진 메서드를 자식 클래스가 사용할 수 있다.
자식 클래스에는 저런 메서드가 선언된 적이 없는데 말이다.
class Animal {
void speak() {
System.out.println("동물이 소리내");
}
}
class Dog extends Animal {
@Override
void speak() {
System.out.println("멍멍");
}
}
Animal a = new Dog();
a.speak(); // 출력: 멍멍
보일러플레이트를 줄이고 코드의 재사용성을 높이기 위해 부모 자식 클래스간에 상속관계를 정의했으면서(꼭 이것때문은 아니지만)
'다형성'을 구현하기 위해 때에 따라 메서드는 입맛대로 바꿔 쓰고 싶다는건데
그럴거면 애초에 '의존성과 복잡도를 높이는 것 보다 약간의 중복된 코드가 낫다'는 go의 철학이 솔직 명료하지 않은가?
그러한 면에서 go의 임베딩을 보면
type Animal struct {
}
func (a Animal) Breathe() {
fmt.Println("Breathing...")
}
type Dog struct {
Animal // Animal을 임베딩
}
func (d Dog) Bark() {
fmt.Println("Barking...")
}
dog := Dog{}
dog.Breathe() // 마치 상속처럼 Animal의 메서드 사용 가능
dog.Animal.Breathe() //이것도 가능
dog.Bark()
그리고 go의 인터페이스를 이용한 다형성의 구현을 보면
type Speaker interface {
Speak()
}
type Dog struct{}
func (d Dog) Speak() {
fmt.Println("멍멍")
}
type Cat struct{}
func (c Cat) Speak() {
fmt.Println("야옹")
}
func main() {
var s Speaker
s = Dog{}
s.Speak() // 멍멍
s = Cat{}
s.Speak() // 야옹
}
'상속'을 통해 취하고자 했던 바(코드의 재사용성과 중복 최소화)는 '임베딩'과 '인터페이스'로 취하면서 상속으로 인해 발생하는 강결합에 의한 강한 의존성과 복잡도 문제는 '적당한 코드 중복'으로 타협했다.
메서드 오버라이딩도 진짜 덮어쓰는 것과는 의미가 다르긴 하지만 가능하다.
go의 동시성이 뭐가 다른가?
go는 언어 자체에서 고루틴이나 채널과 같은 동시성 기본 요소를 포함하여 만들어졌다.
그래서 외부 라이브러리의 도움 없이 동시성 애플리케이션을 개발할 수 있고, 언어 자체에서 지원하는 만큼 안정성과 보편적 사용을 어느정도 담보받을 수 있다.
와중에 성능까지 뛰어나니 덕분에 동시성 프로그래밍에서 go가 각광받을 수 밖에.
하지만 go를 사용해 개발된 docker, kubernetes 등의 프로젝트에서 시스템 중단을 야기하는 버그 중 대다수가 go의 채널을 통한 메세지 전달을 잘못 사용했기 때문으로 지적하는 주장이 있었다.
언어 자체적으로 지원하는 채널을 통한 메세지 전달이 공유 메모리 방식에 비해 버그를 덜 발생시킬 것이라는 통념과는 전혀 다른 주장인 것이다.
그럼 채널을 쓰지 말고 공유메모리를 쓰라는건가?
그건 아니다.
Do not communicate by sharing memory; instead, share memory by communicating.
(메모리를 공유해서 통신하지 말고, 통신을 통해 메모리를 공유하라.)
공유 메모리 방식은 기존의 OOP 언어에서 사용하던 방식이다.
여러 스레드가 같은 메모리 공간에 접근해서 데이터를 읽고 쓰는 경우 뮤텍스나 세마포어를 통해 락을 거는 방식으로 동시 접근을 제어한다.
잘못하면 데드락이나 레이스컨디션과 같은 문제에 봉착할 수 있으므로 '동시성 프로그래밍'이 실제로 구현하기에는 난이도가 있다고들 한다.
Go의 채널기반 메세지 전달 방식은 CSP (Communicating Sequential Processes) 모델을 따른다.
각각의 goroutine들은 독립적으로 동작하되, 이들간의 데이터 교환은 채널을 통해서 이루어진다.
메모리를 직접 공유하지 않고 채널을 통해 전달(공유라기보다는)하기 때문이다.
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
count := 0
for {
v := <-ch // 외부에서 값 받음
if v == -1 {
fmt.Println("최종 카운트:", count)
return
}
count += v
}
}()
ch <- 1
ch <- 1
ch <- 1
ch <- -1 // 종료 신호
}
이러한 예시에서, 받는 쪽이 준비될 때까지 보내는 쪽이 기다리기 때문에 채널은 기본적으로 blocking 상태에 놓이게 된다.
데이터를 밀어넣고 있는 애는 main 고루틴 혼자이니 락이 필요 없고
데이터를 받아가는 '익명 고루틴'도 한개이므로 락이 필요 없다.
만약 여러 고루틴이 같은 채널에서 값을 받으려고 하면 값은 고루틴들 중 단 하나에게만 전달되고 어떤 고루틴이 받을지는 스케줄러가 결정한다.
여기서 말하는 스케줄러는 Go 런타임이 내부적으로 가지고 있는 고루틴 스케줄러가 따로 있음.
스레드랑 고루틴은 뭐가 다른데?
고루틴은 '경량 스레드'임.
왜 경량이냐?
일반 스레드는 OS가 관리하고 스레드마다 메모리에 독립된 스택 영역을 가진다. 보통 512KB~1MB쯤 된다고 함.
그리고 하나의 프로세스 안에 여러 스레드가 존재할 수 있는데
스레드는 OS수준에서 관리되므로 이들 스레드간에 컨텍스트 스위칭이 일어나면 그 오버헤드가 매우 큰 편이다.
- Java의 Thread
- C, C++의 pthread
- Python의 threading
얘네가 다 그렇다.
경량 스레드는 OS가 가니라 Go 런타임이 관리하는 실행 단위다.
스레드 위에 한층 더 올려진 논리적 단위이고, Go 런타임에서 실행한다는건 OS수준이 아닌 유저스페이스에서 컨텍스트 스위칭이 발생한다는거니까 커널을 거치지 않기 때문에 매우 빨라진다.
게다가 경량 스레드인 고루틴의 스택 영역은 2KB정도로 매우 작다.
그래서 고루틴은 네트워크 서버 / 병렬 I/O / 대량 동시성에 특화하여 사용되고 있고
수천~수만 개 동시 실행이 필요한 상황에서 경량 스레드 모델이 훨씬 효율적으로 동작할 수 있다.
'Golang' 카테고리의 다른 글
golang의 실수 계산(부동소수점 오차, 머신 엡실론) (0) | 2022.11.13 |
---|