본문 바로가기
고민의 흔적../KAU-Notice-Hub

안정적으로 인프라를 구축해보자!

by sungjinp0805 2026. 5. 23.

최근 KAU-NoticeHub를 개발하면서 Vercel에 배포한 프론트엔드에서 가끔 502 오류가 발생했다.

더 이상한 건 새로고침하면 다시 정상적으로 동작했다는 점이었다.

처음에는 단순한 서버 오류라고 생각했지만, Vercel 로그와 서버 로그를 함께 확인해 보니 원인은 OOM(Out Of Memory)이었다.


원인 분석

Vercel 로그에는 다음과 같은 오류가 남아 있었다.

BackendApiError: 공지 목록을 불러오지 못했습니다.
status: 502

502는 애플리케이션이 직접 반환한 오류가 아니라 Reverse Proxy가 백엔드에 연결하지 못했을 때 발생하는 상태 코드다.

현재 서비스 구조는 다음과 같다.

  • Frontend: Next.js (Vercel)
  • Reverse Proxy: Caddy
  • Backend: FastAPI + Uvicorn

그래서 서버에 접속해 OOM 흔적부터 확인했다.

sudo dmesg -T | grep -iE 'killed process|out of memory'

결과는 예상대로였다.

Out of memory: Killed process (uvicorn)

메모리가 부족해지면서 Linux의 OOM Killer가 Uvicorn 프로세스를 종료하고 있었고,

백엔드가 사라진 순간 Caddy가 upstream에 연결하지 못하면서 502가 발생했던 것이다.

 

그렇다면 왜 OOM이 발생한 걸까?


 

당시 구조의 문제

당시 KAU-NoticeHub는 크롤링한 공지를 JSON 파일로 저장하고 있었다. (간편해서...)

그리고 서버가 시작될 때 JSON 파일 전체를 메모리에 올려 API 요청을 처리하는 구조였다.

Crawler
    ↓
notice.json
    ↓
FastAPI 시작
    ↓
JSON 전체 메모리 로드
    ↓
API 응답

처음에는 큰 문제가 없었지만, 공지가 계속 누적되면서 JSON 파일도 점점 커졌다.

데이터가 커질수록 메모리에 올라가는 데이터도 함께 증가했고, 결국 메모리 부족으로 프로세스가 종료되는 상황까지 발생했다.


SQLite로 구조 변경

그래서 데이터 저장 방식을 SQLite로 변경했다.

JSON 전체를 메모리에 올리는 대신, 요청이 들어올 때 필요한 데이터만 SQLite에서 조회하도록 구조를 변경했다.

Crawler
    ↓
SQLite
    ↓
API 요청
    ↓
필요한 데이터만 조회

구조를 변경한 뒤에는 더 이상 JSON 전체를 메모리에 유지할 필요가 없어졌다.

이렇게 OOM의 직접적인 원인은 해결했다.

 

하지만 한 가지 의문이 남았다.

정말 이제는 안정적인 걸까?

 

평소에는 잘 동작하더라도, 트래픽이 몰리는 상황에서는 또 다른 병목이 발생할 수도 있었다.

그래서 이번에 소프트웨어 공학 과목에서 배운 부하 테스트를 진행해보기로 했다.


k6 부하 테스트

부하 테스트 목표는 두 가지였다.

  • 서버의 무릎 지점(Knee Point) 확인
  • 동시 사용자 100명을 버틸 수 있는지 확인

실제 프로덕션 환경에서 100명 이상이 동시에 요청을 보내는 경우는 희박할 것이라고 판단했다.

테스트 환경은 다음과 같았다.

  • AWS Lightsail (RAM 911MiB)
  • FastAPI + Uvicorn
  • SQLite
  • Caddy Reverse Proxy

부하는 k6를 사용해 생성했다.

실제 사용 패턴과 비슷하도록 다음과 같은 비율로 요청을 구성했다.

  • 헬스 체크 15%
  • 공지 목록 55%
  • 검색 20%
  • 상세 조회 10%

다시 나타난 OOM

100명의 동시 사용자를 적용하자 서버가 다시 종료됐다.

다시 확인해 보니 이번에도 OOM이었다.

 

다만 이번에는 원인이 달랐다.

JSON를 메모리에 올리던 구조는 이미 제거한 상태였다.

 

문제는 RAM 911MiB라는 서버 자체의 한계였다.

메모리 상태를 확인해 보니 다음과 같았다.

free -h
Mem:    911Mi
Swap:   0B

 

Swap이 전혀 설정되어 있지 않았다.

즉, 메모리가 부족해지는 순간 운영체제가 선택할 수 있는 방법은 프로세스를 종료하는 것뿐이었다.


조치 1 — Swap 추가

우선 서버가 종료되지 않도록 Swap을 추가했다.

Swap은 디스크 일부를 가상 메모리로 사용하는 기능이다.

 

메모리가 부족하면 당장 사용하지 않는 메모리 페이지를 디스크로 이동시켜 OOM 발생을 늦출 수 있다.

2GB Swap을 추가하고, cgroup의 MemoryHigh와 MemoryMax도 함께 설정해 메모리 사용량이 한계에 가까워질 때 회수 압박이 먼저 발생하도록 구성했다.

 

Swap은 성능을 높여주는 기능은 아니다.

하지만 서버가 종료되는 것보다 잠시 느려지는 편이 훨씬 안전하다.


조치 2 — Uvicorn 워커 2개

다음으로 처리 성능을 개선하기 위해 Uvicorn 워커를 2개로 변경했다.

서버는 2개의 vCPU를 사용하고 있었지만 기존에는 단일 워커만 실행하고 있었다.

 

워커를 2개로 변경하면서 두 코어를 모두 활용할 수 있게 됐다.

다만 워커를 늘리면 메모리 사용량도 증가한다.

그래서 순서가 중요했다.

 

먼저 Swap으로 서버가 종료되지 않도록 안전망을 만들고, 이후 워커를 늘려 처리 성능을 개선했다.


결과

부하 테스트 결과 약 동시 사용자 20명 부근에서 Knee Point가 나타났다.

이후에는 처리량 증가보다 응답 지연 증가가 더 크게 나타났다.

 

그리고 처음 테스트에서는 100명 부하에서 서버가 종료됐지만,

개선 이후에는 동시 사용자 100명까지 서버가 종료되지 않는 것을 확인했다.


마무리

이번 작업을 하면서 같은 OOM이라도 원인은 서로 달랐다.

 

처음에는 애플리케이션 구조의 문제였다.

JSON 데이터를 메모리에 모두 올리는 구조를 SQLite로 변경하면서 해결했다.

 

그 이후 부하 테스트에서는 인프라의 한계가 드러났다.

911MiB RAM 환경에서는 순간적인 메모리 스파이크만으로도 프로세스가 종료될 수 있었고,

Swap과 메모리 가드, 워커 구성을 통해 이를 개선할 수 있었다.

 

처음에는 단순히 502 오류 하나를 해결하려고 시작했는데,

결과적으로는 애플리케이션 구조와 서버 운영 환경까지 함께 점검할 수 있는 계기가 됐다.

 

공부할 게 참 많다..