10. Spring/실험

02. 100명의 사용자가 동시에 요청을 하면 서버는 올바르게 처리하는가?

THE HEYDAZE 2020. 7. 8. 02:04
OS Windows 10 Home 64bit 버전 1903 (OS 빌드 18362.836)
Edit Tool IntelliJ IDEA 2019.1
FrameWork Spring Boot
DB H2
ORM JPA
Test Tool JMeter

 

시나리오

1. 한 사이트에 동시 접속자가 100명이라 가정한다.

2. 영화 좌석을 예매하기 위해 100명의 사용자가 한 곳의 자리를 동시에 예매한다고 가정한다.

(밀리초 까지 동일시간으로 맞추려면 어렵기 때문에 JMeter 를 이용하여 비슷하게 맞춰 테스트를 진행)

 

코드

SeatRestController 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import com.example.demo3.seat.application.SeatService;
import com.example.demo3.seat.domain.Seat;
import com.example.demo3.seat.domain.SeatRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
 
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
 
@RestController
@RequestMapping("/seats")
public class SeatRestController {
 
    @Autowired
    private SeatRepository seatRepository;
 
    @Autowired
    private SeatService seatService;
 
    @PostMapping()
    public ResponseEntity<?> create(@RequestBody Seat resources) throws URISyntaxException {
 
        System.out.println(resources.toString());
 
        Seat seat = seatRepository.save(seatService.save(Seat.builder()
                .xPos(resources.getXpos())
                .yPos(resources.getYpos())
                .build()
        ));
 
        URI location = new URI("/seats/" + seat.getId());
 
        return ResponseEntity.created(location).body(seat);
    }
 
    @GetMapping()
    public List<Seat> list() {
        return seatRepository.findAll();
    }
}
 
cs

 

Seat 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import lombok.*;
import javax.persistence.*;
 
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
@Table(uniqueConstraints = {
        @UniqueConstraint(columnNames = {"xpos""ypos"})
})
public class Seat {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    private Long xpos;
 
    private Long ypos;
 
 
    @Builder
    public Seat(Long xPos, Long yPos) {
        this.xpos = xPos;
        this.ypos = yPos;
    }
 
}
 
cs

 

실험 1

이미지 클릭

[첫 번째 실험 결과]

ID 가 1이여야 하는데 3으로 비정상적으로 들어간 모습을 볼 수 있다.

 

 

실험 2

 

이미지 클릭

[두 번째 실험 결과]

정상적으로 1이 들어간 모습

 

 

실험 3

이미지 클릭

[세 번째 실험 결과]

ID가 2로 비정상적으로 들어간 모습

 

 

실험 4

실험 1,2,3 은 서버를 재시작하지 않고 값만 바꿔서

JMeter 로 다시 서버에 Post 로 보낼경우

id 값은 100이 증가101 부터 시작이 된다.

똑같이 또 보내면 다음 생성되는 ID 값은 201 이상이 된다.

 

이유는 DB에 값이 있는지 체크를 하지않고 seatRepository.save() 메소드를 실행했기 때문이다

Seat 클래스 private Long id 부분에

@GeneratedValue(strategy = GenerationType.IDENTITY) 로 주었기 때문에

DBInert 오류가 발생하기도 전에 같은시간에서의 AUTO_INCREMENT 처리가 빨라

id 값이 증가 된 후오류가 발생한다

 

이를 막기위해서는 값 중복체크를 한 후 save() 메소드를 사용하여야 한다.

 

Contoller 에서 하는 일이 아니고 코드가 길어지기때문에

Service 를 추가하여 코드를 분리시킨다.

 

SeatRestController 클래스 (수정)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import com.example.demo3.seat.application.SeatService;
import com.example.demo3.seat.domain.Seat;
import com.example.demo3.seat.domain.SeatRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
 
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
 
@RestController
@RequestMapping("/seats")
public class SeatRestController {
 
    @Autowired
    private SeatRepository seatRepository;
 
    @Autowired
    private SeatService seatService;
 
    @PostMapping()
    public ResponseEntity<?> create(@RequestBody Seat resources) throws URISyntaxException {
 
        System.out.println(resources.toString());
 
        Seat seat = seatService.save(resources);
 
        URI location = new URI("/seats/" + seat.getId());
 
        return ResponseEntity.created(location).body(seat);
    }
 
    @GetMapping()
    public List<Seat> list() {
        return seatRepository.findAll();
    }
}
 
cs

 

SeatService 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import com.example.demo3.seat.domain.Seat;
import com.example.demo3.seat.domain.SeatRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
 
@Service
public class SeatService {
 
    private SeatRepository seatRepository;
 
    @Autowired
    public SeatService(SeatRepository seatRepository) {
        this.seatRepository = seatRepository;
    }
 
   public Seat save(Seat resources) {
 
        Seat tempSeat = seatRepository.findByXposAndYpos(resources.getXpos(), resources.getYpos()).orElse(null);
 
        if (tempSeat != null) {
 
            System.out.println(tempSeat.toString());
 
            if (tempSeat.getXpos().equals(resources.getXpos()) && tempSeat.getYpos().equals(resources.getYpos())) {
 
                throw new IllegalArgumentException("값 중복");
            }
        }
 
        Seat seat = Seat.builder()
                .xPos(resources.getXpos())
                .yPos(resources.getYpos())
                .build();
 
        return seatRepository.save(seat);
    }
}
 
cs

실험 4 결과 - 이미지 클릭

 

실험 5

중복체크를 했음에도 불구하고, 실험4 에서는 ID값이 증가되어있다.

 

이번 이유는 DB 에 Insert 문이 들어가기도 전에 

사용자들이 DB를 조회하여 

tempSeat 객체안에는 null (DB에 값이 존재하지않음)이 들어가게 된다.

 

예를들어 사용자 1 ~ 사용자 50 까지 null 로 들어간 경우

seatRepository.save() 메소드가 실행 할 수 있게되어

아까와 같은 문제가 발생되는 것이다.

 

이렇게 증가되는 원인을 본다면 위 그림처럼 생각된다 (필자생각)

 

멀티 쓰레드 환경을 위해 SeatService 클래스 코드를 수정한다

 

SeatService 클래스 (수정)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import com.example.demo3.seat.domain.Seat;
import com.example.demo3.seat.domain.SeatRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
 
@Service
public class SeatService {
 
    private SeatRepository seatRepository;
 
    @Autowired
    public SeatService(SeatRepository seatRepository) {
        this.seatRepository = seatRepository;
    }
 
    synchronized public Seat save(Seat resources) {
 
        Seat tempSeat = seatRepository.findByXposAndYpos(resources.getXpos(), resources.getYpos()).orElse(null);
 
        if (tempSeat != null) {
 
            System.out.println(tempSeat.toString());
 
            if (tempSeat.getXpos().equals(resources.getXpos()) && tempSeat.getYpos().equals(resources.getYpos())) {
 
                throw new IllegalArgumentException("값 중복");
            }
        }
 
        Seat seat = Seat.builder()
                .xPos(resources.getXpos())
                .yPos(resources.getYpos())
                .build();
 
        return seatRepository.save(seat);
    }
}
 
cs

 

실험5 결과 - 이미지 클릭

 

 

synchronized 미적용 - 이미지 클릭

10명의 사용자로 요청했더니 DB 조회 결과 모두 null 로 나왔다

 

synchronized 적용 - 이미지 클릭

적용 후에는 1명의 사용자만이 null 로 나왔으며

나머지 9명의 사용자들에게는 DB 에서 조회 된 값을 반환했다.

 

결론

실제로 사용자들이 밀리초 까지 똑같게 클릭 확률은 적지만,

가끔 11시 특가 할인 상품 100개 판매 같은 이벤트 할 때

지금 시나리오에서는 100명으로 설정 했지만 1000명 ~ 2000명 이상이 클릭 한다면,

이런 문제가 야기될 수 있다고 본다.

 

가장 최악에 상황을 가정한다면, DB에서 unique 설정을 하지 않았고

조회를 통해 중복검사를 하였다면, 

insert 로 들어갈 때 10명의 사용자가 전부 같은 좌석을 예매하게 된다고 볼 수 있다.

 

참고

면접에 갔을 때 6년차 된 개발자도 synchronized 의 동기 비동기의 이론만 알고있고

실제로는 어떻게 사용하는지 모르는 사람을 본 적 있었다