1 of 19

티켓 구조대

김태익, 박기범, 이승현, 정석현

2 of 19

문제 정의

- 동시성 제어 부재로 인한 데이터 무결성 위험

- 특정 리소스에 대한 다중 요청의 동시 처리 시 발생하는 데이터 정합성 문제 및 신뢰도 저하

3 of 19

락 방식 1: LETTUCE 락

- Redis client library Lettuce

- 특징 : 비동기 지원, 연결 관리

- 장점 : 구현 용이성, 확장성, 성능

- 단점 : Pub/Sub 관련 제약, 오류 처리, 모니터링

- 고려사항 : 분산 락 구현 시 별도의 타임아웃 처리 필요, 높은 트래픽 환경에서 연결 관리 전략 수립, 장애 복구 시나리오 사전 계획 필요

4 of 19

락 방식 2: MYSQL 락

- JPA Pessimistic Lock (MySQL)

- SELECT ... FOR UPDATE 동시성 제어

- JPA에서는 @Lock(LockModeType.PESSIMISTIC_WRITE)

- 장점: DB 기반, 안정성, 구현 용이, 데이터 정합성

- 단점: 성능 병목, 확장성 제한, 대기 시간, 데드락

5 of 19

락 방식 3: REDISSON 분산 락

- Redis + Redisson 라이브러리

- 특징 : TTL, 자동 재시도, 공정성, 복구

- 장점 : 성능, 확장성, 유연성, 안정성

- 단점 : TTL 관리, 인프라, 복잡성, 네트워크

6 of 19

최종선택 및 이유

- Redisson 락 선택

- 이유: 완성도 높은 구현체, 검증된 성능, 자동화된 관리

- 다른 방식의 한계

- Lettuce : Pub/Sub 메시지 유실 가능성, 락 해제 시 경쟁 조건 발생, 복구 메커니즘 부재

- MySQL : 높은 DB 리소스 사용, 동시 요청 시 성능 저하, 확장성 한계

7 of 19

테스트 결과 비교 (1)

  • - 테스트 조건: 동시 요청 1000개, 10000개

  • 테스트 지표: 성공/실패 비율, 응답 시간, TPS

8 of 19

테스트 결과 비교 (2)

  • 전 (Basic): 동시성 제어율 약 99.0%

(1000건 기준 9건 중복 예매)

  • 후 (Lock): 동시성 제어율 99.9%

(1000건 기준 0건 중복 예매)

9 of 19

결론

- REDISSON LOCK 적용으로 데이터 정합성 확보 + 테스트 확인

- 클러스터 환경 및 대규모 요청에 최적

10 of 19

CI/CD 구성 개요

  • Github Actions + Docker 기반 자동화 파이프 라인 구축
  • Main/develop PR -> build -> test
  • Main push -> Docker image -> EC2 배포 흐름 자동화

11 of 19

CI 절차

  • Github Actions workflow 구성
  • Main과 develop 브랜치에 PR이 생성되었을 때 build 후 test 진행

name: CI - Build and Test

on:

pull_request:

branches:

- main

- develop

jobs:

build-and-test:

runs-on: ubuntu-latest

services:

redis:

image: redis:7.0-alpine

ports:

- 6379:6379

options: >-

--health-cmd "redis-cli ping"

--health-interval 10s

--health-timeout 5s

--health-retries 5

steps:

- name: Checkout code

uses: actions/checkout@v3

- name: Set up JDK 17

uses: actions/setup-java@v3

with:

java-version: '17'

distribution: 'adopt'

- name: Grant permission for Gradle

run: chmod +x ./gradlew

- name: Build Project

run: ./gradlew build -x test

- name: Run tests

run: ./gradlew clean test

env:

SPRING_REDIS_HOST: localhost

SPRING_REDIS_PORT: 6379

12 of 19

CD 절차

  • Github Actions workflow 구성
  • Main 브랜치에 merge 되었을 때 Docker-compos에 환경변수를 주입해 줄 .env 파일 생성 (환경변수는 github secrets로 주입)
  • EC2에 .env와 docker-compose 복사
  • SSH를 통해 apt 업데이트, 자바 17 설치, docker와 docker compose 설치
  • 설치된 후 docker에 docker-compose에 정의한 내용을 실행

name: CD - Deploy to EC2

on:

push:

branches:

- main

jobs:

deploy:

runs-on: ubuntu-latest

steps:

- name: Checkout code

uses: actions/checkout@v3

- name: Create .env file

run: |

echo "SPRING_DATASOURCE_URL=${{ secrets.DB_URL }}" >> docker/.env

echo "SPRING_DATASOURCE_USERNAME=${{ secrets.DB_USERNAME }}" >> docker/.env

echo "SPRING_DATASOURCE_PASSWORD=${{ secrets.DB_PASSWORD }}" >> docker/.env

echo "SPRING_DATA_REDIS_HOST=${{ secrets.REDIS_HOST }}" >> docker/.env

echo "SPRING_DATA_REDIS_PORT=${{ secrets.REDIS_PORT }}" >> docker/.env

echo "SPRING_JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }}" >> docker/.env

chmod 600 docker/.env

- name: Copy files to EC2

uses: appleboy/scp-action@master

with:

host: ${{ secrets.EC2_HOST }}

username: ubuntu

key: ${{ secrets.EC2_KEY }}

port: '22'

source: "./docker/docker-compose.yml, docker/.env"

target: "~/app"

- name: Login to DockerHub

run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin

- name: Build Docker image

run: docker build -t shlee054/ticket911-app:latest ./docker

- name: Push Docker image

run: docker push shlee054/ticket911-app:latest

- name: Deploy via SSH

uses: appleboy/ssh-action@master

with:

host: ${{ secrets.EC2_HOST }}

username: ubuntu

key: ${{ secrets.EC2_KEY }}

script: |

# System update and required packages installation

sudo apt-get update -y

sudo apt-get install -y \

openjdk-17-jdk \

ca-certificates \

curl \

gnupg

# Docker repository setup and installation

sudo install -m 0755 -d /etc/apt/keyrings

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

sudo chmod a+r /etc/apt/keyrings/docker.gpg

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt-get update -y

sudo apt-get install -y \

docker-ce \

docker-ce-cli \

containerd.io \

docker-buildx-plugin \

docker-compose-plugin \

docker-compose

# Add ubuntu user to docker group and reload group settings

sudo usermod -aG docker ubuntu

# Kill existing Java Processes

pkill -f 'java -jar' || true

cd ~/app || exit 1

sudo docker compose pull

sudo docker compose down --remove-orphans || true

sudo docker compose up -d[

13 of 19

Dockerfile & Docker-compose.yml

version: "3.9"��services:� app:� build:� context: ..� dockerfile: docker/Dockerfile� container_name: ticket911-app� ports:� - "8080:8080"� env_file:� - .env�� prometheus:� image: prom/prometheus� container_name: ticket911-prometheus� ports:� - "9090:9090"� restart: always� volumes:� - ./prometheus/prometheus-prod.yml:/etc/prometheus/prometheus.yml�� grafana:� image: grafana/grafana� container_name: ticket911-grafana� ports:� - "3000:3000"�

FROM gradle:8.12-jdk17 AS build�WORKDIR /app��COPY build.gradle settings.gradle ./�COPY src ./src��RUN gradle bootJar --no-daemon��FROM openjdk:17-jdk-slim�WORKDIR /app��COPY --from=build /app/build/libs/ticket911-0.0.1-SNAPSHOT.jar .��EXPOSE 8080��ENTRPOINT ["java", "-jar", "ticket911-0.0.1-SNAPSHOT.jar"]

14 of 19

트러블슈팅 - 서비스 레이어 분리 이슈

  • 발단: 기존 서비스 레이어에서 타 도메인의 기능을 참조하는 것을 분리하고 싶어 새로운 구조를 고민
  • 문제: 팀원 모두가 정확한 이해없이 레이어를 분리하다보니, 이전과 다를 게 없는 의미없는 구조가 됨
  • 해결: 각 도메인 서비스에서는 해당 도메인의 Repository를 참조하고 검증과 같은 도메인 로직들을 수행하는 메서드들이 위치, Application Service Layer에서는 필요한 도메인 서비스를 주입 받아 Facade Pattern이나 Usecase와 유사하게 조합하는 방식으로 해결
  • 결과: 외부 Repository의 의존도를 줄일 수 있었고, 각 레이어의 책임이 명확하게 개선, 추가적으로 코드 가독성도 높아지고 코드의 응집도가 향상

15 of 19

트러블슈팅 - 동시성 제어 실패 이슈

  • 발단: 공연 예매 시스템에서 동일 좌석에 대해 100개 이상의 요청을 JMeter를 통해 진행

  • 문제: 100건 이하의 동시 요청에서는 정상적으로 1건만 성공하고 나머지는 실패 처리 되었으나, 동시 요청이 100건을 초과하면 2~3건의 예매가 동시에 성공하는 문제가 발생.

즉, 동일 좌석에 대해 중복 예매가 허용되는 Race Condition이 발생

1000건 결과

16 of 19

트러블슈팅 - Lettuce 해결 과정

  • Lettuce 기반 구조에서 원인을 분석한 결과, 분산 락은 정상적으로 동작하고 있었지만 락이 적용되는 시점과 트랜잭션의 시작 시점 사이의 시간 차이로 인해 동시 접근이 허용되는 경우인 것으로 파악

  • 이를 해결하기 위해 다음과 같은 방식으로 구조 변경:

분산 락과 트랜잭션을 명확히 분리

락 획득은 트랜잭션 외부에서 처리하고, 트랜잭션이 필요한 로직은 별도의 서비스 메서드로 위임

별도 메서드에는 @Transactional을 직접 적용하여, Spring 트랜잭션 프록시가 제대로 동작하도록 구성

1000건 결과

17 of 19

트러블슈팅 - MySQL해결 과정

1000건 결과

18 of 19

트러블슈팅 - Redisson 해결 과정

  • Redisson 기반 구조에서는 AOP 방식으로 락을 적용

스프링 AOP 내부에서 여러 어드바이스(예: 트랜잭션, 로깅 등)가 정해진 순서대로 적용되기 때문에 트랜잭션 커밋이 언락보다 늦게 적용되는 경우 동시성 문제가 재현될 수 있음

  • 따라서 락 어드바이스가 트랜잭션 어드바이스보다 먼저 실행되도록 하기 위해 @Order(Ordered.HIGHEST_PRECEDENCE)어노테이션을 락 AOP에 명시하여 모든 Aspect 중 가장 먼저 실행되도록 설정하였다.

우선순위를 명확히 조정함으로써 AOP 기반 구조에서도 중복 예매를 완벽하게 차단

1000건 결과

19 of 19

THANK YOU

티켓 구조대

GitHub : https://github.com/SeungHyunLee054/Ticket-911-spring.git