생성형 AI의 시대... 이젠 보안이 필수다!
생성형 AI로 코딩을 잘 몰라도 쉽게 웹사이트를 만들 수 있고, 생산성을 올려 빠르게 MCP를 구현해 서비스를 운영할 수 있는 시대가 왔습니다. 이 때 주니어 개발자들은 어떤 일들을 해야 할까요?
Cursor로 최대한 빠르게 개발해서 배포하기? 이모티콘이 잔뜩 달린 주석을 그대로 사용하기? 우리는 생성형 AI와 멀어질 수 없는 시대에 왔습니다. 우리는 AI가 만든 빠르고 간결한 서비스가 놓친 것들을 체크하고, 일관성 없는 코드를 일관성 있게 만드는 작업이 우선입니다.
대부분의 팀이 짧은 기간동안 서비스를 완성하기 위해 생성형 AI로 요구사항만 충족하는 서비스를 만들 때, 우리는 좀 더 안정적이고 문제없는 서비스를 만들기 위해 시큐어 코딩을 적용하는 방법을 택했습니다.
시큐어 코딩이란?
시큐어코딩이란 소프트웨어 개발 과정에서 발생할 수 있는 취약점을 고려해, 입력 검증, 데이터 보호, 오류 처리, 파일 처리 등을 안전한 방식으로 구현하는 개발 방법론입니다. 큰 의미로, 사이버 공격을 미리 방어할 수 있는 코드를 짜는 것으로 이해할 수 있습니다. 보안 사고는 대부분 코딩 단계의 실수에서 시작됩니다. SQL Injection, XSS, CSRF, 파일 업로드 취약점, 인증 우회 같은 문제들은 개발자가 코드에 살짝만 신경을 쓰지 않아도 발생합니다.
이런 취약점을 그대로 방치하면 공격자들은 우리의 서비스를 쉽게 망칠 수 있습니다. 회원정보를 유출해가거나, 계정을 탈취할 수 있고, 서버를 다운시킬 수 있습니다.
왜 필요할까?
나중에 서비스를 배포한 후 서버가 다운되거나 회원정보 유출 등의 문제가 생기면 그 때의 비용은 정말 어마무시하게 큽니다. 미리 개발 단계에서 안전하게 짜면 유지보수 비용이 크게 줄 수 있습니다.
특히나 우리의 서비스가 이화의 벗들을 대상으로 하는 서비스인만큼, 개인정보 유출이나 외부인 공격에 민감한 이화 벗들을 안심시키기 위해선 보안에 신경을 쓰는 것이 필요합니다.
적용한 프로젝트에 대한 간단한 설명
저희 팀의 백엔드는 Flask+Firebase 환경으로 구성되어있습니다. 데이터베이스가 전통적 SQL 기반이 아니기 때문에 웹 취약점 점유율 1등의 SQL Injection 취약점은 구조적으로 발생하지 않는 환경이었습니다.
그럼에도 불구하고 남아있는 보안 이슈들은 많습니다. 로그인 회원가입의 형식과 규칙을 지키도록 해야하고, 입력값 검증을 통해 잘못된 값이 들어와 서버의 item을 망치진 않을지 체크해야 했습니다.
또한 SQL Injection 다음으로 유명한 취약점인 XSS 방어와 CSRF 방어에 대해 신경을 쓰게 되었습니다.
Flask의 Jinja 문법은 자동으로 XSS 방어를 적용해주고 있기 때문에 XSS 방어에 대해서 신경 쓸 것은 크게 없었습니다. 이에 우리는 CSRF 적용을 통해 보안성을 높이는 것과 입력값 검증을 통해 서버 내의 데이터를 공격자가 악의적으로 망칠 수 없도록 하는 것에 집중했습니다.
XSS, CSRF이 무엇인지 궁금하시다면 아래의 링크를 참고해보세요.
[1주차 - 웹 해킹 기초 이론] XSS & CSRF
XSS (Cross Site Scripting) 이란?동적으로 출력하는 페이지에 대해 악의적인 스크립트를 삽입하여 비정상적인 행위를 하는 것 기능 단위의 공격전체 기능이나 프로세스를 기준으로 공격 대상을 파악
pokpung-ganji.tistory.com
해당 문제들을 flask 백엔드 레벨에서 직접 검증하고 방어하는 코드를 작성해야 할 때, 큰 도움이 된 것이 Flask-WTF 오픈소스입니다. Flask-WTF는 다음과 같은 기능들을 제공해 개발 편의성과 보안성을 동시에 높여주었습니다.
- CSRF 토큰 자동 생성 및 검증
- 폼 데이터 검증 구조화
- 서버사이드 validation을 일관된 방식으로 관리
덕분에 폼 처리 전반에서 발생할 수 있는 여러 취약점을 줄였고, 서비스 전반의 안정성과 신뢰성을 높은 수준으로 유지할 수 있었습니다.
초기 코드 구조와 문제점
프로젝트 초반에는 실습 과제에 맞추어 진행하며, 일단 기본 CRUD와 로그인 기능을 빠르게 구현하는 것에 집중했습니다.
data = request.form.to_dict()
지금까지의 백엔드 기능은 모두 request.form의 값으로 직접 꺼내 db에 저장하고 꺼내는 형식을 사용했습니다. 겉으로 보기에는 필요한 기능은 다 구현된 상태이지만, 보안 관점에서는 여러가지 문제가 있었습니다
1. 입력값 검증
상품을 등록하거나 리뷰를 등록할때, 잘못된 정보나 악성 파일이 들어오지 않는지에 대한 검증이 미비했습니다. 별점으로 -1을 줄 수 있다거나, 가격을 음수로 설정할 수 있었습니다. 또한 이미지 파일의 형식에 제한이 없으니 실행 가능한 악성 파일이 서비스에서 작동될 수 있었습니다.
2. CSRF 보호 없음
초기에는 Flask의 기본 request.form 처리만 사용했고, 템플릿에서도 CSRF 토큰 없이 form을 만들고 있었습니다. 브라우저에 로그인만 되어있다면, 공격자가 외부 사이트에서도 우리 이화 마켓 서비스로 POST 요청을 유도할 수 있는 심각한 문제가 발생될 수 있습니다. 즉 사용자가 모르는 사이에 상품 정보, 리뷰, 게시글, 회원정보 등이 바뀔 수 있었습니다.
<form method="post" action="{{ url_for('reviews.insert_review') }}">
<input type="text" name="title">
<textarea name="content"></textarea>
<button type="submit">등록</button>
</form>
3. 단순한 아이디/비밀번호 허용
초기 코드에서는 아이디와 비밀번호의 길이 등등 형식에 대한 검증이 없어 한글자의 짧은 아이디와 비밀번호로도 회원가입과 로그인이 가능했습니다. 공격자가 쉽게 계정 탈취를 해 서비스는 물론 사용자에게 악영향을 끼칠 수 있는 일이 발생할 수 있었습니다.
email = request.form.get("email")
password = request.form.get("password")
if not email or not password:
flash("이메일과 비밀번호를 입력하세요")
return redirect(request.referrer)
해결 방법
입력값 검증
1. FlaskForm 사용
기존에는 request.form으로 정보를 직접 받아, 조건문으로 해당 정보가 존재하는지 아닌지에 대해서만 받았습니다.
data = request.form.to_dict()
이 경우 필요한 정보들이 실제로 들어있는지, 올바른 범위에 있는지 체크하기 위해서는 백엔드 라우터 안에서 길고 복잡한 코드가 작성되어야 합니다. 놓치는 케이스가 생길 수 있고, 매 라우트 마다 해당 정보에 대한 점검이 필요하니 중복 코드가 발생하여 유지보수가 난잡해질 수 있습니다.
저희는 이에 FlaskForm + validator로 구조화하여 입력값 검증과 허용 문자/형식 제한을 백엔드단에서 간편하게 진행할 수 있도록 했습니다.
class ReviewForm(FlaskForm):
item_id = HiddenField(validators=[Optional()])
name = StringField(validators=[Optional(), Length(max=100)])
seller = StringField(validators=[Optional(), Length(max=100)])
p_details = TextAreaField(validators=[Optional(), Length(max=2000)])
title = StringField(validators=[DataRequired(), Length(min=1, max=50)])
r_details = TextAreaField(validators=[DataRequired(), Length(min=1, max=2000)])
rating = IntegerField(validators=[DataRequired(), NumberRange(min=1, max=5)])
FlaskForm은 데이터의 길이 제한, 형식 제한, 필수/옵션 등을 자유롭게 설정할 수 있고, Form을 만드는 과정에서 자동으로 점검해주기 때문에 기타 오류처리에 있어 불편함을 해소할 수 있었습니다.
form = ReviewForm()
if not form.validate_on_submit():
for field, errors in form.errors.items():
for e in errors:
flash(f"{field}: {e}")
return redirect(request.referrer)
백엔드 라우터에서도 상품/리뷰를 작성할 때 request.form 대신 FlaskForm 객체인 ReviewForm을 통해 받아왔습니다. ReviewForm 안에 이미 입력값에 대한 여러 옵션이 설정되어 있기 대문에 백엔드 단에서 for문을 통해 모든 입력 정보들을 한번에 검증, 검증이 필요한 파트에 대해서만 flash를 통해 오류를 띄울 수 있었습니다.
2. 이미지 파일 형식 검증
앞서 말했듯 이미지 파일을 받을 때는 여러 차례의 점검이 필요합니다. 파일 이름이 악성코드의 형태는 아닌지, 또는 파일의 형식이 올바른지에 대한 점검이 필요합니다.
from werkzeug.utils import secure_filename
for image_file in files[:3]:
if not image_file or not image_file.filename:
continue
filename = secure_filename(image_file.filename)
save_path = f"static/images/{filename}"
image_file.save(save_path)
img_filenames.append(filename)
파일의 이름을 점검하기 위해 secure_filename을 사용했으나, 해당 코드를 적용하자 서로 다른 이름의 파일들이 하나의 특정 사진으로 뜨는 상황이 발생했습니다.

해당 문제가 발생한 이유는 "한글"명이 들어간 사진 파일이 secure_filename을 적용하면 한글이 모두 삭제되고, 결국 공백 이름의 파일이 되어 같은 이름의 파일로 취급되는 것이었습니다.
#secure_filename 사용시 한글 깨짐 및 중복 방지를 위해 UUID 적용
original = secure_filename(image_file.filename)
name, ext = os.path.splitext(original)
unique_name = f"{name}_{uuid.uuid4().hex[:8]}{ext}"
save_path = f"static/images/{unique_name}"
해당 문제를 해결하기 위해 uuid를 사용해 각각의 이미지 파일에 고유한 id 번호를 붙이니 문제가 해결되었습니다.
이미지 파일의 이름 뿐만이 아니라 형식 또한 검증하기 위해 프론트엔드에서도 JS를 통해 허용된 파일만 추가할 수 있는 코드를 넣었습니다. 그럼에도 JS 우회를 통한 공격 방법이 존재하므로 FlaksForm에 file에 대한 형식도 추가하였습니다.
file = FileField(validators=[FileAllowed(['jpg', 'png'. 'jpeg', 'gif'], '이미지 파일만 업로드 가능합니다.')])
CSRF 보호
csrf = CSRFProject(app)
app 자체를 만들때 Flask-WTF를 통해 자동으로 POST/PUT/DELET 요청에 대해 CSRF 토큰 검정을 하도록 만들었습니다. 앱 전체를 CSRF safe 모드로 만드는 역할입니다.
쉽게말해 CSRF 검증을 도와주는 기능을 추가했습니다.
해당 코드를 추가하다보니 기존의 모든 POST 요청에서 문제가 생겨 모두 CSRF 토큰을 추가해주는 번거로움이 있었습니다.
fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken(),
'Content-Type': 'application/json'
},
body: JSON.stringify({})
})
.then(res => {
if (!res.ok) {
return res.text().then(text => {
console.error('Wish API error:', text);
throw new Error('Wish API failed');
});
}
return res.json();
})
입력값 검증을 통해 FlaskForm을 적용한 Product와 Review 라우터의 경우 hidden 으로 자동으로 csrf 태그가 들어가지만, 그 외의 라우터에는 들어가지 않아 직접 Form에 추가해주거나, Ajax 요청에 추가해주어야 했습니다.
로그인/회원가입/회원정보 수정 정규 표현식
기존의 로그인/회원가입은 아이디와 비밀번호 형식이 제한적이지 않다는 문제가 있었습니다. 몇몇 유저가 굉장히 단순한 패스워드를 사용한다면 계정 탈취의 우려가 있었습니다. (쉬운 비밀번호를 하나하나 대입해보는 것은 아직도 가장 큰 웹 취약점 중 하나입니다.) 이에 회원가입 시에 단순한 아이디와 패스워드로는 회원가입이 불가능하도록 로직을 수정하였습니다.
ID_PATTERN = re.compile(r"^[A-Za-z0-9_]{3,20}$")
PW_PATTERN = re.compile(r"^(?=.*[A-Za-z](?=.*\d).{8,64}$")
아이디와 비밀번호 정규식을 지정한 후 입력한 값이 match하는지를 확인했습니다.
# 2) 아이디 형식 체크
if not ID_PATTERN.match(user_id):
flash("아이디는 3~20자 사이의 영문, 숫자, 밑줄(_)만 사용할 수 있습니다.")
return redirect(url_for("pages.signup"))
# 3) 비밀번호 형식 체크
if not PW_PATTERN.match(pw):
flash("비밀번호는 8자 이상이어야 하고, 영문과 숫자를 각각 최소 1자 이상 포함해야 합니다.") return redirect(url_for("pages.signup"))
해당 로직이 마이페이지의 회원정보 수정에도 적용되어야 하므로 똑같이 PW_PATTERN을 적용하여 비밀번호 형식을 체크하였습니다.
if new_password:
if not PW_PATTERN.match(new_password):
flash("비밀번호는 8자 이상이어야 하고, 영문과 숫자를 각각 최소 1자 이상 포함해야 합니다.")
return redirect(url_for("user.mypage", section="profile"))
hashed = _hash_pw(new_password) # 암호화 후 전달
DB.update_user_password(user_id, hashed)
또한 회원정보 수정에서는 기본의 비밀번호와 일치하는 경우를 막기 위해 AJAX로 매 글자 요청을 받아오는 라우터를 추가했습니다.
@user_bp.route("/check_password_match", methods=["POST"])
def check_password_match():
data = request.get_json()
input_password = data.get("password")
user_id = session.get("id")
if not user_id or not input_password:
return jsonify({"match": False}) # 로그인 안됐거나 입력 없으면 불일치 처리
DB = current_app.config["DB"]
# DB에서 현재 유저 정보 가져오기
current_user = DB.get_user(user_id)
if not current_user:
return jsonify({"match": False})
# 입력된 비밀번호 암호화 (기존 _hash_pw 함수 사용)
hashed_input = _hash_pw(input_password)
# DB 비밀번호와 비교
current_db_password = current_user.get("pw")
if hashed_input == current_db_password:
return jsonify({"match": True})
else:
return jsonify({"match": False})
결과
시큐어코딩 적용과 코드 리팩토링을 거치며 백엔드의 안정성이 체감될 정도로 향상되었습니다. 단순히 기능이 “된다”에서 끝나는 것이 아니라, 더 발전시키면 실제 서비스화 할 수 있겠다 라는 믿음이 생기게 되었습니다.
마무리
이번 프로젝트를 통해 단순한 CRUD 이상의 것을 얻었습니다. 기능만 구현하면 되는 과제 수준의 개발이 아닌 서비스가 갖춰야 하는 최소한의 보안 기준을 알게 되었습니다.
돌아가는 코드와 안전한 코드는 다르다는 것, 보안은 선택이 아니라 기본값이라는 것을 알게되었습니다. 시큐어 코딩은 백엔드 개발자로서 최소한의 기본기라는 것을 깨달았습니다. 특히 Flask처럼 기능이 가벼운 백엔드의 경우 개발자가 직접 보안에 대해 신경써야 할 점이 더 많다는 것을 느꼈습니다.
Flask-WTF를 통해 크게 신경쓰지 않아도 여러 시큐어코딩을 간단하게 해결할 수 있어 편리한 면도 많았지만, 시큐어코딩 하나를 적용하면 다른 곳에서 터지는 오류 때문에 기존의 코드가 얼마나 엉터리이고 허술했는지 뼈저리게 느끼게 되었습니다.
결과적으로 시큐어코딩을 적용하며 리팩토링 하는 과정은 정말 귀찮고 생각보다 많은 것을 뜯어고쳐야 했습니다. 하지만 리팩토링 이후에는 이제야 백엔드다운 백엔드가 되었구나, 이래서 전문성이 필요한 분야라는 것을 크게 느꼈습니다.
Reference
https://www.kisa.or.kr/skin/doc.html?fn=20240123_171651_543.pdf&rs=/result/2024-01/