#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
docker 의 공식 이미지를 통해 개발환경을 셋업 하는 것은 이제 보편적으로 사용되는 개발 방식 로컬에 마운트 되는 부분이 좀 있지만 그래도 훨씬 간단하고 직접 선언하는 것보다 깔끔함 길수도 있는 docker 명령어도 makefile 을 사용하면 간단히 사용 가능
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
자동으로 빌드, 테스트, 환경별 배포가 가능하도록 작성한 CI/CD 물론 가끔 빌드 테스트시 이유모를 Fail 이 한 건 씩 뜨는데, 테스트코드 자체 문제라 파악됨 특히 main 과의 작동점 구분을 두고, 마지막엔 헬스 체크를 할 수 있도록 추가하여서 실제 작동하고 있는지도 파악 가능하게 해두었음
// 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);
}
}
Spring 4 버전에도 사용할 수 있는 Redis 설정 자기 자신의 락인지 확인하며 원자성을 지키는 SpinLock 구현 부 흐름에 따라 분산락이 어떻게 작동하는지 알 수 있는데, Redisson 의 동작 구현과 비슷하도록 작성하여 작동 흐름을 알 수 있다

각 도메인의 흐름을 알 수 있고, 기능 구현에 대해서 직관적으로 토론이 가능함
// 모든 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 {
.....
}