포스트

[Python 100일 챌린지] Day 55 - API 설계 기초

[Python 100일 챌린지] Day 55 - API 설계 기초

@app.route('/api/users')return jsonify(users) → 내가 만든 API 완성! 😊

데이터만 쓰는 게 아니라 직접 API를 만들어 제공하기! Flask로 5분 만에 RESTful API 서버를 만들 수 있습니다!

(40-50분 완독 ⭐⭐⭐)

🎯 오늘의 학습 목표

📚 사전 지식


🎯 학습 목표 1: API 설계 원칙 이해하기

1.1 좋은 API란?

좋은 API의 특징:

특징 설명 예시
일관성 네이밍, 구조, 응답이 일관적 모든 리소스 URL이 복수형 (/users, /posts)
직관성 문서 없이도 이해 가능 GET /users/123 → 123번 사용자 조회
예측 가능성 결과를 예측할 수 있음 DELETE 성공 → 204 No Content
단순성 복잡하지 않고 명확함 하나의 엔드포인트는 하나의 역할만
확장 가능성 쉽게 기능 추가 가능 버전 관리 (/api/v1/...)

1.2 RESTful API 설계 원칙

REST (Representational State Transfer) 아키텍처 스타일의 핵심 원칙:

1. 자원 중심 설계 (Resource-Oriented):

1
2
3
4
5
6
7
8
9
10
11
✅ 좋은 예:
GET    /api/users          # 사용자 목록
GET    /api/users/123      # 특정 사용자
POST   /api/users          # 사용자 생성
PUT    /api/users/123      # 사용자 전체 수정
DELETE /api/users/123      # 사용자 삭제

❌ 나쁜 예:
GET    /api/getUsers
POST   /api/createNewUser
POST   /api/deleteUserById?id=123

2. HTTP 메서드 적절히 사용:

메서드 용도 예시 성공 응답
GET 조회 GET /users 200 OK
POST 생성 POST /users 201 Created
PUT 전체 수정 PUT /users/123 200 OK
PATCH 부분 수정 PATCH /users/123 200 OK
DELETE 삭제 DELETE /users/123 204 No Content

3. 무상태성 (Stateless):

1
2
3
4
5
6
7
8
# ✅ 좋은 예: 모든 요청에 필요한 정보 포함
headers = {
    'Authorization': 'Bearer token123',
    'Content-Type': 'application/json'
}

# ❌ 나쁜 예: 서버 세션에 의존
# 클라이언트가 "이전에 로그인했으니 기억하겠지" 가정

1.3 URL 설계 Best Practices

1. 명사 사용, 동사 피하기:

1
2
3
4
5
6
7
✅ /users
✅ /posts
✅ /products

❌ /getUsers
❌ /createPost
❌ /deleteProduct

2. 복수형 사용:

1
2
3
4
✅ /users/123        (일관성)
✅ /posts/456

❌ /user/123         (혼란 유발)

3. 계층 구조 표현:

1
2
3
4
5
6
7
8
# 사용자의 게시글
GET /users/123/posts

# 게시글의 댓글
GET /posts/456/comments

# 특정 댓글
GET /posts/456/comments/789

4. 필터링, 정렬, 페이지네이션:

1
2
# 쿼리 파라미터 사용
GET /users?status=active&sort=name&page=2&limit=20

1.4 Flask로 간단한 API 만들기

1
2
# 설치
pip install flask

기본 API:

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

app = Flask(__name__)

# 간단한 Hello World API
@app.route('/api/hello', methods=['GET'])
def hello():
    return jsonify({
        'message': 'Hello, World!',
        'status': 'success'
    })

# 상태 확인 API
@app.route('/api/health', methods=['GET'])
def health():
    return jsonify({
        'status': 'healthy',
        'version': '1.0.0'
    })

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

실행 및 테스트:

1
2
3
4
5
6
# 서버 실행
python app.py

# 다른 터미널에서 테스트
curl http://localhost:5000/api/hello
curl http://localhost:5000/api/health

🎯 학습 목표 2: 엔드포인트 구조 설계하기

2.1 CRUD API 설계

User 리소스 완전한 CRUD:

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
from flask import Flask, jsonify, request, abort

app = Flask(__name__)

# In-memory 데이터 (실제로는 DB 사용)
users = [
    {'id': 1, 'name': 'Alice', 'email': 'alice@example.com', 'age': 25},
    {'id': 2, 'name': 'Bob', 'email': 'bob@example.com', 'age': 30}
]
next_id = 3

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

    data = request.get_json()

    # 유효성 검사
    if not data or 'name' not in data or 'email' not in data:
        return jsonify({'error': 'Name and email are required'}), 400

    # 새 사용자 생성
    new_user = {
        'id': next_id,
        'name': data['name'],
        'email': data['email'],
        'age': data.get('age')  # 선택 사항
    }

    users.append(new_user)
    next_id += 1

    return jsonify(new_user), 201  # 201 Created

# 2. READ - 모든 사용자 조회
@app.route('/api/users', methods=['GET'])
def get_users():
    # 쿼리 파라미터로 필터링
    status = request.args.get('status')
    page = request.args.get('page', 1, type=int)
    limit = request.args.get('limit', 10, type=int)

    # 페이지네이션
    start = (page - 1) * limit
    end = start + limit
    paginated_users = users[start:end]

    return jsonify({
        'data': paginated_users,
        'page': page,
        'limit': limit,
        'total': len(users)
    })

# 3. READ - 특정 사용자 조회
@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 not user:
        return jsonify({'error': 'User not found'}), 404

    return jsonify(user)

# 4. UPDATE - 사용자 전체 수정 (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()

    # 전체 교체 (PUT)
    user['name'] = data.get('name', user['name'])
    user['email'] = data.get('email', user['email'])
    user['age'] = data.get('age')

    return jsonify(user)

# 5. UPDATE - 사용자 부분 수정 (PATCH)
@app.route('/api/users/<int:user_id>', methods=['PATCH'])
def patch_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()

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

    return jsonify(user)

# 6. DELETE - 사용자 삭제
@app.route('/api/users/<int:user_id>', methods=['DELETE'])
def delete_user(user_id):
    global users

    user = next((u for u in users if u['id'] == user_id), None)

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

    users = [u for u in users if u['id'] != user_id]

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

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

2.2 중첩 리소스 (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
# 사용자의 게시글
posts = [
    {'id': 1, 'user_id': 1, 'title': '첫 번째 글', 'content': '...'},
    {'id': 2, 'user_id': 1, 'title': '두 번째 글', 'content': '...'},
    {'id': 3, 'user_id': 2, 'title': '밥의 글', 'content': '...'}
]

# 특정 사용자의 모든 게시글
@app.route('/api/users/<int:user_id>/posts', methods=['GET'])
def get_user_posts(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

    # 해당 사용자의 게시글 필터링
    user_posts = [p for p in posts 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):
    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()

    new_post = {
        'id': len(posts) + 1,
        'user_id': user_id,
        'title': data['title'],
        'content': data['content']
    }

    posts.append(new_post)

    return jsonify(new_post), 201

2.3 검색과 필터링

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
@app.route('/api/users/search', methods=['GET'])
def search_users():
    # 쿼리 파라미터
    name = request.args.get('name', '').lower()
    min_age = request.args.get('min_age', type=int)
    max_age = request.args.get('max_age', type=int)

    # 필터링
    results = users

    if name:
        results = [u for u in results if name in u['name'].lower()]

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

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

    return jsonify({
        'query': {
            'name': name,
            'min_age': min_age,
            'max_age': max_age
        },
        'results': results,
        'count': len(results)
    })

🎯 학습 목표 3: 요청과 응답 형식 정의하기

3.1 요청 형식 (Request Format)

1. Path Parameters (경로 매개변수):

1
2
3
4
5
# /api/users/123
@app.route('/api/users/<int:user_id>')
def get_user(user_id):
    # user_id는 URL에서 추출
    pass

2. Query Parameters (쿼리 매개변수):

1
2
3
4
5
6
# /api/users?page=2&limit=10&status=active
@app.route('/api/users')
def get_users():
    page = request.args.get('page', 1, type=int)
    limit = request.args.get('limit', 10, type=int)
    status = request.args.get('status')

3. Request Body (요청 본문):

1
2
3
4
5
6
7
# POST /api/users
# Body: {"name": "Charlie", "email": "charlie@example.com"}
@app.route('/api/users', methods=['POST'])
def create_user():
    data = request.get_json()
    name = data.get('name')
    email = data.get('email')

3.2 응답 형식 (Response Format)

표준 응답 구조:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 성공 응답
{
    "status": "success",
    "data": {
        "id": 123,
        "name": "Alice"
    }
}

# 에러 응답
{
    "status": "error",
    "error": {
        "code": "USER_NOT_FOUND",
        "message": "User with ID 123 not found"
    }
}

응답 헬퍼 함수:

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
def success_response(data, status_code=200):
    """성공 응답 생성"""
    return jsonify({
        'status': 'success',
        'data': data
    }), status_code

def error_response(message, code='ERROR', status_code=400):
    """에러 응답 생성"""
    return jsonify({
        'status': 'error',
        'error': {
            'code': code,
            'message': message
        }
    }), status_code

# 사용
@app.route('/api/users/<int:user_id>')
def get_user(user_id):
    user = next((u for u in users if u['id'] == user_id), None)

    if not user:
        return error_response('User not found', 'USER_NOT_FOUND', 404)

    return success_response(user)

3.3 HTTP 상태 코드

자주 사용하는 상태 코드:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 2xx 성공
return jsonify(data), 200  # OK
return jsonify(data), 201  # Created
return '', 204             # No Content

# 4xx 클라이언트 오류
return jsonify(error), 400  # Bad Request
return jsonify(error), 401  # Unauthorized
return jsonify(error), 403  # Forbidden
return jsonify(error), 404  # Not Found
return jsonify(error), 409  # Conflict

# 5xx 서버 오류
return jsonify(error), 500  # Internal Server Error

3.4 에러 핸들링

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
from flask import Flask, jsonify

app = Flask(__name__)

# 404 Not Found 핸들러
@app.errorhandler(404)
def not_found(error):
    return jsonify({
        'status': 'error',
        'error': {
            'code': 'NOT_FOUND',
            'message': 'Resource not found'
        }
    }), 404

# 500 Internal Server Error 핸들러
@app.errorhandler(500)
def internal_error(error):
    return jsonify({
        'status': 'error',
        'error': {
            'code': 'INTERNAL_ERROR',
            'message': 'An internal error occurred'
        }
    }), 500

# 커스텀 예외
class ValidationError(Exception):
    pass

@app.errorhandler(ValidationError)
def handle_validation_error(error):
    return jsonify({
        'status': 'error',
        'error': {
            'code': 'VALIDATION_ERROR',
            'message': str(error)
        }
    }), 400

# 사용
@app.route('/api/users', methods=['POST'])
def create_user():
    data = request.get_json()

    if not data or 'name' not in data:
        raise ValidationError('Name is required')

    # 사용자 생성...

🎯 학습 목표 4: API 문서화 방법 익히기

4.1 수동 문서화 (Markdown)

API 문서 예시 (API_DOCS.md):

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
# User API Documentation

## Get All Users

**Endpoint**: `GET /api/users`

**Query Parameters**:
- `page` (integer, optional): 페이지 번호 (default: 1)
- `limit` (integer, optional): 페이지당 항목 수 (default: 10)

**Response**: 200 OK
```json
{
  "data": [
    {
      "id": 1,
      "name": "Alice",
      "email": "alice@example.com",
      "age": 25
    }
  ],
  "page": 1,
  "limit": 10,
  "total": 50
}

Errors:

  • 500: Internal server error

Create User

Endpoint: POST /api/users

Request Body:

1
2
3
4
5
{
  "name": "Charlie",
  "email": "charlie@example.com",
  "age": 28
}

Response: 201 Created

1
2
3
4
5
6
{
  "id": 3,
  "name": "Charlie",
  "email": "charlie@example.com",
  "age": 28
}

Errors:

  • 400: Validation error (name or email missing) ```

4.2 코드 내 문서화 (Docstrings)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@app.route('/api/users', methods=['GET'])
def get_users():
    """
    Get all users with pagination and filtering.

    Query Parameters:
        page (int): Page number (default: 1)
        limit (int): Items per page (default: 10)
        status (str): Filter by status

    Returns:
        200: List of users with pagination info
        {
            "data": [...],
            "page": 1,
            "limit": 10,
            "total": 50
        }

    Example:
        GET /api/users?page=2&limit=20&status=active
    """
    pass

4.3 Postman Collection

Postman으로 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
{
  "info": {
    "name": "User API",
    "description": "User management API"
  },
  "item": [
    {
      "name": "Get All Users",
      "request": {
        "method": "GET",
        "url": "http://localhost:5000/api/users",
        "description": "Retrieve all users"
      }
    },
    {
      "name": "Create User",
      "request": {
        "method": "POST",
        "url": "http://localhost:5000/api/users",
        "body": {
          "mode": "raw",
          "raw": "{\"name\": \"Test\", \"email\": \"test@example.com\"}"
        }
      }
    }
  ]
}

4.4 API 버전 관리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 버전 1
@app.route('/api/v1/users', methods=['GET'])
def get_users_v1():
    return jsonify(users)

# 버전 2 (다른 응답 형식)
@app.route('/api/v2/users', methods=['GET'])
def get_users_v2():
    return jsonify({
        'version': 'v2',
        'users': users,
        'meta': {
            'total': len(users)
        }
    })

💡 실전 팁 & 주의사항

✅ DO: 이렇게 하세요

  1. 일관된 네이밍
    • 복수형 리소스: /users, /posts
    • 소문자 사용: /api/users (not /API/Users)
  2. 적절한 HTTP 메서드 사용
    • GET: 조회만
    • POST: 생성만
    • PUT/PATCH: 수정만
    • DELETE: 삭제만
  3. 의미 있는 상태 코드
    1
    2
    
    return jsonify(user), 201  # 생성 성공
    return '', 204             # 삭제 성공
    
  4. 에러 정보 명확하게
    1
    2
    3
    4
    
    return jsonify({
        'error': 'Email already exists',
        'field': 'email'
    }), 400
    

❌ DON’T: 이러지 마세요

  1. URL에 동사 사용
    1
    2
    3
    4
    5
    
    # 나쁜 예
    @app.route('/api/getUserById/<id>')
    
    # 좋은 예
    @app.route('/api/users/<id>')
    
  2. 모든 요청을 POST로
    • 각 작업에 맞는 HTTP 메서드 사용
  3. 에러 시 항상 500 반환
    • 클라이언트 오류는 4xx
    • 서버 오류만 5xx

🧪 연습 문제

문제 1: Blog Post API 설계

블로그 포스트 API를 설계하세요.

요구사항:

  • 게시글 CRUD (생성, 조회, 수정, 삭제)
  • 목록 조회 시 페이지네이션
  • 제목으로 검색
  • 적절한 상태 코드 반환
💡 힌트
  1. /api/posts 엔드포인트
  2. GET, POST, PUT, DELETE 메서드
  3. request.args.get() for 쿼리 파라미터
  4. 201 Created, 204 No Content, 404 Not Found
✅ 정답
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
from flask import Flask, jsonify, request

app = Flask(__name__)

posts = [
    {'id': 1, 'title': '첫 포스트', 'content': '내용 1', 'author': 'Alice'},
    {'id': 2, 'title': '두 번째 포스트', 'content': '내용 2', 'author': 'Bob'}
]
next_id = 3

# 목록 조회 (페이지네이션 + 검색)
@app.route('/api/posts', methods=['GET'])
def get_posts():
    page = request.args.get('page', 1, type=int)
    limit = request.args.get('limit', 10, type=int)
    search = request.args.get('search', '').lower()

    # 검색
    filtered = posts
    if search:
        filtered = [p for p in posts if search in p['title'].lower()]

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

    return jsonify({
        'data': paginated,
        'page': page,
        'total': len(filtered)
    })

# 생성
@app.route('/api/posts', methods=['POST'])
def create_post():
    global next_id
    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

    new_post = {
        'id': next_id,
        'title': data['title'],
        'content': data['content'],
        'author': data.get('author', 'Anonymous')
    }

    posts.append(new_post)
    next_id += 1

    return jsonify(new_post), 201

# 조회
@app.route('/api/posts/<int:post_id>', methods=['GET'])
def get_post(post_id):
    post = next((p for p in posts if p['id'] == post_id), None)

    if not post:
        return jsonify({'error': 'Post not found'}), 404

    return jsonify(post)

# 수정
@app.route('/api/posts/<int:post_id>', methods=['PUT'])
def update_post(post_id):
    post = next((p for p in posts if p['id'] == post_id), None)

    if not post:
        return jsonify({'error': 'Post not found'}), 404

    data = request.get_json()
    post.update(data)

    return jsonify(post)

# 삭제
@app.route('/api/posts/<int:post_id>', methods=['DELETE'])
def delete_post(post_id):
    global posts

    post = next((p for p in posts if p['id'] == post_id), None)

    if not post:
        return jsonify({'error': 'Post not found'}), 404

    posts = [p for p in posts if p['id'] != post_id]

    return '', 204

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

📝 오늘 배운 내용 정리

주제 핵심 내용
API 설계 원칙 일관성, 직관성, 예측 가능성, REST 아키텍처
엔드포인트 리소스 중심, HTTP 메서드, URL 구조
요청/응답 경로/쿼리 파라미터, JSON, 상태 코드
문서화 Markdown, Docstring, Postman, 버전 관리

핵심 코드 패턴:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Flask 기본 구조
from flask import Flask, jsonify, request

app = Flask(__name__)

# CRUD 패턴
@app.route('/api/resource', methods=['GET'])     # 목록 조회
@app.route('/api/resource', methods=['POST'])    # 생성
@app.route('/api/resource/<id>', methods=['GET'])    # 단일 조회
@app.route('/api/resource/<id>', methods=['PUT'])    # 수정
@app.route('/api/resource/<id>', methods=['DELETE']) # 삭제

# 응답 패턴
return jsonify(data), 200   # 성공
return jsonify(error), 404  # 실패

🔗 관련 자료


📚 이전 학습

📚 다음 학습


“늦었다고 생각할 때가 가장 빠른 때입니다. 오늘도 한 걸음 더 나아갔습니다!” 🚀

내일은 Day 56: RESTful API에서 만나요!

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