
안녕하세요, 오늘은 REST API에 대한 깊이 있는 이야기를 나누고자 합니다.
많은 백엔드 개발자와 프론트엔드 개발자들이 REST API를 사용하지만, 과연 그 본질을 정확히 이해하고 있을까요?
로이 필딩(Roy Fielding)이 주창한 REST의 원래 의도를 파헤쳐 보고, Java 백엔드와 JavaScript 프론트엔드 관점에서 어떻게 하면 더 나은 API를 설계하고 활용할 수 있을지 함께 고민해 봅시다.
여러분에게 REST API란 무엇인가요?
아마 대부분의 백엔드 개발자들은 Spring Boot와 같은 프레임워크를 이용해 GET, POST, PUT, DELETE와 같은 HTTP 메서드를 사용하여 특정 리소스(URL)에 대한 CRUD(Create, Read, Update, Delete) 작업을 수행하는 방식으로 이해하고 있을 것입니다.
프론트엔드 개발자들은 fetch나 axios를 이용해 이 URL에 요청을 보내고 JSON 형태의 응답을 받아 웹 페이지를 동적으로 업데이트하는 데 익숙할 것입니다.
예를 들어, 백엔드에서는 다음과 같이 API를 정의하고,
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping
public List<User> getAllUsers() {
// 사용자 목록 조회 로직
return userService.findAllUsers();
}
@PostMapping
public User createUser(@RequestBody User user) {
// 새 사용자 생성 로직
return userService.saveUser(user);
}
@PutMapping("/{id}")
public User updateUser(@PathVariable Long id, @RequestBody User user) {
// 사용자 정보 수정 로직
return userService.updateUser(id, user);
}
@DeleteMapping("/{id}")
public void deleteUser(@PathVariable Long id) {
// 사용자 삭제 로직
userService.deleteUser(id);
}
}
프론트엔드에서는 다음과 같이 이 API를 호출합니다.
import React, { useEffect, useState } from 'react';
import axios from 'axios';
function UserList() {
const [users, setUsers] = useState([]);
useEffect(() => {
axios.get('/api/users')
.then(response => {
setUsers(response.data);
})
.catch(error => console.error('Error fetching users:', error));
}, []);
const handleDeleteUser = (id) => {
axios.delete(`/api/users/${id}`)
.then(() => {
setUsers(users.filter(user => user.id !== id));
})
.catch(error => console.error('Error deleting user:', error));
};
return (
<div>
<h2>User List</h2>
<ul>
{users.map(user => (
<li key={user.id}>
{user.name} <button onClick={() => handleDeleteUser(user.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
export default UserList;
이 방식은 매우 보편적이며 효율적입니다.
하지만 로이 필딩은 2000년 그의 박사 논문에서 제시한 REST의 핵심 철학이, 우리가 흔히 사용하는 이 방식과는 거리가 있다고 이야기합니다.
그의 논문에서 가장 강조되는 개념은 바로 HATEOAS(Hypermedia As The Engine Of Application State) 입니다.
HATEOAS는 API 응답이 단순한 데이터를 넘어, 다음에 어떤 작업을 수행할 수 있는지에 대한 정보를 링크 형태로 포함해야 한다는 개념입니다.
이는 클라이언트(프론트엔드)가 서버(백엔드)의 API 문서 없이도 동적으로 애플리케이션 상태를 전이하고 상호작용할 수 있도록 돕는 REST의 가장 중요한 제약 조건 중 하나입니다.
Java 백엔드에서는 Spring HATEOAS 라이브러리를 사용하여 HATEOAS를 쉽게 구현할 수 있습니다.
예를 들어, 특정 사용자 정보를 조회했을 때, 해당 사용자에 대한 수정 및 삭제 링크를 응답에 포함시킬 수 있습니다.
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping("/{id}")
public EntityModel<User> getUserById(@PathVariable Long id) {
User user = userService.findUserById(id);
// EntityModel을 사용하여 User 객체와 함께 링크를 추가
return EntityModel.of(user,
linkTo(methodOn(UserController.class).getUserById(id)).withSelfRel(),
linkTo(methodOn(UserController.class).updateUser(id, null)).withRel("update").withType("PUT"),
linkTo(methodOn(UserController.class).deleteUser(id)).withRel("delete").withType("DELETE")
);
}
}
위 코드에서 EntityModel.of()를 통해 User 객체와 함께 self (자기 자신 조회), update (수정), delete (삭제) 링크를 생성하여 응답에 포함시킵니다.
이렇게 되면 응답은 다음과 같은 형태를 띠게 됩니다.
{
"id": 123,
"name": "홍길동",
"email": "hong@example.com",
"_links": {
"self": {
"href": "http://localhost:8080/users/123"
},
"update": {
"href": "http://localhost:8080/users/123",
"type": "PUT"
},
"delete": {
"href": "http://localhost:8080/users/123",
"type": "DELETE"
}
}
}
프론트엔드 개발자는 이 응답을 받아 단순히 데이터를 파싱하는 것을 넘어, 응답에 포함된 링크를 통해 다음 액션을 동적으로 결정할 수 있습니다.
import React, { useState, useEffect } from 'react';
import axios from 'axios';
function UserDetail({ userId }) {
const [user, setUser] = useState(null);
const [links, setLinks] = useState({});
useEffect(() => {
axios.get(`/api/users/${userId}`)
.then(response => {
setUser(response.data);
setLinks(response.data._links); // 응답에서 _links 추출
})
.catch(error => console.error('Error fetching user:', error));
}, [userId]);
const handleUpdate = () => {
if (links.update) {
axios({
method: links.update.type, // 'PUT'
url: links.update.href, // 'http://localhost:8080/users/123'
data: { name: "새로운 이름" } // 수정할 데이터
})
.then(response => {
setUser(response.data);
alert('사용자 정보가 업데이트되었습니다!');
})
.catch(error => console.error('Error updating user:', error));
}
};
const handleDelete = () => {
if (links.delete) {
axios({
method: links.delete.type, // 'DELETE'
url: links.delete.href // 'http://localhost:8080/users/123'
})
.then(() => {
alert('사용자가 삭제되었습니다.');
// 삭제 후 목록으로 돌아가거나 UI 업데이트 로직
})
.catch(error => console.error('Error deleting user:', error));
}
};
if (!user) return <div>Loading...</div>;
return (
<div>
<h2>User Detail: {user.name}</h2>
<p>Email: {user.email}</p>
{links.update && <button onClick={handleUpdate}>Update User</button>}
{links.delete && <button onClick={handleDelete}>Delete User</button>}
</div>
);
}
export default UserDetail;
이렇게 하면 백엔드가 /users/{id} 대신 /api/v2/users/{id}와 같이 URL 구조를 변경하더라도, 프론트엔드는 응답에 포함된 _links.update.href나 _links.delete.href를 그대로 사용하기 때문에 코드 수정 없이 유연하게 대응할 수 있습니다.
즉, 프론트엔드는 백엔드의 URL 구조에 대한 의존성을 줄이고, 백엔드는 API 변경에 대한 부담을 덜 수 있게 됩니다.
많은 개발팀에서 HATEOAS를 잘 사용하지 않는 주된 이유는 복잡성 때문입니다.
Spring HATEOAS 같은 라이브러리가 있지만, 모든 API 응답에 동적으로 링크를 생성하고 관리하는 것은 개발 초기 단계에서 추가적인 시간과 노력을 요구합니다.
특히 '빨리빨리' 문화가 강한 국내 개발 환경에서는 이러한 추가적인 설계 및 구현 비용이 부담으로 작용합니다.
하지만 HATEOAS를 완벽하게 구현하지 못하더라도, REST의 정신을 이해하고 API를 개선할 수 있는 방안은 많습니다.
많은 API가 백엔드 데이터베이스의 필드명을 그대로 노출하는 경향이 있습니다.
{
"user_id": 123,
"status_cd": "ACT",
"reg_dt": "2023-10-01T12:00:00Z"
}
프론트엔드 개발자는 status_cd가 무엇을 의미하는지, ACT가 Active를 의미하는지 API 문서를 찾아보거나 백엔드 개발자에게 물어봐야 합니다.
또한, 내부 데이터베이스 필드명이 그대로 노출되어 보안상 취약점을 노출할 수도 있습니다.
백엔드에서는 DTO(Data Transfer Object)를 사용하여 데이터베이스 모델과 프론트엔드에 전달되는 데이터 형식을 분리해야 합니다.
public class UserResponseDto {
private Long id;
private String name;
private String email;
private String status; // "active" 또는 "inactive" 등으로 변환
private String createdAt; // "2023년 10월 1일" 등으로 포맷팅
// 생성자와 Getter/Setter
}
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping("/{id}")
public UserResponseDto getUserById(@PathVariable Long id) {
User user = userService.findUserById(id);
// User entity를 UserResponseDto로 변환 로직
return UserResponseMapper.toDto(user);
}
}
프론트엔드는 이처럼 의미 있는 필드명과 포맷팅된 데이터를 받아 직접 사용자에게 보여줄 수 있습니다.
// 개선된 응답을 받는 프론트엔드
axios.get('/api/users/123')
.then(response => {
const user = response.data;
console.log(`사용자 이름: ${user.name}, 상태: ${user.status}, 생성일: ${user.createdAt}`);
// 바로 UI에 적용 가능
});
프론트엔드에서 API의 URL을 직접 코딩하는 것은 백엔드 API의 변경에 매우 취약하게 만듭니다. 백엔드가 리소스의 경로를 변경하면, 프론트엔드는 해당 URL을 사용하는 모든 코드를 수정하고 재배포해야 합니다.
// 하드코딩된 URL 예시 (프론트엔드)
const createUser = (userData) => {
axios.post('/api/users', userData) // '/api/users'가 변경되면 프론트엔드 코드도 변경해야 함
.then(...)
};
개선 방안: HATEOAS를 완벽하게 사용하지 않더라도, 최소한 API의 루트(Root) URL 또는 초기 진입점만 하드코딩하고, 이후의 모든 리소스는 해당 응답에서 제공되는 경로를 사용하는 방식을 고려할 수 있습니다.
// 초기 진입점 API 요청
axios.get('/api') // 예: 모든 API의 시작점을 알려주는 Root API
.then(response => {
// response.data 에 { "users": { "href": "/api/users" } } 같은 링크가 있다고 가정
const usersLink = response.data.users.href;
// 이후 요청은 이 링크를 사용
axios.get(usersLink)
.then(userListResponse => {
// ...
});
});
백엔드에서는 이 초기 진입점 API를 통해 주요 리소스들의 링크를 제공할 수 있습니다.
로이 필딩이 강조한 REST의 진정한 의미는 프론트엔드가 API 문서 없이도 유연하게 상호작용할 수 있는 시스템을 만드는 것입니다.
HATEOAS는 이러한 목표를 달성하기 위한 핵심적인 메커니즘입니다.
하지만 현실적인 제약과 개발 속도를 고려할 때, 모든 API에 HATEOAS를 완벽하게 적용하는 것은 어려울 수 있습니다.
내부 시스템용 API의 경우, 잘 정의된 API 문서(Swagger, OpenAPI 등)와 백엔드-프론트엔드 간의 긴밀한 협업만으로도 충분히 효율적인 개발이 가능합니다.
반면, 외부 개발자에게 제공되는 공용 API의 경우 HATEOAS를 적용하여 API의 유연성과 확장성을 높이는 것이 장기적으로 유리할 수 있습니다.
중요한 것은 우리가 흔히 사용하는 HTTP 메서드와 URL 조합이 REST API의 전부는 아니라는 것을 이해하는 것입니다.
REST의 '정신'인 애플리케이션 상태 전이(State Transfer)와 하이퍼미디어(Hypermedia)의 개념을 염두에 두고 API를 설계한다면, 백엔드는 더욱 견고하고 유지보수하기 쉬운 시스템을 구축하고, 프론트엔드는 백엔드에 대한 의존성을 줄여 더욱 유연한 개발을 할 수 있을 것입니다.