Skip to content

fix: UUID 생성 시 clockSequence 경합으로 중복 UUID가 생성되는 문제 수정 - #299

Open
z3rotig4r wants to merge 1 commit into
eGovFramework:mainfrom
z3rotig4r:fix/uuid-clock-sequence-race
Open

fix: UUID 생성 시 clockSequence 경합으로 중복 UUID가 생성되는 문제 수정#299
z3rotig4r wants to merge 1 commit into
eGovFramework:mainfrom
z3rotig4r:fix/uuid-clock-sequence-race

Conversation

@z3rotig4r

Copy link
Copy Markdown
Contributor

변경 이유

EgovUUIdGnrServiceImpl의 MAC 주소 기반 UUID 생성은 같은 파일 안의 TimeBasedUUIDGenerator.generateIdFromTimestamp()가 담당합니다. 시간 필드가 밀리초 단위라서, 같은 밀리초에 들어온 호출끼리 UUID를 갈라주는 요소는 clockSequence 하나뿐입니다.

그런데 이 값을 증가시키는 자리와 읽는 자리가 다릅니다. 증가는 synchronized (LOCK) 안에서 하는데 UUID에 실을 값을 읽는 코드는 락 밖에 있습니다. 필드는 volatile도 아닌 static long입니다.

synchronized (LOCK) {
    if (currentTimeMillis > lastTime) {
        lastTime = currentTimeMillis;
        clockSequence = 0;
    } else {
        ++clockSequence;
    }
}

// ... 락 밖
long clockSequenceHi = clockSequence;

두 스레드가 락을 나온 뒤 같은 clockSequence를 읽으면 시간 필드까지 같아지므로, 완전히 동일한 UUID가 나옵니다.

16개 스레드를 CyclicBarrier로 동시에 출발시켜 각각 5,000건씩 모두 80,000건을 생성하면 수정 전에는 5회 실행이 전부 실패합니다. 실행마다 수백 건에서 1,700건대까지 중복이 나옵니다.

변경 내용

고친 곳은 generateIdFromTimestamp() 한 메서드이고, 수정은 두 갈래입니다. 두 번째를 빼면 중복이 남습니다.

  1. clockSequence 읽기를 락 안으로 옮겨 지역 변수 sequence에 담습니다.
  2. UUID 시간 필드의 출처를 인자 currentTimeMillis에서 락 안에서 확정된 lastTime(지역 변수 timestamp)으로 바꿉니다.

2번이 필요한 이유는 이렇습니다. 각 스레드는 락에 들어가기 전에 시계를 읽어 둡니다. 늦게 락에 진입한 스레드는 그사이 시각이 넘어가면서 0으로 리셋된 시퀀스를, 자기가 들고 있던 낡은 시각과 함께 쓰게 됩니다. 그 조합이 앞서 지나간 (시각, 시퀀스) 쌍과 겹칩니다. 락 안에서 확정된 lastTime을 쓰면 이 값이 단조증가하므로 한 번 지나간 쌍은 다시 나오지 않습니다.

같은 부하(16 스레드 × 5,000건)로 세 버전을 5회씩 돌린 결과입니다.

버전 중복 건수 (5회)
수정 전 746 / 854 / 759 / 1,091 / 1,307
1번만 적용 35 / 73 / 98 / 107 / 129
이번 PR (1+2) 0 / 0 / 0 / 0 / 0

수정본은 스레드 2·4·8·16·32·64개, 1만~32만 건 범위에서 20회 넘게 돌려도 중복이 0건입니다.

동시성 검증 테스트 EgovUUIdGnrServiceConcurrencyTest도 함께 추가했습니다. 16 스레드 × 4,000건(총 64,000건)을 동시에 생성해 중복이 없는지 확인합니다. UUID version이 1인지, 하위 48비트에 설정한 MAC 주소가 그대로 실리는지도 같은 클래스에서 확인합니다.

테스트 방법

mvn -B -o -pl Foundation/org.egovframe.rte.fdl.idgnr test
  • 모듈 테스트 37건이 모두 통과합니다.
  • 추가한 동시성 테스트는 2초 안에 끝납니다. 느린 환경을 감안해 @Timeout(30)을 걸어 두었습니다.
  • 수정 전 소스에 이 테스트만 올려 실행하면 5회 모두 중복이 검출되어 실패합니다.
  • 테스트 규모를 64,000건으로 잡은 이유는 아래 '남은 한계'의 65,536건 임계보다 낮게 두기 위해서입니다. 시계 해상도가 거친 환경에서 이번 수정과 무관한 기존 한계 때문에 실패하는 일을 피하려는 목적입니다.

영향 범위

  • 수정 대상은 EgovUUIdGnrServiceImpl.java 아래쪽의 package-private 클래스 TimeBasedUUIDGenerator입니다. 같은 파일 밖에서 호출하는 코드는 없습니다.
  • 단일 스레드에서는 출력이 종전과 같습니다. 이때는 lastTime이 곧 호출자가 넘긴 currentTimeMillis입니다.
  • 출력이 달라지는 경우는 다른 스레드가 시각을 먼저 진행시켰거나 시계가 뒤로 돌아갔을 때입니다. 이 경우 시간 필드에는 호출자가 읽은 시각 대신 락 시점의 최신 시각이 실리고, UUID 시각의 단조성은 오히려 지켜집니다.
  • UUID version은 1 그대로이고, 시간 필드를 low/mid/hi 세 조각으로 나누는 구조도 건드리지 않았습니다. 하위 64비트 구성(clockSequence + hostId)도 동일합니다.
  • 락 구간에는 지역 변수 대입 두 줄만 늘었습니다. 임계 구역 길이는 사실상 그대로입니다.

남은 한계

같은 밀리초 안에서 65,536건을 넘겨 생성하면 clockSequence << 48이 넘쳐 중복이 생길 수 있습니다. 시각을 고정하고 70,000회를 돌리면 수정 전과 수정 후 모두 65,536번째부터 중복 4,464건으로, 수치까지 같게 나옵니다. 기존 자료구조에서 비롯한 한계라 이번 수정으로 달라지지 않았고, 해소하려면 시퀀스 폭이나 시각 해상도를 함께 바꿔야 해서 이번 PR 범위에는 넣지 않았습니다.

TimeBasedUUIDGenerator.generateIdFromTimestamp()는 clockSequence를 락 안에서
증가시키면서 그 값은 락 밖에서 읽었다. 같은 밀리초 안에서 유일성을 담보하는
요소가 clockSequence뿐이므로, 두 스레드가 같은 값을 읽으면 time 성분도 같아져
완전히 동일한 UUID가 생성된다.

락 안에서 (lastTime, clockSequence)를 지역변수로 함께 스냅샷하고 이후 계산은
스냅샷 값만 사용하도록 수정했다. 시간이 역전된 호출(스레드 지연으로 더 오래된
System.currentTimeMillis() 값이 뒤늦게 도착)에도 lastTime 기준으로 계산하므로
(시각, 순번) 쌍의 유일성이 보장된다.

16스레드 x 4,000건 동시 생성 기준 수정 전 중복 283~1,567건, 수정 후 0건이다.
시각이 단조 증가하는 기존 호출 경로에서는 생성 결과가 이전과 완전히 동일하다.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant