티스토리 뷰

반응형

배경설명

회사에서 기업 신용 평가 서비스를 개발 중 프론트엔드 개발자로부터 이슈를 전달받았다.
"가업 재무제표가 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

 

 

비관적 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이 불가능하다는 점이다.

 

id=3이 없다...

 

그래서 존재하지 않는 데이터에 대한 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에서 해당 메서드를 사용하는 식으로 문제를 해결하기로 가닥을 잡았다.

 

AdvisoryLockRepository

 

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

AdvisoryLockUtil

 

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

 

SomeService

 

 

 

3줄 마무리 및 비교 테이블

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

 

 

이상 전달 끝!

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/04   »
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
글 보관함