분산락을 활용한 실전 예제

개발자 팀·2026년 1월 25일

self-study-series

목록 보기
9/16
post-thumbnail

이번엔 앞서 살펴봤던 분산락 개념을 통합하여 실제 운영 환경에서 바로 사용할 수 있는 시스템을 만들어보도록 하겠습니다.
'포인트 시스템'으로 잔액을 조회하고, 충전하고, 차감하고 다른 사용자에게 이체하는 방식의 서비스를 구현해보도록 하겠습니다.
프로젝트 구조는 아래와 같습니다.

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사용자 ID12345
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차) 조합

원자성이 보장되어야 하는 비즈니스에는 분산락을 적극적으로 고려하여 도입해보시길 바랍니다.
읽어주셔서 감사합니다 🫡

profile
공부하고 기록하고 공유하는 개발자 팀(Tim) 입니다. 늘끄적입니다.

0개의 댓글