개발자가 내팔자

[TIL] 미니 프로젝트가 끝났다 본문

STUDY

[TIL] 미니 프로젝트가 끝났다

야생의 개발자 2022. 8. 5. 02:52

들어가며

하루만에 기획을 우다다 완성하고, 3일간의 프로젝트 구현 시간이 주어졌다.

솔직히 미친 커리큘럼이라고 생각한다. 불만은 많지만 여기에 다 적진 않겠다.

애초에 여기에 온 목적은 뭔가 새로운 것을 배운다기 보다는 어떤 검증을 하고 싶었다.

퇴사하고 오랫동안 힘들어 했었는데, 프로젝트를 리딩하고,  원활하게 소통하며 협업해서

프로젝트를 성공적으로 마무리하는 경험을 통해 성취감을 얻고 싶었다.

 

입문자들에게는 힘겨운 시간이었을 것 같다. 들어온지 하루만에 다짜고짜 프로젝트를 만들라고 하니,

이게 다 무슨 소린가 싶을 것 같았다. 사실 나도 예전엔 그랬었으니까 그 마음이 너무 이해가 됐다.

그래서 예전의 나를 떠올리며 다른 사람들의 질문에 답변을 달아주고 다녔다.

종종 고맙단 말도 못 들어서 조금 허무하고 기분이 별로 안좋아지기도 했지만..

그래도 이제는 내가 누군가에게 도움을 받기만 하는 것이 아니라

남에게 알려줄 수도 있다는 것을 느끼게 되어 좋은 경험이 되었다.

 

 

 

프로젝트

프로젝트의 요구사항은 Flask, Jinja2, MongoDB를 이용하여

간단한 회원가입/로그인과 게시판 CRUD 기능을 구현하는 것 정도였다.

나도 예전에 처음으로 서버를 공부할 때 동일한 스택으로 프로젝트를 두 번정도 했었다.

그래서 만드는 것 자체는 어렵지 않았는데, 거기서 조금 더 욕심을 내고 싶었다.

한 번 하고 더 이상 건들지 않을 것 같은 프로젝트지만,

그렇다고 하더라도 좀 더 구조적으로 안정적이고,

가독성 있고 재사용 가능한 클린 코드를 만들고 싶었다.

 

그래서 환경설정을 하면서 아예 구조를 잡아버리고 시작을 했다.

처음 하는 분들은 SECRET_KEY와 DB 비밀번호를 그대로 노출한 채로 github에 올리고 있던데,

난 예전에 실수로 token을 노출했다가 돌이킬 수 없는 어떤 일을 경험한 적이 있어서 이런 부분에 굉장히 예민한 사람이 되었다.

그래서 python-dotenv를 이용하여 .env로 민감한 정보를 관리하게 만들었고,

PR template과 Issue Template, .gitignore를 셋팅해두었다.

그리고 watchdog이라는 패키지를 설치했다. 이게 진짜 사소한 거지만 중요한데,

코드 수정할 때마다 매번 껏켯 할 필요가 없어서 생산성이 극도로 올라간다.

 

 

 

app.py

그리고 대망의 app.py를 만들어야 하는데... 여기서부터 구조에 대한 고민을 하기 시작했다.

대부분 처음에 시작할 땐 그냥 별 생각 없이 이런 코드를 짜기 십상이다.

from flask import Flask
from pymongo import MongoClient

client = MongoClient('localhost', 27017)
db = client.test
app = Flask(__name__)

app.route('/')
def hello():
	return 'hello'

 

이 상태에서 파일을 분리하지 않고 app.py 하나에 모든 api를 다 때려넣는다.

그러면 스크롤이 길어지고... 보기가 점점 힘들어진다.

 

하지만 나는 별로 그렇게 하고 싶지 않았다.

아무리 작은 프로젝트를 하더라도 좀 더 구조적으로 안정적이고 협업하기 수월한 구조를 만들고 싶었다.

혹시나 나중에 "아 예전에 이런 프로젝트를 했었지~ 심심한데 여기에 테스트 코드를 붙여볼까?" 라는 생각이 들 때

막상 코드를 까보니 너무 더러워서 손도 못 쓰고 그냥 private repo로 돌리게 되는 그런 슬픈 불상사가 일어나지 않길 바라기 때문이다.

 

개발자의 시간은 귀중하다. 나의 시간은 매우 비싼 자원이다.

나는 내가 이렇게 시간과 에너지를 들여 무언가를 했으면 뭐라도 남아야 한다고 생각한다.

무릇 '호랑이는 가죽을 남기고 개발자는 코드를 남긴다'고 했다.

나도 의미있는 코드를 남기고 싶다. 그게 나 자신에게만 의미가 있다고 하더라도.

 

이런 사유로 나는 __init__.py에 create_app() 이라는 메서드를 만들었다.

 

import os
from flask import Flask
from dotenv import load_dotenv

from app.db import get_db
from app.middleware.load_logged_in_user import load_logged_in_user

load_dotenv(verbose=True)


def create_app(test_config=None):
    app = Flask(__name__, instance_relative_config=True)

    app.config.from_mapping(
        SECRET_KEY=os.getenv('SECRET_KEY'),
    )

    if test_config is None:
        # load the instance config, if it exists, when not testing
        app.config.from_pyfile('config.py', silent=True)
    else:
        # load the test config if passed in
        app.config.from_mapping(test_config)

    # ensure the instance folder exists
    try:
        os.makedirs(app.instance_path)
    except OSError:
        pass

    from .middleware import load_logged_in_user
    app.register_blueprint(load_logged_in_user.bp)

    from .controller import test
    app.register_blueprint(test.bp)

    from .controller import home
    app.register_blueprint(home.bp)

    from .controller import posts
    app.register_blueprint(posts.bp)

    from .controller import account
    app.register_blueprint(account.bp)

    app.debug = True
    return app

사실 공식문서 튜토리얼만 봐도 나오는 내용이긴 하다.

app을 반환하는 factory 함수를 만들어서 여러가지 설정을 그 안에서 해주고 있다.

이렇게 하면 테스트를 돌릴 때에도 이 함수를 이용할 수 있어서 재사용성을 고려한 좋은 패턴인 것 같다.

 

 

 

directory structure

디렉토리 구조는 아래와 같이 잡았는데, controller 단에서 로직을 대부분 처리하는 것이 조금 찝찝했지만, 공식 문서에서 flask는 glue layer라고 하기도 했고, service layer와 repository layer를 또 나누자니 그건 너무 over engineering이 아닌가 하는 생각이 들어 controller 하나로 만족했다.

📦 app
 ┣ 📂 controller
 ┣ 📂 decorators
 ┣ 📂 middleware
 ┣ 📂 static
 ┃ ┣ 📂 css
 ┃ ┣ 📂 image
 ┃ ┣ 📂 js
 ┣ 📂 templates
 ┃ ┣ 📂 account
 ┃ ┣ 📂 components
 ┃ ┣ 📂 error
 ┃ ┣ 📂 post
 ┣ 📜 __init__.py
 ┣ 📜 constants.py
 ┣ 📜 db.py
 ┗ 📜 utils.py

 

 

db.py

여기에서 db.py를 보면 아래와 같은 코드를 짰는데, 이것도 사실 공식문서를 보면서 알게 된 코드이다.

Flask 공식문서는 못생겼지만 굉장히 친절하고 자세하게 설명을 해줘서 읽는 재미가 있다.

import os
from flask import g

from pymongo import MongoClient
from dotenv import load_dotenv

load_dotenv(verbose=True)


def get_db():
    if 'db' not in g:
        client = MongoClient(os.getenv('DATABASE'))
        g.db = client.test

    return g.db

이 때 g가 뭔지에 대해 공부하다가 nodejs의 cls와 비슷한 스레드 로컬 저장소 같은 개념이라는 거라고 어렴풋이 알게 되었다.

공식 문서에 따르면 각각의 request 고유의 객체라고는 하는데... 

아직 명확하게 그림이 그려지진 않지만 완전한 전역은 아니면서도 request마다 격리되는 고유한 어떤 저장소 정도로 지금은 인식하고 있다.

flask에서 제공하는 g 덕분에 같은 요청 내에서라면 언제 어디서 get_db를 호출하더라도 매번 새로 연결하지 않고 재사용을 할 수 있다.

그리고 여기에서도 dotenv를 활용하여 .env에 있는 주소를 가져왔다. 거기에 비밀번호가 있어서 노출시키고 싶지 않았기 때문이다.

 

 

middleware

import os

import jwt
from bson import ObjectId
from flask import request, g, Blueprint

from app.db import get_db


bp = Blueprint('load_logged_in_user', __name__)

@bp.before_app_request
def load_logged_in_user():
    token = request.cookies.get('token')

    try:
        payload = jwt.decode(token, os.getenv('SECRET_KEY'), algorithms=['HS256'])
        user_id = payload.get('_id')

        if not user_id:
            raise Exception('토큰 정보가 잘못되었습니다.')

        db = get_db()
        g.user = db.user.find_one({'_id': ObjectId(user_id)})

    except:
        print("Error!")

middleware에는 이런 함수를 만들어놨는데, request의 cookie를 뒤져서 'token'이라는 key로 value를 가져와 decode를 한 뒤, 거기에 담겨있는 _id로 db에 접근하여 user 객체를 g에 바인딩하는 과정을 작성한 것이다. 여기서 before_app_request라는 메서드가 재밌는데, request가 실행될 때마다 항상 발빠르게 움직여 먼저 실행되게 만들어주는 녀석이다. 이 과정이 필수적으로 있어야 나중에 후술할 login_required같은 데코레이터를 적용할 때 그 안에서 로그인을 했는지 아닌지 검증할 수 있다. (물론 다른 방법도 있겠지만 여기서는 그렇게 구현했다.)

 

사실 이 함수를 구현하면서 아쉬움이 좀 남았었는데, 저기서 날 수 있는 에러가 꽤 다양하고 많은데 나는 그걸 일일이 명시하지 않고 그냥 except만 대충 해버리고 그마저도 그냥 print("Error!")로 퉁쳐버렸다는 점이다. 원래라면 에러를 좀더 세밀하게 관리해야 하는데 사실 이 부분은 좀 더 고민이 필요할 것 같다고 느꼈다. 나중에 리팩토링을 한다면 저 except가 일어날 때마다 로그를 남겨서 로깅하는 코드를 추가해보고 싶다.

 

 

decorators

from functools import wraps
from flask import g, redirect, url_for


def login_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if not g.get('user'):
            return redirect(url_for('account.signin'))
        return f(*args, **kwargs)
    return decorated_function
Footer

나는 회원가입/로그인 기능을 맡았고, 다른 팀원들은 웰컴페이지와 메인화면, 게시판 CRUD 등을 맡아서 했는데 로그인이 필요한 부분을 맡으신 분들이 나중에 합칠 것을 굉장히 두려워하셨다. 아마도 뭔진 모르겠지만 엄청난 코드 수정이 있을 거란 두려움 때문이었을까? 관심사의 분리는 이래서 중요한 것 같다. 함수는 하나의 기능만을 해야 한다. 그리고 객체는 변경의 이유가 오직 하나여야 한다. 나는 팀원들의 두려움을 덜어주기 위해, 관심사를 분리하기로 했고, 그래서 데코레이터를 만들게 되었다. 덕분에 팀원들은 저 데코레이터 하나로 로그인이 필요한 곳에는 @login_required 한 줄만 붙이면 된다는 것을 알게 되었다.

 

근데 사실 이것도 내가 세상에 없던 것을 발명해낸 것이 아니라 그냥 공식문서 가면 다 나와있다. 거기서 중간에 조건문 생긴 게 마음에 안들어서 조금 고친 것 뿐이다. if 뭐뭐 is None : 이런 식의 코드는 안티패턴으로 알고 있고, .get()을 쓰지 않고 바로 접근하면 에러가 날 수 있기 때문이다. 이 프로젝트를 하며 많은 사람들이 도대체 .get()이 뭐냐고, 이게 무슨 차이냐고 물어봤는데, .get()은 key값으로 불러왔을 때 값이 없으면 None을 return하기 때문에 에러 처리에서 조금 더 자유로워질 수 있다.

 

 

utils.py

from flask import make_response, url_for, redirect, Response


def make_redirect(controller: str) -> Response:
    return make_response(redirect(url_for(controller)))

이건 그냥 helper 함수인데, return에서 보다싶이 함수의 중첩이 너무 더러운 것 같아 내가 그냥 만들었다.

 

 

 

signin.py

    payload = {
        '_id': str(user['_id']),
        'exp': datetime.utcnow() + timedelta(hours=EXPIRE_TIME)
    }

    token = jwt.encode(payload, os.getenv('SECRET_KEY'), algorithm='HS256')

    response = make_redirect('home.main')
    response.set_cookie(key='token', value=token)
    return response

로그인 api의 일부분인데 여기서 cookie를 설정하는 부분을 의외로 다들 몰라서 삽질을 하고 있었다. make_response라는 메서드를 이용하면 response를 만들어 내려줄 수 있는데, 거기에 set_cookie로 쿠키를 살짝 얹어주면 알아서 브라우저로 내려가는 게 정말 신기했다.

 

 

signup.py

    reg = '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$'
    if not re.search(reg, password):
        flash('비밀번호 형식이 올바르지 않습니다. (영문/숫자포함 8자이상)')
        return render_template('account/signup.html')

    hashed_password = hashlib.sha256(password.encode('utf-8')).hexdigest()

    db.user.insert_one({"email": email, "password": hashed_password, "nickname": nickname})

회원가입 api의 일부분인데 정규표현식 앞에서 또 정신이 아득해졌다. 언젠가는 넘어야 할 산이지만 지금 별로 하고 싶진 않은...

하지만 언젠가는 꼭 정복하고 말거다. password를 해싱하는 부분도 보안상 중요하기 때문에 필수적으로 알아야 한다.

 

 

signout.py

def api_signout():
    response = make_redirect('home.main')
    response.delete_cookie('token')
    return response

 

로그아웃할 때, 막연히 아래와 같이 request의 cookie를 지우면 되겠지 라고 했었는데, request.cookie는 Immutable 객체라 지울 수 없다는 에러가 나왔다. 그래서 response를 만들고, 거기서 delete_cookie라는 메서드를 이용했다.

request.cookies.clear()

 

그 외 놀라웠던 점들

flask run host=0.0.0.0

그냥 flask run 해버리면 외부에서 접근이 불가능한데, 저렇게 host를 0.0.0.0으로 따로 지정해줘야 외부에서 접근이 가능해진다. 이건 네트워크에 대해 좀 더 공부를 해봐야 정확한 원인을 설명할 수 있을 것 같다.

 

.env 파일을 filezilla로 전송하니 점 하나가 빠지면서 env로 변환되어 삽질을 좀 했다. (당연히 그대로 전송될거라 생각했기 때문). 원래는 vim으로 그냥 그자리에서 복붙해서 만들어버리는데, 팀원분이 filezilla를 더 편하게 쓰시는 것 같아서 그렇게 됐다. 이름을 filezilla 쪽에서 변환을 해버린 것인지, 원래 파일 전송 프로토콜에서는 숨김파일이 이렇게 되는 것인지, 그런 것들이 궁금해져서 내일 실험해볼 예정이다.

 

그리고 팀원분 중 하나가 git push를 하는 도중에 recursive 에러가 났었는데, 난 이런 에러를 처음봐서 그냥 일단 강제 푸시를 하고 넘어갔는데, 도대체 이게 원인이 뭔지는 좀 더 공부를 해봐야 할 것 같다.

 

그리고 localhost:5000으로 접속하면 안되는데 127.0.0.1:5000으로는 된다거나, 그마저도 안되고 0.0.0.0:5000으로 접속하니 되는 경우가 있었다. 이 부분도 네트워크 쪽을 좀 더 공부해봐야 할 것 같다.

 

https://velog.io/@lky9303/127.0.0.1-%EA%B3%BC-localhost%EC%9D%98-%EC%B0%A8%EC%9D%B4

 

127.0.0.1 과 localhost의 차이

우리는 내부적으로 서버를 구축하고 백엔드 서버를 개발할 때 흔히들 localhost:3000 을 사용하곤 한다.그리고 localhost:3000 과 이것의 ip주소인 127.0.0.1:3000 을 브라우져 창에 입력하면 같은 결과가 나

velog.io

 

 

 

문서화의 흔적들

프로젝트를 하며 문서화한 노션들과 더 공부하고 싶은 부분들
wiki를 활용하여 문서화

 

 

팀 내 기여도

팀 내 기여도

 

 

 

소스 코드

 

https://github.com/innovation-camp/heonchaegjulge

 

GitHub - innovation-camp/heonchaegjulge

Contribute to innovation-camp/heonchaegjulge development by creating an account on GitHub.

github.com

 

 

 

 

 

 

 

 

 

 

 

'STUDY' 카테고리의 다른 글

[회고] 면접 후기  (0) 2022.08.25
[WIL] ruby on rails로 기술 과제하기  (0) 2022.08.14
[TIL] 프로젝트를 시작하며  (0) 2022.08.02
[InnovationCamp] 첫 번째 프로젝트  (0) 2022.08.01
[회고] 2020 - 2021 그동안 본 강의들  (0) 2022.07.25
Comments