이전에 로컬에서 도커 이미지를 빌드하고 이를 도커 허브에 올린 뒤 ec2에서 이미지를 풀하고 컨테이너를 돌리는 식으로 배포해봤었는데, 새로운 기능을 추가하고 수정도 하면 이 과정을 하나하나 하기는 귀찮다. 그래서 jenkins라는 오픈소스를 활용해 빌드하고 테스트, 배포하는 과정을 자동화해본다.

 

또한, 나름대로의 코드 품질 개선과 혹시 모를 보안 위협에 대비하기 위해 sonarqube라는 코드 정적 분석 툴을 사용할 건데, 이는 sonarcloud라는 서비스를 통해 간단하게 도입해볼 것이다.

 

그림으로 시퀀스를 나타내면 다음과 같다.

  1. pull request가 생기면 sonarqube(sonarcloud)에서 코드 정적 분석을 한다.
  2. main브랜치에 푸시가 발생하면(merge) 도커로 띄운 젠킨스로 웹훅(webhook)을 보낸다
  3. 웹훅을 수신하면, jenkins쪽에서 지정된 깃헙 리포지토리의 코드를 땡겨온다 (+ application.properties 설정)
  4. 이를 gradle을 통해 빌드하고(jar)
  5. 이를 docker image로 빌드한 뒤 도커 허브에 푸쉬.
  6. 실제 서비스가 돌아갈 ec2에서 도커 허브에 푸쉬된 이미지를 땡겨와 돌리게끔 하면 된다

 

이 때 단순히 gradle로 빌드한 jar를 서비스를 돌릴 ec2로 보낼 수도 있지만(scp등으로) 멘토님 가라사대 요즘은 도커 모르면 안 된다라고 하셔서, 도커에 익숙해질 겸 도커 이미지를 만들고 이를 전달하는 식으로 가기로 했다. 그러면 도커로 띄운 젠킨스에서 도커 이미지를 만들어내야 해서, 도커 내에 도커를 설치해야 하니 이 점 유의

 


0. sonarcloud 설정

깃헙 계정으로 가입해서 설정만 딸깍딸깍 누르면 된다. sonarcloud를 설치할 레포지토리들을 선택하면 지가 알아서 설치되고, 이후 pull request를 날리면 지가 알아서 다음과 같이 분석 결과를 남겨준다

 

 

1. ec2 띄우고 docker 설치 및 swap 메모리 설정

참고로 ec2는 public subnet에 띄웠다. private subnet에 띄우면 깃헙 쪽에서 웹훅을 보낼 때 직접 받을 수 없기 때문에, 편의를 위해 그리 한 것임.

 

아 그리고 EBS볼륨을 20GB로 해줬다! 기본인 8GB로 해줬다가 용량이 없다고 하는 문제가 생겼었기 때문.

 

나는 ec2는 ubuntu로 띄웠고, 공식 매뉴얼에 나온 대로 도커를 설치했다

https://docs.docker.com/engine/install/ubuntu/

 

Install Docker Engine on Ubuntu

Jumpstart your client-side server applications with Docker Engine on Ubuntu. This guide details prerequisites and multiple methods to install.

docs.docker.com

 

그 뒤 swap 메모리를 설정해줬다. 나같은 경우 ec2를 프리티어로 해서 젠킨스를 띄울 건데, 이 때 프리티어는 메모리가 부족해서 멈추는 경우들이 있다고 한다. swap메모리를 설정해 이를 방지할 수 있다고 해서, 설정해줬다

(swap 메모리 : 메모리의 부족한 부분을 하드디스크로 대체하는 것)

 

아래 공식문서 글을 참고하면 된다

https://repost.aws/ko/knowledge-center/ec2-memory-swap-file

 

스왑 파일을 사용하여 Amazon EC2 인스턴스의 스왑 공간으로 메모리 할당

Amazon Elastic Compute Cloud(Amazon EC2) 인스턴스에서 스왑 파일로 사용할 메모리를 할당하려고 합니다. 어떻게 해야 하나요?

repost.aws

 

 

2. jenkins 이미지 풀 땡기고 컨테이너로 돌리기

sudo docker pull jenkins/jenkins:lts

 

docker run --name jenkins -p 8080:8080 -p 50000:50000 -d -v /var/run/docker.sock:/var/run/docker.sock -v jenkins_home:/var/jenkins_home -u root jenkins/jenkins:lts
  • --name : 컨테이너의 이름을 지정(여기서는 jenkins라는 이름으로)
  • -p : 컨테이너를 띄우는 호스트의 포트와 컨테이너의 포트를 매핑하는 것(즉 호스트의 8080과 컨테이너의 8080이 매핑) 참고로 jenkins 웹 인터페이스가 8080으로 열리고, jenkins 슬레이브 에이전트가 50000으로 열린다
  • -d : 컨테이너를 백그라운드에서 실행. 이를 통해 터미널로 다른 작업이 가능
  • -v : 호스트와 컨테이너를 볼륨 마운트. 이를 통해 컨테이너와 호스트 머신 간에 데이터를 공유할 수 있음
  • -v /var/run/docker.sock:/var/run/docker.sock -> 컨테이너 내부에서 호스트머신의 docker 데몬과 상호작용하게끔 하는 것.
  • -v jenkins_home:/var/jenkins_home -> jenkins 설정과 데이터를 영구적으로 저장하기 위함
  • -u root : 컨테이너 내에서 jenkins를 root사용자로 실행

 

그 뒤 다음 커맨드를 통해 docker.sock파일의 권한을 수정해준다(다른 사용자도 접근 가능하게끔). 참고로 컨테이너 밖에서 실행해준다.

sudo chmod 666 /var/run/docker.sock

참고로 docker.sock은 도커 데몬과 컨테이너 간의 통신을 위한 소켓 파일이다.(그래서 ls -l로 보면 파일타입이 s가 나옴) 이를 통해 컨테이너 내부에서도 호스트 머신의 docker데몬과 통신 가능하며, 이를 통해 컨테이너 내부에서도 docker커맨드를 쓸 수 있게 되는 것. 컨테이너 내에서도 docker데몬에 대한 권한을 부여하기 위해 위 커맨드를 통해 권한을 주는 거라고 생각하면 된다.

 

 

3. 컨테이너 내 도커 설치

엇? 컨테이너 내에서도 호스트의 도커 데몬과 통신할 수 있게끔 한 거면 도커를 설치할 필요가 없지 않나? (나는 도알못이기 때문..) 왜 도커를 설치하지?

 

이럴 거면 docker run을 할 때 굳이 -v를 해서 docker.sock을 컨테이너로 마운트한 이유가 없지 않나? 그냥 컨테이너에서 도커 깔았으면 거기서 자체적으로 도커 데몬 쓰면 되지..(참고로 이를 Docker in Docker, DinD라고 부르고 소켓파일 마운트해서 호스트의 도커 데몬을 쓰는 방식을 Docker ouf of docker, Dood라고 부른다)

 

간단하다. 제 아무리 호스트 꺼여도, 어찌됐건 도커 데몬과 통신하려면 도커 클라이언트가 필요하기 때문에 도커를 설치하는거다. 다만 데몬을 호스트 꺼를 사용한다는 것. (그냥 DinD로 컨테이너 내에서 자체 도커 데몬을 써도 되나, 보안상 이를 권하지 않는다고 한다. 무려 도커공식피셜임. 참고로 도커를 요로코롬 컨테이너 내에 깔아도 docker.sock은 호스트와 마운트된 걸 사용하게 된다)

 

그림으로 표현하면 아래와 같은 형태가 되겠다.

 

찾아보니 나는 젠킨스 이미지를 바로 컨테이너로 띄우면서 docker.sock을 마운트하고 컨테이너 내에 도커를 설치한 반면, 젠킨스 이미지를 베이스로 도커를 설치하는 도커이미지를 만든 다음(즉 젠킨스를 한 번 더 포장한 이미지인데 도커가 설치돼있는 이미지가 된다) 컨테이너 띄울 때 docker.sock을 마운트하는 방식도 있다.

 

암튼.. 컨테이너 내에 도커를 설치해본다

 

우선 docker exec을 해서 컨테이너 내부로 들어간 다음, 위에서 한 것과 똑같은 가이드북대로 설치했다.

 

근데 중간에 apt-get update를 하니 The repository 'https://download.docker.com/linux/ubuntu bookworm Release' does not have a Release file이라는 오류가 났었다. 이는 아래 글을 참고해 /etc/apt/sources.list.d/docker.list를 지운 뒤 실행함으로써 해결했다

 

(/etc/apt/sources.list.d : 이 디렉토리에 있는 파일들은 APT 패키지 관리자에게 소프트웨어 패키지 저장소를 추가하는 데 사용됨)

(docker.list : Docker 패키지를 제공하는 저장소를 가리키며, 이 저장소는 Docker 엔진과 관련된 패키지를 제공)

 

https://dct-wonjung.tistory.com/entry/apt-update-404-not-found

 

apt update 404 Not Found 에러 해결 방법 - The repository does not have a Release file.

TmaxOS21에서 docker를 설치하려고 했더니 설치는 안되고 apt update를 할 때마저 아래와 같은 에러가 발생했다. $ sudo apt update 기존:1 http://tos-repo.tmaxos.com/tmax nabi InRelease 기존:2 http://tos-repo.tmaxos.com/tmax t

dct-wonjung.tistory.com

 

근데 또 이번엔 sudo apt-get install docker-ce docker-ce-cli containerd.io (이하 생략) 커맨드에서 Package docker-ce is not available, but is referred to by another package. This may mean that the package is missing, has been obsoleted, or is only available from another source라는 메시지와 함께 오류가 났다. 아까 docker.list를 삭제했던게 원인인 듯 한데.. 문제는 얘를 삭제안하면 다른 오류가 계속 나던 상황이라.. 해결방법을 찾아보다가, docker.list를 지운 상태에서 아래 커맨드를 실행하니 설치가 잘 됐다.

 

curl -fsSL get.docker.com -o get-docker.sh

bash get-docker.sh // 다운받은 쉘 스크립트 파일 실행

이렇게 하니 설치가 됐다. curl을 통해 get.docker.com으로부터 도커를 설치하는 스크립트를 get-docker.sh란 파일로 다운받은 뒤 이를 실행하는 커맨드다.

 

 

4. application.properties 설정

내 프로젝트는 .gitignore를 통해 application.properties를 깃 관리 대상에서 제외했다. 따라서 나중에 젠킨스에서 깃헙 리포지토리에 있는 코드를 땡겨가도 그래도 빌드해 사용한다면 문제가 생길 것이다. application.properties를 주입하는 방법은 여러 가지가 있겠지만, 나는 컨테이너의 /home/env밑에 application.properties를 두고 나중에 젠킨스에서 이걸 가져다 쓰는 식으로 구성했다.

 

docker exec -it jenkins bash // 컨테이너 내부로 들어가기

cd /home

mkdir env // /home밑에 env 디렉토리 생성

cd env

echo "{application.properties 내용}" >> application.properties

 

이런 방식으로 만들었었다.

 

여까지 했다면, 이제 젠킨스라는 툴을 사용해 ci cd 파이프라인을 구축할 준비가 된 거다. 젠킨스 설정은 젠킨스에서 제공하는 웹 인터페이스(즉 사이트)를 통해 가능하니, 웹사이트로 들어가 설정을 진행해본다.

 

5. 젠킨스 웹사이트 접속 (ec2의 퍼블릭ip:8080)

docker exec -it {컨테이너명} bash 같은 걸로 컨테이너 안에 들어가서 저 경로로 cat찍으면 패스워드 볼 수 있으니 그리 하면 된다. (사실 엄밀히 말하면 들어간다기보다는 컨테이너에서 작업할 수 있게 해주는 커맨드라고 보는게 맞다)

 

추천해주는 플러그인 설치한 뒤, 회원가입 페이지(?)같은 게 나온다. 자신의 플젝에 맞게 설정하면 된다.

 

6. 젠킨스 설정  - jdk, gradle, github & dockerhub token

jar 빌드를 위해 jdk와 gradle을 설정해줄 필요가 있고, 깃허브 리포지토리에 있는 코드들을 땡겨와야 하기 때문에 github token이 필요하며, 도커 이미지를 도커허브에 올리기 위해 dockerhub token도 필요하다. 우선 jdk와 gradle설정부터 시작해본다

 

프로젝트에서 jdk11버전을 썼다면 모를까, 나는 17버전을 쓰기 때문에 따로 설정을 해줘야 한다.

jenkins관리 - tools로 가서 jdk를 설정해준다.

이 설정은 아래 글을 참고했었다.

https://royleej9.tistory.com/entry/Jenkins-jdk-%EC%84%A4%EC%A0%95

 

[Jenkins] pipeline jdk 설정

[Jenkins] pipeline jdk 설정 작업 순서 JDK 설정 pipeline에서 특정 버전의 JDK 사용하기 1. JDK 설정 Jenkins 관리 > Global Tool Configuration > JDK 항목 > Add JDK https://stackoverflow.com/questions/55243120/jenkins-add-jdk-11-to-jdk-l

royleej9.tistory.com

 

download url : 내가 원하는 버전의 jdk를 다운받는 경로

https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.8.1%2B1/OpenJDK17U-jdk_x64_linux_hotspot_17.0.8.1_1.tar.gz

 

subdirectory : url을 통해 다운된 파일을 압축 해제 했을 때 나오는 폴더 이름

jdk-17.0.8.1+1

 

gradle 설정도 jenkins관리 - tools에서 해주며, 플젝에서 쓰는 spring boot에서 사용중인 버전과 똑같은 놈으로 하면 된다.

본인 스프링부트 프로젝트의 gradle > wrapper > gradle-wrapper.properties에서 확인 가능하다

 

그리고 jenkins관리 - Credentials로 가서 깃허브 토큰을 등록한다. 깃허브 토큰을 발급받는 과정은 생략한다.

이 때 kind를 secret text가 아닌 username with password로 해준다. git이 뭐 지원 안한다고 함

username : 깃허브 계정 (이메일)

password : 깃헙 토큰

id : 본인이 식별할 수 있는 거 암거나 작성

 

마찬가지로 dockerhub token도 발급받은 뒤 똑같이 username with password로 등록해주면 된다. username은 도커허브 계정(아이디) 넣으면 되고, password에 토큰값 넣어주면 되고, id도 본인이 식별가능한 거 써주면 된다

 

7. 젠킨스 설정 - ssh 관련 설정

도커 이미지를 도커 허브에 푸시한 뒤, ssh로 서비스를 배포할 ec2에 접속해 도커허브로부터 이미지를 땡기는 작업을 하게 된다. 이를 위해 ssh를 사용하는데, 예전엔 Publish Over SSH라는 걸 썼던 듯 하나 지금은 못 쓰는 듯 하다. (새로운 openssh에서 ssh-rsa키 유형이 비활성화됐다고 한다. https://github.com/jenkinsci/publish-over-ssh-plugin/issues/247#issuecomment-1153768171)

 

그러면 이제 젠킨스가 띄워진 ec2에 ssh키를 물리적으로 둘 수도 있을 텐데, 이건 사실상 현관문 바로 앞에 키를 두는 방식이니 좋지 않다. 찾아보니 ssh agent플러그인을 젠킨스에서 다운받아 쓰는 방법이 있어, 이를 쓰기로 한다.

 

jenkins관리 -> 플러그인 -> available plugins로 가서 ssh agent를 install한다

난 이미 설치해서 안 뜬다

 

그 다음 아까 깃헙&도커허브 토큰을 등록했을 때처럼 ssh key정보를 등록한다. 

cat으로 ec2의 ssh key(pem파일)을 본 다음, 내용물을 적어주면 된다.

 

8. 깃허브 웹훅 설정

리포지토리 세팅 -> Webhooks로 가서 http://젠킨스서버ip:8080/github-webhook/을 등록한다

그리고 깃허브 쪽에서 보내는 웹훅을 젠킨스서버가 잘 받기 위해, 젠킨스 서버에 대한 보안그룹에서 깃허브 쪽에서 오는 인바운드들이 열려 있어야 한다.

 

https://api.github.com/meta

요기에서 현재 깃허브에서 웹훅을 보낼 때 쓰는 ip들을 확인 가능하다. 현재

 

"hooks": [
    "192.30.252.0/22",
    "185.199.108.0/22",
    "140.82.112.0/20",
    "143.55.64.0/20",
    "2a0a:a440::/29",
    "2606:50c0::/32"
  ],

 

요 ip들에서 8080으로 오는 인바운드들을 다 열어주면 된다.

 

 

9. 파이프라인 작성

새로운 아이템 - 파이프라인을 선택해주고 이름을 지어준다

General - GitHub project에 리포지토리 url(.git으로 끝나는..)을 입력한다.

 

그리고 build triggers에서 다음과 같이 체크해준다

 

그리고 파이프라인 스크립트를 작성한다.

난 다음과 같이 작성해줬다.

pipeline {
    environment { 
        repository = "{도커허브계정}/{레포지토리 이름}" // 이메일말고 아이디
        DOCKERHUB_CREDENTIALS = credentials('등록해둔 도커허브토큰id') // jenkins에 등록해 놓은 docker hub credentials 이름
        dockerImage = '' 
    }
    
    tools {
        jdk "{jdk이름}" //아까 jdk등록할 때 name으로 준 값
    }
    
    agent any 
    
    stages {
        stage('GitHub Repository Clone') { 
            steps { // url의 main branch 내용을 credential을 통해 땡겨온다
              
                git branch: 'main', credentialsId: '{등록해둔 깃헙토큰id}', url: '{깃헙 리포지토리 주소(.git으로 끝나는)}'
              
            }
        }
        
        stage('Set Application.properties') {
            steps {
                sh '''
                    if [ ! -d ./src/main/resources ]; then
                        mkdir ./src/main/resources
                    else
                        echo "resources directory alreay exist"
                    fi
                    
                    if [! -e ./src.main/resources/application.properties ]; then
                        rm ./src/main/resources/application.properties
                    fi
                    
                    cp /home/env/application.properties /var/jenkins_home/workspace/{지어준 파이프라인 이름}/src/main/resources
                    
                    cat ./src/main/resources/application.properties
                    '''
                
            }
        }
        
        stage('Build jar') {
            steps{
                sh '''
                    echo 'start bootJar'
                    echo $JAVA_HOME
                    ./gradlew clean bootJar
                    '''
            }
        }
        
        stage('Docker build') {
            steps {
                sh '''
                    docker build -t $repository:latest .
                    '''
            }
        }
        
        stage('Dockerhub Login'){
            steps {
                sh '''
                    echo $DOCKERHUB_CREDENTIALS_PSW | docker login -u $DOCKERHUB_CREDENTIALS_USR --password-stdin
                    '''
            }
        }
        
        stage('Push Docker image') {
            steps {
                sh '''
                    docker push $repository:latest
                    '''
            }
            
            post {
                success {
                    echo 'push success'
                }
                
                failure {
                    echo 'push failure'
                }
            }
        }
        
        stage('Run New Docker Container'){
            steps {
                sshagent(credentials: ['ssh-key']) {
                    sh '''
                    ssh -o StrictHostKeyChecking=no ubuntu@{서비스 띄울 ec2 ip} 'sudo docker ps -a'
                    
                    // 기존에 돌아가던 컨테이너 삭제
                    ssh -o StrictHostKeyChecking=no ubuntu@{서비스 띄울 ec2 ip} '
                        if [ "$(sudo docker ps -q --filter name={삭제할 컨테이너명}" ]; then
                            sudo docker rm -f $(sudo docker ps -aq --filter name={삭제할 컨테이너명})
                        else
                            echo "no there containers"
                        fi
                    '
                    
                    // 기존 이미지 삭제
                    ssh -o StrictHostKeyChecking=no ubuntu@{서비스 띄울 ec2 ip} '
                        if [ "$(sudo docker images -q)" ]; then
                            sudo docker rmi $(sudo docker images -q)
                        else
                            echo "no there images"
                        fi
                    '
                    
                    ssh -o StrictHostKeyChecking=no ubuntu@{서비스 띄울 ec2 ip} 'sudo docker pull {아까 푸시한 이미지명}'
                    ssh -o StrictHostKeyChecking=no ubuntu@{서비스 띄울 ec2 ip} 'sudo docker run -d --name={컨테이너명} -p 8080:8080 {풀받은 이미지명}'
                    
                    '''
                }
            }
        }
        
        stage('Clean Docker Image') {
            steps {
                sh '''
                    docker rmi $repository:latest
                    '''
            }
        }
    }
}

참고 : docker image -q 또는 docker ps -q -> 아이디값만 보게 해준다.

 

이후 깃헙에서 푸시를 해보니..

 

다음과 같이 파이프라인이 잘 돌아가는 걸 확인할 수 있다.


어찌보면 남들 다 하는 ci cd 구성이지만.. 그래도 처음 해보면서 삽질 여러가지 하면서 배운 게 많은 것 같다. DinD나 DooD도 있고, 특히 쉘 스크립트 작성을 꽤나 해보게 됐다ㅋㅋㅋㅋ.. jar로 빌드하고, 도커 이미지 빌드하고, 도커허브에 푸쉬하고, 서비스 띄울 ec2에선 기존 컨테이너랑 이미지 삭제한 뒤에 새로 풀받아서 돌리고.. 한 스텝 한 스텝 성공할 때마다 왠지 모를 희열이 느껴졌다. 

 

다만 아직 고려해야할 부분들이 더 많다. 당장 도커 이미지만 봐도 일단 전부다 latest를 달아서 하게끔 했는데, 이것보다는 빌드별로 번호를 매겨서 붙여주는게 더 나을 거다. 또한 지금 구축한 것대로라면 기존에 돌아가던 컨테이너를 내리고 새로 이미지를 받은 뒤 컨테이너를 띄우는 동안은 서비스가 중단된다!! 즉 무중단 배포도 고려해야 할 것이다.

 

기능 개발을 해야 할 게 남아 있는 만큼, 그리고 https 구축도 해야 하는 만큼 일단 그것들에 먼저 신경을 쓰고 해야 할 듯 하다..

'INFRA & DEVOPS > CI & CD' 카테고리의 다른 글

[GitHub Action] 플러터 프로젝트 CI 구축하기  (0) 2023.07.09

(우분투 기준) nginx설치 후 /etc/nginx로 가보면 nginx.conf라는 파일이 있고, 여기엔 nginx의 설정 파일인 nginx.conf가 있다

 

user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;

events {
	worker_connections 768;
	# multi_accept on;
}

http {

	##
	# Basic Settings
	##

	sendfile on;
	tcp_nopush on;
	types_hash_max_size 2048;
	# server_tokens off;

	# server_names_hash_bucket_size 64;
	# server_name_in_redirect off;

	include /etc/nginx/mime.types;
	default_type application/octet-stream;

	##
	# SSL Settings
	##

	ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
	ssl_prefer_server_ciphers on;

	##
	# Logging Settings
	##

	access_log /var/log/nginx/access.log;
	error_log /var/log/nginx/error.log;

	##
	# Gzip Settings
	##

	gzip on;

	# gzip_vary on;
	# gzip_proxied any;
	# gzip_comp_level 6;
	# gzip_buffers 16 8k;
	# gzip_http_version 1.1;
	# gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

	##
	# Virtual Host Configs
	##
	include /etc/nginx/conf.d/*.conf;
	include /etc/nginx/sites-enabled/*;

}


#mail {
#	# See sample authentication script at:
#	# http://wiki.nginx.org/ImapAuthenticateWithApachePhpScript
#
#	# auth_http localhost/auth.php;
#	# pop3_capabilities "TOP" "USER";
#	# imap_capabilities "IMAP4rev1" "UIDPLUS";
#
#	server {
#		listen     localhost:110;
#		protocol   pop3;
#		proxy      on;
#	}
#
#	server {
#		listen     localhost:143;
#		protocol   imap;
#		proxy      on;
#	}
#}

 

여기서 http블록 안에 직접 server 블록을 작성해줌으로써 웹 서버에서 특정 도메인, 호스트 이름, 포트 또는 IP 주소와 관련하여 요청을 어떻게 처리할지를 정의할 수 있다. 또는 위에 적혀있듯이 /etc/nginx/sites-enabled나 /etc/nginx/conf.d에 작성한 conf파일에 적힌 내용을 include하는 식으로도 server 블록을 정의할 수 있다(즉 직접 nginx.conf안에 작성하든가, 아니면 외부에서 작성한 후 include로 끌고 올 수 있다)

 

이 server 블록은 각 블록별로 하나의 웹사이트 또는 가상 호스트(Virtual Host)를 의미한다. 즉 여러 개의 server 블록을 작성해 다수의 도메인을 하나의 서버에서 가동할 수 있는 것이고, 이는 다수의 도메인에 대해 하나의 서버에서 각기 다른 페이지를 서비스할 수 있다는 말이다. server 블록은 다음과 같이 작성 가능하다.

server {
    listen 80;
    server_name example.com;

    location / {
        # example.com에 대한 설정 ...
    }
}
  • listen : nginx server가 클라이언트로부터 요청을 수신하는 방법을 정의. 걍 어느 ip 어느 포트로 올 건지를 말 그대로 listen한다는 것을 설정하는 부분
  • server_name : 해당 server 블록이 처리할 도메인이나 호스트네임 또는 ip주소를 지정. 클라이언트로부터 request가 오면 header에 있는 Host값과 이 server_name값이 일치하면 해당 server블록을 활성화하는 것임. server_name을 없애고 listen에 example.com:80으로도 작성 가능함
  • location 블록 : 특정 URL 경로 또는 패턴에 대한 요청을 처리하는 방법을 정의. 해당 경로에 대한 프록시 패스를 처리한다든지..그런 것들을 설정해줄 수 있다. 위 예제에선 example.com의 모든 요청(/이니까~)에 대한 모든 요청에 대해 location블록에 설정한 내용이 동작할 것임. 필요에 따라 한 server 블록에 여러 location블록을 둘 수 있음
  • 기타 다른 설정도 가능..

 


여기서 내가 처음에 헷갈렸던 건, "server_name으로 해당 server블록이 처리할 도메인을 적으라는 것". 아니 그러면 naver 도메인같은 걸 여기 적어줘도 되는거야? 란 생각이 들었다. 그러면 말이 안 되지 않나? 저 문장대로면 브라우저에 naver치고 들어가는 걸 naver서버가 아니라 내가 띄운 nginx가 처리해야 한다는 거 아닌가? 

 

그리고 애당초 난 처음엔 server_name을 프록시 대상의 도메인이나 ip주소를 적어줘야 하는 줄 알고 있었다.. (nginx를 리버스 프록시로 쓸 생각으로 설치했었고 자료를 찾아봤어서...) 그래서 nginx 처음 쓰는데 server_name에 프록시 대상으로 was의 주소(nginx를 웹서버, spring boot를 was로 사용해서 was의 주소를 적어줬었음)를 줬는데 잘만 동작하길래 내가 이해한 게 맞다 싶었다. 근데 알고 보니 server_name으로는 server블록이 처리할 도메인과 같이 웹서버에 관한 뭔가를 쓰는 거여서 뭐지? 싶던 거.

 

"난 server_name으로 웹서버가 아닌 was에 대한 걸 적어줬는데 왜 프록시 패스가 잘 되는거지?"

 

그래서 실험을 좀 했다.

우선 server 블록은 다음과 같이 1개만 작성된 상태

server_name에 was(spring boot)의 사설 ip를 박아뒀었다. nginx는 public subnet에 띄웠었고, was랑 같은 vpc안에 있으니 사설 ip를 통해서도 접근이 가능하기 때문에, was의 사설 ip를 server_name에 박아줘도 된다고 생각했음. 그래서 proxy_pass에 server_name에 준 주소를 그대로 써먹은 모습을 확인 가능하다.

 

암튼 이렇게 두면, 잘 된다. nginx쪽에 request를 보내면 잘만 was쪽으로 request를 던져준다. 근데 왜 잘 되는 건지는 몰랐다. 일단 server_name에 10.0.1.88을 박아뒀으니 reqeust의 Host와 매칭되진 않을텐데? 근데 왜 프록시 패스가 잘 되지?

 

바보같은 생각이지만(?) Host와 server_name이 매칭이 안 되면 10.0.1.88로 뭔지 모르겠지만 보내나,,? 싶었다. 그래서 server 블록을 하나 더 추가해줬다.

이렇게 하니, 안된다. nginx쪽으로 보낸 request가 was쪽으로 던져지지 않는다(즉 was에 request가 오지 않음). 매칭이 안 된다고 10.0.1.88로 무조건 보내는 건 아닌 듯 하다. 근데 이상하게 두 server 블록의 순서를 바꿔주면 됐었음. (이때까진 이유를 몰랐다)

 

사실 이때까지도 server_name을 프록시 대상의 ip를 줘야 하지 않을까란 의심(?)을 버리지 못한 상태라,, server_name이랑 request의 Host가 정확히 어떻게 매칭이 되는거지라는 생각이 들었다.. server_name을 아예 nginx가 띄워져있는 ec2의 public ip를 주면 어떨까?란 생각이 들어서 바꿔봤고..실험해봤다

ㅇㅇ. 이렇게 하니 nginx로 보낸 request가 was로 잘 전달된다.

 

음 그러면 확실하게 server_name은 프록시 대상과는 아무런 연관이 없다. server_name과 request의 Host가 매칭돼야 하는거라면, nginx가 띄워진 ec2의 public ip를 server_name으로 줬을 때 해당 ip로 보내는 request의 Host와 매칭되는건 당연하다. 그러면 아까 들었던 의문으로 돌아가서.. server_name으로 아무 도메인(네이버 등)이나 막 줘도 request의 Host랑 매칭만 되면 동작하겠다는 생각이 들었다. 그러나 주소창에 naver를 치고 들어가면 당연히 내 nginx가 이를 캐치할 리는 없고..엇? nginx로 보내는 request에 담긴 Host값만 잘 조작하면 프록시 패스가 되겠네?란 생각이 들었다. 당장 실험에 옮기기로 했다

server_name에 www.naver.com 입력하고

nginx로 보내는 request의 Host를 www.naver.com적어서 보내본다. 

결과는..!

response가 왔다는거 = nginx에서 was로 request를 던져줬다. 라는 것.. 즉 예측에 성공했다.

 

아하! server 블록을 여러 개 둔 뒤 블록별로 server_name을 이것저것 주면, 클라이언트에서 request의 Host를 직접 조작해서 보내는 것을 통해 이런저런 조작을 할 수 있구나~! 이렇게 활용하나보다~!

 

싶었으나! 이내 깨달았다.

 

"하나의 ip주소에 여러 도메인을 연결한다면"..!

 

즉..nginx는 물리적으로 하나의 콤퓨타 위에서 돌아가고 있으니, 해당 컴퓨터에 대한 하나의 ip로 요청을 보낼 건데.. 그 ip가 여러 도메인에 연결돼있다면, 우리 킹갓 nginx가 그 도메인들 별로 각기 다른 서비스를 제공해줄 수 있다는 거다. 뭐 클라이언트에서 직접 request의 Host를 직접 조작해서 보내는..그런 이상한 짓(?)은 안 하는거(물론 원한다면 할 순 있겠지만..)

 

근데 그러면 아까 server블록을 하나만 작성했는데 server_name이 request의 Host와 매칭이 안 됐는데도 동작했던 이유는 뭘까? 그리고 server블록을 여러 개 뒀었는데 순서에 따라 어떨 땐 동작하고 어떨 땐 동작하지 않던 이유는 뭘까?

 

간단하다. Host와 매칭되는 server블록이 하나도 없으면, 기본으로 설정된 server 블록 또는 제일 처음 작성된 server 블록이 활성화된다고 한다. 그래서 그랬던 거..따라서 server 블록의 server_name은 필요에 따라 굳이 작성할 필요가 없다. (server블록이 하나만 있다거나 등등..)

 

 

이런 삽질과 구글링을 통해 배운 걸 정리하면..

  • nginx설정은 nginx.conf를 편집해서 가능
  • server블록에 대한 걸 설정하려면 -> nginx.conf에 직접 작성 또는 /etc/nginx/sites-enabled에 심볼릭 링크로 걸어둔 파일을 통해(nginx.conf에서 여깄는 것들을 include로 땡겨오니까)
  • server_name은 nginx가 띄워진 ip 또는 그 ip에 묶인 도메인들을 작성. ip에 묶인 도메인이 여러 개면 각 도메인별로 다른 페이지 등을 제공 가능
  • server 블록이 여러 개일때 request의 Host와 server_name이 매칭되는게 하나도 없으면 기본 또는 젤 처음 작성된 server블록을 활성화

 

 

 

 

 

public subnet에 띄운 ec2에 고정된 공인ip, 즉 elastic ip를 붙이고 이를 route 53에서 구입한 도메인과 연결해본다


Elastic IP 생성 및 ec2와 연결

1. Elastic IP를 생성한다. 생성하는 모습은 생략함

 

2. 생성한 elastic ip를 선택하고 작업 탭을 눌러 "탄력적 IP 주소 연결"을 누른다

리소스 유형 = 인스턴스고르고 연결할 ec2 고르면 된다. 프라이빗 주소는 그 인스턴스의 사설ip를 고르면 된다

 

3. 그러면 해당 ec2의 상세화면에서 public ip로 elastic ip가 연결된 걸 볼 수 있다 

 


route53에서 구입한 도메인과 elastic ip연결

1. 구입한 도메인의 호스팅 영역에 들어가서 "레코드 생성"을 누른다

 

2. 다음과 같이 생성해주면 끝

값으로는 아까 생성한 elastic ip를 주면 된다

 

3. 이렇게 하고 나면, www.petdori.com으로 트래픽을 보내면 ec2로 가진다. 

로컬에서 spring boot로 작업중인 서버를 private subnet의 ec2에 띄워보고, public subnet에는 nginx를 띄워서 둘을 연결해본다. 즉 nginx로 보내는 요청이 spring boot로 가게끔 설정해본다. (즉 nginx를 프록시로 활용!)

 


private subnet에 띄운 ec2에 스프링부트 서버 배포

 

1. ubuntu베이스의 ec2를 생성한다 (amazon linux 베이스여도 됨). 보안그룹 적용시 public subnet에 둘 nginx로부터 오는 트래픽에 대한 인바운드를 열어야 하며, rds나 elastiCache를 사용하는 경우 그 놈들로 가는 트래픽에 대한 아웃바운드를 열어야 한다. (당연히 nginx쪽에서의 아웃바운드와 rds & elastiCache쪽에서의 인바운드도 열어야 함). 또한 bastion host를 통한 ssh접속을 위해 bastion host로부터 오는 22번 포트 인바운드도 열어둬야 한다.

 

2. ssh등을 활용해 private subnet에 띄운 ec2에 접속한다. ssh로 접속하는 경우 키가 필요하며, scp(secure copy)를 통해 로컬에 있는 키를 public subnet에 띄운 ec2로 보내서 ssh를 두 번 거치는 방법도 있으나 사실 이는 비밀번호가 걸린 현관문 바로 앞에 열쇠를 두는 행위나 마찬가지다. 따라서 나는 ssh jump라는 방식을 통해 ec2에 접속했다. 

Host somsatang-webserver-1
  HostName 13.124.18.245
  User ubuntu
  IdentityFile /Users/chosanghyun/Downloads/sst.pem

Host somsatang-was-1
  HostName 10.0.1.88
  User ubuntu
  IdentityFile /Users/chosanghyun/Downloads/sst.pem
  ProxyJump somsatang-webserver-1

 

 

3. 다음 공식문서 링크를 참고해 docker를 설치한다

https://docs.docker.com/engine/install/ubuntu/

 

Install Docker Engine on Ubuntu

Jumpstart your client-side server applications with Docker Engine on Ubuntu. This guide details prerequisites and multiple methods to install.

docs.docker.com

 

4. rds나 elastiCache를 사용한다면 application.properties에 적어준 설정에서 엔드포인트들을 적어줘야한다.

// localhost를 rds 엔드포인트로 바꿔준다. 실험결과 3306 포트는 따로 명시 안해도 되긴 함
spring.datasource.url=jdbc:mysql://localhost:3306/wooyoungsoo

// localhost를 elastiCache redis 엔드포인트로 바꿔준다
spring.data.redis.host=localhost

// 나머지 설정들은 생략했음

 

5. Intellij에서 다음과 같이 우측의 Gradle을 누르고 bootJar을 눌러 jar를 만들어준다

 

6. /build/libs에 jar파일이 생긴다. /build/libs, 즉 jar가 있는 디렉토리로 가서 Dockerfile을 만들어주고 다음과 같이 작성한다

FROM openjdk:17
ARG JAR_FILE=*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

openjdk는 프로젝트에서 사용중인 자바 버전에 맞게 11이나 17등을 써주면 된다.

 

  • FROM openjdk:17: Docker 이미지의 기본 이미지를 지정. 이 경우에는 OpenJDK 17 이미지를 사용하는 것이며, 이 이미지는 Java 17 환경을 제공한다
  • ARG JAR_FILE=*.jar: Docker 빌드 시에 사용할 변수를 정의. JAR_FILE이란 변수를 정의한 것이며, 기본값은 현재 디렉토리에 있는 모든 .jar 확장자를 가진 파일 중 하나
  • COPY ${JAR_FILE} app.jar: COPY 명령을 사용하여 호스트 머신에서 Docker 컨테이너로 JAR 파일을 복사하라고 하는 것. ${JAR_FILE} 변수는 방금 정의한 그 변수가 맞다. 이 파일은 컨테이너 내부에 app.jar로 복사된다.
  • ENTRYPOINT ["java","-jar","/app.jar"]: 컨테이너가 시작될 때 실행할 명령을 정의하는 것. 이 경우에는 Java를 실행하여 app.jar 파일을 실행한다는 의미다. 즉, Docker 컨테이너가 시작되면 Java 애플리케이션을 실행하고 jar 파일(= app.jar)을 실행한다.

 

즉 정리하면

 

"자바 17버전 환경에서 컨테이너를 실행할 건데",

"현재 디렉토리에서 .jar파일이 있으면 걜 JAR_FILE이란 변수에 저장하고",

"app.jar라는 이름으로 복사한 다음",

"컨테이너를 시작할 때 'java -jar'라는 커맨드를 활용해 app.jar를 구동할 거야"

 

라는 의미다.

 

 

7. 다음 커맨드를 입력해 도커 이미지를 빌드한다

docker build {옵션} {도커계정}/{프로젝트명}:{버전} {경로}
ex) docker build -t jofe/toyproject:0.1.0 .

-t는 --tag와 같은 의미이며 이미지에 부여할 이름과 태그를 지정할 수 있게끔 한다

 

참고로 맥북 m1이나 m2를 사용중인 유저라면 빌드 플랫폼이 arm기반이 되는데, aws에서 만든 ec2가 amd기반이면 호환성 문제가 생긴다. 따라서 만약 aws에서 만든 ec2가 amd기반이라면 다음과 같이 플랫폼을 지정해 빌드한다

docker build --platform linux/amd64 {옵션} {도커계정}/{프로젝트명}:{버전} {경로}

 

하지만 다음과 같이 ec2를 만들 때 arm기반으로 만들었다면 맥북도 방금 말한 거 안해도 된다. 

 

 

8. 도커허브에 방금 생성한 도커 이미지를 푸시한다

docker push {이미지명}
ex) docker push jofe/toyproject:0.1.0

 

도커허브로 접속해서 보면 내가 푸시한 이미지가 잘 올라와있는 걸 다음과 같이 확인 가능함

 

9. ssh로 접속한 private subnet ec2에서 docker pull로 이미지를 땡겨온다

sudo docker pull {이미지명}
ex) sudo docker pull jofe/toyproject:0.1.0

 

10. 다음 커맨드를 입력해 컨테이너를 돌린다

sudo docker run --name {컨테이너명} -d -p 8080:8080 {이미지명}
ex) sudo docker run --name "jofe-service" -d -p 8080:8080 jofe/toyproject:0.1.0

-p 옵션 : -p 호스트포트:컨테이너포트 형식으로 사용되며,  이 옵션을 통해 호스트 머신과 컨테이너 간의 포트 포워딩(매핑)을 설정한다. 즉 호스트 머신의 몇 번 포트로 보내면 컨테이너의 몇 번 포트로 보낸다 이런 의미.

 

-d 옵션 : 컨테이너를 백그라운드에서 돌리게끔 하는 역할

 

 

참고 : 호스트의 8080번 포트로 설정한 만큼, 보안그룹에서 nginx 쪽에서 8080포트로 보내는 아웃바운드와 spring boot쪽에서 8080으로 받는 인바운드를 열어야 한다.

 

 


public subnet에 nginx 띄우고 연결

1. ssh로 public subnet에 띄운 ec2에 접속한다. 당연히 외부에서 22번 포트로 오는 ssh접속에 대한 인바운드가 열려있어야 한다

 

2. nginx를 설치한다

sudo apt install nginx

 

3. nginx를 설정할 차례다. 우선 /etc/nginx/sites-available 디렉토리로 이동한다

cd /etc/nginx/sites-available

 

4. 이 디렉토리는 nginx의 설정 파일들이 위치했으며, 처음에 들어가면 default라는 기본적인 설정 파일이 있을 것이다. 우리가 사용할 설정파일을 새로 만들어준다. (vi로 해도 상관없음)

sudo nano {원하는 설정파일명}

 

5. 새로 만든 설정파일의 내용은 다음과 같이 설정한다

server {
        listen 80;
        server_name {nginx가 띄워진 ec2의 ip 주소 또는 도메인};

        location / {
                proxy_pass http://{spring boot를 배포한 서버의 ip 또는 도메인}:8080;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header Host $http_host;
        }
}

 

  • listen 80 : 80번 포트로 웹서버를 서비스한다는 의미. 이를 통해 http://xxx.xxx.xxx.xxx:80이 아닌 http://xxx.xxx.xxx.xxx로 접속 가능하다 
  • server_name : 이 값으로는 도메인 or 호스트 or IP주소 작성. server 블록을 여러 개 둘 수 있으며, 클라이언트가 보낸 request의 header안에 있는 Host값이 이 server_name값과 일치하는 server 블록이 활성화된다. 만약 Host값과 매칭되는게 없다면 기본적으로 설정된 server블록 또는 첫 번째로 작성된 server 블록이 활성화된다
  • location / {블라블라..} : /에 해당하는 URL, 즉 모든 요청에 대한 설정을 {}안에 정의한다는 뜻 (만약 /api로 했다면 해당 경로로 오는 요청들에 대한 설정을 정의한다는 뜻이 되겠져)
  • proxy_pass : /에 해당하는 URL, 즉 모든 요청을 이 URL로 리다이렉트한다
  • proxy_set_header : 헤더 설정

 

6. 방금 작성한 설정파일을 nginx가 읽을 수 있도록 설정할 차례. /etc/nginx/sites-enabled로 이동한다.

cd /etc/nginx/sites-enabled

이 디렉토리는 /etc/nginx/sites-available에 있는 설정 파일 중 활성화할 설정 파일을 "링크(심볼릭 링크)"로 관리하는 디렉토리다. ls를 누르면 default가 링크돼있는 걸 확인할 수 있고, 다음 커맨드를 통해 default를 지운다

sudo rm default

 

7. 우리가 아까 만든 설정파일에 링크를 걸어준다

sudo ln -s /etc/nginx/sites-available/{아까 만든 설정파일명}

심볼릭 링크 : 파일 시스템에서 다른 파일이나 디렉토리를 가리키는 일종의 포인터. 링크된 파일의 내용을 복제하는 개념이 아닌 단순히 참조만 하는, 걍 포인터라고 생각하면 됨

 

8. nginx를 재시작해 설정 파일을 적용한다

sudo service nginx restart

 

9. nginx가 띄워진 ec2의 ip주소로 요청을 보내면 private subnet에 띄운 spring boot로 전달되는 걸 볼 수 있다

현재 내가 소마에서 진행 중인 프로젝트는 jwt를 활용중이며, rdb에 refresh token을 저장하고 재발급에 활용하고 있다. 시퀀스 다이어그램으로 보면 다음과 같은 구조다.

 

(참고 : refresh token을 db에 저장한 뒤 클라이언트가 재발급 요청 시 보내진 refresh token과 대조하는 이유 = 악의적 사용자가 지 맘대로 만든 refresh token으로 재발급 요청해서 access token을 받으려 하는걸 막을 수 있고, 악의적 사용자가 refresh token을 탈취했다고 해도 우리 쪽에서 db에 있는 refresh token을 없애준다든가 하는 식으로 조치를 취해줄 수도 있고,, 로그아웃 구현 시에도 편리하고.. 등등)

 

그러나 이 방법의 단점을 꼽자면 다음과 같은 부분들이 있다

 

  1. rdb에서 refresh token을 가져오는데 시간이 좀 걸린다
  2. rdb에 저장된 토큰데이터들 중 만료기간이 지난 토큰들을 개발자가 코드를 작성하든 뭐 하든 해서 직접 삭제시켜줘야한다

 

이를 인메모리 데이터베이스인 redis를 사용하면, 해당 단점들을 개선할 수 있다

 

  1. 우선 메모리에 저장하기 때문에 rdb대비 토큰을 가져오는 속도가 빠를 것이고
  2. ttl을 설정해주면 만료기간 지난 토큰들은 알아서 없어진다

 

따라서 refresh token을 관리하던 방법을 rdb에서 redis로 바꾸기로 했다. 물론 인메모리 데이터베이스인 만큼 꺼지면 다 날라가긴 하지만, refresh token은 날라가도 크리티컬한 피해는 없다. 번거로울 수 있지만 다시 로그인하면 되는 거니까.

 


우선 AWS에서 ElastiCache로 Redis를 쓸 거다. 문제는 ElastiCache는 같은 VPC에서만 접속하는걸 허용해서 로컬에서 접근할 수 없다는 것. 그러나 ssh 터널링 등을 통한 방법으로 로컬에서도 ElastiCache에 접근이 가능하다. 이건 내가 쓴 글이 있으니 링크를 달겠다.

 

https://jofestudio.tistory.com/110

 

[AWS] SSH 포트포워딩 및 SSM을 통해서 로컬에서 Private Subnet에 있는 RDS 접근하기

소마 프로젝트를 진행하면서, 인프라 쪽을 내가 어느 정도 담당하고 있어서(사실 담당한다고 하기에도 민망할 정도로 기본적인 부분들만 담당하긴 하지만) RDS를 띄워보기로 했다. Private Subnet에

jofestudio.tistory.com

 

Bastion Host를 통해 접근하는 방식이므로 ElastiCache에 달아주는 보안그룹에는 Bastion Host 쪽에서 오는 6379포트에 대한 인바운드를 열어줘야 한다.

 

스프링부트에선 다음과 같은 의존성을 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

그 다음 application.properties에 다음과 같이 작성해준다(application.yml을 사용한다면 그에 맞춰 수정하면 됨)

spring.data.redis.host=localhost
spring.data.redis.port=6379

 

참고로 로컬에서 ssh 포트포워딩을 통해 elastiCache에 접속하는 중이므로 host는 localhost로 한 것이다.

그리고 다음과 같은 Configuration Class를 만들어준다.

 

@Configuration
public class RedisConfig {
    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate =  new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());

        // 일반적인 key:value의 경우 시리얼라이저
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());

        return redisTemplate;
    }
}

 

  • RedisConnectionFactory : Redis 서버와의 연결을 만들고 관리하는 빈
  • RedisTemplate : Redis와의 상호작용을 돕는 빈. 즉 얘를 핸들링하여 레디스와 짝짝꿍하는 것
  • Serializer설정 : RedisTemplate의 디폴트 Serializer는 JdkSerializationRedisSerializer인데, 문제는 redis-cli를 통해 개발자가 직접 redis안에 있는 애들을 볼 때 Jdk직렬화된 애들은 그 값을 도무지 알아먹을 수가 없어서, 편의를 위해 StringRedisSerializer로 설정(이렇게 하면 String값 그대로 Redis에 저장됨)

 

참고 : 직렬화(seriailize) = 객체 등을 바이트 스트림 형태의 연속적인 데이터로 변환하는 것. 반대는 역직렬화(deserialize)

 

그리고 다음과 같은 RefreshTokenService를 만들어준다.

@RequiredArgsConstructor
@Service
public class RefreshTokenService {
    private final RedisTemplate<String, Object> redisTemplate;
    private final Long REFRESH_TOKEN_EXPIRE_TIME = 60 * 60 * 24 * 30L;

    public void saveRefreshToken(String email, String refreshToken) {
    	// 이메일을 key로, 토큰값을 value로
        redisTemplate.opsForValue().set(email, refreshToken, REFRESH_TOKEN_EXPIRE_TIME, TimeUnit.SECONDS);
    }

    public String getRefreshToken(String email) {
        Object refreshToken = redisTemplate.opsForValue().get(email);
        return refreshToken == null ? null : (String) refreshToken;
    }

    public void deleteRefreshToken(String email) {
        redisTemplate.delete(email);
    }
}

 

나는 Refresh Token의 유효기간을 1달로 잡았기 때문에, 60 * 60 * 24 * 30 = 30일을 유효기간으로 설정해 저장하도록 구현했다. 분이 아니라 다른 시간 단위로도 저장 가능하니, 본인 취향따라 하면 된다.(TimeUnit.SECONDS 이쪽)

 

이제 이를 활용해 원래 rdb를 사용하던 로직을 바꿔줬다.

 

  • 로그인 후 jwt(access token, refresh token)을 생성하고 refresh token을 rdb에 저장하던 것 => refresh token을 redis로 저장. 이 때 redis에 기존에 쓰던 refresh token이 있으면 덮어씌운다
  • 토큰 재발급 요청시 rdb에서 refresh token가져오던 것 => redis에서 가져온다
  • 토큰 재발급 후 재발급된 refresh token을 rdb에 저장하던 것 => redis로 저장. 이 때 redis에 기존에 쓰던 refresh token이 있으면 덮어씌운다

 

이제 로그인 요청, 인가인증이 필요한 요청, 토큰 재발급 요청에 대한 수행과정들을 시퀀스 다이어그램으로 표현하면 다음과 같아진다.

 

 

ppt로 힘들게 만드러따..

아 그리고 이 과정에서 추가적으로 고민했던 게, 토큰재발급 요청을 인증된 유저만 받을지였다. 구글링해서 나오는 다른 예제들을 보면 재발급 요청은 인증을 안 해도 가능하게끔 하는 예제가 대부분이었는데, 나는 고민 끝에 재발급 요청도 인증을 한 유저만 할 수 있게끔 했다. 왜냐하면, 당연히 보안을 위해서. 인증할 필요없이 refresh token만 덜렁 보내서 재발급받을 수 있다면, refresh token이 탈취당하면 너도나도 내 refresh token으로 재발급을 무료로 시도할 수 있기 때문이다. 

 

인증은 access token을 통해 이뤄지며, 재발급 요청은 우리 플젝에선 앱 접속시 자동로그인을 위한 재발급을 위할 때가 아니라면 access token이 만료됐을 때 이뤄질 거다. 만료된 토큰은 검증과정에서 ExpiredJwtException을 뱉을 텐데, 그러면 만료된 토큰에 대한 인증을 어떻게 할 수 있는가?

 

처음에 생각한 것은 임시 통행증(Authentication)을 끊어주는 거였다. 스프링 시큐리티는 ContextHolder에 Authentication이 들어있다면 인증됐다고 판단하므로, ExpiredJwtException이 터졌을 때 재발급경로로 요청이 온 것이라면(이는 request객체를 까서 판단 가능) 임시로 Authentication을 만들어 ContextHolder에 넣어주면 되는거다. 

try {
	jwtProvider.validateAccessToken(accessToken);
} catch (ValidTimeExpiredJwtException ex) {
	// access token이 만료됐지만 재발급하는 요청에 대한 처리 - 임시로 인증됐다고 처리한다
	if (request.getRequestURI().equals(REISSUE_PATH)) {
		setTemporaryAuthenticationToContextHolder();
		return;
	}
	request.setAttribute("exception", ex.getMessage());
	return;
} catch (CustomJwtException ex) {
	request.setAttribute("exception", ex.getMessage());
	return;
}
    private void setTemporaryAuthenticationToContextHolder() {
        // 임시 권한 생성
        List<GrantedAuthority> temporaryAuthorities = new ArrayList<>();
        temporaryAuthorities.add(new SimpleGrantedAuthority(ROLE_USER.name()));
        // 임시 통행증 발급
        Authentication authentication = new UsernamePasswordAuthenticationToken(
                "temporaryAuthentication", "", temporaryAuthorities
        );
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

 

이렇게 해도 잘 동작한다. 하지만! 나중에 소마 내 다른 연수생이 작성한 코드들을 몰래보다가, ExpiredJwtException의 인스턴스로부터 jwt클레임을 추출 가능하다는 놀라운 사실(!)을 깨달았다. 따라서 단순히 유효기간이 만료된 토큰이라면 정상적인 토큰에서 claim을 빼내는 것처럼 claim을 빼낼 수 있다.

    private String extractEmailFromToken(String accessToken) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(accessKey)
                    .build()
                    .parseClaimsJws(accessToken)
                    .getBody()
                    .getSubject();
        } catch (ExpiredJwtException ex) {
            return ex.getClaims().getSubject();
        }
    }

역시 남의 코드 보면서 배우는게 참 많아.. 암튼, 이를 통해 ExpiredJwtException이 터지면 재발급 요청인지를 판단하고, 재발급 요청이라면 기존 인증 절차와 마찬가지로 동작시킬 수 있다.

 


이런 과정들을 거쳐 redis를 도입했다. 그러면 속도는 얼마나 개선됐을까?

 

RDB(AWS RDS)만 사용했을 땐 평균 180ms정도가 걸렸으나,

 

Redis(AWS ElastiCache)를 사용하고나선 평균 100ms정도가 걸리게 됐다. 약 80ms정도 속도가 빨라진 것이다.

 

아직 학생 수준인 내게 80ms 정도라는 숫자가 의미가 있는지는 모르겠다. 그러나 트래픽이 많아질수록, 단축된 시간 X 요청횟수가 실제적인 성능 개선이라고 생각한다면 의미있는 개선을 이뤄낸게 아닐까싶다. okky에 문의한(?) 결과 운영자분께서 elastiCache를 통해 80ms 정도 개선한 것이면 잘 한 것이라고 해서 나름 뿌듯함.

 

굳이 레디스를 도입했어야 하는가? 라는 질문도 받을 수 있다. 맞는 말이다. 일단 배포를 안 한 만큼 재발급 요청에 대한 트래픽이 현재 없는 상태인데 굳이 80ms 높이겠다고 레디스? + 사용자 입장에서도 체감되는 개선인가? 라는 질문도 추가적으로 받을 수 있겠다. 한마디로, 지금 레디스를 도입하는 건 오버 엔지니어링이 아니겠냐는 말로 정리할 수 있겠다

 

그러나 속도 측면 말고도 유효기간이 만료된 토큰이 개발자가 뭘 하지 않아도 레디스에서 날라간다는 점에서 개인적으로 나름의 의미는 있다고 생각한다. 또한 학생 신분인 내가 레디스를 이렇게 처음 접하게 된 것도 의미가 충분한다. 마지막으로 돈 문제. ElastiCache 그거 돈 들지 않느냐! 굳이 레디스 필요없는데 왜 레디스 달아서 돈 나가게 하냐! 

 

당연히 실무라면, 혹은 내가 실제로 이 서비스를 운영하고 있는 단계면 어떻게든 돈 적게 쓸라고 쥐어짰겠지만, 난 지금 소마에서 지원금을 받으면서 플젝을 하고 있는 상태. 돈이 좀 드는 AWS 서비스들을 이것저것 써보면서 학습하는게 스스로한테 좋을 터. 그렇기 때문에 Redis를 이렇게 써보는게 의미가 있다고 생각한다. 허허헣

 

 

 

 

 

 

+ Recent posts