๐Ÿš€ spring boot 4.0 ์‹ ๊ทœ ๊ธฐ๋Šฅ ์™„๋ฒฝ ๊ฐ€์ด๋“œ

ยท17 min readยท8ยท
๐Ÿš€ Spring Boot 4.0 ์‹ ๊ทœ ๊ธฐ๋Šฅ ์™„๋ฒฝ ๊ฐ€์ด๋“œ

๐Ÿš€ Spring Boot 4.0 ์‹ ๊ทœ ๊ธฐ๋Šฅ ์™„๋ฒฝ ๊ฐ€์ด๋“œ

๋ฆด๋ฆฌ์Šค ์ผ์ž: 2025๋…„ 11์›” 20์ผ

Spring Framework: 7.0 ๊ธฐ๋ฐ˜

์ตœ์†Œ JDK: 17 (LTS), ๊ถŒ์žฅ: 25


๐Ÿ“‹ ๋ชฉ์ฐจ

  1. ๊ธฐ๋ณธ ์š”๊ตฌ์‚ฌํ•ญ
  2. ์ฝ”๋“œ๋ฒ ์ด์Šค ์™„์ „ ๋ชจ๋“ˆํ™”
  3. HTTP Service Clients
  4. API ๋ฒ„์ „ ๊ด€๋ฆฌ
  5. JSpecify Null Safety
  6. ๋‚ด์žฅ Resilience ๊ธฐ๋Šฅ
  7. OpenTelemetry ์Šคํƒ€ํ„ฐ
  8. Kotlin Serialization ์ง€์›
  9. BeanRegistrar
  10. RestTestClient
  11. Redis Static Master/Replica
  12. ์ฃผ์š” ์˜์กด์„ฑ ์—…๊ทธ๋ ˆ์ด๋“œ
  13. ๊ธฐํƒ€ ๊ฐœ์„ ์‚ฌํ•ญ
  14. ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๊ฐ€์ด๋“œ

Gemini Generated Image jvcsgtjvcsgtjvcs

1. ๊ธฐ๋ณธ ์š”๊ตฌ์‚ฌํ•ญ

ํ•ญ๋ชฉ์š”๊ตฌ์‚ฌํ•ญ
JDK์ตœ์†Œ 17 (LTS), ๊ถŒ์žฅ 25
Spring Framework7.0
Jakarta EE11
Servlet6.1
JPA3.2
Hibernate7.1
GraalVM24 ์™„์ „ ์ง€์›
Kotlin2.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/usersURL ๊ฒฝ๋กœ์— ๋ฒ„์ „ ํฌํ•จ
HeaderX-API-Version: 1.0HTTP ํ—ค๋”๋กœ ๋ฒ„์ „ ์ „๋‹ฌ
Query Parameter?version=1.0์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฒ„์ „ ์ „๋‹ฌ
Media TypeAccept: 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 ์ฒดํฌ๋ฅผ ํ™œ์„ฑํ™”ํ•˜๋ ค๋ฉด:

  1. Settings โ†’ Editor โ†’ Inspections
  2. Java โ†’ Probable bugs โ†’ Nullability problems
  3. 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. ์ฃผ์š” ์˜์กด์„ฑ ์—…๊ทธ๋ ˆ์ด๋“œ

๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฒ„์ „
Micrometer1.16
Micrometer Tracing1.6
Reactor2025.0
Spring Security7.0
Spring Data2025.1
Spring Batch6.0
Hibernate7.1
Jackson3.0
Tomcat11.0
Jetty12.1
Kotlin2.2.20
TestContainers2.0
MongoDB Driver5.6.0
Elasticsearch Client9.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 ํ™œ์„ฑํ™” ๊ฒ€ํ† 


๐Ÿ“š ์ฐธ๊ณ  ์ž๋ฃŒ


๐Ÿ“… ๋ฌธ์„œ ์ž‘์„ฑ์ผ: 2025๋…„ 12์›” 12์ผ

โœ๏ธ ์ž‘์„ฑ์ž: Claude AI

๐Ÿท๏ธ ๋ฒ„์ „: 1.0

// tags