Skip to content

[6주차 - 안유진] 컨트롤러 확장 2#125

Open
anewjean wants to merge 3 commits into
Loopers-play-dev-lab:anewjeanfrom
anewjean:anewjean-w6
Open

[6주차 - 안유진] 컨트롤러 확장 2#125
anewjean wants to merge 3 commits into
Loopers-play-dev-lab:anewjeanfrom
anewjean:anewjean-w6

Conversation

@anewjean

Copy link
Copy Markdown
Collaborator

Summary

5주차까지 어노테이션 기반(@Controller + @RequestMapping) 컨트롤러를 구현하면서 인터페이스 기반 컨트롤러는 HandlerMapping/HandlerAdapter 추상화 위에 존재하지만, 실제로는 SimpleControllerHandlerMappingput()으로 직접 등록하지 않으면 동작하지 않는 경로였습니다.

이번 주차에서는 인터페이스 기반 컨트롤러를 ApplicationContext가 자동으로 등록하도록 만들고, 어노테이션 기반과 인터페이스 기반 컨트롤러가 하나의 DispatcherServlet에서 처리되도록 정리했습니다.

1단계: 등록 폼 페이지 추가

뷰 이름만 반환하는 강의 등록 화면(lecture-registration.jsp)을 띄우는 핸들러를 만들었습니다.

@Bean(name = "/lectures/new")
public Controller lectureFormController() {
    return params -> new ModelAndView("lecture-registration");
}

이런 단순 핸들러에는 어노테이션 기반(@Controller 클래스 + @RequestMapping 메서드)보다 Controller 인터페이스를 람다로 구현하는 쪽이 훨씬 가볍습니다. Controller@FunctionalInterface이므로 params -> new ModelAndView(...) 람다가 그대로 Controller 구현체가 됩니다.

여기서 든 고민: 이 람다를 어떻게 빈으로 등록할까?

  • @Component: @Component는 클래스에 붙이는 애너테이션인데, 람다는 익명이라 붙일 곳이 없습니다. 빈 이름도 클래스 이름에서 따오기 때문에 원하는 이름을 줄 수 없습니다.
  • @Bean: 외부 라이브러리나 직접 new해야 하는 객체는 @Bean을 쓴다는 원칙을 적용할 수 있습니다. 람다도 "개발자가 직접 만들어 반환하는 객체"이므로 @Bean 메서드가 적합합니다.

2. @bean의 name을 URL로: 빈 이름이 곧 매핑 키

@Bean(name = "/lectures/new")처럼 빈 이름을 URL로 지정했습니다.

5주차에 빈 저장 방식을 타입(Class)에서 이름(String)으로 바꿨기 때문에(Map<String, Object>), 빈 이름을 자유롭게 정할 수 있었습니다. MethodBeanDefinition@Bean(name = ...)이 비어 있지 않으면 그 값을, 비어 있으면 메서드 이름을 빈 이름으로 사용합니다.

// MethodBeanDefinition
if (beanAnnotation.name() == null || beanAnnotation.name().isBlank()) {
    this.beanName = factoryMethod.getName();   // 예: "lectureFormController"
} else {
    this.beanName = beanAnnotation.name();     // 예: "/lectures/new"
}

빈 이름을 URL로 두면, 별도의 URL-> 핸들러 매핑 테이블을 만들 필요 없이 ApplicationContext에 저장된 빈 이름 자체가 매핑 키가 됩니다.

3. SimpleControllerHandlerMapping을 ApplicationContext 기반으로 하드코딩 제거

기존 SimpleControllerHandlerMappingput("GET /lectures", controller)처럼 외부에서 직접 등록해야만 했습니다. 이전 주차의 한계로 남았던 "컨트롤러가 추가될 때마다 직접 추가해야 하는" 점이 인터페이스 기반 경로에는 그대로 남아 있었습니다.

AnnotationHandlerMappingApplicationContext.getAllBeans()를 순회하며 @Controller 빈을 자동으로 찾는 것과 똑같은 방식으로, SimpleControllerHandlerMappingController 타입 빈을 자동으로 찾도록 바꿨습니다.

public SimpleControllerHandlerMapping(ApplicationContext applicationContext) {
    this.applicationContext = applicationContext;
}

public void initialize() {
    for (Map.Entry<String, Object> entry : applicationContext.getAllBeans().entrySet()) {
        Object bean = entry.getValue();
        if (bean instanceof Controller) {
            // 빈 이름을 그대로 매핑 키로 사용
            controllers.put(entry.getKey(), (Controller) bean);
        }
    }
}

getAllBeans()로 모든 빈을 가져와 instanceof Controller로 거른 뒤, 빈 이름(URL)을 매핑 키로 등록합니다. @Bean으로 Controller만 등록하면 별도 put() 호출 없이 자동으로 라우팅됩니다.

Before: DispatcherServlet이 simpleHandlerMapping.put("...", controller) 직접 호출
After:  ApplicationContext에서 Controller 빈을 스캔 → 빈 이름(URL)으로 자동 등록

DispatcherServlet도 이에 맞춰, 두 HandlerMapping 모두 ApplicationContext를 받아 initialize()하도록 통일했습니다.

// 1) 애너테이션 매핑(METHOD + URL)
AnnotationHandlerMapping annotationHandlerMapping = new AnnotationHandlerMapping(applicationContext);
annotationHandlerMapping.initialize();
handlerMappings.add(annotationHandlerMapping);

// 2) 인터페이스 매핑(URL)
SimpleControllerHandlerMapping simpleHandlerMapping = new SimpleControllerHandlerMapping(applicationContext);
simpleHandlerMapping.initialize();
handlerMappings.add(simpleHandlerMapping);

4단계: 키 불일치 문제 - "METHOD + path" vs "path"

여기서 고민이 있었습니다. DispatcherServlet은 요청이 들어오면 조회 키를 "GET /lectures/new"처럼 METHOD + path 형태로 만듭니다.

final String key = req.getMethod() + " " + path;   // "GET /lectures/new"

그런데 인터페이스 기반 컨트롤러는 빈 이름, 즉 path(URL)만 키로 가집니다("/lectures/new"). 어노테이션 매핑처럼 HTTP Method 정보가 없습니다. 키 형태가 달라 그대로는 매칭되지 않습니다.

어노테이션 기반 인터페이스 기반
매핑 키 "GET /lectures" (METHOD + URL) "/lectures/new" (URL만)
HTTP Method 구분함 구분하지 않음 (모든 메서드 동일 처리)

이 차이는 두 방식의 본질적인 차이라고 판단했습니다. 어노테이션 기반은 @RequestMapping(methods = GET)으로 메서드별 핸들러를 따로 두지만, 인터페이스 기반 Controller는 URL 하나에 핸들러 하나여서 메서드를 구분할 개념 자체가 없습니다.

그래서 getHandler()에서 HTTP Method를 떼고 path로만 조회하도록 했습니다.

@Override
public Object getHandler(String key) {
    // DispatcherServlet 은 "GET /lectures" 처럼 "METHOD path" 로 조회하지만,
    // 인터페이스 매핑은 path(URL)만 키로 가진다. HTTP Method 를 떼고 path 로만 조회한다.
    String path = key.contains(" ") ? key.split(" ", 2)[1] : key;
    return controllers.get(path);
}

결과적으로 /lectures/newGET이든 POST든 동일한 폼 컨트롤러로 매핑됩니다.

5. HandlerMapping 순회 순서 - 어노테이션 먼저, 인터페이스 나중

DispatcherServlet은 handlerMappings를 순서대로 순회하며 처음 매칭되는 핸들러를 사용합니다. 어노테이션 매핑을 먼저 등록한 이유는 더 구체적인 키(METHOD + URL)를 우선 적용하기 위해서입니다.

요청 "GET /lectures"      -> 어노테이션 매핑이 "GET /lectures" 보유 -> 즉시 매칭
요청 "GET /lectures/new"  -> 어노테이션 매핑에 없음(null) -> 인터페이스 매핑으로 폴백 -> METHOD 제거 후 "/lectures/new" 로 매칭

어노테이션 매핑이 구체적인 규칙, 인터페이스 매핑이 URL 단위의 폴백 역할을 하는 구조입니다. 새 컨트롤러 방식이 생겨도 DispatcherServlet은 그대로 두고 HandlerMapping 구현체와 순서만 조정하면 됩니다.

이로써 @Controller/@RequestMapping을 서로 다른 HandlerMapping/HandlerAdapter 쌍으로 동시에 지원하는 구조를 만들었습니다.

최종 구조

com.diy
├── app/
│   ├── LectureController.java       // @Controller + @RequestMapping (어노테이션 기반)
│   ├── LectureConfig.java           // @Component + @Bean("/lectures/new") 폼 컨트롤러(인터페이스 기반)
│   ├── LectureService.java          // @Component + @Autowired
│   └── LectureRepository.java       // @Component
│
└── framework/web/
    ├── mvc/
    │   ├── DispatcherServlet.java        // 두 HandlerMapping 모두 ApplicationContext로 initialize
    │   ├── ModelAndView.java
    │   ├── controller/
    │   │   └── Controller.java           // @FunctionalInterface — 람다로 구현 가능
    │   ├── annotation/
    │   │   ├── Controller.java           // @Component 메타 어노테이션 포함
    │   │   ├── RequestMapping.java
    │   │   └── RequestMethod.java
    │   ├── view/
    │   │   └── DefaultViewResolver.java  // "lecture-registration" → JspView
    │   └── handler/
    │       ├── HandlerMapping.java
    │       ├── HandlerAdapter.java
    │       ├── SimpleControllerHandlerMapping.java   // ApplicationContext에서 Controller 빈 자동 등록(URL 키)
    │       ├── SimpleControllerHandlerAdapter.java
    │       ├── AnnotationHandlerMapping.java         // @Controller 빈 스캔(METHOD+URL 키)
    │       ├── AnnotationHandlerAdapter.java
    │       └── HandlerMethod.java
    ├── beans/factory/
    │   ├── BeanScanner.java
    │   ├── BeanDefinition.java
    │   ├── ComponentBeanDefinition.java
    │   └── MethodBeanDefinition.java     // @Bean(name=...)을 빈 이름으로 사용
    └── context/
        └── ApplicationContext.java       // getAllBeans()로 매핑 초기화에 빈 목록 제공

resources/
└── lecture-registration.jsp             // /lectures/new 가 렌더링하는 등록 폼

- SimpleControllerHandlerMapping이 ApplicationContext의 Controller 타입 빈을 스캔하여 빈 이름(URL)을 매핑 키로 등록하도록 변경
- getHandler는 조회 키에서 HTTP Method를 떼고 path로만 조회하며, DispatcherServlet도 두 HandlerMapping을 모두 ApplicationContext로 초기화하도록 통일
- 파라미터 없이 뷰 이름만 반환하는 폼 핸들러를 LectureConfig의 @bean(name=/lectures/new)으로 등록
- Controller가 @FunctionalInterface이므로 람다로 구현
- 빈 이름이 곧 매핑 URL
- 인터페이스 기반 컨트롤러는 빈 이름(URL)으로 등록되어 HTTP Method와 무관하게 매핑되고, 어노테이션 기반 컨트롤러는 URL과 HTTP Method 조합으로 매핑됨을 검증
@anewjean anewjean self-assigned this May 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants