문제
이번 주차를 지나며 겪었던 문제가 무엇이었나요?
첫번째는 TDD로 개발을 하는 것이었습니다. TDD에 익숙하지 않다 보니 TDD로 개발하는 것이 어려웠습니다. 특히 bottom-up 방식에 익숙했던 제가 top-down 방식으로 개발을 하려고 하니 시간이 더 오래걸렸습니다.
두번째는 단일 서버에서의 동시성 이슈를 해결하기 위해서 어떤 방법을 쓸지에 대한 고민이었습니다.
시도
문제를 해결하기 위해 어떤 시도를 하셨나요?
문제를 해결하기 위해 먼저 synchronized 키워드를 사용하였습니다. 동시성 이슈가 발생하는 메서드에 synchronized 키워드를 사용하여 메서드 전체에 lock을 건다면 다른 스레드에서 접근을 하지 못해 순차적으로 로직을 수행하게 됩니다. 다만, 블록 전체에 lock을 사용하기 때문에 같은 유저든 다른 유저든 똑같이 blocking 발생해 수행 속도가 느릴 수 밖에 없습니다.
해결
문제를 어떻게 해결하셨나요?
이 문제를 해결하기 위해 ConcurrentHashMap 을 이용하였습니다. ConcurrentHashMap 은 엔트리 별로 부분 lock을 사용하기 때문에 같은 유저에 대해서만 lock이 걸려 로직을 수행합니다. 좀 더 사용성을 높이기 위해 ConcurrentHashMap 을 전역변수로 가지는 LockHelper 를 만들어 어디서든 내가 원할 때 lock을 걸어 로직을 수행하도록 수정하였습니다. 이렇게 하니 같은 유저에 대한 동시성 이슈를 해결할 수 있었습니다.
코드는 다음과 같습니다.
- LockHelper
// LockHelper
package io.hhplus.tdd.point.common;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Supplier;
@Component
public class LockHelper {
private final Map<Long, Lock> lockMap;
public LockHelper() {
// 동시성 이슈를 막기 위해 부분락을 사용하는 ConcurrentHashMap 을 이용
this.lockMap = new ConcurrentHashMap<>();
}
public Lock getLock(Long id) {
// computeIfAbsent 이걸 사용하면 원자성 보장, 만약 userId가 없다면 새로운 lock 을 생성
return lockMap.computeIfAbsent(id, k -> new ReentrantLock());
}
public <T> T executeWithLock(Long id, Supplier<T> supplier) {
Lock lock = getLock(id);
lock.lock();
try {
return supplier.get();
} finally {
lock.unlock();
}
}
public void executeWithLock(Long id, Runnable runnable) {
Lock lock = getLock(id);
lock.lock();
try {
runnable.run();
} finally {
lock.unlock();
}
}
}
- PointService
package io.hhplus.tdd.point.service;
import io.hhplus.tdd.point.common.LockHelper;
import io.hhplus.tdd.point.domain.PointHistory;
import io.hhplus.tdd.point.domain.UserPoint;
import io.hhplus.tdd.point.exception.PointException;
import io.hhplus.tdd.point.repository.PointHistoryRepository;
import io.hhplus.tdd.point.repository.UserPointRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import static io.hhplus.tdd.point.enums.TransactionType.CHARGE;
import static io.hhplus.tdd.point.enums.TransactionType.USE;
import static io.hhplus.tdd.point.exception.ErrorCode.INVALID_CHARGE_POINT;
import static io.hhplus.tdd.point.exception.ErrorCode.NOT_ENOUGH_POINT;
@Service
@RequiredArgsConstructor
public class PointService {
private final UserPointRepository userPointRepository;
private final PointHistoryRepository pointHistoryRepository;
private final LockHelper lockHelper;
//포인트 조회
public UserPoint getPoint(long id) {
return userPointRepository.selectById(id);
}
//포인트 충전
public UserPoint charge(long id, long amount) {
// 포인트의 유효성 체크
if (!validPoint(amount)) {
throw new PointException(INVALID_CHARGE_POINT, "0보다 작은 포인트는 충전되지 않습니다.");
}
// 동시성 이슈를 개선하기 위해 lockHelper 를 사용
return lockHelper.executeWithLock(id, () -> {
UserPoint curUser = userPointRepository.selectById(id);
// 포인트 충전
UserPoint userPoint = userPointRepository.insertOrUpdate(id, curUser.point() + amount);
// 포인트 충전 내역 추가
pointHistoryRepository.insert(id, amount, CHARGE, System.currentTimeMillis());
return userPoint;
});
}
//포인트 사용
public UserPoint use(long id, long amount) {
// 포인트의 유효성 체크
if (!validPoint(amount)) {
throw new PointException(INVALID_CHARGE_POINT, "0보다 작은 포인트는 사용할 수 없습니다.");
}
// 동시성 이슈를 개선하기 위해 lockHelper 를 사용
return lockHelper.executeWithLock(id, () -> {
UserPoint curUser = userPointRepository.selectById(id);
// 포인트가 부족하지 않은지 체크
if (!isPossibleUse(amount, curUser.point())) {
throw new PointException(NOT_ENOUGH_POINT, "포인트가 부족합니다.");
}
// 포인트 차감
UserPoint userPoint = userPointRepository.insertOrUpdate(id, curUser.point() - amount);
// 포인트 차감 내역 추가
pointHistoryRepository.insert(id, amount, USE, System.currentTimeMillis());
return userPoint;
});
}
//포인트 내역 조회
public List<PointHistory> getHistory(long id) {
return pointHistoryRepository.selectAllByUserId(id);
}
// 충전하려는 포인트가 0 미만인지 체크
private boolean validPoint(long amount) {
return amount >= 0;
}
// 사용하려는 포인트가 남아있는 포인트보다 적은지 체크
private boolean isPossibleUse(long amount, long userPoint) {
return amount <= userPoint;
}
}
- IntegrationTest
package io.hhplus.tdd.point.integration;
import io.hhplus.tdd.point.domain.PointHistory;
import io.hhplus.tdd.point.domain.UserPoint;
import io.hhplus.tdd.point.repository.PointHistoryRepository;
import io.hhplus.tdd.point.repository.UserPointRepository;
import io.hhplus.tdd.point.service.PointService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import static io.hhplus.tdd.point.enums.TransactionType.CHARGE;
import static io.hhplus.tdd.point.enums.TransactionType.USE;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
public class IntegrationTest {
@Autowired
private PointService pointService;
@Autowired
private UserPointRepository userPointRepository;
@Autowired
private PointHistoryRepository pointHistoryRepository;
@DisplayName("유저 id를 받아, 해당 유저의 포인트를 조회한다")
@Test
void getPoint() {
//given
long userId = 1L;
long amount = 1000L;
userPointRepository.insertOrUpdate(userId, amount);
// when
UserPoint result = pointService.getPoint(userId);
//then
assertThat(result.point()).isEqualTo(amount);
}
@DisplayName("기존 1000 포인트에 1000포인트를 충전하면 2000 포인트가 된다.")
@Test
void charge() {
//given
long userId = 2L;
long initAmount = 1000L;
long chargeAmount = 1000L;
long resultAmount = 2000L;
userPointRepository.insertOrUpdate(userId, initAmount);
//when
UserPoint result = pointService.charge(userId, chargeAmount);
//then
assertThat(result.point()).isEqualTo(resultAmount);
}
@DisplayName("5000 포인트를 가진 유저가 동시에 100 포인트를 3번 충전하면 5300이 되어야 한다.")
@Test
void chargeWhenConcurrencyEnv() throws InterruptedException {
//given
long userId = 3L;
int numThreads = 3;
long initAmount = 5000L;
long chargeAmount = 100L;
long resultAmount = 5300L;
userPointRepository.insertOrUpdate(userId, initAmount);
CountDownLatch doneSignal = new CountDownLatch(numThreads);
ExecutorService executorService = Executors.newFixedThreadPool(numThreads);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
//when
for (int i = 0; i < numThreads; i++) {
executorService.execute(() -> {
try {
pointService.charge(userId, chargeAmount);
successCount.getAndIncrement();
} catch (RuntimeException e) {
failCount.getAndIncrement();
} finally {
doneSignal.countDown();
}
});
}
doneSignal.await();
executorService.shutdown();
UserPoint result = pointService.getPoint(userId);
//then
assertThat(result.point()).isEqualTo(resultAmount);
assertThat(successCount.get()).isEqualTo(numThreads);
}
@DisplayName("사용하는 포인트만큼 차감이 된다.")
@Test
void use() {
//given
long userId = 4L;
long initAmount = 1000L;
long useAmount = 100L;
long resultAmount = 900L;
userPointRepository.insertOrUpdate(userId, initAmount);
//when
UserPoint result = pointService.use(userId, useAmount);
//then
assertThat(result.point()).isEqualTo(resultAmount);
}
@DisplayName("500포인트를 가진 유저가 동시에 100 포인트를 3번 사용하면 200 포인트가 된다.")
@Test
void useWhenConcurrencyEnv() throws InterruptedException {
//given
long userId = 5L;
int numThreads = 3;
long useAmount = 100L;
long remainAmount = 500L;
long resultAmount = 200L;
userPointRepository.insertOrUpdate(userId, remainAmount);
CountDownLatch doneSignal = new CountDownLatch(numThreads);
ExecutorService executorService = Executors.newFixedThreadPool(numThreads);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
//when
for (int i = 0; i < numThreads; i++) {
executorService.execute(() -> {
try {
pointService.use(userId, useAmount);
successCount.getAndIncrement();
} catch (RuntimeException e) {
failCount.getAndIncrement();
} finally {
doneSignal.countDown();
}
});
}
doneSignal.await();
executorService.shutdown();
UserPoint result = pointService.getPoint(userId);
//then
assertThat(result.point()).isEqualTo(resultAmount);
assertThat(successCount.get()).isEqualTo(numThreads);
}
@DisplayName("포인트 사용 내역을 조회한다.")
@Test
void history() {
//given
long userId = 6L;
long amount1 = 1000L;
long amount2 = 500L;
long amount3 = 1000L;
pointHistoryRepository.insert(userId, amount1, CHARGE, System.currentTimeMillis());
pointHistoryRepository.insert(userId, amount2, USE, System.currentTimeMillis());
pointHistoryRepository.insert(userId, amount3, CHARGE, System.currentTimeMillis());
//when
List<PointHistory> result = pointService.getHistory(userId);
//then
assertThat(result.size()).isEqualTo(3);
}
}
알게된 것
문제를 해결하기 위해 시도하며 새롭게 알게된 것은 무엇인가요?
일단 동시성 이슈가 되게 흔하게 생길 수 있는 문제라는 것을 깨달았습니다. 그리고 Java 내에서 동시성 이슈를 해결하는 방법이 어떤 것이 있는지 다시 한번 상기하게 되었습니다. 다른 방법으로도 동시성 이슈를 해결할 수 있겠지만, 코치님도 ConcurrentHashMap 을 사용해서 구현하는 것이 제일 최적화스러운 방법이라고 하셨습니다.
지난 목표 회고
지난 주에 설정해두었던 목표는 달성하셨나요? 잘된 것은 무엇이고 안된 것은 무엇인가요?
지난주에 설정했던 목표는 기한 앞에 모든 과제를 끝내는 것이었습니다. 잘된 것은 어쨌든 모든 과제를 다 해서 제출했다는 것이고, 잘 안됐던 것은 정해진 기간 안에 제출을 하지 못했던 것입니다. 생각보다 회사 일이 바쁘기도 했고, 고민하는 시간이 길었던 것 같습니다.
다음 목표 설정
반복적인 성장을 위한 실천 가능한 단기적인 목표를 설정해보세요!
일단 기한 안에 제출하기 입니다. 아무리 회사 일이 바빠도, 하루에 한시간 이상은 꼭 과제를 하는데 시간을 쓸 생각입니다.
'세션 > 항해플러스' 카테고리의 다른 글
항해 플러스 백엔드 챕터 2 후기 (0) | 2024.07.20 |
---|---|
WIL 3주차 회고 (0) | 2024.07.06 |
WIL 2주차 회고 (1) | 2024.06.30 |
99클럽 코테 스터디 28일차 TIL, 그리디(Iterator for Combination) (0) | 2024.06.18 |
시작하는 마음 (0) | 2024.06.15 |