심화 2 컬렉션📚 전체 맵심화 4 @Transactional
05c · 3차 심화과정 · STEP 3 / 5

예외처리 심화

CustomException + GlobalExceptionHandler 도입으로 모든 Controller의 try/catch 제거, 에러 응답 형식 일관화

이 파일의 학습 목적
STEP 3 — 예외처리를 왜 다시 배우나?

3차 프로젝트에서 예외처리는 이미 하고 있었다. 다만 Controller마다 try/catch를 직접 썼고, 에러 응답 형식이 제각각이었다. STEP 3는 CustomException + GlobalExceptionHandler 두 파일을 추가해서, Controller에서 try/catch를 완전히 없애고 에러 응답을 일관된 JSON 형식으로 통일한다.

심화 5단계 중 지금 여기 — STEP 3
STEP 1 ✅
AOP
로깅 자동화
STEP 2 ✅
컬렉션
이론 정리
📌 지금 여기
STEP 3
예외처리
STEP 4
@Transactional
readOnly 최적화
STEP 5
@Builder
DTO 생성 개선
3차까지의 문제
  • · BoardController · UserController · AdminController
      전부 try/catch 반복
  • · 에러 응답 형식이 Controller마다 제각각
  • · IllegalArgumentException은 HTTP 상태코드를 담지 못함
  • · 500 에러 시 내부 메시지가 클라이언트에 노출될 위험
STEP 3이 해결하는 것
  • · CustomException — message + httpStatus 함께 담기
  • · ErrorResponse — 통일된 JSON 응답 형식 DTO
  • · GlobalExceptionHandler — 모든 Controller try/catch 대신 처리
  • · Controller 코드 대폭 간결화 + 500에러 메시지 보안 처리
신규 생성
exception/CustomException.java
신규 생성
exception/ErrorResponse.java
신규 생성
exception/GlobalExceptionHandler.java
수정 파일 (try/catch 제거 + CustomException 교체): BoardService · UserService · AdminService · BoardController · UserController · AdminController
Why
왜 바꾸는가

3차 코드에서 모든 Controller 메서드는 try/catch로 예외를 직접 잡고 있다. Controller가 늘어나면 같은 에러 처리 코드를 계속 반복해야 한다. 또한 서비스마다 throw new IllegalArgumentException("...")을 던지는데, 이걸 CustomException으로 바꾸면 에러 코드, HTTP 상태코드를 예외 클래스 자체에 담을 수 있어 관리가 쉬워진다.

❌ 3차 방식 — 반복되는 try/catch
BoardController.java (3차)
@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()));
    }
}
✅ 심화 방식 — 깔끔한 Controller
BoardController.java (심화)
@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(...);
}
Error Response
에러 응답 JSON — 3차 vs 심화
❌ 3차 — 형식이 제각각
{
    "message": "삭제 권한이 없습니다."
}
// Map.of("message", ...) 로 직접 만든 것
// 상태코드가 뭔지, 에러코드가 뭔지 없음
✅ 심화 — 일관된 형식
{
    "status": 403,
    "message": "삭제 권한이 없습니다.",
    "code": "FORBIDDEN",
    "timestamp": "2024-01-01T12:00:00"
}
// ErrorResponse DTO로 만든 것
// 프론트에서 status, code로 분기 처리 가능
Flow
예외 발생 시 전체 흐름
예외 발생 → 자동으로 GlobalExceptionHandler가 받아서 처리
React
api.delete("/api/board/delete/5") 요청 (다른 사람 글 삭제 시도)
권한 없는 삭제 요청 시나리오
Controller
BoardController.delete() — try/catch 없음
boardService.delete() 그냥 호출 — 예외가 터지면 위로 전파됨
try/catch 없음 → 예외 전파
Service
BoardService.delete() — 권한 체크 실패
작성자가 다름 → CustomException 던짐
throw new CustomException("삭제 권한이 없습니다.", 403)
GlobalHandler
GlobalExceptionHandler — CustomException 자동으로 받음
@RestControllerAdvice가 모든 Controller에서 발생한 예외를 자동으로 가로챔
@ExceptionHandler(CustomException.class)
React
일관된 ErrorResponse JSON 응답
{"status":403, "message":"삭제 권한이 없습니다.", "code":"FORBIDDEN", "timestamp":"..."}
HTTP 403 Forbidden
Full Code
전체 코드
exception/CustomException.java — 신규
📌 파일 위치

src/main/java/com/example/board/exception/CustomException.java
exception 패키지를 새로 만들고 아래 파일들을 추가한다.

exception/CustomException.java
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;
    }
}
exception/ErrorResponse.java — 신규
exception/ErrorResponse.java
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();
    }
}
exception/GlobalExceptionHandler.java — 신규
exception/GlobalExceptionHandler.java
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, "서버 오류가 발생했습니다."));
        // 클라이언트에는 내부 에러 메시지 노출 안 함 — 보안상 중요!
    }
}
Service — IllegalArgumentException → CustomException 교체

3차에서 throw new IllegalArgumentException("...")으로 던지던 것을 모두 throw new CustomException("...", HTTP상태코드)로 교체한다.

service/BoardService.java — 예외 교체
@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() 는 변경 없음
}
service/UserService.java — 예외 교체
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());
}
service/AdminService.java — 예외 교체
@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);
}
Controller — try/catch 제거 후 전체 코드
controller/BoardController.java — try/catch 완전 제거
@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", "삭제 성공"));
    }
}
controller/UserController.java — try/catch 완전 제거
@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 제거
HTTP 상태코드 — 우리 프로젝트 기준
상태코드의미우리 프로젝트 사용 상황
200 OK성공모든 정상 응답
400 Bad Request잘못된 요청요청 형식 오류 (일반적인 입력 오류)
401 Unauthorized인증 실패로그인 실패 (아이디/비밀번호 오류)
403 Forbidden권한 없음다른 사람 글 수정/삭제 시도
404 Not Found없는 리소스없는 게시글/유저 조회
409 Conflict충돌중복 아이디/닉네임 가입
500 Internal Server Error서버 내부 오류예상 못한 에러 — 클라이언트에 내부 정보 노출 안 함
심화 2 컬렉션📚 전체 맵심화 4 @Transactional