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)은 운영에 쓸 수 있습니다. 다만 코드를 쓰기 전에 세 가지를 먼저 확정하세요.
- 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")
- (a)
- restdocs-api-spec 버전: 0.20.x를 씁니다. 메인테이너가 Boot 4에서 “minor compatibility issues”가 남아 있다고 경고했습니다.
@SoftDelete+ Envers 검증: 이 조합에서 REVTYPE이 어떻게 기록되는지는 공식 문서로 확인되지 않았습니다. 통합 테스트로 직접 확인해야 합니다.
한눈에 보기
버전 기준
| 라이브러리 | 버전 |
|---|---|
| Spring Boot | 4.1.1 (2026-08-20) |
| Spring Framework | 7.0.x (stable 7.0.9) |
| Spring Data JPA | 4.1.0 |
| Hibernate ORM | 7.4.10.Final |
| Hibernate Validator | 9.1.4.Final (Jakarta Validation 3.1.1) |
| restdocs-api-spec | 0.20.1 |
| Spring REST Docs | 4.0 |
Boot 4에서 바뀐 핵심 API
@JsonComponent→@JacksonComponentMappingJackson2HttpMessageConverter→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 Boot | 4.1.1 (4.1.0: 2026-06-10) | Hibernate 7.4 탑재. 4.0에서 deprecated된 항목은 4.1에서 제거됨 |
| Spring Framework | 7.0.x (7.1.0-M1) | Jackson 2 지원은 7.1에서 자동감지가 꺼지고 7.2에서 제거될 예정 |
| Spring Data JPA | 4.1.0 (4.0 = Spring Data 2025.1) | AOT repository와 PredicateSpecification 추가, ListenableFuture 제거 |
| Hibernate ORM/Envers | 7.4.10.Final (2026-09-20) | 7.4에 incubating 단계의 @Audited/@Temporal 추가 |
| Hibernate Validator | 9.1.4.Final | Java 17 이상 필요 |
| Jackson | 3.x (tools.jackson) | 어노테이션은 계속 com.fasterxml.jackson.annotation 패키지 |
| Kotlin / kotlinx.coroutines | 2.2 이상 필수 / 1.10.x 사용 확인 | 최신 패치 번호는 확인하지 못함. Boot BOM을 따르세요검증 필요 |
| restdocs-api-spec | 0.20.1 | Boot 4.x는 0.20.X 이상, Boot 3.x는 0.17.1–0.19.4 |
| Spring REST Docs | 4.0 (2025-11-19) | JUnit 6, Framework 7, Jackson 3 기반. REST Assured 통합 제거 |
| Micrometer/OTel | Boot 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, @AssertFalse | boolean/Boolean | 참/거짓 |
| 숫자 | @Min, @Max(value) | BigDecimal, BigInteger, 정수형 | 하한/상한. double/float는 지원하지 않음 |
| 숫자 | @DecimalMin, @DecimalMax(value, inclusive) | 숫자, CharSequence | 경계값을 문자열로 지정 |
| 숫자 | @Positive(OrZero), @Negative(OrZero) | 숫자 | 부호 |
| 숫자 | @Digits(integer, fraction) | 숫자, CharSequence | 정수부·소수부 자릿수 |
| 문자열 | @Pattern(regexp, flags) | CharSequence | Flag: UNIX_LINES, CASE_INSENSITIVE, COMMENTS, MULTILINE, DOTALL, UNICODE_CASE, CANON_EQ |
| 문자열 | @Email(regexp, flags) | CharSequence | 기본 규칙이 느슨하므로 regexp로 좁히세요 |
| 문자열 | @NotBlank | CharSequence | 앞뒤 공백을 제거한 길이가 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, @DurationMax | Duration 범위 |
@ISBN, @EAN, @CreditCardNumber, @LuhnCheck, @Mod10Check, @Mod11Check | 코드·체크섬 |
@Currency | MonetaryAmount의 통화 |
@BitcoinAddress | HV 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를 빼세요.
- Spring 6.1부터는
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 / Or | findByLastnameAndFirstname | a = ?1 and/or b = ?2 |
Is, Equals | findByFirstnameIs | = ?1 (값이 null이면 is null) |
Between | findByStartDateBetween | between ?1 and ?2 |
LessThan(Equal), GreaterThan(Equal) | findByAgeLessThan | <, <=, >, >= |
After / Before | findByStartDateAfter | > / < |
IsNull/Null, IsNotNull/NotNull | findByAgeIsNull | is (not) null |
Like / NotLike | findByFirstnameLike | (not) like ?1 |
StartingWith / EndingWith / Containing | findByFirstnameContaining | like에 바인딩하는 값의 뒤/앞/양쪽에 %를 붙임 |
OrderBy | findByAgeOrderByLastnameDesc | order by … desc |
Not | findByLastnameNot | <> ?1 |
In / NotIn | findByAgeIn(ages) | (not) in ?1 |
True / False | findByActiveTrue() | = true/false |
IgnoreCase | findByFirstnameIgnoreCase | UPPER(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 인덱스를 검토하세요.
3.2 반환 타입·파라미터·프로젝션
반환 타입
- 단건:
T?(없으면 null, 2건 이상이면IncorrectResultSizeDataAccessException),Optional<T>, VavrOption - 다건:
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 UPDATE | Envers |
|---|---|---|---|
파생 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 4KotlinSerializationJsonHttpMessageConverter:@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 4spring.jackson.read/write.*는spring.jackson.json.read/write.*로 옮겨졌습니다.Boot 4spring.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 | 예외 리졸버 |
configureApiVersioning | 7.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 시 실행되는 SQL | REVTYPE | 확신도 |
|---|---|---|---|
@SQLDelete + @SQLRestriction | 커스텀 UPDATE | DEL (delete 이벤트가 발생함) | 높음 |
직접 deleted = true로 수정 | 일반 UPDATE | MOD | 높음 |
@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을 반환하면 안 됩니다.- 삭제 컬럼은 엔티티 필드로 노출되지 않습니다.
- strategy는
- 7.4의 incubating
@Audited: 문서에는 “backward compatible with Hibernate Envers”라고 되어 있지만, 아직 incubating이므로 운영 적용은 보류하세요.
5.2 Envers 설정 키
모두 spring.jpa.properties.org.hibernate.envers.* 아래에 둡니다.
| 키 | 기본값 | 설명 |
|---|---|---|
audit_table_suffix, audit_table_prefix | _AUD, "" | 감사 테이블 이름 |
revision_field_name, revision_type_field_name | REV, REVTYPE | 컬럼 이름 |
store_data_at_delete | false | DEL 행에도 데이터를 보존. true 권장 |
audit_strategy | Default | …strategy.internal.ValidityAuditStrategy로 바꾸면 REVEND가 생겨 시점 조회가 빨라짐 |
audit_strategy_validity_store_revend_timestamp (+_end_rev_field_name, _revend_timestamp_field_name, _numeric, _legacy_placement) | false | REVEND_TSTMP 저장(파티셔닝용) |
allow_identifier_reuse | false | undelete나 id 재사용이 있으면 true |
global_with_modified_flag, modified_flag_suffix | false, _MOD | 컬럼별 변경 플래그 |
track_entities_changed_in_revision | false | REVCHANGES 테이블 사용 |
revision_on_collection_change | true | 소유하지 않은 쪽 컬렉션이 바뀌어도 리비전 생성 |
do_not_audit_optimistic_locking_field | true | @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를 모두 우회하는 경로
- 해당 경로:
@ModifyingJPQL,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.REVFK를 함께 마이그레이션해야 합니다. - 리스너에 빈 주입:
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로 추적합니다.
- JPA Auditing(
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 | 메타데이터 |
privateResource | separatePublicApi 사용 시 공개 스펙에서 제외 |
deprecated | deprecated 표시 |
requestSchema(Schema.schema("Name")), responseSchema | 스키마 이름. 같은 이름이면 $ref로 재사용 |
requestFields, responseFields, requestHeaders, responseHeaders | 바디 필드와 헤더 |
pathParameters | 요청을 RestDocumentationRequestBuilders.get("/x/{id}", id)로 보내야 함 |
queryParameters, formParameters | REST 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로 닫기),currentSpanSpan:tag,event,error,endCurrentTraceContext.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 메서드에는 적용되지 않습니다.
확장 포인트
ObservationHandlerObservationFilter: 공통 태그를 붙일 때 씁니다.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 starter | otel.* 설정, 네이티브 이미지 지원 | 일부 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-enabledspring.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,GaugeGauge는 대상을 약참조로 잡으므로 대상 객체를 강참조로 따로 유지해야 합니다.
- 등록과 필터:
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 권장 사항
여덟 개 장의 결론을 도입 순서대로 모았습니다.
- 빌드: param-property 옵션,
plugin.jpa/plugin.spring/allOpen, Boot 4 test 스타터부터 정리하세요. - soft delete: 기본 조합은
@SQLDelete(바인딩 순서 id, version) +@SQLRestriction(관리자 화면이 필요하면@Filter) +store_data_at_delete=true입니다.@SoftDelete는 REVTYPE 테스트를 통과한 뒤에만 쓰세요. - 감사 대상 보호: 감사 대상 엔티티에는 벌크 쓰기와 네이티브 쓰기를 금지하고,
_AUD테이블은 Flyway로 관리하세요. - API 문서: restdocs-api-spec 0.20.1과 MockMvc를 씁니다. suspend 엔드포인트는
asyncDispatch에서 문서화하고, 제약은ConstrainedFields로 반영하세요. - 관측성:
spring-boot-starter-opentelemetry하나로 통일하세요. agent와 함께 쓰지 말고, Kafka/Rabbit observation을 켜세요. - 동시성 모델: 기본은 가상 스레드로 가고, 코루틴은 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)가 아직 열려 있는 것으로 보입니다. 메시지 순서가 중요하면 동기 리스너 + 가상 스레드를 택하세요.