프로젝트를 진행을 하면서 인기 검색어를 구현을 하게 되었습니다. 인기 검색어는 사용자가 검색을 한 단어를 DB에 저장을 하고 그 단어가 검색이 될 때마다 카운트를 올려서 1씩 증가를 하도록 했습니다. 그런데 이 부분에서 동시성 테스트를 할 필요성을 느꼈습니다. 티켓 판매가 오픈이 되는 날이 되면 사용자들이 몰리기 때문에 동시성 문제 발생을 할 수 있다고 판단을 하였습니다. 그래서 테스트를 진행을 해보았습니다.

위에 사진처럼 동시에 접근하는 수는 8000명으로 잡았습니다. 그 이유는 K리그 평균 관중 수가 8000명 정도이기 때문입니다. 수원이라는 단어로 검색을 하였고 제가 원하는 결과는 DB의 카운트에 8000이 들어가 있는 결과였습니다.

하지만 결과는 831의 카운트만이 올라갔습니다. 즉, 동시성 문제가 발생을 한 것입니다.
그렇다면 동시성 문제는 무엇인데 8000이 동시에 요청을 했는데 831번만 카운트가 올라간 것일까요?
동시성 문제에 대해서 알아보겠습니다.
동시성 문제에 대해서 알아보겠습니다.
동시성 문제는 여러 사용자가 같은 자원이나 데이터를 사용하기 위해서 동시에 접근을 하고 수정을 하려고 할 때 발생을 하는 문제입니다.

예시를 들어보겠습니다.
모임 통장을 공유하는 사용자A와 사용자B가 있습니다. 모임 통장에는 10000원이 들어있습니다.
사용자A와 사용자B는 각각 회비로 2000원, 5000원을 냅니다. 두 사용자가 모두 회비를 내면 잔액는 17000원이 되어야 합니다. 회비를 내는 날이 되어서 둘이 동시에 내거나 사용자A가 진행을 하던 중 사용자 B가 돈을 낸다고 가정을 해봅시다.
사용자A가 먼저 현재 잔고를 조회를 합니다. 그러면 현재 잔고가 10000원이라는 것을 알게됩니다. 이때, 사용자B도 마찬가지로 회비를 내기 위해서 잔고를 조회를 합니다. 이 때, 사용자에는 자신이 내야 하는 2000원를 넣게 되고 잔고 12000원이 됩니다. 사용자B도 회비를 5000원을 내서 기존에 조회를 했던 10000원과 5000원이 합쳐저 총액이 15000원이 됩니다.
총 잔고 17000원이 15000원이 되는 문제가 발생을 합니다.
두 사용자가 같은 데이터를 동시에 접근을 했고 각자 다르게 업데이트를 하면서 발생하게 된 문제입니다.
이를 동시성 문제라고 합니다.
그렇다면 이 동시성 문제를 어떻게 해결할 수 있을까요?
동시성을 해결하는 방법에는 Synchronized, Opyimistic Lock(낙관적 락), Pessimistic Lock(비관적 락)이 있습니다. 이 방법들의 동작하는 방식에 대해서 알아보겠습니다.
첫 번째는 Sychronized라는 자바의 키워드를 사용하는 것입니다.
public class SearchKeywordService {
private int searchCount;
public synchronized void incrementSearchCount() {
searchCount++;
}
public int getSearchCount() {
return searchCount;
}
}
동작 방식을 설명을 하기 위해서 간단한 코드를 가지고 왔습니다.
incrementSearchCount 라는 메소드에 synchronized 키워드를 사용을 하고 있습니다.
이렇게 되면 synchronized 키워드가 공유 자원에 대해서 동시에 접근하는 것을 제어를 할 수 있습니다.
synchronized는 모니터 락을 사용을 해서 공유 자원을 보호를 합니다.
먼저, 작업을 수행을 하려는 스레드가 synchronized가 관리하는 임계 영역이나 메소드를 실행하려고 할 때, 락을 얻으려고 하고 얻게 되면 작업을 시작을 합니다. 작업을 시작을 하면서 다른 스레드들이 임계 영역에 접근을 하면 락이 이미 다른 스레드가 가지고 있기 때문에 대기를 하게 됩니다. 그리고 작업 중인 스레드가 작업을 마치면 락을 해제를 하고 대기 중인 다른 스레드가 작업 기회를 얻습니다.
Optimistic Lock은 트랜잭션 충돌이 발생이 되지 않은 것이라고 예상을 하고 사용하는 Lock입니다.
이 말은 접근하고자 하는 데이터에 Lock을 걸지 않고 진행을 하다가 트랜잭션 충돌이 발생을 했을 때 트랜잭션을 롤백을 시키는 방법입니다.
@Entity
@Getter
public class SearchKeyword {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String keyword;
private int searchCount;
@Version // 낙관적 락을 위한 버전 필드
private int version;
}
Optimistic Lock을 사용한 간단한 예시를 가지고 왔습니다.
이 부분은 인기 검색어의 엔티티인데 필드 중에 version이 있고 어노테이션으로 @Vesion이 있다.
이 버전을 통해서 동시성을 제어합니다. 트랜잭션이 완료가 되면 이 버전을 증가를 시켜서 이 버전을 비교해서 트랜잭션을 롤백을 시키면서 동시성을 제어를 합니다.
예시를 들어 보겠습니다.
만약 사용자A가 검색을 통해서 검색어의 카운트를 증가하려고 합니다. 이 때 버전은 1입니다.
마찬가지로 사용자B가 같은 검색어를 검색하면서 카운트를 증가를 시키려고 합니다. 이 때 버전도 1입니다.
이 때, 사용자A가 검색을 한 작업이 종료가 되면서 카운트가 올라가고 버전 또한 올라갑니다. 그래서 버전이 2가 됩니다.
사용자B가 검색을 한 작업 완료가 된 버전과 비교를 했을 때, 사용자B의 작업은 버전 1이고 현재 데이터의 버전은 2이기 때문에 버전 차이가 발생을 합니다.
이 차이로 인해서 사용자B의 작업은 롤백이 되게 됩니다.
데이터의 Lock을 거는 것이 아니라 version을 통해서 트랜잭션 충돌이 일어났을 때 롤백을 시키면서 동시성을 제어를 합니다.
Pessimitic Lock은 트랜잭션 충돌이 일어날 거라고 예상을 하고 사용을 하는 Lock입니다.
접근을 하고자 하는 데이터의 Lock을 걸어서 Lock 걸려 있다면 그 데이터에는 접근할 수 없게 만드는 방법입니다.
@Repository
public interface SearchKeywordRepository extends JpaRepository<SearchKeyword, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<SearchKeyword> findByKeyword(String keyword);
}
Pessimitic Lock을 사용을 하기 위해서는 @Lock 어노테이션을 통해서 사용을 합니다.
이 어노테이션을 사용을 하면 이 메소드를 통해서 읽거나 수정을 하는 데이터에 Lock이 걸리고 동시성을 제어를 할 수 있게 됩니다.
예시를 들어보겠습니다.
Optimistic Lock에서 사용을 했던 예시를 사용을 하겠습니다.
사용자A가 검색어를 검색을 하고 사용자B도 검색을 합니다.
사용자A가 접근을 하면 그 검색어 Lock이 걸리게 됩니다. Lock이 걸려 있기 때문에 사용자B는 이 데이터에 접근을 할 수가 없습니다.
사용자A가 작업을 마치고 Lock을 풀게 되면 그제서야 사용자B가 접근을 할 수 있게 됩니다.
Pessimitic Lock의 경우는 데이터에 Lock을 걸어서 동시성을 제어를 합니다.
프로젝트의 인기 검색어에서는 어떤 방법이 맞을까요? 한 번 각 방법들을 적용을 해보겠습니다!
@Service
@RequiredArgsConstructor
public class PopularSearchService {
private final PopularSearchRepository popularSearchRepository;
public synchronized void saveOrUpdatePopularSearch(String keyword) {
PopularSearchEntity popularSearchEntity = popularSearchRepository.findByKeyword(keyword);
if (popularSearchEntity != null) {
popularSearchEntity.update(1);
popularSearchRepository.save(popularSearchEntity);
} else {
PopularSearchEntity newPopularSearchEntity = new PopularSearchEntity(keyword, 1);
popularSearchRepository.save(newPopularSearchEntity);
}
}
public List<PopularSearchEntity> getPopularKeywords() {
return popularSearchRepository.findTop10ByOrderBySearchCountDesc();
}
}
동시성 문제가 일어나는 saveOrUpdatePopularSearch메소드에 synchronized 키워드를 붙혀줍니다.
이제 스레드가 saveOrUpdatePopularSearch메소드에 접근을 할 때는 모니터 락을 가지게 되고 동시성을 제어를 해주게 됩니다.
동시성 테스트를 하고 결과를 보겠습니다.

결과가 8000으로 동시성이 제어가 된 것을 볼 수 있습니다!
하지만 Synchronized 키워드에는 단점이 존재합니다. 바로 하나의 프로세스에서만 적용이 된다는 것입니다.
즉, 다중 서버 서버가 2대 이상일 경우 2개의 프로세스가 존재를 하기 때문에 결국 동시성을 제어를 할 수 없습니다.
현재 프로젝트는 대용량 트랙픽을 감당을 해야하기 때문에 서버의 확장 방식을 Scale Out을 사용하고 있습니다. 트래픽이 많아지면 서버의 수가 들어나는 것입니다. 이는 여러 프로세스가 생성이 된다는 것이고 Synchronized 키워드로는 동시성을 제어하는데 한계가 있습니다.
그래서 Synchronized 키워드는 프로젝트에 적합하지 않은 방식입니다.
이제 Optimistic Lock을 사용을 해보겠습니다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "PopularSearch")
public class PopularSearchEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "popular_search_id")
private Long PopularSearchId;
@Column(name = "keyword", nullable = false, unique = true)
private String keyword;
@Column(name = "search_count")
private int searchCount;
@Version
@Column(name = "version")
private int version;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDate createdAt;
public PopularSearchEntity(String keyword, int searchCount) {
this.keyword = keyword;
this.searchCount = searchCount;
}
void update (int addCount) {
this.searchCount = this.searchCount + addCount;
}
}
위에서 설명을 한 것처럼 엔티티에 version이라는 필드를 만들고 @Version을 붙여주면 됩니다.
그러면 말그대로 version이 생기고 그 version을 비교하면서 동시성을 제어를 합니다.
그러면 테스트를 해보겠습니다.

테스트를 해보니 893이라는 결과 나왔습니다.
Lock을 사용을 해서 동시성을 제어를 했는데 왜 동시성이 제어가 안 되었을까요?

만약 count라는 값에 Transaction1이 수정을 하려고 합니다. 이 때, 2, 3, 4도 같이 수정을 하려고 접근을 합니다. 이 때, Transactino1이 작업을 할 때 version이 같기 때문에 작업이 이루어집니다. 작업을 하면서 count만 변경을 하는 것이 아니라 version도 같이 변경이 됩니다. 그러면 version이 2가 됩니다.
나머지 Transaction 2, 3, 4는 version이 1이기 때문에 version이 맞지 않기 때문에 작업을 진행을 하지 못 하게 됩니다.
즉, 처음 작업에 성공을 하고 version을 바꾼 최초의 요청만을 커밋을 하기 때문입니다.
이러한 이유에서 8000명을 동시에 했을 때 841번 count가 됩니다.
물론 에러가 발생을 했을 때 재시도를 하는 로직을 짜서 어느 정도 해결을 할 수 있지만 데이터를 수정을 해야 하는 경우 우리가 원하는 결과를 못 얻을 수 있습니다.
또한 재시도 로직의 경우 출돌이 많은 경우 재시도 로직이 많이 실행이 되면 DB의 성능이 저하가 될 수 있습니다.
이러한 이유로 데이터의 수정이 많은 인기 검색어에서는 이 Optimistic Lock도 적합하지 않다라고 판단을 하였습니다.
이제 Pessimitic Lock을 사용해보겠습니다.
public interface PopularSearchRepository extends JpaRepository<PopularSearchEntity, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
PopularSearchEntity findByKeyword(String keyword);
List<PopularSearchEntity> findTop10ByOrderBySearchCountDesc();
}
Pessimistic Lock은 Repository에 사용하고자하는 메소드에 @Lock 어노테이션을 붙혀주면 됩니다.
LockModeType으로 Lock의 타입을 정해줍니다.
그러면 테스트를 해보겠습니다.

테스트 결과 8000이 나온다는 것을 알 수 있습니다.

Pessimistic Lock의 경우 메소드를 통해서 변경을 하고자 하는 데이터에 접근을 하면 아예 Lock을 걸어 버립니다. 그래서 다른 트랜잭션이 접근을 하려고 할 때, Lock으로 인해서 접근을 하지 못 하고 대기를 하게 되고 이미 작업을 수행 중이던 트랜잭션이 작업을 마치고 Lock을 풀면 대기하던 트랜잭션이 다시 Lock을 걸로 작업을 진행을 합니다.
이렇게 되면 작업을 하는 동안 다른 트랜잭션이 접근을 하지 못 하기 때문에 데이터의 일관성을 유지를 할 수 있습니다.
그래서 현재 프로젝트의 인기 검색어에는 적합한 방법이라고 판단을 했습니다.
인기 검색어는 사용자들이 검색한 양에 따라서 달라집니다. 그래서 데이터가 잘 유지가 되는 것이 중요합니다.
하지만 현재 구현을 한 인기검색어는 많은 사용자들이 동시에 접근을 했을 때, 동시성이 제어가 되지 않아 데이터가 정확하게 유지가 되지 않습니다. 그래서 동시성을 제어하는 방법인 Synchronized, Optimistic Lock(낙관적 락), Pessimitic Lock(비관적 Lock)에 대해서 알아보았습니다.
Synchronized는 동시성은 되지만 단일서버에서만 적용이 됩니다. 그래서 Scale Out으로 다중 서버가 될 수 있는 현재 프로젝트에서 적합한 방법은 아니라고 판단을 했습니다.
Optimistic Lock(낙관적 락)은 트랜잭션이 충돌이 일어났을 때 동시성을 제어를 하는데 이 판단을 version이라는 필드를 통해서 합니다. 그런데 이 version이 맞지 않은 경우 롤백을 시키기 때문에 동시성의 수 만큼 작업을 하지 못 하게 되어서 수정이 필요한 데이터에는 적합하지 않습니다. 그래서 이 Optimistic Lock도 현재의 프로젝트에는 맞지 않은 방법입니다.
Pessimitic Lock(비관적 Lock)은 트랜잭션 충돌이 일어날 것을 예상을 하고 변경을 하고자 하는 데이터에 Lock을 걸기 때문에 동시성의 수 만큼 작업을 진행을 할 수 있습니다. 또한 데이터 베이스에서 Lock을 걸기 때문에 다중 서버에서는 데이터의 동일성을 유지를 할 수 있습니다. 그래서 현재의 프로젝트의 인기 검색어에 적합한 방법이라고 판단을 하였습니다.
그래서 이 프로젝트의 인기검색어에서는 Pessimitic Lock을 사용하기로 했습니다.
그런데 Pessimitic Lock(비관적 Lock)의 단점은 없을까요?
테스트의 결과를 보면 단점을 하나 알 수 있습니다.
아래 사진은 동시성 제어하지 않았을 때 걸리는 시간입니다.

아래 사진은 Optimistic Lock으로 제어를 했을 때 걸리는 시간입니다.

아래는 사진은 Pessimitic Lock으로 제어를 했을 때 걸리는 사진입니다.
사진의 시간을 보시면 Pessimitic Lock으로 테스트를 했을 때, 확실하게 시간이 더 걸린다는 것을 알 수 있습니다. 그 이유는 무엇일까요?
위에서 설명을 했듯이 Pessimitic Lock의 경우 데이터의 Lock을 걸어서 동시성을 제어를 합니다. 그래서 Lock이 풀릴 때까지 다른 트랜잭션은 대기를 해야 합니다. 그렇기 때문에 시간이 걸리고 심한 경우 병목 현상으로 성능저하가 일어날 수 있습니다. 지금은 8000명을 대상으로 했을 때 결과인데 더 많은 사용자 몰린다면 시간은 더 늘어날 것입니다. 시간이 늘어나면 사용자가 불편함을 느낄 것입니다.
그렇다면 Pessimitic Lock처럼 데이터의 일관성을 유지를 하면서 속도를 개선하는 방법은 무엇이 있을까요?
그 방법은 다음 포스트에서 알아보겠습니다.