ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 스프링부트 게시판 (3)
    이커머스 devops 2025. 11. 28. 16:44

    좋아요 요구사항

    - 게시글 좋아요 

        - 사용자는 각 게시글에 1회 좋아요 가능하다

        - 취소도 가능하다

    - 좋아요 수 집계 

    > (게시글 ID + 사용자 ID)로 유니크 인덱스 생성

     

     

    좋아요 테이블 설계 

    • Shard Key = article_id
    • 적절한 분산의 단위로서 선정

     

     

    create table article_like ( article_like_id bigint not null primary key, article_id bigint not null, user_id bigint not null, created_at datetime not null );

    create unique index idx_article_id_user_id on article_like(article_id asc, user_id asc);

     

     

    좋아요 수 설계

    • 조회 시점에 전체 개수를 실시간 조회하는 게 큰 비용이 든다면 좋아요가 생성/삭제될 때마다 미리 좋아요 수를 갱신해 두는 방법이 있다
    • 좋아요 테이블의 게시글 별 데이터 개수를 미리 하나의 데이터로 비정규화
    • 좋아요 수 데이터 특성
      • 쓰기 트래픽이 비교적 크지 않다 > 관계형 데이터베이스의 트랜잭션 활용
        • 관계형 데이터베이스의 트랜잭션 활용  
        • 좋아요 테이블의 데이터 생성/삭제와 좋아요 수 갱신을 하나의 트랜잭 션으로 묶는 것
      • 데이터의 일관성이 비교적 중요하다
    • 게시글 테이블에 좋아요 수 컬럼을 추가하고 좋아요가 생성/삭제될 때마다 좋아요 수 갱신
      • Record Lock, 분산 트랜잭션 제약 발생

     

     

    Record Lock

    • Record(=Row) : 테이블의 행 데이터
    • Lock : 여러 프로세스 또는 스레드가 자원에 동시 접근하는 경쟁 상태를 방지하기 위한 제한
    • Record Lock : 레코드에 락을 거는 것, 동일한 레코드를 동시에 조회 또는 수정할 때 데이터 무결성 보장, 경쟁 상태 방지 

     

     

    Record Lock 테스트

    • 락 테스트를 위한 lock_test 테이블 생성
    • id-1234, content=test 레코드 삽입
    • 트랜잭션 시작, 업데이트 후 commit 전 상태 유지

     

     

    select * from performance_schema.data_locks;

    • 레코드에 수행한 쓰기 작업에 의해 id=1234 레코드에 Exclusive Lock(=X Lock)이 걸린 것을 확인할 수 있다

     

     

    • 새로운 터미널에서 레코드를 조회해 보면 트랜잭션1에서 커밋되지 않았으므로 기존 데이터가 조회된다 

     

     

    • 커밋전 트랜잭션1 상황에서 트랜잭션2를 시작하고 id=1234 레코드 수정
    • 트랜잭션 1에서 잡힌 Exclusive Lock에 의해, 트랜잭션 2는 Lock이 해제될 때까지 기다려야 한다 

     

     

     

    • 레코드 락은 그대로 유지되고 있고 ENGGINE_TRASNACTION_ID가 변경되었다 
    • 트랜잭션 1이 종료되면서 exclusive lock을 해제하고 트랜잭션 2가 수행하면서 다시 exclusive lock을 획득했다 
    • 트랜잭션 2를 commit하여 정상종료 하면 모든 트랜잭션이 종료되면서 레코드에 걸렸던 락도 해제된다 

     

     

    Record Lock으로 인해 게시글 테이블에 좋아요 수 컬럼을 비정규화하는 것은 제약이 생길 수도 있다

    • 게시글과 좋아요 수의 변경 lifecycle이 다르다
      • 게시글은 작성한 사용자가 쓰기 작업을 수행하고 트래픽이 상대적으로 적다
      • 좋아요 수는 게시글을 조회한 사용자들이 쓰기 작업을 수행하고 트래픽은 상대적으로 많다 
      • 즉 서로 다른 주체에 의해서 레코드에 락이 잡힐 수 있다
      • 게시글 쓰기 / 좋아요 수 쓰기 사용자 입장에서 독립적으로 수행되는 기능이지만 각 기능이 서로에게 영향을 끼칠 수 있는 것이다 
    • 그래서, 게시글과 좋아요 수의 변경은 독립적인 테이블로 분리한다 
    • 1:1 관계이지만 테이블 비정규화가 필요하다 

     

     

    다시 좋아요 수 설계

    • 좋아요의 생성/삭제에 따른 좋아요 수 갱신은 단일 데이터베이스에서 하나의 트랜잭션으로 묶어서 처리한다
      • 좋아요 테이블과 좋아요 수 테이블
    • 높은 쓰기 트래픽이 들어올 수 있는 상황에서는 동시성 문제가 발생할 수 있다 
      • 동시 쓰기 요청이 들어올 때 데이터 유실 또는 장애 없이 처리하기 위한 방법으로는 비관적 락, 낙관적 락, 비동기 순차 처리가 있다 

     

     

    비관적 락 (Pessimistic Lock)

    • 데이터 접근 시에 항상 충돌이 발생할 가능성이 있다고 가정한다
    • 데이터를 보호하기 위해 항상 락을 걸어 다른 트랜잭션 접근 방지
      • 다른 트랜잭션은 락이 해제되기까지 대기
      • 락을 오래 점유하고 있으면 성능 저하 또는 deadlock 등으로 인한 장애 문제 
    비관적 락(Pessimistic Lock) – 방법 1. 데이터베이스에 저장된 데이터 기준으로 UPDATE 문을 수행한다
    1. transaction start;
    2. insert into article_like values({article_like_id}, {article_id}, {user_id}, {created_at});
        • 좋아요 데이터 삽입
    3. update article_like_count set like_count = like_count + 1 where article_id = {article_id};
        • 좋아요 수 데이터 갱신
        • Pessimistic Lock 점유
    4. commit;
        • Pessimistic Lock 해제

    비관적 락(Pessimistic Lock) – 방법 2. for update 구문으로 조회 결과에 대해 락을 점유하겠다고 명시하고 트랜잭션에 조회된 데이터 기준으로 update문을 수행한다 
    1. transaction start;
    2. insert into article_like values({article_like_id}, {article_id}, {user_id}, {created_at});
        • 좋아요 데이터 삽입
    3. select * from article_like_count where article_id = {article_id} for update;
        • for update 구문으로 데이터 조회
        • 조회된 데이터에 대해서 Pessimistic Lock 점유(이 시점부터 다른 Lock은 점유될 수 없다.)
        • 애플리케이션에서 JPA를 사용하는 경우, 객체(엔티티)로 조회할 수 있다.
    4. update article_like_count set like_count = {updated_like_count} where article_id = {article_id};
        • 좋아요 수 데이터 갱신
        • 조회된 데이터를 기반으로 새로운 좋아요 수를 만들어준다. (조회 시점부터 Lock을 점유하고 있기 때문에 가능)
        • Client(애플리케이션)에서 JPA를 사용하는 경우, 엔티티로 위 과정을 수행할 수 있다.
    5. commit;
        • Pessimistic Lock 해제
      방법 1(update 구문) 방법 2(select for update + update 구문)
    락 점유 - UPDATE 문 수행하는 시점에 락을 점유한다
    - 락 점유하는 시간이 상대적으로 짧다
    - 데이터 조회 시점부터 락을 점유한다
    - 락 점유하는 시간이 상대적으로 길다
    - 데이터를 조회한 뒤 중간 과정을 수행해야 하기 때문에, 락 해제가 지연될 수 있다
    애플리케이션 개발 데이터베이스의 현재 저장된 데이터 기준으로 증감 처리하기 때문에 SQL문을 직접 전송한다 JPA를 사용하는 경우, 엔티티를 이용하여 조금 더 객체지향스럽게 개발할 수 있다

     

     

    낙관적 락(Optimistic Lock)

    • 데이터 접근 시에 항상 충돌이 발생할 가능성이 없다고 가정한다
    • 데이터의 변경 여부를 확인하여 충돌을 처리한다
      • 데이터가 다른 트랜잭션에 의해 수정되었는지 확인하고 수정된 내역이 있으면 후처리(rollback 혹은 재처리 등)
    • 각 테이블은 version 컬럼으로 데이터의 변경 여부를 추적한다

     

     

    비동기 순차 처리

    • 모든 상황을 실시간으로 처리하고 즉시 응답해 줄 필요는 없다는 관점
      • 요청을 대기열에 저장해두고, 이후에 비동기로 순차적으로 처리할 수도 있다
      • 동시성 문제 없음
      • 락으로 인한 지연 혹은 실패 케이스가 최소화
    • 큰 비용이 든다 
      • 비동기 처리 시스템 구축 비용
      • 실시간으로 결과 응답이 안되기 때문에 클라이언트 측 추가 처리 필요
      • 서비스 정책 납득
      • 데이터 일관성 관리를 위한 비용

     

     

    좋아요 수 설계 –비관적 락–방법 1

    • update article_like_count set like_count = like_count + 1 where article_id = {article_id}
    • update 문을 직접 수행한다

    좋아요 수 설계 –비관적 락–방법 2

    • 트랜잭션에 조회된 데이터 기준, select * from article_like_count where article_id = {article_id} for update
    • update article_like_count set like_count = {updated_like_count} where article_id = {article_id}
    • for update 구문으로 데이터를 조회한 뒤, 조회된 데이터 기반으로 좋아요 수 갱신

    좋아요 수 설계 –낙관적 락

    • version 컬럼을 추가하고, 애플리케이션에서 낙관적 락에 의한 충돌 처리 작업이 필요하다
    • 충돌 시, rollback 처리는 @Version 사용

     

     

    좋아요 수 테이블 설계

    • Shard Key = article_id (게시글 ID)
    • article_like 테이블과 동일한 데이터베이스 샤드에서 트랜잭션 처리 위함
    • version = 낙관적 락 처리를 위한 버전 컬럼 생성

    @Repository
    public interface ArticleLikeCountRepository extends JpaRepository<ArticleLikeCount, Long> {
        @Lock(LockModeType.PESSIMISTIC_WRITE)
        Optional<ArticleLikeCount> findLockedByArticleId(Long articleId);
    
        @Query(
                value = "update article_like_count set like_count + 1 where article_id = :articleId",
                nativeQuery = true
        )
        @Modifying
        int increase(@Param("articleId") Long articleId);
    
        @Query(
                value = "update article_like_count set like_count - 1 where article_id = :articleId",
                nativeQuery = true
        )
        @Modifying
        int decrease(@Param("articleId") Long articleId);
    }
    @Repository
    public interface ArticleLikeRepository extends JpaRepository<ArticleLike, Long> {
        Optional<ArticleLike> findByArticleIdAndUserId(Long articleId, Long userId);
    }
    @Service
    @RequiredArgsConstructor
    public class ArticleLikeService {
        private final Snowflake snowflake = new Snowflake();
        private final ArticleLikeRepository articleLikeRepository;
        private final ArticleLikeCountRepository articleLikeCountRepository;
    
        public ArticleLikeResponse read(Long articleId, Long userId) {
            return articleLikeRepository.findByArticleIdAndUserId(articleId, userId)
                    .map(ArticleLikeResponse::from)
                    .orElseThrow();
        }
    
        @Transactional
        public void unlike(Long articleId, Long userId) {
            articleLikeRepository.findByArticleIdAndUserId(articleId, userId)
                    .ifPresent(articleLikeRepository::delete);
        }
        // ---------------------------------------------------------------------
    
        @Transactional
        public void like(Long articleId, Long userId) {
            articleLikeRepository.save(
                    ArticleLike.create(
                            snowflake.nextId(),
                            articleId,
                            userId
                    )
            );
        }
    
        // 1. update 구문
        @Transactional
        public void likePessimisticLock1(Long articleId, Long userId) {
            articleLikeRepository.save(
                    ArticleLike.create(
                            snowflake.nextId(),
                            articleId,
                            userId
                    )
            );
            int result = articleLikeCountRepository.increase(articleId);
            if (result == 0) {
                // 최초 요청 시에는 update 되는 레코드가 없으므로 1로 초기화한다
                // 트래픽이 한 번에 몰릴 수 있는 상황에는 유실될 수 있으므로 게시글 생성 시점에 미리 0으로 초기화 해둘 수 있다
                articleLikeCountRepository.save(
                        ArticleLikeCount.init(articleId, 1L)
                );
            }
        }
    
        @Transactional
        public void unlikePessimisticLock1(Long articleId, Long userId) {
            articleLikeRepository.findByArticleIdAndUserId(articleId, userId)
                    .ifPresent(articleLike -> {
                        articleLikeRepository.delete(articleLike);
                        articleLikeCountRepository.decrease(articleId);
                    });
        }
        // ---------------------------------------------------------------------
    
        // 2. select ... for update + update
        @Transactional
        public void likePessimisticLock2(Long articleId, Long userId) {
            articleLikeRepository.save(
                    ArticleLike.create(
                            snowflake.nextId(),
                            articleId,
                            userId
                    )
            );
            ArticleLikeCount articleLikeCount = articleLikeCountRepository.findLockedByArticleId(articleId)
                    .orElseGet(() -> ArticleLikeCount.init(articleId, 0L));
            articleLikeCount.increase();
            articleLikeCountRepository.save(articleLikeCount);
        }
    
        @Transactional
        public void unlikePessimisticLock2(Long articleId, Long userId) {
            articleLikeRepository.findByArticleIdAndUserId(articleId, userId)
                    .ifPresent(articleLike -> {
                        articleLikeRepository.delete(articleLike);
                        ArticleLikeCount articleLikeCount = articleLikeCountRepository.findLockedByArticleId(articleId).orElseThrow();
                        articleLikeCount.decrease();
                    });
        }
        // ---------------------------------------------------------------------
    
        // 3.
        @Transactional
        public void likeOptimisticLock(Long articleId, Long userId) {
            articleLikeRepository.save(
                    ArticleLike.create(
                            snowflake.nextId(),
                            articleId,
                            userId
                    )
            );
            ArticleLikeCount articleLikeCount = articleLikeCountRepository.findById(articleId)
                    .orElseGet(() -> ArticleLikeCount.init(articleId, 0L));
            articleLikeCount.increase();
            articleLikeCountRepository.save(articleLikeCount);
        }
    
        @Transactional
        public void unlikeOptimisticLock(Long articleId, Long userId) {
            articleLikeRepository.findByArticleIdAndUserId(articleId, userId)
                    .ifPresent(articleLike -> {
                        articleLikeRepository.delete(articleLike);
                        ArticleLikeCount articleLikeCount = articleLikeCountRepository.findById(articleId).orElseThrow();
                        articleLikeCount.decrease();
                    });
        }
        // ---------------------------------------------------------------------
    
        public Long count(Long articleId) {
            return articleLikeCountRepository.findById(articleId)
                    .map(ArticleLikeCount::getLikeCount)
                    .orElse(0L);
        }
    }
    public class LikeApiTest {
        RestClient restClient = RestClient.create("http://localhost:9002");
    
        @Test
        void likeAndUnlikeTest() {
            Long articleId = 9999L;
    
            like(articleId, 1L, "pessimistic-lock-1");
            like(articleId, 2L, "pessimistic-lock-1");
            like(articleId, 3L, "pessimistic-lock-1");
    
            ArticleLikeResponse response1 = read(articleId, 1L);
            ArticleLikeResponse response2 = read(articleId, 2L);
            ArticleLikeResponse response3 = read(articleId, 3L);
            System.out.println("response1 = " + response1);
            System.out.println("response2 = " + response2);
            System.out.println("response3 = " + response3);
    
            unlike(articleId, 1L);
            unlike(articleId, 2L);
            unlike(articleId, 3L);
        }
    
        void like(Long articleId, Long userId, String lockType) {
            restClient.post()
                    .uri("/v1/article-likes/articles/{articleId}/users/{userId}/" + lockType, articleId, userId)
                    .retrieve();
        }
    
        void unlike(Long articleId, Long userId) {
            restClient.delete()
                    .uri("/v1/article-likes/articles/{articleId}/users/{userId}", articleId, userId)
                    .retrieve();
        }
    
        ArticleLikeResponse read(Long articleId, Long userId) {
            return restClient.get()
                    .uri("/v1/article-likes/articles/{articleId}/users/{userId}", articleId, userId)
                    .retrieve()
                    .body(ArticleLikeResponse.class);
        }
    
    
        @Test
        void likePerformanceTest() throws InterruptedException {
            ExecutorService executorService = Executors.newFixedThreadPool(100);
            likePerformanceTest(executorService, 1111L, "pessimistic-lock-1");
            likePerformanceTest(executorService, 2222L, "pessimistic-lock-2");
            likePerformanceTest(executorService, 3333L, "optimistic-lock");
        }
    
        void likePerformanceTest(ExecutorService executorService, Long articleId, String lockType) throws InterruptedException {
    
            CountDownLatch latch = new CountDownLatch(3000);
            System.out.println(lockType + " start");
    
            like(articleId, 1L, lockType);
    
            long start = System.nanoTime();
            for(int i=0; i < 3000; i++) {
                long userId = i + 2;
                executorService.submit(() -> {
                    like(articleId, userId, lockType);
                    latch.countDown();
                });
            }
    
            latch.await();
    
            long end = System.nanoTime();
    
            System.out.println("lockType = " + lockType + ", time = " + (end - start) / 1000000 + "ms");
            System.out.println(lockType + " end");
    
            Long count = restClient.get()
                    .uri("/v1/article-likes/articles/{articleId}/count", articleId)
                    .retrieve()
                    .body(Long.class);
    
            System.out.println("count = " + count);
        }
    }

     

     

    게시글 수, 댓글 수 테이블 설계

    @Table(name = "board_article_count")
    @Entity
    @ToString
    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class BoardArticleCount {
        @Id
        private Long boardId; // shard key
        private Long articleCount;
    
        public static BoardArticleCount init(Long boardId, Long articleCount) {
            BoardArticleCount boardArticleCount = new BoardArticleCount();
            boardArticleCount.boardId = boardId;
            boardArticleCount.articleCount = articleCount;
            return boardArticleCount;
        }
    }
    public interface BoardArticleCountRepository extends JpaRepository<BoardArticleCount, Long> {
        @Query(
                value = "update board_article_count set article_count = article_count + 1 where board_id = :boardId",
                nativeQuery = true
        )
        @Modifying
        int increase(@Param("boardId") Long boardId);
    
        @Query(
                value = "update board_article_count set article_count = article_count - 1 where board_id = :boardId",
                nativeQuery = true
        )
        @Modifying
        int decrease(@Param("boardId") Long boardId);
    }
    @Service
    @RequiredArgsConstructor
    public class ArticleService {
        private final Snowflake snowflake = new Snowflake();
        private final ArticleRepository articleRepository;
        private final BoardArticleCountRepository boardArticleCountRepository;
    
        @Transactional
        public ArticleResponse create(ArticleCreateRequest request) {
            Article article = articleRepository.save(
                    Article.create(snowflake.nextId(), request.getTitle(), request.getContent(), request.getBoardId(), request.getWriterId())
            );
            int result = boardArticleCountRepository.increase(request.getBoardId());
            if (result == 0) {
                boardArticleCountRepository.save(
                        BoardArticleCount.init(request.getBoardId(), 1L)
                );
            }
            return ArticleResponse.from(article);
        }
    
        @Transactional
        public ArticleResponse update(Long articleId, ArticleUpdateRequest request) {
            Article article = articleRepository.findById(articleId).orElseThrow();
            article.update(request.getTitle(), request.getContent());
            return ArticleResponse.from(article);
        }
    
        public ArticleResponse read(Long articleId) {
            return ArticleResponse.from(articleRepository.findById(articleId).orElseThrow());
        }
    
        @Transactional
        public void delete(Long articleId) {
            Article article = articleRepository.findById(articleId).orElseThrow();
            articleRepository.delete(article);
            boardArticleCountRepository.decrease(article.getBoardId());
        }
    
        public ArticlePageResponse readAll(Long boardId, Long page, Long pageSize) {
            return ArticlePageResponse.of(
                    articleRepository.findAll(boardId, (page-1) * pageSize, pageSize).stream()
                            .map(ArticleResponse::from)
                            .toList(),
                    articleRepository.count(
                            boardId,
                            PageLimitCalculator.calculatePageLimit(page, pageSize, 10L)
                    )
            );
        }
    
        public List<ArticleResponse> readAllInfiniteScroll(Long boardId, Long pageSize, Long lastArticleId) {
            List<Article> articles = lastArticleId == null ?
                articleRepository.findAllInfiniteScroll(boardId, pageSize) :
                articleRepository.findAllInfiniteScroll(boardId, pageSize, lastArticleId);
            return articles.stream().map(ArticleResponse::from).toList();
        }
    
        public Long count(Long boardId) {
            return boardArticleCountRepository.findById(boardId)
                    .map(BoardArticleCount::getArticleCount)
                    .orElse(0L);
        }
    }
    728x90

    '이커머스 devops' 카테고리의 다른 글

    동시성문제 - 비관적 락  (0) 2026.01.03
    스프링부트게시판 (4)  (0) 2025.11.29
    스프링부트 게시판 (2)  (0) 2025.11.27
    스프링부트 게시판 (1)  (1) 2025.11.25
    Distributed Relational Database  (0) 2025.11.25
Designed by Tistory.