인덱스란 검색 속도를 높이기 위한 색인 기술이다.
일반적으로 SELECTWHERE에 사용할 컬럼을 효율적으로 검색하거나 다른 테이블과의 JOIN에 사용된다.
DB 테이블에는 INDEX가 없는 경우 처음 레코드부터 마지막 레코드까지 풀 스캔(Full Scan) 하게 된다.

이때, 검색 속도 향상을 이유로 인덱스를 사용하게된다.

 

Index 설정기준
- 카디널리티가 높은 컬럼(한 컬럼이 가지고있는 중복도가 낮음을 의미)
- 선택도가 낮다(한 컬럼이 가지고있는 값 하나로 적은 row만 찾아진다, *선택도: 5~10%)
- 수정 빈도가 낮은 컬럼
- WHERE에 자주 사용되는 컬럼
- ORDER BY에 자주 사용되는 컬럼
- JOIN에 자주 사용되는 컬럼
- LIKE 사용할경우 %가 뒤에 사용되도록하기

* 선택도 계산법( 컬럼 특정 값 row / 테이블 총 row * 100)
ex) 총 10 row에서 고유 학번(grade), 2명의 같은 이름(name), 5명의 같은 나이(age)
1. 학번(grade) 컬럼 선택도 - 1 / 10 = 10%
2. 이름(name) 컬럼 선택도 - 2 / 10 = 20%
3. 나이(age) 컬럼 선택도 - 5 / 10 = 50%

 

테이블 정보 및 사용 쿼리 수집

더보기

User

create table users (
    id BIGINT PK
    name VARCHAR,
    point BIGINT,
    create_at DATETIME,
    update_at DATETIME,
    primary key (id)
)

User - 사용 쿼리

// 1. 사용자 조회
SELECT * FROM users
WHERE id = #{id};

// 2. 포인트 충전/사용
UPDATE users SET
point = #{point}
WHERE id = #{id};

Cart

create table cart (
    id BIGINT PK,
    userId BIGINT,
    create_at DATETIME,
    update_at DATETIME,
    primary key (id)
)

Cart - 사용 쿼리

// 1. 카트 조회
SELECT * FROM cart
WHERE userId = #{userId}

// 2. 카트 생성
INSERT INTO cart (user_id, created_at, updated_at)  
VALUES (?, ?, ?);

CartItem

create table cart_item (
    id BIGINT PK,
    cart_id BIGINT,
    product_id BIGINT,
    quantity BIGINT,
    create_at DATETIME,
    update_at DATETIME,
    primary key (id)
)

CartItem - 사용 쿼리

// 1. 장바구니 추가
INSERT INTO cart_item (cart_id, product_id, quantity, created_at, updated_at)
VALUES (?, ?, ?, ?, ?);

Product

create table product (
    id BIGINT PK,
    name varchar(255),
    price BIGINT,
    create_at DATETIME,
    update_at DATETIME,
    primary key (id)
)

Product - 사용 쿼리

// 1. 상품 상세
SELECT *
FROM product
WHERE id = {productId}

// 2. 상품 리스트
SELECT *
FROM product

// 3. 주문신청 상품정보
SELECT
    p.id, p.name, p.price, ps.stock
FROM ProductEntity p
LEFT JOIN ProductStockEntity ps
ON p.id = ps.productId
WHERE p.id IN :productIds

// 4. 최근 3일간 인기상품 조회
SELECT
    p.name, SUM(oi.quantity)
FROM ProductEntity p
JOIN OrderItemEntity oi
ON p.id = oi.product_id
WHERE oi.create_at BETWEEN :startDate AND :endDate
ORDER BY SUM(oi.quantity) DESC
GROUP BY oi.product_id

ProductStock

create table product_stock (
    product_id BIGINT PK,
    stock BIGINT,
    create_at DATETIME,
    update_at DATETIME,
    primary key (product_id)
)

ProductStock - 사용 쿼리

// 1. 상품재고 조회
SELECT * FROM product_stock
WHERE product_id = #{id}

// 2. 상품재고 수정
UPDATE product_stock SET
WHERE product_id = #{id}

Order

create table orders (
    id BIGINT PK,
    user_id BIGINT,
    create_at DATETIME,
    update_at DATETIME,
    primary key (id)
)

Order - 사용 쿼리

// 1. 주문 생성
INSERT INTO order (user_id, created_at, updated_at)
VALUES (?, ?, ?)

OrderItem

create table order_Item (
    id BIGINT PK,
    order_id BIGINT,
    product_id BIGINT,
    quantity BIGINT,
    create_at DATETIME,
    update_at DATETIME,
    primary key (id)
)

OrderItem - 사용 쿼리

// 주문 상품 저장
INSERT INTO order_item (order_id, product_id, quantity, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)


주요 쿼리 및 개선

  • 최근 3일간 인기 판매상품(인덱싱 전)

  • 쿼리 응답시간
test_db    > SELECT
                 p.name,
                 SUM(oi.quantity)
             FROM
                 product p
                     JOIN order_item oi
                          ON p.id = oi.product_id
             WHERE oi.create_at BETWEEN '2024-11-11 10:36:06.221586' AND '2024-11-12 10:36:06.221586'
             GROUP BY
                 oi.product_id
[2024-11-13 16:21:55] 43 rows retrieved starting from 1 in 32 ms (execution: 7 ms, fetching: 25 ms)

 

  • 최근 3일간 인기 판매상품(인덱싱 후)
CREATE INDEX idx_order_item_create_at ON order_item (create_at);

  • 쿼리 응답시간
test_db    > SELECT
                 p.name,
                 SUM(oi.quantity)
             FROM
                 product p
                     JOIN order_item oi
                          ON p.id = oi.product_id
             WHERE oi.create_at BETWEEN '2024-11-11 10:36:06.221586' AND '2024-11-12 10:36:06.221586'
             GROUP BY
                 oi.product_id
[2024-11-13 16:22:07] completed in 2 ms

Index 선정기준 1,2 를 만족한 주문아이템 생성시간(create_at)이 인덱스 컬럼으로 적합하다 판단했습니다.
기존에는 약 90만건의 테이블 풀 스캔하여 데이터를 가져왔다면 이후에는 범위만큼(range Index)만 데이터를 가져옵니다. 

 

MySQL 8.0 기준 InnoDB 에서의 인덱스 자료구조는 B+Tree 입니다.
자식노드가 2개 이상인 B-Tree를 개선시킨 자료구조이며 특성은 다음과 같습니다.

  • 리프노드만 인덱스와 함께 데이터를 가진다.
  • 나머지 노드들은 데이터를 위한 인덱스(key)만 가진다.



InnoDB에서는 같은 레벨 노드끼리는 Double LinkedList로 연결, 자식 노드는 Single LinkedList로 연결됩니다.

 

참고자료

https://mangkyu.tistory.com/96

https://velog.io/@emplam27/%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0-%EA%B7%B8%EB%A6%BC%EC%9C%BC%EB%A1%9C-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-B-Plus-Tree

데이터의 변화가 적거나, 연산 비용이 큰 쿼리들을 매번 DB에 호출하는 것은 불필요한 쿼리 비용과 서비스 성능 저하로 이어질 것입니다. 이러한 경우 적절한 캐시 전략을 통해 해결할 수 있습니다.

캐시는 로컬 캐시, 글로벌 캐시 등의 종류가 있습니다.
로컬 캐시는 개별 서버의 내장 메모리로 캐싱하여 속도는 빠르지만, 다른 분산서버의 캐시 참조가 어렵습니다.
글로벌 캐시는 별도의 고유 서버를 두어 서버 간 공유가 쉬운 반면, 네트워크를 통하기 때문에 로컬 캐시보다 느립니다.

캐시 전략 선택 시 고려해야 될 점

- 캐시 무효화
DB에 A라는 상품의 이름에 변화가 생길 시 캐시에도 수정/삭제를 하는 작업입니다.

- 메시지 전파
분산 서버를 구축한 경우(상품 서비스, 재고 서비스) 재고 수량 변경 시 상품 서버에도 메시지가 전파되는 것을 의미합니다. 따라서, 분산서버의 캐시들을 무효화 시킬 필요가 없는 경우 로컬 캐시가 유리합니다.

글로벌 캐시(Redis)를 선택한 이유

사용자의 상품 주문 등으로 캐시 무효화가 빈번할 것으로 판단했습니다.
그리고 로컬 캐시 구현시 10명의 사용자가 있다면 상품리스트API 호출 시 캐시 미스로 10번의 쿼리를 실행하게됩니다.
반면, 글로벌 캐시는 최초 캐시 미스로 1번의 쿼리만 실행하여 효율적입니다

Redis 캐시 읽기 전략

읽기 전략 1. Look aside(cache aside)

https://velog.io/@hwaya2828/Redis-%EC%BA%90%EC%8B%9C-%EC%A0%84%EB%9E%B5

- 원하는 데이터가 캐시에 있는지 확인하고 없다면 DB에서 가져오는 전략
- 레디스에 장애가 생겨도 서비스 장애로 이어지지않고 DB에서 데이터를 가져온다.
- 하지만, 이때 레디스에 Connection이 많이 붙어있다면 DB로 한꺼번에 몰릴 수 있어 성능에 영향
- 초기에는 DB에만 데이터가 있어 캐시 미스 발생율이 높아 성능에 영향

읽기 전략 2. Read Through

https://velog.io/@hwaya2828/Redis-%EC%BA%90%EC%8B%9C-%EC%A0%84%EB%9E%B5

- 원하는 데이터를 캐시에서만 확인하는 전략, 원하는 데이터가 없다면 캐시가 DB로 접근하여 저장한다.
- 레디스에 장애가 생기면 서비스에 치명적이다.
- 데이터 동기화를 라이브러리 또는 캐시 제공자에게 위임히기 떄문에 전체적으로 속도 느림
- 대신, 캐시와 DB 간 동기화가 항상 이루어져 데이터 정합성 문제에서 벗어난다.

Redis 캐시 쓰기 전략

쓰기 전략 1. Write Through

- 데이터베이스와 캐시 동시에 데이터를 저장하는 전략
- 데이터 저장 시, 캐시에 먼저 저장 후 데이터베이스에 저장한다.
- 데이터베이스와 캐시가 항상 동기화되어 있기 때문에, 데이터 일관성 유지
- 캐시는 항상 최신 데이터를 갖고 있는 반면, 2번의 쓰기 작업으로 지연시간 발생
- 자주 사용되지 않는 데이터도 저장되어 리소스 낭비 발생

쓰기 전략 2. cache invalidation

- 데이터베이스에 값을 업데이트 할 때 캐시에서는 데이터를 삭제한다.
- 데이터를 삭제하는 것이 새로운 데이터를 생성하는 것보다 리소스를 적게 사용하기 떄문이다.

쓰기 전략 3. Write Behind

- 쓰기 작업이 많은 경우, 캐시에 우선 저장 후 비동기로 DB저장
- 저장되는 데이터가 실시간으로 정확하지 않아도 되는경우 유용하다 ex) 유튜브 좋아요 수

그렇다면 현재 프로젝트 에서는 어떤 전략을 취해야 할까?

📌 Look aside + Write Through 전략

Read Through 읽기 전략방식으로 데이터 정합성을 보장한다는 장점이 있지만
레디스 장애로 인해 전 서비스 장애가 발생할 수 있다는 단점도 보유하고 있습니다.
이로인해 DB에 조금 부하가 가더라도 안정적인 Look aside 방식과
캐시와 데이터베이스에 저장/변경사항을 저장하여 데이터 일관성을 확보하는 Write Through 방식을 채택했습니다.

📌 해당 전략을 채택해서 성능 개선을 시도해볼만 한 비즈니스 로직

인기상품 조회
- 사용자에 의한 조회 빈번할 것으로 예상
- 비싼 연산 비용으로 쿼리 질의 최소화

@Configuration
public class RedisConfig {
    @Value("${spring.data.redis.host}")
    private String redisHost;

    @Value("${spring.data.redis.port}")
    private int redisPort;

    private static final String REDISSON_HOST_PREFIX = "redis://";

	@Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        return redisTemplate;
    }

    @Primary
    @Bean
    public RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofHours(1))
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(config)
                .build();
    }
}

@Service
@RequiredArgsConstructor
public class ProductService {
    private final ProductReader productReader;
    
    @Cacheable(value = "popularProducts", key = "'top5'", cacheManager = "redisCacheManager")
    public List<Product> readProductPopulars() {
        return productReader.readProductPopulars();
    }
}

1. 최근 3일간 판매량 인기상품 5개 조회

(before) 

먼저, Redis에 캐싱 데이터가 없는 상태에서 최초 API를 날려 캐싱되기를 기대합니다.
100만 row 기준, 약 2초 가량의 응답시간이 나타났습니다.


(after)

 API를 한번 호출했기 때문에 캐싱 성공과 응답속도 개선을 기대합니다.
Reids에 캐싱 목록이 확인되고, 개선된 응답속도를 볼 수 있었습니다 (2.99ms)

 

 

결과 및 개선

캐싱 전 : 평균: 1.96s, 최대: 1.96s
캐싱 후 : 평균: 2.99ms, 최대: 2.99ms
개선율: 약 655배 성능 향상

 

참고자료

https://www.yes24.com/Product/Goods/123182350

 

개발자를 위한 레디스 - 예스24

개발자가 인메모리 데이터베이스인 레디스를 잘 활용할 수 있도록 초점을 맞춘 포괄적인 안내서다. 레디스를 처음 배우는 독자나 NoSQL 데이터베이스의 개념을 쌓고자 하는 개발자를 위해, 레디

www.yes24.com

 

DB Lock

낙관적 락/비관적 락이 가장 기본으로 사용됩니다.

- 낙관적 락

DB에 Lock을 걸지 않고 읽기 시점/쓰기 시점의 데이터 변경 여부에 따라
동시성을 제어하는 방식이라 성능이 비관적 락보다 상대적으로 높습니다.

트랜잭션 간 충돌이 많아질 경우 retry 빈도 증가하게 되며 DB Connection, 스레드 점유 등의 단점이 존재합니다.

- 비관적 락

DB Lock(x-lock)을 사용해 하나의 트랜잭션만 작업이 가능하여 일관성을 확보하지만 해당 작업이 끝날때 까지 다른 트랜잭션은 대기 상태입니다.
트래픽이 몰리는 경우 락 대기시간으로 성능에 영향을 줄 수 있습니다.

 

Distributed Lock(Redis)

- Lettuce

  • Netty 기반의 Redis Client로 넌블로킹 I/O로 구현되어 비동기 방식으로 처리, 고성능
  • SETNX를 이용하여 Spin Lock 형태를 구현
    • 경쟁 스레드들이 지속적으로 요청을 보내기 때문에 서버 부하가 심하다
    • 개발자가 직접 retry, timeout 구현해야하며 지속적인 재시도로 네트워크 비용과 스레드 점유등의 문제가 발생

- Redisson

Redisson은 Redis 기반의 Java 클라이언트로 네트워크 트래픽 또는 CPU 사용량을 줄이기 위해 Lua 스크립트를 활용합니다. 재시도 로직을 내장하고 있어 락 흭득을 위한 별도의 재시도 로직을 작성하지 않아도 됩니다. 동시에 락 흭득 요청 시 FIFO 형태로 요청 순서를 보장합니다.

 

현재 시나리오에서 발생할 수 있는 동시성 문제는 재고 감소이며 코드는 아래와 같습니다. 

 

동시성 제어 방식 비교해보기


DB Lock

 

상품정보 불러오기

@Lock(LockModeType.PESSIMISTIC_WRITE)
ProductStockEntity findByProductIdWithPessimisticLock(@Param("productId") Long productId);

 

배타락을 사용하여 재고를 감소 시키는 로직

@Component
@RequiredArgsConstructor
public class OrderUseCase {
	
    public Long order(OrderRequest request) {

        request.setPaymentPrice((long) getProductsPrice(request));

        // 1. Order테이블, OrderItem 테이블 저장
        OrderEntity order = orderService.serviceOrder(request.getUser_id(), request);

        // 2. 재고 감소
        productService.decreaseStock(request.getProducts());

        // 3. 유저 포인트 차감
        userService.payment(request.getUser_id(), request.getPaymentPrice());

        return order.getId();
    }
}

@Component
@RequiredArgsConstructor
public class ProductUpdater {

    @Transactional
    public List<ProductStockEntity> decreaseStock(List<OrderProductsRequest> req) {
        List<ProductStockEntity> stockEntities = new ArrayList<>();

        for (OrderProductsRequest orderRequest : req) {
            ProductStockEntity productStock = productStockRepository.findByProductIdWithPessimisticLock(orderRequest.getProduct_id());
            productStock.decreaseStock(orderRequest.getProduct_id(), (long) orderRequest.getProduct_quantity());

            stockEntities.add(productStock);
            productStockRepository.stockSave(productStock);
        }

        return stockEntities;
    }
}

 

동시성 테스트 코드

@Test
void DB락_재고_100개_상품에_101번의_주문시도() throws InterruptedException {
    int threadCount = 101;
    Long userId = 1L;
    Long productId = 1L;

    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    List<OrderProductsRequest> orderProductsRequests = List.of(
            new OrderProductsRequest(productId, 1)
    );

    OrderRequest orderRequest = new OrderRequest(userId, orderProductsRequests, 0L);

    AtomicInteger successCount = new AtomicInteger();
    AtomicInteger failCount = new AtomicInteger();

    long startTime = System.currentTimeMillis();
    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {
                orderUseCase.order(orderRequest);
                successCount.getAndIncrement();
            } catch (OutOfStockException e) {
                failCount.getAndIncrement();
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();
    long endTime = System.currentTimeMillis();
    log.info("실행 시간 : {} milliseconds", endTime - startTime);

    Product product = productService.readProductDetail(productId);
    UserEntity userInfo = userService.getUserInfo(userId);

    assertEquals(100, successCount.get());
    assertEquals(1, failCount.get());
    assertEquals(0, product.getStock());
    assertEquals(800000, userInfo.getPoint());
}

 

성능

실행 시간 348 ms


복잡성

JPA 레포지토리에 @Lock 어노테이션으로 배타락을 설정할 수 있어 간편하다는 장점이 있습니다.  


효율성

배타락은 하나의 트랜잭션만 작업이 가능하여 데이터의 일관성은 확보됩니다. 그러나, 다른 트랜잭션의 대기시간 발생해 트래픽이 순식간에 몰리는 상황에서는 latency 증가를 초래할 수 있습니다.
현재 stock 테이블만 락을 걸기때문에 문제가 없지만, 여러 테이블에 락을 설정해야되는 트랜잭션이라면 데드락 발생이 예상됩니다.


 

Distributed Lock

 

분산 락 구현시 왜 Redis인가?


분산락에 관한 Reference들을 보면 Redis를 활용하는 것을 볼 수 있습니다.

  • 간단하게 Redis의 명령어를 사용하여 Lock 구현이 가능하다.
  • retry, timeout과 같은 부가 기능들을 제공한다.
  • Redis는 싱글 스레드병렬적으로 들어오는 요청들을 직렬화하여 원자성을 보장할 수 있다. 

 

Lettuce 분산 락 구현

@RequiredArgsConstructor
@Component
public class RedisLockRepository {
    private final RedisTemplate<String, String> redisTemplate;

    public Boolean lock(Object key) {
        return redisTemplate
                .opsForValue()
                .setIfAbsent(key.toString(), "lock", Duration.ofMillis(3000));
    }

    public Boolean unlock(Object key) {
        return redisTemplate.delete(key.toString());
    }
}

public class OrderUseCase {
    private final RedisLockRepository redisLockRepository;
    private final OrderService orderService;
    private final ProductService productService;
    
    public Long orderWithLettuce(OrderRequest request) {

        request.setPaymentPrice((long) getProductsPrice(request));

        // 1. Order테이블, OrderItem 테이블 저장
        OrderEntity order = orderService.serviceOrder(request.getUser_id(), request);

        // 2. 재고 감소
        for (OrderProductsRequest prod : request.getProducts()) {
            while(!redisLockRepository.lock(prod.getProduct_id())){
                try{
                    Thread.sleep(500);
                }catch (InterruptedException e){
                    throw new RuntimeException(e);
                }
            }

            try{
                productService.LettuceDecreaseStock(prod.getProduct_id(), (long) prod.getProduct_quantity());
            }finally {
                redisLockRepository.unlock(prod.getProduct_id());
            }
        }

        // 3. 유저 포인트 차감
        userService.payment(request.getUser_id(), request.getPaymentPrice());

        return order.getId();
    }
}

// 재고 감소 로직
@Component
@RequiredArgsConstructor
public class ProductUpdater {
    private final ProductStockRepository productStockRepository;
    
    @Transactional
    public List<ProductStockEntity> LettuceUpdateStock(Long productId, Long quantity) {
        List<ProductStockEntity> stockEntities = new ArrayList<>();

        ProductStockEntity productStock = productStockRepository.findById(productId);
        productStock.decreaseStock(productId, quantity);
        stockEntities.add(productStock);
        productStockRepository.stockSave(productStock);

        return stockEntities;
    }
}

@SpringBootTest
public class OrderConcurrentlyTest {
	
    @Test
    void Lettuce_재고_100개_상품에_101번의_주문시도() throws InterruptedException {

        int threadCount = 101;
        Long userId = 1L;
        Long productId = 1L;

        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        List<OrderProductsRequest> orderProductsRequests = List.of(
                new OrderProductsRequest(productId, 1)
        );

        OrderRequest orderRequest = new OrderRequest(1L, orderProductsRequests, 0L);

        AtomicInteger successCount = new AtomicInteger();
        AtomicInteger failCount = new AtomicInteger();

        long startTime = System.currentTimeMillis();
        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    orderUseCase.orderWithLettuce(orderRequest);
                    successCount.getAndIncrement();
                } catch (OutOfStockException e) {
                    failCount.getAndIncrement();
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        long endTime = System.currentTimeMillis();
        log.info("실행 시간 : {} milliseconds", endTime - startTime);

        Product product = productService.readProductDetail(productId);
        UserEntity userInfo = userService.getUserInfo(userId);

        assertEquals(100, successCount.get());
        assertEquals(1, failCount.get());

        assertEquals(0, product.getStock());
        assertEquals(800000, userInfo.getPoint());
    }
}

 

성능

실행 시간 17660 ms

 

복잡성

1. 의존성 추가로 간단하게 구현 가능합니다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

효율성

경쟁 스레드들이 락 흭득을 위해 반복적으로 retry를 진행하면서 스레드 점유와 같은 부하를 가져오게 됩니다.


Redisson 분산 락 구현

@Configuration
public class RedissonConfig {
    @Value("${spring.data.redis.host}")
    private String redisHost;

    @Value("${spring.data.redis.port}")
    private int redisPort;

    private static final String REDISSON_HOST_PREFIX = "redis://";

    @Bean
    public RedissonClient redissonClient() {
        RedissonClient redisson = null;
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
        redisson = Redisson.create(config);
        return redisson;
    }
}

public class OrderUseCase {
    private final RedisLockRepository redisLockRepository;
    private final OrderService orderService;
    private final ProductService productService;
    
    public Long orderWithRedisson(OrderRequest request) {
        request.setPaymentPrice((long) getProductsPrice(request));

        // 1. Order테이블, OrderItem 테이블 저장
        OrderEntity order = orderService.serviceOrder(request.getUser_id(), request);

        // 2. 재고 감소
        for (OrderProductsRequest prod : request.getProducts()) {
            RLock rLock = redissonClient.getLock(String.format("LOCK:PROD-%d", prod.getProduct_id()));

            try {
                boolean available = rLock.tryLock(10, 1, TimeUnit.SECONDS);
                if(!available) {
                    throw new IllegalArgumentException("Lock Not acquired");
                }
                productService.RedissonDecreaseStock(prod.getProduct_id(), (long) prod.getProduct_quantity());
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                rLock.unlock();
            }
        }

        // 3. 유저 포인트 차감
        userService.payment(request.getUser_id(), request.getPaymentPrice());

        return order.getId();
    }
}

// 재고 감소 로직
@Component
@RequiredArgsConstructor
public class ProductUpdater {
    private final ProductStockRepository productStockRepository;
    
    @Transactional
    public List<ProductStockEntity> LettuceUpdateStock(Long productId, Long quantity) {
        List<ProductStockEntity> stockEntities = new ArrayList<>();

        ProductStockEntity productStock = productStockRepository.findById(productId);
        productStock.decreaseStock(productId, quantity);
        stockEntities.add(productStock);
        productStockRepository.stockSave(productStock);

        return stockEntities;
    }
}

@Test
void Redisson_재고_100개_상품에_101번의_주문시도() throws InterruptedException {

    int threadCount = 101;
    Long userId = 1L;
    Long productId = 1L;

    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    List<OrderProductsRequest> orderProductsRequests = List.of(
            new OrderProductsRequest(productId, 1)
    );

    OrderRequest orderRequest = new OrderRequest(1L, orderProductsRequests, 0L);

    AtomicInteger successCount = new AtomicInteger();
    AtomicInteger failCount = new AtomicInteger();

    long startTime = System.currentTimeMillis();
    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {

                orderUseCase.orderWithRedisson(orderRequest);
                successCount.getAndIncrement();

            } catch (OutOfStockException e) {

                failCount.getAndIncrement();

            } finally {

                latch.countDown();

            }
        });
    }

    latch.await();
    long endTime = System.currentTimeMillis();
    log.info("실행 시간 : {} milliseconds", endTime - startTime);

    Product product = productService.readProductDetail(productId);
    UserEntity userInfo = userService.getUserInfo(userId);

    assertEquals(0, product.getStock());
    assertEquals(100, successCount.get());
    assertEquals(1, failCount.get());

    assertEquals(800000, userInfo.getPoint());
}

 

  • redissonClient.getLock(name) : Lock 인스턴스 생성 로직, 파라미터 이름으로 Lock Key 설정
  • rLock.tryLock(waitTime, leaseTime, TimeUnit) : Lock 흭득 시도
    • waitTime : Lock 흭득 위한 대기 시간
    • leaseTime : Lock 흭득 후 작업 처리 최대시간 (해당 시간 지나면 Lock 자동 반납)
    • TimeUnit : 앞의 인자값들 시간 단위

성능

실행 시간 673 ms

 

복잡성

1. 의존성 추가

implementation 'org.redisson:redisson-spring-boot-starter:3.24.3'

 2. RedissonConfig 파일 추가

  • 호스트 및 포트 설정

 

효율성

개발자가 직접 retry와 timeout 을 구현할 필요없이 추상화 메서드를 사용할 수 있었습니다.
Pub-Sub 동작으로 인해 Lettuce에 비해 Redis에 부하가 덜 생기는 것을 확인했습니다.

3주차 과제

  • 시나리오 요구사항 별 분석자료 제출
    • 시퀀스 다이어그램 작성
    • ERD 작성
    • API 명세 및 Mock API 작성




✍️ 문제( 과제, 프로젝트를 진행하면서 부딪혔던 기술적인 문제 )

이번 주차를 지나며 겪었던 문제가 무엇이었나요? 

- 주문/결제 시퀀스다이어 그램에 대한 고민



 

✍️ 시도

문제를 해결하기 위해 어떤 시도를 하셨나요?
 - 나름대로 구상한 주문결제 시퀀스로 코치님께 피드백 요청

 

✍️ 해결

문제를 어떻게 해결하셨나요?
 - 잔고부족과 재고부족 예외처리를 결제 시퀀스 제일 앞단에 배치하고 나머지 재고차감, 잔고차감, Order, OrderItem 등과 같은 DB작업을 일괄로 묶었다. (추가적으로 개선점에 대해 더 구상해볼 필요가 있음)

 

 

✍️ 알게된 것

문제를 해결하기 위해 시도하며 새롭게 알게된 것은 무엇인가요?
- API 멱등성 키워드
- 시퀀스 다이어그램에 표시되는 점선 화살표, 실선 화살표, Opt 박스 등 용도 이해

 

 

✍️ Keep : 현재 만족하고 계속 유지할 부분

이번 주를 마무리 하며 나에게 만족했던 부분은 무엇인가요?
 - 문서작성으로 다소 여유로웠던 주차였지만 그럼에도 불구한 나의 무거운 옹동이

 

 

✍️ Problem : 개선이 필요하다고 생각하는 문제점

이번 주를 마무리 하며 개선이 필요하다고 생각했던 문제점은 무엇인가요?
 - 문서도 처음 작성해본 것들이 많아서 시간이 많이 소요됐다는 점!?

 

 

✍️ Try : 문제점을 해결하기 위해 시도해야 할 것

이 문제점을 해결하기 위해 다음 한 주간 시도 할 것은 무엇인가요? 
 - 많이 작성해보고 많이 건드려보고 많이 눈으로 접하기!!

🛕 간단한 자기소개

안녕하세요! 저는 1년차 주니어 백엔드 개발자입니다.
첫 회사에서 첫 실무 프로젝트를 진행했을 때, 마감기한에 급급한 나머지 개발자로써의 덕목을 챙기지 못해 아쉬움이 많이 남았습니다. 더군다나 1~2년차 주니어들끼리의 협업이었기 때문에 기술적 의구심과 내가 적합한 방법으로 적합한 코드를 작성했는가를 정확히 알지 못했습니다. 사정상 회사를 나오게 되었고 이러한 의구심은 외부의 도움을 받기로 하여 항해플러스를 선택하게 되었습니다.

또한, 시니어분들의 피드백과 멘토링을 진행하며 모범적인 프로젝트 사례를 경험 해보고싶었습니다.

 

✍️이번 챕터를 시작하며 꼭 해내고 싶었던 목표

이번 항플 백엔드 6기의 Chapter1은 TDD & 클린 아키텍처에 대한 내용이었는데요!
단어들만 알고있었지, 직접 코드 치면서 구현해 보거나 설계를 해본 적이 없어서 두려움이 굉장히 많았는데요
깨달으려면 직접 부딪히는 방법밖에 없는 것 같았습니다. 직접 TDD 방법론과 클린아키텍처를 겪어보면서 그 과정에서 발견되는 문제점들과 해결하는 과정을 통해 낯선 키워드인 TDD, 클린 아키텍처와 친해지는 게 가장 큰 목표였습니다.

항상 "기초라도 알자" 마인드로 공부하면 부담도 많이 덜고 나중에는 응용하는 법도 궁금해지더라구요.
마치, 방금 출항한 성장 곡선 배를 탄 것만 같은 기분이 들었습니다.




✍️ 이번 챕터를 마무리하며 가장 기억에 남는 성취

과제를 제출했다는 것.

1주 차는 생전 처음 작성해 보는 동시성 제어 및 검증, TDD 등 익숙하지 않은 것 투성이라 과제를 제출하지 못했었는데요.
이로 인해 완전 우물 안 개구리였구나를 체감했습니다.
낙담할 시간도 없습니다! 스스로 부담을 덜기 위해 당연한 거다라고 생각하고 공부를 계속 이어가는 것이 맞다 판단했습니다ㅎㅎ

✍️ 이번 챕터에서 반드시 이뤘으면 했는데 이루지 못한 것

나름대로 목표를 정하고 얻어간 점이 더 많은 것 같습니다.

 

✍️ 내가 강화해야 할 강점 중 가장 중요하다고 생각하는 한 가지

"체력"
모르는 것을 계속 붙잡고 스스로 이해될 때까지 있어야 하기 때문에 생각보다 체력소모가 심하다...
모르는 거 해결하고 넘어가면 또 모르는 거..
나름대로 리프레쉬하는 방법도 알아야 한다. 운동하고 있었어서 다행이다

✍️ 내가 개선해야 할 개선점 중 가장 중요하다고 생각하는 한 가지

예외까지 챙겨가는 코드 작성 습관. ex) 조회를 한다 하더라도 당연히 있을 거라 생각하지 말 것...

1주차 과제

  • 포인트 충전/사용에 대한 기능 구현 및 테스트코드 작성
  • 동시에 여러 요청이 들어오더라도 순서대로 제어




✍️ 문제


과제, 프로젝트를 진행하면서 부딪혔던 기술적인 문제
이번 주차를 지나며 겪었던 문제가 무엇이었나요? 
 - 테스트코드 작성에 대한 요령 및 작성 방법이 어려웠다.
 - 동시성 제어에 대한 구현체 코드 작성 및 그에 따른 테스트코드를 어떻게 작성해야하는지 몰랐다.

✍️ 시도


문제를 해결하기 위해 어떤 시도를 하셨나요?
 - GPT로 간단한 예제코드를 찾아보고, 설명된 코드의 어노테이션이나 함수를 검색을 통해 이해했다.

 - "멀티 쓰레드 환경에서의 동시성 제어"라는 키워드로 검색
 - 동시성 테스트 작성 시 어떤 방식으로 작성하는지 검색

✍️ 해결


문제를 어떻게 해결하셨나요?
 - synchronized 사용 시 공유자원에 접근하지 않는 경우 불필요한 대기시간 소요를 방지하여 ConcurrentLinkedQueue를 사용하여 충전/사용에 대한 요청을 하나의 큐로 순서대로 담았다.

 

✍️ 알게된 것


문제를 해결하기 위해 시도하며 새롭게 알게된 것은 무엇인가요?
- 테스트 코드 작성 흐름 
- given(), when()의 동작흐름 및 사용법을 이해했고 stub 가짜 객체를 리턴하여 예상 값을 반환하는 것을 알게 되었다.
 - 동시성 제어의 여러 방법을 알게 되었다. (synchronized, 명시적 락, ConcurrentHashMap)


 

✍️ Keep : 현재 만족하고 계속 유지할 부분

 

이번 주를 마무리 하며 나에게 만족했던 부분은 무엇인가요?
 - 비슷한 내용의 글들을 계속보며 이해되지 않았을 때 한계에 부딪힌 느낌을 받았지만 계속해서 답을 찾으려는 의지와 노력
 - 동료들의 코드를 보며 내 코드의 개선점 확보 

 

✍️ Problem : 개선이 필요하다고 생각하는 문제점

 

이번 주를 마무리 하며 개선이 필요하다고 생각했던 문제점은 무엇인가요?
 - 익숙하지 않은 방법과 자료구조들이 대거 등장하면서 검색으로 이해하는데 시간이 오래걸렸다.
 - 요구사항에 대한 예외처리가 전체적으로 부족했다. 
 -  아직 동시성제어에 대한 테스트 코드 작성이 감이 안온다. 현재 ExecutorService, countdownlatch의 키워드만 알고있다.

 

✍️ Try : 문제점을 해결하기 위해 시도해야 할 것

 

이 문제점을 해결하기 위해 다음 한 주간 시도 할 것은 무엇인가요? 
 - 요구사항을 러프하게라도 빠르게 분석하는 연습이 필요할 것 같다.
 - 동시성 제어에 대한 테스트 코드 작성 및 이해

+ Recent posts