Notion에서 작성 된 글입니다. 템플릿이 깨진다면 Notion을 확인해주세요.
비관적 락 성능 개선을 해보자 | Notion
들어가기 앞서 . . .
hail-buttercup-c86.notion.site
들어가기 앞서 . . .
동시성 보장을 위해 비관적 락을 적용했지만, 부하 테스트에서 TPS가 1.3으로 너무 낮게 측정되는 문제를 확인했다.
락 자체를 포기하지 않은 상태에서(=비관적 락 유지) 어디에서 병목이 생기고 무엇을 줄이면 성능을 끌어올릴 수 있는지 검증·개선해 보고자 이 글을 정리했다.
비관적 락 적용 후 성능
부하 테스트를 진행하며 성능이 너무 느리게 나와 가장 먼저 드는 의문이 DB가 잘못설계되었나?라는 생각이 들었다.
쿼리를 개선할 수 있는 부분이 있지 않나해서 요청할 때 실행되는 SQL을 먼저 뜯어보았다.
해결과정1: 필요없는 테이블을 조회하지말자
연관관계 LAZY 설정
2025-08-29T16:48:10.094+09:00 DEBUG 11684 --- [nio-8081-exec-2] org.hibernate.SQL :
select
m1_0.member_id,
m1_0.member_birthday,
m1_0.member_created_at,
m1_0.member_deleted_at,
m1_0.member_email,
m1_0.host_biz_address,
m1_0.host_biz_bank,
m1_0.host_biz_bank_number,
m1_0.host_biz_ceo,
m1_0.host_biz_depositor,
m1_0.host_biz_ecommerce_registration_number,
m1_0.host_biz_name,
m1_0.host_biz_number,
m1_0.member_img,
m1_0.member_role,
m1_0.member_nickname,
m1_0.member_pw,
m1_0.member_number,
m1_0.member_point_balance,
m1_0.member_updated_at
from
member m1_0
where
m1_0.member_email=?
2025-08-29T16:48:10.117+09:00 INFO 11684 --- [nio-8081-exec-2] c.p.t.global.security.filter.JwtFilter : Authentication: UsernamePasswordAuthenticationToken [Principal=com.profect.tickle.global.security.util.principal.CustomUserDetails@265291ab, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[MEMBER]]
2025-08-29T16:48:10.172+09:00 DEBUG 11684 --- [nio-8081-exec-2] org.hibernate.SQL :
select
e1_0.event_id,
e1_0.event_accrued,
e1_0.coupon_id,
e1_0.event_created_at,
e1_0.event_goal_price,
e1_0.event_name,
e1_0.event_per_price,
e1_0.status_id,
e1_0.event_type,
e1_0.event_updated_at
from
event e1_0
where
e1_0.event_id=? for no key update
2025-08-29T16:48:10.191+09:00 DEBUG 11684 --- [nio-8081-exec-2] org.hibernate.SQL :
select
s1_0.seat_id,
s1_0.seat_created_at,
s1_0.event_id,
s1_0.member_id,
s1_0.performance_id,
s1_0.preempted_at,
s1_0.preempted_until,
s1_0.preemption_token,
s1_0.reservation_id,
s1_0.seat_code,
s1_0.seat_grade,
s1_0.seat_number,
s1_0.seat_price,
s1_0.status_id
from
seat s1_0
where
s1_0.event_id=?
2025-08-29T16:48:10.244+09:00 DEBUG 11684 --- [nio-8081-exec-2] org.hibernate.SQL :
select
m1_0.member_id,
m1_0.member_birthday,
m1_0.member_created_at,
m1_0.member_deleted_at,
m1_0.member_email,
m1_0.host_biz_address,
m1_0.host_biz_bank,
m1_0.host_biz_bank_number,
m1_0.host_biz_ceo,
m1_0.host_biz_depositor,
m1_0.host_biz_ecommerce_registration_number,
m1_0.host_biz_name,
m1_0.host_biz_number,
m1_0.member_img,
m1_0.member_role,
m1_0.member_nickname,
m1_0.member_pw,
m1_0.member_number,
m1_0.member_point_balance,
m1_0.member_updated_at
from
member m1_0
where
m1_0.member_id=?
2025-08-29T16:48:10.255+09:00 DEBUG 11684 --- [nio-8081-exec-2] org.hibernate.SQL :
select
p1_0.member_id,
p1_0.point_id,
p1_0.point_created_at,
p1_0.point_credit,
p1_0.point_order_id,
p1_0.point_target
from
point p1_0
where
p1_0.member_id=?
2025-08-29T16:48:10.291+09:00 DEBUG 11684 --- [nio-8081-exec-2] org.hibernate.SQL :
insert
into
point
(point_created_at, point_credit, member_id, point_order_id, point_target)
values
(?, ?, ?, ?, ?)
2025-08-29T16:48:10.331+09:00 DEBUG 11684 --- [nio-8081-exec-2] org.hibernate.SQL :
update
event
set
event_accrued=?,
coupon_id=?,
event_created_at=?,
event_goal_price=?,
event_name=?,
event_per_price=?,
status_id=?,
event_type=?,
event_updated_at=?
where
event_id=?
2025-08-29T16:48:10.343+09:00 DEBUG 11684 --- [nio-8081-exec-2] org.hibernate.SQL :
update
member
set
member_birthday=?,
member_deleted_at=?,
member_email=?,
host_biz_address=?,
host_biz_bank=?,
host_biz_bank_number=?,
host_biz_ceo=?,
host_biz_depositor=?,
host_biz_ecommerce_registration_number=?,
host_biz_name=?,
host_biz_number=?,
member_img=?,
member_role=?,
member_nickname=?,
member_pw=?,
member_number=?,
member_point_balance=?,
member_updated_at=?
where
member_id=?
2025-08-29T16:48:33.127+09:00 INFO 11684 --- [MessageBroker-2] o.s.w.s.c.WebSocketMessageBrokerStats : WebSocketSession[0 current WS(0)-HttpStream(0)-HttpPoll(0), 0 total, 0 closed abnormally (0 connect failure, 0 send limit, 0 transport error)], stompSubProtocol[processed CONNECT(0)-CONNECTED(0)-DISCONNECT(0)], stompBrokerRelay[null], inboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], outboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], sockJsScheduler[pool size = 4, active threads = 1, queued tasks = 2, completed tasks = 1]
위의 이벤트 응모 요청 시 도출되는 SQL을 확인해보면 Seat 테이블이 조회되는 것을 확인할 수 있다. 이벤트 응모할 때 해당 좌석에 대한 정보가 필요없는데 왜 조회하는걸까?
현재 Event와 연관관계가 설정되어있는 Seat에 Lazy설정이 아닌 디폴트 설정이 되어있었다.
만약 Event에 응모되면 Seat정보가 필요하기때문에, FetchType Lazy로 명시하여 필요할 때만 조회하도록 하자.
요청을 한 번 다시 보내보았다. Seat가 조회되지않는 것을 확인할 수 있었다.
025-08-29T17:39:32.052+09:00 INFO 12686 --- [nio-8081-exec-2] c.p.t.global.security.filter.JwtFilter : Token: eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyNkBleGFtcGxlLmNvbSIsImF1dGhvcml0aWVzIjpbIk1FTUJFUiJdLCJ1c2VySWQiOjYsIm5pY2tuYW1lIjoi7Jyg7KCANiIsImlzcyI6InRpY2tsZS1hcGkiLCJpYXQiOjE3NTY0NTY3NzEsImV4cCI6MTc1NjU0MzE3MX0.8lePT4WSW8ziHYPzuZpv2XYeLLjYrIkTK7DxiNLfkko84TfQhkhhiLetmsjiIuaBqLtKvbqiw3jBBVdrujWUmA
2025-08-29T17:39:32.062+09:00 DEBUG 12686 --- [nio-8081-exec-2] org.hibernate.SQL :
select
m1_0.member_id,
m1_0.member_birthday,
m1_0.member_created_at,
m1_0.member_deleted_at,
m1_0.member_email,
m1_0.host_biz_address,
m1_0.host_biz_bank,
m1_0.host_biz_bank_number,
m1_0.host_biz_ceo,
m1_0.host_biz_depositor,
m1_0.host_biz_ecommerce_registration_number,
m1_0.host_biz_name,
m1_0.host_biz_number,
m1_0.member_img,
m1_0.member_role,
m1_0.member_nickname,
m1_0.member_pw,
m1_0.member_number,
m1_0.member_point_balance,
m1_0.member_updated_at
from
member m1_0
where
m1_0.member_email=?
2025-08-29T17:39:32.080+09:00 INFO 12686 --- [nio-8081-exec-2] c.p.t.global.security.filter.JwtFilter : Authentication: UsernamePasswordAuthenticationToken [Principal=com.profect.tickle.global.security.util.principal.CustomUserDetails@17bb47b8, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[MEMBER]]
2025-08-29T17:39:32.104+09:00 DEBUG 12686 --- [nio-8081-exec-2] org.hibernate.SQL :
select
e1_0.event_id,
e1_0.event_accrued,
e1_0.coupon_id,
e1_0.event_created_at,
e1_0.event_goal_price,
e1_0.event_name,
e1_0.event_per_price,
e1_0.status_id,
e1_0.event_type,
e1_0.event_updated_at
from
event e1_0
where
e1_0.event_id=? for no key update
2025-08-29T17:39:32.125+09:00 DEBUG 12686 --- [nio-8081-exec-2] org.hibernate.SQL :
select
m1_0.member_id,
m1_0.member_birthday,
m1_0.member_created_at,
m1_0.member_deleted_at,
m1_0.member_email,
m1_0.host_biz_address,
m1_0.host_biz_bank,
m1_0.host_biz_bank_number,
m1_0.host_biz_ceo,
m1_0.host_biz_depositor,
m1_0.host_biz_ecommerce_registration_number,
m1_0.host_biz_name,
m1_0.host_biz_number,
m1_0.member_img,
m1_0.member_role,
m1_0.member_nickname,
m1_0.member_pw,
m1_0.member_number,
m1_0.member_point_balance,
m1_0.member_updated_at
from
member m1_0
where
m1_0.member_id=?
2025-08-29T17:39:32.138+09:00 DEBUG 12686 --- [nio-8081-exec-2] org.hibernate.SQL :
select
p1_0.member_id,
p1_0.point_id,
p1_0.point_created_at,
p1_0.point_credit,
p1_0.point_order_id,
p1_0.point_target
from
point p1_0
where
p1_0.member_id=?
2025-08-29T17:39:32.160+09:00 DEBUG 12686 --- [nio-8081-exec-2] org.hibernate.SQL :
insert
into
point
(point_created_at, point_credit, member_id, point_order_id, point_target)
values
(?, ?, ?, ?, ?)
2025-08-29T17:39:32.186+09:00 DEBUG 12686 --- [nio-8081-exec-2] org.hibernate.SQL :
update
event
set
event_accrued=?,
coupon_id=?,
event_created_at=?,
event_goal_price=?,
event_name=?,
event_per_price=?,
status_id=?,
event_type=?,
event_updated_at=?
where
event_id=?
2025-08-29T17:39:32.196+09:00 DEBUG 12686 --- [nio-8081-exec-2] org.hibernate.SQL :
update
member
set
member_birthday=?,
member_deleted_at=?,
member_email=?,
host_biz_address=?,
host_biz_bank=?,
host_biz_bank_number=?,
host_biz_ceo=?,
host_biz_depositor=?,
host_biz_ecommerce_registration_number=?,
host_biz_name=?,
host_biz_number=?,
member_img=?,
member_role=?,
member_nickname=?,
member_pw=?,
member_number=?,
member_point_balance=?,
member_updated_at=?
where
member_id=?
2025-08-29T17:40:05.209+09:00 INFO 12686 --- [MessageBroker-2] o.s.w.s.c.WebSocketMessageBrokerStats : WebSocketSession[0 current WS(0)-HttpStream(0)-HttpPoll(0), 0 total, 0 closed abnormally (0 connect failure, 0 send limit, 0 transport error)], stompSubProtocol[processed CONNECT(0)-CONNECTED(0)-DISCONNECT(0)], stompBrokerRelay[null], inboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], outboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], sockJsScheduler[pool size = 4, active threads = 1, queued tasks = 2, completed tasks = 1]
해결과정2: 멤버의 포인트를 차감하는 SQL문을 줄여보자
UPDATE문 한 번만 날리기
현재 멤버의 포인트를 차감하기 위해서 멤버를 조회하고(SELECT) + 포인트를 차감하는(UPDATE)로직으로 작성되어있다.
SELECT + UPDATE문을 한 번에 UPDATE하도록 변경해보자.
서비스 로직에서 deductPoint 메서드로 따로 포인트를 감소시키는 기능을 분리하였다.
수정 전 Service
수정 전 Member
수정 후 Service
수정 후 Repository
2025-08-29T20:06:00.916+09:00 INFO 15204 --- [nio-8081-exec-2] c.p.t.global.security.filter.JwtFilter : Authentication: UsernamePasswordAuthenticationToken [Principal=com.profect.tickle.global.security.util.principal.CustomUserDetails@4f9b4b44, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[MEMBER]]
2025-08-29T20:06:00.955+09:00 DEBUG 15204 --- [nio-8081-exec-2] org.hibernate.SQL :
select
e1_0.event_id,
e1_0.event_accrued,
e1_0.coupon_id,
e1_0.event_created_at,
e1_0.event_goal_price,
e1_0.event_name,
e1_0.event_per_price,
e1_0.status_id,
e1_0.event_type,
e1_0.event_updated_at
from
event e1_0
where
e1_0.event_id=? for no key update
2025-08-29T20:06:01.005+09:00 DEBUG 15204 --- [nio-8081-exec-2] org.hibernate.SQL :
UPDATE
member
SET
member_point_balance = member_point_balance - ?,
member_updated_at = NOW()
WHERE
member_id = ?
AND member_point_balance >= ?
2025-08-29T20:06:01.022+09:00 DEBUG 15204 --- [nio-8081-exec-2] org.hibernate.SQL :
select
m1_0.member_id,
m1_0.member_birthday,
m1_0.member_created_at,
m1_0.member_deleted_at,
m1_0.member_email,
m1_0.host_biz_address,
m1_0.host_biz_bank,
m1_0.host_biz_bank_number,
m1_0.host_biz_ceo,
m1_0.host_biz_depositor,
m1_0.host_biz_ecommerce_registration_number,
m1_0.host_biz_name,
m1_0.host_biz_number,
m1_0.member_img,
m1_0.member_role,
m1_0.member_nickname,
m1_0.member_pw,
m1_0.member_number,
m1_0.member_point_balance,
m1_0.member_updated_at
from
member m1_0
where
m1_0.member_id=?
2025-08-29T20:06:01.035+09:00 DEBUG 15204 --- [nio-8081-exec-2] org.hibernate.SQL :
select
p1_0.member_id,
p1_0.point_id,
p1_0.point_created_at,
p1_0.point_credit,
p1_0.point_order_id,
p1_0.point_target
from
point p1_0
where
p1_0.member_id=?
2025-08-29T20:06:01.126+09:00 DEBUG 15204 --- [nio-8081-exec-2] org.hibernate.SQL :
insert
into
point
(point_created_at, point_credit, member_id, point_order_id, point_target)
values222222
(?, ?, ?, ?, ?)
memberSelect문 줄이기
현재 한 번 이벤트에 응모할 시 실행되는 SQL문이다.
원래 우리의 로직은 → → 이므로, 이렇게 되어야하는데 실제로 실행되는 SQL문은
→ → 이다. member select가 너무 잦으니 줄여보자.
select
m1_0.member_id,
m1_0.member_birthday,
m1_0.member_created_at,
m1_0.member_deleted_at,
m1_0.member_email,
m1_0.host_biz_address,
m1_0.host_biz_bank,
m1_0.host_biz_bank_number,
m1_0.host_biz_ceo,
m1_0.host_biz_depositor,
m1_0.host_biz_ecommerce_registration_number,
m1_0.host_biz_name,
m1_0.host_biz_number,
m1_0.member_img,
m1_0.member_role,
m1_0.member_nickname,
m1_0.member_pw,
m1_0.member_number,
m1_0.member_point_balance,
m1_0.member_updated_at
from
member m1_0
where
m1_0.member_email=?
2025-09-02T12:56:00.774+09:00 INFO 31485 --- [nio-8081-exec-1] c.p.t.global.security.filter.JwtFilter : Authentication: UsernamePasswordAuthenticationToken [Principal=com.profect.tickle.global.security.util.principal.CustomUserDetails@20439a0f, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[MEMBER]]
2025-09-02T12:56:00.801+09:00 DEBUG 31485 --- [nio-8081-exec-1] org.hibernate.SQL :
select
e1_0.event_id,
e1_0.event_accrued,
e1_0.coupon_id,
e1_0.event_created_at,
e1_0.event_goal_price,
e1_0.event_name,
e1_0.event_per_price,
e1_0.status_id,
e1_0.event_type,
e1_0.event_updated_at
from
event e1_0
where
e1_0.event_id=? for no key update
2025-09-02T12:56:00.900+09:00 DEBUG 31485 --- [nio-8081-exec-1] org.hibernate.SQL :
update
member m1_0
set
member_point_balance=(m1_0.member_point_balance-?)
where
m1_0.member_id=?
and m1_0.member_point_balance>=?
2025-09-02T12:56:00.992+09:00 DEBUG 31485 --- [nio-8081-exec-1] org.hibernate.SQL :
update
event
set
event_accrued=?,
coupon_id=?,
event_created_at=?,
event_goal_price=?,
event_name=?,
event_per_price=?,
status_id=?,
event_type=?,
event_updated_at=?
where
event_id=?
2025-09-02T12:56:01.193+09:00 DEBUG 31485 --- [nio-8081-exec-1] org.hibernate.SQL :
select
m1_0.member_id,
m1_0.member_birthday,
m1_0.member_created_at,
m1_0.member_deleted_at,
m1_0.member_email,
m1_0.host_biz_address,
m1_0.host_biz_bank,
m1_0.host_biz_bank_number,
m1_0.host_biz_ceo,
m1_0.host_biz_depositor,
m1_0.host_biz_ecommerce_registration_number,
m1_0.host_biz_name,
m1_0.host_biz_number,
m1_0.member_img,
m1_0.member_role,
m1_0.member_nickname,
m1_0.member_pw,
m1_0.member_number,
m1_0.member_point_balance,
m1_0.member_updated_at
from
member m1_0
where
m1_0.member_id=?
2025-09-02T12:56:01.278+09:00 DEBUG 31485 --- [nio-8081-exec-1] org.hibernate.SQL :
select
p1_0.member_id,
p1_0.point_id,
p1_0.point_created_at,
p1_0.point_credit,
p1_0.point_order_id,
p1_0.point_target
from
point p1_0
where
p1_0.member_id=?
@Service
@RequiredArgsConstructor
public class PostActionsService {
private final PointRepository pointRepository;
private final MemberRepository memberRepository;
private final SeatRepository seatRepository;
private final ReservationRepository reservationRepository;
private final PerformanceRepository performanceRepository;
private final StatusProvider statusProvider;
// (락 밖) 포인트 내역 저장
@Transactional
public void recordPointHistory(Long memberId, int amount, PointTarget target) {
pointRepository.save(Point.deduct(memberRepository.getReferenceById(memberId), amount, target));
}
}
// Member
@Builder
public class Member {
@OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Point> points = new ArrayList<>();
}
// Point
public class Point {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member member;
}
Member와 Point가 서로 양방향이기때문에, Point 저장 시, JPA가 반대편 컬렉션인 Member와 일관성을 맞추기 위해서 컬렉션을 초기화하여 member select가 발생하는 것 이였다.(cascade=ALL, orphanRemoval=true가 붙은 @OneToMany)
// Member
@Builder
public class Member {
// @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
// private List<Point> points = new ArrayList<>();
}
pointRepository.findByMemberId(memberId)
마이페이지에서 사용자의 포인트 내역을 조회하기 위해 양방향 관계를 설정해주었는데, 양방향 관계를 단방향 관계로 변경하고 포인트 내역 조회시 멤버의 ID로 포인트를 조회하도록 변경하였다.
결과
select
m1_0.member_id,
m1_0.member_birthday,
m1_0.member_created_at,
m1_0.member_deleted_at,
m1_0.member_email,
m1_0.host_biz_address,
m1_0.host_biz_bank,
m1_0.host_biz_bank_number,
m1_0.host_biz_ceo,
m1_0.host_biz_depositor,
m1_0.host_biz_ecommerce_registration_number,
m1_0.host_biz_name,
m1_0.host_biz_number,
m1_0.member_img,
m1_0.member_role,
m1_0.member_nickname,
m1_0.member_pw,
m1_0.member_number,
m1_0.member_point_balance,
m1_0.member_updated_at
from
member m1_0
where
m1_0.member_email=?
2025-09-02T13:49:23.166+09:00 INFO 32791 --- [nio-8081-exec-1] c.p.t.global.security.filter.JwtFilter : Authentication: UsernamePasswordAuthenticationToken [Principal=com.profect.tickle.global.security.util.principal.CustomUserDetails@73b6b1df, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[MEMBER]]
2025-09-02T13:49:23.207+09:00 DEBUG 32791 --- [nio-8081-exec-1] org.hibernate.SQL :
select
e1_0.event_id,
e1_0.event_accrued,
e1_0.coupon_id,
e1_0.event_created_at,
e1_0.event_goal_price,
e1_0.event_name,
e1_0.event_per_price,
e1_0.status_id,
e1_0.event_type,
e1_0.event_updated_at
from
event e1_0
where
e1_0.event_id=? for no key update
2025-09-02T13:49:23.410+09:00 DEBUG 32791 --- [nio-8081-exec-1] org.hibernate.SQL :
update
member m1_0
set
member_point_balance=(m1_0.member_point_balance-?)
where
m1_0.member_id=?
and m1_0.member_point_balance>=?
2025-09-02T13:49:23.557+09:00 DEBUG 32791 --- [nio-8081-exec-1] org.hibernate.SQL :
update
event
set
event_accrued=?,
coupon_id=?,
event_created_at=?,
event_goal_price=?,
event_name=?,
event_per_price=?,
status_id=?,
event_type=?,
event_updated_at=?
where
event_id=?
TPS가 소폭 상승된 것을 확인할 수 있다.
해결방안3. 락 거는 범위를 최소화해보자.
락 범위 최소화
현재의 코드는 아래와 같다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public TicketApplyResponseDto applyTicketEventOnce(Long eventId) {
final Long memberId = SecurityUtil.getSignInMemberId();
Event event = getEvent(eventId);
final short perPrice = event.getPerPrice();
int updated = memberRepository.tryDeductPoint(memberId, perPrice);
if (updated == 0) {
throw new BusinessException(ErrorCode.INSUFFICIENT_POINT);
}
Member member = memberRepository.getReferenceById(memberId);
Point point = Point.deduct(member, perPrice, PointTarget.EVENT);
pointRepository.save(point);
event.accumulate(event.getPerPrice());
boolean isWinner = (event.getAccrued() >= event.getGoalPrice());
if (isWinner) {
event.updateStatus(statusProvider.provide(StatusIds.Event.COMPLETED));
Seat seat = seatRepository.findByEventId(eventId)
.orElseThrow(() -> new BusinessException(ErrorCode.SEAT_NOT_FOUND));
Reservation r = Reservation.create(
member,
seat.getPerformance(),
statusProvider.provide(StatusIds.Reservation.PAID),
event.getAccrued()
);
r.assignSeat(seat);
Status reserved = statusProvider.provide(StatusIds.Seat.RESERVED);
seat.completeReservation(member, reserved, null);
reservationRepository.save(r);
}
위의 코드를 수행하는동안 락에 걸려있게 된다.
지금은 이벤트 응모 → 포인트 감소 → 우승자 확인 → 우승자일 시 티켓 발급 까지 이루어져있는데, 이렇게 락에 걸려있는 범위가 크면 대기시간이 더 늘어나지않을까?
LOCK의 범위가 너무 넓어진 것 같으니, 분리해보자
- 이벤트 응모 후 포인트 차감 후 목표금액에 누적. 목표 금액에 도달시 이벤트 상태 종료로 변경
- 포인트 내역을 저장
- 우승자일 시, 좌석 상태를 변경하고 예매권을 발급
PessimisticEventApplyExecutor
@Service
@RequiredArgsConstructor
public class PessimisticEventApplyExecutor {
private final SeatRepository seatRepository;
private final CouponRepository couponRepository;
private final CouponReceivedRepository couponReceivedRepository;
private final EventRepository eventRepository;
private final MemberRepository memberRepository;
private final StatusProvider statusProvider;
private final EventCoreLockService core;
public TicketApplyResponseDto applyTicketEventOnce(Long eventId) {
Long memberId = SecurityUtil.getSignInMemberId();
EventDecision dec = core.applyCore(eventId, memberId);
return TicketApplyResponseDto.from(dec.eventId(), dec.memberId(), dec.winner());
}
}
EventCoreLockService
이벤트 응모 Service
@Service
@RequiredArgsConstructor
public class EventCoreLockService {
private final EventRepository eventRepository;
private final MemberRepository memberRepository;
private final StatusProvider statusProvider;
private final ApplicationEventPublisher publisher;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public EventDecision applyCore(Long eventId, Long memberId) {
Long seatId = null;
Event event = eventRepository.findForUpdateById(eventId)
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_NOT_FOUND));
if (!StatusIds.Event.IN_PROGRESS.equals(event.getStatus().getId())) {
throw new BusinessException(ErrorCode.EVENT_NOT_IN_PROGRESS);
}
short perPrice = event.getPerPrice();
int memberPoint = memberRepository.tryDeductPoint(memberId, perPrice);
if (memberPoint == 0) throw new BusinessException(ErrorCode.INSUFFICIENT_POINT);
event.accumulate(perPrice);
boolean winner = event.getAccrued() >= event.getGoalPrice();
if (winner) {
event.updateStatus(statusProvider.provide(StatusIds.Event.COMPLETED));
seatId = event.getSeat().getId();
}
publisher.publishEvent(new TicketApplied(
memberId,
seatId,
perPrice,
event.getAccrued(),
PointTarget.EVENT,
winner
));
return new EventDecision(event.getId(), memberId, perPrice, winner, event.getAccrued(), seatId);
}
}
PostActionsService
이벤트 응모 후, 후속처리 서비스
@Slf4j
@Service
@RequiredArgsConstructor
public class PostActionsService {
private final PointRepository pointRepository;
private final MemberRepository memberRepository;
private final SeatRepository seatRepository;
private final ReservationRepository reservationRepository;
private final PerformanceRepository performanceRepository;
private final StatusProvider statusProvider;
private final EntityManager entityManager;
//포인트 내역 저장
@Transactional
public void recordPointHistory(Long memberId, int amount, PointTarget target) {
Member member = memberRepository.getReferenceById(memberId);
var p = Point.deduct(member, amount, target);
pointRepository.save(p);
}
@Transactional
public void reserveSeatAndCreateReservation(Long seatId, Long memberId, int accrued) {
final Long RESERVED = statusProvider.provide(StatusIds.Seat.RESERVED).getId();
final Long AVAILABLE = statusProvider.provide(StatusIds.Seat.AVAILABLE).getId();
int updated = seatRepository.tryReserveSeat(seatId, memberId, RESERVED, AVAILABLE);
if (updated == 0) return; // 경합에서 졌으면 조용히 종료
Long perfId = seatRepository.findPerformanceIdBySeatId(seatId);
Reservation r = Reservation.create(
memberRepository.getReferenceById(memberId),
performanceRepository.getReferenceById(perfId),
statusProvider.provide(StatusIds.Reservation.PAID),
accrued
);
r.assignSeat(seatRepository.getReferenceById(seatId));
reservationRepository.save(r);
}
}
TicketAppliedListener
@Slf4j
@Component
@RequiredArgsConstructor
public class TicketAppliedListener {
private final PostActionsService post;
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void afterCommit(TicketApplied e) {
log.info("[TicketAppliedListener] start member={}, winner={}, seatId={}, perPrice={}, accrued={}",
e.memberId(), e.isWinner(), e.seatId(), e.perPrice(), e.accrued());
try {
post.recordPointHistory(e.memberId(), e.perPrice(), e.target());
if (e.isWinner() && e.seatId() != null) {
post.reserveSeatAndCreateReservation(e.seatId(), e.memberId(), e.accrued());
}
log.info("[TicketAppliedListener] done member={}", e.memberId());
} catch (Exception ex) {
log.error("[TicketAppliedListener] failed member={} reason={}", e.memberId(), ex.toString(), ex);
}
}
}
결과
TPS가 85로 높아졌다.
마무리하며
현재는 비관적 락을 적용하여 한 명이 접근했을 때 락을 걸어서 다른 사용자들은 대기하도록 하였다.
하지만 만약에 100만 명의 요청이 온다면 최소 100만 번의 데이터베이스 콜과 업데이트가 이루어져야하는데 이것이 과연 효율적일까를 생각하게 되었다. 또, 비관적 락을 적용하였을 때, 커넥션 풀 사이즈가 30일때 락을 흭득한 한 개의 요청만 처리하고, 29개의 요청은 대기를 하게 되는데 이게 과연 좋은 설계라고 할 수 있을까를 고민하게되었다.
이후에 다른 추가적은 기술을 도입하여 성능을 개선해볼 예정이다.
'Spring' 카테고리의 다른 글
[ Spring ] Redis의 분산 락을 적용하고 Pub/Sub로 동시성 문제를 해결하자. (0) | 2025.09.30 |
---|---|
[ Spring ] 동시성을 잠금으로 안전하게 처리해보자 (0) | 2025.09.19 |
[SQL]Error: 1406-22001: Data too long for column '?' at row 1 (0) | 2024.10.01 |
[Spring Security] Security 기본 (0) | 2024.08.29 |
[Spring] 컨트롤러 예외처리(Exception Handling) (0) | 2024.07.21 |