Recording/멋쟁이사자처럼 BE 13기

[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_54일차_"@RestController"

LEFT 2025. 2. 25. 18:57

🦁멋쟁이사자처럼 백엔드 부트캠프 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의 흐름 분석

  1. 사용자의 URL을 통한 GET 요청
  2. DispatcherServlet은 요청을 받아 처리
  3. DispatcherServlet은 HandlerMapping을 사용하여 요청 URL에 해당하는 핸들러를 찾음
  4. DispatcherServlet은 HandlerAdapter를 통해 실제 메서드를 호출
  5. 해당 메서드로 요청을 처리하고 반환
  6. HandlerAdapter는 반환된 데이터를 DispatcherServlet에 반환
  7. 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의 유틸리티 메소드로써 입력 스트림에서 출력 스트림으로 데이터를 효율적으로 복사

localhost:8080/download : 테스트

 

  • 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실습