Spring Boot SNS 포트폴리오 시리즈 4편. Post Service의 게시글 생성에 Redisson 분산 락이 트랜잭션을 감싸는 이유, 좋아요 동시 요청을 DB UNIQUE 제약으로 막는 트릭, afterCommit() 으로 캐시·랭킹·ES 인덱싱을 트랜잭션 일관성에 맞추는 흐름까지 한 글에 정리합니다.
이 글은 Spring Boot SNS 마이크로서비스 포트폴리오 시리즈의 4편입니다. 1편에서 큰 그림, 2·3편에서 정문 보안실(API Gateway)과 회원 관리부(User Service)를 봤다면, 4편에서는 Post Service — 콘텐츠부 한 곳을 줌인합니다. 게시글 생성에 Redisson 분산 락이 왜 트랜잭션을 감싸는지, 좋아요 동시 요청을 DB UNIQUE 제약 한 줄로 어떻게 막는지, 캐시·랭킹·ES 인덱싱이 왜 모두 afterCommit() 안에서 일어나야 하는지 — 콘텐츠부 일과의 가장 흥미로운 부분들을 한 줄씩 풀어 갑니다.
비유는 1·2·3편을 그대로 이어 가요 — 콘텐츠부 직원 한 명이 게시글 한 편의 생애(접수·검토·저장·검색대 등록·게시판 공지)를 처음부터 끝까지 맡는 그림. 직원이 한 게시글을 처리하는 동안 같은 사람이 두 번째 게시글을 또 들이밀면 어떻게 막을지(분산 락), 좋아요 두 번이 동시에 들어오면 어떻게 한 번만 인정할지(UNIQUE 제약), 저장이 실패하면 게시판 공지도 같이 취소돼야 하는 것(트랜잭션 일관성) — 이 세 가지가 콘텐츠부의 핵심 챌린지입니다.
프로젝트 SNS는 이메일·비밀번호와 구글·깃허브 OAuth2 로그인, 게시글·댓글·좋아요·랭킹·구독·알림·한국어 검색까지 흔한 기능을 한 번씩 다 다뤄 보면서, 그 평범한 기능들을 마이크로서비스 패턴(Database-per-Service · API Gateway · Outbox + CDC · Redisson 분산 락)과 인프라(Kafka·Redis·Elasticsearch·LocalStack S3)로 어떻게 묶어 내는가를 직접 손으로 짜 보는 학습용 포트폴리오입니다.
왜 Post Service 동시성이 처음엔 어렵게 느껴질까
처음 콘텐츠부 코드를 보면 막히는 지점이 두 가지예요.
첫째, 트랜잭션 경계와 락의 관계가 헷갈립니다. "락은 트랜잭션 안에 두면 안 됨" 이라는 룰을 처음 보면 왜인지 안 보이거든요. @Transactional 메서드 안에서 락을 잡고 풀어도 되는 것 아닌가 싶지만, 실은 그러면 락이 commit 전에 풀려서 다른 스레드가 끼어듭니다. Redisson RLock 코드를 처음 보면 "왜 굳이 메서드를 둘로 쪼갰지" 싶어요.
둘째, 트랜잭션 커밋 전후의 일관성 문제예요. 게시글을 저장하면서 동시에 캐시도 갱신하고, Redis 랭킹도 갱신하고, Elasticsearch에도 인덱싱하고, Kafka에도 이벤트를 발행해야 하는데 — 이 중 하나라도 실패하면 어떻게 되는지가 불안합니다. "DB는 롤백됐는데 ES에는 인덱싱돼 있는" 유령 게시글이 진짜로 생길 수 있거든요.
해결법은 두 단계예요. 먼저 락의 위치 — @Transactional 메서드의 바깥에 둔다는 룰 한 줄을 머리에 박고, 그다음에 외부 시스템 호출은 모두 afterCommit() 안으로 라는 룰 한 줄을 박습니다. 이 두 줄이 통과되면 콘텐츠부 코드가 한결 명료해져요.
콘텐츠부의 일과 — 게시글 한 편의 생애
콘텐츠부의 핵심 API와 흐름을 한 표로 정리하면 이렇게 됩니다.
| 동작 | API | 핵심 처리 |
|---|---|---|
| 게시글 작성 | POST /v1/posts | Redisson 락 + DB 저장 + Outbox + ES 인덱싱(afterCommit) |
| 게시글 조회 | GET /v1/posts/{id} | 캐시 → DB → 조회수↑ + afterCommit 랭킹·캐시 갱신 |
| 좋아요 토글 | POST /v1/posts/{id}/likes | DB UNIQUE + DataIntegrityViolation catch |
| 댓글 작성 | POST /v1/posts/{id}/comments | parentId 검증(같은 게시글 소속인가) |
| 랭킹 조회 | GET /v1/posts/ranking | Redis ZREVRANGE → DB IN 쿼리 |
겉보기엔 평범한 CRUD인데, 각 동작마다 동시성 함정이 한두 개씩 박혀 있어요. 한 줄씩 짚어 갑니다.
게시글 생성 — Redisson 분산 락이 트랜잭션을 감싸는 이유
게시글 생성 코드의 가장 큰 특징은 메서드가 둘로 쪼개져 있다는 점이에요. 하나는 락 잡는 바깥쪽, 하나는 트랜잭션 안쪽.
// 1단계: 분산 락 (non-transactional) — 락이 트랜잭션을 감싸야 commit 이후 해제 가능
public PostResponse createPost(Long userId, PostRequest request) {
RLock lock = redissonClient.getLock("lock:post:create:" + userId);
try {
// waitTime=0: 즉시 실패, leaseTime=-1: Watchdog 자동 갱신
if (!lock.tryLock(0, -1, TimeUnit.SECONDS)) {
throw new DuplicatePostRequestException(userId); // → HTTP 409
}
return doCreatePost(userId, request); // 2단계 호출
} finally {
if (lock.isHeldByCurrentThread()) lock.unlock();
}
}
// 2단계: DB 저장 (transactional) — 락 안에서 실행
@Transactional
PostResponse doCreatePost(Long userId, PostRequest request) {
Post saved = postRepository.save(post);
postMediaRepository.findAllByIdIn(mediaIds).forEach(m -> m.updatePostId(saved.getId()));
redisTemplate.delete("cache:posts:list");
outboxEventRepository.save(OutboxEvent.of("Post", postId, "PostCreated", payload));
TransactionSynchronizationManager.registerSynchronization(...afterCommit → ES index);
return buildPostResponse(saved);
}
여기서 정말 중요한 시험 함정 — 락이 트랜잭션을 감싸야 commit 이후에 해제됩니다. 만약 @Transactional 안에서 lock.lock() ... lock.unlock() 했다면, finally 블록의 unlock이 commit보다 먼저 실행돼요. 그러면 commit이 끝나기 전에 다른 스레드가 락을 잡고 같은 사용자의 게시글을 또 만들 수 있습니다.
잘못된 순서: 올바른 순서:
[락 잡기] [락 잡기]
[트랜잭션 시작] [트랜잭션 시작]
DB 작업 DB 작업
[트랜잭션 커밋] [트랜잭션 커밋]
[락 해제] ← OK [락 해제] ← OK
@Transactional 안에 lock 두면:
[트랜잭션 시작]
[락 잡기]
DB 작업
[락 해제] ← 이 시점에 다른 스레드 진입 가능
[트랜잭션 커밋] ← 이미 늦음
tryLock(0, -1, SECONDS) 두 인자가 핵심이에요. waitTime=0은 "기다리지 말고 즉시 실패", leaseTime=-1은 "Watchdog 활성화" 입니다. Watchdog은 Redisson 백그라운드 스레드가 10초마다 락 TTL을 자동 갱신하는 메커니즘이에요. unlock()을 호출하거나 서버가 비정상 종료되면 Watchdog도 같이 끝나서 락이 자연 해제됩니다.
RLock lock = redissonClient.getLock("lock:post:create:" + userId);
boolean acquired = lock.tryLock(
0, // waitTime=0: 즉시 실패 (대기 없음)
-1, // leaseTime=-1: Watchdog 활성화
TimeUnit.SECONDS
);
if (!acquired) throw new DuplicatePostRequestException(userId); // HTTP 409
같은 사용자가 빠르게 더블 클릭해서 게시글 생성 요청이 두 번 들어와도, 두 번째 요청은 락 획득 실패로 즉시 409 받고 중복 생성이 차단됩니다. Watchdog 덕분에 락 TTL을 너무 짧게 잡지 않아도 안전해서 코드가 단순해져요.
Redisson RLock = 락은 @Transactional 바깥. waitTime=0(즉시 실패) + leaseTime=-1(Watchdog 자동 갱신). finally에서 isHeldByCurrentThread() 확인 후 unlock.
Outbox Pattern — Kafka 발행을 트랜잭션과 묶기
doCreatePost() 안에 흥미로운 한 줄이 있어요.
// ✅ 옳은 방식: DB 트랜잭션 안에 Outbox 이벤트도 같이 저장
outboxEventRepository.save(OutboxEvent.of("Post", postId, "PostCreated", payload));
처음 보면 "왜 Kafka에 직접 발행 안 하지" 싶지만, 여기에 Post Service의 가장 중요한 패턴이 숨어 있어요. Kafka 발행과 DB 저장의 원자성을 보장하는 Outbox Pattern입니다.
문제 상황:
1. DB 저장 성공 → Kafka 발행 실패 → 알림이 사라짐
2. Kafka 발행 성공 → DB 롤백 → 유령 이벤트 (실제 게시글 없는데 알림)
Outbox 해법:
DB 트랜잭션 안에서 outbox_events 테이블에 이벤트 INSERT
↓ (커밋)
Debezium이 PostgreSQL WAL 감지 (wal_level=logical)
↓
Kafka 토픽 "Post"에 자동 발행
↓
Notification Service가 구독해 알림 처리
DB 저장과 Kafka 발행이 한 트랜잭션에 묶여서 둘이 같이 성공하거나 같이 실패해요. 자세한 Debezium 설정과 outbox_events 파티셔닝(pg_partman 월별)은 5편(Kafka + Outbox)에서 풀어 갑니다.
게시글 조회 — afterCommit() 으로 캐시·랭킹 일관성 잡기
게시글 조회 코드는 짧지만 정말 중요한 패턴이 두 번 등장해요.
@Transactional
public PostResponse getPost(Long id) {
// 1. Redis 캐시 히트 시 즉시 반환
String cacheKey = "cache:posts:" + id;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return objectMapper.readValue(cached, PostResponse.class);
}
// 2. DB 조회 + 조회수 증가 (dirty checking으로 자동 UPDATE)
Post post = postRepository.findById(id).orElseThrow(...);
post.incrementViewCount(); // post.viewCount++
// 3. 트랜잭션 커밋 후 Redis 랭킹 점수 업데이트
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
double score = likeCount * 2.0 + viewCount;
redisTemplate.opsForZSet().add("ranking:posts", String.valueOf(id), score);
}
});
// 4. 트랜잭션 커밋 후 Redis 캐시 저장 (5분 TTL)
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
redisTemplate.opsForValue().set(cacheKey, serialized, Duration.ofMinutes(5));
}
});
return buildPostResponse(post);
}
여기서 정말 중요한 시험 함정 — 외부 시스템 갱신은 절대로 트랜잭션 안에서 하지 말 것. 캐시 저장·랭킹 업데이트·ES 인덱싱·Kafka 발행 같은 것들은 모두 afterCommit() 안에서 해야 합니다. 이유 한 줄로 — 트랜잭션이 롤백되는 경우 외부 시스템에 유령 데이터가 남거든요.
트랜잭션 안에서 캐시 갱신 (잘못된 패턴):
1. 캐시 SET
2. DB UPDATE
3. 트랜잭션 롤백 ← DB는 원상복구
→ 캐시에는 잘못된 값이 남음 (다른 요청이 잘못된 값을 읽음)
afterCommit 사용 (올바른 패턴):
1. DB UPDATE
2. 트랜잭션 커밋 OK → afterCommit 트리거 → 캐시 SET
3. 트랜잭션 롤백 → afterCommit 미실행 → 캐시 그대로
→ 일관성 유지
조회수 증가도 같은 맥락에서 안전해요. JPA dirty checking으로 post.incrementViewCount() 만 호출해도 commit 시점에 자동 UPDATE 쿼리가 나갑니다. 그 commit이 끝난 후에야 정확한 viewCount가 DB에 반영되니까, 그 시점에 랭킹 점수를 계산해야 정확합니다. commit 전에 계산하면 아직 메모리상의 값일 뿐이라 DB와 어긋날 수 있어요.
좋아요 토글 — DB UNIQUE 제약을 동시성 방패로
좋아요는 토글 동작이라 같은 사용자가 같은 게시글에 동시에 두 번 좋아요를 눌렀을 때 처리가 까다로워요. 일반적으로는 분산 락을 잡거나 SELECT FOR UPDATE 같은 비싼 기법을 쓰는데, 이 프로젝트는 더 가볍게 갑니다.
@Transactional
public void toggleLike(Long postId, Long userId) {
Post post = postRepository.findById(postId).orElseThrow(...);
Optional<Like> existing = likeRepository.findByUserIdAndPostId(userId, postId);
if (existing.isPresent()) {
// 좋아요 취소
likeRepository.delete(existing.get());
post.decrementLikeCount();
} else {
try {
likeRepository.save(Like.builder().userId(userId).postId(postId).build());
post.incrementLikeCount();
} catch (DataIntegrityViolationException e) {
// 동시 요청으로 DB UNIQUE 제약 위반 → 멱등하게 무시
return;
}
}
// 커밋 후 랭킹 점수 갱신
...
}
여기서 시험 함정이 하나 있어요. likes 테이블에 UNIQUE(user_id, post_id) 제약이 박혀 있어, 동시에 두 INSERT가 들어와도 하나만 성공합니다. 두 번째 INSERT가 DataIntegrityViolationException으로 떨어지는데, 이걸 catch해서 그냥 무시(return)하면 좋아요 한 번만 인정되고 결과가 멱등(idempotent)해져요.
시나리오: 같은 사용자가 같은 게시글에 동시에 두 번 좋아요 요청
트랜잭션 A 트랜잭션 B
SELECT (없음) SELECT (없음)
INSERT (성공) INSERT (UNIQUE 위반 → 예외)
COMMIT ROLLBACK / catch 후 정상 응답
→ 결과: 좋아요 1개 (정확)
분산 락 없이도 안전한 이유는, DB가 이미 동시성 제어 도구를 한 번 더 가지고 있기 때문이에요. 가벼우면서 정확한 패턴이라 자주 쓰입니다. 실패 케이스를 catch만 잘 처리하면 별도 락 없이 충분.
좋아요 동시성 = DB UNIQUE 제약 + DataIntegrityViolationException catch → 멱등. 분산 락보다 가볍고 안전.
댓글 — 대댓글 부모 검증
댓글은 단순한데 대댓글이 있어 한 가지 검증이 필요해요.
@Transactional
public CommentResponse createComment(Long postId, Long userId, CommentRequest request) {
if (!postRepository.existsById(postId)) throw new ...;
if (request.getParentId() != null) {
Comment parent = commentRepository.findById(request.getParentId()).orElseThrow(...);
// 부모 댓글이 같은 게시글에 속하는지 검증
if (!parent.getPostId().equals(postId)) {
throw new IllegalArgumentException("부모 댓글이 해당 게시글에 속하지 않습니다.");
}
}
// parentId가 null이면 최상위 댓글, 아니면 대댓글
Comment comment = Comment.builder()
.postId(postId).userId(userId)
.parentId(request.getParentId())
.content(request.getContent())
.build();
return commentMapper.toCommentResponse(commentRepository.save(comment));
}
여기서 시험 함정이 하나 있어요. parentId 만 받으면 부모 댓글이 어느 게시글에 속하는지 알 수 없어요. 누군가 게시글 A에 댓글 다는 척하면서 게시글 B의 댓글을 부모로 지정하면, 댓글 트리가 한 게시글을 넘어 다른 게시글까지 뻗어 가는 이상한 상황이 생깁니다. 그래서 parent.getPostId().equals(postId) 한 줄로 같은 게시글 소속인지 확인해야 합니다.
이 검증이 빠지면 데이터 무결성이 미묘하게 깨져요 — DB FK 제약은 통과하는데(부모 댓글이 실제로 존재하니까), 의미상으로는 잘못된 트리가 만들어집니다. 보안 이슈는 아니지만 데이터 품질 문제로 이어져요.
랭킹 조회 — ZREVRANGE + DB IN 쿼리
랭킹은 Redis Sorted Set으로 점수 순 상위 N개를 빠르게 가져오고, 실제 게시글 정보는 DB에서 채워 옵니다.
public List<PostSummaryResponse> getRanking(int limit) {
// 1. Redis Sorted Set에서 상위 limit개를 score 내림차순으로 조회
Set<String> postIds = redisTemplate.opsForZSet()
.reverseRange("ranking:posts", 0, limit - 1);
// 2. 순서를 유지하면서 DB에서 게시글 정보 조회
List<Long> orderedIds = postIds.stream().map(Long::parseLong).toList();
Map<Long, Post> postMap = postRepository.findAllById(orderedIds).stream()
.collect(Collectors.toMap(Post::getId, p -> p));
// 3. Redis의 순서를 유지해 응답 구성
return orderedIds.stream()
.filter(postMap::containsKey)
.map(postMap::get)
.map(postMapper::toPostSummaryResponse)
.toList();
}
ZREVRANGE의 시간복잡도는 O(log N + M) — Redis가 정렬을 항상 유지하기 때문에 수백만 건이 있어도 상위 10개를 가져오는 데 밀리초 단위로 처리됩니다. 그 후 DB에서 IN 쿼리 한 번으로 게시글 정보를 채워 오면 끝.
여기서 정말 중요한 시험 함정 — Set으로 받은 결과를 findAllById에 넘기면 순서가 깨질 수 있어요. JPA의 findAllById는 결과 순서를 보장하지 않거든요. 그래서 일단 Map으로 변환한 후, Redis가 알려준 orderedIds 순서로 다시 조립하는 한 번 더의 단계가 필요합니다. 이걸 빼먹으면 좋아요 25점인 게시글이 18점인 게시글보다 아래로 표시되는 미묘한 버그가 생겨요.
랭킹 점수 갱신은 게시글 조회 시 afterCommit에서, 좋아요 토글 시에도 afterCommit에서. 또 5편에서 다룰 Spring Batch Job이 100건씩 청크 단위로 전체 게시글의 랭킹을 주기적으로 재계산해서 정확성을 보장합니다.
시리즈 다음 편 — Notification + Kafka 이벤트 흐름
여기까지가 4편입니다. 콘텐츠부 일과 — 게시글 생성 분산 락, 캐시·랭킹의 트랜잭션 일관성, 좋아요 동시성, 대댓글 검증, 랭킹 조회 — 의 동시성 함정들을 한 줄씩 봤어요. 락이 트랜잭션을 감싸는 이유, afterCommit() 패턴, DB UNIQUE 제약을 동시성 방패로 활용하는 트릭이 핵심.
5편에서는 Notification Service + Kafka 이벤트 흐름을 줌인합니다. Outbox Pattern + Debezium CDC가 어떻게 DB 트랜잭션과 Kafka 발행을 원자적으로 묶는지, 토픽·컨슈머 그룹 설정, JsonSerializer trusted packages 같은 디테일까지 한 줄씩 풀어 갑니다.
공식 문서: Redisson 분산 락 가이드와 Spring Data Redis 공식 가이드에 이 글의 코드가 어디서 왔는지가 잘 정리돼 있어요.
시리즈 다른 편
- 1편 — 마이크로서비스 아키텍처 전체 그림
- 2편 — API Gateway JWT 검증
- 3편 — User Service · OAuth2
- 4편 — Redisson 분산 락 · 동시성 (현재 글)
- 5편 — Kafka 이벤트 흐름 · Outbox
- 6편 — Redis 4가지 활용 패턴
- 7편 — Elasticsearch + S3 업로드
시험 직전 한 번 더 — Post Service 동시성 함정 압축 노트
- Redisson
RLock= 분산 락.redissonClient.getLock("키")로 가져옴 - 락은
@Transactional메서드의 바깥에서 — 안에 두면 commit 전에 풀려 무용지물 tryLock(0, -1, SECONDS)= 즉시 실패 + Watchdog 활성화- Watchdog = Redisson 백그라운드 스레드가 10초마다 TTL 자동 갱신
unlock()호출 시 또는 서버 비정상 종료 시 Watchdog도 같이 종료 → 락 자연 해제finally에서isHeldByCurrentThread()확인 후unlock()— 다른 스레드 락 풀지 않게- Outbox Pattern = DB 트랜잭션 안에
outbox_events같이 INSERT → Debezium이 Kafka에 발행 - Outbox 안 쓰면 = DB 성공 + Kafka 실패(알림 누락) 또는 Kafka 성공 + DB 롤백(유령 이벤트)
- Debezium CDC = PostgreSQL WAL 감지 (wal_level=logical 필요) → Kafka 자동 발행
afterCommit()= 트랜잭션 커밋 직후에만 실행되는 콜백- 외부 시스템(Redis 캐시·랭킹·ES·Kafka) 갱신은 모두
afterCommit()안에서 - 트랜잭션 안에서 캐시 SET 하면 = 롤백 시 캐시에 유령 데이터 남음
- JPA dirty checking =
post.incrementViewCount()만 호출해도 commit 시 자동 UPDATE - 캐시 키 설계 =
cache:posts:{id}(TTL 5분) /cache:posts:list(이벤트 기반 삭제) - 목록 캐시는 새 글 생성 시
delete()— TTL 안 씀 (즉시 무효화) - 좋아요 동시성 = DB
UNIQUE(user_id, post_id)+DataIntegrityViolationExceptioncatch - catch한 후
return→ 멱등(idempotent), 분산 락 불필요 - 같은 사용자 같은 게시글 두 번 좋아요 동시 요청 → DB가 한 INSERT만 받아 줌
- 대댓글 검증 =
parent.getPostId().equals(postId)확인 — 빠뜨리면 게시글 넘어 댓글 트리 형성 - 랭킹 = Redis Sorted Set, 키
ranking:posts, score =likeCount × 2 + viewCount ZREVRANGE시간복잡도 O(log N + M) — 수백만 건도 밀리초 처리findAllById는 순서 보장 X → Map 변환 후 Redis 순서로 재조립 필요- 게시글 생성 락 키 =
lock:post:create:{userId}— 사용자별 락 - HTTP 409 Conflict = 락 획득 실패 (중복 생성 시도)
다음 글(5편)에서는 Notification Service + Kafka 이벤트 흐름 + Outbox Pattern + Debezium CDC를 코드 한 줄씩 따라가며 풀어 갑니다.