과제 제출 - #8
Conversation
Red: 실패하는 테스트를 먼저 작성 Green: 테스트를 통과하는 최소한의 코드 작성 [구현 메서드] - charge(), use(), getUserPoint(), getPointHistory()
동시성 제어 방식에 대한 분석 및 보고서 작성
|
안녕하세요 현호님. 과제 진행하느라 고생 많으셨습니다. 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작성하신 // 현재 구현 - 좋습니다
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. 동시성 테스트 도구 선택 기준현호님이 정리하신 것처럼 상황에 따라 적절한 도구를 선택하는 것이 중요합니다.
추천 드리는 프로세스: Q2-Q7. TDD 실무 적용 관련 답변TDD 적용 순서 (Q2): 현호님처럼 서비스부터 시작하는 것도 좋은 선택입니다. 핵심 비즈니스 로직에 집중할 수 있습니다. TDD 100% 적용이 어려운 경우 (Q5):
레거시 코드 수정시 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_전체플로우() {
// 실제와 유사한 환경
}
}추가 학습 키워드동시성 제어 심화
분산 환경 대비
성능 모니터링// 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 분석이 좋았습니다. 이런 사고방식은 좋은 개발자가 되는 데 필요한 역량입니다. 궁금한 점이나 추가로 논의하고 싶은 내용은 편하게 말씀주세요. 고생 많으셨습니다! |
커밋 설명
동시성 처리 및 테스트 코드 추가 (동시성 제어 방식에 대한 분석 및 보고서 작성): d4620f7
TDD 프로세스 Red-Green 이용한 기본 테스트 코드 작성 : 48fab16
리뷰 받고 싶은 내용(질문)
PointServiceTest.java (557줄, 585줄)PointServiceTest.java과제 셀프 피드백
모호/애매했던 부분
잘한 점
아쉬운 점 / 개선 여지
다음 단계
기술적 성장
새로 학습한 개념
기존 지식의 심화
확장 관점 학습