
내 DB가 점 하나(') 때문에 털렸다 (SQL Injection)
SQL Injection으로 관리자 권한을 탈취당하고 데이터를 날린 경험담. 왜 Prepared Statement가 유일한 해결책인지, ORM은 정말 안전한지 파헤쳐봤습니다.

SQL Injection으로 관리자 권한을 탈취당하고 데이터를 날린 경험담. 왜 Prepared Statement가 유일한 해결책인지, ORM은 정말 안전한지 파헤쳐봤습니다.
로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

DB 설계의 기초. 데이터를 쪼개고 쪼개서 이상 현상(Anomaly)을 방지하는 과정. 제1, 2, 3 정규형을 쉽게 설명합니다.

Debug에선 잘 되는데 Release에서만 죽나요? 범인은 '난독화'입니다. R8의 원리, Mapping 파일 분석, 그리고 Reflection을 사용하는 라이브러리를 지켜내는 방법(@Keep)을 정리해봤습니다.

개발 초기, 제가 만든 로그인 페이지는 완벽해 보였습니다. 아이디와 비밀번호를 받아서 DB에서 확인하는 아주 간단한 로직이었죠.
const query = `
SELECT * FROM users
WHERE username = '${username}' AND password = '${password}'
`;
어느 날 출근해 보니, 관리자 계정으로 로그인한 로그가 수백 개 찍혀 있었습니다. 비밀번호는 전혀 유출되지 않았는데 말이죠. 로그를 확인한 저는 경악했습니다. 누군가 아이디 란에 이런 걸 입력했습니다.
admin' --
이게 들어가자 완성된 쿼리는 이렇게 변했습니다.
SELECT * FROM users
WHERE username = 'admin' --' AND password = '...'
-- 뒤로는 모조리 주석 처리(무시)되어 버렸습니다.
비밀번호 검사 로직이 통째로 사라진 겁니다.
고작 작은따옴표(') 하나 때문에, 제 보안은 휴지 조각이 되었습니다.
이 공격이 가능한 이유는 "데이터(사용자 입력)를 코드(SQL 명령어)로 오해했기 때문"입니다.
컴퓨터는 멍청해서, 저 'admin'이 이름인지 SQL 문법의 끝인지 구분하지 못합니다.
쉽게 설명하면 "빈칸 채우기 게임(Mad LIbs)"과 같습니다.
하지만 악의적인 사용자는 이렇게 말합니다.
문장의 구조 자체가 바뀌어 버렸습니다. 이것이 SQL Injection의 본질입니다.
공격자가 꼭 데이터를 화면에서 봐야만 해킹할 수 있는 건 아닙니다. 만약 로그인 실패 시 "아이디가 존재하지 않습니다"와 "비밀번호가 틀렸습니다" 메시지가 다르다면? 공격자는 이를 이용해 스무고개 하듯이 데이터를 알아낼 수 있습니다.
id=admin AND 1=1 -> "비밀번호 틀림" (참)id=admin AND 1=2 -> "아이디 없음" (거짓)이 반응 차이(0.1초의 시간 차이 포함)만으로도 DB의 모든 내용을 빼낼 수 있습니다. 그래서 에러 메시지는 뭉뚱그려서("로그인 정보가 올바르지 않습니다") 보여줘야 합니다.
이 문제를 막는 방법은 입력값을 검사하는 게 아닙니다. (블랙리스트 방식은 100% 뚫립니다). 유일하고 가장 확실한 해결책은 Prepared Statement를 쓰는 것입니다.
이건 "빈칸 채우기"가 아니라 "변수 바인딩"입니다.
// 1. 쿼리의 구조를 먼저 보냄 (미리 컴파일)
const query = 'SELECT * FROM users WHERE username = ? AND password = ?';
// 2. 나중에 데이터를 '값'으로만 전달
db.execute(query, [username, password]);
이렇게 하면 DB는 username 자리에 무엇이 들어오든 무조건 단순 문자열로 취급합니다.
아까처럼 admin' --를 넣으면 어떻게 될까요?
SELECT * FROM users
WHERE username = 'admin\' --' AND password = '...'
DB는 "사용자 이름이 admin' -- 인 사람"을 찾습니다. 당연히 그런 사용자는 없으니 로그인은 실패합니다.
공격 코드가 평범한 텍스트로 무력화된 것입니다.
Web Application Firewall (WAF)는 SQL Injection 같은 공격 패턴을 탐지해서 차단해 줍니다. 하지만 WAF는 보조 수단일 뿐입니다.
그래서 "WAF가 있으니까 코드 대충 짜도 돼"는 위험한 생각입니다. 최후의 보루는 언제나 코드(Prepared Statement)여야 합니다.
"저는 ORM(TypeORM, Prisma, JPA) 쓰니까 괜찮죠?" 네, 대부분은 괜찮습니다.
ORM은 내부적으로 99% Prepared Statement를 사용합니다.
// Prisma 예시 (안전함)
const user = await prisma.user.findFirst({
where: {
username: input // 자동으로 이스케이프 처리됨
}
});
하지만 방심은 금물입니다. ORM을 쓰더라도 Raw Query(직접 SQL 작성) 기능을 쓸 때는 여전히 위험합니다.
// ❌ 위험한 코드 (JPA 예시)
em.createNativeQuery("SELECT * FROM users WHERE name = '" + name + "'");
// ✅ 안전한 코드
em.createNativeQuery("SELECT * FROM users WHERE name = :name")
.setParameter("name", name);
개발자가 편하려고 문자열을 + 로 합치는 순간, ORM의 보호막은 사라집니다.
SQL Injection은 20년도 더 된 공격 기법이지만, 여전히 OWASP Top 10의 상위권을 차지합니다. 이유는 단순합니다. 개발자가 귀찮아서 문자열을 그냥 합치기 때문입니다.
보안은 대단한 기술이 아닙니다. "사용자가 입력한 모든 값은 더럽다"고 가정하는 태도. 그리고 귀찮더라도 원칙(Prepared Statement)을 지키는 끈기. 그것이 여러분의 소중한 데이터를 지킵니다.
지금 당장 여러분의 코드에서 ${variable} 처럼 변수가 쿼리에 직접 박혀있는 곳을 찾으세요.
그곳이 바로 해커가 들어올 대문입니다.
As a junior developer, my login page looked perfect. It simply took an ID and password and checked them against the database.
const query = `
SELECT * FROM users
WHERE username = '${username}' AND password = '${password}'
`;
One morning, I found hundreds of login logs as the administrator. But the admin password hadn't been leaked. I checked the logs and was horrified. Someone had entered this in the ID field:
admin' --
Once this was inserted, the query transformed into:
SELECT * FROM users
WHERE username = 'admin' --' AND password = '...'
Everything after -- was treated as a comment (ignored).
The password check logic completely vanished.
Because of a single single-quote ('), my security became useless.
This attack works because "Data (User Input) is mistaken for Code (SQL Commands)."
Computers are dumb; they can't tell if 'admin' is a name or the end of SQL syntax.
It's like playing "Mad Libs".
But a malicious user says:
The structure of the sentence itself changed. This is the essence of SQL Injection.
Attackers don't always need to see the data to steal it. If your login error messages distinguish between "User not found" and "Wrong password", you are vulnerable. Attackers can play "Twenty Questions" with your database.
id=admin AND 1=1 -> "Wrong password" (True)id=admin AND 1=2 -> "User not found" (False)Even the Time Difference (Time-based Blind SQLi) can leak data. Always genericize error messages ("Invalid credentials") to prevent this.
Checking input values (Regex, Blacklists) is not the solution. (They can always be bypassed). The only and most certain solution is using Prepared Statements.
This isn't "Filling in blanks," it's "Variable Binding."
// 1. Send query structure first (Pre-compile)
const query = 'SELECT * FROM users WHERE username = ? AND password = ?';
// 2. Send data only as 'values' later
db.execute(query, [username, password]);
Now, the DB treats whatever is in username as strictly a string.
If we input admin' -- like before?
SELECT * FROM users
WHERE username = 'admin\' --' AND password = '...'
The DB looks for "A user whose name is literally admin' --". Of course, no such user exists, so login fails.
The attack code is neutralized into plain text.
Web Application Firewall (WAF) monitors network traffic and blocks SQL Injection patterns. However, WAF is just First Aid, not a Cure.
Thinking "I have AWS WAF, so I can write bad code" is a recipe for disaster. Your last line of defense must always be the Code (Prepared Statements).
Standard SQLi happens immediately (Reflected). Second Order is a time-bomb.
admin' --.SELECT * FROM logs WHERE user = 'admin' --'.Lesson: Data from the Database is also untrusted input. Always assume everything is tainted.
The British telecom giant TalkTalk was hacked by a 17-year-old using a simple SQL Injection. They lost £77 million and 100,000 customer records.
The Cause: A legacy web page aimed at customers was left unpatched. It used an outdated database driver that didn't support prepared statements properly, and input fields were not sanitized. The attacker used an automated tool (SQLMap) to identify the vulnerability and dump the database.
Takeaway: Legacy code is often where security dies. If you have "that old PHP page" no one touches, shut it down or fix it today.
Even if your code is perfect, 0-day vulnerabilities exist in libraries you use. You need a shield: Web Application Firewall (WAF).
' OR 1=1.Pro Tip: Enable Cloudflare's "OWASP Core Ruleset" today. It handles 90% of common attacks.
"I use an ORM (TypeORM, Prisma, JPA), so I'm safe, right?" Yes, mostly.
ORMs use Prepared Statements internally 99% of the time.
// Prisma Example (Safe)
const user = await prisma.user.findFirst({
where: {
username: input // Automatically escaped
}
});
But don't let your guard down. Even with ORMs, if you use Raw Queries, you are still at risk.
// ❌ Dangerous Code (JPA Example)
em.createNativeQuery("SELECT * FROM users WHERE name = '" + name + "'");
// ✅ Safe Code
em.createNativeQuery("SELECT * FROM users WHERE name = :name")
.setParameter("name", name);
The moment a developer gets lazy and concatenates strings with +, the ORM's shield vanishes.
SQL Injection is an attack technique over 20 years old, yet it still ranks high in OWASP Top 10. The reason is simple. Developers are too lazy and just concatenate strings.
Security isn't about fancy tech. It's the attitude of assuming "All user input is dirty." And the persistence to follow principles (Prepared Statements) even when it's annoying. That is what protects your precious data.
Go search your code right now for places where variables like ${variable} are directly embedded in queries.
That is the open gate for hackers.