Spring Boot 어노테이션 시리즈 #1: 기본 핵심 어노테이션 완벽 가이드
Java 개발 생태계에서 Spring Boot는 이제 선택이 아닌 필수가 되었습니다. 최근 JRebel 조사에 따르면 80% 이상의 Java 개발자들이 Spring Boot를 사용하고 있으며, 그 핵심에는 바로 어노테이션(Annotation)이 있습니다. 어노테이션은 복잡했던 XML 설정을 대체하고, 코드를 더욱 간결하고 읽기 쉽게 만들어주는 Spring Boot의 핵심 기능입니다.
이번 글은 Spring Boot 어노테이션 시리즈의 첫 번째로, 실무에서 가장 빈번하게 사용되는 기본 어노테이션들을 체계적으로 정리해보겠습니다. 어노테이션을 제대로 이해하지 못하면 Spring Boot의 진정한 힘을 발휘할 수 없기 때문입니다.
Spring Boot 어노테이션이란?
어노테이션은 자바 프로그래밍에서 코드에 메타데이터를 추가하는 방법입니다. 사전적 의미로는 '주석'이지만, 실제로는 프로그램 코드의 일부가 아닌 프로그램에 관한 데이터를 제공하고, 코드에 정보를 추가하는 정형화된 방법입니다.
Spring Boot에서 어노테이션은 다음과 같은 혁신적인 변화를 가져왔습니다:
설정의 혁명적 변화
- XML 지옥 탈출: 수백 줄의 XML 설정 파일이 몇 개의 어노테이션으로 대체
- 자동 구성: 개발자가 직접 설정하지 않아도 Spring이 자동으로 필요한 빈을 생성
- Convention over Configuration: 관례를 통한 설정으로 생산성 향상
실무에서의 실질적 이점
- 개발 시간 단축: 보일러플레이트 코드 최소화
- 유지보수성 향상: 설정과 코드가 한 곳에 위치
- 가독성 개선: 코드를 읽는 것만으로 기능 파악 가능
핵심 Spring Boot 어노테이션 TOP 8
1. @SpringBootApplication - 애플리케이션의 심장
@SpringBootApplication
public class EcommerceApplication {
public static void main(String[] args) {
SpringApplication.run(EcommerceApplication.class, args);
}
}
@SpringBootApplication은 Spring Boot 애플리케이션의 진입점이자 가장 중요한 어노테이션입니다. 이 하나의 어노테이션이 실제로는 세 가지 핵심 어노테이션을 결합한 메타 어노테이션입니다:
내부 구성 요소:
- @Configuration: 현재 클래스를 Spring의 설정 파일로 지정
- @EnableAutoConfiguration: Spring Boot의 자동 설정 마법을 활성화
- @ComponentScan: 현재 패키지와 하위 패키지에서 Spring 컴포넌트를 자동 스캔
실무 팁:
// 특정 패키지 스캔 제외
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class Application { }
// 커스텀 스캔 범위 지정
@SpringBootApplication(scanBasePackages = {"com.example.core", "com.example.web"})
public class Application { }
2. @RestController - REST API의 핵심
@RestController
@RequestMapping("/api/v1/users")
@CrossOrigin(origins = "https://frontend.example.com")
public class UserController {
private final UserService userService;
// 생성자 주입 (권장)
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public ResponseEntity<list> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
return ResponseEntity.ok(userService.getAllUsers(page, size));
}
@PostMapping
public ResponseEntity createUser(@Valid @RequestBody CreateUserRequest request) {
UserDto created = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@GetMapping("/{id}")
public ResponseEntity getUser(@PathVariable Long id) {
return userService.getUserById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
</list
@RestController는 @Controller와 @ResponseBody를 결합한 특화된 어노테이션입니다. RESTful 웹 서비스 개발의 핵심으로, 모든 메서드의 반환값이 자동으로 JSON/XML 형태로 HTTP 응답 본문에 직렬화됩니다.
3. @Service - 비즈니스 로직의 집약체
@Service
@Transactional(readOnly = true) // 기본적으로 읽기 전용
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
public UserService(UserRepository userRepository, EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
public Optional<UserDto> getUserById(Long id) {
return userRepository.findById(id)
.map(this::convertToDto);
}
@Transactional // 쓰기 작업은 별도 트랜잭션
public UserDto createUser(CreateUserRequest request) {
// 중복 이메일 검증
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateEmailException("이미 존재하는 이메일입니다: " + request.getEmail());
}
User user = User.builder()
.name(request.getName())
.email(request.getEmail())
.build();
User savedUser = userRepository.save(user);
// 비동기 환영 이메일 발송
emailService.sendWelcomeEmailAsync(savedUser.getEmail());
return convertToDto(savedUser);
}
private UserDto convertToDto(User user) {
return UserDto.builder()
.id(user.getId())
.name(user.getName())
.email(user.getEmail())
.createdAt(user.getCreatedAt())
.build();
}
}
@Service는 비즈니스 로직을 처리하는 서비스 레이어의 핵심입니다. @Component의 특수화된 형태로, 비즈니스 규칙과 도메인 로직을 캡슐화합니다.
4. @Repository - 데이터 접근의 관문
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u WHERE u.email = :email AND u.active = true")
Optional<User> findActiveUserByEmail(@Param("email") String email);
@Query("SELECT u FROM User u WHERE u.createdAt >= :startDate ORDER BY u.createdAt DESC")
List<User> findRecentUsers(@Param("startDate") LocalDateTime startDate);
boolean existsByEmail(String email);
@Modifying
@Query("UPDATE User u SET u.lastLoginAt = :loginTime WHERE u.id = :userId")
void updateLastLoginTime(@Param("userId") Long userId, @Param("loginTime") LocalDateTime loginTime);
}
// 커스텀 Repository 구현체
@Repository
public class CustomUserRepositoryImpl implements CustomUserRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
public List<User> findUsersByComplexCriteria(UserSearchCriteria criteria) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<User> query = cb.createQuery(User.class);
Root<User> root = query.from(User.class);
List<Predicate> predicates = new ArrayList<>();
if (criteria.getName() != null) {
predicates.add(cb.like(cb.lower(root.get("name")),
"%" + criteria.getName().toLowerCase() + "%"));
}
if (criteria.getEmail() != null) {
predicates.add(cb.equal(root.get("email"), criteria.getEmail()));
}
query.where(predicates.toArray(new Predicate[0]));
return entityManager.createQuery(query).getResultList();
}
}
@Repository는 데이터 접근 계층의 핵심으로, 데이터베이스 예외를 Spring의 DataAccessException으로 변환해주는 추가 기능을 제공합니다.
5. @Autowired와 의존성 주입 전략
// 1. 생성자 주입 (가장 권장되는 방식)
@Service
public class OrderService {
private final UserService userService;
private final PaymentService paymentService;
private final InventoryService inventoryService;
// @Autowired 어노테이션 생략 가능 (Spring 4.3+)
public OrderService(UserService userService,
PaymentService paymentService,
InventoryService inventoryService) {
this.userService = userService;
this.paymentService = paymentService;
this.inventoryService = inventoryService;
}
}
// 2. 옵셔널 의존성 처리
@Service
public class NotificationService {
private final EmailService emailService;
private final SmsService smsService;
public NotificationService(EmailService emailService,
@Autowired(required = false) SmsService smsService) {
this.emailService = emailService;
this.smsService = smsService; // null일 수 있음
}
public void sendNotification(String message, String recipient) {
emailService.send(message, recipient);
if (smsService != null) {
smsService.send(message, recipient);
}
}
}
// 3. 컬렉션 주입
@Service
public class PaymentProcessorService {
private final List<PaymentProcessor> processors;
public PaymentProcessorService(List<PaymentProcessor> processors) {
this.processors = processors;
}
public PaymentResult processPayment(PaymentRequest request) {
return processors.stream()
.filter(processor -> processor.supports(request.getType()))
.findFirst()
.map(processor -> processor.process(request))
.orElseThrow(() -> new UnsupportedPaymentTypeException(request.getType()));
}
}
6. @Configuration과 빈 설정
@Configuration
@EnableConfigurationProperties({AppProperties.class, DatabaseProperties.class})
public class ApplicationConfig {
@Bean
@Primary
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory());
// 타임아웃 설정
HttpComponentsClientHttpRequestFactory factory =
(HttpComponentsClientHttpRequestFactory) restTemplate.getRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(10000);
return restTemplate;
}
@Bean
@ConditionalOnProperty(name = "app.cache.enabled", havingValue = "true")
public CacheManager cacheManager() {
ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager();
cacheManager.setCacheNames(Arrays.asList("users", "products", "orders"));
return cacheManager;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
@Bean
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public AuditLogger auditLogger() {
return new AuditLogger();
}
}
7. HTTP 매핑 어노테이션 완전 활용
@RestController
@RequestMapping("/api/v1/products")
@Validated
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping
public ResponseEntity<Page<ProductDto>> getProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String category,
@RequestParam(required = false) BigDecimal minPrice,
@RequestParam(required = false) BigDecimal maxPrice,
@RequestParam(defaultValue = "name") String sortBy,
@RequestParam(defaultValue = "ASC") String sortDir) {
ProductSearchCriteria criteria = ProductSearchCriteria.builder()
.category(category)
.minPrice(minPrice)
.maxPrice(maxPrice)
.build();
Pageable pageable = PageRequest.of(page, size,
Sort.Direction.fromString(sortDir), sortBy);
Page<ProductDto> products = productService.searchProducts(criteria, pageable);
return ResponseEntity.ok(products);
}
@PostMapping
public ResponseEntity<ProductDto> createProduct(@Valid @RequestBody CreateProductRequest request) {
ProductDto created = productService.createProduct(request);
URI location = URI.create("/api/v1/products/" + created.getId());
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<ProductDto> updateProduct(
@PathVariable Long id,
@Valid @RequestBody UpdateProductRequest request) {
ProductDto updated = productService.updateProduct(id, request);
return ResponseEntity.ok(updated);
}
@PatchMapping("/{id}/status")
public ResponseEntity<Void> updateProductStatus(
@PathVariable Long id,
@RequestParam ProductStatus status) {
productService.updateProductStatus(id, status);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
productService.deleteProduct(id);
return ResponseEntity.noContent().build();
}
}
8. @Component와 스테레오타입 어노테이션
// 범용 컴포넌트
@Component
public class FileUploadHandler {
@Value("${app.upload.directory}")
private String uploadDirectory;
@Value("${app.upload.max-size:10485760}") // 기본값 10MB
private long maxFileSize;
public UploadResult handleFileUpload(MultipartFile file) {
validateFile(file);
String fileName = generateUniqueFileName(file.getOriginalFilename());
Path targetPath = Paths.get(uploadDirectory, fileName);
try {
Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);
return UploadResult.success(fileName, targetPath.toString());
} catch (IOException e) {
throw new FileUploadException("파일 업로드 실패", e);
}
}
private void validateFile(MultipartFile file) {
if (file.isEmpty()) {
throw new IllegalArgumentException("빈 파일은 업로드할 수 없습니다");
}
if (file.getSize() > maxFileSize) {
throw new IllegalArgumentException("파일 크기가 너무 큽니다: " + file.getSize());
}
}
private String generateUniqueFileName(String originalFilename) {
String extension = StringUtils.getFilenameExtension(originalFilename);
return UUID.randomUUID().toString() + "." + extension;
}
}
// 이벤트 리스너 컴포넌트
@Component
public class OrderEventListener {
private static final Logger logger = LoggerFactory.getLogger(OrderEventListener.class);
@EventListener
@Async
public void handleOrderCreated(OrderCreatedEvent event) {
logger.info("주문 생성됨: {}", event.getOrderId());
// 주문 확인 이메일 발송, 재고 업데이트 등
}
@EventListener
@Async
public void handleOrderCancelled(OrderCancelledEvent event) {
logger.info("주문 취소됨: {}", event.getOrderId());
// 환불 처리, 재고 복구 등
}
}
실무 베스트 프랙티스
1. 생성자 주입을 선호하는 이유
// ✅ 권장: 생성자 주입
@Service
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
// final 필드로 불변성 보장
// 순환 의존성 컴파일 타임 검출
// 테스트 시 목 객체 주입 용이
public UserService(UserRepository userRepository, EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
}
// ❌ 비권장: 필드 주입
@Service
public class UserService {
@Autowired
private UserRepository userRepository; // 불변성 보장 안됨
@Autowired
private EmailService emailService; // 순환 의존성 런타임 검출
}
2. 예외 처리 전략
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ErrorResponse> handleValidation(ValidationException e) {
ErrorResponse error = ErrorResponse.builder()
.code("VALIDATION_ERROR")
.message(e.getMessage())
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.badRequest().body(error);
}
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(EntityNotFoundException e) {
ErrorResponse error = ErrorResponse.builder()
.code("ENTITY_NOT_FOUND")
.message(e.getMessage())
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.notFound().build();
}
}
3. 프로파일별 설정 관리
@Configuration
@Profile("!test") // 테스트 환경 제외
public class ProductionConfig {
@Bean
@Profile("prod")
public DataSource productionDataSource() {
// 운영 DB 설정
return DataSourceBuilder.create()
.url("jdbc:postgresql://prod-db:5432/myapp")
.build();
}
@Bean
@Profile("dev")
public DataSource developmentDataSource() {
// 개발 DB 설정
return DataSourceBuilder.create()
.url("jdbc:h2:mem:testdb")
.build();
}
}
성능 최적화를 위한 팁
1. 지연 초기화 활용
@Configuration
public class PerformanceConfig {
@Bean
@Lazy // 실제 사용될 때까지 초기화 지연
public ExpensiveService expensiveService() {
return new ExpensiveService();
}
@Bean
@ConditionalOnProperty(name = "feature.ml.enabled", havingValue = "true")
public MachineLearningService mlService() {
// 기능이 활성화된 경우에만 빈 생성
return new MachineLearningService();
}
}
2. 캐시 활용
@Service
public class ProductService {
@Cacheable(value = "products", key = "#id")
public ProductDto getProduct(Long id) {
// 데이터베이스에서 조회
return productRepository.findById(id)
.map(this::convertToDto)
.orElseThrow(() -> new ProductNotFoundException(id));
}
@CacheEvict(value = "products", key = "#result.id")
public ProductDto updateProduct(Long id, UpdateProductRequest request) {
// 업데이트 후 캐시에서 제거
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
// 업데이트 로직...
return convertToDto(productRepository.save(product));
}
}
'Java & Spring > Annotation' 카테고리의 다른 글
Spring Boot 어노테이션 시리즈 #3: 검증과 보안 어노테이션 마스터하기 (0) | 2025.06.01 |
---|---|
Spring Boot 어노테이션 시리즈 #2: 고급 설정과 조건부 빈 생성 마스터하기 (0) | 2025.05.31 |