Quarkus로 REST API를 만들다 보면 예외 처리 코드가 컨트롤러마다 제각각으로 흩어지는 순간이 온다. 어떤 엔드포인트는 {"error": "not found"}를 던지고, 어떤 엔드포인트는 스택 트레이스가 그대로 노출되고, 어떤 엔드포인트는 상태 코드만 200에 에러 메시지를 바디에 욱여넣는다. Spring Boot는 3.0부터 ProblemDetail을 프레임워크에 내장해서 이 문제를 표준으로 풀었지만, Quarkus와 Jakarta EE 진영에는 그런 내장 기능이 없다. 대신 커뮤니티가 만든 quarkus-http-problem 확장이 그 자리를 채운다. 이 확장은 RFC 9457(Problem Details for HTTP APIs) 표준에 맞춰 예외를 application/problem+json 응답으로 자동 변환해준다. 이번 포스팅에서는 Quarkus Problem Details 확장을 설치하는 것부터 커스텀 에러 만들기, 실제로 자주 걸리는 함정까지 정리하고자 한다.
Quarkus Problem Details, 왜 기본 내장이 안 돼 있을까
결론부터 말하면 Jakarta EE 표준 자체에 RFC 9457을 위한 네이티브 지원이 없기 때문이다. RFC 9457은 순수하게 JSON 응답 구조와 미디어 타입(application/problem+json)을 정의한 스펙이라 특정 프레임워크가 반드시 구현해야 할 의무가 없다. Spring은 Spring Framework 6부터 ProblemDetail 클래스와 ResponseEntityExceptionHandler를 자체적으로 얹었지만, Jakarta REST(JAX-RS) 스펙에는 그런 조항이 없다.
그래서 Quarkus에서 표준화된 에러 응답을 만들려면 세 가지 선택지가 있다. 직접 ExceptionMapper를 하나하나 작성하거나, Zalando의 problem 라이브러리 같은 범용 서드파티를 가져다 쓰거나, Quarkus 전용으로 만들어진 quarkus-http-problem 확장을 쓰는 것이다. 직접 작성하는 방식은 예외 종류가 늘어날 때마다 매퍼가 따라 늘어나는 유지보수 부담이 있고, Zalando problem은 Quarkus의 blocking/reactive 실행 모델이나 Jackson/JsonB 이원화를 고려하지 않고 만들어졌다. quarkus-http-problem은 이 세 가지 상황을 이미 흡수해서, 설정 없이 붙이기만 해도 JAX-RS 표준 예외 대부분을 자동으로 Problem Details 응답으로 바꿔준다. 이런 이유로 이 글에서는 quarkus-http-problem을 Quarkus Problem Details의 사실상 표준 구현체로 놓고 이야기를 이어간다.
Spring Boot의 내장 ProblemDetail과 나란히 놓고 보면 두 진영의 접근 방식 차이가 분명하게 드러난다.
| 항목 | Spring Boot ProblemDetail | Quarkus quarkus-http-problem |
|---|---|---|
| 제공 주체 | Spring Framework 코어 내장 | Quarkiverse 커뮤니티 확장 |
| 최소 버전 | Spring Framework 6 / Spring Boot 3.0 | Quarkus 3.32+ |
| 설치 | 별도 의존성 불필요 | 의존성 1개 추가 |
| 표준 준수 | RFC 9457 (구 7807) | RFC 9457 & RFC 7807 |
| 기본 예외 매핑 | 수동 핸들러 작성 필요 | JAX-RS 표준 예외 자동 매핑 |
| 커스텀 필드 추가 | ProblemDetail.setProperty() | HttpProblem.builder().with() |
Spring Boot는 프레임워크가 ProblemDetail 타입을 제공하고, spring.mvc.problemdetails.enabled=true(기본값 off)를 켜면 Spring MVC의 내장 예외는 자동으로 RFC 9457 응답으로 변환된다. 다만 이 옵션은 프레임워크 예외만 처리하므로 도메인 예외는 여전히 개발자가 @ExceptionHandler로 직접 매핑해야 한다. 반면 quarkus-http-problem은 표준 예외에 대한 매퍼를 미리 다 만들어두고 확장을 붙이는 순간 활성화한다. 그래서 “무엇을 자동으로 해주는가”의 범위가 Quarkus 쪽이 더 넓고, 대신 그만큼 확장이 언제 개입하고 언제 빠지는지를 정확히 알아야 하는데 이 부분은 뒤에서 함정과 함께 짚는다.
RFC 9457이 RFC 7807과 실제로 뭐가 다른가
실무 관점에서는 큰 차이가 없다고 봐도 된다. RFC 9457은 2023년에 RFC 7807을 대체(obsolete)한 최신 표준이지만, JSON 응답 구조(type, title, status, detail, instance)와 application/problem+json 미디어 타입은 그대로 유지된다. 실제로 바뀐 부분은 스펙 문서의 서술 방식과 확장 필드(extension members) 사용에 대한 가이드라인이 더 명확해진 정도다. 그래서 이미 RFC 7807 형식으로 에러 응답을 만들고 있었다면, 클라이언트 쪽 파싱 로직을 바꿀 필요 없이 그대로 RFC 9457 준수라고 봐도 무방하다.
quarkus-http-problem GitHub 저장소 설명에 두 RFC 번호가 함께 표기된 것도 이 때문이다. “RFC9457 & RFC7807″이라고 명시되어 있는데, Quarkus Problem Details 구현체 하나로 두 버전 모두를 만족한다는 뜻이다. 새로 프로젝트를 시작한다면 최신 표준인 RFC 9457을 기준으로 문서화하고, 기존에 RFC 7807 기반 클라이언트를 쓰던 팀이라면 응답 포맷이 깨지지 않는다는 점만 확인하면 된다.
Quarkus Problem Details 설치, 의존성 한 줄이면 충분할까
quarkus-http-problem은 Maven 의존성을 추가하는 순간부터 별도 설정 없이 동작한다. 2026년 8월 기준 최신 버전은 3.38.1이며, Quarkus 3.32 이상을 요구한다(Maven Central의 maven-metadata.xml로 직접 확인한 버전이다).
<dependency>
<groupId>io.quarkiverse.httpproblem</groupId>
<artifactId>quarkus-http-problem</artifactId>
<version>3.38.1</version>
</dependency>XML의존성만 추가하면 NotFoundException, ForbiddenException, UnauthorizedException, ConstraintViolationException, ValidationException, WebApplicationException을 포함해 처리되지 않은 일반 Exception까지 built-in 매퍼가 자동으로 잡아서 application/problem+json 응답으로 바꾼다. Jackson을 쓰든 JsonB를 쓰든, blocking 모델이든 reactive 모델이든 응답 포맷이 동일하게 나오고, 스택 트레이스나 내부 DTO 이름 같은 구현 세부사항은 기본적으로 노출되지 않는다.
@Path("/products")
public class ProductResource {
@Inject
ProductRepository productRepository;
@GET
@Path("/{id}")
public Product findById(@PathParam("id") Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Product " + id + " not found"));
}
}Java위 코드는 NotFoundException을 던지는 것 말고는 아무것도 하지 않는다. 그런데도 요청이 실패하면 상태 코드 404와 함께 다음과 같은 JSON이 자동으로 나간다.
{
"status": 404,
"title": "Not Found",
"detail": "Product 42 not found",
"instance": "/products/42"
}JSON별도의 ExceptionMapper 클래스를 작성하지 않았는데도 표준 형식이 나온다는 게 핵심이다. 예전처럼 NotFoundExceptionMapper, ValidationExceptionMapper를 예외 종류마다 하나씩 만들어 등록하던 코드를 전부 지울 수 있다.
실제로 Quarkus 3.38.1 프로젝트에 quarkus-http-problem 확장만 추가하고 위와 같은 리소스를 띄운 뒤 요청을 보내면, 아래처럼 Content-Type: application/problem+json과 함께 표준 응답이 그대로 나온다. 아무 매퍼도 직접 작성하지 않았고, NotFoundException을 던진 것과 커스텀 HttpProblem을 던진 것 모두 자동으로 처리된 결과다.

sku처럼 표준에 없는 확장 필드까지 응답에 그대로 실려 나가는 것도 확인할 수 있는데, 이 커스텀 에러를 만드는 방법은 바로 다음 절에서 다룬다.
상황에 맞는 커스텀 에러는 어떻게 만드나
Quarkus Problem Details의 기본 매핑만으로는 부족한 경우가 있다. 예를 들어 “SKU가 중복됐다”처럼 도메인 로직에서 발생하는 에러는 JAX-RS 표준 예외 타입에 맞아떨어지지 않는다. 이럴 때는 HttpProblem 빌더로 직접 응답을 구성한다. HttpProblem은 이 확장이 제공하는 예외 클래스로, RuntimeException을 상속하기 때문에 그냥 던지기만 하면 나머지는 확장이 알아서 처리한다.
@POST
public Response create(@Valid CreateProductRequest request) {
if (productRepository.existsBySku(request.sku())) {
throw HttpProblem.builder()
.withTitle("Duplicate SKU")
.withStatus(Response.Status.CONFLICT)
.withDetail("SKU " + request.sku() + " already exists")
.with("sku", request.sku())
.build();
}
Product saved = productRepository.save(Product.from(request));
return Response.status(Response.Status.CREATED).entity(saved).build();
}JavawithTitle, withStatus, withDetail은 RFC 9457 표준 필드를 채우고, with("sku", request.sku())처럼 임의의 키-값을 추가하면 표준 필드 외의 확장 필드(extension member)로 응답에 포함된다. 필요하다면 withHeader()로 커스텀 응답 헤더도 함께 실어 보낼 수 있다. 이렇게 던진 예외는 다음과 같은 응답으로 나간다.
{
"status": 409,
"title": "Duplicate SKU",
"detail": "SKU ABC-123 already exists",
"instance": "/products",
"sku": "ABC-123"
}JSON반복적으로 같은 형태의 커스텀 필드를 붙여야 한다면 HttpProblem을 상속한 별도 클래스를 만들어 재사용하는 방법도 있다. 이 경우 OpenAPI 문서에 필드 구조가 정확히 노출되도록 @Schema 애너테이션을 함께 붙여주는 것이 좋다.
Bean Validation 에러 응답, 상태 코드는 400일까 422일까
Bean Validation이 던지는 ConstraintViolationException도 기본으로 잡히지만, 상태 코드가 팀 컨벤션과 다를 수 있다. HTTP 스펙상 유효성 검증 실패에 대해 400(Bad Request)을 쓸지 422(Unprocessable Entity)를 쓸지는 프레임워크마다 관례가 갈리는 지점이다. Quarkus Problem Details는 기본값을 강제하지 않고 application.properties에서 바꿀 수 있게 열어뒀다.
# Bean Validation 실패 시 상태 코드와 제목을 팀 컨벤션에 맞춰 조정
quarkus.http-problem.constraint-violation.status=422
quarkus.http-problem.constraint-violation.title=Validation failed
# JSON 파싱 실패 시 상세 원인까지 노출할지 (프로덕션에서는 false 권장)
quarkus.http-problem.include-details=false
# 특정 매퍼만 끄고 싶을 때 (케바브 케이스 이름 사용)
quarkus.http-problem.mapper.not-found-exception.enabled=falseinclude-details는 특히 눈여겨봐야 한다. 기본값이 켜져 있으면 JSON 파싱 실패 같은 경우에 클라이언트가 보낸 원본 값 일부가 에러 응답에 그대로 노출될 수 있다. 개발 환경에서는 디버깅에 유용하지만, 프로덕션에서는 false로 꺼서 불필요한 정보 노출을 막는 편이 안전하다. 로그 레벨도 quarkus.log.category.http-problem.level=ERROR처럼 별도 카테고리로 조정할 수 있어서, 5xx 에러만 로그에 남기고 4xx는 조용히 넘어가는 식의 운영도 가능하다.
WebApplicationException을 던졌는데 왜 매퍼가 무시될까

여기서부터는 Quarkus Problem Details 공식 트러블슈팅 문서에 실제로 정리되어 있는, 가장 많이 걸리는 함정이다. WebApplicationException에 본문(entity)이 있는 Response를 실어서 던지면, 이 확장의 ExceptionMapper를 포함한 모든 JAX-RS Exception Mapper를 건너뛴다. JAX-RS 스펙 자체가 그렇게 정의되어 있어서 확장 쪽에서 손쓸 방법이 없다.
// 이렇게 던지면 Problem Details 매퍼를 우회하고 이 JSON이 그대로 나간다
throw new WebApplicationException(
Response.status(400).entity("{\"message\": \"This request is bad\"}").build());
// 본문 없이 던지면 매퍼가 정상적으로 개입해 Problem Details로 변환한다
throw new WebApplicationException(400);Java두 코드의 차이는 Response에 entity()를 채웠는지 여부뿐이다. 첫 번째처럼 본문을 직접 넣어서 던지는 습관이 있는 코드베이스라면, quarkus-http-problem을 붙여도 그 부분만 표준 형식에서 빠져나간다. 마이그레이션할 때는 본문이 있는 WebApplicationException을 검색해서 HttpProblem.builder()로 바꾸거나, 상태 코드만 넘기는 형태로 정리하는 작업이 필요하다.
MicroProfile REST Client로 외부 API를 호출하는 코드에서는 상황이 조금 다르다. RestClient가 던지는 WebApplicationException은 서버 쪽 Exception Mapper 체인을 거치지 않고 그대로 호출한 코드까지 전파된다. 이 경우에는 클라이언트 인터페이스에 ResponseExceptionMapper를 직접 등록해야 한다.

@RegisterRestClient(configKey = "inventory-api")
@RegisterProvider(InventoryClientExceptionMapper.class)
public interface InventoryClient {
@GET
@Path("/stock/{sku}")
StockInfo getStock(@PathParam("sku") String sku);
}
public class InventoryClientExceptionMapper implements ResponseExceptionMapper<HttpProblem> {
@Override
public HttpProblem toThrowable(Response response) {
return HttpProblem.builder()
.withTitle("Inventory service unavailable")
.withStatus(Response.Status.fromStatusCode(response.getStatus()))
.withDetail("Failed to reach inventory-api")
.build();
}
}JavaInventoryClient가 호출하는 외부 서비스가 500을 반환하면, InventoryClientExceptionMapper가 그 응답을 가로채 HttpProblem으로 다시 감싼다. 그 후에는 quarkus-http-problem의 built-in 매퍼가 HttpProblem을 정상적으로 처리하므로, 외부 API 실패도 내부 API 실패와 동일한 형식의 응답으로 클라이언트에 전달된다.
커스텀 Jackson 설정을 쓰면 왜 응답이 깨질까
프로젝트에 이미 ObjectMapper나 Jsonb 빈을 직접 등록해서 쓰고 있다면 한 가지 더 확인할 게 있다. Quarkus Problem Details는 Problem 관련 타입을 올바르게 직렬화하기 위해 자체 serializer를 등록하는데, 코드에서 ObjectMapper 타입의 빈을 통째로 새로 제공하면 이 serializer가 통째로 사라진다. Quarkus 공식 문서에서도 ObjectMapper 빈을 직접 재정의하는 방식을 권장하지 않는데, 이 확장에서는 그 부작용이 에러 응답 자체가 깨지는 형태로 나타난다.
// Jackson을 쓰는 경우 - 새 모듈이 필요해도 ObjectMapper 빈을 통째로 새로 만들지 않는다
@ApplicationScoped
public class ProblemModuleCustomizer implements ObjectMapperCustomizer {
public void customize(ObjectMapper mapper) {
mapper.registerModule(new JavaTimeModule());
}
}Java위 코드는 ObjectMapperCustomizer 인터페이스를 구현해서 JavaTimeModule 같은 추가 모듈만 얹는 방식이다. Quarkus가 내부에서 만든 ObjectMapper 인스턴스를 그대로 넘겨받아 필요한 설정만 덧붙이므로, 확장이 등록해둔 Problem 직렬화 설정이 그대로 살아남는다. @ApplicationScoped로 등록해두면 Quarkus가 부팅 시점에 자동으로 찾아 적용한다.
// JsonB를 쓰는 경우도 마찬가지로 JsonbConfigCustomizer를 거쳐야 한다
@ApplicationScoped
public class FooSerializerRegistrationCustomizer implements JsonbConfigCustomizer {
public void customize(JsonbConfig config) {
config.withSerializers(new FooSerializer());
}
}JavaJSON 처리기로 JsonB를 쓰는 프로젝트라면 Jackson 대신 JsonbConfigCustomizer를 구현하면 된다. 동작 원리는 동일하다. JsonbConfig 인스턴스를 새로 만들지 않고 넘겨받은 것에 커스텀 serializer만 추가하기 때문에, 확장이 등록한 기존 설정을 건드리지 않는다. Jackson이든 JsonB든 “전체 교체가 아니라 기존 설정 위에 얹기”라는 원칙은 그대로다.
핵심은 ObjectMapper나 JsonbConfig를 처음부터 새로 만들지 않고, Quarkus가 제공하는 ObjectMapperCustomizer 또는 JsonbConfigCustomizer 인터페이스를 구현해서 기존 설정 위에 필요한 부분만 얹는 것이다. 만약 정말 ObjectMapper 빈 자체를 교체해야 하는 상황이라면, 트러블슈팅 문서에 나온 대로 애플리케이션에 등록된 모든 io.quarkus.jackson.ObjectMapperCustomizer 빈을 직접 주입받아 수동으로 적용해줘야 한다. 이 과정을 빼먹으면 에러 응답에 스택 트레이스 비슷한 이상한 필드가 섞여 나오거나, 필드 이름이 camelCase와 snake_case가 뒤섞인 채로 나가는 증상으로 나타난다.
OpenAPI 문서에서도 에러 응답이 보이게 하려면
quarkus-smallrye-openapi를 함께 쓰고 있다면, Quarkus Problem Details 에러 응답 스키마도 문서에 반영하고 싶어진다. 기본적으로는 HttpProblem의 표준 필드만 스키마로 노출되지만, 커스텀 필드를 자주 추가하는 프로젝트라면 대표 스키마를 지정해두는 편이 API 문서를 읽는 프런트엔드 개발자 입장에서 훨씬 편하다.
quarkus.http-problem.openapi.default-schema=MyHttpProblem이렇게 지정하면 OpenAPI 문서의 에러 응답 스키마가 MyHttpProblem을 기준으로 생성된다. MyHttpProblem은 HttpProblem을 상속한 커스텀 클래스이며, @Schema 애너테이션으로 각 필드에 설명을 붙여두면 그대로 Swagger UI에 반영된다. API 계약을 문서 기반으로 관리하는 팀이라면 이 설정 하나로 “에러 응답이 대체 어떤 필드를 갖고 있는지” 물어보는 질문이 확실히 줄어든다.
FAQ
Spring Boot의 ProblemDetail과 Quarkus의 quarkus-http-problem은 같은 건가
목표는 같지만 구현 주체가 다르다. ProblemDetail은 Spring Framework 6부터 프레임워크에 내장된 기능이고, Quarkus Problem Details는 Quarkus 코어가 아니라 Quarkiverse 커뮤니티가 만든 별도 확장(quarkus-http-problem)이다. 둘 다 RFC 9457/7807 JSON 구조를 따르므로 클라이언트 입장에서는 응답 형식이 거의 동일하게 보인다.
RFC 9457과 RFC 7807은 실제로 뭐가 다른가
일반적인 JSON 사용 사례에서는 응답 구조와 application/problem+json 미디어 타입이 그대로 유지돼 실질적인 차이가 거의 없다. RFC 9457은 RFC 7807을 공식적으로 대체(obsolete)한 최신 버전이며, 확장 필드 사용에 대한 가이드라인이 더 명확해진 정도의 변화다. quarkus-http-problem은 두 RFC 모두를 만족하는 하나의 구현체를 제공한다.
quarkus-http-problem 없이 그냥 ExceptionMapper로 직접 구현하면 안 되나
가능은 하지만 예외 종류가 늘어날 때마다 매퍼를 하나씩 추가로 작성하고 등록해야 한다. quarkus-http-problem은 JAX-RS 표준 예외 대부분에 대한 매퍼를 이미 구현해뒀고, Jackson/JsonB 이원화와 blocking/reactive 실행 모델 차이까지 흡수해뒀기 때문에, 직접 구현했을 때 놓치기 쉬운 엣지 케이스를 줄일 수 있다.
WebApplicationException을 던졌는데 application/problem+json이 아니라 이상한 응답이 나온다
Response에 entity()로 본문을 직접 채워서 WebApplicationException을 던졌을 가능성이 높다. 이 경우 JAX-RS 스펙에 따라 모든 Exception Mapper가 우회된다. throw new WebApplicationException(400)처럼 본문 없이 상태 코드만 넘기거나, HttpProblem.builder()로 바꿔서 던지면 정상적으로 Problem Details 형식이 적용된다.
프로덕션에서 스택 트레이스가 노출될까 걱정되는데 괜찮나
기본 설정 상태로는 스택 트레이스나 내부 클래스 이름 같은 구현 세부사항이 응답에 노출되지 않는다. 다만 quarkus.http-problem.include-details가 켜져 있으면 JSON 파싱 실패 시 원인 일부가 노출될 수 있으므로, 프로덕션 환경에서는 이 값을 false로 명시적으로 꺼두는 것을 권장한다.
마치며
처음 Quarkus Problem Details를 도입했을 때 가장 만족스러웠던 부분은 새 기능이 아니라 지워진 코드였다. 예외 종류마다 하나씩 늘어나던 XxxExceptionMapper 클래스들을 걷어내고 나니, 정작 도메인 로직에 집중해야 할 리소스 클래스가 훨씬 얇아졌다. 다만 WebApplicationException에 본문을 직접 채워 던지던 예전 습관 때문에 몇 군데는 여전히 Problem Details를 우회하고 있었는데, 이 글에서 정리한 트러블슈팅 문서를 다시 읽고 나서야 원인을 찾았다. 표준을 따르는 라이브러리를 가져다 쓰는 것과, 그 라이브러리가 어디까지 개입할 수 있는지 정확히 아는 것은 별개의 문제라는 걸 다시 확인한 경험이었다.
