FourChak은 Spring Boot 기반의 식당 예약 플랫폼으로, 개발 과정에서 다양한 성능 이슈와 인프라 구축 과제에 직면했습니다.
검색 기능에서 LIKE 쿼리로 인한 심각한 성능 저하가 발생했습니다.
// 문제가 된 기존 코드
@GetMapping("/search")
public Page<StoreResponseDto> searchStores(@RequestParam String keyword, Pageable pageable) {
// LIKE 쿼리로 인한 전체 테이블 스캔
return storeRepository.findByStoreNameContaining(keyword, pageable);
}
성능 문제:
구분 | Local Cache | Remote Cache (Redis) |
---|---|---|
속도 | 빠름 | 보통 (네트워크 지연) |
확장성 | 서버별 불일치 | 서버 간 공유 가능 |
메모리 | 애플리케이션 메모리 사용 | 별도 서버 |
적합성 | 단일 서버 | Scale-out 환경 ✅ |
@Configuration
@EnableCaching
public class RedisCacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration
.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10)) // 10분 TTL
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
@Service
public class StoreSearchService {
@Cacheable(
value = "storeSearch",
key = "#keyword + '_' + #pageable.pageNumber + '_' + #pageable.pageSize"
)
public Page<StoreResponseDto> searchStoreWithCache(String keyword, Pageable pageable) {
return storeRepository.findByStoreNameContaining(keyword, pageable);
}
@Cacheable(value = "popularKeywords")
public List<PopularKeywordResponseDto> getPopularKeywords() {
return popularKeywordRepository.findTop10ByOrderBySearchCountDesc();
}
}
# 대안 성능 측정 방법
time curl -s "http://localhost:8080/api/v1/search?keyword=맛집" # 캐시 X
time curl -s "http://localhost:8080/api/v2/search?keyword=맛집" # 캐시 O
메트릭 | 캐시 적용 전 | 캐시 적용 후 | 개선율 |
---|---|---|---|
평균 응답시간 | 1,200ms | 150ms | 87.5% |
DB 쿼리 수 | 100% | 20% | 80% 감소 |
동시 처리량 | 50 TPS | 200 TPS | 300% 향상 |
수동 배포의 반복적인 작업을 줄이고, 자동화된 빌드/배포 파이프라인 구축을 목표로 했습니다.
# 기존 수동 배포 과정 (10-15분 소요)
1. git push origin main
2. EC2 SSH 접속
3. git pull origin main
4. ./gradlew build
5. 기존 프로세스 종료
6. nohup java -jar app.jar &
7. 로그 확인
pipeline {
agent any
tools {
gradle 'gradle-8.5'
jdk 'openjdk-17'
}
stages {
stage('Checkout') {
steps {
git branch: 'main', url: 'https://github.com/YejinY00n/FourChak.git'
}
}
stage('Build') {
steps {
sh './gradlew clean build -x test'
}
}
stage('Test') {
steps {
sh './gradlew test'
}
post {
always {
junit 'build/test-results/test/*.xml'
}
}
}
stage('Archive') {
steps {
archiveArtifacts artifacts: 'build/libs/*.jar'
}
}
}
}
CI 성과: ✅ GitHub 푸시 → 자동 빌드 → .jar 파일 생성 완료
# 문제: Permission denied (publickey)
# 해결 과정
ssh-keygen -t rsa -b 4096 -C "jenkins@fourchak.com"
ssh-copy-id -i ~/.ssh/id_rsa.pub ec2-user@your-ec2-ip
# Jenkins SSH Agent 플러그인 사용
sshagent(['ec2-ssh-key']) {
sh 'scp build/libs/*.jar ec2-user@your-ec2-ip:/app/'
sh 'ssh ec2-user@your-ec2-ip "sudo systemctl restart fourchak"'
}
#!/bin/bash
# 개선된 deploy.sh
APP_NAME="fourchak"
JAR_PATH="/home/ec2-user/app"
JAR_NAME="fourchak-0.0.1-SNAPSHOT.jar"
# 기존 프로세스 안전하게 종료
PID=$(pgrep -f $APP_NAME)
if [ ! -z "$PID" ]; then
echo "Stopping existing process (PID: $PID)"
kill -15 $PID
sleep 10
# 강제 종료가 필요한 경우
if kill -0 $PID 2>/dev/null; then
kill -9 $PID
fi
fi
# 새 애플리케이션 시작
nohup java -jar $JAR_PATH/$JAR_NAME \
--spring.profiles.active=prod \
> $JAR_PATH/application.log 2>&1 &
# 헬스 체크
for i in {1..30}; do
if curl -f http://localhost:8080/actuator/health >/dev/null 2>&1; then
echo "Deployment successful!"
exit 0
fi
sleep 2
done
echo "Deployment failed!"
exit 1
구성 요소 | 현재 상태 | 다음 단계 |
---|---|---|
CI (빌드) | ✅ 완료 | 코드 품질 검사 추가 |
CD (배포) | 🔄 구현 중 | 자동 배포 완성 |
모니터링 | ❌ 미구현 | 로그 수집/알림 시스템 |
대량의 데이터에서 특정 정보를 찾는 것은 전체 컬럼을 조회하는 구조상 상당히 오래 걸립니다.
숫자 맞추기 게임으로 이해하는 인덱스
상황: 1~100 중 상대방이 생각한 숫자(70) 맞추기
일반적인 방식: 1인가요? 2인가요? 3인가요? ... (순차 검색)
인덱스 방식: 50보다 큰가요? 75보다 작은가요? 63보다 큰가요? (이진 검색)
인덱스 타입 | 특징 | 장점 | 단점 |
---|---|---|---|
Hash Index | Key-Value 쌍 저장 | 빠른 검색 속도 | 한 요청에 하나의 응답만 가능 |
B-Tree Index | 균형 트리, 한 key에 여러 value | 범위 쿼리 지원 | 리프노드 데이터 크기가 큼 |
B+Tree Index ⭐ | 내부노드에 데이터, 리프노드에 가이드 | 순차 조회 가능, 범위 응답 최적화 | 전체 데이터 크기 증가 |
@SpringBootTest
@ActiveProfiles("test")
public class UserRepositoryIndexTest {
@Autowired
private UserRepository userRepository;
private final String testEmail = "emailnY8HtX"; // 9998번째 데이터
private final Long testId = 9998L;
private final int REPEAT_COUNT = 100;
@Test
@DisplayName("이메일 기반 조회 성능 측정")
void testFindByEmailPerformance() {
long start = System.currentTimeMillis();
for (int i = 0; i < REPEAT_COUNT; i++) {
userRepository.findByEmail(testEmail);
}
long end = System.currentTimeMillis();
double avgTime = (end - start) / (double) REPEAT_COUNT;
System.out.println("이메일 조회 평균 시간: " + avgTime + " ms");
}
@Test
@DisplayName("ID 기반 조회 성능 측정")
void testFindByIdPerformance() {
long start = System.currentTimeMillis();
for (int i = 0; i < REPEAT_COUNT; i++) {
userRepository.findById(testId);
}
long end = System.currentTimeMillis();
double avgTime = (end - start) / (double) REPEAT_COUNT;
System.out.println("ID 조회 평균 시간: " + avgTime + " ms");
}
}
조회 방식 | 평균 응답시간 | 인덱스 타입 | 성능 비율 |
---|---|---|---|
Email 조회 | 4.19ms | UNIQUE 인덱스 | 기준값 |
ID 조회 | 1.21ms | Primary Key | 3.46배 빠름 |
핵심 인사이트: Primary Key 인덱스는 B+Tree 구조로 최적화되어 가장 빠른 성능을 보여줍니다.
시나리오: 정원 5명 식당에 동일 시간대로 30명이 동시에 예약 시도
// 문제가 된 기존 코드
@Transactional
public ReservationResponseDto saveReservation(Long storeId, ReservationRequestDto dto) {
// 1. 현재 예약 인원 수 조회
int currentCount = countReservationPeopleAtTime(storeId, dto.getReservationTime());
// 2. 좌석 여유 확인 (동시성 문제 발생 지점!)
if (store.getSeatCount() - currentCount < dto.getPeopleNumber()) {
throw new CustomRuntimeException(ExceptionCode.NO_SEAT_AVAILABLE);
}
// 3. 예약 저장 (여러 스레드가 동시에 저장 가능)
Reservation reservation = new Reservation(dto, store, user);
return reservationRepository.save(reservation);
}
결과: 정원 5명 → 실제 예약 7~10명 (오버부킹 발생)
구분 | Lettuce | Redisson |
---|---|---|
방식 | 비동기, 논블로킹 | 동기, 블로킹 |
재시도 | 수동 구현 필요 | 자동 재시도 지원 |
락 타입 | 단순 SET NX PX | 재진입 락 지원 |
복잡도 | 낮음 | 높음 |
성능 | 높음 | 보통 |
@Service
@RequiredArgsConstructor
public class DistributedLockService {
private final StringRedisTemplate stringRedisTemplate;
public boolean tryLock(String key, String value, Duration timeout) {
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeout);
return Boolean.TRUE.equals(success);
}
public boolean releaseLock(String key, String value) {
// Lua 스크립트로 원자성 보장
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long result = stringRedisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key), value
);
return result != null && result == 1L;
}
}
@Aspect
@Component
@RequiredArgsConstructor
public class LettuceLockAspect {
private final DistributedLockService lockService;
private final SpelExpressionParser parser = new SpelExpressionParser();
@Around("@annotation(lettuceLock)")
public Object applyLock(ProceedingJoinPoint joinPoint, LettuceLock lettuceLock)
throws Throwable {
// SpEL을 통한 동적 키 생성
String evaluatedKey = evaluateKey(joinPoint, lettuceLock.key());
String value = UUID.randomUUID().toString();
Duration timeout = Duration.ofMillis(lettuceLock.timeout());
// 재시도 로직 구현
boolean locked = tryLockWithRetry(evaluatedKey, value, timeout);
if (!locked) {
throw new CustomRuntimeException(ExceptionCode.LOCK_EXCEPTION);
}
try {
return joinPoint.proceed();
} finally {
lockService.releaseLock(evaluatedKey, value);
}
}
private boolean tryLockWithRetry(String key, String value, Duration timeout)
throws InterruptedException {
long deadline = System.currentTimeMillis() + timeout.toMillis();
while (System.currentTimeMillis() < deadline) {
if (lockService.tryLock(key, value, timeout)) {
return true;
}
Thread.sleep(100); // 100ms 간격으로 재시도
}
return false;
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LettuceLock {
String key();
long timeout() default 5000;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
@LettuceLock(key = "'lock:reservation:' + #storeId + ':' + #dto.reservationTime")
public ReservationResponseDto saveReservationWithLock(
CustomUserPrincipal userDetail,
ReservationRequestDto dto,
Long storeId,
Long userCouponId) {
Store store = storeRepository.findById(storeId)
.orElseThrow(() -> new CustomRuntimeException(ExceptionCode.CANT_FIND_DATA));
// 락 내부에서 안전한 동시성 제어
int reservationCount = countReservationPeopleAtTime(store.getId(), dto.getReservationTime());
if (store.getSeatCount() - reservationCount < dto.getPeopleNumber()) {
throw new CustomRuntimeException(ExceptionCode.NO_SEAT_AVAILABLE);
}
// 안전한 예약 처리
Reservation reservation = createReservation(userDetail, dto, store, userCouponId);
return ReservationResponseDto.from(reservationRepository.save(reservation));
}
// 잘못된 방식: 락이 트랜잭션 내부에 있음
@Transactional
public void problematicMethod() {
if (tryLock()) {
// 락 획득 실패 시에도 이미 트랜잭션이 시작됨
// → DB 커넥션 낭비, 롤백 비용 증가
}
}
// 올바른 방식: 락이 트랜잭션 외부에 있음
public void improvedMethod() {
if (tryLock()) {
try {
executeTransactionalLogic(); // @Transactional 메서드 호출
} finally {
releaseLock();
}
}
}
// 문제: leaseTime이 너무 짧아 트랜잭션 커밋 전에 락 해제
boolean acquired = lock.tryLock(5, 3, TimeUnit.SECONDS); // 3초 후 자동 해제
// 해결: 충분한 leaseTime 설정
boolean acquired = lock.tryLock(5, 10, TimeUnit.SECONDS); // 10초로 증가
정원 5명 식당 동시 예약 테스트 (30개 동시 요청)
락 구현 방식 | 트랜잭션 위치 | 격리 수준 | 성공 예약 수 | 상태 |
---|---|---|---|---|
락 미적용 | - | Default | 7~10명 | ❌ 오버부킹 |
Lettuce (블로킹 X) | 내부 | Default | 3~4명 | ⚠️ 과도한 실패 |
Lettuce (블로킹 O) | 내부 | Default | 10~14명 | ❌ 여전히 오버부킹 |
Lettuce + AOP | 외부 | Default | 5명 | ✅ 정확한 제어 |
Redisson | 외부 | REPEATABLE_READ | 5~6명 | ⚠️ 간헐적 오버부킹 |
Redisson | 외부 | READ_COMMITTED | 5명 | ✅ 완벽한 제어 |
쿠폰 수량: 1000개
동시 발급 요청 사용자: 1200명
목표: 최대 1000명에게만 발급되도록 동시성 제어
public void issueCouponWithNamedLockAndJdbc(User user, Long couponId) {
String key = "coupon:" + couponId;
nameLockWithJdbcTemplate.executeWithLock(
key, 5, () -> {
issueCoupon(user, couponId);
return null;
}
);
}
public void issueCouponWithNamedLockAndDS(User user, Long couponId) {
String key = "coupon:" + couponId;
nameLockWithDataSource.executeWithLock(
key, 3, () -> {
issueCoupon(user, couponId);
return null;
}
);
}
구현 방식 | 장점 | 단점 | 적합성 |
---|---|---|---|
JDBC Template | 코드 간결, 빠른 개발 | 커넥션 분리로 락 해제 실패 가능 | 단순한 로직 |
DataSource 직접구현 | 커넥션 일관성 보장, 안정성 우수 | 직접 커넥션 관리 필요 | 복잡한 트랜잭션 |
// ❌ 문제 코드: Spring AOP 프록시 한계
@Service
public class UserCouponService {
@Transactional
public void issueCouponWithNamedLock(User user, Long couponId) {
String key = "coupon:" + couponId;
nameLockWithJdbcTemplate.executeWithLock(key, 5, () -> {
// 내부 호출로 인해 @Transactional 무시됨
this.issueCoupon(user, couponId); // ❌ 트랜잭션 적용 안됨
return null;
});
}
@Transactional
public void issueCoupon(User user, Long couponId) {
// 트랜잭션이 동작하지 않음
coupon.use();
couponRepository.save(coupon);
}
}
// ✅ 해결 코드: Self 주입으로 프록시 호출
@Service
public class UserCouponService {
@Lazy
@Autowired
private UserCouponService self; // 프록시 객체 주입
// 락만 처리하는 메서드 (트랜잭션 없음)
public void issueCouponWithNamedLock(User user, Long couponId) {
String key = "coupon:" + couponId;
nameLockWithJdbcTemplate.executeWithLock(key, 5, () -> {
// 프록시를 통한 호출로 트랜잭션 적용
self.issueCoupon(user, couponId); // ✅ 트랜잭션 정상 동작
return null;
});
}
@Transactional
public void issueCoupon(User user, Long couponId) {
log.info("ISSUE COUPON CALLED");
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(() -> new BaseException(ExceptionCode.NOT_FOUND_COUPON));
coupon.use();
couponRepository.save(coupon);
userCouponRepository.save(UserCoupon.from(user, coupon));
}
}
// ❌ 문제 코드: NamedParameterJdbcTemplate 바인딩 실패
@Component
public class NameLockWithJdbcTemplate {
private final NamedParameterJdbcTemplate jdbcTemplate;
public boolean tryLock(String lockName, int timeoutSeconds) {
// MySQL GET_LOCK 함수는 Named Parameter 지원하지 않음
String sql = "SELECT GET_LOCK(:name, :timeout)"; // ❌ 바인딩 오류
Map<String, Object> params = Map.of(
"name", lockName,
"timeout", timeoutSeconds
);
try {
Integer result = jdbcTemplate.queryForObject(sql, params, Integer.class);
return result != null && result == 1;
} catch (Exception e) {
log.error("Lock acquisition failed", e);
return false;
}
}
}
private final JdbcTemplate jdbcTemplate;
public boolean tryLock(String lockName, int timeoutSeconds) {
// Positional Parameter 사용
String sql = "SELECT GET_LOCK(?, ?)"; // ✅ 정상 동작
try {
Integer result = jdbcTemplate.queryForObject(
sql,
Integer.class,
lockName,
timeoutSeconds
);
return result != null && result == 1;
} catch (Exception e) {
log.error("Lock acquisition failed: {}", e.getMessage());
return false;
}
}
public void releaseLock(String lockName) {
String sql = "SELECT RELEASE_LOCK(?)";
try {
jdbcTemplate.queryForObject(sql, Integer.class, lockName);
} catch (Exception e) {
log.warn("Lock release failed: {}", e.getMessage());
}
}
public <T> T executeWithLock(String lockName, int timeoutSeconds, Supplier<T> supplier) {
if (!tryLock(lockName, timeoutSeconds)) {
throw new BaseException(ExceptionCode.LOCK_ACQUISITION_FAILED);
}
try {
return supplier.get();
} finally {
releaseLock(lockName);
}
}
}// ❌ 문제 코드: 재시도 로직 없는 단순 구현
@Component
public class NameLockWithDataSource {
private final DataSource dataSource;
public boolean tryLock(String lockName, int timeoutSeconds) {
try (Connection connection = dataSource.getConnection();
PreparedStatement stmt = connection.prepareStatement("SELECT GET_LOCK(?, ?)")) {
stmt.setString(1, lockName);
stmt.setInt(2, timeoutSeconds);
ResultSet rs = stmt.executeQuery();
rs.next();
int result = rs.getInt(1);
// 실패 시 즉시 반환 → 동시성 충돌에 취약
return result == 1; // ❌ 재시도 없음
} catch (SQLException e) {
log.error("MySQL lock failed", e);
return false;
}
}
}
항목 | MySQL (배타적 락) | Redis (비관적 락 대안으로 분산 제어) |
---|---|---|
락 단위 | 이름 기반 전역 잠금 (User-level) | 키 기반 분산 잠금 (Redisson 등 활용) |
락 획득 실패 시 | 즉시 실패, 직접 핸들링 필요 | TTL 기반 재시도 및 자동 회복 가능 |
DB 트랜잭션 연동 | 가능 (특히 DataSource 방식) | 불가능, 어플리케이션 수준에서 분리 처리 필요 |
충돌 방지 메커니즘 | 없음 (락 대기 순서 보장 X) | Lua/Redisson 사용 시 대기 및 자동 제어 가능 |
적합한 환경 | 단일 서버, 빠른 PoC | 분산 환경, 고동시성 서비스 |
영역 | 주요 성과 | 개선 지표 |
---|---|---|
검색 성능 | Redis 캐싱 도입 | 응답시간 87.5% 개선 |
조회 성능 | 인덱싱 최적화 | ID 조회 3.46배 빠름 |
동시성 제어 | 분산락 구현 | 오버부킹 100% 해결 |
개발 효율성 | CI 파이프라인 구축 | 빌드 자동화 완료 |
예상: 기술 도입 → 설정 → 완료
실제: 문제 발생 → 원인 분석 → 대안 모색 → 해결 → 재발 방지