Post

Go 언어로 구축하는 REST API 서버

Go의 표준 라이브러리만을 사용하여 CRUD(Create, Read, Update, Delete) 기능을 갖춘 RESTful API 서버를 구축하는 방법을 학습함.

Go 언어로 구축하는 REST API 서버

Go 언어로 구축하는 REST API 서버

REST(Representational State Transfer)는 웹 서비스 아키텍처 스타일의 하나로, 자원(Resource)을 URI로 표현하고 해당 자원에 대한 행위(Verb)를 HTTP 메서드(GET, POST, PUT, DELETE 등)로 정의하는 방식임. 지난 시간에 배운 net/http 패키지를 기반으로, 이번에는 완전한 CRUD 기능을 갖춘 REST API 서버를 구축하는 방법을 알아보겠음.

Java Spring Boot vs Go net/http

Java 진영에서 REST API를 개발할 때 사실상의 표준은 Spring Boot (Spring Web MVC)임. Spring Boot는 어노테이션(Annotation)을 사용하여 매우 선언적이고 간결하게 라우팅을 정의할 수 있음.

구분Go (net/http)Java (Spring Boot)설명
라우팅http.HandleFunc + r.Method 스위치@RestController, @GetMapping, @PostMappingGo는 코드 기반으로 명시적으로 라우팅. Spring은 어노테이션 기반으로 선언적으로 라우팅.
JSON 처리encoding/json 패키지Jackson/Gson 라이브러리 (자동)Go는 Marshal, Unmarshal을 수동 호출. Spring은 프레임워크가 자동으로 객체-JSON 변환을 처리.
의존성표준 라이브러리만으로 가능Spring 프레임워크 의존성Go는 외부 의존성 없이도 충분히 구현 가능.

Go의 방식이 더 절차적이고 명시적인 반면, Spring은 더 선언적이고 마법 같은(magic) 부분이 많음. 이번 시간에는 표준 라이브러리만 사용하여 REST API의 동작 원리를 깊이 이해하는 데 초점을 맞추겠음. 관리할 자원은 할 일(Todo) 목록임.


1단계: API 기본 구조 및 조회(Read) 기능 구현

먼저 Todo 항목을 표현할 구조체와 데이터를 임시로 저장할 메모리 내 데이터베이스(슬라이스)를 만듦. 그리고 모든 할 일 목록을 조회하는 GET /todos 엔드포인트를 구현함.

실습 1: 할 일 목록 조회 API

API파라미터리턴값설명
json.Marshal(v interface{})interface{}([]byte, error)Go의 데이터 구조(struct, slice 등)를 JSON 형식의 바이트 슬라이스로 인코딩(마샬링)함.
http.Error(w, error, code)ResponseWriter, string, int없음클라이언트에게 지정된 상태 코드와 에러 메시지를 응답으로 보냄.
(h Header).Set(key, value string)string, string없음응답 헤더에 특정 키와 값을 설정함. 기존에 키가 있으면 덮어씀.
(w ResponseWriter).Write(b []byte)[]byte(int, error)클라이언트에게 응답 본문으로 바이트 슬라이스를 씀.

실행 흐름

sequenceDiagram
    participant Client as "클라이언트 (curl)"
    participant Server as "Go API 서버"
    participant todosHandler as "todosHandler()"
    participant json as "encoding/json"

    Client->>Server: GET /todos 요청
    Server->>todosHandler: 요청 전달
    todosHandler->>json: json.Marshal(todos)
    json-->>todosHandler: JSON 바이트 슬라이스 반환
    todosHandler->>Server: 응답 헤더 설정 및 JSON 데이터 쓰기
    Server-->>Client: HTTP 200 OK (Body: JSON 배열)

실습 파일: 18-REST-API/01-기본-API-구조/main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main

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

// 1. Todo 자원을 나타내는 구조체 정의
type Todo struct {
	ID        int    `json:"id"`
	Title     string `json:"title"`
	Completed bool   `json:"completed"`
}

// 2. 데이터베이스를 대신할 인메모리 슬라이스
var todos = []Todo{
	{ID: 1, Title: "Learn Go", Completed: false},
	{ID: 2, Title: "Build REST API", Completed: false},
}

// 3. /todos 경로의 요청을 처리하는 핸들러
func todosHandler(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	// 4. GET 요청 처리
	case http.MethodGet:
		jsonBytes, err := json.Marshal(todos)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.Write(jsonBytes)
	default:
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
	}
}

func main() {
	http.HandleFunc("/todos", todosHandler)
	fmt.Println("Server starting on port 8080...")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

코드 해설

  1. Todo 구조체: API가 다룰 자원을 Go의 구조체로 모델링함. json:... 형태의 태그는 encoding/json 패키지가 이 구조체를 JSON으로 변환하거나 JSON에서 구조체로 변환할 때 사용할 필드 이름을 지정함.
  2. var todos: 실제 데이터베이스를 대신하여 할 일 목록을 저장하는 슬라이스. 서버가 시작될 때 두 개의 초기 데이터가 추가됨.
  3. todosHandler: /todos 경로로 들어오는 모든 HTTP 요청을 처리하는 함수.
  4. switch r.Method: 요청의 HTTP 메서드(r.Method)에 따라 다른 로직을 수행함. case http.MethodGetGET 요청만을 처리하도록 분기함. json.Marshal을 사용해 todos 슬라이스를 JSON 바이트 배열로 변환하고, w.Write를 통해 클라이언트에 응답함.

2단계: 생성(Create) 기능 구현

이제 POST /todos 요청을 통해 새로운 할 일 항목을 추가하는 기능을 구현함. 클라이언트는 JSON 형식의 Todo 데이터를 요청 본문(Request Body)에 담아 전송해야 함.

실습 2: 새 할 일 항목 추가 (POST)

todosHandlerhttp.MethodPost 케이스를 추가함.

API파라미터리턴값설명
json.NewDecoder(r io.Reader)io.Reader*json.Decoderio.Reader(예: r.Body)에서 JSON 데이터를 읽는 디코더를 생성함.
(dec *Decoder).Decode(v interface{})interface{}errorJSON 데이터를 읽어 Go 데이터 구조(주로 포인터)에 디코딩(언마샬링)함.
(w ResponseWriter).WriteHeader(code int)int없음HTTP 응답 상태 코드를 설정함. w.Write 호출 전에 사용해야 함.
json.NewEncoder(w io.Writer)io.Writer*json.Encoderio.Writer(예: w)에 JSON 데이터를 쓰는 인코더를 생성함.
(enc *Encoder).Encode(v interface{})interface{}errorGo 데이터 구조를 JSON으로 인코딩하여 스트림에 직접 씀.

실행 흐름

sequenceDiagram
    participant Client as "클라이언트 (curl, Postman)"
    participant Server as "Go API 서버"

    Client->>Server: POST /todos (Body: {"title":"Test"})
    Server->>Server: JSON 디코딩 -> newTodo
    Server->>Server: 새 ID 할당, todos 슬라이스에 추가
    Server-->>Client: HTTP 201 Created (Body: 생성된 Todo 객체)

실습 파일: 18-REST-API/02-POST-새-항목-추가/main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// (이전 실습의 Todo 구조체, todos 슬라이스는 동일)
// ...
var nextID = 3

func todosHandler(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		// ... (이전 실습과 동일)

	// 1. POST 요청 처리
	case http.MethodPost:
		var newTodo Todo
		// 2. 요청 본문의 JSON을 newTodo 구조체로 디코딩
		if err := json.NewDecoder(r.Body).Decode(&newTodo); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		// 3. 새 ID를 할당하고 전역 ID 값을 1 증가
		newTodo.ID = nextID
		nextID++
		// 4. 새 Todo를 슬라이스에 추가
		todos = append(todos, newTodo)

		// 5. 성공 응답 전송
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusCreated)
		json.NewEncoder(w).Encode(newTodo)

	default:
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
	}
}
// ...

코드 해설

  1. case http.MethodPost: todosHandlerswitch 문에 POST 메서드를 처리하는 case를 추가함.
  2. json.NewDecoder(r.Body).Decode(&newTodo): 요청의 본문(r.Body)으로부터 JSON 데이터를 읽어 newTodo 구조체 인스턴스에 채워넣음(디코딩). r.Bodyio.Reader이므로 NewDecoder의 인자로 사용될 수 있음.
  3. newTodo.ID = nextID: 새로운 할 일 항목의 ID를 서버에서 직접 할당함. nextID는 마지막으로 사용된 ID를 추적하는 간단한 카운터.
  4. todos = append(todos, newTodo): 완성된 newTodotodos 슬라이스에 추가하여 데이터를 저장함.
  5. w.WriteHeader(http.StatusCreated): REST API 규칙에 따라, 새로운 리소스가 성공적으로 생성되었음을 알리는 201 Created 상태 코드를 응답 헤더에 씀. 그 후 json.NewEncoder(w).Encode(newTodo)를 통해 생성된 객체를 다시 클라이언트에게 응답으로 보내줌.

3단계: 수정(Update) 및 삭제(Delete) 기능 구현

마지막으로 특정 ID를 가진 자원을 수정(PUT)하고 삭제(DELETE)하는 기능을 구현함. 이를 위해서는 /todos/{id}와 같은 형태의 URL에서 {id} 부분을 파싱해야 함. 표준 라이브러리만으로는 이 작업이 다소 번거롭기 때문에, 실제 프로젝트에서는 gorilla/muxchi 같은 라우팅 라이브러리를 사용하는 것이 일반적임. 여기서는 strings.TrimPrefix을 사용하여 간단히 구현해 보겠음.

실습 3: 특정 할 일 수정 및 삭제

/todos/ 경로를 처리할 새로운 핸들러 todoHandler를 추가함.

API파라미터리턴값설명
strings.TrimPrefix(s, prefix string)string, stringstring문자열 sprefix로 시작하면, 그 부분을 제외한 나머지 문자열을 반환함.
strconv.Atoi(s string)string(int, error)문자열을 정수로 변환함. Atoi는 “ASCII to Integer”의 약자.
http.StatusNoContent(상수)int204 No Content 상태 코드를 나타내는 상수. 성공적으로 처리했지만 반환할 콘텐츠가 없을 때 사용.

실행 흐름 (PUT 요청)

sequenceDiagram
    participant Client as "클라이언트"
    participant Server as "Go API 서버"
    participant todoHandler as "todoHandler()"

    Client->>Server: PUT /todos/1 (Body: JSON)
    Server->>todoHandler: 요청 전달
    todoHandler->>todoHandler: URL에서 ID '1' 파싱
    todoHandler->>todoHandler: 요청 Body 디코딩
    todoHandler->>todoHandler: ID '1'을 가진 Todo 검색 및 수정
    Server-->>Client: HTTP 200 OK (Body: 수정된 Todo)

실습 파일: 18-REST-API/03-PUT-DELETE-항목-수정-삭제/main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// ... (이전 코드와 거의 동일)

// 1. /todos/{id} 경로를 처리할 핸들러
func todoHandler(w http.ResponseWriter, r *http.Request) {
	// 2. URL 경로에서 ID를 추출
	idStr := strings.TrimPrefix(r.URL.Path, "/todos/")
	id, err := strconv.Atoi(idStr)
	if err != nil {
		http.Error(w, "Invalid ID", http.StatusBadRequest)
		return
	}

	switch r.Method {
	// 3. PUT 요청 처리 (수정)
	case http.MethodPut:
		// ... (요청 Body 디코딩)
		for i := range todos {
			if todos[i].ID == id {
				// ... (항목 업데이트)
				return
			}
		}
		http.Error(w, "Todo not found", http.StatusNotFound)

	// 4. DELETE 요청 처리 (삭제)
	case http.MethodDelete:
		for i, t := range todos {
			if t.ID == id {
				todos = append(todos[:i], todos[i+1:]...)
				w.WriteHeader(http.StatusNoContent)
				return
			}
		}
		http.Error(w, "Todo not found", http.StatusNotFound)

	default:
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
	}
}

func main() {
	http.HandleFunc("/todos", todosHandler)
	// 5. /todos/ 경로에 대한 핸들러 등록
	http.HandleFunc("/todos/", todoHandler)

	fmt.Println("Server starting on port 8080...")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

코드 해설

  1. todoHandler: /todos/{id} 형태의 경로를 전담하여 처리할 새로운 핸들러.
  2. strings.TrimPrefixstrconv.Atoi: URL r.URL.Path (예: /todos/1)에서 /todos/ 부분을 제거하여 ID("1")를 얻고, 이를 정수로 변환함. 이는 표준 라이브러리만으로 경로 파라미터를 처리하는 간단한 방법임.
  3. case http.MethodPut: PUT 요청을 처리. 요청 본문을 디코딩하여 updatedTodo를 만들고, for 루프를 돌며 ID가 일치하는 기존 항목을 찾아 필드 값을 업데이트함.
  4. case http.MethodDelete: DELETE 요청을 처리. ID가 일치하는 항목을 찾은 뒤, append(todos[:i], todos[i+1:]...)라는 슬라이스 트릭을 사용하여 해당 요소를 슬라이스에서 제거함. 성공 시에는 본문이 없는 204 No Content 상태 코드를 반환함.
  5. http.HandleFunc("/todos/", ...): /todos/로 시작하는 모든 경로(예: /todos/1, /todos/2/anything)가 todoHandler로 전달되도록 등록함. 이 방식은 간단하지만, 더 복잡한 라우팅 규칙이 필요하다면 전용 라우터를 사용하는 것이 좋음.

이제 Go 표준 라이브러리만으로 완전한 CRUD REST API가 완성되었음.

This post is licensed under CC BY 4.0 by the author.