Nick Dev

[Outstagram] 좋아요 동시성 문제 해결 본문

Outstagram

[Outstagram] 좋아요 동시성 문제 해결

Nick99 2024. 12. 11. 12:54
반응형

고민하게 된 배경

  • 동시에 여러 thread들이 좋아요 누르거나, 좋아요 취소했을 때, 해당 게시물의 좋아요 개수가 정확히 수정되는 것이 보장되나??
  • 현재는 그냥 @Transactional 애노테이션만 있다 → 이게 동시성 보장을 해주는건 아님
    • 현재 상태는 동시에 여러 thread가 좋아요를 누르면 race condition이 발생할 수 있다
/**
 * 좋아요 증가 메서드 - 게시물의 좋아요 개수 증가 - 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);
}

해결 방법 1

메서드에 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);
}
  • 기존보다는 덜 race condition이 발생하지만 아직 race condition이 발생함

이번에는 @Transactional 때문에 race condition 발생

  • syncrhonized 는 해당 메서드가 끝나면 다른 thread가 해당 메서드를 실행할 수 있음
  • 하지만 @Transational은 메서드 종료되고 트랜잭션이 커밋되고 DB에 반영한다
  • 즉, 메서드 종료트랜잭션 커밋 후 DB에 실제로 반영될 때까지 중간에 시간이 빈다.
  • 이때, 다른 thread가 들어와 DB에서 좋아요 개수를 가져오면 아직 수정된 개수가 반영되기 전이다.
  • 그래서 아직도 race condition이 발생함

서버가 여러 대라면 애초에 synchronized로 동시성 해결이 불가능

  • synchronized
    • 동시에 하나의 스레드만 접근이 가능하다는 조건이 하나의 프로세스에서만 보장되는 특징
  • 서버가 여러 대라면 동시성이 보장될 수가 없는 것!!!
  • Server 1 입장에서는 동시에 thread1만 Stock에 접근하도록 제한했기에 동시성을 보장한 것 같지만
  • Server 2까지 있는 경우 바로 동시성 문제가 재발함

애플리케이션 단에서 동시성 처리 어렵다


해결 방안 2

DB 단에서 Lock으로 해결해보자

  • 애플리케이션에서 동시성 처리하려니 분산 DB까지 처리하기 힘듦
  • DB에서 제공하는 Lock으로 해결해보자

Lock 종류 3가지

  1. Pessimistic Lock
  2. Optimistic Lock
  3. Named Lock

1. Pessimistic Lock

  • 실제 데이터에 락을 걸어서 정합성을 맞추는 락
  • 장점
    • 충돌이 빈번하게 일어난다면 Optimistic 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);
    }
}

2. Optimistic Lock

  • version을 통해 데이터 관리
    • version이 같아야 데이터 업데이트 가능
  • 장점
    • 별도의 lock이 없기에 성능상 이점이 있다
  • 단점
    • 업데이트 실패 시, 재시도 로직을 개발자가 직접 구현해야함 → Facade 클래스에서 구현하면 된다
  • 사용 시점
    • 충돌이 빈번이 일어난다면 Pessimisitic Lock
    • 빈번하게 일어나지 않을 것으로 예상된다면 Optimistic Lock
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);
            }
        }
    }
}

3. Named Lock

  • 이름을 가진 메타데이터 락
  • 이름을 가진 락을 획득한 후 해제할 때까지 다른 세션은 이 락을 획득할 수 없게 된다
  • 트랜잭션 종료될 때 자동으로 락이 해제되지 않음 → 별도의 명령어로 해제해주거나 선점시간이 끝나여 해제된다
  • MySql에서는 get-lock 명령어를 통해 named-lock을 획득할 수 있음
  • release-lock 명령어를 통해 lock을 해제할 수 있음
  • 즉, Stock 자체에 lock을 거는게 아닌 별도의 공간에 lock을 걸게 함
  • 실무에 Named Lock을 적용할 때는 데이터 소스를 분리해서 사용해라
    • 같은 데이터 소스를 사용하면 커넥션 풀이 부족해지는 현상으로 인해 서비스에 영향을 끼칠 수 있음
  • 사용 시점
    • 분산 Lock를 구현할 때 주로 사용
    • Pessmistic 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)되고, 새로운 트랜잭션이 시작됩니다.
  • 독립적 실행: 이 옵션을 사용하면 메서드는 호출자의 트랜잭션 환경으로부터 독립적으로 실행됩니다. 즉, 이 메서드 내에서 발생하는 변경사항은 외부 트랜잭션에 영향을 주지 않으며, 외부 트랜잭션이 롤백되더라도 이 메서드에서의 변화는 롤백되지 않습니다.
  • 트랜잭션 분리: 이전에 진행 중이던 트랜잭션은 메서드 실행이 완료될 때까지 중단되고, 메서드의 실행이 끝나면 원래 트랜잭션이 다시 재개됩니다. 이 방식은 리소스의 락이나 다른 트랜잭션 자원을 관리할 때 유용하게 사용할 수 있습니다.

해결방안 3

  • Redis를 활용하여 동시성 문제를 해결할 수 있음
  • 분산 락 구현할 때 사용하는 대표적인 라이브러리는 Lettuce(레투스)와 Redisson(레지슨)

1. Lettuce

  • setnx 명령어를 활용해 분산락 구현
    • set if not exist의 줄임말
    • 키와 벨류를 set할 때, 기존의 값이 없을 때만 set하는 명령어
    • 이 때, spin lock 방식임으로 재시도 로직을 개발자가 작성해야 함
  • MySql의 Named Lock과 유사
    • 다른 점은 Redis를 이용한다는 점과 Session 관리에 신경쓰지 않아도 된다
  • 장점
    • 구현이 간단함
  • 단점
    • spin 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);
        }
    }

}

2. Redisson

  • 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
    • 구현이 간단함
    • lettuce가 기본 라이브러리(Spring data redis 사용시) → 별도의 라이브러리 설치 필요 X
    • spin lock → 동시에 많은 thread가 lock 획득 대기 상태라면 redis에 부하가 갈 수 있다
  • Redisson
    • 락 획득 재시도를 기본으로 제공
    • pub-sub 방식으로 구현되어 있기 때문에 lettuce에 비해 부하가 덜 간다
    • 별도의 라이브러리 사용해야 함
  • 결론
    • 재시도 필요하지 않으면 lettuce
    • 재시도 필요하면 redission 활용

MySql VS Redis 장단점 비교

  • MySql
    • 이미 MySql 사용중이라면 별도의 비용 없이 사용 가능함
    • 성능이 Redis보다 좋지는 않음
      • 대신 그래도 어느정도 트래픽까지는 활용 가능
  • Redis
    • 활용중인 Redis가 없다면 별도의 구축 비용과 인프라 관리 비용 발생
    • MySql보다 성능은 좋음

나의 Outstagram 프로젝트에는 어떤 lock을 구현할 것인가?

인스타그램과 같이 동시에 많은 유저가 게시물에 좋아요를 누르고,
서버 또한 단일 서버가 아니라고 가정
즉, 충돌이 많이 발생하고 서버 여러 대인 상태

  • 만약 유저가 적당히(?) 많다면 MySqlPessimistic Lock이나 Named Lock으로 구현
  • 유저가 매우 많아 MySql로 커버가 되지 않는다면 Redis를 사용하고 유저가 누른 좋아요 버튼은 lock 획득 실패했더라도 재시도해서 적용시켜야 하기 때문에 Redisson을 사용하는 것이 좋다
  • 또한, pub-sub 방식으로 인해 redis에 부하가 덜 가해진다

결론

  • Redis의 Redission 라이브러리를 활용해 lock 구현
반응형