Python REST API | Flask/Django로 API 서버 만들기

이 글의 핵심

Flask로 메모리 기반 CRUD API를 만들고 Django REST Framework의 시리얼라이저·ViewSet·라우터로 같은 일을 줄여 봅니다. PUT에서 id까지 덮어쓰이는 문제, 인증 클래스만 설정하고 권한 클래스를 빠뜨려 API가 그대로 열려 있는 문제처럼 예제 코드에 숨은 함정을 함께 짚습니다.

들어가며

REST API는 프론트엔드와 백엔드를 연결하는 가장 흔한 방식입니다. 웹 프론트엔드, 모바일 앱, 다른 서버가 모두 같은 HTTP 규칙으로 데이터를 주고받을 수 있기 때문입니다. 이 글에서는 Flask로 CRUD API를 직접 만들어 구조를 이해한 뒤, Django REST Framework(DRF)가 같은 일을 얼마나 줄여 주는지 비교하고, 인증과 에러 응답까지 붙여 봅니다. Flask 자체의 기본 사용법은 Flask 기초에서 먼저 다뤘습니다.


RESTful 설계 원칙

RESTful 설계 원칙

리소스 중심 설계:
GET    /api/users       - 사용자 목록
GET    /api/users/1     - 사용자 1 조회
POST   /api/users       - 사용자 생성
PUT    /api/users/1     - 사용자 1 수정
DELETE /api/users/1     - 사용자 1 삭제

핵심은 URL은 무엇을(자원), HTTP 메서드는 어떻게(행동)를 나타낸다는 분리입니다. 같은 /api/users/1이라도 GET이면 조회, DELETE면 삭제입니다. 이렇게 하면 클라이언트 개발자가 엔드포인트 목록을 외우지 않아도 규칙만으로 API를 예측할 수 있습니다.

메서드마다 지켜야 할 성질도 있습니다. GET은 서버 상태를 바꾸지 않아야 하고(safe), PUT과 DELETE는 같은 요청을 여러 번 보내도 결과가 한 번 보낸 것과 같아야 합니다(멱등성). 네트워크 오류로 클라이언트가 재시도해도 안전하게 만들기 위한 약속입니다. POST는 멱등하지 않아서 재시도하면 중복 생성될 수 있고, 결제처럼 중복이 치명적인 API는 Idempotency-Key 헤더 같은 별도 장치를 둡니다. 부분 수정은 PUT(전체 교체) 대신 PATCH를 쓰는 것이 관례입니다.


Flask로 CRUD API 만들기

CRUD API 구현

아래 예제는 메모리에 있는 users 리스트를 JSON으로 주고받는 미니 API입니다. GET은 목록·단건 조회, POST는 새 항목 추가, PUT/DELETE는 수정·삭제에 대응합니다. 실제 서비스에서는 이 자리를 DB와 바꿉니다.

from flask import Flask, jsonify, request
app = Flask(__name__)
users = [
    {'id': 1, 'name': '철수', 'email': '[email protected]'},
    {'id': 2, 'name': '영희', 'email': '[email protected]'}
]
# 목록 조회 (GET)
@app.route('/api/users', methods=['GET'])
def get_users():
    return jsonify({'users': users, 'count': len(users)})
# 단일 조회 (GET)
@app.route('/api/users/<int:user_id>', methods=['GET'])
def get_user(user_id):
    user = next((u for u in users if u['id'] == user_id), None)
    if user:
        return jsonify(user)
    return jsonify({'error': 'User not found'}), 404
# 생성 (POST)
@app.route('/api/users', methods=['POST'])
def create_user():
    data = request.get_json()
    
    if not data or 'name' not in data or 'email' not in data:
        return jsonify({'error': 'Invalid data'}), 400
    
    new_user = {
        'id': max(u['id'] for u in users) + 1 if users else 1,
        'name': data['name'],
        'email': data['email']
    }
    users.append(new_user)
    return jsonify(new_user), 201
# 수정 (PUT)
@app.route('/api/users/<int:user_id>', methods=['PUT'])
def update_user(user_id):
    user = next((u for u in users if u['id'] == user_id), None)
    if not user:
        return jsonify({'error': 'User not found'}), 404
    
    data = request.get_json()
    user.update(data)
    return jsonify(user)
# 삭제 (DELETE)
@app.route('/api/users/<int:user_id>', methods=['DELETE'])
def delete_user(user_id):
    global users
    users = [u for u in users if u['id'] != user_id]
    return '', 204
if __name__ == '__main__':
    app.run(debug=True)

코드에서 눈여겨볼 부분을 순서대로 보겠습니다.

  • 목록 응답을 객체로 감싼 이유: get_users는 리스트를 바로 반환하지 않고 {'users': [...], 'count': 2} 형태로 돌려줍니다. 나중에 페이지 정보나 다음 페이지 링크를 추가할 때 응답 구조를 깨지 않고 필드만 늘릴 수 있습니다.
  • id 생성: max(...) + 1은 len(users) + 1보다 낫습니다. 중간 항목이 삭제되면 len 방식은 기존 id와 겹치기 때문입니다. 다만 동시에 두 요청이 들어오면 같은 id가 나올 수 있어서, 실제로는 DB의 자동 증가 키나 UUID에 맡깁니다.
  • 입력 검증: create_user는 data가 None이거나 필수 필드가 빠진 경우 400을 반환합니다. 검증 없이 data['name']을 꺼내면 KeyError가 500 오류로 이어지는데, 클라이언트 잘못을 서버 오류로 보고하는 셈이라 원인 파악이 어려워집니다.
  • 삭제 응답 204: 본문 없이 성공만 알리는 상태 코드입니다. 존재하지 않는 id를 삭제해도 204를 돌려주는데, “결과적으로 그 자원이 없다”는 상태는 같으므로 멱등성 관점에서 허용되는 선택입니다. 없는 자원 삭제를 오류로 알리고 싶다면 404를 반환하도록 바꿀 수 있습니다.

update_user에는 예제라서 남겨 둔 함정이 있습니다. user.update(data)는 요청 본문을 그대로 합치기 때문에 클라이언트가 {"id": 99}를 보내면 id가 바뀌고, {"is_admin": true}처럼 정의하지 않은 필드도 저장됩니다. 이런 문제를 대량 할당(mass assignment)이라고 부르며, 실제 API에서는 수정 가능한 필드 목록을 정해 두고 그 필드만 반영해야 합니다. 본문 없이 PUT을 보내면 data가 None이 되어 TypeError가 나는 것도 같은 방식으로 막습니다.

또 이 API는 데이터를 프로세스 메모리에 두므로 서버를 재시작하면 초기화되고, Gunicorn 워커를 여러 개 띄우면 워커마다 서로 다른 목록을 갖습니다. 처음 배포해 보면 “방금 만든 사용자가 조회할 때마다 있다 없다 한다”는 증상을 흔히 겪는데, 대부분 이 이유입니다. 저장소는 데이터베이스 연동에서 DB로 바꿉니다.


Django REST Framework의 시리얼라이저와 ViewSet

Flask 예제는 CRUD 다섯 개에 70줄 가까이 필요했고, 입력 검증과 필드 제한은 아직 제대로 하지도 않았습니다. DRF는 이 반복을 모델 정의에서 자동으로 만들어 줍니다. 대신 Django 프로젝트 구조와 ORM을 전제로 하므로, 이미 Django를 쓰는 서비스이거나 관리자 화면·인증이 함께 필요한 경우에 가장 효과가 큽니다.

설치

pip install djangorestframework

설치 후 settings.py의 INSTALLED_APPS에 'rest_framework'를 추가해야 합니다. 빠뜨리면 API 자체는 동작하지만 브라우저에서 접속했을 때 보이는 탐색용 HTML 화면(Browsable API)의 템플릿을 찾지 못해 TemplateDoesNotExist 오류가 납니다.

시리얼라이저

# blog/serializers.py
from rest_framework import serializers
from .models import Post
class PostSerializer(serializers.ModelSerializer):
    class Meta:
        model = Post
        fields = ['id', 'title', 'content', 'author', 'created_at']
        read_only_fields = ['id', 'created_at']

시리얼라이저는 두 방향의 변환을 맡습니다. 조회할 때는 모델 인스턴스를 JSON으로 바꾸고, 생성·수정할 때는 들어온 JSON을 검증한 뒤 모델에 저장합니다. fields에 나열한 필드만 입출력되고 read_only_fields는 응답에만 나오므로, Flask 예제의 대량 할당 문제가 구조적으로 막힙니다. 모델 필드 정의(max_length, null 여부 등)에서 검증 규칙도 자동으로 만들어집니다.

fields = '__all__'로 모든 필드를 열어 두는 방법도 있지만, 나중에 모델에 비밀번호 해시나 내부 메모 필드를 추가하면 API에 그대로 노출됩니다. 필드를 명시적으로 나열하는 쪽이 안전합니다.

ViewSet

# blog/views.py
from rest_framework import viewsets
from .models import Post
from .serializers import PostSerializer
class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

ModelViewSet은 목록, 단건 조회, 생성, 전체 수정(PUT), 부분 수정(PATCH), 삭제 여섯 가지 동작을 한 클래스로 제공합니다. 두 줄로 CRUD가 완성되는 대신, 모든 동작이 기본으로 열린다는 점을 기억해야 합니다. 조회만 허용하려면 ReadOnlyModelViewSet을 쓰고, 로그인한 사용자의 글만 보여 주려면 get_queryset()을 오버라이드해 request.user로 필터링합니다.

URL 라우팅

# blog/urls.py
from rest_framework.routers import DefaultRouter
from .views import PostViewSet
router = DefaultRouter()
router.register(r'posts', PostViewSet)
urlpatterns = router.urls

라우터는 ViewSet을 보고 /posts/와 /posts/<pk>/ URL을 자동으로 만들어 줍니다. DefaultRouter는 여기에 API 루트 페이지와 .json 같은 포맷 접미사까지 추가합니다. register에 queryset 속성이 없는 ViewSet을 등록하면 URL 이름을 정할 수 없어 basename 인자를 넘기라는 오류가 나므로, get_queryset()만 정의했다면 router.register(r'posts', PostViewSet, basename='post')처럼 적습니다.


JWT 인증과 권한

JWT 인증

pip install djangorestframework-simplejwt
# settings.py
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ],
}
# urls.py
from django.urls import path
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
)
urlpatterns = [
    path('api/token/', TokenObtainPairView.as_view()),
    path('api/token/refresh/', TokenRefreshView.as_view()),
]

/api/token/에 아이디와 비밀번호를 POST하면 짧게 유효한 access 토큰과 오래 유효한 refresh 토큰을 받습니다. 클라이언트는 이후 요청마다 Authorization: Bearer <access> 헤더를 붙이고, access가 만료되면 refresh로 새 access를 받습니다. 서버는 토큰 서명만 확인하면 되므로 세션 저장소가 필요 없다는 것이 JWT의 장점이고, 반대로 이미 발급한 토큰을 만료 전에 무효화하기 어렵다는 것이 단점입니다. access 토큰 수명을 짧게 두는 이유가 여기에 있습니다.

여기서 가장 많이 하는 실수는 인증(authentication)과 권한(permission)을 혼동하는 것입니다. 위 설정은 “요청에 토큰이 있으면 누구인지 확인한다”까지만 하고, DRF의 기본 권한은 AllowAny라서 토큰 없이 보낸 요청도 그대로 통과합니다. JWT를 설정했는데 로그인 없이 글이 써진다면 이 때문입니다. 'DEFAULT_PERMISSION_CLASSES': ['rest_framework.permissions.IsAuthenticated']를 함께 설정하거나, ViewSet에 permission_classes를 지정해야 합니다. 목록 조회는 공개하고 쓰기만 막으려면 IsAuthenticatedOrReadOnly가 맞습니다.


커스텀 에러 응답

커스텀 에러 응답

from flask import jsonify
@app.errorhandler(404)
def not_found(error):
    return jsonify({
        'error': 'Not Found',
        'message': '리소스를 찾을 수 없습니다'
    }), 404
@app.errorhandler(500)
def internal_error(error):
    return jsonify({
        'error': 'Internal Server Error',
        'message': '서버 오류가 발생했습니다'
    }), 500

Flask는 등록되지 않은 URL이나 처리되지 않은 예외를 HTML 오류 페이지로 응답합니다. 프론트엔드가 response.json()으로 파싱하다가 Unexpected token '<' 같은 오류를 내는 것이 전형적인 증상입니다. errorhandler를 등록하면 모든 오류가 같은 JSON 형태로 나가서 클라이언트가 한 가지 방식으로 처리할 수 있습니다.

500 핸들러의 메시지를 일부러 일반적으로 쓴 데에도 이유가 있습니다. 예외 메시지나 스택 트레이스를 응답에 그대로 넣으면 테이블 이름, 파일 경로 같은 내부 정보가 노출됩니다. 상세 내용은 서버 로그에 남기고, 응답에는 요청 id 정도만 넣어 로그와 연결하는 방식이 일반적입니다. 참고로 debug=True 상태에서는 500 핸들러 대신 디버거 화면이 먼저 나오므로, 핸들러 동작은 디버그 모드를 끄고 확인해야 합니다. 예외를 계층적으로 다루는 방법은 예외 처리를 참고하세요.


Flask API에 인증과 페이지네이션 붙이기

from flask import Flask, jsonify, request
from functools import wraps
app = Flask(__name__)
# 간단한 인증
API_KEY = "secret-key-123"
def require_api_key(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        api_key = request.headers.get('X-API-Key')
        if api_key != API_KEY:
            return jsonify({'error': 'Unauthorized'}), 401
        return f(*args, **kwargs)
    return decorated
# 데이터베이스 대신 메모리 사용
posts = []
@app.route('/api/posts', methods=['GET'])
def get_posts():
    page = request.args.get('page', 1, type=int)
    per_page = request.args.get('per_page', 10, type=int)
    
    start = (page - 1) * per_page
    end = start + per_page
    
    return jsonify({
        'posts': posts[start:end],
        'page': page,
        'total': len(posts)
    })
@app.route('/api/posts', methods=['POST'])
@require_api_key
def create_post():
    data = request.get_json()
    
    required_fields = ['title', 'content']
    if not all(field in data for field in required_fields):
        return jsonify({'error': 'Missing required fields'}), 400
    
    post = {
        'id': len(posts) + 1,
        'title': data['title'],
        'content': data['content'],
        'author': data.get('author', 'Anonymous')
    }
    posts.append(post)
    
    return jsonify(post), 201
if __name__ == '__main__':
    app.run(debug=True)

require_api_key는 뷰 함수를 감싸 헤더를 먼저 검사하는 데코레이터입니다. @wraps(f)가 없으면 모든 감싼 함수의 이름이 decorated가 되어, 두 번째 뷰에 데코레이터를 붙이는 순간 Flask가 “View function mapping is overwriting an existing endpoint function: decorated” 오류를 냅니다. 데코레이터 순서도 중요해서, @app.route가 가장 바깥(위)에 있어야 인증 검사가 포함된 함수가 라우트에 등록됩니다. 데코레이터의 동작 원리는 데코레이터에서 자세히 다룹니다.

request.args.get('page', 1, type=int)는 쿼리 문자열을 정수로 바꾸고, ?page=abc처럼 변환에 실패하면 예외 대신 기본값을 돌려줍니다. 다만 per_page=100000 같은 값도 그대로 받으므로 최대값을 정해 두는 편이 좋습니다. 오프셋 방식 페이지네이션은 구현이 쉽지만, 데이터가 계속 추가되는 목록에서는 페이지를 넘기는 사이 항목이 밀려 중복이나 누락이 생깁니다. 피드처럼 최신 항목이 계속 쌓이는 API라면 마지막으로 본 id 이후를 가져오는 커서 방식을 검토하세요.

API 키를 코드에 문자열로 적은 것은 예제용입니다. 실제로는 환경 변수에서 읽고, 비교할 때는 hmac.compare_digest()를 써서 응답 시간 차이로 키를 추측하는 타이밍 공격을 막습니다. 또 get_json()이 None을 반환하면 field in data에서 TypeError가 나므로 앞 절처럼 not data 검사를 먼저 두는 것이 안전합니다.


URL 설계와 HTTP 상태 코드 (REST 관례)

REST API는 주소(URL)만 봐도 어떤 자원인지 드러나게 짓는 것이 일반적입니다. 동사를 경로에 넣기보다 명사·복수형·계층으로 자원을 표현하며, 성공·실패는 HTTP 상태 코드로 구분하면 클라이언트가 응답을 처리하기 쉽습니다.

# ✅ 명사 사용, 동사 X
GET /api/users  # O
GET /api/getUsers  # X
# ✅ 복수형 사용
GET /api/users  # O
GET /api/user  # X
# ✅ 계층 구조
GET /api/users/1/posts  # 사용자 1의 포스트
# ✅ HTTP 상태 코드
200 OK          # 성공
201 Created     # 생성 성공
400 Bad Request # 잘못된 요청
401 Unauthorized # 인증 필요
404 Not Found   # 리소스 없음
500 Server Error # 서버 오류

상태 코드에서 자주 헷갈리는 것은 401과 403입니다. 401은 “누구인지 모르겠다”(토큰 없음, 만료)이고, 403은 “누구인지는 알지만 권한이 없다”입니다. 클라이언트는 401을 받으면 다시 로그인하거나 토큰을 갱신하고, 403을 받으면 재시도해도 소용없다는 것을 알 수 있습니다. 이 밖에 본문 없는 성공(204), 이메일 중복처럼 현재 상태와 충돌하는 요청(409), 형식은 맞지만 검증에 실패한 요청(422)도 자주 씁니다.

모든 응답을 200으로 보내고 본문에 {"success": false}를 넣는 API도 흔히 보이는데, 이렇게 하면 브라우저 개발자 도구, 모니터링, 캐시, 재시도 로직이 모두 성공으로 인식합니다. 오류는 상태 코드로 알리고, 본문에는 사람이 읽을 메시지와 기계가 구분할 오류 코드를 함께 넣는 편이 운영할 때 훨씬 편합니다.


REST API 요약

  1. REST: URL은 자원, 메서드는 행동, 결과는 상태 코드로 표현
  2. Flask: 구조를 직접 만들 수 있지만 검증·필드 제한도 직접 해야 함
  3. Django REST Framework: 시리얼라이저·ViewSet·라우터로 CRUD 자동화, 모든 동작이 기본 공개된다는 점 주의
  4. 인증: JWT, API Key, 인증 설정과 권한 설정은 별개
  5. 에러 처리: JSON 에러 핸들러, 내부 정보 노출 금지, 401과 403 구분

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Flask API에서 404나 500이 HTML 페이지로 응답되면 어떻게 JSON으로 바꾸나요?

A. Flask는 기본적으로 에러를 HTML 페이지로 돌려주므로, @app.errorhandler(404)와 @app.errorhandler(500)로 핸들러를 등록해 jsonify로 만든 응답과 상태 코드를 함께 반환해야 합니다. 이렇게 하면 클라이언트가 모든 응답을 같은 JSON 형식으로 처리할 수 있습니다. 성공 응답도 생성은 201, 잘못된 요청은 400처럼 상황에 맞는 HTTP 상태 코드를 쓰면 클라이언트 쪽 분기가 단순해집니다.