본문으로 바로가기
dobidugi
목록으로
아키텍처회고

감사 로그를 설계하고 구축하며 고민한 것들

12분
목차

파일 로그만으로는 부족했다

기존 서비스에는 Logger로 남기는 파일 로그가 있었지만, 변경이나 조회 이력을 따로 기록하는 기능은 없었다.

특히 “누가 어느 환자의 정보를 조회했는가?”를 확인하기 어려웠다. 환자 목록을 조회한 요청이 있어도, 그 응답에 어떤 환자가 포함됐는지까지 남지는 않았다. 특정 환자가 언제, 어느 계정의 조회 결과에 포함됐는지를 거슬러 올라가기도 어려웠다.

파일 로그는 수정·삭제 권한이 있으면 내용을 바꾸거나 지울 수 있다는 점도 마음에 걸렸다. 나중에 이력을 확인할 근거로 쓰려면, 기록을 남기는 것뿐 아니라 보호하는 방법도 함께 생각해야 했다.

이런 문제를 보완하려고 별도의 감사 로그를 구축해보기로 했다. 직접 설계하는 건 처음이라, 초반에 팀장님께 기록 범위와 트랜잭션 처리에 대해 간단히 조언을 구했다.

변경 기록과 조회 기록을 별도 테이블로 나눴다

먼저 로그를 저장할 테이블부터 설계했다. 변경 기록과 조회 기록은 남기려는 내용이 달라, 두 테이블로 나누기로 했다.

변경 기록에서는 누가 어떤 작업을 했고, 그 작업이 성공했는지가 중요했다. 조회 기록에서는 어떤 환자의 정보에 접근했는지를 찾을 수 있어야 했다. 특히 목록 조회는 요청 한 번에 여러 환자가 포함된다는 차이도 있었다.

테이블기록 범위주로 답하려는 질문
audit_log변경·반출·인증과 실패한 변경 시도누가 어떤 작업을 했고 결과는 무엇인가?
access_record환자 정보 조회이 환자의 정보를 누가 조회했는가?

audit_log에는 데이터 생성·수정·삭제, 로그인·로그아웃, 비밀번호 변경, 리포트 반출을 기록하기로 했다. access_record에는 환자 목록·상세, 검사·영상·판독 결과를 조회한 행위를 기록하기로 했다.

조회 기록에는 정보의 주체인 환자의 기본키를 남기기로 했다. 검사나 영상을 조회했더라도, 나중에 “이 환자의 정보를 누가 봤는가?”를 찾을 수 있어야 했기 때문이다.

조회 기록은 환자 목록을 한 번 조회해도 여러 행이 쌓일 수 있다. 변경 기록과 따로 두면, 보관 기간과 점검 주기도 각 기록의 용도에 맞춰 관리하기 편하겠다고 생각했다.

처음에는 최소한의 정보만 남겼다

변경 기록은 다섯 가지 값에서 시작했다.

항목의미
기관 ID해당 기관의 기본키
요청자 ID요청자 계정의 기본키
타겟 테이블작업 대상 테이블
이벤트명수행한 작업
타겟 ID대상 레코드의 기본키

이 정도면 누가 어느 레코드에 무슨 작업을 했는지는 알 수 있었다. 실제로 먼저 이 형태로 구현했다.

그런데 수정 기록을 보니 한 가지가 빠져 있었다. 수정했다는 사실은 알 수 있지만, 무엇이 어떻게 바뀌었는지는 알 수 없었다. 그래서 변경 전후 값인 before와 after를 추가할지 고민했다.

before/after를 저장하지 않기로 한 이유

고민 끝에 변경 전후의 실제 값은 남기지 않기로 했다. 전후 값을 저장하면 변경 내용을 추적하기는 쉽지만, 그 안에 환자의 개인정보가 그대로 들어갈 수 있었기 때문이다.

이름이나 생년월일을 수정할 때 이전 값과 새 값을 모두 기록하면, 감사 로그에 환자 정보의 사본이 하나 더 생긴다. 원본 데이터를 삭제해도 예전 값은 로그에 남을 수 있다.

감사 로그의 수정과 삭제도 제한할 계획이었다. 개인정보를 한 번 넣으면 나중에 그 값을 정리하는 일도 어려워질 수 있었다.

그래서 이름이나 생년월일, 판독 메모를 복제하는 대신 누가 어떤 대상에 무슨 일을 했는지를 남겼다.

대신 현재 감사 로그만으로 예전 필드 값을 복원할 수는 없다. 변경 전후의 환자 값을 복제하지 않기로 하면서 받아들인 한계다.

나중에 변경 내용을 더 자세히 알아야 한다면, 실제 값 없이 변경된 필드 이름만 남기는 방법을 검토할 수 있다. 수정 전 값을 보관해야 한다는 요구가 생긴다면 데이터 이력의 범위와 보존 정책도 따로 설계해야 한다.

목록 조회 한 번을 몇 건으로 남겨야 할까?

환자 목록은 요청을 설명하는 대표 행 1건과 조회된 환자별 행 N건으로 기록하기로 했다. 같은 요청에서 나온 행에는 모두 같은 trace_id를 넣었다.

이 부분은 조회 기록을 설계하면서 가장 오래 고민했다. 상세 조회는 대상이 환자 한 명이지만, 목록 조회는 요청 한 번에 여러 환자의 정보가 돌아오기 때문이다.

목록을 봤다는 사실과 검색 조건만 기록하면 특정 환자가 당시 결과에 포함됐는지 알 수 없다. 그래서 목록에 나온 환자 각각의 기본키를 남기는 방식이 필요했다.

대표 행에는 조회 건수, 페이지, 옵션, 검색어 해시를 담았다. 환자별 행에는 응답에 포함된 환자의 기본키를 한 명당 하나씩 남겼다.

환자 두 명이 나온 목록이라면 다음처럼 기록된다. 아래 값은 설명을 위한 예시다.

actiontarget_seqtrace_iddetail
PATIENT_LIST_VIEWEDNULL요청 Acount: 2, page: 0, 조회 옵션, keywordHash
PATIENT_LIST_VIEWED101요청 ANULL
PATIENT_LIST_VIEWED102요청 ANULL

대표 행과 환자별 행의 action은 모두 PATIENT_LIST_VIEWED다. 대표 행에는 target_seq가 없고, 환자별 행에는 해당 환자의 기본키가 있다. 환자 상세 화면을 조회할 때는 별도의 PATIENT_VIEWED 이벤트를 남긴다.

trace_id로 찾으면 한 요청의 조건과 환자들을 함께 확인할 수 있다. 반대로 환자 기본키로 검색하면 그 환자가 어느 계정의 어떤 목록 요청에 포함됐는지 알 수 있다. 행이 저장된 순서에 의존하지 않아도 된다.

검색어는 원문 대신 keywordHash를 저장하기로 했다. 환자 이름 조회에 사용하는 블라인드 인덱스와 같은 값을 남겨, 필요할 때 검색 조건을 대조할 수 있도록 했다.

검색어에 환자 이름이 들어갈 수 있어서 원문을 그대로 기록하기는 어려웠다. 기존 파일 로그에서도 같은 이유로 URL 파라미터를 제외하고 있었다.

검색어 원문을 뺐다고 해서 조회 기록이 익명 데이터가 되는 것은 아니다. 환자 기본키는 원본 데이터와 연결되고, 계정과 접속지 정보도 남는다. 기록 자체의 접근 권한은 따로 관리해야 한다.

환자별로 남기는 만큼 기록량은 늘어난다. 그래도 특정 환자의 조회 이력을 찾으려면 필요한 기록이라고 봤다. 월 단위 분할과 만료 기록 정리는 운영 과제로 남겼다.

다건 삽입은 JdbcTemplate으로 처리했다

조회 기록은 JdbcTemplate으로 여러 행을 한꺼번에 저장했다. 대표 행과 환자별 행을 하나의 INSERT 문에 담는 방식이다.

목록이 길어지면 조회 요청 한 번에 많은 기록을 저장해야 할 수 있다. 환자 한 명당 INSERT를 따로 실행하기보다, 한 요청에서 생긴 기록을 함께 넣을 방법이 필요했다.

기존 데이터 접근에는 JPA와 QueryDSL을 쓰고 있었다. 하지만 조회 기록의 기본키는 IDENTITY 방식이었고, Hibernate는 이 방식의 엔티티에 대해 INSERT 배칭을 지원하지 않는다. Hibernate 공식 문서에도 설명된 제약이다.

SQL을 단순화하면 다음과 같다.

INSERT INTO access_record (
    occurred_at, institution_seq, account_seq, login_id,
    action, target_type, target_table, target_seq, trace_id, detail
)
VALUES
    (?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
    (?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
    (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);

여기서 사용한 방식은 하나의 SQL 문에 여러 행을 넣는 다중 행 INSERT다. JDBC 배치 API로 여러 SQL 문을 묶어 실행하는 방식과는 구분된다.

기본키 전략을 바꾸지 않고도 한 요청에서 생긴 여러 조회 기록을 함께 저장할 수 있었다.

변경과 조회에 서로 다른 트랜잭션 경계를 적용했다

성공한 변경은 함께 커밋하고, 실패한 시도는 따로 남긴다

성공한 변경은 비즈니스 작업과 같은 트랜잭션에 기록하고, 실패한 변경 시도는 별도로 기록하기로 했다.

변경이 롤백됐다면 성공 기록도 남지 않아야 했다. 반영되지 않은 작업을 성공으로 기록하면 실제 데이터와 감사 기록이 맞지 않기 때문이다.

반면 실패한 시도까지 같은 트랜잭션에 저장하면 롤백할 때 함께 사라진다. 그래서 성공 기록과 실패 기록은 저장 경로를 나눴다.

변경이 성공하면 서비스가 이벤트를 발행하고, 동기 리스너가 같은 트랜잭션에서 audit_log를 저장한다. 실제 데이터와 성공 기록이 함께 커밋되고, 변경이 롤백되면 성공 기록도 함께 사라진다.

sequenceDiagram
    participant S as 변경 서비스
    participant L as 감사 이벤트 리스너
    participant DB as DB
    S->>DB: 데이터 변경
    S->>L: 성공 감사 이벤트 발행
    L->>DB: audit_log INSERT
    Note over S,DB: 변경과 성공 기록은 같은 트랜잭션
    S->>DB: 함께 COMMIT

일반 변경 경로의 감사 기록 저장에는 MANDATORY를 적용했다. 이미 열린 트랜잭션이 없으면 예외가 나므로, 성공 기록만 따로 커밋되는 상황을 막을 수 있다. 이 동작은 Spring의 전파 속성 문서에도 설명돼 있다.

리포트 파일 반출처럼 비즈니스 변경 트랜잭션이 없는 경로에서는 감사 기록을 위한 트랜잭션을 따로 연다.

대상이 없거나 권한이 없거나, 충돌·서버 오류로 변경이 실패하면 성공 행을 남기지 않는다. 변경을 취소한 뒤 처리 결과와 오류 코드를 담은 실패 기록을 별도로 저장한다. 입력 형식 오류는 기록 대상에서 제외했다.

조회 기록은 따로 저장하고, 저장이 끝나야 응답한다

조회 서비스는 기본적으로 읽기 전용(readOnly) 트랜잭션에서 데이터를 읽는다. 조회 기록은 쓰기 작업이므로 새 트랜잭션을 열어 저장하도록 했다.

조회 기록 저장에 실패하면 환자 정보도 반환하지 않도록 했다. 기록 오류를 무시하고 응답하면 환자 정보는 제공했는데 조회 이력은 없는 상황이 생기기 때문이다.

구현 방식은 조회 서비스에서 이벤트를 발행하고, 리스너를 통해 기록을 저장하는 구조로 잡았다.

데이터를 가져온 뒤 조회 이벤트를 발행하면 리스너가 저장할 행을 만들고, 기록 저장을 담당하는 컴포넌트가 REQUIRES_NEW로 새 트랜잭션을 열어 access_record에 저장한다.

목록 조회에서는 대표 행과 환자별 행을 함께 저장한다.

이벤트는 동기로 처리해서, 기록 저장이 끝나야 조회 서비스도 다음 단계로 진행한다.

Spring의 기본 이벤트 처리도 동기 방식이어서 publishEvent()를 호출한 쪽은 리스너의 처리가 끝날 때까지 기다린다. Spring 이벤트 문서에서 이 동작을 확인할 수 있다.

목록 조회의 저장 흐름은 다음과 같다.

sequenceDiagram
    participant S as 조회 서비스
    participant L as 조회 이벤트 리스너
    participant W as 기록 writer
    participant DB as DB
    S->>DB: SELECT - 기존 트랜잭션
    S->>L: 조회 이벤트 발행 및 동기 처리
    L->>W: 대표 행과 환자별 행 전달
    W->>DB: 다중 행 INSERT - 새 트랜잭션
    alt 기록 저장 성공
        W->>DB: COMMIT
        W-->>L: 완료
        L-->>S: 완료
        Note over S: 이후 응답 반환
    else 기록 저장 실패
        DB-->>W: 오류
        W-->>L: 예외 전파
        L-->>S: 예외 전파
        Note over S: 환자 정보를 반환하지 않고 요청 실패
    end

응답을 막으려면 저장 오류를 조회 서비스까지 전파하고, 응답을 쓰기 전에 요청을 실패시켜야 한다. 작은 응답과 큰 응답 모두에서 기록 저장에 실패하면 환자 정보가 응답에 담기지 않는지 확인했다.

권한이 없어 거부된 조회 시도는 두 테이블 모두에 남기지 않았다.

다만 기록이 커밋된 뒤 응답 전송이 실패할 가능성은 남는다. 이 조회 기록은 서버가 요청을 처리하며 접근한 대상을 뜻하며, 사용자가 화면에서 실제로 읽었다는 증명까지 하지는 않는다.

새 트랜잭션을 열면 DB 연결도 하나 더 필요하다. 조회 트랜잭션이 연결을 잡고 있는 동안 기록용 연결을 별도로 얻기 때문이다.

요청이 몰리면 커넥션 풀이 부족해질 수 있으므로, 풀 크기와 동시 요청 시 동작은 배포 전에 확인해야 한다. Spring도 REQUIRES_NEW의 연결 사용에 관한 주의점을 안내한다.

기록을 찾기 위한 정보도 함께 남겼다

최종 기록에는 기관·요청자·이벤트·대상 정보 외에 행위 시각, 당시 로그인 ID, 접속지 IP, trace_id, 부가 정보를 남겼다. audit_log에는 성공·실패 결과와 오류 코드도 기록했다. 대상의 종류와 실제 테이블 이름은 원본 데이터를 찾아갈 수 있도록 함께 저장했다.

로그인 ID와 대상 테이블 이름은 기록 당시의 값으로 보관했다. 나중에 계정 정보나 테이블 이름이 바뀌더라도 당시 기록을 읽을 수 있어야 했기 때문이다. 계정 기본키도 함께 남겨 같은 로그인 ID가 재사용되는 경우에 대비했다.

원본 대상을 가리키는 외래키는 두지 않기로 했다. 대상이 여러 테이블에 걸쳐 있고, 원본이 삭제된 뒤에도 기록은 남아야 했기 때문이다.

대상 정보, 계정과 시간, 기관과 시간, trace_id에는 조회에 필요한 인덱스를 뒀다. 특히 조회 기록에는 target_table, target_seq, occurred_at을 묶은 인덱스를 사용했다. 특정 환자의 조회 이력을 시간순으로 찾기 위한 것이다.

구현을 마치고 남은 운영 작업

배포할 때는 애플리케이션 DB 계정의 감사 로그·조회 기록 UPDATE/DELETE 권한을 회수하고, 실제로 막히는지도 확인해야 한다. DB에 저장하는 것만으로 기록을 수정하거나 삭제할 가능성이 사라지는 것은 아니기 때문이다.

조회 기록은 목록에 나온 환자 수만큼 늘어난다. 월 단위 분할과 만료 기록 정리 일정을 마련해야 한다. 접속기록 점검 주기와 두 테이블의 보관 기간도 운영 정책으로 확정해야 한다.

처음에는 누가 무엇을 했는지만 남기면 될 줄 알았다. 구현해보니 무엇을 남길지뿐 아니라, 무엇을 남기지 않을지도 계속 결정해야 했다. 남은 일은 이 기준이 실제 요청량과 장애 상황에서도 지켜지는지 운영에서 확인하는 것이다.



이전 글
pnpm과 npm, 무엇이 다르고 언제 선택하면 좋을까?
다음 글
벡터의 변화량과 유클리드 거리