[프로젝트]/[백엔드] LAITEU

EC2에서 Multi-Version API 서버 구현하기

danhan 2024. 11. 20. 15:39

들어가며

Laiteu의 포트폴리오 서비스를 운영하면서 큰 구조 변경이 필요한 시점이 왔습니다. V1 API는 MVP 단계에서 빠르게 구현되어 기본적인 기능을 제공했지만, 서비스가 성장하면서 새로운 요구사항들이 늘어났습니다. 특히 기존 API 구조로는 수용하기 어려운 데이터 모델의 근본적인 변경이 필요했고, API 응답 포맷도 전면적인 수정이 불가피했습니다.

이러한 변경사항을 적용하면서도 이미 서비스 중인 웹 서비스와의 호환성을 유지해야 하는 과제가 있었습니다. 이에 따라 API 버전을 분리하는 전략을 선택했고, EC2 인스턴스 내에서 여러 버전의 API 서버를 동시에 운영하는 구조를 구현하게 되었습니다. 이 글에서는 다중 버전 API 서버 구현 과정에서 겪은 기술적 도전과 해결 방법을 공유하고자 합니다.

실제 구현을 위한 초기 설정

Nginx와 리버스 프록시의 역할

다중 버전 API 서버를 구현하기 위해 가장 먼저 고려한 것은 트래픽 라우팅 방식이었습니다. Nginx를 리버스 프록시로 활용하면 클라이언트의 요청을 URL 경로에 따라 적절한 백엔드 서버로 전달할 수 있습니다. 리버스 프록시란 클라이언트의 요청을 받아 적절한 내부 서버로 전달하는 중간 서버를 말합니다. 예를 들어, /v1로 시작하는 요청은 기존 API 서버로, /v2로 시작하는 요청은 새로운 API 서버로 라우팅할 수 있습니다.

Docker를 활용한 API 서버 격리

각 버전의 API 서버는 독립된 Docker 컨테이너에서 실행되어야 했습니다. 이를 위해 다음과 같이 Dockerfile을 구성했습니다:

FROM openjdk:17-jdk

WORKDIR /app
COPY build/libs/laiteu-be-0.0.1-SNAPSHOT.jar app.jar

EXPOSE 8081

ENTRYPOINT ["java", "-Dspring.profiles.active=docker", "-jar", "app.jar"]

컨테이너를 실행할 때는 포트 매핑을 통해 호스트의 포트와 연결했습니다:

sudo docker run -d -p 8081:8081 danhandev/v2

여기서 포트 바인딩 설정인 8081:8081의 의미를 자세히 살펴볼 필요가 있습니다. 첫 번째 8081은 호스트의 포트를, 두 번째 8081은 컨테이너 내부의 포트를 의미합니다. 이를 통해 호스트의 8081 포트로 들어온 모든 요청이 컨테이너의 8081 포트로 포워딩됩니다.

전체 네트워크 흐름은 다음과 같습니다:

Client Request → Nginx(80) → Host(8081) → Container(8081) → Spring Boot(8081)

Nginx 설정

Nginx 설정에서는 각 API 버전에 대한 라우팅 규칙을 정의했습니다:

server {
        listen       80;
        listen       [::]:80;
        server_name  api.laiteu.com;
        root         /usr/share/nginx/html;

        include /etc/nginx/default.d/*.conf;

        # v1 API로 라우팅 (기존)
        location /v1/ {
            proxy_pass <http://localhost:8080/v1/>;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        # v2 API로 라우팅 (새로 추가)
        location /v2/ {
            proxy_pass <http://localhost:8081/v2/>;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        # 기본 라우팅 (v1으로)
        location / {
            proxy_pass <http://localhost:8080/v1/>;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
          }

      error_page 404 /404.html;
      location = /404.html {
      }

      error_page 500 502 503 504 /50x.html;
      location = /50x.html {
      }
  }                                                         

Nginx 설정 상세 분석

Nginx 설정에서는 여러 버전의 API를 효율적으로 라우팅하기 위해 세 가지 location 블록을 사용했습니다. 먼저 기본적인 서버 설정으로 80번 포트에서 들어오는 HTTP 요청을 수신하도록 했고, IPv4와 IPv6 모두를 지원하기 위해 listen [::]:80을 추가했습니다. server_name을 통해 api.laiteu.com 도메인으로 들어오는 요청만을 처리하도록 설정했습니다.

각 API 버전에 대한 라우팅은 다음과 같이 구성했습니다:

# V1 API 라우팅
location /v1/ {
    proxy_pass <http://localhost:8080/v1/>;
}

# V2 API 라우팅
location /v2/ {
    proxy_pass <http://localhost:8081/v2/>;
}

# 기본 라우팅 (V1으로)
location / {
    proxy_pass <http://localhost:8080/v1/>;
}

이 설정에서 특히 중요한 점은 proxy_pass 지시자의 URI 경로 처리 방식입니다. proxy_pass의 끝에 슬래시(/)를 포함시키면 요청 URI의 경로가 그대로 유지되어 백엔드 서버로 전달됩니다. 예를 들어 클라이언트가 /v2/artists/123으로 요청을 보냈을 때, 슬래시가 있는 경우에는 location 패턴(/v2/)이 매칭된 부분이 유지되어 전달되고, 슬래시가 없는 경우에는 매칭된 부분이 제거되어 잘못된 경로로 전달될 수 있습니다.

  1. 슬래시가 있는 경우 (proxy_pass <http://localhost:8081/v2/>)
    • location 패턴(/v2/)이 매칭된 부분이 그대로 유지됨
    • 최종 요청: http://localhost:8081/v2/artists/123
  2. 슬래시가 없는 경우 (proxy_pass http://localhost:8081)
    • location 패턴이 매칭된 부분이 제거되고 나머지 경로만 추가됨
    • 최종 요청: http://localhost:8081artists/123

저희 서비스의 경우 Spring Boot 애플리케이션이 /v2로 시작하는 경로로 설정되어 있기 때문에, proxy_pass 끝에 슬래시를 포함시켜 전체 경로가 그대로 전달되도록 했습니다. 이는 백엔드 서버의 라우팅 구조와 일치시키기 위한 중요한 설정이었습니다.

각 location 블록에서는 다음과 같은 프록시 헤더들을 설정하여 원본 요청의 정보를 백엔드 서버에 전달합니다:

  • Host: 클라이언트가 요청한 원본 도메인 정보
  • X-Real-IP: 실제 클라이언트의 IP 주소
  • X-Forwarded-For: 프록시 서버를 거치며 누적된 IP 주소 목록
  • X-Forwarded-Proto: 클라이언트가 사용한 프로토콜(http/https)

마지막으로 에러 처리를 위해 404와 50x 에러에 대한 페이지를 지정했습니다. 특히 502 Bad Gateway와 같은 프록시 관련 에러가 발생했을 때 사용자에게 적절한 에러 페이지를 보여줄 수 있도록 했습니다.

502 Bad Gateway 에러와의 싸움

초기 설정을 완료하고 첫 테스트를 진행했을 때, 502 Bad Gateway 에러가 발생했습니다. 문제를 해결하기 위해 단계적으로 원인을 분석했습니다.

문제 발견과 원인 분석

Nginx 에러 로그를 확인해보니 다음과 같은 메시지가 있었습니다:

2024/11/14 14:40:09 [error] 30744 #30744: *5 recv() failed (104: Connection reset by peer)
while reading response header from upstream

로그 분석 결과, Spring Boot 애플리케이션의 포트(8080)와 Docker 컨테이너의 포트 매핑(8081)이 일치하지 않는 것이 문제였습니다.

시스템 상태 확인

문제 해결을 위해 각 계층별로 상태를 확인했고, 포트 설정의 불일치를 파악할 수 있었습니다.

# Docker 컨테이너 상태 확인
sudo docker ps
# 포트 리스닝 상태 확인
sudo netstat -tulpn | grep LISTEN
# Nginx 에러 로그 확인
sudo tail -f /var/log/nginx/error.log

해결 방안 적용

Spring Boot 애플리케이션의 포트를 Docker 컨테이너 포트와 일치하도록 수정했습니다:

server.port=8081

이후 애플리케이션을 재배포하고 Nginx를 재시작하니 문제가 해결되었습니다.

실무 적용 시 주의사항

이번 프로젝트를 통해 몇 가지 중요한 점을 배웠습니다:

  1. 포트 설정의 일관성
    • 애플리케이션, Docker, Nginx 설정의 포트가 모두 일치해야 합니다.
      • 애플리케이션 포트
      • Dockerfile의 EXPOSE
      • docker run의 포트 매핑
    • 설정 변경 시 모든 계층의 포트 설정을 함께 검토해야 합니다.
  2. 로그 기반 디버깅
    • 문제 발생 시 각 계층별 로그를 순차적으로 확인하는 것이 중요합니다.
    • 로그 메시지를 통해 문제의 정확한 원인을 파악할 수 있습니다.
  3. Nginx 설정 관리
    • 설정 변경 전 반드시 nginx -t 명령어로 문법을 검증해야 합니다.
    • 운영 환경 적용 전 테스트 환경에서 충분한 검증이 필요합니다.

마치며

Docker 컨테이너와 Nginx를 이용한 다중 버전 API 운영 환경을 구축하면서, 각 계층별 설정의 정확성과 일관성이 얼마나 중요한지 깊이 이해할 수 있었습니다. 특히 포트 매핑과 라우팅 설정 과정에서 겪은 시행착오들은 실제 운영 환경에서 마주할 수 있는 다양한 문제들을 해결하는 좋은 경험이 되었습니다.

결과적으로 V2 API를 개발하는 동안 V1은 안정적으로 유지할 수 있었고, 클라이언트 개발팀에게도 충분한 마이그레이션 기간을 제공할 수 있었습니다. 이는 기존 사용자들의 서비스 이용에 영향을 주지 않으면서도 새로운 기능을 자유롭게 구현할 수 있는 기반이 되었습니다.

다음에는 이번에 구축한 다중 버전 API 환경을 기반으로, 더 체계적인 CI/CD 파이프라인을 구축하고 모니터링 시스템을 도입하는 것이 목표입니다. 이번 경험을 통해 얻은 인사이트들이 향후 시스템 확장과 운영 고도화에 중요한 밑거름이 될 것으로 기대합니다.