
쌩초보도 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) 서버를 조금만 더 개선하면 훨씬 다양한 기능들을 구현할 수 있답니다.
예를 들어, 요청 내용을 살펴보고 특정 요청을 막거나, 캐싱 기능을 추가하는 등의 멋진 일들을 해볼 수 있겠죠?
'Go' 카테고리의 다른 글
| 고(Go)에서 특정 폴더를 패키지 취급 안 받게 하는 법? 이렇게 해보세요! (0) | 2025.05.20 |
|---|---|
| 고(Go)에서 go get으로 파일 제외하기? 빌드 제약으로 똑똑하게 관리하는 법! (0) | 2025.05.20 |
| Golang (고랭) 타이머 정밀도: 얼마나 정확할 수 있을까요? (6) | 2025.05.17 |
| Hugo (휴고) 심층 분석: 이상적인 정적 블로그 프레임워크 (3) | 2025.05.17 |
| Go (고): 다양한 시나리오에서 RWMutex (알더블유뮤텍스)와 Mutex (뮤텍스)의 성능 비교 (0) | 2025.05.17 |