๐ Spring Boot 4.0 ์ ๊ท ๊ธฐ๋ฅ ์๋ฒฝ ๊ฐ์ด๋
๋ฆด๋ฆฌ์ค ์ผ์: 2025๋ 11์ 20์ผ
Spring Framework: 7.0 ๊ธฐ๋ฐ
์ต์ JDK: 17 (LTS), ๊ถ์ฅ: 25
๐ ๋ชฉ์ฐจ
- ๊ธฐ๋ณธ ์๊ตฌ์ฌํญ
- ์ฝ๋๋ฒ ์ด์ค ์์ ๋ชจ๋ํ
- HTTP Service Clients
- API ๋ฒ์ ๊ด๋ฆฌ
- JSpecify Null Safety
- ๋ด์ฅ Resilience ๊ธฐ๋ฅ
- OpenTelemetry ์คํํฐ
- Kotlin Serialization ์ง์
- BeanRegistrar
- RestTestClient
- Redis Static Master/Replica
- ์ฃผ์ ์์กด์ฑ ์ ๊ทธ๋ ์ด๋
- ๊ธฐํ ๊ฐ์ ์ฌํญ
- ๋ง์ด๊ทธ๋ ์ด์ ๊ฐ์ด๋

1. ๊ธฐ๋ณธ ์๊ตฌ์ฌํญ
| ํญ๋ชฉ | ์๊ตฌ์ฌํญ |
|---|---|
| JDK | ์ต์ 17 (LTS), ๊ถ์ฅ 25 |
| Spring Framework | 7.0 |
| Jakarta EE | 11 |
| Servlet | 6.1 |
| JPA | 3.2 |
| Hibernate | 7.1 |
| GraalVM | 24 ์์ ์ง์ |
| Kotlin | 2.2 ๊ณต์ ๋ฒ ์ด์ค๋ผ์ธ |
2. ์ฝ๋๋ฒ ์ด์ค ์์ ๋ชจ๋ํ
Spring Boot 4.0์์๋ spring-boot-autoconfigure JAR์ด ๊ธฐ์ ๋ณ ๋ชจ๋๋ก ๋ถ๋ฆฌ๋์์ต๋๋ค.
์ฃผ์ ์ด์
- โ ๋ฉ๋ชจ๋ฆฌ ์ฌ์ฉ๋ ๊ฐ์
- โ ๋น๋ ์๊ฐ ๋จ์ถ
- โ IDE ์๋์์ฑ์์ ๋ถํ์ํ ํด๋์ค ์ ๊ฑฐ
- โ ํ์ํ ๋ชจ๋๋ง ์ ํ์ ์ผ๋ก ์ฌ์ฉ ๊ฐ๋ฅ
3. HTTP Service Clients
์ ์ธ์ HTTP ํด๋ผ์ด์ธํธ๋ฅผ ํตํด ์ธํฐํ์ด์ค๋ง์ผ๋ก REST API๋ฅผ ํธ์ถํ ์ ์์ต๋๋ค.
3.1 ์์กด์ฑ ์ถ๊ฐ
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
3.2 ์ธํฐํ์ด์ค ์ ์
import org.springframework.web.service.annotation.*;
import org.springframework.web.bind.annotation.*;
@HttpExchange("/posts")
public interface PostClient {
@GetExchange
List<Post> getPosts();
@GetExchange("/{id}")
Post getPostById(@PathVariable("id") Integer id);
@PostExchange
Post createPost(@RequestBody Post post);
@PutExchange("/{id}")
Post updatePost(@PathVariable("id") Integer id, @RequestBody Post post);
@DeleteExchange("/{id}")
void deletePost(@PathVariable("id") Integer id);
}
3.3 ๊ทธ๋ฃน๋ณ ์ค์ (application.yml)
spring:
http:
client:
service:
group:
github:
base-url: https://api.github.com
connect-timeout: 5s
read-timeout: 10s
jsonplaceholder:
base-url: https://jsonplaceholder.typicode.com
connect-timeout: 3s
read-timeout: 5s
3.4 @ImportHttpServices๋ฅผ ํตํ ๊ทธ๋ฃน ๊ด๋ฆฌ
import org.springframework.boot.http.client.service.ImportHttpServices;
@Configuration
@ImportHttpServices(
group = "jsonplaceholder",
types = { PostClient.class, UserClient.class }
)
public class HttpClientConfig {
}
3.5 ์๋น์ค์์ ์ฌ์ฉ
@Service
@RequiredArgsConstructor
public class PostService {
private final PostClient postClient;
public List<Post> getAllPosts() {
return postClient.getPosts();
}
public Post getPost(Integer id) {
return postClient.getPostById(id);
}
public Post createNewPost(String title, String body, Integer userId) {
Post post = new Post(null, userId, title, body);
return postClient.createPost(post);
}
}
3.6 WebClient ๊ธฐ๋ฐ ๋ฆฌ์กํฐ๋ธ ํด๋ผ์ด์ธํธ
@HttpExchange("/posts")
public interface ReactivePostClient {
@GetExchange
Flux<Post> getPosts();
@GetExchange("/{id}")
Mono<Post> getPostById(@PathVariable("id") Integer id);
@PostExchange
Mono<Post> createPost(@RequestBody Post post);
}
4. API ๋ฒ์ ๊ด๋ฆฌ
Spring Boot 4.0์ 4๊ฐ์ง API ๋ฒ์ ๊ด๋ฆฌ ์ ๋ต์ ๊ธฐ๋ณธ ์ ๊ณตํฉ๋๋ค.
4.1 ๋ฒ์ ๊ด๋ฆฌ ์ ๋ต
| ์ ๋ต | ์์ | ์ค๋ช |
|---|---|---|
| Path Segment | /api/v1/users | URL ๊ฒฝ๋ก์ ๋ฒ์ ํฌํจ |
| Header | X-API-Version: 1.0 | HTTP ํค๋๋ก ๋ฒ์ ์ ๋ฌ |
| Query Parameter | ?version=1.0 | ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ๋ก ๋ฒ์ ์ ๋ฌ |
| Media Type | Accept: application/vnd.api.v1+json | ๋ฏธ๋์ด ํ์ ์ ๋ฒ์ ํฌํจ |
4.2 Java Configuration
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer;
@Configuration
public class ApiVersionConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
// Path Segment ๋ฐฉ์: /api/v1/users
.usePathSegment(1)
// ์ง์ ๋ฒ์ ๋ชฉ๋ก
.addSupportedVersions("1.0", "1.1", "2.0")
// ๊ธฐ๋ณธ ๋ฒ์
.setDefaultVersion("1.0")
// Deprecated ๋ฒ์ ์ค์
.addDeprecatedVersions("1.0");
}
}
4.3 Header ๋ฐฉ์ ์ค์
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
.useHeader("X-API-Version")
.addSupportedVersions("1.0", "2.0")
.setDefaultVersion("1.0");
}
4.4 Properties ์ค์ (application.yml)
spring:
mvc:
apiversion:
default: v1.0
use:
header: api-version
# ๋๋
# path-segment: 1
# query-param: version
# media-type-param: version
4.5 ์ปจํธ๋กค๋ฌ์์ ๋ฒ์ ๋ณ ์๋ํฌ์ธํธ
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
// v1.0 - ๊ธฐ๋ณธ ์๋ต
@GetMapping(version = "1.0")
public List<UserDTOv1> getUsersV1() {
return userService.findAll().stream()
.map(user -> new UserDTOv1(user.getId(), user.getName()))
.toList();
}
// v2.0 - ํ์ฅ๋ ์๋ต
@GetMapping(version = "2.0")
public List<UserDTOv2> getUsersV2() {
return userService.findAll().stream()
.map(user -> new UserDTOv2(
user.getId(),
user.getName(),
user.getEmail(),
user.getCreatedAt()
))
.toList();
}
// ํน์ ๋ฒ์ ์ด์
@GetMapping(value = "/{id}", version = "1.1+")
public UserDTOv2 getUserById(@PathVariable Long id) {
User user = userService.findById(id);
return new UserDTOv2(user.getId(), user.getName(),
user.getEmail(), user.getCreatedAt());
}
}
4.6 DTO ํด๋์ค
// v1.0 DTO
public record UserDTOv1(
Long id,
String name
) {}
// v2.0 DTO - ํ์ฅ๋ ํ๋
public record UserDTOv2(
Long id,
String name,
String email,
LocalDateTime createdAt
) {}
4.7 ํด๋ผ์ด์ธํธ ์ธก API ๋ฒ์ ์ง์
@Service
public class ExternalApiService {
private final RestClient restClient;
public ExternalApiService(RestClient.Builder builder) {
this.restClient = builder
.baseUrl("https://api.example.com")
.apiVersionInserter(ApiVersionInserter.useHeader("API-Version"))
.build();
}
public Account getAccount(Long accountId) {
return restClient.get()
.uri("/accounts/{id}", accountId)
.apiVersion(1.1) // API ๋ฒ์ ์ง์
.retrieve()
.body(Account.class);
}
public Account getAccountV2(Long accountId) {
return restClient.get()
.uri("/accounts/{id}", accountId)
.apiVersion(2.0) // v2.0 API ํธ์ถ
.retrieve()
.body(Account.class);
}
}
4.8 ํ ์คํธ ์ฝ๋
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerVersionTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldReturnV1Response() throws Exception {
mockMvc.perform(get("/api/users")
.header("X-API-Version", "1.0"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].email").doesNotExist());
}
@Test
void shouldReturnV2Response() throws Exception {
mockMvc.perform(get("/api/users")
.header("X-API-Version", "2.0"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].email").exists())
.andExpect(jsonPath("$[0].createdAt").exists());
}
@Test
void shouldUseDefaultVersionWhenNotSpecified() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].email").doesNotExist()); // v1.0 ์๋ต
}
}
5. JSpecify Null Safety
JSpecify๋ฅผ ํตํ ํ์คํ๋ null ์์ ์ฑ ์ด๋ ธํ ์ด์ ์ ์ง์ํฉ๋๋ค.
5.1 ์์กด์ฑ ์ถ๊ฐ
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>1.0.0</version>
</dependency>
5.2 ์๋น์ค ํด๋์ค์์ ์ฌ์ฉ
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
@Service
public class UserService {
private final UserRepository userRepository;
// ๋ฐํ๊ฐ์ด null์ผ ์ ์์์ ๋ช
์
public @Nullable User findByEmail(@NonNull String email) {
return userRepository.findByEmail(email).orElse(null);
}
// ๋ฐํ๊ฐ์ด ์ ๋ null์ด ์๋์ ๋ณด์ฅ
public @NonNull User getById(@NonNull Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
// ์ปฌ๋ ์
๋ด๋ถ ์์๋ null์ด ์๋
public @NonNull List<@NonNull User> findActiveUsers() {
return userRepository.findByActiveTrue();
}
// ํ๋ผ๋ฏธํฐ๊ฐ null์ผ ์ ์์
public @NonNull List<@NonNull User> searchUsers(
@Nullable String name,
@Nullable String email) {
if (name == null && email == null) {
return userRepository.findAll();
}
return userRepository.searchByNameOrEmail(name, email);
}
}
5.3 Record์ ํจ๊ป ์ฌ์ฉ
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
public record CreateUserRequest(
@NonNull String name,
@NonNull String email,
@Nullable String phoneNumber,
@Nullable String address
) {
// Compact Constructor๋ก ๊ฒ์ฆ
public CreateUserRequest {
Objects.requireNonNull(name, "name must not be null");
Objects.requireNonNull(email, "email must not be null");
}
}
public record UserResponse(
@NonNull Long id,
@NonNull String name,
@NonNull String email,
@Nullable String phoneNumber,
@Nullable LocalDateTime lastLoginAt
) {}
5.4 ํจํค์ง ๋ ๋ฒจ ์ค์
// package-info.java
@NullMarked // ํจํค์ง ๋ด ๋ชจ๋ ํ์
์ด ๊ธฐ๋ณธ์ ์ผ๋ก NonNull
package com.example.myapp.domain;
import org.jspecify.annotations.NullMarked;
5.5 IntelliJ IDEA ์ค์
IntelliJ IDEA 2025.3+์์ ์ปดํ์ผ ํ์ null ์ฒดํฌ๋ฅผ ํ์ฑํํ๋ ค๋ฉด:
- Settings โ Editor โ Inspections
- Java โ Probable bugs โ Nullability problems
- Configure annotations ์์ JSpecify ์ด๋ ธํ ์ด์ ์ถ๊ฐ
6. ๋ด์ฅ Resilience ๊ธฐ๋ฅ
Spring Framework 7์ @Retryable, @ConcurrencyLimit ์ด๋
ธํ
์ด์
์ด ํตํฉ๋์์ต๋๋ค.
6.1 ์ค์ ํ์ฑํ
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableResilientMethods;
@Configuration
@EnableResilientMethods
public class ResilienceConfig {
}
6.2 ๊ธฐ๋ณธ @Retryable ์ฌ์ฉ
import org.springframework.retry.annotation.Retryable;
@Service
public class NotificationService {
// ๊ธฐ๋ณธ ์ค์ : 3๋ฒ ์ฌ์๋, 1์ด ๊ฐ๊ฒฉ
@Retryable
public void sendNotification(String message) {
// ์ธ๋ถ API ํธ์ถ
externalNotificationApi.send(message);
}
}
6.3 ์์ธ ์ค์ : ์ง์ ๋ฐฑ์คํ
@Service
public class NotificationService {
@Retryable(
includes = { MessageDeliveryException.class, TimeoutException.class },
excludes = { InvalidMessageException.class },
maxAttempts = 5,
delay = 100, // ์ด๊ธฐ ์ง์ฐ (ms)
jitter = 10, // ์งํฐ (ms)
multiplier = 2, // ์ง์ ๋ฐฐ์
maxDelay = 1000 // ์ต๋ ์ง์ฐ (ms)
)
public void sendNotificationWithBackoff(String notification) {
log.info("Attempting to send notification: {}", notification);
messagingClient.send(notification);
}
// ์ฌ์๋ ์คํจ ์ ํด๋ฐฑ ๋ฉ์๋
@Recover
public void recoverFromFailure(MessageDeliveryException e, String notification) {
log.error("Failed to send notification after retries: {}", notification);
// ํด๋ฐฑ ๋ก์ง: ํ์ ์ ์ฅ, ์๋ฆผ ๋ฑ
failedNotificationRepository.save(new FailedNotification(notification, e.getMessage()));
}
}
6.4 ๋ฆฌ์กํฐ๋ธ ์ง์
@Service
public class ReactiveNotificationService {
@Retryable(maxAttempts = 5, delay = 100)
public Mono<Void> sendReactiveNotification(String message) {
return webClient.post()
.uri("/notifications")
.bodyValue(new NotificationRequest(message))
.retrieve()
.bodyToMono(Void.class);
}
@Retryable(maxAttempts = 3, delay = 200, multiplier = 2)
public Flux<NotificationResult> sendBatchNotifications(List<String> messages) {
return Flux.fromIterable(messages)
.flatMap(msg -> webClient.post()
.uri("/notifications")
.bodyValue(new NotificationRequest(msg))
.retrieve()
.bodyToMono(NotificationResult.class));
}
}
6.5 @ConcurrencyLimit
@Service
public class HeavyProcessingService {
// ์ต๋ 10๊ฐ์ ๋์ ์คํ๋ง ํ์ฉ
@ConcurrencyLimit(10)
public Result processHeavyTask(Request request) {
log.info("Processing heavy task: {}", request.getId());
// CPU ์ง์ฝ์ ์์
return performHeavyComputation(request);
}
// ๋์์ฑ ์ ํ + ์ฌ์๋ ์กฐํฉ
@ConcurrencyLimit(5)
@Retryable(maxAttempts = 3)
public Report generateReport(ReportRequest request) {
return reportGenerator.generate(request);
}
}
6.6 RetryTemplate (ํ๋ก๊ทธ๋๋งคํฑ ๋ฐฉ์)
import org.springframework.retry.RetryPolicy;
import org.springframework.retry.RetryTemplate;
@Service
public class JmsNotificationService {
private final JmsClient jmsClient;
private final RetryTemplate retryTemplate;
public JmsNotificationService(JmsClient jmsClient) {
this.jmsClient = jmsClient;
// RetryPolicy ์์ฑ
RetryPolicy retryPolicy = RetryPolicy.builder()
.includes(MessageDeliveryException.class)
.excludes(InvalidDestinationException.class)
.maxAttempts(5)
.delay(Duration.ofMillis(100))
.jitter(Duration.ofMillis(10))
.multiplier(2)
.maxDelay(Duration.ofSeconds(1))
.build();
this.retryTemplate = new RetryTemplate(retryPolicy);
}
public void sendToQueue(String destination, Object message) {
retryTemplate.execute(() -> {
jmsClient.destination(destination).send(message);
return null;
});
}
public <T> T sendAndReceive(String destination, Object message, Class<T> responseType) {
return retryTemplate.execute(() ->
jmsClient.destination(destination)
.sendAndReceive(message, responseType)
);
}
}
7. OpenTelemetry ์คํํฐ
๋ถ์ฐ ์ถ์ ๊ณผ ๋ฉํธ๋ฆญ์ ์ํ OpenTelemetry ํตํฉ์ ์ ๊ณตํฉ๋๋ค.
7.1 ์์กด์ฑ ์ถ๊ฐ
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency>
7.2 ์ค์ (application.yml)
management:
otlp:
metrics:
export:
url: http://localhost:4318/v1/metrics
step: 10s
tracing:
endpoint: http://localhost:4318/v1/traces
tracing:
sampling:
probability: 1.0 # ๊ฐ๋ฐํ๊ฒฝ: 100%, ์ด์ํ๊ฒฝ: 0.1 ๊ถ์ฅ
metrics:
tags:
application: my-application
environment: ${SPRING_PROFILES_ACTIVE:local}
7.3 @Observed ์ด๋ ธํ ์ด์ ์ฌ์ฉ
import io.micrometer.observation.annotation.Observed;
@Service
public class PaymentService {
@Observed(
name = "payment.process",
contextualName = "process-payment",
lowCardinalityKeyValues = {"payment.type", "card"}
)
public PaymentResult processPayment(String orderId, BigDecimal amount) {
log.info("Processing payment for order: {}", orderId);
// ๊ฒฐ์ ๋ก์ง
PaymentResult result = paymentGateway.charge(orderId, amount);
log.info("Payment completed: {}", result.getTransactionId());
return result;
}
@Observed(name = "payment.refund")
public RefundResult refundPayment(String transactionId, BigDecimal amount) {
return paymentGateway.refund(transactionId, amount);
}
}
7.4 @MeterTag๋ก ๋์ ํ๊ทธ ์ถ๊ฐ
import io.micrometer.core.annotation.Timed;
import io.micrometer.core.annotation.Counted;
import io.micrometer.core.aop.MeterTag;
@Service
public class OrderService {
@Timed(value = "order.processing.time", description = "Order processing time")
@Counted(value = "order.processed.count", description = "Number of orders processed")
public Order processOrder(
@MeterTag(key = "order.type") String orderType,
@MeterTag(key = "customer.tier") String customerTier,
OrderRequest request) {
log.info("Processing {} order for {} customer", orderType, customerTier);
return orderProcessor.process(request);
}
@Timed("order.fulfillment.time")
public FulfillmentResult fulfillOrder(
@MeterTag(key = "fulfillment.method") String method,
@MeterTag(key = "warehouse.region") String region,
String orderId) {
return fulfillmentService.fulfill(orderId, method, region);
}
}
7.5 ์ปค์คํ Span ์์ฑ
import io.micrometer.tracing.Tracer;
import io.micrometer.tracing.Span;
@Service
@RequiredArgsConstructor
public class ComplexBusinessService {
private final Tracer tracer;
public void performComplexOperation(String operationId) {
Span parentSpan = tracer.nextSpan().name("complex-operation").start();
try (Tracer.SpanInScope ws = tracer.withSpan(parentSpan)) {
// Step 1
Span step1Span = tracer.nextSpan().name("step-1-validation").start();
try {
validateInput(operationId);
} finally {
step1Span.end();
}
// Step 2
Span step2Span = tracer.nextSpan().name("step-2-processing").start();
try {
processData(operationId);
} finally {
step2Span.end();
}
// Step 3
Span step3Span = tracer.nextSpan().name("step-3-notification").start();
try {
sendNotification(operationId);
} finally {
step3Span.end();
}
} finally {
parentSpan.end();
}
}
}
8. Kotlin Serialization ์ง์
kotlinx.serialization์ Spring Boot์ ํตํฉํ์ฌ ์ฌ์ฉํ ์ ์์ต๋๋ค.
8.1 ์์กด์ฑ ์ถ๊ฐ (build.gradle.kts)
plugins {
kotlin("plugin.serialization") version "2.2.20"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-kotlin-serialization")
}
8.2 ์ค์ (application.yml)
spring:
kotlin:
serialization:
json:
pretty-print: true
ignore-unknown-keys: true
encode-defaults: false
explicit-nulls: false
8.3 ๋ฐ์ดํฐ ํด๋์ค ์ ์
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName
@Serializable
data class User(
val id: Long,
val name: String,
val email: String,
@SerialName("phone_number")
val phoneNumber: String? = null,
val roles: List<String> = emptyList()
)
@Serializable
data class CreateUserRequest(
val name: String,
val email: String,
@SerialName("phone_number")
val phoneNumber: String? = null
)
@Serializable
data class ApiResponse<T>(
val success: Boolean,
val data: T? = null,
val error: String? = null,
val timestamp: Long = System.currentTimeMillis()
)
8.4 ์ปจํธ๋กค๋ฌ์์ ์ฌ์ฉ
import kotlinx.serialization.json.Json
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/users")
class UserController(
private val userService: UserService,
private val json: Json // ์๋ ์ฃผ์
) {
@GetMapping
fun getUsers(): List<User> = userService.findAll()
@GetMapping("/{id}")
fun getUser(@PathVariable id: Long): ApiResponse<User> {
val user = userService.findById(id)
return ApiResponse(success = true, data = user)
}
@PostMapping
fun createUser(@RequestBody request: CreateUserRequest): ApiResponse<User> {
val user = userService.create(request)
return ApiResponse(success = true, data = user)
}
// ์ปค์คํ
JSON ์ง๋ ฌํ
@GetMapping("/{id}/raw")
fun getUserRaw(@PathVariable id: Long): String {
val user = userService.findById(id)
return json.encodeToString(User.serializer(), user)
}
}
8.5 ์ปค์คํ Serializer
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
object LocalDateTimeSerializer : KSerializer<LocalDateTime> {
private val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDateTime) {
encoder.encodeString(value.format(formatter))
}
override fun deserialize(decoder: Decoder): LocalDateTime {
return LocalDateTime.parse(decoder.decodeString(), formatter)
}
}
@Serializable
data class Event(
val id: Long,
val name: String,
@Serializable(with = LocalDateTimeSerializer::class)
val startTime: LocalDateTime,
@Serializable(with = LocalDateTimeSerializer::class)
val endTime: LocalDateTime
)
9. BeanRegistrar
์กฐ๊ฑด๋ถ ๋๋ ๋์ ๋น ๋ฑ๋ก์ ์ํ ํ๋ก๊ทธ๋๋งคํฑ ๋ฐฉ์์ ์ ๊ณตํฉ๋๋ค.
9.1 ๊ธฐ๋ณธ ์ฌ์ฉ๋ฒ
import org.springframework.beans.factory.config.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.context.annotation.BeanRegistrar;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class DynamicBeanConfig implements BeanRegistrar {
@Override
public void registerBeans(BeanDefinitionRegistry registry) {
// ํผ์ฒ ํ๋๊ทธ์ ๋ฐ๋ฅธ ์กฐ๊ฑด๋ถ ๋น ๋ฑ๋ก
if (isFeatureEnabled("advanced-logging")) {
registry.registerBeanDefinition(
"advancedLogger",
new RootBeanDefinition(AdvancedLogger.class)
);
}
if (isFeatureEnabled("metrics-collector")) {
registry.registerBeanDefinition(
"metricsCollector",
new RootBeanDefinition(MetricsCollector.class)
);
}
}
private boolean isFeatureEnabled(String feature) {
return Boolean.parseBoolean(
System.getenv("FEATURE_" + feature.toUpperCase().replace("-", "_"))
);
}
}
9.2 ํ๊ฒฝ ๊ธฐ๋ฐ ๋น ๋ฑ๋ก
@Configuration(proxyBeanMethods = false)
public class EnvironmentBasedBeanConfig implements BeanRegistrar {
@Override
public void registerBeans(BeanDefinitionRegistry registry) {
String env = System.getProperty("spring.profiles.active", "local");
switch (env) {
case "production":
registerProductionBeans(registry);
break;
case "staging":
registerStagingBeans(registry);
break;
default:
registerDevelopmentBeans(registry);
}
}
private void registerProductionBeans(BeanDefinitionRegistry registry) {
RootBeanDefinition cacheDef = new RootBeanDefinition(RedisCacheService.class);
cacheDef.setScope("singleton");
registry.registerBeanDefinition("cacheService", cacheDef);
registry.registerBeanDefinition(
"emailService",
new RootBeanDefinition(ProductionEmailService.class)
);
}
private void registerDevelopmentBeans(BeanDefinitionRegistry registry) {
registry.registerBeanDefinition(
"cacheService",
new RootBeanDefinition(InMemoryCacheService.class)
);
registry.registerBeanDefinition(
"emailService",
new RootBeanDefinition(MockEmailService.class)
);
}
}
9.3 ํ๋ฌ๊ทธ์ธ ๊ธฐ๋ฐ ๋น ๋ฑ๋ก
@Configuration(proxyBeanMethods = false)
public class PluginBeanConfig implements BeanRegistrar {
@Override
public void registerBeans(BeanDefinitionRegistry registry) {
// ํ๋ฌ๊ทธ์ธ ๋๋ ํ ๋ฆฌ ์ค์บ
List<String> plugins = discoverPlugins();
for (String plugin : plugins) {
try {
Class<?> pluginClass = Class.forName(plugin);
String beanName = generateBeanName(pluginClass);
RootBeanDefinition definition = new RootBeanDefinition(pluginClass);
definition.setLazyInit(true);
registry.registerBeanDefinition(beanName, definition);
log.info("Registered plugin bean: {}", beanName);
} catch (ClassNotFoundException e) {
log.warn("Plugin class not found: {}", plugin);
}
}
}
private List<String> discoverPlugins() {
// ํ๋ฌ๊ทธ์ธ ์ค์ ํ์ผ ์ฝ๊ธฐ ๋๋ ๋๋ ํ ๋ฆฌ ์ค์บ
return List.of(
"com.example.plugins.PaymentPlugin",
"com.example.plugins.NotificationPlugin",
"com.example.plugins.ReportPlugin"
);
}
private String generateBeanName(Class<?> clazz) {
String simpleName = clazz.getSimpleName();
return Character.toLowerCase(simpleName.charAt(0)) + simpleName.substring(1);
}
}
10. RestTestClient
ํตํฉ ํ ์คํธ๋ฅผ ์ํ ์๋ก์ด ํ ์คํธ ํด๋ผ์ด์ธํธ์ ๋๋ค.
10.1 ๊ธฐ๋ณธ ์ฌ์ฉ๋ฒ
import org.springframework.boot.test.web.client.RestTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.beans.factory.annotation.Autowired;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerIntegrationTest {
@Autowired
private RestTestClient restTestClient;
@Test
void shouldGetAllUsers() {
restTestClient.get()
.uri("/api/users")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBodyList(User.class)
.hasSize(10)
.contains(expectedUser);
}
@Test
void shouldGetUserById() {
restTestClient.get()
.uri("/api/users/{id}", 1L)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.id").isEqualTo(1)
.jsonPath("$.name").isNotEmpty()
.jsonPath("$.email").value(email ->
assertThat(email.toString()).contains("@"));
}
@Test
void shouldCreateUser() {
CreateUserRequest request = new CreateUserRequest(
"John Doe",
"john@example.com"
);
restTestClient.post()
.uri("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.exchange()
.expectStatus().isCreated()
.expectBody(User.class)
.value(user -> {
assertThat(user.getId()).isNotNull();
assertThat(user.getName()).isEqualTo("John Doe");
assertThat(user.getEmail()).isEqualTo("john@example.com");
});
}
}
10.2 ์ธ์ฆ์ด ํ์ํ ํ ์คํธ
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class SecuredEndpointTest {
@Autowired
private RestTestClient restTestClient;
@Test
void shouldAccessProtectedEndpoint() {
restTestClient.get()
.uri("/api/admin/users")
.header("Authorization", "Bearer " + getValidToken())
.exchange()
.expectStatus().isOk()
.expectBodyList(User.class)
.hasSize(greaterThan(0));
}
@Test
void shouldRejectUnauthorizedAccess() {
restTestClient.get()
.uri("/api/admin/users")
.exchange()
.expectStatus().isUnauthorized();
}
@Test
void shouldRejectForbiddenAccess() {
restTestClient.get()
.uri("/api/admin/users")
.header("Authorization", "Bearer " + getUserToken()) // ์ผ๋ฐ ์ฌ์ฉ์ ํ ํฐ
.exchange()
.expectStatus().isForbidden();
}
}
10.3 ์๋ฌ ์๋ต ํ ์คํธ
@Test
void shouldReturn404WhenUserNotFound() {
restTestClient.get()
.uri("/api/users/{id}", 99999L)
.exchange()
.expectStatus().isNotFound()
.expectBody()
.jsonPath("$.error").isEqualTo("User not found")
.jsonPath("$.code").isEqualTo("USER_NOT_FOUND")
.jsonPath("$.timestamp").isNotEmpty();
}
@Test
void shouldReturn400ForInvalidRequest() {
CreateUserRequest invalidRequest = new CreateUserRequest("", "invalid-email");
restTestClient.post()
.uri("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(invalidRequest)
.exchange()
.expectStatus().isBadRequest()
.expectBody()
.jsonPath("$.errors").isArray()
.jsonPath("$.errors[?(@.field == 'name')]").exists()
.jsonPath("$.errors[?(@.field == 'email')]").exists();
}
11. Redis Static Master/Replica
Redis Master/Replica ๊ตฌ์ฑ์ ์ํ ์ ์ ์ค์ ์ ์ง์ํฉ๋๋ค.
11.1 ์ค์ (application.yml)
spring:
data:
redis:
masterreplica:
nodes:
- redis://master.example.com:6379
- redis://replica1.example.com:6379
- redis://replica2.example.com:6379
read-from: REPLICA_PREFERRED # MASTER, REPLICA, REPLICA_PREFERRED
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
11.2 ์ฝ๊ธฐ/์ฐ๊ธฐ ๋ถ๋ฆฌ
@Service
@RequiredArgsConstructor
public class CacheService {
private final RedisTemplate<String, Object> redisTemplate;
// ์ฐ๊ธฐ๋ Master๋ก
public void put(String key, Object value, Duration ttl) {
redisTemplate.opsForValue().set(key, value, ttl);
}
// ์ฝ๊ธฐ๋ Replica์์ (์ค์ ์ ๋ฐ๋ผ ์๋ ๋ผ์ฐํ
)
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}
public void evict(String key) {
redisTemplate.delete(key);
}
}
11.3 MicrometerTracing ์๋ ๊ตฌ์ฑ
# Redis ๋ฉํธ๋ฆญ ๋ฐ ํธ๋ ์ด์ฑ ํ์ฑํ
management:
metrics:
export:
prometheus:
enabled: true
tracing:
sampling:
probability: 1.0
# Redis ํธ๋ ์ด์ฑ ์ค์
spring:
data:
redis:
lettuce:
micrometer:
enabled: true # ๋ฉํธ๋ฆญ ์์ง
tracing: true # ๋ถ์ฐ ์ถ์
12. ์ฃผ์ ์์กด์ฑ ์ ๊ทธ๋ ์ด๋
| ๋ผ์ด๋ธ๋ฌ๋ฆฌ | ๋ฒ์ |
|---|---|
| Micrometer | 1.16 |
| Micrometer Tracing | 1.6 |
| Reactor | 2025.0 |
| Spring Security | 7.0 |
| Spring Data | 2025.1 |
| Spring Batch | 6.0 |
| Hibernate | 7.1 |
| Jackson | 3.0 |
| Tomcat | 11.0 |
| Jetty | 12.1 |
| Kotlin | 2.2.20 |
| TestContainers | 2.0 |
| MongoDB Driver | 5.6.0 |
| Elasticsearch Client | 9.1 |
13. ๊ธฐํ ๊ฐ์ ์ฌํญ
13.1 JmsClient ์๋ ๊ตฌ์ฑ
@Service
@RequiredArgsConstructor
public class MessageService {
private final JmsClient jmsClient;
public void sendMessage(String destination, Object message) {
jmsClient.destination(destination).send(message);
}
public <T> T sendAndReceive(String destination, Object message, Class<T> responseType) {
return jmsClient.destination(destination)
.sendAndReceive(message, responseType);
}
}
13.2 Virtual Thread ์ง์ HTTP ํด๋ผ์ด์ธํธ
spring:
threads:
virtual:
enabled: true # Virtual Thread ํ์ฑํ
http:
client:
virtual-threads: true # HTTP ํด๋ผ์ด์ธํธ์์ Virtual Thread ์ฌ์ฉ
@Configuration
public class VirtualThreadHttpClientConfig {
@Bean
public RestClient restClient(RestClient.Builder builder) {
return builder
.baseUrl("https://api.example.com")
.requestFactory(new JdkClientHttpRequestFactory(
HttpClient.newBuilder()
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build()
))
.build();
}
}
13.3 ์ฝ์ ๋ก๊น ๋นํ์ฑํ
logging:
console:
enabled: false # ์ฝ์ ๋ก๊น
์์ ๋นํ์ฑํ
file:
name: logs/application.log
13.4 Elasticsearch API Key ์ธ์ฆ
spring:
elasticsearch:
uris: https://elasticsearch.example.com:9200
api-key: your-api-key-here # ์๋ก์ด API Key ์ธ์ฆ ๋ฐฉ์
13.5 Tomcat ์ ์ ์บ์ ํฌ๊ธฐ ์ค์
server:
tomcat:
resource:
cache-max-size: 102400 # 100MB (KB ๋จ์)
allow-linking: true
13.6 TaskDecorator ๋ค์ค ๋น ์ง์
@Configuration
public class AsyncConfig {
@Bean
public TaskDecorator loggingTaskDecorator() {
return runnable -> {
MDC.put("async", "true");
return () -> {
try {
runnable.run();
} finally {
MDC.remove("async");
}
};
};
}
@Bean
public TaskDecorator securityTaskDecorator() {
return runnable -> {
SecurityContext context = SecurityContextHolder.getContext();
return () -> {
try {
SecurityContextHolder.setContext(context);
runnable.run();
} finally {
SecurityContextHolder.clearContext();
}
};
};
}
}
14. ๋ง์ด๊ทธ๋ ์ด์ ๊ฐ์ด๋
14.1 javax โ jakarta ๋ง์ด๊ทธ๋ ์ด์
// Before (Spring Boot 2.x)
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.validation.constraints.NotNull;
import javax.servlet.http.HttpServletRequest;
// After (Spring Boot 4.x)
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.validation.constraints.NotNull;
import jakarta.servlet.http.HttpServletRequest;
14.2 ํ๋กํผํฐ ๋ณ๊ฒฝ
# Before
spring:
dao:
exceptiontranslation:
enabled: true
management:
tracing:
enabled: true
# After
spring:
persistence:
exceptiontranslation:
enabled: true
management:
tracing:
export:
enabled: true
14.3 Spring Retry โ Framework Resilience
// Before (Spring Retry)
@EnableRetry
@Configuration
public class RetryConfig {
}
@Service
public class MyService {
@org.springframework.retry.annotation.Retryable(
value = Exception.class,
maxAttempts = 3
)
public void doSomething() { }
}
// After (Spring Framework 7 Resilience)
@EnableResilientMethods
@Configuration
public class ResilienceConfig {
}
@Service
public class MyService {
@org.springframework.retry.annotation.Retryable(
includes = Exception.class,
maxAttempts = 3
)
public void doSomething() { }
}
14.4 Jackson 2.x โ 3.0
// Jackson 3.0 ํจํค์ง ๋ณ๊ฒฝ
// Before
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonProperty;
// After (๋์ผํ์ง๋ง ๋ด๋ถ ๋์ ๋ณ๊ฒฝ ํ์ธ ํ์)
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonProperty;
// ์ฃผ์ ๋ณ๊ฒฝ์ฌํญ:
// - ๊ธฐ๋ณธ์ ์ผ๋ก ๋ ์๊ฒฉํ JSON ํ์ฑ
// - ์ผ๋ถ deprecated ๋ฉ์๋ ์ ๊ฑฐ
// - ์ฑ๋ฅ ๊ฐ์
14.5 ์ ๊ฑฐ๋ ๊ธฐ๋ฅ
# ๋ ์ด์ ์ง์ํ์ง ์๋ ์ค์ ๋ค
spring:
mvc:
pathmatch:
matching-strategy: ant_path_matcher # ์ ๊ฑฐ๋จ
use-suffix-pattern: true # ์ ๊ฑฐ๋จ
use-registered-suffix-pattern: true # ์ ๊ฑฐ๋จ
servlet:
load-on-startup: 1
14.6 ๋ง์ด๊ทธ๋ ์ด์ ์ฒดํฌ๋ฆฌ์คํธ
## Spring Boot 4.0 ๋ง์ด๊ทธ๋ ์ด์
์ฒดํฌ๋ฆฌ์คํธ
### ํ์ ์ฌํญ
- [ ] JDK 17 ์ด์ ํ์ธ
- [ ] javax.* โ jakarta.* ํจํค์ง ๋ณ๊ฒฝ
- [ ] build.gradle ๋๋ pom.xml ์์กด์ฑ ์
๋ฐ์ดํธ
- [ ] application.yml ํ๋กํผํฐ ์ด๋ฆ ๋ณ๊ฒฝ
### Spring Framework 7 ์ ์ฉ
- [ ] @EnableRetry โ @EnableResilientMethods ๋ณ๊ฒฝ
- [ ] Spring Retry ์ด๋
ธํ
์ด์
์์ฑ๋ช
ํ์ธ (value โ includes)
### ํ
์คํธ
- [ ] JUnit 4 โ JUnit 5 ๋ง์ด๊ทธ๋ ์ด์
- [ ] RestTestClient ์ฌ์ฉ ๊ฒํ
- [ ] ํตํฉ ํ
์คํธ ์คํ ํ์ธ
### ์ ํ์ ๊ฐ์
- [ ] HTTP Service Clients ๋์
๊ฒํ
- [ ] API Versioning ์ ์ฉ ๊ฒํ
- [ ] JSpecify Null Safety ๋์
- [ ] OpenTelemetry ์ค์
- [ ] Virtual Thread ํ์ฑํ ๊ฒํ
๐ ์ฐธ๊ณ ์๋ฃ
- Spring Boot 4.0 Release Notes
- Spring Framework 7.0 Documentation
- JSpecify Official Site
- OpenTelemetry Documentation
- Kotlin Serialization Guide
๐ ๋ฌธ์ ์์ฑ์ผ: 2025๋ 12์ 12์ผ
โ๏ธ ์์ฑ์: Claude AI
๐ท๏ธ ๋ฒ์ : 1.0



