FourChak 프로젝트 총 트러블슈팅

김현정·2025년 5월 23일
1

성능 최적화부터 DevOps까지 - 식당 예약 플랫폼 개발 여정

🎯 프로젝트 개요

FourChak은 Spring Boot 기반의 식당 예약 플랫폼으로, 개발 과정에서 다양한 성능 이슈와 인프라 구축 과제에 직면했습니다.

주요 기능

  • 🔍 실시간 식당 검색 (키워드 기반, 지역별 필터링)
  • 📅 좌석 예약 시스템 (시간대별 예약, 쿠폰 적용)
  • 📊 인기 검색어 통계 (실시간 트렌드 분석)
  • 👥 사용자 관리 (회원가입, 로그인, 프로필 관리)

기술 스택

  • Backend: Spring Boot 3.4.5, Spring Security, Spring Data JPA
  • Database: MySQL, Redis
  • DevOps: Jenkins, Docker, AWS EC2
  • Testing: JUnit 5, nGrinder

1. Redis 캐싱을 통한 검색 성능 최적화

🚨 문제 상황

검색 기능에서 LIKE 쿼리로 인한 심각한 성능 저하가 발생했습니다.

// 문제가 된 기존 코드
@GetMapping("/search")
public Page<StoreResponseDto> searchStores(@RequestParam String keyword, Pageable pageable) {
    // LIKE 쿼리로 인한 전체 테이블 스캔
    return storeRepository.findByStoreNameContaining(keyword, pageable);
}

성능 문제:

  • 평균 응답시간: 1.2초
  • LIKE 쿼리로 인한 Full Table Scan
  • 동일한 키워드 반복 검색으로 인한 DB 부하

💡 캐싱 전략 수립

Local Cache → Remote Cache 전환 이유

구분Local CacheRemote 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();
    }
}

🧪 성능 테스트 트러블슈팅

nGrinder 구축 실패 경험

  1. 포트 충돌: Docker 환경에서 여러 포트 시도
  2. 웹 UI 오작동: 스크립트 생성 버튼이 동작하지 않음
  3. 브라우저 호환성: JavaScript 오류로 인한 기능 장애
# 대안 성능 측정 방법
time curl -s "http://localhost:8080/api/v1/search?keyword=맛집" # 캐시 X
time curl -s "http://localhost:8080/api/v2/search?keyword=맛집" # 캐시 O

📈 캐싱 최종 결과

메트릭캐시 적용 전캐시 적용 후개선율
평균 응답시간1,200ms150ms87.5%
DB 쿼리 수100%20%80% 감소
동시 처리량50 TPS200 TPS300% 향상

2. Jenkins CI/CD 구축 도전기

🎯 도입 배경

수동 배포의 반복적인 작업을 줄이고, 자동화된 빌드/배포 파이프라인 구축을 목표로 했습니다.

# 기존 수동 배포 과정 (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. 로그 확인

✅ CI (지속적 통합) 성공

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 파일 생성 완료

❌ CD (지속적 배포) 트러블슈팅

1. SSH 권한 설정 문제

# 문제: 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"'
}

2. 배포 스크립트 안정성 문제

#!/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 (배포)🔄 구현 중자동 배포 완성
모니터링❌ 미구현로그 수집/알림 시스템

3. DB 인덱싱 성능 최적화

🔍 인덱스의 필요성

대량의 데이터에서 특정 정보를 찾는 것은 전체 컬럼을 조회하는 구조상 상당히 오래 걸립니다.

숫자 맞추기 게임으로 이해하는 인덱스

상황: 1~100 중 상대방이 생각한 숫자(70) 맞추기

일반적인 방식: 1인가요? 2인가요? 3인가요? ... (순차 검색)
인덱스 방식: 50보다 큰가요? 75보다 작은가요? 63보다 큰가요? (이진 검색)

📊 인덱스 종류별 특성

인덱스 타입특징장점단점
Hash IndexKey-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.19msUNIQUE 인덱스기준값
ID 조회1.21msPrimary Key3.46배 빠름

핵심 인사이트: Primary Key 인덱스는 B+Tree 구조로 최적화되어 가장 빠른 성능을 보여줍니다.


4. Redis 분산락을 통한 동시성 제어

⚡ 동시성 문제 발생

시나리오: 정원 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명 (오버부킹 발생)

🔒 분산락 솔루션 비교

구분LettuceRedisson
방식비동기, 논블로킹동기, 블로킹
재시도수동 구현 필요자동 재시도 지원
락 타입단순 SET NX PX재진입 락 지원
복잡도낮음높음
성능높음보통

🛠 Lettuce 분산락 구현

@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;
    }
}

🎨 AOP를 활용한 분산락 어노테이션

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

🚨 주요 트러블슈팅 케이스

Case 1: 트랜잭션 범위와 락 범위 불일치

// 잘못된 방식: 락이 트랜잭션 내부에 있음
@Transactional
public void problematicMethod() {
    if (tryLock()) {
        // 락 획득 실패 시에도 이미 트랜잭션이 시작됨
        // → DB 커넥션 낭비, 롤백 비용 증가
    }
}

// 올바른 방식: 락이 트랜잭션 외부에 있음
public void improvedMethod() {
    if (tryLock()) {
        try {
            executeTransactionalLogic(); // @Transactional 메서드 호출
        } finally {
            releaseLock();
        }
    }
}

Case 2: Redisson leaseTime 설정 오류

// 문제: leaseTime이 너무 짧아 트랜잭션 커밋 전에 락 해제
boolean acquired = lock.tryLock(5, 3, TimeUnit.SECONDS); // 3초 후 자동 해제

// 해결: 충분한 leaseTime 설정
boolean acquired = lock.tryLock(5, 10, TimeUnit.SECONDS); // 10초로 증가

📊 분산락 성능 테스트 결과

정원 5명 식당 동시 예약 테스트 (30개 동시 요청)

락 구현 방식트랜잭션 위치격리 수준성공 예약 수상태
락 미적용-Default7~10명❌ 오버부킹
Lettuce (블로킹 X)내부Default3~4명⚠️ 과도한 실패
Lettuce (블로킹 O)내부Default10~14명❌ 여전히 오버부킹
Lettuce + AOP외부Default5명✅ 정확한 제어
Redisson외부REPEATABLE_READ5~6명⚠️ 간헐적 오버부킹
Redisson외부READ_COMMITTED5명✅ 완벽한 제어

5. MySQL 기반 분산락 구현

✅ 테스트 시나리오

쿠폰 수량: 1000개
동시 발급 요청 사용자: 1200명
목표: 최대 1000명에게만 발급되도록 동시성 제어

🔧 구현 방식 개요

JDBC Template 방식

  • NamedParameterJdbcTemplate을 사용해 GET_LOCK/RELEASE_LOCK 쿼리 실행
  • 자동 커넥션 관리로 인한 커넥션 분리 문제 발생 가능
public void issueCouponWithNamedLockAndJdbc(User user, Long couponId) {
    String key = "coupon:" + couponId;
    
    nameLockWithJdbcTemplate.executeWithLock(
        key, 5, () -> {
            issueCoupon(user, couponId);
            return null;
        }
    );
}

DataSource 직접 구현 방식

  • Connection을 수동으로 얻고, GET_LOCK → 로직 수행 → RELEASE_LOCK 을 동일 커넥션에서 처리
  • 트랜잭션과 커넥션의 일관성 보장에 유리
public void issueCouponWithNamedLockAndDS(User user, Long couponId) {
    String key = "coupon:" + couponId;
    
    nameLockWithDataSource.executeWithLock(
        key, 3, () -> {
            issueCoupon(user, couponId);
            return null;
        }
    );
}
구현 방식장점단점적합성
JDBC Template코드 간결, 빠른 개발커넥션 분리로 락 해제 실패 가능단순한 로직
DataSource 직접구현커넥션 일관성 보장, 안정성 우수직접 커넥션 관리 필요복잡한 트랜잭션

🛠️ 트러블슈팅

1. issueCoupon() 트랜잭션 내부 호출 실패 (JDBC Template 방식)

  • 트랜잭션이 포함된 메서드에서 내부 호출이 무시되어 로직 실행되지 않음
// ❌ 문제 코드: 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);
    }
}
  • 해결: 트랜잭션은 issueCoupon()에만 적용, 외부 메서드는 단순히 락만 처리
// ✅ 해결 코드: 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));
    }
}

2. NamedParameter 사용 오류 (JDBC Template 방식)

  • 문제: SELECT GET_LOCK(:name, :timeout) → 파라미터 바인딩 오류
// ❌ 문제 코드: 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;
        }
    }
}
  • 해결: SELECT GET_LOCK(?, ?)로 수정 후 positional parameter 사용
    // ✅ 해결 코드: JdbcTemplate과 Positional Parameter 사용
    @Component
    public class NameLockWithJdbcTemplate {
    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);
        }
    }
    }

3. 동시성 충돌 및 성능 문제 (MySQL 락 자체 이슈)

  • GET_LOCK()은 락 대기 큐 보장 X → 실패 시 즉시 반환
// ❌ 문제 코드: 재시도 로직 없는 단순 구현
@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;
        }
    }
}
  • Redis에 비해 충돌율 높고, 재시도 로직 없으면 실패 다발

MySQL vs Redis 락 비교 (구조적 관점)

항목MySQL (배타적 락)Redis (비관적 락 대안으로 분산 제어)
락 단위이름 기반 전역 잠금 (User-level)키 기반 분산 잠금 (Redisson 등 활용)
락 획득 실패 시즉시 실패, 직접 핸들링 필요TTL 기반 재시도 및 자동 회복 가능
DB 트랜잭션 연동가능 (특히 DataSource 방식)불가능, 어플리케이션 수준에서 분리 처리 필요
충돌 방지 메커니즘없음 (락 대기 순서 보장 X)Lua/Redisson 사용 시 대기 및 자동 제어 가능
적합한 환경단일 서버, 빠른 PoC분산 환경, 고동시성 서비스

요약

  • DataSource 기반 구현이 안정성과 락 일관성 확보에 가장 유리
  • Redis는 멀티 인스턴스/고성능 환경에서 락 충돌 회피 측면에서 강점
  • JPA 비관적 락은 DB 레벨에서 순차적 처리되므로 충돌에 강함
  • 상황에 따라 GET_LOCK() 기반보다 @Lock(PESSIMISTIC_WRITE) 또는 Redis 기반 접근이 효율적일 수 있음

종합 결과 및 학습

🎯 프로젝트 전체 성과

영역주요 성과개선 지표
검색 성능Redis 캐싱 도입응답시간 87.5% 개선
조회 성능인덱싱 최적화ID 조회 3.46배 빠름
동시성 제어분산락 구현오버부킹 100% 해결
개발 효율성CI 파이프라인 구축빌드 자동화 완료

🎓 핵심 학습 내용

1. 이론과 실무의 차이

  • 문서의 간단함 vs 실제 구현의 복잡함
  • 로컬 환경 성공 vs 운영 환경 문제
  • 도구의 한계와 대안 마련의 중요성

2. 트러블슈팅의 가치

예상: 기술 도입 → 설정 → 완료
실제: 문제 발생 → 원인 분석 → 대안 모색 → 해결 → 재발 방지

3. 단계적 접근의 중요성

  1. 최소 기능부터 구현 (MVP 접근)
  2. 점진적 개선 (성능 → 안정성 → 확장성)
  3. 충분한 테스트 (단위 → 통합 → 성능)
  4. 모니터링 준비 (문제 조기 발견)

📚 참고 자료

캐싱 관련

CI/CD 관련

인덱싱 관련

동시성 제어 관련

0개의 댓글