

동시성(concurrency)을 다룰 때 가장 중요한 문제는
“여러 트랜잭션이 동시에 같은 데이터를 수정하려 할 때
어떻게 데이터의 일관성을 보장할 것인가?”이다.
예를 들어 두 사용자가 같은 게시글에 동시에 좋아요를 누른다고 가정해보자.
이 두 요청이 동시에 처리되면,
결과는 12가 아니라 11로 덮어씌워지는 문제가 발생한다.
이것이 바로 동시 수정 충돌(Write Conflict)이다.
데이터베이스는 이러한 충돌을 방지하기 위해 Lock(잠금)을 사용한다.
락은 쉽게 말해 “이 데이터는 지금 누군가 수정 중이니 잠깐 기다려라”라는 신호다.
하지만 이 락을 언제 거는지가 문제다.
너무 일찍 잠그면 병목이 생기고, 너무 늦게 감지하면 충돌이 생긴다.
이 시점의 선택이 바로 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)의 차이를 만든다.
| 구분 | 비관적 락 (Pessimistic Lock) | 낙관적 락 (Optimistic Lock) |
|---|---|---|
| 철학 | “충돌은 자주 일어나므로 미리 막자.” | “충돌은 드물므로 나중에 감지하자.” |
| 동작 시점 | 트랜잭션 시작 시점에서 Row 잠금 | 커밋 시점에서 버전(version) 비교 |
| 충돌 처리 | 순차 대기 (WAIT) → 데드락 가능 | 예외 발생 → 재시도 필요 |
| 주요 비용 | 대기 시간 | 재시도 비용 |
| 적합 서비스 | 결제, 송금, 재고 등 절대 정합성 | 좋아요, 조회수, 포인트 등 즉시성 중심 |
“언제 충돌이 날지 모르니, 미리 잠가두자.”
비관적 락은 말 그대로 “비관적인” 가정에서 출발한다.
충돌이 언제든 발생할 수 있다고 보고,
트랜잭션이 데이터를 수정하기 전에 먼저 락을 건다.
SELECT * FROM post WHERE id = 1 FOR UPDATE;
UPDATE post SET like_count = like_count + 1 WHERE id = 1;
이 경우 다른 트랜잭션은 해당 Row가 해제될 때까지 기다린다.
즉, 항상 순서를 보장하지만 대기 시간이 생긴다.
한 줄 요약: “안전하지만 느리다.”
“충돌은 드물다. 일단 진행하고, 나중에 확인하자.”
낙관적 락은 트랜잭션이 데이터를 수정하더라도
락을 걸지 않고 자유롭게 진행한다.
단, 커밋 시점에서 데이터의 버전을 비교하여
누군가 먼저 변경했다면 예외를 발생시킨다.
@Entity
class Post {
@Id @GeneratedValue
private Long id;
@Version
private Long version;
private int likeCount;
}
@Version 필드는 데이터의 버전을 의미한다.
A가 먼저 저장하여 version이 2가 되면,
B가 나중에 커밋할 때 version이 맞지 않아 예외가 발생한다.
한 줄 요약: “빠르지만 충돌 시 실패한다.”
이론을 이해했다면 이제 실무에서 이를 적용해볼 차례다.
사이드 프로젝트의 게시판(Post) 서비스에서
좋아요 기능을 구현하면서 락 전략을 직접 실험해보기로 했다.
좋아요는 동시에 여러 사용자가 같은 게시글을 수정할 수 있지만,
금융 거래처럼 강한 정합성이 필요한 기능은 아니다.
즉, 충돌이 자주 발생하지 않으며, 빠른 응답이 더 중요하다.
그래서 “락을 미리 거는 비관적 락”보다
“충돌 시점에서 감지하는 낙관적 락”이 더 적합하다고 판단했다.
좋아요 기능은 다음 두 가지 데이터가 필요하다.
이 두 역할을 각각 PostLike와 Post로 나누었다.
@Entity
class PostLike {
@Id @GeneratedValue
private Long id;
private Long postId;
private Long memberId;
}
PostLike는 사용자별 좋아요 여부만 관리하며
중복을 막기 위해 UNIQUE(post_id, member_id) 제약을 둔다.
@Entity
class Post {
@Id @GeneratedValue
private Long id;
@Version
private Long version;
private int likeCount;
}
Post의 likeCount는 총 좋아요 수를 단순 정수 컬럼으로 관리한다.
이렇게 하면 다음과 같은 장점이 생긴다.
| 구분 | 기존 (JOIN 방식) | 개선 (likeCount 컬럼) |
|---|---|---|
| 조회 쿼리 | SELECT COUNT(*) FROM post_like WHERE post_id = ? | SELECT like_count FROM post WHERE id = ? |
| 속도 | 느림 (JOIN + COUNT) | 빠름 (단일 컬럼 조회) |
| 구조 | 관계형 계산 중심 | 캐시 친화적 단순 구조 |
즉, Post 하나만으로도 좋아요 수를 바로 응답할 수 있으므로
조회 성능과 캐시 효율성이 크게 향상된다.
@Transactional
public void toggleLike(Long postId, Long memberId) {
Post post = postRepository.findByIdWithOptimisticLock(postId)
.orElseThrow(PostNotFoundException::new);
boolean liked = postLikeRepository.existsByPostIdAndMemberId(postId, memberId);
if (liked) {
postLikeRepository.deleteByPostIdAndMemberId(postId, memberId);
post.decreaseLikeCount();
} else {
postLikeRepository.save(new PostLike(postId, memberId));
post.increaseLikeCount();
}
}
@Version)으로 동시 수정 충돌을 감지한다.PostLike 테이블을 JOIN하지 않고 likeCount만으로 응답한다.OptimisticLockException이 발생한다.
좋아요 기능의 동시성 안정성을 검증하기 위해
K6를 이용해 약 100명의 사용자가 동시에 같은 게시글을 좋아요 요청하는 상황을 시뮬레이션했다.
테스트 결과,
낙관적 락(@Version)을 적용했기 때문에 데이터 불일치는 발생하지 않았지만,
일부 요청이 커밋 시점에서 OptimisticLockException으로 실패했다.
이는 낙관적 락의 정상 동작 결과다.
트랜잭션이 자유롭게 실행되다가,
커밋 시점에서 동일한 Row를 갱신한 다른 트랜잭션이 있으면
DB가 충돌을 감지하고 예외를 던진다.
즉, 정합성은 확보되었지만,
일부 요청은 실패로 끝나는 상황이 발생한다.
이 시점에서 자연스럽게 다음 고민이 생긴다.
“충돌이 발생했을 때, 단순히 실패로 끝낼 것인가?
아니면 자동으로 다시 시도해 복구할 것인가?”
비관적 락은 정합성이 절대적인 시스템에서 유리하지만,
짧은 트랜잭션이 반복되는 서비스에서는 병목을 만든다.
낙관적 락은 충돌을 허용하지만, 그 대신 빠른 처리와 높은 처리율을 제공한다.
좋아요 기능은 후자에 속한다.
따라서 “충돌 감지 → 재시도” 구조를 선택하는 것이 합리적이다.
이제 남은 과제는 단 하나다.
충돌을 어떻게, 어떤 정책으로 재시도할 것인가.