# Spring Boot 면접 질문 30선: 자바 개발자를 위한 완벽 가이드
> 오토 컨피그레이션, 스타터, Spring Data JPA, 보안, 테스트를 망라한 30문항으로 Spring Boot 면접을 준비하십시오.
- Published: 2026-02-07
- Updated: 2026-04-28
- Author: SharpSkill
- Tags: spring boot, java, interview, backend, java interview
- Reading time: 18 min
---
Spring Boot는 엔터프라이즈 Java 개발의 표준 프레임워크로 자리 잡았습니다. 기술 면접에서는 내부 동작 원리, 모범 사례, Spring 생태계에 대한 이해도를 평가합니다. 본 가이드는 기초 개념부터 심화 주제까지 가장 자주 출제되는 30문항을 정리합니다.
> **준비 팁**
>
> 면접관은 각 기능 뒤에 있는 "왜"를 이해하는 지원자를 높이 평가합니다. 문법뿐 아니라 Spring Boot가 어떤 문제를 해결하는지를 함께 설명하십시오.
## Spring Boot 기본
### 1. Spring과 Spring Boot의 차이는 무엇입니까?
Spring은 의존성 주입, 트랜잭션 관리, 다양한 기술과의 통합을 제공하는 모듈식 프레임워크입니다. Spring Boot는 그 위에 자리한 추상화 계층으로, 설정과 애플리케이션 시작을 단순화합니다.
```java
// Application.java
// 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`과 오토 컨피그레이션 클래스에 정의된 조건들입니다.
```java
// CustomAutoConfiguration.java
@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
my-company-starter
org.springframework.boot
spring-boot-starter
com.mycompany
logging-utils
```
사용자 정의 스타터를 만들려면 오토 컨피그레이션 클래스를 정의하고 `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 애플리케이션의 동작을 결정하는 세 가지 핵심 어노테이션을 결합합니다.
```java
// DemoApplication.java
// @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의 차이
이들 인터페이스는 데이터 접근 기능을 단계적으로 확장하는 계층 구조를 이룹니다.
```java
// UserRepository.java
// CrudRepository: basic CRUD operations
public interface UserCrudRepository extends CrudRepository {
// save(), findById(), findAll(), deleteById(), count(), existsById()
}
// PagingAndSortingRepository: adds pagination and sorting
public interface UserPagingRepository extends PagingAndSortingRepository {
// Inherits from CrudRepository + findAll(Sort), findAll(Pageable)
}
// JpaRepository: complete JPA features
public interface UserRepository extends JpaRepository {
// Inherits from previous + flush(), saveAndFlush(), deleteInBatch()
// Derived query methods
List findByEmailContaining(String email);
Optional findByUsernameAndActiveTrue(String username);
}
```
대부분의 경우 JPA 애플리케이션 개발에 필요한 기능을 모두 갖춘 `JpaRepository`가 권장됩니다.
### 7. @Query로 사용자 정의 쿼리를 작성하는 방법
`@Query` 어노테이션은 파생 메서드만으로 부족할 때 JPQL이나 네이티브 SQL을 작성할 수 있게 합니다.
```java
// OrderRepository.java
public interface OrderRepository extends JpaRepository {
// JPQL with named parameters
@Query("SELECT o FROM Order o WHERE o.customer.id = :customerId AND o.status = :status")
List 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 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 ids, @Param("status") OrderStatus status);
}
```
JPQL 쿼리는 데이터베이스 간 이식성이 좋고, 네이티브 쿼리는 DBMS 고유 기능에 접근할 수 있습니다.
### 8. @Transactional로 트랜잭션 관리하기
이 어노테이션은 메서드나 클래스의 트랜잭션 경계를 선언합니다. Spring이 커밋, 롤백, 전파를 자동으로 처리합니다.
```java
// PaymentService.java
@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 로딩은 즉시 로딩합니다.
```java
// User.java
@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 roles;
// Lazy: orders are only loaded on access
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List orders;
}
```
```java
// UserService.java
@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 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개의 추가 쿼리가 발생하는 현상입니다.
```java
// ArticleRepository.java
public interface ArticleRepository extends JpaRepository {
// PROBLEM: this query generates N+1 queries
List findAll();
// SOLUTION 1: JOIN FETCH in JPQL
@Query("SELECT a FROM Article a JOIN FETCH a.author JOIN FETCH a.comments")
List findAllWithDetails();
// SOLUTION 2: Entity Graph
@EntityGraph(attributePaths = {"author", "comments"})
@Query("SELECT a FROM Article a")
List findAllWithGraph();
}
```
```java
// Article.java
@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 comments;
}
```
`@EntityGraph`나 `JOIN FETCH`를 사용하면 N+1 쿼리를 조인 한 건으로 줄여 성능을 크게 개선할 수 있습니다.
## REST API와 웹
### 11. 검증을 갖춘 REST 컨트롤러 만들기
Spring Boot는 `@RestController`와 Bean Validation을 결합해 입력 검증이 자동화된 견고한 API를 제공합니다.
```java
// UserController.java
@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 listUsers(
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Max(100) int size
) {
return userService.findAll(PageRequest.of(page, size));
}
}
```
```java
// CreateUserRequest.java
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로 예외를 전역 처리하기
예외 처리기를 중앙화하면 오류 응답을 표준화하고 코드 중복을 막을 수 있습니다.
```java
// GlobalExceptionHandler.java
@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 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);
}
}
```
```java
// ErrorResponse.java
public record ErrorResponse(
String code,
String message,
Map details,
Instant timestamp
) {
public ErrorResponse(String code, String message, Map details) {
this(code, message, details, Instant.now());
}
}
```
이 방식은 모든 오류가 동일한 형식을 따르도록 해 클라이언트 처리가 단순해집니다.
### 13. @RequestParam, @PathVariable, @RequestBody의 차이
세 어노테이션은 HTTP 요청의 다른 부분에서 데이터를 추출합니다.
```java
// ProductController.java
@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 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)는 다른 출처의 요청을 제어합니다. 여러 수준에서 설정이 가능합니다.
```java
// CorsConfig.java - Global configuration
@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+는 보안 설정에 함수형 접근을 따릅니다.
```java
// SecurityConfig.java
@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();
}
}
```
```java
// JwtAuthenticationFilter.java
@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로 메서드 보호
메서드 레벨 보안은 역할, 권한, 데이터 기반의 세밀한 제어를 제공합니다.
```java
// UserService.java
@Service
@PreAuthorize("isAuthenticated()") // All methods require authentication
public class UserService {
// Only ADMINs can list all users
@PreAuthorize("hasRole('ADMIN')")
public List 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 getUserProjects() {
return projectRepository.findAll();
}
}
```
```java
// SecurityConfig.java - Activation
@Configuration
@EnableMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig {
// Additional configuration if needed
}
```
`@PreAuthorize`는 실행 전에, `@PostAuthorize`는 실행 후에 검증을 수행합니다. SpEL 표현식으로 복잡한 규칙도 가능합니다.
### 18. 엔드포인트를 CSRF 공격으로부터 보호하기
CSRF(Cross-Site Request Forgery)는 인증된 사용자의 세션을 악용합니다. 세션 기반 애플리케이션에서는 보호가 기본 활성화되어 있습니다.
```java
// SecurityConfig.java
@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();
}
}
```
```java
// CsrfController.java - Endpoint to retrieve token (SPA)
@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의 차이
두 어노테이션은 필요에 따라 테스트 컨텍스트를 다르게 구성합니다.
```java
// UserControllerIntegrationTest.java
@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 response = restTemplate.postForEntity(
"/api/users", request, User.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody().getName()).isEqualTo("John");
}
}
```
```java
// UserControllerUnitTest.java
@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를 사용해 영속성 계층을 테스트하는 최소 컨텍스트를 구성합니다.
```java
// UserRepositoryTest.java
@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 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 result = userRepository.findActiveUsersWithOrders();
assertThat(result).hasSize(1);
assertThat(result.get(0).getEmail()).isEqualTo("active@test.com");
}
}
```
`TestEntityManager`는 JPA 테스트를 위한 유틸리티를 제공하며, 특히 `persistAndFlush()`는 즉시 DB에 기록되도록 보장합니다.
### 21. 통합 테스트에서 Testcontainers 사용하기
Testcontainers는 테스트 동안 실제 DB나 서비스의 도커 컨테이너를 띄웁니다.
```java
// IntegrationTestBase.java
@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");
}
```
```java
// OrderServiceIntegrationTest.java
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`를 불필요하게 만듭니다.
## 설정과 모니터링
### 22. @ConfigurationProperties로 설정 외부화하기
이 어노테이션은 properties를 검증을 갖춘 타입 있는 자바 객체에 매핑합니다.
```java
// MailProperties.java
@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
```
```java
// MailConfig.java
@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
```
```java
// DevDataInitializer.java
@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는 모니터링 시스템을 위한 파사드를 제공합니다. 사용자 정의 메트릭은 관측성을 향상시킵니다.
```java
// OrderMetrics.java
@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();
}
}
```
```java
// OrderService.java
@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는 핵심 컴포넌트의 상태를 모니터링합니다.
```java
// ExternalApiHealthIndicator.java
@Component
public class ExternalApiHealthIndicator implements HealthIndicator {
private final RestClient restClient;
private final ExternalApiProperties properties;
@Override
public Health health() {
try {
long startTime = System.currentTimeMillis();
ResponseEntity 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();
}
}
}
```
```java
// DatabaseHealthContributor.java - Composite health
@Component
public class DatabaseHealthContributor implements CompositeHealthContributor {
private final Map 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> 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 같은 분산 캐시 통합을 단순화합니다.
```java
// CacheConfig.java
@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 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();
}
}
```
```java
// ProductService.java
@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 search(String category, BigDecimal minPrice, BigDecimal maxPrice) {
return productRepository.findByCategoryAndPriceBetween(category, minPrice, maxPrice);
}
}
```
캐시 어노테이션은 투명하게 작동하며, 비즈니스 코드는 그대로 두고 캐시는 선언적으로 관리됩니다.
### 27. @Scheduled로 예약 작업 구현하기
Spring은 반복 작업을 예약하는 여러 방식을 제공합니다.
```java
// SchedulerConfig.java
@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;
}
}
```
```java
// ScheduledTasks.java
@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();
}
}
```
```java
// ConditionalScheduling.java - Conditional activation
@Component
@ConditionalOnProperty(name = "app.scheduling.enabled", havingValue = "true")
public class ConditionalScheduledTasks {
@Scheduled(fixedRate = 60000)
public void monitorSystem() {
// Monitoring active only if configured
}
}
```
예약 작업은 별도 스레드 풀에서 실행되어 요청 처리에 영향을 주지 않습니다.
### 28. ApplicationEvent로 이벤트 처리하기
이벤트 시스템은 애플리케이션 컴포넌트를 느슨하게 결합하도록 합니다.
```java
// UserRegisteredEvent.java
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; }
}
```
```java
// UserService.java - Event publishing
@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;
}
}
```
```java
// UserEventListeners.java - Event listeners
@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`을 대체합니다.
```java
// ExternalApiClient.java
@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 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>() {});
}
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 findUser(Long id) {
ResponseEntity response = restClient.get()
.uri("/users/{id}", id)
.retrieve()
.toEntity(User.class);
return response.getStatusCode().is2xxSuccessful()
? Optional.ofNullable(response.getBody())
: Optional.empty();
}
}
```
`RestClient`는 `WebClient`와 비슷한 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);
```
```java
// FlywayConfig.java - Advanced configuration
@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 호출 다루기
실제 프로젝트에서 꾸준히 연습하는 것이 이 지식을 굳건히 하고, 기술적인 질문에 자신 있게 답하는 가장 좋은 방법입니다.
---
Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack.
HTML version of this page: https://sharpskill.dev/ko/blog/spring-boot/30-spring-boot-interview-questions