Nextjs API Routes를 통해 API 서버로 파일 보내기
웹 성능 최적화 #1 네트워크
2024-12-22
Explanation
제목 그대로, 웹 서비스의 성능 최적화에 대해서 글을 적어보려고 하는데요.
오늘은 그 첫 번째로 네트워크 통신 간의 성능을 향상시키기 위한 대표적인 몇 가지를 알아보겠습니다.
일반적으로 웹 서비스는 클라이언트와 서버 간의 HTTP 통신을 통해서 데이터를 주고 받으며 동작하게 되는데요. 웹이 발전하면서 HTTP도 계속 발전해서 이제는 HTTP/3 버전까지 등장하게 되었고 이미 대부분의 주요 브라우저들은 HTTP/3 버전을 지원한답니다.
웹 서비스 대부분의 클라이언트는 웹 브라우저이기 때문에 클라이언트는 기본적으로 준비가 되어 있으니, 이제 서버에서 HTTP/3 사용을 위한 설정을 해주면 되는데요.
보통 웹 서버로 NGINX와 Apache가 많이 사용되는데, 현재(24년 12월) Apache는 HTTP/3 버전을 지원하지 않고 NGINX는 RHEL(Red Hat Enterprise Linux) 9와 이를 기반으로 하는 이진 호환 변형 OS 또는 Ubuntu 22.04 이상의 환경에서 v1.25.1부터 HTTP/3을 지원합니다.
간략한 HTTP/3의 특징으로는, TCP 기반이 아닌 UDP 기반으로 TCP의 커넥션 과정(3-Way-Handshake)이 생략되고 또한, HTTP/2 버전에서 멀티플렉싱 기능이 추가되어서 TCP 연결에서 여러 요청에 대한 응답을 동시에 처리할 수 있게 되었었지만, TCP는 기본적으로 데이터 전송 과정 중 패킷이 손실이 발생하였을 때, 패킷을 재전송하고 이 패킷이 도착할 때까지 모든 스트림을 기다려야 하는 문제가 있었습니다.(HOL Blocking: Head Of Line Blocking) 하지만 HTTP/3는 UDP 기반으로 여러 연결이 독립적으로 실행하기 때문에 패킷의 손실이 발생하더라도 다른 스트림에는 영향을 주지 않고 동작하기 때문에 성능적인 이점이 있습니다.
Ubuntu 환경의 NGINX 웹 서버에 HTTP/3를 설정하는 방법은 이전에 작성한 글이 있어서, 해당 글의 링크로 대신하겠습니다!
링크: https://falsy.me/ubuntu를-사용하는-nginx-웹-서버에-http-3-설정하기/
만약..
NGINX의 가상 호스트 설정에서 여러개의 server 블록을 사용하는 경우에는 “listen 443 quic reuseport;”의 reuseport 속성이 중복 사용되면 NGINX 설정에 오류가 발생하는데요. 그래서 저는 메인 server 블록에 “listen 443 quic reuseport default_server;” 로 설정하고 그밖의 server 블록에서는 “listen 443 quic;” 로 작성하였습니다.
Gzip은 데이터를 압축하는 데 사용되는 파일 포맷을 이야기 하는데요. 웹 환경에서 HTTP 응답 데이터를 압축하여 전송하는 방법으로 많이 사용되는 방법입니다. 말 그대로 Gzip으로 압축해서 데이터의 크기를 줄이고, 이를 통해 전송 속도를 향상 시키고 네트워크 대역폭을 절약할 수 있습니다.
Gzip 역시, 대부분의 브라우저가 지원하고 있기 때문에 간단하게 서버에서 설정해주면 된답니다!
간단하게, NGINX 서버와 Node.js 기반으로 많이 사용되는 NestJS에서 설정하는 방법을 알아볼게요.
제 기억에는 NGINX를 설치하면 기본적으로 Gzip이 활성화되어 있던 것으로 기억하는데요.
1 |
$ vi /etc/nginx/nginx.conf |
위와 같이 NGINX 설정 파일을 열어보면,
1 2 3 4 5 6 7 8 9 10 11 |
... http { ... gzip on; include /etc/nginx/conf.d/*.conf; include /etc/nginx/sites-enabled/*; } |
위와 같이 “gzip on;”으로 활성화 되어 있는 것으로 확인 할 수 있습니다.
그 밖에도 “gzip_types text/plain text/css application/json application/javascript;” 와 같이 Gzip으로 압축할 타입을 설정하거나 “gzip_min_length 1024;”와 같이 최소 크기 제한 설정하는 등.. 다양한 설정을 추가할 수 있습니다.
Node.js 기반의 서버에서는 대부분 “compression” 라이브러리를 사용하면 간단하게 설정할 수 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import * as compression from "compression" import { NestFactory } from "@nestjs/core" import { AppModule } from "./app.module" async function bootstrap() { const app = await NestFactory.create(AppModule) ... app.use(compression()) await app.listen() } bootstrap() |
크기 제한과 같은 추가 설정이 필요한 경우 아래와 같이 설정할 수 있습니다.
1 2 3 4 5 |
app.use( compression({ threshold: 1000 }) ) |
Gzip을 사용할 때에도 몇가지 주의해야할 점이 있는데요. 일단 너무 작은 크기의 데이터의 경우에는 오히려 압축 헤더로 인해 크기가 커질 수도 있으며, 무엇보다도 압축을 하는 과정에서 CPU가 사용되기 때문에 트래픽이 많은 경우 리소스가 부족해질 수 있습니다.
Cache-Control 헤더와 max-age 속성을 통해서 해당 리소스를 브라우저 또는 중간 서버(CDN, 프록시)에게 해당 리소스를 설정된 기간동안 캐시해도 된다는 설정을 추가할 수 있습니다.
예를 들어, 아래와 같이 NGINX에 /static/ 이라는 경로의 데이터를 1년이라는 시간으로 캐싱 설정을 하고
1 2 3 4 5 6 7 8 9 10 11 12 |
server { server_name falsy.me; location /static/ { root /var/www/html/static; add_header Cache-Control "public, max-age=31536000, immutable"; } ... } |
브라우저에서 “http://falsy.me/static/test.jpg” 라는 요청이 온다면 NGINX에서는 아래와 같은 응답을 반환합니다.
1 2 3 4 5 6 |
HTTP/1.1 200 OK Content-Type: image/jpeg Cache-Control: public, max-age=31536000, immutable Content-Length: 12345 (binary data of test.jpg) |
그러면 이제 브라우저는 응답 데이터를 1년 동안 캐시에 저장하고 서버에 재요청하지 않고 응답하게 됩니다.
ETag는 리소스의 특정 버전을 나타내는 고유 식별자인데요. 서버에서 ETag를 설정을 통해서, 클라이언트의 요청에 응답을 할 때 해당 리소스에 대한 고유 식별자(ETag)값을 헤더에 포함해서 응답해줄 수 있답니다.
그러면 클라이언트에서는 해당 ETag 값을 가지고 있다가 재요청을 할때, 헤더에 If-None-Match 속성의 값으로 이전의 ETag 값을 포함해서 요청을 보내게 된다면, 서버에서는 이제 다시 요청받은 응답에 대한 ETag를 우선 만들고 If-None-Match를 통해 받은 ETag 값과 비교해서 변경되지 않았다면 빈 바디의 “304 Not Modifed”를 응답해줍니다.
그러면 다시 클라이언트는 304 응답을 통해 이전 요청과 현재 요청의 값이 차이가 없다는 것을 알 수 있습니다.
간단하게, NestJS를 사용한 서버와 React를 사용한 클라이언트를 예시로 구현해보면,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import { NestFactory } from "@nestjs/core" import { AppModule } from "./app.module" async function bootstrap() { const app = await NestFactory.create(AppModule) ... app.enableCors({ exposedHeaders: ["ETag"] }) await app.listen(3000) } bootstrap() |
NestJS는 기본적으로 ETag를 지원하기 때문에, 클라이언트에서 ETag 헤더를 확인할 수 있도록 CORS 설정을 통해 ETag를 헤더로 노출되도록 설정만 해주면 됩니다.
클라이언트에서는 대략적으로 아래와 같이 구성할 수 있습니다.
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 49 |
import { useState } from "react" export default function ClientComponent() { const [data, setData] = useState([]) const [etag, setEtag] = useState("") const handleClickRequest = async () => { try { const res = await fetch( "http://falsy.me/api/example", etag ? { headers: { "If-None-Match": etag } } : {} ) if (res.status === 304) { return } if (res.status !== 200) { throw new Error("Error") } const json = await res.json() const newEtag = res.headers.get("ETag") if (newEtag) { setEtag(newEtag) } setData(json) } catch (e) { console.error(e) } } return ( <div> <button onClick={handleClickRequest}>Request</button> <div> {data.length > 0 && ( <ul> {data.map((item) => ( <li key={item.id}>{item}</li> ))} </ul> )} </div> </div> ) } |
처음 API(“http://falsy.me/api/example”) 요청을 보내고 서버는 헤더에 ETag를 포함한 응답이 오게 되며, 이후 받은 ETag 값을 인 메모리 상에 가지고 있다가 다음 재요청 시 ETag 값을 “If-None-Match”에 포함하여 요청을 보냅니다.
만약 서버에서 새로 만들어진 데이터의 ETag와 값이 다르지 않다면 304를 응답하며, 클라이언트에서는 304 응답이 오면 데이터를 갱신하지 않습니다. 만약 변경이 있다면 200 응답으로 새로운 ETag 값이 헤더에 포함되어 오고 클라이언트는 다시 새로운 응답 데이터로 화면을 갱신하고 새로운 ETag 값도 갱신하여 보관합니다.
ETag와 If-None-Match를 사용하는 서버의 동작을 생각해보면, If-None-Match 헤더를 포함한 요청이 와도 기존과 동일하게 새로운 응답 데이터를 생성하기 위한 로직을 수행합니다. 그리고 새로운 응답 데이터를 기반으로 ETag 값을 생성하고 이를 If-None-Match와 비교해서 동일한 경우 응답 본문(Body)를 포함하지 않은 상태로 304 응답을 하고, 변경이 있는 경우 새로운 Etag 값과 응답 데이터를 가지고 200 응답을 합니다.
1. Cache-Control: no-cache + If-None-Match
no-cache는 항상 유효성 검사를 요구하기 때문에 서버는 ETag를 비교하여 동일하면 “304 Not Modified”, 다르면 “200 OK”를 응답합니다.
2. Cache-Control: no-store + If-None-Match
no-store는 항상 서버에 새로운 데이터를 요청하기 때문에 If-None-Match는 무시되며 서버는 항상 새 데이터 “200 OK”를 응답합니다.
3. Cache-Control: max-age=<초> + If-None-Match
클라이언트는 max-age 내에서는 캐시된 데이터를 사용하고 max-age가 만료된 후에 If-None-Match를 포함하여 서버로 요청합니다. (ETag를 비교하여 동일하면 “304 Not Modified”, 다르면 “200 OK”)
정확하게는 max-age 내에는 브라우저가 로컬 캐시 데이터를 사용하고 서버에 요청을 보내지 않기 때문에 If-None-Match가 동작할 수 없습니다.
대략, Cache-Control 설정이 우선 시 되고 이후 If-None-Match가 적용되는 것을 알 수 있습니다.
CDN는 콘텐츠(HTML, CSS, JavaScript, 이미지, 비디오 등..)을 사용자와 지리적으로 가까운 위치에 서버에서 제공하여 성능과 안정성을 향상시키는 분산형 네트워크를 이야기합니다.
저는 대부분의 시간을 AWS를 사용하였기 때문에.. AWS에서는 “CloudFront”를 통해서 CDN을 제공하고 있습니다. S3와 연결하여 정적인 데이터를 사용하거나 Lambda@Edge를 사용해서 실행 지연을 줄일 수 있습니다. 또한 TTL과 Cache-Control와 같이 캐싱 설정과 SSL/TLS 암호화 설정도 할 수 있습니다.