
프로젝트 개요
주류 마켓 컨셉의 백엔드 API 구현하기
단순 CRUD 비즈니스 로직 외에 아래 사항들이 종합적으로 반영되어야 함
캐싱 + 동시성 제어가 증빙될 시나리오 + 인덱스 생성 + CI/CD +
AWS 배포 + 부하 테스트 (K6) + Redis + Distributed Lock
진행 경과
맡은 도메인
결제, 이벤트, 쿠폰, Redis 설정 및 SpinLock 방식의 분산 락 구현,
Redisson 락 구현, CI/CD 구현
기술 스택
Backend
- Java 21
- Spring Boot 4.0.3
- JPA, OpenFeign QueryDSL 7.1
- Spring Security, JWT, OAuth2
- Spring Cache, Redis, Caffeine
- Redis Pub/Sub
- Redisson 4.3
- Flyway
- 검색 형태소 분석 : Komorran
- WebSocket
- ULID, DataFaker
Database & Infra
- MySQL 8.4.8
- AWS ElastiCache
- Docker, docker-compose
- GitHub Action
AWS
- EC2
- RDS (MySQL 8.4.8)
- ElastiCache
- ALB
1주차

- 헤더 + 디테일 분리 되도록 구조를 맞추고 JPA 연관관계가 아니라 직접 참조 테이블의 PK, 아이디를 통해 조인을 맺어 참조하기 위한 구조로 확립
- 다만 캐싱의 영역을 잘 고려하지 못해 변경사항이 잦은 영역에 대해서 좀더 세분화 했어야 하는 필요성이 있음

- 와이어 프레임을 통한 UX 흐름에 따른 로직 구성 시도
- 쿠폰, 사은품. 이벤트, 결제, 환불 주요 5개 비즈니스 로직에 대해 구상

- 결제의 진행

결제의 진행에 있어 재고의 차감을 먼저 하고 결제 완료의 행위를 하는 것이 아니라, (선점의 행위)
상품의 재고와 그에 따른 사은품, 쿠폰의 재고를 먼저 차감하고 결제를 완료 시키는 것이 쟁점
- 쿠폰, 상품, 사은품 재고 소모 처리
- 결제가 다수 몰리면 동시성 문제가 발생하는데 이에 대해서 제어가 필요
이번 구현에서는 Redis 사용이 목적이라서 분산 락 구현 및 Redisson 사용 2가지 버전으로 분산 락 제어 + 락 별 비교
- 결제의 유니크 아이디는 ULID 사용
- UUID 보다 짧고 시간 순 정렬이 가능한 식별키
- UUID 보다 생성 성능이 좋고 시간 정렬이 가능하다는 점에 사용하기로 정하였음
2주차
Redis 설정하기
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
@Primary
public RedisCacheManager redisCacheManager() {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(15))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(RedisSerializer.json())
);
// 캐시별 TTL 개별 설정
Map<String, RedisCacheConfiguration> configs = new HashMap<>();
//region 각 도메인 캐싱 설정부
//...
//endregion
return RedisCacheManager.builder(redisConnectionFactory())
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(configs)
.build();
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(RedisSerializer.json());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(RedisSerializer.json());
template.afterPropertiesSet();
return template;
}
}- Boot 4 를 사용하므로 RedisSerializer.json 으로 Jackson 3 기반 직렬화 보장
- 캐싱 사용시 Spring Cache 와 RedisTemplate 를 통한 간접 및 직접 설정 가능하도록 설정
설정 기반으로 사용되어 구현된 캐싱 처리부





직접 구현 방식 Redisson 을 활용하여 구현하기
2가지 방안 전부 구현하여 실제 사용하며 비교해보도록 구현
- 직접 구현 방식 (이하 Lettuce 구현이라 표현)
Spin Lock 을 통하여 Redisson 의 작동을 모티브로 실제 구현
구현 코드
// RedisLock.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLock {
String key();
long waitTime() default 5L; // waitTime 을 0 으로 설정해 사용하면 즉시 실패
long leaseTime() default 10L;
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
// RedisLockAspect.java
@Slf4j
@Aspect
@Component
@Order(1) // 항상 첫 실행 필요
@RequiredArgsConstructor
public class RedisLockAspect {
private final RedisLockService redisLockService;
private final AopInTransaction aopInTransaction;
@Around("@annotation(redisLock)")
public Object lock(ProceedingJoinPoint joinPoint, RedisLock redisLock) throws Throwable {
String key = "lock:" + redisLock.key();
String lockValue = redisLockService.tryLock(
key
, redisLock.waitTime()
, redisLock.leaseTime()
, redisLock.timeUnit()
);
if (lockValue == null) {
throw new ServiceErrorException(ERR_GET_REDIS_LOCK_FAIL);
}
// WatchDog 시작
ScheduledFuture<?> watchDog = redisLockService.setWatchDog(
key
, lockValue
, redisLock.leaseTime()
, redisLock.timeUnit()
);
try {
return aopInTransaction.proceed(joinPoint);
} finally {
watchDog.cancel(true); // WatchDog 종료
redisLockService.unLock(key, lockValue); // 락 해제
}
}
}// AopInTransaction.java
// 별도의 REQUIRES_NEW 전파 트랜잭션으로 사용되도록 구성
// 락 해제 전 비즈니스 로직의 트랜잭션이 커밋되도록 보장
// 사용 서비스엔 @Transacional 어노테이션 삭제 할 것 - 이중 커넥션
@Component
public class AopInTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}// RedisLockService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class RedisLockService {
private static final long INTERVAL_WAIT_TIME = 100;
private static final long WATCH_DOG_INCREMENT_TIME = 1000;
private final RedisLockRepository redisLockRepository;
private final ScheduledExecutorService watchDogExecutor = Executors.newScheduledThreadPool(8); // WatchDog 스레드 풀
// 락 시도
public String tryLock(String key, long waitTime, long leaseTime, TimeUnit unit)
throws InterruptedException {
long deadline = System.currentTimeMillis() + unit.toMillis(waitTime);
while (System.currentTimeMillis() < deadline) { // 만료 되기 전 재시도 계속 시도 (SpinLock)
String lockValue = redisLockRepository.getLock(key, leaseTime, unit);
if (lockValue != null) return lockValue;
Thread.sleep(INTERVAL_WAIT_TIME);
}
return null;
}
// 락 해제
public void unLock(String key, String lockValue) {
redisLockRepository.checkOwnLock(key, lockValue);
}
// WatchDog
public ScheduledFuture<?> setWatchDog(String key, String lockValue, long leaseTime, TimeUnit unit) {
return watchDogExecutor.scheduleAtFixedRate(() -> redisLockRepository.setWatchDog(key, lockValue, leaseTime, unit)
, WATCH_DOG_INCREMENT_TIME, WATCH_DOG_INCREMENT_TIME, TimeUnit.MILLISECONDS
);
}
}@Component
@RequiredArgsConstructor
public class RedisLockRepository {
private final RedisTemplate<String, Object> redisTemplate;
// 락 획득하기
public String getLock(String key, long leaseTime, TimeUnit unit) {
String lockValue = UUID.randomUUID().toString();// 개별 LockValue 를 위한 UUID 셋업
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(key, lockValue, leaseTime, unit);
return Boolean.TRUE.equals(result) ? lockValue : null;
}
// 원자성 체크 (자기 락 확인)
public void checkOwnLock(String key, String lockValue) {
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end";
redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(key), lockValue
);
}
// WatchDog - 원자성 체크 후 아직 내 락이면 TTL 자동 연장
public void setWatchDog(String key, String lockValue, long leaseTime, TimeUnit unit) {
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('expire', KEYS[1], ARGV[2]) " +
"else return 0 end";
redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(key),
lockValue, String.valueOf(unit.toSeconds(leaseTime))
);
}
}- 커스텀 어노테이션을 통하여 메서드 레벨에서 실행하도록 설정 or Service 단을 이용하여 구현하도록 설정
- 서비스 와 레포지토리 로 분리하여 실제 연산부를 따로 분리
- 자신의 락인지 확인하도록 UUID 사용, 작업의 원자성 체크도 추가하여 락 시도와 해제 사이에 다른 작업이 끼어들 수 없도록 보장
- 별도의 트랜잭션으로 감싸도록 설정하여 서비스의 트랜잭션이 필요없이, 락 트랜잭션이 감싸 락 해제전 비즈니스 로직이 먼저 커밋되도록 보장
- Spin Lock 구현이므로 waitTime 동안 100ms 단위로 실패시 재시도 하도록 계속 시도
- WatchDog 을 마련하여 자동으로 TTL 을 늘릴 수 있도록 구현
- finally 구문 안 락이 해제하도록 보장하여 점유 방지 보장
- Redisson 구현
Redisson 라이브러리를 통한 분산 락 구현
Redisson 은 Pub/Sub 으로 구현된 분산 락 사용
구현 코드
// RedissonConfig.java
@Configuration
public class RedissonConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + host + ":" + port)
.setConnectionMinimumIdleSize(2)
.setConnectionPoolSize(10);
return Redisson.create(config);
}
}// RedissonLock.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedissonLock {
String key();
long waitTime() default 5L;
long leaseTime() default 5L;
TimeUnit timeUnit() default TimeUnit.SECONDS;
}// AopInTransaction.java
// 별도의 REQUIRES_NEW 전파 트랜잭션으로 사용되도록 구성
// 락 해제 전 비즈니스 로직의 트랜잭션이 커밋되도록 보장
// 사용 서비스엔 @Transacional 어노테이션 삭제 할 것 - 이중 커넥션
@Component
public class AopInTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}// RedissonLockAspect.java
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RedissonLockAspect {
private final RedissonClient redissonClient;
private final AopInTransaction aopInTransaction;
private final ExpressionParser parser = new SpelExpressionParser();
@Around("@annotation(redissonLock)")
public Object lock(ProceedingJoinPoint joinPoint, RedissonLock redissonLock) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] paramNameArr = signature.getParameterNames(); // 메서드 파라미터 이름 추출
Object[] argsArr = joinPoint.getArgs(); // 메서드 파라미터 전달 값 추출
StandardEvaluationContext standardEvaluationContext = new StandardEvaluationContext();
for (int index = 0; index < paramNameArr.length; index++) {
standardEvaluationContext.setVariable(paramNameArr[index], argsArr[index]); // 실제 값 바인딩
}
String key = "rLock:" + parser.parseExpression(redissonLock.key()).getValue(standardEvaluationContext, String.class); // SpEL 반영된 키
RLock rLock = redissonClient.getLock(key);
boolean isLock = rLock.tryLock( // 실제 Lock 획득
redissonLock.waitTime()
, redissonLock.leaseTime()
, redissonLock.timeUnit()
);
if (!isLock) { // 자동 WatchDog 내부 수행
throw new ServiceErrorException(ERR_GET_REDIS_LOCK_FAIL);
}
try {
return aopInTransaction.proceed(joinPoint);
} finally {
if(rLock.isHeldByCurrentThread()) { // 아직 RLock이 쓰레드 점유 시 (자신이 건 락이 맞는지 체크)
rLock.unlock();
}
}
}
}- 커스텀 어노테이션을 통하여 메서드 레벨에서 실행하도록 설정
- SpEL 키를 반영되도록 추가
3주차
실제 구현 및 속도 비교
- 상품의 결제시 재고 차감을 Lettuce 로 구현한 부분
@Service
@RequiredArgsConstructor
public class ProductStockService {
private final RedisLockService redisLockService;
private final ProductService productService;
private static final String LOCK_PREFIX = "product:stock:lock:";
private static final long LOCK_WAIT = 10;
private static final long LOCK_LEASE = 5L;
private void executeWithLock(Long productId, Runnable task) {
String lockKey = LOCK_PREFIX + productId;
String lockValue = null;
ScheduledFuture<?> watchDog = null;
try {
lockValue = redisLockService.tryLock(lockKey, LOCK_WAIT, LOCK_LEASE, TimeUnit.SECONDS);
if(lockValue == null) {
throw new ServiceErrorException(ProductExceptionEnum.ERR_PRODUCT_LOCK_FAILED);
}
watchDog = redisLockService.setWatchDog(lockKey, lockValue, LOCK_LEASE, TimeUnit.SECONDS);
task.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ServiceErrorException(CommonExceptionEnum.ERR_GET_REDIS_LOCK_FAIL);
} finally {
if (watchDog != null) {
watchDog.cancel(true);
}
if (lockValue != null) {
redisLockService.unLock(lockKey, lockValue);
}
}
}
public void decreaseStock(Long productId, Long quantity) {
executeWithLock(productId, () -> productService.decreaseStock(productId, quantity));
}
public void increaseStock(Long productId, Long quantity) {
executeWithLock(productId, () -> productService.increaseStock(productId, quantity));
}
}- 상품의 결제시 재고 차감을 Redisson 으로 구현한 부분
@CacheEvict(value = "productCache", allEntries = true, condition = "#result == true")
@RedissonLock(key = "'stock:product:' + #id")
public void decreaseStockWithRedisson(Long id, Long quantity) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ServiceErrorException(ProductExceptionEnum.ERR_PRODUCT_NOT_FOUND));
product.decreaseStock(quantity);
}
@RedissonLock(key = "'stock:product:' + #id", waitTime = 10L)
public void increaseStockWithRedisson(Long id, Long quantity) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ServiceErrorException(ProductExceptionEnum.ERR_PRODUCT_NOT_FOUND));
product.increaseStock(quantity);
}Redisson 을 통해 구현하는 것이 코드 양만 보아도 우선 간단함을 알 수 있음
- 비관적 락, 낙관적 락, Lettuce 락, Redisson 사용 속도 비교
속도 비교를 위해 구현된 테스트 코드
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class ProductStockLockComparisonTestContainer extends RedisTestContainer {
@Autowired
private ProductService productService;
@Autowired
private ProductPessimisticLockService productPessimisticLockService;
@Autowired
private ProductOptimisticLockService productOptimisticLockService;
@Autowired
private ProductStockService productStockService;
@Autowired
private ProductRepository productRepository;
private Long productId;
@BeforeEach
void beforeSetUp() {
// 재고 100개 상품 생성
Product product = productRepository.save(Product.register("test", 1000L, BigDecimal.valueOf(10L), 1000, 1L, 100L));
productId = product.getId();
}
@AfterEach
void tearDown() {
productRepository.deleteAll();
}
@Test
@Order(1)
@DisplayName("withoutLock")
void withoutLock_decreaseStock() throws InterruptedException {
int threadCount = 100;
AtomicInteger failCount = new AtomicInteger(0);
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
long start = System.currentTimeMillis();
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
productService.decreaseStock(productId, 1L);
} catch (Exception e) {
failCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
long time = System.currentTimeMillis() - start;
Product product = productRepository.findById(productId).orElseThrow();
assertThat(product.getQuantity()).isNotEqualTo(0);
System.out.printf("[락 없음] 소요 시간: %dms, 최종 재고: %d, 실패: %d%n", time, product.getQuantity(), failCount.get());
}
@Test
@Order(2)
@DisplayName("withPessimisticLock")
void withPessimisticLock_decreaseStock() throws InterruptedException {
int threadCount = 100;
AtomicInteger failCount = new AtomicInteger(0);
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
long start = System.currentTimeMillis();
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
productPessimisticLockService.decreaseStock(productId, 1L);
} catch (Exception e) {
failCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
long time = System.currentTimeMillis() - start;
Product product = productRepository.findById(productId).orElseThrow();
assertThat(product.getQuantity()).isEqualTo(failCount.get());
System.out.printf("[비관적락] 소요 시간: %dms, 최종 재고: %d, 실패: %d%n", time, product.getQuantity(), failCount.get());
}
@Test
@Order(3)
@DisplayName("withOptimisticLock")
void withOptimisticLock_decreaseStock() throws InterruptedException {
int threadCount = 100;
AtomicInteger failCount = new AtomicInteger(0);
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
long start = System.currentTimeMillis();
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
productOptimisticLockService.decreaseStock(productId, 1L);
} catch (Exception e) {
failCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
long time = System.currentTimeMillis() - start;
Product product = productRepository.findById(productId).orElseThrow();
assertThat(product.getQuantity()).isEqualTo(failCount.get());
System.out.printf("[낙관적락] 소요 시간: %dms, 최종 재고: %d, 실패: %d%n", time, product.getQuantity(), failCount.get());
}
@Test
@Order(4)
@DisplayName("WithSpinLock")
void withSpinLock_decreaseStock() throws InterruptedException {
int threadCount = 100;
AtomicInteger failCount = new AtomicInteger(0);
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
long start = System.currentTimeMillis();
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
productStockService.decreaseStock(productId, 1L);
} catch (Exception e) {
failCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
long time = System.currentTimeMillis() - start;
Product product = productRepository.findById(productId).orElseThrow();
assertThat(product.getQuantity()).isEqualTo(failCount.get());
System.out.printf("[분산락(Lettuce)] 소요 시간: %dms, 최종 재고: %d, 실패: %d%n", time, product.getQuantity(), failCount.get());
}
@Test
@Order(5)
@DisplayName("WithRedisson")
void withRedisson_decreaseStock() throws InterruptedException {
int threadCount = 100;
AtomicInteger failCount = new AtomicInteger(0);
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
long start = System.currentTimeMillis();
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
productService.decreaseStockWithRedisson(productId, 1L);
} catch (Exception e) {
failCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
long time = System.currentTimeMillis() - start;
Product product = productRepository.findById(productId).orElseThrow();
assertThat(product.getQuantity()).isEqualTo(failCount.get());
System.out.printf("[분산락(Redisson)] 소요 시간: %dms, 최종 재고: %d, 실패: %d%n", time, product.getQuantity(), failCount.get());
}
}
단일 서버 환경이므로 속도만으로는 비관적 락이 추천됨 다중 서버 에서 활용될 분산 락은 Redisson 을 사용하는 것이 직접 구현한 Lettuce 락 보다 좋은 성능을 보임
- Lettuce 구현 락의 경우 100ms 마다 재시도 하는 로직이 포함되어 있어 오버헤드가 발생하여 느려지지 않았나 생각함
4주차
CI/CD 구성하기, AWS 환경 배포
- AWS 환경 배포 (EC2), ElastiCache 사용 (노드 기반 클러스터)

최종 배포된 AWS 구조
- GitHub Action 사용
- CI/CD 작동 골자
Dev Push/PR 시
-> 빌드 및 테스트
-> 빌드 결과물 업로드
Main Push 시
-> Dev Push/PR 흐름 작업
-> 빌드 결과물 다운로드
-> dockerFile 이미지 배포
-> EC2 환경 에서 이미지 pull
-> 헬스 체크CI/CD 구현 코드
name: CI/CD
on:
push:
branches:
- main
- dev
workflow_dispatch:
pull_request:
branches:
- main
- dev
jobs:
build-and-test:
runs-on: ubuntu-latest
services:
redis:
image: redis:8.6.1
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: 코드 체크아웃
uses: actions/checkout@v6
- name: JDK 21 설정
uses: actions/setup-java@v5
with:
java-version: '21'
distribution: 'temurin'
- name: Gradle 캐시 설정
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Gradle 실행 권한 부여
run: chmod +x gradlew
- name: 빌드 및 테스트
run: ./gradlew build
env:
SPRING_PROFILES_ACTIVE: test
- name: 빌드 결과물 업로드 # 빌드된 JAR를 임시 저장 (재활용 하여 빌드에서 사용)
uses: actions/upload-artifact@v4
with:
name: build-jar
path: build/libs/*.jar
retention-days: 1
docker-publish:
needs: build-and-test
if: github.event_name == 'push' && github.ref == 'refs/heads/main' # main 에만 배포될 때 작동하게 함
runs-on: ubuntu-latest
steps:
- name: 코드 체크아웃
uses: actions/checkout@v6
- name: 빌드 결과물 다운로드 # 어차피 profile Prod 를 통해 운영 환경으로 빌드됨
uses: actions/download-artifact@v4
with:
name: build-jar
path: build/libs
- name: Set up QEMU # ARM 빌드에 필요
uses: docker/setup-qemu-action@v3
- name: Docker Buildx 설정
uses: docker/setup-buildx-action@v3
- name: Docker Hub 로그인
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: 이미지 빌드 및 푸시
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ secrets.DOCKER_USERNAME }}/the-one:latest
deploy:
needs: docker-publish
runs-on: ubuntu-latest
steps:
- name: 코드 체크아웃
uses: actions/checkout@v6
- name: AWS 자격증명 설정
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: EC2 배포
run: |
COMPOSE_B64=$(base64 -w 0 docker-compose.yml)
COMPOSE_PROD_B64=$(base64 -w 0 docker-compose.prod.yml)
cat > commands.json << EOF
{
"InstanceIds": ["${{ secrets.EC2_INSTANCE_ID }}"],
"DocumentName": "AWS-RunShellScript",
"Parameters": {
"commands": [
"echo '$COMPOSE_B64' | base64 -d > /home/ec2-user/docker-compose.yml",
"echo '$COMPOSE_PROD_B64' | base64 -d > /home/ec2-user/docker-compose.prod.yml",
"echo 'DB_HOST=${{ secrets.DB_HOST }}' > /home/ec2-user/.env.prod",
"echo 'DB_PORT=${{ secrets.DB_PORT }}' >> /home/ec2-user/.env.prod",
"echo 'DB_NAME=${{ secrets.DB_NAME }}' >> /home/ec2-user/.env.prod",
"echo 'DB_USER=${{ secrets.DB_USER }}' >> /home/ec2-user/.env.prod",
"echo 'DB_PASSWORD=${{ secrets.DB_PASSWORD }}' >> /home/ec2-user/.env.prod",
"echo 'REDIS_HOST=${{ secrets.ELASTI_CACHE_HOST }}' >> /home/ec2-user/.env.prod",
"echo 'REDIS_PORT=${{ secrets.ELASTI_CACHE_PORT }}' >> /home/ec2-user/.env.prod",
"echo 'JWT_KEY=${{ secrets.JWT_KEY }}' >> /home/ec2-user/.env.prod",
"echo 'ADMIN_SIGNUP_KEY=${{ secrets.ADMIN_SIGNUP_KEY }}' >> /home/ec2-user/.env.prod",
"echo 'GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}' >> /home/ec2-user/.env.prod",
"echo 'GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}' >> /home/ec2-user/.env.prod",
"cd /home/ec2-user && docker compose -f docker-compose.yml -f docker-compose.prod.yml pull",
"cd /home/ec2-user && docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --remove-orphans"
]
}
}
EOF
COMMAND_ID=$(aws ssm send-command \
--cli-input-json file://commands.json \
--query "Command.CommandId" \
--output text)
for i in $(seq 1 30); do
STATUS=$(aws ssm get-command-invocation \
--command-id "$COMMAND_ID" \
--instance-id "${{ secrets.EC2_INSTANCE_ID }}" \
--query "Status" \
--output text 2>/dev/null || echo "Pending")
if [ "$STATUS" = "Success" ]; then
echo "배포 성공"
break
elif [ "$STATUS" = "Failed" ] || [ "$STATUS" = "Cancelled" ] || [ "$STATUS" = "TimedOut" ]; then
echo "배포 실패: $STATUS"
exit 1
fi
echo "배포 중... ($STATUS)"
sleep 10
done
- name: 헬스체크
run: |
sleep 10
COMMAND_ID=$(aws ssm send-command \
--instance-ids "${{ secrets.EC2_INSTANCE_ID }}" \
--document-name "AWS-RunShellScript" \
--parameters 'commands=["for i in {1..30}; do curl -f http://localhost:8080/actuator/health && exit 0; sleep 5; done; exit 1"]' \
--query "Command.CommandId" \
--output text)
for i in $(seq 1 12); do
STATUS=$(aws ssm get-command-invocation \
--command-id "$COMMAND_ID" \
--instance-id "${{ secrets.EC2_INSTANCE_ID }}" \
--query "Status" \
--output text 2>/dev/null || echo "Pending")
if [ "$STATUS" = "Success" ]; then
echo "헬스체크 성공"
exit 0
elif [ "$STATUS" = "Failed" ] || [ "$STATUS" = "Cancelled" ]; then
echo "헬스체크 실패"
aws ssm get-command-invocation \
--command-id "$COMMAND_ID" \
--instance-id "${{ secrets.EC2_INSTANCE_ID }}" \
--output json
exit 1
fi
sleep 10
done

5주차
각종 오류 보완
- Redis TestContainer 클래스의 다량 사용
1개의 TestContainer 당 300MB 소요, 테스트에 활용한 클래스만 16개가 넘음
보통 TestContainer 는 싱글톤 패턴으로 공유하며 사용해야 함
// 모든 Redis 통합 테스트
// Redis 컨테이너를 JVM 내에서 한 번만 기동하고 전체 테스트가 공유
@SpringBootTest
@ActiveProfiles("test")
public abstract class RedisTestContainer {
@MockitoBean
KomoranCorrector komoranCorrector;
@MockitoBean
PointEarnPublisher pointEarnPublisher;
// static 블록으로 JVM 전체에서 단 한 번만 컨테이너 기동, Singleton
static final RedisContainer redisContainer;
static {
redisContainer = new RedisContainer(DockerImageName.parse("redis:8.6.1"))
.withExposedPorts(6379);
redisContainer.start();
}
@DynamicPropertySource
static void redisProperties(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redisContainer::getHost);
registry.add("spring.data.redis.port", () -> redisContainer.getMappedPort(6379));
}
}이 후 테스트 클래스에서 extends 하여 사용하도록 조치 후 문제 없음 확인
- 커스텀 어노테이션을 사용한 분산 락 구현에서 유독 느린 현상 발생
추후 더 적절하게 20~50개 커넥션 풀을 유지하게 하고 재테스트 하니 정상적인 속도로 통과
Share article