선착순 쿠폰 발급 이벤트
- 한정된 수량의 쿠폰을 먼저 신청한 사용자에게 제공하는 이벤트
요구 사항
이벤트 기간 내에(예시 2024-02-01일 오후 1시~ 2024-02-10일 오후 1시) 발급- 선착순 이벤트는
유저당 1번의 쿠폰 발급 - 선착순 쿠폰의
최대 쿠폰 발급 수량설정
쿠폰 발급 기능
- 쿠폰 발급 기능
- 쿠폰 발급 기간 검증 (기간 내 발급)
- 쿠폰 발급 수량 검증
- 쿠폰 전체 발급 수량
- 중복 발급 요청 검증 (중복 참여 방지)
- 쿠폰 발급 (검증 후 발급)
- 쿠폰 발급 수량 증가
- 쿠폰 발급 기록 저장
- 쿠폰 ID
- 유저 ID
쿠폰 발급 기능 구현의 목표
- 정확한 발급 수량 제어 (동시성 이슈 처리)
- 높은 처리량
- 복잡한 쿠폰 구조는 생략
환경 구성
- 기본 시스템 생성 후 src 폴더 지움
- 멀티 모듈로 구축
- coupon-core 모듈 생성
- api와 컨슈머에서 공통적으로 사용하는 기능
- 엔티티, 레포지토리 정의
- coupon-api 모듈 생성
- api 서버를 통해 유저의 요청 처리
- 쿠폰 검증 및 요청 처리
- coupon-consumer 모듈 생성
- 비동기 구조로 발급, 큐에 목록을 읽어 처리하는 서버
- 쿠폰 발급 처리 및 Persistance에 저장
총 3개의 멀티 모듈이 생성 되었다.
- coupon-management-system 하위에 다른 멀티 모듈 gradle을 include 시킨다.
- setting.gradle.kts에 추가하면 된다.
1
include("coupon-core", "coupon-api", "coupon-consummer")
- build.gradle 세팅 부분
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
subprojects {
apply(plugin = "java")
apply(plugin = "io.spring.dependency-management")
apply(plugin = "org.springframework.boot")
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
implementation("org.springframework.boot:spring-boot-starter")
implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-registry-prometheus")
annotationProcessor("com.querydsl:querydsl-apt:5.0.0:jakarta")
annotationProcessor("jakarta.annotation:jakarta.annotation-api")
annotationProcessor("jakarta.persistence:jakarta.persistence-api")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
}
subprojects 은 최상단 프로젝트인 coupon-management-system 하위의 서브프로젝트인 coupon-api, coupon-consumer, coupon-core 는 아래 설정값을 적용한다는 의미이다.
/coupon-core/build.gradle.kts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
val bootJar: org.springframework.boot.gradle.tasks.bundling.BootJar by tasks
bootJar.enabled = false
repositories {
mavenCentral()
}
dependencies {
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
implementation("com.fasterxml.jackson.core:jackson-databind")
implementation("org.redisson:redisson-spring-boot-starter:3.16.4")
implementation("org.springframework.boot:spring-boot-starter")
implementation("com.github.ben-manes.caffeine:caffeine")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<Test> {
useJUnitPlatform()
}
이런 식으로 설정 반영
- /coupon-api/build.gradle.kts
- /coupon-consumer/build.gradle.kts
1
2
3
4
5
6
7
8
9
10
dependencies {
implementation(project(":coupon-core"))
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<Test> {
useJUnitPlatform()
}
각각 역시 이런 식으로 의존성 추가
- implementation(project(“:coupon-core”)) 의미는
coupon-core의 모듈을 가져다가 쓰겠다는 의미 - coupon-core는 다른 모듈에 import하는 형태로 사용되므로 couponCoreApplication은 필요가 없다.
- 사용 할 서버는 coupon-api 웹서버, coupon-consumer 웹서버 두개
1
2
3
4
5
6
7
8
9
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
@ComponentScan
@EnableAutoConfiguration
public class CouponCoreConfiguration {
}
- 공통으로 사용한 쿠폰코어설정을 만들어준다.
1
2
3
4
5
6
7
8
9
10
@Import(CouponCoreConfiguration.class)
@SpringBootApplication
public class CouponApiApplication {
public static void main(String[] args) {
System.setProperty("spring.config.name", "application-core, application-api");
SpringApplication.run(CouponApiApplication.class, args);
}
}
CouponCoreConfiguration이 만들어졌다면 각 coupon-api, coupon-consumer 애플리케이션에 공통으로 사용한 CouponCoreConfiguration을 @Import 어노테이션을 통해 추가한다.
또한 application.yml파일을 가져올때 환경변수를 각각 application-core, application-api와 application-core, application-consumer로 가져온다.
resources/application-core.yml
resources/application-consumer.yml
resources/application-api.yml
Mysql & Redis 환경 구성
Docker-compose를 통한 데이터 소스 구성
docker-compose는 여러 컨테이너를 하나의 애플리케이션처럼 정의하고 관리하는 도구. docker-compose.yml이라는 파일을 사용하여 컨테이너 구성을 정의하고, 단일 명령어로 컨테이너를 생성, 시작, 중지, 삭제할 수 있다.
- 우선 도커 데스크톱을 설치 https://www.docker.com/products/docker-desktop/
- /coupon-management-system/docker-compose.yml 생성 및 compose 작성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
version: '3.7'
services:
redis:
container_name: coupon-redis
image: redis:7.2-alpine
command: redis-server --port 6380
labels:
- "name=redis"
- "mode=standalone"
ports:
- 6380:6380
mysql:
container_name: coupon-mysql
image: ubuntu/mysql:edge
command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --explicit_defaults_for_timestamp=1
ports:
- 3306:3306
environment:
- MYSQL_DATABASE=coupon
- MYSQL_USER=abcd
- MYSQL_PASSWORD=1234
- MYSQL_ROOT_PASSWORD=1234
- TZ=UTC
volumes:
- ./mysql/init:/docker-entrypoint-initdb.d
Docker-compose.yml 분석
버전
version: '3.7': Docker Compose 파일 형식의 버전을 지정. 이 버전은 서비스, 네트워크, 볼륨 등을 정의하는 데 사용할 수 있는 여러 기능을 제공.
서비스
두 개의 주요 서비스가 명확하게 정의되어 있다.
1. Redis
container_name: coupon-redis: Redis 컨테이너의 이름을 설정.image: redis:7.2-alpine: 공식 Redis 이미지(버전 7.2, 작은 공간을 위해 Alpine Linux 기반)를 사용.command: redis-server --port 6380: 기본 Redis 명령을 재정의하여 포트 6380에서 실행.labels:컨테이너에 메타데이터를 추가.
name=redismode=standalone
ports: - 6380:6380: 호스트 머신의 포트 6380을 Redis 컨테이너 내부의 포트 6380에 매핑하여 Redis 서비스를 외부에서 사용.
2. MySQL
container_name: coupon-mysql: MySQL 컨테이너의 이름을 설정.image: ubuntu/mysql:edge: Ubuntu 기반 MySQL 이미지(edge 태그는 덜 안정적이지만 기능이 풍부한 버전을 의미)를 사용.command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --explicit_defaults_for_timestamp=1:다음과 같은 특정 구성으로 기본 MySQL 명령을 재정의.
- UTF-8(utf8mb4) 문자 인코딩을 사용.
- 정확한 타임스탬프 처리를 적용.
ports: - 3306:3306: 호스트 머신의 포트 3306을 MySQL 컨테이너 내부의 포트 3306(표준 MySQL 포트)에 매핑.environment:컨테이너 내부에서 환경 변수를 설정.
MYSQL_DATABASE=coupon: ‘coupon’이라는 데이터베이스를 생성.MYSQL_USER=abcdandMYSQL_PASSWORD=1234: 데이터베이스 사용자와 비밀번호를 생성.MYSQL_ROOT_PASSWORD=1234: 데이터베이스의 루트 비밀번호를 설정.TZ=UTC: 컨테이너의 시간대를 UTC로 설정.
volumes: - ./mysql/init:/docker-entrypoint-initdb.d: 로컬 디렉토리./mysql/init을 컨테이너의 초기화 디렉토리에 연결. 이를 통해./mysql/init내부에 SQL 스크립트를 저장하면 MySQL 컨테이너가 시작될 때 실행.
- 터미널에서 도커 컴포즈 파일 실행
1
$ docker-compose up -d
docker-compose up -d 명령어 분석
up 명령은 docker-compose.yml 파일에서 정의된 컨테이너를 모두 생성하고 시작한다. 옵션 없이 실행하면 모든 컨테이너를 시작하지만, 옵션을 사용하여 특정 컨테이너만 시작하거나 다른 설정을 변경할 수 있다.
-d 옵션은 컨테이너를 백그라운드에서 실행하도록 지정한다.
- application-core.yml 환경변수 수정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
spring:
config:
activate:
on-profile: local
datasource:
hikari:
jdbc-url: jdbc:mysql://localhost:3306/coupon?useUnicode=yes&characterEncoding=UTF-8&rewriteBatchedStatements=true
driver-class-name: com.mysql.cj.jdbc.Driver
maximum-pool-size: 10
max-lifetime: 30000
connection-timeout: 3000
username: abcd
password: 1234
jpa:
hibernate:
ddl-auto: none
show-sql: true
properties:
hibernate:
format_sql: true
data:
redis:
host: localhost
port: 6380
application-core.yml 분석
1. spring.config.activate.on-profile: local
- 이 섹션은 “local”이라는 Spring 프로필이 활성화된 경우에만
spring섹션 내의 설정이 활성화된다는 것을 나타냄. 이를 통해 로컬 개발 환경과 프로덕션 환경에 대해 서로 다른 설정을 사용할 수 있다.
2. spring.datasource.hikari
jdbc-url:
MySQL 데이터베이스에 대한 JDBC 연결 문자열.
jdbc:mysql://localhost:3306/coupon- 로컬 MySQL 서버(포트 3306)의coupon데이터베이스에 연결한다.useUnicode=yes&characterEncoding=UTF-8- 다양한 문자의 올바른 처리를 위해 UTF-8 인코딩을 적용한다.rewriteBatchedStatements=true- 일괄 업데이트를 위한 잠재적인 최적화.
driver-class-name: MySQL용 JDBC 드라이버를 지정 (
com.mysql.cj.jdbc.Driver).maximum-pool-size: Hikari 연결 풀에서 허용되는 최대 연결 수를 설정 (여기서는 10).
max-lifetime: 연결 풀에서 연결의 최대 수명 (30000 밀리초).
connection-timeout: 연결을 기다리는 최대 시간 (3000 밀리초).
username and password: 데이터베이스 사용자 이름과 비밀번호.
3. spring.jpa
- hibernate.ddl-auto: none
- JPA/Hibernate가 시작 시 데이터베이스 스키마를 자동으로 수정하지 못하도록 함. 이는 프로덕션 환경에서 데이터 무결성을 유지하는 데 매우 중요.
- show-sql: true
- 디버깅에 유용하도록 생성된 SQL 문을 콘솔에 기록함.
- properties.hibernate.format_sql: true
- Hibernate에게 SQL 출력을 더 읽기 쉬운 방식으로 형식화하도록 지시함.
4. spring.data.redis
- host and port: Redis 인스턴스의 호스트 이름 (localhost)과 포트 (6380).