CustomException + GlobalExceptionHandler 도입으로 모든 Controller의 try/catch 제거, 에러 응답 형식 일관화
3차 프로젝트에서 예외처리는 이미 하고 있었다. 다만 Controller마다 try/catch를 직접 썼고, 에러 응답 형식이 제각각이었다. STEP 3는 CustomException + GlobalExceptionHandler 두 파일을 추가해서, Controller에서 try/catch를 완전히 없애고 에러 응답을 일관된 JSON 형식으로 통일한다.
IllegalArgumentException은 HTTP 상태코드를 담지 못함exception/CustomException.java
exception/ErrorResponse.java
exception/GlobalExceptionHandler.java
3차 코드에서 모든 Controller 메서드는 try/catch로 예외를 직접 잡고 있다.
Controller가 늘어나면 같은 에러 처리 코드를 계속 반복해야 한다.
또한 서비스마다 throw new IllegalArgumentException("...")을 던지는데,
이걸 CustomException으로 바꾸면 에러 코드, HTTP 상태코드를 예외 클래스 자체에 담을 수 있어 관리가 쉬워진다.
@PostMapping("/write") public ResponseEntity<?> write(...) { try { boardService.write(boardDto, user); return ResponseEntity.ok(...); } catch (IllegalArgumentException e) { // 😩 Controller마다 반복 return ResponseEntity.badRequest() .body(Map.of("message", e.getMessage())); } } @DeleteMapping("/delete/{id}") public ResponseEntity<?> delete(...) { try { boardService.delete(id, user); return ResponseEntity.ok(...); } catch (IllegalArgumentException e) { // 😩 또 반복 return ResponseEntity.badRequest() .body(Map.of("message", e.getMessage())); } }
@PostMapping("/write") public ResponseEntity<?> write(...) { boardService.write(boardDto, user); // ✨ try/catch 없음! // 예외는 GlobalExceptionHandler가 처리 return ResponseEntity.ok(...); } @DeleteMapping("/delete/{id}") public ResponseEntity<?> delete(...) { boardService.delete(id, user); // ✨ try/catch 없음! return ResponseEntity.ok(...); }
{
"message": "삭제 권한이 없습니다."
}
// Map.of("message", ...) 로 직접 만든 것
// 상태코드가 뭔지, 에러코드가 뭔지 없음
{
"status": 403,
"message": "삭제 권한이 없습니다.",
"code": "FORBIDDEN",
"timestamp": "2024-01-01T12:00:00"
}
// ErrorResponse DTO로 만든 것
// 프론트에서 status, code로 분기 처리 가능
{"status":403, "message":"삭제 권한이 없습니다.", "code":"FORBIDDEN", "timestamp":"..."}src/main/java/com/example/board/exception/CustomException.java
exception 패키지를 새로 만들고 아래 파일들을 추가한다.
package com.example.board.exception; import lombok.Getter; /** * 우리 프로젝트 전용 커스텀 예외 * RuntimeException을 상속 → throws 선언 없이 어디서든 던질 수 있음 * message + httpStatus를 함께 담아서 GlobalExceptionHandler가 그대로 응답에 사용 */ @Getter public class CustomException extends RuntimeException { private final int httpStatus; // HTTP 상태코드 (400, 403, 404, 500...) // 메시지 + 상태코드만 받는 생성자 public CustomException(String message, int httpStatus) { super(message); // RuntimeException(message) 호출 → getMessage()로 꺼낼 수 있음 this.httpStatus = httpStatus; } }
package com.example.board.exception; import lombok.Getter; import java.time.LocalDateTime; /** * 에러 응답 DTO — GlobalExceptionHandler가 이 형식으로 JSON 응답을 만든다 * 프론트(React)에서 error.response.data.status, .message 로 접근 */ @Getter public class ErrorResponse { private final int status; // HTTP 상태코드 private final String message; // 에러 메시지 (사용자에게 보여줄 텍스트) private final LocalDateTime timestamp; // 발생 시각 public ErrorResponse(int status, String message) { this.status = status; this.message = message; this.timestamp = LocalDateTime.now(); } }
package com.example.board.exception; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; /** * @RestControllerAdvice = @ControllerAdvice + @ResponseBody * → 모든 Controller에서 발생하는 예외를 자동으로 가로채서 처리 * → 리턴값을 JSON으로 자동 변환 */ @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { // 1. 우리가 직접 만든 CustomException 처리 @ExceptionHandler(CustomException.class) public ResponseEntity<ErrorResponse> handleCustomException(CustomException e) { log.warn("CustomException: status={}, message={}", e.getHttpStatus(), e.getMessage()); return ResponseEntity .status(e.getHttpStatus()) .body(new ErrorResponse(e.getHttpStatus(), e.getMessage())); } // 2. 예상 못한 서버 에러 (NullPointerException 등) 처리 @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleException(Exception e) { // error 레벨 — 예상 못한 에러는 스택트레이스 포함해서 로그 log.error("Unexpected error", e); return ResponseEntity .status(500) .body(new ErrorResponse(500, "서버 오류가 발생했습니다.")); // 클라이언트에는 내부 에러 메시지 노출 안 함 — 보안상 중요! } }
3차에서 throw new IllegalArgumentException("...")으로 던지던 것을 모두 throw new CustomException("...", HTTP상태코드)로 교체한다.
@Service @RequiredArgsConstructor public class BoardService { public BoardDto getDetail(Long id, User loginUser) { Board board = boardRepository.findById(id) .orElseThrow(() -> new CustomException("없는 게시글입니다.", 404)); // 404 Not Found // 조회수 증가 로직... return toDto(board); } @Transactional public void update(Long id, BoardDto boardDto, User user) { Board board = boardRepository.findById(id) .orElseThrow(() -> new CustomException("없는 게시글입니다.", 404)); if (!board.getUser().getId().equals(user.getId())) throw new CustomException("수정 권한이 없습니다.", 403); // 403 Forbidden board.setTitle(boardDto.getTitle()); board.setContent(boardDto.getContent()); } @Transactional public void delete(Long id, User user) { Board board = boardRepository.findById(id) .orElseThrow(() -> new CustomException("없는 게시글입니다.", 404)); if (!board.getUser().getId().equals(user.getId())) throw new CustomException("삭제 권한이 없습니다.", 403); boardRepository.delete(board); } // write(), getBoardList(), toDto() 는 변경 없음 }
public void register(UserDto userDto) { if (userRepository.existsByUsername(userDto.getUsername())) throw new CustomException("이미 사용중인 아이디입니다.", 409); // 409 Conflict if (userRepository.existsByNickname(userDto.getNickname())) throw new CustomException("이미 사용중인 닉네임입니다.", 409); // 나머지 로직 동일 } public Map<String, String> login(String username, String password) { User user = userRepository.findByUsername(username) .orElseThrow(() -> new CustomException("아이디 또는 비밀번호가 틀렸습니다.", 401)); // 401 Unauthorized if (!bCryptPasswordEncoder.matches(password, user.getPassword())) throw new CustomException("아이디 또는 비밀번호가 틀렸습니다.", 401); String token = jwtUtil.createToken(user.getUsername(), user.getNickname(), user.getRole()); return Map.of("token", token, "role", user.getRole()); }
@Transactional public void deleteUser(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException("없는 유저입니다.", 404)); boardRepository.deleteByUser(user); userRepository.delete(user); } @Transactional public void deleteBoard(Long boardId) { Board board = boardRepository.findById(boardId) .orElseThrow(() -> new CustomException("없는 게시글입니다.", 404)); boardRepository.delete(board); }
@RestController @RequiredArgsConstructor @RequestMapping("/api/board") public class BoardController { private final BoardService boardService; @GetMapping("/list") public ResponseEntity<?> list(@RequestParam(defaultValue="0") int page) { return ResponseEntity.ok(boardService.getBoardList(page)); } @GetMapping("/detail/{id}") public ResponseEntity<?> detail( @PathVariable Long id, @AuthenticationPrincipal CustomUserDetails userDetails) { return ResponseEntity.ok(boardService.getDetail(id, userDetails.getUser())); } @PostMapping("/write") public ResponseEntity<?> write( @RequestBody BoardDto boardDto, @AuthenticationPrincipal CustomUserDetails userDetails) { boardService.write(boardDto, userDetails.getUser()); return ResponseEntity.ok(Map.of("message", "글쓰기 성공")); } @PutMapping("/edit/{id}") public ResponseEntity<?> edit( @PathVariable Long id, @RequestBody BoardDto boardDto, @AuthenticationPrincipal CustomUserDetails userDetails) { boardService.update(id, boardDto, userDetails.getUser()); return ResponseEntity.ok(Map.of("message", "수정 성공")); } @DeleteMapping("/delete/{id}") public ResponseEntity<?> delete( @PathVariable Long id, @AuthenticationPrincipal CustomUserDetails userDetails) { boardService.delete(id, userDetails.getUser()); return ResponseEntity.ok(Map.of("message", "삭제 성공")); } }
@RestController @RequiredArgsConstructor @RequestMapping("/api/user") public class UserController { private final UserService userService; @PostMapping("/register") public ResponseEntity<?> register(@RequestBody UserDto userDto) { userService.register(userDto); return ResponseEntity.ok(Map.of("message", "회원가입 성공")); } @PostMapping("/login") public ResponseEntity<?> login(@RequestBody UserDto userDto) { Map<String, String> result = userService.login( userDto.getUsername(), userDto.getPassword() ); return ResponseEntity.ok(result); } } // AdminController도 동일하게 try/catch 제거
| 상태코드 | 의미 | 우리 프로젝트 사용 상황 |
|---|---|---|
| 200 OK | 성공 | 모든 정상 응답 |
| 400 Bad Request | 잘못된 요청 | 요청 형식 오류 (일반적인 입력 오류) |
| 401 Unauthorized | 인증 실패 | 로그인 실패 (아이디/비밀번호 오류) |
| 403 Forbidden | 권한 없음 | 다른 사람 글 수정/삭제 시도 |
| 404 Not Found | 없는 리소스 | 없는 게시글/유저 조회 |
| 409 Conflict | 충돌 | 중복 아이디/닉네임 가입 |
| 500 Internal Server Error | 서버 내부 오류 | 예상 못한 에러 — 클라이언트에 내부 정보 노출 안 함 |