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 .idea/.name

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions .idea/codeStyles/Project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .idea/codeStyles/codeStyleConfig.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions .idea/gradle.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

112 changes: 112 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# **포인트 관리시스템**

이 프로젝트는 사용자 포인트 관리를 위한 백엔드 애플리케이션입니다.
사용자는 포인트를 충전하거나 사용할 수 있으며, 현재 잔여포인트와 포인트 변동 이력을 조회할 수 있습니다.
동시성 제어를 통해 다수의 사용자가 동시에 포인트를 요청을 처리할 때 발생할 수 있는 문제를 방지합니다.

## **1. 요구사항**

### **1.1 포인트 조회**
- 사용자별 **현재 포인트 조회** 가능
- 존재하지 않는 사용자는 **0포인트**로 초기화

### **1.2 포인트 이력 조회**
- 사용자별 **포인트 변동 이력** 조회 가능
- 존재하지 않는 사용자는 **빈 리스트** 반환

### **1.3 포인트 충전**
- **충전 조건**:
- 0보다 큰 포인트만 충전 가능. (최소 충전 포인트: 1점)
- 최대 10,000점까지 충전 가능 (최대값을 변경될 수 있음)
- 현재 포인트와 충전 포인트의 합이 **10,000점을 초과할 수 없음**
- **포인트 이력 기록**:
- 충전 시 **포인트 변동 이력**이 기록되어야 함

### **1.4 포인트 사용**
- **사용 조건**:
- 0보다 큰 포인트만 사용 가능 (최소 사용 포인트: 1점)
- 현재 보유한 포인트보다 **많은 포인트는 사용할 수 없음**
- **포인트 이력 기록**:
- 사용 시 **포인트 변동 이력**이 기록되어야 함

### **1.5 동시성 제어**
- **동일 사용자의 포인트 변동**은 **순차적으로(서비스 레이어에 도달한 순서대로) 처리**
- **서로 다른 사용자**의 포인트 변동은 **동시 처리** 가능 (사용자별로 **독립적인 락** 관리)


---

## **2. 동시성 제어 방식에 대한 분석**

`PointService`에서 `UserPoint` 객체를 사용해 공유 자원에 동시 접근시 동시성 문제가 발생할 수 있습니다.

### **예시 상황 #1**
- 유저의 잔여 포인트 : 5,000 포인트
1. 첫 번째 요청: 3,000 포인트 사용 -> 결과 2,000포인트
2. 두 번째 요청: 2,000 포인트 사용 -> 결과 0 포인트

### **문제 발생**
- 두 요청이 동시에 잔여포인트(5,000)를 조회.
- 첫 번째 요청 처리 후 잔여 포인트가 2,000이 되었지만, 두 번째 요청이 여전히 **5,000으로 조회된 값**을 기준으로 계산.
- 결과적으로 잔여 포인트가 3,000이 되는 문제가 발생.

### **예시 상황 #2**
- 유저의 잔여 포인트 : 5,000 포인트
1. 첫 번째 요청: 3,000 포인트 사용 -> 결과 2,000포인트
2. 두 번째 요청: 5,000 포인트 사용 -> 오류 발생

### **문제 발생**
- 첫 번째 요청 처리 후에도 잔여 포인트가 5,000으로 인식하여 두 번째 요청이 그대로 진행되다가 오류 발생

---

## **3. 동시성 제어 전략**

### **1. synchronized 키워드의 한계**
- **장점**: 한 번에 하나의 스레드만 접근하도록 제한 가능.
- **단점**: 모든 요청을 순차적으로 처리하므로, **다른 사용자**의 요청도 대기해야 함.
- 예: A 유저의 포인트 처리가 끝나야 B 유저와 C 유저의 요청도 처리됨.
- 결과 Deadlock 등 무한 대기 상황이 발생할 수 있음.

### **2. ConcurrentHashMap과 ReentrantLock 활용**
- 따라서 사용자별로 **ReentrantLock**을 **ConcurrentHashMap**을 사용해 관리하여 동시성을 제어.
- **ReentrantLock**을 사용해서 서로 다른 사용자가 동시에 처리 가능.
- **ConcurrentHashMap**은 여러 스레드에서 **동시에 안전하게 관리할 수 있는 Thread-safe 컬렉션**으로,
락을 효율적으로 저장하고 관리. 동일 사용자의 요청은 같은 **ReentrantLock**을 통해 순차적으로 처리할 수 있다.

---

## **4. 동시성 제어 동작 원리**

1. **ConcurrentHashMap**에서 사용자 **`userId`로 기존 락을 조회.**
2. **락이 없으면 `computeIfAbsent`를 사용해 새로운 락을 생성.**
3. **생성된 락은 `fair=true`로 설정**하여, **요청이 서비스 레이어에 도달한 시간을 기준으로**
스레드 대기 시간이 긴 순서대로 락을 획득하도록 보장

### **결과**
- **동일 사용자의 요청:** 동일한 `ReentrantLock`으로 순차 처리.
- **서로 다른 사용자의 요청:** 서로 다른 `ReentrantLock`으로 동시 처리 가능.

---

## **5. 동시성 제어 처리 로직**

```java
public UserPoint chargePointById(long userId, long chargeAmount) {
ReentrantLock lock = lockManager.getLock(userId); // 1. 락 획득 시도
lock.lock(); // 2. 코드 락 걸기
try{
//...
} finally {
lock.unlock(); // 완료 후 락 해제
}
}
```
### **세부 동작 설명**
#### 1. 락 획득 시도
- lockManager.getLock(userId)를 통해 사용자별 락을 가져오고, 락을 획득.
- 락이 이미 다른 스레드에 의해 사용 중이라면, 대기열에 들어가 순서를 기다림.
#### 2. 포인트 처리
- 사용자 포인트 정보를 조회한 뒤, 비즈니스 로직(충전 또는 사용)을 처리.
#### 3. 락 해제
- 작업이 완료되면 락을 해제하여 다음 스레드가 작업을 수행할 수 있도록 함.
40 changes: 40 additions & 0 deletions pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
### **커밋 링크**
<!--
좋은 피드백을 받기 위해 가장 중요한 것은 코드를 작성할 때 커밋을 작업 단위로 잘 쪼개는 것입니다.
모든 작업을 하나의 커밋에 진행하고 PR을 하면 구조 파악에 많은 시간을 소모하기 때문에 절대로
좋은 피드백을 받을 수 없습니다.


필수 양식)
커밋 이름 : 커밋 링크

예시)
동시성 처리 : c83845
동시성 테스트 코드 : d93ji3
-->

---
### **리뷰 포인트(질문)**
- 리뷰 포인트 1
- 리뷰 포인트 2
<!-- - 리뷰어가 특히 확인해야 할 부분이나 신경 써야 할 코드가 있다면 명확히 작성해주세요.(최대 2개)

좋은 예:
- `ErrorMessage` 컴포넌트의 상태 업데이트 로직이 적절한지 검토 부탁드립니다.
- 추가한 유닛 테스트(`LoginError.test.js`)의 테스트 케이스가 충분한지 확인 부탁드립니다.

나쁜 예:
- 개선사항을 알려주세요.
- 코드 전반적으로 봐주세요.
- 뭘 질문할지 모르겠어요. -->
---
### **이번주 KPT 회고**

### Keep
<!-- 유지해야 할 좋은 점 -->

### Problem
<!--개선이 필요한 점-->

### Try
<!-- 새롭게 시도할 점 -->
2 changes: 2 additions & 0 deletions src/main/java/io/hhplus/tdd/database/UserPointTable.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ public class UserPointTable {

private final Map<Long, UserPoint> table = new HashMap<>();

// 사용자 Id 기반 데이터 조회
public UserPoint selectById(Long id) {
throttle(200);
return table.getOrDefault(id, UserPoint.empty(id));
}

// USER POINT 삽입 함수
public UserPoint insertOrUpdate(long id, long amount) {
throttle(300);
UserPoint userPoint = new UserPoint(id, amount, System.currentTimeMillis());
Expand Down
25 changes: 25 additions & 0 deletions src/main/java/io/hhplus/tdd/point/LockManager.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.hhplus.tdd.point;

import org.springframework.stereotype.Component;

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

/* Spring 컨테이너에 클래스를 등록하는 부분 -> 이제 AutoWired로 끌어올 수 있음*/
@Component
public class LockManager {
/**
* Long -> 사용자 ID, ReentrantLock -> 사용자에 해당하는 Thread-Safe한 ReentrantLock 객체 할당
* ReentrantLock : 다중 스레드 환경에서 **임계 구역(Critical Section)**에 대한 안전한 접근을 보장하기 위해 사용,
* 단, 명시적으로 **unlock()**을 호출, 복잡성 증가, 성능 오버헤드
* ConcurrentHashMap은 다중 스레드 환경에서도 안전하게 데이터를 삽입하고 접근할 수 있도록 동시성 처리가 보장
* */
private final ConcurrentHashMap<Long, ReentrantLock> userLocks = new ConcurrentHashMap<>();

public ReentrantLock getLock(long userId) {
/**
* 주어진 userId가 맵에 없을 경우, 해당 ID를 키로 새로운 락을 생성하여 삽입 후 반환
* */
return userLocks.computeIfAbsent(userId, k -> new ReentrantLock());
}
}
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
Expand Up @@ -6,11 +6,15 @@

import java.util.List;

import lombok.RequiredArgsConstructor;

@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.selectPointById(id);
}

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

/**
Expand All @@ -40,7 +44,7 @@ public UserPoint charge(
@PathVariable long id,
@RequestBody long amount
) {
return new UserPoint(0, 0, 0);
return pointService.chargePointById(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.usePointById(id, amount);
}
}
6 changes: 6 additions & 0 deletions src/main/java/io/hhplus/tdd/point/PointHistory.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package io.hhplus.tdd.point;

/*
* 포인트 내역 클래스
*
* 포인트 충전/사용 내역을 저장하는 클래스
*
*/
public record PointHistory(
long id,
long userId,
Expand Down
72 changes: 72 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,72 @@
package io.hhplus.tdd.point;

import io.hhplus.tdd.point.UserPoint;
import org.springframework.stereotype.Service;

import io.hhplus.tdd.database.PointHistoryTable;
import io.hhplus.tdd.database.UserPointTable;
import lombok.RequiredArgsConstructor;

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

@Service
@RequiredArgsConstructor // 클래스의 final 필드와 @NonNull로 표시된 필드에 대한 생성자를 자동으로 생성해 줍니다.
public class PointService {
private final UserPointTable userPointTable;
private final PointHistoryTable pointHistoryTable;
private final LockManager lockManager;

public UserPoint selectPointById(long userId) {
return userPointTable.selectById(userId);
}

public List<PointHistory> selectHistoryById(long id)
{
return pointHistoryTable.selectAllByUserId(id);
}

public UserPoint chargePointById(long userId, long chargeAmount) {
ReentrantLock lock = lockManager.getLock(userId);
lock.lock();
try{
// check for user exists
UserPoint userPoint = userPointTable.selectById(userId);
if(userPoint == null){
userPoint = UserPoint.empty(userId);
}
/// DDD work
userPoint = userPoint.charge(userPoint, chargeAmount);
userPoint = userPointTable.insertOrUpdate(userId, userPoint.point());

// recording
pointHistoryTable.insert(userId, chargeAmount, TransactionType.CHARGE, System.currentTimeMillis());
return userPoint;
} finally {
lock.unlock();
}
}

public UserPoint usePointById(long userId, long useAmount) {
ReentrantLock lock = lockManager.getLock(userId);
lock.lock();
try{
// check for user exists
UserPoint userPoint = userPointTable.selectById(userId);
if(userPoint == null){
return UserPoint.empty(userId);
}
userPoint.chkForValidate(TransactionType.USE, useAmount);

// DDD
userPoint = userPoint.use(userPoint, useAmount);
userPoint = userPointTable.insertOrUpdate(userId, userPoint.point());

// recording
pointHistoryTable.insert(userId, useAmount, TransactionType.USE, System.currentTimeMillis());
return userPoint;
} finally {
lock.unlock();
}
}
}
Loading