T_era
좋아요 기능과 동시성 문제? 본문
좋아요 기능을 개발하면서 동시에 여러 사용자가 버튼을 누를 때 발생할 수 있는 동시성 문제에 대해 고민하게 되었다. 이는 여러 스레드나 프로세스가 공유 자원에 동시에 접근하여 예상치 못한 결과를 초래하는 현상을 말한다.
동시성 문제의 종류와 좋아요 기능의 연관성
동시성 문제는 여러 형태로 나타날 수 있다.
- 경쟁 조건 (Race Condition): 여러 스레드가 동시에 공유 자원에 접근할 때, 자원의 최종 상태가 어떤 스레드가 마지막으로 접근했는지에 따라 달라지는 현상이다. 이는 예측 불가능한 결과를 초래한다.
- 교착 상태 (Deadlock): 두 개 이상의 스레드가 서로 상대방이 점유하고 있는 자원을 기다리며 무한정 대기하는 상태를 말한다. 모든 스레드가 작업을 진행할 수 없게 되어 시스템이 멈추는 결과를 초래한다.
- 자원 고갈 (Resource Starvation): 특정 스레드가 필요한 자원을 계속해서 할당받지 못해 작업을 진행할 수 없는 상태를 의미한다. 우선순위가 낮은 스레드에서 흔히 발생한다.
- 무한 대기 (Livelock): 두 개 이상의 스레드가 서로 양보하며 끊임없이 상태를 변경하지만, 실제로는 아무런 작업도 진행되지 못하는 상태를 의미한다. 교착 상태와 유사하게 시스템이 멈춘 것처럼 보일 수 있다.
좋아요 기능을 구현하면서 우려한 문제는 주로 경쟁 조건에 해당한다. 여러 사용자가 동시에 '좋아요'를 눌러 좋아요 개수가 의도와 다르게 증가하거나 감소할 가능성이 있기 때문이다.
동시성 문제 해결 방법: 락(Lock)
경쟁 조건을 해결하기 위한 대표적인 방법은 **락(Lock)**을 사용하는 것이다.
1. 비관적 락 (Pessimistic Lock)
비관적 락은 여러 트랜잭션이 무조건 동시성 문제를 발생시킬 것이라고 비관적으로 예상하여, 자원에 접근하기 전에 미리 락을 걸어두는 방식이다.
- 작동 방식: 트랜잭션이 특정 자원을 읽거나 수정할 때 해당 자원에 락을 획득한다. 이 트랜잭션이 작업을 완료하고 락을 해제할 때까지 다른 트랜잭션은 해당 자원에 접근할 수 없다.
- 장점: 데이터의 무결성을 강력하게 보장하며, 데이터 충돌이 빈번하게 발생하는 환경에서 매우 효율적이다.
- 단점:
- 동시성 저하: 락으로 인해 다른 트랜잭션이 대기해야 하므로, 시스템의 동시성 처리 능력이 감소하고 전체 처리량이 줄어들 수 있다.
- 교착 상태 가능성: 여러 락이 사용될 경우 교착 상태가 발생할 위험이 있다.
- 성능 오버헤드: 락을 획득하고 해제하는 과정에서 추가적인 비용이 발생한다.
- 예시: 데이터베이스에서 SELECT ... FOR UPDATE 구문을 사용하여 특정 레코드에 락을 걸어 다른 트랜잭션이 해당 레코드를 수정하지 못하도록 하는 경우.
2. 낙관적 락 (Optimistic Lock)
낙관적 락은 자원에 동시에 접근할 가능성은 있지만 낮다고 낙관적으로 예상하여, 접근은 모두 허용하되 커밋 단계에서 충돌 여부를 검사하는 방식이다.
- 작동 방식: 트랜잭션이 자원에 접근할 때 버전 정보도 함께 읽어온다. 작업을 완료하고 커밋할 때, 읽어온 버전과 자원의 현재 버전을 비교한다. 버전이 일치하면 수정을 허용하지만, 일치하지 않으면 충돌이 발생한 것으로 간주하고 재시도 또는 롤백을 수행한다.
- 장점: 동시성이 높아 락으로 인한 처리량 감소가 발생하지 않는다.
- 단점:
- 재시도/롤백 비용: 데이터 충돌이 자주 발생하는 환경에서는 재시도 또는 롤백이 빈번하게 발생하여 오히려 성능 저하를 초래할 수 있다.
- 복잡한 로직: 재시도 또는 롤백 로직을 직접 구현해야 하므로 코드의 복잡도가 증가할 수 있다.
- 예시: 스프링 JPA의 @Version 어노테이션을 사용하여 구현하는 방식.
좋아요 기능에 대한 락 선택 및 구현
좋아요 기능에는 위 두 가지 방법 중 낙관적 락이 더 적합하다고 판단했다.
"여러 명이 동시에 좋아요를 누를 가능성이 충분히 높지 않나?"라는 의문이 들 수 있지만, 좋아요 연산 자체는 매우 짧게 완료되는 원자적인 작업이다. 따라서 특정 순간에 수백, 수천 명이 동시에 정확히 같은 레코드를 업데이트하지 않는 이상, 충돌 발생 빈도는 생각보다 높지 않을 수 있다.
만약 비관적 락을 사용한다면, 락으로 인해 동시성이 저하되어 사용자에게 응답하는 속도가 느려질 수 있으며, 이는 기능 본연의 의도(빠른 피드백)와 맞지 않는다. 반면, 낙관적 락은 충돌이 발생했을 때만 재시도를 수행하므로 평상시에는 높은 동시성을 유지할 수 있다.
낙관적 락 구현: @Version 어노테이션과 @Retryable
스프링에서 낙관적 락은 @Version 어노테이션을 사용하여 구현할 수 있다.
package com.example.newspeed.entity;
import jakarta.persistence.*;
import lombok.Getter;
@Entity
@Getter
@Table(
name = "post_likes",
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "post_id"})
)
public class PostLike {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long likeId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@Version // 낙관적 락을 위한 버전 필드
private Long version;
public PostLike() {
}
public PostLike(User user, Post post) {
this.user = user;
this.post = post;
}
}
@Version 어노테이션이 붙은 필드는 엔티티가 수정될 때마다 JPA에 의해 자동으로 값이 증가하며, 커밋 단계에서 읽어온 버전과 현재 데이터베이스의 버전이 다를 경우 OptimisticLockingFailureException이 발생하여 충돌을 감지한다.
이러한 충돌 예외 발생 시 재시도를 구현하기 위해 스프링의 @Retryable 어노테이션을 사용할 수 있다.
@Retryable(
value = OptimisticLockingFailureException.class, // 특정 예외 발생 시 재시도
maxAttempts = 3, // 최대 3회 재시도
backoff = @Backoff(delay = 100) // 100ms 딜레이 후 재시도
)
@Transactional
public void toggleLike(Long postId, AuthUserDto authUserDto) {
Optional<PostLike> postLike = likeRepository.findByPostIdAndUserId(postId, authUserDto.getId());
Post post = postRepository.findById(postId).orElseThrow(() -> new NotFoundException("Not Found Post"));
if (postLike.isPresent()) {
PostLike postLikeEntity = postLike.get();
// likeRepository.updateLikeStatus(post, false); // 좋아요 수 감소 (원자적 연산으로 대체)
deletePostLike(postLikeEntity);
} else {
// likeRepository.updateLikeStatus(post, true); // 좋아요 수 증가 (원자적 연산으로 대체)
createPostLike(postId, authUserDto);
}
// 좋아요 수 업데이트는 별도의 원자적 연산 메서드(updatePostLikeCount)로 처리
updatePostLikeCount(post, postLike.isPresent() ? -1 : 1);
}
@Retryable 어노테이션은 OptimisticLockingFailureException과 같은 특정 예외가 발생했을 때 지정된 횟수만큼 (여기서는 3회) 재시도를 수행하고, 각 재시도 사이에 지연 시간(여기서는 100ms)을 설정할 수 있게 해준다.
원자적 연산 (Atomic Operations)을 통한 성능 및 안정성 강화
원자적 연산이란 더 이상 분해될 수 없는 최소 단위의 연산으로, 중간에 다른 스레드에 의해 방해받지 않고 한 번에 완료됨을 보장하는 연산을 의미한다. 좋아요 기능에 원자적 연산을 추가하면 다음과 같은 장점을 얻을 수 있다.
1. 네트워크 통신량 감소 및 성능 향상
일반적인 낙관적 락 (애플리케이션 단에서 조회 후 업데이트)은 '조회 -> 계산 -> 업데이트'의 세 단계를 거친다. 하지만 데이터베이스의 원자적 연산을 활용하면, 애플리케이션에서 현재 값을 조회할 필요 없이 데이터베이스에게 직접 값의 증가/감소를 지시할 수 있다.
- 일반적인 방식:
- 데이터 조회 (현재 좋아요 수 N, 버전 V)
- 애플리케이션에서 좋아요 수 N+1로 계산
- 데이터 업데이트 (N+1로 업데이트, 버전 V+1로 업데이트, 조건: 기존 버전이 V일 경우)
- 원자적 연산 방식:
- 애플리케이션에서 업데이트 쿼리 실행 (예: UPDATE post SET user_like_count = user_like_count + 1, version = version + 1 WHERE post_id = ? AND version = ?)
이렇게 하면 데이터를 조회하는 한 번의 왕복(Round Trip) 네트워크 통신을 줄일 수 있어 전반적인 성능이 향상된다. 특히 트랜잭션의 수가 많고 짧게 완료되어야 하는 경우에 이점이 크다.
2. 레이스 컨디션 발생 가능성 감소
데이터베이스의 원자적 연산을 활용하기 때문에, 데이터베이스는 내부적으로 UPDATE 문을 처리할 때 해당 로우에 잠금을 걸고 연산을 수행한다. 따라서 여러 트랜잭션이 동시에 좋아요 수를 증가시키는 연산을 시도하더라도, 중간에 다른 트랜잭션이 끼어들어 잘못된 값을 만들 위험이 없다. 데이터베이스 수준에서 이 연산의 원자성을 보장한다.
3. OptimisticLockingFailureException 발생 감소
만약 좋아요 기능처럼 단순히 숫자를 증가시키는 것이 목적이라면, @Version 필드와 함께 원자적 연산으로 UPDATE 쿼리를 직접 실행하는 방식을 사용하면 OptimisticLockingFailureException 발생 빈도를 줄일 수 있다.
@Version 필드를 쿼리에 포함시키면, UPDATE 쿼리의 WHERE 절에 버전 조건을 함께 넣어 충돌을 감지한다. 이때 데이터베이스에서 직접 숫자를 증가시키는 원자적 연산(SET count = count + 1)과 함께 버전 필드도 증가(SET version = version + 1)시키면, 쿼리 자체는 성공하지만 WHERE 절의 버전 조건이 일치하지 않아 영향받은 행(rows affected) 수가 0이 될 수 있다. 이 0이라는 결과를 통해 충돌을 간접적으로 감지하고 재시도 로직을 적용할 수 있다.
QueryDSL을 활용한 원자적 연산 예시:
// PostRepository (또는 Custom PostRepository Impl)
public void updateLikeStatus(Post post, Boolean status) {
QPost qPost = QPost.post;
JPAUpdateClause updateClause = queryFactory.update(qPost).where(qPost.postId.eq(post.getPostId()));
if (status) {
updateClause.set(qPost.userLikeCount, qPost.userLikeCount.add(1));
} else {
updateClause.set(qPost.userLikeCount, qPost.userLikeCount.subtract(1));
}
// 중요: @Version 필드를 함께 업데이트하여 낙관적 락 메커니즘이 작동하도록 한다.
// 만약 PostLike 엔티티가 아닌 Post 엔티티에 직접 좋아요 수가 있다면, 해당 Post 엔티티의 @Version 필드를 업데이트.
// updateClause.set(qPost.version, qPost.version.add(1)); // Post 엔티티에 @Version 필드가 있다면 추가
long affectedRows = updateClause.execute(); // 영향을 받은 행의 수 반환
// 만약 affectedRows가 0이라면, WHERE 절 조건이 일치하지 않았다는 뜻이므로 충돌로 간주하고 재시도 로직을 트리거할 수 있다.
// 이는 @Version 필드를 쿼리에 명시적으로 포함했을 때 OptimisticLockingFailureException 대신 사용될 수 있는 방식이다.
if (affectedRows == 0) {
// 충돌 처리 로직 (예: OptimisticLockingFailureException을 다시 던지거나 재시도 트리거)
throw new OptimisticLockingFailureException("좋아요 업데이트 중 버전 충돌이 발생했습니다.");
}
}
좋아요 버튼을 눌렀을 때 데이터베이스에서 직접 좋아요 수를 증가/감소시키는 원자적 연산을 수행하도록 코드를 작성하는 것은 동시성 문제를 효율적으로 해결하는 강력한 방법이다. @Version을 통한 낙관적 락과 함께 원자적 연산을 적용하면, 좋아요 기능의 동시성 문제를 현저히 낮추고 안정성을 크게 향상시킬 수 있다.
'Programing > Spring' 카테고리의 다른 글
| API 로깅 Interceptor VS AOP (1) | 2025.06.05 |
|---|---|
| N+1 문제와 페치 조인 적용해 해결하기 (1) | 2025.06.02 |
| 스프링 시큐리티 인증 및 인가 흐름 (0) | 2025.05.26 |
| 쿼리를 직접적으로 작성하는 건 아쉬워서 DSL을 사용해봤다 근데 왜 업데이트가 반영이 안되지? (1) | 2025.05.23 |
| 매번 인증 코드를 넣어야하는데 이를 하나로 해결해주는 ArgumentResolver를 사용해보자 (0) | 2025.05.22 |