@Transactional
애노테이션만 있다 → 이게 동시성 보장을 해주는건 아님/**
* 좋아요 증가 메서드 - 게시물의 좋아요 개수 증가 - like table에 row 추가하기
*/
@Transactional
public void increaseLike(Long postId, Long userId) {
// 게시물 좋아요 1 증가
int result = postMapper.updateLikeCount(postId, 1);
if (result == 0) {
throw new ApiException(ErrorCode.UPDATE_ERROR);
}
// like 테이블에 좋아요 기록 저장
likeService.insertLike(userId, postId);
}
/**
* 좋아요 취소 기능 - 게시물 좋아요 개수 1 감소 - like table에서 해당 기록 삭제
*/
@Transactional
public void unlikePost(Long postId, Long userId) {
// 게시물의 좋아요 개수 1 감소
int result = postMapper.updateLikeCount(postId, -1);
if (result == 0) {
throw new ApiException(ErrorCode.UPDATE_ERROR);
}
// 좋아요 누른 기록 삭제
likeService.deleteLike(userId, postId);
}
synchronized
처리 /**
* 좋아요 증가 메서드 - 게시물의 좋아요 개수 증가 - like table에 row 추가하기
*/
@Transactional
public synchronized void increaseLike(Long postId, Long userId) {
// 게시물 좋아요 1 증가
int result = postMapper.updateLikeCount(postId, 1);
if (result == 0) {
throw new ApiException(ErrorCode.UPDATE_ERROR);
}
// like 테이블에 좋아요 기록 저장
likeService.insertLike(userId, postId);
}
/**
* 좋아요 취소 기능 - 게시물 좋아요 개수 1 감소 - like table에서 해당 기록 삭제
*/
@Transactional
public synchronized void unlikePost(Long postId, Long userId) {
// 게시물의 좋아요 개수 1 감소
int result = postMapper.updateLikeCount(postId, -1);
if (result == 0) {
throw new ApiException(ErrorCode.UPDATE_ERROR);
}
// 좋아요 누른 기록 삭제
likeService.deleteLike(userId, postId);
}
@Transactional
때문에 race condition 발생syncrhonized
는 해당 메서드가 끝나면 다른 thread가 해당 메서드를 실행할 수 있음
하지만 @Transational
은 메서드 종료되고 트랜잭션이 커밋되고 DB에 반영한다
즉, 메서드 종료와 트랜잭션 커밋 후 DB에 실제로 반영될 때까지 중간에 시간이 빈다.
이때, 다른 thread가 들어와 DB에서 좋아요 개수를 가져오면 아직 수정된 개수가 반영되기 전이다.
그래서 아직도 race condition이 발생함
synchronized
로 동시성 해결이 불가능synchronized
서버가 여러 대라면 동시성이 보장될 수가 없는 것!!!
Server 1 입장에서는 동시에 thread1만 Stock에 접근하도록 제한했기에 동시성을 보장한 것 같지만
Server 2까지 있는 경우 바로 동시성 문제가 재발함
애플리케이션 단에서 동시성 처리 어렵다
애플리케이션에서 동시성 처리하려니 분산 DB까지 처리하기 힘듦
DB에서 제공하는 Lock으로 해결해보자
Pessimistic Lock
Optimistic Lock
Named Lock
실제 데이터에 락을 걸어서 정합성을 맞추는 락
장점
단점
package com.example.stock.repository;
import com.example.stock.domain.Stock;
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;
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);
}
package com.example.stock.service;
import com.example.stock.domain.Stock;
import com.example.stock.repository.StockRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class PessimisticLockStockService {
private final StockRepository stockRepository;
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findByIdWithPessimisticLock(id);
stock.decrease(quantity);
stockRepository.save(stock);
}
}
package com.example.stock.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@NoArgsConstructor
@Getter
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
***@Version
private Long version;***
public Stock(Long productId, Long quantity) {
this.productId = productId;
this.quantity = quantity;
}
public void decrease(Long quantity) {
if (this.quantity - quantity < 0) {
throw new RuntimeException("재고는 0개 미만이 될 수 없습니다.");
}
this.quantity -= quantity;
}
}
package com.example.stock.repository;
import com.example.stock.domain.Stock;
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;
public interface StockRepository extends JpaRepository<Stock, Long> {
***@Lock(LockModeType.OPTIMISTIC)***
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
}
package com.example.stock.service;
import com.example.stock.domain.Stock;
import com.example.stock.repository.StockRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class OptimisticLockStockService {
private final StockRepository stockRepository;
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findByIdWithOptimisticLock(id);
stock.decrease(quantity);
stockRepository.save(stock);
}
}
package com.example.stock.facade;
import com.example.stock.service.OptimisticLockStockService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class OptimisticLockStockFacade {
private final OptimisticLockStockService optimisticLockStockService;
/**
* lock 획득 실패시 재시도하는 로직
*/
public void decrease(Long id, Long quantity) throws InterruptedException {
while (true) {
try {
optimisticLockStockService.decrease(id, quantity);
break;
} catch (Exception e) {
Thread.sleep(50);
}
}
}
}
이름을 가진 메타데이터 락
이름을 가진 락을 획득한 후 해제할 때까지 다른 세션은 이 락을 획득할 수 없게 된다
트랜잭션 종료될 때 자동으로 락이 해제되지 않음 → 별도의 명령어로 해제해주거나 선점시간이 끝나여 해제된다
MySql에서는 get-lock
명령어를 통해 named-lock을 획득할 수 있음
release-lock
명령어를 통해 lock을 해제할 수 있음
즉, Stock 자체에 lock을 거는게 아닌 별도의 공간에 lock을 걸게 함
실무에 Named Lock을 적용할 때는 데이터 소스를 분리해서 사용해라
사용 시점
단점
package com.example.stock.service;
import com.example.stock.domain.Stock;
import com.example.stock.repository.StockRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class StockService {
private final StockRepository stockRepository;
/**
* 부모의 transaction과 별도로 실행되어야 하기 때문에 propagation 변경
*
* NamedLockStockFacade 클래스의 decrease 메서드도 @Transactional 걸려있지만,
* 그 트랜잭션과 별개로 아래 decrease는 새로운 트랜잭션을 시작함
* NamedLockStockFacade의 decrease 메서드가 실패해서 트랜잭션 롤백되어도 이 decrease는 성공했을 시 롤백되지 않는다
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.save(stock);
}
}
package com.example.stock.facade;
import com.example.stock.repository.LockRepository;
import com.example.stock.service.StockService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
@RequiredArgsConstructor
public class NamedLockStockFacade {
private final LockRepository lockRepository;
private final StockService stockService;
@Transactional
public void decrease(Long id, Long quantity) {
try {
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
}
Propagation.REQUIRES_NEW
의미새로운 트랜잭션 시작: 해당 메서드가 호출될 때 항상 새로운 트랜잭션을 시작합니다. 이미 진행 중인 트랜잭션이 있다면, 그 트랜잭션은 잠시 보류(suspend)되고, 새로운 트랜잭션이 시작됩니다.
독립적 실행: 이 옵션을 사용하면 메서드는 호출자의 트랜잭션 환경으로부터 독립적으로 실행됩니다. 즉, 이 메서드 내에서 발생하는 변경사항은 외부 트랜잭션에 영향을 주지 않으며, 외부 트랜잭션이 롤백되더라도 이 메서드에서의 변화는 롤백되지 않습니다.
트랜잭션 분리: 이전에 진행 중이던 트랜잭션은 메서드 실행이 완료될 때까지 중단되고, 메서드의 실행이 끝나면 원래 트랜잭션이 다시 재개됩니다. 이 방식은 리소스의 락이나 다른 트랜잭션 자원을 관리할 때 유용하게 사용할 수 있습니다.
Redis를 활용하여 동시성 문제를 해결할 수 있음
분산 락 구현할 때 사용하는 대표적인 라이브러리는 Lettuce
(레투스)와 Redisson
(레지슨)
setnx
명령어를 활용해 분산락 구현
set if not exist의 줄임말
키와 벨류를 set할 때, 기존의 값이 없을 때만 set하는 명령어
이 때, spin lock 방식임으로 재시도 로직을 개발자가 작성해야 함
MySql의 Named Lock과 유사
장점
단점
Thread.sleep(100);
을 통해 락 획득 재시도 간에 텀을 둬야 함package com.example.stock.repository;
import java.time.Duration;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class RedisLockRepository {
private final RedisTemplate<String, String> redisTemplate;
public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
}
public Boolean unlock(Long key) {
return redisTemplate.delete(generateKey(key));
}
private String generateKey(Long key) {
return key.toString();
}
}
package com.example.stock.facade;
import com.example.stock.repository.RedisLockRepository;
import com.example.stock.service.StockService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class LettuceLockStockFacade {
private final RedisLockRepository redisLockRepository;
private StockService stockService;
public void decrease(Long id, Long quantity) throws InterruptedException {
while (!redisLockRepository.lock(id)) {
Thread.sleep(100); // 락 획득 실패 시 100ms 대기 -> 레디스에 갈 수 있는 부하를 좀 줄여주기 위해
}
// lock 획득 성공 시, 재고 감소 진행
try {
stockService.decrease(id, quantity);
} finally {
// 로직 모두 종료 후 unlock을 통해 lock 해제
redisLockRepository.unlock(id);
}
}
}
pub-sub 기반으로 Lock 구현 제공
Lettuce는 spin lock으로 계속 lock 획득을 시도하는 반면 Redisson은 락 해제가 되었을 때, 한 번 혹은 몇 번만 시도 → Redis 부하 줄어듦
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.29.0'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
package com.example.stock.facade;
import com.example.stock.service.StockService;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class RedissonLockStockFacade {
private final StockService stockService;
private final RedissonClient redissonClient;
public void decrease(Long id, Long quantity) {
RLock lock = redissonClient.getLock(id.toString());
try {
// 몇 초동안 락 획득을 시도할 것인지, 몇 초 동안 점유할 것인지 설정
// 예제에서는 10초동안 락 획득 시도, 1초 동안 점유
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!available) {
System.out.println("lock 획득 실패");
return;
}
stockService.decrease(id, quantity);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
Lettuce
Redisson
결론
MySql
Redis
Outstagram
프로젝트에는 어떤 lock을 구현할 것인가?인스타그램과 같이 동시에 많은 유저가 게시물에 좋아요를 누르고,
서버 또한 단일 서버가 아니라고 가정
즉, 충돌이 많이 발생하고 서버 여러 대인 상태
만약 유저가 적당히(?) 많다면 MySql
의 Pessimistic Lock
이나 Named Lock
으로 구현
유저가 매우 많아 MySql로 커버가 되지 않는다면 Redis
를 사용하고 유저가 누른 좋아요 버튼은 lock 획득 실패했더라도 재시도해서 적용시켜야 하기 때문에 Redisson을 사용하는 것이 좋다
또한, pub-sub 방식으로 인해 redis에 부하가 덜 가해진다