개발 slecs

apiphones 보안 감사 대응으로 운영 안정성 개선

목차

보안 감사 항목을 한 줄씩 따라가다 보면, 실제로 코드를 건드려야 하는 지점이 생각보다 많다. 이번 apiphones 플랫폼 감사 대응도 그랬다. 체크리스트를 받아보는 순간 "아, 이건 내부 클래스 수준에서 손봐야 하는 거구나"라는 게 바로 보였다.

규모 자체는 크지 않았지만, 이런 작업이 대충 넘어가면 나중에 훨씬 더 큰 공수가 드는 것도 알고 있어서 꼼꼼하게 챙겼다.

보안 감사가 내부 클래스를 건드리는 이유

Spring MVC + MyBatis 구조에서 내부 클래스는 꽤 많은 역할을 쥐고 있다. 요청 파라미터를 받아서 바인딩하고, 비즈니스 로직을 처리한 뒤, 쿼리 결과를 매핑해서 응답으로 돌려주는 흐름 전체가 거기 있다고 보면 된다. 보안 감사에서 자주 지적되는 항목들, 입력값 검증 미흡이나 예외 흐름의 정보 노출 같은 것들이 대부분 이 레이어에서 나온다.

이번에 확인한 항목은 크게 세 가지였다:

  • 요청 파라미터의 null, 빈 값, 비정상 입력에 대한 사전 검증 누락 가능성
  • 예외 발생 시 스택 트레이스가 응답에 노출될 수 있는 구조
  • 트랜잭션 범위가 명시되지 않아 부분 실패 시 롤백이 보장되지 않는 케이스

세 번째는 보안이라기보다 운영 안정성 이슈에 가깝긴 한데, 감사 보고서에서 "데이터 정합성 위험"으로 묶여 나왔다. 사실 따지고 보면 세 가지 모두 근본적으로는 같은 문제다. 코드가 예상치 못한 입력이나 상황에 얼마나 방어적으로 반응하느냐.

구현하면서 챙긴 것들

파라미터 검증은 바인딩 직후에 처리하는 게 원칙이다. 컨트롤러 레이어에서 들어올 때 한 번 막아주고, 서비스 레이어로 넘어가기 전에 한 번 더 체크하는 구조가 방어적으로 맞다. 단순해 보이지만 이게 없으면 MyBatis 쪽 SQL에 null이 그대로 내려가거나, NPE가 터져서 스택 트레이스가 응답에 섞이는 상황이 생긴다.

// 내부 클래스 파라미터 바인딩 후 검증 예시
if (param == null || param.getId() == null || param.getId().isBlank()) {
    throw new IllegalArgumentException("필수 파라미터 누락: id");
}

특히 JSP 뷰로 렌더링하는 경우 에러 페이지에 서버 경로 정보나 클래스명이 노출되는 건 꽤 오래된 문제인데, 입력값 검증 하나로 이 흐름 자체를 막을 수 있다.

예외 처리 전략은 이번에도 고민했다. RuntimeException을 그냥 전파할지, 잡아서 응답 형태로 변환할지. 결론은 내부 클래스에서는 예측 가능한 예외를 잡고 변환하고, 그 이상의 예외는 전파해서 글로벌 핸들러에 위임하는 쪽으로 가닥을 잡았다.

try {
    result = service.process(param);
} catch (BusinessException e) {
    log.warn("처리 실패: {}, param={}", e.getMessage(), param.getId());
    return errorResponse(e.getCode(), e.getMessage());
} catch (Exception e) {
    log.error("예상치 못한 예외", e);
    throw e;
}

BusinessException처럼 예측 가능한 케이스는 잡아서 정제된 에러 응답으로 돌리고, 그 외는 전파해서 글로벌 핸들러가 처리하게 두었다. 스택 트레이스가 클라이언트로 새는 걸 막으면서도 서버 로그에는 디버깅 정보가 온전히 남는 구조다. 양쪽 다 챙기려면 이 패턴이 현실적으로 제일 무난하다.

트랜잭션 범위@Transactional 어노테이션 위치를 명확히 하는 작업이었다. DB를 건드리는 서비스 메서드 단위로 걸어두고, 읽기 전용 쿼리는 readOnly = true로 명시했다. 이게 없으면 INSERT 중간에 예외가 터져도 롤백이 안 되는 상황이 생기는데, 운영에서 이걸 사후에 잡아내려면 로그 뒤지는 시간이 배로 든다.

항목 수정 전 수정 후
파라미터 검증 서비스 레이어 내부에서 산발 처리 바인딩 직후 명시적 검증
예외 응답 스택 트레이스 포함 가능성 BusinessException 변환 후 정제된 응답 반환
트랜잭션 범위 불명확 @Transactional 명시, readOnly 구분
로깅 주요 입출력 누락 케이스 INFO 레벨로 key param, 결과 상태 기록

SQL XML과 결과 매핑은 MyBatis 특성상 파라미터 타입이 맞지 않으면 런타임에 터지는 구조라서 resultType, parameterType 명시도 다시 한번 확인했다. 명시하지 않아도 동작하긴 하는데, IDE에서 타입 추적이 안 되고 나중에 리팩토링할 때 손이 더 많이 간다. 지금 당장 문제가 없어 보여도 이런 게 쌓이면 유지보수 비용이 올라간다.

배포 전까지의 흐름과 검증

로컬 기동 확인 → 개발 DB 반영 → 배포 순서를 그대로 따랐다. 내부 클래스 수정이 중심이어서 인프라 변경은 없었지만, 기존 흐름에 영향이 없는지 추적하는 게 오히려 더 신경 쓰이는 부분이었다.

변경한 내부 클래스를 참조하는 다른 컨트롤러나 서비스 경로가 있는지 IDE에서 먼저 확인하고, 관련 경로를 하나씩 호출해서 기존 동작이 그대로인지 검증했다. 자동화된 테스트가 있었으면 훨씬 편했겠지만, 없는 상황에서는 이게 현실적인 방법이다. "수정한 로직이 기존 흐름에 영향 없는지 추적"이 코드 리뷰 체크리스트에서 제일 마지막에 있지만, 실제로는 제일 먼저 챙겨야 하는 항목이기도 하다.

로그는 운영에서 디버깅 가능하도록 중요 입출력값을 INFO 레벨로 남겨두는 걸 기준으로 삼았다. 너무 많으면 노이즈가 되고, 너무 적으면 문제 생겼을 때 단서가 없다. 파라미터 ID, 처리 결과 상태 정도는 남기는 게 나중에 훨씬 덜 힘들다는 게 경험칙이다.

작업 규모 자체는 크지 않았지만, 보안 감사 대응이 단순히 "지적 사항 해소"가 아니라는 걸 다시 확인한 작업이었다. 파라미터 검증 하나, 예외 처리 전략 하나, 트랜잭션 범위 명시 하나가 운영에서 실제로 차이를 만든다. 이런 작업들이 쌓여서 전체 시스템의 신뢰도가 올라가는 거라고 생각한다.

댓글 0

첫 댓글 달아줘.