"Access logs should be kept for 2 years, right?" — Two failures this one sentence creates
Bring up personal information logs and practitioners almost always split into two camps.
- Over-design camp: "Let's play it safe" and retain access logs for 2 years on every system → log tables balloon into hundreds of millions of rows, and backup/storage cost and query performance collapse together.
- Under-retention camp: Habitually keep only 1 year → an inspection or audit flags that "this system is a 2-year target," and you discover too late that reconstruction is impossible.
Both fail for the same reason: you started without deciding which cell your system belongs in.
This post is not a statute commentary. It walks an execution sequence: decide → schema → tamper prevention → monthly inspection automation → failure branches. It is structured so you can open your own access-log table today and immediately find the gaps.
Scope and as-of date: This article assumes the July 2026 interpretation of Article 8 (Retention and Inspection of Access Records) of the Standards for Measures to Ensure the Safety of Personal Information (Personal Information Protection Commission notice). The notice can be amended, and sector-specific rules (e-finance, healthcare, telecom, etc.) may overlay it, so final decisions must be confirmed against the original notice and your internal privacy office / legal counsel. ISMS-P certification as a whole is covered separately in 2026 ISMS-P Certification Prep Checklist.
1 year or 2 years — retention-period decision table
The bottom line first. The baseline for access-log retention is at least 1 year. If any of the conditions below apply, it becomes at least 2 years.
Four decision questions
| # | Question | If Yes |
|---|---|---|
| Q1 | Does the system process personal information of 50,000 or more data subjects? | 2 years |
| Q2 | Does it process unique identifiers (resident registration number, passport number, driver's license number, alien registration number)? | 2 years |
| Q3 | Does it process sensitive information (health, ideology/beliefs, union membership, genetic information, criminal records, etc.)? | 2 years |
| Q4 | Is the operator a facilities-based telecommunications business? | 2 years |
| — | All No | 1 year (default) |
The most commonly missed point: the unit of decision is the "system," not the "company"
This is where practitioners get it wrong most often. The decision is made per personal information processing system. Even inside the same company, results split like this.
(주)예시커머스
├─ 회원/주문 시스템 → 회원 12만 명 → Q1 Yes → 2년
├─ 사내 인사(HR) 시스템 → 임직원 300명, 주민번호 보유 → Q2 Yes → 2년
└─ 마케팅 이벤트 DB → 응모자 8천 명, 이름·연락처만 → 전부 No → 1년If you lump it as "our company is a 2-year shop" and apply 2 years everywhere, even marketing DB logs pile up twice as much as needed. Conversely, if you lump it as "we're small, so 1 year," you get flagged on the HR system.
Four decision examples by service type
| Service type | Situation | Decision | Basis |
|---|---|---|---|
| Online store | 120,000 members; name, contact, address; no unique identifiers | 2 years | Q1 — 50,000+ data subjects |
| B2B SaaS | 8,000 end users; no sensitive or unique-identifier data | 1 year | Q1–Q4 all No (however, if you process customer personal data as a processor, re-decide using the sum of the customers' data subjects) |
| Hospital booking | 4,000 bookers; processes health information such as department and symptoms | 2 years | Q3 — sensitive information, regardless of scale |
| Fintech | Processes resident registration numbers and account real-name verification data | 2 years | Q2 — unique identifiers (e-finance overlay rules may apply; this article is limited to Article 8) |
Principles for edge cases
- When to count 50,000: Do not use a single-point snapshot. Conservatively use the peak during the year. If you only exceed during an event season, treating it as over the threshold is safer for defense.
- Dormant and withdrawn accounts: Data that has not been destroyed and is held in segregated storage is still, under a conservative reading, personal information being processed. Exclude only records that have been fully destroyed.
- Retroactivity for growing services: Designed for 1 year at 40,000 members → 8 months later you cross 60,000. Logs already deleted cannot be restored. All you have left is a document saying "2 years applies from this date."
- ✅ Practical takeaway: If you expect to cross 50,000 within 1–2 years, design for 2 years from day one. Redesign and explanation costs exceed log storage costs.
Do this now: Build a system inventory table with five columns: system name / data subject count / unique identifier or sensitive data (Y/N) / decision / rationale. "No documented basis for the retention period" is a staple audit finding.
Mapping the log schema: five required fields into a real table
Access records require five fields: account / access timestamp / source information / data subject processed / work performed.
PostgreSQL DDL example
CREATE TABLE access_log (
id BIGSERIAL PRIMARY KEY,
-- ① 계정: 개인정보취급자 식별자 (공용계정 금지)
actor_account VARCHAR(64) NOT NULL,
actor_emp_no VARCHAR(32), -- 퇴사 후에도 인사번호로 추적
-- ② 접속일시: UTC 저장 + 표시 시 KST 변환 (혼용 금지)
accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- ③ 접속지 정보
src_ip INET NOT NULL,
user_agent TEXT,
session_id VARCHAR(64),
-- ④ 처리한 정보주체 정보 (원문 대신 내부 ID/해시)
subject_ref VARCHAR(128), -- 예: user:830112 또는 sha256 해시
subject_type VARCHAR(32), -- member / patient / applicant
-- ⑤ 수행업무
action VARCHAR(24) NOT NULL, -- READ/UPDATE/DELETE/EXPORT/PRINT
resource VARCHAR(128) NOT NULL, -- 예: /admin/members/detail
target_count INTEGER NOT NULL DEFAULT 1, -- 대량조회 탐지 핵심
result VARCHAR(16) NOT NULL DEFAULT 'SUCCESS',
detail JSONB
) PARTITION BY RANGE (accessed_at);
CREATE TABLE access_log_2026_07 PARTITION OF access_log
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE INDEX idx_access_log_actor ON access_log (actor_account, accessed_at DESC);
CREATE INDEX idx_access_log_subject ON access_log (subject_ref, accessed_at DESC);
CREATE INDEX idx_access_log_action ON access_log (action, accessed_at DESC);If you use MySQL 8.0, map TIMESTAMPTZ → DATETIME(3) (store UTC), INET → VARBINARY(16) + INET6_ATON(), JSONB → JSON, and partitioning to PARTITION BY RANGE (TO_DAYS(accessed_at)).
Application audit-log JSON example
{
"ts": "2026-07-28T09:14:22.481Z",
"tz_display": "Asia/Seoul",
"actor": { "account": "kim.cs", "emp_no": "E20231045", "role": "CS_AGENT" },
"src": { "ip": "10.20.5.31", "xff": "203.0.113.44", "session": "s_9f2a..." },
"action": "EXPORT",
"resource": "/admin/members/export",
"subject": { "type": "member", "refs": ["m_10023", "m_10024"], "count": 2 },
"target_count": 2,
"result": "SUCCESS",
"reason": "CS-2026-4412 환불 민원 처리"
}Design notes per column
| Column | Watch-outs |
|---|---|
actor_account | No shared accounts (admin, operator). If you cannot identify the actor, the entire log loses explanatory power. Do not reuse departed employees' accounts either — also record actor_emp_no. |
accessed_at | Store UTC and display KST. Mixed server time zones make correlation itself impossible. |
src_ip | Behind ALB/Nginx you must record the original client IP, not the proxy IP. Extract the leftmost trusted value of X-Forwarded-For against a trusted-proxy list. |
subject_ref | Do not put raw personal information (name, phone number) in the log. The log itself becomes another personal-information store, dragging destruction and encryption duties with it. Use an internal ID or a hash. |
action | Separate read/update/delete from download and print. Most leaks happen on EXPORT/PRINT. |
target_count | The only basis for detecting bulk reads. Always record the row count returned by list APIs. |
Common gaps: Before / After
① Missing data-subject identifier — cannot size the blast radius in an incident
# Before
log.info(f"{user.id} viewed member detail page")
# After
audit.write(
actor_account=user.account, action="READ",
resource="/admin/members/detail",
subject_type="member", subject_refs=[member.id], target_count=1,
)Finding: In a suspected leak you cannot tell whose information was viewed, so you cannot determine who must be notified.
② Only IP and timestamp — mistaking web access logs for access records
# Before (Nginx access.log — 접속기록이 아님)
203.0.113.44 - - [28/Jul/2026:09:14:22 +0900] "GET /admin/members?page=3 HTTP/1.1" 200
# After (수행업무 + 정보주체 + 건수 포함)
{"ts":"...","actor":"kim.cs","action":"READ","resource":"/admin/members",
"subject":{"type":"member","count":50},"target_count":50}Finding: Missing account, work performed, and data-subject information, so the required fields are not met. Web-server logs are supporting material only.
③ No row count — cannot detect bulk reads
# Before
rows = repo.search(keyword) # 12,000건 반환, 로그엔 "search" 한 줄
# After
rows = repo.search(keyword)
audit.write(action="READ", resource="/admin/members/search",
target_count=len(rows), detail={"keyword_hash": h(keyword)})Finding: An insider can scan every member and look identical to a single-row read, so anomaly inspection becomes a formality.
Tamper prevention: comparison of four implementation options
Access records must be stored securely so they cannot be forged or altered. If a handler can erase their own traces, the log is not evidence.
| Option | Initial build cost | Monthly run cost | Operational difficulty | Explanatory power at audit | Standalone use |
|---|---|---|---|---|---|
| Access-control separation (revoke developer UPDATE/DELETE on the log DB/table; INSERT-only account) | Low | None | Low | Medium | △ (minimum baseline) |
| Ship to a separate log server (rsyslog / Fluent Bit → isolated collector) | Medium | Medium | Medium | High | ○ |
| Object-storage WORM (e.g. S3 Object Lock Compliance mode) | Medium | Medium–high | Medium | Highest | ○ |
| Hash chain / checksum (daily integrity hash chained to the previous hash) | High | Low | High | High (depends on implementation quality) | △ |
Recommended baseline: Start with access-control separation + shipping to a separate store, and add WORM or a hash chain only on high-risk systems that handle unique identifiers or sensitive information. Teams that implement a hash chain from scratch often collapse under operational load.
INSERT-only privilege example:
-- 애플리케이션 계정은 삽입만 가능
REVOKE ALL ON access_log FROM app_user;
GRANT INSERT ON access_log TO app_user;
GRANT USAGE, SELECT ON SEQUENCE access_log_id_seq TO app_user;
-- 점검자는 읽기만
CREATE ROLE auditor LOGIN PASSWORD '...';
GRANT SELECT ON access_log TO auditor;Daily integrity checksum (simple version):
#!/usr/bin/env bash
# /usr/local/bin/log-checksum.sh — 전일 로그 해시를 append-only 파일에 기록
set -euo pipefail
DAY=$(date -d 'yesterday' +%F)
OUT=/var/log/audit/chain.log
PREV=$(tail -n1 "$OUT" 2>/dev/null | awk '{print $3}' || echo "GENESIS")
HASH=$(psql -qAt -c "SELECT md5(string_agg(id::text||actor_account||action, '|' ORDER BY id))
FROM access_log WHERE accessed_at::date = '$DAY'")
echo "$DAY $PREV ${PREV:0:8}$HASH" >> "$OUT"
chattr +a "$OUT" 2>/dev/null || true # append-only 속성Expected healthy result: one line per day is appended to
chain.log, and attempts to edit existing lines fail withOperation not permitted. If you do not see that message,chattr +ais not in effect — check filesystem support (ext4/xfs) and execution privileges.
Monthly inspection automation: three anomaly-detection SQL queries
Retention is not enough. Inspection at least once a month is also required. Treat the three queries below as the default set.
(a) Extract download and print activity
SELECT actor_account, accessed_at, resource, target_count, src_ip
FROM access_log
WHERE accessed_at >= date_trunc('month', now() - interval '1 month')
AND accessed_at < date_trunc('month', now())
AND action IN ('EXPORT', 'PRINT', 'DOWNLOAD')
ORDER BY target_count DESC, accessed_at;Treat every row as requiring an explanation. If you have hundreds of rows, revisit the business process itself (habitual unnecessary Excel downloads).
(b) Bulk reads above threshold
WITH baseline AS (
SELECT actor_account, AVG(daily_cnt) AS avg_cnt
FROM (SELECT actor_account, accessed_at::date d, SUM(target_count) daily_cnt
FROM access_log
WHERE accessed_at >= now() - interval '90 days'
GROUP BY 1,2) t
GROUP BY 1
)
SELECT l.actor_account, l.accessed_at::date AS d,
SUM(l.target_count) AS today_cnt, ROUND(b.avg_cnt) AS avg_cnt
FROM access_log l JOIN baseline b USING (actor_account)
WHERE l.accessed_at >= date_trunc('month', now() - interval '1 month')
AND l.accessed_at < date_trunc('month', now())
GROUP BY 1,2,4
HAVING SUM(l.target_count) > GREATEST(500, b.avg_cnt * 3)
ORDER BY today_cnt DESC;Threshold is the larger of
more than 500 rows in a dayor3× the actor's 90-day daily average. Tune it to your organization, but write the threshold into a document so you are not flagged for "arbitrary judgment."
(c) Off-hours and unauthorized IP-range access
SELECT actor_account, accessed_at, src_ip, action, resource, target_count
FROM access_log
WHERE accessed_at >= date_trunc('month', now() - interval '1 month')
AND accessed_at < date_trunc('month', now())
AND (
EXTRACT(hour FROM accessed_at AT TIME ZONE 'Asia/Seoul') NOT BETWEEN 8 AND 20
OR EXTRACT(dow FROM accessed_at AT TIME ZONE 'Asia/Seoul') IN (0, 6)
OR NOT (src_ip << ANY (ARRAY['10.0.0.0/8'::inet, '172.16.0.0/12'::inet]))
)
ORDER BY accessed_at;Cron schedule skeleton
# /etc/cron.d/privacy-audit — 매월 1일 07:00 전월 점검 리포트 생성·발송
0 7 1 * * auditor /usr/local/bin/monthly_audit.sh >> /var/log/audit/cron.log 2>&1#!/usr/bin/env bash
# monthly_audit.sh
set -euo pipefail
PERIOD=$(date -d 'last month' +%Y-%m)
OUT="/var/log/audit/report_${PERIOD}.csv"
for q in export_activity bulk_read offhours_access; do
echo "== ${q} ==" >> "$OUT"
psql -qA -F',' -f "/opt/audit/sql/${q}.sql" >> "$OUT"
done
mail -s "[개인정보] ${PERIOD} 접속기록 점검 결과" -a "$OUT" \
privacy@example.com < /opt/audit/mail_body.txtExpected healthy result:
report_2026-06.csvis created and the owner receives mail. If the file is 0 bytes, first check theauditoraccount's SELECT privilege or.pgpass. For cron itself, see Practical Cron Job Scheduler Guide; for tracing failed service runs, see Getting the Most out of journalctl.
Inspection report template
Automation without documents still gets flagged as "no inspection history." Keep a monthly document that fills in the items below.
| Item | What to record |
|---|---|
| Inspection period | 2026-06-01 ~ 2026-06-30 |
| Systems in scope | Member/order system, HR system (2-year retention) |
| Method | Three automated detection queries + sample manual review |
| Detections | 12 downloads / 3 bulk reads / 5 off-hours accesses |
| Explanations | 2 of 3 bulk reads were regular settlement batches; 1 was a CS bulk check (ticket CS-2026-4412) |
| Actions taken | Separated the settlement-batch account; retrained one handler |
| Inspector / confirmer | Security team OOO (signature) / Personal Information Protection Officer OOO (signature) |
- Anomaly handling flow: detect → request explanation (3 business days) → receive and record explanation → decide (normal/abnormal) → remediate → close
- Retain the report itself for whatever period your internal standard requires, and store it where it cannot be freely edited — same idea as the access logs.
Failure branch 1: log volume explosion
On a 2-year system, 5 million rows a day becomes 3.6 billion rows. Respond in three stages.
- Monthly partitioning — keep query performance, and simplify destruction with DROP of expired partitions
- Compressed archive — after 3 months, dump partitions to CSV/Parquet and compress
- Move to object storage — S3 Standard-IA / Glacier Instant Retrieval, etc.
# 3개월 지난 파티션 아카이브 → S3 이관 → DROP
TBL=access_log_2026_04
psql -c "\copy ${TBL} TO PROGRAM 'gzip > /tmp/${TBL}.csv.gz' CSV HEADER"
aws s3 cp /tmp/${TBL}.csv.gz s3://corp-audit-archive/access_log/ \
--storage-class STANDARD_IA
psql -c "DROP TABLE ${TBL};"⚠️ Two critical caveats
- The retention clock keeps running after you move the data. Cold storage is not "retention complete"; the records must remain alive for the decided 1 or 2 years.
- You must be able to restore within a reasonable time on request. Glacier Deep Archive can take hours to more than ten hours to restore, which hurts during incident response or an inspection request. Keep the last 3–6 months on an immediately queryable tier and send only older data to deep archive.
Destruction automation and destruction records: Auto-delete expired retention, but leave a separate destruction record of what was destroyed and when. Destruction records are not access records, so they are not themselves deletion targets.
-- 파기 기록 테이블 (삭제 대상 아님)
CREATE TABLE audit_disposal_log (
id BIGSERIAL PRIMARY KEY,
target_table VARCHAR(64), period_from DATE, period_to DATE,
row_count BIGINT, disposed_at TIMESTAMPTZ DEFAULT now(),
operator VARCHAR(64), method VARCHAR(32) -- DROP PARTITION / S3 DELETE
);Failure branch 2: audit blind spots in managed cloud DBs and serverless
On RDS, Aurora, and serverless, native DB audit logs are limited — or, even when they exist, they do not carry work performed or data-subject identifiers. A DB audit log tells you "this SQL ran," not "a CS agent looked up three members to process a refund."
Recommended dual structure
| Layer | Role | Notes |
|---|---|---|
| Application audit log | Authoritative copy — records all five required fields | Covers all service-path access |
| DB audit log (pgaudit, etc.) | Supporting — detect application-bypass access | Cross-check against the authoritative copy for gaps |
| Admin console, SSH, bastion | Separate control — access approval + session recording | This is the largest blind spot |
- Paths where operators connect to the DB directly via console or a SQL client need a separate approval process and session recording (e.g. terminal recording), then correlation with application logs.
- Serverless (Lambda, etc.) instances disappear, so local file logging is meaningless. Prevent loss with an async queue → collector (not synchronous ship), and always design retries and a DLQ on send failure.
- More teams fold audit logs into a SIEM, but a SIEM retention policy (e.g. 90-day hot storage) does not replace the statutory retention period. Use SIEM for detection; keep the authoritative copy separately.
Checklist of frequent audit findings
| Finding | What you can do this week |
|---|---|
| No documented basis for the retention period | Put the four decision answers and the conclusion on one page of the system inventory and get it approved |
| Missing data-subject identifier | Add subject_ref and subject_type to the audit-log middleware and roll out on the top 5 read APIs first |
| Download/print not recorded | Force action='EXPORT' + target_count on Excel/CSV export endpoints |
| Read row count not recorded | Write len(rows) to the log immediately after the list API returns |
| No inspection history as a document | Backfill the last 1–3 months with the report template above and get the officer's signature |
| Handlers have write access to the log store | REVOKE UPDATE, DELETE on the log table; split out an INSERT-only account |
| Shared accounts, actor cannot be identified | Disable admin and other shared accounts; issue personal accounts mapped to employee numbers |
| Mixed time zones, timestamps cannot be correlated | Standardize all servers on UTC, or store an explicit offset (+09:00) |
30-day execution sequence
| Week | Work | Deliverable |
|---|---|---|
| Week 1 | Build the system inventory → decide 1 year vs 2 years with the four questions | Retention-period basis document |
| Week 2 | Gap-analyze current log schema vs the five required fields → change DDL | Schema-change PR, gap list |
| Week 3 | Privilege split (INSERT-only) + ship to a separate store | Tamper-prevention architecture diagram |
| Week 4 | Three detection queries + cron automation + first report | Monthly inspection report v1 |
Order matters. Touch the schema before you decide and you will rebuild it; start with inspection automation and you get empty reports because there is nothing worth keeping.
To restate: every criterion in this article assumes the July 2026 interpretation of Article 8 of the Standards for Measures to Ensure the Safety of Personal Information. The notice can be amended and sector-specific rules may overlay it, so always get a final check against the Personal Information Protection Commission notice and your Personal Information Protection Officer / legal counsel.
FAQ
Q1. We have 40,000 members and will likely cross 50,000 soon. Can we still design for 1 year? A. Under the rule you are currently a 1-year target, but once you cross 50,000 you cannot recover logs already deleted. If you expect to exceed within 1–2 years, designing for 2-year retention from the start is how you avoid redesign and explanation costs. Document the decision date and the rationale either way.
Q2. We have Nginx access logs and CloudTrail. Does that satisfy the access-record requirement? A. No. Access records must include, on top of account, timestamp, and source information, the data subject processed and the work performed. Web-server logs do not say whose personal information was processed or for what work. Treat application-layer audit logs as the authoritative copy and use web/cloud logs as supporting evidence.
Q3. We already run an automated inspection script every month. Do we still need a separate document? A. Yes. One of the most common findings in practice is "detection runs, but there is no documented inspection history." Keep a monthly report that includes detection counts, explanations, actions taken, and confirmation by the inspector and the Personal Information Protection Officer, and store it where it cannot be freely edited — same idea as the access logs.
Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
Comments
Be the first to comment.