포스트

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

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

POST /api/users → 생성, GET /api/users → 조회, PUT → 수정, DELETE → 삭제! 😊

CRUD 4가지 작업을 HTTP 메서드 4개로 완벽 구현! RESTful 원칙으로 전문가처럼 API를 설계합니다!

(40-50분 완독 ⭐⭐⭐)

🎯 오늘의 학습 목표

📚 사전 지식


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

1.1 REST란?

REST (Representational State Transfer)는 웹 아키텍처 스타일로, 자원을 정의하고 자원에 대한 주소를 지정하는 방법입니다.

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 스타일)
GET  /getUserById?id=123
POST /createUser
POST /updateUser
POST /deleteUser?id=123

# ✅ RESTful API (자원 중심)
GET    /users/123      # 조회
POST   /users          # 생성
PUT    /users/123      # 수정
DELETE /users/123      # 삭제

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

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 구현

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 처리

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

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)

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

메서드 멱등성 안전성 설명
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

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으로 일관성 유지

🧪 연습 문제

문제: TODO API 구현

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

요구사항:

  • CRUD 모두 구현
  • 완료 상태 토글 (PATCH /todos/123/toggle)
  • 완료/미완료 필터링
  • 우선순위 정렬
  • 적절한 상태 코드
✅ 정답
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 57: API 인증에서 만나요!

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.