Spring Boot 면접 질문 30선: 자바 개발자를 위한 완벽 가이드

오토 컨피그레이션, 스타터, Spring Data JPA, 보안, 테스트를 망라한 30문항으로 Spring Boot 면접을 준비하십시오.

자바 개발자를 위한 Spring Boot 면접 질문 30선

Spring Boot는 엔터프라이즈 Java 개발의 표준 프레임워크로 자리 잡았습니다. 기술 면접에서는 내부 동작 원리, 모범 사례, Spring 생태계에 대한 이해도를 평가합니다. 본 가이드는 기초 개념부터 심화 주제까지 가장 자주 출제되는 30문항을 정리합니다.

준비 팁

면접관은 각 기능 뒤에 있는 "왜"를 이해하는 지원자를 높이 평가합니다. 문법뿐 아니라 Spring Boot가 어떤 문제를 해결하는지를 함께 설명하십시오.

Spring Boot 기본

1. Spring과 Spring Boot의 차이는 무엇입니까?

Spring은 의존성 주입, 트랜잭션 관리, 다양한 기술과의 통합을 제공하는 모듈식 프레임워크입니다. Spring Boot는 그 위에 자리한 추상화 계층으로, 설정과 애플리케이션 시작을 단순화합니다.

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는 오토 컨피그레이션, 의존성 스타터, 임베디드 서버, Actuator를 통한 운영 메트릭을 제공합니다. 목표는 아이디어부터 작동하는 코드까지 몇 분 만에 도달하는 것입니다.

2. 오토 컨피그레이션은 어떻게 동작합니까?

오토 컨피그레이션은 클래스패스를 분석하여 필요한 빈을 자동으로 구성합니다. 핵심은 @SpringBootApplication에 포함된 @EnableAutoConfiguration과 오토 컨피그레이션 클래스에 정의된 조건들입니다.

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*)을 평가해 어떤 빈을 만들지 결정합니다. 이 방식은 충돌을 방지하고 기본 설정을 손쉽게 재정의하도록 합니다.

3. 스타터란 무엇이며 어떻게 만듭니까?

스타터는 특정 기능에 필요한 의존성과 설정을 묶은 Maven/Gradle 모듈입니다. 예를 들어 spring-boot-starter-web은 Spring MVC, Jackson, 임베디드 Tomcat, 검증 기능을 포함합니다.

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>

사용자 정의 스타터를 만들려면 오토 컨피그레이션 클래스를 정의하고 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports에 등록합니다.

4. application.properties와 application.yml

둘 다 설정을 외부화하는 형식입니다. YAML은 중첩 구조에 가독성이 좋고, 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

프로파일은 환경별로 설정을 전환합니다: 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);
    }
}

해당 클래스를 패키지의 루트에 두는 것이 매우 중요합니다: @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);
}

대부분의 경우 JPA 애플리케이션 개발에 필요한 기능을 모두 갖춘 JpaRepository가 권장됩니다.

7. @Query로 사용자 정의 쿼리를 작성하는 방법

@Query 어노테이션은 파생 메서드만으로 부족할 때 JPQL이나 네이티브 SQL을 작성할 수 있게 합니다.

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);
}

JPQL 쿼리는 데이터베이스 간 이식성이 좋고, 네이티브 쿼리는 DBMS 고유 기능에 접근할 수 있습니다.

8. @Transactional로 트랜잭션 관리하기

이 어노테이션은 메서드나 클래스의 트랜잭션 경계를 선언합니다. Spring이 커밋, 롤백, 전파를 자동으로 처리합니다.

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));
    }
}

전파 수준(REQUIRED, REQUIRES_NEW, NESTED 등)은 트랜잭션 메서드 호출이 중첩될 때 어떻게 결합되는지를 결정합니다.

흔한 함정

@Transactional은 Spring 프록시를 거치는 호출에서만 동작합니다. 같은 클래스 내부 호출은 프록시를 우회하여 어노테이션이 무시됩니다.

9. Lazy/Eager 로딩 관계는 어떻게 다루나요?

관계 로딩 방식은 성능에 직접적인 영향을 줍니다. Lazy 로딩은 접근 시까지 로딩을 미루고, Eager 로딩은 즉시 로딩합니다.

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 문제는 컬렉션의 요소마다 쿼리가 발생할 때 생기며, JOIN FETCH나 프로젝션으로 해결합니다.

10. N+1 문제란 무엇이고 어떻게 해결합니까?

초기 쿼리(1) 후 각 엔티티의 관계를 가져오기 위해 N개의 추가 쿼리가 발생하는 현상입니다.

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;
}

@EntityGraphJOIN FETCH를 사용하면 N+1 쿼리를 조인 한 건으로 줄여 성능을 크게 개선할 수 있습니다.

Spring Boot 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

REST API와 웹

11. 검증을 갖춘 REST 컨트롤러 만들기

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
) {}

검증 메시지는 국제화를 위해 메시지 파일로 분리할 수 있습니다.

12. @ControllerAdvice로 예외를 전역 처리하기

예외 처리기를 중앙화하면 오류 응답을 표준화하고 코드 중복을 막을 수 있습니다.

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 요청의 다른 부분에서 데이터를 추출합니다.

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은 특정 리소스를 식별하고, @RequestParam은 결과를 필터링·페이지화하며, @RequestBody는 복잡한 데이터를 전달합니다.

14. 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. Spring Boot에서 CORS는 어떻게 설정합니까?

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);
    }
}

운영 환경에서는 허용 출처를 제한하고 HTTPS를 사용하십시오. *allowCredentials(true)를 함께 쓰는 것은 금지됩니다.

Spring Security

16. JWT 기반으로 Spring Security 구성하기

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);
    }
}

이 구성은 모든 요청이 Authorization 헤더에 유효한 JWT 토큰을 포함해야 하는 stateless API를 만듭니다.

17. @PreAuthorize와 @PostAuthorize로 메서드 보호

메서드 레벨 보안은 역할, 권한, 데이터 기반의 세밀한 제어를 제공합니다.

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. 엔드포인트를 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;
    }
}

JWT를 사용하는 stateless REST API에서는 악용할 세션 쿠키가 없어 CSRF를 비활성화할 수 있습니다. 전통적인 세션 기반 애플리케이션에서는 필수입니다.

Stateless API와 세션 기반 애플리케이션

JWT API는 토큰을 매 요청에 명시적으로 포함해야 하므로 자연스럽게 CSRF에 강합니다. 세션 쿠키를 사용하는 애플리케이션은 능동적인 CSRF 보호가 필요합니다.

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는 지정한 컨트롤러와 직접 의존성만 로드해 빠르고, 통합 테스트가 필요할 때는 @SpringBootTest를 사용합니다.

20. @DataJpaTest로 리포지토리 테스트하기

이 어노테이션은 임베디드 DB를 사용해 영속성 계층을 테스트하는 최소 컨텍스트를 구성합니다.

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()는 즉시 DB에 기록되도록 보장합니다.

21. 통합 테스트에서 Testcontainers 사용하기

Testcontainers는 테스트 동안 실제 DB나 서비스의 도커 컨테이너를 띄웁니다.

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로 설정 외부화하기

이 어노테이션은 properties를 검증을 갖춘 타입 있는 자바 객체에 매핑합니다.

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 16 이후)는 불변이고 간결해 @ConfigurationProperties에 잘 맞습니다.

23. 환경별 Spring 프로파일 사용하기

프로파일은 실행 환경에 맞게 설정을 조정할 수 있게 합니다.

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);
        }
    }
}

프로파일은 결합 가능합니다: spring.profiles.active=prod,metrics은 두 프로파일을 동시에 활성화합니다.

24. Actuator와 Micrometer로 사용자 정의 메트릭 노출하기

Micrometer는 모니터링 시스템을 위한 파사드를 제공합니다. 사용자 정의 메트릭은 관측성을 향상시킵니다.

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 API는 /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();
    }
}

/actuator/health는 모든 Indicator를 집계합니다. management.endpoint.health.show-details=always 설정으로 세부정보를 표시할 수 있습니다.

고급 주제

26. 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);
    }
}

캐시 어노테이션은 투명하게 작동하며, 비즈니스 코드는 그대로 두고 캐시는 선언적으로 관리됩니다.

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
    }
}

예약 작업은 별도 스레드 풀에서 실행되어 요청 처리에 영향을 주지 않습니다.

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());
    }
}

트랜잭션 이벤트(@TransactionalEventListener)는 데이터베이스와 부수 효과 사이의 일관성을 보장합니다.

29. RestClient(Spring Boot 3+)로 HTTP 클라이언트 구현하기

RestClient는 동기 HTTP 호출을 위한 현대적이고 유려한 API로, 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();
    }
}

RestClientWebClient와 비슷한 API를 제공하지만 블로킹 호출에 사용되며, 리액티브 프로그래밍을 사용하지 않는 애플리케이션에 적합합니다.

30. Flyway로 데이터베이스 마이그레이션 관리하기

Flyway는 스키마 마이그레이션을 버전 관리하면서 자동화하고 재현 가능하게 만듭니다.

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");
                }
            }
        };
    }
}

각 마이그레이션 파일은 한 번만 실행되며 체크섬이 검증되어 의도치 않은 수정이 감지됩니다.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

결론

이 30문항은 기술 면접에서 평가되는 Spring Boot의 핵심 영역을 포괄합니다. 이러한 개념을 숙달하면 프레임워크와 자바 개발 모범 사례에 대한 깊이 있는 이해를 보여줄 수 있습니다.

준비 체크리스트:

  • ✅ 오토 컨피그레이션 메커니즘과 조건 이해하기
  • ✅ Spring Data JPA 마스터: 쿼리, 트랜잭션, N+1 문제
  • ✅ JWT와 메서드 보안으로 Spring Security 구성하기
  • ✅ 다양한 테스트 어노테이션과 사용 사례 숙지하기
  • ✅ 프로파일과 @ConfigurationProperties로 설정 관리
  • ✅ 캐싱, 스케줄링, 이벤트 구현
  • ✅ 사용자 정의 메트릭과 Health Indicator 노출
  • ✅ RestClient로 현대적인 HTTP 호출 다루기

실제 프로젝트에서 꾸준히 연습하는 것이 이 지식을 굳건히 하고, 기술적인 질문에 자신 있게 답하는 가장 좋은 방법입니다.

태그

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

공유

관련 기사