포스트

[Python 100일 챌린지] Day 56 - RESTful API 실전

[Python 100일 챌린지] Day 56 - RESTful API 실전

어제는 간단한 API를 만들었다면, 오늘은 “제대로” 만들어봅시다! 😊

POST /api/users → 생성, GET /api/users → 조회, PUT → 수정, DELETE → 삭제! CRUD 4가지 작업을 HTTP 메서드로 깔끔하게 구현하는 RESTful 원칙을 배워요!

(40-50분 완독 ⭐⭐⭐)

🎯 오늘의 학습 목표

📚 사전 지식


🎯 학습 목표 1: REST API 개념 이해하기

1.1 REST란?

REST (Representational State Transfer)는 웹 API를 설계하는 규칙(아키텍처 스타일)이에요.

💡 쉽게 말하면: REST는 “API를 이렇게 만들면 좋아요”라는 모범 사례 모음집이에요. URL은 명사(리소스)로, 동작은 HTTP 메서드(GET, POST 등)로 표현하는 게 핵심!

REST 6가지 제약 조건:

제약 조건 설명 예시
Client-Server 클라이언트와 서버 분리 프론트엔드 ↔ 백엔드
Stateless 무상태 (각 요청 독립적) 모든 요청에 인증 정보 포함
Cacheable 캐시 가능 GET 응답은 캐시 가능
Uniform Interface 일관된 인터페이스 모든 리소스에 동일한 규칙 적용
Layered System 계층화된 시스템 로드 밸런서, 캐시 서버 등
Code on Demand (선택) 실행 가능한 코드 전송 JavaScript 전송

1.2 RESTful API vs 일반 API

두 스타일의 차이를 비교해볼까요?

1
2
3
4
5
6
7
8
9
10
11
# ❌ 일반 API (RPC 스타일) - URL에 동사가 들어감
GET  /getUserById?id=123     # 사용자 조회
POST /createUser             # 사용자 생성
POST /updateUser             # 사용자 수정
POST /deleteUser?id=123      # 사용자 삭제

# ✅ RESTful API (자원 중심) - URL은 명사, 동작은 HTTP 메서드로!
GET    /users/123      # 조회 (GET = 읽기)
POST   /users          # 생성 (POST = 새로 만들기)
PUT    /users/123      # 수정 (PUT = 전체 교체)
DELETE /users/123      # 삭제 (DELETE = 삭제)

💡 핵심 차이: RESTful API는 URL에 동사를 쓰지 않아요! “무엇을(URL)”과 “어떻게(HTTP 메서드)”를 분리합니다.

1.3 REST API 성숙도 모델 (Richardson Maturity Model)

REST를 얼마나 잘 따르는지 4단계로 나눈 모델이에요. Level 2만 달성해도 충분히 RESTful합니다!

Level 0: 단일 엔드포인트 (RPC)

1
POST /api

Level 1: 리소스 기반 URL

1
2
POST /users
POST /posts

Level 2: HTTP 메서드 활용

1
2
3
4
GET    /users
POST   /users
PUT    /users/123
DELETE /users/123

Level 3: HATEOAS (Hypermedia)

1
2
3
4
5
6
7
8
{
  "id": 123,
  "name": "Alice",
  "_links": {
    "self": "/users/123",
    "posts": "/users/123/posts"
  }
}

🎯 학습 목표 2: RESTful 원칙 적용하기

2.1 완전한 CRUD API 구현

Day 55에서 배운 Flask 기초를 바탕으로, 이번에는 완전한 사용자 관리 API를 만들어봅시다!

⚠️ 실행 전 확인: pip install flask 설치와 Day 55의 Flask 기초를 복습하세요!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
from flask import Flask, request, jsonify
from datetime import datetime

app = Flask(__name__)

# In-memory 데이터 저장소
users = {}
next_id = 1

# ==================== CREATE ====================
@app.route('/api/users', methods=['POST'])
def create_user():
    """새 사용자 생성"""
    global next_id

    data = request.get_json()

    # 유효성 검사
    if not data:
        return jsonify({'error': 'No data provided'}), 400

    required_fields = ['name', 'email']
    for field in required_fields:
        if field not in data:
            return jsonify({'error': f'{field} is required'}), 400

    # 이메일 중복 체크
    if any(u['email'] == data['email'] for u in users.values()):
        return jsonify({'error': 'Email already exists'}), 409

    # 새 사용자 생성
    user = {
        'id': next_id,
        'name': data['name'],
        'email': data['email'],
        'age': data.get('age'),
        'created_at': datetime.now().isoformat(),
        'updated_at': datetime.now().isoformat()
    }

    users[next_id] = user
    next_id += 1

    # 201 Created + Location 헤더
    return jsonify(user), 201, {'Location': f'/api/users/{user["id"]}'}

# ==================== READ (Collection) ====================
@app.route('/api/users', methods=['GET'])
def get_users():
    """모든 사용자 조회 (필터링, 정렬, 페이지네이션)"""
    # 쿼리 파라미터
    page = request.args.get('page', 1, type=int)
    limit = request.args.get('limit', 10, type=int)
    sort_by = request.args.get('sort', 'id')
    order = request.args.get('order', 'asc')

    # 필터링
    name_filter = request.args.get('name', '').lower()
    min_age = request.args.get('min_age', type=int)
    max_age = request.args.get('max_age', type=int)

    # 사용자 목록
    user_list = list(users.values())

    # 필터 적용
    if name_filter:
        user_list = [u for u in user_list if name_filter in u['name'].lower()]

    if min_age:
        user_list = [u for u in user_list if u.get('age', 0) >= min_age]

    if max_age:
        user_list = [u for u in user_list if u.get('age', 999) <= max_age]

    # 정렬
    reverse = (order == 'desc')
    user_list.sort(key=lambda x: x.get(sort_by, 0), reverse=reverse)

    # 페이지네이션
    total = len(user_list)
    start = (page - 1) * limit
    end = start + limit
    paginated = user_list[start:end]

    return jsonify({
        'data': paginated,
        'pagination': {
            'page': page,
            'limit': limit,
            'total': total,
            'pages': (total + limit - 1) // limit
        }
    })

# ==================== READ (Single) ====================
@app.route('/api/users/<int:user_id>', methods=['GET'])
def get_user(user_id):
    """특정 사용자 조회"""
    user = users.get(user_id)

    if not user:
        return jsonify({'error': 'User not found'}), 404

    return jsonify(user)

# ==================== UPDATE (Full) ====================
@app.route('/api/users/<int:user_id>', methods=['PUT'])
def update_user(user_id):
    """사용자 전체 수정 (PUT)"""
    user = users.get(user_id)

    if not user:
        return jsonify({'error': 'User not found'}), 404

    data = request.get_json()

    if not data:
        return jsonify({'error': 'No data provided'}), 400

    # 필수 필드 검사
    required_fields = ['name', 'email']
    for field in required_fields:
        if field not in data:
            return jsonify({'error': f'{field} is required'}), 400

    # 이메일 중복 체크 (자신 제외)
    if any(u['email'] == data['email'] and u['id'] != user_id
           for u in users.values()):
        return jsonify({'error': 'Email already exists'}), 409

    # 전체 업데이트
    user.update({
        'name': data['name'],
        'email': data['email'],
        'age': data.get('age'),
        'updated_at': datetime.now().isoformat()
    })

    return jsonify(user)

# ==================== UPDATE (Partial) ====================
@app.route('/api/users/<int:user_id>', methods=['PATCH'])
def patch_user(user_id):
    """사용자 부분 수정 (PATCH)"""
    user = users.get(user_id)

    if not user:
        return jsonify({'error': 'User not found'}), 404

    data = request.get_json()

    if not data:
        return jsonify({'error': 'No data provided'}), 400

    # 이메일 중복 체크
    if 'email' in data:
        if any(u['email'] == data['email'] and u['id'] != user_id
               for u in users.values()):
            return jsonify({'error': 'Email already exists'}), 409

    # 제공된 필드만 업데이트
    allowed_fields = ['name', 'email', 'age']
    for field in allowed_fields:
        if field in data:
            user[field] = data[field]

    user['updated_at'] = datetime.now().isoformat()

    return jsonify(user)

# ==================== DELETE ====================
@app.route('/api/users/<int:user_id>', methods=['DELETE'])
def delete_user(user_id):
    """사용자 삭제"""
    if user_id not in users:
        return jsonify({'error': 'User not found'}), 404

    del users[user_id]

    # 204 No Content (성공, 응답 본문 없음)
    return '', 204

if __name__ == '__main__':
    app.run(debug=True)

2.2 CORS 처리

💡 CORS(Cross-Origin Resource Sharing)란?: 웹 브라우저의 보안 정책이에요. 예를 들어 http://localhost:3000(React 앱)에서 http://localhost:5000(Flask API)으로 요청하면 기본적으로 차단됩니다. 이를 허용하려면 CORS 설정이 필요해요!

프론트엔드와 API 서버가 다른 도메인일 때 필요합니다.

1
2
# flask-cors 설치
pip install flask-cors
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from flask import Flask
from flask_cors import CORS

app = Flask(__name__)

# 모든 도메인 허용 (개발용)
CORS(app)

# 특정 도메인만 허용 (프로덕션)
CORS(app, origins=['https://myapp.com'])

# 또는 수동으로
@app.after_request
def after_request(response):
    response.headers.add('Access-Control-Allow-Origin', '*')
    response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization')
    response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,PATCH')
    return response

🎯 학습 목표 3: 리소스 중심 설계하기

3.1 중첩 리소스 (Nested Resources)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# 게시글 데이터
posts = {}
next_post_id = 1

# 사용자의 모든 게시글
@app.route('/api/users/<int:user_id>/posts', methods=['GET'])
def get_user_posts(user_id):
    """특정 사용자의 모든 게시글"""
    if user_id not in users:
        return jsonify({'error': 'User not found'}), 404

    # 해당 사용자의 게시글 필터링
    user_posts = [p for p in posts.values() if p['user_id'] == user_id]

    return jsonify({
        'user_id': user_id,
        'posts': user_posts,
        'count': len(user_posts)
    })

# 사용자의 새 게시글 생성
@app.route('/api/users/<int:user_id>/posts', methods=['POST'])
def create_user_post(user_id):
    """특정 사용자의 게시글 생성"""
    global next_post_id

    if user_id not in users:
        return jsonify({'error': 'User not found'}), 404

    data = request.get_json()

    if not data or 'title' not in data or 'content' not in data:
        return jsonify({'error': 'Title and content required'}), 400

    post = {
        'id': next_post_id,
        'user_id': user_id,
        'title': data['title'],
        'content': data['content'],
        'created_at': datetime.now().isoformat()
    }

    posts[next_post_id] = post
    next_post_id += 1

    return jsonify(post), 201

# 특정 게시글 조회
@app.route('/api/users/<int:user_id>/posts/<int:post_id>', methods=['GET'])
def get_user_post(user_id, post_id):
    """특정 사용자의 특정 게시글"""
    if user_id not in users:
        return jsonify({'error': 'User not found'}), 404

    post = posts.get(post_id)

    if not post or post['user_id'] != user_id:
        return jsonify({'error': 'Post not found'}), 404

    return jsonify(post)

3.2 리소스 관계 표현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 1. 중첩 (Nested) - 강한 의존성
GET /users/123/posts         # 사용자의 게시글

# 2. 쿼리 파라미터 - 약한 의존성
GET /posts?user_id=123       # 사용자 필터링

# 3. 별도 엔드포인트 + 링크
GET /users/123
{
  "id": 123,
  "name": "Alice",
  "_links": {
    "posts": "/users/123/posts"
  }
}

🎯 학습 목표 4: HTTP 메서드 올바르게 사용하기

4.1 멱등성 (Idempotency)

멱등성: 같은 요청을 여러 번 해도 결과가 동일

💡 왜 중요한가요?: 네트워크 오류로 요청이 중복 전송될 수 있어요. 멱등한 메서드(PUT, DELETE)는 여러 번 호출해도 안전하지만, POST는 매번 새 데이터가 생성되므로 주의해야 해요!

메서드 멱등성 안전성 설명
GET 조회만 하므로 멱등 + 안전
POST 매번 새 리소스 생성
PUT 전체 교체, 같은 결과
PATCH 구현에 따라 다름
DELETE 이미 삭제돼도 결과 동일

예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# POST - 멱등하지 않음 (매번 새 ID)
POST /users
 {"id": 1, "name": "Alice"}
POST /users
 {"id": 2, "name": "Alice"}

# PUT - 멱등함 (같은 결과)
PUT /users/123 {"name": "Bob"}
 {"id": 123, "name": "Bob"}
PUT /users/123 {"name": "Bob"}
 {"id": 123, "name": "Bob"}  # 동일

# DELETE - 멱등함
DELETE /users/123  204 No Content
DELETE /users/123  404 Not Found (하지만 결과는 동일: 리소스 없음)

4.2 응답 코드 가이드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 성공 응답
@app.route('/api/users', methods=['GET'])
def get_users():
    return jsonify(users_list), 200  # OK

@app.route('/api/users', methods=['POST'])
def create_user():
    # 생성 후
    return jsonify(new_user), 201  # Created

@app.route('/api/users/<int:user_id>', methods=['DELETE'])
def delete_user(user_id):
    # 삭제 후
    return '', 204  # No Content

# 클라이언트 오류
@app.route('/api/users', methods=['POST'])
def create_user():
    if not valid:
        return jsonify({'error': 'Invalid data'}), 400  # Bad Request

    if exists:
        return jsonify({'error': 'Already exists'}), 409  # Conflict

@app.route('/api/users/<int:user_id>', methods=['GET'])
def get_user(user_id):
    if not found:
        return jsonify({'error': 'Not found'}), 404  # Not Found

# 서버 오류
@app.errorhandler(500)
def internal_error(error):
    return jsonify({'error': 'Internal server error'}), 500

4.3 Content Negotiation

💡 Content Negotiation이란?: 클라이언트가 원하는 응답 형식(JSON, XML 등)을 요청 헤더로 알려주면, 서버가 그에 맞게 응답하는 방식이에요. 대부분의 API는 JSON만 지원하므로, 이 부분은 참고로만 알아두세요!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from flask import request, jsonify

@app.route('/api/users/<int:user_id>')
def get_user(user_id):
    user = users.get(user_id)

    if not user:
        return jsonify({'error': 'Not found'}), 404

    # Accept 헤더 확인
    accept = request.headers.get('Accept', 'application/json')

    if 'application/json' in accept:
        return jsonify(user)
    elif 'application/xml' in accept:
        # XML 응답 (예시)
        xml = f"<user><id>{user['id']}</id><name>{user['name']}</name></user>"
        return xml, 200, {'Content-Type': 'application/xml'}
    else:
        return jsonify({'error': 'Unsupported media type'}), 415

💡 실전 팁 & 주의사항

✅ DO: 이렇게 하세요

  1. 명확한 상태 코드 사용
    1
    2
    3
    
    return jsonify(user), 201  # 생성
    return '', 204             # 삭제
    return jsonify(error), 404 # 없음
    
  2. 일관된 응답 형식
    1
    2
    3
    4
    5
    
    # 성공
    {"data": {...}}
    
    # 에러
    {"error": "message"}
    
  3. Location 헤더 제공
    1
    
    return jsonify(user), 201, {'Location': f'/api/users/{user_id}'}
    
  4. 유효성 검사 철저히
    1
    2
    
    if not data or 'email' not in data:
        return jsonify({'error': 'Email required'}), 400
    

❌ DON’T: 이러지 마세요

  1. POST로 모든 작업
    • 각 작업에 맞는 메서드 사용
  2. 200만 반환
    • 생성 시 201, 삭제 시 204 사용
  3. 에러 시 HTML 반환
    • 항상 JSON으로 일관성 유지

🧪 연습 문제

💡 실습 팁: 2.1절의 User CRUD 코드를 참고하면서 직접 작성해보세요!

문제: TODO API 구현

완전한 TODO 관리 API를 구현하세요.

요구사항:

  • CRUD 모두 구현
  • 완료 상태 토글 (PATCH /todos/123/toggle)
  • 완료/미완료 필터링
  • 우선순위 정렬
  • 적절한 상태 코드
💡 힌트
  1. todos = {} 딕셔너리로 데이터 저장
  2. completed 필드는 True/False로 관리
  3. 필터링: request.args.get('completed')로 쿼리 파라미터 받기
  4. 토글: todo['completed'] = not todo['completed']
✅ 정답
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
from flask import Flask, request, jsonify
from datetime import datetime

app = Flask(__name__)

todos = {}
next_id = 1

# CREATE
@app.route('/api/todos', methods=['POST'])
def create_todo():
    global next_id
    data = request.get_json()

    if not data or 'title' not in data:
        return jsonify({'error': 'Title required'}), 400

    todo = {
        'id': next_id,
        'title': data['title'],
        'completed': False,
        'priority': data.get('priority', 'medium'),
        'created_at': datetime.now().isoformat()
    }

    todos[next_id] = todo
    next_id += 1

    return jsonify(todo), 201

# READ ALL
@app.route('/api/todos', methods=['GET'])
def get_todos():
    completed = request.args.get('completed')
    priority = request.args.get('priority')

    todo_list = list(todos.values())

    # 필터링
    if completed is not None:
        is_completed = completed.lower() == 'true'
        todo_list = [t for t in todo_list if t['completed'] == is_completed]

    if priority:
        todo_list = [t for t in todo_list if t['priority'] == priority]

    # 정렬 (우선순위: high > medium > low)
    priority_order = {'high': 0, 'medium': 1, 'low': 2}
    todo_list.sort(key=lambda x: priority_order.get(x['priority'], 1))

    return jsonify(todo_list)

# READ ONE
@app.route('/api/todos/<int:todo_id>', methods=['GET'])
def get_todo(todo_id):
    todo = todos.get(todo_id)
    if not todo:
        return jsonify({'error': 'Not found'}), 404
    return jsonify(todo)

# UPDATE
@app.route('/api/todos/<int:todo_id>', methods=['PUT'])
def update_todo(todo_id):
    todo = todos.get(todo_id)
    if not todo:
        return jsonify({'error': 'Not found'}), 404

    data = request.get_json()
    todo.update({
        'title': data.get('title', todo['title']),
        'priority': data.get('priority', todo['priority']),
        'completed': data.get('completed', todo['completed'])
    })

    return jsonify(todo)

# TOGGLE
@app.route('/api/todos/<int:todo_id>/toggle', methods=['PATCH'])
def toggle_todo(todo_id):
    todo = todos.get(todo_id)
    if not todo:
        return jsonify({'error': 'Not found'}), 404

    todo['completed'] = not todo['completed']

    return jsonify(todo)

# DELETE
@app.route('/api/todos/<int:todo_id>', methods=['DELETE'])
def delete_todo(todo_id):
    if todo_id not in todos:
        return jsonify({'error': 'Not found'}), 404

    del todos[todo_id]
    return '', 204

if __name__ == '__main__':
    app.run(debug=True)

📝 오늘 배운 내용 정리

주제 핵심 내용
REST 개념 자원 중심, 무상태, 일관된 인터페이스
CRUD POST(생성), GET(조회), PUT/PATCH(수정), DELETE(삭제)
멱등성 PUT, DELETE는 멱등, POST는 비멱등
상태 코드 200 OK, 201 Created, 204 No Content, 404 Not Found

핵심 코드 패턴:

1
2
3
4
5
6
# 완전한 RESTful CRUD
@app.route('/api/resource', methods=['GET'])      # 목록
@app.route('/api/resource', methods=['POST'])     # 생성 → 201
@app.route('/api/resource/<id>', methods=['GET']) # 단일
@app.route('/api/resource/<id>', methods=['PUT']) # 수정
@app.route('/api/resource/<id>', methods=['DELETE']) # 삭제 → 204

🔗 관련 자료


📚 이전 학습

Day 55: API 설계 기초 ⭐⭐⭐

어제는 API 설계 원칙, 엔드포인트 구조, 요청/응답 형식을 배웠습니다!

📚 다음 학습

Day 57: API 인증 ⭐⭐⭐

내일은 API 키, JWT, OAuth로 API를 보호하는 방법을 배웁니다!


“늦었다고 생각할 때가 가장 빠른 때입니다. 오늘도 한 걸음 성장했어요!” 🚀

Day 56/100 Phase 6: 웹 스크래핑과 API #100DaysOfPython
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.