/img/avatar.jpg

라이프타임에 대한 고찰

개요

이전 포스팅과 동일한 프로젝트에 대한 회고를 내용으로 합니다. 이 포스트에선 비교적 안일하게 생각했던 라이프타임에 대해 생각했던 걸 적어보려합니다. 여기서 말하는 라이프타임은 객체의 라이프사이클, 캐시 수명, 요청의 타임아웃 등을 포함하는 어떤 것의 발생과 소멸까지의 시간을 의미합니다.

뭐가 문제였지?

대부분의 라이프타임 관리는 괜찮게 했습니다. 하지만 몇몇 경우에 대해서 시스템을 맹신한 나머지 안일하게 관리를 했고, 관련 부분에 대한 내용이 주가 될 것같습니다.

본문

해당 프로젝트가 Go로 구현된 만큼 context 패키지를 주로 활용하였고, context 패키지에 대해 처음 보신다면, 제가 이전에 작성한 포스트를 같이 읽으시면 좋아 보입니다.

로그 정책에 대한 고찰

개요

최근 그래도 규모가 조금 있는 프로젝트를 처음부터 설계하고, 구현 및 운영한 경험이 생겼습니다. 상당히 많은 부분에서 하고 싶은 걸 했고, 부족한 점을 느끼기고 했고, 발전했다고 생각합니다. 하지만 안타깝게도, 기반 시스템(혹은 라이브러리)에 대해서 조직 내에서도 명확히 어떻게 해왔다는 게 없었어서 상당히 아쉬운 결과를 낳게 되었습니다. 지금에 와서 매우 많은 부분에 스며들어 있어서 일일이 교체하기에도 비용이 큰 작업이 되었구요. 그 중 하나가 로그입니다.

뭐가 문제?

해당 프로젝트의 로그 라이브러리는 zerolog를 사용하고 있습니다. 그리고 필요한 곳에서, 물론 실제 코드와는 다르지만, 단순하게 log.Error().Str("error", err).Str("ip", ip).Int("port", port).Msg("not expected connection closed")처럼 그 자리에서 필요한 필드를 넣고 로그를 남기는 형태입니다.
그리고 이벤트 로그에 대한 개념도 필요했습니다. 저에게 이벤트 로그에 대한 개념이 부족 했던 것도 있지만, 과연 ‘모든 행동에 로그를 찍는 것이, 이벤트 로그를 작성하는 바람직한 방식인가’에 대해서 고찰의 시간이 필요했습니다.

리스코프 치환 법칙에 대한 고찰

Liskov Substitution Principle with inheritance

리스코프 치환 법칙은 객체지향 프로그래밍에서 중요한 법칙 중 하나입니다.

서브 타입은 언제나 슈퍼 타입으로 대체될 수 있어야 한다.

개인적으로는 살짝 헷갈린 적이 있는 표현이지만, 코드 내의 인스턴스 타입을 교체하는 케이스로 이해하면 쉽습니다.

상속을 활용한 케이스

이 법칙은 일반적인 상속이 존재하는 객체지향 지향 언어에서 쉽게 설명되는 법칙입니다.
예를 들어 보통 자바에선 이런 식으로 많이 예제를 작성합니다.

class Shape {
    int width;
    int height;
}

class Rectangle extends Shape {
    void setWidth(int width) {
        this.width = width;
    }

    void setHeight(int height) {
        this.height = height;
    }
}

class Square extends Shape {
    void setWidth(int width) {
        this.width = width;
        this.height = width;
    }

    void setHeight(int height) {
        this.width = height;
        this.height = height;
    }
}

위 코드에서 RectangleSquareShape를 상속받고 있습니다.
그리고 SquareRectangle을 주고 받을 수 있는 곳은 Shape로 대체할 수 있습니다.
그리고 상속받은 객체를 다음과 같이 수퍼 타입으로 받을 수 있습니다.

프로젝트 설계에 대해

개요

코드를 구조화하는 건 생각보다 어렵지 않다고 생각합니다. 하지만 생각보다 어렵기도 하죠. 그래서 사실 이걸 어떻게 표현해야할 지는 모르겠는데, 그냥 편하게 아키텍처를 만드는 것에서 프로젝트 구조를 짜는 것, 코드를 작성하는 것까지의 제 나름대로의 룰을 정리해보려고 합니다.

모듈 혹은 서비스를 분리하세요.

모듈을 서비스와 동치해서 서술합니다.

아키텍처나 프로젝트 전반에서 한번에 거대한 문제를 해결하려고 하지 않습니다.
흔히 말하는 분할-정복 방식을 적용합니다. 각 문제를 분할하여 모듈화하고, 각 모듈을 독립적으로 개발 후 통합합니다.
이런 구조를 가지면 여러 레이어로 나누어진 모듈의 역할이 분리된 만큼 많은 이점이 생깁니다.

HTTP API 에러 쓰는 법

왜?

HTTP API에 에러 쓰는 건 개인 취향 혹은 조직에서 정한 룰 아닌가요?

저도 그렇게 생각하고 있습니다.
모름지기 학사 과정을 수료했든, 학원을 수료했든, 부트캠프를 수료했든, 백엔드 개발자라면 알아서 잘 남길 수 있을 것입니다.

그럼 왜 이 글이 있는 거죠?

RFC에 HTTP API의 에러에 관한 내용이 있더라구요. 흥미로워서 가져왔습니다.

RFC7807

RFC7807은 Problem Details for HTTP APIs라는 이름의 문서입니다.
이름 그대로 HTTP API에서 발생하는 오류 응답 형식을 적어놓은 문서죠.

이 문서에서는 application/problem+json MIME 타입을 소개합니다.

DevContainer로 개발환경 구성하기

Dev Container

Dev Container는 도커 컨테이너를 이용하여 개발 환경을 구축하는 방법입니다.
이 방식을 이용하면 쉽게 어느 곳에서나 동일한 개발 환경을 구축할 수 있습니다.
이 글에서는 Go 언어를 사용하는 개발 환경을 구축하는 방법을 소개합니다.

devcontainer 폴더 구성

프로젝트 폴더 내에 .devcontainer 폴더를 생성합니다.
그리고 다시 한번 그 안에 원하는 개발 환경 이름으로 폴더를 생성합니다.

$ mkdir -p .devcontainer/practice-go

devcontainer 설정 파일 작성

.devcontainer/practice-go 폴더 내에 devcontainer.json 파일을 생성합니다.
저희는 전반적인 구성을 docker-compose로 하기 때문에 docker-compose.yml 파일을 참조하도록 설정합니다.

고 언어의 의존성 주입을 위한 Provider

Provider

Provider는 제가 만들고 있는 고 언어에서 의존성 주입을 위한 컨테이너입니다.

정의

type Provider struct {
	constructors map[reflect.Type]map[reflect.Value]struct{}
	container    map[reflect.Type]any
	lock         sync.RWMutex
}

Providerconstructorscontainer를 가지고 있습니다.

  1. constructors는 생성자를 저장하는 맵입니다.
  2. container는 실제로 생성된 인스턴스를 저장하는 맵입니다.

생성자 등록

func (p *Provider) Register(constructFunction ...any) error {
	p.lock.Lock()
	defer p.lock.Unlock()
	for _, con := range constructFunction {
		if err := p.register(con); err != nil {
			return err
		}
	}
	return nil
}

func (p *Provider) register(constructFunction any) error {
	args, _, err := analyzeConstructor(constructFunction)
	if err != nil {
		return err
	}

	for _, arg := range args {
		if _, ok := p.constructors[arg]; !ok {
			p.constructors[arg] = make(map[reflect.Value]struct{})
		}
		p.constructors[arg][reflect.ValueOf(constructFunction)] = struct{}{}
	}

	return nil
}

func analyzeConstructor(constructFunction any) ([]reflect.Type, []reflect.Type, error) {
	if reflect.TypeOf(constructFunction).Kind() != reflect.Func {
		return nil, nil, ErrNotAFunction{}
	}

	constructor := reflect.ValueOf(constructFunction)
	var args []reflect.Type

	for i := 0; i < constructor.Type().NumIn(); i++ {
		args = append(args, constructor.Type().In(i))
	}

	var returns []reflect.Type

	for i := 0; i < constructor.Type().NumOut(); i++ {
		returns = append(returns, constructor.Type().Out(i))
	}

	return args, returns, nil
}

Register 메서드는 생성자를 등록하는 메서드입니다.
...any로 가변인자를 받아서 여러개의 생성자를 등록할 수 있습니다.
내부에 정의된 register 메서드를 호출해서 각 함수를 constructors에 저장합니다.

git, buf, 그리고 패키지 매니저로 프로토버퍼 관리하기

프로토버퍼?

프로토버퍼는 구글에서 개발한 직렬화 라이브러리입니다.
프로토버퍼는 다양한 언어를 지원하며, 단일 IDL(Interface Description Language)을 사용하여 여러 언어에 대한 DTO 및 시리얼라이저를 생성할 수 있습니다.

그렇기에 서버와 클라이언트 사이에 서로 다른 언어를 사용하더라도, 같은 IDL만 공유할 경우 서로가 주고 받는 메시지의 종류만 안다면, 별도의 DTO를 문서를 보고 만들 필요 없이 바로 사용할 수 있습니다.

프로토버퍼의 문제점

프로토버퍼는 .proto 확장자를 가진 IDL 파일을 각 언어에 맞게 컴파일해서 사용해야한다는 단점이 있습니다.

그래서 프로토버퍼 파일 버전이 상이할 경우에, 같은 메시지(DTO)를 주고 받는 다더라도, 예외가 발생할 수 있어 주의가 필요합니다.