Overview

What is Caching ? 글을 참고하여 캐시 전략에 대해 정리했습니다.


1. Cache 란?

위키 백과에는 이렇게 나와 있습니다.

데이터나 값을 미리 복사해 놓는 임시 장소를 가리킨다.
캐시는 캐시의 접근 시간에 비해 원래 데이터를 접근하는 시간이 오래 걸리는 경우나 값을 다시 계산하는 시간을 절약하고 싶은 경우에 사용한다.
캐시에 데이터를 미리 복사해 놓으면 계산이나 접근 시간 없이 더 빠른 속도로 데이터에 접근할 수 있다.

설명 그대로 저장소에 접근하는 시간을 절약하기 위해 별도의 저장소에 저장해두는 것을 의미합니다.

서버에서는 MySQL, Oracle 같은 DBMS 에 매번 접근하지 않도록 메모리에 임시로 저장해두는 용도로 많이 사용합니다.

Redis 같은 별도의 캐시 저장소를 사용하는게 일반적이며, DB 내부에 있는 캐시, 브라우저 캐시, CDN 등등도 다 캐시라고 볼 수 있습니다.


2. 캐싱하기에 적절한 데이터

캐시가 효율적으로 동작하기 위해선 데이터가 다음과 같은 특징을 가져야 합니다.

  • 자주 조회됨
  • 데이터의 결과값이 일정함
  • 연산이 무거움

3. 읽기 캐시 전략

캐시를 읽을 때 어떤 전략을 사용할지 결정합니다.

기본적으로는 캐시 먼저 조회 후 DB 를 다시 확인한 뒤, 캐시를 갱신하는 방법입니다.


3.1. Cache Aside

  1. 캐시에 데이터가 있는지 확인
  2. 데이터가 존재하면 (Cache Hit) 해당 캐시 데이터를 반환
  3. 데이터가 존재하지 않으면 (Cache Miss) 애플리케이션에서 DB 에 데이터 요청 후 캐시에 저장하고 데이터를 반환

애플리케이션에서 가장 일반적으로 사용되는 캐시 전략입니다.

주로 읽기 작업이 많은 애플리케이션에 적합합니다.

Cache Hit 의 경우 DB 를 확인하지 않기 때문에 캐시가 최신 데이터를 가지고 있는지 (동기화) 가 중요합니다.

캐시가 분리되어 있기 때문에 원하는 데이터만 별도로 구성하여 캐시에 저장할 수 있고 캐시에 장애가 발생해도 DB 에서 데이터를 가져오는 데 문제가 없습니다.

하지만 캐시에 장애가 발생했다는 뜻은 DB 로 직접 오는 요청이 많아져서 전체적인 장애로 이어질 수 있습니다.


3.2. Read Through

  1. 캐시에 데이터 요청
  2. 캐시는 데이터가 있으면 (Cache Hit) 바로 반환
  3. 데이터가 없다면 (Cache Miss) 캐시가 DB 에서 데이터를 조회한 후에 캐시에 저장 후 반환

Cache Aside 와 비슷하지만 데이터 동기화를 라이브러리 또는 캐시 제공자에게 위임하는 방식이라는 차이점이 있습니다.

마찬가지로 읽기 작업이 많은 경우에 적합하며 두 방법 다 데이터를 처음 읽는 경우에는 Cache Miss 가 발생해서 느리다는 특징이 있습니다.

Cache Aside 와는 다르게 캐시에 의존성이 높기 때문에 캐시에 장애가 발생한 경우 바로 전체 장애로 이어집니다.

이를 방지하기 위해 Cache Cluster 등 가용성 높은 시스템을 구축해두는 것이 중요합니다.


3.3. 읽기 캐시에서 발생 가능한 장애: Thundering Herd

캐시 서버를 구축했다고 해서 아무런 문제가 없는 것은 아닙니다.

캐시 읽기 전략에서는 공통적으로 캐시 확인 -> DB 확인 순서로 이어지는데 이 과정에서 캐시에 데이터가 있으면 DB 확인을 생략하는 것으로 성능을 향상시킵니다.

하지만 서비스를 이제 막 오픈해서 캐시가 비어있는 경우에는 들어오는 요청이 전부 Cache Miss 가 발생하고 DB 조회 후 캐시를 갱신하느라 장애가 발생할 수 있습니다.

이를 회피하기 위해서 캐시에 데이터를 미리 세팅해두는 Cache Warm up 작업을 하거나 첫 요청이 캐시 갱신될 때까지 기다린 후에 이후 요청은 전부 캐시에서 반환하게 할 수 있습니다.

Cache Warm up 작업을 할 때 어떤 데이터를 넣느냐에 따라 마찬가지로 Cache Miss 가 발생할 수 있기 때문에 자주 들어올만한 데이터의 예측이 중요합니다.


4. 쓰기 캐시 전략

쓰기 요청 시 어떤 시점에 캐시 갱신을 하는지에 따라 나뉩니다.

  • Write Around: 캐시를 갱신하지 않음
  • Write Through: 캐시를 바로 갱신
  • Write Back: 캐시를 모아서 나중에 갱신

4.1. Write Around

  1. 데이터 추가/업데이트 요청이 들어오면 DB 에만 데이터를 반영
  2. 쓰기 작업에서 캐시는 건들지 않고 읽기 작업 시 Cache Miss 가 발생하면 업데이트 됨

캐시 쓰기 전략이라고 하기는 좀 애매하게 캐시를 전혀 건들지 않습니다.

수정사항은 DB 에만 반영하고 캐시는 그대로 두기 때문에 Cache Miss 가 발생하기 전까지는 캐시 갱신이 발생하지 않습니다.

Cache 가 갱신된지 얼마 안된 경우에는 캐시 Expire 처리 되기 전까지 계속 DB 와 다른 데이터를 갖고 있다는 단점이 있습니다.

만약 업데이트 이후 바로 조회되지 않을거라는 확신이 있다면 캐시를 초기화하여 Cache Miss 를 유도하는 방법으로 보완할 수 있습니다.


4.2. Write Through

  1. 캐시에 데이터를 추가하거나 업데이트
  2. 캐시가 DB 에 동기식으로 데이터 갱신
  3. 캐시 데이터를 반환

Read Through 와 마찬가지로 DB 동기화 작업을 캐시에게 위임합니다.

동기화까지 완료한 후에 데이터를 반환하기 때문에 캐시를 항상 최신 상태로 유지할 수 있다는 장점이 있습니다.

캐시 및 DB 를 동기식으로 갱신한 후에 최종 데이터 반환이 발생하기 때문에 전반적으로 느려질 수 있습니다.

새로운 데이터를 캐시에 미리 넣어두기 때문에 읽기 성능을 향상시킬 수 있지만 이후에 읽히지 않을 데이터도 넣어두는 리소스 낭비가 발생할 수 있습니다.


4.3. Write Back (Write Behind)

  1. 캐시에 데이터를 추가하거나 업데이트
  2. 캐시 데이터 반환
  3. 캐시에 있던 데이터는 이후에 별도 서비스 (이벤트 큐 등) 를 통해 DB 에 업데이트

캐시와 DB 동기화를 비동기로 하는 방법이며 동기화 과정이 생략되기 때문에 쓰기 작업이 많은 경우에 적합합니다.

캐시에서 일정 시간 또는 일정량의 데이터를 모아놓았다가 한번에 DB 에 업데이트 하기 때문에 쓰기 비용을 절약할 수 있습니다.

다른 캐시 전략에 비해 구현하기 복잡한 편이며 캐시에서 DB 로 데이터를 업데이트 하기 전에 장애가 발생하면 데이터가 유실될 수 있습니다.


4.4. Refresh Ahead

자주 사용되는 데이터를 캐시 만료 전에 미리 TTL (Expire time) 을 갱신합니다.

캐시 미스 발생을 최소화 할 수 있지만 Warm Up 작업과 마찬가지로 자주 사용되는 데이터를 잘 예측해야 합니다.


Reference

'공부 > Database' 카테고리의 다른 글

MySQL Optimizer 와 USE INDEX vs FORCE INDEX  (0) 2022.06.21
MySQL Index 특징 및 유의사항 정리  (2) 2022.05.22
Redis 설치 및 명령어  (0) 2021.08.07

Overview

애플리케이션을 개발하면 외부에서도 접근 가능하도록 클라우드 환경에 배포합니다.

이전에 포스팅 했던 AWS 1편에서는 마지막에 scp 명령어로 로컬에 존재하는 빌드 파일을 EC2 인스턴스로 복사한 후 ssh 로 접속해서 실행시켰습니다.

하지만 매 배포마다 이렇게 하면 굉장히 번거롭고 실수할 가능성도 높아집니다.

그래서 이런 수작업을 자동화하는 여러 가지 툴과 기법들이 등장했고 Github Actions 도 그 중 하나입니다.

Github Actions 에 대해서는 지난 포스팅 에서 한번 다룬 적이 있습니다.

이번에는 Github Actions 를 사용해서 AWS EC2 에 자동으로 배포하는 과정을 알아봅니다.

글은 다음과 같은 순서로 진행됩니다.

  1. Github Actions 에서 AWS 에 배포하는 방법
  2. AWS EC2 설정 추가
  3. AWS S3 버킷 생성
  4. AWS CodeDeploy 앱 생성 및 배포 설정
  5. Github Actions 에서 사용할 사용자 권한 추가
  6. AppSpec 파일 작성
  7. 배포 스크립트 작성
  8. Github Actions Workflow 작성
  9. Github 에서 push 로 배포하기

1. 배포 방법

main 브랜치에 Push 하면 자동으로 EC2 까지 배포되는 Workflow 를 만들어봅시다.

먼저 Workflow 를 작성하기 전에 어떤 방식으로 EC2 까지 배포가 이루어지는 지 전체적인 플로우를 알아야 합니다.

Github Actions 를 확인하면 CI 과정에서 했던 것처럼 aws.yml 이라는 기본 Workflow 를 제공합니다.

배포하는 방법은 여러 가지 있겠지만 AWS 의 경우 큰 흐름은 하나입니다.

소스 코드를 압축하여 AWS 스토리지에 저장 후 서버에 전달해서 실행한다

그리고 AWS 에서 공식적으로 가이드하는 방법은 크게 두 가지가 있습니다.

  1. AWS S3 빌드파일 압축해서 업로드 -> AWS EC2 배포 (CodeDeploy 활용)
  2. AWS ECR 에 도커 이미지 업로드 -> AWS ECS 배포 (Task Definition 활용)

1.1. 어떤 차이가 있을까?

우리는 EC2 인스턴스에 배포해야 하기 때문에 1번 방법을 사용합니다.

Github Actions 에서 제공하는 AWS Workflow 는 2번 방법을 안내하고 있어서 차이점을 먼저 알아봤습니다.

AWS ECR 은 Docker Image 를 저장하는 레지스트리고 AWS ECS 일종의 도커 컨테이너 서비스입니다.

AWS ECS 는 미리 정의한 Task Definition 을 기반으로 클러스터에 인스턴스를 생성하고 ECR 에 저장된 도커 이미지를 배포하는 등 인스턴스를 관리하며 스케일 인/아웃을 지원합니다.

여러 서버 인스턴스를 관리하기 위해선 더 편할 수 있으나 우리는 이미 존재하는 EC2 인스턴스에 배포하는 걸 목적으로 하기 때문에 1번 방법을 사용합니다.


1.2. 배포 과정

큰 흐름을 요약하면 다음과 같습니다.

  1. Github Actions 에서 코드 빌드 (테스트는 CI 에서 했다고 검증했다고 판단하여 생략)
  2. AWS 인증
  3. 코드 압축해서 AWS S3 에 업로드
  4. AWS CodeDeploy 실행하여 S3 에 있는 코드 EC2 에 배포

결국 Github 프로젝트 코드를 AWS S3 에 업로드 한 후 AWS EC2 에서 끌어다 쓰는 것이 가장 핵심이며 AWS CodeDeploy 는 그걸 보조해주는 역할을 담당합니다.

Github 에서 CodeDeploy, S3 에 접근하기 위한 권한이 필요하고 EC2 에서 S3 에 접근하기 위한 권한도 필요하기 때문에 설정이 조금 복잡하게 느껴질 수도 있습니다.

차근차근 진행 해보겠습니다.


2. EC2 설정 추가

AWS EC2 편 에서 생성한 인스턴스를 기준으로 아래 작업들을 추가로 진행합니다.

  1. Tag 추가 (CodeDeploy 에서 어떤 인스턴스에 실행할 지 구분하는 값)
  2. IAM 역할 등록
  3. EC2 서버에 CodeDeploy Agent 설치

2.1. Tag 추가

CodeDeploy 를 생성할 때 어떤 인스턴스에서 수행할 지 구분하는 값으로 태그를 사용하기 때문에 추가가 필요합니다.

기존에 인스턴스 생성할 때 태그까지 같이 생성했다면 이 과정은 생략해도 괜찮습니다.


2.1.1. EC2 설정에서 태그 관리 선택

EC2 인스턴스 정보에 들어가 태그 관리를 선택합니다.


2.1.2. 태그 추가

원하는 키 값을 입력하고 저장을 누릅니다.


2.1.3. 태그 확인

다시 EC2 인스턴스 정보에서 태그가 등록되었는지 확인할 수 있습니다.


2.2. IAM 역할 추가

EC2 인스턴스에서 S3 에 올려놓은 파일에 접근할 수 있도록 권한을 추가해줘야 합니다.


2.2.1. IAM 역할 관리 페이지로 이동

기본적으로 존재하는 역할들이 있는데 신경쓰지 말고 새로운 역할 만들기를 선택합니다.


2.2.2. EC2 엔티티 선택

IAM 역할을 연결할 서비스를 선택합니다.


2.2.3. S3 접근 권한 추가

EC2 인스턴스에서 S3 접근할 수 있도록 AmazonS3FullAccess 권한을 추가합니다.


2.2.4. 이름 설정

마지막으로 원하는 이름을 입력한 뒤 생성을 완료합니다.


2.2.5. EC2 인스턴스에서 IAM 연결

EC2 인스턴스 관리 페이지로 이동해서 "작업 > 보안 > IAM 역할 수정" 을 선택합니다.


방금 만든 EC2 전용 IAM 역할을 선택한 뒤 저장을 누르면 연결이 완료됩니다.


2.3. CodeDeploy Agent 설치

$ sudo apt update
$ sudo apt install ruby-full
$ sudo apt install wget
$ cd /home/ubuntu
$ wget https://aws-codedeploy-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/latest/install
$ chmod +x ./install
$ sudo ./install auto > /tmp/logfile
$ sudo service codedeploy-agent status

CodeDeploy Agent 설치 를 보고 명령어를 따라 치기만 하면 됩니다.

EC2 환경이 Ubuntu 가 아니거나 버전이 다르다면 공식 문서를 참고해주세요.


정상적으로 설치가 완료되면 이런 응답이 와야 합니다.


3. AWS S3 생성

AWS S3 버킷이란 이미지 또는 zip 파일을 저장하기 위한 스토리지 서비스입니다.

빌드한 프로젝트 코드를 압축해서 S3 에 저장한 후 EC2 서버에서 S3 에 접근해서 압축한 파일을 가져오기 위해 사용합니다.


3.1. S3 메뉴에서 버킷 생성

S3 메뉴로 이동해서 "버킷 만들기" 를 누릅니다.


3.2. 일반 구성과 객체 소유권 설정

원하는 버킷 이름과 리전을 선택합니다.

ACL 은 기본값을 선택해서 비활성화 합니다.


3.3. 액세스, 버킷 버전, 암호화 비활성화

나머지 설정을 마저 합니다.

변경할 필요 없이 기본값 그대로 두면 됩니다.


3.4. S3 버킷 생성 완료

버킷 생성이 완료되면 이렇게 나타납니다.


4. CodeDeploy 생성

배포를 도와주는 CodeDeploy 생성 및 설정을 진행해봅니다.


4.1. CodeDeploy 전용 IAM 역할 만들기

CodeDeploy 를 사용하기 위해선 IAM 에서 역할을 만들어야 합니다.


4.1.1. IAM 메뉴에서 역할 선택

IAM 서비스로 이동해서 역할 만들기를 선택합니다.


4.1.2. CodeDeploy 엔티티 선택

기본적으로 제공되는 AWS 서비스에서 CodeDeploy 를 검색한 후 가장 기본적인 걸 선택합니다.


4.1.3. IAM 이름 설정

나머지는 건들 필요 없고 이름만 새로 추가합니다.

저는 my-codedeploy-iam 로 설정했습니다.

이름을 입력했다면 "역할 생성" 을 눌러 마무리합니다.


4.2. CodeDeploy 애플리케이션 생성

이제 우리가 사용할 CodeDeploy 앱을 생성합니다.


메뉴에서 생성 버튼을 누릅니다.


원하는 이름을 입력 후 컴퓨팅 플랫폼은 EC2/온프레미스 를 선택합니다.


4.3. CodeDeploy 배포 그룹 생성

CodeDeploy 애플리케이션에서 사용하는 배포 그룹을 생성합니다.


4.3.1. 메뉴에서 선택

방금 만든 애플리케이션에서 배포 그룹 생성을 누릅니다.


4.3.2. 이름, 역할, 유형 선택

원하는 배포 그룹 이름, 역할, 유형을 설정합니다.

서비스 역할은 위에서 만든 IAM 역할을 선택할 수 있게 나옵니다.


4.3.3. EC2 인스턴스 선택

어떤 인스턴스에서 동작할 지 선택합니다.

EC2 인스턴스에서 태그를 추가해야 선택할 수 있습니다.

우리는 위에서 이미 기존 EC2 인스턴스에 태그를 추가했기 때문에 해당 태그 키를 선택합니다.


4.3.4. 나머지 설정 후 배포 그룹 생성

AWS Systems Manager 는 크게 중요한거 같지 않으니 적당히 선택하고 로드 밸런싱을 사용하지 않으니 체크만 해제합니다.

다 설정했으면 배포 그룹 생성을 눌러 마무리합니다.


5. Github Actions 에서 사용할 IAM 사용자 추가

AWS 를 Github Actions 워크 플로우에서 접근하려면 권한이 필요합니다.

지금까지는 IAM 역할만 추가해서 특정 서비스 (EC2, CodeDeploy) 에게 부여 했지만 이번에는 IAM 사용자를 추가해봅니다.


5.1. IAM 사용자 메뉴로 이동

IAM 메뉴에서 사용자 추가를 선택합니다.


5.2. IAM 사용자 이름 및 액세스 유형 설정

사용자 이름을 추가하고 액세스 유형을 선택합니다.

우리는 Github Actions 에서 사용해야 하기 때문에 암호 방식 대신 액세스 키 방식을 선택합니다.


5.3. 접근이 필요한 권한 추가

이 사용자에게 추가할 접근 권한을 고릅니다.

워크 플로우에서 CodeDeploy 를 실행해야 하기 때문에 다음 두 권한을 추가합니다.

  • AWSCodeDeployFullAccess
  • AmazonS3FullAccess

5.4. 사용자 만들기 완료

태그는 필요 없기 때문에 생략하고 잘못된 설정이 없는지 마지막으로 확인 후 사용자를 만듭니다.


5.5. Access Key 및 Secret Key 확인

사용자를 만들고 나면 "액세스 키 ID" 와 "비밀 액세스 키" 가 존재합니다.

이 두 개의 키 값을 사용해서 IAM 권한을 획득할 수 있습니다.

우리는 이걸 Github Actions 에서 사용할 수 있도록 등록합니다.


5.6. Github Repository 의 Secrets 추가

Github Actions 을 적용하려는 Github > Repository > Settings > Secrets 로 이동해서 위 키 값들을 등록합니다.

키 이름은 적당히 편한 것으로 설정합니다.

Github Secrets 에 저장한다고 해도 값을 직접 확인할 수 없기 때문에 필요한 경우 따로 저장해둡니다.


6. AppSpec 파일 작성

지금까지 우리는 서버를 띄울 EC2, 배포할 결과물을 저장할 S3, 배포를 도와줄 CodeDeploy 이렇게 총 세 가지 AWS 서비스를 만들었습니다.

이제 CodeDeploy 에서 배포를 위해 참조할 AppSpec 파일을 작성합니다.

AppSpec 파일을 사용해서 우리는 프로젝트의 어떤 파일들을 EC2 의 어떤 경로에 복사할지 설정 가능하고, 배포 프로세스 이후에 수행할 스크립트를 지정하여 자동으로 서버를 띄울 수도 있습니다.

AppSpec 파일은 기본적으로 루트 디렉터리에 위치해야 합니다.


version: 0.0
os: linux

files:
  - source:  /
    destination: /home/ubuntu/app
    overwrite: yes

permissions:
  - object: /
    pattern: "**"
    owner: ubuntu
    group: ubuntu

hooks:
  AfterInstall:
    - location: scripts/stop.sh
      timeout: 60
      runas: ubuntu
  ApplicationStart:
    - location: scripts/start.sh
      timeout: 60
      runas: ubuntu

전체 파일은 위와 같으며 각 섹션별로 조금씩만 살펴보겠습니다.


6.1. files 섹션

files:
  - source:  /
    destination: /home/ubuntu/app
    overwrite: yes

배포 파일에 대한 설정입니다.

  • source: 인스턴스에 복사할 디렉터리 경로
  • destination: 인스턴스에서 파일이 복사되는 위치
  • overwrite: 복사할 위치에 파일이 있는 경우 대체

AppSpec "files" 섹션 문서를 참고하면 더 자세한 내용을 알 수 있습니다.


6.2. permissions 섹션

permissions:
  - object: /
    pattern: "**"
    owner: ubuntu
    group: ubuntu

files 섹션에서 복사한 파일에 대한 권한 설정입니다.

  • object: 권한이 지정되는 파일 또는 디렉터리
  • pattern (optional): 매칭되는 패턴에만 권한 부여
  • owner (optional): object 의 소유자
  • group (optional): object 의 그룹 이름

AppSpec "permissions" 섹션 문서를 참고하면 더 자세한 내용을 알 수 있습니다.


6.3. hooks 섹션

hooks:
  AfterInstall:
    - location: scripts/stop.sh
      timeout: 60
      runas: ubuntu
  ApplicationStart:
    - location: scripts/start.sh
      timeout: 60
      runas: ubuntu

배포 이후에 수행할 스크립트를 지정할 수 있습니다.

일련의 라이프사이클이 존재하기 때문에 적절한 Hook 을 찾아 실행할 스크립트를 지정하면 됩니다.

위 코드에서는 파일을 설치한 후 AfterInstall 에서 기존에 실행중이던 애플리케이션을 종료시키고 ApplicationStart 에서 새로운 애플리케이션을 실행합니다.

  • location: hooks 에서 실행할 스크립트 위치
  • timeout (optional): 스크립트 실행에 허용되는 최대 시간이며, 넘으면 배포 실패로 간주됨
  • runas (optional): 스크립트를 실행하는 사용자

AppSpec "hooks" 섹션 문서를 참고하면 더 자세한 내용을 알 수 있습니다.


7. 배포 스크립트 작성

바로 위 AppSpec Hooks 에서 실행할 스크립트 stop.shstart.sh 를 설정했습니다.

이제 수행할 스크립트 파일을 작성합니다.


7.1. stop.sh

#!/usr/bin/env bash

PROJECT_ROOT="/home/ubuntu/app"
JAR_FILE="$PROJECT_ROOT/spring-webapp.jar"

DEPLOY_LOG="$PROJECT_ROOT/deploy.log"

TIME_NOW=$(date +%c)

# 현재 구동 중인 애플리케이션 pid 확인
CURRENT_PID=$(pgrep -f $JAR_FILE)

# 프로세스가 켜져 있으면 종료
if [ -z $CURRENT_PID ]; then
  echo "$TIME_NOW > 현재 실행중인 애플리케이션이 없습니다" >> $DEPLOY_LOG
else
  echo "$TIME_NOW > 실행중인 $CURRENT_PID 애플리케이션 종료 " >> $DEPLOY_LOG
  kill -15 $CURRENT_PID
fi

애플리케이션이 이미 떠있으면 종료하는 스크립트입니다.

간단히 주석으로 설명을 달아두었으니 쉘 스크립트를 작성할 줄 안다면 보기에 어려움은 없을 겁니다.


7.2. start.sh

#!/usr/bin/env bash

PROJECT_ROOT="/home/ubuntu/app"
JAR_FILE="$PROJECT_ROOT/spring-webapp.jar"

APP_LOG="$PROJECT_ROOT/application.log"
ERROR_LOG="$PROJECT_ROOT/error.log"
DEPLOY_LOG="$PROJECT_ROOT/deploy.log"

TIME_NOW=$(date +%c)

# build 파일 복사
echo "$TIME_NOW > $JAR_FILE 파일 복사" >> $DEPLOY_LOG
cp $PROJECT_ROOT/build/libs/*.jar $JAR_FILE

# jar 파일 실행
echo "$TIME_NOW > $JAR_FILE 파일 실행" >> $DEPLOY_LOG
nohup java -jar $JAR_FILE > $APP_LOG 2> $ERROR_LOG &

CURRENT_PID=$(pgrep -f $JAR_FILE)
echo "$TIME_NOW > 실행된 프로세스 아이디 $CURRENT_PID 입니다." >> $DEPLOY_LOG

애플리케이션을 실행하는 스크립트입니다.

Github Actions 워크플로우에서 이미 빌드는 마쳤기 때문에 JAR 파일만 복사 후 실행합니다.


7.3. build.gradle 파일 수정

위 스크립트를 보면 /build/libs/*.jar 파일을 $JAR_FILE 파일로 복사합니다.

그런데 Spring Boot 2.5 버전부터는 빌드 시 일반 jar 파일 하나와 -plain.jar 파일 하나가 함께 만들어집니다.

그래서 빌드 시 plain jar 파일은 만들어지지 않도록 build.gradle 파일에 다음 내용을 추가해야 합니다.

jar {
    enabled = false
}

build.gradle.kts 파일은 이렇게 작성하시면 됩니다.

tasks.getByName<Jar>("jar") {
    enabled = false
}

8. Github Actions Workflow 작성

이제 필요한 사전 작업은 모두 끝났으니 Github Actions 워크 플로우만 작성하면 됩니다.


8.1. Sample Workflow 선택

Github Actions CI 편에서는 기본적으로 제공되는 gradle 샘플을 수정했지만 배포 플로우는 거의 다 수정해야 하므로 그냥 가장 심플한 워크 플로우를 선택합니다.


8.2. deploy.yml 파일 작성

name: Deploy to Amazon EC2

on:
  push:
    branches:
      - main

# 본인이 설정한 값을 여기서 채워넣습니다.
# 리전, 버킷 이름, CodeDeploy 앱 이름, CodeDeploy 배포 그룹 이름
env:
  AWS_REGION: ap-northeast-2
  S3_BUCKET_NAME: my-github-actions-s3-bucket
  CODE_DEPLOY_APPLICATION_NAME: my-codedeploy-app
  CODE_DEPLOY_DEPLOYMENT_GROUP_NAME: my-codedeploy-deployment-group

permissions:
  contents: read

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    environment: production

    steps:
    # (1) 기본 체크아웃
    - name: Checkout
      uses: actions/checkout@v3

    # (2) JDK 11 세팅
    - name: Set up JDK 11
      uses: actions/setup-java@v3
      with:
        distribution: 'temurin'
        java-version: '11'

    # (3) Gradle build (Test 제외)
    - name: Build with Gradle
      uses: gradle/gradle-build-action@0d13054264b0bb894ded474f08ebb30921341cee
      with:
        arguments: clean build -x test

    # (4) AWS 인증 (IAM 사용자 Access Key, Secret Key 활용)
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ${{ env.AWS_REGION }}

    # (5) 빌드 결과물을 S3 버킷에 업로드
    - name: Upload to AWS S3
      run: |
        aws deploy push \
          --application-name ${{ env.CODE_DEPLOY_APPLICATION_NAME }} \
          --ignore-hidden-files \
          --s3-location s3://$S3_BUCKET_NAME/$GITHUB_SHA.zip \
          --source .

    # (6) S3 버킷에 있는 파일을 대상으로 CodeDeploy 실행
    - name: Deploy to AWS EC2 from S3
      run: |
        aws deploy create-deployment \
          --application-name ${{ env.CODE_DEPLOY_APPLICATION_NAME }} \
          --deployment-config-name CodeDeployDefault.AllAtOnce \
          --deployment-group-name ${{ env.CODE_DEPLOY_DEPLOYMENT_GROUP_NAME }} \
          --s3-location bucket=$S3_BUCKET_NAME,key=$GITHUB_SHA.zip,bundleType=zip

전체 파일은 위와 같으며 스텝 별로 간단한 주석을 달아두었습니다.

결국 프로젝트를 빌드한 후 AWS S3 버킷에 푸시 후 CodeDeploy 를 수행하는 겁니다.

(4), (5), (6) 에 대해서만 간략한 설명을 덧붙이겠습니다.


8.2.1. (4) AWS 인증

# (4) AWS 인증 (IAM 사용자 Access Key, Secret Key 활용)
- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v1
  with:
    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    aws-region: ${{ env.AWS_REGION }}

AWS 에 접근하기 위해 인증하는 스텝입니다.

우리는 위에서 IAM 사용자를 만든 후 Access Key, Secret Key 를 Github 레포에 저장했습니다.

그러면 secrets 변수를 통해 우리가 저장한 키 값들을 가져와서 사용할 수 있습니다.

필요한 Access Key, Secret Key 등을 프로젝트 코드에 노출시키지 않은 채로 사용할 수 있다는 편리함이 있습니다.


8.2.2. (5) AWS S3 에 업로드

# (5) 빌드 결과물을 S3 버킷에 업로드
- name: Upload to AWS S3
  run: |
    aws deploy push \
      --application-name ${{ env.CODE_DEPLOY_APPLICATION_NAME }} \
      --s3-location s3://$S3_BUCKET_NAME/$GITHUB_SHA.zip \
      --ignore-hidden-files \
      --source .

원하는 파일들을 압축해서 AWS S3 에 업로드 하는 스텝입니다.

공식 문서를 참고하면 더 자세한 정보를 알 수 있습니다.

  • --application-name: CodeDeploy 애플리케이션 이름
  • --s3-location: 압축 파일을 업로드 할 S3 버킷 정보
  • --ignore-hidden-files (optional): 숨겨진 파일까지 번들링할지 여부

$GITHUB_SHA 라는 변수가 보이는데 간단하게 생각해서 Github 자체에서 커밋마다 생성하는 랜덤한 변수값입니다. (자세한 정보는 Github Context 참조)

이렇게 랜덤한 값을 사용하면 파일 업로드 시에 이름 중복으로 충돌날 일이 없습니다.


8.2.3. (6) AWS EC2 에 배포

# (6) S3 버킷에 있는 파일을 대상으로 CodeDeploy 실행
- name: Deploy to AWS EC2 from S3
  run: |
    aws deploy create-deployment \
      --application-name ${{ env.CODE_DEPLOY_APPLICATION_NAME }} \
      --deployment-config-name CodeDeployDefault.AllAtOnce \
      --deployment-group-name ${{ env.CODE_DEPLOY_DEPLOYMENT_GROUP_NAME }} \
      --s3-location bucket=$S3_BUCKET_NAME,key=$GITHUB_SHA.zip,bundleType=zip

위 스텝에서 S3 에 저장한 파일을 EC2 에서 땡겨온 후 압축을 풀고 스크립트를 실행합니다.

공식 문서를 참고하면 더 자세한 정보를 알 수 있습니다.

  • --application-name: CodeDeploy 애플리케이션 이름
  • --deployment-config-name: 배포 방식인데 기본값을 사용
  • --deployment-group-name: CodeDeploy 배포 그룹 이름
  • --s3-location: 버킷 이름, 키 값, 번들타입

9. Github Actions 사용해서 배포하기

이제 모든 세팅이 끝났으므로 배포를 진행해봅니다.

yaml 파일로 설정한 것처럼 main 브랜치에 push 되는 경우 Github Actions Workflow 가 수행됩니다.


9.1. Github Repo 에서 확인

워크 플로우가 정상적으로 수행되면 이렇게 커밋에 체크 표시가 생깁니다.


"Details" 를 눌러보거나 "Actions" 탭으로 이동하면 수행한 스텝이 나옵니다.

만약 실패한다면 어떤 스텝에서 실패했는지 확인할 수 있습니다.


9.2. CodeDeploy 에서 배포 내역 확인

AWS CodeDeploy 메뉴로 이동하면 배포 내역을 확인인할 수 있습니다.


9.3. EC2 서버에서 애플리케이션 실행 확인

EC2 서버에 접속해서 확인 해보면 Spring Boot 프로젝트 코드가 있으며 서버도 정상적으로 떠있는 것을 확인할 수 있습니다.

  • CodeDeploy 배포 로그 확인: /var/log/aws/codedeploy-agent/codedeploy-agent.log
  • CodeDeploy Hooks 로그 확인: /opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log

Reference

'공부 > CI & CD' 카테고리의 다른 글

Github Actions CI: 자동 빌드 및 테스트 하기  (0) 2022.04.27
AWS 2편: RDS 생성 후 EC2 와 연동  (2) 2022.01.23
AWS 1편: EC2 생성 후 Spring Boot 띄우기  (7) 2022.01.21
Docker 명령어  (0) 2022.01.05
kubectl 명령어  (0) 2021.12.23

Overview

한 프로젝트를 여러 사람이 개발할 때 코드의 안정성은 굉장히 중요합니다.

환경 변수 변경, 비즈니스 로직 수정, Git 충돌 등 여러 사람들이 코드를 공유하면서 발생할 수 있는 문제점은 굉장히 많습니다.

실수를 방지하기 위해 테스트 코드를 작성하지만 매 PR 리뷰 때마다 각 리뷰어들이 일일히 테스트 코드를 돌려보며 리뷰하면 생산성이 저하됩니다.

단순히 리뷰 요청자의 테스트 잘 돌아갑니다~ 같은 코멘트보다 테스트의 성공을 확실히 보장해주는 수단이 필요합니다.

그래서 CI/CD 라는 개념이 등장했습니다.


1. CI/CD

  • CI (Continuous Integration)
    • 해석하면 "지속적 통합" 으로 여러 개발자가 하나의 프로젝트를 같이 개발할 때 발생하는 불일치를 최소화 해주는 개념입니다.
    • CI 를 제대로 구현하면 애플리케이션 변경 사항 반영 시 자동으로 빌드 및 테스트 되어 잘못된 코드가 공유되는 걸 방지합니다.
  • CD (Continuous Deployment)
    • "지속적 배포" 라는 뜻으로 프로젝트의 변경 사항을 가상 환경에 자동으로 배포하는 것을 의미합니다.
    • CD 를 구성해두면 변경 사항을 배포할 때 사용하는 파이프라인을 공유하여 번거로움을 없앨 수 있습니다.

좀더 자세한 내용은 RedHat 공식문서를 참고하세요.

쉽게 표현하자면 CI 는 자동 빌드 및 테스트를 진행하여 여러 개발자들이 공유하는 코드의 신뢰성을 높이는 개념이고 CD 는 배포 플로우를 자동화하여 누구나 동일한 플로우로 배포할 수 있게 만들어주는 개념입니다.


2. Github Actions

Github Actions 는 Github 에서 제공하는 CI/CD 툴입니다.

build, test, deploy 등 필요한 Workflow 를 등록해두면 Gihtub 의 특정 이벤트 (push, pull request) 가 발생했을 때 해당 워크 플로우를 수행합니다.

예를 들어 Pull Request 를 올리면 자동으로 해당 코드의 테스트를 수행하여 수행한다던지 master branch 에 코드를 push 하면 자동으로 코드를 배포하는 등 여러 가지 반복적인 작업을 자동으로 수행해줍니다.

Jenkins, Circle CI, Travis CI 등 다른 후보들도 있지만 Github Actions 은 별다른 툴을 설치하지 않아도 Github Repository 에서 바로 사용할 수 있다는 장점이 있습니다.


3. Github Actions 자동 빌드 및 테스트

Github Actions 를 사용한 CI 환경을 구축해봅니다.

Pull Request 를 올렸을 때 자동으로 빌드 및 테스트를 수행하여 코드의 품질을 검사합니다.


3.1. Workflow 선택

Github Repository 로 이동해서 Actions 탭에서 새로운 Workflow 를 추가할 수 있습니다.

자주 사용되는 언어나 프레임워크는 이미 제공되는게 있기 때문에 검색해서 선택만 하면 됩니다.

Configure 버튼을 누르면 기본적으로 제공되는 gradle.yml 파일이 제공됩니다.


3.2. gradle.yml 파일 수정

name: Java CI with Gradle

on:
  pull_request:
    branches: [ main ]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest

    steps:

    # 1) 워크플로우 실행 전 기본적으로 체크아웃 필요
    - uses: actions/checkout@v3

    # 2) JDK 11 버전 설치, 다른 JDK 버전을 사용한다면 수정 필요
    - name: Set up JDK 11
      uses: actions/setup-java@v3
      with:
        java-version: '11'
        distribution: 'temurin'

    # 3) Gradle 사용. arguments 를 붙이면 뒤에 그대로 실행된다고 생각하면 됨
    # 이 워크플로우는 gradle clean build 를 수행함
    - name: Build with Gradle
      uses: gradle/gradle-build-action@0d13054264b0bb894ded474f08ebb30921341cee
      with:
        arguments: clean build

기본적으로 제공되는 파일에서 조금 수정했습니다.

크게 신경써야 해줄 부분은 on 부분과 jobs 부분입니다.

on 에서는 워크플로우를 수행할 이벤트를 결정합니다.

위 코드는 main 을 베이스 브랜치로 한 Pull Request 를 생성하였을 때 수행된다는 뜻입니다.

jobs 에서는 수행할 워크플로우를 차례대로 입력하면 됩니다.

어떤 동작인지 간단하게 주석으로 표현했으며 더 궁금한 점은 Github Actions Docs 를 참고해주세요.


3.3. Pull Request 작성

이제 PR 을 작성하면 자동으로 Github Actions 가 동작하여 빌드를 실행합니다.


3.3.1. Workflow 실패

Github Actions 이 실패하면 이렇게 실패했다고 알려줍니다.

여러 워크 플로우를 한번에 수행시킬 수도 있으며, Github 설정에 따라 워크 플로우가 성공하지 않으면 머지 불가능 하도록 제한할 수도 있습니다.


3.3.2. Workflow 성공

모든 워크플로우가 성공하면 이렇게 녹색으로 성공 여부를 알려줍니다.

코드를 리뷰하는 사람은 직접 코드를 돌려보지 않아도 빌드와 테스트가 성공한다는 사실을 알 수 있습니다.


Conclusion

Github Actions 의 가장 큰 이점은 Github 과의 연계성이라고 생각합니다.

대부분의 프로젝트가 Github 저장소를 활용하고 있기 때문에 별다른 세팅 없이도 쉽게 이용할 수 있습니다.

이번 포스팅 내용에서 좀더 나아가 Workflow 에서 테스트 커버리지를 파악해서 알려주는 라이브러리도 존재합니다.

커버리지가 일정 기준을 넘지 않으면 머지 불가능하게 만들어 테스트 코드 작성을 강제할 수도 있습니다.

다음에는 Github Actions 를 사용해 AWS EC2 에 배포하는 과정을 작성해볼 예정입니다.

'공부 > CI & CD' 카테고리의 다른 글

Github Actions CD: AWS EC2 에 Spring Boot 배포하기  (28) 2022.05.05
AWS 2편: RDS 생성 후 EC2 와 연동  (2) 2022.01.23
AWS 1편: EC2 생성 후 Spring Boot 띄우기  (7) 2022.01.21
Docker 명령어  (0) 2022.01.05
kubectl 명령어  (0) 2021.12.23

순전히 개인적인 경험과 주관적인 의견으로 작성된 글입니다.

잘못된 정보가 있으면 지적 부탁드립니다.

Overview

웹플럭스는 흔히 비동기/논블로킹 이벤트루프 모델이라고 말합니다.

적은 수의 쓰레드로 많은 요청을 처리할 수 있는 걸 장점으로 내세우고 있는데, 항상 의문이 있었습니다.

흔히 블록킹 구간이라고 하는 네트워크 I/O 또는 데이터베이스 I/O 작업은 결국 어디선가 또는 누군가가 대기했다가 결과를 받아서 처리해야 합니다.

기존 MVC 모델에서 비동기 처리를 하면 별도의 쓰레드를 사용해서 처리했었기 때문에 큰 의문이 없었지만 쓰레드 하나로는 어떻게 처리하는지 궁금했습니다.

만약 웹플럭스에서도 별도 쓰레드풀을 만들어 처리한다면 요청량 증가에 따라 점점 백그라운드 쓰레드가 많아질거고 적은 수의 쓰레드로 컨텍스트 스위칭 비용 최소화 라는 장점이 무색해질 것 같았습니다.

이것저것 찾아본 결과 I/O 요청은 커널단으로 넘기고 JVM 은 I/O 요청에 필요한 데이터 복사만 해주기 때문에 블록킹 구간이 없다는 정보를 얻었습니다.

그렇다면 실제로 쓰레드가 처리해야 하는 연산이 오래 걸리는 경우에는 어떻게 될까? 라는 의문도 함께 들었습니다.

궁굼증을 해결하기 위해 코드를 짜서 테스트를 진행해보기로 했습니다.

전체 코드는 Github 에서 확인할 수 있습니다.

  • 테스트 환경
    • Kotlin / WebFlux 기반의 Spring Boot
    • 쓰레드는 단 하나만 사용
  • 테스트 내용
    • WebClient 로 응답이 오래 걸리는 외부 API 요청 시 쓰레드가 블락되는가?
    • 쓰레드가 처리해야 하는 무거운 연산 요청이 동시에 들어오면 비동기/논블로킹으로 처리 가능한가?
  • 주의사항
    • API 테스트를 할 때 동일 브라우저에서 여러 탭을 열어서 같은 요청을 보내면 순서대로 처리하기 때문에 시크릿 브라우저를 열어서 테스트 필요

1. 요청이 오래 걸리는 외부 API 를 요청하면 어떻게 될까?

Spring WebFlux 에서 외부 API 를 호출할 때는 WebClient 를 사용합니다.

WebClient 는 기존 MVC 모델에서 사용하던 RestTemplate 클래스와는 다르게 비동기로 API 를 호출하고 응답받을 수 있는 기능을 지원합니다.

WebClient 는 Spring WebFlux 에서 사용하는 이벤트 루프 워커 쓰레드를 공유합니다.

그래서 만약 외부 API 요청 시에 쓰레드가 Block 된다면 굉장한 문제가 생깁니다.

웹플럭스는 Core * 2 개의 쓰레드를 사용하기 때문에 많은 쓰레드를 사용하는 MVC 모델에 비해 쓰레드 블락의 영향이 큽니다.


1.1. 외부 API 서버 만들기

@SpringBootApplication
class ServerMvcApplication

fun main(args: Array<String>) {
    System.setProperty("server.port", "8181")
    runApplication<ServerMvcApplication>(*args)
}

@RestController
class BlockController {
    val log: Logger = LoggerFactory.getLogger(BlockController::class.java)

    @GetMapping("/block/{id}")
    fun block(@PathVariable id: Long): ResponseEntity<String> {
        log.info("request $id start")
        Thread.sleep(5000)
        log.info("request $id end")
        return ResponseEntity.ok().body("response $id")
    }
}

API 요청을 받아 5초 뒤에 응답해주는 서버입니다.

일반적인 상황을 위해 외부 서버는 Spring Boot WebMVC 로 만들었습니다.

로컬에서 동시에 띄우기 위해 포트를 8181 로 변경하였고 /block/{id} API 를 요청하면 쓰레드를 5초동안 슬립시킨 후에 응답합니다.

MVC 모델은 요청마다 쓰레드를 하나씩 할당해서 처리하기 때문에 여러 요청이 들어와도 5초씩만 지연됩니다.


크롬 브라우저와 시크릿 브라우저에서 요청하면 별도 쓰레드에서 각각 요청을 처리하는 걸 볼 수 있습니다.


1.2. WebFlux 서버 만들기

위에서 만든 MVC 를 호출하는 웹플럭스 서버를 만들어봅니다.


1.2.1. Server Code

@SpringBootApplication
class ServerWebfluxApplication

fun main(args: Array<String>) {
    // 쓰레드 1개만 사용
    System.setProperty("reactor.netty.ioWorkerCount", "1")
    runApplication<ServerWebfluxApplication>(*args)
}

@Configuration
class RouterConfig {
    val log: Logger = LoggerFactory.getLogger(RouterConfig::class.java)

    @Bean
    fun route(handler: RouterHandler) = router {
        "v1".nest {
            GET("/call/{id}", handler::call)

            before { request ->
                log.info("Before Filter ${request.pathVariable("id")}")
                log.info("$request")
                request
            }

            after { request, response ->
                log.info("After Filter ${request.pathVariable("id")}")
                response
            }
        }
    }
}

@Controller
class RouterHandler {
    val log: Logger = LoggerFactory.getLogger(RouterHandler::class.java)
    val webClient = WebClient.create("http://localhost:8181")

    fun call(request: ServerRequest): Mono<ServerResponse> {
        val id = request.pathVariable("id")

        return webClient.get()
            .uri("/block/$id")
            .retrieve()
            .bodyToMono(String::class.java)
            .flatMap {
                ServerResponse.ok().json().body(
                    Mono.just("[request $id] response $it")
                )
            }
    }
}

/v1/call/{id} 요청을 받으면 http://localhost:8181/block/{id} 호출한 결과값을 응답하는 API 입니다.

쓰레드 블록 여부를 판단해야 하기 때문에 워커 쓰레드 갯수를 1 개로 세팅합니다.


1.2.2. Thread Count

실제로 쓰레드가 한 개만 뜬 것을 확인할 수 있습니다.

요청은 모두 하나의 쓰레드로만 들어오며 쓰레드가 블락되는 경우 API 응답이 지연될 겁니다.


1.2.3. Log

쓰레드 하나로만 처리하는데도 Block 되지 않고 각각 5초만에 응답을 리턴합니다.


1.3. WebClient 대신 RestTemplate 을 사용하면?

@Controller
class RouterHandler {

    fun rest(request: ServerRequest): Mono<ServerResponse> {
        val id = request.pathVariable("id")
        val restTemplate = RestTemplate()
        val response = restTemplate.getForObject("http://localhost:8181/block/$id", String::class.java)

        return ServerResponse.ok().json().body(
            Mono.just(response!!)
        )
    }
}

테스트 하는 김에 RestTemplate 으로도 테스트 해봤습니다.

/v1/rest/{id} 를 호출하면 쓰레드 1개를 블록시키기 때문에 요청이 순차적으로 처리됩니다.


일반 브라우저와 시크릿 브라우저에서 동시에 호출해도 순서대로 처리되는 걸 볼 수 있습니다.


2. 무거운 연산을 수행하는 경우에는 어떻게 될까?

API 요청은 논블로킹으로 처리하는데 무거운 연산을 쓰레드가 직접 수행하는 경우에는 어떻게 되는지 확인해봤습니다.


2.1. Server Code

@Controller
class RouterHandler {
    val log: Logger = LoggerFactory.getLogger(RouterHandler::class.java)

    fun heavy(request: ServerRequest): Mono<ServerResponse> {
        val id = request.pathVariable("id")

        (0..1_000_000_000).forEach {
            if (it % 100_000_000 == 0) {
                log.info("Request [$id] for: $it")
            }
        }

        return ServerResponse.ok().json().body(
            Mono.just("heavy response $id")
        )
    }
}

for 문을 많이 돌면서 오래 걸리는 API 를 만들었습니다.


2.2. Log

위와 마찬가지로 쓰레드는 하나만 사용했습니다.

로그를 보면 알 수 있듯이 기존 요청을 처리하는 동안 대기했다가 순서대로 요청을 처리하는 것을 알 수 있습니다.


2.3. Response Time

id=1 인 요청은 5초만에 응답했지만 id=2 인 경우에는 앞의 연산 때문에 지연되어 9초나 걸린 것을 확인할 수 있습니다.


Conclusion

직접 테스트를 해보니 인터넷에서 알아본 것처럼 NIO 쓰레드를 사용하면 I/O 요청 시 쓰레드가 대기하지 않고 다른 일을 처리할 수 있었습니다.

하지만 실제로 쓰레드가 일을 해야하는 무거운 연산을 수행하는 경우에는 응답이 지연되는 결과를 얻었습니다.

흔히 웹플럭스를 사용하기 좋은 환경으로 무거운 연산이 적고 I/O 위주의 로직이 존재하는 환경을 이야기합니다.

DB 를 연동할 때도 R2DBC 나 NoSQL 처럼 Reactive 모델을 지원하지 않는 경우 블로킹 구간이 발생해서 사용할 수 없다 라고도 말합니다.

그동안은 막연하게 생각해오기만 했는데 실제로 테스트 해서 눈으로 확인해보니 이유를 알 수 있었습니다.

Overview

Java 에서 메서드를 호출 시 파라미터를 전달하는 방법에 대해 알아봅니다.

순서는 다음과 같이 진행합니다.

  1. Call by Value, Call by Reference 차이
  2. Java 에서의 파라미터 전달 방법
  3. JVM 메모리에 변수가 저장되는 위치
  4. 원시 타입 (Primitive Type) 전달
  5. 참조 타입 (Reference Type) 전달

1. Call by Value, Call by Reference

메서드를 호출할 때 파라미터를 전달하는 방법에는 두 가지가 있습니다.


1.1. Call by Value

Call by Value 는 메서드를 호출할 때 값을 넘겨주기 때문에 Pass by Value 라고도 부릅니다.

메서드를 호출하는 호출자 (Caller) 의 변수와 호출 당하는 수신자 (Callee) 의 파라미터는 복사된 서로 다른 변수입니다.

값만을 전달하기 때문에 수신자의 파라미터를 수정해도 호출자의 변수에는 아무런 영향이 없습니다.


1.2. Call by Reference

Call by Reference 는 참조 (주소) 를 직접 전달하며 Pass By Reference 라고도 부릅니다.

참조를 직접 넘기기 때문에 호출자의 변수와 수신자의 파라미터는 완전히 동일한 변수입니다.

메서드 내에서 파라미터를 수정하면 그대로 원본 변수에도 반영됩니다.


2. Java 에서의 파라미터 전달 방법

그럼 Java 에서는 어떤 방법을 사용할까요?

Java 로 개발을 해봤다면 메서드로 변수를 넘기고 거기서 값을 수정해본 경험이 있을 겁니다.

그래서, Call by Reference 라고 오해하기 쉽지만, Java 는 오직 Call by Value 로만 동작합니다.


3. JVM 메모리에 변수가 저장되는 위치

Java 의 Call by Value 에 대해 이해하기 위해선 먼저 변수 생성 시 메모리에 어떤 식으로 저장되는 지 알아야 합니다.

Java 에서 변수를 선언하면 Stack 영역에 할당됩니다.

원시 타입 (Primitive Type) 은 Stack 영역에 변수와 함께 저장되며

참조 타입 (Reference Type) 객체는 Heap 영역에 저장되고 Stack 영역에 있는 변수가 객체의 주소값을 갖고 있습니다.


그림으로 표현하면 이렇습니다.

원시 타입, 참조 타입을 생성할 때마다 동일한 방식으로 메모리에 할당됩니다.

이제 각 타입별로 파라미터를 넘겨줄 때 어떤 식으로 동작하는지 알아봅니다.


4. 원시 타입 (Primitive Type) 전달

원시 타입은 Stack 영역에 위치합니다

메서드 호출 시 넘겨받는 파라미터들도 원시 타입이라면 Stack 영역에 생성됩니다.

간단한 예시 코드와 함께 확인해봅니다.


public class PrimitiveTypeTest {

    @Test
    @DisplayName("Primitive Type 은 Stack 메모리에 저장되어서 변경해도 원본 변수에 영향이 없다")
    void test() {
        int a = 1;
        int b = 2;

        // Before
        assertEquals(a, 1);
        assertEquals(b, 2);

        modify(a, b);

        // After: modify(a, b) 호출 후에도 값이 변하지 않음
        assertEquals(a, 1);
        assertEquals(b, 2);
    }

    private void modify(int a, int b) {
        // 여기 있는 파라미터 a, b 는 이름만 같을 뿐 test() 에 있는 a, b 와 다른 변수
        a = 5;
        b = 10;
    }
}

위 코드에서 test() 의 변수 a, bmodify(a, b) 로 전달받은 파라미터 a, b 의 이름과 값은 같습니다.

하지만 다른 변수입니다.

modify(a, b) 를 호출하는 순간 Stack 영역에 새로운 변수 a, b 가 새로 생성되어 총 4 개의 변수가 존재합니다.


그림으로 보면 한눈에 이해가기 쉽습니다.

Stack 내부에 test()modify() 라는 영역이 나뉘어져 있고 거기에 동일한 이름을 가진 변수 a, b 가 존재합니다.

그래서 modify() 영역의 값을 바꿔도 test() 영역의 변수는 변화가 없습니다.

원시 타입의 전달은 값만 전달하는 Call by Value 로 동작합니다.


5. 참조 타입 (Reference Type) 전달

참조 타입은 원시 타입과는 조금 다릅니다.

변수 자체는 Stack 영역에 생성되지만 실제 객체는 Heap 영역에 위치합니다.

그리고 Stack 에 있는 변수가 Heap 에 있는 객체를 바라보고 있는 형태입니다.

마찬가지로 코드 예시와 함께 알아봅니다.


class User {
    public int age;

    public User(int age) {
        this.age = age;
    }
}

public class ReferenceTypeTest {

    @Test
    @DisplayName("Reference Type 은 주소값을 넘겨 받아서 같은 객체를 바라본다" +
                 "그래서 변경하면 원본 변수에도 영향이 있다")
    void test() {
        User a = new User(10);
        User b = new User(20);

        // Before
        assertEquals(a.age, 10);
        assertEquals(b.age, 20);

        modify(a, b);

        // After
        assertEquals(a.age, 11);
        assertEquals(b.age, 20);
    }

    private void modify(User a, User b) {
        // a, b 와 이름이 같고 같은 객체를 바라본다.
        // 하지만 test 에 있는 변수와 확실히 다른 변수다.

        // modify 의 a 와 test 의 a 는 같은 객체를 바라봐서 영향이 있음
        a.age++;

        // b 에 새로운 객체를 할당하면 가리키는 객체가 달라지고 원본에는 영향 없음
        b = new User(30);
        b.age++;
    }
}

원시 타입 코드와 마찬가지로 동일한 변수 a, b 가 존재합니다.

여기서 modify(a, b) 를 호출한 후에 a.age 의 값이 변경되었기 때문에 Call by Reference 로 파라미터를 넘겨주었다고 착각하기 쉽습니다.

하지만 Reference 자체를 전달하는 게 아니라 주소값만 전달해주고 modify() 에서 생긴 변수들이 주소값을 보고 객체를 같이 참조하고 있는 겁니다.

단계별 그림으로 확인해봅니다.


5.1. 처음 변수 선언 시 메모리 상태

원시 타입과는 다르게 변수만 Stack 영역에 생성되고 실제 객체는 Heap 영역에 생성됩니다.

각 변수는 Heap 영역에 있는 객체를 바라보고 있습니다.


5.2. modify(a, b) 호출 시점의 메모리 상태

넘겨받은 파라미터는 Stack 영역에 생성되고 넘겨받은 주소값을 똑같이 바라봅니다.


5.3. modify(a, b) 수행 직후 메모리 상태

test() 영역과 modify() 영역에 존재하는 a 라는 변수들은 같은 객체인 User01 을 바라보고 있기 때문에 객체를 공유합니다.

b 라는 변수는 서로 같은 객체인 User02 를 바라보고 있었지만 modify(a, b) 내부에서 새로운 객체를 생성해서 할당했기 때문에 User03 이라는 객체를 바라봅니다.

그래서 User03age 값을 변경해도 test() 에 있는 b 에는 아무런 변화가 없습니다.


5.4. test() 끝난 후 최종 메모리 상태

modify(a, b) 메서드를 빠져나오면 Stack 영역에 할당된 변수들은 사라집니다.

최종적으로 위와 같은 상태가 되며 User03 은 어떤 곳에서도 참조되고 있지 않기 때문에 나중에 Garbage Collector 에 의해 제거될 겁니다.


Conclusion

"결국 주소값을 넘기는 게 결국 Call by Reference 아닌가?" 라는 생각을 할 수도 있습니다.

하지만 Call by Reference 는 참조 자체를 넘기기 때문에 새로운 객체를 할당하면 원본 변수도 영향을 받습니다.

가장 큰 핵심은 호출자 변수와 수신자 파라미터는 Stack 영역 내에서 각각 존재하는 다른 변수다 라고 생각합니다.


Reference

Overview

Java8 에서는 Collection 을 다루기 위해 Stream 을 사용합니다.

Kotlin 은 Collections 자체에서 filter, map 등의 여러 가지 API 를 제공하기 때문에 매번 .streams() 를 붙이지 않아도 사용 가능하다는 장점이 있습니다.

하지만 비슷해보이는 두 코드 사이에는 큰 차이점이 하나 있는데요.

바로 Lazy Evaluation 입니다.


1. Lazy Evaluation

Lazy Evaluation 이란 쉽게 말해서 필요하지 않은 연산을 하지 않는다 라고 이해할 수 있습니다.

어떤 로직이나 연산을 그 즉시 수행하지 않고 실제로 사용되기 전까지 미루는 걸 의미합니다.

반대의 의미인 Eager Evaluation 은 연산이 있으면 그때그때 수행하는 것을 의미합니다.

Java Stream 의 예시와 함께 보면 쉽게 이해할 수 있습니다.


2. Java Streams

Stream.of(1, 2, 3, 4, 5, 6)
        .filter(e -> {
            System.out.println("filter: " + e);
            return e < 3;
        })
        .map(e -> {
            System.out.println("map: " + e);
            return e * e;
        })
        .anyMatch(e -> {
            System.out.println("anyMatch: " + e);
            return e > 2;
        });
  • (1, 2, 3, 4, 5, 6) 의 숫자 묶음 존재
  • filter: 3 보다 작은 수만 추출
  • map: 제곱으로 변환
  • anyMatch: 2 보다 큰 수가 있는지 확인

조금 지저분해 보이지만 이해를 돕기 위해 연산 중간중간마다 print 문을 추가했습니다.

위 코드를 있는 그대로 나열하면 6 개의 숫자 묶음에서 3 보다 작은 수만 뽑아서 제곱한 뒤 그 중에서 2 보다 큰 수가 있는지 확인하는 겁니다.

위 코드의 결과값은 아래와 같습니다.


filter: 1
map: 1
anyMatch: 1
filter: 2
map: 2
anyMatch: 4

총 6 개의 숫자가 있었지만 실제로 연산된 것은 두 개 뿐입니다.

anyMatch 조건에 해당하는 숫자가 나오자 이후 숫자들은 볼 필요가 없다고 판단하여 전부 생략했습니다.

이게 바로 Lazy Evaluation (필요하지 않는 연산은 하지 않는다) 입니다.


3. Kotlin Collections

listOf(1, 2, 3, 4, 5, 6)
    .filter {
        println("filter: $it")
        it < 3
    }
    .map {
        println("map: $it")
        it * it
    }
    .any {
        println("any: $it")
        it > 2
    }

그럼 이제 같은 로직을 Kotlin 으로 작성해보았습니다.

위 로직을 실행하며 어떻게 될까요?


filter: 1
filter: 2
filter: 3
filter: 4
filter: 5
filter: 6
map: 1
map: 2
any: 1
any: 4

한 눈에 봐도 결과가 다른 것을 알 수 있습니다.

Kotlin Collections 는 매 연산마다 모든 원소에 대해서 수행합니다.

데이터의 양이 많으면 많을수록 성능 차이는 더욱 벌어질 겁니다.


4. Kotlin Sequences

Kotlin 에서도 Lazy Evaluation 을 수행하게 하는 방법이 있습니다.

바로 Sequence 를 사용하는 겁니다.

위 코드에서 한줄만 추가하면 됩니다.


listOf(1, 2, 3, 4, 5, 6)
    .asSequence()   // 이 부분을 추가해서 Sequence 로 변경
    .filter {
        println("filter: $it")
        it < 3
    }
    .map {
        println("map: $it")
        it * it
    }
    .any {
        println("any: $it")
        it > 2
    }

Collection 에서 수행하지 말고 asSequence() 를 사용해서 Sequence 로 변경한 뒤에 연산을 수행하면 됩니다.

위 코드의 결과는 다음과 같습니다.


filter: 1
map: 1
any: 1
filter: 2
map: 2
any: 4

이제 불필요한 연산을 하지 않는 것을 볼 수 있습니다.


5. 왜 그럴까?

Lazy Evaluation 에 대해 좀더 설명하면 중간 단계 (intermediate step) 의 결과를 바로 리턴하냐 아니냐의 차이에 있습니다.

Kotlin Collections 은 매 연산을 수행할 때마다 결과 Collection 을 반환합니다.

이에 비해 Kotlin Sequences 또는 Java Streams 는 종료 (terminate) 함수가 호출되기 전까지는 연산을 수행하지 않습니다.

위에서 사용한 any() 함수 또한 종료 함수입니다.

이 차이를 쉽게 알려면 종료 함수가 없는 Sequences 를 사용해보면 됩니다.


5.1. Kotlin Sequences 의 Lazy Evaluation 확인

val sequence: Sequence<Int> = listOf(1, 2, 3)
    .asSequence()
    .filter {
        println("filter: $it")
        it < 2
    }
    .map {
        println("map: $it")
        it * it
    }

println("종료함수를 아직 호출하지 않음")
sequence.toList()

Sequences 는 매 함수의 결과로 Sequence 를 반환합니다.

그래서 최종적으로 Collection 으로 변환하려면 다시 toList() 를 호출해야 합니다.

toList() 역시 종료함수라서 호출되는 순간에 모든 연산이 수행됩니다.

Java Streams 의 collect(Collectors.toList()) 와 같다고 생각하시면 됩니다.


종료함수를 아직 호출하지 않음
filter: 1
map: 1
filter: 2
filter: 3

5.2. Kotlin Collections 의 Eager Evaluation 확인

val list: List<Int> = listOf(1, 2, 3)
    .filter {
        println("filter: $it")
        it < 2
    }
    .map {
        println("map: $it")
        it * it
    }

println("Collection 은 매번 List 를 반환하기 때문에 이미 연산됨")

Sequences 와 다르게 Collections 은 매 함수의 결과로 Collection 을 반환합니다.

사실상 매 함수가 모두 종료 함수라고 볼 수 있으며, 그래서 결과를 다음 단계로 넘기지 못하고 매번 전부 연산을 하는겁니다.


filter: 1
filter: 2
filter: 3
map: 1
Collection 은 매번 List 를 반환하기 때문에 이미 연산됨

Conclusion

이제 Kotlin Sequences 는 Lazy Evaluation 때문에 불필요한 연산을 생략한다는 점을 알았습니다.

하지만 Sequences 가 항상 좋은 것은 아닙니다.

Sequences by Kotlin Reference 를 보면 다음과 같은 문구가 있습니다.

So, the sequences let you avoid building results of intermediate steps, therefore improving the performance of the whole collection processing chain. However, the lazy nature of sequences adds some overhead which may be significant when processing smaller collections or doing simpler computations. Hence, you should consider both Sequence and Iterable and decide which one is better for your case.


요약하자면 Sequences 는 중간 단계의 결과를 생략하기 때문에 성능 향상이 되지만, 오버헤드가 있기 때문에 데이터가 적거나 연산이 단순한 컬렉션을 처리할 때는 오히려 안좋을 수가 있다고 합니다.

그러므로 각자 상황에 맞춰 적절한 방법을 선택하는게 가장 좋습니다.


Reference

Overview

이미지는 HTML 에서 넣거나 CSS 에서 넣을 수 있습니다.

둘다 이미지가 노출된다는 사실은 같으나 약간의 차이점이 있습니다.


1. HTML 에서 태그 사용

<img src="/temp/image">
  • <img> 태그를 사용하면 이미지 업로드 실패 시 "깨진 이미지 아이콘" 과 "alt" 가 함께 노출된다.
  • SEO 나 성능 등에서 이점이 많다.

2. CSS 에서 background-image 속성 사용

background-image: url(image.jpg);
  • 순전히 디자인 목적이라면 CSS 를 이용해도 된다.
  • CSS 는 이미지 사이즈가 큰 경우 로딩하는데 시간이 더 걸린다
  • 이미지 업로드 실패 시 아무것도 노출되지 않는다.

3. 각각 언제 사용하는게 좋을까?

만약 배경 이미지가 있어도 그만 없어도 문제 없는 상황이라면 실패했을 경우 아예 이미지가 노출되지 않는 편이 좋을 수도 있습니다.

이미지가 없어도 컨텐츠를 이해하는 데 무리가 없기 때문에 사용자에게 굳이 에러 상황을 알려줄 필요가 없습니다.

img 태그는 이미지가 컨텐츠와 관련이 깊고 검색 엔진에 노출이 필요한 경우에 사용하고 background-image 속성은 순수하게 디자인을 위한 목적인 경우에 사용합니다.

제 개인적인 생각으로는 웹 접근성도 고려해서 웬만하면 HTML 태그를 사용하는 게 좋다고 생각합니다.


Reference

'Language > HTML and CSS' 카테고리의 다른 글

Textarea 내부에 HTML Tag 를 넣고 싶을 때  (2) 2021.05.21

Overview

지난 포스팅에서는 AWS 에서 EC2 인스턴스를 생성하고 Spring Boot 서버를 띄워 외부에서 요청하는 것까지 해봤습니다.

이번에는 데이터베이스 연동을 위해 RDS 인스턴스를 생성하고 이전에 만든 EC2 와 연동하는 것까지 진행해봅니다.

다음과 같은 순서로 진행됩니다.

  • RDS 인스턴스 생성
  • 보안 그룹 설정
  • RDS 접속 테스트
  • 파라미터 그룹 설정

1. RDS 인스턴스 생성

EC2 서버에 DB 연동을 하기 위한 RDS 인스턴스를 생성해봅니다.


1.1. RDS 메뉴로 이동

EC2 와 마찬가지로 검색하면 쉽게 찾을 수 있습니다.


1.2. 데이터베이스 생성

대시보드에서 선택해도 되고 아니면 이렇게 메뉴에 진입해서 직접 데이터베이스 생성을 눌러도 됩니다.


1.3. DB 종류 선택

저는 MySQL 을 선택했습니다.


1.4. DB 설정 입력

데이터베이스 이름, 마스터 이름, 비밀번호를 입력합니다.

실제 DB 에 접근할 때 사용할 정보이므로 신중하게 입력해야 합니다.


1.5. 스토리지 설정

사실 프리 티어일 때는 선택권이 거의 없습니다.

그냥 대부분 기본 설정을 사용하면 되는데 "스토리지 자동 조정" 저 부분 체크만 해제해주시면 됩니다.

안그러면 개발을 진행하다 임계값이 초과되면 자동으로 스토리지가 늘어나서 과금될 가능성이 있습니다.


1.6. 보안 그룹 설정

퍼블릭 액세스는 "예" 로 지정해줍니다.

"아니요"를 선택하면 퍼블릭 IP 주소가 할당되지 않기 때문에 외부에서 접속할 수 없습니다.

그리고 EC2 와 마찬가지로 보안 그룹을 설정하거나 새로 생성할 수 있습니다.

기존에 사용 중인게 있다면 "기존 항목 선택" 을 누르고 보안 그룹을 추가하면 됩니다.

여기서는 "새로 생성" 으로 해보겠습니다.


1.7. 추가 구성

추가 구성에서 데이터베이스 이름을 적고 자동 백업을 비활성화 합니다.

어차피 개발용이라 데이터가 중요하지 않아서 자동 백업을 비활성화 했지만, 만약 지워져도 복구해야 하는 데이터라면 당연히 백업을 활성화 해야 합니다.

여기까지 진행했으면 "데이터베이스 생성" 을 눌러서 생성을 완료합니다.

생성 완료까지는 시간이 좀 걸립니다.


2. RDS 보안 그룹 설정

RDS 인스턴스를 생성할 때 보안 그룹을 새로 생성한 걸 기억하실 겁니다.

데이터베이스는 서버에서 접근 가능해야 하기 때문에 보안 그룹 설정이 추가로 필요합니다.

여기서 서버란 곧 "EC2 인스턴스의 탄력적 IP" 를 의미합니다.

탄력적 IP 를 직접 넣는 대신 손쉽게 설정할 수 있는 방법이 있습니다.


2.1. 현재 보안 그룹 확인

RDS 인스턴스 정보로 들어가면 하단에서 보안 그룹 규칙을 확인할 수 있습니다.

친절하게 제가 접속할 수 있게 로컬 IP 만 인바운드 규칙에 추가해두었네요.

아웃바운드는 EC2 와 마찬가지로 모든 트래픽에 대해 열어두었습니다.

RDS 보안 그룹은 EC2 보안 그룹이랑 별도로 관리하지 않고 같은 곳에서 관리합니다.

따라서, 보안 그룹을 편집하려면 EC2 대시보드로 이동하거나 저 보안 그룹 이름을 클릭해서 페이지를 열어야 합니다.


2.2. 보안 그룹 리스트에서 EC2 보안 그룹 ID 복사

EC2 대시보드의 보안 그룹 메뉴로 이동하면 지금까지 만들었던 보안 그룹 리스트를 확인할 수 있습니다.

이전에 만들었던 MySecurityGroup 도 있을 텐데 보안 그룹 ID 를 복사해줍니다.


2.3. RDS 인바운드 규칙에 EC2 보안 그룹 ID 입력

유형을 MYSQL/Aurora 로 선택하고 사용자 지정으로 EC2 보안 그룹의 ID 를 추가하고 저장합니다.


3. RDS 접속 테스트

보안 그룹 설정까지 했다면 실제로 연결이 잘 되는지 다음 두 가지를 테스트 해봅시다.

  • 로컬 PC 에서 접속
  • EC2 인스턴스 서버에서 접속

3.1. 엔드포인트와 포트 확인

RDS 인스턴스 정보에서 엔드포인트와 포트를 확인합니다.


3.2. 로컬 PC (Sequel Ace) 에서 접속

Database GUI 툴은 여러 가지가 있으므로 각자 편한걸로 접근하시면 됩니다.

저는 Sequel Ace 에서 접속을 시도해보겠습니다.

아래 세가지만 입력 후 Connect 를 누르면 접속 가능합니다.

  • Host: RDS 엔드포인트
  • Username: RDS 생성 시 입력했던 정보
  • Password: RDS 생성 시 입력했던 정보

RDS 생성 시에 지정했던 초기 데이터베이스 이름도 확인할 수 있네요.


3.3. EC2 에서 접속

먼저 EC2 에 접속해줍니다.

우선 MySQL 을 먼저 설치해줘야 합니다.

# Ubuntu 에서 MySQL 설치
$ sudo apt-get update
$ sudo apt-get install mysql-server

그리고 mysql 명령어로 접속을 시도합니다.

권한 문제가 있으면 sudo 로 재시도 합니다.

# mysql -u {유저이름} -p --host {엔드포인트}
$ mysql -u admin -p --host my-rds-instance.ciweuig9oiko.ap-northeast-2.rds.amazonaws.com

접속이 잘 된것을 확인할 수 있습니다.


4. RDS 파라미터 그룹 설정

이제 추가적으로 파라미터 그룹 설정을 해줍시다.

RDS 는 다음 세가지 설정을 필수로 해줘야 합니다.

  • Time Zone
  • Character Set
  • Max Connection

4.1. 파라미터 그룹 페이지로 이동

먼저 파라미터 그룹 메뉴를 찾아 이동합니다.


4.2. 파라미터 그룹 생성

파라미터 그룹 패밀리는 RDS DB 와 맞춰서 선택하고 이름과 설명만 입력해서 생성합니다.

생성한 파라미터 그룹을 클릭해서 파라미터 편집을 누릅니다.


4.3. Time Zone

타임존을 Asia/Seoul 로 변경합니다.


4.4. Character Set

character_set 으로 검색해서 나온 6 개의 값을 전부 utf8mb4 로 변경해줍니다.

원래는 utf8 을 많이 사용했으나 utf8mb4 가 이모지까지 지원하기 때문에 더 많이 사용되는 추세입니다.


collation 으로 검색해서 나온 값들도 전부 utf8mb4_general_ci 로 변경해줍니다.


4.5. Max Connection

마지막으로 max_connections 을 수정해줍니다.

이 값은 원래 RDS 인스턴스 사양에 의해 결정됩니다.


4.6. 최종 변경사항 정리

저장하기 전에 미리보기를 하면 마지막으로 변경 사항들을 확인할 수 있습니다.


4.7. RDS 파라미터 그룹 변경

RDS 인스턴스로 이동해서 "수정" 버튼을 클릭합니다.

그리고 "추가 구성" 탭으로 이동해서 DB 파라미터 그룹을 변경해줍니다.


RDS 는 인스턴스를 수정할 때 예약 적용과 즉시 적용을 선택할 수 있는데 초기 설정이므로 "즉시 적용" 을 선택해줍시다.


Conclusion

이렇게 해서 EC2 에 인스턴스 연동까지 진행해봤습니다.

Spring Boot 에서 DB 에 연동하고 싶다면 로컬에서 접속한 것처럼 세팅해주고 진행하면 됩니다.

이제 DB 연동한 서버를 외부에 노출하는 것까지 가능합니다.

+ Recent posts