우리 서버는 왜 계속 죽었을까?

Jayson·2025년 7월 3일
0
post-thumbnail

서버는 왜 자꾸 죽었을까? 개발-배포-운영 파이프라인 안정성 강화 여정기 (1)

터닝 프로젝트를 1년 동안 유지보수 해오면서 많은 일들이 있었다. 그중에서도 서버 쪽을 보면 이해가 안 가는 포인트들이 몇몇 있었다. 제일 아쉬운 건 사용자나 팀원들한테 "서버 터졌어요"라는 말을 듣는 거였다. 모니터링 시스템 만들자고 계속 얘기했지만 여러 이유로 미뤄졌고, 너무 답답해서 혼자 어찌어찌 만들긴 했는데 이것도 결국 다 돈이라... 다시 설득할 근거가 필요했다.

그래서 이참에 개발 프로세스 전체를 처음부터 다시 짚어보면서, 원인도 찾고 해결하는 과정을 기록해보기로 했다.

그래서, 뭐가 문제였냐면

이런 문제들을 겪고 있었다.

  1. 이유 없이 CPU가 터지면서 서버가 죽는 문제: 로그를 보면 이상한 JWT 토큰으로 API를 부를 때 CPU가 치솟았다. 아니, 서버가 만들어준 토큰인데 왜 이상한 모양으로 요청이 오는 건지, 비정상적인 요청을 막을 방법은 없는지... 정확한 원인을 몰라 답답했다.
  2. 배포만 돌리면 서버가 죽는 문제: 우리는 Github Actions랑 Docker로 블루/그린 배포를 쓰고 있다. 근데 staging 브랜치에 머지해서 CD를 돌리면 높은 확률로 서버가 죽었다. 결국 자동 배포라기보다, EC2에 직접 들어가서 Docker 이미지를 지우고 다시 실행하는 수동 배포를 하고 있었다. (이게 맞나...)
  3. 배포 스크립트 보려면 서버에 직접 들어가야 하는 문제: 배포 로직이 deploy-staging.sh 같은 파일로 EC2 서버 안에 숨어있었다. 하나 고치거나 확인하려면 무조건 SSH로 서버에 접속해서 파일을 열어봐야 하는 번거로움이 있었다.
  4. 믿을 수 없는 테스트 코드: 새 기능이 기존 API를 망가뜨리는지 확인해 줄 테스트 코드가 거의 없었다. 배포할 때마다 Postman으로 일일이 찍어봐야 해서 너무 불안했다.
  5. 짠내나는 EC2/RDS 성능: 비용 때문에 낮은 사양을 쓰고는 있는데, 이것 때문에 얼마나 힘든 건지 명확한 근거가 없어서 "서버 사양 좀 올려주세요!"라고 자신 있게 말하기가 어려웠다.

하나씩 뜯어보기 시작했다

일단 내 로컬 환경부터 차근차근 뜯어보기로 했다.

1단계: 내 컴퓨터부터 정리하자

오랫동안 쌓여있던 자잘한 문제부터 해결했다. QueryDSL 의존성이 꼬여서 빌드도 안 되는 문제를 해결하고, 아무도 안 돌려보던 테스트 코드를 돌려봤다. (지금 생각해보면 팀원들이 테스트 코드를 안 돌려봐서 이런 문제를 몰랐던 것 같다, 테스트 코드 없기도하고,,) 일단 지금은 테스트 코드 고치는 것보다 시급한 게 많아서 빌드만 되게끔 하고 넘어가기로 했다.

개발 초부터 계속 뜨던 Lombok @Builder 경고 메시지도 이번 기회에 @Builder.Default를 붙여서 간단하게 해결했다.

// 맨날 보던 경고 메시지
warning: @Builder will ignore the initializing expression entirely.
private List<Scrap> scrapList = new ArrayList<>();

// 이렇게 간단하게 해결!
@Builder.Default
private List<Scrap> scrapList = new ArrayList<>();

이렇게 정리하고 나니 일단 내 로컬에서는 앱이 잘 돌아가는 걸 확인할 수 있었다.

2단계: 설정 파일(yml)과 환경 변수, 범인은 이 안에 있다!

다음으로 설정 파일들을 분석했는데, 여기서 핵심 문제를 발견했다.

  • 서로 다른 설정값: 내 컴퓨터 application.yml에 적힌 값이랑 서버 환경 변수 값이 달랐다. 스프링 부트는 환경 변수를 1순위로 읽기 때문에, 실제 서버는 내가 생각한 거랑 전혀 다른 설정으로 돌고 있었다. (예: JWT 만료 시간 yml엔 30일, 서버엔 1시간...)
  • 하드코딩된 비밀번호: DB 정보, JWT 비밀키 같은 민감 정보들이 yml 파일에 그냥 대놓고 적혀있었다.
  • 위험천만한 ddl-auto: update: 개발할 땐 편하지만, 이게 운영 서버까지 잘못 건드릴 수 있는 위험한 구조였다.

3단계: 안전하고 깔끔하게 리팩토링

찾아낸 문제들을 해결하기 위해 대대적인 공사를 시작했다.

  1. 설정 파일 템플릿으로 만들기: application.yml에 있던 비밀번호 같은 정보들을 다 ${...} 이런 식으로 바꿨다. 이제 yml 파일은 그냥 껍데기고, 진짜 값은 배포할 때 밖에서 넣어주게 되니 훨씬 안전해졌다.
  2. 비밀 정보는 Github Secret으로: 모든 비밀키는 Github Actions의 Secret으로 옮겨서 코드랑 완벽하게 분리했다.
  3. 배포 스크립트 구출 작전: 맨날 서버 들어가서 고치던 .sh 파일을 Github Actions 워크플로우(.yml) 안으로 전부 옮겨왔다. 이제 배포 로직도 깃으로 관리할 수 있고, 더 이상 무섭게 서버에 직접 들어갈 필요가 없어져서 너무 편해졌다.
  4. 환경별로 깔끔하게 분리: application-dev.yml, application-prod.yml 파일을 만들어서 환경마다 설정을 다르게 할 수 있도록 구조를 싹 바꿨다.

4단계: 배포 실패, 드디어 범인을 찾았다

설정 문제를 해결하고 나니, 배포가 왜 그렇게 실패했는지 진짜 원인이 보이기 시작했다. 범인은 바로 블루/그린 배포할 때 잠깐 생기는 메모리 부족(OOM) 현상이었다.

docker stats 명령어로 확인해보니, 평소에도 우리 서버는 메모리를 43%나 쓰고 있었다. 블루/그린 배포는 새 버전을 먼저 띄우고 옛날 버전을 끄니까, 아주 잠깐 컨테이너 2개가 동시에 돌아가는 순간이 생긴다.

원래 쓰던 거(43%) + 새로 띄운 거(43%) = 순간 메모리 (86% 이상)

결국 메모리가 부족해지면 리눅스가 스스로를 지키기 위해 프로세스를 강제로 죽여버리는데(OOM Killer), 이게 바로 우리가 겪었던 "서버 다운"의 실체였던 거다.

다음 이야기...

일단 이번 글에서는 우리가 겪던 문제들을 진단하고, 설정 파일을 뜯어고쳐서 CI/CD 파이프라인을 개선하는 과정까지 이야기해봤다.

하지만 아직 진짜 문제인 배포할 때 메모리가 터지는 문제JWT 때문에 CPU가 터지는 문제는 아직 해결하지 못했다. 다음 글에서는 이 문제들을 어떻게 해결했는지, 그 삽질의 과정을 계속 이야기해보겠다.

profile
Small Big Cycle

0개의 댓글