본문 바로가기
IT/개발 & IT 생산성 툴

게임 서버 MSSQL 실무 예제: 자주 쓰는 SQL 쿼리 20선과 성능 튜닝 팁

by logro 2025. 5. 10.
반응형

게임 서버 개발에서는 데이터베이스(SQL) 활용이 필수적입니다. 수천만 건의 유저 정보와 로그, 랭킹 데이터를 신속하게 조회하고 처리하려면 효율적인 SQL 쿼리 작성 능력이 필요합니다. 그러나 초보 개발자들은 종종 비효율적인 쿼리로 인해 서버 부하나 지연을 겪곤 합니다. 이 글에서는 MSSQL을 사용하는 게임 서버 환경을 기반으로, 실무에서 자주 쓰이는 SQL 쿼리 20가지 예제와 함께 각각의 성능 튜닝 팁을 소개합니다.

 

예제 시나리오는 게임 도메인에 맞춰 가정했습니다:

  • Users 테이블 – 유저 기본 정보 (UserID, UserName, Level, JoinDate, LastLogin 등)
  • LoginHistory 테이블 – 유저 로그인 기록 (RecordID, UserID, LoginTime 등)
  • Transactions 테이블 – 게임 재화 변동 내역 (TransID, UserID, Amount, Type, TransDate 등)
  • Events 테이블 – 게임 이벤트 목록 (EventID, EventName, ...), 
  • EventParticipation – 이벤트 참여 기록 (UserID, EventID, JoinDate 등)
  • UserScores 테이블 – 유저별 점수/랭킹 정보 (UserID, Score 등)

각 쿼리마다 코드 예시 설명, 그리고 튜닝 포인트를 담았습니다. 초보자도 이해할 수 있도록 하나씩 차근히 살펴보면서, 쿼리 실행 계획 확인 방법, 인덱스 활용 요령, 성능 저하 원인과 개선 전략까지 함께 알아보겠습니다.


자주 쓰는 SQL 쿼리 20선 (예제 및 튜닝 팁)

1. 전체 유저 목록 조회 (SELECT *)

SELECT * 
FROM Users;

 

설명: 가장 기본이 되는 SELECT 구문으로, Users 테이블의 모든 컬럼과 모든 행을 조회합니다. 게임 서버에서 관리하는 전체 유저 목록을 확인해야 할 때 사용할 수 있는 간단한 쿼리입니다.

 

성능 튜닝 팁: SELECT * 는 편리하지만 불필요한 컬럼까지 조회하기 때문에 네트워크 트래픽과 I/O 부하를 높일 수 있습니다. 실무에서는 필요한 컬럼만 지정하여 조회하는 것을 권장합니다. 필요한 컬럼만 선택하면 데이터 전송량이 줄고, 경우에 따라 커버링 인덱스(covering index)를 통해 테이블을 읽지 않고 인덱스만으로도 쿼리를 처리할 수 있어 더 빠릅니다. 예를 들어 UserID와 Level만 필요한 경우 아래와 같이 쓰는 것이 효율적입니다.

SELECT UserID, Level 
FROM Users;

2. 특정 컬럼 조회 및 별칭 사용 (SELECT column AS alias)

SELECT UserID, UserName AS 이름, Level AS Lv 
FROM Users;

 

설명: 특정 컬럼만 선택하고, AS 키워드를 사용해 컬럼에 별칭(alias)을 부여하는 예제입니다. 위 쿼리는 UserID, UserName, Level 컬럼만 조회하며, 출력 시 UserName을 "이름"으로 표시하고 Level을 "Lv"로 표시합니다. 별칭은 결과를 읽기 쉽게 하거나, 이후 연산에서 컬럼명을 간편하게 사용할 때 유용합니다.

 

성능 튜닝 팁: 이 쿼리는 필요한 컬럼만 조회하므로 불필요한 데이터 접근을 줄이는 효과가 있습니다. 특히 대용량 테이블에서 일부 컬럼만 사용할 경우 해당 컬럼에 인덱스가 존재하면 성능이 향상될 수 있습니다. 예를 들어 UserName 컬럼에 인덱스가 있고, 그 컬럼만 조회하면 인덱스 Seek만으로도 결과를 얻을 수도 있습니다. 또한, 별칭 자체는 성능에 영향은 없지만, SQL을 읽기 쉽게 하여 유지보수 생산성을 높여줍니다.

3. 조건 조회 (WHERE 절 - 숫자 조건)

SELECT UserID, UserName, Level 
FROM Users
WHERE Level >= 50;

 

설명: WHERE 절을 사용하여 조건에 맞는 행만 필터링하는 예시입니다. 이 쿼리는 Users 테이블에서 Level이 50 이상인 고레벨 유저들만 조회합니다. 게임에서 특정 레벨 이상의 유저 목록을 뽑거나, 조건에 따른 유저 통계를 볼 때 활용할 수 있습니다.

 

성능 튜닝 팁: WHERE 조건을 사용하면 데이터베이스는 조건에 맞는 행만 찾기 위해 탐색합니다. 이때 조건 컬럼(Level)에 인덱스가 있으면 해당 조건에 맞는 행만 빠르게 찾아낼 수 있어 성능이 향상됩니다. 반대로 인덱스가 없으면 테이블 전체를 스캔해야 할 수 있습니다. 예를 들어 유저가 수백만 명이라면, Level 컬럼에 인덱스가 있는 경우와 없는 경우 속도 차이가 크게 날 것입니다. 자주 사용하는 검색 조건(예: Level, IsActive 등)은 인덱스를 생성해두고, 범위 조건(>= 50 등)을 걸 때는 매치되는 행의 비율이 적을수록 인덱스 효율이 높다는 점도 참고하세요. 또한, 조건 비교 시 컬럼을 그대로 사용해야 인덱스를 활용할 수 있습니다 (이 부분은 뒤에서 자세히 설명합니다).

4. 부분 문자열 검색 (WHERE 절 - 문자 패턴 LIKE)

SELECT UserID, UserName 
FROM Users
WHERE UserName LIKE 'Knight%';

 

설명: LIKE 연산자를 사용하여 문자 패턴으로 필터링하는 쿼리입니다. UserName LIKE 'Knight%'는 이름이 "Knight"로 시작하는 모든 유저를 조회합니다. 게임에서 닉네임이 특정 단어로 시작하는 유저를 찾거나, 부분 검색 기능을 구현할 때 쓰일 수 있습니다.

 

성능 튜닝 팁: LIKE 패턴 검색은 와일드카드(%) 사용 위치에 따라 성능 차이가 큽니다. 접두사 패턴(예: 'Knight%')의 경우 해당 컬럼(UserName)에 인덱스가 있으면 인덱스 탐색으로 효율적으로 찾을 수 있습니다. 그러나 포함/접미사 패턴(예: '%Knight%' 또는 '%Knight')은 인덱스를 활용하지 못하고 전체 탐색이 발생하기 쉽습니다. 따라서 접두사로 검색하는 것이 성능에 유리하며, 만약 이름 내에 포함된 단어 검색이 잦다면 전문 검색 인덱스(Full-text index) 도입을 고려해야 합니다. 또한 대소문자나 초성 검색 등을 위해 문자열 함수를 WHERE절에 사용하면 인덱스 효용이 떨어지므로, 가능하면 컬럼 그대로 비교하거나 데이터 대비 테이블을 활용하는 전략이 필요합니다.

5. 날짜 범위 조회 (WHERE 절 - 날짜 조건)

SELECT *
FROM LoginHistory
WHERE LoginTime >= '2025-05-01' 
  AND LoginTime < '2025-06-01';

설명: 날짜/시간 범위를 지정해 로그인 기록에서 2025년 5월 한 달간의 데이터를 조회하는 예제입니다. 게임 서버에서 특정 기간의 접속 기록, 예컨대 월간 접속 통계를 뽑을 때 사용할 수 있습니다. LoginTime이 5월 1일 이상 6월 1일 미만인 행만 조회하여, 5월 1일부터 5월 31일까지의 로그를 가져옵니다.

 

성능 튜닝 팁: 날짜 범위 조건은 대량의 데이터 중 일부만 가져올 때 매우 효율적인 패턴입니다. LoginTime 컬럼에 인덱스가 있다면 데이터베이스는 5월 1일 지점부터 5월 31일까지 인덱스 Seek을 통해 필요한 부분만 읽어올 수 있습니다. 그러나 유의사항: LoginTime에 함수를 적용하여 조건을 주면 안 됩니다. 예를 들어 WHERE CONVERT(varchar(10), LoginTime, 120) = '2025-05-01'처럼 컬럼을 가공하면 인덱스를 탈 수 없어서 빠른 검색이 불가능합니다. 실제 사례로, 약 1억 건의 로그 테이블에서 좌변 컬럼을 가공하지 않고 조회했을 때는 약 6만 건만 읽고 끝났지만, 함수를 적용하여 조회하니 1천만 건 이상을 스캔하는 바람에 0.3초도 안 걸리던 쿼리가 35초나 소요된 예시가 있습니다. 그러므로 날짜 조건에서는 BETWEEN 또는 >=, < 연산을 활용하고, 필요하면 애플리케이션 단에서 날짜 포맷을 맞추는 식으로 쿼리에서의 함수 사용을 피하세요.

6. 결과 정렬하기 (ORDER BY)

SELECT UserID, JoinDate
FROM Users
ORDER BY JoinDate DESC;

 

설명: ORDER BY 절을 사용하여 결과를 특정 컬럼으로 정렬하는 예제입니다. 위 쿼리는 Users 테이블의 유저ID와 가입일(JoinDate)을 조회하되, JoinDate 기준 내림차순(DESC)으로 정렬하여 최근 가입한 유저부터 보여줍니다. 정렬은 랭킹 표시나 최근 활동순 정렬 등 게임 내 여러 리스트 출력에 활용됩니다.

 

성능 튜닝 팁: 정렬은 결과를 가공하는 단계이므로 추가 자원이 듭니다. 특히 대량 데이터 정렬 시 성능 영향이 큰데, 이때 인덱스의 도움을 받을 수 있습니다. JoinDate 컬럼에 인덱스가 있다면 데이터가 이미 정렬된 순서로 저장되어 있기 때문에 별도 정렬 비용 없이 순서대로 읽어올 가능성이 높습니다. 예를 들어 클러스터형 인덱스가 JoinDate에 설정되어 있다면 테이블이 그 순서로 저장되므로 빠르게 DESC 순으로 가져올 수 있습니다. 비클러스터형 인덱스라도 커버링 인덱스로 동작하면 추가 정렬이 불필요할 수 있습니다. 반면 인덱스가 없으면 DB는 임시 영역에 모든 결과를 모아 정렬해야 하므로 느려집니다. 요약: 자주 사용하는 정렬 키(날짜, 점수 등)는 인덱스 고려, 필요한 경우 쿼리에서만 정렬되는 소량 데이터로 한정하거나 TOP와 함께 사용하여 불필요한 전체 정렬을 피하는 것이 좋습니다.

7. 상위 N개 조회 (TOP 사용)

SELECT TOP 10 UserName, Level
FROM Users
ORDER BY Level DESC;

 

설명: MSSQL의 TOP 구문을 활용하여 상위 10명의 데이터를 조회하는 예제입니다. 위 쿼리는 Users 테이블에서 Level이 가장 높은 유저 10명을 찾아 이름과 레벨을 출력합니다. 게임에서 랭킹 TOP 10을 구하거나, 상위 몇 개의 결과만 필요할 때 사용됩니다.

 

성능 튜닝 팁: TOP을 사용하면 필요한 부분만 가져오기 때문에 전체 결과를 다루는 것보다 효율적입니다. 특히 랭킹이나 최근 기록처럼 상위 일부만 보는 경우, ORDER BY와 함께 TOP을 쓰면 데이터베이스가 요구된 범위까지만 처리하고 나머지는 무시할 수 있습니다. 인덱스가 있다면 더 효과적입니다. 예를 들어 위 쿼리에서는 Level 컬럼에 인덱스가 있다면 가장 높은 레벨부터 정렬된 순으로 데이터를 가져올 수 있습니다. 실제로 MSSQL 옵티마이저는 인덱스가 있는 경우 TOP + ORDER BY 쿼리에 대해 인덱스의 끝부분부터 탐색하여 필요한 개수만 추출하기도 합니다. 반면 인덱스가 없으면 전체 유저를 정렬한 후 상위 10개를 선택해야 하므로 비효율적입니다. 결론적으로, TOP N 쿼리를 빠르게 하려면 정렬 키에 대한 인덱스 WHERE로 미리 범위를 좁히는 습관이 중요합니다. (예: 활성 유저 중 TOP 10 등)

8. 집계와 그룹화 (GROUP BY 기본)

SELECT UserID, COUNT(*) AS LoginCount 
FROM LoginHistory
GROUP BY UserID;

 

설명: GROUP BY 절을 사용하여 데이터를 그룹화하고 집계 함수(예: COUNT)로 통계를 낸 예제입니다. 이 쿼리는 LoginHistory 테이블에서 UserID별로 그룹을 묶은 뒤 해당 유저의 로그인 횟수를 세어 LoginCount로 보여줍니다. 즉, 유저별 누적 로그인 횟수를 구하는 쿼리입니다. 게임 서비스에서 유저 활동도를 파악하거나 업적 시스템(예: 누적 접속 100회 달성)을 구현할 때 유용합니다.

 

성능 튜닝 팁: 그룹화와 집계는 많은 행을 대상으로 연산하므로, 인덱스 데이터량 감소 전략이 중요합니다. 위 쿼리의 경우 LoginHistory에서 모든 로그를 읽어야 하는데, UserID에 인덱스가 있다면 DB는 인덱스 순으로 로그를 모아가며 COUNT를 계산할 수 있습니다. 이는 인덱스가 없어서 테이블 풀 스캔을 하는 경우보다 훨씬 효율적입니다. 또한, 미리 WHERE로 기간 등을 제한하면 대상 데이터가 줄어들어 속도가 빨라집니다. 예를 들어 최근 30일간의 로그인 횟수만 보고 싶다면 WHERE LoginTime >= ...으로 범위를 좁힌 뒤 GROUP BY를 하는 것이 좋습니다. 정리: GROUP BY 대상 컬럼(UserID)에 인덱스가 있으면 그룹핑에 유리하며, 필요 없는 데이터(기간 밖 로그 등)는 애초에 제외하여 처리량을 줄입니다.

9. 그룹화 결과에 조건 걸기 (HAVING)

SELECT UserID, COUNT(*) AS LoginCount
FROM LoginHistory
WHERE LoginTime >= DATEADD(day, -30, GETDATE())
GROUP BY UserID
HAVING COUNT(*) >= 100;

 

설명: HAVING 절은 그룹화된 결과에 조건을 적용할 때 사용합니다. 이 예시는 최근 30일 동안 로그인 횟수가 100회 이상인 유저를 찾아냅니다. 먼저 WHERE로 최근 30일의 로그만 대상으로 하고, GROUP BY UserID로 유저별 로그인 횟수를 구한 뒤, HAVING COUNT(*) >= 100 조건을 통해 30일 내 100회 이상 접속한 헤비유저만 결과로 남깁니다. 운영 측면에서 핵심 유저군을 파악하거나 이벤트 보상 대상자를 추리는데 활용할 수 있습니다.

 

성능 튜닝 팁: HAVING은 GROUP BY 이후에 적용되므로, 필터링을 가급적 미리 WHERE로 하는 것이 중요합니다 (위 쿼리에서는 최근 30일로 제한). 이렇게 하면 불필요한 과거 데이터에 대해 그룹 계산을 하지 않아도 되죠. 또한 LoginTime이나 UserID 컬럼에 인덱스를 활용하면 앞서 언급한대로 범위 제한과 그룹화가 빨라집니다. HAVING COUNT(*) >= 100 자체는 집계 결과에 대한 조건이라 인덱스로 직접 가속할 수는 없지만, 전체 데이터량을 줄이는 노력이 이 단계까지의 부하를 줄여줍니다. 만약 이 쿼리가 여전히 느리다면, 자주 사용하는 집계 결과(예: 유저별 월간 접속수)를 별도 테이블에 미리 계산해두고 조회하는 캐싱 전략도 고려할 수 있습니다.

10. 고유 값 추출 (DISTINCT)

SELECT DISTINCT Country 
FROM Users;

 

설명: DISTINCT 키워드를 사용하여 중복을 제거한 고유 값 리스트를 얻는 예제입니다. 이 쿼리는 Users 테이블에서 Country(국가) 컬럼의 모든 고유한 값을 뽑아냅니다. 예를 들어 전세계 서비스 중인 게임에서 현재 유저들이 속한 국가 목록을 보고자 할 때 활용할 수 있습니다.

 

성능 튜닝 팁: DISTINCT는 내부적으로 해당 컬럼에 대해 **그룹핑(중복 묶기)**과 동일한 작업을 수행합니다. 따라서 대상 컬럼에 인덱스가 있다면 성능에 도움이 됩니다. 위 예시에서는 Country 컬럼에 인덱스가 있으면 DB가 국가별로 정렬/그룹화하여 유니크 값만 뽑아내기 수월해집니다. 인덱스가 없으면 전체 테이블을 스캔하면서 일시적으로 고유값 집합을 관리해야 하므로 느려질 수 있습니다. 또한 결과 건수가 많을 경우 클라이언트로 전송되는 데이터도 고려해야 합니다. Tip: 상황에 따라 DISTINCT 대신 GROUP BY를 써서 의도를 명확히 하거나, 대량의 고유값을 처리할 때는 애플리케이션 레벨 캐싱을 사용해 반복 실행을 줄이는 것도 방법입니다.

11. 테이블 조인 (INNER JOIN) 기본

SELECT U.UserName, L.LoginTime
FROM Users AS U
JOIN LoginHistory AS L
  ON U.UserID = L.UserID
WHERE L.LoginTime >= '2025-05-01'
  AND L.LoginTime < '2025-06-01';

 

설명: 두 테이블을 INNER JOIN하여 필요한 정보를 조합하는 예제입니다. Users 테이블의 별칭을 U, LoginHistory를 L로 두고, U.UserID = L.UserID로 조인했습니다. 이 쿼리는 2025년 5월 한 달 동안 발생한 로그인 기록을 가져오면서, 각 기록에 대해 유저의 이름(UserName)을 함께 조회합니다. 즉, 특정 기간의 접속 로그를 유저 이름과 함께 보는 용도입니다. JOIN은 데이터베이스에서 가장 강력하고 많이 쓰이는 기능으로, 게임 서버에서도 유저 정보와 아이템, 로그 등을 결합해 조회할 때 자주 사용됩니다.

 

성능 튜닝 팁: 조인 성능의 핵심은 조인 조건 컬럼에 적절한 인덱스가 있는가 입니다. 위 예시에선 Users.UserID와 LoginHistory.UserID가 조인 키인데, 일반적으로 Users.UserID는 기본 키(클러스터 인덱스)일 것이며, LoginHistory.UserID에도 인덱스(예: 비클러스터 인덱스)가 있어야 합니다. 이렇게 하면 데이터베이스가 **중첩 반복 조인(Nested Loops Join)**이나 **병합 조인(Merge Join)**을 통해 효율적으로 매칭할 수 있습니다. 반대로 인덱스가 없으면 해시 조인 등으로 대량 데이터를 메모리에 올려 비교해야 하거나, 최악의 경우 두 테이블을 모두 Full Scan하여 모든 조합을 일일이 맞춰보는 일이 생길 수 있어 성능이 매우 떨어집니다. 또한, 위 쿼리는 LoginHistory에 시점 필터가 있어 5월 로그로 한정하므로 조인 전에 L 테이블의 데이터량을 크게 줄여줍니다 (인덱스가 있으면 해당 기간 로그만 빠르게 추출). 이처럼 데이터 범위를 먼저 줄이고 조인하는 순서도 중요합니다. MSSQL의 옵티마이저가 최적 순서를 찾아주긴 하지만, 개발자도 WHERE절 필터가 조인에 미치는 영향을 이해하고 있어야 합니다.

12. 존재하지 않는 경우까지 조회 (LEFT JOIN)

-- 유저별 누적 소비 금액 (소비 내역이 없는 유저도 포함)
SELECT U.UserName, ISNULL(SUM(T.Amount), 0) AS TotalSpent
FROM Users AS U
LEFT JOIN Transactions AS T
  ON U.UserID = T.UserID AND T.Type = 'SPEND'
GROUP BY U.UserName;

 

설명: LEFT JOIN은 왼쪽 테이블(여기서는 Users)의 모든 행을 유지하면서, 오른쪽 테이블(Transactions)에서 조건이 맞는 행을 가져옵니다. 만약 오른쪽에 해당하는 데이터가 없으면 결과에서 NULL로 표시되는데, 이를 ISNULL(..., 0) 함수를 써서 0으로 치환했습니다. 이 예시는 각 유저(U.UserName)별로 사용한 게임 재화의 총액(TotalSpent)을 계산합니다. Transactions 테이블에서 Type = 'SPEND'인 소비 내역을 합산(SUM)하며, 소비 내역이 전혀 없는 유저도 LEFT JOIN 덕분에 사용자 이름과 합계 0으로 출력됩니다. 즉, 모든 유저의 소비 통계를 한 번에 볼 수 있습니다.

 

성능 튜닝 팁: LEFT JOIN에서도 조인 조건과 인덱스가 중요합니다. Transactions.UserID와 Type에 복합 인덱스가 있다면 (UserID, Type 순 등), 각 유저에 대해 Transactions를 효율적으로 찾고 합산할 수 있습니다. GROUP BY는 U.UserName으로 했지만 사실상 UserID별 그룹과 동일한 효과이므로, Transactions 쪽에서 UserID별로 미리 집계된 결과를 가져오는 전략도 가능합니다. 데이터가 매우 많다면 서브쿼리로 Transactions를 집계하고 조인하는 편이 유리할 수도 있습니다. 하지만 MSSQL은 이러한 패턴을 내부적으로 최적화해주기도 합니다. 주의: LEFT JOIN에서는 오른쪽 테이블 조건(T.Type = 'SPEND')을 ON 절에 넣어야 "해당 유저에 소비 내역이 없으면 NULL"을 올바르게 처리할 수 있습니다. 만약 잘못하여 WHERE T.Type='SPEND'로 작성하면 INNER JOIN과 다름없어져 소비 내역 없는 유저가 결과에서 제외됩니다. 성능 면에서, 불필요한 데이터를 줄이기 위해 조인 조건을 걸었는데 이 또한 인덱스 지원을 받을 수 있도록 하는 것이 좋습니다. 마지막으로, ISNULL(또는 COALESCE)은 단순 값 치환이므로 성능 영향은 무시해도 되지만, SUM 결과가 NULL일 가능성을 대비한 방어적 코드입니다.

13. 서브쿼리 활용 (IN 절)

-- 이벤트 ID 2025에 참여한 유저 목록 (서브쿼리 사용)
SELECT UserID, UserName
FROM Users
WHERE UserID IN (
    SELECT EP.UserID 
    FROM EventParticipation AS EP
    WHERE EP.EventID = 2025
);

 

설명: **서브쿼리(Subquery)**를 사용하는 예입니다. Users 테이블에서 특정 이벤트(이벤트ID = 2025)에 참여한 유저들만 조회하려고 합니다. 이를 위해 WHERE UserID IN ( ... ) 절 안에 하위 쿼리를 넣어, EventParticipation 테이블에서 EventID 2025인 참여 기록의 UserID 목록을 가져왔습니다. 결국 바깥 쿼리는 그 UserID 목록에 들어있는 유저만 선택하므로 이벤트 참가자만 남게 됩니다. 이와 같이 IN 서브쿼리는 어떤 컬렉션(집합)에 속하는지를 필터링할 때 유용합니다.

 

성능 튜닝 팁: 일반적으로 서브쿼리 vs JOIN 중에 어떤 게 빠를지 고민되곤 하는데, 현대 SQL 엔진들은 많은 경우 IN 서브쿼리를 내부적으로 조인으로 변환하여 최적화합니다. 그러나 몇 가지 주의점이 있습니다. 첫째, 서브쿼리 결과에 인덱스가 활용될 수 있게 해야 합니다. 위 예시에선 EP.UserID가 서브쿼리 출력이고 바깥 쿼리의 비교 대상인데, EventParticipation.UserID에 인덱스가 있고, 또한 EventParticipation에서 EventID로 필터링할 때도 인덱스가 있으면 (예: 복합 인덱스 (EventID, UserID)) 서브쿼리 자체는 매우 빠르게 실행됩니다. 둘째, 서브쿼리 결과가 너무 크면 비효율적입니다. 이벤트 참가자가 수십만 명이라면 IN 리스트도 그만큼 커지기 때문입니다. 그런 경우 차라리 JOIN으로 처리하거나 EXISTS 방법(다음 예제 참고)이 나을 수 있습니다. 정리: IN 서브쿼리는 간결하지만 결과 건수가 적당히 작을 때 쓰고, 서브쿼리 내부에 적절한 인덱스를 통해 빠르게 결과를 가져올 수 있도록 설계하세요. 또한, UserID IN (subquery)은 SELECT DISTINCT UserID로 서브쿼리에서 중복을 제거하지 않아도 논리적으로는 동일합니다. DB가 중복은 무시하고 판단하기 때문에, 불필요한 DISTINCT 연산 등을 서브쿼리 내에 넣지 않는 것이 좋습니다.

14. 서브쿼리 활용 (NOT EXISTS 절)

-- 최근 7일간 접속하지 않은 휴면 유저 목록
SELECT U.UserID, U.UserName
FROM Users AS U
WHERE NOT EXISTS (
    SELECT 1 
    FROM LoginHistory AS L
    WHERE L.UserID = U.UserID
      AND L.LoginTime >= DATEADD(day, -7, GETDATE())
);

 

설명: 이번에는 NOT EXISTS를 사용한 반(反)조인 예제입니다. Users 별칭 U를 기준으로, LoginHistory L에서 최근 7일 이내의 로그인 기록이 존재하지 않는 유저만 선택합니다. EXISTS(subquery)는 서브쿼리에 결과가 하나라도 있으면 TRUE가 되고 없으면 FALSE가 되는데, NOT EXISTS는 그 반대입니다. 따라서 위 쿼리는 지난 일주일 간 로그인이 한 번도 없는 유저들, 즉 휴면 유저 목록을 출력합니다. 이벤트나 캠페인 때 7일 이상 미접속자에게 리마인드 메일을 보내는 등의 용도로 사용할 수 있습니다.

 

성능 튜닝 팁: NOT EXISTS는 JOIN으로도 표현할 수 있지만, 가독성 성능 양면에서 좋은 선택입니다. 내부적으로 세미 조인 형태로 동작하여, 조건을 만족하는 첫 행을 찾으면 더 탐색하지 않고 넘어가기 때문에 (SELECT 1 처럼 상수만 SELECT해도 되는 이유입니다) 많은 경우 효율적입니다. 중요한 것은 역시 서브쿼리의 검색 조건에 인덱스 지원이 되어야 한다는 것입니다. 위 예시에선 LoginHistory의 UserID, LoginTime 컬럼 조합에 인덱스가 있으면 특정 유저의 최근 7일 로그인 여부를 금방 파악할 수 있습니다. 없다면 모든 로그인 기록을 뒤져봐야겠죠. 또한, Users 테이블이 기준이므로, Users가 매우 클 경우 이미 휴면인 유저를 별도로 관리하거나, 최근 로그인 날짜를 Users 테이블에 업데이트해두는 등의 데이터 모델링으로 쿼리를 단순화하는 방법도 있습니다. (예를 들어 Users.LastLogin 컬럼이 있다면 이 쿼리는 JOIN 없이 WHERE LastLogin < GETDATE()-7로 훨씬 간단하고 빠르게 구할 수 있습니다.) 정리하면, NOT EXISTS는 "존재하지 않음" 조건을 직관적으로 표현해주며, 적절한 인덱스 하에 N+1 쿼리 없이 한 번에 검출할 수 있어 편리합니다.

15. 상관 서브쿼리 (Correlated Subquery) 사용

-- 유저별 참여한 이벤트 개수 (상관 서브쿼리 이용)
SELECT U.UserName,
    (SELECT COUNT(*) 
     FROM EventParticipation AS EP
     WHERE EP.UserID = U.UserID) AS EventCount
FROM Users AS U;

 

설명: 상관 서브쿼리는 메인 쿼리의 행 하나하나에 대해 실행되는 서브쿼리입니다. 위 예시에서는 Users 테이블의 각 유저(U)에 대해, EventParticipation 테이블을 조회하여 그 유저가 참여한 이벤트가 몇 건인지 세어(COUNT(*)) EventCount로 표시합니다. 결과적으로 유저별 이벤트 참여 횟수를 얻을 수 있습니다. (참여하지 않은 유저는 0으로 나오겠죠.)

 

성능 튜닝 팁: 상관 서브쿼리는 편리하지만, 잘못 사용하면 성능 저하의 주범이 될 수 있습니다. 왜냐하면 Users가 1만 명이면 저 서브쿼리가 1만 번 돌기 때문입니다. MSSQL은 상관 서브쿼리를 최적화하여 꼭 1만 번 개별 실행하지 않고 조인처럼 처리해주기도 하지만, 그렇지 않은 경우 N+1 문제로 이어집니다. 위 쿼리의 경우 차라리 JOIN과 GROUP BY를 사용해서 한 번에 구하는 편이 효율적입니다. 예를 들어 다음과 같이 대체 가능합니다.

SELECT U.UserName, ISNULL(E.EventCount, 0) AS EventCount
FROM Users U
LEFT JOIN (
    SELECT UserID, COUNT(*) AS EventCount
    FROM EventParticipation
    GROUP BY UserID
) E
ON U.UserID = E.UserID;

 

이렇게 하면 EventParticipation을 전체 한 번만 훑어서 유저별 집계를 구한 뒤 조인하므로 훨씬 적은 작업으로 동일한 결과를 얻습니다. 요령: 상관 서브쿼리를 써도 무방한 경우는, 메인 쿼리 결과 건수가 매우 작을 때나 서브쿼리가 인덱스 이용으로 즉시 결과를 줄 때입니다. 그 외에는 가능하면 JOIN이나 다른 집계 방법으로 풀어쓰는 것이 좋습니다. 상관 서브쿼리를 꼭 써야 할 땐, EXISTS 형태로 바꾸는 것도 고려하세요 (EXISTS는 조건 확인 후 바로 중단하므로 COUNT보다는 효율적일 수 있습니다).

16. CASE 식으로 조건부 출력하기

SELECT UserName,
    CASE 
       WHEN DATEDIFF(day, LastLogin, GETDATE()) <= 7 
            THEN '최근 접속'
       ELSE '휴면 유저'
    END AS Status
FROM Users;

 

설명: CASE...WHEN 구문을 사용하여 쿼리 결과에 조건부 로직을 적용하는 예제입니다. 각 유저의 LastLogin 날짜와 현재 날짜(GETDATE())의 차이를 일 단위로 계산해서 7일 이내이면 '최근 접속', 그렇지 않으면 '휴면 유저'라고 표시합니다. 이렇게 하면 유저 목록을 조회하면서 접속 상태를 한눈에 파악할 수 있습니다. 게임 운영툴 등에서 최근 활동 여부를 표시하거나, 조건에 따라 다른 값(예: 등급, 상태 메세지 등)을 출력할 때 활용됩니다.

 

성능 튜닝 팁: CASE 표현식 자체는 계산 로직이므로, 데이터베이스에 큰 부담을 주지 않습니다. 위 쿼리는 모든 유저의 LastLogin을 확인해야 하므로 Users 테이블 full scan이 일어날 수 있지만, LastLogin이 인덱싱되어 있다면(예: LastLogin을 기준으로 정렬된 클러스터형 인덱스) 그 순서로 읽게 될 수도 있습니다. 다만 여기서는 결과를 모두 보여주는 용도라 인덱스 효과가 크지 않을 수 있습니다. 주 의 사 항: CASE 구문 내에서 서브쿼리나 복잡한 계산을 하지 않는 것이 좋습니다. 만약 CASE 내부에서 SELECT를 또 한다면 행마다 쿼리를 수행하게 되어 심각한 성능 저하가 발생합니다. 그리고 WHERE절에서 CASE를 쓰는 일은 없도록 하세요. CASE는 SELECT 출력이나 ORDER BY, GROUP BY에서 사용가능하지만 WHERE 조건식으로는 부적합합니다 (대신 OR 등을 사용). 상기의 DATEDIFF 함수의 경우, 인덱스를 못 타게 하는 변환이긴 하나, LastLogin <= GETDATE()-7 로 바꾸면 동일한 결과를 인덱스로 검사할 수도 있습니다. 즉, 조건부 출력은 CASE로, 조건부 필터는 WHERE로 구분하는 것이 핵심입니다.

17. 윈도우 함수 – 순위 구하기 (RANK)

SELECT UserID, Score,
    RANK() OVER (ORDER BY Score DESC) AS Rank
FROM UserScores;

 

설명: **윈도우 함수(Window Function)**를 사용한 예제로, 전체 UserScores 테이블에 대해 Score 기준 내림차순으로 **랭킹(RANK)**을 부여합니다. RANK() OVER (ORDER BY Score DESC)는 점수가 가장 높은 사람에게 1등을 주고, 그 다음 등수를 매깁니다. 동점자가 있을 경우 같은 랭크를 부여하고 다음 랭크는 건너뛰는 방식입니다. (예: 1위, 2위, 2위가 있다면 다음은 4위). 결과적으로 각 유저의 현재 순위를 함께 출력할 수 있습니다. 게임의 PvP 랭킹, 공헌도 랭킹 등에 활용할 수 있습니다.

 

성능 튜닝 팁: 윈도우 함수는 결과를 전체 집합에 대해 계산하기 때문에, 일반적인 GROUP BY보다 많은 데이터를 다룰 수 있습니다. 위 쿼리는 모든 유저의 Score를 일단 정렬해야 순위를 매길 수 있는데, 만약 Score에 인덱스가 있다면 그 정렬을 인덱스 순서로 처리할 수 있어 유리합니다. 그래도 많은 행에 순위를 메기면 DB 부하는 생길 수밖에 없으므로, 필요에 따라 TOP N과 결합해서 상위 랭킹만 구하거나, 윈도우 함수를 PARTITION으로 범위를 나눠서 계산할 수도 있습니다. 예를 들어 전체 랭킹이 아니라 길드별 랭킹이라면 PARTITION BY GuildID ORDER BY Score DESC 식으로 윈도우 함수를 쓰면 됩니다. 인덱스도 이 경우 (GuildID, Score) 복합 인덱스가 있으면 효율적이겠죠. 정리: 순위 매기기 같은 윈도우 함수 활용은 편리하지만, 대용량 데이터에 대해서는 인덱스와 결합해도 비용이 높습니다. 가능하면 필요한 범위로 한정하거나, 정말 많은 데이터라면 주기적으로 별도 테이블에 랭크를 계산해 저장하고 조회하는 방법(예: 랭킹 보드 캐싱)도 고려해야 합니다.

18. 윈도우 함수 – 최근 기록 추출 (ROW_NUMBER)

-- 각 유저별 마지막 로그인 기록 가져오기 (윈도우 함수 활용)
SELECT UserID, LoginTime
FROM (
    SELECT UserID, LoginTime,
        ROW_NUMBER() OVER (
            PARTITION BY UserID 
            ORDER BY LoginTime DESC
        ) AS rn
    FROM LoginHistory
) AS sub
WHERE sub.rn = 1;

 

설명: 윈도우 함수의 또 다른 활용으로 **ROW_NUMBER()**를 사용한 예입니다. 이 쿼리는 LoginHistory 테이블에서 유저별 가장 최신 로그인 기록만 가져옵니다. 서브쿼리 sub 내에서 PARTITION BY UserID ORDER BY LoginTime DESC를 하면 유저마다 로그인시간 내림차순으로 1,2,3,... 순번을 매기는데, 그 중 rn = 1인 것만 외부에서 선택했습니다. 결국 각 UserID 그룹에서 최신 LoginTime 하나씩만 남게 됩니다. 이는 LEFT JOIN이나 서브쿼리로 Max(LoginTime)을 구하는 방법 (앞선 예제 12의 변형) 없이도, 간단히 윈도우 함수로 그룹별 최댓값 레코드 추출을 구현한 것입니다.

 

성능 튜닝 팁: 이러한 최신 기록 per 그룹 쿼리는 굉장히 흔하며, 윈도우 함수를 쓰면 코드가 직관적입니다. 다만 성능은 내부적으로 LoginHistory를 전부 읽은 후 정렬 및 partition 연산을 해야 하므로, 데이터량이 많으면 비용이 큽니다. 인덱스 활용으로 최적화하려면 (UserID, LoginTime) 복합 인덱스가 필요합니다. 그런 인덱스가 있으면 DB는 우선 UserID별로 모인 순서대로 데이터를 읽을 수 있고, 각 UserID 내에서는 LoginTime 역정렬이므로 첫 번째 행만 취하는 최적화가 가능해집니다. (실제로는 옵티마이저가 INDEX SEEK + TOP 1 을 UserID마다 수행하는 전략을 택할 수도 있습니다). 하지만 인덱스가 없으면 모든 로그인 기록을 정렬해야 해서 부하가 큽니다. 만약 로그인 기록이 엄청 많은 게임이라면, 차라리 유저 테이블에 LastLogin 칼럼을 유지하거나, LoginHistory를 파티셔닝/아카이빙하여 범위를 줄이는 것이 장기적 해결책일 수 있습니다. 결론: 윈도우 함수를 사용한 그룹 내 1위 추출은 편의와 성능을 모두 잡을 수도 있지만, 반드시 적절한 인덱스 데이터 관리 전략이 뒷받침되어야 합니다.

19. 공통 테이블 표현식 (CTE) 활용하기

-- CTE를 이용해 유저별 접속 횟수를 구한 뒤 이름과 함께 정렬
WITH LoginCounts AS (
    SELECT UserID, COUNT(*) AS LoginCount
    FROM LoginHistory
    GROUP BY UserID
)
SELECT U.UserName, L.LoginCount
FROM LoginCounts AS L
JOIN Users AS U ON U.UserID = L.UserID
ORDER BY L.LoginCount DESC;

 

설명: **공통 테이블 표현식(CTE)**은 쿼리를 더 읽기 쉽게 구성하는 방법입니다. 위 예시는 CTE LoginCounts를 정의하여 유저별 로그인 횟수 집계를 미리 계산하고, 그 결과를 이용해 Users와 JOIN하여 LoginCount가 많은 순으로 정렬 출력합니다. CTE는 WITH ... AS (...) 문으로 쿼리의 앞부분에 정의하며, 이후 SELECT 문에서 마치 임시 테이블처럼 활용할 수 있습니다. 이 방식은 쿼리를 단계별로 나눠볼 수 있어 가독성이 좋고, 재귀적 CTE를 통해 계층 구조 데이터를 처리할 수도 있습니다.

 

성능 튜닝 팁: CTE는 문법적인 편의이므로, 성능 면에서는 서브쿼리 파생 테이블(FROM 절의 서브쿼리)과 큰 차이가 없습니다. 위 쿼리는 사실 한 문장으로도 작성 가능하지만, CTE로 나눠보면 논리적 흐름이 명확해집니다. 성능상의 유의점은, CTE는 참조될 때 매번 계산된다는 것입니다. 하나의 CTE를 여러 번 조인에 사용하면 그만큼 중복 연산이 일어날 수 있으니 피해야 합니다. (MSSQL의 경우 CTE를 복수 번 참조하면 각각 별도 쿼리로 실행됩니다.) 또한 재귀 CTE를 사용할 때는 MAXRECURSION 옵션으로 무한 루프를 방지하고, 심도가 깊으면 스택 부하를 고려해야 합니다. 일반적인 CTE 사용에서는 큰 문제가 없지만, 너무 큰 중간 결과를 CTE로 물고 들어오면 결국 성능은 그 중간 결과를 처리하는 비용만큼 나옵니다. 따라서 CTE 내부 쿼리도 적절히 인덱스 WHERE 조건으로 튜닝해야 함은 동일합니다. 위 LoginCounts CTE는 LoginHistory 전체를 그룹핑하므로 인덱스 혜택이 크지 않을 수 있지만, LoginHistory의 크기에 따라 필요하면 기간 조건이나 인덱스를 고려해야 합니다. 요약: CTE는 SQL을 구조화하여 개발 생산성을 높여주며, 성능은 내부 구현에 달렸으므로 CTE 안팎으로 일반 쿼리 튜닝 원칙을 적용하면 됩니다.

20. 조건부 집계 (CASE + 집계 함수)

-- 유저별 재화 획득/사용 합계
SELECT UserID,
    SUM(CASE WHEN T.Type = 'EARN' THEN T.Amount ELSE 0 END) AS TotalEarned,
    SUM(CASE WHEN T.Type = 'SPEND' THEN T.Amount ELSE 0 END) AS TotalSpent
FROM Transactions AS T
GROUP BY UserID;

 

설명: CASE 문을 집계 함수와 결합하여 조건에 따른 합계를 한 번에 구하는 예제입니다. Transactions 테이블에서 각 유저의 거래 내역을 합산하되, Type이 'EARN'인 경우 Amount를 TotalEarned로, 'SPEND'인 경우 Amount를 TotalSpent로 집계합니다. 즉, 유저별 획득한 재화 총량과 사용한 재화 총량을 동시에 구해주는 쿼리입니다. 이처럼 하나의 스캔/그룹핑으로 여러 조건의 합계를 낼 수 있어 효율적입니다. 게임 내 재화 흐름을 유저단위로 분석하거나 밸런스를 볼 때 사용할 수 있습니다.

 

성능 튜닝 팁: 해당 패턴은 매우 유용하며, 별도의 JOIN이나 여러 번의 집계를 하지 않고도 한 번의 Group By로 여러 지표를 계산할 수 있다는 장점이 있습니다. 위 쿼리는 기본적으로 Transactions를 UserID별로 그룹화하므로 UserID 인덱스가 있다면 도움이 됩니다. 그리고 CASE 표현식은 집계 함수 내부에서 사용될 때, **조건에 맞는 경우에만 값을 반환하고 그렇지 않으면 0 (또는 NULL)**을 반환하여 합계에 영향이 없도록 하는 트릭입니다. 성능 면에서 이 CASE 계산은 가벼운 편이며, SUM 자체의 비용에 비해 크지 않습니다. 다만, 산출 컬럼이 많아질수록 쿼리가 복잡해지고 인덱스 커버링에서 벗어날 수 있습니다. 예를 들어 Type이 5가지라서 5개의 SUM(CASE ...)를 쓰더라도 동작은 하지만, 이러한 경우 차라리 피벗(PIVOT) 구문이나 OLAP 기능을 고려할 수도 있습니다. 그러나 간단한 조건부 합계는 CASE가 읽기도 쉽고 효율적입니다. 마지막으로, Transactions 테이블이 방대하다면 역시 기간을 WHERE로 제한하거나, UserID별로 파티션 관리된 환경이라면 해당 Partition만 스캔하도록 유도하는 등 추가 튜닝을 생각해야 합니다. 기본 원칙은: 한 번의 패스로 필요한 집계를 모두 계산하는 것은 성능에 이득이니 적극 활용하되, 그 한 번의 작업이 너무 과중하지 않도록 인덱스와 범위 조건을 적절히 사용한다는 것입니다.


SQL 실행 계획 확인과 성능 최적화 전략

위에서 다양한 예제와 함께 성능 팁을 다루었지만, 종합적으로 SQL 성능 튜닝에 유용한 방법들을 정리해보겠습니다.

1. 실행 계획(Execution Plan) 확인하기

MSSQL에서는 쿼리가 어떻게 실행되는지 실행 계획을 확인할 수 있습니다. SSMS(관리자 툴)에서 실행 계획 보기 옵션을 켜고 쿼리를 실행하면 그래픽 실행 계획을 볼 수 있고, SET STATISTICS IO ON; SET STATISTICS TIME ON; 등을 사용하면 텍스트로 I/O와 시간 통계를 확인할 수도 있습니다. 실행 계획에는 인덱스 사용 여부, 조인 방식(Nested Loop, Hash, Merge), 정렬 여부 등의 정보가 담겨 있어 병목 원인을 파악하는 데 핵심적인 역할을 합니다.

 

실행 계획을 볼 때 주목해야 할 것들:

  • 인덱스 Seek vs Scan: Seek는 특정 범위만 탐색하는 것이고 Scan은 전체를 훑는 것입니다. 가능하면 Seek으로 처리되도록 쿼리를 작성하고 인덱스를 설계해야 합니다. 예를 들어 인덱스가 없는 경우 1건을 찾기 위해 100만 건을 풀스캔하는 비효율적 계획이 선택될 수 있습니다. 이는 불필요한 리소스 소모로 이어집니다.
  • 조인 방식: 작은 테이블과 큰 테이블 조인에는 Nested Loop가, 큰 테이블 간 조인에는 Merge Join이나 Hash Join이 선택됩니다. 인덱스가 없으면 Hash Join이 많이 등장하는데, 메모리 사용이 증가하고 비용이 높습니다. 조인 컬럼에 인덱스를 부여하여 Merge Join이나 Index Nested Loop가 가능하도록 유도하는 편이 좋습니다.
  • 비용 높은 연산자: Sort(정렬), Spill(디스크 임시 저장), Table Spool 등이 보이면, 쿼리를 개선할 여지가 있는지 살펴봐야 합니다. 예를 들어 ORDER BY나 GROUP BY로 인한 Sort가 크다면 관련 인덱스를 고려해볼 수 있습니다.
  • Estimated vs Actual: SSMS에서는 예상 비용(Estimated)과 실제 실행 결과(Actual)를 비교할 수 있습니다. 간혹 통계 정보가 부정확하여 예상 행수와 실제 행수가 크게 어긋나면 비효율적 계획이 선택될 수 있습니다. 이런 경우 통계 업데이트나 인덱스 재구성이 필요할 수 있습니다.

요약하면, 쿼리를 작성한 후에는 실행 계획을 꼭 확인하여 DB가 의도대로 인덱스를 타고 최소한의 작업으로 결과를 내는지 검증하세요. 예상과 다른 풀스캔이나 거대한 Sort가 발생하면, 아래의 최적화 전략을 적용해 개선합니다.

 

SQL 쿼리 성능 비교

2. 인덱스(Index) 활용 요령

인덱스는 데이터베이스 성능의 핵심입니다. 잘 설계된 인덱스는 WHERE, JOIN, ORDER BY, GROUP BY 쿼리의 속도를 획기적으로 향상시킵니다. 반면, 인덱스를 남발하면 쓰기 작업(INSERT/UPDATE/DELETE)이 느려지고 관리 오버헤드가 늘어나니 균형 잡는 것이 중요합니다.

 

인덱스 사용에 대한 팁:

  • 자주 조회되는 컬럼에 인덱스 생성: 쿼리에서 조건으로 자주 사용하는 컬럼(예: 사용자 ID, 이름, 날짜, 상태값 등)은 인덱스를 걸어두면 좋습니다. 특히 선택도(특정 조건으로 전체 행 중 몇 %를 선택하는지)가 낮은 컬럼일수록 효과가 큽니다. (예: IsDeleted = 0 같은 90%가 0인 경우는 오히려 전체 스캔이 나을 수도 있으나, UserID 같이 고유하거나 EventID처럼 범위 좁은 조건은 인덱스 효율 高)
  • 조합 인덱스(복합 인덱스): WHERE나 JOIN에 여러 컬럼이 함께 쓰이면 복합 인덱스를 고려합니다. 예를 들어 UserID와 LoginTime 둘 다 조건에 쓰이면 (UserID, LoginTime) 순으로 인덱스를 만들면 둘 다 한 번에 검색 가능입니다. 단, 인덱스 컬럼의 순서가 적절해야 하고, 너무 많은 컬럼을 한 인덱스에 넣으면 오히려 관리비용이 큽니다.
  • 커버링 인덱스: 인덱스에 포함된 컬럼만으로 쿼리를 해결할 수 있다면 RID Lookup 없이 곧바로 결과를 낼 수 있어 빠릅니다. 예를 들어 SELECT UserID, LoginTime FROM LoginHistory WHERE UserID=...? 쿼리에 (UserID, LoginTime) 인덱스가 있다면 테이블 본문을 읽지 않고 인덱스에서 바로 결과를 가져옵니다. 필요한 컬럼을 인덱스에 포함**(INCLUDE 절 사용)**하거나, 아예 그 컬럼들로 구성된 인덱스를 만들어두면 효과적입니다.
  • 인덱스 너무 많이 만들지 않기: 읽기 성능을 위해 이것저것 인덱스를 만들다 보면, 데이터 변경 시마다 모든 인덱스를 갱신해야 하므로 쓰기 성능이 급격히 떨어집니다. 특히 초당 많은 로그가 적재되는 테이블에 다수 인덱스가 있으면 INSERT 지연이 발생할 수 있습니다. 실제로 자주 쓰는 쿼리 위주로 최소한의 인덱스를 유지하세요. 사용하지 않는 인덱스는果断 제거하는 것이 좋습니다.
  • 인덱스 유지보수: MSSQL에서는 인덱스가 단편화되어 성능이 저하될 수 있습니다. 정기적으로 **인덱스 재구성(Rebuild)**이나 재구성(Reorganize) 작업을 통해 페이지 단편화를 줄이고 최적의 상태로 유지합니다. 특히 대규모 업데이트가 있었거나 데이터가 많이 뒤섞였을 경우 수행하면 좋습니다.
  • 필터된 인덱스: 일부 행만 인덱싱하는 기능으로, 예를 들어 IsActive = 1인 유저만 인덱싱하면 활활유저 조회에 최적화된 인덱스를 만들 수 있습니다. 데이터의 특정 부분만 자주 조회될 때 고려할 수 있는 고급기법입니다.

인덱스를 언제 만들지 감이 안 온다면, 실행 계획을 보고 Index Scan이 자주 일어나는 곳을 찾는 것이 한 방법입니다. Scan은 많은 데이터를 읽었다는 뜻이므로 그 부분을 Seek로 바꾸기 위해 인덱스를 추가하는 식이죠. 그리고 쿼리 튜닝 전후로 실제 실행 시간을 측정하여 효과를 확인하세요. 인덱스 추가로 어떤 쿼리는 수 초에서 몇 밀리초로 빨라지기도 하지만, 반대로 너무 빈번한 업데이트 쿼리의 성능이 미묘하게 저하될 수도 있으니 모니터링이 필요합니다.

3. SQL 성능 저하 원인과 튜닝 전략

마지막으로, 전반적인 성능 저하 원인 개선 전략을 정리합니다:

  • 원인: 비효율적 쿼리 구조 – 잘못된 쿼리 작성으로 인덱스를 타지 못하거나, 불필요하게 큰 데이터셋을 처리하는 경우가 많습니다. 예: 컬럼에 함수 적용, SELECT *, 필요 없는 외부 조인 등.
    전략: 쿼리를 단순하고 sargable하게 만듭니다. sargable이란 "Search ARGument able"의 약자로, DB가 인덱스를 활용할 수 있는 형태를 말합니다. WHERE 절에 함수/연산이 없는지 확인하고, 필요한 조건은 미리 걸러서 전달합니다. 또, 한 번에 너무 많은 일을 하는 쿼리는 단계별로 쪼갤 수 있으면 쪼개는 것도 방법입니다 (중간 결과를 테이블 변수나 임시 테이블에 담는 등).
  • 원인: 인덱스 부재 또는 남용 – 인덱스가 없어서 모든 쿼리가 Full Scan되는 경우 당연히 느리고, 반대로 인덱스가 너무 많아서 업데이트마다 디스크 쓰기 병목이 생기거나 옵티마이저가 갈피를 못 잡는 경우도 있습니다.
    전략: 위 인덱스 활용 요령을 참고하여 적재적소에 인덱스를 만든다. 또한 주기적으로 실제 쿼리 패턴을 모니터링해서 안 쓰이는 인덱스는 제거하고, 새로운 쿼리에 필요한 인덱스는 추가합니다. 통계 정보 업데이트도 인덱스의 효용을 최대화하는데 중요합니다. 통계가 오래되어 잘못된 카디널리티 추정이 나오면, 1건 찾는 쿼리에 Full Scan 계획이 나와버리는 사태가 발생할 수 있습니다.
  • 원인: 과도한 데이터 – 한번에 너무 많은 행을 가져오거나 (예: 실시간으로 수백만 행 리포트 생성), 너무 빈번하게 무거운 쿼리를 돌리는 경우, DB 성능 한계를 넘어서게 됩니다.
    전략: 페이징 기법을 도입하여 한 번에 처리하는 양을 줄이고, 캐싱을 통해 동일한 결과를 반복 계산하지 않도록 합니다. 또한 아카이빙/파티셔닝으로 오래된 데이터나 필요없는 데이터를 분리하여, 현재 운영 데이터셋을 가볍게 유지합니다. 예를 들어 월별 파티션으로 LoginHistory를 나눠두면 최근 파티션만 집중 관리하고 나머지는 잘 안 보거나, 따로 배치로 돌리는 식입니다. 대규모 집계는 OLAP이나 비동기 백엔드 프로세스로 넘기고, DB에는 미리 계산된 결과를 조회만 하는 구조도 고려합니다.
  • 원인: 동시성 및 락 – 읽기/쓰기 경합으로 인한 락 대기가 성능을 떨어뜨릴 수도 있습니다. 예를 들어, 업데이트 트랜잭션이 길게 잡혀있으면 다른 쿼리가 그 테이블 읽다가 대기하게 됩니다.
    전략: 트랜잭션은 가능하면 짧게 유지하고, 필요한 곳에서만 행 수준 락을 걸도록 쿼리를 설계합니다. 조회 쿼리에 (nolock) 힌트를 사용해 락을 무시하는 방법도 있지만 일관성 문제가 생길 수 있으니 신중히 사용합니다. 데이터베이스 격리 수준도 성능에 영향이 있으므로, 성능이 중요하고 약간의 일관성 저하는 허용된다면 Read Committed Snapshot 등으로 조정할 수 있습니다.

이러한 원인을 진단하고 조치하는 과정이 바로 튜닝입니다. 튜닝은 작은 변화로 큰 효과를 볼 수도 있고, 때로는 DB 구조를 재설계해야 할 때도 있습니다. 중요한 건 테스트와 측정입니다. 쿼리 실행 전에 실행 계획과 예상 비용을 보고, 튜닝 후 실제 실행 시간과 I/O를 측정해보세요. 그리고 가능하면 동일 기능을 하는 여러 쿼리 버전을 실험하여 최적의 방안을 찾는 것도 좋습니다.

 

끝으로, 게임 서버 환경에서는 데이터가 폭증할 수 있고, 실시간 요구사항이 많으므로 지속적인 모니터링 사전 최적화가 필요합니다. 개발 초기부터 SQL 튜닝을 염두에 두면 서비스 성장에 따른 병목을 완화할 수 있습니다.

 

게임 서버 통계 대시보드

맺음말

지금까지 MSSQL을 기반으로 게임 서버에서 자주 사용하는 SQL 쿼리 20가지와 각각에 대한 설명, 그리고 성능 튜닝 팁을 살펴보았습니다. 요약하자면, 올바른 인덱스 설계, 효율적인 쿼리 작성 (조건절 최적화, 최소 데이터 처리), 그리고 실행 계획을 통한 검증이 SQL 튜닝의 3대 요소입니다. 초보 개발자라도 위에 소개한 패턴들을 하나씩 따라 해보고, 직접 쿼리를 개선해보면서 체감하는 성능 향상을 경험해 보세요. 작은 쿼리 변화가 전체 서버 성능과 사용자 경험에 큰 영향을 줄 수 있기 때문에, 세심한 최적화 노력이 필요합니다.

 

게임 서버에서는 새로운 이벤트나 기능이 추가될 때마다 복잡한 SQL이 만들어질 수밖에 없지만, 항상 **"이 쿼리가 최선의 방법인가?"**를 자문해보세요. 그리고 문제가 있다면 본문에서 다룬 다양한 기법 (인덱스 추가, 쿼리 구조 변경, 데이터 분할 등)을 적용해 개선할 수 있을 것입니다. 꾸준한 튜닝과 모니터링으로, 빠르고 안정적인 DB 환경을 구축해 즐겁고 끊김없는 게임 서비스를 제공하시길 바랍니다!

반응형