전체 목록
SpringEasy#68

@Component, @Service, @Repository, @Controller의 차이를 설명해주세요.

#Spring#어노테이션#컴포넌트#MVC
힌트

모두 @Component의 특수화이지만 계층별 역할과 부가 기능이 다릅니다.

정답 및 해설

@Component, @Service, @Repository, @Controller의 차이를 설명해주세요.

네 애노테이션 모두 Spring 컴포넌트 스캔의 대상이 되는 @Component의 특수화(specialization)입니다. 기술적으로는 모두 @Component를 메타 애노테이션으로 가지므로 Spring 컨테이너에 빈으로 등록된다는 점은 동일합니다. 하지만 각 계층의 역할과 목적을 명확히 표현하며, 일부는 계층별 부가 기능을 제공합니다.

애노테이션 계층 구조

@Component (범용 빈 등록)
    ├── @Service    (비즈니스 로직 계층)
    ├── @Repository (데이터 접근 계층)
    └── @Controller (프레젠테이션 계층)
                └── @RestController (@Controller + @ResponseBody)

모든 특수화 애노테이션은 내부적으로 @Component를 포함합니다.

// @Service의 실제 선언
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component  // ← @Component를 메타 애노테이션으로 포함
public @interface Service {
    @AliasFor(annotation = Component.class)
    String value() default "";
}

@Component

범용적인 Spring 빈 등록 애노테이션입니다. 어느 특정 계층에도 속하지 않는 일반 컴포넌트에 사용합니다.

// 범용 유틸리티 빈
@Component
public class JwtTokenProvider {
    private final String secretKey;
    private final long expirationMs;

    public JwtTokenProvider(
            @Value("${jwt.secret}") String secretKey,
            @Value("${jwt.expiration:3600000}") long expirationMs) {
        this.secretKey = secretKey;
        this.expirationMs = expirationMs;
    }

    public String generateToken(String username) {
        // JWT 토큰 생성
        return Jwts.builder()
            .setSubject(username)
            .setExpiration(new Date(System.currentTimeMillis() + expirationMs))
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
    }

    public boolean validateToken(String token) { /* ... */ }
}

// 설정 관련 컴포넌트
@Component
public class AppProperties {
    @Value("${app.name:MyApp}")
    private String appName;

    @Value("${app.version:1.0.0}")
    private String version;

    // getter들
}

@Service

비즈니스 로직을 담당하는 서비스 계층에 사용합니다. 기능적으로는 @Component와 동일하지만 계층의 의도를 명확히 합니다. 트랜잭션 경계가 되는 경우가 많습니다.

@Service
@Transactional(readOnly = true) // 클래스 레벨 기본 트랜잭션
public class OrderService {

    private final OrderRepository orderRepository;
    private final ProductService productService;
    private final PaymentService paymentService;
    private final ApplicationEventPublisher eventPublisher;

    public OrderService(OrderRepository orderRepository,
                        ProductService productService,
                        PaymentService paymentService,
                        ApplicationEventPublisher eventPublisher) {
        this.orderRepository = orderRepository;
        this.productService = productService;
        this.paymentService = paymentService;
        this.eventPublisher = eventPublisher;
    }

    @Transactional // 쓰기 작업에만 readOnly = false 오버라이드
    public Order placeOrder(OrderRequest request) {
        // 1. 재고 확인
        productService.validateStock(request.getProductId(), request.getQuantity());

        // 2. 주문 생성
        Order order = Order.from(request);
        Order saved = orderRepository.save(order);

        // 3. 결제 처리
        paymentService.process(saved);

        // 4. 이벤트 발행
        eventPublisher.publishEvent(new OrderPlacedEvent(saved));

        return saved;
    }

    public List<Order> getUserOrders(Long userId) {
        return orderRepository.findByUserId(userId);
    }
}

@Repository

데이터 접근 계층(DAO)에 사용합니다. @Component와 달리 중요한 부가 기능이 있습니다.

핵심 기능: 예외 변환 (Exception Translation)

@Repository가 붙은 빈은 PersistenceExceptionTranslationPostProcessor에 의해 데이터 접근 기술별 예외(JDBC의 SQLException, JPA의 PersistenceException 등)를 Spring의 통합 예외 계층DataAccessException으로 자동 변환합니다.

// 예외 변환이 필요한 이유
// JDBC 사용 시: java.sql.SQLException (checked)
// JPA 사용 시: javax.persistence.PersistenceException (unchecked)
// MyBatis 사용 시: org.apache.ibatis.exceptions.PersistenceException
// → 기술마다 다른 예외 → 서비스 계층이 특정 기술에 종속됨

// @Repository가 해결:
// 모든 DB 예외 → DataAccessException 하위 예외로 통일
@Repository
public class UserRepositoryImpl implements UserRepository {

    private final JdbcTemplate jdbcTemplate;

    public UserRepositoryImpl(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public User findById(Long id) {
        try {
            return jdbcTemplate.queryForObject(
                "SELECT * FROM users WHERE id = ?",
                new UserRowMapper(), id
            );
        } catch (EmptyResultDataAccessException e) {
            return null; // DataAccessException의 하위 클래스
        }
        // SQLException은 Spring이 DataAccessException으로 자동 변환
    }
}

Spring Data JPA에서의 @Repository

// Spring Data JPA: @Repository 없이도 동작하지만 관례상 명시하기도 함
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    List<User> findByAgeGreaterThan(int age);

    @Query("SELECT u FROM User u WHERE u.status = :status")
    List<User> findByStatus(@Param("status") UserStatus status);
}

// JPA 직접 구현
@Repository
public class ProductRepositoryCustomImpl implements ProductRepositoryCustom {

    @PersistenceContext
    private EntityManager em;

    public List<Product> searchByCondition(ProductSearchCondition condition) {
        return em.createQuery(
            "SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max",
            Product.class)
            .setParameter("min", condition.getMinPrice())
            .setParameter("max", condition.getMaxPrice())
            .getResultList();
    }
}

@Controller

Spring MVC의 프레젠테이션 계층에 사용합니다. DispatcherServlet이 요청을 라우팅할 때 @Controller 빈을 스캔합니다.

@Controller
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    // View 이름을 반환 (Thymeleaf, JSP 등 템플릿 엔진 사용)
    @GetMapping("/{id}")
    public String getUser(@PathVariable Long id, Model model) {
        User user = userService.findById(id);
        model.addAttribute("user", user);
        return "user/detail"; // templates/user/detail.html
    }

    // @ResponseBody와 함께 JSON 반환
    @GetMapping("/{id}/json")
    @ResponseBody
    public UserDto getUserJson(@PathVariable Long id) {
        return UserDto.from(userService.findById(id));
    }

    @PostMapping
    public String createUser(@ModelAttribute UserCreateForm form,
                              BindingResult result,
                              RedirectAttributes attrs) {
        if (result.hasErrors()) {
            return "user/create";
        }
        userService.create(form);
        attrs.addFlashAttribute("message", "회원가입 완료");
        return "redirect:/users";
    }
}

@RestController

@Controller + @ResponseBody의 조합으로, REST API 엔드포인트 구현에 특화되어 있습니다.

// @RestController = @Controller + @ResponseBody
@RestController
@RequestMapping("/api/v1/products")
public class ProductApiController {

    private final ProductService productService;

    public ProductApiController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping
    public ResponseEntity<List<ProductDto>> getProducts(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        List<ProductDto> products = productService.findAll(page, size);
        return ResponseEntity.ok(products);
    }

    @GetMapping("/{id}")
    public ResponseEntity<ProductDto> getProduct(@PathVariable Long id) {
        return productService.findById(id)
            .map(ProductDto::from)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<ProductDto> createProduct(
            @RequestBody @Valid ProductCreateRequest request) {
        ProductDto created = productService.create(request);
        URI location = URI.create("/api/v1/products/" + created.getId());
        return ResponseEntity.created(location).body(created);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

계층형 아키텍처와 애노테이션 배치

HTTP 요청
    ↓
@Controller / @RestController  (프레젠테이션 계층)
    ↓ 호출
@Service                        (비즈니스 로직 계층)
    ↓ 호출
@Repository                     (데이터 접근 계층)
    ↓ 접근
Database
// 계층별 역할 분리 예시

// 1. Controller: HTTP 요청/응답 처리, 입력 검증
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    private final OrderService orderService;

    @PostMapping
    public ResponseEntity<OrderDto> createOrder(
            @RequestBody @Valid OrderCreateRequest request,
            @AuthenticationPrincipal UserPrincipal principal) {
        OrderDto result = orderService.placeOrder(principal.getId(), request);
        return ResponseEntity.status(HttpStatus.CREATED).body(result);
    }
}

// 2. Service: 비즈니스 규칙, 트랜잭션 경계
@Service
@Transactional
public class OrderService {
    private final OrderRepository orderRepository;
    private final InventoryService inventoryService;

    public OrderDto placeOrder(Long userId, OrderCreateRequest request) {
        inventoryService.reserve(request.getItems()); // 재고 예약
        Order order = Order.create(userId, request);
        return OrderDto.from(orderRepository.save(order));
    }
}

// 3. Repository: 데이터 CRUD
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    List<Order> findByUserIdOrderByCreatedAtDesc(Long userId);
}

네 애노테이션 비교 요약

구분@Component@Service@Repository@Controller
계층범용비즈니스 로직데이터 접근프레젠테이션
빈 등록OOOO
컴포넌트 스캔OOOO
예외 변환XXO (DataAccessException)X
요청 매핑XXXO (DispatcherServlet)
View 반환XXXO (템플릿 엔진)
트랜잭션 경계-주로 여기--
사용 예유틸, 헬퍼 클래스비즈니스 서비스DAO, JPA RepositoryMVC Controller
@RestController---= @Controller + @ResponseBody