✏️ 서론
Study Together는 유저들이 함께 모여 공부할 수 있는 플랫폼으로, 효율적인 학습 환경을 제공하고자 기획했다. 유저가 Study Group 기능을 통해 공부 모임에 자유롭게 참여가 가능하다. 그러나, 다수의 유저가 동시에 Study Group에 참여하려고 할 때, 동시성 문제가 발생할 수 있다.
동시성 문제란, 여러 작업이 동시에 수행될 때 공유 자원에 대한 접근 순서나 처리 과정이 엉키며 데이터 일관성 및 시스템 안정성이 깨지는 현상이다.
현재 내 프로젝트에서 동시성 문제가 발생할 수 있는 지점을 파악하고, 이에 대한 해결 방안을 모색해 보았다. 짧은 과정은 아니었기에, 우선 요약을 하자면 나의 목표는 무조건적인 대규모 트래픽에 대비한 동시성 제어가 아닌, 현재 프로젝트 규모를 예측하고 오버 엔지니어링 하지 않는 제어 기법을 찾는것이었다.
그리고 이를 위해 성능 테스트 결과를 바탕으로 동시성 제어 방법을 개선해 나갔다.
초기에는 Database Lock으로 충분히 해결이 될 것이라고 생각했으나, DB 락으로 인한 성능 문제 및 DB 락 자체가 가진 한계로 인한 문제가 발생했다. 이후 Redis를 도입해 개선을 시도했으며, 최종적으로 비동기 처리 방식을 도입해 문제를 해결했다. 각 단계에서 마주한 문제들과 해결 과정을 정리해보고자 한다.
Study Group 엔티티 설계
유저들이 모인 그룹을 나타내는 StudyGroup 도메인이 존재한다. 그리고 필드로 그룹을 참여하는 사람들을 관리하기 위해 Participant 도메인을 리스트로 담은 Participants 일급 컬렉션을 사용하고 있다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StudyGroup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String groupTitle;
private int maxParticipants;
@Embedded
private Participants participants;
public StudyGroup(String groupTitle, int maxParticipants) {
this.groupTitle = groupTitle;
this.maxParticipants = maxParticipants;
this.participants = new Participants();
}
public void joinGroup(Participant participant) {
if(this.maxParticipants <= participants.getCurrentParticipantsCount())
throw new GroupCapacityExceededException("정원 초과");
participants.addParticipant(participant);
}
}
@Embeddable
@NoArgsConstructor
public class Participants {
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "studyGroup",
orphanRemoval = true)
private List<Participant> participants = new ArrayList<>();
public Participants(List<Participant> participants) {
this.participants = participants;
}
public void addParticipant(Participant participant) {
participants.add(participant);
}
public int getCurrentParticipantsCount() {
return participants.size();
}
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Participant {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne @JoinColumn(name = "study_group_id")
private StudyGroup studyGroup;
public Participant(StudyGroup studyGroup) {
this.studyGroup = studyGroup;
}
}
StudyGroup 설계
- 스터디 그룹의 정보를 담고 있는 엔티티로 그룹 이름, 최대 참여 가능 인원수, 그리고 참여자 목록이 포함된다.
- 참여자 목록은 List 형태로 직접 관리하지 않고, Participants라는 클래스를 만들어 비즈니스 규칙을 책임지도록 설계했다.
- 그룹에 새로운 유저가 참여하려면, 현재 참여 인원과 최대 정원을 비교해 초과 여부를 확인한다. 정원이 초과되면 예외를 발생시켜 추가를 막는다.
Participants
- 참여자(Participant) 객체들을 관리하는 일급 컬렉션이다.
- 참여자 수를 계산하거나 새로운 참여자를 추가하는 로직을 담당한다.
@Service
@AllArgsConstructor
public class StudyGroupJoinService {
private final StudyGroupRepository studyGroupRepository;
@Transactional
public void joinGroup(Long roomId, Long memberId) {
StudyGroup studyGroup = studyGroupRepository.findById(roomId)
.orElseThrow(() -> new StudyGroupNotFoundException(roomId));
Participant participant = new Partcipant(studyGroup.getId(), memberId);
studyGroup.joinGroup(participant);
studyGroupRepository.save(studyGroup);
}
}
서비스 동작(새로운 유저가 Study Group에 참여하는 경우)
- 그룹 ID를 사용해 StudyGroup을 조회한다.
- 조회된 StudyGroup에 새로운 Participant 인스턴스를 생성하여 추가한다.
- 마지막으로 StudyGroup을 저장한다.
참고로, 현재 내 프로젝트에선 Participant의 Repository를 별도로 선언하지 않았다. Participant의 생명주기는 부모 엔티티인 StudyGroup과 동일하다고 판단하여, 부모 클래스인 StudyGroup의 생명주기를 따르도록 CascadeType.ALL로 설정했다. 따라서, 새로운 유저가 Study Group에 참여하여도 Participant를 직접 영속화하지 않고, StudyGroup Repository에서 save를 호출하면 JPA가 변경 사항을 감지하여 Participant 테이블에 자동으로 INSERT 되도록 구현했다.
동시성 문제 발생 요소
@Service
@AllArgsConstructor
public class StudyGroupJoinService {
private final StudyGroupRepository studyGroupRepository;
@Transactional
public void joinGroup(Long roomId, Long memberId) {
StudyGroup studyGroup = studyGroupRepository.findById(roomId)
.orElseThrow(() -> new StudyGroupNotFoundException(roomId));
Participant participant = new Partcipant(studyGroup.getId(), memberId);
studyGroup.joinGroup(participant);
studyGroupRepository.save(studyGroup);
}
}
만약, 스터디 그룹 입장에 대한 여러 요청이 동시에 발생할 경우, StudyGroup 내의 최대 수용 인원을 초과하지 않도록 보장하기 어렵다.
예를 들어, 최대 입장 가능한 인원이 10명이라고 가정하고 현재 입장한 인원이 9명이라고 생각해 보자. 동시에 10건 이상의 입장 요청이 들어오면, 각 요청은 현재 인원이 9명인 Study Group 데이터를 읽어 들이게 된다. 따라서, 모든 요청에 대한 입장이 가능하게 되고, 이로 인해 참여자가 최대 정원을 초과할 수 있는 동시성 문제가 발생할 수 있다.
동시성 제어 전략 정하기
동시성을 제어하는 방법은 여러 가지가 존재한다.
- Java synchronized 키워드
- DB Lock
- Redis
- 비동기 메시징 제어
Java synchronized 키워드를 객체나 메서드에 선언하면 한 번에 하나의 스레드만 해당 코드에 접근할 수 있다. 이처럼 synchronized 사용으로 간단하게 동시성 문제가 해결되면 좋겠지만, 안타깝게도 그렇지 못하다.
synchronized 사용에 관해 자세히 정리된 글들이 많기에 해당 포스트에선 구체적으로 다루지 않겠으나, synchronized는 다음과 같은 한계를 가진다.
1. @Transactional 어노테이션과의 동작 문제
Spring @Transactional 어노테이션은 프록시 기반으로 동작하기 때문에 synchronized 키워드를 함께 사용하면 동시성 문제를 완벽히 제어하지 못한다. 메서드 호출이 프록시 객체를 통해 이루어질 때 트랜잭션이 시작되며, 메서드 실행이 끝나면 트랜잭션이 커밋 또는 롤백된다. synchronized로 스레드 하나만 실행되도록 막아도, 트랜잭션이 실제로 커밋되기 전이라면 다른 트랜잭션에서 데이터에 접근할 가능성이 있다.
2. 단일 프로세스 제한
synchronized 키워드는 하나의 프로세스 안에서만 동작하도록 보장하기 때문에 서버가 여러 대인 경우에선 사용할 수 없다.
그렇다면 이제 남은 선택지는 synchronized 키워드를 제외한 DB Lock, Redis 분산 락, 비동기 메시징 제어 세 가지가 존재한다. 현재 시스템의 트래픽 규모, 시스템 요구사항에 적합한 방식을 선택하는 것이 중요하다고 생각했다. 따라서, 타 서비스 등을 분석해 보며 트래픽 규모를 예측해 보기로 하였다.
트래픽 규모 예측
- 스터디 그룹 정원 : 하나의 스터디 그룹에 3~50명 입장 가능할 것으로 예정.
- 스터디 그룹 수 : 타 서비스 분석을 통해 현재 규모로는 100개 이내의 스터디 그룹이 존재할 것으로 가정.
- 동시 요청 수 : 스터디 그룹 입장 요청 건수를 최대치로 가정했을 때 한 시간당 약 200건 정도가 될 것으로 생각. 그러나 그중 실시간 동시 요청 발생 건은 평균적으로 3~4건, 많게는 10 ~ 15건 사이로 발생할 것으로 예상
- 대규모 트래픽 여부 : 대규모 트래픽이 아니기에, 초기에 DB Lock을 사용하여 동시성 제어가 충분히 가능할 것으로 예측.
선택한 전략
- 성능 테스트 후 개선 : DB Lock 사용으로 제어 후, 성능 테스트를 통해 병목 지점을 확인하고, 필요에 따라 다른 제어 방법과의 성능을 비교하여 개선할 계획.
Database Lock
*현재 사용 중인 데이터베이스 : MySQL InnoDB 스토리지 엔진
Database Lock(데이터베이스 잠금)이란, 트랜잭션이 데이터에 접근하거나 수정할 때 다른 트랜잭션의 접근을 제한하는 방법이다. DB Lock 기법은 두 가지가 존재한다. 바로 낙관적 락(Optimistic Lock)과 비관적 락(Pessimistic Lock)이다.
✔️ 낙관적 락(Optimistic Lock)
낙관적 락이란, 데이터가 자주 변경되지 않을 것이라고 가정하고, 쓰기 시점에 데이터가 변경되었는지 확인하는 방법이다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StudyGroup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String groupTitle;
private int maxParticipants;
@OneToMany(cascade = CascadeType.ALL, mappedBy = "studyGroup", orphanRemoval = true)
private List<Participant> participants;
@Version
private Integer version;
// ...
}
JPA에서 낙관적 락 적용을 위해선 Entity에 @Version 어노테이션이 필요하다.
@Transactional
public void joinGroup(Long roomId, Long memberId) {
StudyGroup studyGroup = studyGroupRepository.findById(roomId)
.orElseThrow(() -> new StudyGroupNotFoundException(roomId));
Participant participant = new Partcipant(studyGroup.getId(), memberId);
studyGroup.joinGroup(participant);
studyGroupRepository.save(studyGroup);
}
데이터를 수정하는 과정에서 버전을 확인하고, 현재 트랜잭션이 가진 버전이랑 다르면 충돌이 일어났다고 판단하여 트랜잭션을 rollback 한다. 충돌이 발생하지 않았다면, 버전의 숫자를 증가시시킨다.
따라서, 낙관적 잠금은 버전 불일치로 인한 충돌이 발생하면 애플리케이션 내에서 개발자가 재시도 로직을 작성해야 한다. 그러나 재시도 로직을 통해 처리하게 되면 요청 순서에 따라 처리되지 못한다. 예를 들어, 트랜잭션 A가 쓰기 작업을 수행하려다 버전 불일치로 롤백된 후, 재시도 로직이 실행되기 전에 다른 사용자의 요청으로 인해 새로운 트랜잭션 B가 먼저 실행되어 쓰기 연산이 성공할 수도 있다. 이는 요청이 순서대로 처리되어야 한다는 비즈니스 규칙을 위반하는 결과를 초래할 수 있다.
✔️ 비관적 락(Pessimistic Lock)
비관적 락이란, 데이터를 수정하기 전에 해당 데이터에 대한 다른 트랜잭션의 접근을 제한하는 방식이다.
Spring Data Jpa를 사용하고 있기에, JpaRepository에서 어노테이션을 사용하여 쉽게 비관적 락을 설정할 수 있다.
@Transactional
public void joinGroup(Long roomId, Long memberId) {
StudyGroup studyGroup = studyGroupRepository.findById(roomId)
.orElseThrow(() -> new StudyGroupNotFoundException(roomId));
Participant participant = new Partcipant(studyGroup.getId(), memberId);
studyGroup.joinGroup(participant);
studyGroupRepository.save(studyGroup);
}
비즈니스 로직을 수행하면서 SELECT... FOR UPDATE 쿼리를 실행하면, 해당 데이터에 대한 배타적 잠금이 설정되어 트랜잭션이 완료되기 전까지 다른 트랜잭션이 해당 데이터를 읽거나 수정할 수 없다. 이를 통해 여러 트랜잭션이 동일한 데이터에 동시에 접근하는 것을 제한할 수 있지만, 동시성이 감소하여 성능 저하가 발생할 수 있다. 따라서, 이는 데이터 일관성이 매우 중요한 환경에서 주로 사용된다.
따라서, 성능 테스트를 통해 비관락을 사용해도 괜찮을지 판단해 보기로 했다.
번외) 비관적 락의 순서는 보장될까?
비관적 락을 사용하더라도 트랜잭션의 순서는 반드시 보장되지 않는다. 이는 MySQL InnoDB가 CATS(Contention-Aware Transaction Scheduling) 알고리즘을 사용하여 트랜잭션 우선순위에 따라 스케줄링하기 때문이다.
CATS 알고리즘 원리
- 트랜잭션이 차단하고 있는 다른 트랜잭션의 수에 따라 스케줄링 가중치를 할당하고, 이를 기준으로 대기 우선순위를 결정한다.
- 만약 가중치가 동일하다면, 가장 오래 대기 중인 트랜잭션이 우선 처리된다.
Database Lock 성능 테스트
*서버 스펙 : mi1-g3(vCPU 1EA, Memory 1GB)
비관락 적용으로 인한 성능이 애플리케이션을 사용하는 데 있어서 어느 정도 영향을 미칠지 알아보기 위해 성능 테스트를 수행했다. 서버에 프로젝트를 올리고, Ngrinder를 사용하여 애플리케이션에 부하를 주면서 성능 테스트를 진행했다.
TPS : 12.4
Naver Cloud Platform에서 제공되는 크레디트를 사용하여 스펙이 좋지 못한 서버를 사용하고 있기에 TPS가 잘 나왔다고 할 순 없으나, 현재 내 애플리케이션에서 예측하는 트래픽 규모는 충분히 처리할 수 있을 만큼 나왔다.
그러나 비관락은 스레드 블로킹으로 인한 성능 저하, 분산 환경에 적합하지 않음, 데드락 발생 가능성, 요청 순서 비보장 등의 문제가 있다. 결국 이러한 Database Lock의 한계를 극복하고자 Redis 분산 락을 사용해 보고 성능을 비교해 보기로 했다.
Redis 분산 락
앞서 말했듯이 DB 비관락은 여러 가지 문제점들이 존재한다. 트랜잭션 간 대기 시간이 길어질 수 있고, 높은 동시성을 요구하는 환경에서는 성능 저하가 발생할 가능성이 크다. 그렇다면 이를 개선하기 위해 메모리 기반 데이터 저장소인 Redis를 활용하는 방법을 고려해 볼 수 있다.
Redis는 싱글 스레드로 동작하기에 분산환경에서도 동시성 제어가 가능하다. 또한, DB에 직접 락을 거는 방식에서 발생할 수 있는 병목 현상도 완화할 수 있을 것으로 보인다. 다만, Redis는 싱글 스레드 기반으로 동작하기에 실제로 성능이 얼마나 뛰어날지는 의문이었다.
따라서 동일한 환경에서 Redis 분산 락 적용 후 성능 테스트를 진행하고 DB 락과 비교하여 Redis를 도입하는 것이 적합한지 고민이 필요하다.
Redis로 분산 락을 구현할 때 많이 사용하는 인터페이스 중 하나는 Redisson이다. Redisson은 Redis의 Pub/Sub 기능을 활용해 락의 획득 및 해제를 효율적으로 처리할 수 있는 구조를 제공한다.
또 다른 방법으론 Lettuce가 있다. Lettuce는 스핀락 기법을 사용해 Lock을 획득한다. 락 요청 후 실패 시 일정 시간을 기다렸다가 다시 요청하는 방식으로 동작한다. 그러나 스핀락 방식이기에 반복적인 재시도를 수행하므로 높은 CPU 사용량이 발생할 수 있고 이는 락 경쟁이 심한 환경에서 성능에 부정적인 영향을 미칠 수 있다.
Redis 분산 락 성능 테스트
성능 테스트를 수행한 결과, 스핀락(Spin Lock) 기법과 Pub/Sub 기법 각각의 TPS(Transactions Per Second) 수치는 다음과 같았다.
- Spin Lock TPS : 5.0
- Pub/Sub TPS : 8.3
이는 DB 비관적 락보다도 낮은 성능을 보이는 수치였다. 따라서 새로운 접근 방식이 필요하다.
Lock에 대한 고찰
💡 Lock이 비즈니스에 반드시 필요할까?
데이터의 일관성이 매우 중요한 금융 시스템이나 돈을 다루는 비즈니스에서는 락을 통해 데이터 접근을 철저히 제한하는 것이 필수적이다. 이런 경우, 단 하나의 트랜잭션만이 특정 데이터에 접근할 수 있도록 제어해야 데이터 무결성과 정확성을 보장할 수 있다.
그러나, 현재 프로젝트의 비즈니스 요구사항 고려해 봤을 때 락을 사용하여 데이터 접근을 엄격하게 제한하는 것이 반드시 최선의 엔지니어링이 아닐 수 있다. 예를 들어, 정원이 10명인 Study Group에 11명이 입장한 상황이 발생하더라도 이것이 비즈니스에 치명적인 문제를 일으키는 것은 아니다. 정원 초과가 발생했다면, 초과된 만큼의 데이터를 삭제하기만 하면 된다. 물론, 이 과정은 유저에게 보이지 않도록 처리해야 한다.
해결 방안 1) 결과적 일관성
락을 사용하지 않고, 사용자가 입장을 요청하면 클라이언트에 일시적인 로딩 화면을 띄우고, 인원 초과 시 늦게 입장한 유저의 DB 데이터를 삭제 후 "입장 불가" 메시지를 전송하여 결과적 일관성을 유지하는 방식으로 구현할 수 있다.
사용자에게 입장 상태를 응답하는 방식으로는 SSE 혹은 웹소켓을 사용할 수 있다. 현재 내 프로젝트는 채팅 기능 구현을 위해 웹소켓이 적용된 상태다. 이미 적용된 기술을 활용하는 것도 좋은 방법이라고 생각하여 웹소켓을 통해 실시간 입장 상태를 전달하며, 클라이언트는 최종 응답 전까지 서버와 연결을 유지해 입장 결과를 실시간으로 전달받도록 했다.
🚨새로운 이슈 : 트랜잭션 동시 접근으로 인한 상태 불일치 및 입장 실패 문제
그러나, 해당 방식에서는 트랜잭션 간 간섭으로 인한 문제가 발생한다. 여러 트랜잭션이 동시에 같은 데이터에 접근하거나 변경하려 할 때 발생하는 예기치 않은 상황은 데이터의 일관성이나 정확성에 영향을 미칠 수 있다.
예를 들어, 유저 A가 정원이 다 찬 스터디 그룹에 입장하려 할 때, 동일한 시점에 해당 스터디 그룹에서 유저 B가 퇴장하면 A의 입장이 가능해야 한다. 하지만 A가 읽은 데이터는 B의 퇴장 요청이 일어나기 전인 정원이 가득 찬 스터디 그룹의 데이터이기 때문에 입장에 실패한다. 이는 공유 자원에 대한 트랜잭션 순서가 보장되지 않아 발생하며, 이를 해결하기 위해 요청을 순서대로 처리할 수 있는 구조가 필요하다.
해결 방안 2) 비동기 처리
유저의 요청을 순서대로 적재하고, 이를 비동기적으로 처리하는 구조를 설계했다. 이를 통해 동시성 문제를 해결하면서도 별도의 락을 걸지 않고 효율적으로 처리할 수 있도록 하였다.
비동기 구조를 구현하는 방법에는 여러 가지가 존재하고, 대표적으로 RabbitMQ, Kafka와 같은 외부 메시지 큐를 사용할 수 있다. 그러나 외부 인프라를 도입할 경우 운영 및 유지보수 부담 증가, 추가적인 비용 발생 등의 트레이드오프를 고려해야 한다.
현재 프로젝트에서는 메시지 큐를 도입하는 대신, 기존에 이벤트 기반 아키텍처 구현을 위해 사용 중이던 Outbox 테이블을 활용하여 스터디 그룹의 입장 및 퇴장 요청을 이벤트 기반으로 처리하도록 설계했다. 비동기 제어 방식은 시스템에 따라 다를 수 있다. 나는 메시지 큐를 도입하면 관리 포인트가 증가하고 러닝 커브에 추가적인 시간이 필요할 것으로 판단했으며, 이에 따라 기존에 사용하던 방식을 활용하는 것이 더 적절하다고 생각했다.
유저가 입장 또는 퇴장 요청을 보내면 해당 요청은 Outbox 테이블에 이벤트 형태로 저장된다. 현재 별도의 외부 메시지 큐 없이 Outbox 테이블 자체를 큐로 활용하고 있으며, 적재된 이벤트는 적재 시간 순서대로 Consumer에서 읽고 처리하도록 구현되어 있다. 이를 통해 요청이 순차적으로 처리되므로, 트랜잭션 간 간섭 문제를 방지할 수 있다.
예를 들어, 정원이 가득 찬 스터디 그룹에서 퇴장 이벤트가 먼저 처리되면, 이후 발생하는 입장 이벤트가 자연스럽게 허용되는 방식으로 동작한다. 이러한 구조는 동시성 문제를 해결하면서도 추가적인 동기화(lock) 없이 안정적인 처리를 가능하게 한다.
또한, 향후 외부 메시지 큐(RabbitMQ, Kafka 등)를 도입할 경우에도 Outbox 테이블에 저장된 이벤트를 그대로 메시지 중개인으로 발행할 수 있으므로, 구조 변경 없이 유연한 확장이 가능하다. 이를 통해 초기에는 간결한 구조로 운영하고, 시스템 규모가 커짐에 따라 확장성을 고려한 설계를 적용할 수 있다.
*Outbox 테이블에 관한 글은 기존 작성한 포스트를 참고 바란다.
'Side Project > Study Together' 카테고리의 다른 글
[Test Code] Repository 단위 테스트로 다져가는 도메인 중심 설계의 기초 (4) | 2024.10.26 |
---|---|
[Event 기반 설계] 다양한 이벤트 확장 : 이벤트 추상화와 동적 매핑 구현 방법 (2) | 2024.09.26 |
[Event 기반 설계] Transactional Outbox 패턴이 필요한 이유 : 프로젝트 적용 사례 (2) | 2024.09.26 |
[JPA] 일대다(OneToMany) 단방향 매핑의 성능 이슈 (1) | 2024.09.25 |
[JPA] JPA 적용 이유, 의존성 주입으로 유연한 Repository 설계 (1) | 2024.09.25 |