
시리즈 흐름
1편: DevTools로 LinkedIn API 요청 구조 관찰
2편: 검색 기능 필터·페이지네이션 구조 확인
3편: 공개 API로 Response JSON 직접 읽기 + CRUD 실행
왜 이번 실습이 필요했나
1·2편에서 LinkedIn API를 관찰했지만 Response가 protobuf로 인코딩되어 있어 데이터를 직접 읽을 수 없었습니다. “응답이 왔다”는 사실만 확인했을 뿐, 그 안에 어떤 구조로 데이터가 담겨있는지는 알 수 없었습니다.
3편에서는 이 한계를 보완하기 위해 Response JSON을 그대로 읽을 수 있는 공개 API 두 가지를 활용했습니다.
| 1편 | 2편 | 3편 | |
|---|---|---|---|
| 도구 | DevTools | DevTools | Postman |
| 대상 | JSONPlaceholder + GitHub API | ||
| 핵심 | 요청 구조 관찰 | 예상과 실제 차이 확인 | Response 직접 읽기 + 데이터 쓰기 |
| Response | 읽지 못함 | 읽지 못함 | 눈으로 직접 확인 |
Postman이란
이번 실습에서 처음으로 Postman을 사용했는데, 처음에는 이게 어떤 도구인지 감이 잘 잡히지 않았습니다.
한 줄로 정리하면 UI 없이 API 요청을 직접 보낼 수 있는 도구입니다.
보통 서비스를 이용할 때는 버튼, 입력창 같은 화면을 통해 요청을 보냅니다. Postman은 그 화면을 건너뛰고 메서드, URL, Body만 직접 채워서 서버에 바로 요청을 던질 수 있습니다.
DevTools가 “브라우저가 보내는 요청을 옆에서 관찰하는 것”이라면, Postman은 “내가 직접 요청을 만들어서 보내는 것”입니다. 개발자는 API 개발 중 테스트 용도로, QA는 정상·비정상 케이스 검증 용도로 사용합니다. PM 입장에서는 이번 실습처럼 API 구조를 직접 눈으로 확인하는 데 활용할 수 있습니다.

실습 전 알아둘 핵심 개념
CRUD와 HTTP 메서드
대부분의 서비스 기능은 Create(생성), Read(조회), Update(수정), Delete(삭제) 네 가지 안에서 움직입니다. PM이 기능 명세를 작성할 때 “이 버튼은 어떤 CRUD 동작인가”를 먼저 정의해야 합니다.
| CRUD | HTTP 메서드 | 예시 |
|---|---|---|
| Read | GET | 게시글 목록 조회 |
| Create | POST | 게시글 작성 |
| Update | PUT (전체) / PATCH (일부) | 게시글 수정 |
| Delete | DELETE | 게시글 삭제 |
PUT vs PATCH — 명세에서 반드시 구분해야 하는 이유
두 방식의 차이점을 간단히 알아봤습니다.
- PUT: 해당 값으로 전체 덮어씌우기
- PATCH: 기존 값 유지하면서 수정한 값만 업데이트
프로필 수정 화면처럼 일부 필드만 바꾸는 기능에 PUT을 쓰면, 보내지 않은 나머지 필드(프로필 이미지, 자기소개 등)가 비어버리는 버그가 생깁니다. 명세에 “부분 수정”인지 “전체 교체”인지 명시하지 않으면 개발팀이 둘 중 어느 방식으로 구현할지 알 수 없습니다.
Status Code
| 코드 | 의미 | 언제 나오는가 |
|---|---|---|
| 200 OK | 조회/수정 성공 | GET, PUT, PATCH 성공 시 |
| 201 Created | 생성 성공 | POST 성공 시 |
| 204 No Content | 삭제 성공 (응답 Body 없음) | DELETE 성공 시 |
| 304 Not Modified | 캐시 사용 | 이전과 동일한 데이터 요청 시 |
| 400 Bad Request | 잘못된 요청 | 필수값 누락, 형식 오류 시 |
| 403 Forbidden | 접근 거부 | Rate Limit 초과 등 |
| 404 Not Found | 존재하지 않는 리소스 | 없는 id 조회 시 |
201과 200의 차이를 명세에 명시해야 프론트엔드가 “생성 후 어떤 화면으로 이동할지”를 판단할 수 있습니다. 204는 Body가 없으므로 삭제 완료 후 화면 처리를 클라이언트가 자체적으로 해야 합니다.
JSONPlaceholder로 CRUD 전체 흐름 실습
JSONPlaceholder(https://jsonplaceholder.typicode.com)는 연습용 가짜 API입니다. 실제 데이터가 저장되거나 변경되지 않기 때문에 부담 없이 요청을 테스트할 수 있습니다.
STEP 1. GET — 단건 조회
메서드: GET
URL: https://jsonplaceholder.typicode.com/posts/1

실제 Response
{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipit\nsuscipit recusandae consequuntur expedita..."
}
Response JSON 구조를 눈으로 읽을 수 있었습니다. 각 필드의 의미는 다음과 같습니다.
userId는 이 게시글을 작성한 사용자의 id로, DB 실습에서 배운 외래키(FK) 구조가 API에서도 동일하게 나타납니다. id는 게시글 자체의 고유 식별자로 URL에 /posts/1처럼 직접 사용됩니다.
실습하면서 생긴 의문 — title이 빈 값이면?
API 명세를 리뷰할 때 각 필드가 필수인지 선택인지를 확인해야 합니다. title이 빈 값일 때 서버가 어떻게 처리하는지 정해두지 않으면 프론트엔드와 백엔드가 다르게 해석합니다.
정의 방식은 세 가지입니다.
- 클라이언트에서 막기
title은 필수값입니다. 빈 값이면 “제목을 입력해주세요” 문구를 노출하고 제출 버튼을 비활성화합니다. - 서버에서 막기 (400 반환)
title이 빈 값이거나 누락된 경우 서버는400 Bad Request를 반환합니다. - 허용하기
title은 선택값입니다. 빈 값으로 저장 가능하며 화면에서는 “(제목 없음)”으로 표시합니다.
실무에서는 1+2 방식으로 함께 쓰는 경우가 많습니다. 1만 있으면 Postman처럼 UI를 거치지 않고 서버에 직접 요청을 보내는 방식으로 빈 title이 그대로 저장될 수 있기 때문입니다. 1은 사용자 경험(UX)용이고, 2는 실제 데이터 보호용으로 역할이 다릅니다.
STEP 2. GET — 목록 조회 + 페이지네이션
메서드: GET
URL: https://jsonplaceholder.typicode.com/posts?_page=1&_limit=5

Response가 {} 객체가 아니라 [] 배열로 시작한다는 점을 확인했습니다. Body에는 5개의 게시글이 담겨 있었습니다.
전체 개수는 어디서 오는가
하단 Headers 탭에서 X-Total-Count: 100을 확인할 수 있었습니다. _limit=5로 요청했기 때문에 실제 받은 데이터는 5개지만, X-Total-Count는 전체 게시글이 100개라는 것을 알려줍니다. 화면에 “총 100개의 결과” 같은 문구를 보여주려면 Body의 데이터 개수(5개)가 아니라 이 값을 써야 합니다.

서비스마다 total을 내려주는 위치가 다릅니다
JSONPlaceholder는 Header에 담아서 내려줬지만 GitHub API는 Body 안에 담아서 내려주는 것을 확인했습니다. 이 차이가 왜 문제가 되냐면, 프론트엔드 개발자가 “total은 당연히 Body에 있겠지”라고 가정하고 구현했는데 백엔드는 Header에 담아서 내려주면 화면에 총 개수가 표시되지 않는 상황이 생깁니다.
두 방식에는 각각 특징이 있습니다. Body에 담는 경우 데이터와 메타 정보가 한 곳에 모여 있어 직관적이지만, 나중에 메타 정보가 늘어나면 구조가 복잡해질 수 있습니다. Header에 담는 경우 데이터와 메타 정보를 분리한다는 설계 원칙을 지킬 수 있지만, total이 어디 있는지 모르면 Body만 뒤지다가 못 찾는 상황이 생길 수 있습니다.
업계 대세는 Body에 담는 쪽으로 기울어져 있는 것 같습니다. 응답 하나만 열어보면 전체 구조를 파악할 수 있어서 협업할 때 더 직관적이기 때문입니다. 다만 절대적인 기준은 아니라, PM 입장에서는 팀 내에서 한 번 합의해두고 API 명세 기본값으로 정해두는 게 현실적인 방법이라고 판단됩니다.
페이지네이션 방식 두 가지
| 방식 | 구조 | 예시 |
|---|---|---|
| Offset 방식 | page=2&per_page=10 | 2페이지의 10개 |
| Cursor 방식 | token=abc123 | 이 토큰 다음부터 |
Offset과 Cursor는 “어떻게 데이터를 끊어서 가져오냐”의 서버 영역의 얘기고, 더보기 버튼과 무한스크롤은 “언제 다음 요청을 보내냐”의 클라이언트 영역의 얘기입니다. 즉 서버에서 데이터 끊는 방식과 상관없이 클라이언트가 요청을 언제 보내냐는 UI 구현 방식에 따라 달라집니다. 명세에는 두 가지를 모두 정의해야 합니다.
STEP 3. POST — 데이터 생성
메서드: POST
URL: https://jsonplaceholder.typicode.com/posts
Body (raw, JSON):
{
"title": "재택근무 정책 등록",
"body": "주 3회 재택 허용, 코어타임 오전 10시~오후 3시",
"userId": 1
}

Response (Status 201 Created)
{
"title": "재택근무 정책 등록",
"body": "주 3회 재택 허용, 코어타임 오전 10시~오후 3시",
"userId": 1,
"id": 101
}
Status가 200이 아닌 201인 것, 그리고 내가 보낸 Body에는 없던 "id": 101이 Response에 추가되어 돌아온 것을 확인했습니다.
POST 시 Content-Type 헤더는 자동 설정됩니다
Postman에서 Body → raw → JSON을 선택하면 Content-Type: application/json 헤더가 자동으로 추가됩니다. 이 헤더가 없으면 서버가 Body를 JSON으로 해석하지 못합니다. 직접 입력하는 게 아니라 Postman이 자동으로 넣어주는 값입니다.
왜 id가 Response에 포함되어야 하는가
클라이언트가 새 게시글을 작성한 후 상세 페이지로 이동하려면 방금 생성된 게시글의 id가 필요합니다. 서버가 이 id를 Response로 내려줘야 프론트엔드가 /posts/101로 이동할 수 있습니다.
실습하기 전까지는 이 부분을 생각해본 적이 없었습니다. “작성 완료 후 상세 페이지로 이동”이라고 기획서에 작성하면 될 줄 알았는데, 상세 페이지로 이동하려면 방금 만든 게시글의 id가 있어야 한다는 것, 그리고 그 id를 서버가 Response로 내려줘야 한다는 것을 API 구조를 직접 보고 나서야 이해했습니다. 알아야 명세에 쓸 수 있고, 모르면 요청 자체를 못 하는 상황이 생긴다는 걸 체감했습니다.
기획서 작성 예시
| 항목 | 내용 |
|---|---|
| Method | POST |
| URL | /posts |
| Request Body | title(필수), body(필수), userId(필수) |
| Response | 201, 생성된 id 포함 |
| 성공 후 동작 | 생성된 id로 상세 페이지 이동 |
| 실패 처리 | 400 시 각 필드 에러 메시지 노출 |
STEP 4. PUT vs PATCH — 수정 방식의 차이
4-A. PUT (전체 교체)
메서드: PUT
URL: https://jsonplaceholder.typicode.com/posts/1
Body:
{
"id": 1,
"title": "수정된 제목만 보냄",
"userId": 1
}
body 필드를 의도적으로 빠뜨렸습니다.

하단 Response에서 body 필드가 사라졌습니다. PUT은 보낸 내용으로 전체를 교체하기 때문입니다.
4-B. PATCH (부분 수정)
메서드: PATCH
URL: https://jsonplaceholder.typicode.com/posts/1
Body:
{
"title": "제목만 바꿉니다"
}

body 필드가 기존 값 그대로 남아있었습니다. PATCH는 보낸 필드만 수정하고 나머지는 유지하기 때문입니다.
URL에서 /posts/1이 수정 대상을 특정합니다. Body에 어떤 필드만 보내든 수정 대상은 URL로 이미 정해져 있습니다.
기획서 작성 예시 — 프로필 수정
| 항목 | 내용 |
|---|---|
| 수정 방식 | 부분 수정 (PATCH) |
| 수정 가능 필드 | 닉네임, 자기소개 |
| 수정 불가 필드 | 프로필 이미지, 이메일 (별도 화면에서 변경) |
| 저장 버튼 클릭 시 | 변경된 필드만 Request Body에 포함 |
| 성공(200) | “저장되었습니다” 토스트 메시지 노출 |
| 실패(400) | 각 필드 하단에 에러 메시지 노출 |
STEP 5. DELETE — 삭제
메서드: DELETE
URL: https://jsonplaceholder.typicode.com/posts/1

JSONPlaceholder는 연습용이라 200 OK에 빈 객체 {}를 내려줍니다. 실무에서는 204 No Content(Body 없음)가 더 일반적일 것입니다.
소프트 딜리트 vs 하드 딜리트
삭제 기능을 기획할 때 반드시 두 가지를 정의해야 합니다.
소프트 딜리트는 DB에서 실제로 지우는 게 아니라 deleted_at 값만 채워두는 방식입니다. 사용자 눈에만 안 보이는 것이라 백엔드에서는 해당 값을 초기화해서 복구가 가능합니다. 고객센터 문의로 복구 요청이 들어올 때 대응할 수 있고, 법적 이슈나 감사 목적으로 데이터 보존이 필요한 서비스에서 많이 사용됩니다.
하드 딜리트는 DB에서 완전히 삭제하는 방식으로 백엔드도 복구할 수 없습니다.
화면에 “삭제 후 복구가 불가능합니다”라는 문구를 표시하는 경우, 소프트 딜리트라면 이는 사용자 기준의 복구 불가능을 의미하고 백엔드에서는 복구가 가능하다는 점을 기획 단계에서 인지해야 합니다.
기획서 작성 예시
| 항목 | 내용 |
|---|---|
| 삭제 방식 | 소프트 딜리트 (숨김 처리) |
| 삭제 확인 다이얼로그 | 있음 |
| 다이얼로그 문구 | “게시글을 삭제하시겠습니까? 삭제 후 복구가 불가능합니다.” |
| 버튼 | 삭제(빨간색), 취소 |
| 성공(200) | deleted_at 필드 Response에 포함, 목록에서 해당 게시글 숨김 처리 |
| 실패(404) | “이미 삭제된 게시글입니다” 메시지 노출 |
GitHub API로 실제 서비스 구조 확인
JSONPlaceholder는 연습용이라 인증, 실제 페이지네이션 헤더, 대용량 데이터 구조를 체험하기 어렵습니다. GitHub API는 인증 없이도 일부 사용 가능하고, total_count, items[], Link 헤더, Rate Limit 같은 실무에서 자주 보는 구조를 직접 확인할 수 있었습니다.
STEP 6. GitHub API — 검색 결과 구조 확인
메서드: GET
URL: https://api.github.com/search/repositories?q=react&per_page=3&page=1

실제 Response 구조
{
"total_count": 6708632,
"incomplete_results": false,
"items": [
{
"id": 10270250,
"node_id": "MDEwOlJlcG9zaXRvcnkxMDI3MDI1MA==",
"name": "react",
"full_name": "facebook/react",
"private": false,
"owner": {
"login": "facebook",
"id": 69631,
...
},
...
}
]
}
각 필드 해석
total_count는 전체 검색 결과 수로 6,708,632개가 나왔습니다. JSONPlaceholder에서는 Header에 있었지만 GitHub는 Body 안에 포함합니다. 서비스마다 위치가 다르다는 것을 직접 확인한 순간이었습니다.
incomplete_results는 false로 나왔습니다. 서버가 결과를 정상적으로 다 보내줬다는 의미입니다. 이 값이 true가 됐을 때 화면에서 어떻게 처리할지도 기획 단계에서 정의해야 합니다.
기획서 작성 예시 — 불완전 결과 처리
| 항목 | 내용 |
|---|---|
| 조건 | incomplete_results: true 응답 수신 시 |
| 노출 위치 | 검색 결과 목록 상단 |
| 노출 문구 | “일부 검색 결과가 누락되었을 수 있습니다. 다시 검색해주세요.” |
| 노출 형태 | 경고 배너 (노란색) |
| 재시도 버튼 | 있음, 클릭 시 동일 검색어로 재요청 |
incomplete_results: false 시 | 배너 노출 안 함 |
items[]는 실제 데이터가 담긴 배열입니다. LinkedIn 2편에서 protobuf 때문에 읽지 못했던 바로 그 구조를 여기서 처음으로 눈으로 확인했습니다. 배열 안 각 객체가 화면의 카드 하나하나에 해당합니다. 예상보다 필드가 훨씬 많았고, owner 안에 또 객체가 중첩된 구조도 확인했습니다.
STEP 7. GitHub API — Link 헤더로 페이지네이션 확인
STEP 6과 동일한 요청 후 Response Headers 탭을 열어보면 아래 헤더를 확인할 수 있습니다.
Link: <https://api.github.com/search/repositories?q=react&per_page=3&page=2>; rel="next",
<https://api.github.com/search/repositories?q=react&per_page=3&page=334>; rel="last"

rel="next"는 다음 페이지 URL이고, rel="last"는 마지막 페이지 URL입니다. 클라이언트는 이 URL을 그대로 호출하면 됩니다.
이 방식을 HATEOAS라고 합니다. 서버가 다음 페이지 URL을 통째로 내려주기 때문에 URL 구조를 직접 조합할 필요가 없다는 점이 흥미로웠습니다.
다만 한 가지 불편한 점도 보였습니다. 전체 페이지 수를 바로 알기 어렵다는 겁니다. total_count처럼 숫자 하나로 딱 떨어지는 게 아니라 rel="last" URL 안에 page=334로 섞여있어서, “총 334페이지”라는 숫자를 꺼내려면 URL을 한 번 더 분석해야 합니다.
처음엔 왜 이렇게 설계했는지 잘 몰랐는데, 서비스마다 설계 방식이 다를 수 있다는 걸 이번 실습에서 느꼈습니다.
STEP 8. GitHub API — Rate Limit 확인
STEP 6·7 Response Headers에서 아래 헤더들을 확인할 수 있습니다.
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 9
X-RateLimit-Reset: 1772189885

X-RateLimit-Limit은 인증 없이 분당 10회까지만 요청 가능하다는 의미입니다. X-RateLimit-Remaining은 현재 남은 요청 횟수입니다. 동일한 URL로 Send를 반복할 때마다 이 값이 하나씩 줄어드는 것을 확인할 수 있습니다.
0이 되면 아래 응답이 옵니다.
{
"message": "API rate limit exceeded for ...",
"documentation_url": "https://docs.github.com/..."
}

Rate Limit이 기획과 어떻게 연결되는가
Rate Limit은 서버 과부하를 방지하고, 악의적인 대량 요청 공격(DDoS)을 막기 위한 설정입니다. 단순히 새로고침을 반복하는 것과 같은 개념으로 이해했습니다.
자사 서비스가 GitHub API를 연동해서 쓰고 있다면, 자사 서버가 GitHub API에 요청을 보내는 횟수가 누적됩니다. 사용자가 많아질수록 요청 횟수도 늘어나고, 한도를 넘으면 전체 사용자에게 403 에러가 노출됩니다.
이런 외부 API 연동은 일상적인 서비스에서도 흔하게 쓰입니다. 배달 앱의 주소 검색(카카오맵 API), 쇼핑몰의 결제(토스페이먼츠 API), “카카오로 로그인” 버튼(소셜 로그인 API) 등이 모두 해당합니다.
외부 API를 연동할 때는 해당 서비스의 정책, 요금, Rate Limit에 종속되는 리스크가 생기기 때문에 기획 단계에서 개발팀과 미리 확인해두는 것이 좋을 것 같습니다.
기획서 작성 예시
| 항목 | 내용 |
|---|---|
| 외부 API | GitHub Search API |
| Rate Limit | 인증 없이 분당 10회 |
| 초과 시 응답 | 403, “API rate limit exceeded” |
| 초과 시 화면 처리 | “잠시 후 다시 시도해주세요” 메시지 노출 |
| 재시도 버튼 | 있음, X-RateLimit-Reset 시간 이후 활성화 |
| 해결 방안 | GitHub 인증 토큰 적용 시 분당 30회로 상향, 유료 플랜 검토 필요 |
STEP 9. DevTools에서 GitHub 실제 요청 관찰
브라우저에서 GitHub 검색 페이지를 열고 DevTools → Network → Fetch/XHR 필터를 켜서 실제 요청을 관찰했습니다.

결과: 내부 API 사용 확인
Postman에서 직접 호출할 때는 api.github.com으로 요청이 갔는데, 브라우저에서는 count?q=react&type=issues 같은 다른 URL로 요청이 가고 있었습니다. LinkedIn 2편에서 확인했던 것처럼 GitHub도 외부에 공개한 API와 내부에서만 쓰는 API를 별도로 운영하고 있는 것으로 보입니다.
Status 304에 대해
캡처 시점에 모든 요청의 Status가 304로 나왔습니다. 처음에는 왜 200이 아닌지 의아했는데, 304는 “이전에 받은 데이터랑 똑같으니 새로 받을 필요 없이 저장해둔 것 그대로 쓰세요”라는 의미였습니다.
페이지를 이미 한 번 로드한 이후에 캡처했기 때문에 뜬 것으로, 처음 진입했을 때는 200으로 데이터를 받았을 겁니다. 캡처 타이밍을 놓쳤지만 “처음엔 200, 이후 동일 요청은 304″라는 흐름 자체가 실제 서비스에서 일어나는 동작이라는 걸 알게 됐습니다.
3편 실습을 마치며 — PM 관점 인사이트 정리
실습을 하면서 “이 구조가 기획과 어떻게 연결되는가”를 반복적으로 확인했습니다. 아래에 주요 포인트를 정리합니다.
필수/선택 필드 정의
각 필드가 필수인지 선택인지, 빈 값일 때 어떻게 처리하는지 명세에 없으면 프론트엔드와 백엔드가 다르게 해석합니다. 클라이언트 검증(①)만으로는 부족하고 서버 검증(②)까지 함께 정의해야 합니다.
201 vs 200 구분
생성 성공 후 어디로 이동하는지는 기획 결정 사항입니다. 이 결정이 없으면 프론트엔드는 임의로 구현하고, 이동에 필요한 id를 Response에 포함해달라는 요청 자체를 놓칠 수 있습니다.
PUT vs PATCH 명시
프로필 수정처럼 일부 필드만 바꾸는 기능은 PATCH가 맞습니다. 명세에 명시하지 않으면 PUT으로 구현해서 수정하지 않은 필드가 삭제되는 버그가 생길 수 있습니다.
소프트/하드 딜리트 구분
삭제 확인 다이얼로그 여부와 함께 반드시 정의해야 합니다. 소프트 딜리트라면 deleted_at 필드가 Response에 포함되어야 하고, 하드 딜리트라면 204와 함께 복구 불가 처리가 필요합니다.
total 위치 합의
서비스마다 전체 개수를 Body에 담는 곳도 있고 Header에 담는 곳도 있습니다. 팀 내에서 한 번 합의해두고 API 명세 기본값으로 정해두는 것이 필요합니다.
페이지네이션 트리거 조건
더보기 버튼과 무한스크롤은 둘 다 페이지네이션이지만 요청 시점이 다릅니다. 명세에 트리거 조건을 명시해야 합니다.
Rate Limit 확인
외부 API를 연동할 때는 Rate Limit 한도와 초과 시 화면 처리를 기획 단계에서 정의해야 합니다. 서비스 규모가 커지면 유료 플랜 비용이 발생할 수 있으므로 개발팀과 미리 확인이 필요합니다.
다음 편에서는 공개 API를 활용해서 직접 토이 프로젝트 기획을 잡아볼 예정입니다.
— Lane

