iamjooon2님의 블로그

갱신 분실(lost update) 문제를 어떻게 해결할 수 있을까 본문

트러블슈팅

갱신 분실(lost update) 문제를 어떻게 해결할 수 있을까

iamjooon2 2024. 3. 27. 23:08

학교 창업팀에서 재밌게 개발하던 중, 갱신 분실 이슈에 대해 고민할 상황을 만났다

 

요구사항은 다음과 같았다

 

매일 자정, 모든 사용자에게 재화를 1개 지급해주세요


 

우리는 spring boot와 jpa를 사용중이므로, 단순하게 생각하면 아래와 같이 할 수 있을 것이다

@Component
@Transactional
@RequiredArgsConstructor
public class SchedulerWorker {

    private final MemberRepository memberRepository;
    
    @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")
    public void giveAttendanceReward() {
        memberRepository.findAll()
            .forEach(member -> member.giveReward(1));
    }

}

 

구현 자체는 어렵지 않지만, 다른 개발자분이 작업 도중 문제가 생길 수 있는 지점을 말씀해주셨다

 

지급과 동시에 사용자가 재화를 사용한다면, 값이 유실될 수도 있을 것 같아요..!

 

 

 

기획팀에서는 처음 이 얘기를 듣고, 비즈니스적으로 풀려고 했다.

1. 지급 시간을 자정이 아닌 새벽 4시같은 사용자가 적은 시간으로 변경

2. 지정된 시간에 지급하는 것이 아닌, 인앱 광고 시청시 재화 지급하도록 변경(사용자가 직접 수령하는 방식)

등등...

 

하지만 일정상의 이유로 인앱 광고는 붙이지 않기로 했고...

내가 사용자라면 재화를 지급받는 시간에 맞추어서 해당 재화를 사용할 것 같았다.

 

그리고 엔지니어로서, 기술적인 문제는 비즈니스로 풀기보단 기술로 풀어보고 싶었다

그렇게 고민한 것들과 해결 방법을 이 글에 정리한다

 

 

문제 상황

우리는 JPA를 사용중이므로, 위와 같이 구현했을때 나가는 쿼리로 생기는 엣지케이스는 다음과 같다

 

 

사용자가 초기에 가지고있는 재화가 1이라고 가정했을 때, 스케줄러에 의해 1을 추가해준다면 기대값은 2가 될 것이다.

사용자가 재화를 1만큼 사용한다면, 2에서 1을 사용해 재화가 1이 되어야겠지만,

스케줄러에 의한 변경이 커밋되기 이전의 값인 1을 읽어들여 재화를 1을 사용하기 때문에..

기대값인 1과 다르게 재화는 0이 되버린다.

 

즉, 갱신 분실(lost update) 문제가 발생하는 것이다

 

고민

1. 어플리케이션 단 락

구글링을 하면 JPA단에서 낙관적 락, 비관적 락을 이용해 동시성 문제를 해결하는 방법이 정말 많이 나온다.

나 역시 이전에 강의를 들으며 학습한 적이 있고, 수많은 블로그 글들을 봐왔기에 해당 방법이 먼저 떠올랐다.

 

다만.. 팀원분 중 spring, jpa를 처음 사용하는 분들도 계셨기에, 이후 유지보수시 수월하게 대응할 수 있을지가 고민이 되었다.

그래서 후순위로 두고, 다른 방법을 찾아보려 했다

 

2. 트랜잭션 격리레벨 변경

트랜잭션과 관련된 문제니까 격리수준을 변경하면 되지 않을까? 라고 생각했다

우리 서비스의 DB는 MySQL이라, 기본 격리수준은 Repetable Read이다.

가장 높은 수준인 SERIALIZABLE로 올리면, 한 레코드에 한 트랜잭션만 접근할 수 있기에 부정합 문제를 방지할 수 있지 않을까?

 

로컬에서 mysql을 띄운 후, 격리수준을 올려 실험을 해보았다

set transaction isolation level serializable; -- 전역 격리수준 변경

SELECT @@transaction_isolation; -- SERIALIZABLE
SELECT @@global.transaction_isolation; -- SERIALIZABLE

-- Tx 1
start transaction;

select * from member where id = 1; -- balance) 10
update member set balance = 11 where id = 1;

commit;

-- Tx 2
start transaction;

select * from member where id = 1; -- balance) 10
update member set balance = 9 where id = 1;

commit;

 

 

 

[40001][1213] Deadlock found when trying to get lock; try restarting transaction

락을 얻는데 실패해 데드락이 발생해 실패했다고 한다.

 

왜 데드락이 걸렸을까?

 

격리 레벨을 Serializable로 올리게 되면, select 쿼리에도 락(공유락)이 걸리게 돼

다른 트랜잭션에서의 조회요청은 허용하지만, 쓰기 요청은 허용되지 않는다

(select for update와 같은 효과)

 

모식도는 아래와 같다

tx 1은 해당 레코드에 대해서 락을 얻기 위해 대기하게 되고

tx 2에서 다른 레코드가 락을 얻기 위해 대기하다가 데드락이 발생하게 된다!

 

데이터베이스는 답이 아닌걸까?

 

3. Synchronized

 

다시 코드레벨로 돌아와 생각한 것은, 자바 동기화에 대해 스쳐지나가듯 접한 synchronized다

Synchronized 키워드는 멀티스레드 환경에서 동기화(synchronization)를 보장하기 위해 사용되는 키워드로, 특정 코드 블록 또는 메서드가 한 번에 하나의 스레드만 접근할 수 있도록 제한하는 자바의 키워드다.

즉, 메서드 선언할 때 그냥 붙이기만 하면 된다

synchronized는 서버가 여러대인 상황에서는 무의미해지니 실무에서 잘 사용하지 않는다고 했다...

우리는 단일 서버니까, 이걸 적용하면 되지 않을까?

 

우리 서비스에서는 @Transactional 어노테이션을 이용해 논리적 트랜잭션 범위를 명시해주고 있다.

 

@Transactional은 AOP 기반으로 동작하고, AOP는 클래스를 상속하여 프록시 객체를 만든다

하지만 synchronized는 메서드 시그니처에 포함되지 않아, 상속받은 클래스에서 사용할 수 없다

(AOP에 관한 게시글은 아니기에, 자세한 설명 생략)

 

즉, synchronized는 @Transactional을 떼지 않는 이상 사용할 수 없다.

당장 @Transactional을 떼더라도, 이후 어떻게 변경될 지 모르고... 또 어떤 어노테이션을 쓸 지 모른다

그래서 지양하기로 했다

 

 

4. Atmoic Query

 

최범균님의 영상에서 갈피를 잡게 됐다

 

DB에 쿼리 날릴 때 원자적인 연산을 보장하는 방식이 있었다

-- before
update member set balance = 1 where id = 1

-- after
update member set balance = balance + 1 where id = 1;

 

단점은 ORM 사용중이지만 직접 날쿼리를 날린다는 것, 객체지향이 조금 깨진다는 것 외에는.. 잘 모르겠다

객체지향 문파에 있었지만(ㅋㅋ), 제일 중요한건 데이터 정합성 아닐까?

 

결과적으로 지금 위 방식으로 코드가 올라가있고... 아직까지 문제없이 잘 버텨줬다

 

마치며

동시성 제어는 신입 개발자로서 필수적인 개념이라고 생각하는데,

프로젝트 진행하면서 마주쳐 고민할 수 있게 되어 다행이라고 생각한다

 

얼른 출시하고 사용자들 만나고싶다

중간고사 준비해야지....