GNPS (Go Network Programming School)의 내용을 정리한 글입니다.
Go 언어를 활용한 네트워크 프로그래밍 - 책으로 스터디를 진행합니다.

2010년 이전에 대부분의 웹사이트는 HTTP를 사용했다. 아무런 암호화 없이 우리가 통신하는 모든 네트워크의 데이터를 누군가가 탈취해서 엿본다면 그냥 그대로 읽을 수 있었다. 하지만 요즘엔 HTTP 통신의 종단 간 암호화를 통한 보안 강화는 필수이다. HTTP를 사용한 웹사이트는 안전하지 않은 사이트라는 경고를 대문짝만 하게 띄워주며, HTTPS가 아닌 사이트를 찾기 어렵다.
TLS 프로토콜의 이해
TLS 프로토콜은 클라이언트와 서버 간에 안전한 통신을 제공한다. TLS를 사용하여 클라이언트와 서버는 통신을 암호화하여 제3자가 중간에서 통신을 가로채어 조작하는 것을 방지한다. TLS의 핵심적인 내용은 다음 3가지이다.
- 암호화: 데이터의 내용을 숨긴다. 누가 탈취하더라도 알아볼 수 없다.
- 인증: 신뢰받는 인증 기관에서 사이트의 신뢰를 보장한다.
- 무결성: 데이터의 내용이 바뀌지 않음을 확인한다. 디지털 서명을 통해 데이터 변조가 불가능하다.
TLS는 핸드셰이크라고 하는 과정을 거쳐서 상호간에 인증과 신뢰를 확인한다.
┌─────────────────────────────────────────────────────────────────────┐
│ TLS 연결 흐름도 │
└─────────────────────────────────────────────────────────────────────┘
클라이언트 서버
(브라우저) (웹서버)
│ │
│ ① ClientHello │
│ - 지원 암호화 방식 │
│ - TLS 버전 │
│─────────────────────────────────────>│
│ │
│ ② ServerHello │
│ - 선택된 암호화 │
│ - 서버 인증서 │
│<─────────────────────────────────────│
│ │
│ ③ 인증서 검증 │
│ - CA 서명 확인 │
│ - 유효기간 확인 │
│ - 도메인 확인 │
│ │
│ ④ 세션 키 생성 및 교환 │
│ (암호화된 랜덤 값) │
│─────────────────────────────────────>│
│ │
│ ⑤ 세션 키 확인 │
│<─────────────────────────────────────│
│ │
│ ⑥ Finished │
│ (핸드셰이크 완료 메시지) │
│─────────────────────────────────────>│
│ │
│ ⑦ Finished │
│<─────────────────────────────────────│
│ │
╔════════════════════════════════════════════════════════╗
║ 안전한 암호화 통신 시작! 🔒 ║
╚════════════════════════════════════════════════════════╝
│ │
│ 🔐 암호화된 HTTP 요청 │
│─────────────────────────────────────>│
│ │
│ 🔐 암호화된 HTTP 응답 │
│<─────────────────────────────────────│
│ │
│ 🔐 암호화된 데이터 전송... │
│<────────────────────────────────────>│
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ 핵심 포인트: │
│ • ①~⑦: TLS 핸드셰이크 (약 0.5~1초) │
│ • 인증서로 서버 신원 확인 │
│ • 세션 키로 실제 데이터 암호화 │
│ • 핸드셰이크 완료 후 안전한 통신 시작 │
└─────────────────────────────────────────────────────────────────────┘
- 1단계: 클라이언트가 지원하는 암호화 방식을 서버에 보내는 것으로 핸드셰이크가 시작된다.
- 2단계: 서버가 자신이 가지고 있는 인증서를 전송한다. 이때 서버의 인증서에는 도메인, CA, 유효기간 등 여러 정보가 있으며 클라이언트는 이런 정보가 유효한지 확인한다.
- 3단계: 데이터 암호화에 사용될 세션 키를 만든다.
- 4단계: 이후 데이터는 암호화되어 전달된다.
여기서 인증서를 인증해주는 신뢰할 수 있는 인증 기관이 존재한다. 이를 Root CA라고 부르며 Root CA가 중간 CA를 인증하고 중간 CA가 웹사이트의 인증서를 발급한다. 이 과정에서 브라우저나 운영체제에 저장되어 있는 CA의 공개키를 기반으로 인증서의 CA로부터 상위로 올라가는 인증 체인을 따라 확인한다.
TLS는 통신에 신뢰성을 더해주는 방법이며 TLS는 안정적인 네트워크 연결이 수립된 이후 핸드셰이크를 실행한다. 따라서 TLS를 사용하기 위해선 신뢰할 수 있는 연결인 TCP가 반드시 필요하다. (UDP 환경에서 사용할 수 있는 DTLS가 별도로 존재한다.)
네트워크 계층 상에서 확인해보면 TLS는 TCP의 계층인 4 계층보다 위의 계층이다. 또한 HTTP 프로토콜의 계층인 7 계층보다는 아래의 계층에서 데이터를 암호화하고 복호화한다. 엄밀히 말해 네트워크상 계층으로 분류하기는 애매하지만 두 계층 사이에 껴있는 암호화와 신뢰성을 위한 프로토콜이라고 이해할 수 있다.
전송 중인 데이터의 보안
클라이언트 사이드 TLS
net/http/httptest 패키지에서 제공하는 함수를 이용하면 Go에서 HTTP 상에서의 TLS 지원을 쉽게 테스트해볼 수 있다.
func TestClientTLS(t *testing.T) {
ts := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS == nil {
u := "https://" + r.Host + r.RequestURI
http.Redirect(w, r, u, http.StatusMovedPermanently)
return
}
w.WriteHeader(http.StatusOK)
}),
)
defer ts.Close()
resp, err := ts.Client().Get(ts.URL)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status %d; actual status %d", http.StatusOK, resp.StatusCode)
}
httptest.NewTLSServer 함수를 이용하여 HTTPS 서버를 반환한다. TLS가 적용된 서버에 HTTP로 요청을 받으면 요청 객체의 TLS 필드는 nil이 된다. 이런 케이스를 확인하여 클라이언트의 요청을 HTTPS로 리다이렉트 할 수 있다. 테스트 서버는 자체 서명된 인증서를 신뢰하도록 미리 설정된 HTTP 클라이언트를 반환하여 요청을 보내면 요청이 성공한다.
// 앞선 코드에 계속
tp := &http.Transport{
TLSClientConfig: &tls.Config{
CurvePreferences: []tls.CurveID{tls.CurveP256},
MinVersion: tls.VersionTLS12,
},
}
err = http2.ConfigureTransport(tp)
if err != nil {
t.Fatal(err)
}
client2 := &http.Client{Transport: tp}
_, err = client2.Get(ts.URL)
if err == nil || !strings.Contains(err.Error(), "certificate signed by unknown authority") {
t.Fatal("expected unknown authority error; actual: %q", err)
}
tp.TLSClientConfig.InsecureSkipVerify = true
resp, err = client2.Get(ts.URL)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status %d; actual status %d", http.StatusOK, resp.StatusCode)
}
HTTP 클라이언트의 프로토콜 처리와 TLS 설정을 위해 사용되는 Transport 객체를 생성하여 TLS를 구성한다. 트랜스포트가 기본 TLS 구성을 사용하지 않기 때문에 클라이언트는 HTTP/2를 기본적으로 지원하지 않는다. 따라서 http2.ConfigureTransport 함수를 호출하여 명시적으로 HTTP/2를 사용할 수 있도록 한다.
명시적으로 신뢰할 인증서를 선택하지 않으면 운영체제가 신뢰하는 인증 저장소의 인증서만 신뢰한다. 테스트는 첫 번째 요청에 대해 인증서 서명자를 신뢰하지 않기 때문에 실패하는 에러 케이스이다. NewTLSServer 는 자체 서명 인증서를 생성하기 때문에 테스트용 자체 서명 인증서를 허용하는 설정인 InsecureSkipVerify 필드를 true로 설정하여 클라이언트의 트랜스포트가 서버의 인증서를 검증하지 않도록 할 수 있다.
주의: InsecureSkipVerify는 테스트 환경에서만 사용해야 하며, 프로덕션 환경에서는 절대 사용하면 안 된다.
서버 사이드 TLS
서버측의 TLS 프로세스에서 중요한 점은 TLS 핸드셰이크 프로세스에서 서버가 클라이언트에게 인증서를 제공해야 한다는 것이다.
먼저 테스트 용도로 사용할 수 있는 인증서를 생성한다.
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout server.key \
-out server.crt \
-days 365 \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
-addext "extendedKeyUsage=serverAuth"
TLS를 사용한 에코 서버 코드는 다음과 같다.
package tls
import (
"context"
"crypto/tls"
"fmt"
"net"
"time"
)
func NewTLSServer(ctx context.Context, address string, maxIdle time.Duration, tlsConfig *tls.Config) *Server {
return &Server{
ctx: ctx,
ready: make(chan struct{}),
addr: address,
maxIdle: maxIdle,
tlsConfig: tlsConfig,
}
}
type Server struct {
ctx context.Context
ready chan struct{}
addr string
maxIdle time.Duration
tlsConfig *tls.Config
}
func (s *Server) Ready() {
if s.ready != nil {
<-s.ready
}
}
func (s *Server) ListenAndServeTLS(certFn, keyFn string) error {
if s.addr == "" {
s.addr = "localhost:443"
}
l, err := net.Listen("tcp", s.addr)
if err != nil {
return fmt.Errorf("binding to tcp %s: %w", s.addr, err)
}
if s.ctx != nil {
go func() {
<-s.ctx.Done()
_ = l.Close()
}()
}
return s.ServeTLS(l, certFn, keyFn)
}
func (s Server) ServeTLS(l net.Listener, certFn, keyFn string) error {
if s.tlsConfig == nil {
s.tlsConfig = &tls.Config{
CurvePreferences: []tls.CurveID{tls.CurveP256},
MinVersion: tls.VersionTLS12,
}
}
if len(s.tlsConfig.Certificates) == 0 && s.tlsConfig.GetCertificate == nil {
cert, err := tls.LoadX509KeyPair(certFn, keyFn)
if err != nil {
return fmt.Errorf("loading key pair: %v", err)
}
s.tlsConfig.Certificates = []tls.Certificate{cert}
}
tlsListener := tls.NewListener(l, s.tlsConfig)
if s.ready != nil {
close(s.ready)
}
for {
conn, err := tlsListener.Accept()
if err != nil {
return fmt.Errorf("accept: %v", err)
}
go func() {
defer func() {
_ = conn.Close()
}()
for {
if s.maxIdle > 0 {
err := conn.SetDeadline(time.Now().Add(s.maxIdle))
if err != nil {
return
}
}
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
return
}
_, err = conn.Write(buf[:n])
if err != nil {
return
}
}
}()
}
}
tls.NewListener 함수를 호출하여 TLS를 인지하는 리스너를 사용했다. 내부적으로는 TLS를 지원하는 연결 객체를 반환한다. 이후 반환된 커넥션을 사용하기 때문에 TLS의 세부 구현은 신경 쓰지 않고도 연결을 사용하여 데이터를 처리할 수 있다.
서버의 최대 연결 가능 시간만큼 소켓의 데드라인을 늦춰 데이터를 처리한다. 만약 서버의 데드라인이 되기까지 소켓으로부터 아무것도 읽지 못한 경우 Read 메서드는 타임아웃 에러를 반환한다.
인증서 고정
인증서 고정이란 운영체제의 신뢰하는 인증서 저장소 정보 중 하나 이상의 신뢰하는 인증서 정보를 서버에서 사용하여 선언된 인증서만 사용한 연결 요청에 대해서만 신뢰하는 방식이다. 명시적으로 클라이언트마다 서버의 인증서를 고정하여 높은 보안성이 필요하거나 민감한 데이터를 다루는 경우 사용을 고려할 수 있다.
package tls
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"io"
"os"
"strings"
"testing"
"time"
)
func TestEchoServerTLS(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
serverAddr := "localhost:34443"
maxIdle := time.Second
server := NewTLSServer(ctx, serverAddr, maxIdle, nil)
done := make(chan struct{})
go func() {
defer func() {
done <- struct{}{}
}()
err := server.ListenAndServeTLS("server.crt", "server.key")
if err != nil && !strings.Contains(err.Error(), "use of closed network connection") {
t.Error(err)
return
}
}()
server.Ready()
cert, err := os.ReadFile("server.crt")
if err != nil {
t.Fatal(err)
}
certPool := x509.NewCertPool()
if ok := certPool.AppendCertsFromPEM(cert); !ok {
t.Fatal("failed to append certificate to pool")
}
tlsConfig := &tls.Config{
CurvePreferences: []tls.CurveID{tls.CurveP256},
MinVersion: tls.VersionTLS12,
RootCAs: certPool,
}
conn, err := tls.Dial("tcp", serverAddr, tlsConfig)
if err != nil {
t.Fatal(err)
}
hello := []byte("hello")
_, err = conn.Write(hello)
if err != nil {
t.Fatal(err)
}
b := make([]byte, 1024)
n, err := conn.Read(b)
if err != nil {
t.Fatal(err)
}
if actual := b[:n]; !bytes.Equal(hello, actual) {
t.Fatalf("expected %q; actual %q", hello, actual)
}
time.Sleep(2 * maxIdle)
_, err = conn.Read(b)
if err != io.EOF {
t.Fatal(err)
}
err = conn.Close()
if err != nil {
t.Fatal(err)
}
cancel()
<-done
}
서버의 인증서를 클라이언트에 고정하는 것은 직관적인 코드이다. 인증서를 읽고 새로운 인증서 풀을 생성한 후 인증서 풀을 tls.Config의 RootCAs 필드에 추가한다. 하나 이상의 신뢰하는 인증서를 추가할 수 있다. 해당 TLS 구성 정보를 이용하여 생성된 클라이언트는 등록된 인증서를 사용하거나 인증서로 서명된 인증서를 사용하는 서버만 인증한다.
tls.Dial로 클라이언트에서 고정된 인증서를 사용하여 서버에 연결한다. 서버 측에서도 클라이언트에 고정된 인증서를 사용하기 때문에 정상적으로 서버와 연결이 가능하다. 이를 통해서 InsecureSkipVerify를 true로 설정하는 보안에 취약한 옵션을 사용한 테스트가 아닌 제대로 된 서버 인증 테스트를 진행할 수 있다.
서버와 클라이언트의 상호 TLS 인증
TLS 인증은 서버와 클라이언트에서 상호적으로 이뤄질 수 있다. 서버와 클라이언트 모두 스스로 신원을 입증해야 하는 아무도 신뢰할 수 없는 네트워크 인프라에서 사용된다. 이처럼 상호 간에 인증을 통해 보안성을 높이고 악의적 공격자의 요청을 막을 수 있는 방법에 대해서 알아보자.
인증을 위한 인증서 생성
TLS 인증서는 Extended Key Usage라는 확장된 필드를 통해 인증서의 용도를 명시한다. 서버 인증서는 serverAuth, 클라이언트 인증서는 clientAuth로 구분된다. Go의 TLS 라이브러리는 상호 TLS 인증을 사용할 때 클라이언트 인증서에 clientAuth EKU가 있는지 확인하기 때문에 반드시 포함되어 생성되어야 한다.
따라서 클라이언트 인증서로 사용할 인증서는 앞서 생성한 서버 인증서로는 사용할 수 없어 새롭게 생성해야 한다.
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout client.key \
-out client.crt \
-days 365 \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
-addext "extendedKeyUsage=clientAuth"
상호 TLS 인증 구현 (mutual TLS = mTLS)
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"os"
"strings"
"testing"
)
func caCertPool(caCertFn string) (*x509.CertPool, error) {
caCert, err := os.ReadFile(caCertFn)
if err != nil {
return nil, err
}
certPool := x509.NewCertPool()
if ok := certPool.AppendCertsFromPEM(caCert); !ok {
return nil, errors.New("failed to add certificate to pool")
}
return certPool, nil
}
서버와 클라이언트 모두 caCertPool 함수를 이용하여 X.509 인증서 풀을 생성한다. 인증서 풀은 앞선 인증서 고정 부분에서 확인했듯 신뢰하는 인증서 풀로 사용한다. 클라이언트는 서버의 인증서를, 서버는 클라이언트의 인증서를 추가한다.
// 앞선 코드에 이어서 작성
func TestMutualTLSAuthentication(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
serverPool, err := caCertPool("client.crt")
if err != nil {
t.Fatal(err)
}
cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
t.Fatal(err)
}
서버 생성 전 클라이언트의 인증서를 사용하여 새로운 CA 인증서 풀을 생성한다.
서버의 TLS 설정
// 앞선 코드에 이어서 작성
serverConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
GetConfigForClient: func(chi *tls.ClientHelloInfo) (*tls.Config, error) {
return &tls.Config{
Certificates: []tls.Certificate{cert},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: serverPool,
CurvePreferences: []tls.CurveID{tls.CurveP256},
MinVersion: tls.VersionTLS13,
VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
opts := x509.VerifyOptions{
KeyUsages: []x509.ExtKeyUsage{
x509.ExtKeyUsageClientAuth,
},
Roots: serverPool,
}
ip := strings.Split(chi.Conn.RemoteAddr().String(), ":")[0]
hostNames, err := net.LookupAddr(ip)
if err != nil {
t.Errorf("PTR lookup: %v", err)
}
hostNames = append(hostNames, ip)
for _, chain := range verifiedChains {
opts.Intermediates = x509.NewCertPool()
for _, cert := range chain[1:] {
opts.Intermediates.AddCert(cert)
}
for _, hostName := range hostNames {
opts.DNSName = hostName
_, err = chain[0].Verify(opts)
if err == nil {
return nil
}
}
}
return errors.New("client authentication failed")
},
}, nil
},
}
mTLS 구현 시 인증서 검증 이전에 클라이언트의 연결 정보를 얻어야 한다. 예를 들어 IP 주소, CN 값 등이다. 따라서 서버의 TLS 구성에서 GetConfigForClient 필드를 정의해야 한다. 해당 필드에 정의될 함수는 인증서 검증 이전에 클라이언트 연결 정보를 얻을 수 있는 유일한 방법이다.
서버 TLS config에 포함되는 주요 필드는 다음과 같다.
- TLS 서버 인증서 ([]tls.Certificate{cert})
- Client Auth에 대한 서버 측 정책 (tls.RequireAndVerifyClientCert)
- 인증된 클라이언트 인증서 (serverPool)
- mTLS 인증을 위한 최소 버전 (tls.VersionTLS13)
- 서버 인증 절차 강화를 위한 함수 (VerifyPeerCertificate)
서버 인증 절차를 강화하기 위한 함수에서는 인증서 검사 이후 리프 인증서를 이용하여 클라이언트의 호스트명을 검증하는 작업이 추가되어 보안을 강화한다.
해당 함수에서는 인증서의 EKU가 clientAuth인지 확인하고, chain [0]의 클라이언트 인증서로부터 중간 CA 인증서들을 거쳐 인증서에 명시된 호스트명이 일치하는지 검증한다.
클라이언트 연결 및 통신
// 앞선 코드에 이어서 작성
serverAddr := "localhost:44443"
server := NewTLSServer(ctx, serverAddr, 0, serverConfig)
done := make(chan struct{})
go func() {
defer func() {
done <- struct{}{}
}()
err := server.ListenAndServeTLS("server.crt", "server.key")
if err != nil && !strings.Contains(err.Error(), "use of closed network connection") {
t.Error(err)
return
}
}()
// Client TLS
clientPool, err := caCertPool("server.crt")
if err != nil {
t.Fatal(err)
}
clientCert, err := tls.LoadX509KeyPair("client.crt", "client.key")
if err != nil {
t.Fatal(err)
}
conn, err := tls.Dial("tcp", serverAddr, &tls.Config{
Certificates: []tls.Certificate{clientCert},
CurvePreferences: []tls.CurveID{tls.CurveP256},
MinVersion: tls.VersionTLS13,
RootCAs: clientPool,
})
if err != nil {
t.Fatal(err)
}
hello := []byte("hello")
_, err = conn.Write(hello)
if err != nil {
t.Fatal(err)
}
b := make([]byte, 1024)
n, err := conn.Read(b)
if err != nil {
t.Fatal(err)
}
if actual := b[:n]; !bytes.Equal(hello, actual) {
t.Fatalf("expected %q; actual %q", hello, actual)
}
err = conn.Close()
if err != nil {
t.Fatal(err)
}
cancel()
<-done
}
이후 로직은 서버를 생성하고 클라이언트 측의 TLS 구성을 통해서 서버에 연결하여 상호 TLS 검증을 통해 안전하게 데이터를 전송하는 테스트이다. 서버의 인증서를 사용하여 서버를 실행하고, 클라이언트는 서버의 인증서를 클라이언트의 신뢰할 수 있는 RootCAs필드로 등록한다.
상호 TLS 인증(mTLS)은 일반적인 서버 인증보다 높은 수준의 보안을 제공하므로, 내부 마이크로서비스 간 통신이나 높은 보안이 요구되는 시스템에서 활용할 수 있다.
'School > GNPS' 카테고리의 다른 글
| [GNPS] 챕터 13 로깅 & 메트릭s (0) | 2025.11.22 |
|---|---|
| [GNPS] 챕터 12 데이터 직렬화 (0) | 2025.11.18 |
| [GNPS] 챕터 9 HTTP 서비스 작성! (0) | 2025.11.12 |
| [GNPS] 챕터 8 HTTP 클라이언트 작성 (0) | 2025.11.11 |
| [GNPS] 챕터 7 유닉스 도메인 소켓! (0) | 2025.11.08 |