서론 풀스택 과정 부트캠프에서 프론트엔드 개발자와 백엔드 개발자가 함께 개발을 수행하다보니 개발 환경의 차이에 의한 문제 그리고 여러 공개할 수 없는 API Key와 같은 것들 때문에 환경을 관리하는 것이 어려웠다. 나는 Docker라는게 있는지는 알고 있었지만 자세히는 알지 못했고, 프로젝트 과정 중에 Docker에 능숙한 팀원이 환경설정을 해준 덕에 환경 설정부터 CI/CD까지 배포와 관련된 문제를 원활히 해결하고 프로젝트를 잘 수행했던 경험이 있다. 당시에는 Docker에 대해 잘 모르고 구성을 했다면, 이제는 Docker에 대해 배경부터 공부해보고 내 프로젝트에 적용한 과정을 설명하려고 한다. 1. Docker의 탄생 배경 도커에 관한 밈을 찾아보게 되었는데 이거로 도커에 대한 많은게 설명되는거 같다. 다음 밈에서 말하고 있는 것은 다음과 같다. - 내 컴퓨터에서는 작동해! - 그럼 너의 컴퓨터를 배송하자! (울고 있는 개발자) 이런 종류의 밈은 개발 원리나 동작에 대해 참 쉽게 설명해주는 것 같다. 1.1) Docker 이전의 문제 프로젝트가 커지거나 팀원이 늘어나면, 실행 환경의 차이가 쉽게 문제가 된다. OS 버전(Windows/macOS/Linux) 차이 런타임/패키지 버전 차이 (Java/Node/Python 등) 라이브러리 설치 상태 차이 환경 변수(.env), 인증 정보(API Key), 설정 파일 차이 로컬/서버의 폴더 구조, 권한, 포트 충돌 등 이런 차이들이 쌓이면 결국, "정확히 어떤 환경에서 어떻게 실행되는지"를 재현하기 어려워지고, 배포 과정에서 예측 불가능한 오류가 쌓인다. 1.2 기존 해법 도커 이전에도 사실은 환경을 통일하려는 시도는 있었다고 한다. 대표적으로 VMware 같은 가상머신(Virtual Machine, VM)이다. 가상머신은 서버 환경(OS 포함)을 통째로 복제하므로, 격리성이 좋으며 그 서버 그대로 옮기는 방식이라 이식성이 좋다. 하지만 가상머신에는 다음과 같은 현실적인 단점들이 있었다. 무겁고 느림: 게스트 OS까지 포함하므로 이미지가 크고 실행/부팅 비용이 크다. 밀도가 낮음: 한 서버에 많은 인스턴스를 띄우기 어렵다. CI/CD 에 불리: 빠르고 만들고 빠르게 버리는 워크플로에 부담이 크다. 즉, 가상머신 "환경 통일"에는 효과적이지만, 속도와 운영 효율 면에서 현대적인 개발 사이클과 맞지 않은 지점이 있다. 도커에서의 인스턴스: 이미지를 기반으로 실제로 만들어져 실행 중인 하나의 컨테이너로, 이미지는 앱 실행에 필요한 것들을 파일로 묶어놓은 패키지, 컨테이너는 이미지를 기반으로 만들어진 실행 중인 프로세스(실행본)이다. 1.3 Docker의 방식 그러나 도커는 새로운 패러다임을 만든 것은 아니다. 기존에 있었던 컨테이너라는 방식(OS를 통째로 포함하지 않고, 실행 환경을 더 가볍게 포장하는 방식)위에 , 애플리케이션을 이미지라는 단위로 패키징하고, 어디서든 동일하게 실행할 수 있도록 실행/배포 흐름을 표준화했다는 데 있다. 결과적으로 도커는 다음을 가능하게 했다. 내 컴퓨터 실행 환경을 그대로 포장하여 배포 단위로 만듦 개발/테스트/운영 환경의 차이를 줄여 재현성 높이기 CI/CD에서 빠르게 빌드하고 배포하는 흐름 만들기 위를 통해서 도커는 "환경 차이 때문에 생기는 문제"해소하고 거의 표준처럼 많이 사용하는 기술이 되었다. 2. VM vs Container 컨테이너에 대해 설명하기 전에 간단하게 가상머신(VM)에 대해 살펴보고자 한다. 2.1 가상머신이란? 가상머신은 말 그대로 가상 컴퓨터 한대이다. 물리 서버 위에서 하이퍼바이저가 가상의 하드웨어를 만들어주고 그 위에 게스트 OS를 설치해서 실행한다. 설명이 어렵지만, 간단히 말해서 OS까지 포함해서 서버 한 대를 통째로 복제하는 방식이다. 따라서 가상머신 A와 가상머신 B를 띄우게 되면 사실상 서로 다른 컴퓨터처럼 작동하며, 커널도 각각 따로 돌아 격리성이 매우 높다. 그러나 다음 문제가 있다. OS를 띄우고 부팅해야함 -> 시작이 느림 OS 자체가 포함됨 -> 이미지가 커짐 자원도 OS 단위로 먹음 -> 밀도가 떨어짐 2.2 컨테이너란? 그래서 호스트 OS(실제 물리 서버, 나의 컴퓨터) 위에서 실행되는 격리된 프로세스를 컨테이너라고 한다. 즉, 컨테이너는 OS를 새로 띄우는 방식이 아니라 호스트의 커널을 공유하면서 프로세스를 격리한다. 컨테이너에서 격리는 실제로는 호스트에서 실행되는 프로세스인데, 파일시스템이 분리되어 보이고, 네트워크가 분리되어 보이고, 프로세스 목록이 분리되어 보이고, 자원 사용량도 제한할 수 있게 마치 자기만의 작은 OS 환경 처럼 보이게 만드는 것이다. 그런데 가상머신의 목표와 동일하게 핵심을 변하지 않는다. 참고) Docker Desktop을 사용하는 경우, macOS/Windows 커널을 직접 공유하는 것이 아니라, 내부적으로 제공되는 Linux 환경의 커널을 공유하는 형태로 동작한다. 따라서, 커널 공유는 Linux 커널을 공유한다.로 이해하는 것이 정확하다. 따라서 호스트 커널을 공유로 커널을 새로 띄우지 않기 때문에 가상머신보다 가볍고 빠르다. 3. 컨테이너가 가능한 이유: 리눅스 커널 3종 세트 앞에서 설명한대로 컨테이너는 파일시스템/네트워크/프로세스를 분리한다고 했는데, 이게 어떻게 가능했는지를 다뤄보고자 한다. 앞에서 컨테이너를 다음과 같이 정의했다. 컨테이너는 호스트 OS 위에서 실행되는 격리된 프로세스이며, 호스트 커널을 공유하지만, "자기만의 작은 OS 환경"처럼 보이게 만든다. 여기서 가장 궁금한 점은 다음과 같다. 같은 커널을 공유하는데 어떻게 "분리되어 보이게" 만들까? 어떻게 서로의 프로세스를 못 보게 하며, 네트워크도 나눠 쓰고, 자원도 제한할까? 컨테이너의 격리/제한은 주로 리눅스 커널 기능 조합으로 구현된다. 핵심은 다음 2가지다. [격리] Namespaces: "보이는 세계"를 분리한다. [제한/쿼터] cgroups: 사용할 수 있는 자원을 제한한다. 그리고 Docker의 이미지 레이어(레이어 재사용, Copy-on-Write) 구조는 보통 다음과 같은 스토리지/유니온 파일 시스템으로 구현된다. [레이어/CoW] Union/Overlay 계열 파일시스템: 파일 시스템을 레이어로 쌓고 변경분을 효율적으로 관리한다. 3.1 Namespaces Namespaces는 한마디로 프로세스가 바라보는 시스템 자원 뷰(view)를 분리하는 기능이다. 같은 호스트에서 돌고 있는 프로세스라도, 네임스페이스로 격리하면, 내가 보는 프로세스 목록 내가 보는 네트워크 인터페이스 내가 보는 파일시스템 루트 를 다르게 설정할 수 있다. 여기서 컨테이너가 자기만의 OS처럼 보이는 이유가 나온다. 3.2 cgroups 네임스페이스는 보이는 세상을 분리한다면, cgroups(control groups)는 자원 사용량을 제한한다. 이 컨테이너는 CPU를 몇 %까지, 메모리는 몇 MB까지 사용하게 하자. 따라서 Docker에서 자주 보게 되는 옵션들도 cgroups 기능을 활용한 것이다. 메모리 제한 CPU 제한 프로세스 수 제한 디스크 I/O 제한 등 3.3 Overlay/Union Filesystem 도커를 사용하다보면 이미지라는 개념이 중요하게 나타난다. 이미지는 앱 실행에 필요한 파일들을 묶어 놓은 패키지인데, 도커 이미지가 효율적인 이유는 보통 레이어 구조 때문이다. 레이어의 개념 예시로 어떤 이미지가 다음을 포함한다고 하자. ubuntu base apt로 설치한 패키지들 애플리케이션 코드 실행 설정 이것을 매번 통째로 복사해서 만들면 너무 비효율적이다. 따라서 도커는 파일시스템을 레이어로 쌓아. 바뀌지 않는 레이어는 재사용하고 바뀐 레이어만 새로 만들도록 한다. 이 레이어 기반 구조를 가능하게 하는 대표적인 기술은 OverlayFS 같은 유니온 파일시스템이다. 컨테이너 실행 시 따라서 컨테이너가 실행 될 때 일반적으로 이미지 레이어는 읽기 전용(read-only) 컨테이너마다 쓰기 레이어(write layer)가 하나 붙는다. 그래서 컨테이너 내부에서 파일을 수정해도, 실제로는 컨테이너의 쓰기 레이어에 변경분이 쌓이는 구조가 된다. 이 구조 덕분에 다음이 자연스럽게 형성된다. 컨테이너는 쉽게 만들고 버릴 수 있다. (휘발성) 데이터를 남기려면 볼륨(volume)이 필요하다. 3.4 컨테이너는 커널 기능의 조합이다. 따라서 리눅스 커널 기능(Namespaces, cgroups, overlayFS)를 조합하여 작은 OS 처럼 보이지만 실제로 프로세스인 컨테이너 기능을 만든 것이다. 4. Docker 핵심 개념 6가지 리눅스의 기능을 이용하여 도커는 위 프로세스를 표준화 해주는 기술이다. 이를 이해하기 위해 각각의 개념을 살펴보자 이미지(Image) 컨테이너(Container) 도커파일(Dockerfile) 레지스트리(Registry) 볼륨(Volume) 네트워크(Network) 4.1 이미지 이미지는 컨테이너를 만들기 위한 템플릿으로 다음과 같은 것들을 파일 묶음으로 패키징한 결과물이다 애플리케이션 코드 런타임/라이브러리 설정 파일 어떤 명령으로 실행할지(CMD/ENTRYPOINT) 이미지는 중요한 성질이 있다. 불변(immutable)에 가깝게 다룬다. 이미지는 실행하면서 이미지 자체를 바꾸는 게 아니라, 이미지를 다시 빌드해서 새 버전을 만든다. 레이어로 구성된다. 바뀌지 않는 부분은 재사용되고, 바뀐 부분만 새로 쌓인다. 다시 말해, 이미지 = 컨테이너의 설계도(템플릿)이다. 4.2 컨테이너 컨테이너는 이미지를 기반으로 만들어진 실행 중인 프로세스이다. 이미지가 정적인 패키지라면, 컨테이너는 동적으로 실행되는 실체이다. 컨테이너는 다음 특징이 있다. 같은 이미지로 여러 컨테이너를 동시에 띄울 수 있다. 컨테이너는 기본적으로 휘발성이다. 컨테이너를 지우면 컨테이너 내부 변경사항도 사라진다. 그래서 데이터는 볼륨으로 분리한다. 즉, 컨테이너 = 이미지를 실행한 인스턴스(격리된 프로세스)이다. 4.3 도커 파일 도커파일(dockerfile)은 어떤 이미지를 어떤 순서로 만들 것인지를 적어두는 빌드 스크립트이다. 즉, 누가 어떤 컴퓨터에서 빌드를 하든 똑같은 이미지가 나오도록 빌드 과정을 표준화 해주는 역할을 한다. 도커파일에서 자주 나오는 키워드들은 다음과 같다. FROM: 베이스 이미지 선택 WORKDIR: 작업 디렉토리 지정 COPY/ADD: 파일 복사 RUN: 설치/빌드 명령 실행(레이어 실행) ENV: 환경 변수 EXPOSE: 포트 정보로 문서의 성격을 가진다. CMD/ ENTRYPOINT: 컨테이너 실행 시 기본 명령 위의 내용을 알아야하는 이유가 있다. 이를 어떤 순서로 작성하느냐에 따라 빌드 캐시 효율이 크게 달라지기 때문이다. 이와 관련해서는 마지막에 다뤄보고자 한다. 4.4 레지스트리 레지스트리는 이미지를 저장/배포하는 저장소로, 이미지 올리고(push) 내려받는(pull) 공간이다. 레지스트리가 중요한 이유는 단순하다. 이미지가 있으면 서버에 무엇을 설치했는지(자바 버전, 라이브러리 등)를 일일이 기억할 필요가 없고, 서버는 이미지를 내려받아(pull) 실행만 하면 된다. CI/CD에서 빌드 산출물이 곧 이미지가 되고, 이 이미지가 배포의 단위가 된다. 예를 들어, 백엔드 앱을 배포한다고 하면 보통 다음 흐름으로 동작한다. Dockerfile에서 Java 런타임이 포함된 베이스 이미지 (예: eclipse-temurin:17-jre)를 선택하고, myapp.jar를 담아 이미지를 만든다. 만들어진 이미지를 레지스트리에 올린다(push). 서버(예: EC2)는 레지스트리에서 이미지를 내려받아(pull) 컨테이너로 실행한다(run). 이 과정 덕분에 서버는 "Java를 어떤 버전으로 설치했는지" 같은 실행 환경을 덜 신경써도 된다. 도커가 설치되어 있고, 이미지를 실행할 수 있으면 된다. 또 다른 예로, Docker Hub는 대표적인 공개 레지스트리이다. 내가 다음을 실행할 때 docker run postgres:16 로컬에 postgres:16 이미지가 없다면 도커가 자동으로 Docker Hub에서 postgres:16 이미지를 내려받고(pull) 내려받은 이미지를 컨테이너로 실행한다(run). 즉, Docker Hub는 postgres 같은 이미지들이 올라가 있는 창고이고, 이런 창고를 레지스트리라고 부른다. 특히 레지스트리가 빛나는 건 CI/CD 시나리오이다. GitHub Actions가 이미지를 빌드한다. 빌드한 이미지를 레지스트리에 push 한다. EC2가 레지스트리에서 pull 한다. EC2에서 docker compose up -d 또는 docker run으로 실행한다. 4.5 볼륨 앞에서 컨테이너는 기본적으로 휘발성이라고 했다. 이 말은 즉, 컨테이너를 지우면 컨테이너 안에서 변경된 파일도 같이 사라질 수 있다는 것을 의미한다. 그런데 일반적으로 백엔드 서비스를 실행시킬 때, DB를 연결하기 때문에 컨테이너 내부에 DB를 같이 띄우게 된다. 그런데 DB는 데이터를 유지하는 것이 생명인데, 컨테이너를 내렸다 올렸다하는 과정에서 데이터가 사라지면 실무에서 사용할 수가 없다. 그래서 도커는 컨테이너와 데이터를 분리하기 위해서 볼륨(Volume)을 제공하고, 볼륨은 컨테이너 밖에서 존재하는 영구 저장소(데이터 저장 공간)이다. 즉, 컨테이너를 삭제하거나 새로 만들어도 볼륨만 그대로 두면 데이터가 유지된다. 그럼 DB 컨테이너가 꺼져도 어떻게 데이터가 저장되는지 살펴보자. 4.5.1 볼륨의 형태 도커에서 볼륨은 두 가지 형태로 사용된다. (1) Named Volume(도커가 관리) 도커가 관리하는 저장공간에 "이름"을 붙여서 사용하는 방식 운영/배포 환경에서 흔히 사용함. 컨테이너 경로에 깔끔하게 연결 가능함 예: postgres_data 같은 이름을 붙이는 방식 (2) Bind Mount(호스트 경로를 직접 연결) 호스트 OS의 특정 폴더를 컨테이너에 그대로 마운트한다. 로컬 개발할 때 소스코드 핫리로드 등에서 자주 사용한다. 예: 로컬의 ./data 폴더를 컨테이너 /var/lib/postgresql/data에 연결한다. 4.5.2 왜 데이터가 사라지지 않는지? (1) 컨테이너 내부 파일은 원래 휘발성(사라질 수 있음) 컨테이너는 실행 될 때 설명했듯이 다음과 같이 구성된다. 이미지 레이어(읽기 전용) + 컨테이너 쓰기 레이어(읽기/쓰기) DB가 컨테이너 안에서 데이터를 저장하면, 기본적으로 그 데이터 파일은 컨테이너 쓰기 레이어에 쌓인다. 그래서 해당 컨테이너를 삭제하면 해당 쓰기 레이어가 같이 날아가고, 데이터도 같이 사라진다. (2) 볼륨을 붙이면 저장 위치가 컨테이너 밖으로 바뀜 Postgres를 예로 들면, DB 데이터는 보통 컨테이너 안의 다음 경로에 저장된다. /var/lib/postgresql/data 여기에 볼륨을 마운트하면 다음과 같은 일이 발생한다. 컨테이너가 이 경로에 쓰는 파일이 컨테이너 내부가 아니라, 볼륨(호스트에 있는 저장공간)에 기록된다. 따라서 컨테이너를 stop 해도 프로세스(DB)가 멈출 뿐이지 볼륨에 저장된 파일은 그대로 남아있게 된다.(그냥 디스크 파일이니까) 5. Dockerfile: 환경을 코드로 만드는 방법 위 설명한 내용들을 이제 Dockerfile 설정으로 실습할 수 있다. Dockerfile은 어떤 이미지를 어떤 순서로 만들 것인지를 적어두는 파일이다. 즉, 개발 환경(설치/빌드/실행 과정)을 코드로 선언해서, 누가 어디서 빌드하든 동일한 이미지를 만든다. 5.1 Dockerfile 기본 구조 Dockerfile은 보통 아래 흐름으로 작성된다 FROM: 어떤 이미지에서 시작할지 COPY: 필요한 파일을 복사하고 RUN: 필요한 설치/빌드를 수행하고 CMD/ENTRYPOINT: 실행 명령을 정의한다. 다음은 Gradle + Java 21을 기준으로 작성된 2개의 Dockerfile에 대해 살펴보고자 한다. 5.1.1 멀티스테이지 빌드 빌드 단계에서는 Gradle + JDK 21로 bootJar를 만들고 실행 단계에서는 JRE 21만 남겨 이미지를 가볍게 만든다. # 빌드 단계: Gradle + JDK 21 FROM gradle:8.7-jdk21 AS builder # 이 단계를 builder로 정의 WORKDIR /workspace # 컨테이너 내부에서 작업할 디렉토리를 /workspace로 정의 소스 복사 후 빌드 COPY . . # 현재 폴더(프로젝트 전체)를 컨테이너의 작업 디렉토리(/workspace)로 복사 RUN gradle bootJar --no-daemon # 컨테이너 안에서 Gradle 명령으로 Spring Boot 실행 JAR(bootJar)를 생성. --no-daemon은 CI/컨테이너 환경에서 Gradle 데몬을 사용하지 않도록 하여 안정적으로 동작하도록 하는 옵션 실행: JRE 21, 빌드는 위에서 끝났으며 여기서는 이제 실행만 함. FROM eclipse-temurin:21-jre # 실행 단계에서 사용할 베이스 이미지 WORKDIR /app # 실행 단계 컨테이너에서 작업 디렉토리를 /app으로 설정함. 빌드 산출물(JAR)를 런타임 이미지로 복사 builder단계에서 만들어진 jar를 가져와서, 현재 단계(/app)에 app.jar라는 이름으로 복사한다. *.jar는 빌드 결과 jar 이름이 프로젝트마다 달라서 와일드카드로 잡음. COPY --from=builder /workspace/build/libs/*.jar app.jar 이 컨테이너가 8080포트를 사용할 것이라는 메타 정보 실제 외부에 포트를 열려면 포트 매핑이 필요함. EXPOSE 8080 컨테이너가 시작될 때 실행할 기본 실행 명령이다. ENTRYPOINT ["java", "-jar", "app.jar"] 위 방법의 장점 - 서버/CI는 **소스만 있으면** 이미지를 만들 수 있음. - 실행 이미지는 빌드 도구(Gradle)가 없어서 **작고 안전**해짐 - 빌드/실행 환경이 분리되어 구조가 깔끔함. **실행 방법** 멀티스테이지는 Dockerfile 안에서 빌드까지 진행하므로, 별도 Gradle 빌드 없이 아래만 하면 된다. ```bash # (프로젝트 루트에서) 이미지 빌드 docker build -t myapp:1.0 . # 컨테이너 실행 (호스트 8080 -> 컨테이너 8080) docker run -d --name myapp -p 8080:8080 myapp:1.0 # 로그 확인 방법 docker logs -f myapp 5.1.2 단순 버전 로컬이나 CI에서 먼저 bootJar를 만든 다음, Docker는 실행만 담당하는 구조 FROM eclipse-temurin:21-jre WORKDIR /app COPY build/libs/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] **실행 방법** 단순 버전은 **이미 build/libs에 jar가 있어야**하므로 먼저 빌드를 한다. ```bash # 먼저 jar 생성 ./gradlew bootJar # 이미지 빌드 docker build -t myapp:1.0 . # 컨테이너 실행 docker run -d --name myapp -p 8080:8080 myapp:1.0 2가지 dockerfile을 구성하는 방법을 살펴보았는데, 실제 백엔드 개발에서는 단일 API 서버만 띄우는 경우가 드물고, 보통 DB, 캐시 등 여러 컨테이너가 함께 필요하다. 다음으로는 이 구성을 docker compose up 한 줄로 통일하는 방법을 정리해보자. 6. Docker Compose: 개발 환경을 1줄로 맞추는 방법 앞에서는 Dockerfile로 Spring Boot 애플리케이션 이미지를 만들고 docker run으로 실행하는 방법을 살펴봤다. 하지만 실제 백엔드 개발에서는 API 서버만 단독으로 띄우는 경우가 드물고, 보통은 다음과 같은 구성 요소가 함께 필요하다. API 서버(Spring Boot) DB(Postgres) 캐시(Redis) 프록시(Nginx) 이런 구성을 매번 docker run ... 명령으로 하나씩 띄우려면 포트, 환경 변수, 네트워크. 볼륨 설정이 길고 복잡해진다. 그래서 이를 Docker Compose가 해결해준다. docker-compose.yml: 여러 컨테이너(서비스)의 실행 구성을 한 파일로 정의함. docker compose up 한 줄로 동일한 개발 환경을 띄울 수 있게 해줌. 6.1 docker-compose.yml Compose는 다음을 한 번에 해결한다. (1) Service: 어떤 컨테이너들을 띄울지 (2) Image/build: 각 컨테이너는 어떤 이미지로 실행할지 (3) Ports: 포트를 어떻게 열지 (4) environment/env_file: 환경 변수/시크릿을 어떻게 주입할지 (5) volumes: 데이터를 어디에 저장할지 (6) network: 컨테이너끼리 어떻게 통신할지 6.2 예제: Spring Boot + Postgres 구성 Spring Boot API 서버와 Postgres DB의 개발 환경을 구성하는 예시를 보고자 한다. api: Spring Boot 애플리케이션(Dockerfile로 빌드) db: Postgres DB (공식 이미지 사용) postgres_data: DB 영구 저장을 위한 볼륨 services: db: image: postgres:16 container_name: myapp-db environment: POSTGRES_DB: myapp POSTGRES_USER: myapp POSTGRES_PASSWORD: myapp_pw ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data api: build: . container_name: myapp-api depends_on: - db ports: - "8080:8080" environment: # DB 컨테이너의 서비스 이름(db)로 접근 SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/myapp SPRING_DATASOURCE_USERNAME: myapp SPRING_DATASOURCE_PASSWORD: myapp_pw volumes: postgres_data: DB 주소가 localhost가 아니라 db인가? Compose에서는 같은 docker-compose.yml에 정의된 서비스들이 기본적으로 같은 Docker 네트워크에 묶인다. 그리고 그 네트워크 안에서는 서비스 이름(여기서는 db)이 호스트명(DNS) 처럼 동작한다. 그래서 API 컨테이너에서 DB에 접속할 때 다음과 같이 된다. localhost:5432 -> 여기서는 localhost는 API 컨테이너 자기 자신이 됨. db:5432 -> 같은 네트워크의 DB 컨테이너 6.3 실행 방법 Compose의 장점은 실행이 단순하고 쉬워진다는 것이다. # 컨테이너들을 백그라운드로 실행 docker compose up -d # 실행 중인 컨테이너 확인 docker compose ps # 로그 확인(전체) docker compose logs -f # 특정 서비스 로그만 확인(예:api) docker compose logs -f api 종료는 다음처럼 한다. # 컨테이너 종료 및 네트워크 정리(볼륨은 유지됨) docker compose down # 볼륨까지 같이 삭제(= DB 데이터도 삭제됨) docker compose down -v 7. Docker의 한계 Docker는 이미지로 실행 환경을 표준화하고, 컨테이너로 빠르게 실행하는 방식을 통해 배포와 개발 환경 통일을 쉽게 만들었다. 그런데 최근 실세 서비스 운영 환경에서는 Kubernetes를 함께 사용하는데, 그 이유를 Docker의 한계와 함께 살펴보고자 한다. 나도 아직 사용해보지는 않았다. 7.1 운영 단위로의 컨테이너 Docker를 처음 사용할 때 보통 다음과 같이 사용한다. docker run으로 컨테이너 실행 docker compose up으로 여러 서비스 실행 이것은 로컬 개발 환경이나 작은 규모에서는 충분히 잘 동작한다. 하지만 서비스가 성장하면, 컨테이너는 단순한 실행 단위가 아니라 운영 단위가 된다. 이 때부터 Docker로만으로 서비스를 제공하는데 한계가 있다고 한다. 7.2 한계점 7.2.1 휘발성 컨테이너는 기본적으로 쉽게 만들고 쉽게 버릴 수 있도록 설계된 실행 단계로 휘발성이다. 컨테이너 내부 파일 시스템에 의존하면, 컨테이너 삭제/교체 시 상태(state)가 사라질 수 있다. 따라서, 다음 조치를 취한다. DB 데이터 -> 볼륨/외부 DB로 분리 필요 로그/업로드 파일 -> 외부 저장소(S3, 로그 시스템 등)로 분리 필요 세션/캐시 -> Redis 같은 외부 저장소로 분리 필요 즉, Docker 자체가 문제라기보다 컨테이너 운영을 시작하면 상태를 분리하는 설계가 필수가 된다. 또한, Docker 공식 문서에서도 컨테이너는 Ephemeral(쉽게 중지/파괴/재생성 가능)하게 만들어야한다고 한다. 7.2.2 많은 컨테이너의 운영 및 관리 서비스가 적은 경우에는 docker run, docker compose로도 충분하지만, 하지만 서비스가 많아지거나(MSA), 트래픽 변화에 따라 인스턴스를 늘려야 하는 순간부터 수동 운영은 한계가 온다. 예시로 운영에서 실제로 필요한 작업은 다음과 같다고 한다. 컨테이너가 죽으면 자동으로 재시작 새 버전 배포 시 무중단 배포 문제가 생기면 빠르게 롤백 트래픽 증가 시 자동으로 스케일 아웃/인 여러 서버에 컨테이너를 적절히 분산 배치 이것을 Docker만으로는 가능은 하지만, 규모가 커질 수록 어려우며 비용이 급격하게 늘어난다고 한다. 7.2.3 네트워크/서비스 찾기가 더 어려워진다. 컨테이너는 생성/삭제가 빠르고, 운영/개발 과정에서 구성이 자주 바뀐다. 즉, 컨테이너의 IP는 고정되지 않는 경우가 많고, **호스트(내 PC/서버)에서 바라보는 접속 정보도 설정에 따라 달라질 수 있다. 이것은 실제 부트캠프 프로젝트에서도 겪었던 문제다. 프로젝트 기간이 짧다 보니 서버를 자주 재시작하거나 DB를 초기화하는 작업이 반복됐는데, 그 과정에서 DB 접속 포트가 매번 달라져 연결 문제가 생겼던 경험이 있다. 엄밀히 말하면 DB 초기화가 포트를 바꾸는 것은 아니며, 보통 포트가 달라지는 원인은 다음 중 하나이다. ports에서 호스트 포트를 고정하지 않은 경우 포트 충돌 따라서 운영/협업 환경에서는 보통 다음을 신경써야한다. 어떤 서비스가 어디에 떠 있는지(서비스 디스커버리) 어떻게 안정적으로 연결할지(고정된 엔드포인트/서비스 이름) 어떻게 트래픽을 분산할지(로드 밸런싱) 장애 인스턴스를 트래픽에서 어떻게 제외할지(헬스체크/자동 복구) 7.3 Kubernetes의 필요성 정리하면 Docker는 컨테이너라는 실행 방식을 표준화 했지만, 서비스가 커질수록 다음 요구가 생긴다. 자동 배치/스케줄링 자동 복구 배포 전략 스케일링 서비스 디스커버리/로드밸런싱 구성/비밀키 관리 위 요구를 운영 가능한 수준으로 해결하는 프레임워크가 오케스트레이션이고, 대표적으로 Kubernetes이다. 즉, Docker는 컨테이너를 만들고 실행하는 표준이라면, Kubernetes는 컨테이너가 많은 시스템을 운영하게 만드는 표준이다. 마치며,,, 부트캠프 프로젝트에서 매 프로젝트 때마다 도커를 사용했었다. 그 이유는 개발 환경의 차이를 줄이기 위함, 배포를 위함 등이었는데, 실제 도커가 어떤 배경과 원리를 가지고 동작하는지 자세히 알지 못했다. 따라서 이번에 위와 같은 구조로 간단히 공부한 것을 정리해보았고, 항상 취업 공고에 도커와 함께 쿠버네이스가 등장하는데, 이것의 필요성까지 한 번 찾아보았다. 다음으로는 쿠버네티스에 대해 간단히 다뤄보고자 한다. 참고문헌 도커 공식 문서: 컨테이너란? 도커 공식 문서: 이미지 레이어 도커 공식 문서: 저장소 도커 공식 문서: 네트워크 구성 도커 공식 문서: Dockerfile best practices
서론 이번 글은 부트캠프에서 겪은 문제 상황과 이후에 고민한 해결 방법에 대해 공유한 내용으로 트러블 슈팅 기록이다. 개발자 부트캠프에서 여러 프로젝트를 진행하면서, API 개발은 대체로 다음과 같은 흐름으로 진행되어 왔다. 이 과정에서 나는 항상 같은 지점에서 불편함을 느꼈다. 바로 문서를 2번 작성하게 된다는 점이다. 하나는 사람이 읽기 위한 API 명세 테스트 및 실행 가능한 명세로 OpenAPI(Swagger) 명세 물론, OpenAPI 문서는 필수가 아니다. Notion 등에 API 명세를 정리하고, 이를 기준으로 구현과 테스트를 진행해도 프로젝트는 충분히 진행할 수 있다. 그러나 실제로 그렇게 작업해보면 다음과 같은 한계가 있다. 문서 변경 이력을 추적하기 어렵고 누가, 언제, 왜 수정했는지 맥락이 남지 않으며 GitHub Pull Request와 같은 보호 장치를 적용하기도 어렵다. 이런 이유로 나는 OpenAPI를 코드로 관리하는 방식이 훨씬 많은 이점을 가진다고 생각한다. OpenAPI를 사용하면 다음 이점이 있다. API 명세와 테스트 기준을 함께 고정할 수 있고 코드 기반이기 때문에 형상 관리가 가능하며 요청•응답 DTO 구조를 통해 자원의 표현 구조를 명확히 드러낼 수 있다. 그래서 나는 API의 역할과 책임, 비즈니스 맥락은 사람이 읽는 API 명세서로 작성하고, 실제 요청과 응답의 형태, 상태 코드, 스키마 정의는 OpenAPI(Swagger)로 관리하는 방식이 가장 이상적이라고 생각한다. 잘 작성된 문서는 많으면 많을수록 좋다고 생각한다. 하지만 아주 기본적인 Swagger 작성 방법으로 작성하게 되면 다음 문제가 있다. 아래는 현재 복습용으로 수행하고 있는 배드민턴 관련 애플리케이션의 내 컨트롤러 코드이다. 컨트롤러에 붙는 Swagger 어노테이션의 양이 실제 비즈니스 코드보다 훨씬 많아지는 문제가 있다. 위 단일 메서드에서의 Swagger 코드가 컨트롤러 코드의 적어도 2.5배 이상이다. 이런 컨트롤러 메서드를 1개 구현 할 때마다 컨트롤러는 2.5배의 속도로 비대해지게 되는 것이다. 이러한 구조는 정작 중요한 컨트롤러의 요청 처리 로직에 집중하기 어려운 상태로 만든다. 따라서, 생산성 측면에서도, 이후 유지보수 관점에서도 바람직하지 않은 구조인 것은 알 수 있다. 따라서 이번 글은 단순한 Swagger 사용법 소개가 아니라, OpenAPI(Swagger) 적용 시 컨트롤러 비대화 문제를 어떻게 해결할 수 있을지에 대한 나의 고민을 정리한 기록이다. 이번 글의 목표는 다음과 같다. OpenAPI와 Swagger의 역할 구분하여 이해한다. Spring Boot 환경에서 OpenAPI(Swagger)를 적용한 데모 프로젝트 구성한다. Swagger 어노테이션을 컨트롤러에서 분리하는 구조를 제안한다. 본 글에서 설명하는 모든 구조와 예제 코드는 다음 데모 프로젝트에 그대로 구현되어 있다. 🔗 https://github.com/Gumraze-git/devlog-demo-openapi-swagger 1. OpenAPI와 Swagger Swagger를 분리하는 것 이전에 먼저 OpenAPI와 Swagger를 구분하여 이해할 필요가 있다. 일반적으로 실무나 학습 과정에서 "Swagger 문서를 작성한다." "OpenAPI를 적용한다." 와 같은 표현을 혼용해서 사용하는 경우가 많다. 그래서 먼저 두 용어를 명확히 하는 것이 좋다고 생각했다. 1.1 OpenAPI? OpenAPI는 기존에 Swagger Specification으로 시작된 API 명세의 형식이었다. 이후 표준화 과정을 거치며 OpenAPI Specification(OAS)로 명명되었다. 공식 문서에서는 OpenAPI를 다음과 같이 정의한다. OAS는 HTTP API를 기술하기 위한 표준적이며 프로그래밍 언어에 종속되지 않는 인터페이스 설명 방식으로, 소스 코드나 추가 문서, 네트워크 트래픽을 직접 분석하지 않고도 사람과 컴퓨터가 서비스의 기능을 발견하고 이해할 수 있도록 한다. 위 내용에서 우리는 다음을 알 수 있다. 소스 코드 없이도 API의 기능을 이해할 수 있도록 하고, 사람이 읽을 수 있는 동시에 도구가 자동으로 처리할 수 있는 구조를 가져야 한다. 즉, ** OpenAPI는 **API의 구현 방법을 설명하는 것이 아니라, API가 제공하는 기능과 입출력 규칙을 정의하는 계약(Contract)이다. OpenAPI가 정의하는 범위는 다음과 같다. 리소스 경로(URI) HTTP 메서드/상태 코드 요청(응답)의 구조 및 방식 인증 및 인가 방식 데이터 스키마 위를 기준으로 알 수 있는 것은, OpenAPI가 다루는 영역과, 일반적으로 작성하는 API 명세의 역할은 다르다는 것이다. 이미 OpenAPI가 요청•응답 구조와 규칙을 표현할 수 있기 때문에, 사람이 읽는 API 명세서는 다음만 있으면 된다고 생각한다. 해당 API가 왜 존재하는지 어떤 책임과 역할을 가지는지 와 같은 의도와 맥락에 더 집중할 수 있다. 1.1.1 OpenAPI의 등장 배경 OpenAPI는 다음과 같은 문제에서 등장하게 되었다. API 명세가 코드와 분리되어 관리되고 있음. 문서와 실제 동작이 쉽게 불일치함. 클라이언트와 서버 간 계약이 명확하지 않음. 자동화 도구(테스트, 코드 생성) 적용이 어려움. 이 문제를 해결하고, 사람과 기계가 동시에 이해할 수 있는 표준화된 API 명세 형식이 필요했고, 그 결과로 OpenAPI(당시 Swagger)가 등장하게 되었다. 그리고 현재 OpenAPI는 리눅스 파운데이션에서 관리되며, 특정 언어나 벤더에 종속되지 않는 형식을 가져, 사실상 HTTP API 명세에 대한 표준으로 자리잡았다. 1.2 Swagger? Swagger는 OpenAPI 명세를 중심으로 한 도구 모음이다. 초기에는 Swagger Specification이라는 단일 프로젝트로 시작되었으며, API 문서 자동화 도구로 업계 전반에서 사용되었다고 한다. 그러나 공식 표준으로 관리되기에는 한계가 있었고, 그 결과 Swagger Specification은 리눅스로 관리의 주체가 편입되면서 OpenAPI Specification으로 분리•표준화되었다. 현재 구조는 다음과 같다. OpenAPI: API 명세를 표현하기 위한 표준 Swagger: OpenAPI를 작성•검증•시각화•활용하기 위한 도구 모음 Swagger 공식 문서에도 이를 다음과 같이 설명한다. OpenAPI는 REST API를 설명하는 방식이며, Swagger는 OAS를 기반으로 구축된 오픈소스 도구 모음이다. Swagger는 다음과 같은 도구를 제공한다. Swagger Editor: API 명세 작성 및 검증 Swagger UI: API 문서 시각화 및 실행 Swagger Codegen: API 인터페이스 코드, 클라이언트 SDK 등의 코드 생성 이 글에서 다루는 대상은 이 중 Spring Boot 환경에서 사용되는 Swagger UI이다. 2. 문제 상황: Swagger를 Controller에 직접 작성할 때의 문제 앞에서 본 것처럼, Swagger는 OpenAPI 명세를 기반으로 API 문서를 시각화하고, 직접 실행(try-out)까지 가능하게 하는 좋은 도구이다. Spring Boot 환경에서는 springdoc-openapi를 통해 간단히 Swagger UI를 붙일 수 있고, 문서를 코드로 관리할 수 있는 장점이 있다. 그런데 문제는 Swagger를 사용하는 것은 쉽지만, Swagger 문서를 상세히 작성하다보면 컨트롤러 코드가 비대해지는 문제가 있다. 실제 요청 처리보다 Swagger 문서 어노테이션이 더 많은 비중을 차지하게 된다. 즉, Swagger 문서를 잘 쓰려고, 친절하게 작성하려 노력할수록 컨트롤러가 요청 처리 코드가 아니라 문서 코드 저장소가 되는 문제가 있다. 2.1 데모 프로젝트 개요 데모 프로젝트를 기반으로 내가 겪은 문제를 재현하고 직접 해결한 전략을 공유하고자 한다. 데모 프로젝트에 대해 간단히 설명하면 다음과 같으며, 본 글의 모든 예제와 구조는 아래 GitHub 저장소를 기준으로 설명한다. 🔗 https://github.com/Gumraze-git/devlog-demo-openapi-swagger 도메인: 커피 주문 시스템 제공 API(기본 CRUD 기반) POST: 주문 생성 GET: 주문 목록 조회, 주문 단건 조회 PUT: 주문 수정 DELETE: 주문 삭제 기술 스택 Spring Boot 4.0.2 springdoc-openapi + Swagger UI etc... 프로젝트 구조는 다음과 같다. com/gumraze/opanapi ├── OpenapiSwaggerDemoApplication.java ├── config │ ├── OpenApiConfig.java │ └── SecurityConfig.java ├── controller │ ├── OrderController.java │ └── api │ ├── CommonErrorResponses.java │ └── OrderApi.java ├── dto │ ├── OrderCreateRequest.java │ ├── OrderItemRequest.java │ ├── OrderItemResponse.java │ ├── OrderResponse.java │ └── OrderUpdateRequest.java ├── entity │ ├── Coffee.java │ ├── Order.java │ ├── OrderItem.java │ └── OrderStatus.java ├── exception │ └── GlobalExceptionHandler.java ├── repository │ ├── CoffeeRepository.java │ └── OrderRepository.java └── service ├── OrderService.java └── OrderServiceImpl.java 본 데모 프로젝트에서는 다음을 다루지 않는다. 도메인 모델링 트랜잭션 설계, 예외 처리 전략 컨트롤러 및 서비스 구현의 디테일 다음을 목표로 한다. Swagger 문서 작성 시의 문제점 구조적 분리 방법 제안 2.2 Swagger + Controller 코드 이제 실제로 Swagger 문서를 상세히 작성한 컨트롤러 코드를 살펴보자. 다음은 주문 생성 API에 대한 OrderController의 메서드 일부이다. @PostMapping @Operation(summary = "주문 생성", description = "주문 항목을 포함해 새로운 주문을 생성합니다.") @RequestBody( required = true, content = @Content( schema = @Schema(implementation = OrderCreateRequest.class), examples = @ExampleObject( name = "주문 생성 예시", value = """ { "items": [ { "coffeeId": 1, "coffeeName": "아메리카노", "unitPrice": 3500, "quantity": 2 }, { "coffeeId": 2, "coffeeName": "라떼", "unitPrice": 4200, "quantity": 1 } ] } """ ) ) ) @ApiResponses({ @ApiResponse(responseCode = "201", description = "생성됨", content = @Content(schema = @Schema(implementation = OrderResponse.class))), @ApiResponse(responseCode = "400", description = "요청 검증 실패", content = @Content) }) public ResponseEntity<OrderResponse> createOrder( @Valid @RequestBody OrderCreateRequest request ) { OrderResponse response = orderService.createOrder(request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } Swagger 문서 관점에서 보면, 요청 예시와 응답 스키마가 명확히 정의된 친절한 문서라고 볼 수 있다. 다음으로 실제로 Swagger UI에서도 다음과 같이 잘 정리된 화면을 볼 수 있다. 그러나 컨트롤러 코드 관점에서 보면 Swagger 관련 어노테이션이 많아 다음 문제가 존재한다. 요청 처리 흐름에 대한 가독성이 떨어진다. 컨트롤러의 책임이 불분명해진다. 즉, 컨트롤러가 가져야 할 역할과 책임이 흐려지는 문제가 있다고 생각한다. 따라서 문서화 영역과 컨트롤러의 역할과 책임이 구분될 필요성을 느끼고, Swagger 관련 코드를 컨트롤러에서 분리하는 구조에 대해 고민하게 되었다. 3. 해결 전략 1: Swagger 전용 API 인터페이스 분리 앞에서 살펴본 문제를 해결하기 위해 가장 먼저 떠올린 방법은 Swagger 관련 코드를 컨트롤러 밖으로 분리하는 것이었다. 이 때 참고한 구조는 익숙한 패턴으로 바로 Service 인터페이스와 구현체를 분리하는 구조이다. 일반적으로 Service 계층에서는 인터페이스에 역할과 계약을 정의하고 구현체에서 실제 비즈니스 로직을 처리한다. 이 구조에 착안해서 컨트롤러의 역할과 문서(계약)을 분리할 수 있지 않을까?라는 생각이 들었다. 3.1 Swagger 전용 인터페이스로 분리 먼저 Swagger 관련 어노테이션을 모두 담은 OrderApi 인터페이스를 정의했다. 이 인터페이스는 HTTP 엔드포인트의 계약(contract) 역할만 수행한다. @Operation(summary = "주문 생성", description = "주문 항목을 포함해 새로운 주문을 생성합니다.") @RequestBody( required = true, content = @Content( schema = @Schema(implementation = OrderCreateRequest.class), examples = @ExampleObject( name = "주문 생성 예시", value = """ { "items": [ { "coffeeId": 1, "coffeeName": "아메리카노", "unitPrice": 3500, "quantity": 2 } ] } """ ) ) ) @ApiResponses({ @ApiResponse(responseCode = "201", description = "생성됨", content = @Content(schema = @Schema(implementation = OrderResponse.class))), @ApiResponse(responseCode = "400", description = "요청 검증 실패", content = @Content) }) ResponseEntity<OrderResponse> createOrder(OrderCreateRequest request); 이 인터페이스에는 다음 특징이 있다. Swagger(OpenAPI) 관련 어노테이션만 존재한다. 비즈니스 로직은 포함하지 않는다. 요청과 응답에 대한 계약을 드러낸다. 즉, OrderApi는 문서이자 계약에 집중하게 된다. 3.2 분리 후 Controller 코드의 변화 이제 OrderController는 OrderApi를 구현하도록 한다. @RestController @RequestMapping("/api/orders") public class OrderController implements OrderApi { @Override @PostMapping public ResponseEntity<OrderResponse> createOrder( @Valid @RequestBody OrderCreateRequest request ) { OrderResponse response = orderService.createOrder(request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } } Swagger 관련 어노테이션 분리 이후로 컨트롤러 코드는 다음 이점이 있다. 가독성 향상 책임 분리 유지보수성 향상 이것으로 컨트롤러는 다시 요청 처리 계층으로서의 역할을 되찾게 되었다. 4. 해결 전략 2: 공통 Swagger 응답 분리 위와 같이 분리함으로 각각의 역할과 책임은 이미 잘 분리되었다고 생각한다. 그러나 Swagger 문서를 다시 살펴보며 또 하나의 반복 패턴을 발견하게 되었다. 바로 에러 응답 정의의 중복이다. 4.1 공통 에러 응답의 성격 대부분의 API는 다음과 같은 에러 응답을 공통적으로 가진다. 400 Bad Request: 요청 값 검증 실패 404 Not Found: 리소스를 찾을 수 없음 500 Internal Server Error: 서버 내부 오류 이 응답들은 엔드포인트마다 의미가 크게 달라지지 않고 응답 스키마 역시 거의 동일하다. 따라서 Swagger 문서를 작성하다 보면 이 공통 에러 응답 정의가 모든 API 메서드에 반복적으로 등장하게 된다. 그래서 이 부분들을 공통 처리 로직으로 분리해두면 개발하는 데 더 편리할 것으로 생각했다. 4.2 @CommonErrorResponse 설계 위 중복을 제거하기 위해, 다음 공통 에러 응답을 하나의 커스텀 어노테이션으로 분리했다. @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @ApiResponses({ @ApiResponse( responseCode = "400", description = "요청 검증 실패", content = @Content(schema = @Schema(implementation = ProblemDetail.class)) ), @ApiResponse( responseCode = "404", description = "리소스를 찾을 수 없음", content = @Content(schema = @Schema(implementation = ProblemDetail.class)) ), @ApiResponse( responseCode = "500", description = "서버 오류", content = @Content(schema = @Schema(implementation = ProblemDetail.class)) ) }) public @interface CommonErrorResponses { } 이 어노테이션을 적용하면, Swagger 문서에서 공통 에러 응답을 일관된 형태로 표현할 수 있다. @CommonErrorResponses ResponseEntity<OrderResponse> createOrder(OrderCreateRequest request); 4.3 성공 응답을 공통화하지 않은 이유 반면에 성공 응답은 공통 어노테이션으로 분리하지 않았다. 그 이유는 성공 응답이 가지는 성격이 에러 응답과 다르기 때문인데, 성공 상태 코드는 200, 201, 204등으로 다양하고 엔드포인트마다 응답 스키마가 다르며 의미 역시 API마다 명확히 구분된다. 따라서 성공 응답까지 공통화하려 하면, 어노테이션이 지나치게 복잡해지거나 오히려 문서의 명확성이 떨어질 수 있다고 생각했다. 5. 정리 간단히 정리하자면 Swagger는 좋은 도구인 만큼 잘 사용해야 하는데, 컨트롤러가 비대해져서 가독성이 떨어지는 문제를 경험했으며, 해당 문제를 해결하는 과정을 기록해봤다. 해결하는 과정은 다음과 같았다. Swagger 문서와 API 계약은 전용 API 인터페이스로 분리함. 컨트롤러는 요청 처리만 집중하도록 역할 명확화. 반복되는 에러 응답 정의는 공통 어노테이션으로 추출함. 성공 응답 공통화는 과한 분리라고 판단하여 수행하지 않음. 그래서 위 구조처럼 해야만 하는 것인가? 그렇지 않다. 필요에 의해 선택하면 되며, 나와 같은 고민을 가진 사람들에게 도움이 되었으면 좋겠다. 마치며,, 이번 글에서는 부트캠프에서 일련의 API 개발과정에서 느낀 점을 기반으로 작성하게 되었다. 먼저 API 명세 작성에 대한 고민과 OpenAPI/Swagger 문서에 대한 고민을 기반으로 이번 글을 작성하게 되었다. 이번 글에서 다룬 내용이 그렇게 복잡하지는 않지만, 나에게는 문서 작성이 코드를 이해하고 협업을 하는데 많은 도움이 되어 그 중요성을 느꼈고 항상 강조하고 싶다. 그러나, 부트캠프에서 느낀점은 이러한 부분을 많이 간과하고 있다고 생각한다. 부트캠프 특성상 짧은 기간에 프로젝트를 구현해야한다고 생각하는 것 같은데, 나는 실무 환경에서는 이것보다 시간이 없을 것 같다는 생각이 든다. 그래서 항상 글로서 내가 고민했던 내용과 기술의 사용 방법 등이라던지 문서를 항상 남기는 습관을 들여서 이것을 하나의 나의 무기로 남기고 싶다. 참고자료 OpenAPI 공식 소개 - What is OpenAPI? OpenAPI Specification 공식 문서 (Toss Payments Glossary) Swagger 공식 문서 - OpenAPI Specification 개요 Swagger 공식 문서 - OpenAPI Specification v3.0 springdoc-openapi 공식 문서 - Getting Started springdoc-openapi GitHub Repository
들어가며 앞선 글에서 REST의 배경과 제약 조건을 기반으로 RESTful한게 어떤 것을 의미하는지 다루었다. 이전 내용을 잠시 Recap하자면, 다음과 같다. REST의 6가지 제약 조건 (1) Client-Server: 클라이언트와 서버를 분리한다. (2) Stateless: 서버는 클라이언트의 상태를 저장하지 않는다. (3) Cache: 응답은 캐시가 가능하며, 캐시 가능 여부를 명시해야 한다. (4) Uniform Interface: 일관된 인터페이스를 가진다. ├─(4.1) Identification of resource: 모든 자원은 URI로 구분된다. ├─(4.2) Manipulation through representations: 클라이언트는 자원의 표현을 통해 자원을 다룬다. ├─(4.3) Self-descriptive messages: 메시지 자체만으로 처리 의미를 해석할 수 있다. └─(4.4) HATEOAS: 응답에 상태 전이를 위한 링크를 포함할 수 있다. (5) Layered System: 시스템은 여러 계층으로 구성 될 수 있다. (6) Code-on-Demand (optional): 서버가 클라이언트에 실행 가능한 코드를 전달할 수 있다.. 그렇다면, 내가 사용하는 Spring에서는 REST를 어떻게 지원하고 있는지 살펴보고자 한다. 1. Spring에서의 REST REST는 특정 기술이나 프레임워크가 아니라, 클라이언트와 서버가 자원을 주고받는 방식을 정의한 아키텍처 스타일이다. 따라서 "Spring이 REST를 제공한다."라는 말은 엄밀히 말하면 정확하지는 않다. 정확하게 표현하자면, REST 스타일의 애플리케이션을 구현하기에 적합한 웹 기술 스택과 추상화 계층을 제공한다. 라고 보는게 더 정확하다. 그러면 이어지는 내용으로 Spring이 어떤 전제를 가진 모델 위에서 동작하며, 그 안에서 REST의 제약을 어떤 방법으로 다루고 있는지 살펴보고자 한다. 1.1 Spring 공식 문서: Web on Servlet Stack 먼저 Spring 프레임워크 공식 문서를 확인해보면, 웹과 관련된 내용은 Web on Servlet Stack이라는 섹션 아래에 정리되어 있다. 이 섹션을 보면, REST와 대놓고 관련되어 보이는 REST Clients라는 항목이 보인다. 그리고 Web on Servlet Stack 하위에는 다음과 같은 항목들이 있다. Spring Web MVC REST Clients Testing WebSockets 이 구조를 보면 Spring은 REST를 하나의 독립된 기술로 다루고 있지 않고, Servlet 기반 웹 스택 안에서 다루고 있다. 따라서 Spring에서의 REST를 이해하기 위해서는 개별 REST 기능을 바로 살펴보기보다는, Web on Servlet Stack에서 말하는 Servlet(서블릿)이 뭔지? 다음으로 Web on Servlet Stack이 뭔지? 마지막으로는 각 하위 항목들을 살펴보고자 한다. 1.2 서블릿이란 무엇인가? 먼저 Servlet이라는 어원부터 살펴보자면, 서블릿은 Service와 "작은 것"을 의미하는 let의 합성어로, 직역하면 작은 서비스 단위 정도로 이해할 수 있다. 어원을 보면 그 의미가 잘 이해가 되는 경우가 많은 것 같다. 이를 서블릿이 어떤 역할을 하는지 중심으로 정리하면 다음과 같다. 서블릿은 서블릿 컨테이너로부터 HTTP 요청을 전달받아 Java 로직을 실행하거나 위임하고, 그 결과를 HTTP 응답으로 작성하는 Java 클래스(작은 서비스 단위)이다. 여기서 중요한 점은, 서블릿이 HTTP 요청을 직접 수신하는 주체가 아니라는 점이다. 네트워크 요청의 수신과 연결 관리를 서블릿 컨테이너의 책임이며, 여기서 인지하고 있어야하는 것은, 서블릿은 요청을 전달받아 애플리케이션 로직을 수행하는 실행 단위에 가깝다는 것이다. 1.2.1 서블릿의 등장 배경 웹 이전의 Java는 주로 콘솔 프로그램이나 데스크톱 애플리케이션을 만들기 위한 언어였다. 하지만 웹이 등장하면서 Java에도 다음 요구가 생겼다. HTTP 요청을 처리하고 Java 코드로 비즈니스 로직을 수행한 뒤 HTTP 응답을 반환하고 싶다. 따라서 서블릿은 위 요구를 충족시키기 위해서 탄생한 것이, Java Servlet API이다. 서블릿 API를 통해서 웹 애플리케이션은 다음과 같은 실행 구조를 가지게 되었다. 브라우저는 HTTP 요청을 전송하고 Tomcat과 같은 서블릿 컨테이너가 요청을 수신한 뒤 적절한 서블릿을 찾아 해당 메서드를 호출한다. 이 구조에서 중요한 점은 다음과 같다. 브라우저는 서블릿의 존재를 알 수 없다. 서블릿 컨테이너가 요청 수신과 전달을 담당한다. 서블릿은 비즈니스 로직 수행과 응답 작성을 담당한다. 이처럼 역할이 분리된 구조는 클라이언트와 서버의 책임을 구분한다는 점에서 REST가 제시하는 Client-Server 제약 조건과 구조적으로 잘 맞는다. 즉, 서블릿은 HTTP 요청을 직접 처리하는 서버라기보다는, 요청을 전달받아 Java 애플리케이션을 실행하고 그 결과를 응답으로 표현하는 역할을 수행한다고 볼 수 있다. 이런 의미에서 서블릿은 네트워크 처리와 애플리케이션 로직 사이에 위치한 실행 단위라고 볼 수 있다. 1.2.2 서블릿 기반 실행 모델 따라서 서블릿은 단독으로 실행되는 컴포넌트가 아니라는 것을 알았는데, 서블릿은 항상 서블릿 컨테이너와 함께 동작하며, 이 둘을 중심으로 한 웹 애플리케이션의 실행 방식에는 공통점이 있다. 이를 보통 서블릿 기반 실행 모델이라고 부른다. 이 실행 모델은 다음과 같은 특성을 가진다. Java Servlet API를 기반으로 한다. Tomcat, Jetty, Undertow와 같은 서블릿 컨테이너 위에서 실행된다. 하나의 HTTP 요청에 하나의 스레드를 할당하는 구조를 가진다. 요청 처리는 기본적으로 Blocking I/O 방식으로 이루어진다. 즉, HTTP 요청이 들어오면 서블릿 컨테이너가 요청을 수신하고 요청마다 스레드를 할당한 뒤 해당 요청을 처리할 서블릿을 호출하는 구조이다. 여기서 중요한 점은, Spring MVC는 이 실행 모델을 변경하지 않는다는 점이다. Spring은 새로운 웹 실행 방식을 제안하기 보다는, 이 서블릿 기반 실행 모델을 전제로 하여 HTTP 요청 처리 과정을 더 구조화하고 추상화한다. 이제 다음 섹션에서는 이러한 서블릿 기반 실행 모델 위에서 Spring이 제공하는 웹 기능 묶음인 Web on Servlet Stack에 대해 살펴보자. 1.3 Web on Servlet Stack의 구성 앞서 살펴본 서블릿 기반 실행 모델을 전제로, Spring 공식 문서에서는 웹 관련 기능을 Web on Servlet Stack이라는 이름으로 묶어 설명하고 있다. 여기서 말하는 "Servlet Stack"이란 다음과 같은 기술 계층을 의미한다. Java 서블릿 API 서블릿 컨테이너 요청당 스레드 실행 모델 Blocking I/O 기반 요청 처리 방식 즉, Web on Servlet Stack은 서블릿 기반 실행 모델을 전제로 하는 Java 웹 애플리케이션 환경을 의미한다. 1.4 하위 항목들에 대한 설명 따라서 Web on Servlet Stack 아래에는 4가지 항목이 있는데 각각 항목을 먼저 간단히 설명하면 다음과 같다. Spring Web MVC HTTP 요청을 받아서 처리하는 서버 프레임워크로 HTTP 요청을 컨트롤러로 전달하고 응답을 생성하는 역할을 수행한다. REST Clients 외부 HTTP 서버에 요청을 보내는 클라이언트용 API로 HTTP 요청을 보내는 클라이언트 도구이다. Testing: Servlet 기반 웹 애플리케이션을 테스트하기 위한 지원 모듈로 서버 없이 테스트를 할 수 있는 MockMvc 등이 담겨 있다. WebSockets Servlet 환경에서 양방향 실시간 통신을 제공하는 기능으로 채팅, 알림, 실시간 상태 업데이트 등에 사용된다. REST는 기본적으로 클라이언트와 서버가 자원을 주고 받는 통신 방식을 정의한 아키텍처 스타일인데, 해당 관점에서 Web on Servlet Stack의 구성 요소 중 REST와 직접적으로 연관되는 항목은 Spring Web MVC와 REST Client이다. 다만 이 글에서는 클라이언트 역할을 수행하는 서버를 설명하기보다는 서버 측에서 REST 스타일의 API를 어떻게 구성하는지에 대해 초점을 맞출 예정이다. 따라서 이후 내용에서는 Spring Web MVC를 중심으로 살펴보고자 한다. 2. Spring Web MVC와 REST 이제 돌고 돌아서 실제로 Spring 정확히는 Spring Web MVC에서 REST 스타일의 애플리케이션을 어떻게 지원하고 있는지를 살펴보고자 한다. 먼저 항상 그렇듯, Spring Web MVC 중 MVC의 어원을 먼저 살펴보고자 한다. 여기서 MVC는 Model · View · Controller의 약자로, 사용자 인터페이스를 구성하기 위한 아키텍처 패턴이다. MVC의 목적은 다음과 같다. 데이터(Model) 화면(View) 입력 및 제어 흐름(Controller) 위 세 가지의 관심사를 분리하여 시스템의 복잡도를 낮추고 변경에 유연하게 대응하는 것이다. 느슨한 결합! 이것도 RESTful 하다고 볼 수 있다. 그렇다면 Web MVC는 뭘까? 웹은 HTTP를 기반으로 동작하는데 이를 MVC 패턴으로 해석한 구조이다. 웹 환경에서는 다음과 같은 전제가 존재한다. 요청: HTTP Request 응답: HTTP Response 화면: HTML과 같은 문서의 형태 입력: URL, Method, Parameter 등으로 전달됨. 이러한 웹 환경의 특성을 MVC에 대응시키면 다음과 같이 정리할 수 있다. 2.1 Spring MVC의 요청 처리 구조 Spring Web MVC가 REST 스타일의 애플리케이션을 어떻게 지원하는지 이해하기 위해서는 먼저 HTTP 요청이 내부에서 어떤 흐름으로 처리되는지를 살펴볼 필요가 있다. Spring Web MVC의 공식 문서에서 가장 중심이 되는 구성 요소로 DispatcherServlet이다. DispatcherServlet은 다음과 같은 역할을 수행한다. 모든 HTTP 요청의 단일 진입점(Single Entry Point) 요청을 직접 처리하지 않고, 적절한 컴포넌트로 위임 Front Controller 패턴의 전형적인 구현체 2.2 Front Controller의 등장 배경 Spring MVC 이전에 전통적인 서블릿 기반 애플리케이션에서는, 요청마다 서로 다른 서블릿을 매핑하는 구조를 사용하는 경우가 있었다. 따라서 다음과 같은 구조를 가지고 있었다. 위 경우에서는 서블릿 컨테이너(Tomcat 등)은 web.xml과 같은 설정을 통해서 요청 URL에 따라 어떤 서블릿을 호출할지 결정한다. 이 구조의 문제점은 다음과 같다. 공통적인 요청 처리 로직이 각 서블릿에 분산된다. 인증, 로깅, 예외 처리와 같은 공통 관심사를 일관되게 적용하기 어렵다. 요청 처리 흐름을 한 곳에서 파악하기 어렵다. Spring은 이러한 상황을 별로 좋아하지 않았다. 따라서 이러한 구조를 개선하기 위해, 모든 HTTP 요청을 하나의 서블릿에서 받아서 처리하는 구조인DispatcherServlet을 도입했다. DispatcherServlet은 HTTP 요청에 대한 처리를 오케스트레이션하는 역할을 한다고 볼 수 있다. Handler* 관련 내용은 이어 설명하고자 한다. 이처럼 모든 요청을 하나의 진입점에서 받고, 공통 규칙에 따라 처리 흐름을 분기하고 위임하는 패턴을 Front Controller 패턴이라고 한다. 2.3 HandlerMapping: 요청은 누가 처리하는가 위 다이어그램을 보면 DispatcherServlet이 요청을 받으면, 다음으로 "이 요청을 누가 처리할 것인지를 결정"하는 것이 이 컴포넌트이다. HandlerMapping은 HTTP + URI를 기준으로 어떤 컨트롤러 메서드가 요청을 처리할지를 결정한다. 예시로 다음 요청이 들어온다. // HTTP 요청 GET /users/1 Spring은 HandlerMapping을 통해 다음과 같은 컨트롤러 메서드를 찾는다. // GET요청 + URI으로 다음 컨트롤러 메서드를 찾는다. @GetMapping("/users/{id}") public UserResponse getUser(@PathVariable Long id) { ... } 위 지점은 REST 관점에서 분석해보자면 Uniform Interface 제약이 Spring Web MVC 내부에서 반영되는 부분이다. 자원은 URI로 식별된다. → /users/{id} 행위는 HTTP Method로 표현된다. → GET 즉, Spring MVC는 URI와 HTTP Method를 조합하여 요청을 매핑함으로써, REST에서 말하는 자원 중심 인터페이스를 자연스럽게 지원한다. 2.4 HandlerAdapter: 다양한 컨트롤러를 하나의 방식으로 요청을 처리할 컨트롤러가 HandlerMapping으로 결정되었다고 해서, 곧바로 해당 메서드를 호출할 수 있는 것은 아니다. 따라서, HandlerAdapter가 선택된 컨트롤러를 DispatcherServlet이 호출할 수 있는 형태로 adapt(적응)시키는 역할을 수행한다. 다이어그램을 다시 확인하면 HandlerMapping이 이후 컨트롤러로 도달하려면 HandlerAdapter를 통해서 실제 비즈니스 로직을 수행하는 컨트롤러로 도달하는데 연결하는 역할을 수행한다. 2.5 Controller: 자원에 대한 행위 정의 컨트롤러는 자원에 대한 표현(행위)를 정의하는 역할을 담당한다. @GetMapping("/users/{id}") public UserResponse getUser(@PathVariable Long id) { ... } REST 관점에서 보면, 이 메서드는 /users/{id}라는 자원을 GET이라는 HTTP Method를 통해 조회하는 행위를 표현한다. 즉, Spring Web MVC의 컨트롤러는 REST에서 말하는 자원과 행위의 결합 지점이라고 분석할 수 있다. 2.6 HttpMessageConverter - Representation의 구현 컨트롤러가 반환하는 값은 보통 다음과 같은 Java 객체 형태이다. UserResponse 하지만 HTTP 응답은 문자열 또는 바이트 스트림 형태로 전달되어야한다. 따라서 Spring Web MVC는 이 문제를 해결하기 위해 HttpMessageConverter를 사용한다. HttpMessageConverter는 다음 역할을 수행한다. Java 객체 ↔ JSON(XML 등) 변환 Content-Type에 따른 표현 방식 결정 이 계층은 REST에서 말하는 Representation이 실제로 구현되는 지점이라고 볼 수 있다. 2.7 ResponseEntity: HTTP 응답의 의미를 표현함 마지막으로, Spring MVC는 ResponseEntity를 통해 HTTP 응답의 의미를 명시적으로 표현할 수 있도록 지원한다. ResponseEntity는 다음 요소를 포함한다. HTTP Status Code Response Header Response Body 이 ResponseEntity를 통해 단순히 데이터를 반환하는 것 뿐만아니라, HTTP 응답 자체에 의미를 부여할 수 있다. 이 부분 역시 REST 스타일의 API를 구성하는데 있어 중요한 부분이다. 2.8 정리: RESTful함을 어디에서 결정되는가? 지금까지 Spring Web MVC의 요청 처리 구조를 따라가며, REST 제약이 프레임워크 내부에서 어떻게 드러나는지를 살펴보았다. 이를 통해서 알 수 있었던 점은 Spring MVC는 일부 REST 제약을 자연스럽게 만족시키는 구조를 제공하지만, REST의 모든 제약을 자동으로 보장하지는 않는다.라는 것이다. 2.8.1 Spring MVC가 결정해주지 않는 것들 Spring MVC는 요청 처리의 실행 모델과 구조를 제공할 뿐, 다음 요소에 대해서는 명시적인 결정을 내려주지는 않는다. 자원을 어떻게 나눌 것인가. URI를 어떤 기준으로 설계할 것인가. HTTP Method를 어떤 의미로 사용할 것인가. 응답에 어떤 Status Code를 사용할 것인가. 캐시 가능 여부를 어떻게 표현할 것인가. 상태 전이(HATEOAS)를 포함할 것인가. 따라서 개발자들은 RESTful함에 대해 다음을 고민해야한다. 이 API에서 자원은 무엇인가? 이 요청은 조회인가, 상태 변경인가? 이 행위는 HTTP Method 중 어떤 것으로 표현하는게 맞는가? 이 결과는 어떤 Status Code인가? 이 응답은 캐시되어도 되는가? 상태 전이를 사용할 것인가? 2.8.2 RESTful함이란, 정리하자면, RESTful함은 어떤 프레임워크를 사용했는지가 아니라, REST 제약을 이해하고, 그 제약을 어디까지 적용할지 판단하여 API를 설계했는지 에 따라 달라지는 것이다. 3. RESTful Spring MVC Demo 이제 실제 코드를 통해 REST 스타일의 API가 Spring Web MVC에서 어떻게 표현되는지 확인해 볼 차례이다. 전체 Demo 코드는 다음 Git Repository에서 확인 가능합니다! 이번 Demo의 목표는 다음과 같다. RESTful API 설계 자원을 중심으로 URI 설계함. HTTP Method와 Status Code를 의미에 맞게 사용함. Spring MVC 요청 처리 구조 확인 Controller -> HttpMessageConverter -> ResponseEntity 흐름을 코드로 확인함. REST 제약이 코드에서 드러나는 지점 확인 Spring MVC가 지원하는 것과 개발자가 결정한 것을 구분한다. 3.1 Demo 시나리오 개요: 주문 이번 Demo에서는 주문(Order)를 자원으로 정의하고, 이 자원이 HTTP 요청/응답에서 표현(representation)이 되는 과정을 확인한다. 자원: Order 자원 컬렉션: /orders 단일 자원 식별: /orders/{orderId} 이를 기준으로 다음 CRUD 기능(행위)를 구현해본다. 주문 생성(Create) 주문 단건 조회(Read) 주문 수정(Update) 주문 삭제(Delete) 각 단계마다 Spring MVC가 지원하는 것과 개발자가 결정해야하는 것을 구분하면 살펴볼 예정이다. 3.2 API 설계 자원 컬렉션: /orders 단일 자원: /orders/{orderId} 기능 HTTP Method URI 성공 Status Code 응답(Representation) 주문 생성 POST /orders 201 Created 생성된 Order(또는 id) 주문 단건 조회 GET /orders/{orderId} 200 OK Order 주문 수정 PATCH /orders/{orderId} 204 No Content 없음 주문 삭제 DELETE /orders/{orderId} 204 No Content(자원 미반환) 없음 수정의 경우에는 200 또는 204 모두 가능하지만, demo에서는 응답 본문을 반환하지 않는 방식으로 단순화하기 때문에 204 No Content로 통일한다. Status Code 간단 정리 200 OK: 요청이 성공하였으며 처리 결과(representation)를 반환한다. 201 Created: 새로운 자원이 생성되었음을 의미한다. REST에서는 Location 헤더로 새 자원의 URI를 함께 제공한다. 204 No Content: 요청은 성공했으나, 응답 본문(처리 결과)이 없음을 의미한다. 400 Bad Request: 요청 값이 잘못되었거나 서버가 이해할 수 없다. 404 Not Found: 요청한 자원이 서버에 존재하지 않는다. 409 Conflict: 서버의 현재 상태와 충돌한다(중복 생성, 상태 충돌 등). 500 Internal Server Error: 서버 내부에서 예상치 못한 오류가 발생했다. 3.3 DTO 설계 DTO는 자원의 표현(Representation)을 구성하는 핵심이다. 즉, REST 관점에서 클라이언트와 서버가 주고 받는 메시지의 구조를 정의한다. 주문 생성 요청 DTO public class CreateOrderRequestDto { String name; int quantity; } 주문 수정 요청 DTO public class UpdateOrderRequestDto { private String name; private int quantity; } 주문 응답 DTO(HATEOAS 포함) import org.springframework.hateoas.RepresentationModel; public class OrderResponseDto extends RepresentationModel<OrderResponseDto> { Long id; String name; int quantity; } RepresentationModel<T>는 응답에 링크를 추가할 수 있는 기반 타입이다. 즉, HATEOAS를 적용하기 위해 응답 DTO가 "링크를 담을 수 있는 구조"를 갖게 된다. 링크는 자동 생성되지 않으며 처리는 컨트롤러가 담당한다. 이에 대한 상세 내용은 컨트롤러 구현 코드에서 설명할 예정이다. 3.4 Controller 구현 이번 글의 목적은 REST가 Spring MVC 코드에서 어떻게 표현되는가?를 확인하는 것이므로, 비즈니스 로직(서비스)은 다루지 않는다. 먼저 @RestController를 사용하여 컨트롤러를 구현했다. @RestController는 Spring MVC에서, HTTP 요청을 처리하고, 그 결과를 HTTP 응답 본문으로 직접 반환하는 컨트롤러임을 의미한다. 이는 다음 두 애노테이션의 조합이다. @Controller @ResponseBody 즉, 컨트롤러 메서드의 반환값이 View 이름이 아니라 HTTP Response Body(Representation)으로 처리되며, Spring MVC의 HttpMessageConverter를 통해 Java 객체가 JSON 형태로 변환되어 응답으로 전달된다. 이러한 특성 때문에 @RestController는 REST 스타일의 API를 구현할 때 기본적으로 사용된다. @RestController @RequestMapping("/orders") @AllArgsConstructor public class OrderController { ... } 3.4.1 주문 생성 API 주문 생성 API의 표현은 POST /orders로 정의하며, 요청이 성공적으로 처리되면 201 Created 상태 코드를 반환한다. @PostMapping public ResponseEntity<OrderResponseDto> createOrder( @RequestBody CreateOrderRequestDto request ) { if (request == null) { throw new ResponseStatusException(BAD_REQUEST, "요청 값이 없습니다."); } OrderResponseDto response = orderService.createOrder(request); addLinks(response); return ResponseEntity .created( linkTo(methodOn(OrderController.class) .getOrder(response.getId())) .toUri() ) .body(response); } private void addLinks(OrderResponseDto response) { response.add(linkTo(methodOn(OrderController.class).getOrder(response.getId())).withSelfRel()); response.add(linkTo(methodOn(OrderController.class).updateOrder(response.getId(), null)).withRel("update")); response.add(linkTo(methodOn(OrderController.class).deleteOrder(response.getId())).withRel("delete")); } 주문 생성 요청이 성공하면, Spring MVC의 ResponseEntity.create(URI)를 통해 다음 의미를 가진 응답이 만들어진다. HTTP Status Code: 201 Created Location Header: 생성된 자원의 URI(/orders/{id}) Response Body: 생성된 Order 자원의 Representation 이는 단순히 요청이 성공했다는 의미가 아니라, 새로운 자원이 생성되었으며, 해당 자원을 다룰 수 있는 위치가 결정되었음을 HTTP 레벨에서 표현하는 방식이다. 따라서 Spring MVC가 지원하는 것 @PostMapping, @RequestBody를 통한 요청 매핑 및 바인딩 요청/응답 DTO ↔ JSON 변환 HttpMessageConverter가 담당 ResponseEntity를 통한 Status Code, Header, Body 구성 개발자가 결정해야하는 것 생성 성공의 의미를 201 Created로 표현할 것인지 생성된 자원의 URI를 Location 헤더로 제공할 것인지 응답 본문에 자원의 Representation을 포함할 것인지 어떤 상태 전이 링크(HATEOAS)를 응답에 포함할 것인지 다시 돌아와서 응답 성공의 예시를 보면 다음과 같다. Header에 Location과 _links로 HATEOAS가 제공되는 것을 확인할 수 있다. 3.4.2 주문 단건 조회 API 주문 단건 조회 API는 GET /orders/{orderId}로 정의하며, 요청이 성공적으로 처리되면 200 OK 상태 코드와 함께 해당 주문 자원의 표현을 반환한다. @GetMapping("/{id}") public ResponseEntity<OrderResponseDto> getOrder(@PathVariable Long id) { OrderResponseDto response = orderService.getOrder(id); addLinks(response); return ResponseEntity.ok(response); } 마찬가지로 addLink를 통해 HATEOAS를 지원한다. 3.4.3 주문 수정 API 주문 수정 API는 PATCH /orders/{orderId}로 정의한다. 이번 demo에서는 수정 성공 시, 응답 본문을 반환하지 않는 방식으로 단순화하여, 204 No Content 상태 코드를 사용한다. @PatchMapping("/{id}") public ResponseEntity<Void> updateOrder( @PathVariable Long id, @RequestBody UpdateOrderRequestDto request ) { if (request == null) { throw new ResponseStatusException(BAD_REQUEST, "요청 값이 없습니다."); } request.setId(id); orderService.updateOrder(request); return ResponseEntity.noContent().build(); } 수정 요청이 성공하는 서버는 요청이 정상적으로 처리되었음을 의미하는 204 No Content 응답을 반환한다. 단건 조회로 수정되었음을 확인할 수 있다. 여기서도 마찬가지로 개발자가 결정하는 것은 수정 성공 시의 응답을 200 OK로 할 것인지, 204 No Content로 할 것인지 이다. 3.4.4 주문 삭제 API 주문 삭제 API는 DELETE /orders/{orderId}로 정의한다. 삭제 성공 시에는 마찬가지로 204 No Content를 반환한다. @DeleteMapping("/{id}") public ResponseEntity<Void> deleteOrder(@PathVariable Long id) { orderService.deleteOrder(id); return ResponseEntity.noContent().build(); } 주문 삭제 성공 시, 서버는 다음 의미를 가진 응답을 반환한다. 요청을 정상적으로 처리됨. 해당 자원은 더 이상 존재하지 않는다. 응답 본문은 제공하지 않는다. 이후 동일한 자원에 대한 조회 요청을 보내면 404 Not Found응답을 받게 된다. 3.4.5 Controller에서 드러나는 REST 제약 정리 이번 Demo의 컨트롤러 코드에서 REST 제약이 드러나는 지점을 정리하면 다음과 같다. Client-Server 클라이언트는 URI와 HTTP Method만 알고 요청을 보낸다 서버는 자원 상태와 Representation만 반환한다. Stateless 각 요청은 독립적으로 처리되며, 서버는 클라이언트의 이전 요청 상태를 저장하지 않는다. Uniform Interface 자원은 URI로 식별된다. 행위는 HTTP Method로 표현된다. HATEOAS _links를 통해 다음 상태 전이를 표현한다. Spring MVC가 강제하지 않으며, Controller에서 명시적으로 구성한다. 나머지 REST 제약인 Cache, Layered System, Code-on-Demand는 실제 검증을 위해 캐시 정책, 중간 계층 구성, 클라이언트 동작까지 함께 고려해야 한다. 이는 배포 환경, 중간 계층, 인프라 구성까지 포함하는 논의로 확장되기 때문에, 이번 Demo에서는 Spring Web MVC 코드 레벨에서 확인 가능한 제약에만 집중하였다. 마치며,, 이번 REST에 대한 Recap과 더 깊은 구현까지 공부하면서 느낀 점은, Spring에서의 REST를 이해하기 위해서는 서블릿, WAS 및 Tomcat과 같은 실행 환경에 대한 이해가 필수적이라는 것이다. 웹에서 데이터가 어떻게 주고 받고 있는지 등에 대한 기본적 구조에 대한 공부를 추가적으로 해야겠다는 생각이 들었다. (정말 공부할 게 많다,, 그런데 재밌긴 하다..) 아무튼 그동안 프레임워크가 추상화해서 지원하는 영역을 물고, 씹고, 뜯어보면서, HTTP 요청이 실제로 어떻게 전달되고 처리되는지 구조적으로 이해할 수 있었다. 그리고 항상 원리에 대해 공부하다 보면 어디까지 파야 완전히 이해할 수 있는지에 대해 명확하지는 않지만, 적어도 어떤 목표를 위해 구현했는지, 구현이 왜 이런지에 대해 이해하면 된다고 생각이 든다. Spring에서 REST가 어떻게 적용되는지 확인해보고자 했는데, 서블릿부터 등등 많은 것을 공부해서 재밌었고, 마지막으로 기술 블로그를 작성하는데 사실 내용이 너무 길어서 이거를 누가 읽을까 싶다. 그래서 Mermaid도 사용하고 그랬는데, 토스에서 제공하는 테크니컬 라이팅 소개를 참고해서 다음 포스팅은 핵심만 압축해서 작성해보면 좋을 것 같다. 참고자료 Spring Web MVC Spring HATEOAS Spring REST Docs IBM-Servlet HTTP Semantics Toss payments 개발자 센터, POST, PUT, PATCH의 차이점