RESTful API Todo 만들기
RESTful API URI 경로
사용자의 모든 Todo 목록 조회
- HTTP 메소드: GET
- URI: /api/users/{userId}/todos
- 설명: 특정 사용자의 모든 Todo 항목을 페이지네이션하여 조회합니다.
특정 Todo 조회
- HTTP 메소드: GET
- URI: /api/users/{userId}/todos/{todoId}
- 설명: 특정 사용자의 특정 Todo 항목의 세부 정보를 조회합니다.
새로운 Todo 생성
- HTTP 메소드: POST
- URI: /api/users/{userId}/todos
- 설명: 특정 사용자에게 새로운 Todo 항목을 생성합니다.
기존 Todo 수정
- HTTP 메소드: PUT
- URI: /api/users/{userId}/todos/{todoId}
- 설명: 특정 사용자의 특정 Todo 항목을 수정합니다.
Todo 삭제
- HTTP 메소드: DELETE
- URI: /api/users/{userId}/todos/{todoId}
- 설명: 특정 사용자의 특정 Todo 항목을 삭제합니다.
URI 설계 원칙
자원(Resource) 중심: URI는 명사로 구성되며, 액션을 나타내는 동사를 사용하지 않습니다.
예: /todos 대신 /api/users/{userId}/todos
계층적 구조: Todo는 User에 속한 자원이므로, URI에 User의 식별자를 포함시켜 계층적 관계를 표현합니다.
예: /api/users/{userId}/todos/{todoId}
일관성: 모든 CRUD(Create, Read, Update, Delete) 작업에 대해 일관된 패턴을 유지하여 API의 예측 가능성을 높입니다.
RESTful 원칙 준수: HTTP 메소드(GET, POST, PUT, DELETE)를 적절히 사용하여 자원의 상태를 관리합니다.
추가 고려사항
버전 관리: API의 변경에 대비하여 URI에 버전 정보를 포함시키는 것을 고려할 수 있습니다.
예: /api/v1/users/{userId}/todos
검색 및 필터링: 필요에 따라 쿼리 파라미터를 사용하여 Todo를 검색하거나 필터링할 수 있습니다.
예: /api/users/{userId}/todos?done=true
페이지네이션: 대량의 데이터를 효율적으로 처리하기 위해 페이지 번호와 페이지 크기를 쿼리 파라미터로 받을 수 있습니다.
예: /api/users/{userId}/todos?page=1&size=10
1. ApiUserController
package net.macaronics.springboot.webapp.api.controller; import java.net.URI; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.hateoas.CollectionModel; import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder; import org.springframework.http.CacheControl; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindingResult; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import net.macaronics.springboot.webapp.dto.ResponseDTO; import net.macaronics.springboot.webapp.dto.todo.TodoCreateDTO; import net.macaronics.springboot.webapp.dto.todo.TodoResponseDTO; import net.macaronics.springboot.webapp.dto.todo.TodoUpdateDTO; import net.macaronics.springboot.webapp.service.TodoService; import net.macaronics.springboot.webapp.utils.PageMaker; @RestController @RequestMapping("/api/users/{userId}/todos") @RequiredArgsConstructor public class ApiTodoController { private final TodoService todoService; /** * HATEOAS 링크 추가 메서드 */ private TodoResponseDTO addTodoLinks(TodoResponseDTO todoResponse) { todoResponse.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiTodoController.class) .getTodoById(todoResponse.getUserId(), todoResponse.getId())) .withSelfRel()); return todoResponse; } /** * 1. GET /api/users/{userId}/todos * 전체 Todo 목록 조회 */ @Operation(summary = "전체 Todo 목록 조회", description = "특정 사용자의 모든 Todo를 페이지네이션하여 조회합니다.") @GetMapping public ResponseEntity<?> getAllTodos(@PathVariable Long userId,@Valid PageMaker pageMaker) { int pageInt = pageMaker.getPage() == null ? 0 : pageMaker.getPage(); PageRequest pageable = PageRequest.of(pageInt, 10); // 예: 페이지당 10개 Page<TodoResponseDTO> todoPage = todoService.getTodosByUserId(userId, pageable); List<TodoResponseDTO> todosWithLinks = todoPage.getContent().stream().map(this::addTodoLinks).collect(Collectors.toList()); CollectionModel<TodoResponseDTO> collectionModel = CollectionModel.of(todosWithLinks); collectionModel.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiTodoController.class).getAllTodos(userId, pageMaker)).withSelfRel()); return ResponseEntity.ok().cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS)) .body(ResponseDTO.builder() .code(1) .message("success") .data(collectionModel) .build()); } /** * 2. GET /api/users/{userId}/todos/{todoId} * 개별 Todo 조회 */ @Operation(summary = "개별 Todo 조회", description = "특정 사용자의 특정 Todo를 조회합니다.") @GetMapping("/{todoId}") public ResponseEntity<?> getTodoById( @PathVariable Long userId, @PathVariable Long todoId) { TodoResponseDTO todo = todoService.getTodoById(userId, todoId); addTodoLinks(todo); return ResponseEntity.ok(todo); } /** * 3. POST /api/users/{userId}/todos * Todo 생성 */ @Operation(summary = "Todo 생성", description = "특정 사용자에게 새로운 Todo를 생성합니다.") @PostMapping public ResponseEntity<?> createTodo( @PathVariable Long userId, @Valid @RequestBody TodoCreateDTO todoCreateDTO, BindingResult bindingResult) throws Exception { if (bindingResult.hasErrors()) { throw new MethodArgumentNotValidException(null, bindingResult); } TodoResponseDTO createdTodo = todoService.createTodo(userId, todoCreateDTO); addTodoLinks(createdTodo); URI location = WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiTodoController.class).getTodoById(userId, createdTodo.getId())).toUri(); return ResponseEntity.created(location) .body(ResponseDTO.builder() .code(1) .message("Todo created successfully") .data(createdTodo) .build()); } /** * 4. PUT /api/users/{userId}/todos/{todoId} * Todo 수정 */ @Operation(summary = "Todo 수정", description = "특정 사용자의 특정 Todo를 수정합니다.") @PutMapping("/{todoId}") public ResponseEntity<?> updateTodo( @PathVariable Long userId, @PathVariable Long todoId, @Valid @RequestBody TodoUpdateDTO todoUpdateDTO, BindingResult bindingResult) throws Exception { if (bindingResult.hasErrors()) { throw new MethodArgumentNotValidException(null, bindingResult); } TodoResponseDTO updatedTodo = todoService.updateTodo(userId, todoId, todoUpdateDTO); addTodoLinks(updatedTodo); return ResponseEntity.ok(ResponseDTO.builder() .code(1) .message("Todo updated successfully") .data(updatedTodo) .build()); } /** * 5. DELETE /api/users/{userId}/todos/{todoId} * Todo 삭제 */ @Operation(summary = "Todo 삭제", description = "특정 사용자의 특정 Todo를 삭제합니다.") @DeleteMapping("/{todoId}") public ResponseEntity<?> deleteTodo( @PathVariable Long userId, @PathVariable Long todoId) { todoService.deleteTodo(userId, todoId); CollectionModel<?> collectionModel = CollectionModel.empty(); collectionModel.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiTodoController.class).getAllTodos(userId, new PageMaker())).withRel("all-todos")); ResponseDTO<?> response = ResponseDTO.builder() .code(1) .message("Todo deleted successfully") .data(collectionModel) .build(); return ResponseEntity.ok(response); } }
기본 URI: @RequestMapping("/api/users/{userId}/todos") 어노테이션은 모든 Todo 관련 엔드포인트의 기본 URI를 설정하여,
각 Todo가 항상 특정 사용자의 컨텍스트에서 접근되도록 보장합니다.
HATEOAS 링크: 각 TodoResponseDTO에는 HATEOAS 링크가 포함되어 있어 API의 발견 가능성과 탐색성을 높입니다. addTodoLinks 메서드는 각 Todo 리소스에 self 링크를 추가합니다.
페이징: getAllTodos 메서드는 PageMaker 유틸리티를 통해 페이징을 지원하여 클라이언트가 Todo 목록을 적절한 크기로 나눠서 받을 수 있게 합니다.
유효성 검사: 입력 유효성 검사는 @Valid 어노테이션과 BindingResult로 처리됩니다. 유효성 검사가 실패하면 MethodArgumentNotValidException이 발생하며, 전역적으로 처리됩니다.
응답 구조: 모든 응답은 일관성을 위해 ResponseDTO로 감싸져 있으며, 여기에는 코드, 메시지, 데이터 페이로드가 포함됩니다.
2. TodoCreateDTO
package net.macaronics.springboot.webapp.dto.todo; import java.time.LocalDate; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Data; @Data public class TodoCreateDTO { @NotBlank(message = "내용은 필수 입니다.") @Size(min = 5, max = 35, message = "글자는 10자에서 35자 사이여야 합니다.") private String description; @NotNull(message = "목표 날짜는 필수입니다.") private LocalDate targetDate; }
3. TodoUpdateDTO
package net.macaronics.springboot.webapp.dto.todo; import java.time.LocalDate; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Data; @Data public class TodoUpdateDTO { @NotBlank(message = "내용은 필수 입니다.") @Size(min = 5, max = 35, message = "글자는 10자에서 35자 사이여야 합니다.") private String description; @NotNull(message = "목표 날짜는 필수입니다.") private LocalDate targetDate; private boolean done; }
4. TodoResponseDTO
package net.macaronics.springboot.webapp.dto.todo; import java.time.LocalDate; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.hateoas.RepresentationModel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @Builder @AllArgsConstructor @NoArgsConstructor public class TodoResponseDTO extends RepresentationModel<TodoResponseDTO> { private Long id; private Long userId; private String username; private String description; @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalDate targetDate; private boolean done; private Long num; // 목록에서 번호를 매길 필드 // 매개변수 있는 생성자 추가 (Projections.constructor에서 요구하는 타입) public TodoResponseDTO(Long id, Long userId, String username, String description, LocalDate targetDate, Boolean done) { this.id = id; this.userId = userId; this.username = username; this.description = description; this.targetDate = targetDate; this.done = done; } }
5. TodoService
package net.macaronics.springboot.webapp.service; import java.time.LocalDate; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import net.macaronics.springboot.webapp.dto.todo.TodoCreateDTO; import net.macaronics.springboot.webapp.dto.todo.TodoResponseDTO; import net.macaronics.springboot.webapp.dto.todo.TodoUpdateDTO; import net.macaronics.springboot.webapp.entity.Todo; import net.macaronics.springboot.webapp.entity.User; import net.macaronics.springboot.webapp.exception.ResourceNotFoundException; import net.macaronics.springboot.webapp.mapper.TodoMapper; import net.macaronics.springboot.webapp.repository.TodoRepository; import net.macaronics.springboot.webapp.repository.UserRepository; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; @Service @Transactional // 이 서비스의 모든 메서드는 기본적으로 트랜잭션 내에서 실행됨 @RequiredArgsConstructor // 필수 필드의 생성자를 자동으로 생성해주는 Lombok 어노테이션 public class TodoService { private final UserRepository userRepository; // User 정보를 관리하는 Repository private final TodoRepository todoRepository; // Todo 정보를 관리하는 Repository private final TodoMapper todoMapper; /** * todo 목록 가져오기 * @param userId * @param pageable * @return */ @Transactional(readOnly = true) public Page<TodoResponseDTO> getTodosByUserId(Long userId, PageRequest pageable) { return todoRepository.findByUserId(userId, pageable).map(todoMapper::convertTodoResponseDTO); } /** * todo 상세정보 가져오기 * @param userId * @param todoId * @return */ @Transactional(readOnly = true) public TodoResponseDTO getTodoById(Long userId, Long todoId) { Todo todo=todoRepository.findByIdAndUserId(todoId, userId).orElseThrow( ()->new ResourceNotFoundException("Todo not found")); return todoMapper.convertTodoResponseDTO(todo); } /** * todo 저장하기 * @param userId * @param createDTO * @return */ public TodoResponseDTO createTodo(Long userId, TodoCreateDTO createDTO) { User user=userRepository.findById(userId).orElseThrow(()->new ResourceNotFoundException(userId +" 유저를 찾츨 수 없습니다.")); Todo todo=todoMapper.ofTodo(createDTO); todo.setUser(user); todo.setDone(false); Todo savedTodo = todoRepository.save(todo); return todoMapper.convertTodoResponseDTO(savedTodo); } /** * todo Update * @param userId * @param todoId * @param updateDTO * @return */ public TodoResponseDTO updateTodo(Long userId, Long todoId, TodoUpdateDTO updateDTO) { Todo todo = todoRepository.findByIdAndUserId(todoId, userId) .orElseThrow(() -> new ResourceNotFoundException("Todo not found")); //더티 체킹 todo.setDescription(updateDTO.getDescription()); todo.setTargetDate(updateDTO.getTargetDate()); todo.setDone(updateDTO.isDone()); return todoMapper.convertTodoResponseDTO(todo); } /** * todo 삭제 * @param userId * @param todoId */ public void deleteTodo(Long userId, Long todoId) { Todo todo = todoRepository.findByIdAndUserId(todoId, userId) .orElseThrow(() -> new ResourceNotFoundException("Todo not found")); todoRepository.delete(todo); } }
6.TodoMapper
package net.macaronics.springboot.webapp.mapper; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import net.macaronics.springboot.webapp.dto.todo.TodoCreateDTO; import net.macaronics.springboot.webapp.dto.todo.TodoFormDTO; import net.macaronics.springboot.webapp.dto.todo.TodoResponseDTO; import net.macaronics.springboot.webapp.entity.Todo; /** * MapStruct를 사용하면, 인터페이스 기반으로 매핑 메서드를 정의하고, 컴파일 시점에 매핑 구현체가 자동으로 생성됩니다. * componentModel = "spring" 설정을 통해 Spring의 빈으로 등록됩니다. */ @Mapper(componentModel = "spring") public interface TodoMapper { @Mapping(source = "user.username", target = "username") TodoResponseDTO convertTodoResponseDTO(Todo todo); // 필요에 따라 다른 매핑 메서드도 추가 // Todo toEntity(TodoRequestDTO dto); @Mapping(source = "username", target = "user.username") Todo ofTodo(TodoFormDTO todoFormDTO); @Mapping(source = "description", target = "description") Todo ofTodo(TodoCreateDTO todoCreateDTO); }
7.TodoRepository
package net.macaronics.springboot.webapp.repository; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import net.macaronics.springboot.webapp.entity.Todo; import net.macaronics.springboot.webapp.entity.User; public interface TodoRepository extends JpaRepository<Todo, Long>, TodoRepositoryCustom { Todo findByIdAndUser(Long id, User user); void deleteByUserId(Long id); Page<Todo> findByUserId(Long userId, Pageable pageable); Optional<Todo> findByIdAndUserId(Long id, Long userId); }
8.GlobalExceptionHandler
package net.macaronics.springboot.webapp.exception; import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.WebRequest; import lombok.extern.slf4j.Slf4j; import net.macaronics.springboot.webapp.dto.ResponseDTO; @ControllerAdvice @Slf4j public class GlobalExceptionHandler { /** * 모든 예외 처리 * @param ex * @param request * @return */ @ExceptionHandler(Exception.class) public final ResponseEntity<?> handleAllExceptions(Exception ex, WebRequest request){ log.error("Unhandled exception occurred", ex); ErrorDetails errorDetails = new ErrorDetails(LocalDateTime.now(), ex.getMessage(), request.getDescription(false)); ResponseDTO<?> response = ResponseDTO.builder() .code(-1) .message("Internal Server Error") .data(errorDetails) .errorCode("INTERNAL_SERVER_ERROR") .build(); return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); } /** * 공통 404 예외 처리: NotFoundException * @param ex * @param request * @return */ @ExceptionHandler(ResourceNotFoundException.class) public final ResponseEntity<?> handleNotFoundException(ResourceNotFoundException ex, WebRequest request){ log.warn("Resource not found exception: {}", ex.getMessage()); ErrorDetails errorDetails = new ErrorDetails(LocalDateTime.now(), ex.getMessage(), request.getDescription(false)); ResponseDTO<?> response = ResponseDTO.builder() .code(-1) .message("Resource Not Found") .data(errorDetails) .errorCode("NOT_FOUND") .build(); return new ResponseEntity<>(response, HttpStatus.NOT_FOUND); } /** * bindingResult.hasErrors() 에러시 반환 처리한다 * 유효성 체크 에러 처리 * @param ex * @param request * @return */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<?> handleValidationExceptions(MethodArgumentNotValidException ex, WebRequest request){ List<String> errors = ex.getBindingResult() .getAllErrors() .stream() .map(DefaultMessageSourceResolvable::getDefaultMessage) .collect(Collectors.toList()); ErrorDetails errorDetails = new ErrorDetails(LocalDateTime.now(), "Validation Failed", request.getDescription(false)); log.warn("Validation failed: {}", errorDetails.getMessage()); ResponseDTO<?> response = ResponseDTO.builder() .code(-1) .message(errors.get(0)) .data(errorDetails) .errorCode("VALIDATION_ERROR") .build(); return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); } // 기타 특정 예외 핸들러 추가 가능 }
9. ResourceNotFoundException
package net.macaronics.springboot.webapp.exception; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; @ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "찾을 수 없습니다.") public class ResourceNotFoundException extends RuntimeException{ private static final long serialVersionUID = -2997057852151179119L; public ResourceNotFoundException(String message) { super(message); } }
10.ErrorDetails
package net.macaronics.springboot.webapp.exception; import java.time.LocalDateTime; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor public class ErrorDetails { private LocalDateTime timestamp; private String message; private String details; }
11. 더미 데이터 JSON 샘플
1.1. Todo 생성 (POST 요청)
새로운 Todo를 생성할 때 사용할 JSON 예시입니다.
{ "description": "Spring Boot 프로젝트 설정하기", "targetDate": "2024-05-01" }
1.2. Todo 수정 (PUT 요청)
기존 Todo를 수정할 때 사용할 JSON 예시입니다.
{ "description": "Spring Boot 프로젝트 설정 및 초기화", "targetDate": "2024-05-05", "done": true }
1.3. Todo 응답 예시 (GET 요청)
특정 Todo를 조회할 때 서버에서 반환하는 JSON 예시입니다.
{ "id": 1, "userId": 1, "description": "Spring Boot 프로젝트 설정하기", "targetDate": "2024-05-01", "done": false, "_links": { "self": { "href": "http://localhost:8080/api/users/1/todos/1" } } }
1.4. Todo 목록 응답 예시 (GET 요청)
Todo 목록을 조회할 때 서버에서 반환하는 JSON 예시입니다.
{ "code": 1, "message": "success", "data": { "_embedded": { "todoResponseDTOList": [ { "id": 1, "userId": 1, "description": "Spring Boot 프로젝트 설정하기", "targetDate": "2024-05-01", "done": false, "_links": { "self": { "href": "http://localhost:8080/api/users/1/todos/1" } } }, { "id": 2, "userId": 1, "description": "데이터베이스 설계", "targetDate": "2024-05-10", "done": false, "_links": { "self": { "href": "http://localhost:8080/api/users/1/todos/2" } } } ] }, "_links": { "self": { "href": "http://localhost:8080/api/users/1/todos?page=0&size=10" } } } }
1.5. Todo 삭제 응답 예시 (DELETE 요청)
Todo를 삭제한 후 서버에서 반환하는 JSON 예시입니다.
{ "code": 1, "message": "Todo deleted successfully", "data": { "_links": { "all-todos": { "href": "http://localhost:8080/api/users/1/todos?page=0&size=10" } } } }
1. Todo 생성 (POST)
HTTP 메소드: POST
URI: http://localhost:8080/api/users/{userId}/todos
예를 들어, userId가 1인 경우:
http://localhost:8080/api/users/1/todos
헤더 설정:
- Content-Type: application/json
바디 설정:
- Body 탭을 클릭하고, JSON 형식을 선택한 후 아래와 같이 더미 데이터를 입력합니다
{ "description": "Spring Boot 프로젝트 설정하기", "targetDate": "2024-05-01" }
요청 전송: 설정이 완료되면 "Send" 버튼을 클릭하여 요청을 전송합니다.
2.3.2. Todo 목록 조회 (GET)
HTTP 메소드: GET
URI: http://localhost:8080/api/users/{userId}/todos
예를 들어, userId가 1인 경우:
http://localhost:8080/api/users/1/todos?page=0&size=10
헤더 설정: 특별한 헤더 설정이 필요하지 않습니다.
요청 전송: "Send" 버튼을 클릭하여 요청을 전송합니다.
2.3.3. 특정 Todo 조회 (GET)
HTTP 메소드: GET
URI: http://localhost:8080/api/users/{userId}/todos/{todoId}
예를 들어, userId가 1이고 todoId가 1인 경우:
http://localhost:8080/api/users/1/todos/1
2.3.4. Todo 수정 (PUT)
HTTP 메소드: PUT
URI: http://localhost:8080/api/users/{userId}/todos/{todoId}
예를 들어, userId가 1이고 todoId가 1인 경우:
http://localhost:8080/api/users/1/todos/1
헤더 설정:
- Content-Type: application/json
바디 설정:
- Body 탭을 클릭하고, JSON 형식을 선택한 후 아래와 같이 더미 데이터를 입력합니다
{ "description": "Spring Boot 프로젝트 설정 및 초기화", "targetDate": "2024-05-05", "done": true }
요청 전송: "Send" 버튼을 클릭하여 요청을 전송합니다.
2.3.5. Todo 삭제 (DELETE)
HTTP 메소드: DELETE
URI: http://localhost:8080/api/users/{userId}/todos/{todoId}
예를 들어, userId가 1이고 todoId가 1인 경우:
http://localhost:8080/api/users/1/todos/1
댓글 ( 0)
댓글 남기기