Skip to main content
Summary

보안은 나중에 붙이는 기능이 아니다

보안은 보안팀만의 일이 아니다. 사용자의 입력을 받고, 데이터를 저장하고, API를 만드는 모든 개발자의 일이다. 로그인 화면, 주문 조회 API, 관리자 페이지, 파일 업로드 기능은 모두 보안 판단을 포함한다. 여기서 판단을 빠뜨리면 기능은 정상처럼 보여도 공격자에게는 열린 문이 된다.

개발자가 기억해야 할 보안의 출발점은 단순하다. 입력은 믿지 않는다. 권한은 매번 확인한다. 비밀은 코드에 넣지 않는다. 실패 상황도 안전하게 처리한다. 이 원칙은 언어나 프레임워크가 바뀌어도 오래 유효하다.

보안 취약점은 대개 아주 특별한 코드에서만 생기지 않는다. 문자열을 직접 이어 붙인 SQL, 사용자 입력을 그대로 출력한 HTML, 로그인만 확인하고 소유자 검증을 빼먹은 API, 로컬에서 쓰던 비밀키를 저장소에 커밋한 실수에서 생긴다. 중요한 것은 취약점 이름을 외우는 것이 아니라, 어떤 개발 습관이 위험을 만드는지 이해하는 것이다.

보안 기본기와 안전한 개발 습관AI 생성 이미지

입력을 믿을 때 생기는 대표 취약점

대부분의 웹 보안 문제는 입력 처리에서 시작된다. 사용자가 입력한 값, URL 파라미터, HTTP 헤더, 쿠키, 업로드 파일, 외부 API 응답은 모두 조작될 수 있다. 브라우저 화면에서 선택지만 제한했다고 안전해지는 것은 아니다. 공격자는 개발자 도구, 스크립트, curl 같은 도구로 요청을 직접 만들 수 있다.

SQL Injection은 사용자 입력이 SQL 명령의 일부로 해석될 때 발생한다. 예를 들어 사용자 조회나 로그인 처리에서 입력값을 문자열로 이어 붙여 쿼리를 만들면, 공격자는 입력값 안에 SQL 문법을 넣어 조건을 바꿀 수 있다.

-- 위험한 방식의 예
SELECT * FROM users WHERE email = '입력값';

예방의 핵심은 사용자 입력을 SQL 코드가 아니라 값으로 전달하는 것이다. 대부분의 언어와 데이터베이스 라이브러리는 prepared statement, parameterized query, ORM의 바인딩 기능을 제공한다. 입력값 검증도 필요하지만, 검증만으로 SQL Injection을 막겠다는 접근은 약하다. 쿼리 구조와 값 전달을 분리하는 것이 기본이다.

XSS는 Cross-Site Scripting의 약자로, 공격자가 넣은 스크립트가 다른 사용자의 브라우저에서 실행되는 문제다. 게시글 제목, 댓글, 프로필 이름 같은 입력을 HTML에 그대로 출력하면 발생할 수 있다. 특히 관리자 페이지에서 사용자 입력을 확인하는 화면은 공격 대상이 되기 쉽다. 공격자는 댓글에 스크립트를 심고, 관리자가 그 댓글을 보는 순간 관리자 권한으로 의도하지 않은 요청을 보내게 만들 수 있다. 토큰을 어디에 저장했는지에 따라 인증 정보 탈취로 이어질 수도 있다.

XSS를 줄이려면 출력 위치에 맞게 이스케이프해야 한다. HTML 본문, HTML 속성, URL, JavaScript 문자열은 각각 안전하게 처리하는 방식이 다르다. 현대 프론트엔드 프레임워크는 기본적으로 텍스트 이스케이프를 제공하지만, raw HTML 삽입 기능을 사용할 때는 특히 조심해야 한다. 또한 쿠키에는 HttpOnly, Secure, SameSite 같은 속성을 적절히 설정해 피해 범위를 줄인다.

CSRF는 Cross-Site Request Forgery의 약자로, 사용자가 로그인된 상태를 악용해 원하지 않는 요청을 보내게 만드는 공격이다. 예를 들어 사용자가 은행 사이트에 로그인한 상태에서 공격자가 만든 페이지를 열었고, 그 페이지가 자동으로 송금 요청을 보낸다면 서버는 정상 사용자의 요청처럼 착각할 수 있다. 브라우저가 쿠키를 자동으로 함께 보내는 인증 방식에서 특히 주의해야 한다.

CSRF는 상태를 바꾸는 요청에서 중요하다. 주문 취소, 이메일 변경, 비밀번호 변경, 관리자 권한 부여 같은 작업은 GET이 아니라 POST, PUT, PATCH, DELETE 같은 메서드를 사용해야 하며 CSRF 토큰이나 SameSite 쿠키 정책을 함께 고려해야 한다. 다만 메서드를 바꾸거나 토큰을 쓴다고 끝나는 것은 아니다. 서버는 Origin 또는 Referer 검증, 인증 방식, 쿠키 정책, API 사용 환경을 함께 보고 방어해야 한다.

SSRF는 Server-Side Request Forgery의 약자로, 서버가 공격자가 지정한 주소로 요청을 보내게 되는 문제다. 이미지 URL을 받아 서버가 다운로드하거나, 웹훅 URL을 등록하거나, 내부 문서를 미리보기로 가져오는 기능에서 생길 수 있다. 공격자는 이 기능을 이용해 외부에서는 접근할 수 없는 내부 관리자 페이지, 메타데이터 서버, 사설망 주소에 접근하려 할 수 있다.

SSRF를 막으려면 URL 입력을 단순히 형식만 검사해서는 부족하다. 허용할 도메인을 명확히 제한하고, 사설 IP와 루프백 주소 접근을 차단하며, 리다이렉트 후 최종 주소도 검증해야 한다. 서버가 외부 요청을 보낼 때는 네트워크 권한 자체도 최소화하는 것이 좋다.

비밀번호는 암호화가 아니라 해싱한다

계정 시스템에서 가장 흔한 질문은 비밀번호를 어떻게 저장할 것인가이다. 정답은 평문 저장 금지다. 데이터베이스가 유출되었을 때 사용자의 비밀번호가 그대로 드러나면 피해는 해당 서비스에 그치지 않는다. 많은 사용자가 여러 서비스에서 같은 비밀번호를 쓰기 때문이다.

비밀번호는 보통 암호화하지 않고 해싱한다. 암호화는 키가 있으면 원문으로 되돌릴 수 있는 방식이다. 반면 해싱은 원문을 되돌릴 수 없도록 고정된 형태의 값으로 바꾸는 방식이다. 로그인할 때는 사용자가 입력한 비밀번호를 같은 방식으로 검증한 뒤 저장된 해시와 비교한다.

하지만 아무 해시나 쓰면 안 된다. SHA-256 같은 빠른 범용 해시는 파일 무결성 확인에는 유용하지만 비밀번호 저장에는 적합하지 않다. 공격자가 대량으로 추측을 시도하기 쉽기 때문이다. 비밀번호 저장에는 계산 비용을 의도적으로 높인 Argon2id, scrypt, bcrypt, PBKDF2 같은 알고리즘을 사용해야 한다. OWASP는 현대적인 비밀번호 저장에 Argon2id 같은 느린 비밀번호 해싱 알고리즘과 사용자별 고유 솔트를 권장한다1.

솔트는 각 비밀번호마다 붙이는 무작위 값이다. 같은 비밀번호를 쓰는 두 사용자가 있어도 서로 다른 해시가 저장되게 만든다. 솔트는 비밀일 필요는 없지만, 사용자마다 고유해야 한다. 대부분의 검증된 라이브러리는 솔트 생성과 저장 형식을 함께 처리한다. 직접 암호학 알고리즘을 만들거나 조합하려고 하지 말고, 널리 검증된 라이브러리를 사용해야 한다. 로그인 검증도 가능하면 라이브러리가 제공하는 verify 함수를 사용해 저장 형식, 비용 파라미터, 비교 방식을 안전하게 처리하도록 한다.

페퍼는 솔트와 다르다. 페퍼는 여러 비밀번호에 공통으로 적용되는 비밀 값이며 데이터베이스와 분리된 안전한 저장소에 보관한다. 페퍼는 추가 방어 수단이 될 수 있지만, 관리가 어렵고 유출되면 교체와 비밀번호 재설정이 필요할 수 있다. 따라서 기본은 강한 해싱 알고리즘과 고유 솔트이며, 페퍼는 운영 환경과 위험 수준을 고려해 선택한다.

비밀키 관리도 비밀번호 저장만큼 중요하다. JWT 서명 키, 데이터베이스 비밀번호, OAuth 클라이언트 시크릿, 외부 결제 API 키는 코드에 하드코딩하지 않는다. Git 저장소에 한 번 올라간 시크릿은 삭제 커밋만으로 안전해지지 않는다. 이미 복제되었을 가능성을 전제로 키를 폐기하고 재발급해야 한다. 시크릿은 환경 변수, 클라우드 시크릿 매니저, Vault 같은 별도 저장소에서 주입하고 접근 권한을 제한한다.

인증과 인가는 다르다

인증은 사용자가 누구인지 확인하는 과정이다. 로그인, 세션, 토큰 검증이 여기에 해당한다. 인가는 그 사용자가 특정 행동을 해도 되는지 판단하는 과정이다. 둘은 반드시 분리해서 생각해야 한다.

예를 들어 사용자가 로그인한 뒤 다음 API를 호출한다고 해보자.

GET /orders/1004

서버가 로그인 여부만 확인하고 주문 번호 1004를 그대로 조회해 반환하면 문제가 생긴다. 공격자는 URL의 주문 번호를 1005, 1006으로 바꾸며 다른 사람의 주문을 볼 수 있다. 이 문제는 흔히 IDOR, 즉 Insecure Direct Object Reference라고 부른다. 핵심은 객체 ID를 직접 참조하는 것이 문제가 아니라, 그 객체에 접근할 권한을 확인하지 않는 것이 문제다.

올바른 처리는 현재 로그인한 사용자와 주문의 소유자를 함께 확인하는 것이다.

SELECT * FROM orders WHERE id = ? AND user_id = ?;

추측하기 어려운 UUID를 쓰는 것도 도움이 될 수는 있다. 그러나 UUID만으로 인가가 끝나는 것은 아니다. 공격자가 다른 사용자의 ID를 알게 되는 순간 같은 문제가 다시 생긴다. 최종 판단은 항상 서버에서, 현재 사용자와 대상 객체의 관계를 기준으로 해야 한다.

관리자 기능도 마찬가지다. 관리자 화면 링크를 숨기는 것은 보안이 아니다. 서버에서 관리자 권한을 확인해야 한다. 더 나아가 모든 관리자에게 모든 권한을 줄 필요도 없다. 고객 문의를 처리하는 운영자, 환불을 승인하는 담당자, 시스템 설정을 바꾸는 최고 관리자는 다른 권한을 가져야 한다.

접근 제어는 코드 여러 곳에 흩어져 있으면 빠뜨리기 쉽다. 가능하면 공통 미들웨어, 정책 객체, 권한 검사 함수처럼 일관된 위치에서 처리한다. 기본값은 허용이 아니라 거부여야 한다. 필요한 권한을 명시적으로 통과한 요청만 실행되도록 설계한다. 접근 제어 실패는 실제 서비스에서 매우 흔한 위험이며, 인증만으로는 막을 수 없다는 점을 기억해야 한다2.

개발 과정에서 생기는 보안 위험

보안 문제는 기능 코드 안에서만 생기지 않는다. 개발 과정과 운영 습관에서도 자주 생긴다.

첫째, 의존성 패키지 취약점이다. 현대 개발은 많은 오픈소스 패키지 위에 서 있다. 문제는 내가 작성하지 않은 코드도 내 서비스의 일부로 실행된다는 점이다. 오래된 라이브러리, 방치된 패키지, 출처가 불명확한 플러그인은 공격 경로가 될 수 있다. 패키지를 추가할 때는 다운로드 수만 보지 말고 유지보수 상태, 최근 릴리스, 이슈 대응, 라이선스, 필요한 권한을 확인한다. CI에서 의존성 취약점 스캔을 실행하고, 보안 업데이트를 정기적으로 반영하는 절차도 필요하다.

둘째, 하드코딩된 시크릿이다. 테스트용 API 키를 코드에 넣고 나중에 지우겠다는 생각은 위험하다. 로컬 설정 파일, 샘플 코드, Dockerfile, CI 설정, 로그에도 시크릿이 남을 수 있다. 저장소에는 실제 키 대신 예시 값을 넣고, 필요한 설정 이름만 문서화한다. 예를 들어 .env.example에는 DATABASE_URL=example처럼 형식만 남긴다.

셋째, 과도한 로그 출력이다. 로그는 디버깅과 장애 대응에 필수지만, 비밀번호, 인증 토큰, 주민등록번호, 카드번호, 세션 쿠키 같은 민감정보가 들어가면 새로운 유출 지점이 된다. 요청 전체를 무심코 출력하는 습관은 특히 위험하다. 로그에는 문제 해결에 필요한 최소 정보만 남기고, 민감한 값은 마스킹하거나 아예 기록하지 않는다.

넷째, 에러 메시지다. 사용자에게 내부 SQL, 스택 트레이스, 서버 경로, 클라우드 리소스 이름을 그대로 보여주면 공격자에게 힌트를 준다. 사용자에게는 이해 가능한 일반 메시지를 주고, 자세한 원인은 서버 로그와 모니터링 도구에 남긴다.

안전한 개발 프로세스 만들기

보안은 마지막 배포 전 점검표만으로 지키기 어렵다. 설계, 구현, 리뷰, 배포, 운영에 자연스럽게 들어가야 한다.

설계 단계에서는 기능이 다루는 자산을 먼저 본다. 개인정보인가, 결제 정보인가, 관리자 권한인가, 외부 시스템 호출인가. 그리고 누가 어떤 조건에서 접근할 수 있는지 정한다. API 설계 문서에는 요청과 응답뿐 아니라 인증 방식, 권한 조건, 실패 시 상태 코드, 감사 로그 기록 여부가 포함되어야 한다.

구현 단계에서는 최소 권한 원칙을 적용한다. 애플리케이션이 데이터베이스에 접속할 때 모든 권한을 가진 계정을 쓰지 않는다. 읽기 전용 작업에는 읽기 권한만, 배치 작업에는 필요한 테이블 권한만 부여한다. 클라우드 권한도 마찬가지다. 개발 편의를 위해 관리자 권한을 넓게 주면, 작은 취약점이 큰 사고로 이어질 수 있다.

코드 리뷰에서는 보안 질문을 습관화한다. 이 입력은 검증되는가. 출력은 이스케이프되는가. 이 API는 소유자나 역할을 확인하는가. 실패할 때 안전한 기본값을 쓰는가. 로그에 민감정보가 남지 않는가. 새 패키지가 꼭 필요한가. 이런 질문은 리뷰어 한 명의 기억에 의존하기보다 체크리스트로 만드는 것이 좋다.

운영 단계에서는 감사 로그가 중요하다. 감사 로그는 누가, 언제, 어떤 중요한 작업을 했는지 추적하는 기록이다. 관리자 권한 변경, 비밀번호 초기화, 결제 취소, 개인정보 다운로드 같은 작업은 반드시 남겨야 한다. 단, 감사 로그에도 민감정보 원문을 남기면 안 된다.

보안 업데이트도 개발 업무의 일부다. 운영체제 이미지, 런타임, 프레임워크, 데이터베이스 드라이버, 프론트엔드 패키지는 계속 취약점이 발견된다. 업데이트를 미루기만 하면 한 번에 큰 변경을 해야 해서 더 위험해진다. 작은 단위로 자주 업데이트하고, 테스트와 롤백 절차를 준비하는 편이 안전하다.

보안을 판단하는 기본 질문

보안 지식은 방대하지만, 일상 개발에서 먼저 던질 질문은 많지 않다.

  • 이 입력은 사용자가 마음대로 바꿀 수 있다고 가정했는가?

  • 이 데이터에 접근할 권한을 서버에서 확인했는가?

  • 비밀번호와 시크릿은 되돌릴 수 없거나 안전한 저장소에 있는가?

  • 로그와 에러 메시지에 민감정보가 섞이지 않는가?

  • 새 의존성은 신뢰할 수 있고 계속 관리되는가?

  • 장애나 예외가 발생했을 때 기본 동작은 안전한가?

좋은 보안은 개발 속도를 무조건 늦추는 장치가 아니다. 오히려 명확한 권한 모델, 안전한 입력 처리, 일관된 비밀 관리, 자동화된 점검은 팀이 더 자신 있게 변경하도록 만든다. 보안은 특별한 순간에만 하는 작업이 아니라, 매일 작성하는 코드의 기본 품질이다.

참고

1Password Storage - OWASP Cheat Sheet Series

2A01:2025 Broken Access Control - OWASP Top 10

책 목록

개발자에게 필요한 필수 지식 정리

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기