---
id: 6e0bce9e22a4bf7d9672c59e67bcf5ec
author: Lemon Mint
title: 'gosuda.org/chromenet: 고정된 릴리스에서 재현 가능하게 빌드된, Go를 위한 Chromium 네트워크 스택'
description: CGO_ENABLED=0 Go http.RoundTripper는 격리된 HQQ 워커에서 Chromium 자체의 //net 스택(HTTP/1.1, HTTP/2, QUIC/HTTP/3, BoringSSL)을 통해 요청을 전송하며, 소스, 툴체인, 그리고 SHA-256으로 고정되고 gvx 샌드박스에서 빌드된 여섯 개의 내장 바이너리를 포함합니다.
language: ko
date: 2026-11-09T00:00:00Z
path: /chromenet
go_package: gosuda.org/chromenet
go_repourl: https://github.com/gosuda/chromenet.git
category: network
---

`gosuda.org/chromenet`는 Chromium 자체의 네트워크 스택을 통해 요청을 전송하는 Go `http.RoundTripper`이다. 이 스택은 공식 Chromium 릴리스의 `//net`으로, HTTP/1.1, HTTP/2, QUIC/HTTP/3 구현, BoringSSL, Chrome의 TLS 구성, Chrome Root Store를 포함한다. 스택은 별도의 워커 프로세스인 `chrome_net_server`에서 실행되며, Go 측은 순수 Go(`CGO_ENABLED=0`)로 유지되고 [HQQ](https://gosuda.org/hqq) 공유 메모리 RPC를 통해 워커와 통신한다.

```bash
go get gosuda.org/chromenet@v0.1.10157081033800
```

## 왜 Chromium의 네트워크 스택을 실행하는가

`net/http`로 웹과 통신하는 Go 프로그램은 서버에 Go 클라이언트로 인식된다. 이는 서비스 간 트래픽에 적합한 기본값이다. 그러나 서버의 동작이 자신이 어떤 종류의 클라이언트와 통신하고 있다고 판단하는지에 따라 달라지는 경우에는 적합하지 않다. 예를 들어 브라우저 전용 엔드포인트, 봇 탐지 미들웨어, TLS 및 HTTP/2 핑거프린트에 따라 서로 다른 프로토콜 버전을 제공하는 CDN, 실제 브라우저의 경험을 재현해야 하는 테스트가 이에 해당한다.

Go 표준 라이브러리로는 브라우저의 와이어 동작을 재현할 수 없다. `crypto/tls`는 자체적인 cipher suite 순서와 확장 집합을 가지며, `net/http`는 자체적인 HTTP/2 설정을 가진다. ClientHello 바이트를 복사하여 브라우저를 모방하는 라이브러리는 릴리스마다 이를 계속 복사해야 한다. `chromenet`은 다른 방식을 택한다. Chromium 네트워크 스택을 모방하지 않고 그 스택을 직접 실행한다.

`darwin/arm64` 엔진에서 공개 핑거프린트 서비스(`tls.peet.ws`)를 대상으로 측정한 결과, 기본 구성은 다음 값을 보고한다.

| 속성                               | 관측값                                                                |
| :--------------------------------- | :-------------------------------------------------------------------- |
| 협상된 프로토콜                    | `h2`                                                                  |
| JA4                                | `t13d1516h2_8daaf6152771_806a8c22fdea`                                |
| HTTP/2 핑거프린트(Akamai 형식)     | `1:65536;2:0;4:6291456;6:262144\|15663105\|0\|m,a,s,p`                |
| User-Agent                         | 엔진 메이저 버전에 해당하는 데스크톱 Chrome, 예: `Chrome/157.0.0.0`   |

`ForceHTTP3`를 사용하면 `cloudflare-quic.com`과 `www.google.com`에 대한 요청이 HTTP/3으로 응답되었다. 이 수치들은 하루 동안 한 플랫폼에서 얻은 것이다. 엔진이 곧 스택이므로, 고정된 Chromium 릴리스가 바뀌면 이 수치도 변한다.

## 사용 방법

`NewTransport`는 `http.RoundTripper`를 반환한다. `http.Transport`를 사용하는 모든 곳에서 사용할 수 있다.

```go
package main

import (
	"fmt"
	"io"
	"net/http"

	"gosuda.org/chromenet"
)

func main() {
	client := &http.Client{Transport: chromenet.NewTransport(chromenet.Options{})}

	resp, err := client.Get("https://example.com/")
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	fmt.Println(resp.Proto, resp.StatusCode, len(body))
}
```

첫 번째 요청 시, 모듈은 내장된 플랫폼별 워커 바이너리를 사용자 캐시 디렉터리에 기록하고, `gosuda.org/hqq/secureexec`로 SHA-256 다이제스트를 검증한 뒤, 부모 생명주기 연결(`--parent-stdin`, 호출 프로세스가 종료되면 워커도 종료됨)과 함께 워커를 시작하고 세션 풀을 연다. 별도로 설치할 것은 없다.

`http.Client`가 변경 없이 동작하도록 몇 가지 동작은 `http.Transport`를 따른다.

- Transport는 리다이렉트를 따르지 않으며 쿠키를 보관하지 않는다. `http.Transport`와 마찬가지로 이 두 가지는 `http.Client`가 수행한다.
- gzip, deflate, Brotli, zstd로 압축된 응답은 디코딩된 상태로 도착하며, `resp.Uncompressed`는 true이다.
- 요청은 응답 본문이 닫힐 때까지 하나의 엔진 세션을 점유한다. 반드시 본문을 닫아야 한다.
- 요청 컨텍스트를 취소하면 Chromium 내부의 요청도 취소된다.
- 네트워크 오류는 Chromium 오류 코드와 이름을 담은 `*client.NetError` 값으로 반환된다.

```go
var ne *client.NetError
if errors.As(err, &ne) {
	fmt.Println(ne.Code, ne.Name) // -105 ERR_NAME_NOT_RESOLVED
}
```

### 옵션

| 필드                          | 효과                                                                                                     |
| :---------------------------- | :------------------------------------------------------------------------------------------------------- |
| `UserAgent`                   | 기본 Chrome User-Agent를 대체한다. 요청 헤더의 `User-Agent`는 여전히 우선한다.                          |
| `AcceptLanguage`              | `en-US,en;q=0.9`를 대체한다.                                                                             |
| `Proxy`                       | `http://127.0.0.1:8080` 또는 `socks5://127.0.0.1:1080`과 같은 Chromium 프록시 규칙. 비어 있으면 직접 연결이다. |
| `DisableHTTP2`, `DisableQUIC` | 해당 프로토콜을 비활성화한다.                                                                            |
| `ForceHTTP3`                  | `Alt-Svc` 광고를 기다리지 않고 모든 오리진에 QUIC을 사용한다.                                            |
| `InsecureSkipVerify`          | 모든 서버 인증서를 수락한다.                                                                             |
| `Pool`                        | 사용할 엔진 세션. nil이면 동시에 64개의 요청을 허용하는 `client.DefaultPool()`을 사용한다.                |

동일한 옵션을 가진 Transport들은 하나의 Chromium `URLRequestContext`를 공유하므로, 연결 풀, HTTP/2 세션, QUIC 세션이 요청 간에 재사용된다. `HQQ_CHROME_NET_SERVER=/path/to/chrome_net_server`를 설정하면 내장 워커를 대체할 수 있으며, 예를 들어 로컬 빌드를 사용할 수 있다.

## 동작 원리

| 계층                | 실행되는 내용                                                                                                                                                                  |
| :------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 사용자 프로그램     | `http.Client`와 `chromenet.Transport`, 순수 Go.                                                                                                                                 |
| 프로세스 경계       | HQQ 공유 메모리 RPC. 인터페이스는 `chrome_net.idl`이며, `hqq-bindgen`이 이를 Go 클라이언트와 C 서버 glue 코드로 컴파일한다.                                                  |
| `chrome_net_server` | Chromium 위에 구축된 작은 C++ 계층. 모든 `//net` 객체를 소유하는 하나의 네트워크 스레드와, 네트워크 스레드가 응답할 때까지 요청에서 블록되는 세션 스레드들로 구성된다.          |
| Chromium `//net`    | `URLRequestContext`, `URLRequest`, HTTP/2 및 QUIC 세션, BoringSSL.                                                                                                              |

IDL에는 중요한 작업이 여섯 가지 있다. `key=value` 옵션으로 엔진을 생성하고, 요청 헤드(`METHOD URL`과 헤더 행)와 본문 길이로 요청을 시작하고, 요청 본문 청크를 기록하고, 응답 헤드를 기다리고, 디코딩된 본문을 읽고, 핸들을 해제한다. 요청 핸들은 `@cancel`을 선언하므로, 버려진 호출은 요청이 끝까지 실행되도록 두는 대신 Chromium 내부의 요청을 중단시킨다.

네트워크 스택을 워커 프로세스에 격리하는 것은 패키징뿐 아니라 안전성의 측면도 있다. Chromium의 네트워크 코드는 개방형 인터넷의 적대적 입력을 파싱한다. 그것이 충돌하더라도 Go 서비스는 함께 충돌하지 않는다. 호출은 오류와 함께 실패하고, 세션은 폐기되며, 클라이언트는 다음에 필요할 때 새 워커를 시작한다.

## SHA-256으로 고정된 재현 가능한 빌드

Chromium을 포함하는 25 MB 바이너리는 그 안에 무엇이 들어 있는지 정확히 말할 수 있을 때에만 신뢰할 수 있다. `chromenet`은 `gosuda.org/sqlite3`가 SQLite를 따르는 방식과 같이 Chromium Stable을 따른다. 소스는 벤더링되지 않고 고정(pin)만 된다.

| 고정 항목                                                                                                                | 위치                           |
| :----------------------------------------------------------------------------------------------------------------------- | :----------------------------- |
| Chromium 릴리스 및 공식 `-lite` 소스 아카이브                                                                            | `third_party/chromium/VERSION` |
| 아카이브 및 해당 릴리스가 고정하는 모든 툴체인(clang, Rust, GN, ninja, Windows clang 런타임, xwin)의 SHA-256            | `third_party/chromium/SHA256`  |
| xwin을 통해 가져오는 MSVC CRT 및 Windows SDK 버전                                                                        | `third_party/chromium/WINSDK`  |
| 샌드박스 런타임(`rungv`) 릴리스, 게스트 이미지 다이제스트, Debian 스냅숏 날짜                                            | `scripts/engine/BUILDENV`      |
| HQQ C 런타임                                                                                                             | `go.mod` 및 `go.sum`           |

현재 고정된 버전은 Chromium `157.0.8103.38`이다. 고정 값과 일치하지 않는 다운로드는 빌드를 중단시킨다. 이 릴리스는 Chromium에 정확히 하나의 패치를 적용한다. Linux에서 `chrome_net_server`는 NSS 없이 빌드되며, 서버 인증서를 Chrome Root Store에 대해서만 검증하므로 바이너리는 libc 이외에 아무것도 필요로 하지 않는다.

Linux 및 Windows 엔진은 [gvx](https://gosuda.org/blog/posts/gvx-cross-platform-linux-containers-z48916d01) 샌드박스 내부에서 빌드된다. 이는 고정된 게스트 이미지를 사용하며 호스트 데몬이 없는 비특권 프로세스이다. Windows 엔진은 Chromium의 clang-cl 및 lld-link를 사용해 Linux에서 교차 컴파일되며, xwin이 Microsoft에서 가져오는 Windows SDK에 대해 링크된다. macOS 엔진은 Chromium의 macOS 빌드에 Xcode가 필요하므로 macOS 러너에서 네이티브로 빌드된다.

같은 커밋을 재빌드하면 별도의 CI 실행 간에 바이트 단위로 동일한 바이너리가 생성된다. 릴리스 바이너리는 모놀리포에 엔진을 반영한 커밋을 위해 이전 CI 실행이 빌드한 바이너리와 여섯 개 플랫폼 모두에서 바이트 단위로 동일하다.

| 플랫폼          | 바이너리 크기 |
| :-------------- | ------------: |
| `linux/amd64`   |       27.9 MB |
| `linux/arm64`   |       27.4 MB |
| `darwin/amd64`  |       26.2 MB |
| `darwin/arm64`  |       23.9 MB |
| `windows/amd64` |       27.3 MB |
| `windows/arm64` |       25.8 MB |

Linux 엔진은 `libc`, `libm`, `libdl`, `libpthread`, `libgcc_s`에만 의존하며, glibc 2.31 이상이 필요하다.

### Chromium 및 릴리스 추적

예약된 워크플로는 6시간마다 Chromium Stable을 확인한다. 새 릴리스가 있으면 고정 값을 다시 작성하고, `chromium/<version>` 브랜치에서 여섯 개 엔진을 모두 빌드하고 테스트하며, 바이너리에 증명(attestation)을 부여하고, 고정 값을 내장 바이너리, 다이제스트, 라이선스 텍스트와 함께 하나의 커밋으로 `main`에 커밋한다. 모놀리포에서는 파일별 `OWNERS`가 워크플로 봇에게 정확히 이 파일들에 대한 권한만 부여하므로, 사람의 개입 없이 업데이트가 반영된다. 다만 빌드 레시피 자체의 변경(Windows SDK 고정 값과 패치)에는 여전히 소유자의 승인이 필요하다.

모든 릴리스 바이너리는 이를 빌드한 워크플로로부터 빌드 출처 증명(build provenance attestation)을 함께 제공한다.

```bash
gh attestation verify chrome_net_server_linux_amd64 --repo gosuda/chromenet
```

각 바이너리에 포함된 내용(Chromium, BoringSSL, QUICHE 및 나머지 빌드 그래프)의 라이선스 텍스트는 Chromium 자체의 `tools/licenses/licenses.py`로 생성되며, `NOTICE_<os>_<arch>.txt`라는 이름으로 릴리스와 함께 배포된다.

## 제한 사항

- `DialWebSocket`은 순수 Go로 구현된 RFC 6455 클라이언트이며 Chromium 스택을 사용하지 않는다.
- `DialWebTransport`는 프로세스 내 구현이며 서버에 연결하지 않는다.
- 기본 JA4 및 HTTP/2 핑거프린트는 고정된 Chromium 릴리스의 것이다. Chromium은 릴리스마다 이를 변경하며, `chromenet`은 이를 따라간다.
- macOS 바이너리는 ad-hoc 서명되어 있으며 공증(notarization)을 받지 않았다.
- 워커 프로세스는 메모리를 소비한다. 각 엔진 바이너리는 디스크에서 24~28 MB이며, Chromium 런타임은 스레드 풀과 네트워크 스레드를 시작한다.
- 응답 본문이 닫힐 때까지 엔진 세션이 유지된다. 응답 본문을 누수시키는 프로그램은 풀을 고갈시킨다.

## 출처

저장소는 [github.com/gosuda/chromenet](https://github.com/gosuda/chromenet)이며, gosuda 모놀리포의 `//net/chromenet`에서 미러링된다. 빌드 레시피는 `scripts/engine`에, 엔진 glue 코드는 `engine/`에, 인터페이스는 `chrome_net.idl`에 있다.
