스프링 사전과제 가이드 3편: Documentation & AOP

스프링 사전과제 가이드 3편: Documentation & AOP


시리즈 네비게이션

이전현재다음
2편: DB & Testing3편: Documentation & AOP4편: Performance

📚 전체 로드맵: 스프링 사전과제 가이드 로드맵 참고


서론

1~2편에서 다룬 핵심 기능 구현 이후, 이번 편에서는 API 문서화와 횡단 관심사를 다룬다.

3편에서 다루는 내용:

  • API 문서화 (Swagger, REST Docs)
  • 로깅 전략 (SLF4J, MDC)
  • AOP 활용 (공통 관심사 분리)

목차


API 문서화 (SpringDoc/Swagger)

과제에서 API 문서화는 필수는 아니지만, 있으면 평가자가 API를 빠르게 파악할 수 있어 좋은 인상을 줄 수 있다.

SpringDoc vs Springfox

  • Springfox는 Spring Boot 2.6+ 호환 이슈로 더 이상 권장되지 않음
  • SpringDoc OpenAPI를 사용하는 것이 현재 표준
💬 Swagger 문서화, 어느 정도까지 해야 할까?

최소한의 문서화 (권장)

  • API 제목, 설명, 버전 정보 (OpenApiConfig)
  • 주요 API의 @Operation (summary 정도)
  • 에러 응답 코드 (@ApiResponse)

과도한 문서화 (비권장)

  • 모든 필드에 @Schema 상세 설명
  • 예시 값 전부 작성
  • 모든 에러 케이스 문서화

실무에서의 현실

대부분의 프로젝트에서 Swagger 문서화는 초기에만 열심히 하고, 이후에는 코드와 동기화가 안 되는 경우가 많다.

문서 관리가 안 될 때 해결 방법

방법설명효과
Spring REST Docs 전환테스트 기반 문서화 → 테스트 실패 시 문서도 실패코드-문서 동기화 강제
최소 문서화 원칙@Tag, @Operation(summary) 정도만 유지유지보수 부담 감소
자동 생성 활용SpringDoc이 자동 생성하는 부분에 의존추가 작업 최소화
CI에서 검증OpenAPI spec 변경 시 리뷰 필수의도치 않은 변경 방지

과제에서의 권장

  1. 기본 설정만 해서 Swagger UI가 동작하도록 함
  2. 복잡한 API 1~2개에만 상세 문서화
  3. 나머지는 기본 자동 생성에 맡김
// ✅ 적절한 수준
@Operation(summary = "상품 등록")
@PostMapping
fun registerProduct(...)

// ❌ 과도한 문서화 (시간 낭비)
@Operation(
    summary = "상품 등록",
    description = "새로운 상품을 등록합니다. 상품명은 100자 이내...",
    responses = [
        ApiResponse(responseCode = "201", description = "...", content = [...]),
        ApiResponse(responseCode = "400", description = "...", content = [...]),
        ApiResponse(responseCode = "500", description = "...", content = [...])
    ]
)

1. 의존성 추가

build.gradle
dependencies {
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
}
build.gradle.kts
dependencies {
    implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0")
}

2. 기본 설정

application.yml
springdoc:
  api-docs:
    path: /api-docs                          # OpenAPI JSON 스펙 경로 (/api-docs로 접근)
  swagger-ui:
    path: /swagger-ui.html                   # Swagger UI 접근 경로
    tags-sorter: alpha                       # Tag(Controller) 알파벳 순 정렬
    operations-sorter: alpha                 # API 메서드 알파벳 순 정렬 (method: HTTP 메서드 순)
  default-consumes-media-type: application/json   # 요청 기본 Content-Type
  default-produces-media-type: application/json   # 응답 기본 Content-Type
  # packages-to-scan: com.example.api.controller  # 특정 패키지만 스캔 (선택)
  # paths-to-match: /api/**                       # 특정 경로만 문서화 (선택)
설정설명기본값
api-docs.pathOpenAPI JSON 스펙 경로/v3/api-docs
swagger-ui.pathSwagger UI 경로/swagger-ui.html
tags-sorterController 정렬 (alpha, 선언순)선언순
operations-sorterAPI 정렬 (alpha, method)선언순
OpenAPI Config (Kotlin)
@Configuration
class OpenApiConfig {

    @Bean
    fun openAPI(): OpenAPI {
        return OpenAPI()
            .info(
                Info()
                    .title("Product API")
                    .description("상품 관리 API 문서")
                    .version("v1.0.0")
                    .contact(
                        Contact()
                            .name("Developer")
                            .email("dev@example.com")
                    )
            )
            .servers(
                listOf(
                    Server().url("http://localhost:8080").description("Local Server")
                )
            )
    }
}
OpenAPI Config (Java)
@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI openAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("Product API")
                .description("상품 관리 API 문서")
                .version("v1.0.0")
                .contact(new Contact()
                    .name("Developer")
                    .email("dev@example.com")))
            .servers(List.of(
                new Server().url("http://localhost:8080").description("Local Server")
            ));
    }
}

3. Controller 문서화

주요 어노테이션:

  • @Tag: API 그룹 지정
  • @Operation: API 설명
  • @Parameter: 파라미터 설명
  • @ApiResponse: 응답 설명
  • @Schema: 모델 필드 설명
Controller 문서화 (Kotlin)
@Tag(name = "Product", description = "상품 관리 API")
@RestController
@RequestMapping("/api/v1/products")
class ProductController(
    private val productService: ProductService
) {
    @Operation(
        summary = "상품 상세 조회",
        description = "상품 ID로 상품 상세 정보를 조회합니다."
    )
    @ApiResponses(
        ApiResponse(responseCode = "200", description = "조회 성공"),
        ApiResponse(responseCode = "404", description = "상품을 찾을 수 없음")
    )
    @GetMapping("/{productId}")
    fun findProductDetail(
        @Parameter(description = "상품 ID", example = "1")
        @PathVariable productId: Long
    ): CommonResponse<FindProductDetailResponse> {
        return CommonResponse.success(productService.findProductDetail(productId))
    }

    @Operation(
        summary = "상품 목록 조회",
        description = "조건에 맞는 상품 목록을 페이징하여 조회합니다."
    )
    @GetMapping
    fun findProducts(
        @Parameter(description = "상품명 (부분 일치)")
        @RequestParam(required = false) name: String?,
        @Parameter(description = "활성화 여부")
        @RequestParam(required = false) enabled: Boolean?,
        @ParameterObject pageable: Pageable
    ): CommonResponse<Page<FindProductResponse>> {
        return CommonResponse.success(
            productService.findProducts(name, enabled, pageable)
        )
    }

    @Operation(
        summary = "상품 등록",
        description = "새로운 상품을 등록합니다."
    )
    @ApiResponses(
        ApiResponse(responseCode = "201", description = "등록 성공"),
        ApiResponse(responseCode = "400", description = "잘못된 요청")
    )
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun registerProduct(
        @RequestBody request: RegisterProductRequest
    ): CommonResponse<Long> {
        return CommonResponse.success(productService.registerProduct(request))
    }
}
Controller 문서화 (Java)
@Tag(name = "Product", description = "상품 관리 API")
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    @Operation(
        summary = "상품 상세 조회",
        description = "상품 ID로 상품 상세 정보를 조회합니다."
    )
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "조회 성공"),
        @ApiResponse(responseCode = "404", description = "상품을 찾을 수 없음")
    })
    @GetMapping("/{productId}")
    public CommonResponse<FindProductDetailResponse> findProductDetail(
            @Parameter(description = "상품 ID", example = "1")
            @PathVariable Long productId) {
        return CommonResponse.success(productService.findProductDetail(productId));
    }

    @Operation(
        summary = "상품 목록 조회",
        description = "조건에 맞는 상품 목록을 페이징하여 조회합니다."
    )
    @GetMapping
    public CommonResponse<Page<FindProductResponse>> findProducts(
            @Parameter(description = "상품명 (부분 일치)")
            @RequestParam(required = false) String name,
            @Parameter(description = "활성화 여부")
            @RequestParam(required = false) Boolean enabled,
            @ParameterObject Pageable pageable) {
        return CommonResponse.success(
            productService.findProducts(name, enabled, pageable)
        );
    }

    @Operation(
        summary = "상품 등록",
        description = "새로운 상품을 등록합니다."
    )
    @ApiResponses({
        @ApiResponse(responseCode = "201", description = "등록 성공"),
        @ApiResponse(responseCode = "400", description = "잘못된 요청")
    })
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public CommonResponse<Long> registerProduct(
            @RequestBody RegisterProductRequest request) {
        return CommonResponse.success(productService.registerProduct(request));
    }
}

4. DTO 문서화

@Schema 어노테이션으로 필드 설명을 추가한다.

Request DTO (Kotlin)

💡 가격 필드는 BigDecimal 사용을 권장

금융/가격 데이터는 Int/Long 대신 BigDecimal을 사용하는 것이 실무 표준이다.

타입장점단점권장 상황
Int/Long단순, 성능 우수소수점 불가, 오버플로우 위험단순 개수, ID
BigDecimal정밀도 보장, 소수점 처리연산 복잡금액, 가격, 비율
// Int 사용 시 (간단한 과제용)
@field:Positive
@Schema(description = "가격", example = "10000")
val price: Int?

// BigDecimal 사용 시 (실무 권장)
@field:DecimalMin(value = "0", inclusive = false)
@Schema(description = "가격", example = "10000.00")
val price: BigDecimal?
@Schema(description = "상품 등록 요청")
data class RegisterProductRequest(
    @field:NotBlank
    @field:Size(max = 100)
    @Schema(description = "상품명", example = "맛있는 사과", maxLength = 100)
    val name: String?,

    @field:NotNull
    @field:DecimalMin(value = "0", inclusive = false)
    @Schema(description = "가격", example = "10000.00", minimum = "0.01")
    val price: BigDecimal?,

    @field:NotNull
    @Schema(description = "카테고리", example = "FOOD")
    val category: ProductCategoryType?
)
Request DTO (Java)
@Schema(description = "상품 등록 요청")
public record RegisterProductRequest(
    @NotBlank
    @Size(max = 100)
    @Schema(description = "상품명", example = "맛있는 사과", maxLength = 100)
    String name,

    @NotNull
    @Positive
    @Schema(description = "가격", example = "10000", minimum = "1")
    Integer price,

    @NotNull
    @Schema(description = "카테고리", example = "FOOD")
    ProductCategoryType category
) {}
Response DTO (Kotlin)
@Schema(description = "상품 상세 응답")
data class FindProductDetailResponse(
    @Schema(description = "상품 ID", example = "1")
    val id: Long,

    @Schema(description = "상품명", example = "맛있는 사과")
    val name: String,

    @Schema(description = "가격", example = "10000")
    val price: Int,

    @Schema(description = "카테고리", example = "FOOD")
    val category: ProductCategoryType,

    @Schema(description = "활성화 여부", example = "true")
    val enabled: Boolean,

    @Schema(description = "생성일시", example = "2024-01-01T10:00:00")
    val createdAt: LocalDateTime
) {
    companion object {
        fun from(product: Product): FindProductDetailResponse {
            return FindProductDetailResponse(
                id = product.id!!,
                name = product.name,
                price = product.price,
                category = product.category,
                enabled = product.enabled,
                createdAt = product.createdAt
            )
        }
    }
}

5. 공통 응답 문서화

CommonResponse 문서화 (Kotlin)
@Schema(description = "공통 응답")
data class CommonResponse<T>(
    @Schema(description = "응답 코드", example = "SUC200")
    val code: String = CODE_SUCCESS,

    @Schema(description = "응답 메시지", example = "success")
    val message: String = MSG_SUCCESS,

    @Schema(description = "응답 데이터")
    val data: T? = null
) {
    companion object {
        const val CODE_SUCCESS = "SUC200"
        const val MSG_SUCCESS = "success"

        fun <T> success(data: T? = null): CommonResponse<T> {
            return CommonResponse(CODE_SUCCESS, MSG_SUCCESS, data)
        }

        fun <T> error(code: String, message: String): CommonResponse<T> {
            return CommonResponse(code, message, null)
        }
    }
}

6. Security 환경에서의 Swagger 설정

Spring Security 사용 시 Swagger 경로를 허용해야 한다.

SecurityConfig (Kotlin)
@Configuration
@EnableWebSecurity
class SecurityConfig {

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        return http
            .csrf { it.disable() }
            .authorizeHttpRequests { auth ->
                auth
                    // Swagger UI 허용
                    .requestMatchers(
                        "/swagger-ui/**",
                        "/swagger-ui.html",
                        "/api-docs/**",
                        "/v3/api-docs/**"
                    ).permitAll()
                    // 그 외 요청
                    .anyRequest().authenticated()
            }
            .build()
    }
}
JWT 인증 설정이 있는 경우 (Kotlin)
@Configuration
class OpenApiConfig {

    @Bean
    fun openAPI(): OpenAPI {
        val securityScheme = SecurityScheme()
            .type(SecurityScheme.Type.HTTP)
            .scheme("bearer")
            .bearerFormat("JWT")
            .`in`(SecurityScheme.In.HEADER)
            .name("Authorization")

        val securityRequirement = SecurityRequirement()
            .addList("bearerAuth")

        return OpenAPI()
            .info(
                Info()
                    .title("Product API")
                    .version("v1.0.0")
            )
            .addSecurityItem(securityRequirement)
            .components(
                Components().addSecuritySchemes("bearerAuth", securityScheme)
            )
    }
}

7. Spring REST Docs (대안)

Swagger 대신 테스트 기반 으로 API 문서를 생성하는 방식이다. 테스트가 통과해야만 문서가 생성되므로 문서와 코드의 동기화가 보장 된다.

💬 Swagger vs REST Docs
비교 항목Swagger (SpringDoc)REST Docs
문서 생성 방식어노테이션 기반테스트 기반
문서-코드 동기화수동 관리 필요테스트 통과 시 자동 보장
런타임 의존성있음 (운영 배포 시 포함)없음 (빌드 시에만 사용)
Try it out 기능✅ 기본 제공❌ 별도 구현 필요
학습 곡선낮음높음
프로덕션 코드 침투어노테이션 추가 필요없음 (테스트 코드에만 존재)

Swagger가 적합한 경우

  • 빠른 프로토타이핑
  • Try it out 기능이 필요한 경우
  • 프론트엔드 협업이 많은 경우

REST Docs가 적합한 경우

  • 문서 정확성이 중요한 경우 (금융, 공공 API 등)
  • 프로덕션 코드를 깔끔하게 유지하고 싶은 경우
  • 테스트 커버리지가 높은 프로젝트

과제에서는 Swagger가 더 적합하다. 설정이 간단하고 Try it out 기능으로 평가자가 바로 테스트할 수 있기 때문이다.

의존성 추가 (build.gradle)
plugins {
    id 'org.asciidoctor.jvm.convert' version '3.3.2'
}

configurations {
    asciidoctorExt
}

dependencies {
    asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}

ext {
    snippetsDir = file('build/generated-snippets')
}

test {
    outputs.dir snippetsDir
}

asciidoctor {
    inputs.dir snippetsDir
    configurations 'asciidoctorExt'
    dependsOn test
}

// 생성된 문서를 static 폴더로 복사
tasks.register('copyDocument', Copy) {
    dependsOn asciidoctor
    from file("build/docs/asciidoc")
    into file("src/main/resources/static/docs")
}

build {
    dependsOn copyDocument
}
의존성 추가 (build.gradle.kts)
plugins {
    id("org.asciidoctor.jvm.convert") version "3.3.2"
}

val asciidoctorExt: Configuration by configurations.creating
val snippetsDir by extra { file("build/generated-snippets") }

dependencies {
    asciidoctorExt("org.springframework.restdocs:spring-restdocs-asciidoctor")
    testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc")
}

tasks.test {
    outputs.dir(snippetsDir)
}

tasks.asciidoctor {
    inputs.dir(snippetsDir)
    configurations(asciidoctorExt.name)
    dependsOn(tasks.test)
}

tasks.register<Copy>("copyDocument") {
    dependsOn(tasks.asciidoctor)
    from(file("build/docs/asciidoc"))
    into(file("src/main/resources/static/docs"))
}

tasks.build {
    dependsOn("copyDocument")
}
테스트 코드 (Java)
@WebMvcTest(ProductController.class)
@AutoConfigureRestDocs  // REST Docs 자동 설정
class ProductControllerDocsTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ProductService productService;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    @DisplayName("상품 상세 조회 API")
    void findProductDetail() throws Exception {
        // given
        FindProductDetailResponse response = new FindProductDetailResponse(
            1L, "맛있는 사과", 10000, ProductCategoryType.FOOD, true, LocalDateTime.now()
        );
        given(productService.findProductDetail(1L)).willReturn(response);

        // when & then
        mockMvc.perform(get("/api/v1/products/{productId}", 1L)
                .accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andDo(document("product-detail",  // 문서 식별자
                pathParameters(
                    parameterWithName("productId").description("상품 ID")
                ),
                responseFields(
                    fieldWithPath("code").description("응답 코드"),
                    fieldWithPath("message").description("응답 메시지"),
                    fieldWithPath("data.id").description("상품 ID"),
                    fieldWithPath("data.name").description("상품명"),
                    fieldWithPath("data.price").description("가격"),
                    fieldWithPath("data.category").description("카테고리"),
                    fieldWithPath("data.enabled").description("활성화 여부"),
                    fieldWithPath("data.createdAt").description("생성일시")
                )
            ));
    }

    @Test
    @DisplayName("상품 등록 API")
    void registerProduct() throws Exception {
        // given
        RegisterProductRequest request = new RegisterProductRequest(
            "맛있는 사과", 10000, ProductCategoryType.FOOD
        );
        given(productService.registerProduct(any())).willReturn(1L);

        // when & then
        mockMvc.perform(post("/api/v1/products")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andDo(document("product-create",
                requestFields(
                    fieldWithPath("name").description("상품명"),
                    fieldWithPath("price").description("가격"),
                    fieldWithPath("category").description("카테고리 (FOOD, HOTEL)")
                ),
                responseFields(
                    fieldWithPath("code").description("응답 코드"),
                    fieldWithPath("message").description("응답 메시지"),
                    fieldWithPath("data").description("생성된 상품 ID")
                )
            ));
    }
}
테스트 코드 (Kotlin - JUnit 스타일)
@WebMvcTest(ProductController::class)
@AutoConfigureRestDocs
class ProductControllerDocsTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @MockkBean
    private lateinit var productService: ProductService

    @Autowired
    private lateinit var objectMapper: ObjectMapper

    @Test
    @DisplayName("상품 상세 조회 API")
    fun findProductDetail() {
        // given
        val response = FindProductDetailResponse(
            id = 1L,
            name = "맛있는 사과",
            price = 10000,
            category = ProductCategoryType.FOOD,
            enabled = true,
            createdAt = LocalDateTime.now()
        )
        every { productService.findProductDetail(1L) } returns response

        // when & then
        mockMvc.perform(
            get("/api/v1/products/{productId}", 1L)
                .accept(MediaType.APPLICATION_JSON)
        )
            .andExpect(status().isOk)
            .andDo(
                document(
                    "product-detail",
                    pathParameters(
                        parameterWithName("productId").description("상품 ID")
                    ),
                    responseFields(
                        fieldWithPath("code").description("응답 코드"),
                        fieldWithPath("message").description("응답 메시지"),
                        fieldWithPath("data.id").description("상품 ID"),
                        fieldWithPath("data.name").description("상품명"),
                        fieldWithPath("data.price").description("가격"),
                        fieldWithPath("data.category").description("카테고리"),
                        fieldWithPath("data.enabled").description("활성화 여부"),
                        fieldWithPath("data.createdAt").description("생성일시")
                    )
                )
            )
    }

    @Test
    @DisplayName("상품 등록 API")
    fun registerProduct() {
        // given
        val request = RegisterProductRequest(
            name = "맛있는 사과",
            price = 10000,
            category = ProductCategoryType.FOOD
        )
        every { productService.registerProduct(any()) } returns 1L

        // when & then
        mockMvc.perform(
            post("/api/v1/products")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request))
        )
            .andExpect(status().isCreated)
            .andDo(
                document(
                    "product-create",
                    requestFields(
                        fieldWithPath("name").description("상품명"),
                        fieldWithPath("price").description("가격"),
                        fieldWithPath("category").description("카테고리 (FOOD, HOTEL)")
                    ),
                    responseFields(
                        fieldWithPath("code").description("응답 코드"),
                        fieldWithPath("message").description("응답 메시지"),
                        fieldWithPath("data").description("생성된 상품 ID")
                    )
                )
            )
    }
}
테스트 코드 (Kotlin - Kotest DescribeSpec 스타일)

Kotest란? Kotlin 전용 테스트 프레임워크로, BDD 스타일의 DescribeSpec을 제공한다. 테스트 구조가 명확하고 가독성이 좋다.

// build.gradle.kts에 의존성 추가
// testImplementation("io.kotest:kotest-runner-junit5:5.8.0")
// testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.3")

@WebMvcTest(ProductController::class)
@AutoConfigureRestDocs
class ProductControllerDocsTest : DescribeSpec() {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @MockkBean
    private lateinit var productService: ProductService

    @Autowired
    private lateinit var objectMapper: ObjectMapper

    init {
        describe("상품 API") {
            context("상품 상세 조회 시") {
                it("상품 정보를 반환한다") {
                    // given
                    val response = FindProductDetailResponse(
                        id = 1L,
                        name = "맛있는 사과",
                        price = 10000,
                        category = ProductCategoryType.FOOD,
                        enabled = true,
                        createdAt = LocalDateTime.now()
                    )
                    every { productService.findProductDetail(1L) } returns response

                    // when & then
                    mockMvc.perform(
                        get("/api/v1/products/{productId}", 1L)
                            .accept(MediaType.APPLICATION_JSON)
                    )
                        .andExpect(status().isOk)
                        .andDo(
                            document(
                                "product-detail",
                                pathParameters(
                                    parameterWithName("productId").description("상품 ID")
                                ),
                                responseFields(
                                    fieldWithPath("code").description("응답 코드"),
                                    fieldWithPath("message").description("응답 메시지"),
                                    fieldWithPath("data.id").description("상품 ID"),
                                    fieldWithPath("data.name").description("상품명"),
                                    fieldWithPath("data.price").description("가격"),
                                    fieldWithPath("data.category").description("카테고리"),
                                    fieldWithPath("data.enabled").description("활성화 여부"),
                                    fieldWithPath("data.createdAt").description("생성일시")
                                )
                            )
                        )
                }
            }

            context("상품 등록 시") {
                it("생성된 상품 ID를 반환한다") {
                    // given
                    val request = RegisterProductRequest(
                        name = "맛있는 사과",
                        price = 10000,
                        category = ProductCategoryType.FOOD
                    )
                    every { productService.registerProduct(any()) } returns 1L

                    // when & then
                    mockMvc.perform(
                        post("/api/v1/products")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request))
                    )
                        .andExpect(status().isCreated)
                        .andDo(
                            document(
                                "product-create",
                                requestFields(
                                    fieldWithPath("name").description("상품명"),
                                    fieldWithPath("price").description("가격"),
                                    fieldWithPath("category").description("카테고리 (FOOD, HOTEL)")
                                ),
                                responseFields(
                                    fieldWithPath("code").description("응답 코드"),
                                    fieldWithPath("message").description("응답 메시지"),
                                    fieldWithPath("data").description("생성된 상품 ID")
                                )
                            )
                        )
                }
            }
        }
    }
}
AsciiDoc 템플릿 (src/docs/asciidoc/index.adoc)
= Product API 문서
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:

[[overview]]
== 개요

상품 관리 API 문서입니다.

[[Product-API]]
== 상품 API

[[Product-상세조회]]
=== 상품 상세 조회

operation::product-detail[snippets='path-parameters,response-fields,curl-request,http-response']

[[Product-등록]]
=== 상품 등록

operation::product-create[snippets='request-fields,response-fields,curl-request,http-response']
💡 REST Docs 실무 팁

테스트 추상화로 중복 제거

// 공통 설정을 상속받아 사용
@Import(RestDocsConfig.class)
public abstract class RestDocsTestSupport {
    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;
}

@TestConfiguration
public class RestDocsConfig {
    @Bean
    public RestDocumentationResultHandler restDocs() {
        return MockMvcRestDocumentation.document(
            "{class-name}/{method-name}",  // 자동 명명
            Preprocessors.preprocessRequest(Preprocessors.prettyPrint()),
            Preprocessors.preprocessResponse(Preprocessors.prettyPrint())
        );
    }
}

필드 제약조건 문서화

// Validation 어노테이션 정보를 문서에 포함
requestFields(
    fieldWithPath("name")
        .description("상품명")
        .attributes(key("constraints").value("필수, 최대 100자")),
    fieldWithPath("price")
        .description("가격")
        .attributes(key("constraints").value("필수, 양수"))
)

에러 응답 문서화

@Test
void findProductDetail_notFound() throws Exception {
    given(productService.findProductDetail(999L))
        .willThrow(new NotFoundException());

    mockMvc.perform(get("/api/v1/products/{productId}", 999L))
        .andExpect(status().isNotFound())
        .andDo(document("product-detail-error",
            responseFields(
                fieldWithPath("code").description("에러 코드"),
                fieldWithPath("message").description("에러 메시지"),
                fieldWithPath("data").description("null")
            )
        ));
}

로깅 전략

과제에서 로깅은 디버깅과 운영 관점에서 중요한 요소다. 적절한 로깅은 코드 품질을 높여준다.

💡 로깅 성능에 대한 오해와 진실

자주 하는 실수

// ❌ 비효율적 - DEBUG 레벨이 꺼져 있어도 문자열 연결이 실행됨
log.debug("User " + userId + " requested " + itemCount + " items");

// ✅ 효율적 - DEBUG 레벨이 꺼져 있으면 문자열 연결 안 함
log.debug("User {} requested {} items", userId, itemCount);

isDebugEnabled() 체크가 필요한 경우

// 단순 변수 대입은 체크 불필요
log.debug("User {} logged in", userId);

// 복잡한 연산이 포함된 경우에만 체크
if (log.isDebugEnabled()) {
    log.debug("Request details: {}", expensiveJsonSerialization(request));
}

실무 팁

  • 대부분의 경우 {} 플레이스홀더로 충분
  • toString()이 비용이 큰 객체만 isDebugEnabled() 체크
  • 루프 안에서의 로깅은 레벨 체크 권장
💬 MDC vs 분산 추적 시스템

MDC (Mapped Diagnostic Context)

  • 단일 애플리케이션 내에서 요청 추적
  • 직접 구현 필요
  • 마이크로서비스 간 추적은 어려움

분산 추적 시스템 (Zipkin, Jaeger, AWS X-Ray 등)

  • 여러 서비스에 걸친 요청 추적
  • 시각적 대시보드 제공
  • 설정 및 인프라 필요

선택 기준

상황권장
단일 애플리케이션, 과제MDC
마이크로서비스분산 추적 시스템
빠른 구현이 필요한 경우MDC

과제에서는 MDC 정도면 충분하다. 분산 추적 시스템은 인프라 설정이 필요하므로 과제 범위를 벗어나는 경우가 많다.

1. Logback 기본 설정

Spring Boot는 기본적으로 Logback을 사용한다.

application.yml (기본 로깅 설정)
logging:
  level:
    root: INFO
    com.example.app: DEBUG
    org.springframework.web: INFO
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
logback-spring.xml (상세 설정)
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- 프로파일별 설정 -->
    <springProfile name="local">
        <property name="LOG_LEVEL" value="DEBUG"/>
    </springProfile>
    <springProfile name="prod">
        <property name="LOG_LEVEL" value="INFO"/>
    </springProfile>

    <!-- Console Appender -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{requestId}] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- File Appender -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/application.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>logs/application.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>100MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{requestId}] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- Root Logger -->
    <root level="${LOG_LEVEL:-INFO}">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
    </root>

    <!-- 패키지별 로그 레벨 -->
    <logger name="com.example.app" level="DEBUG"/>
    <logger name="org.springframework.web" level="INFO"/>
    <logger name="org.hibernate.SQL" level="DEBUG"/>
</configuration>

2. MDC (Mapped Diagnostic Context)

MDC를 활용하면 요청별로 고유 ID를 부여하여 로그 추적이 용이해진다.

MDC Filter (Kotlin)
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class MdcFilter : OncePerRequestFilter() {

    companion object {
        const val REQUEST_ID = "requestId"
    }

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val requestId = request.getHeader("X-Request-ID")
            ?: UUID.randomUUID().toString().substring(0, 8)

        try {
            MDC.put(REQUEST_ID, requestId)
            response.setHeader("X-Request-ID", requestId)
            filterChain.doFilter(request, response)
        } finally {
            MDC.clear()
        }
    }
}
MDC Filter (Java)
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class MdcFilter extends OncePerRequestFilter {

    public static final String REQUEST_ID = "requestId";

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        String requestId = request.getHeader("X-Request-ID");
        if (requestId == null || requestId.isBlank()) {
            requestId = UUID.randomUUID().toString().substring(0, 8);
        }

        try {
            MDC.put(REQUEST_ID, requestId);
            response.setHeader("X-Request-ID", requestId);
            filterChain.doFilter(request, response);
        } finally {
            MDC.clear();
        }
    }
}

3. 로깅 레벨 가이드

레벨용도예시
ERROR즉시 대응이 필요한 오류DB 연결 실패, 외부 API 장애
WARN잠재적 문제, 대응 필요재시도 발생, 임계치 근접
INFO주요 비즈니스 이벤트주문 완료, 결제 성공
DEBUG개발/디버깅용 상세 정보메서드 진입/종료, 파라미터 값
TRACE매우 상세한 정보루프 내 값 변화
로깅 예시 (Kotlin)
@Service
class ProductService(
    private val productRepository: ProductRepository
) {
    private val log = LoggerFactory.getLogger(javaClass)

    @Transactional
    fun registerProduct(request: RegisterProductRequest): Long {
        log.debug("상품 등록 요청: name={}, price={}", request.name, request.price)

        val product = Product(
            name = request.name!!,
            price = request.price!!,
            category = request.category!!
        )

        val saved = productRepository.save(product)
        log.info("상품 등록 완료: productId={}", saved.id)

        return saved.id!!
    }

    fun findProductDetail(productId: Long): FindProductDetailResponse {
        log.debug("상품 조회: productId={}", productId)

        val product = productRepository.findById(productId)
            ?: run {
                log.warn("상품을 찾을 수 없음: productId={}", productId)
                throw NotFoundException()
            }

        return FindProductDetailResponse.from(product)
    }
}
로깅 예시 (Java)
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    @Transactional
    public Long registerProduct(RegisterProductRequest request) {
        log.debug("상품 등록 요청: name={}, price={}", request.name(), request.price());

        Product product = new Product(
            request.name(),
            request.price(),
            request.category()
        );

        Product saved = productRepository.save(product);
        log.info("상품 등록 완료: productId={}", saved.getId());

        return saved.getId();
    }

    public FindProductDetailResponse findProductDetail(Long productId) {
        log.debug("상품 조회: productId={}", productId);

        Product product = productRepository.findById(productId)
            .orElseThrow(() -> {
                log.warn("상품을 찾을 수 없음: productId={}", productId);
                return new NotFoundException();
            });

        return FindProductDetailResponse.from(product);
    }
}

4. 민감 정보 마스킹

로그에 민감 정보가 노출되지 않도록 주의한다.

마스킹 유틸리티 (Kotlin)
object MaskingUtils {

    fun maskEmail(email: String?): String {
        if (email.isNullOrBlank()) return "***"
        val atIndex = email.indexOf('@')
        if (atIndex <= 1) return "***"
        return email.substring(0, 2) + "***" + email.substring(atIndex)
    }

    fun maskPhone(phone: String?): String {
        if (phone.isNullOrBlank() || phone.length < 4) return "***"
        return phone.substring(0, 3) + "****" + phone.takeLast(4)
    }

    fun maskCardNumber(cardNumber: String?): String {
        if (cardNumber.isNullOrBlank() || cardNumber.length < 4) return "***"
        return "*".repeat(cardNumber.length - 4) + cardNumber.takeLast(4)
    }
}
마스킹 유틸리티 사용 예제
@Service
@RequiredArgsConstructor
class MemberService(
    private val memberRepository: MemberRepository
) {
    private val log = LoggerFactory.getLogger(javaClass)

    fun findMemberDetail(memberId: Long): MemberDetailResponse {
        val member = memberRepository.findById(memberId)
            .orElseThrow { NotFoundException("회원을 찾을 수 없습니다") }

        // 로그에는 마스킹된 정보만 출력
        log.info(
            "회원 조회 완료: memberId={}, email={}, phone={}",
            member.id,
            MaskingUtils.maskEmail(member.email),    // ho***@example.com
            MaskingUtils.maskPhone(member.phone)     // 010****1234
        )

        return MemberDetailResponse.from(member)
    }

    fun processPayment(memberId: Long, cardNumber: String, amount: Int) {
        // 결제 처리 전 로그
        log.info(
            "결제 요청: memberId={}, card={}, amount={}",
            memberId,
            MaskingUtils.maskCardNumber(cardNumber),  // ************1234
            amount
        )

        // 결제 처리 로직...
    }
}

실무 팁: 마스킹은 로그 출력 시점 에만 적용하고, 실제 비즈니스 로직에서는 원본 데이터를 사용해야 한다. 마스킹된 데이터로 비교나 처리를 하면 안 된다.


AOP 활용

AOP를 활용하면 횡단 관심사(로깅, 성능 측정 등)를 깔끔하게 분리할 수 있다.

⚠️ AOP 남용 주의

AOP가 적합한 경우

  • 로깅, 모니터링
  • 트랜잭션 관리
  • 보안/권한 체크
  • 캐싱

AOP가 부적합한 경우

  • 비즈니스 로직 구현
  • 복잡한 조건 분기
  • 특정 메서드에만 적용되는 로직

주의사항

  1. 디버깅 어려움: AOP로 처리되는 로직은 코드에서 직접 보이지 않아 디버깅이 어려움
  2. 성능 오버헤드: 모든 메서드에 Aspect를 적용하면 성능 저하 가능
  3. 순서 문제: 여러 Aspect가 있을 때 실행 순서 관리 필요 (@Order)
  4. self-invocation 문제: 같은 클래스 내 메서드 호출 시 AOP 적용 안 됨
@Service
public class ProductService {

    public void methodA() {
        methodB();  // ❌ AOP 적용 안 됨 (self-invocation)
    }

    @ExecutionTime
    public void methodB() { ... }
}

과제에서의 권장

  • 요청/응답 로깅 AOP 정도는 좋은 인상을 줄 수 있음
  • 너무 많은 AOP는 오히려 복잡성 증가
  • AOP를 사용했다면 README에 설명 추가
💬 AOP vs Filter vs Interceptor
구분적용 범위실행 시점사용 예시
Filter서블릿DispatcherServlet 전/후인코딩, CORS, 인증
InterceptorSpring MVCController 전/후인증, 로깅, 권한
AOPSpring Bean메서드 실행 전/후트랜잭션, 로깅, 캐싱

선택 가이드

  • HTTP 요청/응답 자체를 다룬다면: Filter
  • Controller 진입 전/후 처리: Interceptor
  • Service/Repository 등 비즈니스 로직: AOP

과제에서는 대부분 AOP나 Interceptor 중 하나만 사용해도 충분하다. 세 가지를 모두 사용할 필요는 없다.

1. 의존성 추가

build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-aop'
}

2. 요청/응답 로깅 AOP

RequestLoggingAspect (Kotlin)
@Aspect
@Component
class RequestLoggingAspect {

    private val log = LoggerFactory.getLogger(javaClass)
    private val objectMapper = ObjectMapper().apply {
        registerModule(JavaTimeModule())
        configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false)
    }

    @Pointcut("within(@org.springframework.web.bind.annotation.RestController *)")
    fun restController() {}

    @Around("restController()")
    fun logAround(joinPoint: ProceedingJoinPoint): Any? {
        val request = (RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)
            ?.request

        val methodName = joinPoint.signature.name
        val className = joinPoint.target.javaClass.simpleName

        // 요청 로깅
        log.info(
            "[REQUEST] {} {} - {}.{}",
            request?.method,
            request?.requestURI,
            className,
            methodName
        )

        if (log.isDebugEnabled) {
            val args = joinPoint.args
                .filterNotNull()
                .filter { it !is HttpServletRequest && it !is HttpServletResponse }
            if (args.isNotEmpty()) {
                log.debug("[REQUEST BODY] {}", toJson(args))
            }
        }

        val startTime = System.currentTimeMillis()

        return try {
            val result = joinPoint.proceed()
            val duration = System.currentTimeMillis() - startTime

            // 응답 로깅
            log.info(
                "[RESPONSE] {} {} - {}ms",
                request?.method,
                request?.requestURI,
                duration
            )

            if (log.isDebugEnabled && result != null) {
                log.debug("[RESPONSE BODY] {}", toJson(result))
            }

            result
        } catch (e: Exception) {
            val duration = System.currentTimeMillis() - startTime
            log.error(
                "[ERROR] {} {} - {}ms - {}",
                request?.method,
                request?.requestURI,
                duration,
                e.message
            )
            throw e
        }
    }

    private fun toJson(obj: Any): String {
        return try {
            objectMapper.writeValueAsString(obj)
        } catch (e: Exception) {
            obj.toString()
        }
    }
}
RequestLoggingAspect (Java)
@Aspect
@Component
@Slf4j
public class RequestLoggingAspect {

    private final ObjectMapper objectMapper;

    public RequestLoggingAspect() {
        this.objectMapper = new ObjectMapper();
        this.objectMapper.registerModule(new JavaTimeModule());
        this.objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
    }

    @Pointcut("within(@org.springframework.web.bind.annotation.RestController *)")
    public void restController() {}

    @Around("restController()")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
            .getRequestAttributes()).getRequest();

        String methodName = joinPoint.getSignature().getName();
        String className = joinPoint.getTarget().getClass().getSimpleName();

        // 요청 로깅
        log.info("[REQUEST] {} {} - {}.{}",
            request.getMethod(),
            request.getRequestURI(),
            className,
            methodName);

        if (log.isDebugEnabled()) {
            Object[] args = Arrays.stream(joinPoint.getArgs())
                .filter(Objects::nonNull)
                .filter(arg -> !(arg instanceof HttpServletRequest))
                .filter(arg -> !(arg instanceof HttpServletResponse))
                .toArray();

            if (args.length > 0) {
                log.debug("[REQUEST BODY] {}", toJson(args));
            }
        }

        long startTime = System.currentTimeMillis();

        try {
            Object result = joinPoint.proceed();
            long duration = System.currentTimeMillis() - startTime;

            // 응답 로깅
            log.info("[RESPONSE] {} {} - {}ms",
                request.getMethod(),
                request.getRequestURI(),
                duration);

            if (log.isDebugEnabled() && result != null) {
                log.debug("[RESPONSE BODY] {}", toJson(result));
            }

            return result;
        } catch (Exception e) {
            long duration = System.currentTimeMillis() - startTime;
            log.error("[ERROR] {} {} - {}ms - {}",
                request.getMethod(),
                request.getRequestURI(),
                duration,
                e.getMessage());
            throw e;
        }
    }

    private String toJson(Object obj) {
        try {
            return objectMapper.writeValueAsString(obj);
        } catch (Exception e) {
            return obj.toString();
        }
    }
}

3. 실행 시간 측정 AOP

특정 메서드의 실행 시간을 측정하고 싶을 때 사용한다.

ExecutionTime 어노테이션 (Kotlin)
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class ExecutionTime
ExecutionTimeAspect (Kotlin)
@Aspect
@Component
class ExecutionTimeAspect {

    private val log = LoggerFactory.getLogger(javaClass)

    @Around("@annotation(com.example.app.common.annotation.ExecutionTime)")
    fun measureExecutionTime(joinPoint: ProceedingJoinPoint): Any? {
        val methodName = joinPoint.signature.name
        val className = joinPoint.target.javaClass.simpleName

        val startTime = System.currentTimeMillis()

        return try {
            joinPoint.proceed()
        } finally {
            val duration = System.currentTimeMillis() - startTime
            log.info("[EXECUTION TIME] {}.{} - {}ms", className, methodName, duration)

            if (duration > 1000) {
                log.warn("[SLOW EXECUTION] {}.{} took {}ms", className, methodName, duration)
            }
        }
    }
}
사용 예시 (Kotlin)
@Service
class ProductService(
    private val productRepository: ProductRepository
) {
    @ExecutionTime
    fun findAllProducts(): List<FindProductResponse> {
        return productRepository.findAll()
            .map { FindProductResponse.from(it) }
    }
}

4. 트랜잭션 로깅 AOP

트랜잭션 시작/커밋/롤백을 로깅한다.

TransactionLoggingAspect (Kotlin)
@Aspect
@Component
class TransactionLoggingAspect {

    private val log = LoggerFactory.getLogger(javaClass)

    @Before("@annotation(transactional)")
    fun logTransactionStart(joinPoint: JoinPoint, transactional: Transactional) {
        val methodName = joinPoint.signature.name
        val readOnly = if (transactional.readOnly) "(readOnly)" else ""
        log.debug("[TX START{}] {}", readOnly, methodName)
    }

    @AfterReturning("@annotation(org.springframework.transaction.annotation.Transactional)")
    fun logTransactionCommit(joinPoint: JoinPoint) {
        val methodName = joinPoint.signature.name
        log.debug("[TX COMMIT] {}", methodName)
    }

    @AfterThrowing(
        pointcut = "@annotation(org.springframework.transaction.annotation.Transactional)",
        throwing = "ex"
    )
    fun logTransactionRollback(joinPoint: JoinPoint, ex: Exception) {
        val methodName = joinPoint.signature.name
        log.warn("[TX ROLLBACK] {} - {}", methodName, ex.message)
    }
}

5. 재시도 로직 AOP

외부 API 호출 등에서 재시도가 필요한 경우 활용한다.

Retry 어노테이션 (Kotlin)
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Retry(
    val maxAttempts: Int = 3,
    val delay: Long = 1000
)
RetryAspect (Kotlin)
@Aspect
@Component
class RetryAspect {

    private val log = LoggerFactory.getLogger(javaClass)

    @Around("@annotation(retry)")
    fun retry(joinPoint: ProceedingJoinPoint, retry: Retry): Any? {
        val methodName = joinPoint.signature.name
        var lastException: Exception? = null

        repeat(retry.maxAttempts) { attempt ->
            try {
                if (attempt > 0) {
                    log.info("[RETRY] {} - attempt {}/{}", methodName, attempt + 1, retry.maxAttempts)
                }
                return joinPoint.proceed()
            } catch (e: Exception) {
                lastException = e
                log.warn("[RETRY FAILED] {} - attempt {}/{} - {}",
                    methodName, attempt + 1, retry.maxAttempts, e.message)

                if (attempt < retry.maxAttempts - 1) {
                    Thread.sleep(retry.delay)
                }
            }
        }

        log.error("[RETRY EXHAUSTED] {} after {} attempts", methodName, retry.maxAttempts)
        throw lastException!!
    }
}

정리

핵심 포인트

항목체크 포인트
API 문서화SpringDoc 설정, 어노테이션 활용, Security 경로 허용
로깅적절한 로그 레벨, MDC 활용, 민감 정보 마스킹
AOP요청/응답 로깅, 실행 시간 측정, 횡단 관심사 분리

체크리스트

  • Swagger UI가 접속 가능한가? (/swagger-ui.html)
  • API 문서에 설명과 예시가 포함되어 있는가?
  • 로그에 요청 ID가 포함되어 추적 가능한가?
  • 민감 정보(비밀번호, 카드번호 등)가 로그에 노출되지 않는가?
  • 적절한 로그 레벨을 사용하고 있는가?
  • 느린 쿼리/메서드를 식별할 수 있는가?
💡 과제에서 플러스 알파가 되는 요소들

가점 요소 (시간이 남으면)

항목효과난이도
Swagger UI 접속 가능평가자가 바로 테스트 가능
요청 ID 로깅 (MDC)로그 추적 용이⭐⭐
실행 시간 로깅 AOP성능 관심 어필⭐⭐
API 버저닝 (/v1/)확장성 고려
Profile 분리 (local/test)환경 관리 역량

시간이 부족할 때 우선순위

  1. 핵심 기능 완성 - 동작하는 코드가 최우선
  2. 테스트 코드 - 주요 로직 1~2개라도
  3. 예외 처리 - GlobalExceptionHandler 필수
  4. README - 실행 방법, 설계 의도

하지 않아도 되는 것

  • 100% 테스트 커버리지
  • 모든 API의 상세 Swagger 문서화
  • 복잡한 AOP 구조
  • 과도한 디자인 패턴 적용

파일 구조 예시

src/main/kotlin/com/example/app/
├── common/
│   ├── annotation/
│   │   ├── ExecutionTime.kt
│   │   └── Retry.kt
│   ├── aop/
│   │   ├── RequestLoggingAspect.kt
│   │   ├── ExecutionTimeAspect.kt
│   │   └── RetryAspect.kt
│   ├── filter/
│   │   └── MdcFilter.kt
│   └── util/
│       └── MaskingUtils.kt
├── config/
│   ├── OpenApiConfig.kt
│   └── SecurityConfig.kt
└── ...

다음 편에서는 N+1 문제 해결, 페이지네이션, 캐싱 전략 에 대해 다룹니다.

👉 이전: 2편 - Database & Testing 👉 다음: 4편 - Performance & Optimization

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.