🦁멋쟁이사자처럼 백엔드 부트캠프 13기 🦁
TIL 회고 - [54]일차
🚀54일차에는 REST API + CURL 명령어로 Todo 프로젝트를 실습해볼 수 있었다.
학습 목표 : @RestController를 활용한 다양한 실습 진행으로 @RestController의 흐름 익히기
학습 과정 : 회고를 통해 작성
▶️실습 - Todo 프로젝트
TodoService 생성
@Service
@RequiredArgsConstructor
public class TodoService {
private final TodoRepository todoRepository;
// 4개의 비즈니스 만들기
// 1. 전체 할 일 조회
@Transactional(readOnly = true)
public List<Todo> getTodos(){
return todoRepository.findAll(Sort.by("id").descending());
}
// 2. 할 일 추가
@Transactional
public Todo addTodo(String todo){
return todoRepository.save(new Todo(todo));
}
// 3. 할 일 완료, 미완료 변경
@Transactional
public Todo updateTodo(Long id){
Todo todo = todoRepository.findById(id).orElseThrow( () -> new EntityNotFoundException("id에 해당되는 todo를 찾을 수 없습니다" + id));
todo.setDone(!todo.isDone());
return todo;
}
// 4. 할 일 삭제
@Transactional
public void deleteTodo(Long id){
// 존재하지 않을 경우에는 예외발생
if(!todoRepository.existsById(id)){
throw new RuntimeException("id에 해당하는 todo가 없습니다." + id);
}
// 존재할 경우 삭제
todoRepository.deleteById(id);
}
}
- return todoRepository.findAll(Sort.by("id").descending());
➡️by에는 정렬할 컬럼을 넣기
➡️.descending() : 내림차순 정렬로 설정 - todo.setDone(!todo.isDone());
➡️반대로 바꿔줄 수 있도록 isDone()을 불러와서 (!)로써 변경
TodoController 생성
@RestController
@RequestMapping("/api/todos")
@RequiredArgsConstructor
public class TodoController {
private final TodoService todoService;
// to-do 얻기
@GetMapping
public ResponseEntity<List<Todo>> getTodos(){
return ResponseEntity.ok(todoService.getTodos());
}
// to-do 추가
@PostMapping
public ResponseEntity<Todo> addTodo(@RequestBody Todo todo){
Todo createTodo = todoService.addTodo(todo.getTodo());
return ResponseEntity.status(HttpStatus.CREATED).body(createTodo);
}
// to-do 수정 (@PatchMapping, @PutMapping 사용 가능) - done (완료/미완료) 여부를 수정함
@PatchMapping("/{id}")
public ResponseEntity<Todo> updateTodo(@PathVariable("id")Long id){
Todo updateTodo = todoService.updateTodo(id);
return ResponseEntity.ok(updateTodo);
}
// to-do 삭제 (@DeleteMapping 사용)
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTodo(@RequestBody Todo todo){
todoService.deleteTodo(todo.getId());
return ResponseEntity.noContent().build();// <Void>로 아무것도 반환하지 않으므로 noContent()를 사용
}
}
- return ResponseEntity.ok(todoService.getTodos());
➡️200 OK 응답과 함께 Todo 리스트 반환 - return ResponseEntity.status(HttpStatus.CREATED).body(createTodo);
➡️201 Created 응답을 반환하고, 생성된 Todo 객체를 응답 바디에 포함 - return ResponseEntity.status(HttpStatus.CREATED).body(createTodo);
➡️상태코드에는 CREATED를 넘기고, body부분에는 만들어놓은 to-do(createTodo)를 넘긴다. - deleteTodo(@RequestBody Todo todo)
➡️@RequestBody로 받는것보다는 @PathVariable 처럼 받는 것이 Rest API 설계원칙을 지키는 것
deleteTodo 와 Rest API
❓@RequestBody 와 @PathVariable 차이점
// to-do 삭제 (@DeleteMapping 사용) - Rest API 고려 X (@RequestBody 사용)
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTodo(@RequestBody Todo todo){
todoService.deleteTodo(todo.getId());
return ResponseEntity.noContent().build();// <Void>로 아무것도 반환하지 않으므로 noContent()를 사용
}
// to-do 삭제 (@DeleteMapping 사용) - Rest API 적용 (@PathVariable 사용)
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTodo(@PathVariable("id")Long id){
todoService.deleteTodo(id);
return ResponseEntity.noContent().build();
}
todo.js - Todo를 위한 자바스크립트 함수 작성
function deleteTodo(id){
// let delTodo = {"id": id};
let xhr = new XMLHttpRequest();
// xhr.open('DELETE','<http://localhost:8080/api/todos>'); // Delete에서 Rest API를 고려하지 않고 @RequestBody 사용
xhr.open('DELETE','<http://localhost:8080/api/todos/'+id>); // Delete에서 Rest API를 고려한 @PathVariable에 대한 것
xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
xhr.send(JSON.stringify()); // Delete에서 Rest API를 고려한 @PathVariable에 대한 것
// xhr.send(JSON.stringify(delTodo)); // Delete에서 Rest API를 고려하지 않고 @RequestBody 사용
}
- xhr.open('DELETE','http://localhost:8080/api/todos');
➡️Delete에서 Rest API를 고려하지 않고 @RequestBody 사용 - xhr.open('DELETE','<http://localhost:8080/api/todos/'+id>);
➡️Delete에서 Rest API를 고려한 @PathVariable에 대한 것 - xhr.send(JSON.stringify());
➡️Delete에서 Rest API를 고려한 @PathVariable에 대한 것 - xhr.send(JSON.stringify(delTodo));
➡️Delete에서 Rest API를 고려하지 않고 @RequestBody 사용
정리하자면
@RequestBody를 없애는 것이 RESTful함
DELETE 요청의 핵심은 "어떤 리소스를 삭제할 것인가"이므로,
URL 경로(/api/todos/{id})에 ID를 포함하는 것이 RESTful한 설계
@RequestBody의 단점 :
➡️@RequestBody를 사용하면, 클라이언트가 불필요하게 JSON 데이터를 요청 바디에 포함해야 함.
@RestController의 흐름 분석
- 사용자의 URL을 통한 GET 요청
- DispatcherServlet은 요청을 받아 처리
- DispatcherServlet은 HandlerMapping을 사용하여 요청 URL에 해당하는 핸들러를 찾음
- DispatcherServlet은 HandlerAdapter를 통해 실제 메서드를 호출
- 해당 메서드로 요청을 처리하고 반환
- HandlerAdapter는 반환된 데이터를 DispatcherServlet에 반환
- DispatcherServlet은 HttpMessageConverter를 사용하여 반환된 맵을 JSON 형식으로 변환
⚠️삭제 오류 해결 - 이벤트 버블링 방지
// 동적으로 x버튼을 클릭했을 때 처리해야할 이벤트를 추가한다.
removeSpan.addEventListener('click',function(){
let liObj = this.parentElement;
console.log(liObj);
deleteTodo(liObj.getAttribute("id"));
liObj.remove();
return false; // 이벤트 전파 (=이벤트 버블링)가능성 존재
});
- return false;
➡️return false를 하지 않으면 수정이 되면서 update를 호출
➡️이미 삭제된 것을 수정하려고 하니 오류가 발생하는 것 (이벤트 전파 가능성 존재) - 함수 시작부분에서 stopPropagation(); 추가
event.stopPropagation();
➡️이벤트 전파 (=이벤트 버블링) 가능성 방지
JS의 (X)버튼 클릭 시 updateTodo()가 실행되지 않도록 수정
- X버튼을 누르면 500 오류가 발생하지 않고 취소선이 성공적으로 적용됨
Rest API에서 예외처리
- GlobalExceptionHandler클래스를 만들어 전역적인 예외를 처리할 수 있도록 구현
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<String> handleRuntimeException(RuntimeException e){
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Error : " + e.getMessage());
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<String> handleResponseStatusException(ResponseStatusException e){
return ResponseEntity.status(e.getStatusCode())
.body("Custom Error : " + e.getMessage());
}
}
ErrorTestController로 테스트
@RestController
public class ErrorTestController {
@GetMapping("/api/e")
public String test(){
throw new RuntimeException("API에서 에러가 발생했습니다!");
}
@GetMapping("api/n")
public String test2(@RequestParam(name = "id", required = false) Long id){
if(id == null){
throw new RuntimeException("ID가 없습니다.");
}
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "user를 찾지 못했습니다.");
}
}
- api/e, api/n 접속
➡️API에러 에러가 발생했습니다. 메시지를 GlobalExceptionHandler에서 정의했던
.body(”Error : “ + e.getMessage())를 통해 Error: 뒤에 문자열이 붙어서 예외가 처리
- n?id=2 처럼 id=2번의 값을 넣어서 처리해보면 user를 찾지 못했다는 예외발생까지 출력 가능
❓@RestControllerAdvice와 @ControllerAdvice의 차이점
➡️주요 차이는 응답을 처리하는 방식
@RestController :
JSON이나 XML 같은 데이터를 반환하는 RESTful 서비스에 적합
모든 메소드가 기본적으로 @ResponseBody 로 처리
메소드가 HTTP 응답 본문에 직접 데이터를 작성
반면, @Controller :
HTML 페이지를 렌더링하는 데 더 적합
또한 @Controller 를 사용하면 파일 다운로드 기능 을 구현 가능
메소드에서 HttpServletResponse 객체를 사용하여 파일의 내용을 직접 응답 스트림에 씀
정리하자면
@ControllerAdvice | HTML View와 API(둘 다) 지원 (@Controller 및 @RestController에 적용) |
@RestControllerAdvice | API 응답에 최적화 (@RestController에만 적용, @ResponseBody가 자동 포함) |
@ControllerAdvice
- @Controller와 @RestController 모두에 적용
- 예외가 발생하면, 기본적으로 view를 반환하려고 시도
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public String handleRunTimeException(RuntimeException e, Model model){
model.addAttribute("message", "Error: " + e.getMessage());
return "errorPage"; // HTML View 반환 (API 응답 아님)
}
}
- RuntimeException이 발생 시 HTML 템플릿(errorPage.html)을 반환
@RestControllerAdvice
- @ResponseBody가 자동으로 적용됨
- 모든 응답이 JSON 형태로 변환
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<String> handleRunTimeException(RuntimeException e){
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Error : " + e.getMessage()); // JSON 응답
}
}
- @RestController에서 발생한 예외가 JSON으로 반환
(=@ResponseBody를 추가할 필요 없이 자동으로 JSON 변환)
파일 다운로드/업로드
- SpringBoot에서 @RestController를 사용하여 Rest API 작성 후 CURL로 파일 업로드 구현
- MultipartFile 인터페이스를 사용하여 Spring에서의 파일 업로드 처리를 구현
application.yml 설정
- 파일의 최대 크기나 전체 요청 크기 제한
spring:
application:
name: restexam
servlet:
multipart:
max-file-size: 2MB
max-request-size: 4MB
domain/UploadInfo 클래스 생성
- 게시판에 게시글 하나 등록 시 이 게시글 안에 파일이 하나 저장될 수 있도록 구현할 수 있을 것
- 게시글, 파일 두 개의 테이블에서 Foreign Key를 어디서 가져야할지를 고려
- 파일이 저장될때 어떤 정보를 가져야하고, 어떤 테이블에 저장해야할지를 고려
파일 다운로드
@Getter@Setter
public class UploadInfo {
private String description; // 파일에 대한 설명
private String tag; // 파일의 태그
}
@Slf4j
@RestController
public class FileController {
// 파일 다운로드
@GetMapping("/download")
public void downloadFile(HttpServletResponse response){
Path path = Paths.get("c:/Temp/DumpFile/upload/cat.jpg"); // 파일의 경로 지정
response.setContentType("image/jpeg"); // 이미지의 타입 지정 (.jpeg)
try(InputStream inputStream = Files.newInputStream(path)){// 파일이 추상화된 객체 = Files, path에서 가져와서 InputStream을 보내기)
StreamUtils.copy(inputStream, response.getOutputStream());
response.flushBuffer();
}catch(IOException e){
log.error("파일 다운로드 중 오류 발생 : " + e.getMessage());
}
}
}
- @RestController
➡️ Spring MVC에서 RESTful 웹 서비스를 만들 때 사용되는 어노테이션
JSON 응답을 기본값으로 반환하며, API 컨트롤러 역할을 수행 - StreamUtils.copy
StreamUtils : Spring의 유틸리티 메소드로써 입력 스트림에서 출력 스트림으로 데이터를 효율적으로 복사
- public void downloadFile(HttpServletResponse response)
➡️클라이언트(브라우저 등)에게 파일 데이터를 응답으로 보내기 위해 사용 - response.setContentType("image/jpeg")
➡️HTTP 응답의 Content-Type을 image/jpeg로 설정
(=브라우저나 클라이언트가 이미지 파일임을 인식할 수 있음) - try(InputStream inputStream = Files.newInputStream(path))
➡️Files.newInputStream(path) : 지정된 파일(path)을 읽기 위한 InputStream을 생성
➡️try-with-resources 문법을 사용하여 자동으로 inputStream을 닫도록 함 - StreamUtils.copy(inputStream, response.getOutputStream());
➡️inputStream에서 읽은 데이터를 response.getOutputStream()으로 복사하여 클라이언트에 전달 - response.flushBuffer();
➡️응답 스트림을 즉시 플러시(전송)하여, 파일이 제대로 전달되도록 함
파일 업로드
- MultipartFile 로부터 다양한 메소드를 통해 파일의 정보를 꺼내올 수 있다.
// 파일 업로드
@PostMapping("/upload")
public ResponseEntity<String> uploadFile(
@RequestParam("file") MultipartFile file,
@RequestParam(name = "info", required = false)UploadInfo uploadInfo){
log.info(file.getOriginalFilename());
try(InputStream inputStream = file.getInputStream()){
StreamUtils.copy(inputStream, new FileOutputStream("c:/Temp/DumpFile/upload/" + file.getOriginalFilename()));
return ResponseEntity.ok().body("파일저장이 완료되었습니다." + file.getOriginalFilename());
}catch(IOException e){
return ResponseEntity
.badRequest()
.body("파일 업로드 실패 : " + file.getOriginalFilename());
}
}
- file.getInputStream()
➡️업로드된 파일을 읽기 위한 InputStream을 가져옴
- new FileOutputStream("c:/Temp/DumpFile/upload/" + file.getOriginalFilename())
➡️파일을 저장할 경로를 지정
➡️"c:/Temp/DumpFile/upload/" 경로에 업로드된 파일 이름으로 저장
- StreamUtils.copy(inputStream, new FileOutputStream(...))
➡️inputStream에서 읽은 데이터를 FileOutputStream으로 복사하여 실제 파일로 저장
🚀실습 - POST로 업로드 테스트
- upload URL에 POST 메소드 방식으로 BODY태그에 file → 업로드할 사진을 넣어준다.
파일 업로드 - 개선
// 파일 업로드
@PostMapping("/upload")
public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file){
log.info("파일명 : " + file.getOriginalFilename());
try(InputStream inputStream = file.getInputStream()){
StreamUtils.copy(inputStream,
new FileOutputStream("c:/Temp/DumpFile/upload/" + UUID.randomUUID().toString() + file.getOriginalFilename()));
return ResponseEntity.ok().body("파일저장이 완료되었습니다. 파일명: " + file.getOriginalFilename());
}catch(IOException e){
return ResponseEntity
.badRequest()
.body("파일 업로드 실패 : " + file.getOriginalFilename());
}
}
curl 로도 업로드 구현 가능
- 명령프롬프트 창에 입력
curl -X POST <http://localhost:8080/upload> -F "file=@C:\\Users\\98758\\Downloads\\free.jpg"
- 해당 경로로가보면 UUID.randomUUID().toString() 으로 인해 랜덤하게 이름이 만들어지고
- 기존의 이름인 free가 맨 뒤에 붙어서 저장된 것을 확인할 수 있다.
- 업로드된 파일은 c:/Temp/DumpFile/upload/ 폴더에 저장
🚀회고 결과 :
이번 회고에서는 파일 업로드, 다운로드 등을 @RestControllerAdvice, @ControllerAdvice 를 통해 실습해볼 수 있었다.
@RestControllerAdvice와 @ControllerAdvice의 차이점에 대해서는 좀 더 공부할 필요가 있을 것 같다.
Todo를 만들어보며 todo.js와 연동되지 않는 것에 대해 오류 해결이 필요할 것 같다.
- Todo프로젝트에 Rest API 방식 적용
- 확장 프로그램을 통한 POST 방식 전달
- CURL 명령프롬프트를 통한 POST 방식 전달
느낀 점 :
Docker 포트 충돌 문제와 todo.js의 출력 문제로 오류 해결할 부분이 많았던 회고였다.
자주 궁금했던 파일 업로드, 다운로드 기능에 대해서도 공부할 수 있었다.
향후 계획 :
- Event 프로젝트 - Rest API실습
- Product 프로젝트 - Rest API실습
'Recording > 멋쟁이사자처럼 BE 13기' 카테고리의 다른 글
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_56일차_"ThreadLocal, Spring Security" (0) | 2025.02.27 |
---|---|
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_55일차_"DTO, Security" (0) | 2025.02.26 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_53일차_"CURL" (0) | 2025.02.25 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_52일차_"RESTful API" (0) | 2025.02.21 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_51일차_"Criteria + hr DB" (0) | 2025.02.20 |