홈으로 돌아가기

Spring Boot 대신 Go와 YDB 서버리스

Yandex Cloud에서 개발자가 Spring Boot와 PostgreSQL 애플리케이션을 Go와 YDB 서버리스로 마이그레이션. manual SQL, 트랜잭션, DI 및 무료 티어 RU 최적화 도전 과제 설명.

Go + YDB: Yandex Cloud 무료 티어에 맞추기
Advertisement 728x90

스프링 부트에서 고(Go)와 YDB 서버리스로의 마이그레이션: 타이핑 트레이너의 여정

자바와 스프링 부트 경험을 가진 개발자는 타입스텝 타이핑 훈련 도구를 라이브러리 클라우드 서버리스로 이전했습니다. 유료 VM과 ECS 유사 서비스를 포기하면서 백엔드에 고(Go)를 선택하고, 포스트그레SQL 대신 YDB를 채택했습니다. 결과적으로 컨테이너가 마이크로초 단위로 냉각 시작되며 월 100만 요청 단위(RU)의 무료 티어 내에서 안정적으로 운영됩니다.

고는 JVM이나 JIT 초기화 없이 정적 바이너리로 컴파일됩니다. 컨테이너는 즉시 실행 가능해, 각 요청마다 새 인스턴스가 생성되는 서버리스 컨테이너 환경에서 매우 중요합니다. 반면, 그라얼VM을 사용하는 스프링 부트는 추가 설정이 필요하며 예산 제약 상 적합하지 않았습니다.

YDB 서버리스는 비용 절감을 위해 포스트그레SQL을 대체했습니다. 월 100만 RU는 무료지만, 문서를 깊이 분석하지 않으면 단위 계산 방식이 명확하지 않았습니다.

Google AdInline article slot

불필요한 계층 없이 깔끔한 아키텍처

프론트엔드는 정적 생성을 지원하는 Next.js로 구현되어 오브젝트 스토리지에 저장됩니다. 백엔드는 서버리스 컨테이너에서 도커로 실행되는 고(Go). 데이터베이스는 YDB 서버리스입니다. 브라우저에서 직접 요청이 처리되며, 정적 자원은 스토리지에서, API 호출은 컨테이너로 전달됩니다. API 게이트웨이 없이도 지연 시간과 비용을 줄일 수 있습니다.

// YDB 컬럼 예시 열거형

type StageType string

const (
    Lowercase   StageType = "lowercase"
    Uppercase   StageType = "uppercase"
    Punctuation StageType = "punctuation"
    Numbers     StageType = "numbers"
)

func (s *StageType) Scan(value interface{}) error {
    return ScanStringEnum(s, value, "StageType")
}

func (s *StageType) Value() (driver.Value, error) {
    return string(*s), nil
}

JPA에서 벗어난 후 수동 스키마 설계와 YQL 쿼리는 다소 회귀처럼 느껴집니다. 각 필드마다 DECLARE, 타입화된 매개변수, 수동 스캔이 필요합니다.

리포지토리와 YQL 쿼리

리포지토리는 트랜잭션을 포함한 YQL을 사용합니다. 레코드 생성 예시:

Google AdInline article slot
func (r *ItemRepository) CreateItem(ctx context.Context, item Item) (*Item, error) {
    var result *Item
    err := r.txManager.DoTx(ctx, func(ctx context.Context, tx table.TransactionActor) error {
        res, err := tx.Execute(ctx, `
            DECLARE $id AS Utf8;
            DECLARE $name AS Utf8;
            DECLARE $status AS Utf8;
            DECLARE $created_at AS Datetime;

            INSERT INTO items (id, name, status, created_at)
            VALUES ($id, $name, $status, $created_at)
            RETURNING id, name, status, created_at;
        `,
            table.NewQueryParameters(
                table.ValueParam("$id", types.UTF8Value(item.Id)),
                table.ValueParam("$name", types.UTF8Value(item.Name)),
                table.ValueParam("$status", types.UTF8Value(string(item.Status))),
                table.ValueParam("$created_at", types.DatetimeValueFromTime(item.CreatedAt)),
            ),
        )
        if err != nil {
            return fmt.Errorf("failed to execute CreateItem: %w", err)
        }
        defer res.Close()

        // ... 결과를 &result에 스캔
        return res.Err()
    })
    return result, err
}

컬럼 추가 시 모든 작업(INSERT, UPDATE, SELECT)에 대한 수정이 필요합니다. JPA처럼 엔티티가 자동으로 업데이트되지 않습니다.

  • 수동 SQL의 장점: 쿼리에 대한 완전한 제어, N+1 문제 없음.
  • 단점: 코드 길이 증가, 수동 타입 매핑, 열거형 처리 번거로움.
  • JPA와 비교: 기본 스키마에서는 JPA가 더 간단하지만, 실행 세부 정보를 숨깁니다.

분산 YDB에서의 트랜잭션 관리

YDB는 외래키를 지원하지 않지만 트랜잭션은 제공합니다. @Transactional 없이도 수동으로 관리해야 합니다.

TransactionManager는 컨텍스트에서 기존 트랜잭션 존재 여부를 확인합니다:

Google AdInline article slot
func (t *TransactionManager) DoTx(ctx context.Context, fn func(ctx context.Context, tx table.TransactionActor) error) error {
    if tx, ok := TxFromContext(ctx); ok {
        return fn(ctx, *tx)
    }
    return t.ydb.NativeDriver.Table().DoTx(ctx, func(ctx context.Context, tx table.TransactionActor) error {
        ctx = context.WithValue(ctx, txKey{}, &tx)
        return fn(ctx, tx)
    })
}

이 방식은 스프링의 전파 전략을 모방합니다: 새 트랜잭션 시작 또는 기존 트랜잭션 재사용. 코드 중복을 제거(예: Create vs CreateTx).

서비스와 리포지토리는 매니저를 주입합니다. 하나의 트랜잭션 내 여러 연산도 자연스럽게 작동합니다.

수동 종속성 주입과 빌드

스프링 IoC 없이 종속성은 main.go에서 순서대로 구성됩니다: DB → 리포지토리 → 서비스 → 핸들러. 리플렉션, 프록시, 런타임 루프 없이 동작합니다.

장점:

  • 종속성 그래프가 명확함.
  • 마법 같은 동작이나 예상치 못한 오류 없음.
  • 리팩터링이 쉬움.

단점: 모든 컴포넌트마다 수동 보일러플레이트 작성 필요.

컨텍스트 기반 오류 처리

고의 오류는 스택 트레이스 없이 값으로 표현됩니다. 해결책: fmt.Errorf에 고유 접두사 사용.

func (s *ItemService) ProcessItem(ctx context.Context, id string) error {
    item, err := s.repo.FindById(ctx, id)
    if err != nil {
        return fmt.Errorf("ProcessItem: failed to find item %s: %w", id, err)
    }
    // ...
}

로그 출력 예: ProcessItem: failed to find item abc123: ItemRepository.FindById: query failed: connection refused. 오류 발생 지점을 정확히 추적할 수 있습니다.

YDB 요청 단위 최적화

초기 스키마는 포스트그레SQL과 유사하게 문자 통계를 별도 행에 저장하고, 키별로 INSERT/UPDATE 루프를 반복했습니다. 이로 인해 요청 단위(RU) 소모가 매우 빨랐습니다.

최적화 방법:

  • 삽입 전 클라이언트에서 데이터 집계.
  • 배치 처리.
  • 인덱스 및 파티셔닝 재검토.

수정 후 무료 티어 내에서 안정적으로 운영됩니다.

핵심 교훈

  • 고는 서버리스 컨테이너에서 냉각 시작 시간이 1초 미만이며, 스프링 부트보다 훨씬 빠릅니다.
  • YDB는 수동 YQL과 DECLARE 매개변수 요구; 스키마를 단순화하면 RU 절약 가능.
  • TransactionManager는 코드 중복 없이 @Transactional을 모방합니다.
  • 수동 DI는 스프링보다 명확하지만, main.go에서의 규율이 필요합니다.
  • 로그 가독성을 높이기 위해 오류에 접두사 추가.

— Editorial Team

Advertisement 728x90

다음 읽기