Skip to content
This repository was archived by the owner on Jun 14, 2022. It is now read-only.

Latest commit

 

History

History
86 lines (60 loc) · 5.37 KB

File metadata and controls

86 lines (60 loc) · 5.37 KB

DB의 변화와 프로세스 메모리에 있는 객체의 변화를 트랜잭션으로 묶기

문제

서버 프로세스의 메모리에 있는 공유 데이터를 여러 세션(파이버)이 함께 건드려야 하는 경우가 있습니다.

가상의 MMORPG 컨텐츠를 예로 들어보겠습니다. 필드에 매우 낮은 확률로 '돌에 꽂힌 칼' 프랍이 생성되는데, 프랍을 클릭하면 플레이어의 가방에 '엑스칼리버' 아이템이 생성되면서 '돌에 꽂힌 칼' 프랍이 제거되는 기획이 있습니다. 분산 서버가 아닌 이상 필드 프랍은 서버 프로세스의 메모리에만 존재하는 객체일 것이고, 가방에 들어오는 아이템은 DB의 Row로 만들어질 것입니다.

'돌에 꽂힌 칼' 프랍을 클릭했을 때 실행될 로직 코드를 별 생각없이 다음과 같이 만들었다고 칩시다.

  1. DB 트랜잭션 시작
  2. '엑스칼리버' 아이템 Row를 생성하고 DB 테이블에 추가
  3. DB 트랜잭션 커밋
  4. 필드에서 '돌에 꽂힌 칼' 프랍 제거

이런 코드에서는 여러 플레이어가 거의 시간차 없이 동시에 '돌에 꽂힌 칼' 프랍을 클릭하면 플레이어들이 모두 '엑스칼리버' 아이템을 가질 수도 있습니다. '돌에 꽂힌 칼' 프랍을 제거하려고 시도할 때 그 프랍이 존재하지 않는다는 걸 알아차리게 되지만 이미 '엑스칼리버' 아이템은 DB에 추가되고 커밋된 뒤입니다. 이것은 부하가 거의 없는 QA 환경에서는 재현하기 힘들지만, 부하가 심한 시간대의 라이브 서버에서는 3번 절차의 실행이 오래 걸리므로 훨씬 발생하기 쉬운 골치아픈 문제입니다.

반대로 먼저 필드에서 프랍을 제거한 다음 DB 트랜잭션을 시작하면, 인벤토리가 꽉 차 있음을 DB 트랜잭션에서 확인하고 중단하게 만들면 인벤토리가 꽉 찬 플레이어가 '돌에 꽂힌 칼' 프랍을 클릭할 경우 아무도 칼을 갖지 못하고 프랍만 사라지게 될 것입니다. DB 트랜잭션이 진행되는 동안 다른 사용자가 프랍에 인터랙션을 할 수 없도록 플래그를 켜두거나, 프랍을 일단 제거했다가 DB 트랜잭션이 실패하면 복원해 놓는 등 여러가지 꼼수를 쓸 수 있겠지만, 로직 코드가 크게 복잡해지고 또다른 구멍을 만들게 될 가능성이 생깁니다.

해결책 요약

이런 유형의 컨텐츠, 즉 서버 프로세스의 메모리에 있는 공유 데이터와 DB에 있는 데이터를 함께 변경해야 하는 경우에는 다음 두 가지를 신경써야 합니다.

  1. 메모리 상의 공유 데이터를 변경하는 어떤 트랜잭션이 진행중인 동안에는, 같은 공유 데이터를 변경하는 다른 트랜잭션이 실행되지 않도록 막아야 합니다. 이것은 EngineAPI.LocalLock 을 사용해서 달성할 수 있습니다.

  2. 어떤 이유로든 트랜잭션이 실패로 끝날 경우, 프로세스 메모리에 있는 공유 데이터에는 아무런 변경이 가해지지 않아야 합니다. 이것은 프로세스 메모리에 있는 공유 데이터에 가할 변경을 격리 보관했다가 DB 트랜잭션이 최종 성공하고 나면 한번에 발생시킴으로써 달성할 수 있습니다.

설계 권고안

DB 트랜잭션과 EngineAPI.LocalLock을 모두 감싸는 클래스를 만드세요. 여기서는 GameTransaction이라고 이름을 지어 보겠습니다.

GameTransactionList<IMessage> 혹은 List<IJsonMessage> 타입의 프로퍼티 SideEffects를 추가합니다.

  1. GameTransaction이 시작하면서, 먼저 변경될 가능성이 있는 모든 공유 데이터에 EngineAPI.LocalLock을 잡습니다. 락 id로는 게임 객체의 고유 id를 그대로 문자열화한 것을 사용합니다. 그리고 나서 바로 DB 트랜잭션을 시작합니다.

  2. 트랜잭션 진행 중에 발생시키고 싶은 사이드 이펙트는 SideEffects 에 달아놓습니다. 트랜잭션 진행 중에는 사이드 이펙트를 발생시키는 것을 금지합니다. 런타임 검사로 막을 수 있는 부분은 막고, 그럴 수 없는 부분은 코드 리뷰로 어떻게든 막습니다.

  3. 트랜잭션을 마지막까지 성공적으로 진행했다면 DB 트랜잭션을 먼저 커밋하고, SideEffects에 들어있는 메시지들 각각의 핸들러를 순차적으로 실행한 뒤, 마지막으로 LocalLock을 풉니다.

이 설계 패턴을 사용하면 위의 '돌에 꽂힌 칼' 예제는 다음과 같이 작성됩니다.

  1. GameTransaction을 시작하면서 '돌에 꽂힌 칼' 프랍의 게임 오브젝트 id와 플레이어의 DocumentId를 넘김 (로직 코드)

1-1. 인자에서 주어진 대로 LocalLock을 얻음 (시스템 코드)

1-2. DB 트랜잭션이 시작되면서 플레이어의 Document 락을 얻음 (시스템 코드)

  1. 필드에서 '돌에 꽂힌 칼' 프랍을 제거하라고 GameTransaction.SideEffects에 기록 (로직 코드)

  2. '엑스칼리버' 아이템 Row를 생성하고 DB 테이블에 추가 (로직 코드)

  3. GameTransaction을 커밋 (로직 코드)

4-1. DB 트랜잭션 커밋 (시스템 코드)

4-2. SideEffects가 모두 실행되면서 필드에서 '돌에 꽂힌 칼' 프랍이 제거됨 (시스템 코드가 로직 코드를 실행)

4-3. '돌에 꽂힌 칼' 프랍의 LocalLock이 해제됨 (시스템 코드)