Skip to content

과제 제출 - #8

Open
hyunolike wants to merge 2 commits into
mainfrom
feature/tdd
Open

과제 제출#8
hyunolike wants to merge 2 commits into
mainfrom
feature/tdd

Conversation

@hyunolike

@hyunolike hyunolike commented Aug 17, 2025

Copy link
Copy Markdown
Contributor

커밋 설명

동시성 처리 및 테스트 코드 추가 (동시성 제어 방식에 대한 분석 및 보고서 작성): d4620f7
TDD 프로세스 Red-Green 이용한 기본 테스트 코드 작성 : 48fab16


리뷰 받고 싶은 내용(질문)

  • 커밋 : 동시성 처리 및 테스트 코드 추가 d4620f7
  • 내용 : 동시성 테스트 코드 작성 시 아래와 같이 메서드들을 만들어서 동시성 테스트를 진행했는데 실무에서는 동시성 처리관련 테스트 코드를 어떻게 작성하는건지 궁금합니다. - PointServiceTest.java (557줄, 585줄)
    • 테스트 도구 선택 시 CountDownLatch, ExecutorService 등을 이용한 Java 코드 vs JMeter/nGrinder 같은 외부 툴 중 언제 어떤 것을 선택하시나요?
/**
         * [유틸리티 메서드]
         * 동일한 작업을 여러 스레드에서 반복 실행하는 동시성 테스트 유틸
         *
         * [작성 이유]
         * - 부하 테스트나 동일 작업의 동시 실행 시나리오 테스트용
         * - CountDownLatch로 모든 스레드가 동시에 시작하도록 보장
         * - CompletableFuture로 모든 작업 완료까지 대기
         */
        private void runConcurrently(int threads, Runnable task) throws Exception {
            ExecutorService ex = Executors.newFixedThreadPool(threads);
            try {
                // 시작과 완료를 동기화하기 위한 카운트다운 래치 (CountDownLatch (동시 시작/종료 동기화))
                CountDownLatch start = new CountDownLatch(1);
                CompletableFuture<?>[] jobs = new CompletableFuture<?>[threads];
                for (int i = 0; i < threads; i++) {
                    jobs[i] = CompletableFuture.runAsync(() -> {
                        try { start.await(); task.run(); } catch (InterruptedException ignored) {}
                    }, ex); // 공용 풀 대신 전용 풀
                }
                start.countDown(); // 동시에 출발
                CompletableFuture.allOf(jobs).get(); // 완료까지 기다려야 하는 경우
            } finally {
                ex.shutdownNow();
                ex.awaitTermination(10, TimeUnit.SECONDS);
            }
        }
  • 커밋 : TDD 프로세스 Red-Green 이용한 기본 테스트 코드 작성 : 48fab16
  • 내용 : 실무에서 TDD의 RED-GREEN-REFACTOR 사이클 적용 순서에 대한 조언 요청드립니다. - PointServiceTest.java
    • 질문 1. API 개발 시 컨트롤러, 서비스, 리포지토리 중 어느 레이어부터 테스트를 작성하시나요? (아마 요구수항
    • 질문 2. 현재 서비스 레이어(요구사항 나열) 테스트 코드 작성(RED) → 구현(GREEN) → 리팩토링(REFACTOR) 순으로 진행하고 있는데, 실무에서도 이런 순서로 진행하시나요?
    • 질문 5. 실무에서 TDD를 100% 적용하기 어려운 상황들이 있을 텐데, 어떤 경우에 TDD를 생략하거나 다른 접근을 하시나요?
    • 질문 6. 레거시 코드나 기존 코드 수정 시에도 TDD 방식으로 접근하시나요?
    • 질문 7. 외부 API 연동이나 DB 트랜잭션이 포함된 복잡한 비즈니스 로직의 경우 TDD 접근법이 달라지나요?
@Test
    void givenValidUserId_whenGetUserPoint_thenReturnUserPoint() {
        // given
        Long userId = 1L;
        PointService pointService = new PointServiceImpl(new UserPointTable(), new PointHistoryTable());

        // when
        UserPoint userPoint = pointService.getUserPoint(userId);

        // then
        assertNotNull(userPoint);
    }

과제 셀프 피드백

  • 모호/애매했던 부분

    • 단일 JVM 기준의 동시성만 요구되는지, 멀티 인스턴스/분산 환경까지 고려해야 하는지 과제 범위가 애매했음 → 현재는 userId 단위 직렬화(Per-User Lock)로 풀었음
  • 잘한 점

    • 같은 사용자만 직렬화하고 다른 사용자는 병렬 허용하는 Per-User Lock 설계로 정확성과 처리량 균형 확보.
    • 락 관리 try/finally 보장 및 필요 시 락 객체 정리(remove(key, lock))로 메모리 누수 방지 고려.
    • 시나리오 맵핑 명확: given_when_then 네이밍 + @nested로 기능별/케이스별로 읽기 쉬움
    • 동시성 스모크: CountDownLatch + CompletableFuture로 동시 시작, 전용 풀로 격리 → 안정적인 동시성 유효성 확인.
  • 아쉬운 점 / 개선 여지

    • 읽기 경로의 강한 일관성 옵션(스냅샷 읽기/버전태깅)에 대한 선택지가 남아 있음(요구 시 추가).
  • 다음 단계

    • 멀티 인스턴스 배포 대비: DB 비관/낙관 락 또는 Redis 분산 락 추가 가능

기술적 성장

  • 새로 학습한 개념

    • Per-Key 동기화 패턴: ConcurrentHashMap<Id, ReentrantLock> + computeIfAbsent로 키 단위 직렬화 구현.
    • 락 공정성(fairness) 트레이드오프: new ReentrantLock(true)가 기아 방지엔 유리하나 처리량 저하 가능 → 트래픽 특성에 따라 선택.
    • 정합성 순서의 중요성: “상태 업데이트 → 이력 기록”로 고아 이력 방지.
  • 기존 지식의 심화

    • ExecutorService 수명 주기: shutdown()/shutdownNow()/awaitTermination()를 테스트에서 엄격히 관리해야 리소스 누수/플레이키 감소.
    • 동시성 테스트 안정화: CountDownLatch로 동시 시작, CompletableFuture.allOf(...).get(timeout)로 예외/타임아웃 표면화, 전용 스레드풀로 풀 경합 제거.
  • 확장 관점 학습

    • 단일 JVM 락의 한계 인지 및 분산 환경 대안(DB 비관/낙관 락, Redis 분산 락, Kafka 파티션 직렬화) 비교.

Red: 실패하는 테스트를 먼저 작성
Green: 테스트를 통과하는 최소한의 코드 작성

[구현 메서드]
- charge(), use(), getUserPoint(), getPointHistory()
동시성 제어 방식에 대한 분석 및 보고서 작성
@khj68

khj68 commented Aug 20, 2025

Copy link
Copy Markdown

안녕하세요 현호님. 과제 진행하느라 고생 많으셨습니다.
PR을 살펴봤는데, 동시성 제어에 대한 깊이 있는 고민과 체계적인 접근 방식이 인상적이었습니다. 특히 Per-User Lock 설계와 동시성 테스트 유틸리티 구현이 좋았습니다. 몇 가지 피드백과 질문에 대한 답변 남겨드립니다.

1. Per-User Lock 설계의 우수성과 개선 방안

현재 구현하신 Per-User Lock 방식이 깔끔합니다. 사용자별로 독립적인 락을 관리하여 다른 사용자의 처리를 막지 않는 설계가 좋습니다.

// 현재 구현 - PointServiceImpl.java
private final Map<Long, ReentrantLock> lockMap = new ConcurrentHashMap<>();

private ReentrantLock getLock(long userId) {
    return lockMap.computeIfAbsent(userId, k -> new ReentrantLock(true));
}

다만, 현호님도 언급하신 메모리 누수 방지를 위한 개선안을 제안드려요.

// 개선안 1 - WeakHashMap과 ReferenceQueue를 활용한 자동 정리
public class LockManager {
    private final Map<Long, WeakReference<ReentrantLock>> lockMap = new ConcurrentHashMap<>();
    private final ReferenceQueue<ReentrantLock> queue = new ReferenceQueue<>();
    
    public ReentrantLock getLock(long userId) {
        // 사용하지 않는 reference 정리
        cleanupStaleEntries();
        
        return lockMap.compute(userId, (k, ref) -> {
            ReentrantLock lock = (ref != null) ? ref.get() : null;
            if (lock == null) {
                lock = new ReentrantLock(true);
                return new WeakReference<>(lock, queue);
            }
            return ref;
        }).get();
    }
    
    private void cleanupStaleEntries() {
        Reference<? extends ReentrantLock> ref;
        while ((ref = queue.poll()) != null) {
            lockMap.values().remove(ref);
        }
    }
}

// 개선안 2 - TTL 기반 락 정리
public class TimedLockManager {
    private final Cache<Long, ReentrantLock> lockCache = CacheBuilder.newBuilder()
        .expireAfterAccess(10, TimeUnit.MINUTES)  // 10분간 미사용시 제거
        .maximumSize(10000)  // 최대 보관 개수 제한
        .removalListener((RemovalListener<Long, ReentrantLock>) notification -> {
            // 락 제거시 로깅
            log.debug("Lock removed for user: {}, reason: {}", 
                notification.getKey(), notification.getCause());
        })
        .build();
    
    public ReentrantLock getLock(long userId) {
        try {
            return lockCache.get(userId, () -> new ReentrantLock(true));
        } catch (ExecutionException e) {
            throw new RuntimeException("Failed to get lock for user: " + userId, e);
        }
    }
}

2. 동시성 테스트 utility

작성하신 runConcurrently 메서드가 잘 설계되었습니다. CountDownLatch로 동시 시작을 보장하고, CompletableFuture로 완료를 대기하는 방식이 좋습니다.

// 현재 구현 - 좋습니다
private void runConcurrently(int threads, Runnable task) throws Exception {
    ExecutorService ex = Executors.newFixedThreadPool(threads);
    try {
        CountDownLatch start = new CountDownLatch(1);
        CompletableFuture<?>[] jobs = new CompletableFuture<?>[threads];
        for (int i = 0; i < threads; i++) {
            jobs[i] = CompletableFuture.runAsync(() -> {
                try { 
                    start.await(); 
                    task.run(); 
                } catch (InterruptedException ignored) {}
            }, ex);
        }
        start.countDown();
        CompletableFuture.allOf(jobs).get();
    } finally {
        ex.shutdownNow();
        ex.awaitTermination(10, TimeUnit.SECONDS);
    }
}

추가로 고려하면 좋을 확장 버전을 제안드려요.

// 더 범용적인 utility
public class ConcurrentTestUtils {
    
    // 결과값 취합
    public static <T> List<T> runConcurrentlyWithResults(
            int threads, 
            Callable<T> task,
            Duration timeout) throws Exception {
        
        ExecutorService executor = Executors.newFixedThreadPool(threads);
        CyclicBarrier barrier = new CyclicBarrier(threads);  // CountDownLatch 대신 재사용 가능
        List<Future<T>> futures = new ArrayList<>();
        
        try {
            for (int i = 0; i < threads; i++) {
                futures.add(executor.submit(() -> {
                    barrier.await();  // 모든 스레드가 준비될 때까지 대기
                    return task.call();
                }));
            }
            
            // 타임아웃과 함께 결과 수집
            return futures.stream()
                .map(f -> {
                    try {
                        return f.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    }
                })
                .collect(Collectors.toList());
                
        } finally {
            executor.shutdownNow();
            if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
                log.warn("Executor did not terminate in time");
            }
        }
    }
    
    // 성공/실패 카운트 집계
    public static ConcurrentTestResult runWithMetrics(
            int threads,
            Runnable task) throws Exception {
        
        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger failureCount = new AtomicInteger(0);
        AtomicLong totalTime = new AtomicLong(0);
        
        runConcurrently(threads, () -> {
            long startTime = System.nanoTime();
            try {
                task.run();
                successCount.incrementAndGet();
            } catch (Exception e) {
                failureCount.incrementAndGet();
            } finally {
                totalTime.addAndGet(System.nanoTime() - startTime);
            }
        });
        
        return new ConcurrentTestResult(
            successCount.get(),
            failureCount.get(),
            Duration.ofNanos(totalTime.get() / threads)
        );
    }
}

3. 트랜잭션 정합성 보장 방안

현재 "상태 업데이트 → 이력 기록" 순서로 잘 구현하셨는데, 실제 운영 환경에서는 트랜잭션 관리가 필요합니다.

@Service
@Transactional(readOnly = true)
public class PointServiceImpl implements PointService {
    
    @Transactional(isolation = Isolation.READ_COMMITTED, timeout = 3)
    public UserPoint charge(long userId, long amount) {
        ReentrantLock lock = getLock(userId);
        
        // 타임아웃과 함께 락 획득 시도
        boolean acquired = false;
        try {
            acquired = lock.tryLock(5, TimeUnit.SECONDS);
            if (!acquired) {
                throw new LockAcquisitionException("Failed to acquire lock for user: " + userId);
            }
            
            // 비즈니스 로직
            UserPoint current = userPointTable.selectById(userId);
            validateChargeAmount(amount);
            
            long newBalance = current.point() + amount;
            validateMaxBalance(newBalance);
            
            // atomic 업데이트 보장
            UserPoint updated = userPointTable.insertOrUpdate(userId, newBalance);
            
            // 이벤트 발행 (비동기 처리도 고려하면 좋습니다)
            publishPointChargedEvent(new PointChargedEvent(userId, amount, newBalance));
            
            // 히스토리는 이벤트 리스너에서 처리
            pointHistoryTable.insert(userId, amount, TransactionType.CHARGE, 
                System.currentTimeMillis());
            
            return updated;
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Thread interrupted while waiting for lock", e);
        } finally {
            if (acquired) {
                lock.unlock();
            }
        }
    }
    
    // 이벤트 기반 히스토리 처리 (loose coupling)
    @EventListener
    @Async
    public void handlePointChargedEvent(PointChargedEvent event) {
        try {
            pointHistoryTable.insert(
                event.getUserId(),
                event.getAmount(),
                TransactionType.CHARGE,
                event.getTimestamp()
            );
        } catch (Exception e) {
            // 히스토리 실패는 포인트 충전을 롤백하지 않음
            log.error("Failed to save point history", e);
            // 보상 트랜잭션이나 재시도 로직
            compensationService.scheduleHistoryRetry(event);
        }
    }
}

Q1. 동시성 테스트 도구 선택 기준

현호님이 정리하신 것처럼 상황에 따라 적절한 도구를 선택하는 것이 중요합니다.

  1. Java 코드 기반 테스트 (선택하신 방식)
  • 장점 - 빠른 실행, CI/CD 통합 용이, 정확한 제어
  • 적합한 경우
    • 단위/통합 테스트
    • 동시성 로직 검증
  • 예시 - CountDownLatch, CyclicBarrier, Phaser
  1. JMeter/nGrinder 같은 부하 테스트 도구
  • 장점 - 실제 네트워크 레이턴시 반영, 대규모 부하 생성
  • 적합한 경우
    • 성능 임계치 측정
    • 실제 운영 환경 시뮬레이션
    • SLA 검증
  • 예시: TPS, 응답시간, 에러율 측정

추천 드리는 프로세스:

1단계: Java 코드로 동시성 로직 검증 (개발 단계)
2단계: 통합 테스트로 시스템 통합 검증 (CI 단계)
3단계: JMeter로 성능 테스트 (QA 단계)
4단계: 카나리 배포로 실제 트래픽 검증 (배포 단계)

Q2-Q7. TDD 실무 적용 관련 답변

TDD 적용 순서 (Q2):

1. domain 모델 → 2. service → 3. repository → 4. controller

현호님처럼 서비스부터 시작하는 것도 좋은 선택입니다. 핵심 비즈니스 로직에 집중할 수 있습니다.

TDD 100% 적용이 어려운 경우 (Q5):

  • 외부 API 연동: Contract Test나 Mock Server 활용
  • 레거시 코드: Characterization Test부터 시작
  • Prototype or MVP**: 빠른 검증 후 테스트 보강

레거시 코드 수정시 TDD (Q6):

// 1. 먼저 현재 동작을 보존하는 테스트 작성
@Test
void preserveExistingBehavior() {
    // 현재 동작 캡처
    String result = legacyService.process(input);
    assertThat(result).isEqualTo(expectedCurrentOutput);
}

// 2. 리팩토링
// 3. 새 기능은 TDD로 추가

복잡한 비즈니스 로직의 TDD (Q7):

// 계층별 테스트 전략
class ComplexBusinessTest {
    
    @Test
    void unitTest_핵심로직() {
        // In-Memory로 빠르게
    }
    
    @Test
    @DataJpaTest
    void integrationTest_DB연동() {
        // TestContainer나 H2
    }
    
    @Test
    @SpringBootTest
    void e2eTest_전체플로우() {
        // 실제와 유사한 환경
    }
}

추가 학습 키워드

동시성 제어 심화

  • Java Memory Model (JMM): happens-before 관계 이해
  • Lock-Free 알고리즘: CAS 기반 AtomicReference 활용
  • Reactive Streams: 백프레셔를 통한 동시성 제어
  • Virtual Threads (Java 21): 경량 스레드로 동시성 개선

분산 환경 대비

  • Distributed Lock: Redisson, Zookeeper
  • Saga Pattern: 분산 트랜잭션 관리
  • Event Sourcing: 이벤트 기반 상태 관리
  • CQRS: 읽기/쓰기 분리

성능 모니터링

// Micrometer를 활용한 메트릭 수집
@Component
public class PointMetrics {
    private final MeterRegistry registry;
    
    public void recordChargeTime(long duration) {
        registry.timer("point.charge.time").record(duration, TimeUnit.MILLISECONDS);
    }
    
    public void incrementConcurrentFailure() {
        registry.counter("point.concurrent.failure").increment();
    }
}

깊은 고민이 보이는 과제였습니다. 동시성 제어에 대한 깊이 있는 분석과 단계적 접근이 인상적이었습니다. Per-User Lock 설계, 동시성 테스트 유틸리티, 그리고 상세한 기술 보고서까지 잘 봤습니다. 실무에서도 이런 체계적인 접근을 하는 개발자가 많지 않다고 생각합니다.

같은 사용자만 직렬화하고 다른 사용자는 병렬 허용하는 로직과 그에 대한 trade-off 분석이 좋았습니다. 이런 사고방식은 좋은 개발자가 되는 데 필요한 역량입니다.

궁금한 점이나 추가로 논의하고 싶은 내용은 편하게 말씀주세요.

고생 많으셨습니다!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants