개인정보 관련 평가·점검 시즌이 되면 늘 같은 질문을 받는다. "개인정보를 다운로드한 기록이 있습니까?" 그런데 실무에서 진짜 봐야 하는 건 그다음 질문이다. "짧은 시간에 반복해서 내려받은 사람이 있습니까?"
한 달에 한 번, 업무상 필요해서 엑셀을 한 번 내려받는 건 정상이다. 하지만 10분 사이에 같은 목록을 세 번 내려받았다면 그건 설명이 필요한 행동이다. 조건을 바꿔가며 전체 데이터를 긁어모으는 중일 수도 있고, 단순히 다운로드가 실패해서 재시도한 것일 수도 있다. 어느 쪽인지는 사람에게 물어봐야 알지만, 물어볼 대상을 골라내는 일은 SQL이 해줄 수 있다.
왜 하필 "반복 다운로드"인가
「개인정보의 안전성 확보조치 기준」(개인정보보호위원회 고시)은 접속기록에 대해 대략 이런 요구를 한다.
- 접속기록은 1년 이상 보관·관리한다. 다만 5만명 이상의 정보주체에 관한 개인정보를 처리하거나 고유식별정보·민감정보를 처리하는 시스템이면 2년 이상이다.
- 접속기록은 월 1회 이상 점검한다. 특히 개인정보를 다운로드한 것이 발견된 경우에는 그 사유를 반드시 확인해야 한다.
- 접속기록이 위·변조되거나 분실되지 않도록 안전하게 보관해야 한다.
즉 "다운로드 기록을 남겼다"로 끝이 아니라, 남긴 기록을 매달 들여다보고 이상한 건에 대해 사유를 확인한 흔적까지가 세트다. 점검 담당자가 수만 건의 로그를 다 볼 수는 없으니 우선순위를 매길 기준이 필요한데, 그중 가장 만들기 쉽고 설명하기도 쉬운 것이 단기간 반복 실행이다.
※ 고시는 개정이 잦다. 조문 번호와 세부 요건은 실제 점검 시점의 최신 고시 원문으로 확인하시길 권한다.
예시로 쓸 로그 테이블
설명을 위해 이런 형태의 로그 테이블을 가정하자. 어떤 프레임워크를 쓰든 접속기록용 테이블이라면 대체로 비슷한 컬럼을 갖고 있을 것이다.
log_modules
├─ id int 아이디
├─ sitecode varchar(20) 사이트코드 -- 멀티 사이트(테넌트) 구분
├─ module varchar(50) 모듈
├─ desc varchar(100) 로그내용
├─ data blob 로그데이터
├─ isrt_id varchar(20) 등록자(계정)
├─ isrt_ip varchar(39) 등록자 IP
└─ isrt_dt datetime(6) 등록일시 -- 마이크로초까지
계정(isrt_id), 접속지 정보(isrt_ip), 접속일시(isrt_dt), 수행업무(module + desc). 접속기록의 기본 항목은 갖춘 셈이다. 여기서 "숙박 목록 엑셀 다운로드" 기능의 로그를 뽑아본다고 하자.
쿼리: 직전 실행과의 간격 구하기
"10분 이내에 다시 실행했는가"는 결국 같은 사람의 직전 실행 시각을 알아야 계산할 수 있다. 예전 같으면 자기 조인(self join)으로 끙끙댔겠지만, MySQL 8.0부터는 윈도우 함수 LAG() 한 줄이면 끝난다.
SELECT
x.*,
TIMESTAMPDIFF(SECOND, x.prev_isrt_dt, x.isrt_dt) AS diff_seconds
FROM (
SELECT
t.*,
LAG(t.isrt_dt) OVER (
PARTITION BY t.isrt_id
ORDER BY t.isrt_dt
) AS prev_isrt_dt
FROM log_modules t
WHERE t.module = 'mallTotalAccommodation'
AND t.`desc` = '숙박 엑셀 다운로드'
) x
WHERE x.prev_isrt_dt IS NOT NULL
AND TIMESTAMPDIFF(SECOND, x.prev_isrt_dt, x.isrt_dt) <= 600 -- 10분 이내 재실행
AND x.isrt_dt >= '2026-01-01 00:00:00.000000'
ORDER BY x.isrt_id, x.isrt_dt;
읽는 순서는 안쪽부터다.
WHERE로 해당 다운로드 로그만 남긴다.PARTITION BY t.isrt_id로 계정별로 줄을 세우고,ORDER BY t.isrt_dt로 시간순 정렬한다.LAG(t.isrt_dt)가 그 줄에서 바로 앞 행의 시각을 현재 행으로 끌어온다.- 바깥에서 두 시각의 차이를 초로 계산하고, 600초 이하만 남긴다.
결과는 "누가, 언제, 직전 실행으로부터 몇 초 만에 또 눌렀는지"가 한 줄씩 나온다. 점검 보고서에 그대로 붙일 수 있는 형태다.
날짜 조건은 왜 바깥에 두는가
여기가 이 쿼리에서 가장 중요한 부분이다. 언뜻 보면 isrt_dt >= '2026-01-01'을 안쪽 WHERE에 넣는 게 효율적으로 보인다. 스캔량이 줄어드니까. 하지만 그렇게 하면 경계에서 탐지가 깨진다.
어떤 계정이 2025-12-31 23:58과 2026-01-01 00:03에 각각 다운로드했다고 하자. 실제로는 5분 간격의 명백한 반복 실행이다. 그런데 안쪽에서 미리 2026년 이후만 걸러버리면, 00:03 행 입장에서 직전 행은 존재하지 않는다(prev_isrt_dt IS NULL). 결국 이 건은 조회 결과에서 사라진다.
prev_isrt_dt는 NULL이 된다.그래서 순서가 이렇게 되어야 한다.
- 안쪽 — 기간 제한 없이 전체 이력으로
LAG를 계산해 직전 기록을 온전히 확보한다. - 바깥 — 계산이 끝난 뒤에 "보고 대상 기간"으로 자른다.
윈도우 함수를 쓸 때 흔히 하는 실수다. 필터링이 윈도우 계산 전인가 후인가를 항상 의식해야 한다. 참고로 WHERE절에서는 윈도우 함수 결과를 직접 조건으로 쓸 수 없기 때문에, 어차피 서브쿼리로 한 번 감싸는 구조가 강제된다.
전체 이력이 너무 크다면 안쪽 조건을 보고 기간보다 조금 앞선 시점으로 잡으면 된다. 예를 들어 임계값이 10분이니 isrt_dt >= '2025-12-31 23:00:00' 정도면 경계 앞 기록을 잃지 않으면서 스캔 범위를 줄일 수 있다. 요령은 임계값만큼의 여유분을 반드시 남기는 것이다.
놓치기 쉬운 지점들
1. 멀티 사이트라면 sitecode를 빠뜨리지 말 것
로그 테이블에 sitecode 같은 테넌트 컬럼이 있다는 건 여러 사이트의 로그가 한 테이블에 섞여 있다는 뜻이다. PARTITION BY t.isrt_id만 쓰면, A사이트와 B사이트에 같은 아이디가 존재할 때 두 사이트의 실행 기록이 한 줄로 엮인다. 서로 다른 시스템에서의 행위가 "10분 내 반복 실행"으로 둔갑할 수 있다.
-- 필터와 파티션 양쪽에 넣는다
WHERE t.sitecode = 'yoursite'
AND t.module = 'mallTotalAccommodation'
...
PARTITION BY t.sitecode, t.isrt_id
2. 계정 컬럼이 NULL일 수 있다
비로그인 상태나 배치(데몬)에서 남은 로그는 등록자가 비어 있을 수 있다. PARTITION BY는 NULL끼리를 하나의 그룹으로 묶기 때문에, 서로 무관한 익명 로그들이 한 줄에 세워져 엉뚱한 간격이 계산된다. 점검 대상은 "사람"이므로 AND t.isrt_id IS NOT NULL을 넣거나, 반대로 NULL 건만 따로 뽑아 "누가 실행했는지 식별되지 않는 다운로드"로 별도 관리하는 게 맞다. 사실 후자가 더 위험한 항목이다.
3. desc는 예약어이고, 한글 문구 매칭은 약하다
desc는 DESCRIBE/DESC 때문에 반드시 백틱으로 감싸야 한다. 더 근본적인 문제는 로그 종류를 한글 표시 문자열로 식별하고 있다는 점이다. 누군가 문구를 "숙박 엑셀 다운로드"에서 "숙박 목록 엑셀 다운로드"로 바꾸는 순간, 이 쿼리는 조용히 0건을 반환한다. 점검 쿼리가 아무것도 못 찾았는데 그게 정상인지 고장인지 구분이 안 되는 상태가 가장 나쁘다.
가능하면 로그에 변하지 않는 코드 컬럼(예: action_code = 'EXCEL_DOWNLOAD')을 하나 두고, 한글 문구는 화면 표시용으로만 쓰자. 당장 스키마를 못 바꾸겠다면 최소한 LIKE '%엑셀 다운로드%' 같은 완충 장치와, "이 모듈의 월간 다운로드 총 건수"를 함께 뽑아 0건 여부를 교차 확인하는 습관이 필요하다.
4. 연속된 두 건만 본다는 한계
LAG는 바로 앞 한 건만 본다. "10분 안에 5번 실행"은 결과에 4개의 행으로 흩어져 나오고, 사람이 눈으로 세야 한다. 애초에 "10분 창 안에 몇 건인가"를 세고 싶다면 COUNT()에 시간 범위 프레임을 씌우는 편이 훨씬 직관적이다.
SELECT *
FROM (
SELECT
t.sitecode,
t.isrt_id,
t.isrt_ip,
t.isrt_dt,
COUNT(*) OVER w AS cnt_10min
FROM log_modules t
WHERE t.sitecode = 'yoursite'
AND t.module = 'mallTotalAccommodation'
AND t.`desc` = '숙박 엑셀 다운로드'
WINDOW w AS (
PARTITION BY t.sitecode, t.isrt_id
ORDER BY t.isrt_dt
RANGE BETWEEN INTERVAL 10 MINUTE PRECEDING AND CURRENT ROW
)
) x
WHERE x.isrt_dt >= '2026-01-01 00:00:00.000000'
AND x.cnt_10min >= 3 -- 10분 창에 3건 이상
ORDER BY x.isrt_id, x.isrt_dt;
RANGE BETWEEN INTERVAL 10 MINUTE PRECEDING AND CURRENT ROW는 "행 개수"가 아니라 시각 기준으로 창을 잡는다. 임계값을 3건, 5건으로 조정하며 노이즈를 줄이기 쉽고, "몇 건짜리 버스트였는가"가 숫자로 바로 보이니 보고서에도 유리하다.
여기에 월간 요약을 하나 더 붙여두면 점검 루틴이 완성된다.
SELECT
DATE(isrt_dt) AS ymd,
isrt_id,
COUNT(*) AS cnt,
COUNT(DISTINCT isrt_ip) AS ip_cnt,
MIN(isrt_dt) AS first_dt,
MAX(isrt_dt) AS last_dt
FROM log_modules
WHERE sitecode = 'yoursite'
AND module = 'mallTotalAccommodation'
AND `desc` = '숙박 엑셀 다운로드'
AND isrt_dt >= '2026-01-01 00:00:00.000000'
GROUP BY DATE(isrt_dt), isrt_id
ORDER BY cnt DESC;
ip_cnt가 특히 유용하다. 하루 동안 여러 IP에서 같은 계정으로 다운로드가 일어났다면, 반복 실행보다 훨씬 먼저 확인해야 할 신호다.
5. 인덱스가 없으면 로그가 쌓일수록 느려진다
로그 테이블은 "쓰기는 많고 읽기는 가끔"이라 PK 외에는 인덱스 없이 만들어지는 경우가 흔하다. 그러면 이 쿼리는 전체 테이블 스캔 + 정렬(filesort)로 동작한다. 로그가 수백만 건이 되면 점검 한 번에 몇 분씩 걸린다.
ALTER TABLE log_modules
ADD INDEX idx_log_scan (sitecode, module, isrt_id, isrt_dt);
sitecode와 module이 등호 조건이므로, 남은 (isrt_id, isrt_dt)가 이미 정렬된 상태로 윈도우 함수에 전달된다. 스캔 범위도 줄고 정렬도 생략된다. 다만 인덱스는 INSERT 비용을 늘리므로, 로그 쓰기가 초당 수백 건인 시스템이라면 실제 부하를 재보고 결정하자.
쿼리 밖에서 챙겨야 할 것
탐지 쿼리를 아무리 잘 짜도, 그 아래의 로그 자체가 부실하면 소용이 없다. 점검 체계를 만들 때 함께 봐야 할 것들이다.
- 보관 기간이 요건을 만족하는가. 로그 테이블은 용량 때문에 오래된 행을 주기적으로 지우는 배치가 붙어 있는 경우가 많다. 그런데 그 테이블에 개인정보 다운로드 기록이 섞여 있다면, 디스크 관리용으로 넣어둔 한 줄이 그대로 보관 기간 미준수가 된다. 로그 테이블 하나에 성격이 다른 기록을 섞으면 보관 정책도 하나밖에 못 갖는다. 그리고 그 하나는 대개 짧은 쪽으로 맞춰진다.
- 지우는 대신 옮긴다. 개인정보 처리 관련 로그는 물리 삭제 대상에서 제외하고, 월 단위로 아카이브 테이블에 옮기자. 운영 테이블은 가볍게 유지하면서 보관 의무도 지킬 수 있다.
- 위·변조 방지를 고려한다. 최소한 운영 계정이 접속기록 테이블을 직접
UPDATE/DELETE할 수 없도록 권한을 분리하자. - 무엇을 내려받았는지도 남긴다. "엑셀을 다운로드했다"만 있고 몇 건의 정보주체 정보가 포함됐는지가 없는 로그가 의외로 많다. 조회 조건과 결과 건수를 함께 기록해두면, 사유 확인 단계에서 "10건짜리였는지 3만건짜리였는지"를 바로 판단할 수 있다. 반복 실행 여부보다 이쪽이 훨씬 강력한 위험 신호다.
마무리 체크리스트
결국 쿼리 한 줄에서 시작해 시스템 설계 이야기까지 왔다. 개인정보 점검을 준비한다면 아래 항목을 한 번씩 확인해보시길 권한다.
- 다운로드 기능마다 로그가 실제로 남고 있는가? (기능은 늘었는데 로그는 안 붙은 화면이 꼭 하나씩 있다)
- 로그에 계정·IP·일시·수행업무가 모두 있는가? 처리 건수도 있는가?
- 로그 보관 기간이 요건을 만족하는가? 크론이나 배치가 몰래 지우고 있지는 않은가?
- 점검 쿼리가 0건을 반환할 때 그게 정상인지 확인할 방법이 있는가?
- 이상 건을 발견했을 때 사유를 확인하고 기록으로 남기는 절차가 문서화되어 있는가?
마지막 항목이 제일 중요하다. 쿼리는 대상을 좁혀줄 뿐이고, 실제 판단은 사람이 한다. 그리고 점검을 했다는 사실 자체도 기록으로 남아야 한다. 기록을 점검한 기록까지가 세트라는 얘기다.
이 글의 쿼리는 MySQL 8.0 기준으로 작성했다. MariaDB 10.2 이상에서도 LAG는 동작하지만, RANGE BETWEEN INTERVAL 프레임은 버전에 따라 지원 여부가 다르니 확인이 필요하다.