Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
.idea
.gradle
build


# User-specific stuff
Expand Down
Binary file removed .gradle/8.4/checksums/checksums.lock
Binary file not shown.
Binary file removed .gradle/8.4/checksums/md5-checksums.bin
Binary file not shown.
Binary file removed .gradle/8.4/checksums/sha1-checksums.bin
Binary file not shown.

This file was deleted.

This file was deleted.

Binary file not shown.
Binary file not shown.
Empty file.
Binary file removed .gradle/8.4/executionHistory/executionHistory.bin
Binary file not shown.
Binary file removed .gradle/8.4/executionHistory/executionHistory.lock
Binary file not shown.
Binary file removed .gradle/8.4/fileChanges/last-build.bin
Binary file not shown.
Binary file removed .gradle/8.4/fileHashes/fileHashes.bin
Binary file not shown.
Binary file removed .gradle/8.4/fileHashes/fileHashes.lock
Binary file not shown.
Binary file removed .gradle/8.4/fileHashes/resourceHashesCache.bin
Binary file not shown.
Empty file removed .gradle/8.4/gc.properties
Empty file.
Binary file removed .gradle/buildOutputCleanup/buildOutputCleanup.lock
Binary file not shown.
2 changes: 0 additions & 2 deletions .gradle/buildOutputCleanup/cache.properties

This file was deleted.

Binary file removed .gradle/buildOutputCleanup/outputFiles.bin
Binary file not shown.
Binary file removed .gradle/file-system.probe
Binary file not shown.
Empty file removed .gradle/vcs-1/gc.properties
Empty file.
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
## 동시성 제어 분석 보고서
### 현재 적용안 (단일 JVM 기준)
#### Per-User Lock (ConcurrentHashMap + ReentrantLock)
- 아이디어: userId → ReentrantLock 매핑으로 같은 사용자 요청만 직렬화. 다른 사용자끼리는 병렬 허용
- 핵심 순서: 검증 → 조회 → 계산 → 업데이트 → 이력
- 오류 처리: 업데이트 실패 시 이력 기록 금지(순서상 불가능)

```java
// 요지 코드
private final ConcurrentHashMap<Long, ReentrantLock> userLocks = new ConcurrentHashMap<>();

private ReentrantLock lockOf(long userId) {
return userLocks.computeIfAbsent(userId, k -> new ReentrantLock(true)); // 공정 락
}

public 메서드 () {
// 예시 사용
ReentrantLock lock = lockOf(userId);
lock.lock();

try {
... // 검증, 조회, 계산, 업데이트
}finally {
try {
releaseIfIdle(userId, lock);
} finally {
lock.unlock();
}
}
}
```

### 구조
```mermaid
flowchart LR
API[REST API] --> Svc[PointService]
subgraph PointService
Dir{userId별 Lock} --> CS[(임계구역)]
end
CS --> DBT1[UserPointTable]
CS --> DBT2[PointHistoryTable]
```

### 대안
| 방식 | 장점 | 단점 | 적용 시점 |
| -------------------------------------- | -------------------------------- | -------------------------- | ------------------ |
| **ConcurrentHashMap + ReentrantLock** | 구현 간단, 빠름, 단일 JVM에 적합 | 멀티 인스턴스에선 무력 | **현재** (개발/단일 서버) |
| **DB 비관적 락 (`SELECT … FOR UPDATE`)** | 분산 환경에서 안전, 단일 소스오브트루스 | DB 부하↑, 지연↑ | 다중 인스턴스, 강한 일관성 필요 |
| **DB 낙관적 락(버전 필드)** | 충돌 적을 때 성능 좋음 | 충돌 시 재시도 필요 | 중간 QPS, 경쟁 낮은 패턴 |
| **원자 쿼리(증감)** | 간결(UPDATE SET point = point + ?) | 복합 로직(이력/검증)엔 부족 | 단순 카운터/캐시성 |
| **Redis 분산 락 (e.g., RedLock)** | 분산 환경 전용, 유연 | Redis 운영/가용성 고려, TTL/해제 이슈 | 수평 확장, DB 락 회피 시 |
| **메시지 큐 직렬화(Kafka partition by user)** | 자연스러운 직렬화/백프레셔 | 실시간성↓, 구조 복잡 | 트래픽↑, 일괄 처리 선호 |


### 개선
1. 읽기 → 무락 유지(스냅샷 허용), 쓰기 → 분산락/DB락
2. 단기: DB 비관락으로 포팅 (가장 단순/확실)
3. 장기: 트래픽 패턴 따라 선택
- 낙관락 + 재시도 (경쟁 낮음)
- Redis 락 (경쟁 중간, DB 부하 회피)
- Kafka 파티션 (고QPS/백프레셔 필요)

### 결론
- 현재: 단일 JVM에서 Per-User Lock이 가장 단순하고 빠른 해법
- 확장: 멀티 인스턴스 전환 시 DB 락/낙관락, Redis 락, Kafka 파티션 중 업무 특성에 맞게 선택
- 테스트/모니터링/멱등성까지 갖추면 운영 안정성이 크게 향상

### 느낀 점 (회고 / Lessons Learned)
- Per-User 락은 단일 JVM에서 정확성·단순성·성능의 균형이 좋다.
- 업데이트 → 이력, 산술 안전, 락 해제 보장, 상태 기반 테스트는 필수 베스트 프랙티스.
- 트래픽/배포 구조가 커지면 분산 락/DB 트랜잭션/큐 직렬화로 자연스럽게 승격하자.
2 changes: 0 additions & 2 deletions build/resources/main/application.yml

This file was deleted.

Binary file removed build/tmp/compileJava/previous-compilation-data.bin
Binary file not shown.
12 changes: 8 additions & 4 deletions src/main/java/io/hhplus/tdd/point/PointController.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package io.hhplus.tdd.point;

import io.hhplus.tdd.database.UserPointTable;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
Expand All @@ -8,9 +10,11 @@

@RestController
@RequestMapping("/point")
@RequiredArgsConstructor
public class PointController {

private static final Logger log = LoggerFactory.getLogger(PointController.class);
private final PointService pointService;

/**
* TODO - 특정 유저의 포인트를 조회하는 기능을 작성해주세요.
Expand All @@ -19,7 +23,7 @@ public class PointController {
public UserPoint point(
@PathVariable long id
) {
return new UserPoint(0, 0, 0);
return pointService.getUserPoint(id);
}

/**
Expand All @@ -29,7 +33,7 @@ public UserPoint point(
public List<PointHistory> history(
@PathVariable long id
) {
return List.of();
return pointService.getPointHistory(id);
}

/**
Expand All @@ -40,7 +44,7 @@ public UserPoint charge(
@PathVariable long id,
@RequestBody long amount
) {
return new UserPoint(0, 0, 0);
return pointService.charge(id, amount);
}

/**
Expand All @@ -51,6 +55,6 @@ public UserPoint use(
@PathVariable long id,
@RequestBody long amount
) {
return new UserPoint(0, 0, 0);
return pointService.use(id, amount);
}
}
39 changes: 39 additions & 0 deletions src/main/java/io/hhplus/tdd/point/PointService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package io.hhplus.tdd.point;

import java.util.List;

public interface PointService {
/**
* 포인트 충전
*
* @param userId 사용자 ID
* @param amount 충전할 포인트 금액
* @return 충전된 포인트 정보
*/
UserPoint charge(long userId, long amount);

/**
* 포인트 사용
*
* @param userId 사용자 ID
* @param amount 사용할 포인트 금액
* @return 사용된 포인트 정보
*/
UserPoint use(long userId, long amount);

/**
* 사용자 포인트 조회
*
* @param userId 사용자 ID
* @return 사용자 포인트 정보
*/
UserPoint getUserPoint(long userId);

/**
* 포인트 이력 조회
*
* @param userId 사용자 ID
* @return 포인트 이력 정보
*/
List<PointHistory> getPointHistory(long userId);
}
180 changes: 180 additions & 0 deletions src/main/java/io/hhplus/tdd/point/PointServiceImpl.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package io.hhplus.tdd.point;

import io.hhplus.tdd.database.PointHistoryTable;
import io.hhplus.tdd.database.UserPointTable;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;

@Service
@Slf4j
@RequiredArgsConstructor
public class PointServiceImpl implements PointService {
private final UserPointTable userPointTable;
private final PointHistoryTable pointHistoryTable;

// userId -> Lock 매핑 (공정 락: 대기 순서 보장)
private final ConcurrentHashMap<Long, ReentrantLock> userLocks = new ConcurrentHashMap<>();

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

private void releaseIfIdle(long userId, ReentrantLock lock) {
// 대기 스레드가 없으면 맵에서 제거(메모리 누수 완화)
if (!lock.hasQueuedThreads()) {
userLocks.remove(userId, lock);
}
}

@Override
public UserPoint charge(long userId, long amount) {
/**
* 진행 프로세스
* 1. 유저 ID와 충전 금액이 유효한지 검증
* 2. 기존 포인트 조회
* 3. 기존 포인트가 없으면 새로 생성
* 4. 포인트 이력 추가
* 5. 포인트 충전
*/
validateUserId(userId);
validatePositiveAmount(amount);

ReentrantLock lock = lockOf(userId);
lock.lock();
try {
// 기존 포인트에 더하기
UserPoint existingUserPoint = userPointTable.selectById(userId);
if (existingUserPoint == null) {
// 기존 포인트가 없으면 새로 생성
existingUserPoint = UserPoint.empty(userId);
}

long newAmount = existingUserPoint.point() + amount;

// 포인트 충전
log.info("[charge] User ID: {}, Amount to Charge: {}, Existing Point: {}", userId, amount, existingUserPoint.point());

UserPoint userPoint = userPointTable.insertOrUpdate(userId, newAmount);

if (userPoint == null) {
throw new IllegalStateException("Failed to charge user point for user ID: " + userId);
}
// 포인트 이력 추가
pointHistoryTable.insert(userId, amount, TransactionType.CHARGE, System.currentTimeMillis());

// 최종 결과 로그
log.info("[charge] User ID: {}, Charged Amount: {}, New Point Balance: {}", userId, amount, userPoint.point());
return userPoint;
} finally {
try {
releaseIfIdle(userId, lock);
} finally {
lock.unlock();
}
}
}

@Override
public UserPoint use(long userId, long amount) {
/**
* 진행 프로세스
* 1. 유저 ID와 충전 금액이 유효한지 검증
* 2. 기존 포인트 조회
* 3. 기존 포인트가 없으면 새로 생성
* 4. 포인트 이력 추가
* 5. 포인트 차감
*/
validateUserId(userId);
validatePositiveAmount(amount);

ReentrantLock lock = lockOf(userId);
lock.lock();

try {
// 기존 포인트 조회
UserPoint userPoint = userPointTable.selectById(userId);
// 기존 포인트가 없으면 새로 생성
if (userPoint == null) {
userPoint = UserPoint.empty(userId);
}

if (userPoint == null) {
throw new IllegalStateException("User point not found for user ID: " + userId);
}

if (userPoint.point() < amount) {
throw new IllegalStateException("Insufficient points for user ID: " + userId);
}

long newPoint = userPoint.point() - amount;
// 포인트가 음수로 떨어지지 않도록 검증 (잔고가 부족할 경우 예외 발생)
if (newPoint < 0) {
throw new IllegalStateException("New point balance cannot be negative for user ID: " + userId);
}

// 포인트 이력 추가
pointHistoryTable.insert(userId, amount, TransactionType.USE, System.currentTimeMillis());

// 포인트 차감
log.info("[use] User ID: {}, Amount to Use: {}, Existing Point: {}", userId, amount, userPoint.point());

UserPoint result = userPointTable.insertOrUpdate(userId, newPoint);

// 최종 결과 로그
log.info("[use] User ID: {}, Used Amount: {}, New Point Balance: {}", userId, amount, result.point());
return result;
} finally {
try {
releaseIfIdle(userId, lock);
} finally {
lock.unlock();
}
}
}

@Override
public UserPoint getUserPoint(long userId) {
validateUserId(userId);

UserPoint userPoint = userPointTable.selectById(userId);

if (userPoint == null) {
userPoint = UserPoint.empty(userId);
}

// 최종 결과 로그
log.info("[getUserPoint] User ID: {}, Current Point Balance: {}", userId, userPoint.point());
return userPoint;
}

@Override
public List<PointHistory> getPointHistory(long userId) {
validateUserId(userId);

List<PointHistory> histories = pointHistoryTable.selectAllByUserId(userId);

if (histories == null || histories.isEmpty()) {
return List.of();
}

// 최종 결과 로그
log.info("[getPointHistory] User ID: {}, Point History Count: {}", userId, histories.size());
// 각 이력 로그
histories.forEach(history -> log.info("[getPointHistory] History ID: {}, Amount: {}, Type: {}, Updated At: {}",
history.id(), history.amount(), history.type(), history.updateMillis()));
return histories;
}

private void validateUserId(long userId) {
if (userId <= 0) throw new IllegalArgumentException("User ID must be greater than 0");
}
private void validatePositiveAmount(long amount) {
if (amount <= 0) throw new IllegalArgumentException("Amount must be greater than 0");
}
}
Loading