
이번엔 앞서 살펴봤던 분산락 개념을 통합하여 실제 운영 환경에서 바로 사용할 수 있는 시스템을 만들어보도록 하겠습니다.
'포인트 시스템'으로 잔액을 조회하고, 충전하고, 차감하고 다른 사용자에게 이체하는 방식의 서비스를 구현해보도록 하겠습니다.
프로젝트 구조는 아래와 같습니다.
com.example
├── domain/ # 엔티티 (데이터 구조)
│ ├── Point.java # 포인트 정보
│ └── PointTransaction.java # 거래 기록
├── repository/ # 데이터베이스 접근
│ └── PointRepository.java
├── service/ # 비즈니스 로직 (분산락 적용!)
│ └── PointService.java
├── controller/ # API 엔드포인트
│ └── PointController.java
├── lock/ # 분산락 관련
│ ├── annotation/DistributedLock.java
│ └── aspect/DistributedLockAspect.java
└── exception/ # 예외 처리
└── GlobalExceptionHandler.java
이제 엔티티 정의부터 쭉 진행해보도록 하겠습니다.
이번에 정의할 엔티티는 Point 엔티티입니다.
다음의 필드를 보유하고 있는 클래스입니다.
| 필드 | 역할 | 예시 |
|---|---|---|
id | 고유 식별자 (자동 생성) | 1, 2, 3... |
userId | 사용자 ID | 12345 |
amount | 현재 포인트 잔액 | 10000 |
updatedAt | 마지막 수정 시간 | 2025-01-23 10:30:00 |
version | 낙관적 락용 버전 | 1, 2, 3... |
package com.example.domain;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "points")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Point {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private Long userId;
@Column(nullable = false)
private Integer amount;
@Column(nullable = false)
private LocalDateTime updatedAt;
@Version // 낙관적 락을 위한 버전 필드
private Long version;
public static Point create(Long userId, Integer initialAmount) {
Point point = new Point();
point.userId = userId;
point.amount = initialAmount;
point.updatedAt = LocalDateTime.now();
return point;
}
public void add(int addAmount) {
if (addAmount <= 0) {
throw new IllegalArgumentException("추가 금액은 0보다 커야 합니다");
}
this.amount += addAmount;
this.updatedAt = LocalDateTime.now();
}
public void deduct(int deductAmount) {
if (deductAmount <= 0) {
throw new IllegalArgumentException("차감 금액은 0보다 커야 합니다");
}
if (this.amount < deductAmount) {
throw new InsufficientBalanceException(
String.format("잔액 부족: 현재 %d, 필요 %d", this.amount, deductAmount)
);
}
this.amount -= deductAmount;
this.updatedAt = LocalDateTime.now();
}
}
다음은 Repository를 정의해봅니다. 기능 정의는 아래와 같습니다.

package com.example.repository;
import com.example.domain.Point;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface PointRepository extends JpaRepository<Point, Long> {
Optional<Point> findByUserId(Long userId);
/**
* 비관적 락(Pessimistic Lock)을 사용한 조회
* DB 레벨에서 추가 보호를 제공
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Point p WHERE p.userId = :userId")
Optional<Point> findByUserIdWithLock(@Param("userId") Long userId);
}
다음은 서비스 레이어에 대해 알아보도록 하겠습니다. 여기선 비즈니스 로직을 처리하고 분산락을 적용합니다.
아래 코드에서 @DistributedLock과 @Transactional 어노테이션을 함께 사용합니다.
또한 이번 서비스 코드에선 이중 보호 전략을 사용합니다. 즉, Redis를 활용한 분산락과 함께 DB 비관적 락을 사용하여 이중 보호 전략을 구현합니다.

다음은 해당 내용을 구현한 PointService 클래스입니다.
package com.example.service;
import com.example.domain.Point;
import com.example.domain.PointTransaction;
import com.example.lock.annotation.DistributedLock;
import com.example.repository.PointRepository;
import com.example.repository.PointTransactionRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class PointService {
private final PointRepository pointRepository;
private final PointTransactionRepository transactionRepository;
/**
* 포인트 조회
*/
@DistributedLock(
key = "'user:' + #userId + ':point:lock'",
waitTime = 3,
leaseTime = 10
)
@Transactional(readOnly = true)
public PointResponse getPoints(Long userId) {
Point point = pointRepository.findByUserId(userId)
.orElseGet(() -> Point.create(userId, 0));
return new PointResponse(userId, point.getAmount());
}
/**
* 포인트 충전
*/
@DistributedLock(
key = "'user:' + #userId + ':point:lock'",
waitTime = 5,
leaseTime = 30
)
@Transactional
public PointResponse addPoints(Long userId, int amount, String reason) {
log.info("포인트 충전 시작: userId={}, amount={}", userId, amount);
// 1. 포인트 조회 (없으면 생성)
Point point = pointRepository.findByUserId(userId)
.orElseGet(() -> {
Point newPoint = Point.create(userId, 0);
return pointRepository.save(newPoint);
});
int beforeAmount = point.getAmount();
// 2. 포인트 추가
point.add(amount);
pointRepository.save(point);
// 3. 트랜잭션 기록
PointTransaction transaction = PointTransaction.create(
userId,
PointTransaction.Type.ADD,
amount,
beforeAmount,
point.getAmount(),
reason
);
transactionRepository.save(transaction);
log.info("포인트 충전 완료: userId={}, before={}, after={}",
userId, beforeAmount, point.getAmount());
return new PointResponse(userId, point.getAmount());
}
/**
* 포인트 차감
*/
@DistributedLock(
key = "'user:' + #userId + ':point:lock'",
waitTime = 5,
leaseTime = 30
)
@Transactional
public PointResponse deductPoints(Long userId, int amount, String reason) {
log.info("포인트 차감 시작: userId={}, amount={}", userId, amount);
// 1. 포인트 조회 (비관적 락 사용 - 이중 보호)
Point point = pointRepository.findByUserIdWithLock(userId)
.orElseThrow(() -> new PointNotFoundException(userId));
int beforeAmount = point.getAmount();
// 2. 포인트 차감
point.deduct(amount);
pointRepository.save(point);
// 3. 트랜잭션 기록
PointTransaction transaction = PointTransaction.create(
userId,
PointTransaction.Type.DEDUCT,
amount,
beforeAmount,
point.getAmount(),
reason
);
transactionRepository.save(transaction);
log.info("포인트 차감 완료: userId={}, before={}, after={}",
userId, beforeAmount, point.getAmount());
return new PointResponse(userId, point.getAmount());
}
/**
* 포인트 이체 (사용자 A → 사용자 B)
*
* 주의: 두 개의 락이 필요한 경우, 항상 같은 순서로 획득해야 데드락 방지
*/
@Transactional
public void transfer(Long fromUserId, Long toUserId, int amount, String reason) {
// 락 키를 정렬하여 항상 같은 순서로 획득
Long firstUserId = Math.min(fromUserId, toUserId);
Long secondUserId = Math.max(fromUserId, toUserId);
// 첫 번째 사용자 락 획득 후 두 번째 사용자 락 획득
transferInternal(firstUserId, secondUserId, fromUserId, toUserId, amount, reason);
}
@DistributedLock(
key = "'user:' + #firstUserId + ':point:lock'",
waitTime = 10,
leaseTime = 60
)
protected void transferInternal(
Long firstUserId, Long secondUserId,
Long fromUserId, Long toUserId,
int amount, String reason) {
// 중첩 락 대신, 두 번째 락도 획득하는 로직
// 실제로는 두 번째 락도 어노테이션으로 처리하거나
// 명시적으로 락을 획득해야 합니다.
// 포인트 차감 (보내는 사람)
deductPointsInternal(fromUserId, amount, "이체 - " + reason);
// 포인트 추가 (받는 사람)
addPointsInternal(toUserId, amount, "이체 수신 - " + reason);
}
}
해당 서비스를 이용하는 API는 다음과 같이 구현합니다.
package com.example.controller;
import com.example.service.PointService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/points")
@RequiredArgsConstructor
public class PointController {
private final PointService pointService;
@GetMapping("/{userId}")
public ResponseEntity<PointResponse> getPoints(@PathVariable Long userId) {
return ResponseEntity.ok(pointService.getPoints(userId));
}
@PostMapping("/{userId}/add")
public ResponseEntity<PointResponse> addPoints(
@PathVariable Long userId,
@RequestBody AddPointRequest request) {
return ResponseEntity.ok(
pointService.addPoints(userId, request.amount(), request.reason())
);
}
@PostMapping("/{userId}/deduct")
public ResponseEntity<PointResponse> deductPoints(
@PathVariable Long userId,
@RequestBody DeductPointRequest request) {
return ResponseEntity.ok(
pointService.deductPoints(userId, request.amount(), request.reason())
);
}
}
분산락은 정말 강력한 도구이지만 잘못사용하면 오히려 시스템의 성능을 저하시키고 치명적인 장애를 일으킬 수도 있습니다. 그러니 반드시 제대로 알고 사용해야 합니다. 또한 아래 5가지 원칙을 지켜서 사용해야 합니다.
| # | 원칙 | 이유 | 실천 방법 |
|---|---|---|---|
| 1 | 락 범위 최소화 | 락 보유 시간이 길수록 다른 요청들이 대기해야 함 | DB 업데이트만 락 안에서, API 호출은 락 밖에서 |
| 2 | 적절한 TTL | 짧으면 작업 중 만료, 길면 장애 복구 지연 | 예상 시간 × 3 또는 Watch Dog 사용 |
| 3 | 재시도 로직 | 동시 요청 시 일부는 실패가 정상 | 지수 백오프(exponential backoff) 적용 |
| 4 | 모니터링 | 문제 발생 시 빠른 감지 필요 | 락 대기 시간, 실패율 대시보드 구성 |
| 5 | 이중 잠금 | 분산락만으로 100% 보장 어려움 | 분산락(1차) + DB 비관적 락(2차) 조합 |
원자성이 보장되어야 하는 비즈니스에는 분산락을 적극적으로 고려하여 도입해보시길 바랍니다.
읽어주셔서 감사합니다 🫡