개인정보 접속기록 안전하게 남기기

개인정보 접속기록 안전하게 남기기

반장노트 백엔드를 개발하면서 여러 도메인을 거쳤지만, 개인정보 감사 로그 쪽을 작업할 때 고민을 꽤 많이 했습니다.

초기에는 구인, 구직 같은 메인 비즈니스 기능들을 만드느라 정신이 없었는데, 서비스의 기능 개발이 마무리되어 가는 시점이 되니 그동안 미뤄왔던 개인정보보호법에 맞춘 감사 로그 체계를 제대로 갖춰야 하는 때가 되었습니다. 특히 반장노트는 구인구직 과정에 필요한 수많은 개인정보들이 오가는 플랫폼이다 보니 대충 로그 파일 몇 줄을 남기는 방식으로는 넘어가기 어려웠습니다.

막상 작업을 시작하려니 챙겨야 할 법적 기준도 깐깐하고, 300개가 넘는 API 사이에서 사람이 일일이 기억해가며 로그를 심는 방식으로는 이후 시스템이 계속 개발되면서, 기록을 누락하는 일이 발생할 게 분명해 보였습니다. 그래서 감사 로그 누락을 막고, 대용량 트래픽에서도 API 응답 속도를 유지할 수 있도록 파이프라인을 짰던 과정을 정리해 보려고 합니다.

법 조항을 코드 스펙으로 옮기는 작업부터

개인정보 보호법 제29조를 보면 시스템에 접속한 기록을 보관하고 위·변조를 막으라는 안전조치 의무가 나옵니다. 그리고 좀 더 상세한 기준은 '개인정보의 안전성 확보조치 기준' 고시 8조에서 찾을 수 있습니다.

법령이 요구하는 개인정보 접속기록으로 남겨야 하는 것들을 정리해보면 다음과 같습니다.

우선 접속기록에 무조건 들어가야 하는 5가지 필수 항목이 있습니다.

  • 누가 접속했는지 알 수 있는 식별자(ID나 계정)

  • 언제 접속했는지 나타내는 일시

  • 어디서 들어왔는지 확인할 수 있는 접속지 IP

  • 열람, 수정, 삭제, 다운로드처럼 시스템에서 실제로 무슨 행동을 했는지 보여주는 수행 업무

  • 그리고 누구의 정보를 조회했는지 가리키는 정보주체 식별자

여기에 몇 가지 조건이 더 붙습니다.

  • 5만 명 이상의 정보나 고유식별정보를 다루면 최소 2년 이상 보관해야 합니다.

  • 한 번 저장된 기록은 누구도 임의로 손댈 수 없어야 합니다.

  • 엑셀이나 파일 형태로 개인정보를 내려받을 때는 왜 다운로드하는지 그 사유를 남겨야 합니다.

  • 대량 조회나 평소와 다른 이상한 접속 흐름이 있는지 주기적으로 모니터링할 수 있어야 합니다.

요구사항 자체는 명확했지만, 이걸 코드에 어떻게 얹을지 고민해보니 몇 가지 걸림돌이 보였습니다.

실제로 만들면서 부딪힌 문제들

스프링 환경에서 감사 로그를 떠올리면 보통 인터셉터나 AOP를 먼저 생각하게 됩니다. 설계 초기, 요청 앞단에서 인터셉터로 가로채 기록하는 방법을 생각하였지만, 실제로 설계를 해보니 그렇게 단순하지 않았습니다.

1. 엔드포인트가 수백 개인데 사람이 다 기억할 수 있을까?

반장노트에는 반장용, 팀장용, 관리자용 등 역할별로 나뉜 API가 300개가 넘게 있었습니다. 개발자가 새 API를 만들 때마다 "이건 개인정보를 다루니까 로그를 남겨야지" 하고 매번 떠올리기는 현실적으로 어렵습니다. 바쁘게 개발하다 보면 누락되기 십상이고, 코드 리뷰만으로 걸러내기에도 한계가 뚜렷했습니다. 또 반대로 기존 API를 지우거나 주소를 바꿨을 때 예전에 등록해 둔 설정이 그대로 방치되는 문제도 있었습니다.

2. 인터셉터는 '누구의 정보'를 봤는지 모른다

인터셉터는 클라이언트가 보낸 URL이나 요청 헤더, 로그인한 사람의 토큰까지는 쉽게 알아냅니다. 그런데 법에서 요구하는 '정보주체 식별자', 즉 "이 사람이 누구의 개인정보를 열어봤는가?"는 알 방법이 없습니다. 예를 들어 특정 현장의 반장 명단을 조회하는 API라면, 실제로 조회 결과로 나온 근로자 10명의 회원 ID는 서비스 로직이 데이터베이스를 조회해보고 나서야 결정됩니다. 웹 계층의 인터셉터와 비즈니스 로직 계층 사이에 데이터가 끊기는 셈이었습니다.

3. 조회할 때마다 DB에 INSERT를 하면 감당이 될까?

건설 현장 특성상 이른 아침 출근 시간에 조회가 집중적으로 몰릴 것으로 예상되었습니다. 개인정보가 포함된 목록을 부를 때마다 감사 로그 테이블에 매번 동기적으로 INSERT 쿼리를 날린다면 DB 커넥션 풀에 바로 무리가 갈 것 같았습니다. 감사 로그를 남기겠다고 정작 중요한 비즈니스 API 속도가 느려지는 상황이 발생할 수도 있습니다. 또한 만약 감사 로그를 쓰다가 일시적인 DB 락이나 지연이 생겼다고 해서, 사용자가 구직정보를 조회하거나 공수를 확인하는 본래 작업까지 같이 롤백되는 일은 피해야 했습니다.

4. 로그 자체로 인한 2차 유출

감사 로그를 꼼꼼하게 남긴답시고 응답 객체 전체를 직렬화해서 로그 테이블에 밀어 넣으면, 그 테이블 자체가 주민번호나 계좌번호 평문이 모여있는 위험한 저장소가 되어버립니다. 데이터 본문은 숨기고, 어떤 종류의 항목(이름, 전화번호, 계좌 등)에 접근했는지만 메타데이터 형태로 남겨야 했습니다.

문제 해결하기

1. 감사기록 누락 방지 - 테스트 코드로 엔드포인트 전수 등록 강제하기

사람의 기억력에 기대지 않으려면, 등록을 빼먹었을 때 빌드 자체가 실패하게 만드는 수밖에 없다고 생각했습니다.

먼저 레지스트리 클래스를 하나 두고, 시스템 내 모든 엔드포인트를 개인정보 취급 대상과 비대상으로 빠짐없이 등록하도록 했습니다. 비대상인 엔드포인트도 그냥 두는 게 아니라 헬스체크용인지, 단순 본인 정보 수정인지 사유 코드를 함께 달아 등록했습니다.

// 대상 엔드포인트 등록
AccessLogTargetRegistry.register(
    "/feature/cm/find-somebodys-detail/fetch",
    true,
    AccessTaskType.Read,
    List.of(
        PersonalDataItem.Name,
        PersonalDataItem.Phone,
        PersonalDataItem.BirthDate,
        PersonalDataItem.Certificate
    )
);

// 비대상 엔드포인트도 사유와 함께 명시
AccessLogNonTargetDeclarations.registerNonTarget(
    "/actuator/health", 
    NonTargetReason.HealthCheck
);

그리고 스프링 메타데이터 리더를 이용하여 프로젝트 내 모든 @RestController의 URL 경로를 긁어오는 테스트를 작성했습니다.

class AccessLogTargetRegistryArchitectureTest {

    @Test
    @DisplayName("모든 Resource 엔드포인트가 레지스트리에 등록되어 있어야 한다")
    void allRoleResourceEndpointsMustBeRegistered() throws Exception {
        Set<String> discoveredEndpoints = scanAllRoleResourceEndpoints();

        // 레지스트리에 대상이든 비대상이든 등록되지 않은 엔드포인트를 찾는다
        List<String> missing = discoveredEndpoints.stream()
                .filter(ep -> !AccessLogTargetRegistry.contains(ep))
                .toList();

        // 하나라도 빠져 있으면 테스트 실패
        assertTrue(missing.isEmpty(),
                "AccessLogTargetRegistry에 등록되지 않은 엔드포인트가 있습니다: " + missing);
    }

    @Test
    @DisplayName("코드에서 삭제된 엔드포인트가 레지스트리에 남아있으면 안 된다")
    void registryMustNotContainStaleEndpoints() throws Exception {
        Set<String> realEndpoints = scanAllRoleResourceEndpoints();
        List<String> stale = AccessLogTargetRegistry.getAll().keySet().stream()
                .filter(ep -> !realEndpoints.contains(ep))
                .sorted()
                .toList();
        assertTrue(stale.isEmpty(), "실제 코드에 없는 엔드포인트가 레지스트리에 남아있습니다: " + stale);
    }
}

이렇게 해두니 새 API를 만들고 레지스트리에 등록하지 않으면 회귀테스트와 CI 과정에서 API 감사기록이 누락되거나, 삭제된 API를 레지스트리에 그대로 방치해도 테스트가 잡아낼 수 있었습니다. 개발자가 따로 신경 쓰지 않아도 엔드포인트 등록 상태가 항상 100% 최신으로 유지되게 만들었습니다.

2. ThreadLocal을 통한 비즈니스 컨텍스트 전달

인터셉터와 비즈니스 로직 사이의 데이터 단절은 ThreadLocal 기반의 컨텍스트(AccessLogContext)를 만들어 메웠습니다.

요청이 들어오면 인터셉터의 preHandle에서 호출자 정보, 클라이언트 IP, 요청 시각 같은 정보를 AccessLogContext에 먼저 세팅합니다.

이후 컨트롤러를 거쳐 서비스 로직으로 들어가면, DB에서 실제 데이터를 가져온 뒤 컨텍스트에 정보주체와 대상 개인정보를 기록하는 메서드를 호출합니다.

@Service
@Transactional
public class WorkLdrFlow {
    //
    public WorkCrewMemberDetailRdo findMemberDetail(FindWorkCrewMemberDetailUdo udo) {
        //
        // 조회 대상이 된 정보주체 식별자를 컨텍스트(AccessLogContext)에 전달
        AccessLogContext.reportTargetSubject(udo.getMemberId(), AccessLogSubjectKind.Member);

        // 실제 업무 로직 수행
        return workCrewTask.findMemberDetail(udo.getMemberId());
    }
}

비즈니스 로직에서는 이렇게 한 줄을 추가하여 context에 기록할 수 있도록 하여 복잡한 로깅 로직이 서비스 레이어를 침범하지 않도록 하였습니다.

그리고 요청이 끝나는 시점에 인터셉터의 afterCompletion에서 이 정보들을 조합해 최종 로그 객체를 만듭니다.

3. 비동기 배치로 응답 속도 지키기

비즈니스를 수행하는 API 응답 속도를 지키기 위해 인터셉터에서는 DB에 직접 쿼리를 날리지 않고, 메모리 버퍼(AccessLogBatchAccumulator)에 객체를 던져두고 바로 빠져나오게 했습니다.

@Component("accessLogBatchAccumulator")
@RequiredArgsConstructor
public class AccessLogBatchAccumulator {

    private final AccessLogBulkInsertStore bulkInsertStore;
    private final List<AccessLogCdo> buffer = new ArrayList<>();
    private final Object bufferLock = new Object();

    private int batchSize = 200; // 200건 기준
    private long flushIntervalMs = 1000; // 1초 주기
    private volatile long lastFlushTime = System.currentTimeMillis();

    public void add(AccessLogCdo cdo) {
        if (cdo == null) return;
        boolean shouldFlush = false;

        // 버퍼에 담을 때만 빠르게 락을 잡고 빠진다
        synchronized (bufferLock) {
            buffer.add(cdo);
            if (buffer.size() >= batchSize) {
                shouldFlush = true;
            }
        }

        // 실제 무거운 DB 작업은 락 바깥에서 수행
        if (shouldFlush) {
            drainAndInsert();
        }
    }

    @Scheduled(fixedDelayString = "1000")
    public void scheduledFlush() {
        // 200건이 안 차더라도 1초가 지나면 남아있는 로그를 비워준다
        boolean hasItems;
        long elapsed;
        synchronized (bufferLock) {
            hasItems = !buffer.isEmpty();
            elapsed = System.currentTimeMillis() - lastFlushTime;
        }
        if (hasItems && elapsed >= flushIntervalMs) {
            drainAndInsert();
        }
    }

    private void drainAndInsert() {
        List<AccessLogCdo> toInsert;
        synchronized (bufferLock) {
            if (buffer.isEmpty()) return;
            toInsert = new ArrayList<>(buffer);
            buffer.clear();
            lastFlushTime = System.currentTimeMillis();
        }

        // 별도 트랜잭션으로 JDBC Batch Insert 실행
        bulkInsertStore.insertAllInNewTransaction(toInsert);
    }

    @PreDestroy
    public void onShutdown() {
        // 서버가 내려갈 때 버퍼에 남은 로그를 비우고 종료
        drainAndInsert();
    }
}

또한 메모리 버퍼에서는 JPA saveAll 대신 네이티브 batchUpdate를 사용해 200건씩 모아서 insert를 하여 DB와 network 부하를 최소화하였습니다..

동시에 버퍼에 담는 순간에만 짧게 락을 걸고, 무거운 DB I/O는 락 바깥에서 돌도록 나누어 스레드끼리 서로 기다리느라 멈추는 병목을 막았습니다. 서버가 재배포되거나 꺼질 때 메모리에 대기 중이던 로그가 증발하지 않도록 @PreDestroy로 남은 데이터를 모두 저장하고 종료할 수 있도록 하였습니다.

개인정보 감사 로그는 서비스의 블랙박스

개발을 하다 보면 보안이나 법적 규제 관련 작업들은 뒤로 밀리기 쉽습니다. 당장 눈에 보이는 기능도 아니고, 챙겨야 할 규칙은 많다 보니 솔직히 번거롭고 귀찮은 일로 느껴질 때도 많습니다.

하지만, 우리가 다루는 데이터는 데이터베이스에 들어있는 단순한 텍스트가 아닙니다. 인터넷 세상에서 누군가를 대표하는 모습이며, 실제 세계를 살아가는 누군가를 식별하게 하는 꼬리표입니다. 이런 정보들이 허술하게 관리되다가 밖으로 새어 나가면 그 피해는 고스란히 사용자들에게 돌아갑니다. 사용자가 우리 서비스를 믿고 개인정보를 맡겨준 만큼, 그 믿음에 시스템으로 답하는 게 개발자의 의무라는 생각이 들었습니다.

그리고 감사 로그는 사용자뿐 아니라 우리 서비스와 개발자 스스로를 지켜주는 장치이기도 합니다. 만약 외부에서 이상한 접근이 있거나 유출 의혹이 생겼을 때, 누가 언제 어디서 무슨 데이터를 건드렸는지 명확한 기록이 없다면 개발자는 아무런 방어도 할 수 없습니다. 꼼꼼하게 남겨둔 접속기록이 있어야 비로소 "우리 시스템은 정상적인 절차를 지켰고, 문제가 된 계정의 접근 범위는 여기까지였다"고 명확하게 설명할 수 있을 것입니다.

컴플라이언스를 지키고, 안전장치를 만든다는 건 서비스 개발 속도를 늦추기 위한 게 아니라, 나중에 더 큰 속도로 달릴 때 사고가 나지 않도록 준비하는 일에 가깝다고 봅니다. 든든한 블랙박스를 만들어 둔다면, 계속 발전하며 커가는 서비스의 기능들을 안심하고 개발할 수 있게 될 것입니다.

informalife

Site footer