스프링 사전과제 가이드 3편: Documentation & AOP — Swagger·MDC·Aspect 운용 기준

스프링 사전과제 가이드 3편: Documentation & AOP — Swagger·MDC·Aspect 운용 기준


서론

“API 문서화, AOP, 로깅까지 신경 써야 가점이 될까?”

사전과제에서 이 영역은 명확하게 갈린다. Swagger UI가 동작하고 요청 ID가 로그에 찍히면 가점이다.

반대로 Springfox를 그대로 쓰거나, 로그 레벨을 모두 INFO로 통일하거나, AOP를 비즈니스 분기에 남용하면 감점이다.

1편은 4계층, 2편은 Database & Testing을 다뤘다. 3편은 그 위에 올라가는 운용·관측 영역이다.

평가자에게 “이 개발자는 운영 환경을 이해한다”는 신호를 어떻게 보낼 수 있는지를 다룬다.

대상 독자는 스프링은 알지만 평가자가 이 영역을 어떻게 보는지 잘 모르겠는 주니어 백엔드 개발자다.

이전 글에서 Database & Testing을 먼저 다뤘다.


TL;DR

  • SpringDoc은 최소 문서화로 충분하다@Tag, @Operation(summary), 주요 @ApiResponse면 평가에 충분하다. 모든 필드에 @Schema를 채우는 건 시간 낭비다.
  • 로깅 성능 미신을 버려라{} 플레이스홀더가 기본이다. isDebugEnabled() 체크는 비싼 toString() 호출이나 루프 안에서만 필요하다.
  • MDC로 단일 앱 요청을 추적하라 — 스레드 로컬에 요청 ID를 박아두면 Logback이 모든 로그에 자동으로 찍는다. 분산 추적은 과제 범위 밖이다.
  • AOP는 횡단 관심사에만 쓴다 — 로깅·트랜잭션·재시도는 AOP가 맞다. 비즈니스 로직 분기에 AOP를 쓰면 디버깅이 불가능해진다.
  • self-invocation 함정을 반드시 알아야 한다 — 같은 클래스 안에서 @Retry 메서드를 호출하면 프록시를 거치지 않아 AOP가 붙지 않는다. 책임을 다른 빈으로 분리하는 게 정답이다.

1. API 문서화 — SpringDoc/Swagger 운용 기준

1.1 SpringDoc 동작 원리와 기본 설정

SpringDoc OpenAPI = 런타임에 OpenAPI 스펙을 생성하고 Swagger UI로 서빙하는 라이브러리다.

애플리케이션이 뜰 때 @Tag, @Operation, @Schema 어노테이션을 스캔해 OpenAPI JSON을 만든다. 이 JSON을 /v3/api-docs(또는 설정한 경로)로 서빙하면, Swagger UI가 그것을 받아 브라우저에 플레이그라운드를 렌더링한다.

flowchart LR
    A["Controller 어노테이션<br/>@Tag · @Operation · @Schema"] --> B["SpringDoc 스캐너<br/>(런타임)"]
    B --> C["OpenAPI 스펙 JSON<br/>/v3/api-docs"]
    C --> D["Swagger UI<br/>/swagger-ui.html"]
    D --> E["브라우저 플레이그라운드"]

참고: Springfox는 Spring Boot 2.6+부터 호환 이슈가 생겨 더 이상 사용하지 않는다. SpringDoc OpenAPI가 현재 표준이다.

버전 — Spring Boot에 맞춰 SpringDoc 라인을 고른다

SpringDoc은 Spring Boot 메이저 버전에 따라 별도 라인으로 배포된다. 과제 환경의 Spring Boot 버전을 먼저 확인하고 거기에 맞춰 잡는다.

Spring BootSpringDoc 라인최신 버전 (2026-05)비고
4.x3.x3.0.3Java 17+, Jakarta EE 9, OpenAPI 3.1 지원
3.x (LTS)2.x2.8.172.8.7부터 BOM 제공, 여전히 활발히 유지보수
2.x1.x1.8.0레거시 — 신규 과제에서 만날 일은 거의 없음

의존성 추가 (Kotlin DSL)

// build.gradle.kts
dependencies {
    // Spring Boot 4.x
    implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3")

    // Spring Boot 3.x (LTS) — 회사 코드베이스가 LTS에 묶여 있는 경우
    // implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.17")
}
More detail — Groovy DSL 의존성
// build.gradle
dependencies {
    // Spring Boot 4.x
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3'

    // Spring Boot 3.x (LTS)
    // implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.17'
}

참고: WebFlux 환경이라면 springdoc-openapi-starter-webflux-ui로 교체한다. artifact 이름만 바뀔 뿐 같은 라인의 같은 버전을 그대로 쓴다.

application.yml 주요 옵션

설정설명기본값
api-docs.pathOpenAPI JSON 스펙 경로/v3/api-docs
swagger-ui.pathSwagger UI 경로/swagger-ui.html
tags-sorterController 정렬 (alpha, 선언순)선언순
operations-sorterAPI 정렬 (alpha, method)선언순
springdoc:
  api-docs:
    path: /api-docs
  swagger-ui:
    path: /swagger-ui.html
    tags-sorter: alpha
    operations-sorter: alpha
  default-consumes-media-type: application/json
  default-produces-media-type: application/json

OpenApiConfig (Kotlin)

API의 메타데이터(제목·버전·연락처·서버 URL)를 한 곳에 모아 두는 빈이다. 시작 시 컨테이너에 등록되면 SpringDoc이 자동으로 픽업해 OpenAPI 스펙에 박는다. 별도 설정이 없어도 SpringDoc은 동작하지만, 평가자가 Swagger UI를 열었을 때 비어 있는 제목·버전을 보면 “기본만 켜뒀다”는 인상을 주므로 이 빈 한 짝은 박아 두는 게 좋다.

@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")))
    }
}
More detail — OpenApiConfig (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")
            ));
    }
}

1.2 Controller·DTO 어노테이션 — “최소” vs “과다”

결론부터: @Tag, @Operation(summary), 주요 @ApiResponse면 평가에 충분하다. 모든 필드에 @Schema를 채우는 건 시간 낭비다. Swagger 어노테이션에 쓰는 시간은 핵심 기능 완성 시간을 잠식한다.

항목최소 (권장)과다 (비권장)
API 설명@Operation(summary = "…")description, 모든 에러 케이스 @ApiResponse
파라미터경로 변수에만 @Parameter모든 쿼리 파라미터 상세 기술
DTO 필드클래스 레벨 @Schema(description)모든 필드 @Schema(example, minimum, maxLength)

Controller 예시 (Kotlin)

위 표의 “최소” 컬럼을 그대로 적용한 모습이다. 클래스에 @Tag 한 번, 메서드마다 @Operation(summary)와 핵심 @ApiResponse 두세 개, 경로 변수에는 @Parameter를 한 줄씩만 붙인다. 이만큼이면 Swagger UI에서 그룹·요약·응답 코드까지 깔끔하게 보인다.

@Tag(name = "Product", description = "상품 관리 API")
@RestController
@RequestMapping("/api/v1/products")
class ProductController(private val productService: ProductService) {

    @Operation(summary = "상품 상세 조회")
    @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 = "상품 등록")
    @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))
    }
}
More detail — Controller 예시 (Java)
@Tag(name = "Product", description = "상품 관리 API")
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    @Operation(summary = "상품 상세 조회")
    @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 = "상품 등록")
    @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));
    }
}

Request DTO — 가격 타입 트레이드오프

가격 필드 타입은 과제 상황에 따라 선택하면 된다.

타입장점단점권장 상황
Int/Long단순, 성능 우수소수점 불가, 오버플로우 위험단순 개수, ID
BigDecimal정밀도 보장, 소수점 처리연산 복잡, 직렬화 주의금액, 가격, 비율

아래 DTO는 표에서 BigDecimal을 골랐을 때의 예시이고, @Schema도 1.2절의 “최소” 원칙대로 클래스 레벨 한 줄 + 필드 레벨 한 줄씩만 붙였다. 모든 필드에 example·minimum·maxLength까지 채우는 일은 의도적으로 피했다.

@Schema(description = "상품 등록 요청")
data class RegisterProductRequest(
    @field:NotBlank
    @field:Size(max = 100)
    @Schema(description = "상품명", example = "맛있는 사과")
    val name: String?,

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

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

1.3 Spring Security와 Swagger 경로 허용

SecurityConfig가 있으면 /swagger-ui/**, /v3/api-docs/** 가 401/403으로 막힌다. 평가자가 Swagger UI에 접근하지 못하면 점수 손해다.

@Configuration
@EnableWebSecurity
class SecurityConfig {

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        return http
            .csrf { it.disable() }
            .authorizeHttpRequests { auth ->
                auth
                    .requestMatchers(
                        "/swagger-ui/**",
                        "/swagger-ui.html",
                        "/api-docs/**",
                        "/v3/api-docs/**"
                    ).permitAll()
                    .anyRequest().authenticated()
            }
            .build()
    }
}
More detail — JWT 환경에서 bearerAuth 스키마 추가

JWT 인증이 있는 경우 Swagger UI에서 토큰을 입력해 테스트할 수 있도록 bearerAuth 스키마를 추가한다.

@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))
    }
}

1.4 참고: SpringDoc vs REST Docs — 선택 기준

과제는 SpringDoc이 정답이다. 설정이 간단하고 평가자가 Try it out으로 바로 API를 테스트할 수 있다.

REST Docs는 테스트를 통과해야만 문서가 생성되므로 코드-문서 동기화가 강제된다. 문서 정확성이 핵심인 금융·공공 API 환경에서 의미 있다.

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

의존성 추가 (build.gradle.kts)

plugins {
    id("org.asciidoctor.jvm.convert") version "4.0.5"
}

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") }

테스트 코드 (Kotlin)

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

    @Autowired private lateinit var mockMvc: MockMvc
    @MockkBean private lateinit var productService: ProductService

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

        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("생성일시")
                )
            ))
    }
}

AsciiDoc 템플릿 (src/docs/asciidoc/index.adoc)

= Product API 문서
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2

== 상품 API

=== 상품 상세 조회

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

2. 로깅 전략 — SLF4J · MDC · 마스킹

2.1 Logback 설정과 프로파일 분기

Spring Boot의 기본 로깅 구현체는 Logback이다. 별도 의존성 없이 spring-boot-starter에 포함되어 있다.

application.yml에서 빠르게 설정하고, 더 세밀한 제어가 필요할 때 logback-spring.xml을 추가한다.

# application.yml — 기본 로깅 설정
logging:
  level:
    root: INFO
    com.example.app: DEBUG
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{requestId}] %-5level %logger{36} - %msg%n"

logback-spring.xml은 프로파일 분기와 파일 롤링 정책이 필요할 때 사용한다.

설계 의도는 세 가지다. (1) <springProfile>로 local/prod 환경별 로그 레벨을 분리해 운영 환경에서 DEBUG 노이즈를 차단한다. (2) CONSOLE appender는 stdout으로 흘려보내 컨테이너 로그 수집기(Docker/Kubernetes)가 받아 처리하게 한다. (3) FILE appender는 100MB 단위로 회전하고 30일치만 보관해 디스크 폭주를 막는다. 운영에서 흔히 마주치는 두 가지 사고(과다 로그·디스크 풀)를 설정 시점에 미리 차단하는 구조다.

<?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>

    <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>

    <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 level="${LOG_LEVEL:-INFO}">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
    </root>

    <logger name="com.example.app" level="DEBUG"/>
    <logger name="org.hibernate.SQL" level="DEBUG"/>
</configuration>

JSON 구조화 로그 — Spring Boot 3.4+ 네이티브 지원

운영 환경에서 ELK/Loki 같은 로그 수집 파이프라인에 들어가려면 JSON 포맷이 표준이다. 예전에는 logstash-logback-encoder 같은 외부 encoder를 logback-spring.xml에 직접 끼워야 했지만, Spring Boot 3.4부터 별도 의존성 없이 한 줄로 켤 수 있다. 4.0에서는 포맷 옵션이 더 늘었다.

logging:
  structured:
    format:
      console: logstash   # 또는 ecs, gelf — 수집기 스펙에 맞춰 선택

사전과제에서 굳이 켤 필요는 없지만, “운영 시 로그 수집 파이프라인을 고려했다”는 신호로 README에 한 줄 언급하는 정도는 가점 요소가 된다.

2.2 로그 레벨 선택 기준

기준 한 줄: INFO에 비즈니스 이벤트, DEBUG에 디버깅용 상세, ERROR는 즉시 대응이 필요한 것만.

레벨용도예시
ERROR즉시 대응이 필요한 오류DB 연결 실패, 외부 API 장애
WARN잠재적 문제, 대응 필요재시도 발생, 임계치 근접
INFO주요 비즈니스 이벤트주문 완료, 결제 성공
DEBUG개발/디버깅용 상세메서드 진입, 파라미터 값
TRACE매우 상세한 정보루프 내 값 변화

이 표를 메서드별로 적용하면 패턴이 자연스럽게 정해진다 — 입력을 받은 직후 debug로 파라미터를 남기고, 비즈니스 이벤트가 완료된 시점에 info로 기록하고, 정상이지만 주의가 필요한 분기(예: not-found)에서 warn을 찍는다. 아래 ProductService가 그 패턴이다.

import org.springframework.data.repository.findByIdOrNull

@Service
class ProductService(private val productRepository: ProductRepository) {
    private val log = LoggerFactory.getLogger(javaClass)

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

        val saved = productRepository.save(Product(command.name, command.price, command.category))
        log.info("상품 등록 완료: productId={}", saved.id)

        return saved.id!!
    }

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

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

        return FindProductDetailResponse.from(product)
    }
}

참고: JpaRepository.findById(id)Optional<T>를 반환하므로 ?: run { … }이 작동하지 않는다. Spring Data가 제공하는 Kotlin 확장 findByIdOrNull(id)을 쓰면 T?를 돌려받아 elvis 연산자(?:)와 자연스럽게 결합된다. Optional을 그대로 쓰고 싶다면 Java 예시처럼 .orElseThrow { … }를 쓴다.

More detail — ProductService 로깅 예시 (Java)
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

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

        Product saved = productRepository.save(
            new Product(command.name(), command.price(), command.category()));
        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);
    }
}

2.3 MDC로 요청 추적

MDC(Mapped Diagnostic Context) = 스레드 로컬에 키-값을 박아 두면 Logback의 %X{key} 패턴이 자동으로 모든 로그에 같이 찍힌다.

요청마다 고유 ID를 MDC에 넣으면 한 요청의 모든 로그를 grep 한 번으로 묶어볼 수 있다.

sequenceDiagram
    participant Client
    participant MdcFilter
    participant MDC
    participant Controller
    participant Service
    participant Logger

    Client->>MdcFilter: HTTP 요청 (X-Request-ID: abc123)
    MdcFilter->>MDC: put("requestId", "abc123")
    MdcFilter->>Controller: filterChain.doFilter()
    Controller->>Service: 비즈니스 호출
    Service->>Logger: log.info("상품 등록 완료: …")
    Logger-->>Client: [abc123] 상품 등록 완료: … (자동 첨부)
    Note over MdcFilter: finally MDC.clear()

아래 MdcFilter가 다이어그램의 두 번째 박스다. OncePerRequestFilter를 상속해 한 요청에 한 번만 실행되도록 보장하고, @Order(HIGHEST_PRECEDENCE)로 다른 모든 Filter보다 먼저 실행되게 해서 이후 단계의 모든 로그가 requestId를 갖게 한다. try-finally로 감싸 응답 후 반드시 MDC.clear()를 호출하는 것이 핵심이다 — 스레드 풀이 이 스레드를 재사용할 때 이전 요청의 ID가 남아 있으면 다른 요청 로그에 잘못된 ID가 찍힌다.

@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()
        }
    }
}
More detail — MdcFilter (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();
        }
    }
}

참고: MDC는 단일 앱 요청 추적용이다. Zipkin·Jaeger 같은 분산 추적 시스템은 인프라 설정이 필요해 과제 범위를 벗어난다.

2.4 민감 정보 마스킹

핵심: 마스킹은 로그 출력 시점에만 적용한다. 비즈니스 로직은 반드시 원본 데이터를 사용한다.

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)
    }
}

호출 측에서는 비즈니스 로직은 원본 member 객체로 처리하고, log.info(...)에 들어가는 인자만 MaskingUtils로 한 번 감싼다. 다음 예시가 그 형태다.

// 사용 예시
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)
}

2.5 참고: 로깅 성능 미신과 진실

미신 1 — 문자열 연결은 {} 플레이스홀더와 같다

// 안티패턴 — DEBUG가 꺼져 있어도 문자열 연결이 실행됨
log.debug("User " + userId + " requested " + itemCount + " items")

// 올바른 방법 — DEBUG가 꺼져 있으면 연결 자체를 건너뜀
log.debug("User {} requested {} items", userId, itemCount)

미신 2 — isDebugEnabled() 체크는 항상 필요하다

isDebugEnabled() 체크가 필요한 경우는 두 가지뿐이다.

// 필요한 경우 1 — 비싼 toString() 호출
if (log.isDebugEnabled) {
    log.debug("Request details: {}", expensiveJsonSerialization(request))
}

// 필요한 경우 2 — 루프 안에서 반복 로깅
items.forEach { item ->
    if (log.isDebugEnabled) {
        log.debug("Processing item: {}", item.toDetailString())
    }
}

// 불필요한 체크 — {} 플레이스홀더는 알아서 평가를 지연시킴
if (log.isDebugEnabled) {
    log.debug("User {} logged in", userId)  // 이건 체크 없이도 됨
}

3. AOP — 횡단 관심사 분리

AOP(Aspect-Oriented Programming) = 본질이 다른 코드(로깅·트랜잭션·재시도·권한 체크)가 비즈니스 로직 메서드 앞뒤에 반복적으로 끼어드는 것을 별도 객체로 떼어내는 기법이다. 이렇게 “여러 메서드에 공통으로 끼어드는 코드”를 횡단 관심사(cross-cutting concern)라고 부른다.

문제는 이런 코드의 발생 빈도다. 메서드 30개짜리 Service에 시작·종료 로그를 박으려면 각 메서드 첫 줄과 끝 줄에 log.info(...)가 들어간다. 60줄이 비즈니스 로직과 무관한 보일러플레이트로 채워지고, 로그 포맷을 한 번 바꾸려면 30곳을 동시에 고쳐야 한다.

// AOP 없이 — 메서드마다 같은 패턴이 반복된다
fun registerProduct(...): Long {
    log.info("[START] registerProduct")                       // ← boilerplate
    val start = System.currentTimeMillis()                    // ← boilerplate
    try {
        val saved = repository.save(...)                      // ← 진짜 비즈니스 로직
        log.info("[END] registerProduct - {}ms", elapsed())   // ← boilerplate
        return saved.id!!
    } catch (e: Exception) {
        log.error("[ERROR] registerProduct - {}", e.message)  // ← boilerplate
        throw e
    }
}

// AOP로 분리 — 비즈니스 로직만 남는다
@Loggable   // ← 이 어노테이션 하나가 30개 메서드의 시작·종료·에러 로그를 일괄 처리
fun registerProduct(...): Long {
    val saved = repository.save(...)
    return saved.id!!
}

@Loggable은 예시용 가상 어노테이션이지만, 3.2~3.5절에서 만들 실제 Aspect들(RequestLoggingAspect, @ExecutionTime, @Retry)이 정확히 이 형태다. 로그 포맷을 바꾸려면 Aspect 한 곳만 고치면 된다.

핵심 용어

용어정의
Aspect횡단 관심사를 모은 클래스 (@Aspect로 선언)
JoinPointAdvice가 실행될 수 있는 지점. 스프링 AOP에서는 “메서드 실행”이 유일한 JoinPoint다
Pointcut어떤 JoinPoint에 Advice를 적용할지 고르는 표현식 (@annotation(...), within(...) 등)
AdviceJoinPoint에서 실행할 동작. @Before / @Around / @AfterReturning / @AfterThrowing
WeavingAspect를 대상 빈에 엮는 과정. 스프링은 런타임에 프록시를 만들어 weave한다

스프링 AOP는 런타임 프록시 기반이다. 컨텍스트가 뜰 때 CGLIB(클래스) 또는 JDK Dynamic Proxy(인터페이스)로 대상 빈을 감싼 객체를 만들고, 다른 빈에 주입할 때 원본 대신 이 프록시를 넘긴다. Pointcut에 매칭되는 메서드 호출은 프록시가 가로채 Advice를 실행한 뒤 원본 메서드로 위임한다. 이 “프록시를 거쳐야만 동작한다”는 사실이 3.4절 self-invocation 함정의 근원이 된다.

3.1 Filter vs Interceptor vs AOP — 선택 기준

셋 다 횡단 처리 도구지만 적용 경계가 다르다. 잘못된 계층에 로직을 두면 의존성이 섞이거나 스프링 컨텍스트에 접근하지 못한다.

flowchart TB
    Client([Client])

    subgraph Servlet["Servlet Container"]
        Filter["Filter Chain\n(인코딩, CORS, MDC, 인증)"]
    end

    subgraph Spring["Spring MVC"]
        DS["DispatcherServlet"]
        Inter["Interceptor\n(preHandle / postHandle)"]
        Ctrl["Controller\n(@RestController)"]
        AOP["AOP Advice\n(@Around, @Before, @AfterReturning)"]
        Svc["Service / Repository"]
    end

    Client --> Filter --> DS --> Inter --> Ctrl
    Ctrl --> AOP --> Svc
구분적용 범위실행 시점사용 예시
Filter서블릿DispatcherServlet 전/후인코딩, CORS, MDC, 인증 토큰 파싱
InterceptorSpring MVCController 전/후권한 체크, 로깅, 공통 응답 헤더
AOPSpring Bean메서드 실행 전/후트랜잭션, 실행 시간 측정, 재시도

선택 기준 한 줄씩:

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

3.2 요청·응답 로깅 Aspect

MDC가 requestId를 이미 처리하므로 RequestLoggingAspect는 메서드명·URI·duration을 한 줄로 모으는 역할만 담당한다. [REQUEST]/[RESPONSE]/[ERROR] 접두사를 붙이면 grep으로 빠르게 묶어볼 수 있다.

@Aspect
@Component
class RequestLoggingAspect {

    private val log = LoggerFactory.getLogger(javaClass)
    private val jsonWriter = 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 className = joinPoint.target.javaClass.simpleName
        val methodName = joinPoint.signature.name

        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 = try {
        jsonWriter.writeValueAsString(obj)
    } catch (e: Exception) {
        obj.toString()
    }
}
More detail — RequestLoggingAspect (Java)
@Aspect
@Component
@Slf4j
public class RequestLoggingAspect {

    private final ObjectMapper jsonWriter;

    public RequestLoggingAspect() {
        this.jsonWriter = new ObjectMapper();
        this.jsonWriter.registerModule(new JavaTimeModule());
        this.jsonWriter.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 className = joinPoint.getTarget().getClass().getSimpleName();
        String methodName = joinPoint.getSignature().getName();

        log.info("[REQUEST] {} {} - {}.{}",
            request.getMethod(), request.getRequestURI(), className, methodName);

        long startTime = System.currentTimeMillis();

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

3.3 실행 시간 측정 Aspect

@ExecutionTime 커스텀 어노테이션을 붙인 메서드의 실행 시간을 측정한다. 1000ms를 초과하면 WARN을 찍어 느린 메서드를 즉시 식별할 수 있게 한다.

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class ExecutionTime
@Aspect
@Component
class ExecutionTimeAspect {

    private val log = LoggerFactory.getLogger(javaClass)

    @Around("@annotation(com.example.app.common.annotation.ExecutionTime)")
    fun measureExecutionTime(joinPoint: ProceedingJoinPoint): Any? {
        val className = joinPoint.target.javaClass.simpleName
        val methodName = joinPoint.signature.name
        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)
            }
        }
    }
}
// 사용 예시
@Service
class ProductService(private val productRepository: ProductRepository) {

    @ExecutionTime
    fun findAllProducts(): List<FindProductResponse> {
        return productRepository.findAll().map { FindProductResponse.from(it) }
    }
}

3.4 재시도 Aspect와 self-invocation 함정

외부 API 호출이나 일시적인 네트워크 실패는 “한 번 실패해도 다시 시도하면 성공할 수 있는” 시나리오다. 이 재시도 로직을 비즈니스 메서드 안에 try-catch로 박으면 메서드마다 같은 패턴이 반복되고 진짜 로직이 묻힌다. 어노테이션 한 줄로 분리하는 게 AOP가 잘 어울리는 전형적인 케이스다.

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Retry(
    val maxAttempts: Int = 3,
    val delay: Long = 1000
)
@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!!
    }
}

self-invocation 함정 — 반드시 알아야 한다

AOP는 스프링 프록시 기반이다. 같은 클래스 안에서 메서드를 직접 호출하면 프록시를 거치지 않아 AOP가 붙지 않는다.

@Service
class ExternalApiService {

    fun callWithFallback() {
        callApi()  // ❌ self-invocation — @Retry가 붙지 않는다
    }

    @Retry(maxAttempts = 3)
    fun callApi() {
        // 외부 API 호출
    }
}

우회 방법은 세 가지다.

방법예시권장 여부
의존성을 다른 빈으로 분리ApiCaller 빈을 별도로 만들어 주입✅ 가장 일반적
AopContext.currentProxy() 사용(AopContext.currentProxy() as ExternalApiService).callApi()❌ 코드 오염
Self-injection@Autowired private lateinit var self: ExternalApiService⚠️ 트랜잭션 패턴과 동일

실무에서는 Spring Retry(@Retryable)나 Resilience4j를 쓰는 편이 낫다. 커스텀 RetryAspect보다 예외 타입 필터링, 백오프 전략, Circuit Breaker 통합이 훨씬 견고하다.

3.5 참고: 트랜잭션 로깅 Aspect와 @Transactional 한계

@Transactional은 트랜잭션을 시작·커밋·롤백 시키지만, 그 사실을 로그로 남기지는 않는다. 운영 환경에서 “이 요청이 트랜잭션을 열었는가, 커밋됐는가 롤백됐는가”를 확인하려면 직접 로그를 찍어야 한다. AOP로 @Transactional이 붙은 메서드 주변에 START/COMMIT/ROLLBACK 마커 로그를 추가하면 이 가시성을 얻을 수 있다.

@Before/@AfterReturning/@AfterThrowing을 조합해 트랜잭션 경계를 따라 로그를 찍는 Aspect는 다음과 같이 만든다.

@Aspect
@Component
class TransactionLoggingAspect {

    private val log = LoggerFactory.getLogger(javaClass)

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

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

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

주의: 이 Aspect는 @Transactional 메서드를 감싸는 것일 뿐이다. 실제 커밋/롤백은 Spring의 TransactionInterceptor가 결정한다. 따라서 이 로그가 실제 DB 트랜잭션 경계와 항상 일치하지는 않는다. 정확한 TX 추적이 필요하다면 TransactionSynchronizationManager로 훅해야 한다.


정리

  • Swagger는 최소 설정이 정답이다@Tag, @Operation(summary), Security 경로 허용으로 평가자가 바로 API를 테스트할 수 있게 하는 것이 목표다.
  • Logback 설정은 프로파일 분기가 핵심이다 — local은 DEBUG, prod는 INFO. logback-spring.xml<springProfile>로 분기하면 프로파일만 바꿔도 레벨이 바뀐다.
  • MDC가 단일 앱 요청 추적의 완성이다MdcFilter에서 requestId를 박고 %X{requestId}로 패턴에 넣으면 모든 로그에 자동으로 찍힌다. 분산 추적 시스템은 과제 범위를 벗어난다.
  • AOP는 로깅·시간 측정·재시도에만 쓴다 — 비즈니스 로직 분기에 AOP를 쓰면 코드가 어디서 실행되는지 추적이 불가능해진다.
  • self-invocation은 면접 단골 질문이다 — “AOP가 왜 안 붙나요?”의 답은 프록시 패턴 이해다. 해결책은 의존성을 다른 빈으로 분리하는 것이다.

다음 편에서는 인증·인가, Validation, N+1·캐싱 같은 후반부 평가 포인트를 다룬다. 운용·관측 기반이 탄탄하면 그 위의 성능·보안 포인트가 더 설득력 있게 전달된다.


부록

사전과제 제출 직전 Quick Checklist

  • Swagger UI가 접속 가능한가? (/swagger-ui.html)
  • Security 환경이면 /swagger-ui/**, /v3/api-docs/** 경로를 허용했는가?
  • 로그에 요청 ID가 포함되어 추적 가능한가?
  • 민감 정보(비밀번호, 카드번호 등)가 로그에 노출되지 않는가?
  • 적절한 로그 레벨을 사용하고 있는가? (ERROR/WARN/INFO/DEBUG 혼용)
  • AOP를 사용했다면 README에 설명을 추가했는가?
  • self-invocation 케이스가 있다면 의존성 분리로 우회했는가?

가점 요소 vs “굳이 안 해도 되는 것”

항목효과가점 여부
Swagger UI 접속 가능평가자가 바로 테스트 가능
요청 ID 로깅 (MDC)로그 추적 용이
실행 시간 로깅 AOP성능 관심 어필
API 버저닝 (/v1/)확장성 고려
모든 필드 @Schema 상세 기술시간 대비 효과 없음
REST Docs 도입과제 범위 초과
분산 추적 시스템 (Zipkin 등)인프라 필요, 과제 범위 초과
100% 테스트 커버리지핵심 기능 완성이 더 중요

파일 구조 예시

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

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