솔직히 동시성 이슈 이런건 남 일이라고 생각했습니다.
동일한 자원에 짧은 시간 간격으로 들어오는 요청 같은 건 솔직히 사용자가 아주 많은 상황에서나 발생할 거라고 생각했었다.
근데, 데모 버전 발표회 당일날 발표장에서 그런 이슈를 맞이할 거라고는 예상하지 못했다..
더블 클릭 이슈
아주 짧은 시간 간격으로 동일한 요청이 오는 상황에서는 동시성 문제가 터질 수 있다.
다만, 간과한건 이런 문제는 사용자가 많은 상황뿐만 아니라 한 사용자가 같은 요청을 짧은 시간에 여러 번 할 때도 발생할 수 있다.
더블 클릭 이슈가 이런 예시이다.
팔로우 신청 기능에서 발생한 문제..!
발표 행사장에 네트워크 환경이 좋지 않아, 사용자들이 버튼을 연타하는 상황이 빈번했다.
사용자들끼리 팔로우 신청하는 기능이 있었는데, 이 기능을 사용하는 도중 이미 진행 중인 신청이 중복으로 DB에 저장되는 이슈가 있었다.
문제 원인
@Transactional
public FriendResponseDto requestFriend(FriendRequestDto friendRequestDto, String accessToken) {
Authentication authentication = getAuthByAccessToken(accessToken);
User to_user = userRepository.findByEmail(friendRequestDto.getEmail())
.orElseThrow(CUserNotFoundException::new);
User from_user = userRepository.findById(Long.parseLong(authentication.getName()))
.orElseThrow(CUserNotFoundException::new);
// 어플리케이션에서 이미 진행중인 요청이 있는지 체크
if (friendRepository.findByRequest(from_user, to_user, Status.ONGOING).isPresent() ||
friendRepository.findByRequest(from_user, to_user, Status.ACCEPT).isPresent()) {
throw new CFriendRequestOnGoingException();
}
// 생략
}
어플리케이션 layer에서 이미 진행되고 있는 요청이 있는지 검사하는 로직이 있으니까, 이것만으로 충분히 방지될 것이라고 생각한 것이 오산이었다. 애초에 repository에 자원이 있는지 조회하고, 없으면 자원을 생성하는 트랜잭션인데, 두 트랜잭션이 동시에 이 작업을 수행하게 되면 두 트랜잭션 모두 조회해오는 시점에서 해당 자원이 존재하지 않는다고 판단할 것이기 때문에 문제가 발생했다.
동시성 문제를 해결할 수 있는 여려 방법들
동시성 문제는 발생 원인과 발생 장소 등에 따라서 다양한 해결법이 있다. 일반적으로 알려진 해결책은 다음과 같다.
- java 어플리케이션에서 해결
- synchronized: 해당 메소드를 동기적으로 동작하도록 구성하는 방법
- DB에서 해결
- 낙관락: 수정할 때 version이나 timestamp 등을 통해 먼저 이 값을 수정했다고 명시하여 다른 요청이 동일한 조건으로 수정할 수 없게 하는 방법
- 비관락: 수정하고자 하는 row에 lock을 걸어 다른 lock이 걸려 있지 않을 경우만 수정이 가능하도록 만드는 것
- 외부 기술을 도입하여 해결
- redis 분산락: 요청 앞 단에 redis를 조회하여 먼저 lock을 획득 후, lock을 획득한 요청만이 제한된 자원에 접근하도록 하는 것
구글링 해보면 동시성 문제의 경우, 위의 해결책들이 대부분 조회가 되는데, 우리 프로젝트에서 발생한 문제 상황을 해결하기에는 적합하진 않다는 판단이 들었다. 그 이유는..
- 동일한 자원에 여러 thread가 동시에 접근하는 상황이 아니라, 동시에 여러 thread가 똑같은 종류의 자원을 생성하려고 시도하는 상황이다. 따라서 이미 존재하는 자원에 대해서 lock을 고려하는 낙관락이나 비관락은 해결책이 되기 힘들다.
- synchronized 키워드를 메소드에 붙일 경우, 모든 팔로우 요청이 동기적으로 동작하게 된다. 즉, A라는 유저가 B라는 유저에게 빠르게 따당 들어오는 팔로우 요청만 막으면 될 것을 C가 D에게 팔로우 요청을 하는 전혀 상관 없는 요청들까지 다 줄 세우기 할 필요는 없다.
- 그나마 redis가 적절한 해결책이겠으나, 배보다 배꼽이 큰 상황이라 판단했다. 외부 자원을 도입한하는 것은 추가 개발 시간도 들어가지만 유지 보수해야되는 입장에서는 관리 포인트가 하나 더 늘어나는 것이다. 그 정도의 가치가 있는 기능이라면 적극 추친해야겠으나, 팔로우 기능이 재고처리나 결제 등의 정확한 데이터 유지가 필수적인 기능도 아니거니와 그렇게 빈번하게 생길 것이라 예상되는 오류는 아니다 라는 판단이었다.
그리하여 이렇게 손을 좀 봤습니다.
먼저, 클라이언트 앱에서 해당 버튼이 더블클릭 되지 않도록 수정했다.
빈번하게 호출되지 않을만한 상황이라 판단하여, db 단에서 처리할 수 있도록 구현했다.
request_from 과 request_to 칼럼을 묶어 unique key를 만들어줌으로써 중복된 insert 요청을 방지했다.
@Entity
@Table(name="Friends", unqueConstraints = { // unique 제약 조건 추가
@UniqueConstraint(
columnNames = {"request_from", "request_to"}
)
})
public class Friend extends BaseEntity {...}
여러 칼럼을 묶어서 하나의 unique key로 만들 수 있다.
좀 맥이 빠지는 해결책이긴 하나 프로젝트의 상황과 여러 제한 조건들을 고려했을 때, 오버하지 않고 적용할 수 있는 방법이었다고 생각한다.
개인적으로는 개발하면서 생각지도 못 했던 오류들을 배포하고 운영하는 과정에서 발견했다는 것이 신기했던 경험이었다.
'Project > Voice Pocket' 카테고리의 다른 글
[Voice Pocket] 강한 결합을 가진 로직을 느슨하게 풀어보자(feat. spring event) (0) | 2024.03.05 |
---|---|
[Voice Pocket] Message Queue 뒷 단으로 보낸 작업의 결과를 확인하는 법 (0) | 2024.03.01 |
[Voice Pocket] TTS 작업 속도 개선기 (0) | 2024.03.01 |
[Voice Pocket] spring boot에서 celery 활용하기 (1) | 2024.03.01 |
[Voice Pocket] 프로젝트 소개 (0) | 2024.02.29 |