예외 처리의 기본 원칙과 실무 적용 방법

1. 들어가며 

안정적인 애플리케이션 실행을 위해 예외 처리(Exception Handling)는 필수적입니다. 프로그램 실행 중 예외가 발생하면 시스템이 갑작스럽게 종료되거나 비정상 동작을 일으킬 수 있습니다. 이러한 상황을 방지하기 위해, 예외를 적절히 처리하는 방법을 익히는 것은 개발자의 기본 역량 중 하나입니다.

이번 포스트에서는 예외 처리의 기본 원칙과 실무 적용 방법을 중심으로, 예외 처리를 어떻게 설계하고 관리해야 하는지에 대해 다뤄보고자 합니다.


2. 기본적인 예외 처리 방법

2.1 try-catch를 활용한 예외 처리

      try {
          // 예외가 발생할 가능성이 있는 코드
      } catch(예외 클래스명 e) {
          // 예외 처리 코드
      }

만약 try 내 에러가 발생하면 메서드 내부의 catch블록에서 예외를 처리합니다.

2.2 다중 예외 처리

      try {
      } catch(ArrayIndexOutOfBoundsException e) {
      } catch(Exception e) {
      }

Exception은 최상위 예외 클래스이므로, 가장 마지막에 작성해야 합니다. 그렇지 않으면 나머지 예외 블록이 무시될 수 있습니다.

2.3 finally 블록

try {
    // 예외 발생 가능 코드
} catch (Exception e) {
    // 예외 처리
} finally {
    // 항상 실행되는 코드
    scan.close();
    System.out.println("프로그램 종료");
}

예외 발생 여부와 상관없이 반드시 실행되어야 하는 코드(자원 해제 등)를 처리합니다


3. 예외를 던지는 throw와 throws

3.1 throws - 예외 전파

메서드 내부에서 예외를 처리하지 않고, 호출한 곳으로 예외를 전파합니다.

    // 메서드를 실행하고 예외를 전파하는 클래스
    public void someMethod() throws IOException {
        if(에러) {
            throw new IOException("파일 읽기 오류"); // 호출한 callMyMethod로 예외를 던짐
        }
    }
    // 메서드를 호출하고 예외를 처리하는 클래스
    public void callMyMethod() {
        try {
            someMethod();
        } catch(IOException e) { // someMethod()로부터 예외를 받아 처리함.
            System.out.println("예외 처리");
        }
    }

3.2 임의의 예외 처리

개발자가 직접 규칙 위반을 처리해야 할 경우, 임의로 예외를 발생시킬 수 있습니다.

if (data < 0) {
    throw new IllegalArgumentException("음수는 입력할 수 없습니다.");
}

3.3 임의의 예외 처리하는 이유?

코드 오류가 아닌 규칙 위반은 기본 예외 처리에 걸리지 않으므로, 임의의 예외 처리가 필요합니다.

3.4 임의의 예외 처리 예제

      import java.util.Scanner;
      public class practice_example {
          public static void main(String[] args) {
              Scanner scan = new Scanner(System.in);
              int count = 0;
              int data = 0;
              int sum = 0;

              try {
                  while(count < 5) {
                      System.out.println("숫자를 입력하세요.");
                      data = scan.nextInt();
                      if(data < 0) {
                      throw new IllegalArgumentException("음수는 아니됩니다.");
                      }
                      sum += data;
                      count ++;
                      }
                  System.out.println("숫자들의 합: " + sum);
                  } catch (IllegalArgumentException e)  {
                      System.out.println("예외 발생");
                  }
          }
      }

4. 현업에서는 왜 런타임 예외(Runtime Exception)를 선호하는가?

체크 예외는 예외 처리를 강제하여 코드가 복잡해질 수 있지만, 복구 불가능한 예외의 경우 강제 처리할 필요가 없으며, Spring이 런타임 예외 중심으로 설계되어 있어 자연스럽게 통합되므로 코드의 간결성과 유연성을 높일 수 있습니다.


5. 체크 예외는 언제 사용할까?

상황 예시:

  • 외부 API와의 통신 중 일시적인 네트워크 장애 발생
  • 체크 예외를 통해 3초 뒤 재시도 후 정상 응답 처리
try {
    externalService.callAPI();
} catch (IOException e) {
    Thread.sleep(3000); // 재시도
    externalService.callAPI();
}

체크 예외의 장점은 복구 가능성이 있다는 것입니다. 1번 서버가 외부 서버 2번과 소통을 하는데 일시적인 네트워크 연결 장애로 연결 실패한다면?

"응답 실패하였습니다"로 메세지를 반환하는 것보다 3초 뒤 연결 재시도 후 정상 응답을 반환하는 것이 더 좋은 반환이 될 수 있습니다.


6. 예외 처리 구조의 한계

로컬(개별) 예외 처리 구조에서는 예외 처리 코드가 각 컨트롤러나 서비스에 분산되어 관리됩니다. 이러한 구조는 작은 규모의 애플리케이션에서는 비교적 쉽게 관리할 수 있지만, 규모가 커지거나 복잡도가 증가하면 여러 가지 문제점이 발생합니다.

// 컨트롤러
@PostMapping
public ResponseEntity<StudentCreateResponseDto> createCourseAPI(
        @RequestBody StudentCreateRequestDto studentCreateRequestDto
) {
    try {
        StudentCreateResponseDto response = studentService
                .createStudent(studentCreateRequestDto);
        return new ResponseEntity<>(response, HttpStatus.CREATED);
    } catch (CourseNotFoundException e) {
        return new ResponseEntity<>(HttpStatus.NOT_FOUND);
    }
// 서비스
@Transactional
public StudentCreateResponseDto createStudent(
StudentCreateRequestDto studentCreateRequestDto
) {
    Long foundCourseId = studentCreateRequestDto.getCourseId();
    Course foundCourse = courseRepository.findById(foundCourseId)
            .orElseThrow(() -> new InvalidRequestException("course not found"));

    Student newStudent = Student.createFrom(
            studentCreateRequestDto.getStudentName(),
            foundCourse
    );

    Student savedStudent = studentRepository.save(newStudent);
    return StudentCreateResponseDto.createFrom(savedStudent);
    }

위 코드에는 2 가지 문제점이 있습니다.

6.1 컨트롤러가 예외 처리 역할을 가진다.

컨트롤러 계층은 요청과 응답에 관한 처리를 담당하는 것은 책임 위반에 해당됩니다. 이를 무시한다면 서비스의 예외 발생 지점의 양에 따라 컨트롤러의 코드 길이가 증가하여 가독성을 크게 헤치는 결과가 초래됩니다.

6.2 컨트롤러 - 응답 객체 간의 강한 결합

CreateResponseDto는 코스 생성 응답만을 위한 객체로, 한정된 역할을 수행하기에 강 결합이 문제라 보기는 어렵습니다. 다만 비즈니스 요구사항은 언제나 변할 수 있고, 그런 예외 상황을 처리하기에는 부족한 부분이 있습니다.

비슷한 예로 최근 뉴스피드를 만드는 팀 프로젝트를 진행 중 deleted 응답을 스트링으로 반환하는 이슈가 있었습니다.

@DeleteMapping
    public ResponseEntity<String> deleteNewsfeed(
            HttpServletRequest request
    ) {
        Long id = (Long) request.getAttribute("userId");
        newsfeedService.deleteNewsfeed(id);
        return ResponseEntity.ok("삭제 완료");
    }
}

7. 문제 해결

7.1 컨트롤러가 예외 처리 역할을 가지는 문제

우선 컨트롤러에서 예외 처리 부담을 덜어내기 위해서는 글로벌 예외 처리 핸들러를 만들어야 합니다. 이렇게 하면 모든 예외 처리를 한 곳에서 관리하므로 코드 간결화 및 가독성이 향상되고 새로운 예외 처리 로직이 추가되더라도 글로벌 핸들러만 수정하면 되므로 유지보수가 간단해집니다.

- 글로벌 예외 처리 적용 예

Spring의 @RestControllerAdvice@ExceptionHandler를 사용

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(InvalidRequestException.class)
    public ResponseEntity<Map<String, Object>> invalidRequestException(InvalidRequestException ex) {
        HttpStatus status = HttpStatus.BAD_REQUEST;
        return getErrorResponse(status, ex.getMessage());
    }
public class InvalidRequestException extends RuntimeException {
    public InvalidRequestException(String message) {
        super(message);
    }
}

컨트롤러 메서드 실행 중 예외가 발생하면 DispatcherServlet이 이를 1차 감지하고 HandlerExceptionResolver를 통해 @RestControllerAdvice@ExceptionHandler로 정의한 메서드로 예외를 전달합니다. 이 과정은 자동으로 진행되므로 try-catch를 사용하지 않고도 예외를 정상적으로 처리할 수 있습니다.

7.2 컨트롤러와 응답 객체 간의 강한 결합 문제

- 중간 매개체(ApiResponse)를 활용한 확장

public record ApiResponse<T>(HttpStatus status, String message, T data) {

    public static <T> ApiResponse<T> success(HttpStatus status, String message, T data) {
        return new ApiResponse<>(status, message, data);
    }

    public static <T> ApiResponse<T> error(HttpStatus status, String message) {
        return new ApiResponse<>(status, message, null);
    }
}
@ExceptionHandler(DataNotFoundException.class)
    public ResponseEntity<ApiResponse<?>> handleDataNotFoundException(DataNotFoundException ex) {
        ApiResponse<Object> errorResponse = ApiResponse.error(HttpStatus.NOT_FOUND, ex.getMessage());
        return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
    }
@DeleteMapping
    public ResponseEntity<ApiResponse> deleteNewsfeed(
            HttpServletRequest request
    ) {
        Long id = (Long) request.getAttribute("userId");

        newsfeedService.deleteNewsfeed(id);
        ApiResponse apiResponse = ApiResponse.success(HttpStatus.OK,"deleted",null);

        return new ResponseEntity<ApiResponse>(apiResponse, HttpStatus.OK);
    }
}
{
    "status": "OK",
    "message": "deleted",
    "Data": null
}

다양한 예외 상황을 커버할 수 있도록 개발자는 응답 형태(틀)에 대한 부분까지 고민해야 합니다. 이 것은 예외 상황에서도 응답의 일관성을 보장하는 하나의 방법입니다.

중간 매개체(ApiResponse) 객체에 DtoResponse 객체를 담아서 반환한다면 변화에 유연하게 대처할 수 있습니다. 그리고 성공 응답 뿐 아니라 실패 응답에 대해서도 일관된 결과를 제공할 수 있습니다.


8. 마치며

이번 글에서는 예외 처리의 기본 원칙과 실무 적용 방법을 중심으로, 예외 처리의 중요성과 효율적인 관리 방법에 대해 살펴보았습니다.

예외 처리는 프로그램의 안정성과 유지보수성을 결정짓는 중요한 요소로 초기 단계에서는 간단한 try-catch 구조로 시작할 수 있지만, 서비스가 커지고 복잡도가 증가함에 따라 글로벌 예외 처리 핸들러, ApiResponse와 같은 전략적 접근이 필요합니다.

앞으로의 저의 목표는 예외 처리를 단순히 오류를 방지하는 수단이 아닌, 사용자 경험을 향상시키고 안정성을 강화하는 도구로 활용하는 것입니다.


  1. api 공통 응답 적용하기(Filter, Interceptor, ResponseBodyAdvice)
  2. 팀 프로젝트 : GRAMSLAM