나만의 작은 도서관

[TIL][C++] 250728 MMO 서버 개발 67일차: TMP를 사용해서 컴파일 타임에 특정 비트 수만큼 모든 비트 1로 채우기: FullBits, “delete” 상태를 이용한 아이템 삭제 정책, Unique_lock과 lock_guard의 차이 본문

Today I Learn

[TIL][C++] 250728 MMO 서버 개발 67일차: TMP를 사용해서 컴파일 타임에 특정 비트 수만큼 모든 비트 1로 채우기: FullBits, “delete” 상태를 이용한 아이템 삭제 정책, Unique_lock과 lock_guard의 차이

pledge24 2025. 7. 28. 23:17
주의사항: 해당 글은 일기와 같은 기록용으로, 다듬지 않은 날것 그대로인 글입니다. 

TMP를 사용해서 컴파일 타임에 특정 비트 수만큼 모든 비트 1로 채우기: FullBits

template<int32 C>
struct FullBits { enum { value = (1 << (C - 1)) | FullBits<C-1>::value }; };
template<>
struct FullBits<1> { enum { value = 1 }; };
template<>
struct FullBits<0> { enum { value = 0 }; };

 

1. 기본 템플릿 구조

template<int32 C>
struct FullBits { 
    enum { value = (1 << (C - 1)) | FullBits<C-1>::value }; 
};
  • 핵심이 되는 재귀적 템플릿으로, 시프트 연산(’<<’)을 이용해서 C번째 비트를 1로 설정한 다음 이전 비트들과 OR 비트 연산(’|’)을 수행한다.

2. 특수화(Specializtion)

template<>
struct FullBits<1> { enum { value = 1 }; };     // 재귀 종료 조건
template<>
struct FullBits<0> { enum { value = 0 }; };     // 0비트일 때
  • 재귀 템플릿은 재귀 함수가 종료 조건이 필요하듯, 똑같이 종료 조건이 필요하다. 따라서 첫 번째 템플릿처럼 종료 조건을 둔다.
  • 두 번째 템플릿은 0비트가 들어왔을 때 처리한다.

3. 단계별 실행 과정 예시

 

FullBits<4>::value 계산 예시

  1. FullBits <4>: (1 << 3) | FullBits <3>::value = 8 | FullBits <3>::value
  2. FullBits <3>: (1 << 2) | FullBits <2>::value = 4 | FullBits<2>::value
  3. FullBits<2>: (1 << 1) | FullBits <1>::value = 2 | 1
  4. FullBits<1>: 1 (특수화로 종료)
  5. 결과 value의 비트패턴: 1111

 

 

“delete” 상태를 이용한 아이템 삭제 정책

  • 아이템을 팔거나 완전히 소모했다면 해당 아이템을 인벤토리에서 삭제해야 할 것이다. 그런데, 상황에 따라선 삭제를 하면 안 되는 경우도 존재한다.
  • 예를 들어, 유저가 실수로 캐시 아이템이나 값어치가 높은 아이템을 삭제했다고 가정해 보자. 유저는 삭제하고 나서야 실수를 했다는 것을 인지하고, 복구하려고 노력할 것이다. 하지만, 시스템상 유저는 삭제한 아이템을 복구할 수 있는 방법은 그렇게 많지 않다. 다행히 게임에서 재구매 시스템(최근에 판매한 아이템을 다시 살 수 있는 시스템)으로 복구 가능하다면 손쉽게 복구할 수 있겠지만, NPC 상인에게 판매 가능한 아이템에 한정된 경우가 많기 때문에 캐시 아이템은 복구가 불가능하다.
  • 이런 경우 어쩔 수 없이 게임 운영사에게 연락을 취해야 한다. “캐시 아이템을 실수로 지웠으니 복구해 줄 수 없나요?”라고 말이다. 게임 개발사가 이러한 요청을 들어주고 싶다고 결정했다면, 이에 맞는 DB 관리 정책을 만들어뒀어야 한다.
  • 그것이 바로 “delete” 방식이다. 원래대로였다면 더 이상 가지고 있지 않은 아이템에 대한 행은 테이블에서 삭제되었겠지만, delete 방식은 그저 해당 아이템의 상태를 delete로 설정하는 것이다. (물론 delete 방식을 사용하려면 애초에 상태를 저장하는 열이 추가로 있어야 한다.)
  • delete 상태가 된 아이템은 유저에게 보이지 않는다. DB에서 유저의 인벤토리 정보를 가지고 올 때, where절을 통해 필터링한 다음 가져오기 때문이다.
  • 개발사는 delete 상태가 된 아이템은 expiredAt과 같은 열을 추가해 추후 “정말로” 삭제할 기간을 설정한다. 언제까지고 가지고 있기에는 부담이 되는 경우가 있기 때문이다.

 

expiredAt을 초과했을 때 어떻게 행을 지우는가? - TTL or Agent

  • NoSQL을 사용하는 경우라면 행에 TTL을 설정하면 된다. 대충 30일 정도로 TTL을 설정해 두면 NoSQL 시스템이 30일 뒤에 알아서 삭제할 것이다.
  • 문제는 RDB인 경우다. RDB는 TTL 속성을 부여할 수 없는 경우가 대부분이다. 그래서 TTL을 이용한 행 자동 삭제가 불가한데, 대신 agent나 event 같은 시스템이 있어, 주기적으로 expiredAt값이 현재 시간보다 작은 행을 지우게 하도록 등록해 둘 수 있다.

 

delete 정책을 적용할 아이템의 기준? - 캐시 또는 등급

  • 모든 아이템에 대해 delete 정책을 적용하는 것은 그리 좋지 못한 생각일 수 있다. MMORPG는 수많은 아이템이 존재하는데 각 아이템마다 delete를 설정해 두면 DB가 저장해야 할 데이터가 몇 배로 늘어나기 때문이다. 안 그래도 DB가 저장하는 데이터 크기가 커질수록 부담이 되는 장르인데, 구태여 몇 배씩이나 크기를 늘리면서 아이템을 복구시켜 주기는 싫을 것이다.
  • 그래서 정말 복구가 필요한 아이템들만 delete 정책을 적용하는 것이 옳다. 그렇다면 delete 정책을 적용할 아이템의 기준은 무엇일까?
  • 캐시 아이템과 등급이 높은 아이템에 적용하면 된다. 현실 재화가 들어가는 경우만 적용하고 싶다면 캐시 아이템에만 적용해도 된다. 어디까지나 내부 정책에 따른 결정이니, 개발자 입장에서 크게 고려해야 할 사항은 아니지 않을까 싶다.

 

C++ DB 관련 함수 이해하기

  • 접미사 ‘A’, ‘W’: SQLExecDirectW(), SQLExecDirectA()와 같이 접미사만 ‘A’ ‘W’로 달리하는 같은 함수가 있다. 이는 문자열 포맷과 관련된 내용으로, A는 char형식, W는 wchar형식의 인자를 받는 함수임을 의미한다.
  • BindParam(): 쿼리문을 작성했을 때 들어가는 변숫값이다. 예를 들어 쿼리문이
    • 와 같다면, 저기? 에 들어가는 값을 설정하는 것이다. BindParma 하나당 매개변수 하나를 바인드 할 수 있으므로, 매개변수가 N개라면, N번 BindParam을 사용해야 한다.
  • SELECT id, gold, name FROM db.Gold WHERE gold = (?)
  • BindCol(): 쿼리문의 결과를 얻을 때 사용한다. 즉, SELECT문의 결과를 얻을때 사용한다. SELECT 쿼리문의 결과를 얻었다면, 해당 결과물이 담긴 stmt에서 BindCol을 통해 하나씩 바인딩해 뜯어온다.
  • SQLExecDirect(): 쿼리문을 DB에게 요청하는 함수이다. 반환값으로 bool 타입이 반환된다.
  • SQLFetch(): SELECT문의 결과를 가져오는 함수이다. 해당 함수는 SELECT문을 SQLExecDirect 계열 함수로 요청을 성공적으로 끝냈을 때 사용한다.

 

전체적인 요약

1) BindParam()을 쿼리문 매개변수만큼 한다.
2) SQLExecDirect()를 호출해 DB에 쿼리문 전송
3-1) 전송한 쿼리문이 SELECT라면 SQLFetch() 호출
3-2) BindCols()를 SELECT문 호출시 설정한 컬럼 개수만큼 바인드해서 가져온다.

 

 

Unique_lock과 lock_guard의 차이

  • 둘 다 Scope 기반의 락 관리를 한다. 즉, 해당 객체가 선언된 { }를 벗어나면 락이 자동으로 해제된다.
  • 기본적으로 이러한 특성을 활용하기 위한 방식으로 lock_guard를 사용한다고 알고 있다. 그렇다면 unique_lock 왜 있는 것일까?
  • 그것은 바로 unique_lock은 Scope 내에서 수동으로 락을 잠그거나/해제할 수 있기 때문이다. lock_guard의 경우 한 번 락을 잠그면 Scope를 벗어나기 전까진 잠그거나 해제할 수 없다. unique_lock은 이러한 락의 수동 잠금/해제가 자유롭다는 특성을 가져, condition_variable에서 사용한다.

 

condition_variable에서 왜 unique_lock을 사용해야 하는가?

  • condition_variable은 락을 점유해도, 조건에 맞을 때만 다음으로 넘어갈 수 있도록 하는 기능으로, 일종의 이벤트 방식이다.
  • condition_variable도 결국 락을 잡기 때문에 락과 관련된 객체를 사용하는데, 이때 lock_guard를 사용하면 안 되고 반드시 unique_lock을 사용해야 한다.
  • 왜냐하면 conditon_variable은 락은 잡은 상태에서 조건이 맞지 않으면 다시 락을 해제(unlock)해야 하기 때문이다. 위에서 말했듯, lock_guard는 “한 번 잠근 락은 Scope를 벗어나기 전까지 해제할 수 없기 때문에”조건이 안 맞았을 때 해제하는 작업을 수행할 수 없다. 따라서, 수동 해제가 자유로운 unique_lock을 사용하는 것이다.

 

condition_variable의 작동 과정 예시 - producer-consumer

  1. Producer와 Consumer 역할을 정한다.

Producer

  • condition_variable(이하 cv)에 등록해 둔 mutex의 락을 잡는다.
  • queue에 데이터를 밀어 넣고 락을 해제한다.
  • 데이터를 넣었으니 cv.notify_one을 호출해 대기 중인 스레드 하나를 깨운다.

Consumer

  • while문에 진입한다.
  • cv에 등록하는 mutex의 락을 잡는다.
  • queue에 데이터가 있는지 확인한다.
    • 만약 데이터가 있다면 그대로 코드를 진행.
    • 데이터가 없다면, “락을 해제하고” sleep
      • sleep상태에서 Producer의 notify_one에 의해 깨워지면, 락을 다시 잡고, 조건을 확인한다.
      • 조건이 충족되면 그대로 코드를 진행한다.

 

 

왜 notify_one으로 깨워났음에도 조건을 확인하는가? - 가짜 기상(spurious wakeup)

  • consumer역할을 맡는 스레드가 여러 개라면, notify_one으로 깨운 시점과 깨어난 스레드가 작업을 하는 시점 사이에 다른 스레드가 락을 잡고 해당 데이터를 가져가는 상황이 발생하기 때문.
    • 이 경우, 분명 데이터 있으니 가져가라고 깨웠지만 데이터는 없는 “양치기 행동”을 당하게 된다.
    • 그래서 반드시 락을 잡고 데이터가 있는지 확인하는 과정을 통해 정상적인 흐름인지 체크해야 한다.(만약 체크를 하지 않고 넘어갔다면, q.front()하는 순간 오류가 터질 수도 있다.)