게임 웹앱 기본키와 슈퍼키 구분 못해서 발생했던 오류와 해결했던 경험

최소성 개념 오해

속성 하나 착각

데이터베이스에서 최소성을 처음 배웠을 때 저는 거의 “최소성 = 속성이 하나여야 한다”라고 외우고 있었습니다. 지금 생각하면 완전히 잘못 이해한 것이지만 당시에는 ‘최소’라는 단어 때문에 기본키는 가능한 한 컬럼 하나로 만들어야 한다고 생각했습니다. 그래서 (유저ID, 시즌ID)처럼 두 개 이상의 속성을 묶어서 사용하는 예제를 보면 오히려 이상하게 느껴졌습니다. “기본키라면 하나여야 하는 것 아닌가?”라는 생각이 먼저 들었습니다. 하지만 최소성은 컬럼의 개수를 하나로 제한한다는 뜻이 아니었습니다. 키를 이루는 속성 중 하나를 제거했을 때 더 이상 데이터를 유일하게 구별할 수 없다면 최소성을 만족합니다.

복합 속성 최소성

게임에서 한 유저가 시즌마다 하나의 최고 기록을 가진다고 생각해보면 이해하기 쉬웠습니다. 유저ID만으로는 부족합니다. 같은 유저가 여러 시즌에 참가할 수 있기 때문입니다. 반대로 시즌ID만으로도 부족합니다. 같은 시즌에는 여러 유저가 참여하기 때문입니다. 하지만 (유저ID, 시즌ID)를 같이 사용하면 특정 유저의 특정 시즌 기록 하나를 정확하게 구별할 수 있습니다. 여기서 유저ID를 빼면 어떤 유저인지 알 수 없고 시즌ID를 빼면 어느 시즌인지 알 수 없습니다. 속성은 두 개지만 둘 다 반드시 필요하기 때문에 최소성을 만족하는 것입니다.

기본키 슈퍼키 차이

유일성 최소성 비교

이 부분을 알고 나니 기본키와 슈퍼키를 보는 기준도 달라졌습니다. 예를 들어 (유저ID, 시즌ID)만으로 게임 기록 하나를 유일하게 구별할 수 있고 둘 중 하나라도 제거하면 구별할 수 없다면 유일성과 최소성을 모두 만족합니다. 반면 (유저ID, 시즌ID, 최고점수) 역시 기록 하나를 유일하게 찾을 수는 있습니다. 하지만 최고점수를 빼더라도 (유저ID, 시즌ID)만으로 충분합니다. 즉, 최고점수는 없어도 되는 속성입니다. 이런 경우 (유저ID, 시즌ID, 최고점수)는 슈퍼키가 될 수 있지만 후보키는 될 수 없습니다.

단일 기본키 고정관념

제가 가장 크게 착각했던 부분도 여기였습니다. 웹 개발을 하다 보면 대부분의 테이블에 id 컬럼을 만들고 자동 증가 값을 넣는 경우가 많습니다. 저도 user_id, post_id, record_id처럼 숫자 하나가 기본키인 테이블에 익숙했습니다. 그러다 보니 기본키는 자연스럽게 컬럼 하나여야 한다고 생각했습니다. 하지만 기본키는 하나의 컬럼으로 만들 수도 있고 여러 컬럼을 묶어서 만들 수도 있습니다. (유저ID, 시즌ID)처럼 두 컬럼을 묶더라도 기본키가 두 개가 되는 것이 아니라 두 컬럼으로 이루어진 하나의 복합 기본키가 됩니다.

게임 순위 기능 오류

기록 테이블 초기 설정

이 개념을 제대로 모르고 있던 상태에서 시즌별 점수 순위를 보여주는 게임 웹앱을 만들었습니다. 게임이 끝나면 서버에 점수를 보내고 유저의 시즌 최고 점수를 저장한 뒤 해당 점수로 순위를 계산하는 기능이었습니다. 처음에는 다음과 비슷한 테이블을 만들었습니다.

기록ID 유저ID 시즌ID 최고점수
1 101 S1 8500
2 205 S1 8100
3 301 S1 7900

중복 기록 첫 발견

기록ID를 자동 증가 값으로 만들고 기본키로 지정했습니다. 처음에는 아무 문제도 없어 보였습니다. 기록ID는 겹치지 않았고 데이터를 저장할 때 오류도 발생하지 않았습니다. 순위 화면도 처음 몇 번 테스트했을 때는 제대로 나왔습니다. 그래서 게임 기록과 관련된 DB 작업은 끝났다고 생각했습니다. 그런데 테스트를 계속하다 보니 어느 순간 같은 유저의 같은 시즌 기록이 여러 개 들어가 있었습니다.

기록ID 유저ID 시즌ID 최고점수
41 101 S1 8500
42 101 S1 8700
43 101 S1 9200

제가 원했던 것은 101번 유저의 S1 시즌 최고 기록이 하나만 존재하는 것이었습니다. 그런데 DB 입장에서는 기록ID가 41, 42, 43으로 모두 달랐기 때문에 세 행 모두 정상적인 데이터였습니다. 기본키 중복도 아니었고 INSERT 오류도 발생하지 않았습니다. 오히려 오류 메시지 하나 없이 너무 멀쩡하게 저장되는 것이 더 짜증났습니다. 오류라도 발생하면 그 지점부터 찾아보면 되는데 저장은 정상이고 결과만 이상하니 어디서부터 봐야 할지 막막했습니다.

순위 중복 문제

동일 유저 반복 노출

가장 먼저 이상함을 발견한 곳은 순위표였습니다. 테스트 계정 수보다 순위 데이터가 더 많았습니다. 처음에는 테스트하면서 이전 데이터를 안 지운 줄 알고 테이블을 비우고 처음부터 다시 해봤습니다. 그런데 또 같은 현상이 나타났습니다. 조금 더 테스트하니 같은 유저가 순위표에 두 번, 세 번씩 등장했습니다.

순위 유저 점수
1 player101 9200
2 player205 9000
3 player101 8700
4 player330 8600
5 player101 8500

조회 SQL 반복 수정

한 유저가 1위와 3위와 5위를 동시에 차지하고 있는 황당한 상황이었습니다. 처음 이 화면을 봤을 때는 정말 어이가 없었습니다. 분명 유저별 최고 점수로 순위를 만들었는데 같은 닉네임이 계속 나왔습니다. 처음에는 당연히 순위 조회 SQL을 의심했습니다. ORDER BY가 잘못됐나 싶어 다시 확인했고 GROUP BY를 추가하고 MAX()로 유저별 가장 높은 점수만 가져오도록 바꿔보기도 했습니다. 그렇게 하면 화면은 일단 정상처럼 보였습니다. 그런데 DB 테이블을 직접 확인하면 같은 유저와 같은 시즌 데이터는 그대로 여러 건 남아 있었습니다. 그때부터 슬슬 빡치기 시작했습니다. 제가 해결한 것은 오류가 아니라 화면에서 오류를 가려놓은 것뿐이었기 때문입니다.

최고점수 갱신 오류

UPDATE INSERT 충돌

최고 점수를 갱신하는 부분에서도 문제가 이어졌습니다. 예를 들어 101번 유저가 S1에서 기존 최고점수 8500점을 가지고 있다가 9200점을 달성했다면 제가 원했던 것은 기존 행의 8500을 9200으로 바꾸는 것이었습니다. 그런데 테스트를 반복하다 보면 기존 8500점 데이터는 그대로 있고 9200점 데이터가 새로운 행으로 추가되는 경우가 있었습니다. 게임을 많이 플레이할수록 같은 유저의 같은 시즌 기록이 계속 늘어났습니다.

기능별 점수 불일치

더 골치 아픈 것은 다른 기능에서도 문제가 생겼다는 점입니다. 순위표에서는 MAX()로 가장 높은 점수를 가져오고 있었는데 개인 기록 화면에서는 가장 최근 데이터를 조회하고 있었습니다. 또 다른 곳에서는 첫 번째로 조회된 값을 사용하고 있었습니다. 같은 유저의 같은 시즌 기록인데 어떤 화면에서는 8500점이 나오고 다른 화면에서는 9200점이 나오는 일이 생겼습니다. 하나의 문제가 순위표, 개인 기록, 참가자 수까지 건드리기 시작하니 처음보다 훨씬 일이 커졌습니다.

원인 추적 반복

프론트엔드 중복 의심

처음에는 게임 종료 버튼을 빠르게 두 번 눌러 API가 중복 호출되는 것이라고 생각했습니다. 그래서 점수 전송을 시작하면 버튼을 비활성화했고 요청이 끝나기 전에는 다시 호출할 수 없도록 수정했습니다. 수정 직후에는 중복이 안 생기는 것 같아서 “드디어 잡았다” 싶었습니다. 그런데 테스트 횟수를 늘리니 다시 중복 데이터가 하나 생겼습니다. 그 순간 정말 맥이 빠졌습니다. 한참 확인하고 고쳤는데 또 나타나니 제가 확인하지 못한 다른 문제가 있다는 뜻이었습니다.

서버 코드 중복 의심

이번에는 서버를 의심했습니다. 점수를 저장하기 전에 기존 (유저ID, 시즌ID) 데이터가 있는지 먼저 조회하고 데이터가 있으면 UPDATE, 없으면 INSERT하도록 만들었습니다. 코드만 보면 아무 문제가 없어 보였습니다. 테스트도 몇 번은 정상적으로 통과했습니다. 그런데 어느 순간 또 같은 유저와 시즌 조합이 두 건 들어왔습니다. 이쯤 되니 사소한 코드 한 줄까지 전부 의심하게 됐습니다. 로그를 더 찍고 요청 시간을 확인하고 SQL 실행 결과를 보고 다시 게임을 플레이했습니다. 그런데 재현하려고 하면 멀쩡하고 한동안 테스트하다 보면 다시 하나씩 생겼습니다. 이런 간헐적인 문제가 정말 사람을 지치게 했습니다.

동시 요청 원인 발견

조회 시점 경쟁 문제

계속 로그를 비교하다가 두 요청이 거의 동시에 들어왔을 때 문제가 발생할 수 있다는 것을 알게 됐습니다. 요청 A가 기존 기록을 조회했는데 아직 데이터가 없었습니다. 거의 같은 시각에 요청 B도 기존 기록을 조회했고 역시 데이터가 없다고 판단했습니다. 그러면 요청 A도 INSERT를 실행하고 요청 B도 INSERT를 실행할 수 있었습니다. 결국 같은 (유저ID, 시즌ID)를 가진 데이터가 두 개 만들어졌습니다. 애플리케이션 입장에서는 두 요청 모두 각자 정상적인 판단을 한 셈이었습니다.

기록ID 기본키 함정

여기서 한동안 보지 못했던 문제가 드디어 보였습니다. 저는 계속 기록ID가 기본키니까 DB 설정에는 문제가 없다고 생각했습니다. 실제로 기록ID는 중복되지 않았습니다. 41과 42는 완전히 다른 값이었습니다. 데이터베이스도 제가 지정한 조건을 정확하게 지키고 있었습니다. 문제는 데이터베이스가 이상했던 것이 아니라 제가 무엇이 유일해야 하는지를 잘못 지정했던 것이었습니다.

게임 기록 유일성 발견

한 유저 한 시즌 조건

제가 정말 보장하고 싶었던 조건을 문장으로 다시 적어보니 답이 보였습니다. “한 유저는 한 시즌에 하나의 최고 기록만 가진다.” 그렇다면 기록ID가 다르다는 사실은 중요하지 않았습니다. 실제 게임에서 중복되면 안 되는 것은 (유저ID, 시즌ID) 조합이었습니다. 101번 유저의 S1 기록은 하나만 존재해야 했습니다.

최소성 개념 재발견

이때 예전에 공부했던 최소성이 갑자기 떠올랐습니다. 유저ID 하나만으로는 안 됩니다. 101번 유저가 S1과 S2에 모두 참여할 수 있기 때문입니다. 시즌ID 하나만으로도 안 됩니다. S1에는 101번뿐만 아니라 여러 유저가 참여합니다. 하지만 (유저ID, 시즌ID)를 함께 사용하면 특정 기록 하나를 정확하게 찾을 수 있습니다. 유저ID를 빼면 안 되고 시즌ID를 빼도 안 됩니다. 둘 다 반드시 필요합니다. 그제야 “아, 이게 최소성이구나”라는 생각이 들었습니다.

복합 기본키 이해

최소 개수 오해 해소

그동안 저는 최소성을 컬럼 개수로 생각했습니다. 하나가 두 개보다 적으니 하나가 더 최소라고 생각했던 것입니다. 그런데 실제 의미는 전혀 달랐습니다. 컬럼이 두 개든 세 개든 상관없었습니다. 데이터를 유일하게 구별하기 위해 모두 필요한 속성이라면 최소성을 만족합니다. 반대로 여러 속성을 사용했는데 그중 일부를 빼도 데이터를 유일하게 구별할 수 있다면 최소성을 만족하지 않습니다. 최소성은 하나만 사용하라는 의미가 아니라 필요 없는 속성을 키에 포함하지 말라는 의미였습니다.

복합 기본키 납득

이걸 이해하고 나니 그동안 이상하게 보였던 복합 기본키도 바로 이해됐습니다. (유저ID, 시즌ID)가 기본키가 될 수 있다는 것이 더 이상 이상하지 않았습니다. 두 컬럼이 합쳐져 하나의 기록을 구별하고 둘 중 어느 것도 제거할 수 없기 때문입니다. 책에서 봤을 때는 그냥 시험에 나오는 개념처럼 느껴졌는데 제가 직접 만든 게임 DB에서 같은 문제를 겪으니 훨씬 확실하게 이해됐습니다.

데이터베이스 제약 수정

복합 기본키 적용

한 가지 방법은 (유저ID, 시즌ID) 자체를 복합 기본키로 사용하는 것이었습니다.

PRIMARY KEY (유저ID, 시즌ID)

이렇게 지정하면 같은 유저와 같은 시즌 조합은 두 번 저장할 수 없습니다. 요청이 거의 동시에 들어오더라도 데이터베이스에서 중복 저장을 막을 수 있습니다.

UNIQUE 제약 적용

다만 제가 만든 게임 웹앱에서는 기존 기록ID를 다른 기능에서도 사용하고 있어서 갑자기 없애기에는 손댈 곳이 많았습니다. 그래서 기록ID는 기존처럼 기본키로 유지하고 (유저ID, 시즌ID)에 UNIQUE 제약을 추가했습니다.

PRIMARY KEY (기록ID)
UNIQUE (유저ID, 시즌ID)

이렇게 바꾸면 기록ID는 그대로 사용할 수 있으면서 동일한 유저와 시즌 조합이 여러 건 저장되는 것도 막을 수 있었습니다.

중복 차단 성공

강제 중복 테스트

수정하고 가장 먼저 한 것은 일부러 중복을 발생시키는 테스트였습니다. 같은 계정으로 같은 시즌에 점수 요청을 연달아 보내고 거의 동시에 요청도 보내봤습니다. 이전에는 아무 일도 없다는 듯 두 행이 들어갔는데 이제는 두 번째 중복 저장을 데이터베이스가 거부했습니다. 그 순간 정말 속이 시원했습니다. 며칠 동안 사람을 미치게 만들던 문제가 정확한 지점에서 막히는 것을 직접 보니 그동안 쌓였던 답답함이 한꺼번에 사라졌습니다.

데이터 확인 쾌감

특히 DB 테이블을 직접 확인했을 때가 기억에 남습니다. 예전 같으면 같은 유저ID와 시즌ID가 두 줄, 세 줄씩 생겼는데 아무리 테스트해도 한 줄만 남았습니다. 최고 점수만 정상적으로 갱신되고 중복 행은 더 이상 생기지 않았습니다. 몇 시간 동안 프론트엔드와 서버 코드를 계속 뜯어보며 막으려고 했던 문제가 제약 하나를 제대로 설정하니 정확하게 해결됐습니다. 단순히 오류 하나를 고쳤다는 것보다 제가 모르고 있던 이유를 드디어 찾아냈다는 데서 오는 쾌감이 더 컸습니다.

순위 기능 정상화

중복 유저 제거

DB에서 동일한 (유저ID, 시즌ID)를 허용하지 않게 하니 순위표에서도 같은 유저가 여러 번 등장하지 않았습니다. 실제 참가 인원과 순위 계산에 사용하는 데이터 수도 맞기 시작했습니다. 예전에는 조회할 때마다 유저별 최고 기록만 다시 골라내야 했는데 이제는 애초에 한 유저의 한 시즌 기록이 한 건밖에 없으니 SQL도 훨씬 단순해졌습니다.

최고점수 갱신 안정화

최고 점수 갱신도 편해졌습니다. 특정 유저와 특정 시즌을 조회하면 데이터가 하나만 나온다는 것을 DB가 보장하니 8500점에서 9200점으로 갱신하는 작업도 명확해졌습니다. 예전처럼 같은 유저의 데이터가 몇 건인지 확인하거나 그중 가장 높은 값을 다시 골라내는 코드를 붙일 필요도 없어졌습니다. 문제를 감추기 위해 추가했던 코드를 하나씩 없앨 때도 상당히 기분이 좋았습니다.

슈퍼키 후보키 이해

불필요 속성 비교

이 경험 이후 슈퍼키와 후보키도 훨씬 쉽게 이해할 수 있었습니다. (유저ID, 시즌ID, 최고점수)도 기록 하나를 유일하게 구별할 수 있으므로 슈퍼키가 될 수 있습니다. 하지만 최고점수를 제거해도 (유저ID, 시즌ID)만으로 충분히 기록을 찾을 수 있습니다. 따라서 최고점수는 키에 반드시 필요한 속성이 아닙니다. 최소성을 만족하지 않으므로 후보키가 될 수 없습니다.

필수 속성 비교

반대로 (유저ID, 시즌ID)는 유저ID를 빼면 특정 유저를 구별할 수 없고 시즌ID를 빼면 어느 시즌인지 구별할 수 없습니다. 두 속성이 모두 필요합니다. 따라서 유일성과 최소성을 모두 만족하는 후보키가 될 수 있고 후보키 중 하나를 기본키로 선택할 수 있습니다. 결국 슈퍼키는 유일하게 구별할 수 있느냐가 중요하고 후보키는 여기에 불필요한 속성이 포함되어 있지 않느냐까지 확인해야 합니다.

오류 해결 경험

며칠간 반복 확인

처음에는 단순한 순위표 오류라고 생각했습니다. 그런데 잡으려고 하니 프론트엔드 버튼부터 API 호출, 서버 코드, 조회 SQL, INSERT와 UPDATE까지 전부 다시 보게 됐습니다. 한 번 고쳤다고 생각하면 다시 중복이 생기고 재현하려고 하면 또 멀쩡했습니다. 특히 “이번에는 진짜 해결했다”고 생각한 직후 다시 중복 행을 발견했을 때가 가장 짜증났습니다. 제가 모르는 문제가 어디엔가 숨어 있다는 생각 때문에 멀쩡한 코드까지 계속 의심하게 됐습니다.

원인 발견 후 해소

결국 원인은 의외로 가까운 곳에 있었습니다. 기록ID가 유일하다는 사실만 보고 안심하고 있었고 실제 서비스에서 유일해야 하는 (유저ID, 시즌ID)를 DB에서 보장하지 않고 있었습니다. 이 사실을 알아낸 뒤 제약을 추가하고 중복 요청이 실제로 차단되는 모습을 봤을 때는 정말 후련했습니다. 오랫동안 이해하지 못했던 기본키, 슈퍼키, 최소성이 실제 오류 하나를 통해 한 번에 연결됐습니다.

기본키 관점 변화

단일 ID 고정관념 탈출

이 일을 겪은 뒤부터는 테이블을 만들 때 무조건 id 컬럼 하나만 보고 끝내지 않게 됐습니다. 게임 데이터라면 한 유저가 한 시즌에 하나의 기록을 가져야 하는지, 한 스테이지마다 별도의 기록을 가져야 하는지, 게임 모드마다 기록이 달라져야 하는지를 먼저 생각하게 됐습니다. 경우에 따라 (유저ID, 시즌ID), (유저ID, 스테이지ID), (유저ID, 시즌ID, 게임모드ID)처럼 여러 컬럼이 함께 필요할 수도 있습니다.

최소성 최종 이해

결국 제가 가장 오래 잘못 알고 있었던 부분은 한 문장으로 설명할 수 있었습니다. 최소성은 컬럼을 하나만 사용하라는 뜻이 아니라, 하나라도 없어도 되는 컬럼을 키에 포함하지 말라는 뜻입니다. 게임 웹앱의 순위 오류를 직접 겪기 전까지는 이 개념을 시험 문제에서 외우는 문장 정도로 생각했습니다. 하지만 중복 데이터 때문에 한참 헤매고 원인을 찾아낸 뒤에는 왜 데이터베이스에서 유일성과 최소성을 따지는지 확실하게 알게 됐습니다. 무엇보다 며칠 동안 저를 괴롭히던 중복 기록이 더 이상 생기지 않는 것을 확인했을 때의 쾌감은 아직도 기억에 남아 있습니다.

결론

기본키와 슈퍼키를 제대로 구분하지 못했을 때는 기록ID만 유일하면 된다고 생각했지만, 실제 게임에서는 (유저ID, 시즌ID)처럼 서비스에서 반드시 하나만 존재해야 하는 조합이 따로 있었습니다. 최소성은 컬럼을 하나만 사용하라는 뜻이 아니라 불필요한 속성을 포함하지 않는다는 의미였고, 이 개념을 이해한 뒤 복합키와 UNIQUE 제약을 적용하면서 중복 기록과 순위 오류를 해결할 수 있었습니다.

FAQ

기본키는 반드시 컬럼 하나로 만들어야 하나요?

아닙니다. 기본키는 하나의 컬럼으로 만들 수도 있고 여러 컬럼을 묶어서 만들 수도 있습니다. 여러 컬럼을 하나의 기본키로 사용하는 것을 복합 기본키라고 합니다. 예를 들어 (유저ID, 시즌ID) 두 값이 모두 있어야 게임 기록 하나를 구별할 수 있다면 두 컬럼을 묶어서 기본키로 사용할 수 있습니다.

최소성은 속성 개수가 하나라는 뜻인가요?

아닙니다. 최소성은 속성의 개수가 하나여야 한다는 뜻이 아닙니다. 키에 포함된 속성 가운데 하나라도 제거했을 때 더 이상 데이터를 유일하게 구별할 수 없다면 최소성을 만족합니다. (유저ID, 시즌ID)에서 유저ID와 시즌ID가 모두 필요하다면 컬럼이 두 개여도 최소성을 만족합니다.

슈퍼키와 후보키는 어떤 차이가 있나요?

슈퍼키는 데이터를 유일하게 구별할 수 있는 속성의 집합입니다. 불필요한 속성이 포함되어 있어도 유일하게 구별할 수 있다면 슈퍼키가 될 수 있습니다. 후보키는 슈퍼키 가운데 최소성까지 만족하는 키입니다. 예를 들어 (유저ID, 시즌ID, 최고점수)에서 최고점수를 빼도 데이터를 구별할 수 있다면 슈퍼키는 될 수 있지만 후보키는 될 수 없습니다.

후보키와 기본키는 같은 건가요?

완전히 같은 의미는 아닙니다. 후보키는 유일성과 최소성을 만족해서 기본키가 될 자격이 있는 키입니다. 후보키가 여러 개 존재할 수 있으며, 그중 실제 테이블의 대표 키로 선택한 하나가 기본키입니다.

기록ID를 기본키로 사용하면 중복 문제가 모두 해결되나요?

그렇지 않습니다. 기록ID가 자동 증가하기 때문에 각각의 행은 유일하게 만들 수 있지만, 게임에서 실제로 중복되면 안 되는 값이 (유저ID, 시즌ID)라면 별도의 제약이 필요합니다. 기록ID가 서로 다르더라도 동일한 유저와 동일한 시즌 데이터가 여러 번 저장될 수 있기 때문입니다.

복합 기본키 대신 UNIQUE 제약을 사용해도 되나요?

가능합니다. 기록ID를 기본키로 유지해야 한다면 (유저ID, 시즌ID)에 UNIQUE 제약을 추가하는 방법을 사용할 수 있습니다. 이렇게 하면 기록ID는 그대로 사용하면서 같은 유저와 같은 시즌 조합이 여러 번 저장되는 것을 데이터베이스에서 막을 수 있습니다.

게임 순위 기능에서는 어떤 값을 유일하게 만들어야 하나요?

게임 규칙에 따라 달라집니다. 한 유저가 시즌마다 최고 기록 하나만 가져야 한다면 (유저ID, 시즌ID) 조합이 유일해야 합니다. 게임 모드까지 구분해야 한다면 (유저ID, 시즌ID, 게임모드ID)처럼 더 많은 컬럼이 필요할 수도 있습니다. 중요한 것은 실제 서비스에서 어떤 데이터가 한 건만 존재해야 하는지를 먼저 판단하는 것입니다.