API 개발
설명 | HTTP 메서드와 URL |
쿠폰 발급하기 | POST /api/v1/issue |
GitHub - happyprogfrog/coupon-sample: 선착순 쿠폰 발급 시스템 <인생은선착순>
선착순 쿠폰 발급 시스템 <인생은선착순>. Contribute to happyprogfrog/coupon-sample development by creating an account on GitHub.
github.com
트래픽 처리 방식
우리의 서비스는 원래 유저의 요청을 처리하는 단순한 서버 구조를 가지고 있었다.
그러다가 서비스가 대박이 나서 유저의 트래픽이 증가했고, 이를 대응하기 위해 API 서버를 수평 확장했다고 가정해 보자.
그런데, 마냥 API 서버를 확장한다고 해서 처리량이 늘어날까...?
만약, Application 서버에 병목이 있었다면 API 서버 수평 확장으로 부하를 해소할 수 있다.
ex) 연산이 복잡해서 CPU의 부하가 올라갔다.
ex) 커넥션이 많아지면서 CPU 부하가 올라갔다.
하지만 데이터베이스 서버에 병목이 있었던 경우라면 API 서버를 확장한다고 해도 부하를 해소할 수 없다. 결국 모든 API 서버가 데이터베이스 서버를 바라보면서 요청을 처리하고 있기 때문이다. 이 데이터베이스의 처리량이 낮은 경우, 결국 응답 시간에 영향을 주게 되고 결과적으로 처리량이 떨어지게 된다.
→ 이 경우는 캐시, 데이터베이스 서버 확장(master, slave), 샤딩 등의 방법을 사용해야 한다.
API 서버를 확장하는 것과, 데이터베이스 서버를 확장하는 방법을 비교해 봤을 때 API 서버를 확장하는 것이 훨씬 더 관리하기가 좋다. 따라서,
- MySQL의 트래픽을 Redis를 사용하여 분산한다.
- 애플리케이션 로직을 최적화하여 데이터베이스의 부하를 최소화한다.
- 애플리케이션 서버의 수평 확장으로 API 서버의 처리량을 늘린다!
와 같이 진행해 본다.
동시성 제어
GitHub - happyprogfrog/coupon-sample: 선착순 쿠폰 발급 시스템 <인생은선착순>
선착순 쿠폰 발급 시스템 <인생은선착순>. Contribute to happyprogfrog/coupon-sample development by creating an account on GitHub.
github.com
쿠폰 발급 요청이 순차적으로 발생했다면 문제가 없었겠지만...트래픽이 높아지는 경우, 아래와 같이 요청이 동시에 발생하는 경우가 있을 수 있다.
Lock을 적용한다는 의미
공유 자원을 동시에 접근해서 문제가 될 수 있는 부분(임계영역, Critical Section)을 순차 처리한다.
그런데 Lock은 처리량의 병목을 발생시킨다는 것을 기억하고 있어야 한다.
1차: synchronized 키워드 사용
- 트랜잭션 내에서 락을 거는 것은 주의해야 한다.
- 락을 해제하고 나왔지만, 트랜잭션 커밋이 아직 덜 된 상태라면...다른 유저가 락을 걸고 들어와 issuedQuantity를 읽을 때 문제가 발생할 수 있다.
@Transactional
public void issue(long couponId, long userId) {
// 쿠폰 발급에 대한 검증을 진행하고, 검증 성공 시 쿠폰 발급처리까지 진행
synchronized (this) {
Coupon coupon = findCoupon(couponId);
coupon.issue();
saveCouponIssue(couponId, userId);
}
}
따라서, 다음과 같이 트랜잭션 바깥에서 락을 거는 것이 좋다.
@Transactional
public void issue(long couponId, long userId) {
// 쿠폰 발급에 대한 검증을 진행하고, 검증 성공 시 쿠폰 발급처리까지 진행
Coupon coupon = findCoupon(couponId);
coupon.issue();
saveCouponIssue(couponId, userId);
}
public class CouponIssueRequestService {
private final CouponIssueService couponIssueService;
public void issueRequestV1(CouponIssueRequestDto requestDto) {
synchronized (this) {
couponIssueService.issue(requestDto.couponId(), requestDto.userId());
}
log.info("쿠폰 발급 완료! coupon_id: %s, user_id: %s".formatted(requestDto.couponId(), requestDto.userId()));
}
}
하지만 synchronized 키워드는 자바 애플리케이션에 종속되며, 여러 서버로 확장이 되는 순간 락을 제대로 관리할 수 없다. 따라서, 분산락 구현을 통해 동시성 이슈를 제어해야 한다. 분산락 구현 방법은 여러 가지가 있지만, 여기서는 redis를 사용한 방법과 MySQL의 X락을 사용하는 방법을 알아본다.
2차: redis를 사용한 분산락 구현
의존성 추가
implementation("org.redisson:redisson-spring-boot-starter:3.16.4")
redisson 설정
package me.progfrog.couponcore.configuration;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedisConfiguration {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Bean
RedissonClient redissonClient() {
Config config = new Config();
String address = "redis://" + host + ":" + port;
config.useSingleServer().setAddress(address);
return Redisson.create(config);
}
}
DistributeLockExecutor
package me.progfrog.couponcore.component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@RequiredArgsConstructor
@Component
@Slf4j
public class DistributeLockExecutor {
private final RedissonClient redissonClient;
public void execute(String lockName, long waitMilliSecond, long leaseMilliSecond, Runnable logic) {
RLock lock = redissonClient.getLock(lockName);
try {
boolean isLocked = lock.tryLock(waitMilliSecond, leaseMilliSecond, TimeUnit.MILLISECONDS);
if (!isLocked) {
throw new IllegalStateException("[" + lockName + "] lock 획득 실패");
}
logic.run();
} catch (InterruptedException e) {
log.error(e.getMessage(), e);
throw new RuntimeException(e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
CouponIssueRequestService
public class CouponIssueRequestService {
private final CouponIssueService couponIssueService;
private final DistributeLockExecutor distributeLockExecutor;
public void issueRequestV1(CouponIssueRequestDto requestDto) {
distributeLockExecutor.execute("lock_" + requestDto.couponId(), 10000, 10000, () -> {
couponIssueService.issue(requestDto.couponId(), requestDto.userId());
});
log.info("쿠폰 발급 완료! coupon_id: %s, user_id: %s".formatted(requestDto.couponId(), requestDto.userId()));
}
}
- execute()에 Runnable을 던져서, execute() 안쪽에서 우리의 로직이 돌도록 한다. 이때, execute() 내부에서 락에 대한 처리를 진행한다.
3차: MySQL의 X락
- X(Exclusive Lock) 락이란? 한 트랜잭션이 자원에 대해 X락을 걸면 다른 트랜잭션은 그 자원에 접근할 수 없다.
- 레코드 잠금을 통해, 데이터 수정 중 다른 트랜잭션이 접근하지 못하게 함으로써 데이터 일관성을 유지할 수 있다.
- 단, 락을 걸 레코드가 반드시 존재해야 한다.
- X락은 중첩해서 걸 수 없다는 특징이 있으며, commit 되면 X락은 해제된다.
CouponJpaRepository
package me.progfrog.couponcore.repository.mysql;
import jakarta.persistence.LockModeType;
import me.progfrog.couponcore.model.Coupon;
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 CouponJpaRepository extends JpaRepository<Coupon, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Coupon c WHERE c.id = :id")
Optional<Coupon> findCouponWithLock(@Param("id") long id);
}
[JPA] 비관적 락 간단 정리
비관적 락(Pessimistic Lock)비관적 락은 동시에 여러 트랜잭션이 같은 데이터를 수정할 때 발생할 수 있는 충돌을 방지하기 위해서 사용된다. 동시성 제어가 중요한 시나리오에서 데이터 무결성을
progfrog.tistory.com
CouponIssueService
@Transactional
public void issue(long couponId, long userId) {
// 쿠폰 발급에 대한 검증을 진행하고, 검증 성공 시 쿠폰 발급처리까지 진행
Coupon coupon = findCouponWithLock(couponId);
coupon.issue();
saveCouponIssue(couponId, userId);
}
@Transactional(readOnly = true)
public Coupon findCouponWithLock(long couponId) {
return couponJpaRepository.findCouponWithLock(couponId)
.orElseThrow(() -> COUPON_NOT_EXIST.build(couponId));
}
CouponIssueRequestService
public class CouponIssueRequestService {
private final CouponIssueService couponIssueService;
private final DistributeLockExecutor distributeLockExecutor;
public void issueRequestV1(CouponIssueRequestDto requestDto) {
couponIssueService.issue(requestDto.couponId(), requestDto.userId());
log.info("쿠폰 발급 완료! coupon_id: %s, user_id: %s".formatted(requestDto.couponId(), requestDto.userId()));
}
}