티스토리 뷰
동시성 해결하다가 Advisory Lock 을 알게된 건에 대하여(Feat. PostgreSql)
GOMSHIKI 2025. 2. 16. 00:12
배경설명
회사에서 기업 신용 평가 서비스를 개발 중 프론트엔드 개발자로부터 이슈를 전달받았다.
"가업 재무제표가 2개씩 조회돼요..!"
(음... 동시성인가 보구먼) "수정하는데 꽤 오래 걸릴 것 같아요 ㅠㅠ." 그렇게 5일 동안 겪은 이야기다.
원인 분석
로직은 아래 순서와 같이 수행된다.
이 과정에서 문제 되는 부분은 "재무제표 테이블 조회 결과가 없으면 - 외부 API 호출 후 응답값을 저장"이다.
1. 사업자번호로 재무제표 테이블에 기업을 조회한다.
2. 조회 결과가 없으면 외부 API를 호출 후 DB에 저장한다.
3. 저장된 기업 정보를 다시 조회한다.

첫 번째 쓰레드가 외부 API 호출 후 DB 저장 전에 두 번째 쓰레드가 재무제표 테이블을 조회하기 때문에 두 번째 쓰레드에서 외부 API 호출 후 DB 저장을 수행하게 된다.
시행착오
1. Unique 제약조건 추가
재무제표 테이블에 적절한 unique 제약 조건을 추가해 중복저장을 물리적을 막는 방법이다.
이 방법은 테이블에 unique로 잡을 명확한 컬럼이 필요한데, 재무제표 테이블의 경우 company_id와 basement_date(기준연도) 두 컬럼을 unique로 설정할 수 있을 것이다.
이 경우 서비스 로직에 try-catch로 감싸고, 저장 시 DataIntegerityViolationException(중복저장 예외) 발생할 경우 다시 한번 조회하거나 IllegalArgumentException으로 예외 처리를 할 수도 있다.
@Transactional
public Company saveData(Company company) {
try {
return myRepository.save(company); // 중복되는 데이터를 저장 시도
} catch (DataIntegrityViolationException ex) {
// unique 제약 조건 위반 시, 이미 존재하는 데이터가 있는지 확인
return myRepository.findByCompany(company)
.orElseThrow(() -> new IllegalArgumentException("Company already exists with the given unique constraint"));
}
}
2. 낙관적 Lock과 비관적 Lock
1) 낙관적 Lock : 동시성 이슈가 많지 않을 경우

- Entity 클래스의 Long, Integer 등 타입 멤버변수에 @Version 어노테이션 적용
- 연관 테이블에 버전 관리를 위한 컬럼을 추가
이 경우 직접적인 DB Lock을 사용하는 것이 아닌, 해당 엔티티를 조회 시 버전 값을 함께 조회한다.
이후 데이터 변경 시 update 쿼리를 통해 버전 값을 1씩 증가시켜 준다.
-- 조회 시 version 값을 항상 같이 조회
SELECT id, version, name
FROM company
-- 데이터 변경 시 version 값을 1씩 증가
UPDATE company
SET name = ?, version = version + 1
WHERE id = ? AND version = ?;
첫 번째 쓰레드가 먼저 해당 데이터를 조회하고 update 쿼리를 수행하기 전에 두 번째 쓰레드가 먼저 update를 수행해 버리면, 첫 번째 쓰레드에서 조회된 version 값이 일치하지 않게 된다. 이때 OptimisticLockException 예외가 발생하게 된다.
이를 해결하기 위해 try-catch로 서비스 로직을 묶고, 예외 발생 시 사후 처리 로직을 추가해 주면 된다.(에러 반환 or update 재시도)

2) 비관적 Lock: 동시성 이슈가 많을 경우
- 비관적 Lock을 적용할 Repository의 메서드에 @Lock을 적용
Lock 종류 | 읽기 가능 | 쓰기 가능 |
PESSIMISTIC_READ | O | X |
PESSIMISTIC_WRITE | X | X |
비관적 Lock은 크게 두 종류를 사용한다.
PESSIMISTIC_READ는 선점한 트랜잭션이 종료될 때까지 다른 트랜잭션에서 읽기는 가능하나 쓰기는 불가능하다.
PESSIMISTIC_WRITE는 선점한 트랜잭션이 종료될 때까지 다른 트랜잭션은 읽기, 쓰기 모두 불가능하다.
-- PESSIMISTIC_READ(읽기 Lock)
SELECT * FROM product WHERE id = 1 FOR SHARE;
-- PESSIMISTIC_WRITE(쓰기 Lock)
SELECT * FROM product WHERE id = 1 FOR UPDATE;
비관적 Lock 은 성능상 빠르게 처리되는 것보다 데이터의 무결성이 중요한 경우에 주로 사용하게 된다. 다만, 로직 구성을 잘못할 경우 서로 다른 트랜잭션이 상대방이 선점한 Lock을 받기 위해 무한정 대기하다 뻗어버리는 데드락이 발생할 수 있다.

그러다 마주한 PostgreSql의 Advisory Lock
다시 로직으로 돌아가 보자면,
1. 사업자번호로 재무제표 테이블에 기업을 조회한다.
2. 조회 결과가 없으면 외부 API를 호출 후 DB에 저장한다.
3. 저장된 기업 정보를 다시 조회한다.
사업자 번호로 재무제표 테이블에 기업을 조회하고, 조회 결과가 없을 때 외부 API 호출한다.그래서 사업자번호로 재무제표 테이블을 조회할 때 Lock을 적용하면 될 것으로 생각했다.앞선 여러 방법들을 적용해 봤을 때 문제는 테이블에 없는 데이터에 대해 Lock이 불가능하다는 점이다.

그래서 존재하지 않는 데이터에 대한 Lock을 적용할 방법을 찾아보다 Advisory Lock 이 있다는 걸 알게 되었다.
대표적으로 MySql과 PostgreSql에서 제공하는 기능으로, 특정 행(Row)이나 테이블과 무관하게, 개발자가 정의한 Integer 타입(숫자 ID)을 기반으로 락을 애플리케이션에서 설정하는 기능이다.

1) 세션 기반 Advisory Lock
- 락을 획득하면 세션이 종료되기 전까지 유지됨
- 다른 트랜잭션이 완료되어도 락이 해제되지 않음
- 직접 해제해야 함! (pg_advisory_unlock())
-- 시작
begin;
-- Lock 획득
SELECT pg_advisory_lock(12345);
-- 조회 수행
SELECT * FROM company where business_number = '10';
-- Lock 반납
SELECT pg_advisory_unlock(12345);
-- 종료
commit;
2) 트랜잭션 기반 Advisory Lock
- 트랜잭션이 끝나면 자동으로 해제됨
- COMMIT 또는 ROLLBACK 하면 자동으로 락이 풀림
BEGIN;
-- 특정 주문 ID(100)에 대한 Advisory Lock 설정
SELECT pg_advisory_xact_lock(100);
-- 주문 처리 (실제 UPDATE 쿼리 예시)
UPDATE orders SET status = 'PROCESSING' WHERE id = 100;
COMMIT; -- 트랜잭션 종료 시 락 자동 해제

위 기능이 현재 로직에 적절한 해답임을 확인하고, 프로젝트에 적용했다.
우선, AdvisoryLockRepository를 만들고, 트랜잭션 기반 advisoryLock을 nativeQuery로 작성 후 메서드를 만들었다.
이후 Service Layer에서 해당 메서드를 사용하는 식으로 문제를 해결하기로 가닥을 잡았다.

pg_advisory_xact_lock()에 들어갈 파리미터는 AdvisoryUtil 클래스를 만들어 자바 내장 AES256을 기반으로 Method 명과 company_id를 혼합하여 unique 한 파라미터를 만들었다.

이 경우 동일 메서드를 두 쓰레드에서 사용 중이라도 서로 다른 company_id를 가지고 있다면 각자 Lock을 가지게 됨으로 성능상에 큰 문제가 발생하지 않도록 했다.

3줄 마무리 및 비교 테이블
구분 | Advisory Lock | 낙관적 Lock | 비관적 Lock |
락 범위 | 개발자 정의 | 레코드 단위(Row) | 레코드 단위(Row) |
성능 영향 | 낮음 | 매우 낮음 | 높음 |
구현 복잡도 | 중간 | 낮음 | 높음 |
확장성 | 높음 | 중간 | 낮음 |
이러한 로직(조회 X -> API 호출 -> 저장)을 가질 때는 AdvisoryLock을 사용하는 방법도 괜찮은 것 같다.
Unique 한 파라미터를 만드는데 번거로움이 있지만, 동일 메서드를 여러 스레드에서 호출하더라도 파라미터값만 다르다면 동시에 트랜잭션을 수행할 수 있다는 점도 장점인 것 같다.
이슈 해결하느라 애를 좀 먹었지만, 덕분에 새로운 기능을 알게 되어 뿌듯하다.

이상 전달 끝!
'프레임워크 > Spring & Spring boot' 카테고리의 다른 글
DTO와 Entity 간 다양한 변환 전략 톺아보기 (0) | 2025.03.02 |
---|---|
2025년 2월 Spring Rest Docs 입문기 (0) | 2025.02.02 |
2024년12월 Swagger 입문기 (0) | 2024.12.22 |
기업정보 조회 API 개발 기록(2024.09.02 ~ 2024.09.30) (0) | 2024.10.06 |
[JPA] 학습테스트로 알아보는 영속과 준영속 (feat. EntityManager) (0) | 2024.07.02 |
- Total
- Today
- Yesterday
- spring boot
- Comparator
- 백준
- RASA
- Spring
- 자바
- 취업리부트코스
- 취리코
- 코드트리
- thymeleaf
- 재기동
- 챗봇
- 글또
- 개발자취준
- Comparable
- 코딩테스트
- 전자정부프레임워크
- 유데미
- 객체정렬
- script
- Java
- dxdy
- JWT
- BufferedReader
- springboot
- BFS
- NLU
- BufferedWriter
- 나만의챗봇
- 항해99
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |