정글은 입소하자마자 프로젝트를 던져준다. 앞으로의 정글 과정을 살아남기 위해선 간단한 프로젝트로 몸을 미리 풀어야 된다는 의미가 아닐까?
우리 3팀이 기획한 프로젝트 명은 jungle express다. 정글을 떠올리면 탐험하다, 헤쳐 나가다, 살아 남다 등이 생각이 나는데 이 중 '탐험하다'를 선택했다. 왜냐? 정글 캠퍼스에 어떤 장소에 어떤 건물이 있는 지 알려주기 위해서 '정글 캠퍼스를 탐험해보라' 라는 취지의 app을 만들거기 때문이다.
정글 캠퍼스는 넓기 때문에 훌륭한 접근이라고 생각한다. 정글에 들어오면 커리큘럼을 소개하는 어떤 영상을 하나 보여주는 데 이전 기수가 어떤 프로젝트를 했는 지 잠깐 나온다. 보니까 강사 이름 맞추기, 기수 동기들 이름 맞추기, 생활 필수품 나눔 장터, 친구 구해요, 배달 해줘요 등 정글에서 살아남는 데 필요한 아이디어들이 대부분이였기 때문이다.
정글 탐험하기 app에 필요한 요소를 생각해 보자. 뭐가 필요할까? 우리는 정글인 한정으로 만들거기 때문에 사용자 위치는 고려 대상이 아니다. '정글인으로서 살아갈 때 필요한 건물이 이곳에 있다.' 라는 내용을 전하고 싶을 때 사용자가 그곳을 한 번 방문해보는 것보다 좋은 수단은 없지 않겠는가?
그래서 준비했다. 우리가 여러 장소 사진을 찍어서 문제를 만든다. 사용자는 문제를 풀기 위해 휴대폰으로 사이트에 접속해야 하고, 접속하면 장소 사진이 뜨면서 답을 적을 수 있는 칸이 떠 있다. 사용자는 그 장소로 직접 가야하고, 가서 QR을 찍으면 답을 알 수 있다. 그렇게 하나씩 문제를 맞춰가면서 자신의 점수를 획득하는 거다. 점수에 따라 랭킹을 부여해 놨으니 친구와 경쟁하면서 승부욕을 불태울 수 도 있다.

그렇다... 시간이 없다... 10시부터 11시까지 정규 시간인데 밤 새는 사람들이 수두룩할 정도다...
멘토진들이 강조하는 것은 무조껀 동작이 되어야 한다는 것이다. 아무리 좋은 기술을 넣어도 동작이 되지 않으면 무용지물이라고 한다.
그래서 야매?로 코드 박아넣었다. 솔직히 돌아가기만 하면 되지 않는가? 리팩토링? 개나 줘버렸다. 진짜 읽기 더럽고, 왜 이렇게 만들었지? 수두룩 나올 것이다. 참고하시고 보시길 바란다.
jwt라는 말을 들어본 적이 있는가? 이거 비인가된 사용자의 접근 권한을 제한하는 기술이다. 로그인도 안하고 app을 이용하게 두면 안되지 않는가? 그래서 사용자가 client에서 사용하려고 버튼을 딱! 누르면 서버에서 들어온 데이터를 확인하고, access token을 확인한다. 근데 토큰이 없으면? 그렇다. 토큰이 없으면 접근이 차단된다.
그러면 이 토큰만 알고 있으면 나도 접근할 수 있는 건가? 그렇지 않을까? 토큰을 서버로 전송하는 과정에서 가로 채서 내가 실 사용자인 것처럼 위장할 수 있지 않을까? 그래서 gpt한테 물어봤다.
JWT를 쿠키에 저장하고 서버로 전송하는 과정에서 보안 설정이 부족하면 공격자가 가로채서 사용자를 위장할 수 있어!
즉, 쿠키 보안 설정이 제대로 되어 있지 않다면 공격(세션 하이재킹, XSS 등)에 취약할 수 있음.
✔ 공격자가 악성 스크립트를 삽입하여 브라우저에서 쿠키 값을 탈취하는 방식
✔ 예를 들어, 사이트에 XSS 취약점이 있다면 공격자가 아래와 같은 스크립트를 삽입할 수 있음:
console.log(document.cookie); // 브라우저에서 쿠키 값 가져오기
🚨 만약 JWT가 쿠키에 저장되어 있다면? 공격자가 도용 가능!
🔒 해결 방법
✅ 쿠키를 HttpOnly 속성으로 설정 → JavaScript에서 접근 불가능하게 만듦
✅ CSP(Content Security Policy) 설정 강화 → XSS 방어
response.set_cookie("jwt", token, httponly=True, secure=True, samesite="Strict")
✔ HTTPS를 사용하지 않으면 네트워크에서 JWT 쿠키가 평문(Plain Text)으로 노출될 수 있음
✔ 공격자가 중간자 공격(Man-in-the-Middle, MITM)을 통해 쿠키를 가로채면 사용자를 위장할 수 있음
🔒 해결 방법
✅ Secure 속성을 설정하여 HTTPS에서만 쿠키 전송
✅ TLS(SSL) 사용하여 모든 통신을 암호화 (HTTPS 적용)
response.set_cookie("jwt", token, httponly=True, secure=True, samesite="Strict")
✔ 공격자가 피해자의 브라우저를 이용해 원치 않는 요청을 서버에 보내는 방식
✔ JWT가 자동으로 쿠키에 포함되어 전송된다면 공격자가 악용 가능
🔒 해결 방법
✅ 쿠키에 SameSite=Strict 속성 추가하여 CSRF 방어
✅ CSRF 토큰을 추가하여 요청 검증
response.set_cookie("jwt", token, httponly=True, secure=True, samesite="Strict")
response.set_cookie(
"jwt",
token,
httponly=True, # JavaScript에서 접근 불가능 (XSS 방어)
secure=True, # HTTPS에서만 전송 가능 (MITM 공격 방어)
samesite="Strict", # 다른 사이트에서 요청 방지 (CSRF 방어)
max_age=3600 # 쿠키 만료 시간 설정 (1시간 후 자동 삭제)
)
✔ HttpOnly=True → JavaScript에서 document.cookie로 접근 불가능 (XSS 방어)
✔ Secure=True → HTTPS 환경에서만 전송 가능 (MITM 방어)
✔ SameSite=Strict → 다른 도메인에서 자동 전송 방지 (CSRF 방어)
Authorization 헤더에 저장하면 안전할까?쿠키에 JWT를 저장하는 대신, 로컬 스토리지(localStorage) 또는 메모리(storage)를 사용하는 방법도 있음
하지만 localStorage도 XSS 공격에 취약하므로, 최적의 방법은 "메모리에 저장"하는 것!
📌 JWT를 Authorization 헤더에 저장하는 방식
fetch("https://example.com/protected", {
method: "GET",
headers: {
"Authorization": "Bearer YOUR_JWT_TOKEN"
}
})
🔒 장점
✅ 쿠키 기반보다 CSRF 공격 위험이 적음
✅ XSS 공격을 방어하기 위해 메모리(예: React 상태 관리)에 저장하는 것이 가장 안전함
| 보안 위협 | 방어 방법 |
|---|---|
| XSS 공격 (쿠키 탈취) | HttpOnly=True 설정 |
| MITM 공격 (네트워크에서 가로채기) | Secure=True 및 HTTPS 사용 |
| CSRF 공격 (자동 요청 문제) | SameSite=Strict 설정 |
| 쿠키 강제 설정 공격 | JWT 서명 검증 (HMAC, RSA) |
✔ JWT를 쿠키에 저장할 때 HttpOnly, Secure, SameSite=Strict 필수 설정!
✔ JWT를 Authorization 헤더에 저장하면 CSRF 방어에 유리!
✔ XSS 방어를 위해 localStorage 대신 "메모리 저장"이 가장 안전! 🚀
아무튼 흐름은
사용자 회원가입 -> id와 password를 서버에 전송 -> DB에 저장 -> 로그인 -> token 발급 -> token을 통해 로그인 된 사용자만 이용할 수 있도록 관리
success: function (response) {
if (response['result'] == 'success') {
// 로그인이 정상적으로 되면, 토큰을 받아옵니다.
// 이 토큰을 mytoken이라는 키 값으로 쿠키에 저장합니다.
$.cookie('mytoken', response['token']);
window.location.href = '/'
} else {
// 로그인이 안되면 에러메시지를 띄웁니다.
alert(response['msg'])
}
로그인 성공 시 token 발급
나의 생각 -> 회원마다 문제를 맞춘 갯수, 총 문제 수가 저장되어 있으니까 조회해서 ranking에 저장해서 쓰자!
하지만 찾아보니 굳이 ranking 컬렉션을 추가해서 DB용량을 낭비할 필요없음. 사이트를 사용하는 회원 수가 작으니까 ranking을 직접 계산해서 사용하는 방법도 있다고 함.
그렇게 랭킹 값을 구하는 과정에서 많은 고민 + 시간이 걸림(솔직히 mongo를 처음 사용하긴 했는데 뭔가 SQL이랑 많이 다른 것 같았습니다..)
def get_ranking_data(page, per_page=10):
# 모든 사용자를 가져와서 메모리에서 정렬
all_users = list(db.user.find({}, {'_id': 0, 'pw': 0}))
# problemList에서 True의 개수를 기준으로 정렬
all_users.sort(key=lambda x: x['problemList'].count(True), reverse=True)
total_users = len(all_users)
total_pages = (total_users + per_page - 1) // per_page
# 페이지에 해당하는 사용자들 추출
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
users = all_users[start_idx:end_idx]
# 각 사용자의 실제 순위 추가 (1부터 시작)
for i, user in enumerate(users):
user['rank'] = start_idx + i + 1
user['solved_count'] = user['problemList'].count(True)
update_ranking_collection(all_users)
return {
'users': users,
'total_pages': total_pages,
'current_page': page,
'total_users': total_users
}
원래는 이것보다 간단했지만 페이지네이션을 추가하면서 수정됐다.
페이지네이션을 추가하는 이유가 뭔지 아는가? 나는 몰라서 실수를 했다. 단순하게 생각했는데 서버에서 DB에 저장되어 있는 데이터를 한 꺼번에 가져와서 버튼을 누를 때마다 일부를 보여주는 게 아니였다. 애초에 필요한 정보만 가져와서 화면에 띄우는 방식으로 DB에서 데이터를 가져오는 시간을 줄일 수 있는 최적화 방식이였다.
여기서 문제가 발생했다. 맞춘 갯수를 기준으로 정렬을 해서 순위가 높은 사람이 맨 위로 가게 끔하려고 했지만 전체를 조회하는 것이 아닌 일부를 조회하다보니 그 안에서 정렬이 되는 것이다. 예를 들어서 2등이 3번째에 저장되어있고, 1등이 23번 째에 저장 -> DB에서 10개씩 가져온다면 1페이지에서 1등은 2등이 되는 결과를 출력하게 된다.
문제 해결은 솔직히 시간이 촉박한 관계로 많이 미흡했던 것 같다. 내 생각은 '데이터 전체를 순위대로 정렬을 해서 가져온 다음 임시로 담아두고, 필요할 때 꺼내주는 방식을 사용하면 어떨까?' 생각했다. 구체적인 코드 작성은 ai의 도움의 많이 받았고, 오류 발생 여부에 대한 전체적인 검사를 했다.
갑자기 추가하게 된 기능이여서 DB설계(설계라고 해도 되는지 모르겠다)에 많은 시간을 사용했다. mongo는 컬렉션이라고 부르던데 스키마 추가가 자유로워서 좋았지만 익숙하지 않은 것이 컸던 것 같다. 그러면서 추가된 코드
def update_ranking_collection(all_users):
# ranking 컬렉션에 사용자 순위 정보 업데이트
ranking_updates = []
for user in all_users: # 전체 사용자에 대해 순위 정보 생성
ranking_data = {
'user_id': user['id'],
'rank': all_users.index(user) + 1,
'solved_count': user['problemList'].count(True),
'updated_at': datetime.datetime.utcnow()
}
ranking_updates.append(ranking_data)
# 기존 ranking 컬렉션 데이터 삭제 후 새로운 데이터 삽입
db.ranking.delete_many({})
if ranking_updates:
db.ranking.insert_many(ranking_updates)
솔직히 말해서 뭐라고 표현해야 될 지 모르겠다... 일단 이렇게 랭킹 컬렉션을 생성하면 추가로 기능을 구현하는 데 있어서 편리하기 때문에 이렇게라도 만들었다고 할 수 있다.
@app.route('/my_rank', methods=['GET'])
def my_rank():
try:
receivedToken = request.cookies.get('mytoken')
payload = jwt.decode(receivedToken, SECRET_KEY, algorithms=['HS256'])
# 모든 사용자를 가져와서 문제 해결 수로 정렬
all_users = list(db.user.find({}, {'_id': 0, 'pw': 0}))
all_users.sort(key=lambda x: x['problemList'].count(True), reverse=True)
# 현재 사용자의 순위 찾기
current_user_id = payload['id']
my_rank = next(i + 1 for i, user in enumerate(all_users) if user['id'] == current_user_id)
solved_count = all_users[my_rank - 1]['problemList'].count(True)
return jsonify({
'result': 'success',
'rank': my_rank,
'solved_count': solved_count,
'nickname': payload['id']
})
except (jwt.ExpiredSignatureError, jwt.exceptions.DecodeError):
return jsonify({'result': 'fail', 'msg': '로그인이 필요합니다.'})
@app.route('/add_friend', methods=['POST'])
def add_friend():
try:
token = request.cookies.get('mytoken')
payload = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
user_id = payload['id']
friend_id = request.form['friend_id']
# 자기 자신은 친구 추가 불가
if user_id == friend_id:
return jsonify({'result': 'fail', 'msg': '자기 자신은 친구 추가할 수 없습니다.'})
# 이미 친구인 경우 체크
user = db.friends.find_one({'user_id': user_id, 'friend_id': friend_id})
if user is not None:
return jsonify({'result': 'fail', 'msg': '이미 친구입니다.'})
# 친구 추가
db.friends.insert_one({
'user_id': user_id,
'friend_id': friend_id
})
return jsonify({'result': 'success'})
except:
return jsonify({'result': 'fail', 'msg': '로그인이 필요합니다.'})
@app.route('/friend_ranking', methods=['GET'])
def friend_ranking():
try:
token = request.cookies.get('mytoken')
payload = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
user_id = payload['id']
# 현재 사용자의 친구 목록 가져오기
friends_list = list(db.friends.find({'user_id': user_id}))
friends = [friend['friend_id'] for friend in friends_list]
# 친구들의 정보 가져오기 (rank 필드 제외)
friend_rank = list(db.ranking.find(
{'user_id': {'$in': friends}},
{'_id': 0, 'pw': 0, 'updated_at': 0}
))
return jsonify({
'result': 'success',
'friends': friend_rank
})
except:
return jsonify({'result': 'fail', 'msg': '로그인이 필요합니다.'})
여기서 시간이 많이 걸렸다.... 핀 버튼을 누르면 누른 내가 누군 지는 당연히 알 수 있고, 친구가 누군지도 알 수 있다. 근데 그 다음은? 여기서 내가 가져오려는 정보는 내가 찍은 친구 정보(id, 순위)이다. 그러니 친구 컬렉션을 조회해서 내 친구들을 전부 불러와야 한다. 이 간단한 생각을 그땐 왜 그리 오래 걸렸는 지 모르겠다.
또한 하나의 문제가 더 생겼는데 친구 id로 랭킹 컬렉션을 조회하니 20개가 넘는 값이 넘어왔다. 분명 3명을 친구 추가했는데 20개? 말이 안되지 않는가? 사실 이 문제가 발생한 원인은 user_id값을 처음에 유니크하게 만들지 않았다. 그러다보니 중복되는 id값이 생겼고, 중복된 id를 삭제하지 않은 채 조회를 해서 문제가 발생된 것이였다.
위 문제는 말이 안되는 실수라고 생각할 수 도 있지만 개발하는 과정에서 이와 같은 비슷한 실수는 얼마든지 생길 수 있다고 생각한다. 위 경험을 통해서 소통의 중요성에 대해서 다시 한 번 체험하게 되었다.
def problem():
receivedToken = request.cookies.get('mytoken')
try:
payload = jwt.decode(receivedToken, SECRET_KEY, algorithms=['HS256'])
userinfo = db.user.find_one({"id": payload['id']})
return render_template('problems.html', idName=userinfo["id"], solvedProblems=userinfo["problemList"])
except jwt.ExpiredSignatureError:
return render_template('problems.html', idName="%", solvedProblems=[])
except jwt.exceptions.DecodeError:
return render_template('problems.html', idName="%", solvedProblems=[])
def solved():
id_receive = request.form['id_give']
number_receive = int(request.form['number_give'])
answer_receive=request.form['answer_give']
correct=answer_receive==answers[number_receive]
if(correct==True):
if(id_receive!="%"):
user_info = db.user.find_one({"id": id_receive})
user_info["problemList"][number_receive] = True
user_info["probSolvedCnt"] += 1
db.user.update_one({"id": id_receive}, {"$set": {"problemList": user_info["problemList"], "probSolvedCnt": user_info["probSolvedCnt"]}})
return jsonify({'result': 'success', 'msg':'correct'})
if(id_receive=="%"):
return jsonify({'result': 'success', 'msg':'correct'})
else:
return jsonify({'result': 'success', 'msg':'incorrect'})
@app.route('/api/comments', methods=['GET'])
def getComments():
# 이 코드는 특정한 문제에 작성된 댓글 목록을 가져오는 것이 목적입니다.
# probNum이라는 값을 프론트에서 가져올 예정입니다. 이 값은 프론트에서 문제 번호를 갖다 주면 됩니다.
probNum_receive = request.args.get('probNum')
# 문제 번호를 통해 댓글을 찾아봅니다. comments 라는 새로운 데이터베이스를 활용합니다.
searchResult = list(db.comments.find({"problemNum": probNum_receive}))
# searchResult는 테이블이고 각 레코드마다 _id, (user)id, contents, problemNum이 담깁니다.
# 이건 html 상에서 makeCard 만들던 내용을 참고하면 좋곘네요
# https://kraftonjungle.notion.site/Chapter-4-4ab9bb5d065048b596d21e8fd5e4b708
return jsonify({'result': 'success', 'list': searchResult})
@app.route('/api/comments', methods=['POST'])
def postComments():
# 이 코드는 특정한 문제에 댓글을 작성하기 위한 것 입니다.
# probNum이라는 값을 프론트에서 가져올 예정입니다. 이 값은 프론트에서 문제 번호를 갖다 주면 됩니다.
probNum_receive = request.form['probNum']
id_receive = request.form['whoPosting']
contents = request.form['contents']
db.comments.insert_one({'userID': id_receive, 'contents': contents, 'problemNum': probNum_receive})
return jsonify({'result': 'success'})
@app.route('/api/comments/delete', methods=['POST'])
def delComments():
# 이 코드는 특정한 문제에 댓글을 삭 제 하기위한 것 입니다.
db_id_receive = request.form['db_id']
id_receive = request.form['whoRequested']
delTarget = db.comments.delete_one({'_id': ObjectId(db_id_receive), 'userID':id_receive})
if delTarget.deleted_count > 0:
return jsonify({'result': 'success'})
else:
return jsonify({'result': 'failed'})
개발하면서 느낀 것은 하나의 코드를 작성하더라도 혼자할 때와 여럿이 할 때가 다르다. 문제를 보는 관점과 해결하는 방법에 대해서 여러 사람의 의견이 모이다 보면 더 좋은 의견이 나오는 경우가 많다. 또한 마감 시간에 따른 긴장감으로 인해 무조껀 돌아가는 프로그램을 만들기 위해서 억지 코드를 작성해본 경험도 흔히 해볼 수 없는 경험이였던 것 같다.