Skip to the content.

OpenAPI ArchUnit Guide — OpenAPI 어노테이션 강제 규칙

이 문서는 adapter-in/rest-api 레이어의 OpenAPI ArchUnit 규칙입니다.

Controller, DTO의 OpenAPI 어노테이션 적용을 자동으로 검증합니다.


1) 테스트 클래스 구조

@AnalyzeClasses(
    packages = "com.ryuqq.adapter.in.rest",
    importOptions = ImportOption.DoNotIncludeTests.class
)
@DisplayName("OpenAPI ArchUnit 규칙")
class OpenApiArchTest {

    // ======= 상수 정의 =======
    private static final String CONTROLLER_PACKAGE = "..controller..";
    private static final String DTO_COMMAND_PACKAGE = "..dto.command..";
    private static final String DTO_QUERY_PACKAGE = "..dto.query..";
    private static final String DTO_RESPONSE_PACKAGE = "..dto.response..";

    // ... 규칙들
}

2) Controller 규칙 (6개)

2.1) Controller에 @Tag 필수

@ArchTest
@DisplayName("Controller 클래스는 @Tag 어노테이션이 있어야 한다")
static final ArchRule controller_should_have_tag_annotation =
    classes()
        .that().resideInAPackage(CONTROLLER_PACKAGE)
        .and().haveSimpleNameEndingWith("Controller")
        .and().areAnnotatedWith(RestController.class)
        .should().beAnnotatedWith(Tag.class)
        .because("Controller는 @Tag로 API 그룹을 정의해야 합니다");

2.2) Controller 메서드에 @Operation 필수

@ArchTest
@DisplayName("Controller public 메서드는 @Operation 어노테이션이 있어야 한다")
static final ArchRule controller_methods_should_have_operation_annotation =
    methods()
        .that().areDeclaredInClassesThat().resideInAPackage(CONTROLLER_PACKAGE)
        .and().areDeclaredInClassesThat().haveSimpleNameEndingWith("Controller")
        .and().arePublic()
        .and().areNotConstructors()
        .and(areHttpMappingMethods())
        .should().beAnnotatedWith(Operation.class)
        .because("모든 API 메서드는 @Operation으로 설명이 필요합니다");

private static DescribedPredicate<JavaMethod> areHttpMappingMethods() {
    return new DescribedPredicate<>("HTTP 매핑 메서드") {
        @Override
        public boolean test(JavaMethod method) {
            return method.isAnnotatedWith(GetMapping.class)
                || method.isAnnotatedWith(PostMapping.class)
                || method.isAnnotatedWith(PutMapping.class)
                || method.isAnnotatedWith(PatchMapping.class)
                || method.isAnnotatedWith(DeleteMapping.class)
                || method.isAnnotatedWith(RequestMapping.class);
        }
    };
}

2.3) Controller 메서드에 @ApiResponses 필수

@ArchTest
@DisplayName("Controller public 메서드는 @ApiResponses 어노테이션이 있어야 한다")
static final ArchRule controller_methods_should_have_api_responses =
    methods()
        .that().areDeclaredInClassesThat().resideInAPackage(CONTROLLER_PACKAGE)
        .and().areDeclaredInClassesThat().haveSimpleNameEndingWith("Controller")
        .and().arePublic()
        .and(areHttpMappingMethods())
        .should().beAnnotatedWith(ApiResponses.class)
        .because("모든 API 메서드는 @ApiResponses로 응답 코드를 정의해야 합니다");

2.4) PathVariable에 @Parameter 필수

@ArchTest
@DisplayName("@PathVariable 파라미터는 @Parameter 어노테이션이 있어야 한다")
static final ArchRule path_variable_should_have_parameter_annotation =
    methods()
        .that().areDeclaredInClassesThat().resideInAPackage(CONTROLLER_PACKAGE)
        .and().areDeclaredInClassesThat().haveSimpleNameEndingWith("Controller")
        .should(haveParameterAnnotationOnPathVariable())
        .because("PathVariable은 @Parameter로 설명이 필요합니다");

private static ArchCondition<JavaMethod> haveParameterAnnotationOnPathVariable() {
    return new ArchCondition<>("PathVariable에 @Parameter가 있어야 함") {
        @Override
        public void check(JavaMethod method, ConditionEvents events) {
            for (JavaParameter param : method.getParameters()) {
                boolean hasPathVariable = param.isAnnotatedWith(PathVariable.class);
                boolean hasParameter = param.isAnnotatedWith(Parameter.class);

                if (hasPathVariable && !hasParameter) {
                    events.add(SimpleConditionEvent.violated(
                        method,
                        String.format("%s의 @PathVariable 파라미터에 @Parameter가 없습니다",
                            method.getFullName())
                    ));
                }
            }
        }
    };
}

2.5) RequestParam에 @Parameter 필수

@ArchTest
@DisplayName("@RequestParam 파라미터는 @Parameter 어노테이션이 있어야 한다")
static final ArchRule request_param_should_have_parameter_annotation =
    methods()
        .that().areDeclaredInClassesThat().resideInAPackage(CONTROLLER_PACKAGE)
        .and().areDeclaredInClassesThat().haveSimpleNameEndingWith("Controller")
        .should(haveParameterAnnotationOnRequestParam())
        .because("RequestParam은 @Parameter로 설명이 필요합니다");

private static ArchCondition<JavaMethod> haveParameterAnnotationOnRequestParam() {
    return new ArchCondition<>("RequestParam에 @Parameter가 있어야 함") {
        @Override
        public void check(JavaMethod method, ConditionEvents events) {
            for (JavaParameter param : method.getParameters()) {
                boolean hasRequestParam = param.isAnnotatedWith(RequestParam.class);
                boolean hasParameter = param.isAnnotatedWith(Parameter.class);

                if (hasRequestParam && !hasParameter) {
                    events.add(SimpleConditionEvent.violated(
                        method,
                        String.format("%s의 @RequestParam 파라미터에 @Parameter가 없습니다",
                            method.getFullName())
                    ));
                }
            }
        }
    };
}

2.6) @Operation에 summary 필수

@ArchTest
@DisplayName("@Operation 어노테이션에 summary가 비어있으면 안 된다")
static final ArchRule operation_should_have_summary =
    methods()
        .that().areDeclaredInClassesThat().resideInAPackage(CONTROLLER_PACKAGE)
        .and().areAnnotatedWith(Operation.class)
        .should(haveNonEmptyOperationSummary())
        .because("@Operation의 summary는 필수입니다");

private static ArchCondition<JavaMethod> haveNonEmptyOperationSummary() {
    return new ArchCondition<>("Operation.summary가 비어있지 않아야 함") {
        @Override
        public void check(JavaMethod method, ConditionEvents events) {
            Operation operation = method.getAnnotationOfType(Operation.class);
            if (operation != null && operation.summary().isBlank()) {
                events.add(SimpleConditionEvent.violated(
                    method,
                    String.format("%s의 @Operation.summary가 비어있습니다",
                        method.getFullName())
                ));
            }
        }
    };
}

3) DTO 규칙 (6개)

3.1) Request DTO 클래스에 @Schema 필수

@ArchTest
@DisplayName("Command Request DTO는 @Schema 어노테이션이 있어야 한다")
static final ArchRule command_request_dto_should_have_schema =
    classes()
        .that().resideInAPackage(DTO_COMMAND_PACKAGE)
        .and().haveSimpleNameEndingWith("ApiRequest")
        .and().areRecords()
        .should().beAnnotatedWith(Schema.class)
        .because("Request DTO는 @Schema로 문서화해야 합니다");

3.2) Query Request DTO 클래스에 @Schema 필수

@ArchTest
@DisplayName("Query Request DTO는 @Schema 어노테이션이 있어야 한다")
static final ArchRule query_request_dto_should_have_schema =
    classes()
        .that().resideInAPackage(DTO_QUERY_PACKAGE)
        .and().haveSimpleNameEndingWith("ApiRequest")
        .and().areRecords()
        .should().beAnnotatedWith(Schema.class)
        .because("Request DTO는 @Schema로 문서화해야 합니다");

3.3) Response DTO 클래스에 @Schema 필수

@ArchTest
@DisplayName("Response DTO는 @Schema 어노테이션이 있어야 한다")
static final ArchRule response_dto_should_have_schema =
    classes()
        .that().resideInAPackage(DTO_RESPONSE_PACKAGE)
        .and().haveSimpleNameEndingWith("ApiResponse")
        .and().areRecords()
        .should().beAnnotatedWith(Schema.class)
        .because("Response DTO는 @Schema로 문서화해야 합니다");

3.4) DTO 필드에 @Schema 필수 (Record Components)

@ArchTest
@DisplayName("Request/Response DTO의 모든 필드는 @Schema가 있어야 한다")
static final ArchRule dto_fields_should_have_schema =
    classes()
        .that().resideInAnyPackage(DTO_COMMAND_PACKAGE, DTO_QUERY_PACKAGE, DTO_RESPONSE_PACKAGE)
        .and().areRecords()
        .and().haveSimpleNameMatching(".*Api(Request|Response)")
        .should(haveSchemaOnAllRecordComponents())
        .because("DTO의 모든 필드는 @Schema로 문서화해야 합니다");

private static ArchCondition<JavaClass> haveSchemaOnAllRecordComponents() {
    return new ArchCondition<>("모든 Record 컴포넌트에 @Schema가 있어야 함") {
        @Override
        public void check(JavaClass javaClass, ConditionEvents events) {
            for (JavaField field : javaClass.getFields()) {
                // synthetic 필드 제외
                if (field.getModifiers().contains(JavaModifier.SYNTHETIC)) {
                    continue;
                }

                boolean hasSchema = field.isAnnotatedWith(Schema.class);
                if (!hasSchema) {
                    events.add(SimpleConditionEvent.violated(
                        javaClass,
                        String.format("%s.%s 필드에 @Schema가 없습니다",
                            javaClass.getSimpleName(), field.getName())
                    ));
                }
            }
        }
    };
}

3.5) @Schema에 description 필수

@ArchTest
@DisplayName("@Schema 어노테이션에 description이 비어있으면 안 된다")
static final ArchRule schema_should_have_description =
    classes()
        .that().resideInAnyPackage(DTO_COMMAND_PACKAGE, DTO_QUERY_PACKAGE, DTO_RESPONSE_PACKAGE)
        .and().areRecords()
        .and().areAnnotatedWith(Schema.class)
        .should(haveNonEmptySchemaDescription())
        .because("@Schema의 description은 필수입니다");

private static ArchCondition<JavaClass> haveNonEmptySchemaDescription() {
    return new ArchCondition<>("Schema.description이 비어있지 않아야 함") {
        @Override
        public void check(JavaClass javaClass, ConditionEvents events) {
            Schema schema = javaClass.getAnnotationOfType(Schema.class);
            if (schema != null && schema.description().isBlank()) {
                events.add(SimpleConditionEvent.violated(
                    javaClass,
                    String.format("%s의 @Schema.description이 비어있습니다",
                        javaClass.getSimpleName())
                ));
            }
        }
    };
}

3.6) Enum에 @Schema(enumAsRef) 필수

@ArchTest
@DisplayName("DTO에서 사용하는 Enum은 @Schema(enumAsRef = true)가 있어야 한다")
static final ArchRule enum_should_have_schema_enum_as_ref =
    classes()
        .that().resideInAPackage("..dto..")
        .and().areEnums()
        .should().beAnnotatedWith(Schema.class)
        .because("Enum은 @Schema로 문서화해야 합니다");

4) 전체 테스트 클래스

package com.ryuqq.adapter.in.rest.architecture.openapi;

import com.tngtech.archunit.core.domain.*;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.*;
import io.swagger.v3.oas.annotations.*;
import io.swagger.v3.oas.annotations.responses.*;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.junit.jupiter.api.DisplayName;
import org.springframework.web.bind.annotation.*;

@AnalyzeClasses(
    packages = "com.ryuqq.adapter.in.rest",
    importOptions = ImportOption.DoNotIncludeTests.class
)
@DisplayName("OpenAPI ArchUnit 규칙")
class OpenApiArchTest {

    // ======= 패키지 상수 =======
    private static final String CONTROLLER_PACKAGE = "..controller..";
    private static final String DTO_COMMAND_PACKAGE = "..dto.command..";
    private static final String DTO_QUERY_PACKAGE = "..dto.query..";
    private static final String DTO_RESPONSE_PACKAGE = "..dto.response..";

    // ======= Controller 규칙 =======

    @ArchTest
    @DisplayName("Controller 클래스는 @Tag 어노테이션이 있어야 한다")
    static final ArchRule controller_should_have_tag_annotation =
        classes()
            .that().resideInAPackage(CONTROLLER_PACKAGE)
            .and().haveSimpleNameEndingWith("Controller")
            .and().areAnnotatedWith(RestController.class)
            .should().beAnnotatedWith(Tag.class)
            .because("Controller는 @Tag로 API 그룹을 정의해야 합니다");

    @ArchTest
    @DisplayName("Controller public 메서드는 @Operation 어노테이션이 있어야 한다")
    static final ArchRule controller_methods_should_have_operation_annotation =
        methods()
            .that().areDeclaredInClassesThat().resideInAPackage(CONTROLLER_PACKAGE)
            .and().areDeclaredInClassesThat().haveSimpleNameEndingWith("Controller")
            .and().arePublic()
            .and(areHttpMappingMethods())
            .should().beAnnotatedWith(Operation.class)
            .because("모든 API 메서드는 @Operation으로 설명이 필요합니다");

    @ArchTest
    @DisplayName("Controller public 메서드는 @ApiResponses 어노테이션이 있어야 한다")
    static final ArchRule controller_methods_should_have_api_responses =
        methods()
            .that().areDeclaredInClassesThat().resideInAPackage(CONTROLLER_PACKAGE)
            .and().areDeclaredInClassesThat().haveSimpleNameEndingWith("Controller")
            .and().arePublic()
            .and(areHttpMappingMethods())
            .should().beAnnotatedWith(ApiResponses.class)
            .because("모든 API 메서드는 @ApiResponses로 응답 코드를 정의해야 합니다");

    // ======= DTO 규칙 =======

    @ArchTest
    @DisplayName("Command Request DTO는 @Schema 어노테이션이 있어야 한다")
    static final ArchRule command_request_dto_should_have_schema =
        classes()
            .that().resideInAPackage(DTO_COMMAND_PACKAGE)
            .and().haveSimpleNameEndingWith("ApiRequest")
            .and().areRecords()
            .should().beAnnotatedWith(Schema.class)
            .because("Request DTO는 @Schema로 문서화해야 합니다");

    @ArchTest
    @DisplayName("Query Request DTO는 @Schema 어노테이션이 있어야 한다")
    static final ArchRule query_request_dto_should_have_schema =
        classes()
            .that().resideInAPackage(DTO_QUERY_PACKAGE)
            .and().haveSimpleNameEndingWith("ApiRequest")
            .and().areRecords()
            .should().beAnnotatedWith(Schema.class)
            .because("Request DTO는 @Schema로 문서화해야 합니다");

    @ArchTest
    @DisplayName("Response DTO는 @Schema 어노테이션이 있어야 한다")
    static final ArchRule response_dto_should_have_schema =
        classes()
            .that().resideInAPackage(DTO_RESPONSE_PACKAGE)
            .and().haveSimpleNameEndingWith("ApiResponse")
            .and().areRecords()
            .should().beAnnotatedWith(Schema.class)
            .because("Response DTO는 @Schema로 문서화해야 합니다");

    // ======= Helper Methods =======

    private static DescribedPredicate<JavaMethod> areHttpMappingMethods() {
        return new DescribedPredicate<>("HTTP 매핑 메서드") {
            @Override
            public boolean test(JavaMethod method) {
                return method.isAnnotatedWith(GetMapping.class)
                    || method.isAnnotatedWith(PostMapping.class)
                    || method.isAnnotatedWith(PutMapping.class)
                    || method.isAnnotatedWith(PatchMapping.class)
                    || method.isAnnotatedWith(DeleteMapping.class)
                    || method.isAnnotatedWith(RequestMapping.class);
            }
        };
    }
}

5) 규칙 요약 (12개)

카테고리 규칙 검증 내용
Controller @Tag 필수 Controller 클래스에 @Tag
Controller @Operation 필수 HTTP 메서드에 @Operation
Controller @ApiResponses 필수 HTTP 메서드에 @ApiResponses
Controller @Parameter 필수 @PathVariable에 @Parameter
Controller @Parameter 필수 @RequestParam에 @Parameter
Controller summary 필수 @Operation.summary 비어있지 않음
DTO Command @Schema 필수 Command DTO 클래스에 @Schema
DTO Query @Schema 필수 Query DTO 클래스에 @Schema
DTO Response @Schema 필수 Response DTO 클래스에 @Schema
DTO 필드 @Schema 필수 모든 필드에 @Schema
DTO description 필수 @Schema.description 비어있지 않음
Enum @Schema 필수 Enum에 @Schema

6) 관련 문서

문서 설명
OpenAPI Guide OpenAPI 어노테이션 가이드
Controller ArchUnit Controller 아키텍처 규칙
DTO ArchUnit DTO 아키텍처 규칙

작성자: Development Team 최종 수정일: 2025-12-04 버전: 1.0.0