안녕하세요 dev_writer입니다. 이번에는 EatToFit 서비스의 API 설계를 진행한 과정에서의 고민점을 기록하려고 합니다.
큰 목록 구성은 이전 글에서 작성한 <프로젝트 기능 설명 및 계획>에서 보실 수 있고, 이 글에서는 고민이 되었던 API에 대해서만 기록하는 식으로 작성하겠습니다.
1. 회원의 정보를 등록하고 수정할 때에는 응답을 어떻게 해야 할까?
본 서비스에서는 회원의 정보를 등록하고, 수정하는 기능이 존재합니다. 이 기능을 이용하여 사용자의 닉네임, 신체 정보, 운동 경력, 선호 스포츠, 선호 헬스 등 다양한 정보를 저장시키도록 합니다.
물론 수정할 때에는 이 모든 값들을 요구하지 않고, 선택적으로 값을 수정하게끔 진행하려고 합니다.
하지만 이 API를 설계하는 과정에서 멘토님과 이견을 가진 부분이 존재했습니다. 바로 응답 방식을 <ID만 제공하거나 HTTP 상태 코드만을 전달하는 방식>과, <요청한 데이터를 토대로 반영된 데이터를 모두 보여주는 방식> 중 어떤 것으로 해야 할지에 대한 문제였습니다.
// 회원 정보 등록, 수정에서 사용되는 JSON 포맷
{
"nickname": "test",
"physicalProfile": {
"birth": "2000.10.10",
"gender": "남성",
"weight": 67.2,
"height": 171.2
},
"preferSports": ["테니스", "탁구"],
"preferFitness": ["벤치프레스", "레그컬"],
"exerciseProfile": {
"level": "입문자",
"goal": "근비대",
"experience": "처음",
"frequency": "매일"
}
}
단순한 HTTP Method 응답 방식을 선택했던 이유
처음에는 201 Created만을 반환 (수정일 때에는 200 OK)하는 방식을 이용하였습니다. 그 판단 근거는 아래와 같습니다.
- 보통 201 Created를 이용하면 그에 대한 DB id 값을 전달하지만, 이는 게시글 작성 등에 어울리며 단순 프로필 저장/수정은 id를 반환할 필요가 없다. (추가로, id 값만을 전달하려면 Location 헤더에 작성하는 게 어울리며 Response Body에 id만 반환하는 것은 어색하다는 사실을 알게 되었습니다.)
- 회원 정보를 저장하고 수정한 다음 실제 데이터베이스에서는 한 테이블에 관리되지 않아, 조회를 할 때 조인 등의 과정이 들어간다.
- 그렇기에 (저장은 한 번만 발생하지만) 수정을 할 때마다 결과를 조인해서 전부 제공하기에는 불필요한 성능 낭비가 발생할 것으로 생각한다.
- 회원 정보 같은 경우에는 특별한 로직 같은 게 존재하지 않기에, 그저 저장되거나 수정되었다는 응답 코드만 반환하면 충분할 것이라 판단을 했다.
요약하자면 프로필을 저장/수정할 때 응답 데이터를 모두 보여주는 방식은 조회를 할 때 발생하는 비용이 너무 클 것이라 생각했기 때문입니다. 또한, 아직 코드를 작성하지는 않았으나 저장/수정을 하는 Service에서 조회를 위한 Repository들을 억지로 의존하게 될 수도 있겠다는 생각이 들었습니다.
반영된 데이터를 모두 보여주는 방식은 어떤 점에서 좋을까?
그렇다면 멘토님께서 이야기해 주신, 반영된 데이터를 모두 보여주는 방식은 어떤 상황에서 쓸 수 있을까요?
반영된 데이터를 모두 보여주는 방식은 다음과 같은 상황에 적합할 것 같다는 생각을 할 수 있었습니다.
- 자체적으로 계산 등의 로직이 발생되는 경우
- 생성할 데이터 (ex: 후술 할 음식 정보 등)의 크기가 매우 큰 경우가 아닐 경우
그동안에는 무작정 ID만 반환하거나 상태 코드를 반환하는 방식을 이용하곤 했는데, 첫 번째인 계산 등의 로직이 발생되는 경우에 대해서는 전혀 생각해보지 못했던 부분이라 쉽게 납득할 수 있었습니다.
최종 결론
회원 정보를 저장하고 수정하는 기능에서는 기존처럼 상태 코드만을 반환하는 식으로 설정하였으나, 무조건 이 방식으로만 진행하지는 않고 적절한 상황에는 데이터를 모두 보여주는 방식을 도입하기로 하였습니다.
별도의 API인 <음식 등록> API가 그 예인데요, 사용자가 먹은 음식을 처음 등록하면 그와 연관된 플랜의 운동 상태를 WAITING 상태로 제공되는 등의 정보를 알려주는 게 낫겠다는 생각이 들었기 때문입니다.
// 요청 예
{
"name": "비빔밥",
"servingSize": 488.0,
"unit": "g",
"kcal": 635.3,
"carbohydrate": 97.1,
"protein": 24.0,
"fat": 16.2,
"sodium": 1248.24,
"imageUrl": "https://www.sample.com..."
}
// 응답 예
{
"name": "비빔밥",
"servingSize": 488.0,
"unit": "g",
"kcal": 635.3,
"carbohydrate": 97.1,
"protein": 24.0,
"fat": 16.2,
"sodium": 1248.24,
"imageUrl": "https://www.sample.com...",
"createdAt": "2024-01-01...", // DB에 실제로 저장된 시각을 나타내기 위해 표시
"planStatus": "WAITING" // 플랜 생성 상태를 나타내기 위해 추가 표시
}
또, 플랜을 생성하기 전 음식의 정보를 수정하는 과정에서도 수정된 무게에 따른 예상 칼로리/영양 성분 등도 모두 변경될 것이므로 이럴 때에도 모든 데이터를 반환하면 클라이언트와 협업을 한다고 가정했을 시에도 변경 사항을 인지시킬 수 있겠다는 생각이 들었습니다.
// 요청 예
{
"name": "비빔밥",
"servingSize": 630.24,
"unit": "g"
}
// 응답 예
{
"id": 1,
"name": "비빔밥",
"servingSize": 630.24,
"unit": "g",
"kcal": 820.47, // servingSize, unit 변경에 따른 수정된 칼로리/영양성분을 표시
"carbohydrate": 125.40,
"protein": 30.99,
"fat": 20.92,
"sodium": 1612.07,
"imageUrl": "https://www.sample.com...",
"createdAt": "2024-01-01...",
"planStatus": "WAITING"
}
간단한 고민거리였지만, 이 덕분에 응답 방식에 대해 제 자신만의 의견을 갖출 수 있게 되었습니다.
- ID만을 반환하고자 할 경우에는 Response Body보다는 Location 헤더를 이용하자.
- 생성/수정 API의 규모가 커서 조회 시 조인 과정이 필요하다면 그때는 위의 ID 방식 또는 HTTP 상태 코드만 전달하는 방식을 고려해 보자. 그리고 생성과 조회의 책임을 분할하자.
- 생성/수정 시 반드시 추가적으로 알려줘야 할 정보가 있거나 데이터의 규모가 크지 않은 경우, 또는 조인 과정이 발생하지 않는 경우 등에 대해서는 전체 데이터 반환 방식을 고려해 보자. (단, 혹시 모를 순환 참조를 주의하여 커스텀 매핑 필요)
어떻게 보면 옛날에 작성했던 <Repository의 반환 타입으로는 어떤 것을 써야 할까?>와도 비슷한 맥락이기도 한 것 같아, 링크를 첨부해 드리겠습니다.
추가로 201 Created와 200 OK를 각각 언제 써야 할지도 고민해 볼 수 있었습니다. 개인적인 생각으로는 "생성"의 경우에는 그 의미에 맞게 201을 사용하고, 나머지 경우에 대해서는 200을 쓰는 게 적합하겠다는 생각을 하였습니다.
2. 파일 (사진) 업로드 과정에서의 성능을 최대한 높이려면 어떻게 해야 할까?
다음으로는 파일 (사진) 업로드 과정에서의 성능 개선 지점을 알게 된 것이 있었습니다.
졸업 프로젝트로 진행했던 것에서도 그렇고, 그동안 백엔드에서 <사진(or 파일) 업로드> 기능을 담당했을 때는 항상 Spring에서 Amazon S3에 직접적으로 multipart/form-data를 이용한 방식을 쓰곤 했습니다. 그리고 이 지점에서 발생할 수 있는 문제까지는 생각하지 않았었습니다.
그러던 도중, 멘토님께서 짚어주신 PresignedURL 방식에 대해 알 수 있었습니다. PresignedURL 방식은 클라이언트가 백엔드에게 파일 업로드를 하기 위한 S3 URL을 요청하면 백엔드는 S3에서 URL을 받은 뒤 제공하여 클라이언트가 백엔드 서버를 거치지 않고 직접 S3에 파일을 업로드할 수 있도록 한 방식입니다.
각각의 차이가 어떻게 되어 있는지 간략히 소개하겠습니다.
백엔드 서버를 이용하여 직접 파일을 올릴 경우 (기존 방식)
- 클라이언트가 백엔드에게 파일 업로드를 요청
- 백엔드가 파일의 바이너리 데이터를 MultipartFile 객체로 변환
- S3 API를 이용하여 백엔드가 S3에 파일을 업로드
장점
- 업로드 과정이 백엔드의 통제 하에 있어 로그 기록, 에러 처리, 업로드 정책 등을 일관되게 적용할 수 있음
- 백엔드를 통해서만 S3에 접근할 수 있으므로 보안이 향상됨
- (개인적으로) 진행해 봤던 방식이기에 구현에 오랜 시간이 걸리지 않음
단점
- 파일 업로드가 백엔드 서버를 거치게 되면 백엔드 서버가 금방 죽을 수 있음
- 파일의 사이즈를 제한하거나 동시 업로드 요청 수를 제한해야 하므로 유저 경험이 저하됨
API는 다음과 같이 구성할 수 있을 것입니다.
// 음식 사진 업로드 예
// 요청: POST HTTP multipart/form-data
// 응답
{
"id" : 1,
"name": "비빔밥",
"servingSize": 488.0,
"unit": "g",
"kcal": 635.3,
"carbohydrate": 97.1,
"protein": 24.0,
"fat": 16.2,
"sodium": 1248.24,
"imageUrl": "https://sample.s3.northeast-2.amazonaws.com/...", // 이미지 주소 응답
"createdAt": "2024-01-01 ...",
"planStatus": "WAITING"
}
PresignedURL을 이용하여 파일을 올릴 경우
- 클라이언트가 백엔드에게 PresignedURL을 받기 위한 요청 전송
- 백엔드는 AWS SDK를 이용하여 S3의 PresignedURL을 생성 후 클라이언트에게 반환
- 클라이언트는 전달받은 PresignedURL을 이용하여 직접 S3에 업로드
장점
- 백엔드 서버를 거치지 않으므로 요청이 많아졌을 때에도 백엔드에게 전가되는 부담이 적음
- PresignedURL을 통해 액세스를 허용하는 기간과 범위를 제어하여 유효 기간 동안에만 액세스가 가능하도록 할 수 있음
단점
- 백엔드 서버 방식에 비해 구현이 조금 복잡함
API는 다음과 같이 구성할 수 있습니다.
// 음식 사진 업로드 예
// PresignedURL 요청 API 추가
// GET /api/foods/presigned-url
// 응답
{
"presignedUrl": "https://sample.s3.northeast-2.amazonaws.com/...",
"imageUrl": "https://sample.s3.northeast-2.amazonaws.com/..."
}
// <음식 생성> 요청
{
"name": "비빔밥",
"servingSize": 488.0,
"unit": "g",
"kcal": 635.3,
"carbohydrate": 97.1,
"protein": 24.0,
"fat": 16.2,
"sodium": 1248.24,
"imageUrl": "https://sample.s3.northeast-2.amazonaws.com/..." // PresignedURL에서 받은 이미지 주소를 작성
}
// <음식 생성>에 대한 응답은 동일함
다른 문서들을 참고해 보니, 우아한 형제들 기술 블로그에서도 해당 글과 관련된 내용을 담은 글이 있음을 발견했습니다. 기존 방식에서 PresignedURL 방식을 이용했을 때 얼마나 개선되는지는 실제로 개발을 할 때 보여드리겠습니다.
또는 해당 글도 추천드립니다.
3. AI를 이용하여 음식을 분석하고 플랜을 만드는 과정은 GET? POST?
다음으로는 음식 분석 AI를 이용해서 칼로리를 분석하는 과정과 ChatGPT를 이용해서 플랜을 만드는 과정 두 가지에 대한 고민이 있었습니다.
원래는 2번 고민을 하기 전에 서버에서 직접 업로드를 하고, AI에게 요청을 하기도 하니 POST가 적절하지 않을까라는 생각을 했었습니다.
그러나 PresignedURL 방식을 쓴 지금은, 칼로리 분석과 플랜 목록 생성이 과연 POST가 적절할지에 대한 의구심이 들었습니다.
사진 또는 기타 정보 (본 서비스에서는 회원의 정보와 음식에 대한 칼로리가 해당됩니다.)를 통해 AI에게 질문을 하는 행위라고 생각하니, GET 방식을 떠올릴 수 있었습니다.
일례로 AI를 사용하지 않더라도 단순 <사진 검색>을 구글에 전송할 때에도 GET 방식으로 동작하기 때문입니다. 그리고 사진에 대한 정보는 쿼리 파라미터에 담기게 됨을 발견하였습니다.
그래서 음식의 DB id를 쿼리 파라미터로 담는다면 굳이 POST를 쓰지 않고도 해결할 수 있지 않을까라는 생각을 하게 되었습니다.
또한 GET 방식을 쓰면 POST 방식보다 속도가 빨라진다는 장점이 있습니다. 추가로 GET 방식을 쓰면 POST 방식에 비해 쿼리 파라미터로 인한 보안 문제가 발생할 수 있다는 단점이 있지만, 중요한 회원 정보는 내부에서 이용할 것이며 쿼리 파라미터로 전달될 값은 음식의 DB id에만 해당될 것이기 때문에 크게 문제가 되지 않을 것이라는 생각을 하였습니다.
다만 서비스에서 이용하고 있는 음식 AI의 API는 POST multipart/form-data만을 지원하고 있는데요, 그래서 생각해 낸 방식은 2번 고민 과정에서 얻게 된 S3 이미지 보관 URL을 쿼리 파라미터로 전달하여 스프링이 내부적으로 파일을 다운로드한 뒤 음식 AI에게 요청하는 식으로 하면 되지 않을까라는 생각을 하게 되었습니다. 물론 이럴 경우에는 클라이언트가 사진을 올리는 방식과 속도 측면에서 크게 차이가 나진 않을 것으로 생각되기 때문에, 더 고민을 해봐야 할 것 같습니다. (다만 성능을 떠나서 API의 의도에는 GET이 더 가깝겠다는 생각을 하였습니다.)
수정: 음식 AI의 API 문서를 보니 다행히 파일이 없을 시 fileUrl을 직접 작성하는 방식으로 해결할 수 있음을 파악하였습니다. 이 두 가지의 방식을 비교 분석해보는 과정도 추후 올려보겠습니다.
4. 조회 API를 통합할 수는 없을까? (+ 페이지네이션의 문제)
다음으로는 조회 API 과정에서의 설계 및 성능 고민을 하였습니다.
기존에는 조회 API가 다음과 같은 것들이 있었습니다.
- 먹은 음식 목록 조회
- 당일 마지막으로 먹은 음식 이름 조회
- 당일 먹은 음식의 영양성분 합계 조회
여기에서 아래 두 가지, 당일 마지막으로 먹은 음식 이름을 조회하고 당일 먹은 음식의 영양성분 합계를 조회하는 API가 불필요하다는 생각이 들었습니다.
구현은 크게 어렵지 않지만, 꼭 API를 나누어 개발해야 할까요?
기존의 <먹은 음식 목록 조회> API는 단순히 목록을 나열하는 것에 그쳤었습니다.
{
"foods" : [
{
"id" : 2,
"name" : "비빔밥",
"servingSize" : 488.0,
"unit" : "g",
"kcal" : 635.3,
"carbohydrate" : 97.1,
"protein" : 24.0,
"fat" : 16.2,
"sodium" : 1248.24,
"exercised" : true,
"imageUrl" : "https:www.sample.com...",
"createdAt": "2024-07-29T15:17:32.732609",
"planStatus": "CREATED"
},
{
"id" : 1,
"name" : "산채비빔밥",
"servingSize" : 433.0,
"unit" : "g",
"kcal" : 596.8,
"carbohydrate" : 93.2,
"protein" : 17.8,
"fat" : 16.2,
"sodium" : 1135.35,
"exercised" : false,
"imageUrl" : "https:www.sample.com...",
"createdAt": "2024-07-29T14:17:32.732609",
"planStatus": "CREATED"
}
]
}
처음 얻게 된 개선점은 다음과 같습니다.
- 먹은 음식이 쌓이게 된다면 매우 많아질 것이다. 페이지네이션 작업이 필요하다.
- 당일 먹은 음식에 대해 쿼리 파라미터로 효율적으로 설계할 수 있을 것 같다.
그래서 아래와 같이 수정하였습니다.
// 요청: GET /api/foods?day=2024-07-29&page=0&size=3
// 응답
{
"summary": {
"kcal": 1232.1,
"carbohydrate": 190.3,
"protein": 41.8,
"fat": 32.4,
"sodium": 2383.59
},
"foods" : [
{
"id" : 2,
"name" : "비빔밥",
"serving_size" : 488.0,
"unit" : "g",
"kcal" : 635.3,
"carbohydrate" : 97.1,
"protein" : 24.0,
"fat" : 16.2,
"sodium" : 1248.24,
"exercised" : true,
"image_url" : "https:www.sample.com...",
"created_at": "2024-07-29T15:17:32.732609",
"plan_status": "CREATED"
},
{
"id" : 1,
"name" : "산채비빔밥",
"serving_size" : 433.0,
"unit" : "g",
"kcal" : 596.8,
"carbohydrate" : 93.2,
"protein" : 17.8,
"fat" : 16.2,
"sodium" : 1135.35,
"exercised" : false,
"image_url" : "https:www.sample.com...",
"created_at": "2024-07-29T14:17:32.732609",
"plan_status": "CREATED"
}
]
}
- 조회할 날짜 day와 페이지네이션 관련 파라미터인 page, size를 지정합니다.
- 조회할 날짜 day에 대한 요약 정보인 summary를 앞에 둠으로써 API 요구사항을 충족하였습니다.
- 정렬 순서를 이용하여 마지막으로 먹은 음식을 맨 앞에 조회시킬 수도 있습니다.
덕분에 불필요한 API들을 하나로 통합할 수 있었습니다. 그런데 페이지네이션을 이용했을 때 추가 문제점을 발견할 수 있었는데요, 그것은 데이터가 많아질수록 성능이 느려질 수 있다는 것이었습니다.
페이지네이션을 적용할 시, 서버에서는 SQL로 Offset과 Limit을 이용하여 데이터를 요청합니다. 그런데 이 Offset 방식은 데이터의 수가 늘어날 때 속도가 현저히 느려지는데, 데이터를 전부 읽은 뒤 필요하지 않은 데이터를 제거해 나가는 방식이기 때문입니다.
이를 개선하기 위해 커서 기반의 페이지네이션 방식을 도입하기로 결정했는데, 자세한 것은 실제로 개발할 때 말씀드리도록 하겠습니다. 참고한 링크를 소개해드립니다.
5. 삭제 처리는 어떻게 할까?
기능 중에는 회원이 먹은 음식을 삭제할 수 있는 기능이 있고, 삭제를 한다면 그와 연결된 플랜 데이터 및 플랜 안에 있던 운동 데이터들도 모두 삭제됩니다. 또한, S3에 저장되어 있던 사진을 실제로 삭제하는 요청을 내부적으로 담기로 했습니다.
즉, <음식 삭제> <플랜 삭제> <운동 데이터 삭제> <S3에서 사진 삭제> 네 가지 요청이 작동하는 것이죠.
그렇지만 특히 <S3에서 사진 삭제>를 할 경우, S3에 사진을 직접 업로드했을 때처럼 S3에서의 응답을 기다려야 한다는 단점이 존재할 수 있다는 사실을 깨달았습니다. 이를 개선하기 위해, Soft delete 방식을 도입하기로 하였습니다.
Soft delete 방식이란, 삭제 요청을 했을 시 실제로 데이터베이스에서 삭제하지 않고 삭제된 "상태"로 변경하는 것을 뜻합니다. 예시로 deletedAt이라는 속성에 삭제를 요청한 날짜를 작성하는 것 등이 Soft delete에 해당됩니다.
생각해 보면 회원의 입장에서는 자신이 먹은 음식을 삭제할 때, 이것이 저장된 S3에서 실제로 데이터가 삭제되어야 하는지 등에 대한 것에는 관심이 없을 것입니다. 그저 빨리 음식을 삭제하면 끝입니다.
이런 점에서 S3에 접속하여 사진을 그때 삭제하는 것보다는, 스케줄러를 이용하여 주기적으로 (ex: 1~2달) deleted 된 음식들, 플랜들을 배치 처리하여 삭제하면 좋지 않을까라는 생각이 들었습니다. S3에 삭제 요청을 할 때에도 배치 처리를 한다면 S3 call을 자주 하지 않아도 될 것이기 때문입니다.
여담: 취소 요청을 반영해 볼 수 있을까? + Rate Limit 관리하기
마지막으로는 <Long-Running 작업을 다루기 위한 지침들>이라는 글을 보고 본 서비스에 적용해 볼 수 있는 지점이 있을까를 고민해 보았습니다.
해당 글에서는 사용자가 시간이 오래 걸리는 작업이라 이탈을 했을 때 서버가 불필요한 연산을 수행하지 않도록 취소 요청 API를 별도로 만든 것이 있었고, 한 명의 사용자가 과다한 요청을 하면 서버에 부담이 발생 (+ AI 서비스는 API 호출 비용이 발생) 하기 때문에 Rate Limit을 관리해 볼 수 있다는 지점이 있었습니다.
Rate Limit에 대해서는 생각해 보니 동의가 되어서, 플랜 생성까지 마쳤다면 한 시간 이내에는 다시 음식 분석/플랜 생성 등의 AI 요청을 할 수 없도록 방지해야겠다는 결론을 내릴 수 있었습니다. 그러나 플랜 생성 과정에서 취소를 했다면, 사용자의 요청이 수행된 것이 아니기에 시간제한을 걸은 상태가 아니도록 할 예정입니다. (그래서 음식에 플랜 상태를 추가하기도 한 것입니다.)
다만 이 <취소 요청 API>에 대해서는 마땅히 어떻게 구체적으로 진행될 수 있는지 과정을 고려해보지 못했습니다. 일례로 서버가 여러 대로 분할되어 있다면, 플랜 생성을 하는 서버와 취소 요청을 받은 서버가 있을 경우 취소 요청을 받은 서버가 플랜 생성을 하는 서버에게 중단하라고 요청을 보낼 수 있을까요? 이 점에 대해서는 더 연구해 봐야겠다는 생각이 들었습니다.
그럼에도 Rate Limit에 대해서는 중요한 지점을 배울 수 있었던 좋은 계기였습니다.
결론
이렇게 EatToFit 프로젝트의 API 설계를 해 보는 과정에서 발생한 고민점을 블로그에 기록해 보았습니다. 멘토링 덕분인지는 몰라도 확실히 서비스에 대해 의식적으로 고민해 보니 생각보다 얻어갈 수 있는 게 많은 프로젝트라는 것을 깨달았습니다.
다음으로는 DB 설계 과정에서 들었던 고민들에 대해 다뤄보겠습니다. 읽어주셔서 감사합니다.
'✨ 프로젝트 > EatToFit [F-Lab]' 카테고리의 다른 글
[EatToFit] 매개변수가 매우 많을 때에는 도메인 생성 로직을 어떻게 작성해야 할까? (feat. 빌더 패턴, DDD) (5) | 2024.10.30 |
---|---|
[EatToFit] DB 설계 과정에서의 고민 (5) | 2024.09.15 |
[EatToFit] API 설계 과정에서의 고민 (2) (0) | 2024.08.27 |
[EatToFit] 프로젝트 아이디어 소개 (0) | 2024.08.05 |