KEEP

docker compose 를 통한 로컬 개발환경 셋업 & MakeFile

#docker-compose.yml
services:
  db:
    profiles: ["dev"]
    image: mysql:8.4.8
    container_name: mysql-the_one
    healthcheck:
      test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ]
      interval: 10s
      timeout: 5s
      retries: 7
    networks:
      the_one-network:

  redis:
    profiles: ["dev"]
    image: redis:8.6.1
    container_name: redis-the_one
    networks:
      the_one-network:

networks:
  the_one-network:
  
#docker-compose.dev.yml
services:
  db:
    env_file:
      - .env.dev
    volumes:
      - ./db-data:/var/lib/mysql
    ports:
      - "${DEV_DB_PORT}:3306"

  redis:
    ports:
      - "${DEV_REDIS_PORT}:6379"
      
#.env.dev
MYSQL_DATABASE=
MYSQL_ROOT_PASSWORD=
MYSQL_USER=
MYSQL_PASSWORD=

DEV_DB_HOST=
DEV_DB_PORT=
DEV_REDIS_HOST=
DEV_REDIS_PORT=
DEV_DB=
DEV_DB_USER=
DEV_DB_PASSWORD=
DEV_JWT_KEY=
DEV_ADMIN_SIGNUP_KEY=

GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=    

#makefile
dev:
	docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.dev up -d

prod:
	docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.prod up -d

down:
	docker compose down

down-v:
	docker compose down -v

Reason

docker 의 공식 이미지를 통해 개발환경을 셋업 하는 것은 이제 보편적으로 사용되는 개발 방식 로컬에 마운트 되는 부분이 좀 있지만 그래도 훨씬 간단하고 직접 선언하는 것보다 깔끔함 길수도 있는 docker 명령어도 makefile 을 사용하면 간단히 사용 가능

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

Reason

자동으로 빌드, 테스트, 환경별 배포가 가능하도록 작성한 CI/CD 물론 가끔 빌드 테스트시 이유모를 Fail 이 한 건 씩 뜨는데, 테스트코드 자체 문제라 파악됨 특히 main 과의 작동점 구분을 두고, 마지막엔 헬스 체크를 할 수 있도록 추가하여서 실제 작동하고 있는지도 파악 가능하게 해두었음

Redis 설정, Redisson 설정, 분산 락 구현

// RedisConfig.java
@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 상품 관련 캐싱
        configs.put("productCache", defaultConfig.entryTtl(Duration.ofMinutes(30)));
        configs.put("categoryCache", defaultConfig.entryTtl(Duration.ofHours(1)));
        configs.put(PRODUCT_SEARCH, defaultConfig.entryTtl(Duration.ofMinutes(30)));
        //endregion

        //region 검색어 관련 캐싱
        configs.put(SEARCH_RANKING, defaultConfig.entryTtl(Duration.ofMinutes(10)));

        //region 이벤트 관련 캐싱
        configs.put("eventListCache", defaultConfig.entryTtl(Duration.ofMinutes(5)));
        //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;
    }
}

// 분산 락 구현부 (락 선언부)
@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);

    // 락 시도
    public String tryLock(String key, long waitTime, long leaseTime, TimeUnit unit)
            throws InterruptedException {
        long deadline = System.currentTimeMillis() + unit.toMillis(waitTime);
        while (System.currentTimeMillis() < deadline) {
            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
        );
    }
}

// 분산 락 구현부 (실질적인 Redis 연산 부)
@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))
        );
    }
}

// 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);
    }
}

Reason

Spring 4 버전에도 사용할 수 있는 Redis 설정 자기 자신의 락인지 확인하며 원자성을 지키는 SpinLock 구현 부 흐름에 따라 분산락이 어떻게 작동하는지 알 수 있는데, Redisson 의 동작 구현과 비슷하도록 작성하여 작동 흐름을 알 수 있다

와이어 프레임

image.png

Reason

각 도메인의 흐름을 알 수 있고, 기능 구현에 대해서 직관적으로 토론이 가능함

TestContainer Singleton 패턴 적용 테스트

// 모든 Redis 통합 테스트
// Redis 컨테이너를 JVM 내에서 한 번만 기동하고 전체 테스트가 공유
@SpringBootTest
@ActiveProfiles("test")
public abstract class RedisTestContainer {
    //@MockitoBean
    //... Mock 할 서비스단 처리
    
    // 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));
    }
}

/// 추후 상속 받아 사용

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class ProductStockLockComparisonTestContainer extends RedisTestContainer {
	.....
}

Reason