생성 패턴 이란?

소프트웨어 엔지니어링에서 생성 디자인 패턴은 개체 생성 메커니즘을 다루는 디자인 패턴으로 상황에 적합한 방식으로 개체를 생성하려고 합니다. 개체 생성의 기본 형태는 디자인 문제를 일으키거나 디자인에 복잡성을 더할 수 있습니다. 생성 디자인 패턴은 이 객체 생성을 어떻게든 제어함으로써 이 문제를 해결합니다. --wikipeda--

어떤 아이디어에 착안해서 패턴으로 자리 잡았는가?

2가지 의 메인 콘셉트가 있다. 하나는 시스템 이 사용하는 구체적인 클래스에 대해 캡슐화하는 것이고, 다른 하나는 이런 구체적인 클래스의 인스턴스 생성 결합되는 방식을 숨기는 것이다.

 

나아가 생성패턴 은 클래스와 객체 생성 패턴으로 분류될 수 있다. 객체 생성 패턴은 객체를 , 클래스 생성 패턴은 클래스 인스턴스화를 

처리한다. 

 

Common Structure

왼쪽 은 대부분의 생성 패턴이 공통적으로 가지고 있는 간단한 클래스 다이어그램이다.
다른 생성 패턴에는 추가 및 다른 참여 클래스가 필요하다.

 

간단 한 설명을 보자면

 

· Creator = 개체의 인터페이스를 선언하고, 반환값을 준다. 

· Concrete Creator = 개체 인터페이스를 구현하는 클래스이다.

 

이에 따라 잘 알려진 생성 디자인 패턴은 

추상 팩토리, 빌더, 팩토리 메서드, 프로토 타입, 싱글톤 패턴으로 나뉘고 이중 팩토리 메서드 패턴부터 보자.

 

[팩토리 메서드 패턴]

클래스 기반 프로그래밍에서 정확한 클래스를 지정하지 않고도 객체 생성 문제를 처리하는 방식이다. 생성자를 호출하는 대신

인터페이스에 지정되고 자식클래스에 의해 구현되거나 기본,파생 클래스 에 의해 선택적으로 구현되는 방식이다.

 

"객체 생성을 공장 클래스, 함수로 캡슐화 처리하여 대신 생성 하게 하는 생성 디자인 패턴이다."

"객체 생성에 필요한 과정을 템플릿처럼 미리 구성해 놓고 , 객체 생성에 관한 전, 후 처리를 통해 객체를 유연하게 만들 수 있다"

 

아래와 같은 문제에 직면하게 된다면? 팩토리 메서드 패턴을 적용해 보면 최적의 선택이 될 수 있다.

  • 서브 클래스가 인스턴스 화 할 클래스를 재정의하려면 어떻게 해야 할까?
  • 클래스는 어떻게 인스턴화를 하위 클래스에 위임할 수 있을까?

의 주된 문제를 해결하는 설루션이 팩토리 메서드 패턴이다. 

 

바로 가보자.

피자 가게를 운영한다고 가정해보자. 주문에 해당하는 코드를 아래와 같이 정의해 보자.

type Pizza struct{}

func (p *Pizza) prepare() {
	fmt.Println("Prepare")
}
func (p *Pizza) bake() {
	fmt.Println("Bake")
}
func (p *Pizza) cut() {
	fmt.Println("cut")
}
func (p *Pizza) box() {
	fmt.Println("boxing")
}

func orderPizza(t string) Pizza {
	var p Pizza	

	switch t {
	case "Cheese":
		// 치즈 피자가 p 가 된다.
	case "Hawaiian":
		// 하와이안 피자가 p 가 된다
	case "Margherita":
		// 마르게리타 피자가 p 가 된다.
	default:
		// 디폴트 피자 는 햄 피자가 p 가 된다.
	}
	
	p.prepare()
	p.bake()
	p.cut()
	p.box()

	return p
}

이 코드의 문제점 만약 새로운 피자가 추가되거나 삭제되거나 하게 된다면 기존 코드 의 변경이 필요하다. 즉 저 orderPizza 부분 이 문제가 된다는 소리이다.  Switch에 해당하는 부분을 캡슐화해보자.

더보기
package main

import "fmt"

type Pizza interface {
	prepare()
	bake()
	cut()
	box()
}

type pizza struct {
	name string
}

func (p *pizza) prepare() {
	fmt.Println("피자 소스, 토핑, 치즈 준비중 .....")
	fmt.Println("오븐 달구는중 .......")
	time.Sleep(time.Millisecond * 500)
}
func (p *pizza) bake() {
	fmt.Println("피자 굽는중 ....")
	time.Sleep(time.Second)
}
func (p *pizza) cut() {
	fmt.Println("피자 자르는중 .........")
}
func (p *pizza) box() {
	fmt.Printf("[%s] 박스에 넣기 완료\n", p.name)
}

type Cheese struct {
	pizza
}
type Hawaiian struct {
	pizza
}
type Margherita struct {
	pizza
}
type Ham struct {
	pizza
}

type FactoryPizza struct {
	p Pizza
}

func (f *FactoryPizza) createPizza(t string) Pizza {
	switch t {
	case "Cheese":
		// 치즈 피자가 p 가 된다.
		f.p = &Cheese{pizza{"치즈 피자"}}
	case "Hawaiian":
		// 하와이안 피자가 p 가 된다
		f.p = &Cheese{pizza{"하와이안 피자"}}
	case "Margherita":
		// 마르게리타 피자가 p 가 된다.
		f.p = &Cheese{pizza{"마르게리타 피자"}}
	default:
		// 디폴트 피자 는 햄 피자가 p 가 된다.
		f.p = &Cheese{pizza{"햄 피자"}}
	}
	return f.p
}

조금 많은 과정이 들어갔지만 하나씩 차근차근 살펴보자.

피자에 해당하는 구조체 들을 각각 선언했으며, 피자 인터페이스 와 중간 구현체 그리고 상속을 받는 구조이다.

이렇게 함에 따라 최하위 구현체들에게 생성의 책임을 위임할 수 있으며 중간구현체 가 있음으로 공통되는 부분을 제거해서

사용할 수 있다. 

 

이에 대해 클라이언트 코드 도 수정해 보자.

type PizzaStore struct {
	factory FactoryPizza
}

func (ps *PizzaStore) orderPizza(pizza string) Pizza {
	p := ps.factory.createPizza(pizza)

	p.prepare()
	p.bake()
	p.cut()
	p.box()
	return p
}

팩토리를 피자 스토어에 던져주고 그걸 이용해 피자만 생성해 주면 된다.

코드를 보면 희한한 감이 생긴다 그냥 스위치 부분을 아래 구조체에 그냥 넘긴 게 아닌가? 뭐가 다른지 모를 수도 있다.

만약 이 피자

팩토리를 사용하는 곳이 정말 많고 생성된 피자의 객체를 받아와서 설명하고, 가격을 알려주는 함수가 추가적으로 있다면 하위클래스 모든 곳에서 이에 해당하는 함수를 전부 구현해야 합니다.

 

가게를 확장해 보자. 새로운 지점과 서로 다른 피자를 제공하는 경우가 생긴다면?

단순하게 생각해 본다면 factoryzPizza를 여러 개 만들면 되지 않을까? 바로 가보자.

더보기

메인 타입

type PizzaStore interface {
	orderPizza(name string) Pizza //최상위 인터페이스 강제할 함수
}
type PizzaProducer interface {
	createPizza(name string) Pizza //인터페이스 구현체 에서 사용될 함수 를 강제할 부분
}

type pizzaStore struct {
	p  Pizza
	pp PizzaProducer // 최초 구현체 생성시 인자를 주입받기 위해 생성
}

func (p *pizzaStore) orderPizza(name string) Pizza {
	p.p = p.pp.createPizza(name)

	p.p.prepare()
	p.p.bake()
	p.p.cut()
	p.p.box()

	return p.p
}

 하위 구현체

type NyPizza struct {
	pizzaStore
}

func (n *NyPizza) createPizza(t string) Pizza {
	switch t {
	case "Cheese":
		// 치즈 피자가 p 가 된다.
		n.p = &Cheese{pizza{"뉴욕 스타일 치즈 피자"}}
	case "Hawaiian":
		// 하와이안 피자가 p 가 된다
		n.p = &Cheese{pizza{"뉴욕 스타일 하와이안 피자"}}
	case "Margherita":
		// 마르게리타 피자가 p 가 된다.
		n.p = &Cheese{pizza{"뉴욕 스타일 마르게리타 피자"}}
	default:
		// 디폴트 피자 는 햄 피자가 p 가 된다.
		n.p = &Cheese{pizza{"뉴욕 스타일 햄 피자"}}
	}
	return n.p
}

type LAPizza struct {
	pizzaStore
}

func (n *LAPizza) createPizza(t string) Pizza {
	switch t {
	case "Cheese":
		// 치즈 피자가 p 가 된다.
		n.p = &Cheese{pizza{"LA 스타일 치즈 피자"}}
	case "Hawaiian":
		// 하와이안 피자가 p 가 된다
		n.p = &Cheese{pizza{"LA 스타일 하와이안 피자"}}
	case "Margherita":
		// 마르게리타 피자가 p 가 된다.
		n.p = &Cheese{pizza{"LA 스타일 마르게리타 피자"}}
	default:
		// 디폴트 피자 는 햄 피자가 p 가 된다.
		n.p = &Cheese{pizza{"LA 스타일 햄 피자"}}
	}
	return n.p
}

최종 클라이언트 코드

func main() {
	a := &pizzaStore{pp: &NyPizza{}} // 피자 의 프로듀서 를 각각 주입 해준다.
	b := &pizzaStore{pp: &LAPizza{}} 

	a.orderPizza("Cheese")
	fmt.Println()
	b.orderPizza("Hawaiian")
}

이렇게 특정 피자 가게 의 주입을 넣어줄 수 있으면서 피자 생성이 가능해진다. 이렇게 생산된 피자의 제조 방법 은 모두 캡슐화되어 관리된다. 그림으로 보면 아래와 같다.

이렇게 병렬 적으로 구성된 생산자, 제품 클래스는 무한히 확장 가능한 형태가 된다.

 

"팩토리 메서드 패턴은 객체를 생성할 때 피룡한 인터페이스를 만듭니다."

"어떤 클래스의 인스턴스를 만들지는 서브 클래스에서 결정합니다. "

 

크리에이터에 해당하는 부분은 실질적인 제품의 생산이 아닌 어떤 방식으로 만들어야 하는지에 대한 방식만 결정하고 있다.

이에 반해 제품에서는 실질적은 인스턴스의 반환 이 일어나고 있다.

다시 말해 사용하는 서브클래스에 따라 반환되는 객체 인스턴스가 결정된다.

 

마지막 클라이언트 호출부에서 고민이 생긴다.

함수형태로 반환해서 이름을 주입하는 방법 도 하나의 좋은 방법이 될 거 같아 공유하고자 한다.

type PizzaStore interface {
	orderPizza(p PizzaProducer) func(name string) Pizza
}

func main() {
	// 호출 /....
    var ps PizzaStore
   	pa.orderPizza(&NyPizza{})("cheese")
    
    a := &pizzaStore{pp: &NyPizza{}}
    a.orderPizza("cheese")
}

구현의 방식은 중요하지 않다. 중요한 부분은 생성하는 서브 클래스 의 생성자 주입이고 그거의 형태를 최초 생성 시 넣어주느냐

아니면 함수 호출 과정에 넣어주느냐의 차이이다.


단 이런 추상 팩토리 패턴은 가끔씩 안티패턴으로 불리기도 한다. 왜?

  1. 과도한 추상화: 추상화는 객체 생성에 대한 복잡성을 감추기 위한 목적으로 사용되어야 합니다. 그러나 과도한 추상화는 코드를 이해하기 어렵게 만들고 유지보수를 어렵게 만듭니다.
  2. 복잡성 증가: 팩토리 메서드 패턴은 객체 생성을 처리하는 인터페이스를 정의하기 때문에, 팩토리 클래스와 그 클래스의 서브 클래스 사이의 상호작용이 복잡해질 수 있습니다.
  3. 새로운 타입 추가의 어려움: 팩토리 메소드 패턴을 사용하면 새로운 객체 타입을 추가하는 것이 상대적으로 어려워집니다. 새로운 타입을 추가하려면 팩토리 클래스와 그 클래스의 서브 클래스를 수정해야 합니다.
  4. 성능 저하: 팩토리 메소드 패턴은 객체 생성 시간이 길어지는 경우 성능에 영향을 미칠 수 있습니다. 이는 팩토리 클래스가 객체를 생성하는 과정에서 오버헤드가 발생하기 때문입니다.

1번 관점에서 이유없는 추상화가 발생한다면 이런경우에 해당한다. 추상화 된 인터페이스에 대해 아무 행동이 없다면 ? 굳이 추상화를 거칠 필요가 없다. 다시말해 그냥 일반적으로 구현하는게 보다 직관적 이라는 소리 이다.

 

2번 은 구현하면서 상당히 많이 느꼇다. 실제적으로 인터페이스 팩토리 클래스 사이 에 어떤 연관 을 규정하고 구현 해야할지 상당히 애를 먹었다.

 

3번 의 관점에서 본다면 위에 구현된 예제는 안티패턴이 되어버린다. 왜? OCP를 어기고 있다. 새로운 타입 이 추가되면 기존코드를 고쳐야한다. 예를들어 Switch 문에서 새로운 뉴욕스타일의 피자가 추가된다면 ? 기존 코드를 고쳐야 한다. 이는 OCP 를 어기고 있다. 

그럼에도 팩토리 메서드  패턴이 사용되는 이유로는 기존 클래스 들과 의 연관관계 가 상당히 적어진다.

즉 개별적으로 동작한다는 소리이다. 

새로운 피자가 추가된다고 해서 기존 클래스에 어떤 연관관계 가 있는지 생각해 본다면 보다 명확해지지 않을까 싶다.
예를 들어 기존 뉴욕 치즈 피자에 새로운 스트링이 추가된다면 ? 그 부분만 수정한다고 해서 LA 피자가 바뀌는가 ? 라고 생각해보면 쉽게 답할수 있다.

 

4번 예를 들어 db 의 커넥션 팩토리 메소드 가 있다고 생각해보자. 그 커녁센 이 무조건 있어야 실행되는 어플리케이션 이라면 ? 그 커넥션 을 위해 계속적인 리트라이가 일어나게 된다면 ? 성능의 저하가 발생될수도 있다. 


다시 처음에 설명했던 생성패턴 은 2가지 콘셉트에 대해 살펴보자.

1. 하나는 시스템 이 사용하는 구체적인 클래스에 대해 캡슐화하는 것이고,

: orderPizza 안에 creaetPizza를 숨겨 클라이언트는 피자의 생성 방식을 알지 못한다.

2. 다른 하나는 이런 구체적인 클래스의 인스턴스 생성 결합되는 방식을 숨기는 것이다.

: 각각의 하위 구현체 들은 피자의 생성된다.

 

이로 인해 얻는 장점은?

객체의 생성을 제어하고, 단순화한다.

단순 한 줄이면 우리는 원하는 객체를 얻을 수가 있으며 심지어 다양한 객체를 생성할 수도 있는 힘이 생긴다.

그렇게 생성된 객체에 대해 우리는 특정 행동을 명시하고 제어할 수 있는 제어권 이 생긴다. 

 

또한 생성패턴이 적용되어. 캡슐화되어 있기 때문에 단위 테스트 하는 데 있어 용이해진다.

보이는가? 정말 낮은 단계에서부터 상위까지 이렇게 정말 쉽게 모든 생성자에 대해 손쉽게 생성할 수 있다. 

팩토리 메서드 패턴의 장점이 여기서 드러난다.

이렇게 생성된 객체 들에 대해 추가적인 행동에 대해 검증하면 된다.

정말 손쉽고 작은 단위로 모든 객체에 대해 이렇게 테스트가 쉽다는 것은 정말 큰 장점이라고 생각한다.

 

패턴은 너무 어렵다 ....

 

 

 

 

출처

Encapsulation and Testing

Go DesignPattern Udemy

ChatGpt 예제

고 디자인 패턴

헤드퍼스트 디자인패턴

위키피디아

 

Why Factory method pattern call as a anti-pattern

출처 : https://go.dev/doc/effective_go#concurrency

 

Effective Go - The Go Programming Language

Effective Go Introduction Go is a new language. Although it borrows ideas from existing languages, it has unusual properties that make effective Go programs different in character from programs written in its relatives. A straightforward translation of a C

go.dev

Concurrency

드디어 고 언어의 꽃인 동시성 프로그래밍 챕터에 왔다. 지나오면서 고 언어에서 oop 를 하기위해 제공되는 여러 키워드 들을 살펴봤고 문법 과 실행 방식에 대해 공부했지만 이번에는 그 결이 다른 동시성 프로그래밍 이다.

바로 읽어보자.

Share by communicating

고 에서는 공유된 자원이 채널 을 통해 전달되는 기존 동시성 프로그래밍 의 방법과 궤를 달리한다고 한다. 

오직 하나의 고루틴 에서만 주어진 시간 동안 값에 접근 할수있다. 이에 따른 데이터 레이스는 설계에 따라 발생할수 없다. 

왜 ? 데이터 레이스란 하나의 값에 동시다발적으로 두개 혹은 그이상의 동시쓰레드 가 접근하려고 할때 발생 되는데 현재 문서에서 말하는 오직 하나의 고루틴에서만 접근하기 때문에 설계적으로 발생할수 없다고 한다.

고 에서 는 변수 주변에 뮤텍스 락을 걸어 수행할수 있다 다만 채널 을 사용해 접근한다면 ? 보다 명확하고 올바르게 프로그램을 더 쉽게 작성할수 있다.

Goroutines

기존 용어의 다양한 전달에 따라 고루틴으로 부르고 있다.  고루틴은 단순한 모델을 가지고 잇는데. 동일한 주소 공간을 가지고 함수 와 고루틴 이 동시실행되는 것을 의미한다.

고루틴 은 가벼우며, 스택 에 할당되는 값보다 조금 더 비용이 소모된다. 스택 은 메모리 할당 의 비용이 저렴하며, 많은 할당 즉 데이터가 요구 되어진다면, 힙메모리를 이용하기도 한다.

응 ? 문장이 매우 난해하다. 기본적으로 이 베이스가 있어야 한다. 스택 > 힙 메모리 공간 보다 빠르다. 그러나 힙 처럼 다량 을 한번에 할당하기에 제한이 있다. 그렇기에 고루틴은 경량 쓰레드 라는 별명이 있다 왜 ? 스택에 할당되기에 가벼워야하며 빠르기때문에
(스택 은 Cpu 단일 메모리 로 접근이 가능하기에 빠르다 주로 함수호출, 함수 반환값 주소, 임시변수 등이 할당됨)

글을 마저 읽다보면 The effect is similar to the Unix shell's & notation for running a command in the background
유닉스 쉘의 & 백그라운드 실행과 유사한 기능을 한다고 한다. 

예시를 보자.

func Announce(message string, delay time.Duration) {
    go func() {
        time.Sleep(delay)
        fmt.Println(message)
    }()  // Note the parentheses - must call the function.
}

우선 저 함수 선언 과 호출 부분 부터 눈에 가장 확실히 들어오는데 go 키워드를 앞에붙여주며 함수를 선언과 동시에 실행한다.

이건 Go 루틴의 일반적인 예시가 아니라고 한다 왜 ? 완료 신호를 보내줄수 없기 떄문에 이 고루틴 함수가 언제 끝나는지 모른다. 

이에 따라 채널을 활용한 방법에 대해 이야기 하는데 예제 몇개를 더 작성해보면서 연습해보자.

func main() {
	practice.F("Direct")

	go practice.F("GoRoutine")

	go func(msg string) {
		for i := 0; i < 3; i++ {
			fmt.Println(msg, ":", i)
		}
	}("anonymous")

	time.Sleep(time.Second)
	fmt.Println("Done")
}

Direct : 0
Direct : 1
Direct : 2
anonymous : 0
anonymous : 1
anonymous : 2
GoRoutine : 0
GoRoutine : 1
GoRoutine : 2
Done

이런 결과 값이 나온다. 그러나 여러번 실행하면 멀티쓰레드 답게 순서 보장없이 마구잡이로 나온다.

단 중간에 타임 슬립 이 있다 이게 왜 필요할까 ?

메인 펑션 또한 고루틴 함수이다. 이에 메인 펑션은 종료되면 ? 모든함수가 종료된다. 즉 저 고루틴 이 실행되는것을 기다려주지 않는다.

만약 저 중간 함수가 없다면 ? 메인 고루틴은 코드를 쭉읽어가면서 함수를 바로 종료한다.

그래서 이 위의 설명중 하나 완료신호 를 주는 방법 중 하나로 채널에 대해 언급하는 이유이다.

(이렇게 말고 sync.WaitGroup 을 이용해 저렇게 슬립을 안하고 이용하는 방법도 있다)

Channels

전에 make 함수를 이용해서 만들수 있는 타입 중에 채널이 언급된 적이있다. 즉 채널을 생성하기 위해서는 make 함수를 이용해 만들고 이에 대한 반환값은 ? 값벨류 이지만 값복사 가 아닌 레퍼런스 복사가 발생한다.

또한 채널의 버퍼사이즈를 설정해줄수 있다. 마치 슬라이스 사이즈를 설정하는것과 유사하다.

ci := make(chan int)            // unbuffered channel of integers
cj := make(chan int, 0)         // unbuffered channel of integers
cs := make(chan *os.File, 100)  // buffered channel of pointers to Files
func main() {
	c := make(chan int)
	go func() {
		fmt.Println("Go routine is Running")
		for i := 0; i < 10; i++ {
			c <- i * i
		}
	}()
	for i := 0; i < 10; i++ {
		fmt.Println("Value is : ", <-c)
	}
	fmt.Println("Function is done")
}

 

채널 에 데이터 를 집어넣고 그 갯수 만큼 뺴는 로직이다. 재미있는 부분이 있는데 <-c 채널에서 이렇게 데이터를 뽑는 부분에서 넣어주는 데이터 와 끝나는 데이터 가 일치하지 않으면 ? 채널을 받는 로직에서는 무한히 기다리면서 메인 고루틴 과의 데드락 을 야기한다.

언퍼버 채널 이라면 ? 송신자는 수신자가 데이터를 수신할떄 까지 기다리고, 수신자는 송신자가 데이터를 전달해줄때 까지 기다린다.

버퍼 채널 이라면 ? 송신자는 버퍼의 사이즈가 가득차기 전까지 계속 데이터를 보낸다. 만약 가득찼다면 수신자 중가 데이터를 원복할떄 까지 기다린이후 다시 재 진행 된다. 아래 예를 보자.

func main() {
	// Buffered channel with buffer size 2
	bufferedChannel := make(chan int, 5)
	go func() {
		for i := 1; i <= 5; i++ {
			bufferedChannel <- i
			fmt.Println("Sent value on buffered channel:", i)
		}
		close(bufferedChannel)
	}()
	for value := range bufferedChannel {
		fmt.Println("Received value from buffered channel:", value)
	}
	fmt.Println("Buffered channel is done.")

	// Unbuffered channel
	unbufferedChannel := make(chan int)
	go func() {
		for i := 1; i <= 5; i++ {
			unbufferedChannel <- i
			fmt.Println("Sent value on unbuffered channel:", i)
		}
		close(unbufferedChannel)
	}()
	for value := range unbufferedChannel {
		fmt.Println("Received value from unbuffered channel:", value)
	}
	fmt.Println("Unbuffered channel is done.")
}

Sent value on buffered channel: 1
Sent value on buffered channel: 2
Sent value on buffered channel: 3
Sent value on buffered channel: 4
Sent value on buffered channel: 5
Received value from buffered channel: 1
Received value from buffered channel: 2
Received value from buffered channel: 3
Received value from buffered channel: 4
Received value from buffered channel: 5
Buffered channel is done.
Sent value on unbuffered channel: 1
Received value from unbuffered channel: 1
Received value from unbuffered channel: 2
Sent value on unbuffered channel: 2
Sent value on unbuffered channel: 3
Received value from unbuffered channel: 3
Received value from unbuffered channel: 4
Sent value on unbuffered channel: 4
Sent value on unbuffered channel: 5
Received value from unbuffered channel: 5
Unbuffered channel is done.

 

 

보는 바와 같이 버퍼 와 언버퍼 간의 차이가 존재한다. 따라서 이런 두종류 의 버퍼에 대해서 선택할떄 여러가지 경우의수를 고려해야할것으로 보인다. 만약 리시버가 느리다면 ? 버퍼 채널이라면 ? 샌더는 자주 락에 걸려 성능저하 가 발생하고 메모리 의 사용 제한이 생긴다면 ? 버퍼 채널인 경우 적절한 사이즈 를 찾는데 있어 고심을 해야한다.

Channels of channels

이러한 채널 또한 타입으로 인정받고 넘길수 있다.

이전 예제로 서버에서 리퀘스트 를 유니크하게 유지하는 방법에 대한 예제를 들었는데

거기서 Request 의 타입에 대한 정의를 내리진 않았다.

type Request struct {
    args        []int
    f           func([]int) int
    resultChan  chan int
}
func sum(a []int) (s int) {
    for _, v := range a {
        s += v
    }
    return
}

request := &Request{[]int{3, 4, 5}, sum, make(chan int)}
// Send request
clientRequests <- request
// Wait for response.
fmt.Printf("answer: %d\n", <-request.resultChan)

클라이언트 는 위와 같은 함수를 제공하고 채널로 데이터를 보내주면 ?

func handle(queue chan *Request) {
    for req := range queue {
        req.resultChan <- req.f(req.args)
    }
}

서버에서는 단순 리퀘스트 안 채널로 받은 함수의 결과값을 받아 넣어주면 된다.

Parallelization

채널 사용의 병렬화 이다. 어떻게 ? 멀티 코어에 각 계산의 조각 을 주고 실행하는것이다. 바로 가보자.

아이템 의 백터를 하는데 있어 비용이 많이들고, 각 값에 대해 모든 아이템은 독립적으로 수행된다고 해보자.

type Vector []float64

// Apply the operation to v[i], v[i+1] ... up to v[n-1].
func (v Vector) DoSome(i, n int, u Vector, c chan int) {
    for ; i < n; i++ {
        v[i] += u.Op(v[i])
    }
    c <- 1    // signal that this piece is done
}

그래서 우리는 총 실행되는 숫자의 갯수만 신경쓰면된다.

const numCPU = 4 // number of CPU cores

func (v Vector) DoAll(u Vector) {
    c := make(chan int, numCPU)  // Buffering optional but sensible.
    for i := 0; i < numCPU; i++ {
        go v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u, c)
    }
    // Drain the channel.
    for i := 0; i < numCPU; i++ {
        <-c    // wait for one task to complete
    }
    // All done.
}

여기서 강조하는 문구로는 동시성 과 병렬성을 혼동하지말라고 한다.

고는 동시성 언어이지, 병렬성을 위한 언어가 아니다. 그럼에도 불구하고 종종 구조 적 인 방법으로 병렬성 문제를 쉽게 해결할수 있지만. 고는 동시성 언어 이다. 모든 병렬적 문제를 해결하기 위해 알맞지 않을수도 있다. 라고 한다.

* 아침을 한다고 가정

동시성 => 계란을굽는다(), 식빵을 굽느다(),커피머신을 돌린다()

병렬성 =>  팬(계란을 굽는다(), 베이컨을 굽는다())

로 분리할수 있다.

A leaky buffer

줄줄 새는 버퍼 라는 즉 관리되지 않는 버퍼 라는 타이틀이다.

var freeList = make(chan *Buffer, 100)
var serverChan = make(chan *Buffer)

func client() {
    for {
        var b *Buffer
        // Grab a buffer if available; allocate if not.
        select {
        case b = <-freeList:
            // Got one; nothing more to do.
        default:
            // None free, so allocate a new one.
            b = new(Buffer)
        }
        load(b)              // Read next message from the net.
        serverChan <- b      // Send to server.
    }
}

고루틴 클라이언트 가 데이터 를 소스로 부터 반복적으로 받는다고 가정해보자 . 버퍼 하나를 잡고 리스트 로 부터 데이터를 잡아 넣어주고 준비가 되었다면 ? 서버채널 로 이 버퍼를 날려준다.

func server() {
    for {
        b := <-serverChan    // Wait for work.
        process(b)
        // Reuse buffer if there's room.
        select {
        case freeList <- b:
            // Buffer on free list; nothing more to do.
        default:
            // Free list full, just carry on.
        }
    }
}

서버 는 서버 채널로 부터 데이터를 가져와서 특정 로직을 거치고 그 채널에 메모리가 남아있다면 다시 리스트로 보내는 로직이다.

만약 freeList 가 가득차있다면 ? 현재 가져온 버퍼는 가비지 컬렉터의 수집대상이 된다. 

이 패턴은 오브젝트를 다시 사용하는 단순한 패턴이다. 메모리 할당의 오버헤드 를 줄이기 위해 사용되는 방법중 하나라고 한다.

 

'Go > Go Basic' 카테고리의 다른 글

Ulitmate-Go-01 (string,메모리 패딩)  (3) 2023.08.19
Go Interface, embedded  (0) 2023.02.28
Effective Go 03  (0) 2023.02.10
Effective Go 02  (0) 2023.02.09
Effective Go 01  (1) 2023.02.08

출처 : https://go.dev/doc/effective_go#append

 

Effective Go - The Go Programming Language

Effective Go Introduction Go is a new language. Although it borrows ideas from existing languages, it has unusual properties that make effective Go programs different in character from programs written in its relatives. A straightforward translation of a C

go.dev

Methods

 

Pointers vs. Values

Go 언어를 공부하면서 제일 고민이 많이 되었던 부분이다. 어떤 예제는 메서드로 어떤 예제는 그냥 함수로 설정해 리턴한다.

두 가지 방법을 모두 확인해 보자.

type ByteSlice []byte

func (slice ByteSlice) Append(data []byte) []byte {
    // Body exactly the same as the Append function defined above.
}

func (p *ByteSlice) Append(data []byte) {
    slice := *p
    // Body as above, without the return.
    *p = slice
}

둘 다 동일한 로직이다. 다만 표현의 차이인데 이에 대해 규칙을 아래와 같이 설명하고 있다.

1. 두  번째 함수는 포인터 그리고 값 모두에서 작동되지만, 값 메서드는 오직 값 타입에서만 함수를 호출할 수 있다 는 차이점이다. ChatGpt 가 준 예시를 보자.

type MyInt int

func (m MyInt) add(x int) MyInt {
    return m + MyInt(x)
}

func (m *MyInt) addPointer(x int) MyInt {
    *m += MyInt(x)
    return *m
}

a := MyInt(5)
b := &a

// Value method can be invoked on a value
fmt.Println(a.add(10)) // 15

// Value method can also be invoked on a pointer
fmt.Println(b.add(10)) // 15

// Pointer method can only be invoked on a pointer
fmt.Println(b.addPointer(10)) // 25

// Pointer method cannot be invoked on a value
// This will result in a compile error: "cannot call pointer method on MyInt value"
// fmt.Println(a.addPointer(10))

여기서 주목할 점은 a , b의 타입이다 a는 값타입 이고, b 는 포인터 타입이다. b 는 값,포인트 메서드 각각을 호출 가능하지만 

a 는 포인터 메서드 호출 시에 에러가 발생한다.

이러한 규칙에 따라 포인터메서드는 호출 값을 수정할 수 있다는 점 과 값메서드는 호출 시 새로운 값을 받는 차이점 이 존재한다.

하나 고에서는 편리한 예외를 적용해 주는데 주소를 지정할 수 있는 변수라면 포인터 타입의 메서드 또한 호출할 수 있다는 점이다.

즉 컴파일러가 이를 도와준다고 한다.

그러면 다시 생각해 보면 위에 예는 틀린 예제이다. chatgpt 한테 다시 물어봤다.

더보기

Okay thanks i see However in view of case on Effective go documentation they said "When the value is addressable, the language takes care of the common case of invoking a pointer method on a value by inserting the address operator automatically. In our example, the variable b is addressable, so we can call its Write method with just b.Write." if this is right your example is wrong, you would be able to call a.addPointer(10) as well.

Would you give me some more details?

Answer is :

Yes, I apologize for that mistake. You're correct, in Go, when the value is addressable (i.e., it has an address), you can call a pointer method on that value and Go will automatically insert the address operator.

In the example from the Effective Go documentation, b is a pointer to an addressable value, so you can call its Write method as b.Write. When you call a pointer method on a value, Go will automatically insert the address operator and convert the value to a pointer.

So, in our example, you could also call a.addPointer(10), and Go would automatically convert the value a to a pointer and call the addPointer method on that pointer. Here's an updated example to show this:

goCopy code
a := MyInt(5) b := &a // Value method can be invoked on a value fmt.Println(a.add(10)) // 15 // Value method can also be invoked on a pointer fmt.Println(b.add(10)) // 15 // Pointer method can be invoked on a pointer fmt.Println(b.addPointer(10)) // 25 // Pointer method can also be invoked on a value fmt.Println(a.addPointer(10)) // 30

In this example, you can see that both b.addPointer(10) and a.addPointer(10) produce the same result.

 

잘못된 예시에 대해 사과를 해주는 게 정말 친절하다 ㅋㅋ
다만 나의 궁금증 은 해결되지 않았다. 어떤 상황에 대해 어떤 메서드를 써야 할지 에 대한 특별한 룰이나 나만의 기준을 정하고 싶어 추가적으로 좀 더 자료를 찾아보았다.

 

대체적으로 값은 변경을 원한다면 포인터 리시버를 그게 아니라면 벨류 리시버 를 사용한다고 한다. 추가적으로 포인터 리시버에 대한 고려사항으로 리시버 자체 의 크기가 너무 크다면? 이건 고려 의 여지없이 포인터 리시버를 사용해야 메모리 이슈가 없을 것이다. 
팀장님 께 여쭤보니 글로벌한 함수에 대해서는 벨류를 보편적으로 사용하는 편이며 일관적으로 사용하는 것을 주로 한다고 하신다.

Interfaces and other types

Interfaces

인터페이스이다 자바에서 도 많이 보던 친숙한 키워드인데 고에서 는 어떻게 정의하는지 확인해 보자. 
객체의 특정 행동 규약을 정의할 때 제공한다고 한다. 

주로 1~2 개의 메서드를 정의하고, io.Writer의 Write를 예시로 들 수 있다.

(포스팅 은 안 했지만 주로 인터페이스 이름 규약으로는 뒤에 er을 붙인다.)

 

자바와 똑같이 하나의 타입은 다양한 인터페이스를 구현할 수 있다. 컬렉션 은 정렬될 수 있는데 sort의 구현체를 구현해야 한다.

Len() Less(i, j int) bool Swap(i, j int) 바로 예시를 들어 정렬을 해보자.

type twitter struct {
	like  int
	title string
}

type twit []twitter

func (t twit) Len() int {
	return len(t)
}
func (t twit) Less(i, j int) bool {
	return t[i].like < t[j].like
}
func (t twit) Swap(i, j int) {
	t[i], t[j] = t[j], t[i]
}

func main() {
	a := twit{}
	a = append(a,
		twitter{249249, "Hi"},
		twitter{123123, "Elon"},
		twitter{1, "Musk"},
		twitter{-1, "Paid for twit"},
	)

	fmt.Println(a)
	sort.Sort(a)
	fmt.Println(a)
}

[{249249 Hi} {123123 Elon} {1 Musk} {-1 Paid for twit}]
[{-1 Paid for twit} {1 Musk} {123123 Elon} {249249 Hi}]

프린트된 값을 보면 정렬된 것이 보이는가? 저기서 레스 함수를 반대로 꺾어준다면 역으로 정렬된다. 

나름 직관적이라고 생각한다. 자바의 Comparable or Compartor 보다 마음에 든다.

Conversions

type Sequence [] int와 변환에 대해 설명하고 있다.

이 부분에서는 자바 인터페이스 Map a = HashMap <> 이런 식의 연관관계 가 생각난다. Map을 구현하고 있는 hashmap에 대해 타입으로 받을 수 있다?

Go에서는 위와 유사하게 형변환이 가능하다.  그래서 예제에서 들어준 sort에 대해 sequence의 len less 기타 등등 구현 없이 바로 가는 거를 보여준다.

type Sequence []int

// Method for printing - sorts the elements before printing
func (s Sequence) String() string {
    s = s.Copy()
    sort.IntSlice(s).Sort()
    return fmt.Sprint([]int(s))
}

 

ㅋㅋㅋㅋ 별다른 구현 없이 이렇게 형변환 해서 들고 갈 수 있다.

저 함수 안으로 들어가면 이미 빌트인으로 다구현되어 있다 , 인트에 대해서

기존 함수 예제에서 String으로 리턴하기 위해 for를 한 번 더 돌리면서 sprint를 해 O(n) 2의 시간복잡도를 가지며 
string을 만들어 갔지만 위와 같이 작성한다면?

o(nlogn)까지 줄어들 수 있다. s의 크기만큼 함수를 돌면서 정렬(고에서는 퀵정렬을 사용) 하기 때문에 o(nlogn) 이 된다.

Interface conversions and type assertions

타입의 변환에 대한 섹션이다. 문서에서 Printf에서 % v를 받을 때 어떻게 핸들링하는지에 대해 설명하고 있다. 

type Stringer interface {
    String() string
}

var value interface{} // Value provided by caller.
switch str := value.(type) {
case string:
    return str
case Stringer:
    return str.String()
}

value.(type)을 통해 가능한 타입을 케이스 별로 분기를 나눈다. 이는 다시 말해 혼합된 타입을 사용할 수 있는 걸 의미한다.

이렇게 해서 원하는 타입으로 뽑아낼 수 있다. 예를 들어

str := value.(string)

이렇게 스트링 타입으로 변환 후 변수 설정이 가능하다 이렇게 되면 str의 타입은 string 이 된다.

만약 이런 벨류에서 뽑아낼 타입이 없다면? 컴파일 에러가 아닌 런타임 에러가 발생하게 된다. 이에 따라 go에서는 아래와 같은 방법 이 관용적으로 사용된다고 한다.

func main(){
    if str, ok := value.(string); ok {
        return str
    } else if str, ok := value.(Stringer); ok {
        return str.String()
    }
}

이 섹션에 대해서 잘 이해가 가질 않아서 검색을 해보다 보니 assertion과 conversion에 대한 개념을 먼저 잡아야 했다.

assertion => 벨류 의 인터페이스 안에서 하나의 타입을 끄집어내는 것이 assertion
conversion => 하나의 타입을 다른 타입으로 변환하는 것을 conversion이라고 한다.
아래 예시를 보자.

type MyType interface {
	What() string
}

func PrintHolyMoly(t MyType) {
	fmt.Println("HOlYMOLY", t.What())
}

type A struct {
	a int
}

func (a A) What() string {
	return fmt.Sprintf("This is A %v", a.a)
}

type B struct {
	a int
}

func (b B) What() string {
	return fmt.Sprintf("This is B %v", b.a)
}

func main() {
	var a MyType
	a = A{1} // Type asseriton worked so a is MyType right now

	if b, ok := a.(B); ok { // Type assertion do one more for type conversion to a => b
		a = b
	} else {
		fmt.Printf("Conversion type failed from MyType(A) to B))\n")
	}

	PrintHolyMoly(a)
}

interface MyType을 구현하는 A, B 가 있다. A를 B 타입으로 바꾸는 과정을 작성한 코드이다.

이렇게 타입을 바꾸기 위해서는 우선 타입추론을 통해 인터페이스 타입으로 뽑아 온다음 인터페이스 타입에서 다시 뽑아서 B로 바꾸는 것이 가능한지 확인 후 타입을 변환하는 과정이다.

 

다형성 때문에 내가 많이 헷갈린 거 같은데 다형성은 저렇게 interface 타입으로 생성한 함수(PrintHolyMoly(a))에서

저렇게 사용이 가능한 거지 

A, B 가 동일한 인터페이스를 구현한다고 하더라도 A,B 간의 타입전환은 가능하나 중간 점검이 필요하다.

Interfaces and methods

인터페이스 매머드의 예로 핸들러 인터페이스를 든다 핸들러 인터페이스를 구현하고 있다면 http 요청을 처리할 수 있는 함수가 된다.

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

ResponseWriter는 Wirte 메서드가 있는데 이 메서드 덕분에 io.Wirter 가 들어가는 모든 값에 responseWriter 벨류를 집어넣을 수 있다 왜? 인터페이스 구현을 하고 있으니깐

serveHttp에 관련해 HandlerFunc을 조금 길게 설명하고 있다 그중 아래와 같이 타입을 시그니쳐 함수로 받을 수 있다. 예를 보자.

// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers.  If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// Handler object that calls f.
type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(w, req).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, req *Request) {
    f(w, req)
}

The blank identifier

go에서는 선언한 값에 대해서는 무조건 적인 사용이 필수적이다 그렇지 않다면 컴파일 에러가 발생하는데 이를 해결하기 위해 _선언을 허용한다.

1. 사용되지 않는 임포트 => 이 임포트를 함으로써 init 함수가 실행되어야 프로그램 이 실행될 때

2. for, map을 loop 돌때 => 인덱스 값에 대해 이용하지 않을 때

1번 케이스를 임포트 사이드 이펙트라고 하는데 고의 기본 정책이니 다른 임포트 할 때 도 주의할 필요가 있다.

Interface checks

자바 같은 언어와 달리 고에서는 인터페이스의 함수만 구현하면 그 자체로 인터페이스 타입을 받을 수 있게 된다. 이에 따라 런타임 시점에 인터페이스에 서 타입을 끄집어내는 type assertion 이 가능하다. 예시로 제시하는 marshaler에 대해 알아보자.

우선 Go에서 엔코딩/디코딩, 마샬러 모두 비슷한 역할을 하는데 이에 대해 명확히 인지하고 사용해야 한다.

Encoding/Decoding 은 json의 문자를 스트링 또는 바이너리 데이터로 읽고 쓰기 하는 것을 의미한다. 

Marshaling/UnMarshaling 은 JsonType으로부터 고  의 원시타입으로 맵핑하는 것 을 의미한다.

func main() {
	var data Singer
	var data2 Singer

	jsonData := []byte(`
	{
		"Name" : "NewJeans",
		"Title" : "Attention",
		"DebutDate" : 2022
	}
	`)

	verify := json.Valid(jsonData)
	if verify {
		json.Unmarshal(jsonData, &data)
		fmt.Println(data)
	}

	json.NewDecoder(strings.NewReader(string(jsonData))).Decode(&data2)
	fmt.Println(data2)
}

이에 동일한 결괏값을 받을 수 있다. 내부적으로 까보면 Decode 내에서 Unmarshal을 호출하고 있는 모습을 볼 수 있다. 즉 다시 말해 

디코딩 은 버퍼에 들어있는 값을 들고 와 얼마샬 을 한다고 이해하면 될 거 같다.

349 업 버튼을 받은 답변 중에(https://stackoverflow.com/questions/21197239/decoding-json-using-json-unmarshal-vs-json-newdecoder-decode)

json.Decoder 는 나의 데이터가 io.Reader 에서 부터 온다면 혹은 스트림 데이터로부터 다양한 값을 디코딩해야 한다면 사용하고,

json.Unmarshal 은 이미 json 데이터가 메모리에 존재한다면 사용하라고 한다. 

var _ json.Marshaler = (*RawMessage)(nil)

예시에서 이렇게 빈칸지시자를 활용해서 오로지 타입 체크만 할 때도 사용된다고 한다. 이는 type conversion 이기 때문에 컴파일 시점에 잡을 수 있다.

Embedding

고에서는 서브클래싱 의 타입 핸들? 드리븐? 개념을 제공하지 않는다? 무슨 의미인지 모르겠으나 고에서는 클래스 개념과 상속의 개념이 없어서 위와 같은 말을 한다고 생각된다. 이 문장 이후 제시된 대안으로

구조체 혹은 인터페이스의 임베딩 타입에 의해 구현된 일부분을 가져올 수 있다라고 강조한다. brrow라는 표현을 강조하는데 예시를 확인해 보자.

글을 읽어보면 자바의 class extend 가 생각난다.

subclass에서 superclass를 접근하는 개념과 유사하게 생각하면 될 거 같다 단지 구현의 차이만 있지 의미하는 바가 매우 유사하다.

글에서 제시된 io reader, writer 그리고 이걸 랩핑 하는 readwriter  가 있다면 reader와 writer에 readwriter 인스턴스가 접근할 수 있다.

예시를 보면 보다 이해가 확고해진다.

func main() {
	type animal struct {
		name string
		age  int
	}

	type cat struct {
		animal animal
		breed  string
	}

	c := &cat{
		animal{"somi", 12},
		"Persian",
	}

	fmt.Printf("Breed is %v, name is %v , age is %v", c.breed, c.animal.name, c.animal.age)
    // Breed is Persian, name is somi , age is 12
}

연속된. 을 이용해 랩핑 된 값들을 꺼내서 사용할 수 있다. 자바와 매우 유사한 부분인 것 같다.

이런 임베딩 타입의 문제점으로 제시되는 부분이 깊이에 따른 동일한 이름의 필드가 존재한다면? 

이에 대한 문제는 매우 심플하게 해결된다. 각 깊이별로 이름이 따로 존재하기 때문에 깊이에 맞는. 을 이용해 체이닝 하면 된다.

 

두 번째로  동일한 깊이에 동일한 이름이 존재한다면? 이건 그냥 오류다 애초에 컴파일조차 허용 되지 않는다. 같은 구조체 안에 같은 이름 이라니 끔찍하다.

 

'Go > Go Basic' 카테고리의 다른 글

Ulitmate-Go-01 (string,메모리 패딩)  (3) 2023.08.19
Go Interface, embedded  (0) 2023.02.28
Effective Go 04  (0) 2023.02.12
Effective Go 02  (0) 2023.02.09
Effective Go 01  (1) 2023.02.08

출처 : https://go.dev/doc/effective_go#arrays

Arrays

C와 Go Array의 주요 차이점

1. Array 는 값이다. 하나의 배열을 다른 배열에 할당하면? 전체복사 가 발생된다.

2. 만약 함수변수로 사용한다면 이건 포인터 타입이 아닌, 카피 값이 넘어간다.

3. 배열의 크기는 하나의 타입이다. 

위 3가지를 직접 증명해 보자.

- Array는 값이다. 하나의 배열을 다른 배열에 할당하면? 전체복사 가 발생된다. 

func main() {
	arr := [3]int{1, 2, 3}
    arr2 := [3]int{}
	arr2 = arr
	fmt.Printf("Type is %T, Point is %p, Values %v\n", arr, &arr, arr)
	fmt.Printf("Type is %T, Point is %p, Values %v\n", arr2, &arr2, arr2)
}

결괏값 

Type is [3] int, Point is 0x1400012e018, Values [1 2 3]
Type is [3]int, Point is 0x1400012 e030, Values [1 2 3]

 

다른 배열에 할당하면 이와 같이 전체복사가 발생된다. 메모리 주소의 생성이 보는 바와 같이 4바이트 int 값 3개 총 12바이트 늘어난 e030부터 시작되는 것도 재밌는 포인트인 것 같다.

- 만약 함수변수로 사용한다면 이건 포인터 타입이 아닌, 카피 값이 넘어간다.

 

func main() {
	arr := [3]int{1, 2, 3}
	func(arr [3]int) {
		fmt.Println("In Function")
		fmt.Printf("Type is %T, Point is %p, Values %v\n", arr, &arr, arr)
	}(arr)
	fmt.Printf("Type is %T, Point is %p, Values %v\n", arr, &arr, arr)
}

결괏값

In Function
Type is [3] int, Point is 0x140000ac030, Values [1 2 3]
Type is [3]int, Point is 0x140000 ac018, Values [1 2 3]

 

보는 바와 같이 익명함수는 arr [3] int 를인자로 받는 함수이다. 거기에 기존에 선언한 arr를 넘겨주었지만 보는 바와 같이 다른 주소값을 반환하게 된다. 다시 말해 arr는  포인터 값이 아니기 때문에 레퍼런스 복사가 아닌 값복사가 발생되어 새로운 값을 할당하는 것이다.

- 배열의 크기는 하나의 타입이다. 

배열의 크기 자체가 타입이라는 말은 단순하게 그냥 위에서 선언한 함수에서 인자로 [5] int를 받는다고 하면 바로 컴파일 에러가 발생한다. 

위에서 말한 이유를 생각한다면 저렇게 받아야만 한다 왜? 값 복사가 일어날 때 메모리 낭비, 데이터 유실 이 되지 않으려면 정확하게 계산된 메모리 값 주소를 할당해주어야 하기 때문이다. 

 

저 문서에서 값을 가지는 속성 또한 매우 유용할 수 있으나 비싸다고 한다. 그래서 만약 c와 같은 방식으로 구현하고 싶다면 go에서도 가능하다

func main() {
	arr := [3]int{1, 2, 3}
	func(arr *[3]int) {
		fmt.Println("In Function")
		fmt.Printf("Type is %T, Point is %p, Values %v\n", arr, &arr, arr)
	}(&arr)
	fmt.Printf("Type is %T, Point is %p, Values %v\n", arr, &arr, arr)
}

결괏값

Type is *[3] int, Point is 0x14000120018, Values &[1 2 3]
Type is [3]int, Point is 0x1400012 e018, Values [1 2 3]

주소값을 넘기고 포인터 타입으로 받으면 손쉽게 해결 가능하다. 그러나 이러한 구현 방식은 고 에 어울리지 않는다고 한다. 이에 대한 해결책으로 슬라이스를 제시하는데 바로 가보자.

Slices

슬라이스는 가장 보편적이고 강력하며 편리한 데이터의 연속적인 인터페이스라고 설명한다. 통상 인터페이스{} 이렇게 하면 모든 타입을 소화할 수 있는 마법의 키워드이다. 문서에서도 대부분의 내장라이브러리 의 배열 관련 된 부분은 모두 슬라이스로 처리한다고 한다.

읽다 보면 엄청 강조하는 부분 중 하나가 바로 슬라이스는 레퍼런스를 홀드 한다고 한다. 즉 배열과 달리 레퍼런스를 홀드 하게 되면 함수 인자 혹은 선언 시에 값 복사가 아닌 레퍼런스 참조를 하게 된다는 의미이다. 

그래서 위에 고 에 구현 방식에 어울리지 않는다고 하는데 한번 확인해 보자.

func main() {
	fmt.Printf("Type is %T, Point is %p and arr[0] Point is %p  value are %v \n", arr, &arr, &arr[0], arr)
	fmt.Printf("Type is %T, Point is value for %p and arr[0] Point is %p, value are %v \n", arr2, &arr2, &arr2[0], arr2)
}

Type is []int, Point is 0x1400000c030 and arr[0] Point is 0x1400001c090  value are [1 2 3] 
Type is []int, Point is value for 0x1400000c048 and arr[0] Point is 0x1400001c090, value are [1 2 3]

주의해서 볼 점은 여기서 arr2의 주소값은 물론 arr와 다르다 다만 arr2 [0]의 주소값을  보면 바로 arr [0]의 주소값을 가리킨다 다시 말해 아래 등호가 성립한다.

주소 arr2!= 주소 arr  / 주소 arr [0] == arr2 [0]

이렇게 되면 당연히 복사가 일어나지 않기 때문에 위 배열과 같은 포인터 타입과 주소의 작업을 하지 않아도 된다.

위의 특징 외에도 슬라이스 하면 길이 없는 배열을 가장 먼저 떠올리게 된다. 이걸 가능하게 해주는 append 함수에 대해서  문서에서 준 예시를 보자.

func Append(slice, data []byte) []byte {
    l := len(slice)
    if l + len(data) > cap(slice) {  // reallocate
        // Allocate double what's needed, for future growth.
        newSlice := make([]byte, (l+len(data))*2)
        // The copy function is predeclared and works for any slice type.
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[0:l+len(data)]
    copy(slice[l:], data)
    return slice
}

기본적인 슬라이스를 선언해서 기존 사이즈 + 현재 들어오는 데이터 *2 만큼의 사이즈를 늘려주는데 자바의 어레이 리스트 내부 로직 중 grow()와 매우 유사하다. copy(목적지, 소스)인 형태이다 카피 구현 설명에 가보면 소스를 목적지로 오버래핑 한다고 되어있다.

추가적으로 인트 리턴 값이 있으나 이는 변경된 요소의 개수이다. 

다시 말해 사이즈를 늘리는 하나의 새로운 슬라이스를 만들고 그에 현재 요소들을 복사해 넣어주고 리턴해주는 방식이다. 그래서 보통 append 빌트인 함수 사용여부를 볼 때 리턴 받은 슬라이스를 대입하곤 한다.

이렇게 되면 당연히 기존 slice의 주소가 바뀌게 된다. 

다시 본다면 기존 포인팅 하던 주소가 바뀌는 걸 의미한다.

func main(){
	arr := make([]int, 3)
	arr[0] = 1
	fmt.Printf("%p %p\n", arr, &arr[0])
	arr = append(arr, 1, 2, 3, 4, 5, 6, 7, 8)
	fmt.Printf("%p %p\n", arr, &arr[0])
}

0x1400012e018 0x1400012e018
0x1400010e060 0x1400010e060

 

결과 값이 보는 바와 같이 주소가 다르다. 

추가적으로 슬라이스는 레퍼런스를 참조한다. 다시 말해 여러 다른 슬라이스에서 접근가능하다는 소리이다. 이는 다시말해 스레드 세이프 하지 않은 문제를 야기한다. 

이 왕같이 포인팅 되게 되면? 모든 곳에서 접근하여 슬라이스 요소를 수정할 수 있다 이는 다시 말해 스레드 세이프 하지 않다는 의미이다. 

아직 문서에서 언급은 없으나 뒷장에서 이에 대해 언급하면 그때 이문제 의 해결법과 트레이드오프에 대해 적어볼까 한다.

자바에서는 이문제를 해결하기 위해 synchronize 키워드와 클래스 하나를 제공해 주는 것으로 기억하고 있는데 어떻게 해결할지 궁금하다.

 

Maps

자바에서 가장 좋아하는 자료구조 중에 하나였다. 고에서도 똑같이 제공해 주며 해쉬맵 형태의 맵을 제공한다.

당연히 키 벨류에는 어느 타입이나 들어갈 수 있으나, 슬라이스는 맵의 키로 지정할 수 없다. 저 문서에는 어렵게 작성해 놨는데 슬라이스는 동등성을 제공하지 않는다.

func main(){
	arr := []int{1,2,3}
    arr2 := []int{1,2,3}
    fmt.Println(arr == arr2) // false
}

벨류를 가져오는 방법으로는 m [KEY]를 하면 VALUE를 리턴하게 된다.

또한 고 의 맵에서 특이한 점으로는 없는 벨류값에 대해 0을 리턴한다. 다시 말해 자바의 contains 같은 함수를 제공하지 않기 때문에 

```java```
class Main(){
	public static void main(){
    	Map<Integer,Integer> map = new HashMap();
        //code .. //
        if(map.conatians("찾는값"){
        	// blarblar
        }
    }
}

```go```

func main(){
	m := make(map[int]int)
    //code .. //
    if v,ok := m["찾는값"]; ok {
    	//blarblar
    }
}

이와 같은 방식으로  자바와 유사하게 로직을 규현 할 수 있다.

추가적으로 delete(map instance, key)를 작성하면 map 안에 있는 요소를 지울 수 있다. 맵 안의 그 벨류가 없더라도 특별한 에러 없이 진행되는데 그냥 사용한다면 버그를 유발할 수 있을 것 같다. 

delete 사용 전에 항상 유무를 체크하고 넘기는 것에 대해 고민해봐야 할 것 같다. 

Append

뭔가 이 문서의 순서가 중구난방인거 같다. 갑자기 print 쪽에 대해 설명하다가 갑자기 append 로 넘어온다.

func append(slice []T, elements ...T) []T

append() 빌트 인 함수 정의 이다. T 는 제네릭 으로 어느 타입이나 받을수 있는것을 의미한다.

실제 함수에서는 T 를 사용할수 없다 이는 호출자에 의해 결정되는 사항이다.

그래서 T 를 컴파일러의 도움을 받아 정의하기에 빌트인 함수인것이다.

 

...T 를 보면 2번쨰 파라미터 로 다량의 데이터를 넘기는것이 가능하다. 다시말해 슬라이스 를 통으로 넘겨도 된다는 의미이다.

타입이 맞으면 x =  append(x,y...) 이런식으로

'Go > Go Basic' 카테고리의 다른 글

Ulitmate-Go-01 (string,메모리 패딩)  (3) 2023.08.19
Go Interface, embedded  (0) 2023.02.28
Effective Go 04  (0) 2023.02.12
Effective Go 03  (0) 2023.02.10
Effective Go 01  (1) 2023.02.08

Go 언어를 배우게 되면서 홈페이지에서 읽은 내용을 정리해 볼까 한다.

(https://go.dev/doc/effective_go)

Redeclaration and reassignment

- 보통 := 이 형태 의 축약형태로 특정 값을 정의하곤 한다.

func test01(a int) (int,error) {
	return a,errors.New("에러 발생")
}

func main(){
	a,err := test01(1)
    fmt.Println(a,err)
}

Go의 다양항 기본 라이브러리에서도 위와 같은 방식의 형태를 취하곤 한다. 다만 여기서 b, err의 같은 값을 새로운 형태로 받아서 정의하고자 한다면 어떻게 해야 할까? := 형태를 이용해서 정의한다는 것은 해당 벨류에 메모리 주소를 부여하고 초기화까지 진행한다는 의미이다. 

통상 일반적인 언어에서는 = 를 이요해 재정의 를 할 것이다. 물론 Go에서도 가능하다. 다만 하나의 벨류값이 아닌 리턴을 받으면서 재정의하려고 한다면 컴파일 에러 undefined value 가 발생한다.

func test01(a int) (int,error){
	return a,errors.New("에러발생")
}

fucn main(){
	a,err := test01(1)
    b,err := test01(2)
    c,err = test01(3) // error 
}

= 방식을 이용해서 재정의 한다면 컴파일 에러가 발생한다. 위와같이 초기화와 같이 동시에 진행할 때는 같은 블록 스코프 안에 있다면 := 이와 같은 형태를 이용한다면 기존값에는 대체, 새로운 값에는 초기화 가 진행된다.

 

이러한 형태를 go 라이브러리 에서 자주 볼 수 있는데 if else 체인에서 err를 핸들링할 때 자주 사용되는 방법 중하나이다.

또한 이런 형태는 {} 스코프 밖에서 진행한 변수에 대해서도 영향을 받는다 

Named result parameter

리턴 벨류 의 값 자체를 정의해서 반환하는 형태를 종종 볼 수 있다.

꼭 이렇게 리턴벨류 값 자체 의 정의를 하는 것이 필수적인 것이 아니지만, 이것은 보다 짧고 명확한 코드를 의미한다. 예를 들어보자.

//return 을 벨류값으로 정해서 사용할때

func ReadFull(r Reader, buf []byte) (n int, err error) {
    for len(buf) > 0 && err == nil {
        var nr int
        nr, err = r.Read(buf)
        n += nr
        buf = buf[nr:]
    }
    return
}

// 그냥 사용할때
func ReadFull(r Reader, buf []byte) (int, error) {
	var (
    	n int
        err error
    )
    for len(buf) > 0 && err == nil {
        var nr int
        nr, err = r.Read(buf)
        n += nr
        buf = buf[nr:]
    }
    return n,err
}

편한 걸 골라서 사용하면 된다. java에서는 통상 아래와 같은 방식으로 사용하고 return으로 끝내고 싶다면 static을 이용한 변수를 사용했던 것 같다. 

Defer

Go를 배우면서 가장 인상 깊었던 키워드이지 않을까 싶다. defer 키워드 가 달린다면 함수의 로직이 전부 실행되고 defer 키워드 로직을 마무리 지으면서 defer 함수가 종료된다.

Doc에서 는 주로 리소스 반환을 할 때 사용하는 것을 예로 들고 또 권장한다고 한다. 

// Contents returns the file's contents as a string.
func Contents(filename string) (string, error) {
    f, err := os.Open(filename)
    if err != nil {
        return "", err
    }
    defer f.Close()  // f.Close will run when we're finished.

    var result []byte
    buf := make([]byte, 100)
    for {
        n, err := f.Read(buf[0:])
        result = append(result, buf[0:n]...) // append is discussed later.
        if err != nil {
            if err == io.EOF {
                break
            }
            return "", err  // f will be closed if we return here.
        }
    }
    return string(result), nil // f will be closed if we return here.
}

파일을 열고, 그 결괏값을 버퍼에 넣어 string으로 반환하는 함수이다. 

위와 같이 defer를 이용해 작성했을 때의 이점은 무엇일까? 그냥 함수 리턴전에 닿아주면 되는 거 아닌가? 

1. 리소스 반환을 까먹지 않고 할 수 있다. 

2. 코드를 보다 명확하게 읽을 수 있다. 

위와 같은 짧은 코드 라면 그렇게 가치 없는 키워드라고 생각할 수 있으나 함수의 크기가 커진다면? 충분히 고려할 사항이다.

defer의 특징으로는 Lifo 구조이다. 스택이다.

아래 예제를 확인해 보자.

func trace(s string) string {
    fmt.Println("entering:", s)
    return s
}

func un(s string) {
    fmt.Println("leaving:", s)
}

func a() {
    defer un(trace("a"))
    fmt.Println("in a")
}

func b() {
    defer un(trace("b"))
    fmt.Println("in b")
    a()
}

func main() {
    b()
}

이렇게 출력했을 때 우리 함수의  콜스택에 어떻게 쌓이는가? 

trace("b")는 먼저 실행되고 이거에 대한결과 값에 대한 로직이 defer로 구분된다. 다시 말해 구분해서 적어보자면

- fucntion = trace("b") / fmt.Println("in b") / trace("a) / fmt.Println("in a )

- defer = un(결괏값(trace("a")) / un(결괏값(trace("b"))

이와 같이 function 이 실행되고 이후 defer 라인이 실행된다. 실제로 그려보면서 따라가면 보다 이해가 쉬워진다.

Allocation with new

Go에서는 메모리 할당 을  하는 두 가지 빌트인 함수가 있다.  make, new인데 new부터 알아보자.

다른 언어와 달리 new 키워드를 사용한다면 단순 메모리 할당만 있을 뿐 초기화를 진행해 주지 않는다. 즉 0 이란 의미이다.

또한 new 함수를 이용해서 만든 반환값은 메모리 주소를 반환해 준다. 

var a = new(customType) => *customType의 메모리주소 타입이다. 

다르게 생각해 본다면? 0의 값이니깐 이 인스턴스 값을 채우기 위한 코드도 필요할텐데 어떻게 핸들링 하는지 마저 읽어보자.

이렇게 0의 값을 리턴 하기 때문에 사용자가 직접 빌드해서 사용하기 좋다고 한다. 데이터를 0 부터 구조화시켜 나가는 이점에 대해서 말해준다.

예시로 들어준 SyncedBuffer  타입을 보면 안에 buffer 필드가 있는데 이 또한 0 의 벨류 이기 때문에 즉각적으로 다음 코드를 빌드해갈수 있다.

p := new(SyncedBuffer)  // type *SyncedBuffer
var v SyncedBuffer      // type  SyncedBuffer

위의 코드 들은 추가적인 조정 없이 바로 사용이 가능하다. 다시 생각해 보면 위와 같이 타입으로 변수를 선언해도 0 값 과 메모리가 할당된다는 의미로 받아들여진다. 다만 포인터 값 과 그냥 값이 주어진다면 함수를 이용할때 복사 가 일어나는 부분에서 주의가 필요하다.

Constructors and composite literals

위에서 제시한 문제점을 해결하기 위한 방법으로 제공된다. 

return &File{fd, name, nil, 0}

자바의 인스턴스 생성과 유사하다. 다만 이렇게 작성한다면 메모리 주소 타입을 반환하지 않기에 따로 위와같이 붙여주어 new의 기능을 상실하지 않게 만든다.

추가적으로 file struct의 필드 값이 모두 정해진 값이 아니라면 생략 가능하다.

return &File{fd: fd, name: name}

다시 말해 new(File)과 &File {}의 타입 은 같으며 동일하게 동작한다.

또한 위와 같은 형태로 초기화 하는 것 은 고에서 제공하는 원시 데이터 타입들 또한 받아서 작성가능하다

	a := [...]string{0: "no error", 1: "Eio", 10: "invalid argument"}
	s := []string{0: "no error", 1: "Eio", 100: "invalid argument"}
	m := map[int]string{1: "no error", 4: "Eio", 5: "invalid argument"}

위와 같은 방식으로 작성할 수 있다. 슬라이스 와 배열에서 위와 같이 숫자를 적어주면 해당 인덱스에 값을 부여하는 방식으로 

3개 의 값을 적었지만 cap는 11 이 된다.

Allocation with make

new 와는 달리 make 함수는 slice, chanel, map 만 만들 수 있으며, 메모리 주소 타입 이 아닌 진짜 값을 반환하는 것이 가장 큰 차이점이다. 이와 같은 구조의 이유는 위의 3가지 타입은 데이터의 구조를 레퍼런스 하기 때문에 사용 전 반드시 초기화 작업이 필요하기 때문이다.

var p *[]int = new([]int)       // allocates slice structure; *p == nil; rarely useful
var v  []int = make([]int, 100) // the slice v now refers to a new array of 100 ints

// Unnecessarily complex:
var p *[]int = new([]int)
*p = make([]int, 100, 100)

// Idiomatic:
v := make([]int, 100)

통상적으로 맨 아래 방식을 가장 많이 사용한다. 

make 함수를 이용해서 작성하는 것은 항상 값타입을 리턴하는 것 을 알고 사용하자.

 

 

'Go > Go Basic' 카테고리의 다른 글

Ulitmate-Go-01 (string,메모리 패딩)  (3) 2023.08.19
Go Interface, embedded  (0) 2023.02.28
Effective Go 04  (0) 2023.02.12
Effective Go 03  (0) 2023.02.10
Effective Go 02  (0) 2023.02.09

이미 끝난 프로젝트 인 워크듀오를 멀티모듈로 바꿔보고자 한다. 전회사에서 프로젝트를 보면서 멀티모듈을 처음 알게 되었다.

모듈 별로 도메인 그리고 각 특정 서비스를 분류해서 한 프로젝트 내에서 여러 개의 서버를 띄우는 게 가능했었다. 

정말 좋다고 느꼈고, 특히나 프로젝트의 어떤 부분을 보고 코드를 확인해야 하는지 에 대한 명확성이 너무나 도 좋은 부분이라고 생각된다.

우리 프로젝트에 접목하지 않을 이유가 없다고 생각하여 분리를 해보고자 한다.

 

첫 번째로 멀티모듈 이란 하나의 프로젝트 내에서 다양한 모듈을 구성하고 공통점을 묶어서 하나의 모듈에서 관리하는 기능을 의미한다.

즉 다시 말해서 우리 워크듀오는 크게 멤버, 그룹의 형태로 서비스가 나뉜다. 이들의 공통점을 하나로 묶어서 관리한다면? 

 

만약 서버를 분리한다고 가정해 보자. 각각 의 서버에서 우리는 멤버를 그룹에서 호출하기도 하고 그룹을 멤버에서 호출하기도 한다. 그렇다면? 복사 붙여 넣기가 필연적으로 생길 수밖에 없다. 그렇다면 어떻게 해결해야 할까?

  • 첫 번째 이런 공통된 부분을 각 프로젝트에서 호출이 가능해야 하며,
  • 두 번째 빌듯이 에 공통된 부분을 포함시켜 주어야 한다.

최초 모듈 구성을 core/ member/ group으로 구성하였으며 코어는 말 그대로 멤버와 그룹이 컴파일될 시에 포함될 메인 소스 가 된다.

위와 같은 부분으로 변화시키고자 한다.

auth는 지금 고민 중이고 테스트 중인 부분이라서 언급하지 않았다.

Core에서 는 위의 사진과 같이 공통으로 관리되는 AWS 혹은 컨피그 파일, 그리고 모든 도메인을 코어에서 관리한다. 

우리는 현재 에러를 커스텀으로 작성하여 핸들링되기 때문에 공통으로 사용되는 에러코드 또한 코어에 위치시켰다. 

 

그룹 같은 경우는 config 상에 레디스를 추가했다. 그룹상 에서만 사용되기 때문에 위와 같이 분리했다.

 

잡설은 그만하고 어떻게 하면 될까 먼저 module 두 개를 만들어 주자. (Gradle version 7.5)

위와 같은 방식으로 기본모듈에 코어를 생성해 주자. 자 이렇게 생성이 완료되면 

settings.gradle 파일을 확인해 보자. (https://docs.gradle.org/current/userguide/multi_project_builds.html)

모듈을 생성한다면 아래와 같이 자동 기입되어 있는 것이 보일 것이다.

rootProject.name = 'workduo'
include 'workduo_core'

이 세팅 그래들이 존재하기 때문에 우리는 그대를 멀티 프로젝트를 구성할 수 있는 부분이니 확인하고 넘어가자. 우리 똑순이 인텔리제이 그래 들 을 눌러보면 위와 같이 코끼리 하나 더 생겼다 즉 하위 프로젝트가 생성된 부분을 확인할 수 있다.

대망의 build.gradle을 보러 가자. 

현재 워크듀오 의 빌드 그래 들 이다. 위에서부터 차근차근 알아보자.

(https://docs.gradle.org/current/javadoc/org/gradle/api/initialization/dsl/ScriptHandler.html)

 

buildscript 블록 안에서는 어떤 태스크를 부여할지, 어떤 플러그인을 사용할지, 어떤 클래스가 나머지 빌드 스크립트 안에 포함되어야 하는지 를 결정할 수 있는 공간이다. 또한 추가적인 외부 서드파티 라이브러리를 사용할 때 클래스 패스를 기입해 주기 위해서 작성하기도 한다.

어떻게 보면 글로벌 레벨 에서의 디펜던시 혹은 레퍼지토리 의 섹션이라고 볼 수도 있다. 

buildscript {
    ext {
        queryDslVersion = "5.0.0"
    }
}

현재 워크듀오에서는 이렇게 스크립트를 작성해서 쓰고 있다. ext는 변수명을 위와 같이 선언해 줄 수 있고 아래 스크립트에서 이 변수를 호출할 수 있다. 

 

plugins(https://docs.gradle.org/current/userguide/what_is_gradle.html)

플러그인 블록 안에서는 프로젝트에 플러그인을 적용하면 플러그인이 프로젝트의 기능을 확장할 수 있습니다.라고 간단하게 정리할 수 있다. 특히나 저 공홈 문서를 보면 플러그인을 프로젝트 로직을 추가하는 대신 플러그인 사용 시 이점에 대해 아래와 같이 설명한다.

  • 재사용을 촉진하고 여러 프로젝트에서 유사한 논리를 유지 관리하는 오버헤드를 줄입니다.
  • 더 높은 수준의 모듈화를 허용하여 이해력과 구성을 향상합니다.
  • 명령형 논리를 캡슐화하고 빌드 스크립트가 가능한 한 선언적일 수 있도록 합니다.

종류 또한 두 가지 가 존재한다.

바이너리 플러그인 (gradle dsl 언어로 작성, plugin interface를 구현한 방식),

plugins {
    id 'com.jfrog.bintray' version '1.8.5'
}

스크립트 플러그인

apply from: 'other.gradle'

차이점이라면 플러그인은 종종 스크립트 플러그인으로 시작하고(작성하기 쉽기 때문에) 코드의 가치가 높아짐에 따라 쉽게 테스트하고 여러 프로젝트 또는 조직 간에 공유할 수 있는 바이너리 플러그인으로 마이그레이션 됩니다.

이중 우리는 바이너리 플러그인을 사용하며, 버전이 명시된 건 커뮤니티 버전, 버전이 없다면? 코어를 사용하는 플러그인이다. 

이렇게 작성된 플러그인 들은 프로젝트를 빌드할 때 먼저 그래 들은 플러그인에서 주어지는 값을 해석해 jar 파일 스크립트 클래스 경로에 추가하는 방식이다.

 

repositories(https://docs.gradle.org/current/userguide/declaring_repositories.html)

 

Gradle은 Maven, Ivy 또는 플랫 디렉터리 형식을 기반으로 하는 하나 이상의 레포지토리에서 종속성을 해결할 수 있습니다. 자세한 내용은 모든 유형의 레포지토리에 대한 전체 참조를 확인하십시오.

통상 메이븐 센트럴을 사용하나 구글을 선택적으로 선택이 가능하기도 하다.

repositories {
    mavenCentral()
    maven {
        url "https://repo.spring.io/release"
    }
    maven {
        url "https://repository.jboss.org/maven2"
    }
}

 

이런 방식의 여러 레포지토리 도 다음과 같이 작성해 빌드스크립트 안에 명시 가 가능하다.

 

dependencies(https://docs.gradle.org/current/userguide/dependency_management_for_java_projects.html)

위에서 레포지토리 대상을 정했으면 어떤 것을 들고 올지 정해주는 부분이다.

다시 말해 여기 지정되는 대상이 위에서 지정한 레포지토리에서 조회 되어야 오류 없이 빌드가 가능하다.

가끔 가다 org. 모시꺵이 하면서 찾을 수 없다는 에러가 보인다면 조회가 안된다는 의미이니 오타 혹은 직접 주소를 가보자.

그렇다면 이런 대상을 들고 올 때 앞에 써주는 키워드 들을 여러 개 보았을 것이다. 당장 스프링. io에서 만들더라도 디펜던시를 확인하면 다양하게 확인이 가능하다.

현재워크듀오 의 디펜던시 일부다.

dependencies{
	"""생략"""
    implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
    implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16'

    compileOnly 'org.projectlombok:lombok'

    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'mysql:mysql-connector-java'

    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

여기 보이는 것처럼 다양하게 있는데 한번 다이브 해보자(https://docs.gradle.org/current/userguide/java_library_plugin.html)

  • 오직 초록색 만이 유저가 사용할 수 있는 부분이다.
  • 핑크색 은 컴포넌트가 컴파일될 당시에 한번 사용되며, 라이브러리에 대해 사용된다.
  • 파랑이는 자체적으로 실행될 때 사용되는 부분이다. 

1. api : 종속적이지 않으며 소비하는 곳에 의해 호출되며 런타임, 컴파일 시점에 호출된다.

-  과거 compie() 이 deprecate 되고 현재 api()로 대체되어 사용 중이다.

- 또한 많이 추천하지 않는다 api를 호출하는 클래스 안에 다른 클래스를 호출하게 된다면 내가 부르는 시점에서 이거는 두 개다 호출하는 경우가 생기기 때문에 의도치 않는 import 가 발생되기에 추천하지 않는다고 한다.

 

2. implementation : 위의 발생되는 문제점을 없애기 위해 생긴 게 이 키워드이다. 종속적이지 않기에 compile에서 잘 작동되던 자바 애플리케이션 중 compile() 키워드를 -> implementation 으로 바꾼다면 호출 못하는 코드들이 생길 수도 있다. 왜? 의도치 않은 임포트를 이용해서 사용하고 있을 수 있기 때문에

 

3. compileOnly: 컴파일 타임에는 필요하지만 런타임에는 필요 없는 것을 의미하는 키워드이다. 위에서는 롬복이 해당되는데 컴파일 시점에 롬복 어노테이션을 이용해서 클래스를 만든 이후 런타임 시점에서는 필요 없는 경우에 해당되기에 이 키워드를 사용한다.

 

4. compileOnlyApi : 3번과 동일하다. 

 

5. runtimeOnly : 3,4번과 는 반대로 컴파일 시점에는 필요하지 않으나 런타임에 필요한 종속성에 표기하는 키워드이다.  대표적인 예로는 로거를 많이 든다. 

 

configurations(https://docs.gradle.org/current/dsl/org.gradle.api.artifacts.Configuration.html)

configuration 블록이 사실 제일 이해가 안 간다. 설명되는 내용만 봐도 그룹과 그에 따른 종속성을 나타낸다는 데 잘 모르겠다 왜 쓰여야 하는지 조차.

    compileQuerydsl {
        compileOnly {
            extendsFrom annotationProcessor
        }
        querydsl.extendsFrom compileClasspath
    }

워크듀오 의 컨피규레이션이다.  저 주소로 찾아가서 검색해 보면 컴파일 시점에는 어노테이션 프로세스를 확장하고 , 쿼리디에스엘 은 컴파일 클래스 패스를 확장한다. 왜 필요할까. from의 대상으로부터 확장되는 것인데 쿼리디에스 엘은쓸떄 우리는 따로 컴파일 쿼리디에스엘을 누르고 자바를 빌드한다. 이때 빌드할 때 쿼리디에스엘에서 먼저 컴파일된 클래스 패스를 가져오기 위해 사용된다고 추측된다. 실제로 빼고 돌려보면 컴파일 조차 되지 않는다. 왜? 컴파일되지 않았기 때문에 Q클래스 가 존재할 수가 없는 이유이다.

 

다음 2편에서는 실제로 build.gradle을 세팅해 보자. 이 쿼리디에스 엘 설정이 계속 괴롭힐 예정이다 ㅠ.ㅠ

 

 

+ Recent posts