CoreERP AWS - EC2, Docker, RDS, Redis, Cloudflare Pages, Nginx, HTTPS 과정 기록

최병현·2026년 3월 28일

coreerp project

목록 보기
44/44

이번 작업에서는 CoreERP 프로젝트를 로컬 개발 환경에서 벗어나 실제 외부에서 접속 가능한 서비스 구조로 올리는 작업을 진행했다. 기존에는 Spring Boot 백엔드와 React 프론트엔드를 각각 로컬에서 실행하며 기능을 검증했다면, 이번에는 백엔드는 AWS EC2 위에서 Docker로 구동하고, 데이터베이스는 RDS MariaDB로 분리하고, 토큰 관리에 필요한 Redis도 함께 연결했으며, 프론트엔드는 Cloudflare Pages로 배포해 실제 서비스처럼 접속 가능한 구조를 완성하는 것이 목적이었다.

이번 작업은 단순히 “배포했다” 수준이 아니라, 프론트엔드와 백엔드가 서로 다른 환경에서 통신하는 구조를 만들고, 도메인과 HTTPS까지 연결해 브라우저 기준으로도 정상 동작하도록 만드는 과정이었다. 특히 이번 배포는 단순 CRUD 프로젝트 배포와 달리, 인증, CORS, Reverse Proxy, 환경변수, DNS, SSL 같은 실무적인 이슈를 함께 다뤄야 했기 때문에 배운 점이 매우 많았다.


1. 이번 단계의 최종 목표

이번 배포 단계에서 최종적으로 만들고자 했던 구조는 아래와 같았다.

  • 프론트엔드: Cloudflare Pages
  • 백엔드: AWS EC2 + Docker + Nginx + Spring Boot
  • 데이터베이스: AWS RDS MariaDB
  • 토큰 저장/로그아웃 처리: Redis
  • 도메인: coreerp.kr / api.coreerp.kr
  • 보안: HTTPS 적용

즉 사용자는 coreerp.kr 로 프론트엔드에 접속하고, 프론트엔드는 api.coreerp.kr 로 백엔드 API를 호출하며, 백엔드는 RDS와 Redis를 활용해 실제 인증과 데이터 처리를 수행하는 구조를 목표로 했다.


2. 전체 아키텍처 구조

이번 배포 이후 CoreERP의 전체 구조는 다음과 같이 정리할 수 있다.

브라우저 사용자는 Cloudflare Pages에 배포된 React 프론트엔드에 접속한다. 프론트엔드는 API 요청이 필요할 때 Spring Boot 백엔드로 직접 요청하지 않고, 도메인 기준으로는 https://api.coreerp.kr/api 주소를 통해 요청을 보낸다. 이 요청은 EC2에 설치한 Nginx가 먼저 받고, Nginx가 내부의 Spring Boot 8080 포트로 Reverse Proxy를 수행한다. Spring Boot 애플리케이션은 실제 비즈니스 로직을 처리하며, DB는 RDS MariaDB에 연결되고, 리프레시 토큰과 로그아웃 관련 처리는 Redis를 활용하는 구조다.

즉 프론트엔드와 백엔드가 서로 다른 계층으로 완전히 분리되어 있고, 브라우저 기준으로도 HTTPS가 적용된 서비스 형태가 만들어졌다.


3. 백엔드 배포 전 정리한 핵심 포인트

백엔드를 EC2에 올리기 전에 먼저 기존 로컬 전용 구조를 운영 가능한 구조로 정리해야 했다. 핵심은 로컬 DB를 그대로 쓰는 것이 아니라, 외부 DB인 RDS로 전환하고, Redis를 함께 연결하는 것이었다.

또한 Spring Boot는 Docker 컨테이너로 실행하고, 환경변수 기반으로 데이터소스와 JWT 설정값을 읽도록 구성했다. 이렇게 해야 민감한 값을 코드에 직접 넣지 않고도 운영 환경에서 안전하게 관리할 수 있다.

이번 배포 기준 백엔드 실행 흐름은 다음과 같았다.

services:
  app:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: coreerp-app
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL}
      SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME}
      SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD}

      SPRING_DATA_REDIS_HOST: ${SPRING_DATA_REDIS_HOST}
      SPRING_DATA_REDIS_PORT: ${SPRING_DATA_REDIS_PORT}
      SPRING_DATA_REDIS_PASSWORD: ${SPRING_DATA_REDIS_PASSWORD}

      COREERP_AUTH_COMPANY_CODE: ${COREERP_AUTH_COMPANY_CODE}
      COREERP_AUTH_JWT_SECRET: ${COREERP_AUTH_JWT_SECRET}
      COREERP_AUTH_JWT_ACCESS_EXPIRATION: ${COREERP_AUTH_JWT_ACCESS_EXPIRATION}
      COREERP_AUTH_JWT_REFRESH_EXPIRATION: ${COREERP_AUTH_JWT_REFRESH_EXPIRATION}

      JAVA_OPTS: ${JAVA_OPTS}
    depends_on:
      - redis
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    container_name: coreerp-redis
    ports:
      - "6379:6379"
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    restart: unless-stopped

volumes:
  redis_data:

여기서 핵심은 Spring Boot가 직접 DB와 Redis 연결 정보를 갖는 것이 아니라, 운영 환경의 .env 파일을 통해 값을 전달받는 구조라는 점이었다. 이 방식은 실무에서도 가장 기본적인 패턴 중 하나이고, GitHub에 민감 정보를 올리지 않으면서도 서버에서는 정상 실행되도록 만드는 핵심 기반이 된다.


4. 프론트엔드 API 연결 구조 수정

프론트엔드도 배포 전에 수정해야 할 부분이 있었다. 로컬 개발에서는 Vite dev server proxy를 사용하면 /api 형태의 상대경로 호출이 자연스럽게 동작하지만, Cloudflare Pages에 배포하면 dev server 자체가 없기 때문에 이 구조만으로는 운영에서 통신이 되지 않는다.

그래서 프론트엔드 axios 설정을 환경변수 기반으로 바꾸는 작업이 필요했다. 즉 개발 환경에서는 로컬 백엔드를 바라보고, 운영 환경에서는 실제 배포된 백엔드 도메인을 바라보도록 구조를 분리해야 했다.

const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;

const apiClient = axios.create({
  baseURL: API_BASE_URL,
  timeout: 10000,
  headers: {
    "Content-Type": "application/json",
  },
});

이 구조로 바꾸면 프론트엔드는 더 이상 하드코딩된 주소를 직접 알 필요가 없고, Cloudflare Pages의 환경변수만 바꾸면 운영 주소를 손쉽게 교체할 수 있다.

운영 배포 기준으로는 다음 값이 핵심이었다.

VITE_API_BASE_URL=https://api.coreerp.kr/api

이 작업을 하면서 프론트 전체에서 하드코딩된 URL이 없는지도 함께 점검했다. 즉 localhost:8080, EC2 퍼블릭 IP, /api/api 중복 호출 같은 문제가 없는지 전체 검색을 통해 확인했다.


5. Spring Security와 CORS 설정 정리

프론트엔드와 백엔드가 서로 다른 도메인에서 동작하게 되면 반드시 CORS 설정이 필요하다. 특히 이번 구조는 JWT를 Authorization Header로 보내는 Stateless 인증 방식이기 때문에, 쿠키 기반 인증과는 다르게 CORS 설정도 그 구조에 맞게 정리해야 했다.

SecurityConfig 쪽은 이미 Stateless JWT 구조로 잘 잡혀 있었고, 핵심은 CORS 설정 파일을 운영 도메인 기준으로 보완하는 것이었다.

@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserRepository userRepository;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .cors(cors -> {})
                .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .exceptionHandling(ex -> ex
                        .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                        .accessDeniedHandler(jwtAccessDeniedHandler)
                )
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(
                                "/api/auth/signup",
                                "/api/auth/login",
                                "/api/auth/refresh",
                                "/api/auth/logout",
                                "/api/auth/check-login-id",
                                "/api/auth/check-email",
                                "/swagger-ui/**",
                                "/v3/api-docs/**"
                        ).permitAll()
                        .requestMatchers(HttpMethod.GET, "/api/users").hasRole("MASTER")
                        .requestMatchers(HttpMethod.PATCH, "/api/users/**").hasRole("MASTER")
                        .requestMatchers(HttpMethod.GET, "/api/audit-logs/**").hasRole("MASTER")
                        .anyRequest().authenticated()
                )
                .addFilterBefore(
                        new JwtAuthenticationFilter(jwtTokenProvider, userRepository),
                        UsernamePasswordAuthenticationFilter.class
                );

        return http.build();
    }
}

그리고 CORS는 다음과 같이 운영 도메인을 허용하도록 수정했다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins(
                        "http://localhost:5173",
                        "https://coreerp.kr",
                        "https://www.coreerp.kr",
                        "https://coreerp.pages.dev"
                )
                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(false)
                .maxAge(3600);
    }
}

여기서 중요한 점은 이번 인증 구조가 Cookie 기반이 아니라 Header 기반이라는 점이다. 따라서 allowCredentials(true) 보다는 현재 구조에 맞게 false 로 두는 것이 더 자연스럽고 관리하기 쉽다.


6. EC2에서 .env 파일을 직접 구성한 이유

이번 배포에서 가장 크게 체감한 부분 중 하나는, 운영 환경에서는 .env 파일이 GitHub에 올라가지 않기 때문에 서버에서 직접 생성해야 한다는 점이었다. 실제로 GitHub에서 코드를 pull 받은 뒤 바로 컨테이너를 재실행했더니, Spring Boot가 데이터소스 URL을 찾지 못해 부팅에 실패했다.

이 문제의 본질은 코드가 아니라 환경변수 공급원이 없다는 점이었다. 즉 Docker Compose는 ${SPRING_DATASOURCE_URL} 같은 값을 기대하고 있었지만, 서버에 .env 파일이 없으니 전부 빈 문자열로 들어가 버린 것이다.

그래서 EC2 서버에서 직접 .env 파일을 생성하고 DB, Redis, JWT 관련 값을 다시 입력해주어야 했다.

SPRING_DATASOURCE_URL=jdbc:mariadb://your-rds-endpoint:3306/coreerp
SPRING_DATASOURCE_USERNAME=admin
SPRING_DATASOURCE_PASSWORD=your-password

SPRING_DATA_REDIS_HOST=redis
SPRING_DATA_REDIS_PORT=6379
SPRING_DATA_REDIS_PASSWORD=your-redis-password

COREERP_AUTH_COMPANY_CODE=COREERP
COREERP_AUTH_JWT_SECRET=your-jwt-secret
COREERP_AUTH_JWT_ACCESS_EXPIRATION=3600000
COREERP_AUTH_JWT_REFRESH_EXPIRATION=1209600000

REDIS_PASSWORD=your-redis-password
JAVA_OPTS=

이후 docker compose up -d --build 로 다시 실행했을 때, Spring Boot 로그에 Started 가 찍히며 정상 부팅되는 것을 확인할 수 있었다.


7. Nginx Reverse Proxy 적용

백엔드 도메인을 깔끔하게 운영하려면 브라우저가 직접 8080 포트로 접근하는 것이 아니라, Nginx가 80/443 포트를 받아서 내부의 Spring Boot 8080으로 연결해주는 구조가 필요하다. 이번 배포에서는 이 역할을 Nginx가 담당했다.

초기에는 Nginx 기본 welcome 페이지가 나와서 설정이 안 먹는 것처럼 보였지만, 실제로는 server_name 매칭과 default server 처리 때문에 생긴 문제였다. 결국 Nginx가 확실하게 모든 요청을 잡아 Spring Boot로 넘기도록 설정을 정리했다.

server {
    listen 80 default_server;
    listen [::]:80 default_server;

    server_name _;

    location / {
        proxy_pass http://localhost:8080;
        proxy_http_version 1.1;

        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;
    }
}

이렇게 구성하고 나서 api.coreerp.kr 요청이 Nginx를 거쳐 정상적으로 Spring Boot 애플리케이션으로 전달되는 것을 확인했다.


8. 도메인과 Cloudflare Pages 연결

프론트엔드는 Cloudflare Pages로 배포했다. 처음에는 Cloudflare UI에서 Worker 생성 화면으로 잘못 들어가 혼동이 있었지만, 실제로는 Pages를 선택해 GitHub 저장소를 연결하고, monorepo 구조에서 frontend 폴더만 빌드하도록 설정해야 했다.

이번 프로젝트는 저장소 루트가 아니라 frontend 폴더 안에 실제 React 프로젝트가 있기 때문에, Root directory 지정이 매우 중요했다.

Project name: coreerp
Framework preset: React (Vite)
Build command: npm run build
Build output directory: dist
Root directory: frontend
Environment variable:
VITE_API_BASE_URL=https://api.coreerp.kr/api

이 설정을 통해 Cloudflare Pages는 저장소 루트가 아니라 frontend 디렉토리 기준으로 npm run build 를 수행하고, 생성된 dist 폴더를 실제 배포 산출물로 사용하게 되었다.

이후 기본 배포 도메인인 coreerp.pages.dev 가 먼저 정상 동작했고, 이후 커스텀 도메인인 coreerp.krwww.coreerp.kr 도 연결하여 최종적으로 실제 서비스 도메인으로 프론트 접속이 가능해졌다.


9. HTTPS 적용과 Mixed Content 해결

이번 배포에서 도메인만 연결한다고 끝나는 것이 아니었다. 브라우저에서 프론트는 HTTPS로 열리는데, 백엔드가 HTTP 상태라면 API 요청은 Mixed Content 문제로 차단된다. 즉 프론트와 백엔드 모두 HTTPS가 적용되어야만 브라우저 기준으로 정상 서비스가 된다.

그래서 EC2 서버에 certbot을 설치하고, Nginx 기준으로 api.coreerp.kr 에 SSL 인증서를 발급받아 HTTPS를 적용했다. 이 과정을 통해 백엔드는 단순히 외부에서 접속 가능한 상태를 넘어, 보안 연결이 된 정식 API 엔드포인트가 되었다.

이후 프론트 환경변수도 https://api.coreerp.kr/api 로 확정할 수 있었고, 브라우저에서도 API 요청이 더 이상 차단되지 않게 되었다.


10. 트러블슈팅 정리

10-1. import.meta.env 를 브라우저 콘솔에서 확인하려다 에러 발생

처음에는 import.meta.env.VITE_API_BASE_URL 을 브라우저 콘솔에서 직접 확인하려 했는데, Cannot use 'import.meta' outside a module 에러가 발생했다. 이 문제는 Vite 환경변수는 브라우저가 직접 이해하는 문법이 아니라, Vite가 빌드/개발 서버 시점에 변환해주는 값이라는 점을 제대로 이해하지 못해 생긴 것이었다.

즉 환경변수는 코드 내부에서 console.log 로 확인하거나, 실제 네트워크 요청 URL을 보는 방식으로 확인해야 한다는 점을 다시 정리할 수 있었다.

10-2. Nginx 설정 문법 오류

Nginx 설정 중 "server_name" directive is not allowed here 오류가 발생했다. 이 문제는 server_name 이 반드시 server { } 블록 안에 있어야 하는데, 설정 블록 구조가 깨진 상태에서 저장되었기 때문에 발생한 문법 오류였다.

결국 설정 파일 전체를 다시 정리하고, sudo nginx -t 로 문법 검사를 통과한 뒤 재시작하는 방식으로 해결했다. 이 과정에서 인프라 설정은 백엔드 코드 수정과 별개라는 점도 다시 체감했다.

10-3. Spring Boot가 DataSource 설정을 찾지 못하고 부팅 실패

가장 큰 트러블슈팅 중 하나는 Docker Compose 재배포 시 Spring Boot가 Failed to configure a DataSource 오류와 함께 부팅에 실패한 일이었다. 원인은 코드가 아니라 서버 쪽 .env 파일이 없어서 Compose가 환경변수를 전부 빈 문자열로 읽고 있었기 때문이었다.

즉 GitHub에는 민감 정보 보호를 위해 .env 가 없는데, 서버에서는 이 값을 따로 만들어줘야 한다는 점을 놓친 것이다. 이후 EC2에서 직접 .env 를 생성하고 DB, Redis, JWT 관련 값을 다시 넣은 뒤 재실행하여 해결했다.

10-4. Nginx welcome 페이지가 계속 나오는 문제

Nginx 설정을 해두었는데도 계속 기본 welcome 페이지가 나와 Reverse Proxy가 안 되는 것처럼 보이는 문제가 있었다. 이 문제는 실제로는 Nginx가 죽은 것이 아니라, 요청의 Host 헤더가 의도한 server_name 과 매칭되지 않아서 기본 서버 블록이 먼저 잡히고 있었던 상황이었다.

결국 default server 설정을 명확히 하고, 로컬 테스트 시에도 Host 헤더를 포함한 형태로 점검하면서 진짜 어떤 블록이 매칭되는지 확인하는 방식으로 해결했다.

10-5. Cloudflare에서 Pages가 아니라 Worker 화면으로 들어간 문제

Cloudflare UI에서 처음 Pages 배포를 시작할 때, Create application을 눌렀더니 Worker 생성 흐름으로 들어가 npx wrangler deploy 같은 설정 화면이 나오는 문제가 있었다. 이때는 “Pages가 아니라 Worker 경로로 들어왔다”는 것을 먼저 구분하는 것이 중요했다.

결국 하단의 Looking to deploy Pages? Get started 경로로 다시 진입해 정상적인 Pages 설정 화면으로 이동했고, 이후 GitHub 저장소와 frontend 폴더 기준 build 설정을 적용했다.

10-6. Root directory / Output directory 설정 실수 가능성

Cloudflare Pages 설정 시 Root directory와 Build output directory에 /frontend, /dist 처럼 슬래시를 붙여 입력하면 경로가 꼬일 수 있었다. 이번 구조에서는 저장소 기준 Root directory를 frontend 로 지정하면, Output directory는 그 기준 폴더 아래의 dist 만 적으면 된다.

즉 monorepo 구조에서는 어느 위치에서 build를 시작하는지가 핵심이고, 이 기준이 맞지 않으면 package.json을 못 찾거나 dist를 못 찾는 문제가 발생할 수 있다는 점을 배웠다.

10-7. coreerp.pages.dev 는 되는데 coreerp.kr 에서 522 발생

프론트 기본 도메인인 coreerp.pages.dev 는 정상 동작했지만, 커스텀 도메인인 coreerp.kr 에서는 522가 발생하는 상황도 있었다. 이 문제는 프론트 코드나 API 호출 문제가 아니라, Cloudflare Pages 커스텀 도메인 검증 및 연결이 아직 완료되지 않은 상태에서 생긴 도메인 라우팅 문제였다.

커스텀 도메인을 Pages 프로젝트에 연결하고 verifying 과정을 기다린 뒤, 최종적으로 도메인 연결이 완료되면서 해결되었다.


11. 이번 단계에서 얻은 가장 큰 의미

이번 작업을 통해 CoreERP는 더 이상 단순히 로컬에서만 실행되는 프로젝트가 아니라, 실제 외부에서 접속 가능한 서비스 형태를 갖추게 되었다. 프론트엔드, 백엔드, 데이터베이스, 인증 저장소가 모두 분리된 상태에서, 도메인과 HTTPS까지 연결된 구조를 직접 구축해보면서 애플리케이션 실행 자체보다 배포와 운영 환경 구성이 얼마나 중요한지 크게 체감했다.

특히 이번 단계는 “코드가 맞는가” 만의 문제가 아니었다. 환경변수는 어디서 주입되는지, 브라우저는 왜 HTTP 요청을 차단하는지, Cloudflare Pages는 monorepo를 어떻게 빌드하는지, Nginx는 어떤 요청을 어떤 서버 블록으로 매칭하는지 등, 애플리케이션 바깥의 인프라 계층이 실제 서비스 품질과 직결된다는 점을 직접 경험한 단계였다.

이 과정을 통해 단순 백엔드 개발을 넘어, 프론트엔드와 백엔드, 인프라, 보안, 배포 과정을 하나의 흐름으로 연결해서 이해하게 되었다는 점이 가장 큰 수확이었다.


12. 마무리

이번 배포 단계가 끝나면서 CoreERP는 아래 구조를 갖춘 상태가 되었다.

  • coreerp.kr : 프론트엔드 서비스 주소
  • api.coreerp.kr : 백엔드 API 주소
  • Cloudflare Pages : 프론트 배포
  • AWS EC2 + Docker + Nginx : 백엔드 배포
  • AWS RDS MariaDB : 운영 데이터베이스
  • Redis : 리프레시 토큰 및 인증 관련 처리
  • HTTPS 적용 완료
profile
Develop

0개의 댓글