해당 게시글은 내용 정리 목적으로 작성되었습니다. 틀린 내용이 있다면 언제든지 말씀해 주세요

이번 게시글에서는 AWS EC2에 젠킨스를 설치하는 방법에 대해서 소개해보겠다.

1. Amazon EC2 생성

1. Amazon Machine Image(AMI) : Amazon Linux 2023 AMI
(리눅스 버전에 따라서 젠킨스 설치가 달라지니 정확한 설치를 위해서 해당 AMI를 선택해야한다)

2. 인스턴스 유형 : t3.medium (선택)
3. 키페어 생성
4. 보안 그룹 생성 : 8080 포트와 22포트를 열어두어야함
5. 스토리지 8GB

2. EC2 SSH 접속

키페어 생성 위치로 이동한 다음 SSH를 통해서 EC2로 이동한다

cd {key-pair.pem 위치}
예시 : ssh -i "jenkins-key.pem" ec2-user@54.180.202.88

3. EC2 jenkins 설치

# 1. Jenkins 설치 전에 시스템 패키지를 최신 상태로 갱신
$ sudo yum update -y

# 2. Jenkins 패키지 저장소를 시스템의 YUM 레포지토리 목록에 추가
$ sudo wget -O /etc/yum.repos.d/jenkins.repo \
    https://pkg.jenkins.io/redhat-stable/jenkins.repo

# 3. Jenkins-CI의 GPG 키 가져오기
$ sudo rpm --import https://pkg.jenkins.io/redhat-stable/jenkins.io-2023.key
# rpm --import 명령은 패키지를 설치할 때 해당 패키지가 신뢰할 수 있는 소스에서 온 것임을 확인하기 위해 사용되는 GPG 키를 가져온다

# 4. YUM 패키지 업그레이드
$ sudo yum upgrade

# 5. Java 설치 (Amazon Linux 2023)
$ sudo dnf install java-17-amazon-corretto -y

# 6. Jenkins 설치
$ sudo yum install jenkins -y

# 7. Jenkins 서비스 자동 시작 설정
$ sudo systemctl enable jenkins

# 8. Jenkins 서비스 시작
$ sudo systemctl start jenkins

9. Jenkins 상태 확인
$ sudo systemctl status jenkins

4. EC2에 Git 설치

# 패키지 목록 업데이트
$ sudo dnf update -y
# Git 설치
$ sudo dnf install git -y
# 설치 확인
$ git --version

5. EC2에 Docker 설치

# Docker 설치
$ sudo dnf install docker -y

# Docker 서비스 시작 및 자동 시작 설정
$ sudo systemctl start docker
$ sudo systemctl enable docker

# Docker 버전 확인
$ docker --version

# Jenkins 사용자를 docker 그룹에 추가
$ sudo usermod -aG docker jenkins

# Jenkins 재시작
$ sudo systemctl restart jenkins

6. 젠킨스 잠금 해제 및 Get Started Customize Jenkins

브라우저에 ec2 public ip, 포트 8080를 입력해서 젠킨스 서버에 접근한다
ex) http://xxx.xxxx.xxxx.xxxx:8080

아래 경로의 initialAdminPassword 파일에 접근해서 비밀번호를 확인한 후 입력한다

$ sudo cat /var/lib/jenkins/secrets/initialAdminPassword

  • Install suggeseted plugins를 선택한다
  • 유용한 젠킨스 플러그인을 설치해준다.
  • 어드민 계정 정보를 입력하고 젠킨스 접근 URL을 입력한다

7. 젠킨스 플러그인 설치

  • Jenkins 관리 > Plugins > Available plugins
    • Git Parameter (선택) : 배포 전 git branch 또는 Tag를 선택할 수 있다. 
    • AWS Credentials

8. Credentials 생성

  • github과 aws ecr credentials를 생성해야한다

1) github credentials 생성

Github Personal access tokens 생성

  • Github 사이트로 이동해서 토큰을 생성한다
  • Settings / Developer Settings / Personal access tokens (classic) / token 생성
  • github에서 repository 관련 권한을 허용한 상태로 만든다.

Github Credentials 등록

  • jenkins 관리 > Credentials > System > Glabal credentials > Add Credentials 으로 이동

  • Username with password Kind 선택
  • Scope는 Global로 선택
  • username에 github username을 입력한다
  • password에 github에서 생성한 토큰을 입력한다
  • ID는 파이프라인에서 참조할 수 있는 값이므로 의미 있는 이름으로 입력한다 (ex : github-credentials)

2) AWS ECR 배포 credentials 등록

  • AWS에서 IAM 정책, IAM 사용자를 미리 만들어야한다

IAM 정책 생성

https://docs.aws.amazon.com/ko_kr/AmazonECR/latest/userguide/image-push-iam.html
  • 위 링크에서 모든 리포지토리로 푸시하는 데 필요한 권한 JSON을 복사한다
  • 정책 생성으로 이동 후 JSON 값을 입력한다 

  • 아래 정책 JSON 참조 
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ecr:CompleteLayerUpload",
                "ecr:GetAuthorizationToken",
                "ecr:UploadLayerPart",
                "ecr:InitiateLayerUpload",
                "ecr:BatchCheckLayerAvailability",
                "ecr:PutImage"
            ],
            "Resource": "*"
        }
    ]
}

IAM 사용자 생성

  • 직접 정책 연결을 선택하고 미리 만들어둔 정책을 연결한다 

  • 사용자를 생성하고 사용자 상세에 들어간 다음 액세스키 만들기를 눌러서 키를 다운로드 받는다.

AWS ECR  Deploy Credentials 등록

  • jenkins 관리 > Credentials > System > Glabal credentials > Add Credentials 으로 이동

  • Kind를 AWS Credentials로 선택한다
  • Scope는 Glabal
  • ID는 파이프라인에서 참조하기 위해 명확하게 지어준다.
  • IAM 사용자의 액세스키인 Access Key ID와 Secret Access Key를 입력한다.

  • 위와 같이 두개의 Credentials가 생성되었는지 확인한다. 

9. AWS ECR 생성

  • Amazon ECR > 프라이빗 레지스트리 > 리포지토리 > 리포지토리 생성으로 이동한다
  • ECR Repository를 생성했으면 URI를 복사해둔다.
    • 파이프라인 작성 시 필요하다

10. 젠킨스 파이프라인 생성

  • 새로운 아이템 생성을 클릭하고 아이템 이름을 입력하고 파이프라인을 선택한 다음 OK를 누른다

  • 파이프 라인을 입력한다

파이프라인

pipeline {
    agent any
    options {
        timeout(time: 1, unit: 'HOURS')
    }

   environment {
        TIME_ZONE = 'Asia/Seoul'
        
        // GitHub
        GIT_TARGET_BRANCH = '배포할 브랜치 명'
        GIT_REPOSITORY_URL = 'Github repository URL'
        GIT_CREDENTIONALS_ID = 'Jenkins Github Credentials Id'


        // AWS ECR
        AWS_ECR_CREDENTIAL_ID = 'Jenkins AWS ECR Credentials Id'
        AWS_ECR_URI = 'ECR URI'
        AWS_ECR_IMAGE_NAME = 'ECR Image Name'
        AWS_REGION = 'ECR Region'
        
    }

    stages {
        stage('init') {
            steps {
                echo 'init stage'
                deleteDir()
            }
        }
        
        stage('Cloning Repository') {
            steps {
                echo 'Cloning Repository'
                git branch: "${GIT_TARGET_BRANCH}",
                    credentialsId: "${GIT_CREDENTIONALS_ID}",
                    url: "${GIT_REPOSITORY_URL}"
            }
        }
        
        stage('Build Gradle') {
            steps {
                echo 'Build Gradle'
                dir('.') {
                    sh '''
                        pwd
                        chmod +x ./gradlew
                        ./gradlew build --exclude-task test
                    '''
                }
            }
        }

        stage('Build Docker Image') {
            steps {
                script {
                    sh '''
                        docker build -t ${AWS_ECR_IMAGE_NAME} .
                        docker tag ${AWS_ECR_IMAGE_NAME} ${AWS_ECR_URI}/${AWS_ECR_IMAGE_NAME}:${BUILD_NUMBER}
                    '''
                }
            }
        }
        
        stage('Push to ECR') {
            steps {
              withCredentials([[$class: 'AmazonWebServicesCredentialsBinding', credentialsId: "${AWS_ECR_CREDENTIAL_ID}"]]) {
                    script {
                        sh '''
                        aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${AWS_ECR_URI}
                        docker push ${AWS_ECR_URI}/${AWS_ECR_IMAGE_NAME}:${BUILD_NUMBER}
                        
                        '''
                    }
                }
            }
        }
        
        stage('Clean Up Docker Images on Jenkins Server') {
            steps {
                echo 'Cleaning up unused Docker images on Jenkins server'
                sh "docker image prune -f --all"
            }
        }
    }

    post {
        success {
            echo 'Pipeline succeeded'
        }
        failure {
            echo 'Pipeline failed'
        }
    }
}
  • 파이프라인은 아래와 같이 진행된다.
  1. init 
    이전에 작업했던 내용들을 모두 초기화 한다
  2. Cloning Repository
    Github에서 Repository를 Clone 한다.
  3. Build Gradle
    소스 코드를 Gradle Build 한다
  4. Build Docker Image
    Docker Image Build 한다 (Dockerfile이 존재해야함)
  5. Push to ECR
    Docker Image를 AWS ECR로 Push한다
  6. Clean Up Docker Images on Jenkins Server
    Build한 Docker Image를 지운다.

여기서 중요한 것은 environment에 정의된 변수 값을 정확하게 입력해주어야 한다.

저장 후 지금 빌드를 누르게 되면 아래와 같이 성공한 것을 볼 수 있다.

  • 추천하는 방법은 파이프라인의 Stage를 하나씩 입력해서 한번씩 빌드해보면서 성공여부를 확인하는 것이다.

11. 젠킨스 시간 설정 (선택)

  • 젠킨스 시간은 기본적으로 UTC로 설정되어 있다. 한국 시간으로 설정하는 방법을 알아보자
# 현재 타임존 확인
$ timedatectl

# Asia/Seoul로 타임존을 변경한다
$ sudo timedatectl set-timezone Asia/Seoul

# 변경되었는지 확인
$ timedatectl

# 젠킨스 재시작
$ sudo systemctl restart jenkins
  • 젠킨스 관리 > System Information으로 이동하여 타임존이 Asia/Seoul으로 변경되었는지 확인한다 

12. 파이프라인 Gradle Build 시 전처리 작업 수행하기 (선택)

  • Build 하기 전 application.yml 이나 다른 설정 파일을 소스코드에 복사하는 경우가 있다.
    ....
        stage('Gradle Build before process') {
            steps {
                echo 'Gradle Build before process'
                script {
                    sh '''
                    mkdir -p ${WORKSPACE}/src/main/resources
                    sudo cp ${APPLICATION_YML} ${WORKSPACE}/src/main/resources/application.yml
                    '''
                }
            }
        }      
    ....
  • 위와 같이 Gradle Build 전 Stage를 추가한다.
  • ${APPLICATION_YML}는 파일이 위치한 전체 경로이고, ${WORKSPACE}는 소스코드 경로이다.
  • ${APPLICATION_YML}는 직접 environment에 추가해주어야한다   
  • 젠킨스가 sudo 명령어를 실행하기 위해서 권한을 추가한다
# sudo vi /etc/sudoers

적당한 곳에 아래 텍스트 추가
jenkins ALL=(ALL) NOPASSWD: ALL
읽기 전용 파일이므로 wq! 로 강제저장하고 종료

13. 원하는 Git Branch를 선택해서 배포하기 (선택)

  • Git Parameter 플러그인을 설치하고 파이프라인으로 이동한다

  • 빌드 매개변수를 활성화하고 Git Parameter를 선택한다.
  • Name에 브랜치 명에 접근할 수 있는 변수명을 입력한다 (중요)
  • Branch Filter는 정확히 브랜치 명만 가져올 수 있도록 필터링을 설정한다 origin/(.*)
  • 밑줄 친 속성 이외에도 원하는 값을 입력하면 된다.
  • 설정이 완료되었다면 파이프라인을 수정한다
   environment {
        ...
        GIT_TARGET_BRANCH = "${GIT_BRANCH_PARAMETER}"
        ...
    }
  • GIT_TARGET_BRANCH에 직접 브랜치 명을 입력했던 것을 GIT_Parameter name에 입력했던 값으로 변경한다. 
  • 브랜치 목록은 한번 배포한 다음에 적용되는 것으로 보인다. default 브랜치로 한번 배포한 다음 브랜치 목록이 잘 노출되는지 확인하자

  • 위와 같이 지금 빌드 버튼이 파라미터와 함께 빌드로 변경된 것을 볼 수 있다.
  • 브랜치를 선택한 다음 빌드하면 원하는 브랜치로 빌드가 되는 것을 볼 수 있다.
  • 마지막으로 AWS ECR에 Push 되었는지 확인해보자

  • 다음 글은 ECR Push 후 ECS에 배포하는 작업을 포스팅해 볼 예정입니다.
  • 도움이 되었다면 공감 한번씩 눌러주세요!
GC(Garbage Collection, 가비지 컬렉션)는 JVM(Java Virtual Machine)에서 메모리 관리를 자동화하는 메커니즘으로, 더 이상 참조되지 않는 객체를 자동으로 식별하고 메모리에서 제거하여 메모리 누수를 방지한다.

JDK의 버전이 올라가면서 GC(Garbage Collection) 방식은 지속적으로 개선되어 왔다. 새로운 GC 알고리즘이 도입되고 기존의 GC 알고리즘이 최적화되었지만, 기본적인 Major GC와 Minor GC의 매커니즘 자체는 근본적으로 변경되지는 않은 듯 하다.

GC에 대한 기초적인 내용과 Major GC와 Minor GC 매커니즘에 대해서 공부해보자

GC를 알아보기 전에 JVM 메모리 구조에 대해서 간단하게 알아보자 

1. JVM (Java Virtual Machine) 메모리 구조

JVM 메모리는 크게 메소드 영역, , 스택, PC Registers, 네이티브 메소드 스택 영역으로 나누어진다   

메소드 영역 (Method Area)

메소드 영역은 클래스 구조(메타데이터), 메소드, 필드 정보, 메소드의 바이트코드, 그리고 상수 풀(Constant Pool) 등을 저장하는 영역이다. 이 영역은 클래스 로딩 시 필요한 메모리를 할당하며, JVM 시작 시 생성되어 프로그램이 종료될 때까지 유지된다.

힙 (Heap)

힙 영역은 자바 애플리케이션에서 런타임 시 동적으로 생성된 객체들이 주로 저장되는 메모리 영역이다. new 키워드로 생성된 인스턴스나 배열의 객체가 저장된다. 대부분의 GC는 해당 영역에서 발생한다

스택 (Stack)

스택 영역은 각 스레드마다 생성되는 메모리 공간으로, 메소드 호출 시 생성되는 프레임(프레임에는 메소드의 매개변수, 로컬 변수, 중간 계산 결과 등이 저장됨)을 저장한다. 스택은 메소드 호출이 끝나면 프레임이 제거되며, LIFO(Last In, First Out) 방식으로 작동한다. 스택 영역은 스레드별로 독립적이며, 주로 메소드 호출과 관련된 임시 데이터를 저장한다.

PC  레지스터

PC 레지스터는 JVM이 현재 실행 중인 명령어의 주소를 저장하는 작은 메모리 공간이다. 각 스레드는 자신만의 PC 레지스터를 가지고 있으며, 스레드가 어느 명령어를 실행 중인지 추적하는 데 사용된다.

네이티브 메소드 스택 (Native Method Stacks)

자바 외 언어로 작성된 네이티브 코드를 위한 메모리 영역이다

클래스 로더 (Class Loader)

클래스 로더는 자바 클래스 파일을 JVM으로 로딩하고, JVM이 실행할 수 있도록 준비하는 역할을 담당한다. 자바는 런타임 시에 필요한 클래스를 동적으로 로딩하며, 클래스 로더는 이 과정을 처리하는 컴포넌트이다.

클래스 로더의 주요 기능은 다음과 같다

  • 클래스 로딩
    • 클래스 파일(.class)을 JVM 메모리 구조 중 메소드 영역(Method Area)으로 로드한다. 여기서 클래스의 구조, 메소드 정보, 필드 정보, 상수 풀 등이 JVM의 메모리 공간에 저장된다.
  • 클래스 링크
    • 로드된 클래스 파일을 JVM의 메모리 구조에 맞게 링크하는 과정이다. 링크 단계는 세 가지 하위 단계로 나뉜다.
    • 검증
      • 클래스 파일이 JVM의 명세에 맞는지 검증한다. 이 과정에서 클래스 파일의 포맷, 바이트코드의 유효성 등이 확인된다.
    • 준비
      • 클래스의 정적 필드를 메모리에 할당하고 기본값으로 초기화한다
    • 분석
      • 상수 풀(Constant Pool) 내의 심볼릭 레퍼런스를 실제 메모리 주소로 변경합니다.
  • 클래스 초기화
    • 클래스 로딩의 마지막 단계로, 정적 초기화 블록(static block)과 정적 필드를 초기화하는 단계이다.
    • 이 단계에서 클래스가 실제로 실행될 준비가 된다.

Execution Engine

Execution Engine은 JVM에서 로드된 클래스 파일의 바이트코드를 실제로 실행하는 컴포넌트이다. Execution Engine 은 자바 바이트코드를 기계어로 변환하여 CPU에서 실행할 수 있도록 한다.

바이트 코드 해석 시 인터프리터, JIT 컴파일러를 사용하며 각 해석기에 대한 내용은 다음과 같다

인터프리터 (Interpreter)

바이트코드를 직접 해석하여 실행한다. 바이트코드를 한 줄씩 읽어와서 실행하는 방식으로, 메모리 사용이 적지만 반복적인 코드 실행에서는 성능이 떨어질 수 있다.

JIT 컴파일러 (Just-In-Time Compiler)

반복적으로 실행되는 바이트코드를 기계어로 컴파일하여 성능을 향상시키는 역할을 한다

애플리케이션이 시작될 때, 인터프리터가 바이트코드를 즉시 실행하여 초기 실행을 처리하고, JIT 컴파일러는 애플리케이션이 실행되는 동안, 자주 실행되는 코드(핫스팟 코드)를 감지하고 이를 기계어로 컴파일하여 성능을 향상시킨다

클래스 로더는 자바 클래스 파일을 메모리에 로드하고, 이를 Execution Engine이 이해할 수 있는 형태로 준비한다. Execution Engine은 Class Loader에 의해 메모리에 로드된 클래스의 바이트코드를 실제로 실행하여 프로그램의 동작을 구현한다.

JDK 구조 (위키백과 참고)

 

2. Heap 영역의 Young Generation과 Old Generation

Heap 영역은 객체가 할당되는 메모리 영역으로, GC의 주요 대상이다
힙은 크게 Young Generation Old Generation으로 나뉜다.

  • Young Generation: 새로 생성된 객체가 할당되는 영역이다. 이 영역은 다시 Eden, Survivor1, Survivor2로 나뉜다
    대부분의 객체가 Young Generation에서 생성되고 참조되지 않으면 제거된다. Young Generation에 대한 가비지 컬랙션을 Minor GC라고 부른다
    • Eden Space : 새롭게 생성된 객체가 최초 할당되는 영역이다.
    • Survivor Spaces : 두 개의 서바이버 영역(S1, S2)이 있으며, 에덴 영역에서 살아남은 객체가 이 영역으로 이동한다. 이 영역은 번갈아 가며 사용된다
  • Old Generation: Young Generation에서 살아남은 객체가 옮겨지는 영역으로, 오래된 객체가 저장된다
    • Young Generation에서 오래 살아남은 객체가 이동하는 영역이다. 이 영역에 있는 객체들은 수명이 길거나 프로그램이 종료될 때까지 남아 있는다.
    • Old Generation 에 대한 가비지 컬랙션을 Major GC라고 부른다
  • Permanent Generation: 클래스 메타데이터와 같은 JVM의 메타데이터를 저장하는 영역이다. JDK 8부터 Metaspace로 대체되었다.

3. GC의 기본 동작 방식

1) 객체 할당

  • 객체가 생성될 때 JVM은 힙 메모리의 Young Generation 내의 Eden 영역에 객체를 할당한다
  • Eden 영역이 가득 차면 Minor GC가 트리거된다.

2) 마킹 (Marking)

  • GC는 힙 메모리에서 여전히 사용 중인 객체와 그렇지 않은 객체를 식별한다.
  • 이 과정을 "마킹"이라고 하며, 살아있는 객체는 마킹된다.
  • 참조되지 않는 객체는 마킹되지 않으며, 삭제 대상이 된다.

3) 객체 이동 (Minor GC)

  • 살아남은 객체는 Eden 영역에서 Survivor 영역(S1 또는 S2)으로 이동한다.
  • 여러 번의 GC를 통해 Survivor 영역에서 살아남은 객체는 Old Generation으로 승격된다.

4) 객체 삭제 및 메모리 해제

  • 마킹되지 않은, 즉 참조되지 않는 객체는 삭제되어 메모리가 해제된다.
  • Minor GC는 주로 Young Generation에서 동작하며, Major GC는 Old Generation에서 발생한다.

5) 메모리 압축 (Compaction):

  • 객체 삭제 후 남은 메모리에 단편화가 발생할 수 있다. 이 경우 GC는 메모리를 압축하여 단편화를 줄일 수 있다.
  • 메모리 압축은 주로 Major GC나 Full GC의 일부로 수행된다.
메모리 단편화란?
메모리 할당과 해제가 반복되면서, 연속된 메모리 블록 사이에 작은 빈 공간이 생겨나는 경우 총 메모리 공간은 충분하지만 연속적인 큰 메모리 블록을 할당할 수 없는 상황을 의미한다.

이를 해결하기 위해서 사용 중인 메모리를 힙의 한쪽으로 몰아 빈 메모리 블록을 연속된 공간으로 만드는 작업을 수행하는데 이 작업을 메모리 압축이라고 한다

4. GC의 주요 유형

1) Minor GC

  • Young Generation에서 발생한다
  • Eden 영역에서 생성된 대부분의 객체는 수명이 짧기 때문에 빠르게 제거된다.
  • 살아남은 객체는 Survivor 영역으로 이동하며, 일정 횟수의 GC를 거친 객체는 Old Generation으로 승격된다.

2) Major GC:

  • Old Generation에서 발생합니다.
  • 수명이 긴 객체들을 관리하며, Minor GC보다 더 많은 시간이 소요된다.
  • Major GC는 힙 메모리의 많은 부분을 정리하며, 성능에 큰 영향을 미칠 수 있다.

3) Full GC:

  • 전체 힙 메모리(Young + Old Generation)를 대상으로 수행된다.
  • 모든 영역을 동시에 청소하므로, 일시 중지 시간이 길어진다.
  • Full GC가 자주 발생하면 애플리케이션 성능이 크게 저하될 수 있다.

 

 

결론

기존의 Major GC와 Minor GC 개념은 새로운 GC 알고리즘에서도 유지되고 있다. 다만, 새로운 GC 알고리즘들은 이들 GC 과정의 효율성을 높이기 위해 다양한 최적화를 적용했다.

Minor GC는 여전히 Young Generation에서 주로 발생하며, 새로운 객체가 메모리에 할당될 때 주기적으로 발생한다. G1 GC, ZGC, Shenandoah GC 모두 Minor GC 과정을 최적화하여 더 짧은 지연 시간과 효율적인 메모리 관리를 제공한다.

Major GC도 Old Generation의 메모리를 정리하는 과정이지만, 새로운 GC 알고리즘은 이 과정을 병행으로 수행하거나, 더 효율적인 메모리 회수 전략을 사용하여 중단 시간을 최소화하고 있다. 

JDK 버전이 올라가면서 GC 알고리즘은 크게 개선되었으나 기본적인 Major GC와 Minor GC 매커니즘은 유지되고 있으며, 이를 더 효율적으로 수행하기 위해 다양한 기술이 적용되고 있다. 개발자는 애플리케이션의 요구사항에 따라 적절한 GC 알고리즘을 선택하고, JVM 튜닝을 통해 성능을 최적화할 수 있다.

해당 게시글은 내용 정리 목적으로 작성되었습니다. 틀린 내용이 있다면 언제든지 말씀해 주세요.

서비스의 데이터 구조 설계 시 JPA 엔티티 상속 전략을 활용해서 확장성과 다형성 등 객체지향의 이점을 활용할 수 있다.
예를 들어서 상위 타입인 쿠폰 엔티티가 있고, 하위 타입인 퍼센트 할인 엔티티, 고정 할인 엔티티가 있다고 가정했을 때, 쿠폰 적용 로직에 대해서 다형성 전략을 활용해 구현할 수 있다.

이 글에 정리된 예시는 JPA 엔티티 상속 전략 활용 방법 및 추상 클래스를 통한 다형성 활용 방법에 대한 내용이다.
우선 JPA 상속 전략의 종류에 대해서 알아보자

1. JPA 상속 전략 종류

JPA는 JOINED, SINGLE_TABLE, TABLE_PER_CLASS의 세 가지 상속 전략을 지원하며, 각각의 전략은 데이터베이스에 서로 다른 방식으로 테이블을 구성한다.

JOINED

  • 상속 구조를 구성하기 위해 공통 속성을 저장하는 상위 테이블과 개별 속성을 저장하는 하위 테이블로 구성된다.
  • 장점
    • 데이터 정규화, 필요한 데이터만 조회 가능, 확장 용이
  • 단점
    • 데이터 저장 시, 상위 테이블과 하위 테이블에 데이터를 저장하며, 데이터 조회 시 하위 테이블의 개수 만큼 left join을 해서 쿼리 성능이 저하될 수 있다. 

SINGLE_TABLE

  • 모든 데이터가 하나의 테이블에 저장된다.
  • 장점
    • 쿼리가 단순하고 성능이 좋다
  • 단점
    • 테이블에 많은 열이 생기고, 불필요한 컬럼이 다수 존재할 수 있다. 
    • 데이터 응집도가 떨어짐 

TABLE_PER_CLASS

  • 각 엔티티 클래스가 독립적인 테이블로 생성되며, 상속 구조를 반영하지 않는다.
  • 장점
    • 간단한 쿼리, 테이블 간의 의존성 없음
  • 단점
    • 동일한 컬럼을 여러 테이블에 중복 저장, 다형성 쿼리에서 성능 저하.

상속 전략에 따라서 테이블의 구조가 변경되며, 실제 비즈니스 로직에 영향은 없다. 
상황에 맞는 전략을 활용해보면 좋을 것 같다. 예제는 JOINED 전략을 활용해보도록 하자
개인적으로 실무에서는  JOINED, SINGLE_TABLE 두 전략을 활용할 수 있을 것 같다. 

JOINED를 활용하면 정규화 측면에서 필요한 데이터만 테이블에 저장되기 때문에 테이블 가독성이 및 데이터 응집도가 올라가지만 테이블 종류가 많아지면 조회 성능이 낮아진다는 단점이 있다. SINGLE_TABLE은 한개 테이블만 활용하기 때문에 조회 속도 및 테이블 관리에 용이하지만 서로 관련 없는 데이터들이 다수 존재하기 때문에 가독성과 응집도가 낮아지며, 컬럼이 많아질수록 데이터간 관련성을 확인하기 여러울 수 있다.    

2. JPA 상속 전략 예제

1) 테이블 구조

  • coupon
    • 모든 쿠폰의 상위 테이블이며 쿠폰 공통 정보를 저장한다
    • 모든 하위 테이블은 쿠폰 테이블의 기본키를 PK로 가지는 식별관계이다.
    • coupon_type으로 쿠폰 정보를 구별한다
  • fixed_discount_coupon
    • 고정 할인 쿠폰이며, 할인 금액 컬럼을 갖고 있다. 
  • percent_discount_coupon
    • 퍼센트 할인 쿠폰이며 할인 퍼센트 컬럼을 갖고 있다.

2) JPA 쿠폰 엔티티 

Money

public record Money(double amount) {
    public static final Money ZERO = Money.of(0);

    public static Money of(double amount) {
        return new Money(amount);
    }

    public Money calculatePercent(double percent) {
        return Money.of(this.amount * percent);
    }

    public Money minus(Money money) {
        return Money.of(this.amount - money.amount);
    }
}
  • 금액 관련 Value Object이다. 불변 객체이며, 계산 관련 메소드 실행 시 계산 결과가 반영된 새로운 Money 인스턴스를 반환한다

Coupon

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "coupon_type")
public abstract class Coupon {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "name", nullable = false)
    private String name;

    @Column(name = "coupon_type", insertable = false, updatable = false)
    @Enumerated(EnumType.STRING)
    private CouponType type;

    protected Coupon(String name, CouponType type) {
        this.name = name;
        this.type = type;
    }

    public abstract Money use(Money price);
}
  • 쿠폰의 공통 정보를 갖는 클래스이다. 추상클래스이며, 쿠폰의 공통 기능을 추상메소드로 정의했다.
  • @Inheritance(strategy = InheritanceType.JOINED)는 부모 엔티티로 선언한다는 것을 의미하며 상속 전략으로 JOINED 속성을 적용했다.
  • @DiscriminatorColumn(name = "coupon_type")은 하위 타입을 구분하는데 필요한 컬럼의 이름을 명시하는 어노테이션이다.
  • 쿠폰 사용 메소드를 추상메소드로 정의했다. Money 타입의 price 변수를 매개변수로 받아서 쿠폰이 적용되었을 때 결과 값을 반환한다
  • CouponType 변수 선언은 선택사항이다. 별도로 선언하지 않아도  @DiscriminatorColumn(name = "coupon_type") 와 같이 어노테이션을 선언했기 때문에 자동적으로 생성된다.
  • couponType 변수는 단순 조회용 변수이므로 insertable,updatable 속성을 false로 설정했다.

FixedDiscountCoupon

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@DiscriminatorValue("FIXED_DISCOUNT")
public class FixedDiscountCoupon extends Coupon {

    @AttributeOverride(name = "amount", column = @Column(name = "discount_amount"))
    @Embedded
    private Money discountAmount;

    private FixedDiscountCoupon(String name, Money discountAmount) {
        super(name, CouponType.FIXED_DISCOUNT);
        this.discountAmount = discountAmount;
    }

    public static FixedDiscountCoupon create(String name, Money discountAmount) {
        return new FixedDiscountCoupon(name, discountAmount);
    }

    @Override
    public Money use(Money price) {
        return price.minus(discountAmount);
    }
}
  • 고정 할인 쿠폰 엔티티이다. Coupon 엔티티를 상속받으며 @DiscriminatorValue("FIXED_DISCOUNT") 어노테이션의 값을 통해서 couponType이 저장된다.
  • 여기서 핵심은 추상메소드를 오버라이딩 한 use 메소드이다.
  • 추상 클래스에 대한 다형성을 활용하기 위해 use 메소드를 재정의했다. 
  • 각 쿠폰마다 할인 방식이 다르기 때문에 각 쿠폰마다 추상메소드를 재정의한다.
  • 고정할인 쿠폰은 정해진 값 만큼 할인되기 때문에 매개변수로 전달된 price에 discountAmount 값을 차감한 결과를 반환한다.

PercentDiscountCoupon

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@DiscriminatorValue("PERCENT_DISCOUNT")
public class PercentDiscountCoupon extends Coupon {


    @Column(name = "discount_percent")
    private double discountPercent;

    private PercentDiscountCoupon(String name, double discountPercent) {
        super(name, CouponType.PERCENT_DISCOUNT);
        this.discountPercent = discountPercent;
    }

    public static PercentDiscountCoupon create(String name, double discountPercent) {
        return new PercentDiscountCoupon(name, discountPercent);
    }

    @Override
    public Money use(Money price) {
        return price.minus(price.calculatePercent(discountPercent));
    }
}
  • 퍼센트 할인 쿠폰이며, 저장된 퍼센트 만큼 금액을 할인한다.
  • use 추상 메소드를 재정의해서 다형성 패턴을 활용할 수 있게 했다. 

CouponRepository

public interface CouponRepository extends JpaRepository<Coupon, Long> {
}
  • 쿠폰을 저장하거나 조회할 때 사용할 수 있는 CouponRepository를 정의했다.

CouponTest

class CouponTest {
    @Test
    public void 고정할인_쿠폰_사용_테스트() {
        //given
        Coupon 고정_할인_쿠폰 = FixedDiscountCoupon.create("고정 할인 쿠폰", Money.of(1000));

        //when
        Money discountPrice = 고정_할인_쿠폰.use(Money.of(2000));

        //then
        Assertions.assertThat(discountPrice).isEqualTo(Money.of(1000));
    }

    @Test
    public void 퍼센트할인_쿠폰_사용_테스트() {
        //given
        Coupon 퍼센트_할인_쿠폰 = PercentDiscountCoupon.create("퍼센트_할인_쿠폰", 0.1);

        //when
        Money discountPrice = 퍼센트_할인_쿠폰.use(Money.of(2000));

        //then
        Assertions.assertThat(discountPrice).isEqualTo(Money.of(1800));
    }
}
  • 고정 할인 쿠폰, 퍼센트 할인 쿠폰을 생성 후 Coupon 클래스에 저장했다.
  • 추상 클래스는 하위 클래스의 인스턴스를 저장할 수 있고 추상메소드 호출 시 각 하위클래스에서 재정의한 메소드의 구현이 동작하기 때문에 캡슐화 및 다형성을 활용할 수 있다.

CouponRepositoryTest

@DataJpaTest
class CouponRepositoryTest {

    @Autowired
    public CouponRepository couponRepository;

    @Autowired
    public TestEntityManager testEntityManager;


    @Test
    public void 고정할인_쿠폰_저장() {
        Coupon coupon = couponRepository.save(FixedDiscountCoupon.create("고정 할인 쿠폰", Money.of(1000)));

        Assertions.assertThat(coupon.getId()).isNotNull();
        Assertions.assertThat(coupon.getType()).isEqualTo(CouponType.FIXED_DISCOUNT);
        Assertions.assertThat(((FixedDiscountCoupon) coupon).getDiscountAmount()).isEqualTo(Money.of(1000));
    }

    @Test
    public void 퍼센트할인_쿠폰_저장() {
        Coupon coupon = couponRepository.save(PercentDiscountCoupon.create("퍼센트 할인 쿠폰", 0.5));

        Assertions.assertThat(coupon.getId()).isNotNull();
        Assertions.assertThat(coupon.getType()).isEqualTo(CouponType.PERCENT_DISCOUNT);
        Assertions.assertThat(((PercentDiscountCoupon) coupon).getDiscountPercent()).isEqualTo(0.5);
    }

    @Test
    public void 고정할인_쿠폰_조회() {
        Coupon coupon = couponRepository.save(FixedDiscountCoupon.create("고정 할인 쿠폰", Money.of(1000)));
        testEntityManager.flush();
        testEntityManager.clear();

        Coupon findCoupon = couponRepository.findById(coupon.getId()).get();

        Assertions.assertThat(findCoupon.getId()).isNotNull();
        Assertions.assertThat(findCoupon.getType()).isEqualTo(CouponType.FIXED_DISCOUNT);
        Assertions.assertThat(((FixedDiscountCoupon) findCoupon).getDiscountAmount()).isEqualTo(Money.of(1000));
    }

    @Test
    public void 퍼센트할인_쿠폰_조회() {
        Coupon coupon = couponRepository.save(PercentDiscountCoupon.create("퍼센트 할인 쿠폰", 0.1));
        testEntityManager.flush();
        testEntityManager.clear();

        Coupon findCoupon = couponRepository.findById(coupon.getId()).get();

        Assertions.assertThat(findCoupon.getId()).isNotNull();
        Assertions.assertThat(findCoupon.getType()).isEqualTo(CouponType.PERCENT_DISCOUNT);
        Assertions.assertThat(((PercentDiscountCoupon) findCoupon).getDiscountPercent()).isEqualTo(0.1);
    }
}
  • 쿠폰을 저장하거나 조회에 대한 테스트이다. 단건 쿠폰 조회 시 Coupon 클래스를 반환하기 때문에 하위 클래스의 상세 정보를 알기 위해서 다운 캐스팅을 해야한다. 실제 로직에서는 CouponType값으로 if문 혹은 switch문을 활용해서 쿠폰 상세 정보를 알 수 있다. 
  • 추상화하는 이유는 코드의 복잡성을 낮추고, 다형성을 활용해서 객체지향 관점에서 OCP(개방 폐쇄 원칙)를 준수한 코드를 만들 수 있다.

3. 쿠폰 생성을 위한 추상 팩토리 메소드 패턴 

  • 쿠폰 생성 시 여러가지 방법이 있지만 추상 팩토리 메소드를 활용해서 간편하게 구현할 수 있다.
public record CouponCreateCommand(CouponType couponType, String couponName, Double fixedDiscountAmount,
                                  Double percentDiscountAmount) {
}

public interface CouponFactory {
    Coupon create(CouponCreateCommand command);

    CouponType getType();
}

@Component
public class FixedDiscountCouponFactory implements CouponFactory {

    @Override
    public Coupon create(CouponCreateCommand command) {
        return FixedDiscountCoupon.create(command.couponName(), Money.of(command.fixedDiscountAmount()));
    }

    @Override
    public CouponType getType() {
        return CouponType.FIXED_DISCOUNT;
    }
}

@Component
public class PercentDiscountCouponFactory implements CouponFactory {

    @Override
    public Coupon create(CouponCreateCommand command) {
        return PercentDiscountCoupon.create(command.couponName(), command.percentDiscountAmount());
    }

    @Override
    public CouponType getType() {
        return CouponType.PERCENT_DISCOUNT;
    }
}
  • CouponFactory 인터페이스에 create 메소드를 선언하고, CouponFactory를 구현한 PercentDiscountCouponFactory, FixedDiscountCouponFactory를 선언한다.
  • 여기서 트레이드 오프는 CouponCreateCommand에 모든 쿠폰에 대한 정보가 선언되어 있다는 점이다.
  • 쿠폰 종류가 늘어남에 따라 변수의 종류가 많아진다. 이 부분은 각 쿠폰 생성에 대한 API 엔드포인트를 추가하고 쿠폰 타입 별로 쿠폰 생성 서비스를 추가하면 해결할 수 있다. 
@Service
public class CouponCreateUseCase {
    private final Map<CouponType, CouponFactory> couponFactoryMap;
    private final CouponRepository couponRepository;

    public CouponCreateUseCase(List<CouponFactory> couponFactories, CouponRepository couponRepository) {
        this.couponFactoryMap = couponFactories.stream()
            .collect(Collectors.toMap(CouponFactory::getType, Function.identity()));
        this.couponRepository = couponRepository;
    }


    @Transactional
    public Long create(CouponCreateCommand command) {
        Coupon coupon = Optional.ofNullable(couponFactoryMap.get(command.couponType()))
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 쿠폰 생성 팩토리"))
            .create(command);

        return couponRepository.save(coupon).getId();
    }
}

 

  •  CouponFactory를 구현한 Bean을 List로 주입받고 CouponFactory.getType() 의 Enum 값을 Key로 Bean 자체를 Value로 정의한 Map을 생성한다.
  • 그리고 command의 CouponType으로 CouponFactory를 검색하고 Coupon 엔티티를 생성한 다음 Coupon을 저장하는 유스케이스이다. 

결론

JPA 상속 전략을 활용한 예제를 구현해보았다. 
중요한 점은 JPA 상속 전략을 사용 방법을 아는 것 보다 어떻게 엔티티를 설계하고 활용할 것인지 고민하는 것이 더 중요하다.

JPA 상속 전략에 매몰되어서 잘못 사용할 경우 오히려 복잡성이 증가하는 결과를 초례할 수 있다. 
해당 전략이 활용되면 좋은 케이스 인지 잘 판단해서 사용하자

 

SQS에 대해서 공부한 내용을 블로그에 정리하는 목적으로 해당 게시글을 작성하게 되었다.
SQS를 적절하게 사용하면 어려운 문제에 대해서 아주 효과적으로 처리할 수 있는데 예를 들어서 선착순 티켓팅 시스템이나 알림 발송 시스템 등 순간적으로 많은 트래픽이 발생하는 작업에 대해서 유연하게 대처할 수 있다.

이번 게시글에서 SQS에 대한 간단한 설명과 설정 그리고 Spring boot와 연동하는 방법에 대해서 설명하겠다.

목차

1. SQS 란 (Amazon Simple Queue Service) ?
2. SQS를 사용하는 이유
3. SQS 유형
4. SQS 생성 및 설정 방법
5. SQS와 Spring Boot 연동 

6. 결론


1. SQS 란 (Amazon Simple Queue Service) ?

AWS SQS(Amazon Simple Queue Service)는 메시지를 송수신할 수 있는 완전 관리형 메시지 큐 서비스이다.
이를 통해 분산 시스템 간의 비동기 통신을 가능하게 하며, 메시지의 유실 없이 안정적인 데이터 전달을 보장한다.

2. SQS를 사용하는 이유

1) 비동기 처리 및 작업 분리

SQS는 비동기 처리를 가능하게 해준다. 시스템에서 시간이 오래 걸리는 작업이나 비동기적으로 처리해야 하는 작업이 있을 때, SQS를 사용하면 작업을 큐에 넣고 리시버에서 작업 수행하도록 설계할 수 있다. 
이런식으로 작업을 큐로 전송하는 서버와 작업을 처리하는 서버로 분리하면 처리량과 안정성을 높일 수 있다.

2) 시스템 간의 결합도 감소

마이크로서비스 아키텍처에서는 서비스 간의 결합도를 낮추는 것이 매우 중요하다. SQS는 서비스 간의 직접적인 통신을 피하고, 메시지를 큐를 통해 전달함으로써 서비스 간의 결합도를 낮출 수 있다. 이를 통해 각 서비스는 독립적으로 개발, 배포 및 확장할 수 있다.

3) 확장성과 유연성

SQS는 AWS의 관리형 서비스로, 메시지 큐의 크기와 처리량을 자동으로 확장할 수 있다. 대규모 메시지를 처리할 수 있으며 트래픽이 급증해도 시스템이 이를 처리할 수 있도록 자동으로 확장된다.

이는 수동으로 인프라를 관리할 필요 없고, 사용한 만큼만 비용을 소모할 수 있다.

4) 내결함성과 안정성

SQS는 메시지의 안정적이고 지속적인 전달을 보장한다. 메시지가 처리되지 못했을 경우 Dead-Letter Queue를 사용해 해당 메시지를 별도로 관리할 수 있다. 이는 시스템 오류나 장애가 발생하더라도 메시지를 안전하게 처리할 수 있도록 도와준다.

Dead-Letter Queue를 사용하면 별도의 큐를 관리해야 하므로 추가적인 비용이 발생할 수 있다. 하지만 중요한 작업이고 실패할 경우 안정적으로 재시도 작업을 원한다면 SQS는 아주 쉽게 해당 기능을 제공한다.

5) 보안

SQS는 AWS IAM을 사용해 액세스 제어를 제공하며, 전송 중인 데이터와 대기열에 저장된 데이터를 AWS KMS(Key Management Service)를 통해 암호화할 수 있다. 이를 통해 민감한 데이터를 안전하게 처리할 수 있다.

6) 저비용

SQS는 메시지의 수에 따라 비용이 청구되므로, 사용한 만큼만 비용을 지불하게 된다. 이는 예산을 효율적으로 관리할 수 있게 해주며, 특히 트래픽이 변동적인 애플리케이션에 유리하다.

3. SQS 유형

SQS는 크게 표준 대기열, FIFO 대기열로 나뉘어진다. 목적에 따라서 적절히 선택해야한다.

1) 표준 대기열

  • 무제한 처리량: 표준 대기열은 API 작업당 거의 무제한의 초당 트랜잭션(TPS)을 지원한다.
  • 최소한 한 번은 전달: 메시지가 최소한 한 번 전달되고, 가끔 2개 이상의 메시지 복사본이 전달될 수 있다.
  • 메시지 전송 순서 보장은 안됌: 가끔 메시지가 전송된 순서와 다르게 전달될 수 있다.

2) FIFO 대기열

  • 높은 처리량: 기본적으로 FIFO 대기열은 초당 최대 300개의 메시지(초당 300개의 전송, 수신 또는 삭제 작업)를 지원합한다. 작업당 최대 10개 메시지를 일괄 처리할 경우, FIFO 대기열은 초당 3000개의 메시지까지 지원할 수 있다. 
  • 정확히 한 번 처리: 메시지가 한 번 전달되고 소비자가 이를 처리 및 삭제할 때까지 유지된다. 중복 메시지는 대기열에 올라가지 않는다..
  • 선입선출 전달: 메시지가 전송되고 수신되는 순서가 엄격하게 지켜진다.

4. SQS 생성 및 설정 방법

1) SQS IAM 사용자 생성

사용자 세부 정보 지정

권한 설정 및 사용자 생성

SQS 전용 IAM 사용자를 생성했다면 액세스 키를 만들고 ARN을 복사해 두자.

2) Amazon SQS 대기열 생성

대기열 생성

  • 표준 대기열을 선택하고 큐 이름을 입력한다.

대기열 구성

  • 표시 제한 시간 (Visibility Timeout) 
    • 이 설정은 메시지가 중복 처리되는 것을 방지하기 위해 사용된다. 메시지가 소비자에게 전달되면 해당 메시지는 표시 제한 시간 동안 큐에서 "잠금(lock)" 상태가 된다. 이 기간 내에 소비자는 메시지를 처리하고 SQS에 처리가 완료되었음을 알려야 한다. 만약 표시 제한 시간이 지나도 처리가 완료되지 않으면, 해당 메시지의 잠금이 풀리며 다른 소비자가 메시지를 소비할 수 있다.
  • 전송 지연 (Delay Seconds)
    • 전송 지연은 메시지가 큐에 추가되자마자 소비자에게 전달되지 않고, 지정된 시간 동안 대기 상태가 되는 설정이다. 이 설정은 메시지의 즉각적인 처리가 필요하지 않거나, 특정 작업이 지연되어 실행되기를 원할 때 유용하다.
  • 메시지 수신 대기 시간 (Receive Message Wait Time)
    • 메시지 수신 대기 시간은 SQS가 폴링 방식으로 메시지를 검색할 때, 메시지가 큐에 없을 경우 대기하는 시간을 지정하는 설정이다. 이 설정을 통해 짧은 폴링 긴 폴링을 제어할 수 있다.  짧은 폴링은 메시지가 없을 때 즉시 응답하지만, 긴 폴링은 설정된 시간 동안 큐에 메시지가 들어올 때까지 대기한다. 긴 폴링을 사용하면 네트워크 트래픽과 비용을 줄일 수 있다.
    • 대기하는 주체는 폴링 요청을 하는 클라이언트(리시버)이며 메시지 수신 대기시간을 길게 설정하면 폴링 할 때 메시지가 없을 경우 바로 연결을 끊지 않고 설정한 시간동안 대기한다.
  • 메시지 보존 기간 (Message Retention Period)
    • 메시지 보존 기간은 큐에 들어온 메시지가 큐에서 삭제되지 않고 보관되는 최대 시간을 설정한다. 메시지가 이 기간 동안 처리되지 않으면, 자동으로 삭제된다.
    • 메시지 보존 기간을 길게 설정하면 메시지를 오랜 기간 동안 보관할 수 있지만, 이 경우 큐에 메시지가 쌓이면서 스토리지 비용이 증가할 수 있다. 반면, 보존 기간을 짧게 설정하면 메시지가 빠르게 삭제되므로 큐의 크기를 줄일 수 있지만, 메시지 손실 위험이 증가할 수 있다.

암호화

  • 메시지에 대한 암호화 기능을 제공한다.

SQS 큐 전송자와 큐 수신자에 대한 액세스 정책

  • 이전에 생성해둔 SQS IAM에 대한 ARN 링크를 복사해서 큐 전송자와 큐 수신자에 입력한다.
  • 큐 전송자와 수신자를 별도의 사용자로 나누고 싶다면 IAM 사용자를 추가로 생성해주면 된다.
  • IAM 사용자 외에도 역할에 SQS 관련 권한을 넣어주고 해당 역할에 대한 ARN 정보를 입력해도 된다. 

Dead-Letter Queue 정책 설정

  • 수신자에서 작업이 실패될 경우 Dead-Letter Queue로 실패 작업에 대한 메시지를 전송할 수 있다.
  •  해당 설정을 해주려면 위에서 대기열을 생성했던 것과 같이  Dead-Letter Queue 전용으로 생성해주어야 한다.
  • 그 다음 해당 대기열의 ARN을 복사해서 대기열 ARN에 입력해준다.

모든 설정이 완료되었으면 대기열을 생성해주자.

5. SQS와 Spring Boot 연동 

Spring Boot 버전에 따라서 연동하는 방법이 다를 수 있다. 이번 게시글에서는 jdk 17과 spring 3.x 으로 연동 테스트를 진행하겠다. Spring project를 생성하고 아래 코드를 참고해서 테스트해보자

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

...

dependencies {
	...
    implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.0.1")
    implementation 'io.awspring.cloud:spring-cloud-aws-starter-sqs'
    ...
}

application.yml

cloud:
  aws:
    credentials:
      access-key: access-key
      secret-key: secret-key
    region:
      static: ap-northeast-2
    sqs:
      queue:
        name: my-test-queue
  • SQS IAM의 access-key와 secret-key를 입력하고 queue의 region과 name을 입력하자.

AwsSqsConfiguration

  • AWS SQS 관련 설정 클래스이다. 편의상 메시지 전송 설정과 메시지 수신 설정을 하나의 클래스에 정의하고 동일한 비동기 클라이언트를 사용하도록 설정한다 
import io.awspring.cloud.sqs.config.SqsMessageListenerContainerFactory;
import io.awspring.cloud.sqs.operations.SqsTemplate;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.sqs.SqsAsyncClient;

import java.time.Duration;

@Slf4j
@Configuration
public class AwsSqsConfiguration {

    private final String accessKey;

    private final String secretKey;

    private final String region;

    public AwsSqsConfiguration(
        @Value("${cloud.aws.credentials.access-key}") String accessKey,
        @Value("${cloud.aws.credentials.secret-key}") String secretKey,
        @Value("${cloud.aws.region.static}") String region
    ) {
        this.accessKey = accessKey;
        this.secretKey = secretKey;
        this.region = region;
    }

    /**
     * SQS 비동기 클라이언트
     *
     * @return
     */
    @Bean
    public SqsAsyncClient sqsAsyncClient() {
        return SqsAsyncClient.builder()
            .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)))
            .region(Region.of(region))
            .build();
    }
    
    /**
     * SQS 메시지 생성 템플릿
     *
     * @return
     */
    @Bean
    public SqsTemplate sqsTemplate() {
        return SqsTemplate.newTemplate(sqsAsyncClient());
    }    

    /**
     * SQS Listener 설정
     */
    @Bean
    public SqsMessageListenerContainerFactory<Object> defaultSqsListenerContainerFactory() {
        return SqsMessageListenerContainerFactory
            .builder()
            .configure(sqsContainerOptionsBuilder ->
                sqsContainerOptionsBuilder
                    .maxConcurrentMessages(10) // 컨테이너의 스레드 풀 크기
                    .maxMessagesPerPoll(10) // 한 번의 폴링 요청으로 수신할 수 있는 최대 메시지 수를 지정
                    .acknowledgementInterval(Duration.ofSeconds(5)) // AWS SQS 응답 간격
                    .acknowledgementThreshold(10) // AWS SQS 응답 최소 개수
            )
            .sqsAsyncClient(sqsAsyncClient())
            .build();
    }
}
  • 크게 SqsAsyncClient, SqsTemplete, SqsMessageListenerContainerFactory으로 구성된다.
  • SqsAsyncClient : AWS SQS 비동기 통신을 제공하며, access-key와 secret-key, region을 설정한 후 생성한다.
  • SqsTemplete : SQS로 메시지 생성 및 전송 템플릿 클래스이다.
  • SqsMessageListenerContainerFactory : SQS 메시지 수신 컨테이너 팩토리 클래스이다. 비동기로 수신하며 설정에 따라서 효율적으로 작업을 수행할 수 있다.
    • maxConcurrentMessages : 컨테이너의 스레드 풀을 설정한다. 기본값은 10이며 해당 설정에 따라서 병렬로 처리할 수 있는 스레드 수를 설정할 수 있다.
    • maxMessagesPerPoll : 한 번의 폴링(polling)으로 SQS 큐에서 가져올 수 있는 최대 메시지 수를 지정한다. 일종의 버퍼 역할을 한다고 보면된다.
    • acknowledgementInterval : 메시지를 처리한 후 즉시 SQS로 응답을 보내지 않고, 설정한 시간 만큼 기다렸다가 시간 내에 처리된 모든 메시지에 대해 한 번에 SQS로 응답을 보낸다.
    • acknowledgementThreshold : 설정한 개수의 메시지가 처리될 때마다 SQS로 응답을 보낸다. 설정한 개수 이하의 메시지가 처리된 경우에는 확인을 보내지 않고, 개수 충족 시 한 번에 SQS로 응답을 보낸다.
    • 일반적으로 acknowledgementInterval과 acknowledgementThreshold는 함께 사용되어, 시간과 메시지 수 기준 중 어느 한 가지 조건이 충족될 때 SQS로 응답을 보낼 수 있다.
  • 자세한 설정 정보를 확인하려면 아래 링크를 참조하면 된다.
https://docs.awspring.io/spring-cloud-aws/docs/3.0.0/reference/html/index.html#sqscontaineroptions-descriptions

NotificationSender

  • 알림 전송 관련 인터페이스 및 구현체를 정의한다. 
// 알림 메시지
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class Notification {
    private String message;
    private LocalDateTime createAt;

    public static Notification create(String message) {
        return new Notification(
            message,
            LocalDateTime.now()
        );
    }
}

// 알림 전송 결과
public record NotificationSendResult(
    String messageId,
    boolean success
) {

    public static NotificationSendResult success(String messageId) {
        return new NotificationSendResult(messageId, true);
    }

    public static NotificationSendResult failure() {
        return new NotificationSendResult(null, false);
    }
}

// 알림 전송 인터페이스
public interface NotificationSender {
    NotificationSendResult sendNotification(Notification notification);
}


// AWS SQS 알림 전송 구현체
@Slf4j
@RequiredArgsConstructor
@Component
public class AwsSqsNotificationSender implements NotificationSender {

    @Value("${cloud.aws.sqs.queue.name}")
    private String queueName;

    private final ObjectMapper objectMapper;

    private final SqsTemplate template;

    @Override
    public NotificationSendResult sendNotification(Notification notification) {
        try {
            String message = objectMapper.writeValueAsString(notification);

            SendResult<String> result = template.send(to -> to
                .queue(queueName)
                .payload(message));

            return NotificationSendResult.success(result.messageId().toString());
        } catch (Exception e) {
            log.error("send notification error : ", e);
            return NotificationSendResult.failure();
        }
    }
}

@RequiredArgsConstructor
@RequestMapping("/api/v1/send-notification")
@RestController
public class NotificationSendController {

    private final NotificationSender notificationSender;

    @PostMapping
    public ResponseEntity<NotificationSendResult> send(@RequestBody String message) {
        return ResponseEntity.ok(notificationSender.sendNotification(Notification.create(message)));
    }
}
  • AWS SQS 요청 및 응답 DTO를 생성하고 AWS SQS 구현체를 작성한 알림 전송 컨트롤러로 메시지를 전송해보자.

알림 메시지 전송 테스트

  • 알림 메시지 전송 엔트포인트로 요청을 보내고 SQS에 메시지가 수신되었는지 확인해보자 

  • AWS SQS 메시지 전송 및 수신 페이지로 이동한다

  • 메시지 수신을 확인해보면 사용가능한 메시지 2개를 확인할 수 있고, 메시지 폴링 버튼을 누르면 확인가능한 메시지 두건을 확인할 수 있다. 메시지의 ID를 보면 postman의 응답에 있는 messageId 와 동일한 것을 알 수 있다.
  • 폴링해서 메시지를 확인한다고 해도 메시지가 소모된 것은 아니며 SQS를 통해 메시지를 사용했고 삭제해달라는 요청을 보내야 비로서 메시지를 큐에서 제거한다

SQS 메시지 수신 Listener

@Slf4j
@Component
@RequiredArgsConstructor
public class AwsSqsListener {

    @SqsListener(value = "${cloud.aws.sqs.queue.name}")
    public void listen(String message) {
        log.info("notification : {}", message);
    }
}
  • @SqsListener(value = "${cloud.aws.sqs.queue.name}")을 어노테이션을 선언한 Bean에 대해서 SQS 메시지 수신을 수행한다.
  • SqsMessageListenerContainerFactory 설정에 따라서 동시에 처리할 수 있는 리시버(워커 또는 노드 라고도 한다)개수를 지정할 수 있다.
  • 서버 인스턴스 사양에 따라서 적절하게 설정 해주면 효율적인 SQS 메시지 처리가 가능하다
  • @SqsListener 어노테이션이 작동하지 않는 경우가 있는데 spring boot 3.0으로 변경되면서 연동하는 방식이 달라졌다고 한다. 라이브러리 버전을 잘 체크해보자
2024-08-14T17:22:59.582+09:00  INFO 38884 --- [ntContainer#0-1] i.s.s.i.sqs.receiver.AwsSqsListener      : notification : {"message":"{\r\n    \"message\" : \"반갑습니다\"\r\n}","createAt":"2024-08-14T17:09:33.4354856"}
2024-08-14T17:22:59.641+09:00  INFO 38884 --- [ntContainer#0-2] i.s.s.i.sqs.receiver.AwsSqsListener      : notification : {"message":"{\r\n    \"message\" : \"안녕하세요\"\r\n}","createAt":"2024-08-14T17:12:50.1727803"}
  • 위와 같이 메시지가 잘 수신된 것을 확인할 수 있다.
  • 별도의 설정을 하지 않으면 작업 종료 시 SQS에 응답하면서 메시지는 삭제된다.

6. 결론

 AWS SQS가 아니어도 Apache Kafka, Redis message broker, rebbitMQ 등을 활용해서 메시징 시스템을 구축할 수 있다. 전략적으로 현재 상황에 맞는 메시징 시스템을 선택하면 된다.

메시지 전송자와 메시지 수신자 (워커, 노드)를 별도의 서버로 구현하는 것이 좋으며 이러한 메시징 시스템을 적절하게 활용하는 것이 매우 중요하다

해당 게시글은 내용 정리 목적으로 작성되었습니다. 틀린 내용이 있다면 언제든지 말씀해 주세요

디자인 패턴 관련해서 공부를 해도 실제 실무에서 어떻게 적용하는지 의문이었던 적이 있었다.
실무 개발을 진행하면서 여러 시행착오를 통해 유지보수 용이한 코드를 작성했던 경험을 공유 해보고자 한다
전략 패턴을 소개하는 용도로 작성한 게시글이라 완성된 코드가 아니다.
전략 패턴을 어떻게 활용했는지 참고하는 용도로 봤으면 좋겠다.

 

개발 요구사항

  • 미리 등록해둔 결제 수단(카드, 페이)을 이용해서 도서를 구매한다.

개발 예제

PaymentMethod, PaymentItem, PaymentCommand, PaymentResult

// 결제수단
public enum PaymentMethod {
    CARD, PAY,
}

// 결제 전략 Not Found 예외
public class PaymentStrategyNotFoundException extends RuntimeException {
    public PaymentStrategyNotFoundException() {
        super("결제 전략을 찾을 수 없습니다.");
    }
}

// 결제 상품
@RequiredArgsConstructor
@Getter
public class PaymentItem {
    private final String name;
    private final int price;
    private final int quantity;
}

// 결제 커맨드
@RequiredArgsConstructor
@Getter
public class PaymentCommand {
    private final Long userId;
    private final Long paymentMethodId;
    private final PaymentMethod paymentMethod;
    private final PaymentItem paymentItem;
}

@Getter
@RequiredArgsConstructor
public class PaymentResult {
    private final Long paymentId;
    private final Long paymentMethodId;
    private final PaymentMethod paymentMethod;
    private final PaymentItem paymentItem;
    private final String message;
    private final boolean success;
}
  • 각각 결제 수단, 결제 상품, 결제 커맨드, 결제결과를 저장하는 클래스이다.
  • 결제 커맨드에는 회원 아이디, 결제 수단 아이디, 결제수단 타입, 결제 상품 정보가 저장되어 있다.
  • 결제 수단은 카드, 페이가 존재하며 중복되지 않는 아이디로 관리되고 있다고 가정한다. 

PaymentStrategy

// 결제 전략 인터페이스
public interface PaymentStrategy {
    PaymentResult pay(PaymentCommand command);
}

// 카드 결제 전략 구현체
@RequiredArgsConstructor
@Service
public class CardPaymentStrategy implements PaymentStrategy {

    private final CardPaymentApi cardPaymentApi; 
    private final CardPaymentMethodRepository cardPaymentMethodRepository;  

    @Override
    public PaymentResult pay(PaymentCommand command) {
        ... 결제 로직
    }
}

// 페이 결제 전략 구현체
@RequiredArgsConstructor
@Service
public class PayPaymentStrategy implements PaymentStrategy {

    private final PayPaymentApi PayPaymentApi;  
    private final PayPaymentMethodRepository payPaymentMethodRepository;  

    @Override
    public PaymentResult pay(PaymentCommand command) {
        ... 결제 로직
    }
}
  • 결제 커맨드를 받아서 결과를 반환하는 전략 패턴 인터페이스이다.
  • 결제 수단은 카드, 페이가 있으므로 각각 결제 전략에 따라서 결제를 수행하는 구현체를 생성한다.
  • 결제 관련 자세한 로직은 생략한다. (전략 패턴 활용에 초점을 맞춰서 해당 내용은 제외하겠습니다.)

BookPaymentUseCase

@RequiredArgsConstructor
@Service
public class BookPaymentUseCase {

    private final CardPaymentStrategy cardPaymentStrategy;
    private final PayPaymentStrategy payPaymentStrategy;

    public PaymentResult pay(PaymentCommand command) {
        PaymentResult paymentResult = getPaymentStrategy(command.getPaymentMethod())
            .pay(command);

        // 결제 이후에 작업들

        return paymentResult;
    }

    private PaymentStrategy getPaymentStrategy(PaymentMethod paymentMethod) {
        switch (paymentMethod) {
            case PAY:
                return payPaymentStrategy;
            case CARD:
                return cardPaymentStrategy;
            default:
                throw new PaymentStrategyNotFoundException();
        }
    }
}
  • 도서를 구매하는 역할을 가진 BookPaymentUseCase 클래스에서 결제 전략에 따라 도서를 구매하고 결과를 반환한다.
  • 중요하게 보아야할 부분은 getPaymentStrategy(...)메소드 인데 paymentMethod 값에 따라서 결제 전략을 반환한다.
  • 결제 전략이 존재하지 않으면 PaymentStrategyNotFoundException 예외가 발생한다.
  • 하지만 BookPaymentUseCase는 OCP(개방 폐쇄 법칙, 확장에는 열려있고 변경에는 닫혀있다.)를 지키지 않는다.  
  • 새로운 전략이 추가될 때마다 BookPaymentUseCase를 수정해주어야 한다.
  • 아래와 같이 OCP를 지키도록 개선해보자

PaymentStrategyProvider

public interface PaymentStrategy {
    PaymentResult pay(PaymentCommand command);

    PaymentMethod getPaymentMethod(); // 메소드 추가
}


@Service
public class PaymentStrategyProvider {
    private final Map<PaymentMethod, PaymentStrategy> provider;

    // PaymentStrategyProvider는 Bean이기 때문에 의존성 주입 시 PaymentStrategy로 구현한 클래스를 List로 주입받을 수 있다.
    public PaymentStrategyProvider(List<PaymentStrategy> paymentStrategies) {
        this.provider = paymentStrategies
            .stream()
            .collect(Collectors.toMap(PaymentStrategy::getPaymentMethod, Function.identity()));
    }
    
    // paymentMethod를 매개변수로 받아서 PaymentStrategy를 반환한다. 
    // PaymentStrategy 구현체가 존재하지 않으면PaymentStrategyNotFoundException 예외를 발생시킨다
    public PaymentStrategy provide(PaymentMethod paymentMethod) {
        return Optional.ofNullable(provider.get(paymentMethod))
            .orElseThrow(PaymentStrategyNotFoundException::new);
    }
}
  • PaymentStratgy 인터페이스에 PaymentMethod getPaymentMethod(); 메소드를 추가하고 각 구현체에 반영한다.
  • PaymentStrategyProvider는 paymentMethod 값에 따라서 PaymentStratgy 구현체를 반환하는 역할을 가진 클래스이다.
  •  PaymentStrategyProvider는 Bean이기 의존성 주입 시 PaymentStrategy로 구현한 클래스를 List로 주입받을 수 있다.
  • 해당 리스트를 Map으로 변환하는데 key 값은 각 구현체의 getPaymentMethod() 값이고, 값으로 결제 전략 구현체가 저장되도록 한다. 
  • provide(...) 메소드를 호출해서 기존에 swtich문을 대체한다.
  • 이로서 새로운 결제 전략이 추가되더라도 코드 수정을 하지 않아도 된다.
  • 이는 스프링의 의존성 주입 특성을 활용하는 것인데, 의존성 주입 시 생성자에 특정 인터페이스를 받도록 선언해두면 그에 따른 구현체를 주입시켜준다. 그리고 리스트 형태로도 받을 수 있다.
  • 스프링이 아니더라도 PaymentStrategyConfig를 만들어 PaymentStrategyProvider를 직접 생성해주는 방식으로 사용해도 된다.  

BookPaymentUseCase 개선

@RequiredArgsConstructor
@Service
public class BookPaymentUseCase {

    private final PaymentStrategyProvider paymentStrategyProvider;

    public PaymentResult pay(PaymentCommand command) {
        PaymentResult paymentResult = getPaymentStrategy(command.getPaymentMethod())
            .pay(command);

        // 결제 이후에 작업들

        return paymentResult;
    }

    private PaymentStrategy getPaymentStrategy(PaymentMethod paymentMethod) {
        return paymentStrategyProvider.provide(paymentMethod);
    }
}
  • PaymentStrategyProvider를 활용하도록 수정하자
  • 고수준 모듈 (BookPaymentUseCase)은 저수준 모듈 (결제 모듈들..)을 직접적으로 의존하지 않고, 새로운 전략이 추가되더라도 고수준 모듈 자체를 수정하지 않아도 된다. (OCP) 

결제 전략 패턴을 적용한 클래스 다이어그램

PaymentStrategyProvider  테스트 코드

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.Arrays;

@DisplayName("PaymentStrategyProvider 테스트 ")
class PaymentStrategyProviderTest {

    @Test
    public void 카드결제_전략조회_성공() {
        PaymentStrategyProvider paymentStrategyProvider = new PaymentStrategyProvider(
            Arrays.asList(new CardPaymentStrategy(), new PayPaymentStrategy()));

        PaymentStrategy paymentStrategy = paymentStrategyProvider.provide(PaymentMethod.CARD);

        Assertions.assertThat(paymentStrategy.getClass()).isEqualTo(CardPaymentStrategy.class);
    }

    @Test
    public void 페이결제_전략조회_성공() {
        PaymentStrategyProvider paymentStrategyProvider = new PaymentStrategyProvider(
            Arrays.asList(new CardPaymentStrategy(), new PayPaymentStrategy()));

        PaymentStrategy paymentStrategy = paymentStrategyProvider.provide(PaymentMethod.PAY);

        Assertions.assertThat(paymentStrategy.getClass()).isEqualTo(PayPaymentStrategy.class);
    }

    @Test
    public void 원하는_결제_전략이_없을경우_예외발생() {
        PaymentStrategyProvider paymentStrategyProvider = new PaymentStrategyProvider(new ArrayList<>());

        Assertions.assertThatThrownBy(() -> paymentStrategyProvider.provide(PaymentMethod.PAY))
            .isInstanceOf(PaymentStrategyNotFoundException.class);
    }
}

 

결론

  • 개발에 정답이 있는 것은 아니지만 내가 생각하는 좋은 코드란 SOLID 원칙을 잘 준수한 코드라고 생각한다. 

해당 게시글은 내용 정리 목적으로 작성되었습니다. 틀린 내용이 있다면 언제든지 말씀해 주세요

동시성 이슈 관련해서 혼자 이것 저것 테스트해보다가 데드락 에러를 발견했다.

Caused by: java.sql.SQLTransactionRollbackException: (conn=644) Deadlock found when trying to get lock; try restarting transaction
	at org.mariadb.jdbc.export.ExceptionFactory.createException(ExceptionFactory.java:303)
	at org.mariadb.jdbc.export.ExceptionFactory.create(ExceptionFactory.java:378)
	at org.mariadb.jdbc.message.ClientMessage.readPacket(ClientMessage.java:172)
	at org.mariadb.jdbc.client.impl.StandardClient.readPacket(StandardClient.java:915)
	at org.mariadb.jdbc.client.impl.StandardClient.readResults(StandardClient.java:854)
	at org.mariadb.jdbc.client.impl.StandardClient.readResponse(StandardClient.java:773)
	at org.mariadb.jdbc.client.impl.StandardClient.execute(StandardClient.java:697)
	at org.mariadb.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:93)
	at org.mariadb.jdbc.ClientPreparedStatement.executeLargeUpdate(ClientPreparedStatement.java:342)
	at org.mariadb.jdbc.ClientPreparedStatement.executeUpdate(ClientPreparedStatement.java:319)
	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61)
	at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.executeUpdate(HikariProxyPreparedStatement.java)
	at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:194)
  • "SQLTransactionRollbackException 예외가 발생했고 Lock을 얻는 과정에서 Deadlock이 발생했다" 라는 메시지였다.
  • 테스트는 다음과 같다. 
  • 상품 응모 이벤트가 있고 정해진 수만큼만 응모가 가능하다.
  • 예를 들어, 선착순으로 100명까지 상품 응모가 가능한 이벤트가 있다고 가정하고 해당 이벤트에서 동시에 100번 응모했을 때 원하는 결과가 나오는지 테스트했다.
  • 엔티티는 EventProduct, ProductDrawEvent, ProductDrawEventHistory가 있고, 각각 이벤트 상품, 상품 응모 이벤트, 상품 응모 이벤트 이력 엔티티이다.
  •  특정 유저가 이벤트 응모에 참가하면 ProductDrawEvent의 drawQuantity가 한개 차감된다. drawQuantity가 0이 된 경우 상품 응모가 모두 소진되었다는 예외 메시지가 반환된다.

EventProductProductDrawEventProductDrawEventHistory 엔티티

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Table(name = "event_product")
@Entity
public class EventProduct {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @Column(name = "name", nullable = false)
    private String name;

    public static EventProduct of(String name) {
        return new EventProduct(null, name);
    }
}

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Table(name = "product_draw_event")
@Entity
public class ProductDrawEvent {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @ManyToOne
    @JoinColumn(name = "event_product_id", nullable = false)
    private EventProduct eventProduct;

    @Column(name = "product_quantity", nullable = false)
    private Long productQuantity;

    @Column(name = "draw_quantity", nullable = false)
    private Long drawQuantity;

    public boolean hasEventProductDrawQuantities() {
        return this.drawQuantity > 0;
    }

    public ProductDrawEventHistory draw(Long userId) {
        if (hasEventProductDrawQuantities()) {
            drawQuantity--;
            return ProductDrawEventHistory.of(userId, this);
        }

        throw new IllegalStateException("상품 응모 개수가 모두 소진되었습니다.");
    }

    public static ProductDrawEvent of(EventProduct eventProduct, Long productQuantity, Long drawQuantity) {
        return new ProductDrawEvent(null, eventProduct, productQuantity, drawQuantity);
    }
}

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Table(name = "product_draw_event_history",
    uniqueConstraints = {
        @UniqueConstraint(
            name = "uk_product_draw_event_history_product_draw_event_id_user_id",
            columnNames = {"product_draw_event_id", "user_id"}
        )
    }, indexes = @Index(name = "idx_product_draw_event_history_user_id", columnList = "user_id"))
@Entity
public class ProductDrawEventHistory {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @ManyToOne
    @JoinColumn(name = "product_draw_event_id", nullable = false)
    private ProductDrawEvent productDrawEvent;

    @Column(name = "user_id", nullable = false)
    private Long userId;

    @Column(name = "is_winner")
    private boolean isWinner;

    @Column(name = "create_at")
    private LocalDateTime createAt;

    public static ProductDrawEventHistory of(Long userId, ProductDrawEvent productDrawEvent) {
        return new ProductDrawEventHistory(null, productDrawEvent, userId, false, LocalDateTime.now());
    }
}

ProductDrawEventEnterUseCase

  • 상품 응모 이벤트 참가 유스케이스
@RequiredArgsConstructor
@Service
public class ProductDrawEventEnterUseCase {
    private final ProductDrawEventRepository productDrawEventRepository;
    private final ProductDrawEventHistoryRepository productDrawEventHistoryRepository;

    @Transactional
    public Long draw(Long userId, Long productDrawEventId) {
        ProductDrawEvent productDrawEvent = productDrawEventRepository.findById(productDrawEventId)
            .orElseThrow(() -> new IllegalStateException("존재하지 않는 이벤트 입니다."));

        return productDrawEventHistoryRepository.save(productDrawEvent.draw(userId))
            .getId();
    }
}
  • draw 메소드 실행 시 상품 응모 이벤트를 조회하고 남은 응모 회수가 있으면 상품 응모 이벤트 내역을 생성한다.
  • 동시성 이슈가 발생하는지 확인하기 위해서 비관적 락을 걸지 않고 productDrawEvent를 조회하게 했다.
  •  그리고 아래와 같은 테스트 코드를 작성했다.
@SpringBootTest
class ProductDrawEventEnterUseCaseTest {

    @Autowired
    public EventProductRepository eventProductRepository;

    @Autowired
    public ProductDrawEventRepository productDrawEventRepository;

    @Autowired
    public ProductDrawEventHistoryRepository productDrawEventHistoryRepository;

    @Autowired
    public ProductDrawEventEnterUseCase productDrawEventEnterUseCase;

    @BeforeEach
    public void setup() {
        productDrawEventHistoryRepository.deleteAll();
        productDrawEventRepository.deleteAll();
        eventProductRepository.deleteAll();
    }

    @Test
    public void 동시에_1000번_이벤트상품_응모_요청_성공_테스트() throws InterruptedException {
        final String eventProductName = "Apple 맥북 프로 14 M2";
        final long productQuantity = 10L;
        final long drawQuantity = 1000L;

        final int nThreads = 20;

        final EventProduct eventProduct = EventProduct.of(eventProductName);

        // 미리 이벤트 상품과 상품 응모 이벤트를 생성한다.
        eventProductRepository.save(eventProduct);
        ProductDrawEvent productDrawEvent = productDrawEventRepository.save(ProductDrawEvent.of(eventProduct, productQuantity, drawQuantity));

        List<Callable<Long>> tasks = new ArrayList<>();

        // task 목록을 생성한다
        for (long index = 0; index < drawQuantity; index++) {
            final long userId = index;
            tasks.add(() -> productDrawEventEnterUseCase.draw(userId, productDrawEvent.getId()));
        }

        // 스레드 개수롤 고정적으로 nThreads 만큼 생성
        ExecutorService executorService = Executors.newFixedThreadPool(nThreads);

        // 멀티 스레드로 동시에 상품 응모를 진행한다.
        List<Future<Long>> futures = executorService.invokeAll(tasks);

        // 상품 응모 결과 확인
        for (Future<Long> future : futures) {
            // 예외가 발생하지 않았는지 확인
            assertThatCode(future::get)
                .doesNotThrowAnyException();
        }

        // 작업 종료
        executorService.shutdown();

        ProductDrawEvent resultProductDrawEvent = productDrawEventRepository.findById(eventProduct.getId()).get();
        Assertions.assertThat(resultProductDrawEvent.getDrawQuantity()).isEqualTo(0L);
    }
}
  • 위 테스트를 실행했을 때 레이스 컨디션 증상이 발생해서 동시성 이슈가 발생할 것으로 예상했는데 데드락 예외가 발생하는 것이다.
  • ProductDrawEventEnterUseCase를 살펴보았을 때 별도로 Lock을 거는 부분이 없었는데 데드락 예외가 발생한 것이다..... (이게 무슨일이지)
  • 그래서 찾아보았는데 충격적인 사실을 알았다.
테이블에 데이터를 insert 할 때 외래키 제약조건이 걸린 외부 테이블의 id를 저장하는 경우 해당 외부 테이블 행에 공유락을 건다. 

즉 자식 테이블에서 데이터 insert 시 실행 시 부모 테이블 행에 공유락이 걸리는 것이다. 
  • ProductDrawEventEnterUseCase 코드를 보면 productDrawEventHistoryRepository.save(productDrawEvent.draw(userId)) 부분이 있는데 productDrawEventHistory를 insert하고 productDrawEvent의 drawQuantity를 한개 차감한다. 
  • 단일 스레드로 한개씩 수행된다면 문제 없지만 동시에 여러 스레드가 해당 메소드를 실행하면 deadlock 문제가 발생하는 것이다.
  • productDrawEventHistory insert 시 productDrawEvent의 id (외래키)를 저장하고 이때 해당 행에 공유락이 걸린다.
순서 트랜잭션 1 트랜잭션 2
1
insert into product_draw_event_history (user_id, product_draw_event_id) values (1, 1);

id 1번 product_draw_event에 공유락 선점
 
2   insert into product_draw_event_history (user_id, product_draw_event_id) values (2, 1);

id 1번 product_draw_event에 공유락 선점
3 update product_draw_event 
set draw_quantity = draw_quantity - 1 
where id = 1;
id 1번 product_draw_event에 쓰기락 대기
 
4   update product_draw_event 
set draw_quantity = draw_quantity - 1 
where id = 1;
id 1번 product_draw_event에 쓰기락 대기
  • id 1번  product_draw_event 행에 공유 락이 걸린 상태에서 트랜잭션 1, 2 모두 id 1번  product_draw_event 에 쓰기 락을 걸려고 하기 때문에 데드락이 발생한다.
  • 더 정확하게 확인해보기 위해 db client에서 테스트해보았다.
# tx1
start transaction;

insert into product_draw_event_history (create_at, is_winner, user_id, product_draw_event_id)
values (now(), false, 1, 1);
  • 트랜잭션 생성 후 1번 상품 응모 이벤트에 대해서 응모 이력을 생성한다. 
  • 커밋하지 않으면 1번 상품 응모 이벤트에 공유락이 걸린 상태로 대기한다.
# tx2
start transaction;

update product_draw_event
set draw_quantity = draw_quantity - 1
where id = 1;
  • 새로운 트랜잭션에서 1번 상품 응모 이벤트의 남은 응모 회수를 1 차감하는 update 문을 실행한다.
  • update 문 실행 시 쓰기락을 걸기 때문에 1번 상품 응모 이벤트에 공유락이 걸려 있으면 공유락이 해제 될 때까지 대기한다. 

  •  1번 트랜잭션을 커밋하기 전까지 2번 트랜잭션의 update 작업은 대기하게 된다.

 

결론

  • insert 시 foreign key가 걸린 id가 있다면 해당 부모 테이블에 공유락이 걸린다. (dbms 마다 다름)
  • 혹여나 알 수 없는 데드락 증상이 발생한다면 해당 이슈를 참고해보자.

해당 게시글은 내용 정리 목적으로 작성되었습니다. 틀린 내용이 있다면 언제든지 말씀해 주세요

목차

1. Callable, Future, ExecutorService, Executors  설명
2. 비동기 기능 테스트 예제 
3. 결론


API를 개발하다 보면 동시성 테스트를 해야하는 경우가 있다.
예를 들어서 포인트 차감, 선착순 이벤트 응모, 재고 관리가 대표적인 예다.
자바 서블릿은 멀티 스레드 환경을 제공하기 때문에 동시에 여러 요청을 받을 수 있다.
이 경우 적절한 조치를 취해서 동시에 요청한 경우라도 데이터 일관성, 무결성을  유지해야한다.
이러한 비동기 요청에 대해서 테스트할 수 있는 방법을 알아보자

1. Callable, Future, ExecutorService, Executors

  • 위 기능은 java.util.concurrent 에서 제공하며 JDK 1.5부터 지원되고 Java의 동시성 프로그래밍을 보다 효율적으로 관리하기 위해 도입된 클래스와 인터페이스이다.
  • 각 인터페이스 및 클래스에 대해서 간단하게 숙지하고 예제를 보면 쉽게 이해가 가능하다

1) Callable

  • Callable은 자바의 인터페이스로, 비동기 작업을 수행하고 결과를 반환하는 메서드를 정의한다.
  • Runnable 인터페이스와 유사하지만, Runnable은 반환값이 없고 예외를 던질 수 없는 반면, Callable은 반환값이 있고 예외를 던질 수 있다.
@FunctionalInterface
public interface Callable<V> {

    //비동기 작업을 수행하고 결과를 반환한다
    V call() throws Exception;
}

2) Future

  • Future는 비동기 작업의 결과를 나타내는 인터페이스이다.
  • 비동기 작업이 완료될 때까지 기다리고 작업의 결과를 확인할 수 있다.
public interface Future<V> {
	
    // 작업을 취소한다.
    boolean cancel(boolean mayInterruptIfRunning);

    // 작업이 취소되었는지 확인한다
    boolean isCancelled();
    
    //작업이 완료되었는지 확인한다.
    boolean isDone();

    //작업이 완료될 때까지 기다렸다가 결과를 반환한다
    V get() throws InterruptedException, ExecutionException;
    
    //지정된 시간 동안 기다렸다가 결과를 반환한다. 시간이 초과되면 TimeoutException을 던진다.
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}

3) ExecutorService

  • ExecutorService는 Executor의 하위 인터페이스로, 더 많은 기능을 제공하여 스레드 풀을 관리하고, 비동기 작업을 제출하고, 종료하는 등의 작업을 할 수 있다.
  • Runnable, Callable 인터페이스를 작업으로 등록이 가능하다.
  • 여러 메소드가 있지만 크게 invokeAll, shutdown 메소드를 사용한다
   // tasks를 받아 동시에 실행하고 그 결과를 List<Future<T>>에 담아서 반환한다
   <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
        throws InterruptedException;

    // 작업 종료
    void shutdown();
  • 미리 정의해둔 task를 동시에 실행하고 모두 종료될 때 까지 대기한다.
  • 설정해둔 스레드 만큼 작업을 동시에 수행한다.

3) Executors

  • Executors는 다양한 유형의 ExecutorService를 생성할 수 있는 유틸리티 클래스이다
  • 여러 종류의 스레드 풀을 쉽게 만들 수 있는 정적 팩토리 메서드를 제공한다.
  • newFixedThreadPool(int nThreads): 고정된 크기의 스레드 풀을 생성한다
  • newCachedThreadPool(): 필요에 따라 새로운 스레드를 생성하고, 이전 스레드를 재사용하는 스레드 풀을 생성한다
  • newSingleThreadExecutor(): 단일 스레드를 사용하여 작업을 순차적으로 실행하는 ExecutorService를 생성한다
  • newScheduledThreadPool(int corePoolSize): 지정된 수의 스레드를 가진 스케줄링 가능한 스레드 풀을 생성한다.

newScheduledThreadPool 사용 예제

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class ScheduledThreadPoolExample {
    public static void main(String[] args) {
        ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(3);

        Runnable task = () -> System.out.println("Executing task at " + System.currentTimeMillis() + " by " + Thread.currentThread().getName());

        // 5초 후에 작업 실행
        scheduledThreadPool.schedule(task, 5, TimeUnit.SECONDS);

        // 초기 지연 후 10초마다 작업 실행
        scheduledThreadPool.scheduleAtFixedRate(task, 5, 10, TimeUnit.SECONDS);

        // 초기 지연 후 10초 간격으로 작업 실행 (이전 작업 종료 후 10초 지연)
        scheduledThreadPool.scheduleWithFixedDelay(task, 5, 10, TimeUnit.SECONDS);
    }
}

 

 

2. 비동기 기능 테스트 예제 

  • 회원 포인트 차감에 대한 유스케이스를 만들어서 동시에 포인트가 차감될 경우 포인트가 일관성 있게 차감되는지 테스트한다
    • 특정 회원이 110 포인트를 갖고 있고 1포인트 씩 100번을 동시에 차감했을 경우 10포인트가 남아있는지 테스트한다

테스트 환경

  • 스프링 부트 3.3.2
  • JPA
  • H2
  • JDK 17
  • Junit 5, assertj
  • lombok

build.gradle dependencies

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

application.yml

spring:
  profiles:
    active: test
  datasource:
    url: jdbc:h2:mem:testdb;MODE=MariaDB;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
    username: sa
    password:
    driverClassName: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: update

UserPoint

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
@Entity
public class UserPoint {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;
    private Long points;

    @Column(name = "user_id")
    private Long userId;

    public void use(Long usePoints) {
        if (this.points < usePoints) {
            throw new IllegalArgumentException("포인트가 부족합니다");
        }
        this.points -= usePoints;
    }
}

UserRepository

public interface UserPointRepository extends JpaRepository<UserPoint, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<UserPoint> findWithExclusiveLockByUserId(Long userId);

    Optional<UserPoint> findByUserId(Long userId);

}

UserPointDeductUseCase

@Slf4j
@RequiredArgsConstructor
@Service
public class UserPointDeductUseCase {
    private final UserPointRepository userPointRepository;

    @Transactional
    public Long usePoints(Long userId, Long usePoints) {

//      비관적 락을 걸지 않았을 때 포인트 차감을 확인 하기 위해 잠시 주석 
//        UserPoint userPoint = userPointRepository.findWithExclusiveLockByUserId(userId)
//            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 고객입니다."));

        UserPoint userPoint = userPointRepository.findByUserId(userId)
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 고객입니다."));

        userPoint.use(usePoints);
        Long remainingPoints = userPoint.getPoints();

        log.info("userId : {} usePoints  : {}, remainingPoints : {}", userId, usePoints, remainingPoints);

        return remainingPoints;
    }
}

UserPoinstDeductUseCaseTest

@ActiveProfiles("test")
@SpringBootTest
class UserPointDeductUseCaseTest {

    @Autowired
    public UserPointDeductUseCase userPointDeductUseCase;

    @Autowired
    public UserPointRepository userPointRepository;

    @BeforeEach
    public void setup() {
        userPointRepository.deleteAll();
    }

    @Test
    public void 동시에_포인트_차감_테스트() throws InterruptedException {
        // given
        long userId = 1;
        long remainingPoints = 110L;
        int taskCount = 100;
        long usePoints = 1L;
        int threads = 10;

        // 스레드 실행기 생성
        ExecutorService executorService = Executors.newScheduledThreadPool(threads);

        // 미리 회원 포인트 엔티티를 생성한다.
        userPointRepository.save(new UserPoint(null, remainingPoints, userId));

        List<Callable<Long>> tasks = new ArrayList<>();

        //태스크를 미리 정의한다.
        for (int index = 0; index < taskCount; index++) {
            tasks.add((() -> userPointDeductUseCase.usePoints(userId, usePoints)));
        }

        // when
        //회원 포인트 차감 태스크 호출
        List<Future<Long>> futures = executorService.invokeAll(tasks);


        //then
        // 결과 확인
        for (Future<Long> future : futures) {
            //예외가 발생하지 않았는지 확인
            // 포인트가 부족하면 예외가 발생한다.
            assertThatCode(future::get)
                .doesNotThrowAnyException();
        }

        // 스레드 실행기 종료
        executorService.shutdown();

        UserPoint resultsUserPoint = userPointRepository.findByUserId(userId).get();
        Assertions.assertThat(resultsUserPoint.getPoints()).isEqualTo(remainingPoints - (taskCount * usePoints));
    }
}
  • 두가지를 테스트해볼 건데 우선 UserPointDeductUseCase에 비관적락을 걸지 않은 상태에서 테스트를 실행해보자
...
2024-07-31T16:12:16.676+09:00  INFO 10000 --- [pool-2-thread-8] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 105
2024-07-31T16:12:16.676+09:00  INFO 10000 --- [pool-2-thread-4] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 102
2024-07-31T16:12:16.677+09:00  INFO 10000 --- [pool-2-thread-2] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 104
2024-07-31T16:12:16.677+09:00  INFO 10000 --- [pool-2-thread-6] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 104
2024-07-31T16:12:16.678+09:00  INFO 10000 --- [pool-2-thread-1] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 104
2024-07-31T16:12:16.678+09:00  INFO 10000 --- [pool-2-thread-8] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 104
2024-07-31T16:12:16.678+09:00  INFO 10000 --- [pool-2-thread-7] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 104
2024-07-31T16:12:16.679+09:00  INFO 10000 --- [pool-2-thread-3] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 103
2024-07-31T16:12:16.679+09:00  INFO 10000 --- [pool-2-thread-1] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 101
2024-07-31T16:12:16.679+09:00  INFO 10000 --- [pool-2-thread-5] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 101
2024-07-31T16:12:16.679+09:00  INFO 10000 --- [pool-2-thread-8] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 101
2024-07-31T16:12:16.680+09:00  INFO 10000 --- [pool-2-thread-4] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 103
2024-07-31T16:12:16.680+09:00  INFO 10000 --- [pool-2-thread-6] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 100
2024-07-31T16:12:16.681+09:00  INFO 10000 --- [pool-2-thread-8] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 104
2024-07-31T16:12:16.681+09:00  INFO 10000 --- [pool-2-thread-7] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 104
2024-07-31T16:12:16.681+09:00  INFO 10000 --- [pool-2-thread-9] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 102
2024-07-31T16:12:16.681+09:00  INFO 10000 --- [pool-2-thread-6] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 102
2024-07-31T16:12:16.682+09:00  INFO 10000 --- [pool-2-thread-2] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 103
2024-07-31T16:12:16.682+09:00  INFO 10000 --- [ool-2-thread-10] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 103
2024-07-31T16:12:16.683+09:00  INFO 10000 --- [pool-2-thread-3] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 101
2024-07-31T16:12:16.683+09:00  INFO 10000 --- [pool-2-thread-6] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 100
2024-07-31T16:12:16.683+09:00  INFO 10000 --- [pool-2-thread-7] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 100

Expected :10L
Actual   :101L
  • 10개의 스레드를 생성하고 총 100번 1포인트씩 차감한다.
  • [pool-2-thread-7] 부분을 확인해보면 1~10 까지 스레드 풀에 있는 스레드를 돌아가면서 사용하는 것을 확인할 수 있다.
  • 100포인트가 차감되어 10포인트가 남은 것을 예상했지만 실제로는 101 포인트가 남아서 테스트가 실패했다.
  • 테스트가 실패한 이유는 비동기로 UserPointDeductUseCase.usePoints()를 호출해서 동시성 이슈가 발생했기 때문이다.
  • 하나의 자원을 동시에 수정할 때 순차적으로 값을 수정하지 않기 때문에 마지막으로 업데이트한 값으로 갱신되는데 이 때문에 원하는 결과가 나오지 않은 것이다.
  • 이를 해결하기 위해서 각 트랜잭션이 특정 자원을 수정할 때 락을 걸어야 한다. 
  • 락을 거는 동안 자원을 선점하고 트랜잭션이 종료되면 다음 트랜잭션이 해당 자원을 선점하는 방식이다.
  • 이번 테스트에서는 비관적 락을 걸어 동시성 이슈를 해결할 것이다.
  • UserPointDeductUseCase에 주석쳤던 부분을 해제해서 비관적락이 걸린 상태로 다시 테스트를 실행해보자.
...
2024-07-31T16:19:55.784+09:00  INFO 59196 --- [pool-2-thread-2] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 17
2024-07-31T16:19:55.785+09:00  INFO 59196 --- [pool-2-thread-6] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 16
2024-07-31T16:19:55.785+09:00  INFO 59196 --- [pool-2-thread-1] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 15
2024-07-31T16:19:55.786+09:00  INFO 59196 --- [pool-2-thread-7] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 14
2024-07-31T16:19:55.787+09:00  INFO 59196 --- [pool-2-thread-8] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 13
2024-07-31T16:19:55.788+09:00  INFO 59196 --- [pool-2-thread-9] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 12
2024-07-31T16:19:55.789+09:00  INFO 59196 --- [pool-2-thread-5] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 11
2024-07-31T16:19:55.790+09:00  INFO 59196 --- [pool-2-thread-3] o.e.s.service.UserPointDeductUseCase     : userId : 1 usePoints  : 1, remainingPoints : 10
  • 포인트가 순차적으로 1포인트씩 총 100포인트가 차감된 것을 확인할 수 있다.

 

3. 결론


ExecutorService는 동시성 이슈를 테스트하기에 매우 유용하다.
해당 라이브러리가 존재하지 않았다면 직접 API를 호출하는 방식으로 테스트를 해야했을 것이다.
이런 수고를 덜려면 ExecutorService 사용법을 잘 숙지해보자

해당 게시글은 내용 정리 목적으로 작성되었습니다. 틀린 내용이 있다면 언제든지 말씀해 주세요

목차

1. k6란
2. 테스트 시나리오 
3. k6 설치 방법 (docker) 
4. 결론



API를 개발할 때는 부하 테스트가 필수적이다. 예를 들어, 선착순 티켓팅이나 이벤트 응모 기능의 경우, 갑작스러운 사용자 접속 증가에 대비해 서버의 안정성을 테스트하고 최적의 인프라 환경을 구축해야 한다

그러려면 예상 접속자를 산정하고 부하 테스트를 해보아야 하는데, 그럴 때 유용한 툴이 k6이다.

1. k6란

  • k6는 오픈 소스 성능 테스트 도구이다.
  • 주로 웹 애플리케이션이나 API의 성능을 테스트하고 부하를 시뮬레이션하는 데 사용된다.
  • Go 언어로 개발되어 높은 성능과 효율성을 제공하며, 대규모 테스트를 빠르고 효과적으로 실행할 수 있다.

2. 테스트 시나리오

  • 사용자 접속 로그를 저장하는 Spring Boot API가 있다. 
  • 엔드 포인트는 POST /api/v1/user-access-logs 이며 body에 userId를 application/json 방식으로 저장한다.
  • userId는 1 ~ 10000 사이로 무작위 값이 입력된다.
  • 50~ 100명의 가상 유저가 해당 API를 1분에 1만번 호출하는 부하테스트를 진행한다.

3. k6 설치 방법 (docker)

1) docker 설치 (생략)

2) 프로젝트 구조 설정

my-k6-project/
├── docker-compose.yml
├── scripts/
│   └── myscript.js
  • 위 와 같이 my-k6-project 디렉토리를 생성하고 하위에 docker-compose.yml와 scripts/myscript.js를 생성한다
  • docker-compose.yml
# docker-compose.yml
version: '3.8'

services:
  k6:
    image: loadimpact/k6
    volumes:
      - ./scripts:/scripts
    entrypoint: ""
    command: ["k6", "run", "/scripts/myscript.js"]
  • scripts/myscript.js
// scripts/myscript.js

import http from 'k6/http';
import { check } from 'k6';

function getRandomUserId(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
}

export let options = {
    scenarios: {
        constant_request_rate: {
            executor: 'ramping-arrival-rate',
            startRate: 167,  // 초당 약 167개의 요청으로 시작 (1분 동안 10,000 요청)
            timeUnit: '1s',
            preAllocatedVUs: 50,  // 초기 할당 VUs 수
            maxVUs: 100,  // 최대 VUs 수
            stages: [
                { duration: '1m', target: 167 },  // 1분 동안 초당 약 167개의 요청을 유지
            ],
        },
    },
};

export default function () {
    const userId = getRandomUserId(1, 10000);
    const payload = JSON.stringify({
        userId: userId
    });
    const params = {
        headers: {
            'Content-Type': 'application/json',
        },
    };

    let response = http.post('http://host.docker.internal:8080/api/v1/user-access-logs', payload, params);

    check(response, {
        'status is 204': (r) => r.status === 204,
    });
}
  • 아래 github에서 k6 테스트를 위한 k6-stress-test-sample-project.git를 clone한 다음 실행한다. (JDK17 필요)
https://github.com/Bamdule/k6-stress-test-sample-project.git
  • k6-stress-test-sample-project를 8080포트로 실행하는 것에 성공했다면 my-k6-project 디렉토리로 이동해서 docker-compose up 명령을 실행한다
  • (다른 api를 테스트하고 싶으면 myscript.js 부분에 테스트 요청 url을 변경하면 된다.)
docker-compose up

부하 테스트 결과

k6-1  |      ✓ status is 204
k6-1  |
k6-1  |      checks.........................: 100.00% ✓ 10017      ✗ 0
k6-1  |      data_received..................: 542 kB  9.0 kB/s
k6-1  |      data_sent......................: 1.8 MB  30 kB/s
k6-1  |      dropped_iterations.............: 3       0.049966/s
k6-1  |      http_req_blocked...............: avg=27.26µs min=1.63µs  med=4.47µs  max=14.78ms  p(90)=6.27µs  p(95)=7.32µs
k6-1  |      http_req_connecting............: avg=21.56µs min=0s      med=0s      max=14.71ms  p(90)=0s      p(95)=0s
k6-1  |      http_req_duration..............: avg=52ms    min=41.45ms med=50ms    max=364.05ms p(90)=56.16ms p(95)=57.17ms
k6-1  |        { expected_response:true }...: avg=52ms    min=41.45ms med=50ms    max=364.05ms p(90)=56.16ms p(95)=57.17ms
k6-1  |      http_req_failed................: 0.00%   ✓ 0          ✗ 10017
k6-1  |      http_req_receiving.............: avg=33.46µs min=3.96µs  med=27.77µs max=5.19ms   p(90)=56.15µs p(95)=65.21µs
k6-1  |      http_req_sending...............: avg=36.32µs min=7.12µs  med=31.64µs max=5.14ms   p(90)=52.46µs p(95)=60.71µs
k6-1  |      http_req_tls_handshaking.......: avg=0s      min=0s      med=0s      max=0s       p(90)=0s      p(95)=0s
k6-1  |      http_req_waiting...............: avg=51.93ms min=41.41ms med=49.93ms max=363.94ms p(90)=56.09ms p(95)=57.09ms
k6-1  |      http_reqs......................: 10017   166.835382/s
k6-1  |      iteration_duration.............: avg=52.18ms min=41.54ms med=50.17ms max=366.28ms p(90)=56.33ms p(95)=57.35ms
k6-1  |      iterations.....................: 10017   166.835382/s
k6-1  |      vus............................: 9       min=7        max=13
k6-1  |      vus_max........................: 53      min=53       max=53
k6-1  |
k6-1  |
k6-1  | running (1m00.0s), 000/053 VUs, 10017 complete and 0 interrupted iterations
k6-1  | constant_request_rate ✓ [ 100% ] 000/053 VUs  1m0s  167.00 iters/s
  • 위 결과 지표에 대해서 간단하게 설명하면 다음과 같다
    • 성공률: 100% 성공률을 기록
    • 성능: 평균 요청 처리 시간 52ms로, 요청 대기 시간 평균 51.93ms 최대 요청 처리 시간 364ms
    • 부하: 1분 동안 초당 약 167개의 요청을 처리
    • 가상 사용자: 평균 9명의 가상 사용자가 테스트 동안 활동했으며, 최대 53명의 VU가 사용되었음
  • Spring Boot API 요청 시 0.04초의 sleep을 주어서 API 응답 지연시간을 조절했다.
  • sleep을 길게 잡을 수록 1분에 1만번의 요청을 하지 못하기 때문에 k6 자체적으로 드롭시키는 요청이 많아진다.

4. 결론

유의미한 테스트를 진행하려면 로컬 테스트가 아닌 실제 운영 환경과 동일한 환경에서 부하테스트를 해야 한다.
AWS를 사용해서 인프라를 구축했다면 테스트용으로 복제한 다음 가상의 사용자 수를 산정하고 테스트 환경에서 부하 테스트를 하는 것이 바람직하다.

그리고 톰캣의 스레드, heap 메모리 등을 조절해서 서버 스펙에 따른 최적의 설정 값을 찾는 것도 백엔드 개발자의 역할이고, 필요하다면 서버 스펙 변경을 고려해보는 것도 좋다

+ Recent posts