PG 시스템 보안 심사를 통과하며 마주친 104개의 보안 이슈
PG 보안 심사에서 확인된 104개 이슈 중 62개를 직접 조치하며 암호화·로그·의존성·TLS·접근제어 기준을 다시 세운 기록이다.
PG 시스템 보안 심사에서 104개의 이슈를 마주했다. 그중 62개를 직접 조치했다. 숫자보다 힘들었던 점은 개별 취약점을 막는 일과 시스템의 개발 기준을 바꾸는 일을 동시에 해야 했다는 것이었다.
내부 구조와 구체적인 취약점은 공개하지 않는다. 대신 여러 이슈에서 반복해서 나타난 개발 기준과 조치 과정을 일반화해 남겼다.
먼저 이슈를 성격별로 나눴다
104개를 한 목록으로 보면 우선순위를 잡기 어려웠다. 개인정보 암호화, 입력값 검증, 로그 개인정보, Dependency 취약점, TLS, 접근제어로 나눴다. 같은 원인에서 생긴 이슈는 공통 조치로 묶었다.
심각도만으로 처리 순서를 정하지 않았다. 실제 외부 노출 여부, 공격에 필요한 조건, 포함된 데이터, 이미 존재하는 방어선을 함께 봤다. 같은 유형이라도 인터넷에서 닿는 API와 내부 배치의 위험은 달랐다.
우선순위 판단
= 영향 데이터
+ 접근 가능한 경로
+ 악용 난이도
+ 기존 방어선
+ 조치 중 서비스 영향
조치 담당과 검증 담당도 구분했다. 코드를 바꾼 사람이 완료라고 표시하는 것으로 끝내지 않고, 재현 조건이 사라졌는지와 정상 결제 흐름이 유지되는지를 다시 확인했다. 심사 대응 문서에는 수정 파일보다 어떤 위험이 어떤 기준으로 닫혔는지를 남겼다.
| 영역 | 다시 세운 기준 |
|---|---|
| 개인정보 | 저장·전송·로그 단계별 노출 범위 |
| 입력값 | 허용 목록과 경계 검증 |
| Dependency | 버전 확인과 정기 점검 |
| TLS | 허용 프로토콜과 인증서 관리 |
| 접근제어 | 업무 역할과 최소 권한 |
개인정보는 저장·전송·출력을 따로 봤다
암호화 컬럼을 추가했다고 개인정보 보호가 끝나지 않았다. 애플리케이션 메모리, 외부 전송, 관리자 조회, 로그, 백업에서 같은 값이 어떻게 다뤄지는지 경로를 따라갔다.
| 구간 | 확인한 기준 |
|---|---|
| 입력 | 필요한 값만 받고 형식과 길이를 제한 |
| 저장 | 민감도에 맞는 암호화와 키 분리 |
| 전송 | TLS와 신뢰할 수 있는 대상 확인 |
| 조회 | 업무 목적에 필요한 범위만 표시 |
| 로그 | 원문을 남기지 않고 안전한 식별자 사용 |
암복호화를 화면과 서비스마다 직접 호출하게 두면 빠지는 경로가 생겼다. 저장 경계에 공통 처리를 두되, 검색과 정렬이 필요한 값은 별도 요구사항을 검토했다. 암호화 방식 때문에 기능이 조용히 달라지지 않게 했다.
키와 암호문을 같은 설정이나 같은 권한으로 다루지 않았다. 키 교체가 필요할 때 기존 데이터를 읽을 방법과 새 데이터에 사용할 버전을 구분했다. 구체적인 키 구조와 운영 환경은 공개하지 않지만, 코드만 바꾸면 끝나는 문제가 아니라는 점이 핵심이었다.
입력값 검증은 허용 목록에서 시작했다
문자열을 받은 뒤 위험한 문자를 제거하는 방식은 빠뜨리기 쉬웠다. 결제 수단 코드, 상태, 정렬 기준처럼 가능한 값이 정해진 입력은 허용 목록으로 제한했다. 숫자와 날짜도 파싱 성공 여부만 보지 않고 비즈니스 범위를 확인했다.
입력 검증 순서
1. 타입과 형식
2. 길이와 범위
3. 허용된 코드값
4. 현재 상태에서 가능한 명령인지
5. 권한이 있는 행위인지
프론트엔드 검증은 사용자 경험을 위한 것이고 보안 경계는 서버였다. 관리자 화면에서 숨긴 값도 HTTP 요청으로 직접 들어올 수 있다고 보고 같은 검증을 적용했다.
오류 응답에는 내부 쿼리나 예외 스택을 그대로 내보내지 않았다. 사용자가 수정할 수 있는 입력 오류와 운영자가 조사해야 하는 내부 오류를 나눴다. 상세 원인은 안전한 식별자로 로그와 연결했다.
로그를 디버깅 출력이 아니라 데이터로 봤다
장애 대응을 위해 남긴 로그가 개인정보 노출 경로가 될 수 있었다. 요청과 응답 전체를 남기는 습관을 버리고, 조사에 필요한 식별자와 상태만 남겼다. 마스킹 위치를 화면마다 따로 구현하지 않고 공통 경계에서 적용했다.
운영 로그 접근 권한과 보존 기간도 코드 밖의 문제로 넘기지 않았다. 누가 어떤 목적으로 조회하는지와 삭제 시점을 함께 확인했다.
공통 마스킹이 놓치는 경우를 테스트했다
필드 이름에만 의존한 마스킹은 같은 값이 다른 이름으로 들어올 때 놓칠 수 있었다. 구조화된 요청과 예외 메시지, 외부 응답을 각각 확인했다. 객체의 toString()이 민감한 필드를 출력하는지도 봤다.
허용한 로그
requestId, operation, result, safe business key
남기지 않은 값
원문 개인정보, 인증정보, 전체 요청·응답 body
장애 대응에 필요하다는 이유로 모든 값을 남기지 않았다. 개별 거래를 찾을 수 있는 안전한 키와 상태 전이를 남기고, 원문 조회가 꼭 필요한 경우에는 더 제한된 시스템과 권한을 사용했다.
Dependency 취약점을 일회성 업데이트로 끝내지 않았다
문제가 된 라이브러리 버전만 올리면 다음 심사에서 같은 일이 반복됐다. 사용 중인 의존성과 전이 의존성을 확인하고, 취약점이 발견됐을 때 영향 범위와 대체 버전을 검토하는 절차를 남겼다.
버전이 최신이라는 이유만으로 바로 올리지 않았다. 결제 흐름과 호환되는지 테스트하고, 변경 내용을 배포 단위에 포함했다.
직접 선언한 라이브러리뿐 아니라 전이 의존성도 목록에 넣었다. 취약한 모듈이 실제 실행 경로에 포함되는지, 제거할 수 있는지, 상위 라이브러리 업데이트가 필요한지 확인했다.
Dependency 발견
→ 사용 여부 확인
→ 영향 경로 확인
→ 제거·업데이트·완화 선택
→ 회귀 테스트
→ 배포와 재검증
바로 업데이트할 수 없는 경우에는 이유와 임시 방어선, 다시 검토할 조건을 남겼다. ‘영향 없음’이라는 문장만 적지 않고 어떤 코드 경로와 설정을 근거로 판단했는지 기록했다.
TLS는 켜짐 여부보다 협상 결과를 확인했다
HTTPS 주소를 사용한다는 사실만으로 안전한 연결이라고 판단하지 않았다. 허용 프로토콜과 암호군, 인증서 신뢰, 호스트 검증, 만료와 교체 절차를 확인했다. 개발 편의를 위해 넣은 인증서 검증 우회가 운영 설정에 남지 않게 했다.
외부 결제사와의 연결은 상대 환경도 함께 바뀔 수 있었다. 지원 범위를 확인하고 변경 전후의 연결 테스트를 배포 절차에 넣었다. 연결 실패 시 민감한 내용을 오류 메시지로 노출하지 않으면서도 원인 분류는 가능하게 했다.
접근제어는 화면이 아니라 행위에 걸었다
메뉴를 숨기는 것과 권한을 막는 것은 달랐다. 승인, 취소, 조회, 설정 변경 같은 행위를 기준으로 권한을 확인했다. 운영 편의를 이유로 넓게 열려 있던 권한은 역할별 최소 범위로 줄였다.
요청
→ 인증 주체 확인
→ 업무 역할 확인
→ 대상 데이터 범위 확인
→ 행위 권한 확인
→ 감사 이력 기록
같은 취소 권한이 있어도 모든 가맹점의 거래를 취소할 수 있다는 뜻은 아니었다. 역할뿐 아니라 사용자가 접근할 수 있는 데이터 범위를 함께 확인했다. 서버의 서비스 경계에서 검사해 다른 화면이나 직접 호출도 같은 규칙을 지나게 했다.
민감한 설정 변경과 금액에 영향을 주는 행위는 누가 언제 무엇을 바꿨는지 감사 이력을 남겼다. 감사 로그에는 변경 전후를 무조건 원문으로 넣지 않고, 조사에 필요한 범위와 개인정보 기준을 함께 적용했다.
조치 완료를 증거로 남겼다
코드가 바뀐 것과 위험이 닫힌 것은 달랐다. 각 이슈에 재현 조건, 수정 원칙, 검증 결과, 영향받는 기능을 연결했다. 자동 테스트가 가능한 항목은 회귀 테스트로 남기고 설정 항목은 배포 점검에 넣었다.
| 증거 | 역할 |
|---|---|
| 테스트 | 같은 입력에서 취약한 동작이 재발하지 않음 |
| 설정 검증 | 운영 환경에도 의도한 제한이 적용됨 |
| 코드 리뷰 | 공통 경계와 우회 경로 확인 |
| 운영 확인 | 정상 결제와 관리자 업무가 유지됨 |
심사 직전에만 확인하면 같은 문제가 다시 쌓였다. 새 API, 새 로그, 새 의존성이 들어올 때 기존 기준을 자동으로 확인할 수 있는 항목부터 개발 절차로 옮겼다.
62개를 조치하며 배운 것
보안 심사를 통과하는 것만 목표로 잡으면 다음 기능에서 같은 문제가 생겼다. 반복되는 이슈를 코딩 규칙, 공통 모듈, 배포 점검 항목으로 옮겨야 했다.
모든 이슈를 공통 모듈 하나로 해결할 수는 없었다. 암호화와 마스킹처럼 중앙에서 강제할 수 있는 기준이 있는 반면, 권한과 비즈니스 입력은 도메인 맥락을 알아야 했다. 공통화할 부분과 각 기능이 책임질 부분을 구분했다.
보안은 완료 상태로 고정되지 않았다. 의존성, 외부 연동, 운영 역할이 바뀌면 위험도 다시 달라졌다. 104개를 닫는 것과 다음 이슈를 더 빨리 발견하는 체계를 만드는 일을 같이 진행했다.
104개 이슈 중 62개를 직접 조치한 경험은 보안을 별도 단계로 보던 시각을 바꿨다. 입력, 저장, 로그, 전송, 권한은 결제 기능을 만든 뒤 덧붙이는 조건이 아니라 처음부터 함께 설계해야 할 경계였다.