Quarkus 개발 패턴
Quarkus 3.x 클라우드 네이티브, 이벤트 기반 서비스를 위한 아키텍처 및 API 패턴 (Apache Camel 포함).
활성화 시점
- JAX-RS 또는 RESTEasy Reactive를 사용한 REST API 구축
- 리소스 → 서비스 → 리포지토리 계층 구조화
- Apache Camel 및 RabbitMQ를 사용한 이벤트 기반 패턴 구현
- Hibernate Panache, 캐싱 또는 리액티브 스트림 구성
- 유효성 검사, 예외 매핑 또는 페이지네이션 추가
- dev/staging/production 환경 프로파일 설정 (YAML 구성)
- LogContext 및 Logback/Logstash 인코더를 사용한 커스텀 로깅
- 비동기 작업을 위한 CompletableFuture 사용
- 조건부 플로우 처리 구현
- GraalVM 네이티브 컴파일 작업
다중 의존성을 가진 서비스 계층
@Slf4j
@ApplicationScoped
@RequiredArgsConstructor
public class OrderProcessingService {
private final OrderValidator orderValidator;
private final EventService eventService;
private final OrderRepository orderRepository;
private final FulfillmentPublisher fulfillmentPublisher;
private final AuditPublisher auditPublisher;
@Transactional
public OrderReceipt process(CreateOrderCommand command) {
ValidationResult validation = orderValidator.validate(command);
if (!validation.valid()) {
eventService.createErrorEvent(command, "ORDER_REJECTED", validation.message());
throw new WebApplicationException(validation.message(), Response.Status.BAD_REQUEST);
}
Order order = Order.from(command);
orderRepository.persist(order);
OrderReceipt receipt = OrderReceipt.from(order);
fulfillmentPublisher.publishAsync(receipt);
auditPublisher.publish("ORDER_ACCEPTED", receipt);
eventService.createSuccessEvent(receipt, "ORDER_ACCEPTED");
log.info("Processed order {}", order.id);
return receipt;
}
}
주요 패턴:
@RequiredArgsConstructor를 사용한 Lombok 기반 생성자 주입@Slf4j를 사용한 Logback 로깅- Panache 또는 리포지토리를 통해 쓰기 작업을 수행하는 서비스 메서드에
@Transactional적용 - 지속성 또는 메시지 발행 전 입력값 검증
- 성공/오류 시나리오에 대한 이벤트 추적
- 비동기 Camel 메시지 발행
커스텀 로깅 컨텍스트 패턴 (Logback)
@ApplicationScoped
public class ProcessingService {
public void processDocument(Document doc) {
LogContext logContext = CustomLog.getCurrentContext();
try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) {
// 모든 로그 문에 컨텍스트 추가
logContext.put("documentId", doc.getId().toString());
logContext.put("documentType", doc.getType());
logContext.put("userId", SecurityContext.getUserId());
log.info("Starting document processing");
// 이 범위 내의 모든 로그는 컨텍스트를 상속받음
processInternal(doc);
log.info("Document processing completed");
} catch (Exception e) {
log.error("Document processing failed", e);
throw e;
}
}
}
Logback 설정 (logback.xml):
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeContext>true</includeContext>
<includeMdc>true</includeMdc>
</encoder>
</appender>
<logger name="com.example" level="INFO"/>
<root level="WARN">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
이벤트 서비스 패턴
@Slf4j
@ApplicationScoped
@RequiredArgsConstructor
public class EventService {
private final EventRepository eventRepository;
private final ObjectMapper objectMapper;
public void createSuccessEvent(Object payload, String eventType) {
Objects.requireNonNull(payload, "Payload cannot be null");
Event event = new Event();
event.setType(eventType);
event.setStatus(EventStatus.SUCCESS);
event.setPayload(serializePayload(payload));
event.setTimestamp(Instant.now());
eventRepository.persist(event);
log.info("Success event created: {}", eventType);
}
public void createErrorEvent(Object payload, String eventType, String errorMessage) {
Objects.requireNonNull(payload, "Payload cannot be null");
if (errorMessage == null || errorMessage.isBlank()) {
throw new IllegalArgumentException("Error message cannot be blank");
}
Event event = new Event();
event.setType(eventType);
event.setStatus(EventStatus.ERROR);
event.setErrorMessage(errorMessage);
event.setPayload(serializePayload(payload));
event.setTimestamp(Instant.now());
eventRepository.persist(event);
log.error("Error event created: {} - {}", eventType, errorMessage);
}
private String serializePayload(Object payload) {
try {
return objectMapper.writeValueAsString(payload);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to serialize event payload", e);
}
}
}
Camel 메시지 발행 (RabbitMQ)
@Slf4j
@ApplicationScoped
@RequiredArgsConstructor
public class BusinessRulesPublisher {
private final ProducerTemplate producerTemplate;
public void publishSync(BusinessRulesPayload payload) {
producerTemplate.sendBody(
"direct:business-rules-publisher",
payload
);
}
}
Camel 경로 설정:
@ApplicationScoped
public class BusinessRulesRoute extends RouteBuilder {
@ConfigProperty(name = "camel.rabbitmq.queue.business-rules")
String businessRulesQueue;
@ConfigProperty(name = "rabbitmq.host")
String rabbitHost;
@ConfigProperty(name = "rabbitmq.port")
Integer rabbitPort;
@Override
public void configure() {
from("direct:business-rules-publisher")
.routeId("business-rules-publisher")
.log("Publishing message to RabbitMQ: ${body}")
.marshal().json(JsonLibrary.Jackson)
.toF("spring-rabbitmq:%s?hostname=%s&portNumber=%d",
businessRulesQueue, rabbitHost, rabbitPort);
}
}
Camel 직접 경로 (인-메모리)
@ApplicationScoped
public class DocumentProcessingRoute extends RouteBuilder {
@Override
public void configure() {
// 오류 처리
onException(ValidationException.class)
.handled(true)
.to("direct:validation-error-handler")
.log("Validation error: ${exception.message}");
// 메인 처리 경로
from("direct:process-document")
.routeId("document-processing")
.log("Processing document: ${header.documentId}")
.bean(DocumentValidator.class, "validate")
.bean(DocumentTransformer.class, "transform")
.choice()
.when(header("documentType").isEqualTo("INVOICE"))
.to("direct:process-invoice")
.when(header("documentType").isEqualTo("CREDIT_NOTE"))
.to("direct:process-credit-note")
.otherwise()
.to("direct:process-generic")
.end();
from("direct:validation-error-handler")
.bean(EventService.class, "createErrorEvent")
.log("Validation error handled");
}
}
Camel 파일 처리
@ApplicationScoped
public class FileMonitoringRoute extends RouteBuilder {
@ConfigProperty(name = "file.input.directory")
String inputDirectory;
@ConfigProperty(name = "file.processed.directory")
String processedDirectory;
@ConfigProperty(name = "file.error.directory")
String errorDirectory;
@Override
public void configure() {
from("file:" + inputDirectory + "?move=" + processedDirectory +
"&moveFailed=" + errorDirectory + "&delay=5000")
.routeId("file-monitor")
.log("Processing file: ${header.CamelFileName}")
.to("direct:process-file");
from("direct:process-file")
.bean(OrderProcessingService.class, "processFile")
.log("File processing completed");
}
}
Camel Bean 호출
@ApplicationScoped
public class InvoiceRoute extends RouteBuilder {
@Override
public void configure() {
from("direct:invoice-validation")
.bean(InvoiceFlowValidator.class, "validateFlowWithConfig")
.log("Validation result: ${body}");
from("direct:persist-and-publish")
.bean(DocumentJobService.class, "createDocumentAndJobEntities")
.bean(BusinessRulesPublisher.class, "publishAsync")
.bean(EventService.class, "createSuccessEvent(${body}, 'PUBLISHED')");
}
}
REST API 구조
@Path("/api/documents")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor
public class DocumentResource {
private final DocumentService documentService;
@GET
public Response list(
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("20") int size) {
List<Document> documents = documentService.list(page, size);
return Response.ok(documents).build();
}
@POST
public Response create(@Valid CreateDocumentRequest request, @Context UriInfo uriInfo) {
Document document = documentService.create(request);
URI location = uriInfo.getAbsolutePathBuilder()
.path(String.valueOf(document.id))
.build();
return Response.created(location).entity(DocumentResponse.from(document)).build();
}
@GET
@Path("/{id}")
public Response getById(@PathParam("id") Long id) {
return documentService.findById(id)
.map(DocumentResponse::from)
.map(Response::ok)
.orElse(Response.status(Response.Status.NOT_FOUND))
.build();
}
}
리포지토리 패턴 (Panache Repository)
@ApplicationScoped
public class DocumentRepository implements PanacheRepository<Document> {
public List<Document> findByStatus(DocumentStatus status, int page, int size) {
return find("status = ?1 order by createdAt desc", status)
.page(page, size)
.list();
}
public Optional<Document> findByReferenceNumber(String referenceNumber) {
return find("referenceNumber", referenceNumber).firstResultOptional();
}
public long countByStatusAndDate(DocumentStatus status, LocalDate date) {
return count("status = ?1 and createdAt >= ?2", status, date.atStartOfDay());
}
}
트랜잭션을 사용한 서비스 계층
@ApplicationScoped
@RequiredArgsConstructor
public class DocumentService {
private final DocumentRepository repo;
private final EventService eventService;
@Transactional
public Document create(CreateDocumentRequest request) {
Document document = new Document();
document.setReferenceNumber(request.referenceNumber());
document.setDescription(request.description());
document.setStatus(DocumentStatus.PENDING);
document.setCreatedAt(Instant.now());
repo.persist(document);
eventService.createSuccessEvent(document, "DOCUMENT_CREATED");
return document;
}
public Optional<Document> findById(Long id) {
return repo.findByIdOptional(id);
}
public List<Document> list(int page, int size) {
return repo.findAll()
.page(page, size)
.list();
}
}
DTO 및 유효성 검사
public record CreateDocumentRequest(
@NotBlank @Size(max = 200) String referenceNumber,
@NotBlank @Size(max = 2000) String description,
@NotNull @FutureOrPresent Instant validUntil,
@NotEmpty List<@NotBlank String> categories) {}
public record DocumentResponse(Long id, String referenceNumber, DocumentStatus status) {
public static DocumentResponse from(Document document) {
return new DocumentResponse(document.getId(), document.getReferenceNumber(),
document.getStatus());
}
}
예외 매핑
@Provider
public class ValidationExceptionMapper implements ExceptionMapper<ConstraintViolationException> {
@Override
public Response toResponse(ConstraintViolationException exception) {
String message = exception.getConstraintViolations().stream()
.map(cv -> cv.getPropertyPath() + ": " + cv.getMessage())
.collect(Collectors.joining(", "));
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "validation_error", "message", message))
.build();
}
}
@Provider
@Slf4j
public class GenericExceptionMapper implements ExceptionMapper<Exception> {
@Override
public Response toResponse(Exception exception) {
log.error("Unhandled exception", exception);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "internal_error", "message", "An unexpected error occurred"))
.build();
}
}
CompletableFuture 비동기 작업
@Slf4j
@ApplicationScoped
@RequiredArgsConstructor
public class FileStorageService {
private final S3Client s3Client;
private final ExecutorService executorService;
@ConfigProperty(name = "storage.bucket-name")
String bucketName;
public CompletableFuture<StoredDocumentInfo> uploadOriginalFile(
InputStream inputStream,
long size,
LogContext logContext,
InvoiceFormat format) {
return CompletableFuture.supplyAsync(() -> {
try (SafeAutoCloseable ignored = CustomLog.startScope(logContext)) {
String path = generateStoragePath(format);
PutObjectRequest request = PutObjectRequest.builder()
.bucket(bucketName)
.key(path)
...