파일 업로드 도중 배포를 진행해도 괜찮을까? — 애플리케이션 개발편

Jake
12 min readFeb 16, 2024

--

Graceful Shutdown Series

해당 시리즈는 애플리케이션을 Graceful 하게 종료하는 방법에 대한 시리즈입니다.

Golang 을 기반으로 Graceful Shutdown 에 대한 주제를 담고 있습니다.

목차

이번 파일 업로드 시리즈에서는 크게 두가지 Part 로 나눠질 예정입니다.

1편

  • HTTP 파일 업로드 동작 원리
  • Golang 기반 업로드 로직 구현 및 업로드 프로세스 진행율 구현

2편

  • application 기반 graceful shutdown 동작 테스트
  • docker 기반 graceful shutdown 동작 테스트
  • kubernetes 기반 graceful shutdown 동작 테스트

파일 업로드를 하는 도중 배포를 해도 괜찮을까요?

파일 업로드 기능은 웹 애플리케이션에서 제공되는 일반적인 기능 중 하나입니다. 개발자는 HTML Form 태그를 통해 multipart/form-data 형식으로 업로드를 할 수 있거나, REST API를 사용하여 HTTP Post 요청으로 업로드 기능을 구현할 수 있습니다.

그러나 사용자가 파일을 업로드하는 중에 배포로 인해 애플리케이션이 재시작되면 어떻게 될까요?

위의 이미지처럼 애플리케이션은 HTTP 에 대한 응답을 주지 못하기 때문에 사용자는 HTTP 응답을 기다리는 현상이 발생하게 됩니다.

이 문제에 대응하기 위해 애플리케이션을 종료할 때 context.WithTimeout 와 같은 기능을 설정하여 기존 요청을 마무리하고 종료할 수 있도록 합니다. 그리고 로드 밸런싱을 통해 새로운 트래픽이 새로운 애플리케이션으로 유도되는 방식으로 무중단 배포를 지원합니다.

그러나 파일 업로드의 경우, 특히 큰 파일의 경우에는 업로드 시간이 길어질 수 있기 때문에 간단한 타임아웃 정책만으로는 graceful한 무중단 배포를 진행하기 어려울 수 있습니다.

예시로 10G 파일을 업로드하는 경우를 고려해서 context.WithTimeout 을 무한정 길게 부여할 수는 없기 때문입니다.

우리가 바라는 graceful 하게 종료되는 것은 파일의 업로드가 종료됐을 때 애플리케이션이 종료되는 것입니다. 바로 아래의 이미지와 같이 말이죠!

(물론 최대 종료를 기다리는 시간은 설정해주는 것은 중요합니다.)

위의 그림과 같이 upload 가 35% 진행되었을 때 ^C 로 종료신호를 받았지만 즉시 종료되지 않고, 업로드를 모두 완료한 후에 종료되는 것을 볼 수 있습니다.

위의 Graceful 한 업로드 세션 종료를 구현해보기 위해 1편을 먼저 시작해보겠습니다.

HTTP 파일 업로드

업로드는 로컬 장치에서 원격 장치로 데이터를 전송하는 것입니다. 일반적으로 원격 장치는 로컬 장치보다 더 큰 서버입니다. 업로드는 네트워크를 통해 파일을 전송하는 데 가장 많이 사용됩니다. 이 작업은 클라이언트 컴퓨터에서 서버로 수행됩니다.

HTTP (Hypertext Transfer Protocol) 업로드는 웹사이트 검색과 동일한 프로토콜을 사용하여 파일 데이터를 보내는 방식입니다. 웹과 동일한 프로토콜을 사용하기 때문에 대부분의 애플리케이션에서 이 방식을 많이 채택하고 있습니다. HTTP 는 TCP 프로토콜을 기반으로 동작하기 때문에 파일 업로드 또한 아래 그림과 같이 핸드세이크 방식으로 정보를 전송합니다.

https://codemyroad.wordpress.com/2013/11/10/p2p-file-transfer-over-tcp/

위의 그림은 HTTP 파일 업로드를 진행할 때 HTTP 요청의 동작 과정입니다.

  • Client : 목적지 주소에 전송하고자 하는 파일 이름과 크기를 전송합니다.
  • Server : 해당 파일을 보내도 되는 지에 대한 허용 여부를 알려줍니다.
  • Client : 파일 전송에 대해 수락받았다면 파일을 전송합니다.
  • Server : 파일 전송이 완료되면 완료되었다는 정보를 전송한 후 연결을 끊습니다.
  • Client : 파일 전송 결과에 대한 정보를 받은 후 연결을 끊습니다.

“패킷”은 컴퓨터 간에 데이터를 주고받을 때 네트워크를 통해 전송되는 작은 데이터 블록을 나타냅니다.

TCP 프로토콜은 최대 전송 단위(MTU)가 1500바이트인데, 이는 TCP가 한 번에 전송할 수 있는 최대 크기를 나타냅니다.

그러나 1500바이트보다 큰 데이터를 전송해야 할 경우 TCP/IP 프로토콜은 데이터를 여러 패킷으로 나누어 전송하며, 각 패킷에는 일련번호가 부여되어 수신측에서 올바른 순서로 조립됩니다.

이 방식을 통해 큰 데이터 파일, 예를 들면 10GB 파일도 여러 조각(chunk)으로 분할되어 전송됩니다.

파일 업로드 걸리는 시간

파일 업로드에는 여러 요인에 의해 영향을 받기 때문에 정확한 공식은 없지만, 아래와 같은 공식으로 나타낼 수 있습니다.

만일 파일 용량이 크고, 사용가능한 네트워크 대역폭이 작을 수록 파일 업로드하는데 걸리는 시간은 늘어나게 됩니다. 오랜 시간 동안 데이터를 전송해야하기 때문에 네트워크가 끊기거나 서버가 응답을 받지 못하는 상황이 오게되면 업로드를 실패할 수 있습니다.

네트워크 문제로 인한 파일 업로드 실패는 client 환경을 제어할 수 없기 때문에 저희가 통제할 수 없지만, 장애가 아닌 배포로 인한 업로드 실패는 우리가 방지해야만 합니다.

Golang 으로 파일 업로드 로직 구현

먼저 application 단에서 파일을 업로드하는 로직을 구현해보고, 현재 업로드가 진행 중임을 명확히 알 수 있게 진행율을 로깅하는 로직으로 구성해보겠습니다.

그리고, Graceful Shutdown 로직으로 Application 에 종료 신호가 들어오더라도 이미 진행중인 업로드를 완료한 후에 종료하도록 구현해보겠습니다.

File Tree 구조

.
├── go.mod
├── go.sum
├── main.go
└── templates
└── index.html

Gin Framework

package main

import (
"github.com/gin-gonic/gin"
"net/http"
)

func main() {
// Gin 엔진을 초기화하고 기본 설정을 사용합니다.
gMux := gin.Default()

// HTML 템플릿 로드
gMux.LoadHTMLGlob("./templates/*")

// 루트 경로에 대한 핸들러
gMux.GET("/", func(c *gin.Context) {
// index.html 템플릿을 렌더링하여 응답합니다.
c.HTML(http.StatusOK, "index.html", gin.H{
"title": "Welcome to My Website!",
})
})

// 서버 시작
_ = gMux.Run(":8080")
}
  • 먼저 gin Freamwork 를 사용하여 File upload 를 할 수 있는 index 페이지를 구성합니다.
  • LoadGTMLGlob 를 사용하여 templates 디렉터리의 html 파일을 로드하여 화면을 보여줍니다.

Upload Logic

gMux.POST("/upload", func(c *gin.Context) {
// MultipartReader로부터 form 데이터를 읽기
multipartReader, err := c.Request.MultipartReader()
if err != nil {
c.String(http.StatusInternalServerError, fmt.Sprintf("MultipartReader error: %s", err.Error()))
return
}

for {
part, err := multipartReader.NextPart()
if err == io.EOF {
break
}
if err != nil {
c.String(http.StatusInternalServerError, fmt.Sprintf("NextPart error: %s", err.Error()))
return
}

if len(part.FileName()) == 0 {
break
}

filename := filepath.Join("./result/", part.FileName())
out, err := os.Create(filename)
if err != nil {
c.String(http.StatusInternalServerError, fmt.Sprintf("Create file error: %s", err.Error()))
return
}
defer out.Close()

var read int64
var written int64
var next int32 = 5

length := c.Request.ContentLength
buffer := make([]byte, 1024*1024)
for {
bytes, readErr := part.Read(buffer)
if readErr == io.EOF {
break
}

if readErr != nil {
c.String(http.StatusInternalServerError, fmt.Sprintf("file byte read error: %s", err.Error()))
return
}

if bytes <= 0 || buffer == nil {
c.String(http.StatusInternalServerError, fmt.Sprintf("close upload session: %s", err.Error()))
return
}

read = read + int64(bytes)
writtenBytes, err := out.Write(buffer[0:bytes])
if err != nil {
c.String(http.StatusInternalServerError, fmt.Sprintf("write session: %s", err.Error()))
return
}
written = written + int64(writtenBytes)

p := int32(float32(read) / float32(length) * 100)
if 0 != p && 0 == p%next {
log.Printf("upload processing : %s\n", strconv.Itoa(int(p)))
next += 5
}

if p == 99 {
log.Printf("upload processing complete")
break
}

}

_, err = io.Copy(out, part)
if err != nil {
c.String(http.StatusInternalServerError, fmt.Sprintf("Copy file error: %s", err.Error()))
return
}

c.String(http.StatusOK, fmt.Sprintf("File %s uploaded successfully!", part.FileName()))
}

})
  • c.Request.MultipartReader() 를 사용하여 멀티파트 데이터를 읽습니다.
  • nextPart() 를 사용하여 여러 개의 파일을 순회합니다.
  • filepath.Join("./result/", part.FileName()) 를 통해 파일을 저장할 경로를 설정합니다.
  • length := c.Request.ContentLength 으로 하나의 파일의 전체 크기를 구합니다.
  • buffer := make([]byte, 1024*1024) 으로 1MB 의 버퍼를 생성합니다.
  • bytes, readErr := part.Read(buffer) 으로 데이터를 읽어서 buffer 에 저장하고, 읽은 bytes 수를 반환합니다.
  • p := int32(float32(read) / float32(length) * 100) 으로 전체 contentLength 의 읽은 bytes 수로 업로드 진도율을 구합니다.
  • _, err = io.Copy(out, part) 로 업로드가 모두 완료되면 위에서 지정한 output dir 에 파일을 copy 합니다.

위의 로직을 통해 파일을 업로드할 때의 진척율을 알 수 있었습니다.

그럼 다음 시간에는 업로드를 진행 중 종료 신호를 수신받았을 때 위의 업로드를 끊기지 않고 모두 완료하는지에 대해서 알아보겠습니다.

  • application 기반 graceful shutdown 동작 테스트
  • docker 기반 graceful shutdown 동작 테스트
  • kubernetes 기반 graceful shutdown 동작 테스트

--

--