๋ณธ๋ฌธ์œผ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ

๐ŸŒ MVC ์ปจํŠธ๋กค๋Ÿฌ ๋งคํ•‘

Sprout Framework๋Š” ์š”์ฒญ ๋ผ์šฐํŒ…, ํŒจ์Šค ํŒจํ„ด ๋งค์นญ, ํ•ธ๋“ค๋Ÿฌ ๋ฉ”์„œ๋“œ ํ•ด๊ฒฐ์„ ์ฒ˜๋ฆฌํ•˜๋Š” ์œ ์—ฐํ•˜๊ณ  ๊ฐ•๋ ฅํ•œ ์ปจํŠธ๋กค๋Ÿฌ ๋งคํ•‘ ์‹œ์Šคํ…œ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ์ด ์‹œ์Šคํ…œ์€ ๋“ค์–ด์˜ค๋Š” HTTP ์š”์ฒญ์„ ์ ์ ˆํ•œ ์ปจํŠธ๋กค๋Ÿฌ ๋ฉ”์„œ๋“œ์— ๋งคํ•‘ํ•˜๊ธฐ ์œ„ํ•ด ํ•จ๊ป˜ ์ž‘๋™ํ•˜๋Š” ์—ฌ๋Ÿฌ ํ•ต์‹ฌ ๊ตฌ์„ฑ ์š”์†Œ๋ฅผ ์ค‘์‹ฌ์œผ๋กœ ๊ตฌ์ถ•๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

์•„ํ‚คํ…์ฒ˜ ๊ฐœ์š”โ€‹

MVC ์ปจํŠธ๋กค๋Ÿฌ ๋งคํ•‘ ์‹œ์Šคํ…œ์€ ์—ฌ๋Ÿฌ ํ•ต์‹ฌ ๊ตฌ์„ฑ ์š”์†Œ๋กœ ๊ตฌ์„ฑ๋ฉ๋‹ˆ๋‹ค:

  • PathPattern: ๋ณ€์ˆ˜, ์™€์ผ๋“œ์นด๋“œ, ์ •๊ทœ์‹์„ ์ง€์›ํ•˜๋Š” ๊ณ ๊ธ‰ ํŒจํ„ด ๋งค์นญ
  • HandlerMethodScanner: ์ปจํŠธ๋กค๋Ÿฌ ๋ฉ”์„œ๋“œ๋ฅผ ๋ฐœ๊ฒฌํ•˜๊ณ  ๋“ฑ๋ก
  • RequestMappingRegistry: ๋ชจ๋“  ์š”์ฒญ ๋งคํ•‘์˜ ์ค‘์•™ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ
  • HandlerMapping: ๋“ค์–ด์˜ค๋Š” ์š”์ฒญ์— ๋Œ€ํ•ด ๊ฐ€์žฅ ์ ํ•ฉํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ฐพ์Œ
  • HandlerMethodInvoker: ํ•ด๊ฒฐ๋œ ์ธ์ˆ˜๋กœ ์„ ํƒ๋œ ํ•ธ๋“ค๋Ÿฌ ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœ

ํŒจ์Šค ํŒจํ„ด ๋งค์นญโ€‹

PathPattern ํด๋ž˜์Šคโ€‹

PathPattern ํด๋ž˜์Šค๋Š” ์ •๊ตํ•œ URL ํŒจํ„ด ๋งค์นญ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค:

public class PathPattern implements Comparable<PathPattern> {
// ํŒจํ„ด: "/users/{id}/orders/{orderId}"
// ๋งค์น˜: "/users/123/orders/456"

public boolean matches(String path);
public Map<String, String> extractPathVariables(String path);
}

์ง€์›๋˜๋Š” ํŒจํ„ด ๋ฌธ๋ฒ•โ€‹

๊ฒฝ๋กœ ๋ณ€์ˆ˜โ€‹

@GetMapping("/users/{id}")
public User getUser(@PathVariable String id) { }

@GetMapping("/users/{id}/orders/{orderId}")
public Order getOrder(@PathVariable String id, @PathVariable String orderId) { }

๊ฒฝ๋กœ ๋ณ€์ˆ˜์—์„œ ์ปค์Šคํ…€ ์ •๊ทœ์‹โ€‹

// ์ปค์Šคํ…€ ์ •๊ทœ์‹์„ ๊ฐ€์ง„ ํŒจํ„ด
@GetMapping("/users/{id:\\d+}") // ์ˆซ์ž ID๋งŒ ๋งค์น˜
public User getUser(@PathVariable String id) { }

์™€์ผ๋“œ์นด๋“œโ€‹

// ๋‹จ์ผ ์™€์ผ๋“œ์นด๋“œ - ํ•˜๋‚˜์˜ ๊ฒฝ๋กœ ์„ธ๊ทธ๋จผํŠธ ๋งค์น˜
@GetMapping("/files/*/download") // ๋งค์น˜: /files/image.jpg/download

// ์ด์ค‘ ์™€์ผ๋“œ์นด๋“œ - ์—ฌ๋Ÿฌ ๊ฒฝ๋กœ ์„ธ๊ทธ๋จผํŠธ ๋งค์น˜
@GetMapping("/static/**") // ๋งค์น˜: /static/css/main.css

๋‹จ์ผ ๋ฌธ์ž ๋งค์นญโ€‹

@GetMapping("/files/?.txt")       // ๋งค์น˜: /files/a.txt, /files/1.txt

ํŒจํ„ด ์šฐ์„ ์ˆœ์œ„์™€ ๋ช…์‹œ๋„โ€‹

์—ฌ๋Ÿฌ ํŒจํ„ด์ด ๋™์ผํ•œ ์š”์ฒญ์— ๋งค์น˜๋  ์ˆ˜ ์žˆ์„ ๋•Œ ํŒจํ„ด์€ ๋ช…์‹œ๋„์— ๋”ฐ๋ผ ์ž๋™์œผ๋กœ ์ •๋ ฌ๋ฉ๋‹ˆ๋‹ค:

@Override
public int compareTo(PathPattern other) {
// 1. ์ด์ค‘ ์™€์ผ๋“œ์นด๋“œ(**)๊ฐ€ ์ ์„์ˆ˜๋ก ๋” ๋ช…์‹œ์ 
int c = Integer.compare(this.doubleStarCount, other.doubleStarCount);
if (c != 0) return c;

// 2. ๋‹จ์ผ ์™€์ผ๋“œ์นด๋“œ(*)๊ฐ€ ์ ์„์ˆ˜๋ก ๋” ๋ช…์‹œ์ 
c = Integer.compare(this.singleStarCount, other.singleStarCount);
if (c != 0) return c;

// 3. ๊ฒฝ๋กœ ๋ณ€์ˆ˜๊ฐ€ ์ ์„์ˆ˜๋ก ๋” ๋ช…์‹œ์ 
c = Integer.compare(this.varNames.size(), other.varNames.size());
if (c != 0) return c;

// 4. ์ •์  ์ฝ˜ํ…์ธ ๊ฐ€ ๊ธธ์ˆ˜๋ก ๋” ๋ช…์‹œ์ 
c = Integer.compare(other.staticLen, this.staticLen);
if (c != 0) return c;

// 5. ์•ˆ์ •์ ์ธ ์ •๋ ฌ์„ ์œ„ํ•œ ์‚ฌ์ „์‹ ์ˆœ์„œ
return this.originalPattern.compareTo(other.originalPattern);
}

์šฐ์„ ์ˆœ์œ„ ์ˆœ์„œ ์˜ˆ์‹œ:

"/users/admin"           // ๊ฐ€์žฅ ๋ช…์‹œ์  (์ •์  ๊ฒฝ๋กœ)
"/users/{id:\\d+}" // ๋” ๋ช…์‹œ์  (์ œ์•ฝ๋œ ๋ณ€์ˆ˜)
"/users/{id}" // ๋œ ๋ช…์‹œ์  (์ œ์•ฝ๋˜์ง€ ์•Š์€ ๋ณ€์ˆ˜)
"/users/*" // ๋œ ๋ช…์‹œ์  (๋‹จ์ผ ์™€์ผ๋“œ์นด๋“œ)
"/**" // ๊ฐ€์žฅ ๋œ ๋ช…์‹œ์  (์ด์ค‘ ์™€์ผ๋“œ์นด๋“œ)

์š”์ฒญ ๋งคํ•‘ ๋“ฑ๋กโ€‹

์ปจํŠธ๋กค๋Ÿฌ ์Šค์บ”โ€‹

HandlerMethodScanner๋Š” ์ปจํŠธ๋กค๋Ÿฌ ํด๋ž˜์Šค์™€ ํ•ธ๋“ค๋Ÿฌ ๋ฉ”์„œ๋“œ๋ฅผ ์ž๋™์œผ๋กœ ๋ฐœ๊ฒฌํ•ฉ๋‹ˆ๋‹ค:

@Component
public class HandlerMethodScanner {
public void scanControllers(BeanFactory context) {
for (Object bean : context.getAllBeans()) {
Class<?> beanClass = bean.getClass();
if (beanClass.isAnnotationPresent(Controller.class)) {
String classLevelBasePath = extractBasePath(beanClass);
for (Method method : beanClass.getMethods()) {
// ์š”์ฒญ ๋งคํ•‘ ์–ด๋…ธํ…Œ์ด์…˜์— ๋Œ€ํ•ด ๊ฐ ๋ฉ”์„œ๋“œ ์ฒ˜๋ฆฌ
}
}
}
}
}

๊ฒฝ๋กœ ๊ฒฐํ•ฉโ€‹

ํด๋ž˜์Šค ๋ ˆ๋ฒจ๊ณผ ๋ฉ”์„œ๋“œ ๋ ˆ๋ฒจ ๊ฒฝ๋กœ๊ฐ€ ์ง€๋Šฅ์ ์œผ๋กœ ๊ฒฐํ•ฉ๋ฉ๋‹ˆ๋‹ค:

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

@GetMapping("/{id}") // ๊ฒฐ๊ณผ: /api/users/{id}
public User getUser(@PathVariable String id) { }

@PostMapping("/") // ๊ฒฐ๊ณผ: /api/users/
public User createUser(@RequestBody User user) { }
}

์–ด๋…ธํ…Œ์ด์…˜ ์ฒ˜๋ฆฌโ€‹

์Šค์บ๋„ˆ๋Š” ๋‹ค์–‘ํ•œ ์š”์ฒญ ๋งคํ•‘ ์–ด๋…ธํ…Œ์ด์…˜์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค:

@RequestMapping(path = "/users", method = HttpMethod.GET)
@GetMapping("/users") // ๋™์ผํ•œ ์ถ•์•ฝํ˜•
@PostMapping("/users")
@PutMapping("/users/{id}")
@DeleteMapping("/users/{id}")
@PatchMapping("/users/{id}")

์š”์ฒญ ๋งคํ•‘ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌโ€‹

๋“ฑ๋ก ๊ณผ์ •โ€‹

RequestMappingRegistry๋Š” ๋ฐœ๊ฒฌ๋œ ๋ชจ๋“  ๋งคํ•‘์„ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค:

@Component
public class RequestMappingRegistry {
private final Map<PathPattern, Map<HttpMethod, RequestMappingInfo>> mappings;

public void register(PathPattern pathPattern, HttpMethod httpMethod,
Object controller, Method handlerMethod) {
mappings.computeIfAbsent(pathPattern, k -> new EnumMap<>(HttpMethod.class))
.put(httpMethod, new RequestMappingInfo(pathPattern, httpMethod,
controller, handlerMethod));
}
}

ํ•ธ๋“ค๋Ÿฌ ํ•ด๊ฒฐโ€‹

์š”์ฒญ์ด ๋“ค์–ด์˜ค๋ฉด ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ๋Š” ๊ฐ€์žฅ ์ ํ•ฉํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ฐพ์Šต๋‹ˆ๋‹ค:

public RequestMappingInfo getHandlerMethod(String path, HttpMethod httpMethod) {
List<RequestMappingInfo> matchingHandlers = new ArrayList<>();

// 1. ์š”์ฒญ ๊ฒฝ๋กœ์™€ ๋งค์น˜๋˜๋Š” ๋ชจ๋“  ํŒจํ„ด ์ฐพ๊ธฐ
for (PathPattern registeredPattern : mappings.keySet()) {
if (registeredPattern.matches(path)) {
Map<HttpMethod, RequestMappingInfo> methodMappings = mappings.get(registeredPattern);
if (methodMappings != null && methodMappings.containsKey(httpMethod)) {
matchingHandlers.add(methodMappings.get(httpMethod));
}
}
}

if (matchingHandlers.isEmpty()) {
return null;
}

// 2. ํŒจํ„ด ๋ช…์‹œ๋„์— ๋”ฐ๋ผ ์ •๋ ฌ (๊ฐ€์žฅ ๋ช…์‹œ์ ์ธ ๊ฒƒ ์šฐ์„ )
matchingHandlers.sort(Comparator.comparing(RequestMappingInfo::pattern));

// 3. ๊ฐ€์žฅ ๋ช…์‹œ์ ์ธ ๋งค์น˜ ๋ฐ˜ํ™˜
return matchingHandlers.get(0);
}

ํ•ธ๋“ค๋Ÿฌ ๋ฉ”์„œ๋“œ ํ˜ธ์ถœโ€‹

์ธ์ˆ˜ ํ•ด๊ฒฐโ€‹

HandlerMethodInvoker๋Š” ๋ฉ”์„œ๋“œ ์ธ์ˆ˜๋ฅผ ํ•ด๊ฒฐํ•˜๊ณ  ํ•ธ๋“ค๋Ÿฌ๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค:

@Component
public class HandlerMethodInvoker {
public Object invoke(RequestMappingInfo requestMappingInfo,
HttpRequest<?> request) throws Exception {

// ๋งค์น˜๋œ ํŒจํ„ด์—์„œ ๊ฒฝ๋กœ ๋ณ€์ˆ˜ ์ถ”์ถœ
PathPattern pattern = requestMappingInfo.pattern();
Map<String, String> pathVariables = pattern.extractPathVariables(request.getPath());

// ๋ชจ๋“  ๋ฉ”์„œ๋“œ ์ธ์ˆ˜ ํ•ด๊ฒฐ
Object[] args = resolvers.resolveArguments(
requestMappingInfo.handlerMethod(), request, pathVariables);

// ํ•ธ๋“ค๋Ÿฌ ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ
return requestMappingInfo.handlerMethod()
.invoke(requestMappingInfo.controller(), args);
}
}

์™„์ „ํ•œ ์˜ˆ์ œโ€‹

๋ชจ๋“  ๊ตฌ์„ฑ ์š”์†Œ๊ฐ€ ํ•จ๊ป˜ ์ž‘๋™ํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ๋ณด์—ฌ์ฃผ๋Š” ํฌ๊ด„์ ์ธ ์˜ˆ์ œ์ž…๋‹ˆ๋‹ค:

์ปจํŠธ๋กค๋Ÿฌ ์ •์˜โ€‹

@Controller
@RequestMapping("/api/v1")
public class BookController {

@GetMapping("/books")
public List<Book> getAllBooks() {
return bookService.findAll();
}

@GetMapping("/books/{id:\\d+}")
public Book getBook(@PathVariable Long id) {
return bookService.findById(id);
}

@GetMapping("/books/{category}/latest")
public List<Book> getLatestBooksByCategory(@PathVariable String category) {
return bookService.findLatestByCategory(category);
}

@PostMapping("/books")
public Book createBook(@RequestBody Book book) {
return bookService.save(book);
}

@PutMapping("/books/{id}")
public Book updateBook(@PathVariable Long id, @RequestBody Book book) {
return bookService.update(id, book);
}

@DeleteMapping("/books/{id}")
public void deleteBook(@PathVariable Long id) {
bookService.delete(id);
}
}

์ƒ์„ฑ๋œ ๋งคํ•‘โ€‹

์Šค์บ๋„ˆ๋Š” ๊ฐ๊ฐ์˜ ํŒจํ„ด๊ณผ ํ•จ๊ป˜ ์ด๋Ÿฌํ•œ ๋งคํ•‘๋“ค์„ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค:

HTTP ๋ฉ”์„œ๋“œํŒจํ„ดํ•ธ๋“ค๋Ÿฌ ๋ฉ”์„œ๋“œ์šฐ์„ ์ˆœ์œ„
GET/api/v1/booksgetAllBooks()๋†’์Œ (์ •์ )
GET/api/v1/books/{id:\\d+}getBook()์ค‘๊ฐ„ (์ œ์•ฝ๋œ ๋ณ€์ˆ˜)
GET/api/v1/books/{category}/latestgetLatestBooksByCategory()์ค‘๊ฐ„ (์ •์  + ๋ณ€์ˆ˜)
POST/api/v1/bookscreateBook()๋†’์Œ (์ •์ )
PUT/api/v1/books/{id}updateBook()๋‚ฎ์Œ (์ œ์•ฝ๋˜์ง€ ์•Š์€ ๋ณ€์ˆ˜)
DELETE/api/v1/books/{id}deleteBook()๋‚ฎ์Œ (์ œ์•ฝ๋˜์ง€ ์•Š์€ ๋ณ€์ˆ˜)

์š”์ฒญ ์ฒ˜๋ฆฌ ํ๋ฆ„โ€‹

  1. ์š”์ฒญ ๋„์ฐฉ: GET /api/v1/books/123

  2. ํŒจํ„ด ๋งค์นญ:

    • /api/v1/books/{id:\\d+} ๋งค์น˜ (์ˆซ์ž ID์— ๋Œ€ํ•ด ๊ฐ€์žฅ ๋ช…์‹œ์ )
    • /api/v1/books/{id} ๋„ ๋งค์น˜๋˜์ง€๋งŒ ๋‚ฎ์€ ์šฐ์„ ์ˆœ์œ„
  3. ํ•ธ๋“ค๋Ÿฌ ์„ ํƒ: getBook() ๋ฉ”์„œ๋“œ ์„ ํƒ

  4. ๊ฒฝ๋กœ ๋ณ€์ˆ˜ ์ถ”์ถœ: {id: "123"}

  5. ์ธ์ˆ˜ ํ•ด๊ฒฐ: "123"์„ Long id = 123L๋กœ ๋ณ€ํ™˜

  6. ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ: bookController.getBook(123L)

์ดˆ๊ธฐํ™”์™€ ์ƒ๋ช…์ฃผ๊ธฐโ€‹

๋งคํ•‘ ์‹œ์Šคํ…œ์€ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹œ์ž‘ ์ค‘์— ์ดˆ๊ธฐํ™”๋ฉ๋‹ˆ๋‹ค:

@Component
public class HandlerContextInitializer implements ContextInitializer {
@Override
public void initializeAfterRefresh(BeanFactory context) {
scanner.scanControllers(context);
}
}

์ด๊ฒƒ์€ ์„œ๋ฒ„๊ฐ€ ์š”์ฒญ์„ ๋ฐ›๊ธฐ ์‹œ์ž‘ํ•˜๊ธฐ ์ „์— ๋ชจ๋“  ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ๋ฐœ๊ฒฌ๋˜๊ณ  ๋“ฑ๋ก๋จ์„ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค.

๋ชจ๋ฒ” ์‚ฌ๋ก€โ€‹

1. ๋ช…์‹œ์ ์ธ ํŒจํ„ด ์‚ฌ์šฉโ€‹

// ์ข‹์Œ: ๋ช…์‹œ์ ์ธ ํŒจํ„ด
@GetMapping("/users/{id:\\d+}")
public User getUser(@PathVariable Long id) { }

// ํ”ผํ•ด์•ผ ํ•จ: ๋„ˆ๋ฌด ์ผ๋ฐ˜์ 
@GetMapping("/users/{id}")
public User getUser(@PathVariable String id) { }

2. ๊ธฐ๋ณธ ๊ฒฝ๋กœ๋กœ ๊ตฌ์กฐํ™”โ€‹

@Controller
@RequestMapping("/api/v1/users")
public class UserController {
@GetMapping("/{id}") // /api/v1/users/{id}
@PostMapping("/") // /api/v1/users/
@PutMapping("/{id}") // /api/v1/users/{id}
}

3. ๋ชจํ˜ธํ•œ ๋งคํ•‘ ์ฒ˜๋ฆฌโ€‹

// ์ถฉ๋Œ์„ ํ”ผํ•˜๊ธฐ ์œ„ํ•ด ์ œ์•ฝ ์กฐ๊ฑด ์‚ฌ์šฉ
@GetMapping("/users/{id:\\d+}") // ์ˆซ์ž ID
@GetMapping("/users/{username}") // ์‚ฌ์šฉ์ž๋ช… (์ˆซ์ž๊ฐ€ ์•„๋‹Œ)

4. ์ผ๊ด€๋œ HTTP ๋ฉ”์„œ๋“œ ์‚ฌ์šฉโ€‹

@GetMapping("/users")           // ๋ชฉ๋ก/์กฐํšŒ
@PostMapping("/users") // ์ƒ์„ฑ
@PutMapping("/users/{id}") // ์—…๋ฐ์ดํŠธ (์ „์ฒด)
@PatchMapping("/users/{id}") // ์—…๋ฐ์ดํŠธ (๋ถ€๋ถ„)
@DeleteMapping("/users/{id}") // ์‚ญ์ œ