Spring Boot 4 + Kotlin 실무 교과서

Spring Boot 4 + Kotlin 실무 교과서

커스텀 인자 주입, Bean Validation, JPA 쿼리 메서드, MVC 확장 포인트, Envers와 soft delete, restdocs-api-spec, OpenTelemetry, 코루틴을 2026년 9월 기준으로 조사해 0장부터 8장까지와 부록으로 엮었습니다.

기준 버전Boot 4.1.1Framework 7.0Data JPA 4.1Hibernate 7.4Validator 9.1restdocs-api-spec 0.20.1Kotlin 2.2+

/ 키로 전체를 검색할 수 있습니다. 상단의 Boot 4 변경점과 검증 필요 버튼은 본문에 흩어진 해당 항목을 한곳에 모아 보여 줍니다.

먼저 확정할 세 가지

지금 스택(Boot 4.1.x, Kotlin 2.2+, Hibernate 7.4/Envers, soft delete, restdocs-api-spec, OTel)은 운영에 쓸 수 있습니다. 다만 코드를 쓰기 전에 세 가지를 먼저 확정하세요.

  1. Kotlin 컴파일 옵션과 테스트 의존성: 서로 다른 두 가지를 같이 정리해야 합니다.
    • (a) -Xannotation-default-target=param-property: Kotlin 2.1까지는 생성자 프로퍼티에 붙인 어노테이션이 파라미터에만 적용돼, Bean Validation이 필드를 읽지 못하고 무시했습니다. 이 옵션을 켜면 어노테이션 하나가 파라미터와 필드 양쪽에 함께 적용됩니다. 자세한 내용은 2.4 Kotlin 함정 참고.
      // 옵션 없음 — @field: 를 직접 붙여야 검증이 동작
      class SignupRequest(@field:NotBlank val name: String)
      
      // 옵션 켬 — 이 어노테이션만으로 파라미터+필드 양쪽에 적용
      class SignupRequest(@NotBlank val name: String)
      kotlin {
          compilerOptions {
              freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property")
          }
      }
    • (b) Boot 4 기능별 test 스타터: Boot 4부터는 스타터마다 test 스타터가 짝으로 나뉘었습니다. 이제 spring-boot-starter-test 하나로는 부족하고, 쓰는 기능의 test 스타터를 골라 추가해야 합니다.Boot 4 자세한 내용은 0.2 Boot 4 공통 함정 참고.
      // Boot 3 — 테스트 의존성 하나로 대부분 커버
      testImplementation("org.springframework.boot:spring-boot-starter-test")
      
      // Boot 4 — 쓰는 기능마다 test 스타터를 따로 추가
      testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
      testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
      testImplementation("org.springframework.boot:spring-boot-starter-security-test")
  2. restdocs-api-spec 버전: 0.20.x를 씁니다. 메인테이너가 Boot 4에서 “minor compatibility issues”가 남아 있다고 경고했습니다.
  3. @SoftDelete + Envers 검증: 이 조합에서 REVTYPE이 어떻게 기록되는지는 공식 문서로 확인되지 않았습니다. 통합 테스트로 직접 확인해야 합니다.

한눈에 보기

버전 기준

라이브러리버전
Spring Boot4.1.1 (2026-08-20)
Spring Framework7.0.x (stable 7.0.9)
Spring Data JPA4.1.0
Hibernate ORM7.4.10.Final
Hibernate Validator9.1.4.Final (Jakarta Validation 3.1.1)
restdocs-api-spec0.20.1
Spring REST Docs4.0

Boot 4에서 바뀐 핵심 API

  • @JsonComponent → @JacksonComponent
  • MappingJackson2HttpMessageConverter → JacksonJsonHttpMessageConverter (기존 것은 deprecated)
  • 신규 configureMessageConverters(HttpMessageConverters.ServerBuilder)
  • spring-boot-starter-aop → spring-boot-starter-aspectj
  • @AutoConfigureObservability 제거
  • OTLP 프로퍼티가 management.opentelemetry.*로 이동
  • Hibernate 7에서 @Where 제거

soft delete + Envers

  • 감사 기록이 남는 것은 EntityManager.remove()를 타는 경로(delete(entity), deleteBy…)뿐입니다.
  • deleteAllInBatch, 벌크 JPQL, 네이티브 쿼리는 Envers를 우회합니다.
  • 권장 조합은 @SQLDelete + @SQLRestriction + store_data_at_delete=true입니다.

코루틴

  • MVC에서 suspend나 Flow를 쓰려면 kotlinx-coroutines-reactor가 필요합니다. 테스트에서는 asyncDispatch를 거쳐야 합니다.
  • suspend 함수에 @Transactional을 붙이면 ReactiveTransactionManager가 필요합니다. JPA에서는 withContext(Dispatchers.IO) { transactionTemplate.execute { … } } 안에서 트랜잭션을 끝내세요.
  • 트레이스 전파에는 PropagationContextElement를 씁니다.

0장 버전·공통 함정

어떤 버전 조합을 기준으로 삼는지, 그리고 Boot 4로 올라오며 모든 모듈에 공통으로 걸리는 함정을 먼저 정리합니다.

이 장의 핵심

  • 패치 번호를 확인하지 못한 라이브러리는 Boot 4.1.1 BOM을 따릅니다.
  • Boot 4는 기능마다 스타터와 test 스타터가 짝으로 나뉘었습니다.
  • @SpringBootTest는 더 이상 MockMvc를 자동구성하지 않습니다.

0.1 버전 기준표

라이브러리기준 버전비고
Spring Boot4.1.1 (4.1.0: 2026-06-10)Hibernate 7.4 탑재. 4.0에서 deprecated된 항목은 4.1에서 제거됨
Spring Framework7.0.x (7.1.0-M1)Jackson 2 지원은 7.1에서 자동감지가 꺼지고 7.2에서 제거될 예정
Spring Data JPA4.1.0 (4.0 = Spring Data 2025.1)AOT repository와 PredicateSpecification 추가, ListenableFuture 제거
Hibernate ORM/Envers7.4.10.Final (2026-09-20)7.4에 incubating 단계의 @Audited/@Temporal 추가
Hibernate Validator9.1.4.FinalJava 17 이상 필요
Jackson3.x (tools.jackson)어노테이션은 계속 com.fasterxml.jackson.annotation 패키지
Kotlin / kotlinx.coroutines2.2 이상 필수 / 1.10.x 사용 확인최신 패치 번호는 확인하지 못함. Boot BOM을 따르세요검증 필요
restdocs-api-spec0.20.1Boot 4.x는 0.20.X 이상, Boot 3.x는 0.17.1–0.19.4
Spring REST Docs4.0 (2025-11-19)JUnit 6, Framework 7, Jackson 3 기반. REST Assured 통합 제거
Micrometer/OTelBoot BOM 관리Logback appender는 아직 -alpha

0.2 Boot 4 공통 함정

  • 모듈화: 기능마다 스타터와 test 스타터가 짝으로 나뉘었습니다(spring-boot-starter-webmvc(-test), -data-jpa-test, -security-test, -opentelemetry, -restdocs).Boot 4
    • @WithMockUser를 쓰려면 spring-boot-starter-security-test가 필요합니다.
    • Flyway를 쓰려면 spring-boot-starter-flyway가 필요합니다.
  • 점진적 이관: 먼저 spring-boot-starter-classic으로 빌드를 살려 두고, spring-boot-properties-migrator로 바뀐 프로퍼티를 찾아냅니다.
  • 테스트 설정: @SpringBootTest가 더 이상 MockMvc를 자동구성하지 않습니다. @AutoConfigureMockMvc를 붙여야 합니다.Boot 4
  • 헬스 프로브: 이제 기본으로 켜집니다.Boot 4

1장 커스텀 인자 주입 (@LoginUser)

컨트롤러 파라미터에 로그인 사용자 같은 값을 넣는 방법입니다. 리졸버를 직접 만드는 방법, Spring Security의 방법, 더 가벼운 대안을 차례로 봅니다.

이 장의 핵심

  • 커스텀 리졸버는 addArgumentResolvers로 등록하며, 내장 리졸버 뒤에 실행됩니다.
  • Principal 하위 타입은 내장 리졸버가 먼저 가져가므로 전용 타입(AuthUser)을 씁니다.
  • 리졸버가 만든 값에는 @Valid가 자동으로 적용되지 않습니다.

1.1 기본 구성 요소

  • supportsParameter(MethodParameter): 이 리졸버가 처리할 파라미터인지 판단합니다. 보통 어노테이션과 타입을 함께 확인합니다.
  • resolveArgument(MethodParameter, ModelAndViewContainer?, NativeWebRequest, WebDataBinderFactory?): 주입할 값을 만듭니다. 원본 요청은 getNativeRequest(HttpServletRequest::class.java)로 꺼냅니다.
  • WebMvcConfigurer.addArgumentResolvers: 리졸버를 등록합니다. 이렇게 등록한 리졸버는 내장 리졸버 뒤에 붙습니다.
  • MethodParameter.isOptional: 파라미터가 Kotlin nullable 타입이면 true입니다. 값이 필수인지 판단할 때 씁니다.
@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class LoginUser(val required: Boolean = true)

@Component
class LoginUserResolver(private val users: UserQuery) : HandlerMethodArgumentResolver {
    override fun supportsParameter(p: MethodParameter) =
        p.hasParameterAnnotation(LoginUser::class.java) && p.parameterType == AuthUser::class.java
    override fun resolveArgument(p: MethodParameter, m: ModelAndViewContainer?,
                                 r: NativeWebRequest, b: WebDataBinderFactory?): Any? {
        val jwt = SecurityContextHolder.getContext().authentication?.principal as? Jwt
        val user = jwt?.let { users.find(it.subject) }
        if (user == null && p.getParameterAnnotation(LoginUser::class.java)!!.required && !p.isOptional)
            throw UnauthenticatedException()
        return user
    }
}
@Configuration
class WebConfig(private val r: LoginUserResolver) : WebMvcConfigurer {
    override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) { resolvers += r }
}

1.2 우선순위 문제

파라미터 타입이 Principal의 하위 타입이면 내장 리졸버가 먼저 가져가서 커스텀 리졸버가 호출되지 않습니다. 해결 방법은 세 가지입니다.

  • Principal을 구현하지 않는 전용 타입(AuthUser)을 씁니다.
  • BeanPostProcessor에서 RequestMappingHandlerAdapter.setArgumentResolvers로 커스텀 리졸버를 목록 맨 앞에 넣습니다.
  • WebMvcRegistrations.getRequestMappingHandlerAdapter()로 어댑터를 교체합니다.

1.3 Spring Security 방식

  • @AuthenticationPrincipal(expression = "claims['sub']"): AuthenticationPrincipalArgumentResolver가 principal을 주입합니다.
  • @CurrentUser: @AuthenticationPrincipal을 메타 어노테이션으로 붙여 만든 커스텀 어노테이션입니다. 6.4부터는 AnnotationTemplateExpressionDefaults로 속성 템플릿을 쓸 수 있습니다.
  • @CurrentSecurityContext(expression = "authentication.name"): SecurityContext에서 표현식으로 값을 꺼내 주입합니다.

1.4 대안

  • HandlerInterceptor + @RequestAttribute: 인터셉터에서 테넌트 같은 값을 파싱해 요청 속성에 넣고, 핸들러에서 꺼내 씁니다.
  • @ControllerAdvice의 @ModelAttribute: 모든 핸들러에 공통 모델 값을 넣습니다.
  • Converter<String, OrderId> / Formatter: @PathVariable, @RequestParam 값을 커스텀 타입으로 변환합니다.
  • @RequestHeader("Idempotency-Key") key: String?: 헤더 값을 바로 주입합니다.

1.5 검증·테스트·suspend

  • 검증: 리졸버가 만든 값에는 @Valid가 자동으로 적용되지 않습니다. binderFactory.createBinder(...)로 바인더를 만들어 validate()를 호출하고, 실패하면 MethodArgumentNotValidException을 던지세요.
  • 테스트:
    • standaloneSetup(ctrl).setCustomArgumentResolvers(r)로 리졸버를 직접 등록합니다.
    • @WebMvcTest에서는 @MockitoBean을 씁니다. Boot 4에서 @MockBean이 제거됐습니다.Boot 4
    • 인증 사용자는 @WithMockUser나 SecurityMockMvcRequestPostProcessors.user() / authentication() / jwt { }로 주입합니다.
    • 복잡한 인증 상태는 커스텀 @WithSecurityContext로 만듭니다.
  • suspend 컨트롤러: 리졸버는 코루틴이 시작되기 전에 요청 스레드에서 실행되므로 안전합니다. 다만 코루틴 본문에서 디스패처를 바꾼 뒤에는 SecurityContextHolder가 비어 있을 수 있습니다.

2장 Bean Validation

Jakarta Validation 3.1의 내장 제약과 Hibernate Validator 추가 제약을 타입별로 정리하고, Kotlin에서 어노테이션이 엉뚱한 곳에 붙는 함정과 MVC 메서드 검증의 예외 구분을 다룹니다.

이 장의 핵심

  • @NotNull, @NotEmpty, @NotBlank, @Null을 뺀 모든 제약은 null을 유효한 값으로 봅니다.
  • Kotlin에서는 컴파일 옵션이나 @field:로 어노테이션 적용 대상을 확실히 합니다.
  • 파라미터 제약 실패는 HandlerMethodValidationException, 바디 객체 실패는 MethodArgumentNotValidException입니다.

2.1 Jakarta 내장 제약 전체

분류 버튼으로 필요한 타입의 제약만 골라 볼 수 있습니다.

분류제약적용 타입속성·의미
공통@Null, @NotNull전체null 여부
boolean@AssertTrue, @AssertFalseboolean/Boolean참/거짓
숫자@Min, @Max(value)BigDecimal, BigInteger, 정수형하한/상한. double/float는 지원하지 않음
숫자@DecimalMin, @DecimalMax(value, inclusive)숫자, CharSequence경계값을 문자열로 지정
숫자@Positive(OrZero), @Negative(OrZero)숫자부호
숫자@Digits(integer, fraction)숫자, CharSequence정수부·소수부 자릿수
문자열@Pattern(regexp, flags)CharSequenceFlag: UNIX_LINES, CASE_INSENSITIVE, COMMENTS, MULTILINE, DOTALL, UNICODE_CASE, CANON_EQ
문자열@Email(regexp, flags)CharSequence기본 규칙이 느슨하므로 regexp로 좁히세요
문자열@NotBlankCharSequence앞뒤 공백을 제거한 길이가 0보다 커야 함
문자열·컬렉션@NotEmpty, @Size(min, max)CharSequence, Collection, Map, 배열비어 있지 않음 / 크기 범위
날짜·시간@Past(OrPresent), @Future(OrPresent)Date, Calendar, Instant, LocalDate(Time), LocalTime, MonthDay, Offset/ZonedDateTime, OffsetTime, Year, YearMonth, Hijrah/Japanese/Minguo/ThaiBuddhistDate“현재”는 ClockProvider 기준. HV의 temporalValidationTolerance(hibernate.validator.temporal_validation_tolerance)로 허용 오차를 줄 수 있음

@NotNull, @NotEmpty, @NotBlank, @Null을 제외한 모든 제약은 null을 유효한 값으로 봅니다. null도 막으려면 @NotNull을 함께 붙이세요.

2.2 Hibernate Validator 추가 제약

제약설명
@Length, @CodePointLength(normalizationStrategy)문자열 길이 / 코드포인트 기준 길이
@Range(min,max)Min과 Max를 합친 제약
@URL(protocol,host,port,regexp), @UUID(allowEmpty,allowNil,version,variant,letterCase)형식 검증
@UniqueElements, @Normalized(form)원소 중복 금지 / 유니코드 정규화 형식
@DurationMin, @DurationMaxDuration 범위
@ISBN, @EAN, @CreditCardNumber, @LuhnCheck, @Mod10Check, @Mod11Check코드·체크섬
@CurrencyMonetaryAmount의 통화
@BitcoinAddressHV 9.0에서 추가
@ScriptAssert, @ParameterScriptAssert스크립트 기반 교차 검증(권장하지 않음)
@ConstraintComposition(AND/OR/ALL_FALSE)여러 제약을 불리언으로 합성
국가별브라질 @CPF, @CNPJ(format = NUMERIC/ALPHANUMERIC), @TituloEleitoral; 폴란드 @NIP, @PESEL, @REGON; 러시아 @INN; 한국 @KorRRN

한국 주민등록번호 @KorRRN검증 필요

HV 9.0에 새로 들어온 주민등록번호 제약입니다. 패키지는 org.hibernate.validator.constraints.kor이고, 체크섬 옵션의 속성명은 javadoc에서 확인하세요.

2.3 컨테이너·그룹·커스텀 제약

  • 컨테이너 원소 제약: List<@NotBlank String>, Map<@NotNull K, @Positive Int>, Optional<@Min(1) Int>처럼 원소 타입에 직접 붙입니다.
    • 컨테이너 쪽에는 @Valid가 필요 없습니다.
    • 위반 경로는 parts[1].<list element> 형태로 나옵니다.
  • cascade: @Valid를 붙이거나 List<@Valid Person>처럼 씁니다. 직접 만든 컨테이너 타입은 ValueExtractor를 구현해야 합니다.
  • 그룹:
    • groups 속성으로 제약마다 그룹을 지정합니다.
    • @GroupSequence는 앞 그룹이 실패하면 뒤 그룹 검증을 중단합니다.
    • @GroupSequenceProvider(HV)는 시퀀스를 동적으로 정합니다.
    • @ConvertGroup(from, to)는 그룹을 변환합니다.
    • Spring에서는 @Validated(OnCreate::class)로 그룹을 지정합니다.
  • 커스텀 제약:
    • @Constraint(validatedBy = [V::class])를 붙이고 message, groups, payload를 반드시 선언합니다.
    • ConstraintValidator에는 Spring 빈을 주입할 수 있습니다(SpringConstraintValidatorFactory 덕분입니다).
    • 단, JPA pre-persist 검증 경로에서는 주입이 안 될 수 있습니다. 이때는 jakarta.persistence.validation.factory를 설정하세요.
  • 교차 필드 검증: 기본 위반을 끄고 특정 필드에 위반을 붙입니다.
ctx.disableDefaultConstraintViolation()
ctx.buildConstraintViolationWithTemplate("{period.invalid}")
    .addPropertyNode("end")
    .addConstraintViolation()
  • 합성 제약: @ReportAsSingleViolation, @OverridesAttribute(constraint = Size::class, name = "max")를 씁니다. HV에서는 HibernateConstraintValidatorContext.addMessageParameter로 메시지 파라미터를 추가할 수 있습니다.

2.4 Kotlin 함정

use-site target

  • Kotlin 2.1까지는 생성자 프로퍼티에 붙인 어노테이션이 파라미터에만 적용됐습니다. 그래서 @field:, @param:, @get:을 명시해야 했습니다.
  • Kotlin 2.2의 새 규칙(param-property)은 파라미터와 프로퍼티/필드에 함께 붙입니다. 다만 2.2의 기본 동작은 경고만 내는 과도기 모드입니다.
  • Spring Initializr는 -Xannotation-default-target=param-property를 기본으로 켭니다(#1666).
  • 예전 동작이 필요하면 -Xannotation-default-target=first-only를 쓰세요.
  • @all:은 아직 프리뷰입니다.
kotlin {
    compilerOptions {
        freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property")
    }
}

non-null 필드에 null이 들어올 때

  • jackson-module-kotlin이 Bean Validation보다 먼저 HttpMessageNotReadableException을 던집니다.
  • 해결책 1: 필드를 String? + @field:NotNull로 선언합니다.
  • 해결책 2: 예외 핸들러에서 원인 예외의 path를 읽어 필드 오류로 바꿉니다.

2.5 MVC 메서드 검증·설정

예외 종류

  • MethodArgumentNotValidException: @Valid @RequestBody나 @ModelAttribute 객체가 검증에 실패할 때 납니다.
  • HandlerMethodValidationException: 파라미터에 직접 붙은 제약(예: @RequestParam @Min(1))이 실패할 때 납니다.
    • Spring 6.1부터는 @Validated 없이도 동작합니다.
    • 클래스에 @Validated를 붙이면 AOP 방식으로 바뀌어 ConstraintViolationException이 납니다. 그러니 컨트롤러에서는 @Validated를 빼세요.
  • ResponseEntityExceptionHandler가 두 예외를 모두 처리합니다.

메시지와 설정

  • 메시지: ValidationMessages.properties나 LocalValidatorFactoryBean.setValidationMessageSource로 정의합니다. 메시지 안에서 {min}, ${validatedValue}를 참조할 수 있습니다.
  • fail-fast: hibernate.validator.fail_fast=true 또는 ValidationConfigurationCustomizer로 켭니다. HV 9.1에는 property 위반 단위의 fail-fast 모드도 있습니다.
  • @ConfigurationProperties + @Validated: 중첩 객체까지 검증하려면 @field:Valid가 필요합니다.

3장 Spring Data JPA 쿼리 메서드

메서드 이름만으로 쿼리를 만드는 키워드 전체와 반환 타입, 프로젝션, Specification을 정리합니다. 마지막으로 soft delete 필터와 어떻게 맞물리는지 봅니다.

이 장의 핵심

  • 메서드 이름은 Subject(find…By)와 Predicate(조건 키워드)로 나뉩니다.
  • Containing과 IgnoreCase는 일반 인덱스를 타지 못합니다.
  • delete(entity), deleteById, deleteBy…만 soft delete와 Envers를 거칩니다.

3.1 Subject·Predicate 키워드

Subject의미
find/read/get/query/search/stream…By조회
exists…By, count…By존재 여부, 개수
delete…By, remove…By엔티티를 조회한 뒤 한 건씩 remove. 반환 타입은 void, 건수, 목록 중 선택
First<N>, Top<N>, Distinct결과 개수 제한, 중복 제거
키워드예시JPQL
And / OrfindByLastnameAndFirstnamea = ?1 and/or b = ?2
Is, EqualsfindByFirstnameIs= ?1 (값이 null이면 is null)
BetweenfindByStartDateBetweenbetween ?1 and ?2
LessThan(Equal), GreaterThan(Equal)findByAgeLessThan<, <=, >, >=
After / BeforefindByStartDateAfter> / <
IsNull/Null, IsNotNull/NotNullfindByAgeIsNullis (not) null
Like / NotLikefindByFirstnameLike(not) like ?1
StartingWith / EndingWith / ContainingfindByFirstnameContaininglike에 바인딩하는 값의 뒤/앞/양쪽에 %를 붙임
OrderByfindByAgeOrderByLastnameDescorder by … desc
NotfindByLastnameNot<> ?1
In / NotInfindByAgeIn(ages)(not) in ?1
True / FalsefindByActiveTrue()= true/false
IgnoreCasefindByFirstnameIgnoreCaseUPPER(a) = UPPER(?1)
IsEmpty, IsNotEmpty컬렉션 속성is (not) empty
  • 동의어: Commons의 IsBetween, StartsWith, Contains, IsIn, IsTrue 같은 동의어도 쓸 수 있습니다.
  • JPA에서 지원하지 않거나 제한적인 키워드: Near, Within, Regex, Exists
  • 기타: AllIgnoreCase를 쓸 수 있습니다. 중첩 경로는 findByAddress_ZipCode처럼 _로 구분합니다.
  • 성능 함정: IgnoreCase와 Containing은 일반 인덱스를 타지 못합니다. 함수 인덱스나 trigram 인덱스를 검토하세요.

쿼리 메서드 해석기

메서드 이름을 입력하면 키워드별로 나누고, 어떤 JPQL과 바인딩 값이 만들어지는지 보여 줍니다.

예시를 누르면 바로 해석합니다.


    학습용 근사 변환기입니다. 실제로 만들어지는 쿼리는 Spring Data 버전과 DB 방언에 따라 다릅니다.

    3.2 반환 타입·파라미터·프로젝션

    반환 타입

    • 단건: T?(없으면 null, 2건 이상이면 IncorrectResultSizeDataAccessException), Optional<T>, Vavr Option
    • 다건: List, Set, Collection, Iterator, Streamable(커스텀 래퍼 포함), Vavr 컬렉션
    • 스트림: Stream<T>는 트랜잭션 안에서 use {}로 닫아야 합니다.
    • 페이징: Page(count 쿼리가 추가로 실행됨), Slice, Window
    • 비동기: CompletableFuture, Future를 @Async와 함께 씁니다. 2025.1에서 ListenableFuture는 제거됐습니다.
    • 기타: SearchResults(벡터 검색), GeoResults
    • Mono/Flux/Flow는 리액티브 스토어 전용입니다.

    파라미터

    • Pageable, Sort, Limit.of(n)
    • ScrollPosition.keyset() / offset()을 받고 Window<T>를 반환합니다. 키셋 방식은 정렬 키가 유일해야 하므로 id를 tie-breaker로 넣으세요.
    • JpaSort.unsafe(...)
    • Kotlin에서는 Sort.by(User::lastname.name), (Book::author / Author::name).toDotPath()로 속성명을 타입 안전하게 씁니다.

    프로젝션

    • 인터페이스 기반: closed 또는 open(@Value("#{…}"))
    • DTO 기반: data class 생성자 파라미터 이름으로 매핑됩니다.
    • 동적 프로젝션: fun <T> findByLastname(n: String, type: Class<T>): List<T>

    어노테이션

    • @Query, @NativeQuery(3.4 이상), @Modifying(clearAutomatically, flushAutomatically)
    • @EntityGraph(attributePaths, type), @Lock, @QueryHints, @Meta
    • SpEL #{#entityName}
    • Query by Example: ExampleMatcher.matching().withIgnoreNullValues()

    Spring Data JPA 4의 Specification 변경Boot 4

    • PredicateSpecification, Specification, UpdateSpecification, DeleteSpecification으로 나뉘었습니다.
    • 조합용으로 unrestricted(), allOf, anyOf가 있습니다.
    • update/delete by spec은 벌크 연산이라 영속성 컨텍스트와 동기화되지 않습니다.

    2025.1 신규 기능

    • AOT repository: 기동이 빨라지고, 잘못된 메서드명을 빌드 시점에 잡아 줍니다.
    • JPQL 기반 파생 쿼리
    • 벡터 검색

    Kotlin 지원: nullable 반환 타입과 findByIdOrNull을 쓸 수 있습니다.

    3.3 soft delete와의 상호작용

    경로필터 (@SQLRestriction/@SoftDelete)soft-delete UPDATEEnvers
    파생 findBy…, JPQL적용––
    네이티브 쿼리미적용––
    delete(entity), deleteById, deleteBy…–적용(엔티티를 로드한 뒤 remove)기록
    deleteAllInBatch, @Modifying, spec 벌크–@SQLDelete는 우회함. @SoftDelete가 HQL delete를 어떻게 번역하는지는 버전별로 확인 필요검증 필요우회

    4장 MVC 확장 포인트

    메시지 컨버터, 반환값 처리, Advice, 예외 처리, WebMvcConfigurer까지 Spring MVC에 끼어드는 지점을 정리합니다. Jackson 3로 넘어가며 바뀐 부분을 특히 챙깁니다.

    이 장의 핵심

    • Jackson 3 컨버터와 새 configureMessageConverters(ServerBuilder) API를 씁니다.
    • 커스텀 반환값 핸들러도 내장 처리기 뒤에 붙으므로 순서를 직접 조정해야 합니다.
    • 공통 응답 봉투는 ResponseBodyAdvice로 만들되 String과 ProblemDetail은 제외합니다.

    4.1 메시지 컨버터

    • AbstractHttpMessageConverter: supports, readInternal, writeInternal만 구현하면 커스텀 포맷 컨버터가 됩니다.
    • SmartHttpMessageConverter: Spring 7에서는 GenericHttpMessageConverter 대신 이쪽이 중심입니다. ResolvableType과 hints를 받습니다.
    • Jackson 3 컨버터(Spring 7 신규): JacksonJsonHttpMessageConverter, JacksonXmlHttpMessageConverter, JacksonSmileHttpMessageConverter, JacksonCborHttpMessageConverter, JacksonYamlHttpMessageConverter가 있습니다.Boot 4
    • MappingJackson2HttpMessageConverter: 7.0부터 deprecated(forRemoval)입니다. @JsonView 처리는 hints 방식으로 바뀌었습니다.Boot 4
    • KotlinSerializationJsonHttpMessageConverter: @Serializable 클래스 전용입니다. Boot 4에서 spring-boot-starter-kotlinx-serialization-json을 쓰면 Jackson보다 앞에 등록됩니다.
    • 등록 방법: List를 받는 예전 configureMessageConverters와 extendMessageConverters는 deprecated입니다. 새 API는 다음과 같습니다.Boot 4
    override fun configureMessageConverters(builder: HttpMessageConverters.ServerBuilder) {
        builder.withJsonConverter(JacksonJsonHttpMessageConverter(JsonMapper.builder().findAndAddModules().build()))
            .addCustomConverter(CsvHttpMessageConverter())
    }

    Boot 4에서 바뀐 점

    • Boot의 HttpMessageConverters 빈 클래스는 deprecated입니다.Boot 4
    • JsonMapperBuilderCustomizer가 Jackson2ObjectMapperBuilderCustomizer를 대체합니다. 4.1에서는 JsonFactoryBuilderCustomizer 등이 추가됐습니다.Boot 4
    • @JsonComponent는 @JacksonComponent로, @JsonMixin은 @JacksonMixin으로 바뀌었습니다.Boot 4
    • spring.jackson.read/write.*는 spring.jackson.json.read/write.*로 옮겨졌습니다.Boot 4
    • spring.jackson.use-jackson2-defaults를 켜면 Boot 3.x 기본값에 가깝게 맞출 수 있습니다.
    • Jackson 3에서는 WRITE_DATES_AS_TIMESTAMPS 기본값이 false이고, 예외가 unchecked입니다.

    4.2 반환값·Advice·예외

    반환값 핸들러

    • 커스텀 HandlerMethodReturnValueHandler는 내장 핸들러 뒤에 등록됩니다. 그래서 @RestController의 반환값은 RequestResponseBodyMethodProcessor가 먼저 가져갑니다.
    • 해결하려면 RequestMappingHandlerAdapter.setReturnValueHandlers로 순서를 바꾸거나 WebMvcRegistrations를 쓰세요.
    • 비동기 반환 타입은 AsyncHandlerMethodReturnValueHandler로 구분합니다.

    Body Advice

    • ResponseBodyAdvice: 응답을 공통 봉투로 감쌀 때 씁니다.
      • String 반환은 감싸면 ClassCastException이 나므로 제외하세요.
      • ProblemDetail도 제외해야 문서화된 스펙과 어긋나지 않습니다.
      • 7.0에서 determineWriteHints가 추가됐습니다.
    • RequestBodyAdvice: 7.0에서 determineReadHints가 추가됐습니다.
    • @JsonView는 Jackson 3에서 hints 기반으로 동작합니다.

    예외 처리

    • @RestControllerAdvice + ResponseEntityExceptionHandler에서 handleMethodArgumentNotValid와 handleHandlerMethodValidationException을 override합니다.
    • ProblemDetail(RFC 9457)은 setProperty로 필드를 추가하고, spring.mvc.problemdetails.enabled=true로 켭니다.
    • ErrorResponse와 ErrorResponseException은 메시지 키로 problemDetail.title.*를 씁니다.
    • 그 밖에 HandlerExceptionResolver와 addErrorResponseInterceptors(6.2 이상)가 있습니다.

    인터셉터·필터

    • HandlerInterceptor는 preHandle, postHandle, afterCompletion을 제공합니다.
    • AsyncHandlerInterceptor.afterConcurrentHandlingStarted는 비동기 처리가 시작될 때 호출됩니다. ThreadLocal은 여기서 정리하세요.
    • OncePerRequestFilter를 async 재디스패치에서도 실행하려면 shouldNotFilterAsyncDispatch()가 false를 반환하게 합니다.
    • ContentCachingRequestWrapper는 바디를 읽은 뒤에야 캐시가 채워집니다.
    • ContentCachingResponseWrapper는 마지막에 copyBodyToResponse()를 반드시 호출해야 합니다.

    4.3 WebMvcConfigurer·변환·비동기

    메서드용도
    configurePathMatch예: addPathPrefix("/api") { it.isAnnotationPresent(RestController::class.java) }
    configureContentNegotiation, configureAsyncSupport, configureDefaultServletHandling콘텐츠 협상, 비동기 타임아웃과 executor, 기본 서블릿
    addFormatters, addInterceptors변환기, 인터셉터(pathPatterns, order)
    addResourceHandlers, addCorsMappings, addViewControllers, configureViewResolvers정적 리소스, CORS, 뷰
    addArgumentResolvers, addReturnValueHandlers인자·반환값 처리
    configureMessageConverters(ServerBuilder)7.0 신규Boot 4
    configureHandlerExceptionResolvers, extendHandlerExceptionResolvers예외 리졸버
    configureApiVersioning7.0 신규. useRequestHeader, usePathSegment, useQueryParam, useMediaTypeParameter, setDefaultVersion으로 설정하고 @GetMapping(version = "1.1+")로 매핑. Boot 프로퍼티는 spring.mvc.apiversion.*Boot 4
    getValidator, getMessageCodesResolver전역 Validator 교체
    • WebMvcRegistrations: HandlerMapping, HandlerAdapter, ExceptionHandlerExceptionResolver를 교체합니다.
    • 변환:
      • 기본 변환기: Converter, ConverterFactory(예: String을 모든 Enum으로), GenericConverter/ConditionalGenericConverter
      • 포맷터: Formatter, FormatterRegistrar, AnnotationFormatterFactory
      • @InitBinder에서 setAllowedFields로 대량 할당(mass assignment)을 막습니다.
    • 비동기 반환 타입:
      • Callable, DeferredResult, StreamingResponseBody, ResponseBodyEmitter, SseEmitter를 쓸 수 있습니다.
      • Mono, Flux, Flow, suspend도 지원합니다. text/event-stream이나 NDJSON이면 스트리밍하고, JSON이면 전부 모은 뒤 한 번에 응답합니다.

    5장 Envers + soft delete

    soft delete 구현 방식에 따라 Envers 이력이 어떻게 남는지, 어떤 삭제 경로가 감사를 우회하는지, 운영에서 자주 부딪히는 유니크 제약과 복구 문제를 어떻게 푸는지 다룹니다.

    이 장의 핵심

    • @SQLDelete로 지우면 DEL, 플래그를 직접 바꾸면 MOD로 기록됩니다.
    • store_data_at_delete=true로 삭제 시점의 데이터를 남깁니다.
    • 벌크 쓰기와 네이티브 쓰기는 soft delete와 감사를 모두 우회합니다.

    5.1 방식별 REVTYPE

    REVTYPE은 Envers가 감사 테이블에 남기는 변경 종류로, 0은 ADD(추가), 1은 MOD(수정), 2는 DEL(삭제)을 뜻합니다.

    방식remove 시 실행되는 SQLREVTYPE확신도
    @SQLDelete + @SQLRestriction커스텀 UPDATEDEL (delete 이벤트가 발생함)높음
    직접 deleted = true로 수정일반 UPDATEMOD높음
    @SoftDelete (6.4 이상, 7.0부터 TIMESTAMP 지원)DeleteCoordinatorSoft가 UPDATE 실행DEL로 추정(미확인)검증 필요낮음~중간

    @SoftDelete가 DEL로 추정되는 이유와 한계

    • 삭제 경로에서 UPDATE를 실행하므로 DEL로 기록될 것으로 봅니다.
    • 하지만 이를 확정해 주는 공식 문서나 JIRA 이슈는 찾지 못했습니다.
    • 정황 증거로, spring-data-jpa #4155에 “@SoftDelete로 삭제할 때 AuditorAware가 호출되지 않는다”는 보고가 있습니다.
    • 도입하기 전에 REVTYPE을 확인하는 통합 테스트를 반드시 작성하세요.

    관련 설정과 제약

    • DEL 행의 데이터: 기본 설정에서는 DEL 행에 id만 남습니다. store_data_at_delete=true를 권장합니다.
    • @SoftDelete의 제약:
      • strategy는 DELETED, ACTIVE, TIMESTAMP 중 하나입니다.
      • converter를 TIMESTAMP와 함께 쓰면 UnsupportedMappingException이 납니다. converter는 null을 반환하면 안 됩니다.
      • 삭제 컬럼은 엔티티 필드로 노출되지 않습니다.
    • 7.4의 incubating @Audited: 문서에는 “backward compatible with Hibernate Envers”라고 되어 있지만, 아직 incubating이므로 운영 적용은 보류하세요.

    삭제 경로 시뮬레이터

    soft delete 방식과 삭제 경로를 고르면 실행되는 SQL, soft delete 적용 여부, Envers 이력을 보여 줍니다.

    soft delete 방식
    삭제 경로

    5.1과 3.3의 표를 조합한 것입니다. 확신도가 낮은 조합은 도입 전에 통합 테스트로 확인하세요.

    5.2 Envers 설정 키

    모두 spring.jpa.properties.org.hibernate.envers.* 아래에 둡니다.

    키기본값설명
    audit_table_suffix, audit_table_prefix_AUD, ""감사 테이블 이름
    revision_field_name, revision_type_field_nameREV, REVTYPE컬럼 이름
    store_data_at_deletefalseDEL 행에도 데이터를 보존. true 권장
    audit_strategyDefault…strategy.internal.ValidityAuditStrategy로 바꾸면 REVEND가 생겨 시점 조회가 빨라짐
    audit_strategy_validity_store_revend_timestamp (+_end_rev_field_name, _revend_timestamp_field_name, _numeric, _legacy_placement)falseREVEND_TSTMP 저장(파티셔닝용)
    allow_identifier_reusefalseundelete나 id 재사용이 있으면 true
    global_with_modified_flag, modified_flag_suffixfalse, _MOD컬럼별 변경 플래그
    track_entities_changed_in_revisionfalseREVCHANGES 테이블 사용
    revision_on_collection_changetrue소유하지 않은 쪽 컬렉션이 바뀌어도 리비전 생성
    do_not_audit_optimistic_locking_fieldtrue@Version은 감사하지 않음
    default_schema, default_catalog, find_by_revision_exact_match–스키마 분리, find 시 리비전 정확 일치

    5.3 @Where 제거·필터·연관 엔티티

    • @Where 제거: Hibernate 7에서 @Where와 @WhereJoinTable이 제거됐습니다. @SQLRestriction과 @SQLJoinTableRestriction으로 바꾸세요.Boot 4
    • 관리자 화면처럼 필터를 꺼야 할 때: @SQLRestriction은 끌 수 없으므로 @FilterDef + @Filter를 씁니다.
    @Entity @Audited
    @FilterDef(name = "notDeleted", defaultCondition = "deleted = false", autoEnabled = true, applyToLoadByKey = true)
    @Filter(name = "notDeleted")
    @SQLDelete(sql = "update member set deleted = true where id = ? and version = ?")
    class Member { @Id @GeneratedValue var id: Long = 0; @Version var version: Long = 0; var deleted = false }
    // 관리자: em.unwrap(Session::class.java).disableFilter("notDeleted")
    • 필터는 기본적으로 find()에는 적용되지 않습니다. autoEnabled, applyToLoadByKey가 어느 버전에서 도입됐는지는 javadoc에서 확인하세요.검증 필요
    • 삭제된 연관 엔티티: 로드할 때 EntityNotFoundException이 납니다. 대응 방법은 세 가지입니다.
      • 연관 엔티티까지 연쇄로 soft delete합니다.
      • @NotFound(IGNORE)를 씁니다. 이 경우 EAGER 로딩이 강제됩니다.
      • 연관 대상 엔티티에서 @SQLRestriction을 뺍니다.
    • targetAuditMode = NOT_AUDITED: 이 설정의 연관은 현재 테이블에서 읽기 때문에 필터에 걸립니다. 양쪽 엔티티를 모두 @Audited로 맞추세요.

    5.4 우회 경로·유니크·버전·복구

    Envers와 @SQLDelete를 모두 우회하는 경로

    • 해당 경로: @Modifying JPQL, deleteAllInBatch, Criteria 벌크, 네이티브 쿼리, StatelessSession.
    • 대량 삭제가 필요하면 findAllById → deleteAll → flush/clear를 청크 단위로 반복하세요.
    • 트랜잭션 밖에서 일어난 쓰기는 감사되지 않습니다.

    유니크 제약

    • PostgreSQL은 부분 인덱스를 씁니다: create unique index … on member(email) where deleted = false
    • MySQL은 unique(email, deleted_token)을 두고 활성 행에는 0, 삭제된 행에는 id를 넣습니다. 생성 컬럼(generated column)으로 풀 수도 있습니다.
    • (email, deleted_at) 조합은 NULL이 여러 개 허용되므로 중복을 막지 못할 수 있습니다.
    • _AUD 테이블에는 unique 제약을 복제하지 마세요.

    @SQLDelete + @Version

    • 파라미터 바인딩 순서는 id, version입니다(where id = ? and version = ?).

    cascade

    • CascadeType.REMOVE나 orphanRemoval을 쓰면 자식마다 UPDATE와 DEL이 기록됩니다.
    • DB의 ON DELETE CASCADE는 soft delete에서 동작하지 않습니다.

    복구(undelete)

    • 네이티브 update로 되돌리면 감사 기록이 남지 않습니다.
    • @Filter를 끄고 엔티티를 로드한 뒤 수정하면 MOD로 기록됩니다.
    • 이 경우 allow_identifier_reuse도 검토하세요.

    5.5 RevisionEntity·조회 API

    @Entity @Table(name = "revinfo") @RevisionEntity(UserRevisionListener::class)
    class Revision(
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @RevisionNumber var id: Long = 0,
        @RevisionTimestamp var timestamp: Long = 0,
        var actorId: String? = null,
    )
    class UserRevisionListener : RevisionListener {
        override fun newRevision(rev: Any) {
            (rev as Revision).actorId = SecurityContextHolder.getContext().authentication?.name ?: "system"
        }
    }

    RevisionEntity 설계

    • REVINFO id 타입: 기본은 int입니다. 새로 시작한다면 Long으로 두세요. 나중에 바꾸려면 모든 _AUD.REV FK를 함께 마이그레이션해야 합니다.
    • 리스너에 빈 주입: SpringBeanContainer로 가능할 수 있지만 버전별로 테스트가 필요합니다. SecurityContextHolder에 정적으로 접근하는 방식이 가장 안전하며, 비동기 상황에 대비해 fallback 값을 두세요.검증 필요
    • EntityTrackingRevisionListener.entityChanged: 엔티티별 변경 내역을 기록할 때 씁니다.

    조회 API

    • AuditReader: AuditReaderFactory.get(em)으로 얻습니다. find(cls, id, rev), getRevisions, findRevision, getRevisionDate를 제공합니다.
    • AuditQuery:
      • createQuery().forRevisionsOfEntity(cls, false, true)는 [entity, rev, RevisionType]을 반환하며 삭제 이력도 포함합니다.
      • 그 밖에 forEntitiesAtRevision, forRevisionsOfEntityWithChanges가 있습니다.
      • 조건은 AuditEntity.id().eq, AuditEntity.revisionType().eq(RevisionType.DEL), property("email").hasChanged(), revisionNumber().maximize()처럼 겁니다.
    • Spring Data Envers:
      • RevisionRepository<T, ID, N>의 findRevisions(id[, pageable]), findRevision, findLastChangeRevision을 씁니다.
      • metadata.revisionType은 INSERT/UPDATE/DELETE입니다.
      • @EnableJpaRepositories(repositoryFactoryBeanClass = EnversRevisionRepositoryFactoryBean::class)로 켭니다.

    운영·역할 분담

    • 스키마 관리: 운영에서는 ddl-auto=validate와 Flyway를 함께 씁니다(Boot 4에서는 spring-boot-starter-flyway 필요). _AUD 컬럼에는 NOT NULL을 걸지 마세요.Boot 4
    • Kotlin 플러그인: plugin.jpa와 plugin.spring을 적용합니다. allOpen { annotation("jakarta.persistence.Entity") }가 없으면 프록시가 동작하지 않습니다. data class 엔티티는 피하세요.
    • JPA Auditing과의 역할 분담:
      • JPA Auditing(@CreatedBy, @LastModifiedDate, AuditorAware)은 현재 행을 채웁니다.
      • Envers는 변경 이력을 남깁니다.
      • 삭제한 사람은 REVINFO의 actorId로 추적합니다.

    6장 epages restdocs-api-spec

    테스트로 OpenAPI 스펙을 만드는 restdocs-api-spec의 스니펫 옵션과 Gradle 플러그인 설정을 정리합니다. Boot 4와의 호환성부터 확인하고 시작하세요.

    이 장의 핵심

    • Boot 4에서는 0.20.x를 쓰되, 남아 있는 호환성 문제를 염두에 둡니다.
    • pathParameters는 RestDocumentationRequestBuilders로 요청해야 문서화됩니다.
    • 제약을 스펙에 반영하려면 ConstrainedFields와 @field: 적용이 필요합니다.

    6.1 호환성

    • Boot 4.x: 0.20.x 이상을 써야 하며 최신 버전은 0.20.1입니다. 릴리스 노트에 이런 경고가 있습니다: “0.20.x line may still contain minor compatibility issues, especially when used with Spring Boot 4”.
    • REST Docs 4.0: REST Assured 통합이 제거됐습니다. restdocs-api-spec-restassured를 쓰던 테스트는 MockMvc나 WebTestClient로 옮기세요.Boot 4
    • 테스트 스타터: Boot 4.0.4부터 spring-boot-starter-restdocs를 씁니다.Boot 4
    • 패키지 이동: @AutoConfigureRestDocs와 @WebMvcTest가 org.springframework.boot.<tech>.test.autoconfigure로 옮겨졌습니다. OpenRewrite로 일괄 변경할 수 있습니다.Boot 4
    • Gradle 플러그인: Plugin Portal에는 0.19.x까지만 보입니다. 0.20.x는 Maven Central에서 확인하세요.검증 필요

    6.2 스니펫 API

    ResourceSnippetParameters.builder() 메서드설명
    summary, description, tag/tags메타데이터
    privateResourceseparatePublicApi 사용 시 공개 스펙에서 제외
    deprecateddeprecated 표시
    requestSchema(Schema.schema("Name")), responseSchema스키마 이름. 같은 이름이면 $ref로 재사용
    requestFields, responseFields, requestHeaders, responseHeaders바디 필드와 헤더
    pathParameters요청을 RestDocumentationRequestBuilders.get("/x/{id}", id)로 보내야 함
    queryParameters, formParametersREST Docs 3.0에서 둘로 분리됨
    links, build()HAL 링크, 빌드

    리소스·파라미터

    • ResourceDocumentation.resource(String) 또는 resource(params)로 resource.json을 만듭니다.
    • parameterWithName("page").type(SimpleType.INTEGER).optional().defaultValue(0)처럼 타입을 지정합니다. 타입은 STRING/INTEGER/NUMBER/BOOLEAN 중 하나이며, 지정하지 않으면 string으로 추정됩니다.

    MockMvcRestDocumentationWrapper.document 오버로드

    • (id, vararg snippets)
    • (id, requestPreprocessor, responsePreprocessor, vararg snippets)
    • (id, description, privateResource, …)
    • 정확한 시그니처는 소스에서 확인하세요.검증 필요

    전처리

    • preprocessRequest(prettyPrint(), modifyHeaders().remove("Authorization"), modifyUris().scheme("https").host("api.example.com").removePort())
    • preprocessResponse(prettyPrint())

    필드 기술

    • 기본은 fieldWithPath와 subsectionWithPath입니다. 공통 봉투를 쓰는 응답은 beneathPath("data").withSubsectionId("data")로 안쪽만 기술합니다.
    • relaxed*는 최소한으로만 쓰세요.
    • 타입(JsonFieldType): STRING/NUMBER/BOOLEAN/OBJECT/ARRAY/NULL/VARIES
    • .optional()은 0.19.1부터 nullable로 렌더링됩니다.
    • .ignored()로 필드를 제외하고, .attributes(key("format").value("date-time"))로 추가 속성을 붙입니다.
    • enum 값은 description에 Status.entries.joinToString()을 넣는 방식이 가장 이식성이 좋습니다. 스키마 쪽 enum 처리는 확인이 필요합니다.검증 필요

    Bean Validation 반영

    • ConstrainedFields(Req::class.java).withPath("name")으로 만든 필드만 제약이 스펙에 반영됩니다.
    • 매핑 규칙:
      • @NotNull, @NotEmpty, @NotBlank → required
      • @NotEmpty, @NotBlank → minLength=1
      • @Length → minLength, maxLength
      • @Pattern → pattern
      • @Min, @Max → minimum, maximum
      • @Size → 최소·최대 길이
    • 제약을 필드에서 읽기 때문에 Kotlin에서는 @field:를 붙이거나 param-property 옵션을 켜야 합니다.

    6.3 Gradle 플러그인

    블록옵션
    공통separatePublicApi(false), outputDirectory(build/api-spec), snippetsDirectory(build/generated-snippets)
    openapi {}host, basePath, schemes, title, description, version, format, tagDescriptionsPropertiesFile, oauth2SecuritySchemeDefinition { flows, tokenUrl, authorizationUrl, scopeDescriptionsPropertiesFile }, outputFileNamePrefix
    openapi3 {}server/servers(변수 지원), title, description, version, format, contact, tagDescriptionsPropertiesFile, oauth2SecuritySchemeDefinition, outputFileNamePrefix(openapi3)
    postman {}title, version, baseUrl, separatePublicApi, outputFileNamePrefix
    plugins { id("com.epages.restdocs-api-spec") version "0.20.1" }
    dependencies {
        testImplementation("org.springframework.boot:spring-boot-starter-restdocs")
        testImplementation("com.epages:restdocs-api-spec-mockmvc:0.20.1")
    }
    configure<com.epages.restdocs.apispec.gradle.OpenApi3Extension> {
        setServer("https://api.example.com"); title = "Member API"; version = "1.0.0"; format = "yaml"
    }
    tasks.register<Copy>("copyOpenApi") {
        dependsOn("openapi3")
        from(layout.buildDirectory.file("api-spec/openapi3.yaml")); into(layout.buildDirectory.dir("resources/main/static/docs"))
    }
    • 인증 헤더: JWT와 Basic 헤더를 자동으로 감지합니다.
    • Swagger UI 연결: 생성된 static/docs/openapi3.yaml을 정적 swagger-ui나 redoc에 연결하세요. springdoc 3.0.x는 Jackson 2를 끌어오는 이슈(#3200, #3216)가 있어 권장하지 않습니다.

    6.4 Kotlin 사용 패턴

    • 요청은 mockMvc.perform(RestDocumentationRequestBuilders.get(...)) 방식을 권장합니다. Kotlin DSL을 쓴다면 andDo { handle(document(…)) }로 연결합니다.
    • 반복되는 설정은 확장 함수로 묶어 두면 편합니다.
    fun ResultActions.docs(id: String, tag: String, summary: String, block: ResourceSnippetParametersBuilder.() -> Unit) =
        andDo(MockMvcRestDocumentationWrapper.document(id, preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()),
            resource(ResourceSnippetParameters.builder().tag(tag).summary(summary).apply(block).build())))
    • 공통 설정은 @AutoConfigureRestDocs(uriScheme, uriHost)나 RestDocsMockMvcConfigurationCustomizer로 합니다.
    • 커스텀 스니펫은 TemplatedSnippet으로 만들 수 있지만 OpenAPI 결과에는 반영되지 않습니다.
    • suspend 엔드포인트는 asyncDispatch 결과에 document()를 겁니다(8.1 참조).

    7장 OpenTelemetry / Micrometer Tracing

    Boot 4의 새 OpenTelemetry 스타터 구성, span과 Observation을 직접 만드는 API, baggage와 커스텀 exporter, 그리고 로그·메트릭·k8s까지 관측성 전체를 이어 봅니다.

    이 장의 핵심

    • spring-boot-starter-opentelemetry 하나로 트레이스, 메트릭, 로그 내보내기를 구성합니다.
    • OTLP 프로퍼티가 management.opentelemetry.*로 옮겨졌습니다.
    • 값 종류가 많은 키는 반드시 high cardinality로 둡니다.

    7.1 Boot 4 구성

    spring-boot-starter-opentelemetry (Boot 4 신규)Boot 4

    • SDK, Micrometer 브리지, OTLP 메트릭을 한 번에 가져오며 actuator 없이도 동작합니다.
    • 테스트용 -test 스타터가 따로 있습니다.
    • 로그 export는 4.0.1부터 actuator 밖으로 옮겨졌습니다(#48488).

    프로퍼티 (Boot 4에서 변경)

    용도프로퍼티
    트레이스management.opentelemetry.tracing.export.otlp.endpoint (3.x의 management.otlp.tracing.endpoint 대체)Boot 4
    로그management.opentelemetry.logging.export.otlp.endpoint, transport=http|grpc
    메트릭management.otlp.metrics.export.url
    샘플링management.tracing.sampling.probability (기본 0.1)
    • 테스트 어노테이션: @AutoConfigureObservability가 제거되고 @AutoConfigureMetrics와 @AutoConfigureTracing으로 나뉘었습니다.Boot 4
    • Boot 4.1 추가 사항:
      • OTLP 로그 exporter 빌더 커스터마이저
      • management.tracing.exemplars.filter
      • @Async 컨텍스트 자동 전파
      • Kafka/Rabbit ObservationConvention 빈 자동 적용

    7.2 API

    fun pay(cmd: PayCmd) {
        val span = tracer.nextSpan().name("pg.approve").tag("pg", cmd.pg).start()
        try { tracer.withSpan(span).use { span.event("sent"); pg.approve(cmd) } }
        catch (e: Exception) { span.error(e); throw e } finally { span.end() }
    }
    fun payObserved(cmd: PayCmd) = Observation.createNotStarted("payment.approve", registry)
        .lowCardinalityKeyValue("pg", cmd.pg).highCardinalityKeyValue("order.id", cmd.orderId)
        .observe { pg.approve(cmd) }

    핵심 타입

    • Tracer: nextSpan, startScopedSpan, withSpan(반드시 use로 닫기), currentSpan
    • Span: tag, event, error, end
    • CurrentTraceContext.wrap(Runnable): 다른 스레드로 컨텍스트를 넘깁니다.
    • Propagator.inject/extract: 커스텀 전송 계층에 컨텍스트를 싣고 꺼냅니다.

    어노테이션

    • 조건: AspectJ 의존성이 있고 management.observations.annotations.enabled=true여야 동작합니다.
      • @NewSpan, @ContinueSpan, @SpanTag에는 SpanAspect가 필요합니다.
      • @Observed에는 ObservedAspect가 필요합니다.
      • @Timed, @Counted, @MeterTag에는 각각의 Aspect가 필요합니다.
    • Boot 4에서 AspectJ 스타터 이름이 spring-boot-starter-aspectj로 바뀌었습니다.Boot 4
    • 같은 클래스 안에서의 자기 호출(self-invocation)과 private 메서드에는 적용되지 않습니다.

    확장 포인트

    • ObservationHandler
    • ObservationFilter: 공통 태그를 붙일 때 씁니다.
    • ObservationPredicate: 예를 들어 /actuator 요청을 제외할 때 씁니다.
    • GlobalObservationConvention: DefaultServerRequestObservationConvention을 상속해 http.server.requests 태그를 바꿀 때 씁니다.
    • ObservationRegistryCustomizer

    카디널리티

    사용자 id처럼 값 종류가 많은 키는 반드시 high cardinality로 두세요. low로 두면 메트릭 시계열이 폭증합니다.

    Baggage

    • 원격 전파할 필드는 management.tracing.baggage.remote-fields로 지정합니다.
    • MDC에 넣을 필드는 …baggage.correlation.fields로 지정합니다.
    • 전파 형식은 management.tracing.propagation.type=W3C|B3로 고릅니다.
    • 코드에서는 tracer.createBaggageInScope(k, v).use { }로 씁니다.

    확장 빈

    • SpanExporter 빈은 BatchSpanProcessor에 자동으로 연결됩니다.
    • SpanProcessor는 PII 마스킹 같은 용도로 씁니다.
    • Sampler 예시: Sampler.parentBased(Sampler.traceIdRatioBased(0.2))
    • SdkTracerProviderBuilderCustomizer

    Resource: management.opentelemetry.resource-attributes.*로 지정합니다. OTEL_* 환경변수를 어디까지 지원하는지는 레퍼런스에서 확인하세요.검증 필요

    7.3 계측 방식 비교

    방식장점단점
    Boot 스타터 (권장)공식 지원, Observation과 일관됨, 설정이 management.* 하나로 통일라이브러리 자동계측 범위가 좁음
    OTel Spring Boot starterotel.* 설정, 네이티브 이미지 지원일부 alpha 의존성
    OTel Java agent코드 수정 없음, 자동계측 범위가 가장 넓음, @WithSpan 지원버전 호환 문제, 브리지와 함께 쓰면 span 중복

    @WithSpan은 agent나 OTel starter에서만 동작합니다. 두 방식을 함께 쓰지 마세요.

    7.4 전파·로그·메트릭·k8s

    컨텍스트 전파

    • Executor: ContextPropagatingTaskDecorator를 ThreadPoolTaskExecutor.setTaskDecorator에 등록하거나, ContextSnapshotFactory…captureAll().wrap(r)로 작업을 감쌉니다.
    • Reactor: spring.reactor.context-propagation=auto
    • 코루틴: withContext(Dispatchers.IO + PropagationContextElement())를 씁니다. Flow를 반환하는 엔드포인트에서는 전파가 안 되는 이슈(#36427)가 있으니, 그 경우 observationRegistry.asContextElement()를 쓰세요.
    • 메시징: 다음 프로퍼티는 기본값이 false라서, 켜지 않으면 트레이스가 끊깁니다.
      • spring.kafka.template.observation-enabled, spring.kafka.listener.observation-enabled
      • spring.rabbitmq.template.observation-enabled, spring.rabbitmq.listener.simple.observation-enabled
    • RestClient: Boot가 주입한 RestClient.Builder로 만들어야 전파됩니다. RestClient.create()로 만들면 전파되지 않습니다.

    Logback

    • logging.pattern.correlation으로 MDC의 traceId와 spanId를 출력합니다.
    • 구조화 로그는 logging.structured.format.console=ecs|logstash로 켭니다.
    • OTLP로 로그를 보내려면 opentelemetry-logback-appender-1.0(alpha)을 추가하고 OpenTelemetryAppender를 선언한 다음 설치합니다.
    @Component
    class OtelAppenderInstaller(private val otel: OpenTelemetry) : InitializingBean {
        override fun afterPropertiesSet() = OpenTelemetryAppender.install(otel)
    }

    Prometheus

    • 미터 종류: Counter, Timer.builder(…).publishPercentileHistogram(), DistributionSummary, Gauge
      • Gauge는 대상을 약참조로 잡으므로 대상 객체를 강참조로 따로 유지해야 합니다.
    • 등록과 필터: MeterBinder 빈으로 등록하고, MeterFilter.denyNameStartsWith, maximumAllowableTags, commonTags로 거릅니다.
    • 공통 태그: management.metrics.tags.*
    • 히스토그램·SLO: management.metrics.distribution.percentiles-histogram.http.server.requests=true, …slo.http.server.requests=100ms,300ms,1s
    • exemplar: trace id가 자동으로 붙습니다. OpenMetrics 포맷으로 스크레이프해야 보입니다.

    k8s

    • 헬스 프로브: 기본으로 켜집니다. liveness에는 DB를 넣지 말고, readiness에만 include=readinessState,db로 넣으세요.
    • 종료: server.shutdown=graceful과 spring.lifecycle.timeout-per-shutdown-phase=30s를 설정합니다. terminationGracePeriodSeconds는 이보다 길게 잡고, preStop에 sleep을 넣습니다.
    • 리소스 속성: Downward API로 받은 POD_NAME을 resource-attributes.k8s.pod.name에 넣거나, Collector의 k8sattributes를 씁니다.
    • Collector 배치: DaemonSet agent와 gateway Deployment(tail sampling 담당)의 두 단으로 구성하세요.

    8장 Kotlin 코루틴 + Spring

    코루틴을 Spring MVC, 트랜잭션, 스케줄러, 메시징과 함께 쓸 때 필요한 의존성과 함정, 자주 쓰는 타입과 연산자를 정리합니다. 가상 스레드와 어떻게 나눠 쓸지도 다룹니다.

    이 장의 핵심

    • MVC의 suspend와 Flow에는 kotlinx-coroutines-reactor가 필요하고, 테스트는 asyncDispatch를 거칩니다.
    • JPA 트랜잭션은 withContext + TransactionTemplate 안에서 끝내고, 그 안에서 suspend하지 않습니다.
    • 기본은 가상 스레드와 동기 코드로 가고, 코루틴은 fan-out과 스트리밍에만 씁니다.

    8.1 MVC

    • 필수 의존성: kotlinx-coroutines-reactor가 필요합니다.
    • 동작 방식: suspend 함수는 Mono로 바뀌어 비동기 요청으로 처리됩니다.
    • Flow 반환: SSE나 NDJSON이면 스트리밍되고, JSON이면 전부 모은 뒤 응답합니다.
    • 테스트: asyncDispatch가 필요합니다. 첫 요청에 문서화를 걸면 바디가 빈 스니펫이 생성되니, 문서화는 asyncDispatch 쪽에 거세요.
    val r = mockMvc.perform(RestDocumentationRequestBuilders.get("/members/{id}", 1))
        .andExpect(request().asyncStarted()).andReturn()
    mockMvc.perform(asyncDispatch(r)).andExpect(status().isOk)
        .andDo(MockMvcRestDocumentationWrapper.document("member-get", resource("회원 조회")))

    8.2 트랜잭션·JPA

    • suspend + @Transactional: 리액티브 트랜잭션으로 처리되므로 ReactiveTransactionManager가 필요합니다. JPA에서는 사실상 쓸 수 없습니다.
    • 원인: EntityManager가 ThreadLocal에 묶여 있습니다. 코루틴이 다른 스레드에서 재개되면 트랜잭션을 잃고, LazyInitializationException이 나거나 커밋이 누락됩니다.
    • 해결: 트랜잭션 블록 안에서는 suspend하지 않습니다.
    suspend fun rename(id: Long, name: String) = withContext(dbDispatcher + PropagationContextElement()) {
        tx.execute { repo.findByIdOrNull(id)!!.also { it.rename(name) }.toDto() }!!
    }
    • HikariCP 풀 고갈: IO 디스패처는 64개까지 병렬 실행하지만 커넥션 풀은 기본 10개입니다. val dbDispatcher = Dispatchers.IO.limitedParallelism(maxPoolSize)로 병렬도를 맞추거나 Semaphore로 제한하세요.

    8.3 스케줄러·이벤트·메시징

    스케줄러·이벤트

    • @Scheduled suspend fun: 리액터 브리지가 있으면 지원됩니다. 에러가 나면 WARN 로그를 남기고 다음 주기를 계속 실행합니다.
    • @Async·@EventListener의 suspend 지원: 확인하지 못했습니다. 앱의 CoroutineScope에서 launch하거나 CompletableFuture를 반환하세요.검증 필요
    • Spring 7 PropagationContextElement: Micrometer, MDC, Reactor Context를 복원합니다. suspend 컨트롤러에는 자동으로 적용됩니다.Boot 4

    Spring Kafka

    • 3.2부터 async 반환 타입(CompletableFuture, Mono, suspend)을 지원합니다.
    • Unit을 반환하면 자동 ack가 되지 않는다는 보고가 있습니다(#3739). Acknowledgment.acknowledge()를 직접 호출하세요.
    • 메시지 순서가 중요하면 async 리스너를 쓰지 마세요.
    • 열린 이슈가 두 개 있습니다: 리밸런스 후 fetch가 멈추는 문제(#4687), stop 후에도 작업이 남는 문제(#4519).검증 필요
    • 전송 결과는 kafkaTemplate.send(t, k, v).await()로 기다립니다.

    Spring AMQP

    • @RabbitListener도 CompletableFuture, Mono, suspend를 지원합니다.
    • ack 동작은 레퍼런스에서 확인하세요.검증 필요

    8.4 타입과 연산자

    분류항목
    스코프·잡CoroutineScope(빈으로 두고 @PreDestroy에서 cancel), SupervisorJob, CoroutineName, coroutineScope {}(하나가 실패하면 전체 취소), supervisorScope {}
    디스패처Default, IO(64), Unconfined(사용 금지), limitedParallelism(n), Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher()(close 필요)
    빌더·제어launch, async/await, awaitAll, withContext, withTimeout(OrNull), runBlocking(요청 스레드·코루틴·트랜잭션 안에서 사용 금지), runInterruptible, NonCancellable, CoroutineExceptionHandler(최상위 launch에서만 동작), ensureActive, yield
    동기화·채널Mutex.withLock(재진입 불가), Semaphore.withPermit, Channel(RENDEZVOUS/BUFFERED/UNLIMITED/CONFLATED), produce, select
    Flow 연산자map, filter, transform, onEach, flatMapMerge(concurrency), flatMapConcat, flatMapLatest, buffer, conflate, debounce, sample, zip, combine, catch(업스트림 예외만 잡음), retry/retryWhen, onStart/onCompletion, flowOn, take, distinctUntilChanged, chunked(버전 확인 필요), launchIn
    생성·공유flow {}, flowOf, asFlow, channelFlow, callbackFlow(awaitClose 필수), MutableSharedFlow, MutableStateFlow, shareIn/stateIn
    브리지mono {}, flux {}, awaitSingle(OrNull), awaitFirst, Flux.asFlow(), Flow.asFlux(), CompletableFuture.await(), Deferred.asCompletableFuture()
    컨텍스트MDCContext()(kotlinx-coroutines-slf4j), ThreadContextElement(테넌트·보안 컨텍스트), PropagationContextElement(), observationRegistry.asContextElement()
    테스트runTest, StandardTestDispatcher, UnconfinedTestDispatcher, advanceUntilIdle, advanceTimeBy, runCurrent, backgroundScope, Turbine
    class SecurityContextElement(private val ctx: SecurityContext) :
        ThreadContextElement<SecurityContext?>, AbstractCoroutineContextElement(Key) {
        companion object Key : CoroutineContext.Key<SecurityContextElement>
        override fun updateThreadContext(context: CoroutineContext): SecurityContext? =
            SecurityContextHolder.getContext().also { SecurityContextHolder.setContext(ctx) }
        override fun restoreThreadContext(context: CoroutineContext, oldState: SecurityContext?) {
            if (oldState == null) SecurityContextHolder.clearContext() else SecurityContextHolder.setContext(oldState)
        }
    }

    8.5 가상 스레드

    • spring.threads.virtual.enabled=true를 켜면 Tomcat, @Async, @Scheduled, 리스너가 가상 스레드에서 실행됩니다.
    • 블로킹 JPA 위주의 MVC라면 가상 스레드 + 동기 코드가 가장 부담 없는 선택입니다. 코루틴은 여러 호출을 동시에 보내는 fan-out과 Flow 스트리밍에만 쓰세요.
    • 커넥션 풀이 병목이 되는 문제는 가상 스레드에서도 똑같이 생깁니다.

    부록 A 권장 사항

    여덟 개 장의 결론을 도입 순서대로 모았습니다.

    1. 빌드: param-property 옵션, plugin.jpa/plugin.spring/allOpen, Boot 4 test 스타터부터 정리하세요.
    2. soft delete: 기본 조합은 @SQLDelete(바인딩 순서 id, version) + @SQLRestriction(관리자 화면이 필요하면 @Filter) + store_data_at_delete=true입니다. @SoftDelete는 REVTYPE 테스트를 통과한 뒤에만 쓰세요.
    3. 감사 대상 보호: 감사 대상 엔티티에는 벌크 쓰기와 네이티브 쓰기를 금지하고, _AUD 테이블은 Flyway로 관리하세요.
    4. API 문서: restdocs-api-spec 0.20.1과 MockMvc를 씁니다. suspend 엔드포인트는 asyncDispatch에서 문서화하고, 제약은 ConstrainedFields로 반영하세요.
    5. 관측성: spring-boot-starter-opentelemetry 하나로 통일하세요. agent와 함께 쓰지 말고, Kafka/Rabbit observation을 켜세요.
    6. 동시성 모델: 기본은 가상 스레드로 가고, 코루틴은 fan-out과 스트리밍에만 쓰세요. JPA는 withContext + TransactionTemplate 안에 격리합니다.

    부록 B 주의 사항

    이 교과서에서 확정하지 못한 부분입니다. 상단의 검증 필요 버튼을 누르면 본문에서 해당 위치를 바로 찾을 수 있습니다.

    • @SoftDelete + Envers: REVTYPE이 DEL이라는 것은 추정입니다. 공식 문서나 JIRA로 확인하지 못했습니다.
    • 최신 GA 번호: Kotlin, kotlinx.coroutines, Jackson, Micrometer, OTel SDK의 최신 GA 번호는 직접 확인하지 못했습니다. Boot 4.1.1 BOM을 기준으로 삼으세요.
    • javadoc이나 소스에서 다시 확인할 항목:
      • @KorRRN의 속성명
      • restdocs-api-spec의 오버로드 시그니처와 enum 스키마 처리
      • @FilterDef 신규 속성이 도입된 버전
      • OTEL_* 환경변수 지원 범위
      • @SoftDelete가 HQL delete를 어떻게 번역하는지
      • Spring 7에서 @EventListener와 @Async의 suspend 지원 여부
      • AMQP suspend 리스너의 ack 동작
    • Spring Kafka suspend 리스너: 관련 이슈(#4687, #4519)가 아직 열려 있는 것으로 보입니다. 메시지 순서가 중요하면 동기 리스너 + 가상 스레드를 택하세요.