2024.09.22 - [Spring] - [Spring] synchronized 키워드를 활용한 동시성 문제 해결 및 한계 - 1
[Spring] synchronized 키워드를 활용한 동시성 문제 해결 및 한계 - 1
동시성 처리우리가 웹 서비스를 개발하다보면 수많은 종류의 동시성 문제를 만날 수 있다.주문을 도메인으로 갖는 서비스에서 상품 재고 동시성 처리선착순 쿠폰에 관한 동시성 처리한정판 등
hdbstn3055.tistory.com
우리는 이전 포스팅에서 아래 내용까지 살펴보았다.
분산 환경에서의 synchronized 한계
synchronized 키워드는 단일 인스턴스 상에서 멀티 쓰레드가 Critical Section에 접근하는 경우 동시성 제어가 가능하다.
하지만, 우리는 SPOF(단일 장애점 실패), 트래픽 부하 분산, 등 여러가지 이유로 서버 자체를 분산시켜 구성한다.
그럼 이때도 synchronized는 우리가 원하는대로 동작할까?
예상할 수 있겠지만 원하는대로 동작하지 않을 것이다.
실제 서비스 배포 시 대부분의 경우에 여러 인스턴스를 띄울 것이다.
(특히, 예매/선착순 시스템처럼 많은 유저의 접속이 필요한 서비스일 경우 더더욱)
그렇기에 `synchronized` 키워드를 사용해 처리를 해도 어차피 다른 인스턴스에서 동시에 접근하면 의미가 없다.
실습과 함께 알아보자.
실습
간단하게 로컬환경에서 `Docker`를 활용해 `WAS` 2대를 띄우고 `NGINX`를 통해 로드밸런싱을 적용하자.
이를 통해 `synchronized`만으로는 동시성 처리가 되지 않음을 확인하자.
application.yml
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://database:3306/ticket
username: root
password: 1234
jpa:
open-in-view: false
hibernate:
ddl-auto: create
properties:
hibernate:
default_batch_fetch_size: 100
---
spring:
config:
activate:
on-profile: server1
server: server1
---
spring:
config:
activate:
on-profile: server2
server: server2
Dockerfile
Dockerfile-server1
FROM amazoncorretto:17-alpine as corretto-jdk
RUN apk add --no-cache binutils
RUN jlink \
--add-modules ALL-MODULE-PATH \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=2 \
--output /jre
FROM alpine:latest
ENV JAVA_HOME=/jre
ENV PATH="${JAVA_HOME}/bin:${PATH}"
COPY --from=corretto-jdk /jre $JAVA_HOME
COPY build/libs/*.jar app.jar
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=80.0", "-Dspring.profiles.active=server1", "-jar", "/app.jar"]
Dockerfile-server2
FROM amazoncorretto:17-alpine as corretto-jdk
RUN apk add --no-cache binutils
RUN jlink \
--add-modules ALL-MODULE-PATH \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=2 \
--output /jre
FROM alpine:latest
ENV JAVA_HOME=/jre
ENV PATH="${JAVA_HOME}/bin:${PATH}"
COPY --from=corretto-jdk /jre $JAVA_HOME
COPY build/libs/*.jar app.jar
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=80.0", "-Dspring.profiles.active=server1", "-jar", "/app.jar"]
Nginx conf
upstream backend {
server was1:8080;
server was2:8080;
}
server {
listen 80;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
docker-compose.yml
version: "3"
services:
was1:
build:
context: .
dockerfile: Dockerfile.server1
container_name: was1
restart: on-failure
ports:
- "8080:8080"
networks:
- application
was2:
build:
context: .
dockerfile: Dockerfile.server2
container_name: was2
restart: on-failure
ports:
- "8081:8080"
networks:
- application
database:
image: mysql:8.0.33
container_name: database
restart: always
ports:
- "13306:3306"
environment:
MYSQL_ROOT_PASSWORD: "1234"
MYSQL_DATABASE: "ticket"
TZ: "Asia/Seoul"
LANG: "C.UTF_8"
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --skip-character-set-client-handshake
networks:
- application
nginx:
image: nginx:1.21.5-alpine
container_name: nginx
ports:
- "80:80"
volumes:
- ./app.conf:/etc/nginx/conf.d/default.conf
depends_on:
- was1
- was2
networks:
- application
networks:
application:
external: true
로드밸런싱 테스트
실제로 로드밸런싱이 잘 되는 것을 확인할 수 있다.
- Controller 자체에 `visitCount`를 두어 `server1`과 `server2`의 인스턴스에 각각 적용된다.
TicketV5Service API 로직
아까 말한 `synchronized` 키워드가 적용된 로직이 분산 환경에서 제대로 동작하는지 확인하기 위한 `TicketV5Service`이다.
TicketApi
@RestController
public class TicketApi {
private final Environment environment;
private final TicketV5Service serviceV5;
private int visitCount = 0;
public TicketApi(Environment environment, TicketV5Service serviceV5) {
this.environment = environment;
this.serviceV5 = serviceV5;
}
@GetMapping("/api/health")
public Map<String, Object> health() {
Map<String, Object> response = new HashMap<>();
response.put("visitCount", visitCount++);
response.put("server", getServer());
return response;
}
static class Request {
private int amount;
public int getAmount() {
return amount;
}
public void setAmount(int amount) {
this.amount = amount;
}
}
static class Response {
private String server;
private Ticket ticket;
public Response(String server, Ticket ticket) {
this.server = server;
this.ticket = ticket;
}
public String getServer() {
return server;
}
public Ticket getTicket() {
return ticket;
}
}
@PostMapping("/api/v1/tickets/{ticketId}/purchase")
public Response purchaseV1(@PathVariable Long ticketId, @RequestBody Request request) {
Ticket ticket = serviceV5.purchase(ticketId, request.getAmount());
return new Response(getServer(), ticket);
}
private String getServer() {
return environment.getProperty("server", "?");
}
@ExceptionHandler(RuntimeException.class)
public String handle(RuntimeException ex) {
return ex.getMessage() != null ? ex.getMessage() : "empty";
}
}
TicketV5Service
@Slf4j
@Service
@RequiredArgsConstructor
public class TicketV5Service {
private final TicketRepository ticketRepository;
@Synchronized
public Ticket purchase(Long ticketId, int amount){
Ticket ticket = ticketRepository.findById(ticketId).orElseThrow(RuntimeException::new);
log.info("{} -> [Ticket{} 현재 보유량={} & 구매 요청량={}", Thread.currentThread().getName(), ticket.getId(), ticket.getStock(), amount);
ticket.purchase(amount);
return ticketRepository.saveAndFlush(ticket);
}
}
RDB에 데이터 넣어두기
아까 `docker-compose`를 활용해 생성한 `MySQL` 컨테이너에 접속해 값을 넣어주자.
그 후, `K6`라는 것을 사용해서 테스트를 해볼 것이다.(K6에 대한 정보)
- 먼저, RDB에 티켓 1000장을 넣어둔다.
- 그 후, K6를 통해 200명의 사용자가 동시에 티켓 5장씩을 구매한다.
K6 Script
import http from "k6/http";
export const options = {
scenarios: {
spike: {
executor: "constant-vus",
vus: 200,
duration: "1s",
gracefulStop: "5m"
},
},
};
export default function () {
const url = "http://localhost/api/v1/tickets/1/purchase"
const data = {
"amount": 5
}
const res = http.post(url, JSON.stringify(data), {
headers: {
"Content-Type": "application/json"
},
});
console.log(res.body);
};
보면 `ticketId` 1인 값에 대해 재고가 115개 남아있는 것을 확인할 수 있다.
즉, `synchronized` 키워드를 사용해도 분산 환경에서 동시성 문제를 해결할 수가 없다는 것이다.
분산 환경에서의 동시성 제어
`Critical Section`을 단일 인스턴스상에서 멀티 쓰레드가 접근하는 상황이라면 `synchronized`로 해결되지만,
분산된 서버에서는 해결되지 않는 것을 보았다.
따라서, 이렇게 분산된 서버에서 `Critical Section`에 대한 `Mutual Exclusion`을 보장하기 위해
사용하는 `Lock`을 분산 락(Distributed Lock)이라고 한다.
분산 락의 경우 락이라는 개념을 N대의 서버가 공통적으로 바라보는 공간에서 제어해야 한다.
- 서버를 분산시키고 앞단에 로드밸런서를 두게 되면 들어오는 여러 요청은 여러 서버로 분산되어 처리된다.
- 만약 해당 요청이 공유 자원에 대한 수정이 일어나는 로직이라면 여러 서버에서 바라보는 공유 자원에 관한 정합성이 굉장히 중요해지고 이를 위해서 분산 락을 사용하여 순차적 처리를 유도한다.
따라서, 위의 티켓 케이스에서는 아래와 같은 방법이 적용이 가능하다. (분산 서버 + 싱글 DB)
- Ticket Record에 직접적인 Lock을 적용해서 제어 (Pessimistic Lock)
- Application 레벨에서 Version을 통해서 갱신 시점에 동기화 (Optimistic Lock)
- DB Record가 아닌 별도의 영역에서 Lock이라는 개념을 관리
- MySQL Named Lock
- Redis
- Zookeeper
- ...
추가적으로, 티켓 케이스의 경우 이미 존재하는 Ticket Record Entity에 대해서 재고에 관한 동시성 제어를 하기 때문에
`Optimistic` or `Pessimistic`으로 제어가 가능하다.
만약, 이미 존재하는 Record Entity가 아니라면 `Optimistic` or `Pessimistic`이 아닌 다른 방법으로 앞단에서
제어하는 메커니즘이 필요하다.
TicketV6Service API 로직
`Pessimistic Lock`을 활용해서 실제 동시성 처리가 이루어지는지 간단하게 테스트해보자.
TicketApi
@RestController
public class TicketApi {
private final Environment environment;
private final TicketV5Service serviceV5;
private final TicketV6Service serviceV6;
private int visitCount = 0;
public TicketApi(Environment environment, TicketV5Service serviceV5, TicketV6Service serviceV6) {
this.environment = environment;
this.serviceV5 = serviceV5;
this.serviceV6 = serviceV6;
}
@GetMapping("/api/health")
public Map<String, Object> health() {
Map<String, Object> response = new HashMap<>();
response.put("visitCount", visitCount++);
response.put("server", getServer());
return response;
}
static class Request {
private int amount;
public int getAmount() {
return amount;
}
public void setAmount(int amount) {
this.amount = amount;
}
}
static class Response {
private String server;
private Ticket ticket;
public Response(String server, Ticket ticket) {
this.server = server;
this.ticket = ticket;
}
public String getServer() {
return server;
}
public Ticket getTicket() {
return ticket;
}
}
@PostMapping("/api/v1/tickets/{ticketId}/purchase")
public Response purchaseV1(@PathVariable Long ticketId, @RequestBody Request request) {
Ticket ticket = serviceV5.purchase(ticketId, request.getAmount());
return new Response(getServer(), ticket);
}
@PostMapping("/api/v2/tickets/{ticketId}/purchase")
public Response purchaseV2(@PathVariable Long ticketId, @RequestBody Request request) {
Ticket ticket = serviceV6.purchase(ticketId, request.getAmount());
return new Response(getServer(), ticket);
}
private String getServer() {
return environment.getProperty("server", "?");
}
@ExceptionHandler(RuntimeException.class)
public String handle(RuntimeException ex) {
return ex.getMessage() != null ? ex.getMessage() : "empty";
}
}
TicketV6Service
@Slf4j
@Service
@RequiredArgsConstructor
public class TicketV6Service {
private final TicketRepository ticketRepository;
@Transactional
public Ticket purchase(Long ticketId, int amount){
Ticket ticket = ticketRepository.findByIdWithLock(ticketId).orElseThrow(RuntimeException::new);
log.info("{} -> [Ticket{} 현재 보유량={} & 구매 요청량={}", Thread.currentThread().getName(), ticket.getId(), ticket.getStock(), amount);
ticket.purchase(amount);
return ticket;
}
}
TicketRepository
public interface TicketRepository extends JpaRepository<Ticket, Long> {
@Query("select t from Ticket t where t.id = :ticketId")
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Ticket> findByIdWithLock(Long ticketId);
}
이때, 중요한 점은 `@Synchronized` 어노테이션 대신에 이제는 `@Transactional`이라는 어노테이션을 작성하자.
또한, `K6 Script`의 API 경로도 `v2`로 바꿔주어야 한다.
`Pessimistic Lock`을 사용하니 티켓 구매 동시성 제어에 해결했다.
정리
우리는 여지껏
프로세스, 스레드, 락, synchronized, synchronized의 한계, @Transactional의 동작원리, 분산 환경, Pessimistic
에 걸쳐 직접 실습을 진행했고 해결해보았다.
다음 포스팅에선 Spring JPA에서 지원하는 여러 종류의 Lock에 관해 포스팅 하겠다.
참고
https://sjiwon-dev.tistory.com/20
[Spring] synchronized 키워드를 활용한 동시성 문제 해결 & 한계
동시성 처리 웹 서비스를 개발하다보면 수많은 종류의 동시성 문제를 경험해볼 수 있다 주문 도메인 상품 재고 동시성 처리 선착순 쿠폰 동시성 처리 … 동시성 문제는 공유 자원을 동시에 접근
sjiwon-dev.tistory.com
'Spring > 동시성 & Lock' 카테고리의 다른 글
[캐시 & 동시성 & Lock] 선착순 티켓 시스템 고도화 - 1 (0) | 2024.11.19 |
---|---|
[Spring Data] Redis Lock을 적용해보자 - 2 (0) | 2024.10.17 |
[Spring Data] Redis Lock을 적용해보자 - 1 (0) | 2024.10.16 |
[Spring] synchronized 키워드를 활용한 동시성 문제 해결 및 한계 - 3 (0) | 2024.09.27 |
[Spring] synchronized 키워드를 활용한 동시성 문제 해결 및 한계 - 1 (0) | 2024.09.22 |