회사 공고나 백엔드 개발자로써 성장하다 보면 가장 많이 듣게되는 부분 중 하나가 동시성 제어다. 특히 자바 개발자는 스프링의 멀티 쓰레드 특성상 해당 부분을 해결할 능력을 갖추는 것이 필수이다.
여러 쓰레드가 동시에 요청을 처리하는 프로세스를 순서대로 처리되도록 개발자가 처리해주면 되는 것이다.
동시성을 처리하기 전에 환경, 프로젝트 규모를 먼저 고려해야 한다.
작은 프로젝트(=1개의 서버만 운영)
작은 프로젝트라면 하나의 서버에서 처리하고 있기 때문에 서버 내에서 java의 Lock이나 Synchronize를 통해 처리할 수 있다.
큰 프로젝트(=여러개의 서버 운영 =분산 시스템)
프로젝트 당 쓰레드도 여러개지만 서버 자체도 여러개라 Lock을 처리해줄 외부 설정이 필요함. DB락이나 Redis락을 사용하여 처리할 수 있다.
선착순 30명 이벤트를 개최했다.
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class RequestDto {
private String name;
private int number;
public MemberEntity toModel() {
return MemberEntity.builder()
.number(number)
.build();
}
}
@Table(name = "MEMBER")
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Setter
@Entity
public class MemberEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long memberId;
private int number;
}
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class NormalService {
private final MemberRepository memberRepository;
@Transactional
public void post(RequestDto requestDto) {
List<MemberEntity> all = memberRepository.findAll();
// 선착순 30명
if (all.size() >= 30) {
return ;
}
memberRepository.save(requestDto.toModel());
}
}
@SpringBootTest
class NormalServiceTest {
@Autowired
private NormalService normalService;
// 동시성 제어 테스트 코드
@Test
public void testConcurrentReservation() throws InterruptedException {
int totalThreads = 100;
int perThread = 1;
CountDownLatch latch = new CountDownLatch(totalThreads);
ExecutorService executorService = Executors.newFixedThreadPool(totalThreads);
for (int i = 0; i < totalThreads; i++) {
final int finalI = i;
executorService.submit(() -> {
try {
for (int j = 0; j < perThread; j++) {
RequestDto request = RequestDto.builder()
.number(finalI)
.build();
normalService.post(request);
}
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
}
}
선착순 이벤트 30명이 제한인데 우리는 39명을 통과시켜버렸다... 이제 너님의 월급에서 9명의 이벤트 당첨자 액수만큼 결제하면 된다...
java에서 간편하게 제공하는 Synchronize로 문제를 해결해보자.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SynchronizeService {
private final MemberRepository memberRepository;
@Transactional
public synchronized void post(RequestDto requestDto) {
List<MemberEntity> all = memberRepository.findAll();
// 선착순 30명
if (all.size() >= 30) {
return ;
}
memberRepository.save(requestDto.toModel());
}
}
이전 문제가 발생한 소스에서 메서드에 synchronized
만 붙여주면 끝이다.
@SpringBootTest
class SynchronizeServiceTest {
@Autowired
private SynchronizeService synchronizeService;
// 동시성 제어 테스트 코드
@Test
public void testConcurrentReservation() throws InterruptedException {
int totalThreads = 100;
int perThread = 1;
CountDownLatch latch = new CountDownLatch(totalThreads);
ExecutorService executorService = Executors.newFixedThreadPool(totalThreads);
for (int i = 0; i < totalThreads; i++) {
final int finalI = i;
executorService.submit(() -> {
try {
for (int j = 0; j < perThread; j++) {
RequestDto request = RequestDto.builder()
.number(finalI)
.build();
synchronizeService.post(request);
}
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
}
}
이제 아무리 요청해도 30명만 받게되었다.
synchronized는 java method에 붙여만 주면 해결되므로 엄청 간단하다. 하지만 단점으로는 method 전체가 lock이 걸리므로 성능이 저하된다는 점이다.
Lock class를 사용하여 동시성을 제어할 수 있다. 위에서 synchronized와는 달리 로직에서 제어하므로 성능 부분에서 이득을 가져올 수 있다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class LockService {
private final MemberRepository memberRepository;
@Transactional
public void post(RequestDto requestDto) {
Lock lock = new ReentrantLock();
try {
lock.lock();
List<MemberEntity> all = memberRepository.findAll();
// 선착순 30명
if (all.size() >= 30) {
return ;
}
} finally {
// 에러가 나던 어찌 됐던 무조건 lock 풀어야 함.
lock.unlock();
}
memberRepository.save(requestDto.toModel());
}
}
서비스 로직 중 우리가 순차적으로 적용되어야 하는 부분은 지금까지 참여자가 30명이 넘었냐는 부분이다. 그래서 해당 부분에 lock을 걸어주고 이후 로직에서는 lock이 필요 없다. 이렇게 필요한 부분에만 lock을 걸어줄 수 있다.
테스트는 service만 변경될 뿐 동일하다.
@SpringBootTest
class LockServiceTest {
@Autowired
private LockService lockService;
// 동시성 제어 테스트 코드
@Test
public void testConcurrentReservation() throws InterruptedException {
int totalThreads = 100;
int perThread = 1;
CountDownLatch latch = new CountDownLatch(totalThreads);
ExecutorService executorService = Executors.newFixedThreadPool(totalThreads);
for (int i = 0; i < totalThreads; i++) {
final int finalI = i;
executorService.submit(() -> {
try {
for (int j = 0; j < perThread; j++) {
RequestDto request = RequestDto.builder()
.number(finalI)
.build();
lockService.post(request);
}
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
}
}
우리는 이제 어떤 이벤트던 어떤 등록이던 로직면에서는 동시성을 제어해줄 수 있을것 같다. 하지만 아니다
그 이유는 위에 동시성 처리 로직들은 하나의 Spring 서버에서만 유효하다. 어느정도 규모가 있는 회사라면 대부분 분산 시스템으로 서버를 관리하고 있을 것이다.
MSA 아키텍처라면 더더욱 그럴 것이고 LB를 사용하여 여러 서버를 운영하고 있을 수도 있다. 이러한 분산 시스템에서는 위에 로직은 그저 실수를 1개정도 줄여줄 뿐 여러 요청에 대해 동시성을 처리할 수 없다는 것은 같다.
그래서 우리가 사용할 수 있는 동시성제어는 DB락
Redis락
과 같이 외부 시스템을 사용하여 락을 걸어줄 수 있다. 이 부분은 다음 포스트에서 같이 진행해보자!