기본 콘텐츠로 건너뛰기

Spring 스테레오타입 애너테이션 차이: Component, Controller, Service, Repository를 언제 써야 할까

Spring 스테레오타입 애너테이션 차이: Component, Controller, Service, Repository를 언제 써야 할까

빠른 답

  • @Service, @Controller, @Repository는 모두 @Component 기반이지만 계층 의미와 일부 런타임 동작이 다르다.
  • Spring 6 이상, 현재 Spring Framework 7 기준에서는 @RequestMapping만 붙은 @Component가 MVC 핸들러로 등록되지 않는다.
  • @Repository는 데이터 접근 예외를 Spring의 DataAccessException 계층으로 변환하는 흐름과 연결된다.
  • 계층별 애너테이션은 요청 처리, 트랜잭션, AOP, 테스트 범위를 읽는 기준이 된다.

한눈에 비교

Bean 등록
네 애너테이션 모두 컴포넌트 스캔 대상이 될 수 있다. @Controller , @Service , @Repository 가 @Component 의 구체화된 스테레오타입이기 때문이다.
역할 표현
@Component 는 일반 Bean, @Controller 는 웹 요청 처리, @Service 는 비즈니스 흐름, @Repository 는 데이터 접근 계층을 나타낸다.
요청 매핑
현재 Spring MVC에서는 타입 레벨에 @Controller 또는 이를 포함한 @RestController 가 있어야 핸들러 후보로 인식된다.
예외 변환
@Repository 는 영속성 기술별 예외를 Spring의 데이터 접근 예외 계층으로 바꾸는 후처리와 맞물린다.
운영 단서
Actuator mappings, AOP 포인트컷, 슬라이스 테스트에서 계층별 애너테이션은 분류 기준으로 쓰인다.

네 애너테이션의 차이는 “Bean이 되느냐”만 보면 작아 보인다. 하지만 서버 애플리케이션은 요청을 받고, 값을 바인딩하고, 검증하고, 서비스를 호출하고, 데이터 접근 중 발생한 예외를 응답으로 바꾸는 흐름을 가진다. 이 흐름에서 각 클래스가 어느 책임을 맡는지 드러내는 표식이 Spring 스테레오타입 애너테이션이다.

시간 흐름으로 이해하기

애플리케이션 시작
컴포넌트 스캔이 스테레오타입 애너테이션이 붙은 클래스를 Bean 후보로 찾는다.
MVC 초기화
RequestMappingHandlerMapping 이 컨트롤러 Bean에서 요청 매핑 메서드를 수집한다.
요청 수신
DispatcherServlet 이 URL, HTTP 메서드, 헤더 조건에 맞는 핸들러를 선택한다.
핸들러 실행
요청 바인딩, 검증, 서비스 호출, 저장소 호출이 이어진다.
응답과 예외 처리
반환값은 메시지 컨버터로 직렬화되고, 예외는 예외 처리 체계를 거쳐 HTTP 응답이 된다.

이 순서에서 @Controller는 MVC 초기화 시점에 중요하다. @Repository는 데이터 접근 계층에서 발생한 예외가 서비스와 웹 계층으로 전달되는 시점에 의미가 커진다. @Service는 Spring이 특별한 요청 매핑 동작을 붙이는 애너테이션은 아니지만, 트랜잭션 경계와 비즈니스 흐름을 읽는 기준으로 자주 쓰인다.

왜 모두 Bean인데 다르게 쓰는가

@Component는 가장 일반적인 스테레오타입이다. 특정 계층이라고 부르기 어려운 변환기, 이름 생성기, 외부 요청 서명기, 공통 어댑터 같은 객체에 잘 맞는다. 계층 의미보다 “Spring이 관리하는 객체”라는 점이 중요할 때 사용한다.

@Controller는 Spring MVC의 웹 경계에 둔다. 이 계층에서는 경로 변수, 쿼리 파라미터, 요청 본문, 검증 결과, HTTP 상태 코드, 응답 직렬화 방식이 드러난다. JSON API에서는 보통 @RestController를 쓰며, 이는 @Controller@ResponseBody를 합친 편의 애너테이션이다.

@Service는 애플리케이션 유스케이스를 표현한다. 회원 가입, 주문 생성, 결제 승인처럼 여러 정책과 저장소 호출을 하나의 흐름으로 묶는 위치다. 트랜잭션 경계를 서비스 메서드에 두는 코드가 많은 이유도 이 계층이 요청 하나의 비즈니스 단위를 대표하는 경우가 많기 때문이다.

@Repository는 데이터 접근 계층을 나타낸다. JDBC, JPA, MyBatis, jOOQ처럼 저장소 기술과 가까운 코드가 놓인다. 직접 작성한 DAO나 저장소 구현체라면 @Repository가 계층 의미뿐 아니라 예외 변환에도 영향을 줄 수 있다.

현재 버전 기준으로 달라진 부분

2026년 4월 기준 Spring의 현재 라인은 Spring Framework 7.x와 Spring Boot 4.x다. Spring Boot 4는 Spring Framework 7.x를 사용하며 Jakarta EE 11 기반으로 이동했다. Spring Boot 3과 Spring Framework 6에서 이미 javax.*에서 jakarta.*로 옮겨온 흐름이 Boot 4에서 더 이어진다.

컨트롤러 탐지 방식도 오래된 설명과 현재 설명을 구분해야 한다. Spring Framework 5.3의 RequestMappingHandlerMapping 문서에는 타입 레벨 @Controller 또는 @RequestMapping이 핸들러 조건으로 언급되어 있었다. 반면 Spring Framework 6과 7의 현재 문서에서는 타입 레벨 @Controller가 필요하다고 설명한다. 그래서 예전 예제처럼 @Component@RequestMapping만 조합한 클래스는 Spring Boot 3 이상, Spring Boot 4 환경에서 요청 매핑이 등록되지 않을 수 있다.

Spring Boot 4로 올릴 때는 의존성 이름도 함께 살펴볼 필요가 있다. 예를 들어 기존 spring-boot-starter-web은 Boot 4에서 더 구체적인 spring-boot-starter-webmvc 방향으로 정리되고, 테스트 스타터도 기술별로 나뉘었다. 이 글의 주제는 애너테이션이지만, 웹 계층이 동작하지 않을 때는 애너테이션뿐 아니라 스타터와 자동 구성도 함께 봐야 한다.

피해야 할 예전 형태는 다음과 같다.

@Component
@RequestMapping("/orders")
public class OrderEndpoint {
    @GetMapping("/{id}")
    public OrderResponse find(@PathVariable Long id) {
        return new OrderResponse(id, "READY");
    }
}

@RestController
@RequestMapping("/orders")
public class OrderController {
    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    public OrderResponse find(@PathVariable Long id) {
        return orderService.find(id);
    }
}

첫 번째 클래스는 Bean으로 등록될 수는 있어도 현재 Spring MVC의 핸들러 탐지 조건에는 맞지 않는다. JSON API라면 두 번째처럼 @RestController를 사용해 웹 요청 처리와 응답 본문 직렬화 의미를 함께 드러내는 편이 명확하다.

요청부터 응답까지의 코드 구조

아래 예시는 컨트롤러, 서비스, 저장소의 책임을 나눈 최소 구조다. 컨트롤러는 HTTP 입출력과 검증을 맡고, 서비스는 유스케이스 흐름을 잡고, 저장소는 데이터 접근만 담당한다.

@RestController
@RequestMapping("/members")
public class MemberController {
    private final MemberService memberService;

    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @PostMapping
    public MemberResponse create(@Valid @RequestBody CreateMemberRequest request) {
        Long id = memberService.create(request.email(), request.name());
        return new MemberResponse(id);
    }
}

@Service
public class MemberService {
    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Transactional
    public Long create(String email, String name) {
        return memberRepository.save(email, name);
    }
}

@Repository
public class MemberRepository {
    private final JdbcTemplate jdbcTemplate;

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

    public Long save(String email, String name) {
        jdbcTemplate.update(
            "insert into members(email, name) values (?, ?)",
            email,
            name
        );

        return jdbcTemplate.queryForObject(
            "select id from members where email = ?",
            Long.class,
            email
        );
    }
}

@RestControllerAdvice
public class ApiExceptionHandler {
    @ExceptionHandler(DuplicateKeyException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public ErrorResponse duplicateKey(DuplicateKeyException ex) {
        return new ErrorResponse("DUPLICATED_RESOURCE", "이미 존재하는 값입니다.");
    }
}

@Valid는 요청 본문이 객체로 바인딩된 뒤 검증을 수행하게 한다. Spring Boot 3 이상에서는 보통 jakarta.validation.Valid를 사용한다. @RequestBody와 반환값은 HttpMessageConverter를 거쳐 JSON 같은 본문으로 역직렬화·직렬화된다.

중복 이메일처럼 데이터베이스 제약 조건이 깨지면 저장소 계층에서 예외가 발생한다. @Repository와 예외 변환 구성이 적용된 경우, 벤더별 SQLException이나 ORM 예외를 웹 계층에서 직접 다루기보다 Spring의 DuplicateKeyException, DataIntegrityViolationException 같은 DataAccessException 계층으로 처리할 수 있다.

설정과 디버깅 단서

요청 매핑이 잡히지 않는 문제는 “Bean 등록 실패”와 “MVC 핸들러 등록 실패”를 나눠서 보는 편이 좋다. Bean이 없으면 컴포넌트 스캔 범위나 의존성 주입 오류를 봐야 하고, Bean은 있는데 404가 난다면 요청 매핑 등록 여부를 확인해야 한다.

Actuator의 mappings 엔드포인트와 요청 매핑 TRACE 로그를 켜면 어떤 컨트롤러가 실제로 등록됐는지 확인하기 쉽다. 운영 환경에서는 Actuator 노출 범위를 인증과 네트워크 정책에 맞게 제한해야 한다.

management:
  endpoints:
    web:
      exposure:
        include: health,mappings

logging:
  level:
    org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping: TRACE
    org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor: DEBUG

다음처럼 mappings 엔드포인트를 조회하면 등록된 핸들러와 URL 조건을 확인할 수 있다.

$ curl -s http://localhost:8080/actuator/mappings | grep -A 5 OrderController

"handler": "com.example.order.OrderController#find(Long)",
"predicate": "{GET [/orders/{id}]}",
"details": {
  "handlerMethod": {
    "className": "com.example.order.OrderController"
  }
}

@Component@RequestMapping만 붙인 클래스가 현재 Spring MVC에서 핸들러로 등록되지 않으면 애플리케이션은 정상 시작해도 요청은 404가 될 수 있다. 로그는 대략 다음 흐름으로 남는다.

2026-04-13T10:15:21.123+09:00 TRACE 12345 --- [main]
o.s.w.s.m.m.a.RequestMappingHandlerMapping :
Mapped "{GET [/actuator/health]}" onto ...

2026-04-13T10:15:25.451+09:00 WARN 12345 --- [nio-8080-exec-1]
o.s.web.servlet.PageNotFound :
No mapping for GET /orders/1

HTTP/1.1 404 Not Found
Content-Type: application/json

{
  "timestamp": "2026-04-13T01:15:25.452+00:00",
  "status": 404,
  "error": "Not Found",
  "path": "/orders/1"
}

이 경우에는 OrderEndpoint가 Bean인지 확인하는 것만으로는 충분하지 않다. /actuator/mappings에 해당 URL이 있는지, 클래스에 @Controller 또는 @RestController가 있는지, 웹 MVC 스타터와 자동 구성이 로딩됐는지를 함께 확인해야 한다.

데이터 접근 쪽에서는 예외 타입을 본다. 제약 조건 위반이 발생했는데 서비스나 컨트롤러까지 벤더별 예외가 그대로 올라온다면 직접 만든 DAO에 @Repository가 빠졌거나, 예외 변환 후처리 구성이 기대와 다르게 적용됐을 수 있다. Spring Data JPA 인터페이스 저장소처럼 프레임워크가 저장소 Bean을 만들어주는 경우에는 직접 @Repository를 붙이지 않는 코드도 많지만, 수동 DAO에서는 이 차이가 드러날 수 있다.

계층별로 잘못 섞이기 쉬운 지점

@Controller 안에 비즈니스 규칙과 데이터 접근 코드가 길게 들어가면 HTTP 요청 처리와 유스케이스가 강하게 엮인다. 이 구조는 같은 기능을 배치, 메시지 컨슈머, 내부 API에서 재사용하기 어렵게 만들고 테스트 범위도 커진다.

@ServiceHttpServletRequest나 세션 객체를 직접 읽기 시작하면 웹 계층과 서비스 계층의 경계가 흐려진다. 서비스는 가능한 한 HTTP 세부사항보다 애플리케이션 입력값과 도메인 규칙을 다루는 쪽에 두는 편이 테스트와 재사용에 유리하다.

@Repository 대신 @Component만 붙인 직접 DAO는 처음에는 동작해 보일 수 있다. 그러나 예외 변환, AOP 포인트컷, 아키텍처 검사에서 저장소 계층으로 분류되지 않아 나중에 원인을 찾기 어려운 차이를 만들 수 있다.

@Service@Repository처럼 특정 후처리 하나로만 설명되지는 않는다. 대신 트랜잭션, 권한 검사, 로깅, 관측성, 아키텍처 규칙을 적용할 때 비즈니스 계층을 가리키는 안정적인 표식으로 쓰인다. 그래서 “Spring이 특별히 해주는 일이 적다”는 이유만으로 모두 @Component로 통일하면 코드가 커질수록 읽을 단서가 줄어든다.

공식 문서에서 확인할 지점

  • Spring Framework 컴포넌트 스캔과 스테레오타입 애너테이션: https://docs.spring.io/spring-framework/reference/core/beans/classpath-scanning.html
  • Spring Framework 현재 RequestMappingHandlerMapping: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.html
  • Spring Framework 5.3 RequestMappingHandlerMapping: https://docs.spring.io/spring-framework/docs/5.3.11/javadoc-api/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.html
  • PersistenceExceptionTranslationPostProcessor API: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/dao/annotation/PersistenceExceptionTranslationPostProcessor.html
  • @RestController API: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/bind/annotation/RestController.html
  • Spring Boot Actuator mappings endpoint: https://docs.spring.io/spring-boot/api/rest/actuator/mappings.html
  • Spring Boot 4.0 마이그레이션 가이드: https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide

원문 참고

https://www.maeil-mail.kr/question/72

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...