매일공부

Flask 첫 프로젝트에서 마주친 삽질들 본문

IT/작업기록

Flask 첫 프로젝트에서 마주친 삽질들

julian 2026. 8. 31. 13:26

이메일 캠페인 도구 개발기 시리즈 (4/9). 전체 개요

Django만 써보다가 처음으로 Flask로 사내 도구를 만들면서 겪은 실수들을 정리한다. 대부분 "왜 이게 문법 에러도 아닌데 안 되지"류의 삽질이라, 비슷한 상황에 참고가 될 것 같아 남긴다.

1. 들여쓰기 하나로 로직이 완전히 바뀐다

Django의 뷰 함수도 들여쓰기가 중요하지만, Flask에서 with conn.cursor() as cur: 블록 안에 if request.method == 'POST': 분기를 넣는 패턴을 쓰다 보니 들여쓰기 실수가 훨씬 치명적으로 드러났다.

# 버그 버전
with conn.cursor() as cur:
    if request.method == 'POST':
        ...저장 로직...
    conn.commit()              # if 블록 밖 — GET이어도 무조건 실행됨
    return redirect('/dashboard')  # 여기도 마찬가지

conn.commit()과 return이 if 블록과 같은 레벨에 있으면, GET으로 접속해도 무조건 리다이렉트되는 문제가 생긴다. 겉보기엔 "페이지가 안 뜨고 자꾸 다른 곳으로 튕긴다"는 증상이라, 원인을 들여쓰기에서 찾기까지 시간이 좀 걸렸다.

# 수정 버전
with conn.cursor() as cur:
    if request.method == 'POST':
        ...저장 로직...
        conn.commit()               # if 블록 안으로
        return redirect('/dashboard')  # if 블록 안으로

    cur.execute("SELECT ...")       # GET일 때만 실행되는 부분
    s = cur.fetchone()

교훈: 로직이 예상과 다르게 동작하는데 에러 메시지가 없다면, 콘솔에 각 분기가 실제로 타는지 print()를 찍어서 확인하는 게 빠르다. 눈으로 코드를 읽는 것보다 실행 경로를 직접 찍어보는 게 들여쓰기 버그를 훨씬 빨리 잡는다.

2. DictCursor는 컬럼명 대소문자를 그대로 따른다

MariaDB는 컬럼명 대소문자를 웬만하면 안 가리지만, PyMySQL의 DictCursor로 결과를 받으면 테이블에 실제 정의된 대소문자 그대로 딕셔너리 키가 생긴다.

-- 테이블 컬럼명이 NAME(대문자)으로 만들어져 있었음
cur.execute("SELECT * FROM company_types")
row = cur.fetchone()
row['name']   # KeyError 또는 None — 실제 키는 'NAME'

템플릿(Jinja2)에서도 {{ t.name }}으로 접근하면 조용히 빈 값만 나오고 에러가 안 떠서, 원인 파악이 더 어려웠다.

해결: 쿼리에서 별칭(AS)으로 항상 소문자로 통일해서 받는다.

cur.execute("SELECT id, NAME AS name FROM company_types")

교훈: 테이블 만들 때 컬럼명 대소문자를 처음부터 일관되게(전부 소문자 snake_case) 정해두면 이 문제 자체가 생기지 않는다. GUI 툴로 테이블을 만들 때 자동완성이나 실수로 대문자가 섞이기 쉬우니 주의.

3. 상대경로 링크는 현재 페이지 기준으로 이어붙는다

네비게이션 링크에서 맨 앞 /를 빠뜨리면:

<a href="settings/smtp">SMTP 설정</a>  <!-- / 없음 -->

이 링크를 /dashboard(루트에 가까운 경로)에서 누르면 우연히 잘 작동하는데, /campaign/new처럼 한 단계 더 깊은 경로에서 누르면 /campaign/settings/smtp로 이상하게 이어붙어서 404가 난다.

증상: 어떤 페이지에서는 링크가 되는데 다른 페이지에서는 같은 링크가 깨진다 — 이런 "페이지마다 다르게 작동"하는 증상이 보이면 상대/절대 경로 문제를 의심할 것.

해결: 내부 링크는 항상 /로 시작하는 절대경로로 쓴다.

<a href="/settings/smtp">SMTP 설정</a>

4. SMTP_SSL과 starttls()는 같이 쓰면 안 된다

메일 발송 코드를 짜면서 두 인증 방식을 섞어 쓴 적이 있다:

# 잘못된 조합
server = smtplib.SMTP_SSL(host, port)
server.starttls()   # 이미 SSL인데 또 TLS 업그레이드 시도 → 에러

SMTP_SSL(보통 465 포트)은 연결 시작부터 이미 암호화된 상태이고, SMTP + starttls()(보통 587 포트)는 평문으로 시작했다가 암호화로 전환하는 별개의 방식이다. 포트 번호에 맞는 방식 하나만 선택해야 한다.

# 587 포트
server = smtplib.SMTP(host, port)
server.starttls()

# 465 포트
server = smtplib.SMTP_SSL(host, port)

5. 로그인 계정과 발신 주소가 다른 메일 시스템

회사 메일 시스템이 "개인 계정으로 인증하고, 대표 메일링 주소로 발신"하는 구조였다. 이걸 코드로 옮길 때 핵심은 SMTP 인증(login)과 봉투 발신자(sendmail의 첫 인자), 헤더상의 발신자(msg['From'])가 각각 다른 값을 가질 수 있다는 점이었다.

server.login(personal_account, password)                 # 인증은 개인 계정
server.sendmail(group_alias_email, recipient, msg.as_string())  # 봉투 발신자는 그룹 별칭
msg['From'] = f"{display_name} <{group_alias_email}>"     # 헤더도 그룹 별칭

메일 서버가 그 개인 계정에 별칭으로 보낼 권한을 부여해뒀다면(회사 메일 시스템에서 흔한 설정), 이 조합이 그대로 작동한다. 클라이언트(아웃룩, 웹메일)로 하던 걸 그대로 SMTP 프로토콜 레벨에서 재현한 것뿐이라, 별도 API 없이도 처리 가능했다.

요약

Flask는 Django보다 자유도가 높은 만큼, 프레임워크가 대신 잡아주던 실수(URL 라우팅 규칙, ORM의 컬럼명 정규화 등)를 직접 신경 써야 하는 지점이 늘어난다. 특히 "에러 없이 조용히 잘못된 결과가 나오는" 유형(들여쓰기, 대소문자, 상대경로)은 처음엔 원인 파악에 시간이 걸리지만, 패턴을 한 번 겪고 나면 다음부턴 바로 의심할 수 있는 지점들이다.

Comments