본문으로 건너뛰기

© 2026 Molayo

Qiita헤드라인2026. 06. 15. 23:37

# 42일 만에 백엔드 엔지니어의 기초를 완전히 이해하기 #2 - Go 기초 편 (전편)

요약

백엔드 엔지니어 기초를 다지기 위한 Go 언어 입문 가이드입니다. Go 개발 환경 구축부터 기본 문법, HTTP 서버를 통한 JSON 응답 구현까지 단계별로 설명합니다.

핵심 포인트

  • Go 개발 환경 설정 및 go mod를 이용한 프로젝트 초기화 방법
  • 변수, 구조체, 함수, map 등 Go의 핵심 기본 문법 학습
  • package main과 import의 개념 및 Go의 패키지 관리 방식 이해
  • HTTP 서버를 구축하여 JSON 데이터를 반환하는 API 개발 기초

Go 언어의 환경 구축부터 시작하여, HTTP 서버를 실행하고, curl로 JSON 응답을 받는 것까지 다룹니다.

이 글을 마치면 다음과 같은 것들을 할 수 있게 됩니다.

  • Go 개발 환경을 설정할 수 있다
  • 변수·구조체(Struct)·함수·map의 기본 문법을 사용할 수 있다
  • Go로 HTTP 서버를 구축하여 JSON을 반환하는 API를 만들 수 있다
  • 위 내용을 "왜 그렇게 작성하는지"를 포함하여 설명할 수 있다

공식 사이트(https://go.dev/dl/)에서 최신 버전을 다운로드하여 설치합니다.

aptsnap으로도 설치할 수 있지만, 버전이 오래된 경우가 많으므로 공식 사이트에서의 설치를 권장합니다.

# 설치 후 확인
go version
# → go version go1.24.1 linux/amd64 와 같이 표시되면 OK
mkdir -p ~/go-practice/day2
cd ~/go-practice/day2
go mod init day2

go mod init은 "여기가 Go 프로젝트의 루트입니다"라고 선언하는 명령어입니다. 실행하면 go.mod라는 파일이 생성됩니다.

이 파일이 있는 디렉토리가 하나의 프로젝트로서 독립합니다. 즉, 다른 디렉토리에 있는 Go 프로젝트(예를 들어 업무용 코드)와는 일절 간섭하지 않습니다.

~/work/my-project/ ← go.mod (업무용. 별도 프로젝트)
~/go-practice/day2/ ← go.mod (학습용. 별도 프로젝트)

안심하고 학습용 코드를 작성해 주세요.

먼저 첫 번째 프로그램을 만들어 실행해 봅시다. 아래 코드를 main.go로 저장해 주세요.

package main
import "fmt"
func main() {
...

실행:

go run main.go
# → Hello, World!

단 6줄만으로 프로그램이 동작했습니다. 이 6줄 안에는 Go 프로그램에 필요한 4가지 요소가 모두 들어있습니다. 하나씩 해설하겠습니다.

package main

"이 파일은 main이라는 그룹에 속해 있습니다"라는 선언입니다.

Go에서는 모든 파일이 반드시 어딘가의 패키지(Package)에 속해야 합니다. 첫 번째 줄에 반드시 작성합니다.

main은 특별한 패키지 이름으로, "이 프로그램은 실행 가능합니다"라는 의미를 가집니다. go run으로 실행하려면 package main인 것이 필수입니다.

비유하자면 회사의 부서와 같습니다.

package main → 실행 부대 (프로그램의 입구가 있는 부서)
package database → DB 담당 부서
package auth → 인증 담당 부서

지금 단계에서는 프로그램 전체가 1개의 파일이므로, 깊게 생각하지 말고 "첫 번째 줄에는 package main이라고 적는다"라고 기억해 주세요. 프로젝트가 커져서 파일을 분할할 때 다른 패키지 이름이 등장하게 됩니다.

import "fmt"

"fmt라는 도구 상자를 사용하겠습니다"라는 선언입니다.

Go에는 처음부터 많은 도구 상자(Package)가 준비되어 있습니다. 사용하고 싶은 것을 import로 지정합니다. 사용하지 않는 패키지를 import하면 컴파일 에러(Compile Error)가 발생합니다. Go는 낭비를 허용하지 않는 언어입니다.

하나만 사용하는 경우에는 그대로 작성합니다.

import "fmt"

여러 개를 사용하는 경우에는 괄호로 묶습니다.

import (
"encoding/json" // JSON을 다루는 도구 상자
"fmt" // 화면 표시·문자열 포맷팅을 위한 도구 상자
...

이 글에서 사용하는 주요 도구 상자는 다음 3가지입니다.

패키지할 수 있는 일
fmt화면에 문자를 표시하거나, 문자열을 포맷팅함
encoding/jsonGo의 데이터와 JSON을 상호 변환함
net/httpHTTP 서버를 만들거나, HTTP 요청을 처리함
func main() {
// 여기에 처리를 작성한다
}

프로그램의 입구입니다. go run main.go를 실행하면, Go는 이 func main() 내부를 위에서부터 순서대로 실행해 나갑니다.

func main() {
fmt.Println("1番目に実行される")
fmt.Println("2番目に実行される")
...

func main()

의 외부에 작성한 코드는 호출되기 전까지 동작하지 않습니다. 현관과 같은 것입니다. 집에 들어가면 먼저 현관을 통과하죠. 프로그램도 우선 func main()

에서 시작합니다.

package main

func main()

이 두 가지가 갖춰져야 비로소 실행 가능한 프로그램이 됩니다.

fmt.Println("Hello, World!")

화면에 문자를 표시하고 줄바꿈을 하는 명령어입니다. Println은 "Print Line" (한 줄 출력)의 약자입니다.

fmt.Println("こんにちは") // → こんにちは
fmt.Println(123) // → 123
fmt.Println("A", "B", "C") // → A B C (공백으로 구분하여 표시)

fmt는 도구 상자의 이름이고, Println은 그 안의 도구 이름입니다. "fmt라는 도구 상자 안의 PrintLine이라는 도구를 사용한다"는 의미가 됩니다.

Go는 Python이나 JavaScript와 달리, **타입이 있는 언어 (Typed Language)**입니다. 변수를 만들 때 "이 변수에는 문자열이 들어간다", "이 변수에는 숫자가 들어간다"라고 사전에 결정합니다.

타입을 결정함으로써, 잘못된 데이터를 넣으려고 할 때 에러로 알려줍니다.

최소한으로 기억해야 할 타입은 4개뿐입니다.

var name string = "washi" // 문자열 (string)
var age int = 30 // 정수 (int)
var score float64 = 3.14 // 소수 (float64)
...

Go에는 변수를 만드는 방법이 두 가지 있습니다.

방법 1: var로 타입을 명시하여 선언하기

var name string = "washi"
var age int = 30

"name이라는 변수를 만듭니다. 타입은 string입니다. 값은 "washi"입니다."라고 정중하게 쓰는 방식입니다.

방법 2: :=로 단축하기

name := "washi"
age := 30

:=를 사용하면, Go가 우변의 값을 보고 타입을 자동으로 추론 (Type Inference)해 줍니다. "washi"는 문자열이니까 string 타입, 30은 정수니까 int 타입, 이런 식입니다.

하고 있는 일은 방법 1과 같지만, 짧게 쓸 수 있습니다. Go 개발자들은 거의 이 방식을 사용합니다.

단, :=는 func 안에서만 사용할 수 있습니다.

var global string = "OK" // ← func 외부에서도 OK
func main() {
local := "OK" // ← func 내부에서만 사용 가능
...

사용하지 않습니다. 구조체(Struct)에서 :=가 등장하는 것처럼 보일 수도 있지만, 자세히 보면 다릅니다.

// 이것은 구조체의 "정의". :=는 사용하지 않음
type User struct {
Name string // ← 필드명과 타입을 적고 있을 뿐
...

:=가 등장하는 것은 항상 func 내부입니다. 구조체의 정의 부분(type ... struct)에서는 :=를 일절 사용하지 않습니다.

:=로 만든 변수는 해당 func 내부에서만 존재합니다. 이것이 스코프 (Scope, 변수의 생존 범위)입니다.

func main() {
name := "washi" // ← main 내부에서만 사용 가능
fmt.Println(name) // ← OK
...

여러 함수에서 사용하고 싶은 변수는 var를 사용하여 func 외부에 작성합니다.

var name string = "washi" // ← func 외부. 어떤 함수에서도 액세스 가능
func main() {
fmt.Println(name) // ← OK
...
구분var (func 외부)var (func 내부):= (func 내부만)
사용 가능한 곳어디서나func 내부func 내부만
스코프프로그램 전체해당 func 내부만해당 func 내부만
타입 지정필요생략 가능불필요 (자동 추론)

main.go

를 다음과 같이 다시 작성하여 실행해 보세요.

package main
import "fmt"
func main() {
...
go run main.go
# → washi 30 Infrastructure Engineer

name

age

var

로 선언되었고, job

:=

로 선언되었습니다. 둘 다 변수를 만들고 있다는 점에서는 동일합니다.

사용자의 정보를 변수로 관리할 경우, 각각 따로 있으면 관리가 어렵습니다.

nameA := "washi"
ageA := 30
nameB := "taro"
...

구조체 (Struct)를 사용하면, 서로 다른 타입의 데이터를 하나로 묶을 수 있습니다.

type User struct {
Name string
Age int
...
type User struct {
Name string
Age int
...

이것은 "User 타입이라는 새로운 타입을 만들겠습니다"라는 선언입니다. string이나 int가 Go에 기본적으로 준비되어 있는 타입인 것에 반해, User는 직접 만든 타입입니다.

한 번 정의해 두면, string이나 int와 똑같이 사용할 수 있습니다.

var u User // 변수의 타입으로 사용할 수 있음
func greet(u User) string {} // 함수의 인자(Argument) 타입으로 사용할 수 있음
// 정의에 따라 데이터를 생성
user := User{Name: "washi", Age: 30}
// 필드(Field)에 접근 (점(.)으로 연결)
...

이 부분은 초보자가 혼란스러워하기 쉬운 포인트입니다.

구조체의 필드를 늘리고 싶다면, 소스 코드의 정의 자체를 수정해야 합니다.

// 최초의 정의 (필드 2개)
type User struct {
Name string
...

이것은 "프로필 카드의 인쇄 형식을 다시 만들어서 칸을 늘렸다"는 의미입니다.

반면, 프로그램 실행 중에 정의에 없는 필드를 나중에 추가할 수는 없습니다.

user := User{Name: "washi", Age: 30}
user.Hobby = "Golf" // ← 에러! User 타입의 정의에 Hobby라는 것은 존재하지 않음

나중에 자유롭게 키(Key)를 추가하고 싶다면, 후술할 map을 사용합니다.

처리를 하나로 묶어 이름을 붙인 것입니다. 같은 처리를 여러 번 작성하지 않아도 됩니다.

func 함수명(인자명 인자타입) 반환타입 {
처리
return 반환값
...

구체적인 예를 살펴보겠습니다.

func greet(u User) string {
return "Hello, " + u.Name + "!"
}

이 한 줄에 여러 정보가 담겨 있으므로, 분해해 보겠습니다.

func greet(u User) string {
// ↑ ↑ ↑ ↑
// | | | 반환 타입 (이 함수는 string 타입의 값을 반환함)
...

greet

→ 함수의 이름. 원하는 이름을 붙일 수 있음 -
u User

→ "User 타입의 데이터를 받아서, 함수 내부에서는 u라는 이름으로 사용하겠다"는 의미 -
string

→ "이 함수는 문자열을 반환한다"는 선언 -
return

→ 호출한 곳으로 값을 반환

u는 단순한 이름이므로 xperson 등 무엇이든 상관없습니다. 다만, Go의 관습(Convention)에서는 타입명의 첫 글자를 소문자로 바꾼 것(Useru)을 사용합니다.

func add(a int, b int) int {
return a + b
}

a int

→ "int 타입의 값을 받아서 a라고 부름" -
b int

→ "int 타입의 값을 받아서 b라고 부름" -
마지막
int

→ "반환하는 값은 int 타입"

호출할 때는 다음과 같이 작성합니다.

result := add(10, 20)
fmt.Println(result) // → 30

main.go

를 다음과 같이 다시 작성하여 실행해 보세요.

package main
import "fmt"
type User struct {
...
go run main.go
# → Hello, washi!

처리 흐름을 따라가 봅시다.

1. user := User{Name: "washi", Age: 30}
→ User 타입의 데이터를 만들어 user에 넣음
2. message := greet(user)
...

함수 안에서 만든 변수는 그 함수 안에서만 존재합니다. 이를 스코프 (Scope)라고 합니다.

func userHandler(w http.ResponseWriter, r *http.Request) {
user := User{Name: "washi", Age: 30}
// ↑ 이 user는 이 함수 안에서만 존재함
...

비유하자면, 각 함수는 서로 다른 방입니다. 방 A에서 작성한 메모는 방 B에서 보이지 않습니다. 방 B에서도 동일한 데이터가 필요하다면, 방 B에서 새로 만들어야 합니다.

"방금 전 함수에서 값을 넣었으니 재사용할 수 있지 않을까?"라고 생각할 수도 있지만, 재사용할 수 없습니다. 함수가 종료되면 그 안의 변수는 사라집니다.

키(Key)와 값(Value)의 쌍을 넣는 상자입니다.

// map[키의 타입]값의 타입{키: 값}
response := map[string]string{"status": "ok"}

map[string]string은 "키가 string 타입이고, 값도 string 타입인 map"이라는 의미입니다. 이 map[string]string 전체가 타입 이름이 됩니다.

// 생성
data := map[string]string{
"status": "ok",
...

이 부분이 중요합니다.

// 구조체 (Struct): 필드가 정의 시점에 고정됨. 나중에 추가할 수 없음
user := User{Name: "washi", Age: 30}
user.Hobby = "Golf" // ← 에러! 정의에 Hobby가 존재하지 않음
...
구분구조체 (Struct)map
필드정의 시점에 고정. 나중에 추가할 수 없음언제든 추가·삭제 가능
타입명map[string]string이 그대로 타입명
접근 방법user.Name (도트)data["name"] (브래킷)
비유항목이 인쇄된 프로필 카드백지 메모장

구조체는 Name stringAge int처럼 서로 다른 타입을 묶을 수 있지만, map[string]string은 값이 모두 string이어야 합니다. 이 또한 큰 차이점입니다.

데이터의 형태가 정해져 있는 경우 (사용자 정보, 상품 정보 등) → 구조체
데이터의 형태가 유연한 경우 (설정값, 일시적인 응답 등) → map

이 부분이 이 글의 메인 이벤트입니다. Go는 표준 라이브러리만으로 외부 라이브러리 없이 HTTP 서버를 만들 수 있습니다.

import (
"encoding/json" // Go의 데이터를 JSON으로 변환하는 도구 상자
"fmt" // 화면 표시를 위한 도구 상자
...

구조체를 JSON으로 변환할 때, 키 이름을 지정하는 것이 JSON 태그 (JSON Tag)입니다.

type User struct {
Name string `json:"name"`
Age int `json:"age"`
...

json:"name"의 백틱 (Backtick)으로 둘러싸인 부분이 태그입니다.

태그가 없는 경우:

{"Name":"washi","Age":30}

Go의 필드명이 그대로 대문자로 시작하는 JSON 키가 됩니다.

태그가 있는 경우:

{"name":"washi","age":30}

소문자가 되어 API 관습에 부합하게 됩니다.

참고로, 태그를 감싸고 있는 것은 백틱 (Backtick) ` 입니다. 작은따옴표 '와는 별개의 문자입니다. 한국어 키보드에서는 Shift + @로 입력할 수 있습니다. "이 URL에 접속 요청이 오면, 이 처리를 실행한다"라는 함수입니다.

func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
response := map[string]string{"status": "ok"}
...

핸들러 함수의 인자는 정해진 패턴이 있습니다. 반드시 이 두 가지를 받습니다.

func healthHandler(w http.ResponseWriter, r *http.Request) {
// ↑ ↑
// 응답(Response) 요청(Request)
...

w http.ResponseWriter

→ 응답을 쓰는 대상. 여기에 작성한 내용이 상대방에게 전달됩니다.

r *http.Request

→ 요청 정보. URL, HTTP 메서드, 헤더, 바디(Body) 등이 들어있습니다.

1행: 헤더 설정

w.Header().Set("Content-Type", "application/json")
w → 응답 (상대방에게 보내는 편지)
.Header() → 그 편지의 헤더 부분 취득
.Set(...) → 헤더에 정보 작성
...

HTTP 응답은 '헤더(Header)'와 '바디(Body)' 두 부분으로 구성되어 있습니다. 편지에 비유하자면, 헤더는 봉투에 적는 정보(받는 사람, 종류)이고, 바디는 그 안에 들어있는 편지 본문입니다.

헤더: Content-Type: application/json ← "무엇을 반환할 것인가"에 대한 정보
바디: {"status":"ok"} ← 실제 데이터

2행: 반환할 데이터 만들기

response := map[string]string{"status": "ok"}

map을 사용하여 {"status": "ok"}라는 데이터를 만들고 있습니다.

3행: JSON으로 변환하여 반환하기

json.NewEncoder(w).Encode(response)
json.NewEncoder(w) → "w에 작성하는 JSON 변환기"를 생성
.Encode(response) → response를 JSON으로 변환하여 w에 작성

즉, Go 측의 데이터가 JSON으로 변환되어 상대방에게 반환됩니다.

Go 측: map[string]string{"status": "ok"}
↓ Encode로 변환
JSON: {"status":"ok"}
...

URL마다 "어떤 핸들러 함수를 실행할지" 연결합니다.

http.HandleFunc("/health", healthHandler)
// → /health에 접속이 오면 healthHandler를 실행한다
http.HandleFunc("/user", userHandler)
...

이 한 줄로 HTTP 서버가 기동됩니다. 분해해서 살펴봅시다.

http.ListenAndServe(":8080", nil)
↑ ↑ ↑ ↑
도구 상자 함수명 인자1 인자2

→ HTTP 서버 도구 상자 (http net/http 패키지)
→ "Listen(요청을 대기)하고 Serve(처리)한다"는 의미. 이름 그대로 요청을 계속 기다리며 처리하는 서버를 기동하는 함수 ListenAndServe
→ 제1인자. 어떤 포트 번호로 요청을 기다릴지 지정합니다. ":8080"에서 :8080은 "8080번 포트"를 의미합니다. 맨 앞의 :는 Go의 작성 규칙입니다.
→ 제2인자. 라우팅(Routing) 설정을 커스텀하고 싶을 때 사용하지만, nil을 전달하면 "기본 설정을 사용한다"는 의미가 됩니다.

"아무것도 없음"을 나타내는 특별한 값입니다. 다른 언어의 null (Java)이나 None (Python)과 같은 개념입니다.

"값이 아직 결정되지 않았다" 또는 "특별히 지정하지 않는다"는 것을 나타내기 위해 사용합니다.

ListenAndServe의 제2인자로 nil을 전달하면, "라우팅 설정은 방금 http.HandleFunc로 등록한 기본 설정을 사용해줘"라는 의미가 됩니다. 즉, 지금 단계에서는 "nil = 기본값으로 OK"라고 기억해 두시면 됩니다.

ListenAndServe를 호출하면, 프로그램은 그 지점에서 멈춰 요청을 계속 기다립니다. 터미널이 멈춘 것처럼 보이지만 정상입니다. 서버를 종료할 때는 Ctrl + C를 누릅니다.

다음 내용을 main.go에 작성하고 실행해 보세요.

package main
import (
"encoding/json"
...
go run main.go
# → Server started on :8080

다른 터미널을 열어서 (VSCode라면 + 버튼), 다음을 실행합니다.

curl http://localhost:8080/health
# → {"status":"ok"}
curl http://localhost:8080/user
...

JSON이 반환되면 성공입니다.

curl http://localhost:8080/health URL을 분해해 봅시다.

http://  localhost  :8080  /health
↑ ↑ ↑ ↑
통신 방식  호스트  포트  패스

http://
→ HTTP 프로토콜로 통신한다 (암호화 없음. 암호화할 경우에는 https:// 사용)

localhost
→ "자기 자신의 PC"를 가리키는 특별한 호스트명. 인터넷상의 서버가 아니라, 지금 바로 자신의 PC에서 동작하고 있는 서버에 접속한다는 의미

:8080
→ 포트 번호. 한 대의 PC에서 여러 서비스를 구동하기 위해 번호로 구분하는 메커니즘. 비유하자면 아파트(PC)의 호수(방 번호). 8080번 방에서 동작하고 있는 서비스에 용건이 있다는 지정

/health
→ 패스 (Path). 서버 내의 어느 엔드포인트 (Endpoint)에 접속할지 지정

curl http://localhost:8080/health는 "내 PC의 8080번 포트에서 동작하고 있는 서버의 /health에 HTTP 요청을 보낸다"는 의미입니다.

코드의 http.ListenAndServe(":8080", nil)가 바로 "8080번 포트에서 요청을 기다린다"고 선언하는 부분입니다.

여기서 무엇이 일어나고 있는지 정리해 봅시다.

1. go run main.go 로 서버가 기동
2. 서버는 포트 8080에서 요청을 기다리고 있음
3. curl http://localhost:8080/health 를 실행
...

지금까지의 지식을 사용하여 스스로 엔드포인트를 추가해 봅시다.

과제: /profile에 접속했을 때 다음과 같은 JSON이 반환되도록 하세요.

{"name":"washi","age":30,"job":"Infrastructure Engineer"}

힌트:

  • User 구조체 (struct)에 Job 필드를 추가한다 (JSON 태그도 잊지 말 것)
  • profileHandler 함수를 새로 만든다 (기존 핸들러의 패턴을 모방한다)
  • main()에서 라우팅 (Routing)을 추가한다

직접 작성해 본 뒤, 아래의 정답을 확인하세요.

package main
import (
"encoding/json"
...
curl http://localhost:8080/profile
# → {"name":"washi","age":30,"job":"Infrastructure Engineer"}

포인트:

  • User 구조체 정의 자체를 수정하여 Job 필드를 추가했습니다. 이는 소스 코드를 변경한 것이지, 실행 중에 동적으로 필드를 추가한 것이 아닙니다.
  • profileHandler 안에서 만든 profile 변수는 이 함수 안에서만 존재합니다 (스코프 (Scope)). userHandler 안의 user와는 완전히 별개의 것입니다. 동일한 데이터가 필요하더라도 함수마다 새로 만들어야 합니다.

다음 질문에 모두 답할 수 있다면 이 글은 완료된 것입니다. 아무것도 보지 않고 답해 보세요.

  • package main은 무엇을 의미합니까?
  • import는 무엇을 하기 위한 선언입니까?
  • fmt 패키지는 무엇을 위한 도구 상자입니까?
  • func main()은 프로그램 내에서 어떤 역할을 합니까?
  • var name string = "washi"name := "washi"의 차이점은 무엇입니까?
  • :=를 사용할 수 없는 곳은 어디입니까?
  • 구조체 (struct)는 무엇을 위해 사용합니까? 배열과의 차이점은?
  • 구조체에 정의되지 않은 필드를 나중에 추가할 수 있습니까?
  • map과 구조체의 차이점을 2가지 드세요.
  • 스코프 (Scope)란 무엇입니까?
  • json:"name"의 백틱 (backtick)은 무엇을 위해 있습니까?
  • 핸들러 함수의 인자 w...

r은 각각 무엇을 나타냅니까?

  • w.Header().Set("Content-Type", "application/json")은 무엇을 하고 있습니까?

  • json.NewEncoder(w).Encode(response)는 무엇을 하고 있습니까?

  • http.HandleFunc("/health", healthHandler)는 무엇을 하고 있습니까?

  • 다음 JSON을 반환하는 /status 엔드포인트를 추가하는 코드를 작성하세요 (map을 사용할 것)

{"status":"running","version":"1.0"}
  • 다음 JSON을 반환하는 /server 엔드포인트를 추가하는 코드를 작성하세요 (구조체 (struct)를 새로 정의하여 사용할 것)
{"host":"tokyo","port":8080}

1. package main은 무엇을 의미합니까?

"이 파일은 main 패키지에 속해 있으며, 실행 가능한 프로그램입니다"라는 선언입니다. go run으로 실행하려면 package mainfunc main()이 모두 필요합니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0