30 คำถามสัมภาษณ์ Spring Boot: คู่มือฉบับเต็มสำหรับนักพัฒนา Java

เตรียมพร้อมสัมภาษณ์ Spring Boot ด้วย 30 คำถามหลักครอบคลุม auto-configuration, starter, Spring Data JPA, ความปลอดภัย และการทดสอบ

30 คำถามสัมภาษณ์ Spring Boot สำหรับนักพัฒนา Java

Spring Boot กลายเป็นเฟรมเวิร์กหลักสำหรับการพัฒนา Java ในระดับองค์กร การสัมภาษณ์เชิงเทคนิคจะประเมินความเข้าใจกลไกภายใน หลักปฏิบัติที่ดี และระบบนิเวศของ Spring คู่มือฉบับนี้รวม 30 คำถามที่พบบ่อย ตั้งแต่แนวคิดพื้นฐานจนถึงหัวข้อขั้นสูง

เคล็ดลับเตรียมตัว

ผู้สัมภาษณ์ชื่นชอบผู้สมัครที่เข้าใจ "เหตุผล" เบื้องหลังของแต่ละฟีเจอร์ นอกเหนือจาก syntax ควรอธิบายว่า Spring Boot แก้ปัญหาอะไรได้

พื้นฐานของ Spring Boot

1. Spring กับ Spring Boot ต่างกันอย่างไร?

Spring เป็นเฟรมเวิร์กแบบโมดูลที่ให้ dependency injection, การจัดการธุรกรรม และการเชื่อมต่อกับเทคโนโลยีหลายชนิด ส่วน Spring Boot คือเลเยอร์นามธรรมที่อยู่บน Spring เพื่อทำให้การตั้งค่าและการเริ่มต้นแอปพลิเคชันง่ายขึ้น

Application.javajava
// With Spring Boot: a single annotation to start
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        // SpringApplication configures and starts the Spring context
        SpringApplication.run(Application.class, args);
    }
}

Spring Boot มอบ auto-configuration, dependency starter, เซิร์ฟเวอร์ฝังตัว และเมตริกระดับโปรดักชันผ่าน Actuator เป้าหมายคือไปจากไอเดียสู่โค้ดที่ทำงานได้ภายในไม่กี่นาที

2. Auto-configuration ทำงานอย่างไร?

Auto-configuration วิเคราะห์ classpath แล้วตั้งค่า bean ที่จำเป็นโดยอัตโนมัติ กลไกนี้อาศัยแอนโนเทชัน @EnableAutoConfiguration (รวมอยู่ใน @SpringBootApplication) และเงื่อนไขที่ประกาศไว้ในคลาส auto-configuration

CustomAutoConfiguration.javajava
@Configuration
@ConditionalOnClass(DataSource.class) // Activates if DataSource is on classpath
@ConditionalOnMissingBean(DataSource.class) // Only acts if no DataSource exists
public class CustomAutoConfiguration {

    @Bean
    @ConditionalOnProperty(name = "app.datasource.enabled", havingValue = "true")
    public DataSource dataSource() {
        // Default DataSource configuration
        return DataSourceBuilder.create()
            .driverClassName("org.h2.Driver")
            .url("jdbc:h2:mem:testdb")
            .build();
    }
}

Spring Boot ตรวจสอบเงื่อนไข (@ConditionalOn*) เพื่อตัดสินใจว่าจะสร้าง bean ใดบ้าง วิธีนี้ช่วยลดความขัดแย้งและทำให้สามารถเขียนทับการตั้งค่าเริ่มต้นได้ง่าย

3. Starter คืออะไรและสร้างได้อย่างไร?

Starter คือโมดูล Maven/Gradle ที่รวบรวม dependency และค่าตั้งค่าที่จำเป็นต่อฟีเจอร์ใดฟีเจอร์หนึ่ง ตัวอย่างเช่น spring-boot-starter-web รวม Spring MVC, Jackson, Tomcat ฝังตัว และ validation

xml
<!-- pom.xml for a custom starter -->
<project>
    <artifactId>my-company-starter</artifactId>
    <dependencies>
        <!-- Base Spring Boot dependency -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        <!-- Company-specific libraries -->
        <dependency>
            <groupId>com.mycompany</groupId>
            <artifactId>logging-utils</artifactId>
        </dependency>
    </dependencies>
</project>

หากต้องการสร้าง starter เอง ให้นิยามคลาส auto-configuration และอ้างอิงไว้ใน META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

4. application.properties กับ application.yml ต่างกันอย่างไร?

รูปแบบทั้งสองช่วยแยกการตั้งค่าออกจากโค้ด YAML เหมาะกับโครงสร้างซ้อนกันด้วย syntax ที่อ่านง่าย ส่วน properties ยังเหมาะกับโครงสร้างแบบราบมากกว่า

properties
# application.properties
server.port=8080
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.datasource.username=admin
spring.jpa.hibernate.ddl-auto=update
yaml
# application.yml - clear hierarchical structure
server:
  port: 8080

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: admin
  jpa:
    hibernate:
      ddl-auto: update

Profile ช่วยปรับการตั้งค่าให้เหมาะกับสภาพแวดล้อม: application-dev.yml, application-prod.yml เปิดใช้งานผ่าน spring.profiles.active

5. @SpringBootApplication ทำงานอย่างไร?

แอนโนเทชันนี้รวมแอนโนเทชันสำคัญสามตัวที่กำหนดพฤติกรรมของแอป Spring Boot

DemoApplication.javajava
// @SpringBootApplication is equivalent to these three annotations:
@SpringBootConfiguration // Equivalent to @Configuration
@EnableAutoConfiguration // Activates auto-configuration
@ComponentScan // Scans components in package and sub-packages
public class DemoApplication {

    public static void main(String[] args) {
        // Creates context, runs configurations, and starts server
        ConfigurableApplicationContext context =
            SpringApplication.run(DemoApplication.class, args);

        // The context contains all configured beans
        UserService userService = context.getBean(UserService.class);
    }
}

การวางคลาสนี้ไว้ที่ root ของแพ็กเกจสำคัญมาก: @ComponentScan จะสแกนแพ็กเกจนั้นพร้อมแพ็กเกจย่อยทั้งหมดเพื่อค้นหาคอมโพเนนต์

Spring Data และการคงอยู่ของข้อมูล

6. JpaRepository, CrudRepository และ PagingAndSortingRepository ต่างกันอย่างไร?

อินเทอร์เฟซเหล่านี้เป็นลำดับชั้นที่ให้ความสามารถในการเข้าถึงข้อมูลเพิ่มขึ้นเรื่อย ๆ

UserRepository.javajava
// CrudRepository: basic CRUD operations
public interface UserCrudRepository extends CrudRepository<User, Long> {
    // save(), findById(), findAll(), deleteById(), count(), existsById()
}

// PagingAndSortingRepository: adds pagination and sorting
public interface UserPagingRepository extends PagingAndSortingRepository<User, Long> {
    // Inherits from CrudRepository + findAll(Sort), findAll(Pageable)
}

// JpaRepository: complete JPA features
public interface UserRepository extends JpaRepository<User, Long> {
    // Inherits from previous + flush(), saveAndFlush(), deleteInBatch()

    // Derived query methods
    List<User> findByEmailContaining(String email);

    Optional<User> findByUsernameAndActiveTrue(String username);
}

สำหรับการใช้งานทั่วไป JpaRepository เป็นตัวเลือกที่แนะนำ เพราะให้ฟังก์ชันที่จำเป็นต่อการพัฒนาแอป JPA อย่างครบถ้วน

7. กำหนด query เองด้วย @Query อย่างไร?

แอนโนเทชัน @Query ทำให้สามารถเขียน JPQL หรือ SQL native ได้เมื่อ derived method ไม่เพียงพอ

OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // JPQL with named parameters
    @Query("SELECT o FROM Order o WHERE o.customer.id = :customerId AND o.status = :status")
    List<Order> findCustomerOrdersByStatus(
        @Param("customerId") Long customerId,
        @Param("status") OrderStatus status
    );

    // Native SQL query for specific needs
    @Query(value = "SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '7 days'",
           nativeQuery = true)
    List<Order> findRecentOrders();

    // Modifying query with @Modifying
    @Modifying
    @Transactional
    @Query("UPDATE Order o SET o.status = :status WHERE o.id IN :ids")
    int updateOrderStatuses(@Param("ids") List<Long> ids, @Param("status") OrderStatus status);
}

Query แบบ JPQL พกพาได้ระหว่างฐานข้อมูล ในขณะที่ native query ให้เข้าถึงฟีเจอร์เฉพาะของ DBMS

8. การจัดการธุรกรรมด้วย @Transactional

แอนโนเทชันนี้ระบุขอบเขตธุรกรรมของเมธอดหรือคลาส Spring จะจัดการ commit, rollback และการส่งต่อให้อัตโนมัติ

PaymentService.javajava
@Service
public class PaymentService {

    private final AccountRepository accountRepository;
    private final TransactionLogRepository logRepository;

    // Transaction with custom configuration
    @Transactional(
        propagation = Propagation.REQUIRED,      // Creates or joins a transaction
        isolation = Isolation.READ_COMMITTED,    // Isolation level
        timeout = 30,                            // Timeout in seconds
        rollbackFor = PaymentException.class     // Rollback on this exception
    )
    public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepository.findById(fromId)
            .orElseThrow(() -> new AccountNotFoundException(fromId));
        Account to = accountRepository.findById(toId)
            .orElseThrow(() -> new AccountNotFoundException(toId));

        from.debit(amount);  // Throws exception if insufficient balance
        to.credit(amount);

        accountRepository.save(from);
        accountRepository.save(to);

        // Log will be rolled back with everything else if an exception occurs
        logRepository.save(new TransactionLog(fromId, toId, amount));
    }
}

ระดับ propagation (REQUIRED, REQUIRES_NEW, NESTED ฯลฯ) กำหนดวิธีที่ธุรกรรมซ้อนกันเมื่อเรียกเมธอดที่เป็น transactional ต่อกันเป็นทอด ๆ

กับดักที่พบบ่อย

@Transactional ทำงานเฉพาะการเรียกที่ผ่าน proxy ของ Spring การเรียกภายในคลาสเดียวกันจะข้าม proxy และไม่สนใจแอนโนเทชัน

9. จัดการความสัมพันธ์ Lazy กับ Eager Loading อย่างไร?

โหมดการโหลดความสัมพันธ์ส่งผลโดยตรงต่อประสิทธิภาพ Lazy Loading จะหน่วงการโหลดจนกว่าจะมีการเข้าถึง ส่วน Eager Loading จะโหลดทันที

User.javajava
@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Eager: roles are loaded with the user
    @ManyToMany(fetch = FetchType.EAGER)
    private Set<Role> roles;

    // Lazy: orders are only loaded on access
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Order> orders;
}
UserService.javajava
@Service
public class UserService {

    @Transactional(readOnly = true)
    public UserDTO getUserWithOrders(Long userId) {
        User user = userRepository.findById(userId).orElseThrow();

        // Accessing orders triggers an additional SQL query
        // This works because the session is open (@Transactional)
        List<OrderDTO> orderDTOs = user.getOrders().stream()
            .map(this::toDTO)
            .collect(Collectors.toList());

        return new UserDTO(user, orderDTOs);
    }

    // Alternative: query with JOIN FETCH to avoid N+1
    public UserDTO getUserWithOrdersOptimized(Long userId) {
        User user = userRepository.findByIdWithOrders(userId);
        // Orders are already loaded, no additional query
        return new UserDTO(user, user.getOrders());
    }
}

ปัญหา N+1 เกิดเมื่อ lazy loading สร้าง query หนึ่งคำสั่งต่อสมาชิกหนึ่งตัวในคอลเลกชัน การใช้ JOIN FETCH หรือ projection ช่วยแก้ได้

10. ปัญหา N+1 คืออะไรและแก้อย่างไร?

ปัญหา N+1 เกิดเมื่อหลังจาก query เริ่มต้น (1) ตามมาด้วย query เพิ่มอีก N รายการเพื่อโหลดความสัมพันธ์ของแต่ละ entity

ArticleRepository.javajava
public interface ArticleRepository extends JpaRepository<Article, Long> {

    // PROBLEM: this query generates N+1 queries
    List<Article> findAll();

    // SOLUTION 1: JOIN FETCH in JPQL
    @Query("SELECT a FROM Article a JOIN FETCH a.author JOIN FETCH a.comments")
    List<Article> findAllWithDetails();

    // SOLUTION 2: Entity Graph
    @EntityGraph(attributePaths = {"author", "comments"})
    @Query("SELECT a FROM Article a")
    List<Article> findAllWithGraph();
}
Article.javajava
@Entity
@NamedEntityGraph(
    name = "Article.withDetails",
    attributeNodes = {
        @NamedAttributeNode("author"),
        @NamedAttributeNode("comments")
    }
)
public class Article {
    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Author author;

    @OneToMany(mappedBy = "article", fetch = FetchType.LAZY)
    private List<Comment> comments;
}

การใช้ @EntityGraph หรือ JOIN FETCH ช่วยลด N+1 ให้เหลือ query เดียวที่มี join ทำให้ประสิทธิภาพดีขึ้นชัดเจน

พร้อมที่จะพิชิตการสัมภาษณ์ Spring Boot แล้วหรือยังครับ?

ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ

REST API และเว็บ

11. สร้าง REST controller พร้อม validation อย่างไร?

Spring Boot ผสาน @RestController กับ Bean Validation เพื่อสร้าง API ที่แข็งแรงและตรวจสอบอินพุตอัตโนมัติ

UserController.javajava
@RestController
@RequestMapping("/api/users")
@Validated // Activates validation on method parameters
public class UserController {

    private final UserService userService;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
        // Validation is performed automatically before execution
        return userService.createUser(request);
    }

    @GetMapping("/{id}")
    public UserResponse getUser(
        @PathVariable @Min(1) Long id // Validation on PathVariable
    ) {
        return userService.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
    }

    @GetMapping
    public Page<UserResponse> listUsers(
        @RequestParam(defaultValue = "0") @Min(0) int page,
        @RequestParam(defaultValue = "20") @Max(100) int size
    ) {
        return userService.findAll(PageRequest.of(page, size));
    }
}
CreateUserRequest.javajava
public record CreateUserRequest(
    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 50, message = "Name must be between 2 and 50 characters")
    String name,

    @NotBlank(message = "Email is required")
    @Email(message = "Invalid email format")
    String email,

    @NotBlank
    @Pattern(regexp = "^(?=.*[A-Z])(?=.*[0-9]).{8,}$",
             message = "Password must contain at least 8 characters, one uppercase and one digit")
    String password
) {}

สามารถนำข้อความ validation ออกไปอยู่ในไฟล์ message เพื่อรองรับหลายภาษาได้

12. จัดการ exception แบบรวมศูนย์ด้วย @ControllerAdvice อย่างไร?

ตัวจัดการ exception รวมศูนย์ช่วยให้รูปแบบของการตอบกลับข้อผิดพลาดเป็นมาตรฐานและลดโค้ดซ้ำ

GlobalExceptionHandler.javajava
@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    // Handle validation errors
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, String> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .collect(Collectors.toMap(
                FieldError::getField,
                FieldError::getDefaultMessage,
                (existing, replacement) -> existing
            ));

        return new ErrorResponse("VALIDATION_ERROR", "Invalid data", errors);
    }

    // Handle resource not found
    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
        return new ErrorResponse("NOT_FOUND", ex.getMessage(), null);
    }

    // Handle unexpected errors
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResponse handleUnexpectedError(Exception ex) {
        log.error("Unexpected error", ex);
        return new ErrorResponse("INTERNAL_ERROR", "An error occurred", null);
    }
}
ErrorResponse.javajava
public record ErrorResponse(
    String code,
    String message,
    Map<String, String> details,
    Instant timestamp
) {
    public ErrorResponse(String code, String message, Map<String, String> details) {
        this(code, message, details, Instant.now());
    }
}

วิธีนี้ช่วยให้ทุกข้อผิดพลาดอยู่ในรูปแบบเดียวกันและฝั่งไคลเอ็นต์จัดการง่ายขึ้น

13. @RequestParam, @PathVariable และ @RequestBody แตกต่างกันอย่างไร?

แอนโนเทชันทั้งสามดึงข้อมูลจากส่วนต่าง ๆ ของ HTTP request

ProductController.javajava
@RestController
@RequestMapping("/api/products")
public class ProductController {

    // @PathVariable: extracts values from the URL
    // GET /api/products/123
    @GetMapping("/{id}")
    public Product getProduct(@PathVariable Long id) {
        return productService.findById(id);
    }

    // @RequestParam: extracts query parameters
    // GET /api/products?category=electronics&minPrice=100&page=0
    @GetMapping
    public Page<Product> searchProducts(
        @RequestParam(required = false) String category,
        @RequestParam(defaultValue = "0") BigDecimal minPrice,
        @RequestParam(defaultValue = "0") int page
    ) {
        return productService.search(category, minPrice, PageRequest.of(page, 20));
    }

    // @RequestBody: deserializes the JSON request body
    // POST /api/products with {"name": "...", "price": ...}
    @PostMapping
    public Product createProduct(@Valid @RequestBody CreateProductRequest request) {
        return productService.create(request);
    }

    // Combination of all three in the same method
    // PUT /api/products/123?notify=true with JSON body
    @PutMapping("/{id}")
    public Product updateProduct(
        @PathVariable Long id,
        @RequestParam(defaultValue = "false") boolean notify,
        @Valid @RequestBody UpdateProductRequest request
    ) {
        Product updated = productService.update(id, request);
        if (notify) {
            notificationService.sendUpdateNotification(updated);
        }
        return updated;
    }
}

@PathVariable ใช้ระบุ resource เจาะจง, @RequestParam ใช้กรองหรือแบ่งหน้าผลลัพธ์ และ @RequestBody ใช้ส่งข้อมูลที่ซับซ้อน

14. ทำ versioning ของ API ได้อย่างไร?

มีหลายกลยุทธ์ในการกำหนดเวอร์ชันให้ REST API แต่ละแบบมีจุดเด่นต่างกัน

java
// Version via URL (recommended for clarity)
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
    @GetMapping("/{id}")
    public UserV1Response getUser(@PathVariable Long id) {
        return userService.findByIdV1(id);
    }
}

@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
    @GetMapping("/{id}")
    public UserV2Response getUser(@PathVariable Long id) {
        // V2 includes additional fields
        return userService.findByIdV2(id);
    }
}
java
// Version via custom header
@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping(value = "/{id}", headers = "X-API-Version=1")
    public UserV1Response getUserV1(@PathVariable Long id) {
        return userService.findByIdV1(id);
    }

    @GetMapping(value = "/{id}", headers = "X-API-Version=2")
    public UserV2Response getUserV2(@PathVariable Long id) {
        return userService.findByIdV2(id);
    }
}
java
// Version via media type (content negotiation)
@RestController
@RequestMapping("/api/users")
public class UserMediaTypeController {

    @GetMapping(value = "/{id}", produces = "application/vnd.myapi.v1+json")
    public UserV1Response getUserV1(@PathVariable Long id) {
        return userService.findByIdV1(id);
    }

    @GetMapping(value = "/{id}", produces = "application/vnd.myapi.v2+json")
    public UserV2Response getUserV2(@PathVariable Long id) {
        return userService.findByIdV2(id);
    }
}

การกำหนดเวอร์ชันผ่าน URL ยังคงได้รับความนิยมมากที่สุด เพราะเรียบง่ายและเห็นชัดในล็อกและเอกสาร

15. ตั้งค่า CORS ใน Spring Boot อย่างไร?

CORS (Cross-Origin Resource Sharing) ควบคุมคำขอจากต้นทางต่าง ๆ มีหลายระดับการตั้งค่าให้เลือกใช้

CorsConfig.java - Global configurationjava
@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://frontend.example.com", "https://admin.example.com")
            .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
            .allowedHeaders("*")
            .exposedHeaders("X-Total-Count", "X-Page-Count")
            .allowCredentials(true)
            .maxAge(3600); // Cache preflight for 1 hour
    }
}
java
// Per-controller or method configuration
@RestController
@RequestMapping("/api/public")
@CrossOrigin(origins = "*", maxAge = 3600)
public class PublicApiController {

    @GetMapping("/data")
    public DataResponse getPublicData() {
        return dataService.getPublicData();
    }

    // Override at method level
    @CrossOrigin(origins = "https://specific-client.com")
    @PostMapping("/submit")
    public SubmitResponse submitData(@RequestBody SubmitRequest request) {
        return dataService.submit(request);
    }
}

บนโปรดักชันควรจำกัด origin ที่อนุญาตและใช้ HTTPS อย่าใช้ * ร่วมกับ allowCredentials(true) เด็ดขาด

Spring Security

16. ตั้งค่า Spring Security กับ JWT

Spring Security 6+ ใช้แนวทางเชิงฟังก์ชันสำหรับการตั้งค่าความปลอดภัย

SecurityConfig.javajava
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable()) // Disabled for stateless API
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/**").authenticated()
            )
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
JwtAuthenticationFilter.javajava
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain chain
    ) throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            chain.doFilter(request, response);
            return;
        }

        String jwt = authHeader.substring(7);
        String username = jwtService.extractUsername(jwt);

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            if (jwtService.isTokenValid(jwt, userDetails)) {
                UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities()
                    );
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        chain.doFilter(request, response);
    }
}

การตั้งค่านี้สร้าง API แบบไม่มีสถานะที่ทุกคำขอต้องแนบ JWT token ที่ถูกต้องในเฮดเดอร์ Authorization

17. ป้องกันเมธอดด้วย @PreAuthorize และ @PostAuthorize

ความปลอดภัยระดับเมธอดให้การควบคุมที่ละเอียดตาม role, สิทธิ์ หรือข้อมูล

UserService.javajava
@Service
@PreAuthorize("isAuthenticated()") // All methods require authentication
public class UserService {

    // Only ADMINs can list all users
    @PreAuthorize("hasRole('ADMIN')")
    public List<User> findAll() {
        return userRepository.findAll();
    }

    // User can view their profile OR admins can view any profile
    @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
    public User findById(Long id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
    }

    // Post-execution check: user can only see their own data
    @PostAuthorize("returnObject.owner.id == authentication.principal.id or hasRole('ADMIN')")
    public Document getDocument(Long documentId) {
        return documentRepository.findById(documentId)
            .orElseThrow(() -> new DocumentNotFoundException(documentId));
    }

    // Collection filtering: returns only authorized elements
    @PostFilter("filterObject.owner.id == authentication.principal.id")
    public List<Project> getUserProjects() {
        return projectRepository.findAll();
    }
}
SecurityConfig.java - Activationjava
@Configuration
@EnableMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig {
    // Additional configuration if needed
}

@PreAuthorize ตรวจก่อนการเรียก @PostAuthorize ตรวจหลังการเรียก ส่วน SpEL ทำให้สามารถเขียนกฎซับซ้อนได้

18. ป้องกัน endpoint จากการโจมตี CSRF

CSRF (Cross-Site Request Forgery) ใช้ประโยชน์จากเซสชันของผู้ใช้ที่ล็อกอินอยู่ การป้องกันถูกเปิดใช้งานโดยปริยายสำหรับแอปที่ใช้เซสชัน

SecurityConfig.javajava
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            // CSRF configuration for session-based applications
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
                // Exclude certain endpoints from CSRF protection
                .ignoringRequestMatchers("/api/webhooks/**")
            )
            .build();
    }
}
CsrfController.java - Endpoint to retrieve token (SPA)java
@RestController
public class CsrfController {

    @GetMapping("/api/csrf")
    public CsrfToken csrf(CsrfToken token) {
        // Returns CSRF token to frontend
        return token;
    }
}

สำหรับ REST API แบบ stateless ที่ใช้ JWT สามารถปิด CSRF ได้เพราะไม่มีคุกกี้เซสชันให้แสวงประโยชน์ ส่วนแอปแบบดั้งเดิมที่ใช้เซสชันต้องเปิดป้องกัน CSRF เสมอ

API stateless กับแอปที่ใช้เซสชัน

API ที่ใช้ JWT จะปลอดภัยจาก CSRF โดยธรรมชาติ เพราะ token ต้องถูกแนบมาทุกคำขอ ส่วนแอปที่ใช้คุกกี้เซสชันจำเป็นต้องเปิด CSRF protection เสมอ

การทดสอบใน Spring Boot

19. @SpringBootTest กับ @WebMvcTest แตกต่างอย่างไร?

แอนโนเทชันเหล่านี้กำหนดบริบทการทดสอบต่างกันตามความต้องการ

UserControllerIntegrationTest.javajava
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class UserControllerIntegrationTest {

    // Loads full context: all beans, database, etc.
    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void shouldCreateUser() {
        CreateUserRequest request = new CreateUserRequest("John", "john@example.com");

        ResponseEntity<User> response = restTemplate.postForEntity(
            "/api/users", request, User.class
        );

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getBody().getName()).isEqualTo("John");
    }
}
UserControllerUnitTest.javajava
@WebMvcTest(UserController.class) // Loads only the web layer
class UserControllerUnitTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean // Replaces @MockBean (deprecated)
    private UserService userService;

    @Test
    void shouldReturnUser() throws Exception {
        // Mock configuration
        when(userService.findById(1L))
            .thenReturn(Optional.of(new User(1L, "John", "john@example.com")));

        mockMvc.perform(get("/api/users/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.name").value("John"))
            .andExpect(jsonPath("$.email").value("john@example.com"));
    }

    @Test
    void shouldReturn404WhenUserNotFound() throws Exception {
        when(userService.findById(999L)).thenReturn(Optional.empty());

        mockMvc.perform(get("/api/users/999"))
            .andExpect(status().isNotFound());
    }
}

@WebMvcTest เร็วกว่าเพราะโหลดเฉพาะ controller ที่ระบุและ dependency โดยตรง ส่วน @SpringBootTest ต้องใช้สำหรับการทดสอบแบบ integration เต็มรูปแบบ

20. ทดสอบ repository ด้วย @DataJpaTest

แอนโนเทชันนี้สร้างบริบทขั้นต่ำเพื่อทดสอบเลเยอร์ persistence ด้วยฐานข้อมูลฝังตัว

UserRepositoryTest.javajava
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // Use Testcontainers
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void shouldFindByEmail() {
        // Arrange: create and persist an entity
        User user = new User("John", "john@example.com");
        entityManager.persistAndFlush(user);
        entityManager.clear(); // Clear L1 cache

        // Act
        Optional<User> found = userRepository.findByEmail("john@example.com");

        // Assert
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("John");
    }

    @Test
    void shouldFindActiveUsersWithOrders() {
        // Testing a complex query with joins
        User activeUser = entityManager.persistAndFlush(new User("Active", "active@test.com", true));
        User inactiveUser = entityManager.persistAndFlush(new User("Inactive", "inactive@test.com", false));

        entityManager.persistAndFlush(new Order(activeUser, BigDecimal.valueOf(100)));

        List<User> result = userRepository.findActiveUsersWithOrders();

        assertThat(result).hasSize(1);
        assertThat(result.get(0).getEmail()).isEqualTo("active@test.com");
    }
}

TestEntityManager มีเมธอดอำนวยความสะดวกสำหรับการทดสอบ JPA โดยเฉพาะ persistAndFlush() ที่บังคับให้เขียนลงฐานข้อมูลทันที

21. ใช้ Testcontainers สำหรับการทดสอบเชิงผสาน

Testcontainers จะรัน Docker container ที่มีฐานข้อมูลหรือบริการจริงระหว่างการทดสอบ

IntegrationTestBase.javajava
@SpringBootTest
@Testcontainers
abstract class IntegrationTestBase {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    @Container
    @ServiceConnection
    static RedisContainer redis = new RedisContainer("redis:7");
}
OrderServiceIntegrationTest.javajava
class OrderServiceIntegrationTest extends IntegrationTestBase {

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldProcessOrderWithRealDatabase() {
        // Create an order
        Order order = orderService.createOrder(
            new CreateOrderRequest(1L, List.of(
                new OrderItem(101L, 2),
                new OrderItem(102L, 1)
            ))
        );

        // Verify in database
        Order saved = orderRepository.findById(order.getId()).orElseThrow();
        assertThat(saved.getItems()).hasSize(2);
        assertThat(saved.getStatus()).isEqualTo(OrderStatus.PENDING);
    }

    @Test
    void shouldCacheOrderInRedis() {
        Order order = orderService.createOrder(createSampleOrderRequest());

        // First call: database
        Order fetched1 = orderService.findById(order.getId());

        // Second call: Redis cache
        Order fetched2 = orderService.findById(order.getId());

        assertThat(fetched1).isEqualTo(fetched2);
        // Verify cache metrics if needed
    }
}

แอนโนเทชัน @ServiceConnection ตั้งค่าการเชื่อมต่ออัตโนมัติ ทำให้ไม่ต้องใช้ @DynamicPropertySource

พร้อมที่จะพิชิตการสัมภาษณ์ Spring Boot แล้วหรือยังครับ?

ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ

การตั้งค่าและการมอนิเตอร์

22. แยกการตั้งค่าออกด้วย @ConfigurationProperties

แอนโนเทชันนี้ผูก property เข้ากับออบเจ็กต์ Java ที่มีประเภทพร้อมการตรวจสอบความถูกต้อง

MailProperties.javajava
@ConfigurationProperties(prefix = "app.mail")
@Validated
public record MailProperties(
    @NotBlank String host,
    @Min(1) @Max(65535) int port,
    @NotBlank String username,
    @NotBlank String password,
    @Valid SenderConfig sender,
    @Valid RetryConfig retry
) {
    public record SenderConfig(
        @NotBlank @Email String address,
        @NotBlank String name
    ) {}

    public record RetryConfig(
        @Min(1) int maxAttempts,
        @Min(100) long delayMs
    ) {
        public RetryConfig {
            // Default values
            if (maxAttempts == 0) maxAttempts = 3;
            if (delayMs == 0) delayMs = 1000;
        }
    }
}
yaml
# application.yml
app:
  mail:
    host: smtp.example.com
    port: 587
    username: ${MAIL_USERNAME}
    password: ${MAIL_PASSWORD}
    sender:
      address: noreply@example.com
      name: MyApp Notifications
    retry:
      max-attempts: 3
      delay-ms: 1000
MailConfig.javajava
@Configuration
@EnableConfigurationProperties(MailProperties.class)
public class MailConfig {

    @Bean
    public JavaMailSender mailSender(MailProperties props) {
        JavaMailSenderImpl sender = new JavaMailSenderImpl();
        sender.setHost(props.host());
        sender.setPort(props.port());
        sender.setUsername(props.username());
        sender.setPassword(props.password());
        return sender;
    }
}

Record ของ Java (ตั้งแต่ Java 16) เหมาะมากกับ @ConfigurationProperties เพราะแก้ไม่ได้และกระชับ

23. ใช้ profile ของ Spring สำหรับสภาพแวดล้อมต่างกัน

Profile ปรับการตั้งค่าให้สอดคล้องกับสภาพแวดล้อมการรัน

yaml
# application.yml - Default configuration
spring:
  profiles:
    active: ${SPRING_PROFILES_ACTIVE:dev}
  datasource:
    url: ${DATABASE_URL:jdbc:h2:mem:testdb}

---
# application-dev.yml
spring:
  config:
    activate:
      on-profile: dev
  datasource:
    url: jdbc:postgresql://localhost:5432/myapp_dev
  jpa:
    show-sql: true
    hibernate:
      ddl-auto: update

logging:
  level:
    com.example: DEBUG

---
# application-prod.yml
spring:
  config:
    activate:
      on-profile: prod
  datasource:
    url: ${DATABASE_URL}
    hikari:
      maximum-pool-size: 20
  jpa:
    show-sql: false
    hibernate:
      ddl-auto: validate

logging:
  level:
    com.example: INFO
DevDataInitializer.javajava
@Component
@Profile("dev") // Only active in development
public class DevDataInitializer implements CommandLineRunner {

    private final UserRepository userRepository;

    @Override
    public void run(String... args) {
        // Create test data
        userRepository.save(new User("dev@example.com", "Dev User", "password"));
        userRepository.save(new User("test@example.com", "Test User", "password"));
    }
}
java
// Service with conditional behavior
@Service
public class NotificationService {

    @Value("${app.notifications.enabled:true}")
    private boolean notificationsEnabled;

    @Autowired(required = false) // Optional based on profile
    private EmailService emailService;

    public void sendNotification(String message) {
        if (notificationsEnabled && emailService != null) {
            emailService.send(message);
        } else {
            log.info("Notification (disabled): {}", message);
        }
    }
}

สามารถรวมหลาย profile ได้: spring.profiles.active=prod,metrics จะเปิดทั้งสองพร้อมกัน

24. ปล่อยเมตริกเฉพาะของแอปด้วย Actuator และ Micrometer

Micrometer ทำหน้าที่เป็น facade สำหรับระบบมอนิเตอร์ เมตริกที่กำหนดเองทำให้สังเกตการณ์ระบบได้ดีขึ้น

OrderMetrics.javajava
@Component
public class OrderMetrics {

    private final Counter ordersCreated;
    private final Counter ordersFailed;
    private final Timer orderProcessingTime;
    private final AtomicInteger activeOrders;

    public OrderMetrics(MeterRegistry registry) {
        // Counter for orders created by status
        this.ordersCreated = Counter.builder("orders.created")
            .description("Number of orders created")
            .tag("application", "order-service")
            .register(registry);

        this.ordersFailed = Counter.builder("orders.failed")
            .description("Number of failed orders")
            .register(registry);

        // Timer to measure processing time
        this.orderProcessingTime = Timer.builder("orders.processing.time")
            .description("Order processing time")
            .publishPercentiles(0.5, 0.95, 0.99)
            .register(registry);

        // Gauge for active orders count
        this.activeOrders = new AtomicInteger(0);
        Gauge.builder("orders.active", activeOrders, AtomicInteger::get)
            .description("Number of orders being processed")
            .register(registry);
    }

    public void recordOrderCreated() {
        ordersCreated.increment();
    }

    public void recordOrderFailed() {
        ordersFailed.increment();
    }

    public Timer.Sample startProcessing() {
        activeOrders.incrementAndGet();
        return Timer.start();
    }

    public void stopProcessing(Timer.Sample sample) {
        sample.stop(orderProcessingTime);
        activeOrders.decrementAndGet();
    }
}
OrderService.javajava
@Service
public class OrderService {

    private final OrderMetrics metrics;

    public Order processOrder(CreateOrderRequest request) {
        Timer.Sample sample = metrics.startProcessing();
        try {
            Order order = createOrder(request);
            metrics.recordOrderCreated();
            return order;
        } catch (Exception e) {
            metrics.recordOrderFailed();
            throw e;
        } finally {
            metrics.stopProcessing(sample);
        }
    }
}

เมตริกเหล่านี้เปิดให้ Prometheus ที่ /actuator/prometheus หรือดึงเป็น JSON ที่ /actuator/metrics

25. สร้าง HealthIndicator เฉพาะของระบบ

Health indicator คอยติดตามสถานะของส่วนประกอบที่สำคัญ

ExternalApiHealthIndicator.javajava
@Component
public class ExternalApiHealthIndicator implements HealthIndicator {

    private final RestClient restClient;
    private final ExternalApiProperties properties;

    @Override
    public Health health() {
        try {
            long startTime = System.currentTimeMillis();

            ResponseEntity<Void> response = restClient.get()
                .uri(properties.getHealthEndpoint())
                .retrieve()
                .toBodilessEntity();

            long responseTime = System.currentTimeMillis() - startTime;

            if (response.getStatusCode().is2xxSuccessful()) {
                return Health.up()
                    .withDetail("responseTime", responseTime + "ms")
                    .withDetail("endpoint", properties.getHealthEndpoint())
                    .build();
            } else {
                return Health.down()
                    .withDetail("status", response.getStatusCode().value())
                    .build();
            }
        } catch (Exception e) {
            return Health.down()
                .withException(e)
                .withDetail("endpoint", properties.getHealthEndpoint())
                .build();
        }
    }
}
DatabaseHealthContributor.java - Composite healthjava
@Component
public class DatabaseHealthContributor implements CompositeHealthContributor {

    private final Map<String, HealthIndicator> indicators;

    public DatabaseHealthContributor(
        DataSource primaryDataSource,
        @Qualifier("replicaDataSource") DataSource replicaDataSource
    ) {
        this.indicators = Map.of(
            "primary", new DataSourceHealthIndicator(primaryDataSource),
            "replica", new DataSourceHealthIndicator(replicaDataSource)
        );
    }

    @Override
    public HealthContributor getContributor(String name) {
        return indicators.get(name);
    }

    @Override
    public Iterator<NamedContributor<HealthContributor>> iterator() {
        return indicators.entrySet().stream()
            .map(e -> NamedContributor.of(e.getKey(), e.getValue()))
            .iterator();
    }
}

Endpoint /actuator/health รวมข้อมูลของ indicator ทั้งหมด ตั้งค่า management.endpoint.health.show-details=always เพื่อแสดงรายละเอียด

หัวข้อขั้นสูง

26. ทำ caching ด้วย Spring Cache และ Redis

ลำดับชั้นนามธรรมของ Spring Cache ทำให้เชื่อมต่อแคชแบบกระจาย เช่น Redis ได้ง่าย

CacheConfig.javajava
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

        // Specific configurations per cache
        Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
            "users", config.entryTtl(Duration.ofHours(1)),
            "products", config.entryTtl(Duration.ofMinutes(30)),
            "sessions", config.entryTtl(Duration.ofMinutes(5))
        );

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(config)
            .withInitialCacheConfigurations(cacheConfigs)
            .build();
    }
}
ProductService.javajava
@Service
public class ProductService {

    // Automatic result caching
    @Cacheable(value = "products", key = "#id")
    public Product findById(Long id) {
        log.info("Fetching product {} from database", id);
        return productRepository.findById(id)
            .orElseThrow(() -> new ProductNotFoundException(id));
    }

    // Cache update on modification
    @CachePut(value = "products", key = "#product.id")
    public Product update(Product product) {
        return productRepository.save(product);
    }

    // Cache invalidation
    @CacheEvict(value = "products", key = "#id")
    public void delete(Long id) {
        productRepository.deleteById(id);
    }

    // Clear entire cache
    @CacheEvict(value = "products", allEntries = true)
    public void clearProductCache() {
        log.info("Product cache cleared");
    }

    // Composite key for searches
    @Cacheable(value = "productSearch", key = "#category + '-' + #minPrice + '-' + #maxPrice")
    public List<Product> search(String category, BigDecimal minPrice, BigDecimal maxPrice) {
        return productRepository.findByCategoryAndPriceBetween(category, minPrice, maxPrice);
    }
}

แอนโนเทชัน cache เป็นแบบโปร่งใส โค้ดทางธุรกิจไม่เปลี่ยน และจัดการแคชด้วยรูปแบบ declarative

27. สร้างงานตามตารางเวลาด้วย @Scheduled

Spring มีหลายวิธีในการตั้งเวลางานที่รันซ้ำ

SchedulerConfig.javajava
@Configuration
@EnableScheduling
public class SchedulerConfig {

    @Bean
    public TaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(5);
        scheduler.setThreadNamePrefix("scheduled-");
        scheduler.setErrorHandler(t -> log.error("Scheduled task error", t));
        return scheduler;
    }
}
ScheduledTasks.javajava
@Component
public class ScheduledTasks {

    private static final Logger log = LoggerFactory.getLogger(ScheduledTasks.class);

    // Execute every 5 minutes
    @Scheduled(fixedRate = 5 * 60 * 1000)
    public void syncExternalData() {
        log.info("Starting external data sync");
        // Sync with external system
    }

    // Execute 10 seconds after the previous one ends
    @Scheduled(fixedDelay = 10000, initialDelay = 5000)
    public void processQueue() {
        // Queue processing with delay between executions
    }

    // Cron expression: every day at 2 AM
    @Scheduled(cron = "0 0 2 * * *", zone = "Europe/Paris")
    public void dailyCleanup() {
        log.info("Running daily cleanup");
        cleanupService.removeExpiredSessions();
        cleanupService.archiveOldData();
    }

    // Configurable cron via properties
    @Scheduled(cron = "${app.reports.cron:0 0 6 * * MON}")
    public void generateWeeklyReport() {
        reportService.generateAndSend();
    }
}
ConditionalScheduling.java - Conditional activationjava
@Component
@ConditionalOnProperty(name = "app.scheduling.enabled", havingValue = "true")
public class ConditionalScheduledTasks {

    @Scheduled(fixedRate = 60000)
    public void monitorSystem() {
        // Monitoring active only if configured
    }
}

งานตามกำหนดทำงานบน thread pool แยกต่างหาก เพื่อไม่ให้ขัดขวางการประมวลผลคำขอ

28. จัดการอีเวนต์ด้วย ApplicationEvent

ระบบอีเวนต์ช่วยให้ส่วนประกอบของแอปถูกเชื่อมโยงแบบหลวม

UserRegisteredEvent.javajava
public class UserRegisteredEvent extends ApplicationEvent {

    private final User user;
    private final Instant registeredAt;

    public UserRegisteredEvent(Object source, User user) {
        super(source);
        this.user = user;
        this.registeredAt = Instant.now();
    }

    public User getUser() { return user; }
    public Instant getRegisteredAt() { return registeredAt; }
}
UserService.java - Event publishingjava
@Service
public class UserService {

    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public User registerUser(CreateUserRequest request) {
        User user = new User(request.email(), request.name());
        user = userRepository.save(user);

        // Publish event after transaction
        eventPublisher.publishEvent(new UserRegisteredEvent(this, user));

        return user;
    }
}
UserEventListeners.java - Event listenersjava
@Component
public class UserEventListeners {

    private static final Logger log = LoggerFactory.getLogger(UserEventListeners.class);

    // Synchronous listener
    @EventListener
    public void handleUserRegistered(UserRegisteredEvent event) {
        log.info("User registered: {}", event.getUser().getEmail());
    }

    // Asynchronous listener to avoid blocking the main thread
    @Async
    @EventListener
    public void sendWelcomeEmail(UserRegisteredEvent event) {
        emailService.sendWelcomeEmail(event.getUser());
    }

    // Transactional listener: executes after commit
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void notifyExternalSystem(UserRegisteredEvent event) {
        // External notification only if transaction succeeds
        externalApiClient.notifyNewUser(event.getUser().getId());
    }

    // Conditional listener with SpEL
    @EventListener(condition = "#event.user.role == 'PREMIUM'")
    public void handlePremiumUserRegistered(UserRegisteredEvent event) {
        premiumService.initializePremiumFeatures(event.getUser());
    }
}

อีเวนต์แบบ transactional (@TransactionalEventListener) รักษาความสอดคล้องระหว่างฐานข้อมูลกับผลข้างเคียง

29. ใช้ HTTP client ด้วย RestClient (Spring Boot 3+)

RestClient คือ API ที่ทันสมัยและไหลลื่นสำหรับการเรียก HTTP แบบซิงโครนัส แทนที่ RestTemplate

ExternalApiClient.javajava
@Component
public class ExternalApiClient {

    private final RestClient restClient;

    public ExternalApiClient(RestClient.Builder builder, ExternalApiProperties props) {
        this.restClient = builder
            .baseUrl(props.getBaseUrl())
            .defaultHeader("Authorization", "Bearer " + props.getApiKey())
            .defaultHeader("Accept", "application/json")
            .requestInterceptor((request, body, execution) -> {
                log.debug("Request: {} {}", request.getMethod(), request.getURI());
                return execution.execute(request, body);
            })
            .build();
    }

    public User fetchUser(Long id) {
        return restClient.get()
            .uri("/users/{id}", id)
            .retrieve()
            .body(User.class);
    }

    public List<Product> searchProducts(String query, int page) {
        return restClient.get()
            .uri(uriBuilder -> uriBuilder
                .path("/products/search")
                .queryParam("q", query)
                .queryParam("page", page)
                .build())
            .retrieve()
            .body(new ParameterizedTypeReference<List<Product>>() {});
    }

    public Order createOrder(CreateOrderRequest request) {
        return restClient.post()
            .uri("/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .body(request)
            .retrieve()
            .onStatus(HttpStatusCode::is4xxClientError, (req, res) -> {
                throw new OrderValidationException("Invalid order: " + res.getStatusCode());
            })
            .body(Order.class);
    }

    // Error handling with ResponseEntity
    public Optional<User> findUser(Long id) {
        ResponseEntity<User> response = restClient.get()
            .uri("/users/{id}", id)
            .retrieve()
            .toEntity(User.class);

        return response.getStatusCode().is2xxSuccessful()
            ? Optional.ofNullable(response.getBody())
            : Optional.empty();
    }
}

RestClient มี API คล้าย WebClient แต่ใช้สำหรับการเรียกแบบ blocking เหมาะกับแอปที่ไม่ใช้การเขียนโปรแกรมเชิงปฏิกิริยา

30. จัดการ migration ฐานข้อมูลด้วย Flyway

Flyway ทำให้การ migrate schema เป็นอัตโนมัติพร้อมระบบเวอร์ชันและสามารถทำซ้ำได้

properties
# application.properties
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
spring.flyway.baseline-on-migrate=true
spring.flyway.validate-on-migrate=true
sql
-- db/migration/V1__create_users_table.sql
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    name VARCHAR(255) NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_email ON users(email);
sql
-- db/migration/V2__add_user_status.sql
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'ACTIVE';

CREATE INDEX idx_users_status ON users(status);
sql
-- db/migration/V3__create_orders_table.sql
CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    total_amount DECIMAL(10, 2) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
FlywayConfig.java - Advanced configurationjava
@Configuration
public class FlywayConfig {

    @Bean
    public FlywayMigrationStrategy migrationStrategy() {
        return flyway -> {
            // Validation before migration
            flyway.validate();
            // Execute migrations
            flyway.migrate();
        };
    }

    // Callback for post-migration actions
    @Bean
    public Callback flywayCallback() {
        return new BaseCallback() {
            @Override
            public void handle(Event event, Context context) {
                if (event == Event.AFTER_MIGRATE) {
                    log.info("Migrations completed successfully");
                }
            }
        };
    }
}

ไฟล์ migration แต่ละไฟล์จะรันเพียงครั้งเดียวและตรวจ checksum เพื่อตรวจจับการแก้ไขโดยบังเอิญ

เริ่มฝึกซ้อมเลย!

ทดสอบความรู้ของคุณด้วยตัวจำลองสัมภาษณ์และแบบทดสอบเทคนิคครับ

บทสรุป

30 คำถามนี้ครอบคลุมแก่นของ Spring Boot ที่ใช้ประเมินในการสัมภาษณ์เชิงเทคนิค การเข้าใจแนวคิดเหล่านี้แสดงให้เห็นถึงความเชี่ยวชาญในเฟรมเวิร์กและแนวทางที่ดีในการพัฒนา Java

เช็กลิสต์เตรียมตัว:

  • ✅ เข้าใจกลไก auto-configuration และเงื่อนไขที่เกี่ยวข้อง
  • ✅ ชำนาญ Spring Data JPA: query, ธุรกรรม, ปัญหา N+1
  • ✅ ตั้งค่า Spring Security ด้วย JWT และความปลอดภัยระดับเมธอดได้
  • ✅ รู้จักแอนโนเทชันทดสอบต่าง ๆ และกรณีการใช้งาน
  • ✅ ใช้ profile และ @ConfigurationProperties เพื่อแยกการตั้งค่า
  • ✅ ทำ caching, scheduling และจัดการอีเวนต์
  • ✅ เผยแพร่เมตริกและ health indicator ที่กำหนดเอง
  • ✅ ใช้ RestClient สำหรับการเรียก HTTP สมัยใหม่

การฝึกฝนสม่ำเสมอบนโปรเจกต์จริงยังคงเป็นวิธีที่ดีที่สุดในการตอกย้ำความรู้และตอบคำถามทางเทคนิคได้อย่างมั่นใจ

แท็ก

#spring boot
#java
#interview
#backend
#java interview

แชร์

บทความที่เกี่ยวข้อง