-
스프링부트 게시판 (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