제 목 :
보안을 위한 시큐어 인프라, 시큐어 코딩 #2 - 코딩편

지난 1편에서는 인프라 이야기를 했다. 서버는 Private Subnet에 숨기고, 외부 트래픽은 ALB와 WAF만 받고, 관리자는 Bastion을 통해서만 들어오게 하는 관리자도 못들어갈 그런 구조를 그렸다. 그런데 1편 마지막에도 썼듯이, 인프라를 아무리 단단하게 잠가도 443 포트 너머에는 결국 우리가 짠 코드가 있다. 정문을 통과해 들어온 요청을 믿어 버리면 앞에서 쌓은 벽은 아무 의미가 없다.
이번 편에서는 내가 실제로 쓰는 스택을 기준으로, 코드 레벨에서 어떤 것들을 신경 쓰는지 정리해 보려고 한다.
요즘 내가 쓰는 스택
- 프론트엔드: Next.js (SSR)
- 백엔드: Java Spring Boot, RESTful API
- 인증/권한: Spring Security
- DB 접근: MyBatis
결국 MyBatis로 돌아왔다
DB 접근 기술은 이것저것 다 써 봤다. JPA / QueryDSL도 써 보고, jOOQ에 Flyway까지 붙여 보기도 했다. 그런데 결국 MyBatis로 돌아왔다. 옛날 개발자라 그런가 보다.
ORM이 개발할 때 편한 건 맞다. 다만 엔티티와 실제 DB 스키마를 맞추는 동기화 과정이 생각보다 번거로웠고, 코드 생성이나 마이그레이션 플러그인을 붙이다 보면 pom.xml 설정이 감당하기 어려울 만큼 복잡해졌다. MyBatis는 SQL을 내가 직접 쓰니 실행되는 쿼리가 눈에 그대로 보이고, 그게 오히려 마음이 편했다.
다만 SQL을 직접 쓴다는 건, SQL 인젝션도 내 손으로 막아야 한다는 뜻이기도 하다.
SQL 인젝션이란
SQL 인젝션(SQL Injection)은 사용자가 입력한 값이 SQL 문의 “값”이 아니라 “명령”의 일부로 실행되면서 생기는 공격이다. 오래된 공격이지만 지금도 OWASP Top 10의 인젝션 항목에 꾸준히 이름을 올리고 있고, 실제 개인정보 유출 사고의 원인으로도 여전히 자주 등장한다.
원리는 간단하다. 로그인 처리를 이렇게 만들었다고 해 보자.
// 사용자 입력을 SQL 문자열에 그대로 이어 붙인 코드String sql = "SELECT * FROM users " + "WHERE login_id = '" + loginId + "' " + "AND password = '" + password + "'";정상적인 사용자가 아이디에 hong을 넣으면 의도한 대로 동작한다. 그런데 누군가 아이디 칸에 admin' --를 입력하면, 완성된 SQL은 이렇게 된다.
SELECT * FROM users WHERE login_id = 'admin' --' AND password = '...'입력값에 들어 있던 작은따옴표(')가 문자열을 닫아 버리고, 뒤에 붙은 --가 나머지 조건을 주석으로 만들어 버린다. 비밀번호 검사는 사라지고, 비밀번호를 몰라도 admin 계정으로 로그인이 된다.
로그인 우회는 시작일 뿐이다. 같은 방식으로 UNION SELECT를 이어 붙여 다른 테이블의 회원 정보를 통째로 꺼내 가거나, DB 계정 권한이 넉넉하다면 데이터를 수정하거나 지우는 것도 가능하다. 1편에서 DB를 Private Subnet 깊숙이 숨겨 두었지만, SQL 인젝션은 애플리케이션 서버가 대신 DB에 접속해 주는 셈이라 네트워크 격리로는 막을 수 없다. 정문을 통과한 요청이 애플리케이션의 권한을 빌려 DB까지 걸어 들어가는 것이다.
문제의 핵심은 데이터와 명령이 같은 문자열 안에 섞여 있다는 데 있다. 그래서 해결책도 그 둘을 분리하는 것이다. PreparedStatement로 SQL의 뼈대를 먼저 만들어 두고, 사용자 입력은 ? 자리에 “값”으로만 바인딩하면, 입력값에 따옴표나 주석 기호가 들어 있어도 SQL 문법으로 해석되지 않는다.
String sql = "SELECT * FROM users WHERE login_id = ? AND password = ?";PreparedStatement ps = conn.prepareStatement(sql);ps.setString(1, loginId); // admin' -- 도 그냥 하나의 "문자열 값"으로만 취급된다ps.setString(2, password);(물론 실제로는 비밀번호를 SQL에서 평문으로 비교하지 않고, 해시로 저장한 뒤 애플리케이션에서 비교해야 한다. 이건 뒤에서 다시 이야기한다.)
입력값에서 따옴표를 지우거나 특정 키워드를 막는 방식으로 대응하는 코드도 종종 보이는데, 우회 방법이 워낙 다양해서 근본적인 해결책이 되지 못한다. 필터링은 보조 수단일 뿐이고, 기본은 언제나 파라미터 바인딩이다. 그리고 MyBatis에서 이 바인딩을 해 주는 문법이 바로 #{}다.
그리고 내 생각이지만 SQL Injection이 허용된 어플리케이션은 죄악이다. 그걸 만든 개발자는 벌 받아야한다. 진짜다. 정말로. 그건 절대로 일어나서는 안되는 재앙같은 일이다.

#{}와 ${}, 한 글자 차이
MyBatis에서 가장 중요한 시큐어 코딩 포인트는 이것 하나라고 해도 과언이 아니다.
<!-- 위험: 문자열을 SQL에 그대로 이어 붙인다 -->SELECT * FROM users WHERE name = '${name}'
<!-- 안전: PreparedStatement의 ? 바인딩으로 처리된다 -->SELECT * FROM users WHERE name = #{name}${}는 값을 SQL 문자열에 그대로 끼워 넣는다. 사용자가 이름 대신 ' OR '1'='1 같은 값을 보내면 쿼리 자체가 바뀌어 버린다. #{}는 값을 파라미터로 바인딩하기 때문에 어떤 값이 들어와도 그냥 “값”으로만 취급된다. 그래서 원칙은 단순하다. 사용자 입력이 닿는 곳에는 무조건 #{}. 아니! 그냥 사용자 입력이 닿건 말건 무조건 #{}이다!
문제는 #{}를 쓸 수 없는 자리가 있다는 것이다. 대표적인 게 정렬 컬럼이다. ORDER BY #{sort}는 컬럼명이 아니라 문자열 값으로 바인딩되어 정렬이 동작하지 않는다. 그래서 여기에 ${sort}를 쓰고 싶은 유혹이 생기는데, 이럴 때는 허용된 값만 고르게 만든다.
ORDER BY<choose> <when test="sort == 'name'">name</when> <when test="sort == 'createdAt'">created_at</when> <otherwise>id</otherwise></choose>LIKE 검색도 자주 실수하는 곳이다. '%${keyword}%'가 아니라 CONCAT('%', #{keyword}, '%')처럼 바인딩을 유지한 채로 문자열을 만든다.
Next.js를 쓰는 이유: 토큰은 브라우저가 아니라 서버 쿠키에
Next.js를 쓰는 이유는 SSR 때문이기도 하지만, 보안 측면에서는 사용자 토큰을 브라우저 JavaScript가 만질 수 없는 곳에 둘 수 있어서가 더 크다.
구조는 이렇다. 백엔드(Spring Boot)는 순수하게 RESTful API만 제공하고, 브라우저는 Spring Boot를 직접 호출하지 않는다. 브라우저는 Next.js 서버와만 이야기하고, Next.js 서버가 토큰을 들고 백엔드 API를 호출한다. 토큰은 Next.js 서버가 HttpOnly 쿠키로 내려 준다.
import { cookies } from 'next/headers';
export async function POST(req: Request) { const body = await req.json();
// API_URL은 NEXT_PUBLIC_이 아니므로 서버에서만 읽힌다 const res = await fetch(`${process.env.API_URL}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { return Response.json({ message: '로그인에 실패했습니다.' }, { status: 401 }); }
const { accessToken } = await res.json(); (await cookies()).set('access_token', accessToken, { httpOnly: true, // JS에서 document.cookie로 읽을 수 없다 secure: true, // HTTPS에서만 전송된다 sameSite: 'lax', // 다른 사이트에서 시작된 요청에는 기본적으로 실리지 않는다 path: '/', maxAge: 60 * 30, });
return Response.json({ ok: true });}토큰을 localStorage에 넣어 두면, XSS 취약점이 하나만 있어도 스크립트 한 줄로 토큰이 밖으로 빠져나간다. HttpOnly 쿠키에 두면 적어도 토큰 자체를 훔쳐 가는 건 막을 수 있다.
물론 이걸로 끝은 아니다. 내 출신이 프론트 앤드 개발자라서 프론트 앤드 보안은 정신병자 정신 나갈 것 같은 집착으로 막아놔야한다. 그래도 뚫리는게 프론트 앤드다. 아래의 내용들을 읽어본다.
- XSS가 사라지는 건 아니다. 토큰을 못 훔쳐 갈 뿐, 페이지 안에서 사용자인 척 요청을 보내는 건 여전히 가능하다. React가 기본적으로 출력값을 이스케이프해 주지만,
dangerouslySetInnerHTML을 쓰는 곳은 반드시 sanitize를 거친다. - 쿠키를 쓰면 CSRF를 신경 써야 한다.
SameSite가 상당 부분을 막아 주지만, 상태를 바꾸는 요청에는 CSRF 토큰이나 Origin 검사를 함께 두는 게 안전하다. NEXT_PUBLIC_환경 변수는 전부 브라우저에 공개된다. API 키나 내부 URL에 습관처럼NEXT_PUBLIC_을 붙이면, 빌드 결과물에 그대로 박혀서 누구나 볼 수 있게 된다. 망할 환경변수. 없앨 수도 없지만 그렇다고 함부로 쓸 수도 없다. 오픈 되더라도 상관 없는 정보만 넣어놓는다. 그리고 그 정보가 정말 공개되더라도 괜찮은가에 대해서 100만번쯤 고민해야한다.- Server Action도 결국 공개 엔드포인트다. 화면에서 버튼을 숨겼다고 호출을 막은 게 아니다. Server Action 안에서도 매번 인증과 권한을 확인한다.
Spring Security: 인증은 기본, 진짜는 권한
Spring Boot 쪽에서는 Spring Security로 사용자 인증과 권한 체크를 처리한다. 기본 원칙은 명시적으로 열어 준 곳 외에는 전부 닫는다는 것이다.
@BeanSecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 브라우저가 아닌 Next.js 서버만 호출하는 API라서 CSRF는 Next.js 단에서 처리한다 .csrf(csrf -> csrf.disable()) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build();}여기까지는 대부분 잘 한다. 그런데 실제 사고가 많이 나는 곳은 그다음, “로그인한 사용자가 남의 데이터를 볼 수 있는가“다. /api/orders/1001을 호출하는 사용자가 로그인했고 USER 권한도 있다고 해서, 1001번 주문이 그 사람 것이라는 보장은 없다. 주소의 숫자만 바꿔서 남의 주문을 조회하는 IDOR(Insecure Direct Object Reference) 공격은 지금도 흔한 개인정보 유출 원인이다.**
그래서 조회 쿼리에는 항상 소유자 조건을 함께 건다. 이때 userId는 요청 파라미터가 아니라 인증 정보(SecurityContext)에서 꺼낸다.
<select id="findOrder" resultType="Order"> SELECT * FROM orders WHERE id = #{orderId} AND user_id = #{userId}</select>이 밖에 Spring 쪽에서 기본으로 챙기는 것들은 이렇다.
- 요청 DTO에는
@Valid와 Bean Validation으로 입력값의 형식과 길이를 서버에서 다시 검증한다. 프론트엔드 검증은 사용자 편의를 위한 것일 뿐이다. - 비밀번호는
BCryptPasswordEncoder같은 단방향 해시로만 저장한다. - 에러 응답에 스택 트레이스나 SQL 문을 그대로 내보내지 않는다.
비밀값은 소스 코드 밖으로
코드를 아무리 잘 짜도, 그 코드 안에 DB 비밀번호나 API 키가 박혀 있으면 소용이 없다. 테스트하느라 잠깐 넣어 둔 비밀번호, 배포 스크립트에 적어 둔 서버 접속 키, application.properties에 그대로 적힌 Secret Key 같은 것들 말이다.
하드코딩된 비밀값이 무서운 이유는 Git은 잊지 않는다는 데 있다. 나중에 코드에서 지워도 커밋 히스토리에는 그대로 남아 있다. 저장소 접근 권한이 있는 사람은 누구나 볼 수 있고, 실수로 저장소가 공개되기라도 하면 공개 저장소를 돌아다니며 키를 수집하는 봇에게 금방 발견된다. 그래서 한 번이라도 커밋된 비밀값은 “지운다”가 아니라 “폐기하고 새로 발급한다“가 맞는 대응이다.
원칙은 하나다. 소스 코드에는 비밀값이 단 하나도 없어야 한다. 비밀값은 빌드·배포 단계에서는 Jenkins Credentials가, 실행 단계에서는 Kubernetes Secret이 들고 있게 한다.
Jenkins Credentials: 빌드와 배포에 쓰는 비밀
Jenkins에는 비밀값을 따로 보관하는 Credentials 기능이 있다. 등록해 두고 파이프라인에서는 ID로만 꺼내 쓰기 때문에, Jenkinsfile을 Git에 올려도 비밀값은 노출되지 않는다. 용도에 따라 이렇게 나눠서 쓴다.
- SSH Username with private key: Git 저장소 접근이나 배포 서버 접속용 SSH 키
- Secret text: API 토큰, Secret Key 같은 단일 값
- Username with password: 컨테이너 레지스트리 계정 등
- Secret file:
kubeconfig처럼 파일 통째로 필요한 것
pipeline { agent any stages { stage('Push Image') { steps { withCredentials([usernamePassword(credentialsId: 'registry-account', usernameVariable: 'REG_USER', passwordVariable: 'REG_PASS')]) { // 작은따옴표: Groovy가 아니라 셸이 변수를 해석하게 한다 sh 'echo "$REG_PASS" | docker login -u "$REG_USER" --password-stdin registry.example.com' } } } stage('Deploy') { steps { sshagent(credentials: ['deploy-ssh-key']) { sh 'ssh -o StrictHostKeyChecking=yes deploy@10.0.1.10 "./deploy.sh"' } } } }}withCredentials 블록 안에서 꺼낸 값은 빌드 로그에 ****로 가려져 출력된다. 다만 이것만 믿으면 안 되는 부분이 있다.
sh명령은 작은따옴표로 감싼다.sh "... ${REG_PASS}"처럼 큰따옴표를 쓰면 Groovy가 먼저 문자열에 값을 끼워 넣으면서 비밀값이 프로세스 인자 등에 노출될 수 있다. Jenkins도 이 경우 경고를 띄운다.- 로그 마스킹은 완벽하지 않다. 값을 base64로 바꾸거나 한 글자씩 출력하면 마스킹을 우회해 그대로 찍힌다. 비밀값은 애초에 출력하지 않는다.
- Credentials에도 권한이 있다. 전역(Global)으로 등록하지 말고 폴더나 프로젝트 단위로 범위를 나누고, 운영 환경 Credentials는 운영 배포 Job만 쓸 수 있게 한다.
- Jenkins 자체가 표적이 된다. 모든 비밀이 모이는 곳이니, Jenkins도 1편의 서버들처럼 Private Subnet에 두고 접근할 수 있는 사람을 최소화한다.
Kubernetes Secret: 실행할 때 쓰는 비밀
애플리케이션이 실행 중에 쓰는 DB 비밀번호나 외부 API 키는 Kubernetes Secret에 넣고, Pod에는 환경 변수나 파일로 주입한다. 컨테이너 이미지와 소스 코드에는 아무것도 남지 않는다.
# deployment.yaml (일부)containers: - name: api image: registry.example.com/api:1.0.0 env: - name: DB_PASSWORD valueFrom: secretKeyRef: name: api-secret key: db-passwordSecret YAML 파일을 Git에 올리면 하드코딩과 다를 게 없으니, Secret은 Jenkins가 Credentials에서 값을 꺼내 배포할 때 만든다. 이렇게 하면 비밀값은 Jenkins Credentials 한 곳에서만 관리된다.
withCredentials([string(credentialsId: 'db-password-prod', variable: 'DB_PASSWORD'), file(credentialsId: 'kubeconfig-prod', variable: 'KUBECONFIG')]) { sh ''' kubectl create secret generic api-secret \ --from-literal=db-password="$DB_PASSWORD" \ --dry-run=client -o yaml | kubectl apply -f - '''}여기서 꼭 알아 둬야 할 점이 있다. Kubernetes Secret은 기본적으로 암호화가 아니라 base64 인코딩일 뿐이다. kubectl get secret -o yaml을 할 수 있는 사람이라면 누구나 디코딩해서 평문을 볼 수 있다. 그래서 몇 가지를 함께 챙긴다.
- 저장 시 암호화: EKS에서는 KMS 키로 Secret을 봉투 암호화하도록 설정해서, etcd에 평문으로 저장되지 않게 한다.
- RBAC로 조회 제한: Secret을
get,list할 수 있는 권한은 정말 필요한 계정에만 준다. 네임스페이스도 서비스나 환경별로 나눠 둔다. - IAM 권한은 키 대신 역할로: Pod가 AWS KMS나 S3에 접근해야 한다면, 액세스 키를 Secret에 넣지 말고 IRSA(IAM Roles for Service Accounts)나 EKS Pod Identity로 Pod에 역할을 직접 부여한다. 아래에서 이야기할 properties 복호화 키도 이 방식으로 받아 오면 비밀값이 하나 줄어든다.
- 더 나아가려면: AWS Secrets Manager에 값을 두고 External Secrets Operator 같은 도구로 동기화하면, 비밀값의 원본을 클러스터 밖에서 관리하고 교체(rotation)도 자동화할 수 있다.
그리고 사람의 실수는 언제든 생기니, 커밋 단계에서 gitleaks 같은 도구로 비밀값이 섞여 들어가지 않았는지 검사해 두면 마지막 안전장치가 된다.
properties 파일 암호화
인프라를 잘 막아도 설정 파일이 털리면 끝이다. DB 접속 정보, 외부 API 키, JWT 서명 키가 전부 properties 파일에 들어 있기 때문이다.
그래서 나는 properties 파일을 통째로 암호화해 두고, 애플리케이션의 main 메서드에서 기동할 때 복호화해서 사용한다. 복호화에 필요한 키는 KMS(Key Management Server)에서 발급받는다. 이렇게 하면 누군가 서버에서 설정 파일을 빼 가더라도, 키가 없으면 그 파일은 그냥 알아볼 수 없는 바이트 덩어리일 뿐이다.
다만 이 구조에서도 짚어 둘 점이 있다.
- 비밀이 사라진 게 아니라 KMS로 옮겨 간 것이다. 결국 “누가 KMS에서 키를 받아 갈 수 있는가”가 새로운 정문이 된다. 키 발급은 해당 서버의 역할(IAM Role 등)로만 허용하고, 사람 계정으로는 키를 꺼낼 수 없게 막아야 한다. 1편에서 이야기한 “진짜 정문은 IAM”이라는 이야기가 여기서 다시 나온다.
- 복호화된 값은 메모리에 평문으로 존재한다. Spring Boot Actuator의
heapdump같은 엔드포인트가 열려 있으면, 애써 암호화한 값이 메모리째로 빠져나갈 수 있다. Actuator는 꼭 필요한 엔드포인트만 노출하고, 외부에서는 접근하지 못하게 막는다. - 로그도 조심한다. 기동할 때 설정값을 디버깅용으로 찍어 두는 코드가 남아 있으면, 암호화는 그 한 줄로 무력화된다.
AES에서 RSA로 바꿔 볼까?
지금은 AES로 암복호화를 하고 있는데, 앞으로는 RSA 비대칭 키 방식으로 바꿔 볼까 고민 중이다. 정리해 보니 따져 볼 점이 몇 가지 있었다.
RSA의 가장 큰 장점은 암호화하는 쪽과 복호화하는 쪽을 분리할 수 있다는 것이다. 공개키는 개발자나 CI 파이프라인에 나눠 줘서 누구나 설정 파일을 암호화할 수 있게 하고, 개인키는 운영 서버만 갖게 하면 “암호화는 할 수 있지만 복호화는 못 하는” 사람을 만들 수 있다. AES는 같은 키로 암호화와 복호화를 모두 하기 때문에 이런 분리가 불가능하다.
그런데 RSA는 큰 데이터를 직접 암호화하지 못한다. RSA-2048에 OAEP(SHA-256) 패딩을 쓰면 한 번에 암호화할 수 있는 크기가 190바이트 정도라서, properties 파일 전체를 RSA로 암호화하는 건 현실적이지 않다. 그래서 실제로는 이렇게 섞어서 쓰게 된다.
- 파일은 매번 새로 만든 AES 키로 암호화한다.
- 그 AES 키를 RSA 공개키로 암호화해서 파일과 함께 둔다.
- 서버는 RSA 개인키로 AES 키를 풀고, 그 AES 키로 파일을 복호화한다.
이게 흔히 말하는 봉투 암호화(Envelope Encryption)다. 즉 고민의 방향은 “AES에서 RSA로 바꾼다”가 아니라 “AES에 RSA를 덧씌운다”에 가깝다. 그리고 RSA를 도입하면 이번에는 개인키를 어디에 보관하느냐가 새 문제가 되니, 결국 개인키도 KMS 안에 두고 밖으로 꺼내지 않는 방식이 가장 깔끔할 것 같다. AWS KMS를 쓴다면 이 봉투 암호화를 기본 기능으로 제공하니, 직접 구현하기 전에 한번 비교해 볼 생각이다.
오류 페이지는 아무것도 말하지 않게
오류 페이지 이야기는 조금 더 짚고 넘어가야 한다. 공격자는 정상 페이지보다 오류 페이지를 더 좋아한다. 일부러 이상한 값을 넣어 오류를 일으키고, 응답에 찍혀 나오는 스택 트레이스에서 프레임워크 버전, 패키지 구조, 클래스 이름, 심지어 SQL 문과 테이블 이름까지 얻어 간다. 2편에서 다룰 SQL 인젝션도 보통 이런 오류 메시지를 단서 삼아 시작된다.
java.sql.SQLSyntaxErrorException: You have an error in your SQL syntax; ... near ''' at line 1 at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(...) at com.example.order.mapper.OrderMapper.findOrder(...)이런 응답이 사용자 화면에 그대로 나간다면, DB 종류와 쿼리에 따옴표가 그대로 들어간다는 사실, 내부 패키지 경로까지 한 번에 알려 주는 셈이다.
원칙은 하나다. 사용자에게는 무슨 일이 있었는지가 아니라 무엇을 하면 되는지만 알려 주고, 자세한 원인은 서버 로그에만 남긴다. 404든 403이든 500이든 마찬가지다.
Spring Boot에서는 @RestControllerAdvice로 모든 예외를 한곳에서 받아, 정해진 형식의 응답으로 바꿔서 내보낸다. 예외 메시지(e.getMessage())를 그대로 응답에 담지 않는 게 핵심이다. 대신 요청 ID를 함께 내려 주면, 사용자가 문의할 때 그 ID로 서버 로그를 바로 찾아볼 수 있다.
public record ErrorResponse(String code, String message, String requestId) {}
@Slf4j@RestControllerAdvicepublic class GlobalExceptionHandler {
@ExceptionHandler(NoResourceFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound(NoResourceFoundException e) { return error(HttpStatus.NOT_FOUND, "NOT_FOUND", "요청하신 페이지를 찾을 수 없습니다."); }
@ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleAll(Exception e) { log.error("Unhandled exception", e); // 스택 트레이스는 로그에만 return error(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "일시적인 오류가 발생했습니다."); }
private ResponseEntity<ErrorResponse> error(HttpStatus status, String code, String message) { return ResponseEntity.status(status) .body(new ErrorResponse(code, message, MDC.get("requestId"))); }}주의할 점은 401과 403이다. 인증과 권한 오류는 Spring Security 필터에서 발생하기 때문에 @RestControllerAdvice까지 오지 않는다. 그래서 Security 설정에서 따로 같은 형식으로 응답하게 맞춰 줘야 한다.
http.exceptionHandling(e -> e .authenticationEntryPoint((req, res, ex) -> writeError(res, HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "로그인이 필요합니다.")) .accessDeniedHandler((req, res, ex) -> writeError(res, HttpStatus.FORBIDDEN, "FORBIDDEN", "접근 권한이 없습니다.")));여기서 한 가지 더. 남의 주문처럼 존재 자체를 알려 주고 싶지 않은 데이터라면 403 대신 404를 돌려주는 것도 방법이다. 403은 “그 데이터는 있는데 너는 못 본다”는 뜻이라, 번호를 바꿔 가며 찔러 보는 공격자에게 어떤 번호가 실제로 존재하는지 알려 주게 된다.
Next.js에서는 app/not-found.tsx와 app/error.tsx로 오류 화면을 직접 만든다. 운영 환경의 Next.js는 서버 컴포넌트의 오류 메시지를 일반적인 문구로 바꾸고 추적용 digest 값만 넘겨 주는데, 오류 화면에서도 error.message를 그대로 보여 주지 말고 정해진 문구와 digest만 보여 준다.
'use client';
export default function Error({ error, reset,}: { error: Error & { digest?: string }; reset: () => void;}) { return ( <div> <h2>일시적인 오류가 발생했습니다.</h2> <p>문제가 계속되면 오류 코드 {error.digest}를 알려 주세요.</p> <button onClick={() => reset()}>다시 시도</button> </div> );}Next.js가 백엔드 API를 대신 호출하는 구조라면, Route Handler에서 백엔드의 오류 응답 본문을 브라우저로 그대로 넘기지 않는 것도 중요하다. 백엔드에서 정리해 둔 code와 message만 골라서 전달하면, 혹시 백엔드 어딘가에서 예외 처리가 빠지더라도 브라우저까지 새어 나가지 않는다. 이중으로 잠그는 셈이다.
보조 장치로서의 로깅
지금까지 이야기한 것들이 “막는” 장치라면, 로깅은 “알아채는” 장치다. 1편에서 완벽한 차단은 없으니 뚫렸을 때 빨리 알아채는 게 중요하다고 했는데, CloudTrail이나 GuardDuty가 인프라 수준의 기록이라면 애플리케이션 안에서 무슨 일이 있었는지는 Spring Boot 로그만이 알고 있다. 그래서 로깅을 보안의 보조 장치로 함께 쓴다.
로그로 공격을 막을 수는 없다. 하지만 같은 IP에서 로그인 실패가 수백 번 찍히거나, 한 사용자가 남의 주문 번호를 순서대로 조회하다가 권한 오류를 연달아 내는 패턴은 로그에서만 보인다. 사고가 났을 때 “언제, 누가, 무엇을” 했는지 되짚을 수 있는 것도 결국 로그다.
무엇을 남기나
보안 관점에서 꼭 남기는 이벤트는 이 정도다.
- 로그인 성공과 실패, 로그아웃
- 권한 부족으로 거부된 요청(401, 403)
- 비밀번호 변경, 권한 변경, 개인정보 다운로드 같은 민감한 작업
- 관리자 기능 사용 내역
Spring Security는 인증 성공과 실패를 이벤트로 발행해 주기 때문에, 리스너 하나로 깔끔하게 남길 수 있다.
@Slf4j@Componentpublic class SecurityEventLogger {
@EventListener public void onSuccess(AuthenticationSuccessEvent event) { log.info("[AUTH_SUCCESS] user={}", event.getAuthentication().getName()); }
@EventListener public void onFailure(AbstractAuthenticationFailureEvent event) { log.warn("[AUTH_FAILURE] user={} reason={}", event.getAuthentication().getName(), event.getException().getClass().getSimpleName()); }}그리고 요청마다 고유한 ID를 MDC(Mapped Diagnostic Context)에 넣어 두면, 흩어져 있는 로그 줄을 요청 단위로 묶어서 추적할 수 있다. Next.js에서 백엔드를 호출할 때 이 ID를 헤더로 함께 넘기면 프론트엔드부터 DB 쿼리까지 한 줄로 이어진다.
@Componentpublic class RequestIdFilter extends OncePerRequestFilter {
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String requestId = Optional.ofNullable(request.getHeader("X-Request-Id")) .filter(id -> id.matches("[A-Za-z0-9-]{1,64}")) .orElse(UUID.randomUUID().toString()); MDC.put("requestId", requestId); try { chain.doFilter(request, response); } finally { MDC.clear(); } }}무엇을 남기지 말아야 하나
로그는 보조 장치이지만, 잘못 쓰면 그 자체가 유출 경로가 된다. 로그 파일은 생각보다 여러 사람이 보고, 여러 곳으로 복사되기 때문이다.
- 비밀번호, 토큰, Secret Key는 절대 남기지 않는다. 요청 본문이나 헤더를 통째로 찍는 디버그 로그가 운영에 그대로 남아 있지 않은지 확인한다. 앞에서 properties를 암호화하고 Jenkins Credentials를 쓴 노력이 로그 한 줄로 무너질 수 있다.
- 개인정보는 마스킹한다. 전화번호, 이메일, 주민등록번호 같은 값은
010-****-1234처럼 가려서 남긴다. - 사용자 입력을 그대로 찍지 않는다. 입력값에 줄바꿈 문자(CR/LF)를 섞어 가짜 로그 줄을 만들어 내는 로그 인젝션 공격이 있다. 사용자 입력은 줄바꿈을 제거하거나 JSON 같은 구조화된 형식으로 남긴다. 앞의
RequestIdFilter에서 헤더 값을 정규식으로 한 번 거르는 것도 같은 이유다.
로그는 서버 밖으로
서버 안에만 있는 로그는 서버가 뚫리면 공격자가 지울 수 있다. 그래서 로그는 CloudWatch Logs 같은 외부 저장소로 바로 보내고, 애플리케이션 서버에서는 지우거나 고칠 수 없게 권한을 나눈다. Spring Boot 3.4부터는 logging.structured.format.console 설정만으로 JSON 형식의 구조화된 로그를 남길 수 있어서, 수집한 뒤 검색하고 경보를 거는 것도 훨씬 수월하다. 로그인 실패가 짧은 시간에 몰리면 알림이 오도록 CloudWatch 경보까지 걸어 두면, 로그가 비로소 “알아채는” 장치가 된다.
로그는 오래, 그리고 고칠 수 없게
보관 기간도 신경 써야 한다. 프로젝트를 하다 보면 로그를 최소 6개월 이상 보관해 달라고 요구하는 클라이언트도 있다. 법으로 정해진 기준도 있다. 「개인정보의 안전성 확보조치 기준」 제8조에 따르면 개인정보처리시스템의 접속기록은 1년 이상 보관해야 하고, 정보주체가 5만 명 이상이거나 고유식별정보·민감정보를 처리하는 시스템이라면 2년 이상 보관해야 한다.
그런데 CloudWatch Logs에 모든 로그를 몇 년씩 쌓아 두면 비용이 만만치 않다. 그래서 나는 로그를 S3에 파일로 떨궈서 장기 보관하는 방법을 같이 쓴다.
- CloudWatch Logs는 단기 보관용으로 쓴다. 최근 로그를 검색하고 경보를 거는 용도라서 보관 기간을 짧게 잡는다.
- S3로 내보내 파일로 보관한다. CloudWatch Logs 구독 필터와 Kinesis Data Firehose를 연결하면 로그가 S3에 자동으로 쌓인다. 날짜별로 경로를 나눠 두면 나중에 Athena로 SQL처럼 조회할 수도 있다.
- 수명 주기 정책으로 비용을 줄인다. 일정 기간이 지난 로그는 S3 Glacier 계열 스토리지로 자동으로 옮긴다. 자주 꺼내 볼 일은 없지만 반드시 남아 있어야 하는 로그에 딱 맞는 저장소다.
- 아무도 고칠 수 없게 잠근다. S3 Object Lock을 걸어 두면 보관 기간 동안에는 관리자 계정으로도 로그 파일을 지우거나 덮어쓸 수 없다. 사고가 났을 때 “이 로그는 조작되지 않았다”고 말할 수 있어야 감사 증적으로서 의미가 있다.
- 로그 버킷도 보안 대상이다. 로그에는 사용자 ID, IP, 요청 경로 같은 정보가 모여 있으니, 버킷은 KMS 키로 암호화하고 접근은 감사 담당자 역할로만 제한한다.
다만 “영구 보관”은 한 번 더 생각해 볼 필요가 있다. 로그에 개인정보가 섞여 있다면, 필요 이상으로 오래 들고 있는 것 자체가 유출됐을 때의 피해 범위를 키운다. 그래서 클라이언트와 보관 기간을 명확히 정해 두고, 그 기간이 지나면 수명 주기 정책으로 자동 삭제되게 하는 쪽이 더 안전하다.
마지막으로, 로깅 라이브러리도 결국 라이브러리다. 몇 년 전 Log4Shell 사태처럼 로그를 남기는 코드 자체가 취약점이 되기도 하니, 보조 장치라고 업데이트를 소홀히 하면 안 된다.
정리하면
인프라가 들어올 수 있는 문을 줄이는 일이라면, 시큐어 코딩은 그 문으로 들어온 것을 하나도 믿지 않는 일이다.
- SQL: MyBatis에서는 무조건
#{}, 정렬처럼 어쩔 수 없는 곳은 허용 목록으로 - 토큰: 브라우저 JS가 아니라
HttpOnly,Secure,SameSite쿠키에 - 권한: 역할 체크에서 멈추지 말고, 데이터의 주인인지까지 확인
- 입력값: 프론트엔드에서 검증했더라도 서버에서 다시 검증
- 비밀값: 소스 코드에는 하나도 없게, 빌드·배포는 Jenkins Credentials, 실행은 Kubernetes Secret
- 설정: 파일은 암호화하고, 키는 KMS에, 복호화된 값은 Actuator와 로그로 새지 않게
- 로그: 보안 이벤트는 빠짐없이 남기되, 비밀값과 개인정보는 남기지 않고, 서버 밖 S3에 고칠 수 없게 장기 보관
그리고 이 모든 건 한 번 해 두고 끝나는 게 아니다. 라이브러리 취약점은 매일 새로 나오고, 오늘 안전한 코드도 내일은 아닐 수 있다. 결국 보안은 설계가 아니라 습관이다. 아니! 집착이다.
남들이 “편집증 걸린 거 아니야?“라고 물어볼 정도로 집착하며 관리해도 뚫리는 게 보안이다. 모든 보안은 결국 뚫리기 마련이다. 하지만 어차피 뚫릴 거라며 손을 놓는 건 어리석은 일이다. 뚫리기까지 걸리는 시간을 최대한 늘리고, 뚫렸을 때 피해를 최소한으로 줄이고, 뚫렸다는 사실을 누구보다 빨리 알아채는 것. 그게 우리가 할 수 있는 전부이자, 반드시 해야 하는 일이다.
그래서 나는 병적인 꼼꼼함, 보안에 대한 집착이야말로 개발자가 기본 소양이라고 생각한다.
안녕!!

꼬리말을 불러오는 중...