쌩초보도 OK! 고(Go) 언어 웹 서버 직접 만들기 A to Z

쌩초보도 OK! 고(Go) 언어 웹 서버 직접 만들기 A to Z

안녕하세요! 오늘은 고(Go) 언어를 사용해서 웹 서버를 처음부터 만들어보는 여러 가지 방법에 대해 알아보려고 한답니다.


혹시 웹 서버와 HTTP 서버가 뭐가 다른지 궁금하신 적 있나요? 바로 그 이야기부터 시작해 볼까요?

웹 서버와 HTTP 서버, 뭐가 다를까요?

HTTP 서버는 이름에서도 알 수 있듯이 HTTP 프로토콜을 지원하는 서버를 말하는데요.

반면에 웹 서버는 HTTP 프로토콜은 기본이고, 그 외에 다른 네트워크 프로토콜도 지원할 수 있는 좀 더 넓은 개념이랍니다.

이번 글에서는 특별히 고랭(Golang)의 공식 패키지를 활용해서 웹 서버를 만드는 몇 가지 대표적인 방법들을 소개해 드릴 예정입니다.

세상에서 제일 간단한 HTTP 서버 만들기

웹 서버를 구현하는 방법 중 이게 아마 가장 쉬운 방법일 텐데요.

아래 예제 코드를 한번 보실까요?

package main

import (
    "fmt"
    "log"
    "net/http"
)

// myHandler 함수는 웹 요청을 처리하는 핸들러입니다.
// http.ResponseWriter는 클라이언트에게 응답을 보내는 역할을 합니다.
// *http.Request는 클라이언트로부터 받은 요청 정보를 담고 있습니다.
func myHandler(w http.ResponseWriter, r *http.Request) {
    // Fprintf는 포맷에 맞춰 문자열을 w(ResponseWriter)에 씁니다.
    fmt.Fprintf(w, "Hello there!\n") // "Hello there!" 메시지를 응답으로 보냅니다.
}

func main() {
    // http.HandleFunc 함수는 특정 경로("/")로 오는 요청을 myHandler 함수가 처리하도록 라우팅합니다.
    http.HandleFunc("/", myHandler) 

    // http.ListenAndServe 함수는 지정된 주소(":8080")에서 HTTP 연결을 기다리고 처리합니다.
    // 두 번째 인자가 nil이면 기본 서브먹스(ServeMux)를 사용합니다.
    // 만약 서버 시작에 실패하면 log.Fatal이 에러를 출력하고 프로그램을 종료합니다.
    log.Fatal(http.ListenAndServe(":8080", nil)) 
}



이 프로그램을 실행한 뒤에, 다른 터미널 창을 열어서 curl localhost:8080 명령어를 입력하거나 웹 브라우저 주소창에 localhost:8080을 직접 입력해 보세요.

그러면 "Hello there!"라는 결과가 화면에 나타나는 것을 볼 수 있을 겁니다.

ListenAndServe 함수는 연결을 듣고 처리하는 역할을 하는데요.

내부적으로는 각 연결마다 새로운 고루틴(goroutine)을 시작해서 요청을 처리한답니다.

하지만 이게 항상 가장 좋은 방법은 아닌데요.

운영체제를 공부해 본 친구들이라면 프로세스나 스레드를 전환하는 데 드는 비용이 꽤 크다는 것을 알고 있을 겁니다.

고루틴(goroutine)은 사용자 수준의 가벼운 스레드라서 전환할 때 사용자 모드와 커널 모드 사이의 전환까지는 일어나지 않지만, 고루틴(goroutine)의 수가 아주 많아지면 전환 비용을 무시할 수 없게 된답니다.

더 나은 방법은 고루틴 풀(goroutine pool)을 사용하는 것인데, 이 글에서는 일단 자세히 다루지는 않겠습니다.

좀 더 유연하게! 핸들러(Handler) 인터페이스 활용법

바로 앞에서 본 방법은 확장성 면에서 조금 아쉬운 점이 있는데요.

예를 들어, 서버의 타임아웃(timeout) 시간을 설정하는 것 같은 세밀한 조정이 불가능하답니다.

이럴 때 우리만의 맞춤 서버를 만들어서 해결할 수 있습니다.

고(Go) 언어의 http 패키지에는 Server라는 구조체가 정의되어 있는데요.

주요 필드들을 살펴보면 다음과 같습니다.

type Server struct {
    Addr         string        // 서버가 리스닝할 TCP 주소 (예: ":8080")
    Handler      Handler       // 요청을 처리할 핸들러 (nil이면 DefaultServeMux 사용)
    ReadTimeout  time.Duration // 요청 전체를 읽는 데 걸리는 최대 시간
    WriteTimeout time.Duration // 응답 전체를 작성하는 데 걸리는 최대 시간
    TLSConfig    *tls.Config   // HTTPS를 위한 TLS 설정 (암호화 통신)
    // ... 기타 여러 유용한 설정들이 있습니다.
}




여기서 Handler는 인터페이스(interface)인데요.

다음과 같이 정의되어 있습니다.

type Handler interface {
    // ServeHTTP는 HTTP 요청을 처리하기 위해 호출되는 메서드입니다.
    ServeHTTP(ResponseWriter, *Request) 
}




그래서 우리가 Handler 인터페이스의 ServeHTTP 메서드만 잘 구현하면, 우리 입맛에 맞는 서버를 만들 수 있는 것이죠.

아래 예제 코드를 통해 확인해 볼까요?

package main

import (
    "fmt" // 응답 내용 작성을 위해 fmt 패키지를 사용합니다.
    "log"
    "net/http"
    "time"
)

// myHandler라는 빈 구조체를 정의합니다. 이 구조체에 메서드를 연결할 겁니다.
type myHandler struct{}

// myHandler 구조체에 ServeHTTP 메서드를 구현해서 http.Handler 인터페이스를 만족시킵니다.
func (h myHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // 여기에 우리가 원하는 구체적인 요청 처리 로직을 작성합니다.
    // 예를 들어, 요청 경로(URL Path)에 따라 다른 응답을 보낼 수 있습니다.
    if r.URL.Path == "/" {
        fmt.Fprintf(w, "Welcome to our custom server!\n")
    } else if r.URL.Path == "/hello" {
        fmt.Fprintf(w, "Hello from the custom handler universe!\n")
    } else {
        // 정의되지 않은 경로로 요청이 오면 404 Not Found 응답을 보냅니다.
        http.NotFound(w, r) 
    }
}

func main() {
    // http.Server 구조체 인스턴스를 생성하고 원하는 대로 설정합니다.
    server := http.Server{
        Addr:         ":8080",             // 서버가 실행될 주소와 포트
        Handler:      myHandler{},         // 위에서 정의한 우리만의 핸들러를 지정합니다.
        ReadTimeout:  3 * time.Second,   // 요청을 읽는 데 최대 3초까지 기다립니다.
        WriteTimeout: 5 * time.Second,   // 응답을 쓰는 데 최대 5초까지 기다립니다.
        // IdleTimeout, MaxHeaderBytes 등 다양한 설정을 추가할 수 있습니다.
    }

    // 설정된 서버를 시작합니다. ListenAndServe 메서드는 에러가 발생하면 해당 에러를 반환합니다.
    log.Println("Starting custom server on :8080")
    log.Fatal(server.ListenAndServe())
}



더 깊숙이! 연결(conn) 직접 다루기

가끔은 HTTP보다 더 낮은 수준에서, 즉 네트워크 연결(connection) 자체를 직접 다뤄야 할 필요가 생기는데요.

이럴 때는 고(Go) 언어의 net 패키지를 사용할 수 있습니다.

서버 쪽 코드를 간단하게 구현해 보면 다음과 같답니다.

package main

import (
    "bufio" // 버퍼를 이용한 I/O를 위해 사용합니다.
    "fmt"   // 간단한 응답 출력을 위해 사용합니다.
    "log"
    "net" // 네트워크 기능을 사용하기 위한 핵심 패키지입니다.
)

// handleConn 함수는 개별 클라이언트 연결(net.Conn)을 처리합니다.
func handleConn(conn net.Conn) {
    // 함수가 종료될 때 연결이 확실히 닫히도록 defer를 사용합니다.
    defer conn.Close() 

    log.Printf("New connection established from: %s", conn.RemoteAddr().String())

    // 클라이언트로부터 데이터를 읽기 위한 버퍼 리더를 생성합니다.
    reader := bufio.NewReader(conn)
    // 간단하게 한 줄만 읽어봅니다 (HTTP 요청의 첫 줄처럼).
    requestLine, err := reader.ReadString('\n')
    if err != nil {
        log.Println("Error reading from connection:", err)
        return
    }
    log.Printf("Received from client: %s", requestLine)

    // 간단한 응답을 클라이언트에게 다시 보냅니다.
    // 실제 HTTP 서버라면 HTTP 프로토콜 형식에 맞춰 응답해야 합니다.
    responseMessage := "Hello from the raw TCP server!\n"
    _, err = conn.Write([]byte(responseMessage))
    if err != nil {
        log.Println("Error writing to connection:", err)
    }
    log.Printf("Sent response to: %s", conn.RemoteAddr().String())
}

func main() {
    // "tcp" 프로토콜과 ":8080" 포트에서 연결을 기다리는 리스너(listener)를 생성합니다.
    listener, err := net.Listen("tcp", ":8080")
    if err != nil {
        // 리스너 생성에 실패하면 에러를 기록하고 프로그램을 종료합니다.
        log.Fatalf("Failed to start listener on :8080, error: %v", err)
    }
    // 프로그램이 종료될 때 리스너도 확실히 닫히도록 defer를 사용합니다.
    defer listener.Close() 
    log.Println("Raw TCP server listening on :8080")

    // 무한 루프를 돌면서 새로운 클라이언트 연결을 계속해서 받습니다.
    for {
        // listener.Accept()는 새로운 연결이 들어올 때까지 여기서 실행을 멈추고 기다립니다(blocking).
        // 새로운 연결이 들어오면 해당 연결(conn)과 에러(err)를 반환합니다.
        conn, err := listener.Accept()
        if err != nil {
            // 연결 수락 중에 에러가 발생하면 로그를 남기고 다음 연결을 기다립니다.
            // (실제 운영 환경에서는 에러 종류에 따라 다른 처리가 필요할 수 있습니다.)
            log.Println("Failed to accept connection, error:", err)
            continue // 다음 반복으로 넘어갑니다.
        }
        // 각 연결을 독립적으로 처리하기 위해 새로운 고루틴(goroutine)을 시작합니다.
        // 이렇게 하면 여러 클라이언트의 요청을 동시에 처리할 수 있습니다.
        go handleConn(conn) 
    }
}




이 예제에서는 설명을 쉽게 하기 위해 각 연결마다 고루틴(goroutine)을 하나씩 시작해서 처리하도록 했는데요.

실제 사용할 때는 앞서 언급했듯이 고루틴 풀(goroutine pool)을 사용하는 것이 보통 더 좋은 선택이랍니다.

클라이언트에게 보낼 응답 정보는 그냥 conn에 다시 써주기만 하면 됩니다.

응용편: 간단한 HTTP 프록시(Proxy) 서버 만들기

지금까지 웹 서버를 만드는 여러 가지 방법들을 살펴봤는데요.

이제 배운 내용을 바탕으로 간단한 HTTP 프록시(Proxy) 서버를 만들어 볼까요?

고(Go) 언어로 프록시(Proxy)를 만드는 건 정말 간단하답니다.

그냥 연결을 전달해주기만 하면 되거든요.

package main

import (
    "io"  // 데이터 복사를 위해 io 패키지를 사용합니다.
    "log"
    "net" // 네트워크 연결을 위해 net 패키지를 사용합니다.
)

// handleConn 함수는 클라이언트(from)와 실제 목적지 서버(to) 사이의 데이터를 중계합니다.
func handleConn(clientConn net.Conn) {
    // 이 함수가 종료될 때 클라이언트 연결이 확실히 닫히도록 합니다.
    defer clientConn.Close() 

    // 프록시 서버가 요청을 전달할 실제 목적지 서버 주소입니다.
    // 예를 들어, 로컬에서 8001 포트로 실행 중인 다른 웹 서버를 지정할 수 있습니다.
    targetServerAddr := "localhost:8001" 
    log.Printf("Incoming connection from %s, attempting to proxy to %s", clientConn.RemoteAddr().String(), targetServerAddr)

    // 목적지 서버와 TCP 연결을 설정(Dial)합니다.
    targetConn, err := net.Dial("tcp", targetServerAddr) 
    if err != nil {
        // 목적지 서버 연결에 실패하면 에러를 기록하고 함수를 종료합니다.
        log.Printf("Failed to connect to target server %s: %v", targetServerAddr, err)
        return
    }
    // 이 함수가 종료될 때 목적지 서버와의 연결도 확실히 닫히도록 합니다.
    defer targetConn.Close() 
    log.Printf("Successfully connected to target server %s", targetServerAddr)

    // 양방향 데이터 복사가 완료되었는지 확인하기 위한 채널입니다.
    // 두 개의 고루틴이 각각 데이터 복사를 마치면 신호를 보낼 겁니다.
    done := make(chan struct{})

    // 고루틴 1: 목적지 서버(targetConn)에서 온 응답을 클라이언트(clientConn)에게 복사합니다.
    go func() {
        // io.Copy는 한쪽 연결이 닫히거나 에러가 발생할 때까지 데이터를 복사합니다.
        // 복사된 바이트 수와 에러를 반환합니다.
        if _, err := io.Copy(clientConn, targetConn); err != nil {
            // EOF(End Of File) 에러는 정상적인 연결 종료일 수 있으므로, 그 외의 에러만 로깅합니다.
            if err != io.EOF {
                log.Printf("Error copying data from target to client: %v", err)
            }
        }
        done <- struct{}{} // 작업 완료 신호 전송
    }()

    // 고루틴 2: 클라이언트(clientConn)가 보낸 요청을 목적지 서버(targetConn)에게 복사합니다.
    go func() {
        if _, err := io.Copy(targetConn, clientConn); err != nil {
            if err != io.EOF {
                log.Printf("Error copying data from client to target: %v", err)
            }
        }
        done <- struct{}{} // 작업 완료 신호 전송
    }()

    // 두 개의 데이터 복사 고루틴이 모두 완료될 때까지 기다립니다.
    <-done 
    <-done 
    log.Printf("Proxying finished for connection from %s", clientConn.RemoteAddr().String())
}

func main() {
    // 프록시 서버가 리스닝할 주소와 포트입니다. (예: 클라이언트는 이 주소로 접속)
    proxyServerAddr := ":8080"
    listener, err := net.Listen("tcp", proxyServerAddr)
    if err != nil {
        log.Fatalf("Failed to start proxy listener on %s: %v", proxyServerAddr, err)
    }
    defer listener.Close()
    log.Printf("Simple HTTP Proxy server listening on %s", proxyServerAddr)

    for {
        conn, err := listener.Accept()
        if err != nil {
            log.Println("Failed to accept incoming connection:", err)
            continue
        }
        // 각 클라이언트 연결을 처리하기 위해 새로운 고루틴을 시작합니다.
        go handleConn(conn) 
    }
}




이 프록시(Proxy) 서버를 조금만 더 개선하면 훨씬 다양한 기능들을 구현할 수 있답니다.

예를 들어, 요청 내용을 살펴보고 특정 요청을 막거나, 캐싱 기능을 추가하는 등의 멋진 일들을 해볼 수 있겠죠?