대규모 트래픽 상황에서 상품 조회수 처리하기

Dev. 로티·2022년 1월 28일
2

Spring boot

목록 보기
6/12
post-thumbnail

얼마전 웹서핑하다가 문뜩 이런 생각이 떠오르더군요..

“학교에서 배웠던 방식(도메인이 자신의 조회수를 직접 갖고 있는 설계 방식)으로 과연 조회수 관련해 대규모 트래픽을 처리할 수 있을까?”


서비스를 제작하는데에 있어 대규모 트래픽이라는 단어는 무시하고 지나칠 수 없는 벽이라고 생각합니다.

Mysql DB 기준으로 기존에 알고있던 방식을 생각해보니 물론 상품 조회시 단순히 조회수 카운트를 1 높이는 식으로 처리를 할 수…는 있으나…
(Shared lock이 필요함, 만약 Shared lock을 하지 않을 경우 멀티 스레드 상황에서 다수의 사용자가 동시 접근시 재대로된 처리가 이루어지지 않을 수 있음)

한건의 Row 가 Locking이 되면서 발생하는 성능 문제를 생각하자니… 끔찍하더군요… (timeout….과 자칫 다른 마이크로 서비스에 영향을 줄 우려도 있어보임…)

그래서 이 문제를 어떻게 좀더 효율적으로 해결할 수 있을까 고민해본결과 두가지 방안이 떠오르더군요.

오늘은 그 두가지 방법에 대해 포스팅 해볼까 합니다.^^


먼저 기존 코드부터 한번 보도록 하겠습니다.

@Entity
@Getter
public class Product {
	@Id
	@GeneratedValue(strategy = IDENTITY)
	private long id;
	…
	…
	private long hit;
	public void incrementHit(){
		hit++;
	}
}

public interface ProductRepository extends JpaRepository<Product, Long>{
	@Lock(LockModeType.PESSIMISTIC_WRITE)	// Shared Lock을 위한 코드 만약 이 어노테이션이 없으면 멀티 스레드 환경에서 동시 처리를 진행할 수 없음
	Optional<Product> findById(long id);
}

@Service
@Transactional
@RequiredArgsConstructor
public class ProductService {
	private final ProductRepository productRepository;
	
	…
	
	public ProductRecord getProduct(long id){
		Product product = productRepository.findById(id).orElseThrow(…);
		product.increamentHit();
		return ProductRecord.from(product);
	}
}

그냥 보기에는 위 코드가 전혀 문제가 없어 보입니다.

하지만 전혀 그렇지 않았습니다 !!

위 코드는 동시성 처리로 인해 엄청난 성능 저하를 불러 일으킬 수 있다는 문제를 갖고 있었고, 지속적으로 다른 lock을 기다리는 상황으로 인해 Timeout 에러가 발생하는 모습을 볼 수 있었습니다.

위 문제를 해결하기 위해 다음과 같은 예시 코드를 보여드리도록 하겠습니다.

@Entity
@Getter
public class Product {
	@Id
	@GeneratedValue(strategy = IDENTITY)
	private long id;
	…
}

@Entity
@Table(indexes = {
        @Index(columnList = “product_id”)	// 굉장히 중요!!
})
public class ProductHit {
	@Id
	@GeneratedValue(strategy = IDENTITY)
	private long id;
	private long productId;	
}

public interface ProductRepository extends JpaRepository<Product, Long>{
}

public interface ProductHitRepository extends JpaRepository<ProductHit, Long>{
}

@Service
@Transactional
@RequiredArgsConstructor
public class ProductService {
	private final ProductRepository productRepository;
	
	// external
	private final ProductHitService productHitService;
	
	…
	
	public ProductRecord getProduct(long productId){
		Product product = productRepository.findById(productId).orElseThrow(…);
		long hitCount = productHitService.increamentHit(productId);
		return ProductRecord.of(product, hitCount);
	}
}

@Service
@Transactional
@RequiredArgsConstructor
public class ProductHitService {
	private final ProductHitRepository productHitRepository;
	
	…
	
	public long incrementHit(long productId){
		ProductHit productHit = ProductHit.productOf(productId);
		productHitRepository.save(productHit);
		return productHitRepository.countByProductId(productId);
	}
}

위 소스코드는 기존에 제품이라는 도메인이 서비스 되고 있는 상황에서 제품에 대한 조회수 서비스가 추가(서비스 확장)되었다고 가정해 작성한점 참고 부탁드립니다^^

위 방식은 기존 방식(조회시 조회 카운트를 1 증가시킴)과는 다르게 조회시 조회 카운트에 관련된 엔티티를 데이터베이스에 저장시키고 해당 제품에 대한 조회 카운트 엔티티의 개수를 가져오는 것을 볼 수 있는데요.

위 방법을 통해 기존에 발생하던 Shared Lock으로 인해 발생하는 성능 저하 문제를 해결할 수 있다고 생각합니다만…

위 방법이 100% 좋은 방법이다 라고 말하긴 좀 애매할 것 같습니다.


이유는 이렇습니다.

  • 기존 방식과는 다르게 디스크 사용량이 증가함
  • 동시성 처리를 크게 신경쓸 필요가 없는 상황이라면 기존 방식이 좀더 빠를 수 있음

이러한 이유들로 인해 100% 좋은 방법이다 라고 말씀드리긴 애매할 것 같습니다…


좀더 깔끔하게 처리할 수 있는 방법이 없을까??… 고민하다 Redis에서 제공하는 increament를 사용하면 어떨까 라는 생각이 문득 들었습니다.

마지막으로 고안한 방법… Redis를 함께 사용해 해결하는 방법을 말씀드려보도록 하겠습니다!

소스코드는 다음과 같습니다.


@Entity
@Getter
public class Product {
	@Id
	@GeneratedValue(strategy = IDENTITY)
	private long id;
	…
	…
}


public interface ProductRepository extends JpaRepository<Product, Long>{
}


@Service
@Transactional
@RequiredArgsConstructor
public class ProductService {
	private final ProductRepository productRepository;
	
	// external
	private final ProductHitService productHitService;
	
	…
	
	public ProductRecord getProduct(long productId){
		Product product = productRepository.findById(productId).orElseThrow(…);
		long hitCount = productHitService.increamentHit(productId);
		return ProductRecord.of(product, hitCount);
	}
}

public interface ProductHitRepository {
	long increamentHit(long productId);
}

@Repository
@RequiredArgsConstructor
public class RedisProductHitRepository {
	private final RedisTemplate redisTemplate;
	private ValueOperations valueOperations;

	@PostConstruct
	public void setUp() {
		valueOperations = redisTemplate.opsForValue();
	}

	private final static String PRODUCT_HIT_KEY = “product:hit:”;
	@Overide
	public long increamentHit(long productId) {
		return valueOperations.increment(PRODUCT_HIT_KEY + productId)
	}
}


@Service
@Transactional
@RequiredArgsConstructor
public class ProductHitService {
	private final ProductHitRepository productHitRepository;
	
	public long incrementHit(long productId){
		return productHitRepository.countByProductId(productId);
	}
}


Redis에서 제공하는 increment를 사용해 조회수를 구현했습니다.

Redis는 메모리 기반이기 때문에 RDB 보다 처리 성능이 우수하고(대신… 메모리 관리를 잘해야함…), 싱글 스레드 기반으로 동작하기 때문에 별도의 Lock 설정 작업없이 쉽게 처리(싱글 스레드 기반으로 동작하기에 앞의 처리를 기다려야하는 것은 동일하지만… RDB에서의 Lock보다는 처리가 빠르다고 생각함)할 수 있습니다.

여기까지 대규모 트래픽 상황에서 상품 조회수 처리하는 방법에 대해 제가 생각한 방법을 알려드렸습니다.

제가 선택한 방법이 100% 모두 옳다고 보장할 수는 없지만, 도움이 되셨으면 좋겠습니다.

감사합니다 ^^

0개의 댓글