땃쥐네

[토이프로젝트] 게시판 시스템(board-system) 6. 커스텀 예외, core 모듈 본문

Project

[토이프로젝트] 게시판 시스템(board-system) 6. 커스텀 예외, core 모듈

ttasjwi 2024. 10. 2. 17:51

 

이번 글에서는 자바/코틀린에서의 예외 개념을 한 번 짚고, 왜 제가 프로젝트에서 커스텀 예외를 정의했는 지, 어떤 식으로 사용했는지 설명해보도록 하겠습니다.

 


1. 예외 계층

출처: https://interviewnoodle.com/exception-in-java-89a0b41e0c45

 

커스텀 예외를 설명하기 전에 Java의 예외 계층을 설명해보겠습니다.

제가 지금 사용하는 언어는 Kotlin 이지만 기본기는 Java 쪽에 있기 때문에 Java 기준으로 설명하겠습니다.

 

  1. Throwable은 모든 예외와 오류의 최상위 조상 클래스입니다.
    • 이 아래에는 ExceptionError가 있습니다.
  2. Error는 주로 JVM 관련 오류나 시스템적인 문제로, 개발자가 예측하거나 처리하기 어려운 심각한 문제들입니다.
    • 예: OutOfMemoryError, StackOverflowError 등
  3. Exception 은 주로 개발자가 처리할 수 있는 예외를 나타냅니다.
    • Exception 아래에 있는 예외들은 프로그램 내에서 발생할 수 있는 상황을 처리할 수 있도록 개발자가 코드에서 다룰 수 있습니다.
  4. RuntimeException 하위의 예외는 언체크 예외(Unchecked Exception)로, 이 예외는 컴파일러가 처리 여부를 강제하지 않습니다. 즉, 발생할 수 있지만 try-catch로 감싸지 않아도 되고, throws로 넘기지 않아도 됩니다.
    • 예: NullPointerException, IllegalArgumentException 등
  5. RuntimeException이 아닌 Exception 하위의 예외들은 체크 예외(Checked Exception)**라고 하며, 컴파일러가 반드시 예외 처리를 강제합니다. 이 경우, try-catch로 처리하거나 메서드 시그니처에 throws로 명시적으로 선언해야 합니다.
    • 예: IOException, SQLException 등
  6. 정리하면:
    • 체크 예외(Checked Exception): RuntimeException을 상속받지 않는 Exception 하위 예외들로, 반드시 처리해야 합니다.
    • 언체크 예외(Unchecked Exception): RuntimeException 및 그 하위 예외들로, 처리 강제가 없습니다.
    • Error는 시스템적으로 매우 심각한 문제로, 일반적으로 개발자가 처리하지 않습니다.

 

그래서 호출하는 측에서 예외 처리를 강제하려면 체크예외로 발생시키고 예외처리를 강제하지 않으면서도 필요에 따라 처리할 수 있는 유연한 코드를 작성하려면 언체크 예외로 예외를 발생시켜야합니다.

 

참고로 코틀린에서는 체크예외의 처리를 강제하지 않습니다. 그래서 코를린에서는 예외를 Exception 으로 발생시켜도 상관은 없습니다만, 보통 호출하는 측에서 처리를 강제하게 만들고 싶다면 Result 를 사용하는게 관리하기 편합니다.


2. 순수 자바 예외 사양

public class Exception extends Throwable {
    @java.io.Serial
    static final long serialVersionUID = -3387516993124229948L;

    public Exception() {
        super();
    }

    public Exception(String message) {
        super(message);
    }


    public Exception(String message, Throwable cause) {
        super(message, cause);
    }

    public Exception(Throwable cause) {
        super(cause);
    }

 

Java 예외는 그 예외를 설명할 수 있도록 message 를 담아 생성하거나, 그 예외가 발생하기 전의 근원 예외가 있다면 그 예외를 담아 생성할 수 있도록 설계되어 있습니다.

 

근원 예외를 담게 된다면 향후 예외 디버깅시 이 예외가 발생한 근원예외도 추적 대상에 포함되기 때문에

만약 try-catch 문을 통해 어떤 근원 예외를 감싸서 새로 예외를 발생시켜야한다면 함께 담아 발생시키도록 해야합니다.

 

 

그런데 이런 기본 Java 예외 사양에서는 담을 수 있는 정보가 위 두 가지밖에 없고 한정적입니다.

 

당장은 우리 나라에서만 서비스하는데 혹시 서비스가 커져서 다국적으로 서비스를 제공해야한다면?

발생한 예외에 따라서 HTTP 상태코드를 편하게, 다르게 내릴 수 있게 할 수 없을까?

사용자가 어떤 부분을 잘못했는지, 특정 부분을 참고할 수 있도록 응답으로 함께 내려주고 싶은데, 그런 방법은 없을까?

 

 

 

자바 개발자분들이 많이들 읽으시는 이펙티브 자바의 Item 72 에서는 "표준 예외를 사용하라" 라는 제목을 붙이며 왜 자바 표준 예외를 써야하는 지 기술합니다.

 

여기서는

 

  • 다른 사용자가 익혀서 사용하기 쉽다
  • 예외는 직렬화할 수 있어서 부담이 된다(직렬화에는 많은 부담이 따른다.)
  • 예외 클래스 수가 적을 수록 메모리 사용량도 줄고 클래스를 적재하는 시간도 적게 걸린다

 

이와 같은 이유로 순수 자바 예외를 사용하는 것을 권장합니다.

한편 그러면서도  더 많은 정보를 제공하길 원한다면 표준 예외를 확장해도 좋다고 합니다.

 

저자님께서 순수 자바 예외를 써야한다고 기술한 부분은 제 애플리케이션 운영관점에서는 치명적인 문제는 아니였는데

 

제 애플리케이션은 Spring 프레임워크 기반으로 작성한다는 관점에서 생각해보면 RestControllerAdvice 등에서 예외를 잡아서 사용자에게 예외 응답 메시지를 내려주는데 사용하는데, 예외 자체가 좀 더 많은 정보를 담아둔다면 예외 응답 메시지를 내려주는데 효과적일 것 같다는 생각이 들었습니다. 그래서 저는 커스텀 예외를 사용하기로 했어요.


3. Core 모듈

 

커스텀 예외는 모든 모듈에서 공통적으로 사용할 수 있어야 해서, core 모듈에 둬서 관리하도록 할거에요.

 

core 모듈은 계층(api, 애플리케이션, 도메인, ...)에 관계없이 모든 모듈들에서 알아야하는 공유 개념들을 모아둔 모듈입니다.

rootProject.name = "board-system"

include(

    // 설정 최종 구성 및 실행
    "board-system-container",

    // 공용 모듈
    "board-system-core",

    // api
    "board-system-api:api-deploy",
)

 

settings.gradle.kts 에서 공용 모듈을 선언하고 intellij gradle을 reload 합니다.

 

subprojects {
	// 생략

    dependencies {

        val sharedModuleNames = listOf("board-system-core")

        if(project.name !in sharedModuleNames) {
            implementation(project(":board-system-core"))
        }

        implementation(Dependencies.KOTLIN_REFLECT.fullName)
        testImplementation(Dependencies.SPRING_BOOT_TEST.fullName)
    }

 

루트의 build.gradle.kts 쪽에서는 core 모듈이 아닐 경우 core 모듈을 모두 implementation 의존성을 가지도록 설정했습니다.

 

 

core 모듈을 만들었겠다, 이곳에 새로 코드를 작성해보죠.

 


4. 커스텀 예외 정의

본격적으로 커스텀 예외를 정의해보겠습니다.

 

4.1 ErrorStatus

package com.ttasjwi.board.system.core.exception

/**
 * 예외가 어떤 이유로 발생했는 지 설명하기 위한 상태 유형입니다.
 */
enum class ErrorStatus {

    /**
     * 연산의 대상을 찾지 못 한 경우
     */
    NOT_FOUND,

    /**
     * 미인증, 인증 실패, ...
     */
    UNAUTHENTICATED,

    /**
     * 요청사항이 애플리케이션 연산이 정의한 입력값 조건과 일치하지 않을 때
     */
    INVALID_ARGUMENT,

    /**
     * 연산을 수행할 권한이 없을 때
     */
    FORBIDDEN,

    /**
     * 요구사항이 현재 애플리케이션 상태와 충돌할 때
     * (예: 닉네임 중복, ...)
     */
    CONFLICT,


    /**
     * 애플리케이션 자체적인 문제 혹은 다른 애플리케이션 연동 과정(DB 장애, 타사 API 장애, 개발자 실수로 인해 처리하지 못한 예외...)
     */
    APPLICATION_ERROR
}

 

예외가 어떤 유형인 지 상태를 정의했습니다.

근데 이 이름들을 보면 뭔가 Http 상태코드 를 생각하실겁니다.

 

맞습니다. 어느 정도 예외 응답을 내려줄 때 어떤 Http 상태코드를 내릴 지 염두해두고 만든 것입니다.

하지만 애플리케이션의 핵심 비즈니스 로직을 담당하는 도메인 계층과 같은 곳에서 웹 요청에 대한 정보를 알게 하는 것, 특히나 HttpStatus 와 같은 스프링 기술 의존이 강한 객체를 두는 것은 의존성 방향 관점에서 맞지 않다고 생각해서 우리 애플리케이션에서 발생할 수 있는 예외의 유형들을 ErrorStatus 로 별도의 개념을 만들었습니다.

 

이후 경우에 따라 새로 추가할 수 있습니다만 일단 생각나는 대로 에러 유형들을 기술했어요.

 

4.2 CustomException

package com.ttasjwi.board.system.core.exception


/**
 * 애플리케이션 내에서 발생하는 커스텀 예외의 기본 클래스.
 * 이 클래스를 상속하여 각 예외에 맞는 커스텀 예외를 정의할 수 있습니다.
 *
 * @param status 예외가 발생한 이유를 설명하는 상태 정보.
 * @param code 예외 메시지 구성을 위한 코드 ("Error.xxx" 형식).
 * @param args 메시지 템플릿에서 사용할 인자들 (빈 리스트일 경우 인자 없고, 순서대로 사용됨).
 * @param source 예외를 발생시킨 필드 또는 맥락을 설명하는 값 (예: "nickname")
 * @param debugMessage 디버깅을 위한 메시지 (사용자에게 제공되지 않음).
 * @param cause 근본 원인이 되는 예외 (선택적).
 *
 *
 * @author ttasjwi
 * @date 2024/10/02
 */
abstract class CustomException(
    val status: ErrorStatus,
    val code: String,
    val args: List<Any?>,
    val source: String,
    val debugMessage: String,
    cause: Throwable? = null
) : RuntimeException(debugMessage, cause)

 

추상 예외인 CustomException 을 정의했습니다. 부모로는 RuntimeException 을 두고 있습니다.

이 예외는 예외처리 기능구현을 담당하는 개발자들이 편리하게 사용할 수 있도록 아래의 6개 공통 속성을 가지게 했습니다.

 

status

위에서 정의한 ErrorStatus 입니다. 예외가 어떤 유형인지 나타내기 위한 enum 으로서, Http 상태코드를 염두해두고 만들었습니다.

 

code

예외 메시지 국제화를 위해 사용한 값입니다. code 를 기반으로 사용자에게 내려둘 예외 메시지를 작성할 것입니다.

예를 들어 이메일로 회원을 조회하는데 실패했다면 "Error.MemberEmailNotFound" 와 같은 코드를 갖게 합니다.

 

이때 코드는 "Error." 으로 시작하도록 하기로 했는데, 향후 예외 메시지와 일반 메시지를 별도로 분리해서 관리하기 쉽게 예외 code는 "Error." 로 시작하도록 하게 했습니다.

 

args

예외 메시지 구성을 위해 필요한 인자들을 args 라는 이름의 리스트에 담습니다. 리스트의 요소로 null 을 허용합니다.

 

source

예외가 어떤 이유로 발생했는 지 그 이유에 해당하는 키워드를 사용자에게 시사할 수 있도록 했습니다.

예를 들어 사용자가 닉네임을 인자로 포함해서 우리에게 전달했는데 닉네임이 누락됐을 경우 source 로 "nickname" 을 지정합니다.

 

debugMessage

디버깅을 위한 메시지입니다.

사용자에게 내려줄 메시지는 어떤 언어로 내려줄 지 모르기 때문에 code, args가 필요한데

 

디버깅을 하는 개발자 관점에서는 사실 국제화가 필요 없습니다.

또, 사용자에게 내려주는 관점과 다른 메시지를 작성해야할 수 있습니다.

따라서 디버깅의 편의를 위해 이를 부모 RuntimeException 의 message 로 넘길 것이고 이를 debueMessage 이름으로서 받아 관리할 것입니다.

 

cause

예외의 근원예외입니다. 없을 수도 있고 있을 수 있기 때문에 선택적으로 받도록 했고 기본적으로는 null로 설정했어요.


5. 커스텀 예외 테스트 픽스쳐

제가 커스텀 예외는 다른 곳에서 확장해서 사용하도록 추상클래스로 선언하긴 했는데, 추상클래스는 구체 인스턴스를 생성할 수 없습니다. 비즈니스 로직을 구현하는 코드에서는 예외를 구체화해서 작성하는게 유지보수에서 좋기 때문에 그렇게 강제할 필요가 있죠.

 

하지만 향후 모듈 내 또는 다른 모듈의 테스트코드 작성에서 구체적인 예외 타입은 중요하지 않지만 구체 커스텀 예외 인스턴스가 필요한 테스트 코드가 있을 수 있어서 테스트 픽스쳐를 만들어두기로 했습니다.

5.1 CustomExceptionFixture

 

core 모듈의 test 폴더 아래에 테스트 픽스쳐를 작성해두겠습니다.

 

어라? 이렇게 하면 Test  폴더에서만 쓸 수 있고 다른 모듈에서는 이 픽스쳐를 못 쓰는거 아닌가? 하는 생각이 들 수 있는데요.

맞습니다. 지금 방식대로면 다른 모듈에서 사용할 수 없는 한계가 있습니다. 이 부분은 뒷 글에서 다뤄서 문제를 해결하도록 하겠습니다.

 

package com.ttasjwi.board.system.core.exception.fixture

import com.ttasjwi.board.system.core.exception.CustomException
import com.ttasjwi.board.system.core.exception.ErrorStatus

fun customExceptionFixture(
    status: ErrorStatus = ErrorStatus.INVALID_ARGUMENT,
    code: String = "Error.SomeThingWrong",
    args: List<Any?> = emptyList(),
    source: String = "someSource",
    debugMessage: String = "테스트 픽스쳐 에러 메시지",
    cause: Throwable? = null
): CustomException {
    return TestCustomException(
        status = status,
        code = code,
        source = source,
        args = args,
        debugMessage = debugMessage,
        cause = cause
    )
}


internal class TestCustomException(
    status: ErrorStatus,
    code: String,
    args: List<Any?>,
    source: String,
    debugMessage: String,
    cause: Throwable?
) : CustomException(
    status = status,
    code = code,
    args = args,
    source = source,
    debugMessage = debugMessage,
    cause = cause
)

 

함수 customExceptionFixture 를 정의하고 이를 통해 구체 커스텀 예외 픽스쳐를 생성하도록 했습니다.

 

사용하는 측에서는 커스텀 예외 인스턴스인 것이 중요하고 status, code, args, source, debugMessage, cause 가 중요하지 않을 수 있기 때문에 기본값을 따로 정의해뒀습니다. 필요에 따라 사용하는 측에서는 원하는 파라미터만 커스터마이징 할 수 있습니다.

 

여기서 구체 인스턴스가 되어줄 TestCustomException 의 경우 외부에서 굳이 알 필요가 없어서 internal class 로 감쌌습니다. 

 

java와 다르게 kotlin 은 디폴트 인자를 지정할 수 있고, 파라미터를 구체적으로 지정해서 함수를 호출할 수 있어서 이게 편하더라구요. 이 기능을 맛보고 나니까 저는 java로 못 돌아갈 것 같아요. 지극히 개인적인 극단적인 생각인데 우리나라 현장에서는 장화하기 그지없는 언어 java를 단계적으로 퇴출해야한다고 생각합니다...

 

5.2 customExceptionFixtureTest

package com.ttasjwi.board.system.core.exception.fixture

import com.ttasjwi.board.system.core.exception.CustomException
import com.ttasjwi.board.system.core.exception.ErrorStatus
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test

@DisplayName("customExceptionFixture(...): 테스트용 커스텀 예외 인스턴스를 생성하는 함수")
class CustomExceptionFixtureTest {

    @Test
    @DisplayName("인자를 전달하지 않아도 기본적인 예외 인스턴스가 생성된다.")
    fun testDefault() {
        // given
        // when
        val exception = customExceptionFixture()

        assertThat(exception).isNotNull
        assertThat(exception).isInstanceOf(CustomException::class.java)
        assertThat(exception.status).isNotNull
        assertThat(exception.code).isNotNull
        assertThat(exception.args).isNotNull
        assertThat(exception.source).isNotNull
        assertThat(exception.debugMessage).isNotNull()
        assertThat(exception.cause).isNull()
    }

    @Test
    @DisplayName("구체적으로 파라미터 - 인자를 지정하여 커스텀 예외 인스턴스를 생성할 수 있다.")
    fun testCustom() {
        // given
        val status = ErrorStatus.INVALID_ARGUMENT
        val code = "Error.InvalidArgumentFixture"
        val args = listOf(1, 2)
        val source = "nickname"
        val debugMessage = "some debug message fixture"
        val cause = IllegalArgumentException("some thing is wrong")

        // when
        val exception = customExceptionFixture(
            status = status,
            code = code,
            args = args,
            source = source,
            debugMessage = debugMessage,
            cause = cause
        )

        assertThat(exception).isNotNull
        assertThat(exception).isInstanceOf(CustomException::class.java)
        assertThat(exception.status).isEqualTo(status)
        assertThat(exception.code).isEqualTo(code)
        assertThat(exception.args).containsExactlyElementsOf(args)
        assertThat(exception.source).isEqualTo(source)
        assertThat(exception.debugMessage).isEqualTo(debugMessage)
        assertThat(exception.cause).isEqualTo(cause)
    }
}

 

픽스쳐 함수 자체도 테스트 대상이라 생각해서, 저는 이것 역시 테스트 코드를 작성했습니다.

 

테스트를 실행하기 전에 인텔리제이 Coverage 란에서, Java Coverage 부분에서 Collect coverage in test folders 쪽을 체크하면 테스트 폴더 쪽도 커버리지로 측정할 수 있는데요.

 

실제 로컬에서 테스트를 돌려보면 픽스쳐 함수의 커버리지가 괜찮게 뜨네요.


6. 커스텀 예외 테스트

픽스쳐를 만들어뒀기 때문에 커스텀 예외 구체 인스턴스를 생성하는 것을 좀 더 간단하게 할 수 있습니다.

이제 이것을 기반으로 해서 커스텀 예외 그 자체를 테스트해보겠습니다.

 

6.1 ErrorStatusTest

package com.ttasjwi.board.system.core.exception

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test

@DisplayName("ErrorStatus: 에러 유형을 정의한 enum(열거형)")
class ErrorStatusTest {

    @Test
    @DisplayName("ErrorStatus.NOT_FOUND 는 연산의 대상을 찾지 못했음을 나타내는 에러 유형이다.")
    fun testNotFound() {
        val notFound = ErrorStatus.NOT_FOUND
        assertThat(notFound.name).isEqualTo("NOT_FOUND")
    }

    @Test
    @DisplayName("ErrorStatus.UNAUTHENTICATED 는 연산의 주체를 식별하지 못 한 상황(예: 로그인 실패, 미인증)을 나타내는 에러 유형이다.")
    fun testUnauthenticated() {
        val unAuthenticated = ErrorStatus.UNAUTHENTICATED
        assertThat(unAuthenticated.name).isEqualTo("UNAUTHENTICATED")
    }

    @Test
    @DisplayName("ErrorStatus.INVALID_ARGUMENT 는 연산에 전달한 값이 유효성에 맞지 않을 때를 나타내는 에러 유형이다.")
    fun testInvalidArgument() {
        val invalidArgument = ErrorStatus.INVALID_ARGUMENT
        assertThat(invalidArgument.name).isEqualTo("INVALID_ARGUMENT")
    }

    @Test
    @DisplayName("ErrorStatus.FORBIDDEN 은 연산을 수행할 권한이 없음을 나타내는 에러 유형이다.")
    fun testForbidden() {
        val forbidden = ErrorStatus.FORBIDDEN
        assertThat(forbidden.name).isEqualTo("FORBIDDEN")
    }

    @Test
    @DisplayName("ErrorStatus.CONFLICT 는 연산 요구사항이 우리 서버의 상황과 충돌되는 경우(예:닉네임 중복)를 나타내는 에러 유형이다.")
    fun testConflict() {
        val conflict = ErrorStatus.CONFLICT
        assertThat(conflict.name).isEqualTo("CONFLICT")
    }

    @Test
    @DisplayName("ErrorStatus.APPLICATION_ERROR 는 우리 서버 자체 문제임을 나타내는 에러 유형이다.")
    fun testApplicationError() {
        val applicationError = ErrorStatus.APPLICATION_ERROR
        assertThat(applicationError.name).isEqualTo("APPLICATION_ERROR")
    }

}

 

우선 ErrorStatus enum 에 대한 테스트입니다.

이 enum은 테스트가 중요하지 않습니다만,

테스트 코드 그 자체가 추후 이것을 유지보수하는 개발자들에게 하나의 명세가 될 수 있기 때문에 추가적으로 테스트코드를 작성했습니다.

 

6.2 CustomExceptionTest

package com.ttasjwi.board.system.core.exception

import com.ttasjwi.board.system.core.exception.fixture.customExceptionFixture
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test

@DisplayName("CustomException: 우리 서비스의 커스텀 예외를 표준화한 추상 예외")
class CustomExceptionTest {

    @Test
    @DisplayName("커스텀 예외는 생성시 전달한 값을 가지고 있다.")
    fun test1() {
        // given
        val status = ErrorStatus.INVALID_ARGUMENT
        val code = "Error.SomeError"
        val args = listOf("someArgs1", "someArgs2")
        val source = "someSource"
        val debugMessage = "Some debug Message with ${args[0]}, ${args[1]}"
        
        // when
        val exception = customExceptionFixture(
            status= status,
            code = code,
            args = args,
            source = source,
            debugMessage = debugMessage,
        )

        // then
        assertThat(exception.status).isEqualTo(status)
        assertThat(exception.code).isEqualTo(code)
        assertThat(exception.args).isEqualTo(args)
        assertThat(exception.source).isEqualTo(source)
        assertThat(exception.debugMessage).isEqualTo(debugMessage)
        assertThat(exception.message).isEqualTo(debugMessage)
        assertThat(exception.cause).isNull()
    }

    @Test
    @DisplayName("커스텀 예외에 근원 예외를 함께 더하여 생성시 근원 예외 정보를 가지고 있다.")
    fun test2() {
        // given
        val cause = IllegalArgumentException("origin cause")

        // when
        val exception = customExceptionFixture(
            cause = cause
        )
        
        // then
        assertThat(exception.cause).isEqualTo(cause)
    }

}

 

 

커스텀 예외의 테스트 코드입니다. Fixture 코드를 작성하지 않았다면 CustomException 을 상속한 익명 클래스를 만들어서 테스트를 작성해야했을건데 커스텀 예외 구체 인스턴스를 만들어주는 Fixture를 통해 간단하게 테스트할 수 있게 됐습니다. 

 

지금은 이 클래스 하나에서만 사용되기 때문에 실효성에 대해 의문을 품을 수 있긴한데, 향후 다른 클래스에서 픽스쳐를 재사용할 가능성이 있어서 의미 있다고 생각합니다.

 

Fixture 자체는 이미 테스트코드를 작성해둬서 신뢰성이 입증된 만큼 우리는 저 픽스쳐 함수에 대해 의심하지 않고, 픽스쳐 함수가 커스텀 예외 인스턴스를 만들어주는 것을 전제로 테스트를 작성하면 돼요.

 

 

마찬가지로 돌려보면 main 쪽 coverage 는 100%로 잘 뜹니다.

 

 

커밋을 하나하나 작성해가면서 풀리퀘를 작성해나가고, 앞에서 고생해서 작성해둔 빌드테스트 덕분에 코드가 잘 작성되고 있다는 것을 어느 정도 안심할 수 있습니다.


7. 예시 - NullArgumentException

이제 여기서 확장해서 구체적인 커스텀 예외를 하나 만들어보겠습니다.

 

Java 에서는 null을 허용하지 않는 메서드에 null을 건내지 않을 때, 혹은 null을 가리키는 변수를 통해 메서드를 호출할 때 NullPointerException 을 터트리라고 제시하고 있긴합니다만 앞에서 언급했듯 저는 이런 상황들을 통틀어서 부가적인 정보를 담은 커스텀 예외를 만들기로 했죠.

 

어떤 값이 Null 이여선 안 되는 상황은 애플리케이션 전반에서 자주 쓰일 수 있다보니 이를 나타내는 예외를 core 모듈에 생성해볼게요.

 

package com.ttasjwi.board.system.core.exception

/**
 * Null 이여서 안 되는 상황, 즉 어떤 필드값이 필수여야함을 나타내는 예외
 * @param source Null 이여서는 안되는 필드 또는 맥락의 이름
 * 
 * @author ttasjwi
 * @since 2024/10/02
 */
class NullArgumentException(
    source: String,
) : CustomException(
    status = ErrorStatus.INVALID_ARGUMENT,
    code = "Error.NullArgument",
    args = listOf(source),
    source = source,
    debugMessage = "${source}은(는) 필수입니다."
)

 

커스텀 예외를 확장한 NullArgumentException 를 정의했습니다.

 

디버깅 메시지은 어떻게 할 지, status 등 code는 뭐로 해서 생성해야할지 등은 이 예외 코드에 모아두고

생성 시점에서는 source 만 전달받음으로서 예외를 터트리는 코드를 작성하는 개발자는 source 만 생각하여 작성할 수 있도록 합니다.

 

package com.ttasjwi.board.system.core.exception

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test

@DisplayName("NullArgumentException: Null 이여서 안 되는 상황, 즉 어떤 필드값이 필수여야함을 나타내는 예외")
class NullArgumentExceptionTest {

    @Test
    @DisplayName("예외 기본값 테스트")
    fun test() {
        // given
        val source = "username"

        // when
        val exception = NullArgumentException(source)

        // then
        assertThat(exception.status).isEqualTo(ErrorStatus.INVALID_ARGUMENT)
        assertThat(exception.code).isEqualTo("Error.NullArgument")
        assertThat(exception.args).containsExactly(source)
        assertThat(exception.source).isEqualTo(source)
        assertThat(exception.debugMessage).isEqualTo("${source}은(는) 필수입니다.")
        assertThat(exception.cause).isNull()
    }
}

 

예외 자체가 가진 값을 테스트 했습니다. 예를 들어 username(사용자 아이디) 이 필수인데, 요청을 보낸 사람이 username 을 보내지 않았다면 이것이 문제가 될 수 있죠. 이 예외를 터트릴 때는 source 만 지정해서 예외를 터트리면 됩니다.


8. 입력값 검증 예외 수집기

 

간혹 입력값을 검증해야하는 상황에서, 문제가 발생한 부분은 10개인데 사용자에게 하나씩 예외를 발생시켜 던지면

사용자는 한 필드가 잘못됐다는 응답을 보고 그것만 고치면 되겠구나 하면서 그 부분만 고쳐서 요청을 보낼거에요.

 

이렇게 되면 사용자는 10번을 계속 요청해가면서 잘못된 부분을 고치게 되겠죠?

이것은 사용자 경험 관점에서도 좋지 못 하고 트래픽 관점에서도 좋지 못 합니다.

 

잘못된 입력을 한번에 모아서 한번에 터트릴 수 있는 수단이 있으면 편할 것 같습니다.

 

package com.ttasjwi.board.system.core.exception

/**
 * 여러 개의 입력값 검증예외를 군집으로 모아 관리하리하기 위한 목적으로 정의한 특수 예외
 */
class ValidationExceptionCollector : CustomException(
    status = ErrorStatus.INVALID_ARGUMENT,
    code = "Error.InvalidArguments",
    source = "*",
    args = emptyList(),
    debugMessage = "입력값 유효성 검증에 실패했습니다.",
) {

    private val _exceptions: MutableList<CustomException> = mutableListOf()

    /**
     * 예외를 추가합니다. 우리 서비스에서 정의한 커스텀 예외는 수집하고, 알려지지 않은 예외는 그대로 던집니다.
     */
    fun addCustomExceptionOrThrow(e: Throwable) {
        if (e is CustomException) {
            _exceptions.add(e)
            return
        } else {
            throw e
        }
    }

    fun getExceptions(): List<CustomException> = _exceptions.toList()

    fun isNotEmpty(): Boolean {
        return this._exceptions.isNotEmpty()
    }

    fun throwIfNotEmpty() {
        if (this._exceptions.isNotEmpty()) {
            throw this
        }
        return
    }
}

 

그래서 저는 주로 입력값 검증 시 커스텀 예외를 수집하는 목적으로 ValidationExceptionCollector 클래스를 만들었습니다. 이것은 CustomException 의 자손이며, 다른 커스텀 예외를 수집하여 관리하는 특수 예외입니다.

 

addCustomExceptionOrThrow

Throwable 의 자손을 인자로 받고, 혹시 이것이 CustomException 의 자손이면 그대로 수집하고 CustomException 이 아닌 부분은 우리가 모르는 예외이므로 그대로 터트리도록 합니다.

 

getExceptions

보유한 커스텀 예외들을 변경 불가능한 리스트로 반환합니다.

 

isNotEmpty

수집된 커스텀 예외가 존재하는 지 여부를 반환합니다. 커스텀 예외가 있다면 true, 없다면 false 를 반환하도록 했어요.

 

throwIfNotEmpty

혹시 커스텀 예외가 수집되어 있다면 자기 자신을 throw 시킵니다.

 

 

나중에 이 예외를 처리하는 쪽에서는 getExceptions 를 통해 하위 예외를 수집하고 이를 묶어서 사용자에게 응답 API 를 편리하게 내릴 수 있도록 할거에요.

 

package com.ttasjwi.board.system.core.exception

import com.ttasjwi.board.system.core.exception.fixture.customExceptionFixture
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.*

@DisplayName("ValidationExceptionCollector: 여러 개의 입력값 검증예외를 군집으로 모아 관리하리하기 위한 목적으로 정의한 특수 예외")
class ValidationExceptionCollectorTest {

    @Test
    @DisplayName("예외 기본값 테스트")
    fun test() {
        // given
        // when
        val exception = ValidationExceptionCollector()

        // then
        assertThat(exception.status).isEqualTo(ErrorStatus.INVALID_ARGUMENT)
        assertThat(exception.code).isEqualTo("Error.InvalidArguments")
        assertThat(exception.args).isEmpty()
        assertThat(exception.source).isEqualTo("*")
        assertThat(exception.debugMessage).isEqualTo("입력값 유효성 검증에 실패했습니다.")
        assertThat(exception.cause).isNull()
    }
    

    @Nested
    @DisplayName("getExceptions: 가지고 있는 커스텀 예외 목록을 변경 불가능한 리스트로 반환한다.")
    inner class GetExceptions {


        @Test
        @DisplayName("예외가 추가되어 있지 않으면 빈 리스트가 반환된다.")
        fun testEmpty() {
            // given
            val exceptionCollector = ValidationExceptionCollector()

            // when
            val exceptions: List<CustomException> = exceptionCollector.getExceptions()

            // then
            assertThat(exceptions).isEmpty()
        }

        @Test
        fun testNotEmpty() {
            // given
            val exceptionCollector = ValidationExceptionCollector()
            val exception = customExceptionFixture()
            exceptionCollector.addCustomExceptionOrThrow(exception)

            // when
            val exceptions: List<CustomException> = exceptionCollector.getExceptions()

            // then
            assertThat(exceptions).containsExactly(exception)
        }
    }


    @Nested
    @DisplayName("addCustomExceptionOrThrow : 커스텀 예외는 수집하고, 커스텀 예외가 아니면 throw 한다.")
    inner class AddCustomExceptionOrThrow {

        @Test
        @DisplayName("커스텀 예외를 추가하고, getExceptions 를 통해 예외 목록을 추출할 수 있다.")
        fun addAndGetTest() {
            // given
            val exception1 = customExceptionFixture(
                debugMessage = "some debug message1"
            )

            val exception2 = customExceptionFixture(
                debugMessage = "some debug message2"
            )

            val exceptionCollector = ValidationExceptionCollector()
            exceptionCollector.addCustomExceptionOrThrow(exception1)
            exceptionCollector.addCustomExceptionOrThrow(exception2)

            val exceptions = exceptionCollector.getExceptions()

            assertThat(exceptions.size).isEqualTo(2)
            assertThat(exceptions).containsExactly(exception1, exception2)
        }

        @Test
        @DisplayName("커스텀 예외가 아닌 것을 추가하면 예외가 던져진다.")
        fun throwTest() {
            val runtimeException = IllegalStateException("something is wrong")

            val exceptionCollector = ValidationExceptionCollector()

            assertThrows<IllegalStateException> {
                exceptionCollector.addCustomExceptionOrThrow(runtimeException)
            }
        }
    }

    @Nested
    @DisplayName("throwIfNotEmpty: 내부에 예외가 있으면 자기 자신을 throw 한다.")
    inner class ThrowIfNotEmpty {

        @Test
        @DisplayName("예외가 있으면 자기 자신을 throw 한다.")
        fun throwIfNotEmptyTest1() {
            val exception = customExceptionFixture(
                status = ErrorStatus.INVALID_ARGUMENT,
                code = "Error.code1",
                args = listOf("1", "2"),
                source = "?",
                debugMessage = "some debug message1"
            )

            val exceptionCollector = ValidationExceptionCollector()
            exceptionCollector.addCustomExceptionOrThrow(exception)

            val thrownException = assertThrows<ValidationExceptionCollector> {
                exceptionCollector.throwIfNotEmpty()
            }
            assertThat(thrownException).isEqualTo(exceptionCollector)
        }

        @Test
        @DisplayName("예외가 없으면 에외가 throw 되지 않는다.")
        fun throwIfNotEmptyTest2() {
            val exceptionCollector = ValidationExceptionCollector()
            assertDoesNotThrow { exceptionCollector.throwIfNotEmpty() }
        }
    }


    @Nested
    @DisplayName("isNotEmpty(): 수집된 커스텀 예외가 있는 지 여부를 반환한다.")
    inner class IsNotEmpty {

        @Test
        @DisplayName("수집된 커스텀 예외가 있으면 true 를 반환한다.")
        fun test1() {
            val exceptionCollector = ValidationExceptionCollector()
            val exception = customExceptionFixture()
            exceptionCollector.addCustomExceptionOrThrow(exception)

            assertThat(exceptionCollector.isNotEmpty()).isTrue()
        }

        @Test
        @DisplayName("수집된 커스텀 예외가 없으면 false 를 반환한다.")
        fun test2() {
            val exceptionCollector = ValidationExceptionCollector()
            assertThat(exceptionCollector.isNotEmpty()).isFalse()
        }
    }

}

 

+ 이를 테스트하는 코드입니다.


9. 커스텀 예외 정의 후 예외 계층

 

이제 java 의 Throwable 부터 시작한 예외 계층을 보겠습니다.

 

앞으로 애플리케이션에서는 대부분 예외를 CustomException 을 확장한 예외를 만들고 이것을 중심으로 관리할 것입니다.

 

CustomException 으로 선언한 예외들은 우리가 코드로 따로 정의한 예외이므로 어떤 맥락에서 발생했는 지 알 기 쉽고, 우리가 확장하여 정의된 값을 가지고 있으므로 예외 응답을 메시지를 만들기 편리합니다.

 

반면 CustomException 이 아닌 예외들은 우리가 만들지 않은 영역에서 발생한 것입니다.

 

이 예외들은 우리가 확장하여 정의한 필드나 값을 가지고 않고 있어서, 실제 애플리케이션 운영 과정에서 따로 추적해서 관리해야하는 부분이 많습니다. 가능한 이 예외들은 별도의 처리 로직을 준비해두거나, 발생 지점에서 try-catch 문을 통해 감싸서 우리가 아는 커스텀 예외로 변환하면 처리가 좀 더 간편해지지 않을까 생각해봅니다.

 

대부분의 예외들을 CustomException 사양으로 만들면 애플리케이션 유지/보수 관점에서 편해질 것 같아요.


 

 

테스트도 잘 통과됐고

 

CI 빌드테스트에서도 잘 동작하며, 

배포에도 성공했습니다.

 

이제 앞으로는 이 커스텀 예외들을 기반으로 확장해서 기능을 구현해나가면 될 것 같네요.

이어지는 글에서 뵙도록 하겠습니다. 글 읽어주셔서 감사합니다!

 


 

작업 리포지토리 : https://github.com/ttasjwi/board-system

 

GitHub - ttasjwi/board-system

Contribute to ttasjwi/board-system development by creating an account on GitHub.

github.com

 

풀리퀘스트 https://github.com/ttasjwi/board-system/pull/20

 

Feature: (BRD-34) 코어 모듈 및 커스텀 예외 정의 by ttasjwi · Pull Request #20 · ttasjwi/board-system

JIRA 티켓 BRD-34 개요 대부분의 예외들을 우리 서비스에서 관리하는 커스텀 예외로 변환하여 관리하면 예외 메시지 관리/국제화 기능의 확장성, 편의성이 좋아진다. 상세 설명 Java 의 기본 예외를

github.com

 

Comments