| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- CORS
- AWS
- Web
- redis
- kafka
- @ComponentScan
- Routing Key
- docker
- dockerhub
- 쿠버네티스
- JWT
- @Transactional
- Dead Letter Queue
- DI
- DLQ
- docker compose
- mybatis
- Spring Data JPA
- 컨테이너
- JPQL
- 서블릿 컨테이너
- Spring Container
- 스프링 부트
- JdbcTemplate
- JPA
- MSA
- 지연 로딩
- 페이징
- Spring
- securitycontextholderfilter
- Today
- Total
look-forest
동시성 문제 (feat.좋아요 수) 본문
게시글에 '좋아요' 기능을 추가하면서, 동시성 문제에 대해 알아보자.
사용자가 게시글에 '좋아요'를 추가/해제하는 작업과, 게시글마다 좋아요 수를 조회하는 것이 있다.
좋아요 설계
사용자는 게시글 하나에 좋아요 하나를 누를 수 있으므로, 제약조건으로써 (게시글ID + 사용자ID)로 유니크 인덱스를 만들면 된다.
그리고 적절한 분산 단위로서, Shard key는 게시글ID로 한다.

구현
@Service
@RequiredArgsConstructor
public class ArticleLikeService {
private final Snowflake snowflake = new Snowflake();
private final ArticleLikeRepository articleLikeRepository;
public ArticleLikeResponse read(Long articleId, Long userId) {
return articleLikeRepository.findByArticleIdAndUserId(articleId, userId)
.map(ArticleLikeResponse::from)
.orElseThrow();
}
@Transactional
public void like(Long articleId, Long userId) {
articleLikeRepository.save(
ArticleLike.create(
snowflake.nextId(),
articleId,
userId
)
);
}
@Transactional
public void unlike(Long articleId, Long userId) {
articleLikeRepository.findByArticleIdAndUserId(articleId, userId)
.ifPresent(articleLikeRepository::delete);
}
}
좋아요 수 설계
대규모 데이터에서 count 쿼리 성능 이슈는 앞서 살펴보았다. 게시글에서는 일부 카운트만 세는 경우도 있었다.
그러나 좋아요 수에서는 전체 개수를 실시간으로 빠르게 보여줘야 한다.
좋아요 수 비정규화(Denormalization)
조회 시점에 전체 개수를 실시간 조회하는게 큰 비용이 든다면,
좋아요가 생성/삭제될 때마다 미리 좋아요 수를 갱신해 두는 방법이 있다.
좋아요 테이블의 게시글 별 데이터 개수를 미리 하나의 데이터로 비정규화해주는 것이다.
비정규화(Denormalization): 정규화로 분리해 둔 데이터를, 조회 성능을 위해 일부러 중복 저장하는 설계.
정규화(Normalization): 데이터의 중복을 제거하고, 테이블을 역할별로 나누어 정합성(정확성)을 보장하는 DB 설계 원칙.
원래 정규화 원칙에서는, 계산 가능한 값은 저장하지 말고, 필요할 때 계산해야 한다.
그럼 좋아요 수 데이터는 어디에서 어떻게 관리해야 할까?
먼저 좋아요 수라는 데이터의 특성을 살펴보자.
- 쓰기 트래픽이 비교적 크지 않다.
사용자는 게시글을 조회하고, 맘에 드는 게시글을 찾은 뒤, 좋아요 액션을 직접 수행한다. - 데이터의 일관성이 비교적 중요하다.
쓰기 트래픽이 비교적 크지 않고, 일관성이 중요하다면 트랜잭션을 활용해볼 수 있다.
좋아요 테이블의 데이터 생성/삭제와 좋아요 수 갱신을 하나의 트랜잭션으로 묶는 것이다.
그럼 어떤 테이블에 저장하는 것이 좋을까?
게시글 테이블에 좋아요 수 컬럼을 추가하고 갱신한다면 아래와 같은 제약이 생길 수 있다.
- Recod Lock
- 분산 트랜잭션
Record Lock
- Record(=Row): 테이블의 행 데이터
- Lock(잠금): 여러 프로세스 또는 스레드가 자원에 동시에 접근하는 경쟁 상태를 방지하기 위해 제한을 거는 것
- Record Lock(=Row Lock): 레코드에 락을 거는 것
- 동일한 레코드를 동시에 조회 또는 수정할 때 데이터의 무결성 보장, 경쟁 상태 방지
트랜잭션1에서 update 후 commit하지 않으면, 트랜잭션2에서는 update가 처리되지 않고 타임 아웃으로 종료된다.
이처럼 락을 오래 점유하면 발하는 문제는 치명적이고 다양하다. (리소스 고갈 등으로 장애가 발생할 수도 있다)

더군다나, 게시글과 좋아요 수의 변경은 Lifecycle이 다르다.
게시글 쓰기와 좋아요 수 쓰기는 사용자 입장에서 독립적으로 수행되는 기능인데, 각 기능이 서로 영향을 끼칠 수 있는 것이다.
타임아웃을 짧게 가져가거나 요청량에 제한을 둔다면, 독립적인 두 기능이 서로에 의해 실패할 수 있다.
이러한 문제를 방지하기 위해 게시글과 좋아요 수의 변경은 독립적인 테이블로 분리하는 것이 좋다.
분산 트랜잭션
그렇다면 어디에 저장하는 것이 좋을까?
- MSA를 지향해 각 서비스 별로 독립적인 데이터베이스를 구성하며, 샤딩이 고려된 분산 데이터베이스를 사용
- 좋아요와 좋아요 수 데이터의 일관성을 위해 관계형 데이터베이스의 트랜잭션을 고려
트랜잭션은 보통 단일 데이터베이스 내에서 안정적이고 빠르게 지원한다.
분산 시스템에서 트랜잭션을 지원하려면 분산 트랜잭션 개념이 필요한데, 분산 트랜잭션은 상대적으로 느리고 복잡할 수 있다.
따라서 게시글 서비스의 데이터베이스에 좋아요 수 테이블을 관리한다면, 분산 환경이므로 트랜잭션 관리가 복잡해진다.
따라서 좋아요 서비스의 데이터베이스에서 좋아요 수 테이블을 관리한다.
좋아요 테이블은 데이터의 적절한 분산을 위해 Shard Key로 article_id를 사용하고 있다.
만약, 좋아요 수 테이블과 좋아요 테이블이 물리적으로 다른 샤드에 있다면, 여전히 분산 트랜잭션이 필요하다.
따라서 좋아요 수 테이블의 Shard Key는 좋아요 테이블과 동일하게 article_id로 한다.
좋아요와 좋아요 수 테이블로 분리하고, 단일 데이터베이스에서 하나의 트랜잭션으로 묶어 처리하기로 결정되었다.
단순하게 트랜잭션을 사용하면 충분할까?
동시성 문제
트래픽이 많다면 동시성 문제는 불가피하게 발생할 수 있다.
여러 개의 요청이 1개의 좋아요 수 레코드를 수정해야 하기 때문이다.
트랜잭션을 사용한다고 하더라도, 동시성 문제로 인해 구현 방법에 따라 데이터의 일관성은 여전히 깨질 수 있다.

Lock을 이용한 동시성 문제 해결
Lock을 이용하면 동시성 문제를 해결하고, 모든 요청을 누락 없이 처리할 수 있을 것 같다.



이번에는 요청이 누락 없이 처리될 수 있었다. 하지만 여기에는 어떤 문제가 있을까?

위 과정에는 트랜잭션 1에서 점유한 Record Lock에 의해 처리가 지연되고 있다.
이렇게 리소스를 점유하고 있는 블로킹 작업은 장애가 발생할 여지가 있다.
발생할 수 있는 문제를 최소화하는 방법은 무엇이고, 어떻게 구현할 수 있을까?
동시 쓰기 요청이 들어올 때, 데이터 유실 또는 장애 없이 처리 하기 위한 방법으로 아래 3가지를 알아보자.
- 비관적 락(Pessimistic Lock)
- 낙관적 락(Optimistic Lock)
- 비동기 순차 처리
비관적 락(Pessimistic Lock)
데이터 접근 시에 항상 충돌이 발생할 가능성이 있다고 가정 (비관적 관점)
데이터를 보호하기 위해 항상 명시적으로 락을 걸어 다른 트랜잭션 접근을 방지
- 락을 오래 점유하고 있으면, 성능 저하 또는 deadlock 등으로 인한 장애 문제
비관적 락 구현 방법 1 - 데이터베이스에 저장된 데이터 기준으로 UPDATE문 수행 (update)
- transaction start;
- insert into article_like values({article_like_id}, {article_id}, {user_id}, {created_at});
- 좋아요 데이터 삽입 - update article_like_count set like_count = like_count + 1 where article_id = {article_id};
- 좋아요 수 데이터 갱신
- Pessimistic Lock 점유 - commit;
- Pessimistic Lock 해제
update문 수행 시점에 락을 점유하므로 락 점유 시간이 상대적으로 짧다.
비관적 락 구현 방법2 - 조회 시점부터 락 점유(select for update + update)
- transaction start;
- insert into article_like values({article_like_id}, {article_id}, {user_id}, {created_at});
- 좋아요 데이터 삽입 - select * from article_like_count where article_id = {article_id} for update;
- for update 구문으로 데이터 조회
- 조회된 데이터에 대해서 Pessimistic Lock 점유(이 시점부터 다른 Lock은 점유될 수 없다.)
- 애플리케이션에서 JPA를 사용하는 경우, 객체(엔티티)로 조회할 수 있다. - update article_like_count set like_count = {updated_like_count} where article_id = {article_id};
- 좋아요 수 데이터 갱신
- 조회된 데이터를 기반으로 새로운 좋아요 수를 만들어준다. (조회 시점부터 Lock을 점유하고 있기 때문에 가능)
- Client(애플리케이션)에서 JPA를 사용하는 경우, 엔티티로 위 과정을 수행할 수 있다. - commit;
- Pessimistic Lock 해제
락 점유 시간이 상대적으로 길다.
JPA를 사용하는 경우, 엔티티를 이용하여 조금 더 객체지향스럽게 개발할 수 있다.
낙관적 락(Optimistic Lock)
데이터 접근 시에 항상 충돌이 발생할 가능성이 없다고 가정 (낙관적 관점)
락을 잡지 않기 때문에 지연은 낮을 수 있지만, 애플리케이션에서 충돌 감지 시에 추가적인 처리가 필요하다.
데이터의 변경 여부를 확인하여 충돌을 감지한다.
- 데이터가 다른 트랜잭션에 의해 수정되었는지 확인. 수정된 내역이 있으면 후처리(rollback 또는 재처리 등)
변경 여부 확인 방법
- 각 테이블은 version 컬럼으로 데이터의 변경 여부를 추적
충돌 확인 방법
1. 각 트랜잭션에서 version을 함께 조회
2. 레코드를 업데이트 (이때 where 조건에 version을 넣고, version은 증가시킨다)
3. 충돌을 확인한다.
- 데이터 변경이 실패했다면, 충돌이 있는 것으로 판단. (다른 트랜잭션에서 version을 이미 증가 시켰음을 의미하므로)

후처리
충돌을 감지하고 후처리를 위한 추가 작업이 필요하다.
충돌 발생 시에, commit 또는 rollback 또는 재시도 (애플리케이션에서 직접 구현)
비동기 순차 처리
모든 상황을 실시간으로 처리하고 즉시 응답해줄 필요는 없다는 관점.
- 요청을 대기열에 저장해두고, 이후에 비동기로 순차적으로 처리할 수도 있다.
- 게시글마다 1개의 스레드에서 순차적으로 처리하면, 동시성 문제도 사라진다
- 락으로 인한 지연이나 실패 케이스가 최소화된다. (즉시 처리되지 않기 때문에 사용자 입장에서는 지연될 수 있다)
단점은, 큰 비용이 든다는 점이다. 따라서 트래픽이 크지 않다면 채택하지 않는다.
- 비동기 처리를 위한 시스템 구축 비용
- 데이터의 일관성 관리를 위한 비용(대기열에서 중복/누락 없이 반드시 1회 실행 보장되기 위한 시스템 구축이 필요)
- 실시간으로 결과 응답이 안되기 때문에 클라이언트 측 추가 처리 필요 (이미 처리된 것처럼 보이게 하고 실패 시 알림)
- 서비스 정책으로 납득이 되어야 한다
테이블 설계
가장 간단한 방법으로, 비관적 락의 방법 1을 채택해도 무리는 없어보이지만, 학습을 위해 아래 3가지 방법 모두 구현해보자.
- 비관적 락 방법 1 (update)
- 데이터베이스에 저장된 데이터 기준으로 update 문을 직접 수행
- 비관적 락 방법 2 (select for update + update)
- select for update 구문으로 데이터를 조회한 뒤, 조회된 데이터 기반으로 좋아요 수를 갱신
- 낙관적 락 (version + 후처리)
- 좋아요 수 테이블에 버전 관리를 위한 컬럼(version)을 추가
- 충돌 시 후처리 작업 구현
- 게시글 단위로 분리되므로 트래픽 충돌이 드물며, 치명적이지 않으므로 단순히 rollback으로 처리
- JPA 사용 시, @Version 애노테이션을 달아주면, version 증감 및 비교해주고 알아서 rollback 처리해준다!

- Shard Key = article_id(게시글 ID)
article_like 테이블과 동일한 데이터베이스 샤드에서 트랜잭션 처리 위함 - version = 낙관적 락 처리를 위한 버전 컬럼 생성
구현
비관적 락 방법1은 그냥 update하면 자동으로 비관적 락이 걸린다.
비관적 락 방법2는 @Lock을 붙여 select ... for update 후 엔티티를 증감시킨다.
낙관적 락은 @Version을 붙이면 자동으로 처리해준다.
@Table(name = "article_like_count")
@Getter
@ToString
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
@Entity
public class ArticleLikeCount {
@Id
private Long articleId; //shard key
private Long likeCount;
@Version //JPA가 UPDATE 시점에 version 값을 자동으로 비교/증가시켜 낙관적 락(Optimistic Lock) 을 구현. 충돌 시 롤백.
private Long version;
public static ArticleLikeCount init(Long articleId, Long likeCount) {
ArticleLikeCount articleLikeCount = new ArticleLikeCount();
articleLikeCount.articleId = articleId;
articleLikeCount.likeCount = likeCount;
// articleLikeCount.version = 0L; JPA가 INSERT 시 자동으로 세팅
return articleLikeCount;
}
/**
* 비관적 락 방법 2 - select ... for update
*/
public void increase() {
this.likeCount++;
}
public void decrease() {
this.likeCount--;
}
}
public interface ArticleLikeCountRepository extends JpaRepository<ArticleLikeCount, Long> {
/**
* 비관적 락 방법 1 (update)
* DB에서 원자적으로 +1 하는 방식이 더 단순/빠르고 충돌에 강하다.
*/
@Query(
value = "update article_like_count set like_count = 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 = like_count - 1 where article_id = :articleId",
nativeQuery = true
)
@Modifying
int decrease(@Param("articleId") Long articleId);
/**
* 비관적 락 방법 2 (select ... for update)
* 조회된 객체을 증감하고 dirty check
*/
@Lock(LockModeType.PESSIMISTIC_WRITE) //select ... for update
Optional<ArticleLikeCount> findLockedByArticleId(Long articleId);
}
@Service
@RequiredArgsConstructor
public class ArticleLikeService {
private final Snowflake snowflake = new Snowflake();
private final ArticleLikeRepository articleLikeRepository;
private final ArticleLikeCountRepository articleLikeCountRepository;
/**
* update (자동으로 비관적 쓰기 락 걸림)
*/
@Transactional
public void likePessimisticLock1(Long articleId, Long userId) {
articleLikeRepository.save(
ArticleLike.create(
snowflake.nextId(),
articleId,
userId
)
);
int result = articleLikeCountRepository.increase(articleId);
// 최초 요청 시에는 update되는 레코드가 없으므로, 1로 초기화한다.
// 트래픽이 순식간에 몰릴 수 있는 상황에서는 유실될 수 있으므로, 게시글 생성 시점에 미리 0으로 초기화해둘 수도 있다.
if (result == 0) {
articleLikeCountRepository.save(
ArticleLikeCount.init(articleId, 1L)
);
}
}
/**
* select ... for update + update(dirty check)
*/
@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);
}
/**
* version + 충돌 시 후 처리(JPA가 자동 롤백)
*/
@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);
}
}
테스트 결과 분석
스레드풀을 100개 만들어 좋아요 1번 초기화 후 3천번 요청을 보냈을 때의 결과는 다음과 같다.

- 확실히 select ... for update가 lock을 잡는 시간이 더 길어서 시간이 오래걸린다.
- 낙관적 락의 경우 충돌이 일어났을 때 롤백을 하기 때문에 좋아요 수가 3001개가 아니라 375개로 나왔다.
게시글 수와 댓글 수도 동일한 방법으로 만들 수 있다.
게시글 수 테이블은 게시글 테이블과 동일한 샤드 키를, 댓글 수 테이블은 댓글 테이블과 동일한 샤드 키를 사용해야
단일 트랜잭션을 사용할 수 있다는 점을 잊지 말자.
참고 자료 & 이미지 출처
스프링부트로 직접 만들면서 배우는 대규모 시스템 설계 - 게시판
'Architecture > 대규모 시스템 설계' 카테고리의 다른 글
| Redis (feat.조회 수) (0) | 2026.02.09 |
|---|---|
| 계층형 구조와 페이징(feat.댓글) (0) | 2026.01.23 |
| 대용량 데이터의 조회(feat.페이징,인덱스) (0) | 2026.01.03 |
| Primary key 생성 전략 (0) | 2026.01.03 |
| Distributed Database (0) | 2026.01.02 |