백엔드

백엔드를 Rust에서 Go로 이전한 이유, 그리고 빌드 시간이 결정적이었던 까닭 ================================================================ 저는 Rust를 사랑합니다. 정말입니다. 훌륭한 커뮤니티를 가진 환상적인 언어이며, 대부분의 개인 프로젝트에서 제가 선택하는 언어입니다. 하지만 최근 한 백엔드 프로젝트에서는 Rust에서 Go로 이전하는 어려운 결정을 내렸습니다. 주된 이유는요? 바로 빌드 시간이었습니다. ## Rust 경험: 강력하지만 컴파일이 느림 저희 백엔드 서비스는 Rust로 작성된 작은 모놀리스로 시작했습니다. 초기 개발은 순조로웠습니다. Rust의 타입 시스템과 소유권 모델은 컴파일 시간에 많은 버그를 잡아주어 저희에게 큰 자신감을 주었습니다. `serde`, `tokio`, `actix-web`과 같은 크레이트가 있는 생태계는 함께 작업하기에 즐거웠습니다. 하지만 프로젝트가 커지면서 컴파일 시간도 늘어났습니다. 개발자 머신에서 클린 빌드를 하는 데 몇 분이 걸렸습니다. 증분 빌드는 더 빠르긴 했지만, 점차 느리게 느껴지기 시작했습니다. 이것은 사소한 불편함을 넘어, 개발 속도를 실질적으로 저해하고 있었습니다. ## CI/CD 병목 현상 하지만 진짜 고통스러운 지점은 CI/CD 파이프라인에 있었습니다. 모든 풀 리퀘스트는 전체 릴리스 빌드와 포괄적인 테스트 스위트 실행을 포함한 일련의 검사를 트리거했습니다. 다음은 CI에서의 빌드 시간을 간략하게 살펴본 것입니다: | 단계 | Rust 빌드 시간 | | --- | --- | | 의존성 캐싱 | ~1분 | | 디버그 빌드 (테스트용) | ~5-7분 | | 릴리스 빌드 (배포용) | ~10-15분 | | **총 파이프라인 시간** | **~20-25분** | 간단한 변경에 대한 25분의 피드백 루프는 받아들일 수 없었습니다. 저희는 `sccache`, Docker 레이어 캐싱, 빌드와 테스트 작업 분리 등 다양한 최적화를 시도했습니다. 이러한 방법들이 도움이 되긴 했지만, 몇 분을 단축하는 데 그쳤습니다. 핵심 문제는 그대로였습니다. Rust 컴파일러는 많은 작업을 수행하고(안전성 측면에서는 훌륭하지만!), 시간이 걸린다는 점입니다. ## Go의 등장: 실용적인 선택 저희는 실험을 해보기로 결정했습니다. 서비스의 핵심적이고 독립적인 부분을 가져와 Go로 다시 작성했습니다. 그 경험은... 달랐습니다. * **단순성:** Go는 훨씬 더 단순한 언어입니다. Rust의 강력한 추상화(트레이트나 고급 열거형 등)는 잃었지만, 코드는 놀라울 정도로 작성하고 읽기 쉬웠습니다. * **동시성:** Go의 고루틴과 채널은 동시성 처리를 쉽게 만들어 주어, 저희 백엔드 요구사항에 아주 적합했습니다. * **컴파일러:** 이것이 바로 게임 체인저였습니다. Go 컴파일러는 *빠릅니다*. 눈부시게 빠릅니다. 서비스의 상당 부분을 다시 작성한 후, 동일한 CI 파이프라인을 실행했습니다. 결과는 놀라웠습니다. | 단계 | Go 빌드 시간 | | --- | --- | | 의존성 캐싱 | ~30초 | | 빌드 및 테스트 | ~1-2분 | | **총 파이프라인 시간** | **~2-3분** | 저희는 25분 걸리던 파이프라인을 3분짜리로 단축했습니다. 이것은 단지 점진적인 개선이 아니라, 개발 워크플로우에 대한 근본적인 변화였습니다. 개발자들은 거의 즉시 피드백을 받을 수 있게 되었습니다. 코드 병합이 더 빨라졌습니다. 핫픽스 배포가 사소한 일이 되었습니다. ## 트레이드오프와 결론 이전 과정이 순탄했냐고요? 아닙니다. 저희는 Rust의 컴파일 타임 보장을 포기했고, 이는 테스트와 코드 리뷰에 더 부지런해야 한다는 것을 의미했습니다. 제네릭의 부재(초기 재작성 시점)는 일부 코드를 더 장황하게 만들었습니다. Go의 오류 처리는 단순하지만, Rust의 `Result`와 `?` 연산자보다 덜 견고하게 느껴집니다. 하지만 개발 속도와 빠른 피드백 루프가 중요한 웹 백엔드라는 저희의 특정 사용 사례에서는 그 트레이드오프가 가치가 있었습니다. 빌드 시간의 극적인 감소는 개발자 생산성과 행복도 증가로 바로 이어졌습니다. 저는 여전히 Rust를 사랑하며, 성능과 안전성 보장이 가장 중요하고 컴파일 시간이 덜 중요한 프로젝트에서는 계속해서 사용할 것입니다. 하지만 이 경험은 때로는 작업에 가장 '최고의' 도구는 방해하지 않고 결과물을 내놓게 해주는 도구라는 것을 강력하게 상기시켜 주었습니다. 저희에게 그 도구는 Go였습니다.

Rust는 우리에게 거의 필요하지 않았던 안전성과 매일 싸워야 했던 컴파일 루프를 주었습니다. 한 서비스를 Go로 전환하게 만든, 수치를 포함한 솔직한 트레이드오프가 여기에 있습니다.

이 글은 영어 원문을 AI 모델이 번역한 것입니다. 표현이 원문과 다를 수 있습니다. 영어 원문 보기

우리는 프로덕션 백엔드를 Rust에서 Go로 다시 작성했으며, 결정적인 요인은 런타임 속도, 메모리 사용량 또는 안전성이 아니었습니다. 그것은 컴파일 루프였습니다. 한 시간에 열 번씩 변경하는 서비스는 그 변경 사항을 얼마나 빨리 확인할 수 있느냐에 따라 성패가 갈립니다. 이것은 우리가 무엇을 포기했고, 무엇을 얻었으며, 시스템의 어떤 부분을 Rust로 남겨두기로 결정했는지에 대한 전체 설명입니다.

이어지는 내용 중 어느 것도 Rust에 대한 불평이 아닙니다. Rust는 약속한 것을 정확히 수행했습니다. 요점은 더 좁습니다. 즉, Rust가 가장 잘하는 것은 이 특정 서비스에 가장 필요한 것이 아니었고, Rust가 요구하는 대가는 우리가 편집할 때마다 치러야 했던 것이었습니다.

피드백 루프가 업무의 대부분입니다

대부분의 백엔드 작업은 빈 파일에 새로운 코드를 작성하는 것이 아닙니다. 한 줄을 바꾸고, 실행하고, 오류를 읽고, 다시 바꾸는 일입니다. 이 과정을 열 번, 스무 번, 쉰 번씩 연속으로 반복합니다. 이 왕복 과정에 40초가 걸린다면, 오전에 하는 디버깅은 대부분 기다리는 시간이 됩니다. 만약 4초가 걸린다면, 문제에 계속 집중할 수 있고 무엇을 테스트하고 있었는지에 대한 단기 기억이 유지됩니다.

마지막 부분은 단순히 시간(초)보다 더 중요합니다. 10초 근방 어딘가에 빌드를 기다리는 것을 멈추고 컨텍스트 전환을 시작하게 되는 임계점이 있습니다. Alt-Tab을 누르고, 메시지를 읽고, 흐름을 놓치게 됩니다. 다시 돌아왔을 때는 무엇을 하고 있었는지에 대한 전체적인 정신적 그림을 다시 불러와야 합니다. 4초짜리 빌드는 당신을 의자에 계속 앉아있게 합니다. 40초짜리 빌드는 모든 테스트를 작은 방해로 만들고, 방해는 더해지는 것이 아니라 곱해집니다.

끊임없이 수정되는 서비스에게 컴파일러는 백그라운드 도구가 아닙니다. 하루 중 가장 많이 사용되는 단일 부분입니다. 우리는 컴파일러의 속도가 실제로는 우리 모든 작업의 속도를 결정하고 있었음에도 불구하고, 그것을 사소한 불편함으로 취급해 왔습니다.

우리가 출시한 버그는 Rust가 방지하는 버그가 아니었다

Rust의 타입 시스템과 borrow checker는 데이터 경쟁, 해제 후 사용, 반복자 무효화, null 역참조와 같은 결함의 범주 전체를 제거한다. 이것들은 실재하며 대가가 큰 버그들이고, 적절한 코드베이스라면 그 보장은 거의 어떤 대가를 치르더라도 가치가 있다.

문제의 서비스는 그런 코드베이스가 아니었다. 그것은 I/O 바운드 접착제(glue) 코드였다: HTTP 핸들러, 데이터베이스 호출, 번역 큐, 약간의 JSON 입력과 약간의 JSON 출력. 몇 달 동안 프로덕션에서 실제로 무엇이 깨졌는지 살펴보면 그 목록은 일관성이 있었다:

  • 잘못된 필터 형태로 인해 엉뚱한 행을 반환한 쿼리.
  • 한 곳에서는 선택 사항이고 다른 곳에서는 필수 사항이었던 JSON 필드.
  • 페이지네이션에서의 off-by-one 오류.
  • 로컬에서는 맞았지만 배포된 리전에서는 틀렸던 시간대 가정.

이 중 어느 것 하나 메모리 안전성 버그가 아니다. Rust는 그 모든 코드를 컴파일했을 것이다. 거의 모든 것이 요청별로 처리되고 상태 비저장(stateless)이었기 때문에, 우리는 경쟁이 일어날 공유 가변 상태가 거의 없는 서비스에서 데이터 경쟁에 대한 보장을 위해 비용을 지불하고 있었던 것이다. 보험은 실재했지만, 우리는 우리가 감수하지 않는 위험에 대해 보험을 들고 있었다.

시간이 실제로 어디에 쓰였는가

고통은 콜드 빌드(cold build)가 아니었습니다. 그 대가는 하루에 한 번 정도 치르면 되고, 커피를 마시러 갈 수도 있습니다. 고통은 한 줄을 수정한 뒤의 증분 빌드(incremental build)였습니다. 왜냐하면 그것이 하루 종일 치러야 하는 대가이기 때문입니다.

다음은 그 형태를 정확한 벤치마크가 아닌 범위로 나타낸 것입니다. 왜냐하면 정확한 수치는 사용자의 머신과 의존성 그래프에 따라 달라지기 때문입니다.

변경 사항 Rust (증분) Go
핸들러 본문 한 곳 수정 수십 초 약 1초
공유 타입 수정 대규모 재컴파일 수 초
의존성 추가 수 분 수 초
전체 클린 빌드 수십 분 수 초

Rust의 수치가 이렇게 나온 데에는 두 가지 요인이 있습니다. 첫 번째는 monomorphization입니다. 이는 제네릭 코드가 사용되는 각 구체적인 타입에 대해 새로 컴파일되는 것을 의미하며, 빠른 바이너리를 생성하지만 빌드는 느려집니다. 두 번째는 의존성 그래프입니다. procedural macros에 의존하는 몇 개의 크레이트(crate)를 가져오면, 빌드 시간의 상당 부분이 한 번도 읽어보지 않은 코드를 확장하고 컴파일하는 데 소요됩니다. 널리 사용되는 타입을 한 줄 변경하면 캐시의 상당 부분이 무효화되고 긴 재컴파일이 뒤따를 수 있습니다.

다시 말하지만, 이것은 Rust가 잘못 동작하는 것이 아닙니다. Monomorphization 덕분에 런타임이 빠릅니다. Proc macros 덕분에 사용 편의성(ergonomics)이 좋습니다. 이것들은 컴파일 루프보다 생성된 바이너리를 우선시하는 의도적인 트레이드오프(trade-off)입니다. 우리가 하루 종일 수정했던 서비스의 경우, 우리는 그 트레이드오프에서 손해를 보는 쪽에 있었습니다.

Go가 포기한 것들과 그것이 가치 있었던 이유

Go는 더 작고, 더 직설적인 언어이며, Go로 전환하는 것은 실질적인 것들을 잃는 것을 의미했습니다. 이러한 손실에 대해 솔직해지는 것이 그 트레이드오프에 신뢰성을 부여하는 유일한 방법입니다.

우리는 적절한 합 타입(sum type)을 잃었습니다. 태그된 유니온(tagged union)에 대한 Go의 해답은 인터페이스와 타입 스위치(type switch)의 조합이며, 컴파일러는 이것의 완전성(exhaustiveness)을 검사하지 않습니다. 우리는 ? 연산자를 잃고, 체감상 세 줄에 한 번꼴로 if err != nil를 돌려받았습니다. 우리는 컴파일 타임에 특정 종류의 동시성 오류를 잡아낼 수 있었던 빌림 검사기(borrow checker)를 잃었고, 대신 레이스 탐지기(race detector)와 코드 리뷰에 의존해야 했습니다. 제네릭(Generics)은 Go에 늦게 도입되었으며 여전히 Rust의 트레이트(trait)보다 표현력이 떨어집니다.

이러한 손실을 감수한 대가로 얻은 것은 다음과 같습니다.

  • 빌드를 더 이상 신경 쓰지 않아도 될 만큼 충분히 빠른 컴파일러. 편집-실행-확인 주기가 방해 역치(interruption threshold) 아래로 떨어졌고 그 상태를 유지했습니다.
  • 대부분의 작업을 수행하는 명확한 한 가지 방법. 루프를 작성하는 방법, 오류를 처리하는 방법, 그리고 누구도 이의를 제기하지 않는 도구에 의해 강제되는 서식 표준이 거의 한 가지로 정해져 있습니다. 이러한 획일성은 생각보다 더 중요하며, 특히 코드의 상당 부분이 AI 어시스턴트에 의해 작성되거나 편집될 때 더욱 그렇습니다. 그럴듯해 보이지만 이례적인 구조가 끼어들 여지가 적기 때문입니다.
  • 우리가 필요로 했던 HTTP 서버, JSON, 컨텍스트 전파(context propagation), 타임아웃을 이미 갖추고 있는 표준 라이브러리. 각각에 대해 의존성 트리(dependency tree)를 끌어올 필요 없이 말입니다.

장황한 오류 처리는 예상보다 작은 비용인 것으로 드러났습니다. 특정한 이유가 있는데, 바로 그것이 지역적(local)이고 훑어보기 쉽다(skimmable)는 점입니다.

// The whole error style in one function. Boring to write, fast to read.
func (s *Server) renderHome(ctx context.Context, lang string) (*Page, error) {
	posts, err := s.store.ListPublishedByLang(ctx, ListQuery{Lang: lang, Limit: 20})
	if err != nil {
		return nil, fmt.Errorf("list posts for %s: %w", lang, err)
	}
	count, err := s.store.CountPublishedByLang(ctx, lang)
	if err != nil {
		return nil, fmt.Errorf("count posts for %s: %w", lang, err)
	}
	return s.buildPage(lang, posts, count), nil
}

모든 오류 발생 지점은 오류를 발생시킨 호출 바로 옆에 있으며, 무엇이 실패했는지 알려주는 컨텍스트로 감싸여 있습니다. 프로덕션 환경에서 무언가 잘못되면, 오류 문자열은 마치 무슨 일이 일어나고 있었는지에 대한 스택처럼 읽힙니다. 반면에 느린 빌드는 지역적이지 않습니다. 모든 변경 사항에 대해 팀 전체를 막아서고, 훑어보는 식으로 지나칠 수도 없습니다.

마이그레이션은 의도적으로 지루하게 진행되었습니다

우리는 빅뱅 방식의 재작성을 하지 않았습니다. 서비스는 인터페이스 뒤에서 분리되었고, 우리는 Go 버전이 동일한 트래픽을 동일한 결과물로 처리할 때까지 Rust 버전을 계속 실행하면서 한 번에 하나씩 경계가 명확한 조각을 옮겼습니다. 마이그레이션의 목표는 지루함입니다. 흥미로운 부분은 버그가 사는 곳입니다.

두 가지 덕분에 포팅이 간단했습니다. 첫째, 데이터 모델은 바뀌지 않았고 주변의 코드만 바뀌었기 때문에, 우리는 동일한 데이터베이스에 대해 두 구현을 비교하고 그 응답의 차이를 확인할 수 있었습니다. 둘째, Go의 표준 라이브러리가 우리가 필요로 하는 영역을 모두 포함했기 때문에, 대부분의 핸들러는 재설계가 아닌 거의 기계적인 번역에 가까웠습니다. 기계적이지 않았던 부분들, 주로 오류 처리와 동시성은, 바로 손으로 천천히 작업할 가치가 있는 부분이었습니다.

우리가 여전히 Rust를 사용해야 할 때

이것은 트레이드오프이지, 판결이 아닙니다. 이를 판결로 취급하면 사람들은 결국 두 번이나 잘못된 도구를 선택하게 됩니다. Rust는 적절한 곳에서 컴파일 시간을 몇 배로 보상해 줍니다.

  • 모든 할당과 모든 분기가 중요한 CPU 집약적인 코어.
  • 데이터 경쟁이 이론적인 위험이 아닌 실제적인 위험인, 진정한 공유 가변 상태와 실제 동시성을 가진 코드.
  • 파서, 코덱, 숫자 연산 핫 패스 등 미묘한 메모리 버그가 발생할 가능성이 높고 치명적일 수 있는 모든 것.

우리는 정확히 그 부분들을 Rust로 유지했습니다. 미디어 트랜스코딩과 몇 가지 숫자 연산 핫 루틴은 절대 이전하지 않았습니다. 왜냐하면 그것들에게는 컴파일러가 매 실행마다 제값을 하기 때문입니다. 그것들을 둘러싼 서비스 계층, 즉 대부분 네트워크와 데이터베이스를 기다리는 부분은 Go가 적합한 곳입니다.

우리가 정한 규칙

버그 위험이 있었으면 하는 곳이 아니라, 실제로 존재하는 곳에 언어를 맞추세요.

안전이 중요하고 CPU에 바인딩된 코드의 경우, 컴파일러에 비용을 지불하고 사람이 놓치는 것을 잡게 하세요. 하루 종일 수정하는 I/O 바운드 서비스 코드의 경우, 주요 위험은 데이터 경합이 아니라 바로 눈앞의 논리 버그를 찾지 못하게 만드는 느린 피드백 루프입니다. 그런 경우에는 그 루프를 되찾으세요.

'버그 위험은 어디에 있는가'라는 이 단 하나의 질문이 이 서비스를 Go로 옮기고 핵심 코어는 Rust로 유지하게 했습니다. 여러분의 시스템에서는 그 답이 같지 않을 것입니다. 하지만 질문은 같아야 합니다.

관련 글

데이터베이스

Firestore의 MongoDB 호환성에서 `retryWrites=false`의 함정 ================================================================ Firestore를 MongoDB 호환 모드에서 사용하고 있다면 쓰기 작업이 때때로 실패하고 온라인에서 찾을 수 있는 유일한 "해결책"은 연결 문자열에 `retryWrites=false`를 추가하는 것뿐인 특이한 문제를 겪었을 수 있습니다. 이것이 문제를 해결하는 것처럼 보일 수 있지만, 데이터 손실 및 기타 문제로 이어질 수 있는 함정입니다. 이러한 현상이 발생하는 이유와 올바른 해결책이 무엇인지 알아보겠습니다. ## 쓰기 작업이 실패하는 이유 Firestore는 MongoDB를 에뮬레이션할 때 MongoDB의 재시도 가능 쓰기(retryable writes) 기능을 완벽하게 지원하지 않습니다. 쓰기 작업이 Firestore로 전송될 때 일시적인 네트워크 오류나 서버 측의 일시적인 문제가 발생하면 MongoDB 드라이버는 자동으로 쓰기 재전송을 시도할 수 있습니다. 하지만 Firestore의 에뮬레이션 레이어는 이 두 번째 시도를 올바르게 처리하지 못할 수 있으며, 특히 첫 번째 시도가 이미 처리 중인 경우 더욱 그렇습니다. 이로 인해 드라이버는 쓰기 작업이 실패했다고 생각하는 오류 상태가 발생할 수 있으며, 실제로는 결국 성공할 수도 있습니다. ## `retryWrites=false` "해결책" 연결 문자열에 `retryWrites=false`를 추가하면 MongoDB 드라이버에게 첫 번째 시도 후에 바로 포기하라고 지시합니다. 일시적인 오류가 발생하면 쓰기 작업은 즉시 실패하고 애플리케이션은 오류를 보게 됩니다. 더 이상 혼란스러운 재시도 관련 오류가 발생하지 않기 때문에 이것이 문제를 해결하는 것처럼 *보입니다*. 쓰기 작업은 첫 시도에 성공하거나 깔끔하게 실패합니다. ## 진짜 문제: 데이터 손실 함정은 재시도를 비활성화함으로써 이제 일시적인 실패를 직접 처리해야 한다는 점입니다. 그렇게 하지 않으면, 애플리케이션은 순간적인 네트워크 문제만으로도 중요한 데이터를 저장하지 못할 수 있습니다. 사용자는 데이터가 저장되었다고 생각할 수 있지만, 실제로는 쓰기 작업이 조용히 실패했을 수 있습니다(오류를 처리하지 않는다면 그다지 조용하지 않을 수도 있습니다). 이는 데이터 신뢰성 측면에서 중대한 후퇴입니다. ## 올바른 해결책 중요한 신뢰성 기능을 비활성화하는 대신, 올바른 접근 방식은 Firestore의 MongoDB 호환성을 위해 환경이 올바르게 설정되었는지 확인하는 것입니다. * **1. 공식적으로 지원되는 드라이버 및 버전 사용:** Google Cloud의 문서를 확인하여 Firestore에서 완전히 테스트되고 지원되는 MongoDB 드라이버 및 버전 목록을 확인하십시오. 지원되지 않는 버전을 사용하면 예측할 수 없는 동작이 발생할 수 있습니다. * **2. 사용자 지정 재시도 로직 구현:** 문제를 일으키는 드라이버 구성을 꼭 사용해야 하는 경우, 재시도를 전역적으로 비활성화하지 마십시오. 대신, 쓰기 실패와 관련된 특정 오류 코드를 포착하여 애플리케이션 코드에 자체 재시도 로직을 구현하십시오. 이를 통해 재시도 횟수와 백오프 전략(예: 지수 백오프)을 제어할 수 있습니다. * **3. 문제 보고:** Firestore의 MongoDB 호환성 레이어에서 버그를 발견했다고 생각되면 Google Cloud 지원팀에 보고하십시오. 드라이버 버전, 연결 문자열(자격 증명 제외), 정확한 오류 메시지에 대한 자세한 정보를 제공하십시오. 이는 모든 사용자를 위해 서비스를 개선하는 데 도움이 됩니다. 요약하자면, `retryWrites=false`는 해결책이 아닙니다. 이는 더 깊은 문제를 숨기고 데이터 손실의 위험을 초래하는 임시방편입니다. 항상 지원되는 구성을 사용하고 애플리케이션에 강력한 오류 처리 및 재시도 메커니즘을 구축하는 것을 우선시해야 합니다.

Firestore는 MongoDB 와이어 프로토콜을 지원하지만 재시도 가능 쓰기는 거부합니다. 드라이버의 기본 설정이 on인 경우, 쓰기가 무작위로 실패하는 것처럼 보입니다. 그 이유와 해결 방법은 다음과 같습니다.

5분 읽기 firestoremongodbgoidempotencydatabases