inblog logo
|
LifeLog, DevLog
    TIL

    TIL 20260306~0325

    주류 판매 플랫폼 컨셉의 백엔드 API 구성하기
    KYJTHEYJ's avatar
    KYJTHEYJ
    Mar 26, 2026
    TIL 20260306~0325
    Contents
    프로젝트 개요진행 경과

    프로젝트 개요

    주류 마켓 컨셉의 백엔드 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주차

    notion image
    • 헤더 + 디테일 분리 되도록 구조를 맞추고 JPA 연관관계가 아니라 직접 참조 테이블의 PK, 아이디를 통해 조인을 맺어 참조하기 위한 구조로 확립
      • 다만 캐싱의 영역을 잘 고려하지 못해 변경사항이 잦은 영역에 대해서 좀더 세분화 했어야 하는 필요성이 있음
     
    notion image
    • 와이어 프레임을 통한 UX 흐름에 따른 로직 구성 시도
    • 쿠폰, 사은품. 이벤트, 결제, 환불 주요 5개 비즈니스 로직에 대해 구상
    notion image
    • 결제의 진행
      • notion image
        결제의 진행에 있어 재고의 차감을 먼저 하고 결제 완료의 행위를 하는 것이 아니라, (선점의 행위) 상품의 재고와 그에 따른 사은품, 쿠폰의 재고를 먼저 차감하고 결제를 완료 시키는 것이 쟁점
    • 쿠폰, 상품, 사은품 재고 소모 처리
      • 결제가 다수 몰리면 동시성 문제가 발생하는데 이에 대해서 제어가 필요
      • 💡
        이번 구현에서는 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 를 통한 간접 및 직접 설정 가능하도록 설정
    설정 기반으로 사용되어 구현된 캐싱 처리부
    notion image
    notion image
     
    notion image
    notion image
     
    notion image
     
    직접 구현 방식 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()); } }
    notion image
    단일 서버 환경이므로 속도만으로는 비관적 락이 추천됨 다중 서버 에서 활용될 분산 락은 Redisson 을 사용하는 것이 직접 구현한 Lettuce 락 보다 좋은 성능을 보임
    • Lettuce 구현 락의 경우 100ms 마다 재시도 하는 로직이 포함되어 있어 오버헤드가 발생하여 느려지지 않았나 생각함

    4주차

    CI/CD 구성하기, AWS 환경 배포
    • AWS 환경 배포 (EC2), ElastiCache 사용 (노드 기반 클러스터)
    notion image
    최종 배포된 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
    notion image

    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개 커넥션 풀을 유지하게 하고 재테스트 하니 정상적인 속도로 통과
     
    프로젝트 KPT
    프로젝트 Github
     
    Share article
    Contents
    프로젝트 개요진행 경과

    LifeLog, DevLog - https://github.com/KYJTHEYJ

    RSS·Powered by Inblog