개요
프로젝트를 진행하던 도중 실수로 유저가 동시에 버튼을 두 번 연속으로 누를 경우에 회원가입이 두번 발생하는 현상이 있다는 것을 알게되었다.
이런 문제를 해결하는 방법을 찾아보던 중 동시성 제어를 해서 두 개의 스레드가 동시에 임계 구역으로 진행하지 못하도록 세마포 역할을 하는 무언가가 있어야한다는 것을 알게되었고 해당 내용을 테스트 해보았습니다.
자바의 동시성 제어
여러 스레드가 하나의 자원을 공유해서 레이스 컨디션이 발생하는 것을 방지해야 합니다. 자바에서는 synchronized 키워드를 통해서 메서드에 대한 동시성을 제어할 수 있었습니다.
📌 synchronized 키워드
오라클에서 제공하는 synchronized 키워드에 대한 설명을 보면 다음과 같습니다.
- First, it is not possible for two invocations of synchronized methods on the same object to interleave. When one thread is executing a synchronized method for an object, all other threads that invoke synchronized methods for the same object block (suspend execution) until the first thread is done with the object.
- Second, when a synchronized method exits, it automatically establishes a happens-before relationship with any subsequent invocation of a synchronized method for the same object. This guarantees that changes to the state of the object are visible to all threads.
번역하면 다음과 같습니다.
첫째, 동일한 객체에 대한 두 개의 synchronized 메서드 호출이 교차되는 것은 불가능하다. 첫 번째 스레드가 synchronized 메서드를 실행할 때, 다른 모든 스레드는 첫 번째 스레드가 종료할 때 까지 대기합니다.
둘째, synchronized 메서드가 종료될 때마다 자동으로 이전 호출과의
happens-before
관계가 설정됩니다.
여기서 happen-before 관계는 하나의 스레드가 임계 구역에 있다면 다른 스레드는 해당 임계구역을 이미 들어가 있는 스레드가 나올 때 까지 기다려야 한다는 관계이다.
- 내가 이해하기에는 세마포어처럼 먼저 임계구역으로 진입한 스레드가 해당 임계구역의 락을 풀때까지 기다려야 한다는 의미인 것 같다.
테스트에 사용할 코드는 다음과 같습니다.
📌 Entity, DTO
@NoArgsConstructor @AllArgsConstructor
@Entity @Getter @Builder
public class SampleEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String name;
private int count;
public void increaseCount(){
this.count++;
}
public static SampleDTO toDTO(SampleEntity sampleEntity){
return SampleDTO.builder()
.id(sampleEntity.getId())
.name(sampleEntity.name)
.count(sampleEntity.count).build();
}
}
@ToString
@Builder @Getter
@AllArgsConstructor
public class SampleDTO {
private Long id;
private String name;
private int count;
public static SampleEntity toEntity(SampleDTO sampleDTO){
return SampleEntity.builder()
.name(sampleDTO.name)
.count(sampleDTO.getCount()).build();
}
}
📌 Service, DAO
@Service
public class SampleServiceImpl implements SampleService {
@Autowired
private SampleDAO sampleDAO;
@Override
public SampleDTO createSample(SampleDTO sampleDTO) {
return SampleEntity.toDTO(
this.sampleDAO.createSample(
SampleDTO.toEntity(sampleDTO)));
}
@Override
public SampleDTO readById(Long id) {
return SampleEntity.toDTO(this.sampleDAO.readById(id));
}
@Override
public void increaseCount(Long id) {
SampleEntity sampleEntity = this.sampleDAO.readById(id);
sampleEntity.increaseCount();
this.sampleDAO.createSample(sampleEntity);
}
}
@Repository
public class SampleDAOImpl implements SampleDAO {
@Autowired
private SampleRepository sampleRepository;
@Override
public SampleEntity readById(Long id) {
return this.sampleRepository.findById(id).orElseThrow(RuntimeException::new);
}
@Override
public SampleEntity createSample(SampleEntity sampleEntity) {
return this.sampleRepository.save(sampleEntity);
}
}
📌 테스트 코드
@Test
public void increaseCount(){
// given
this.service.createSample(
SampleDTO.builder().name("first sample").count(0).build()
);
// when
this.service.increaseCount(1L);
// then
SampleDTO sampleDTO = this.service.readById(1L);
assertThat(sampleDTO.getCount()).isEqualTo(1);
}
먼저 작성한 코드가 성공적으로 작동하는지 테스트하기 위해 새로 만든 엔티티의 카운트를 1만큼 올리는 테스트를 했습니다.
동시성 테스트
하나의 스레드가 접근했을 때 성공적으로 동작하는 것은 올바른 경우입니다. 하지만 처음에 말했던 것 처럼 로그인 버튼을 두 번 누르거나 여러번의 요청이 온다면 레이스 컨디션이 발생할 수 있습니다.
@Test
public void increaseCount() throws InterruptedException {
// given
this.service.createSample(
SampleDTO.builder().name("first sample").count(0).build()
);
int thread = 200;
ExecutorService executorService = Executors.newFixedThreadPool(100);
CountDownLatch countDownLatch = new CountDownLatch(thread);
// when
for(int i=0; i<thread; i++){
executorService.submit(() ->{
this.service.increaseCount(1L);
countDownLatch.countDown();;
});
}
countDownLatch.await();
// then
SampleDTO sampleDTO = this.service.readById(1L);
assertThat(sampleDTO.getCount()).isEqualTo(thread);
}
200개의 스레드가 모두 count 값을 증가시키도록 만들었습니다.
만약 모든 스레드가 레이스 컨디션 없이 카운트 값을 증가시켰다면 count는 200이 되어야 합니다.
하지만 다음 처럼 5번 밖에 수행되지 못했습니다.
Java Synchronized 키워드 적용
다음처럼 increaseCount 메서드에 동기화를 적용시켜 주고 테스트를 다시 진행해보았습니다.
@Override
public synchronized void increaseCount(Long id) {
SampleEntity sampleEntity = this.sampleDAO.readById(id);
sampleEntity.increaseCount();
this.sampleDAO.createSample(sampleEntity);
}
성공적으로 카운트는 200이 되었고 임계 구역에 대한 락이 성공적으로 걸렸습니다.
결론
자바에서 간단하게 synchronized 키워드를 적용해서 메서드를 스레드에 세이프하도록 만드는 방법을 보았습니다.
하지만 이 방식은 데이터베이스를 공유하는 서버가 있을 경우 다른 서버에 까지는 영향을 미치지 못하기 때문에 또다시 동시성에 대한 문제가 발생합니다.
이 경우 데이터베이스에 대한 락을 걸어주어야 합니다.
다음 포스트에서는 Spring Data JPA를 이용해서 데이터베이스에 락을 거는 방법을 알아보도록 하겠습니다.
'Spring' 카테고리의 다른 글
[Spring WebSocket] Spring WebSocket에서 STOMP를 사용해보자~~ (0) | 2024.12.09 |
---|---|
[Spring Boot] 등록 요청의 중복 방지하기, 멱등성 보장 (0) | 2024.08.01 |
[Spring Boot] Spring Boot에서 JPA QueryDSL 적용 방법 (0) | 2024.04.17 |
[Spring Boot] 도커 컨테이너에 환경에서 application.yml 민감한 정보 환경변수로 묶어내기 (0) | 2024.04.04 |
[Spring] 스프링 프로젝트에 카카오 로그인, 회원가입 구현 - (2) (0) | 2024.03.31 |