기본적으로 SSL Termination과 3-Tier Architecture 구조로 구성하였다. DB는 성능과 안정성을 위해 호스트 프로세스로 직접 운영하는 하이브리드 구성을 택했다.

 

서버 이중화 시 고려한 점

1. Sticky Session과 Session Clustering

Sticky Session은 LB가 특정 사용자의 요청을 처음 접속했던 서버로 보내주는 것으로 사용자의 브라우저 쿠키나 IP 주소를 확인한다. 서버가 죽었을 때 해당 서버에 있는 사용자 세션이 전부 날라가 버린다. 그래서 WAS 서버에서 로직으로 failover가 가능한 Session Clustering도 같이 고려해야한다. 보통 속도를 위해서 redis에 세션을 저장해서 클러스터링한다. Redis Sentinel 구조로 고가용성을 확보해야하고 굉장히 인프라적으로 복잡하다. 그런데 JWT를 사용하면 Stateless 방식이라서 둘 다 필요없다.

 

Sticky Session을 구성하는 방식

1. ip_hash

 

nginx가 HTTP 레벨(L7)에서 동작하지만, 클라이언트의 IP(L3 정보)를 기준으로 해싱(hash 알고리즘)해 항상 같은 서버로 보낸다.

L4의 알고리즘 방식과 관계없이 L7의 알고리즘 방식을 hash를 사용해 Client IP가 같으면 항상 Target WAS를 같게 해주는 것이다. 특정 서버로 트래픽이 몰릴 수 있다.

upstream backend_servers {
    ip_hash;
    server was1.example.com;
    server was2.example.com;
}

 

2. Cookie Insertion - Nginx Plus(유료) 버전만 가능

Nginx에서 들어오는 요청의 쿠키값을 본다. 서버에 고르게 트래픽이 분산되지만 유료 버전만 가능하여 인프라 종속성이 생긴다.

upstream backend_servers {
    server was1.example.com;
    server was2.example.com;

    # 로드밸런서가 'route'라는 이름의 쿠키를 삽입함
    sticky cookie route expires=1h domain=.example.com path=/;
}

 

결론은 JWT를 사용해서 인프라 관리 복잡성을 낮추는게 좋다!!!!!

 

메커니즘 역할
proxy_next_upstream 매 요청마다 실행됨. 현재 서버가 실패하면 그 요청 안에서 즉시 다음 서버로 재시도
max_fails / fail_timeout 실패 횟수가 누적되면, 이후 새로운 요청들이 아예 그 죽은 서버를 시도조차 안 하게 미리 건너뛰게 함 (일종의 캐시)

트러블 슈팅

서버 shutdown 시, 재시도를 하면서 무한로딩 발생한다. Docker 다운이나 HTTP 오류일때는 무한로딩이 발생하지 않고, 서버 다운일 경우에만 발생했다. 해당 문제는 타임아웃일 경우에만 발생하는 것으로 error나 http일때는 상관없다.

-> timeout 발생한 서버를 헬스체크 한번 발생했다면 DB의 Active-Stanby 형식처럼 down된 서버에 접속이 안되는지 찾아봤는데 이건 Nginx Plus(유료)에서 지원하는 Active Health Check 기능이다.

 

특히 이건 내가 proxy_connect_timeout + max_fails  + fail_timeout 의 정말 알맞은 값을 찾기 위해서 여러 시나리오를 생각하고 작성한 것이다. 무료버전에서 서버 shutdown으로 타임아웃 발생 시 웹 페이지에서 크롬으로 따지면 맨 위에서 빙글빙글 돌면서 무한 로딩이 발생하는 이유는 타임아웃 시간만큼 기다렸다가 upstream을 하기 때문이다. 그래서 fail_timeout 시간 내에 max_fails 만큼 오류가 나야되는데 proxy_connect_timeout을 잘 조절해야한다. 즉 30초 안에 3번 오류가 날때 타임아웃 오류일 경우엔 바로 다음 서버로 넘기는게 아니라 proxy_connect_timeout 시간만큼 기다렸다가 넘긴다. 그 모든게 fail_timeout 시간 안에 이루어져야한다. 30초 안에 사용자 3명이 동시에 접속할 경우 3명의 사용자(병렬처리)가 모두 proxy_connect_timeout 만큼 무한로딩을 겪고 다음 4번째 사용자부터 죽은 서버를 제외하고 바로 upstream된다.

최악의 경우는 1명의 사용자가 홀로 접속해서 30초 내에 3번의 클릭을 했는데 proxy_connect_timeout 시간을 너무 길게 잡아서 fail_timeout 시간을 넘어 max_fails 가 초기화되는 경우이다. 이러면 정상 서버로 넘어갈 때 계속 비정상 서버를 체크하면서 proxy_connect_timeout 만큼 기다리기에 계속 로딩이 오래걸린다고 느끼게 된다.

또 다른 최악의 상황은 3명의 사용자가 순차적으로 접속하는 경우이다. 이런 경우엔 upstream되면서 fail_timeout이 초기화되지 않고 첫번째 요청을 기준으로 한다. 즉 fail_timeout - proxy_connect_timeout 시간 만큼만 죽은 서버를 제외해서 fail_timeout보다 훨씬 빠르게 죽은 서버를 다시 찔러본다.

 

1. 동시 접속 3명 (병렬)

t=0: 3명 동시 요청 시작
t=30: 3명 다 거의 동시에 타임아웃 → 실패 카운트 1,2,3이 "거의 동시"에 쌓임 (창은 t=30에 막 시작)
      → 이 순간 마킹됨. 창이 "막 시작"했으니 fail_timeout(30초)이 거의 통째로 남아있음
t=30~60: 이 구간(약 30초) 동안 죽은 서버 스킵됨 ✅

 

2. 1명 순차 클릭 (= 3. 3명 순차 접속과 동일)

t=0:  요청1 → 죽은 서버 시도 → 30초 대기
t=30: 실패 카운트 1 (창 시작: t=30~60) → 정상 서버로 재시도 → 응답
      (요청2가 바로 이어서 들어옴, 1명이 다시 클릭하든 다른 사용자든 동일)
t=30: 요청2 → 죽은 서버 시도 → 30초 대기
t=60: 실패 카운트 시도... 근데 창(t=30~60)이 "지금 막" 끝나는 시점!
      → fail_timeout(30초)이 이미 다 지나버려서 카운터가 리셋됨
      → 이 실패가 "카운트 2"가 아니라 새로운 창의 "카운트 1"이 됨 (창 재시작: t=60~90)
t=60: 요청3 → 죽은 서버 시도 → 30초 대기
t=90: 또다시 창(t=60~90)이 끝나는 시점에 걸림 → 다시 리셋, 카운트 1로 재시작

결과: max_fails=3에 영원히 도달하지 못함 ❌
      → 서버는 절대 unavailable로 마킹되지 않음
      → 모든 요청이 각각 30초씩 무한 로딩을 반복

 

결론은 max_fails × proxy_connect_timeout이 fail_timeout보다 충분히 작아야 한다. 이상적으로는 fail_timeout ≥ max_fails × proxy_connect_timeout × 2 정도의 여유를 두면, 어떤 시나리오(동시/순차)에서도 안정적인 스킵 구간이 확보된다.


proxy_next_upstream + proxy_connect_timeout

Nginx가 WAS 1번으로 보냈는데 타임아웃이나 에러가 났을 때, 사용자에게 에러를 주지 않고 즉시 WAS 2번으로 재시도하게 만드는 설정이다. 오픈소스에서 사용할 수 있는 옵션이다. 둘 다 정상적으로 서버가 살아있다면 해당 옵션은 무시된다.

upstream backend_servers {
    server was1:8080 max_fails=3 fail_timeout=30s;
    server was2:8080 max_fails=3 fail_timeout=30s;
}

server {
    location / {
        proxy_pass http://backend_servers;
        
        # 타임아웃, 에러, HTTP 500번대 발생 시 다음 서버로 즉시 넘김
        # error timeout은 기본값이라서 http_500 http_502 http_503 http_504를 적어야지 의미가 있다.
        # 그렇다고 error timeout를 빼고 http만 적는다면 error timeout일때 upstream이 안되니 주의필요
        proxy_next_upstream error timeout http_500 http_502 http_503 http_504;
        
        # 타임아웃 발생 시 해당 시간만큼 빙글빙글이 발생하게 된다.
        proxy_connect_timeout 15s;
        
        # 다음 서버로 넘길 때의 타임아웃 제한 (너무 오래 걸리면 안 되니까)
        proxy_next_upstream_timeout 10s;
        proxy_next_upstream_tries 2;
    }
}

 

결론적으로는 무료 nginx에서 proxy_next_upstream과 proxy_connect_timeout의 값을 조절하여 정상 서버로 넘기는 것을 보장할 수 있다.

 

Nginx Plus(유료)에서 지원하는 Active Health Check 기능을 구현할 수 있는 무료 nginx에서의 2가지 방법

운영중인 서비스에 적용하기에는 리스크가 커서 적용하지는 못했다. 시간상 여유가 있다면 개발 혹은 스테이징 서버에서 충분한 테스트 후에 적용해야한다.

 

1. nginx_upstream_check_module로 Nginx Plus(유료)에서 지원하는 Active Health Check 기능을 구현할 수 있는데, nginx 코어의 upstream 라운드로빈 로직 자체를 수정해야해서 nginx를 처음부터 통째로 다시 빌드해야하고 2014년 이후로 사실상 유지보수가 멈춘 프로젝트라서 추천하지 않는다. 기존 yum으로 설치된 nginx는 삭제하고 소스 재빌드해야한다.

 

2. OpenResty(Nginx+Lua 통합 배포판)를 쓰면 순수 Lua 스크립트로 active health check를 구현할 수 있다. 그러나 config 파일 경로 이동도 필수적이고 Nginx 서비스 다운타임도 필수적이라 영향도 파악에 너무 많은 시간이 소요될거같아서 따로 더 이상 찾아보지는 않았다.

 

정적 모듈 vs 동적 모듈

정적 모듈: Nginx 컴파일 시 소스코드에 포함되어 함께 빌드되며, 항상 메모리에 로드되어 있어서 사용하지 않아도 리소스를 차지한다. 정적모듈은 적용 시 무중단이 불가능하다.
동적 모듈: 별도의 .so 파일로 컴파일되어 필요할 때만 로드하며 유연성이 높고 메모리 효율적이다. 동적모듈은 적용 시 무중단이 가능하다.
 
동적모듈 예시
 

  • ngx_http_image_filter_module: 이미지 처리
  • ngx_http_geoip_module: IP 기반 지리 정보
  • ngx_http_xslt_module: XML/XSLT 변환
  • ngx_stream_module: TCP/UDP 로드밸런싱
  • ngx_http_perl_module: Perl 스크립트 실행

 

so 파일은 뭘까?

.so 파일은 Shared Object 파일이며, 리눅스/유닉스 시스템에서 사용하는 동적 링크 라이브러리(Dynamic Link Library) 파일이다. 동적모듈 최종 파일이라고 보면 된다.
 

동적모듈의 장점

  • 런타임에 로드: 프로그램 실행 중에 필요할 때 메모리에 적재
  • 코드 공유: 여러 프로그램이 동일한 .so 파일을 공유해서 사용 가능
  • 메모리 효율: 중복 코드를 메모리에 한 번만 로드
  • 독립적 업데이트: 메인 프로그램 재컴파일 없이 라이브러리만 교체 가능
  • 무중단 배포 가능

 

1. 동적모듈 소스 다운로드

nginx version을 확인해서 이와 맞는 모듈 소스코드를 다운받아 서버에 업로드 한 뒤 파일의 압축을 푼다.
나는 nginx version이 1.28이라서 해당 tar.gz 파일을 다운받았다. (버전이 맞지 않는다면 nignx -t에서 오류 발생하기에 버전 확인 필수!)
C 파일(.c)과 헤더 파일(.h), config 파일 등으로 구성되어 있다.
공식 다운로드 사이트: https://nginx.org/download/

tar -xzvf nginx-1.28.0.tar.gz

 
 

2. 동적모듈 빌드

동적모듈을 빌드할때 각종 오류가 나는데 보통 의존성 라이브러리가 설치되어있지 않아 오류가 발생했다.
어떤 모듈인지에 따라 필요한 의존성 라이브러리는 각각 다르며, 오류 문구에서 나오는 라이브러리를 설치하면 된다.

# Nginx 소스 디렉토리로 이동
cd /usr/local/src/nginx-1.28.0

# configure 실행 (동적 모듈 추가)
# 의존성 라이브러리 설치가 모두 완료되어야 오류없이 실행 가능
./configure --with-compat --with-http_xslt_module=dynamic

# 모듈에 따라 실행 문법 확인
./configure --help | grep {모듈명}

# 모듈만 빌드
# 이 명령으로 .so 파일(공유 라이브러리)이 생성 
# 빌드된 .so 파일은 objs/ 디렉토리에 생성
make modules

# 빌드된 .so 파일을 모듈 디렉토리로 복사
# 모듈을 빌드하면 nginx-1.28.0/objs 디렉토리 밑에 so 파일이 생성된다.
cp objs/ngx_http_geoip2_module.so /usr/lib64/nginx/modules/

 

⭐ 동적모듈 빌드 옵션 설명

./configure

Nginx 빌드 환경을 설정하는 스크립트입니다. Makefile을 /usr/local/src/nginx-1.28.0 디렉토리 안에 생성합니다.

--with-compat

호환성 모드 활성화

  • Nginx 바이너리와 동적 모듈 간의 ABI(Application Binary Interface) 호환성을 보장 ( 이미 설치된 Nginx에 나중에 동적 모듈만 따로 추가할 때 필수 옵션)
  • 이게 없으면 모듈이 제대로 로드되지 않을 수 있음

--with-http_xslt_module=dynamic

XSLT 모듈을 동적 모듈로 빌드

  • 모듈에 따라서 --with-{모듈명}=dynamic 형식
  • --with-http_xslt_module: XSLT 모듈 포함
  • =dynamic: 정적이 아닌 동적 모듈(.so 파일)로 빌드하라는 의미
  • =dynamic을 안 붙이면 정적 모듈로 컴파일되며, make modules이 안 먹히고, so 파일도 안 만들어진다.

3. 모듈 로드 및 사용

# nginx.conf 파일 수정
vi /etc/nginx/nginx.conf

# nginx.conf 파일 최상단에 추가
# 모듈을 사용하기 위함
include /usr/share/nginx/modules/*.conf;

 
/usr/share/nginx/modules/ 안에 conf 파일을 만들어서 모듈을 로드한다.

# conf 파일 생성 후 파일 안에 load_module 추가
vi /usr/share/nginx/modules/mod-http-image-filter.conf

# 파일 안에 추가
load_module "/usr/lib64/nginx/modules/ngx_http_image_filter_module.so";

 

4. 설정 확인 및 재시작

# 설정 파일 문법 검사
nginx -t

# Nginx 재시작 (무중단)
systemctl reload nginx

 
설정파일 문법 검사 시, 아래와 같이 OK와 successful을 확인했다면 재시작한다.
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
 
 

정적/동적에 따른 Nginx 재시작 방법

# 정적모듈: Nginx 바이너리 자체가 교체되므로 재시작 필요 (중지 발생)
systemctl restart nginx  

# 동적모듈: .so 파일만 교체 후 reload만으로 가능 (무중단)
systemctl reload nginx

우선 Nginx 설정 파일은 크게 2가지로 나뉜다.
구조를 나눔으로써 운영 영향도를 줄이고 설정 충돌 방지, 서비스 추가/삭제가 쉬우며 롤백이 쉽다.
 

nginx 설정 구조

/etc/nginx/
├── nginx.conf          # 메인 설정 파일
├── conf.d/                # 서버별 설정 include
│   ├── default.conf
│   └── api.conf
│   └── web.conf
 
 

수정 시 영향도 확인

  • nginx.conf 수정 → nginx 전체 영향
  • conf.d/*.conf 수정 → 해당 서비스만 영향

 
 

1. 메인 설정 (Main Config) - /etc/nginx/nginx.conf 

-> nginx 전체 프로세스/동작 방식을 정의하는 루트 설정
 
1-1. 전역(Global) 설정

user  nginx;
worker_processes  auto;
error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

 

  • user : nginx 프로세스 실행 계정
  • worker_processes : 워커 프로세스 개수
  • error_log : 에러 로그
  • pid : PID 파일

2-2. 이벤트 블록
동시에 처리 가능한 커넥션 수

events {
    worker_connections  1024;
}

 
2-3. http 블록
HTTP 서버 전역 설정

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format main '$remote_addr - $remote_user ...';
    access_log /var/log/nginx/access.log main;

    sendfile        on;
    keepalive_timeout 65;

    include /etc/nginx/conf.d/*.conf; //서비스 설정파일 include
    
    map $http_upgrade $connection_upgrade {
    default upgrade;   # 1순위: 아무것도 매칭 안 되면 이거
    '' close;          # 2순위: 빈 문자열이면 이거
    }
}
  • MIME 타입
  • 로그 포맷
  • gzip
  • proxy 관련 기본값
  • SSL 기본값
  • upstream 정의 가능
  • 중요! 서비스별 설정을 직접 다 넣기보다는 include 로 분리하는 구조

 

트러블슈팅

  일반 HTTP WebSocket
연결요청할 때마다 새로 연결한 번 연결하면 계속 유지
방향클라이언트 → 서버 (단방향)양방향 자유롭게
사용 예웹페이지, REST API, 이미지 로딩채팅, 알림, 게임, 실시간 데이터

 
만약에 WebSoket을 사용하는 시스템이라면 서비스 설정 파일 안에 해당 Proxy header가 들어있을것이다.

       proxy_set_header Upgrade $http_upgrade;
       proxy_set_header Connection $connection_upgrade;

 
그렇다면 메인 설정 파일의 http 블록 안에 아래 코드가 들어가야하며 그렇지 않으면 오류 발생한다.

    map $http_upgrade $connection_upgrade {
    default upgrade;   # 1순위: 아무것도 매칭 안 되거나 빈 문자열이 아니면 이거
    '' close;          # 2순위: 빈 문자열이면 이거
    }

 
케이스 1: WebSocket 요청

  • $http_upgrade = "websocket" (Upgrade 헤더 값)
  • 매칭 확인:
    • close 매칭 ❌ (빈 문자열 아님)
    • default upgrade 매칭 ⭕
  • 결과: $connection_upgrade = "upgrade"

케이스 2: 일반 HTTP 요청

  • $http_upgrade = "" (빈 문자열, Upgrade 헤더 없음)
  • 매칭 학인:
    • close 매칭 ⭕ (여기서 끝!)
  • 결과: $connection_upgrade = "close"
  • default는 확인조차 안 함

케이스 3: 이상한 값이 들어온 경우

  • $http_upgrade = "something-weird"
  • 매칭 확인:
    • close 매칭 ❌ (빈 문자열 아님)
    • default upgrade 매칭 ⭕
  • 결과: $connection_upgrade = "upgrade"

 

2. 서비스 설정 (Service / Virtual Host) - /etc/nginx/conf.d/*.conf

HTTPS + WebSocket 예시
아래 코드는 nginx를 proxy server로 사용한 예시이다.

server {
    listen 443 ssl;
    server_name chat.example.com;
    
    # SSL 해독처리로 HTTPS 여기서 처리!
    ssl_certificate /etc/nginx/ssl/cert.pem;
    ssl_certificate_key /etc/nginx/ssl/key.pem;
    
    location / {
        proxy_pass http://localhost:8080; # proxy server
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade; # HTTP/WebSocket 처리
        proxy_set_header Connection $connection_upgrade; # HTTP/WebSocket 처리
        proxy_set_header Host $host;
    }
}

 
 

1. HTTPS 요청 (일반 웹페이지)

브라우저 → https://chat.example.com (443 포트)
         → nginx (SSL 해독)
         → http://localhost:8080 (평문 HTTP로 전달)

 

2. WebSocket 연결 (wss://)

브라우저 → wss://chat.example.com (443 포트)
         → nginx (SSL 해독 + WebSocket 업그레이드)
         → ws://localhost:8080 (평문 WebSocket으로 전달)

 

Proxy Server란 뭘까?

-> 중간 다리 역할을 해주는 서버이다.
-> Apache (with mod_proxy), Caddy, Envoy 같은 Proxy Server가 존재한다.
 
사용자 브라우저 → nginx (80포트) → Node.js 앱 (3000포트)
 
사용자는 80, 443 인터넷 포트만 알고 접속하면 알아서 여러 서버의 포트로 연결시켜준다.
 

  • 보안: 실제 서버(3000포트) 직접 노출 안 함
  • 로드밸런싱: 여러 서버에 트래픽 분산
  • SSL 처리: nginx가 HTTPS 처리, 앱은 HTTP만
  • 정적 파일: 이미지 같은 건 nginx가 직접 서빙

왜 nginx가 정적 파일 서빙에 빠를까?

-> 이미지 요청 → nginx가 디스크에서 바로 읽어서 전송하기 때문!
 

  • 엄청 빠름: C로 작성됨, 파일 읽기에 최적화
  • 백엔드 부하 감소: Node.js가 파일 처리 안 해도 됨 ( 이미지 요청 → nginx → Node.js → 파일 읽기 → nginx → 브라우저 ) 
  • 효율적 메모리: 파일 캐싱, sendfile 시스템콜 사용

 
 

+ Recent posts