소프트웨어 개발은 견고한 성을 짓는 것과 같습니다. 아무리 화려하고 기능이 뛰어난 성을 지었더라도, 성벽에 작은 구멍 하나가 있다면 적군은 그곳을 통해 침투하여 모든 것을 무너뜨릴 수 있습니다. 소프트웨어의 세계에서 이 ‘작은 구멍’이 바로 ‘보안 취약점’이며, 성벽을 처음부터 튼튼하게 쌓아 올리는 행위가 바로 ‘시큐어 코딩(Secure Coding)’입니다. 시큐어 코딩은 개발 단계에서부터 보안 위협을 미리 예측하고, 해커의 공격을 원천적으로 차단할 수 있도록 안전한 코드를 작성하는 모든 개발 활동을 의미합니다.
과거에는 방화벽이나 침입 탐지 시스템과 같은 외부 보안 솔루션에 의존하는 경향이 컸습니다. 하지만 공격 기법이 점차 고도화되면서, 이제는 애플리케이션 내부의 논리적 허점을 파고드는 공격이 주를 이루고 있습니다. 2025년 상반기, 한 대형 금융 플랫폼에서 입력값 검증 미흡으로 인해 발생한 SQL 인젝션 공격으로 수십만 명의 개인정보가 유출된 사건은, 시큐어 코딩이 더 이상 선택이 아닌 개발자의 기본 책임이자 의무임을 명확히 보여주었습니다.
본 글에서는 행정안전부가 권고하고 OWASP(Open Web Application Security Project)가 강조하는 핵심 보안 약점들을 중심으로, 개발자가 코드 한 줄 한 줄에 어떻게 보안의 갑옷을 입힐 수 있는지 구체적인 ‘나쁜 코드’와 ‘좋은 코드’ 예시를 통해 알아보겠습니다. 이 가이드를 통해 여러분은 해커의 창을 막아내는 견고한 방패를 만드는 실질적인 방법을 배우게 될 것입니다.
1. 입력 데이터 검증 및 표현: “아무도 믿지 마라”
시큐어 코딩의 제1원칙은 “외부로부터 들어오는 모든 입력은 잠재적인 공격이다”라고 가정하는 것입니다. 사용자, 다른 시스템, 파일 등 출처를 불문하고 프로그램으로 들어오는 모든 데이터는 악의적인 공격 코드를 포함할 수 있으므로, 시스템 내부 로직에서 사용하기 전에 반드시 그 유효성을 철저히 검증하고 안전한 형태로 처리해야 합니다.
SQL 인젝션 (SQL Injection)
가장 고전적이면서도 여전히 가장 파괴적인 공격 중 하나입니다. 공격자가 입력 데이터에 악의적인 SQL 구문을 삽입하여 데이터베이스를 비정상적으로 조작하는 공격입니다.
나쁜 코드: 입력값을 그대로 쿼리에 합치는 경우
Java
String userID = request.getParameter("id");
String query = "SELECT * FROM users WHERE user_id = '" + userID + "'";
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(query);
만약 공격자가 id 값으로 admin'-- 나 ' OR 1=1-- 같은 값을 입력하면, 쿼리는 조작되어 인증을 우회하거나 모든 사용자 정보를 노출시킬 수 있습니다.
좋은 코드: PreparedStatement를 사용하여 입력과 쿼리를 분리
Java
String userID = request.getParameter("id");
// 사용자 입력이 들어갈 자리는 '?'로 대체 (바인딩 변수)
String query = "SELECT * FROM users WHERE user_id = ?";
PreparedStatement pstmt = connection.prepareStatement(query);
// 입력값은 순수한 데이터로만 설정됨
pstmt.setString(1, userID);
ResultSet rs = pstmt.executeQuery();
PreparedStatement는 입력값을 SQL 구문의 일부가 아닌, 순수한 데이터로만 취급하도록 강제합니다. 따라서 공격자가 악의적인 구문을 삽입하더라도, 이는 단순히 ‘user_id’가 ‘admin’–‘인 사용자를 찾는 쿼리가 될 뿐, 쿼리 전체의 구조를 바꾸지 못합니다.
크로스 사이트 스크립팅 (Cross-Site Scripting, XSS)
공격자가 웹사이트에 악성 스크립트를 삽입하고, 다른 사용자가 해당 페이지를 열람할 때 그 스크립트가 실행되도록 하여 사용자의 쿠키나 세션 정보를 탈취하는 공격입니다.
나쁜 코드: 사용자 입력을 필터링 없이 그대로 출력
HTML
<div><%= boardContent %></div>
위 코드는 boardContent 변수에 담긴 악성 스크립트를 그대로 HTML 페이지에 출력합니다. 다른 사용자가 이 페이지를 보는 순간, 해당 스크립트가 브라우저에서 실행되어 ‘hacked!’라는 경고창이 뜨거나, 사용자의 세션 쿠키가 공격자의 서버로 전송될 수 있습니다.
좋은 코드: 특수 문자를 HTML 엔티티로 치환
Java
public String escapeHtml(String input) {
if (input == null) return null;
return input.replace("<", "<").replace(">", ">");
}
// ...
String safeContent = escapeHtml(boardContent);
<%- safeContent %> (JSP EL 등 출력 시 이스케이프 지원 기능 사용)
<, >, " 등 HTML에서 특별한 의미를 갖는 문자들을 <, >, " 와 같은 HTML 엔티티 코드로 변환하여 출력해야 합니다. 이렇게 하면 브라우저는 해당 문자들을 스크립트 코드가 아닌, 단순한 텍스트로만 인식하여 화면에 보여주게 되므로 스크립트가 실행되는 것을 원천적으로 막을 수 있습니다.
2. 보안 기능: “인증, 인가, 세션, 암호는 철옹성처럼”
시스템의 핵심 자산을 보호하는 보안 기능 자체에 허점이 있다면 다른 모든 노력이 무용지물이 될 수 있습니다. 사용자의 신원을 확인하고(인증), 권한을 관리하며(인가), 암호를 안전하게 다루는 것은 보안의 심장과도 같습니다.
안전하지 않은 비밀번호 저장
사용자의 비밀번호를 평문(Plain Text)으로 데이터베이스에 저장하는 것은 최악의 보안 실수입니다. DB가 유출되면 모든 사용자의 계정이 그대로 노출됩니다.
나쁜 코드: 비밀번호를 평문 또는 부적절한 해시 함수로 저장
Java
// 최악의 경우: 평문 저장
String query1 = "INSERT INTO users (id, pw) VALUES ('" + id + "', '" + password + "')";
// 조금 나아졌지만 여전히 위험: MD5/SHA-1 같은 취약한 해시 함수 사용
String hashedPw = md5(password);
String query2 = "INSERT INTO users (id, pw) VALUES ('" + id + "', '" + hashedPw + "')";
MD5나 SHA-1은 현재 ‘레인보우 테이블’ 공격 등으로 쉽게 원본 값을 유추할 수 있어 안전하지 않습니다.
좋은 코드: Salt를 사용한 강력한 해시 함수 적용
Java
import org.mindrot.jbcrypt.BCrypt;
// 회원가입 시
String password = "user_password123";
String salt = BCrypt.gensalt(); // 각 사용자마다 고유한 Salt 생성
String hashedPassword = BCrypt.hashpw(password, salt); // Salt와 함께 해싱
// DB에는 hashedPassword (salt 포함)를 저장
// 로그인 시
String inputPassword = "user_password123";
String storedHash = // DB에서 사용자의 해시된 비밀번호를 가져옴
if (BCrypt.checkpw(inputPassword, storedHash)) {
// 인증 성공
}
Bcrypt, Scrypt, PBKDF2와 같이 비밀번호 저장에 특화된 강력한 해시 함수를 사용해야 합니다. 이 함수들은 내부에 ‘솔트(Salt)’라는 임의의 문자열을 추가하여 해싱하고, 여러 번 반복해서 해싱하는 ‘키 스트레칭(Key Stretching)’ 기법을 사용하여 무차별 대입 공격(Brute-force Attack)을 매우 어렵게 만듭니다.
부적절한 인가 (Improper Authorization)
인증을 통과한 사용자가 다른 사용자나 관리자의 권한을 넘어서는 행위를 할 수 없도록 막는 ‘인가’ 로직의 부재는 심각한 보안 사고로 이어집니다.
나쁜 코드: 접근 권한을 확인하지 않는 경우
Java
// URL: /board/delete?boardId=101
String boardId = request.getParameter("boardId");
// 현재 로그인한 사용자가 boardId 101번 게시글의 작성자인지 확인하는 로직이 없음
boardRepository.deleteById(boardId);
위 API는 로그인만 되어 있다면, URL의 boardId 값만 바꿔가면서 다른 모든 사용자의 게시글을 삭제할 수 있는 치명적인 ‘불안정한 직접 객체 참조(IDOR)’ 취약점을 가지고 있습니다.
좋은 코드: 모든 요청에 대해 권한 확인
Java
// URL: /board/delete?boardId=101
String boardId = request.getParameter("boardId");
User currentUser = (User) session.getAttribute("user"); // 세션에서 현재 사용자 정보 가져오기
Board targetBoard = boardRepository.findById(boardId);
// 현재 사용자가 게시글의 작성자가 맞는지 반드시 확인
if (targetBoard != null && currentUser.getId().equals(targetBoard.getAuthorId())) {
boardRepository.deleteById(boardId); // 권한이 있을 때만 삭제 수행
} else {
// 권한 없음 에러 처리
}
모든 중요 기능에 대해서는 반드시 현재 사용자의 역할(Role)이나 소유권(Ownership)을 확인하여, 자신의 권한을 벗어나는 요청을 수행할 수 없도록 철저히 통제해야 합니다.
3. 에러 처리 및 로깅: “실패는 하되, 정보는 주지 마라”
오류가 발생했을 때 시스템이 어떻게 반응하는가는 보안에 매우 중요합니다. 너무 상세한 에러 메시지는 공격자에게 시스템의 내부 구조나 취약점에 대한 힌트를 제공하는 ‘정보 유출’의 통로가 될 수 있습니다.
시스템 정보 노출
나쁜 코드: 예외 발생 시 스택 트레이스를 사용자에게 그대로 노출
Java
try {
// ... 데이터베이스 작업 ...
} catch (SQLException e) {
// 개발자에게는 유용하지만, 공격자에게는 내부 구조를 알려주는 보물 지도
e.printStackTrace(response.getWriter());
}
SQL 예외의 스택 트레이스(stack trace)는 사용된 데이터베이스의 종류, 테이블 및 컬럼 이름, 소스 코드의 경로 등 민감한 정보를 고스란히 노출합니다.
좋은 코드: 일반적인 에러 메시지 처리 및 상세 로그 기록
Java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
// ...
private static final Logger logger = LoggerFactory.getLogger(MyController.class);
try {
// ... 데이터베이스 작업 ...
} catch (SQLException e) {
// 사용자에게는 일반적인 메시지를 보여줌
response.sendError(500, "서버 내부 오류가 발생했습니다. 관리자에게 문의하세요.");
// 개발자는 상세한 원인을 파악할 수 있도록 로그 파일에 기록
logger.error("DB 작업 중 오류 발생: UserID=" + userId, e);
}
사용자에게는 모호하고 일반적인 오류 메시지만을 보여주고, 실제 문제 해결에 필요한 상세한 기술 정보(예외 종류, 스택 트레이스, 관련 파라미터 등)는 관리자만 접근할 수 있는 로그 파일에만 기록해야 합니다.
4. 캡슐화: “중요한 것은 보이지 않게”
객체 지향 프로그래밍의 핵심 원칙인 캡슐화는 데이터와 그 데이터를 처리하는 메소드를 하나로 묶고, 외부에서 데이터에 직접 접근하는 것을 막아 정보 은닉(Information Hiding)을 구현하는 것입니다. 이는 보안에서도 매우 중요한 원칙입니다.
제거되지 않은 임시 파일
나쁜 코드: 민감한 정보를 담은 임시 파일을 사용 후 방치
Java
// 사용자 정보를 포함한 임시 보고서 파일을 생성
File tempFile = new File("report_" + userId + ".tmp");
// ... 파일에 민감한 데이터 쓰기 ...
// 작업 완료 후 파일을 삭제하는 로직이 없음
// 서버의 임시 디렉토리에 개인정보가 담긴 파일이 그대로 남게 됨
만약 서버의 다른 취약점을 통해 공격자가 파일 시스템에 접근할 수 있다면, 이렇게 방치된 임시 파일들은 손쉬운 정보 탈취의 목표물이 됩니다.
좋은 코드: try-finally 구문을 이용한 자원 해제 보장
Java
File tempFile = null;
try {
tempFile = File.createTempFile("report_", ".tmp");
// ... 파일에 민감한 데이터 쓰기 ...
// ... 작업 수행 ...
} finally {
if (tempFile != null && tempFile.exists()) {
tempFile.delete(); // 예외 발생 여부와 상관없이 항상 파일을 삭제
}
}
파일, 데이터베이스 커넥션, 네트워크 소켓 등 사용이 끝난 시스템 자원은 finally 블록이나 try-with-resources 구문을 사용하여 어떤 경우에도 반드시 해제되도록 보장해야 합니다. 이는 보안 취약점을 줄일 뿐만 아니라, 시스템의 안정성을 높이는 좋은 프로그래밍 습관입니다.
마무리: 시큐어 코딩은 문화이자 습관이다
시큐어 코딩은 단순히 몇 가지 보안 규칙을 암기하고 적용하는 것을 넘어, 개발의 모든 단계에서 ‘어떻게 하면 이 코드를 공격할 수 있을까?’라고 해커의 관점에서 생각하는 ‘보안 중심의 사고방식(Security Mindset)’을 갖추는 것입니다.
오늘 살펴본 가이드라인 외에도 파일 업로드 취약점, 메모리 관리 오류, 안전하지 않은 암호화 사용 등 수많은 보안 약점들이 존재합니다. 완벽한 보안은 없지만, 시큐어 코딩은 최소한의 노력으로 최대의 방어 효과를 낼 수 있는 가장 비용 효율적인 보안 활동입니다.
정적 분석 도구(SAST)를 CI/CD 파이프라인에 통합하여 코드 커밋 시 자동으로 취약점을 점검하고, 동료 간의 코드 리뷰 시 보안 관점을 필수로 포함시키며, 정기적인 보안 교육을 통해 새로운 공격 트렌드를 학습하는 등, 시큐어 코딩을 개발팀의 ‘문화’로 정착시키는 것이 무엇보다 중요합니다. 안전한 코드는 더 이상 선택 사항이 아닌, 사용자의 신뢰를 얻고 서비스의 가치를 지키는 프로 개발자의 핵심 역량입니다.

