[Spring] 관심사의 분리와 MVC 패턴
2023.03.25- -
9. 관심사의 분리와 MVC 패턴 - 이론
9-1. 관심사의 분리 (Separation of Concerns)
package com.fastcampus.ch2;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Calendar;
@Controller
public class YoilTeller {
@RequestMapping("/getYoil") // http://localhost:8080/ch2/getYoil?year=2021&month=10&day=1
// public static void main(String[] args) {
public void main(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 1. 입력
// String year = args[0];
// String month = args[1];
// String day = args[2];
String year = request.getParameter("year");
String month = request.getParameter("month");
String day = request.getParameter("day");
int yyyy = Integer.parseInt(year);
int mm = Integer.parseInt(month);
int dd = Integer.parseInt(day);
// 2. 처리
Calendar cal = Calendar.getInstance();
cal.set(yyyy, mm - 1, dd);
int dayOfWeek = cal.get(Calendar.DAY_OF_WEEK);
char yoil = " 일월화수목금토".charAt(dayOfWeek); // 일요일:1, 월요일:2, ...
// 3. 출력
// System.out.println(year + "년 " + month + "월 " + day + "일은 ");
// System.out.println(yoil + "요일입니다.");
response.setContentType("text/html"); // 응답의 형식을 html로 지정
response.setCharacterEncoding("utf-8"); // 응답의 인코딩을 utf-8로 지정
PrintWriter out = response.getWriter(); // 브라우저로의 출력 스트림(out)을 얻는다.
out.println("<html>");
out.println("<head>");
out.println("</head>");
out.println("<body>");
out.println(year + "년 " + month + "월 " + day + "일은 ");
out.println(yoil + "요일입니다.");
out.println("</body>");
out.println("</html>");
out.close();
}
}
위 프로그램은 지난 포스팅때 만들어 보았던 년,월,일을 입력으로 받아 해당 날짜의 요일을 응답하는 프로그램 코드이다.
해당 코드는 입력, 요일 계산, 출력 이렇게 세 가지 부분으로 나눌 수 있는데, 각각은 "관심사"라고 부른다. concern을 번역 하다보니 관심사라는 단어가 되었는데 그냥 우리가 관심을 갖고 해야할 작업 정도로 생각하면 될 것 같다.
그래서 위 main이라는 메서드에서는 총 3가지 관심사를 가지고 있는 것으로 볼 수 있다.
혹시 OOP 5대 설계 원칙이라는 말을 들어보았는가?
보통 "SOLID" 라고 하는데 5개 원칙의 각 앞글자를 딴 단어이다. 앞으로 하나하나 설명하겠지만 가장먼저 1번 원칙인 S, 즉 SRP에 대해서 설명하겠다.
SRP(Single Responsibility Principle)는 단일 책임의 원칙이다.
- "하나의 메서드는 하나의 책임(관심사)만 진다."
그래서 위 코드에서는 하나의 메서드에서 3개의 책임이나 가지고 있기 때문에 OOP 설계 원칙에 어긋난 것으로 볼 수 있다.
분리라는 것에는 크게 "관심사의 분리"와 "변하는 것, 자주 변하지 않는 것끼리의 분리", "공통 코드(중복)의 분리" 와 같이 세 가지의 의미가 존재한다.
이 분리가 제대로 이루어졌는지를 확인하면서 코딩을 하면 좋은 코드가 될 수 있다.
9-2. 공통 코드의 분리 - 입력의 분리
그렇다면 공통된 부분을 먼저 분리시켜 보겠다.
위 코드를 도식화화면 위와 같은 형태가 되는데 각각의 네모박스 하나는 Controller를 의미하는데 각각의 Controller에서 입력하는 부분은 모두 똑같이 request.getParameter()를 쓰는 것을 볼 수 있다. 이는 공통된 코드이며 분리시키도록 해 보자.
그러기 위해선 위와 같이 입력 부분을 공통 코드로 앞에 빼내어 먼저 받도록 하면 된다.
이렇게 하나의 관심사가 분리되었다.
// 변경 전
@RequestMapping("/getYoil") // http://localhost:8080/ch2/getYoil?year=2021&month=10&day=1
public void main(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 1. 입력
String year = request.getParameter("year");
String month = request.getParameter("month");
String day = request.getParameter("day");
int yyyy = Integer.parseInt(year);
int mm = Integer.parseInt(month);
int dd = Integer.parseInt(day);
// 변경 후
@RequestMapping("/getYoil") // http://localhost:8080/ch2/getYoil?year=2021&month=10&day=1
public void main(String year, String month, String day, HttpServletReponse response) throws IOException {
// 1. 입력
int yyyy = Integer.parseInt(year);
int mm = Integer.parseInt(month);
int dd = Integer.parseInt(day);
그리고 매개변수 부분을 보면 매개변수로 request를 받아올 때 개별 매개변수로 받음으로써 getParameter 코드가 사라질 수 있게 할 수 있다. (이 과정은 뒤에서 자세히 설명)
// 변경 후
@RequestMapping("/getYoil") // http://localhost:8080/ch2/getYoil?year=2021&month=10&day=1
public void main(int year, int month, int day, HttpServletReponse response) throws IOException {
// 1. 입력
또한 입력 처리 뿐만 아니라 각각의 매개변수를 String으로 받지 않고 int형으로 받음으로써 String-to-Integer 변환 과정 마저 없애 버릴 수 있게 된다.
벌써 코드 몇 줄이 사라지도록 하였고 이를 통해 더욱 간결한 코드가 되었다.
이제 나머지 두 개의 관심사를 처리해야 한다.
9-3. 출력(view)의 분리 - 변하는 것과 자주 변하지 않는 것의 분리
위 그림처럼 각각의 관심사를 메서드를 통해 따로 분리시킬 수 있지만 해당 코드에서 사용되는 "yoil" 과 같은 변수의 경우 같은 메서드 안에 있는 것이 아니기 때문에 외부 메서드에서 해당 변수를 사용할 수 없게 된다. (year, month, day도 마찬가지)
그래서 이를 이어줄 중간 객체가 필요한데 이 객체를 바로 Model 객체라고 한다.(MVC 패턴의 M)
이 모델 객체에 필요한 값들을 저장한 후 출력하는 부분에게 모델을 전달을 하는 방식이다.
이러한 방식으로 처리하는 것을 우리는 MVC 패턴이라고 부른다.
- 처리하는 부분: Controller
- 출력 부분: View
- Controller와 View 간에 데이터를 주고 받을 수 있도록 도와주는 객체: Model
정리하면 위와 같이 입력을 별도의 메서드로 빼내었으니 이제 나머지 두 개의 관심사도 위와 같이 따로 분리를 하려고 한다.
이를 Spring MVC에서는 아래와 같이 처리한다.
- 사용자 요청이 들어오면 DispatcherServlet이라는 애가 입력 처리를 하고 해당 Controller에게 넘겨준다.
- 그러면 Controller에서 요청에 맞게 처리하고 해당 결과를 다시 입력으로 보내주면 그것을 View에게 전달을 해준다.
- 즉, Model을 Controller에게 주고 처리 결과를 다시 Model에 저장을 한 다음 그 Model을 다시 View에게 전달하는 것이다.
- 그러면 View에서는 작업 결과(Model)를 읽어서 응답을 만들어 그걸 클라이언트에게 전송하게 된다.
실제로는 더 복잡하지만 단순하게 설명한 것이다.
- Controller에서는 어떤 View를 통해서 결과를 보여줄 지를 결정할 수 있다.
- 그래서 return "yoil"의 의미는 결과를 보여줄 View의 이름이 "yoil"이라는 뜻이다.
- 그래서 main 메서드의 반환 타입이 void에서 String으로 바뀐 것을 볼 수 있다.
- 그리고 dispatcherServlet이 넘겨주는 모델을 받아야 하기 때문에 매개변수에 Model 객체를 받는 부분도 추가되었다.
- 모델에 데이터가 key, value 형태로 저장되면 jsp 파일에서 ${year} 와 같이 되어있는 부분에 model의 year에 해당하는 값으로 채워진 결과가 클라이언트로 넘어갈 수 있게 되는 것이다.
- 또한 유효성 검사에 통과하지 못해 Controller가 yoilError를 반환하게 되면 dispatcherServlet은 그것에 해당하는 jsp 파일을 클라이언트로 보낸다.
※ 우리가 은행에서 거래내역 조회를 했을 때 그 내역을 다운로드 받아서 보고 싶은데 pdf로 할 것인지, csv로 할 것인지 excel로 할 것인지 버튼이 존재한다. 그래서 사실 은행 페이지에서는 여러 개에 해당하는 View를 만들어 놓고 사용자의 요청에 따라 해당하는 View를 전달하는 것이다.
10. 관심사의 분리와 MVC 패턴 - 실습
실제로 YoilTeller 파일을 YoilTellerMVC 이름으로 복사를 해서 새로운 클래스를 하나 만들고 해당 부분에 관심사 분리와 MVC 패턴을 적용한 코드로 만들어 보자.
그러기 위해서 아래과 같은 과정을 진행한다.
1. YoilTeller 파일 복사 (YoilTellerMVC 파일 생성)
2. 매개변수 request 대신 int형의 year, month, day 사용
3. 입력 부분과 입력 부분 타입 처리 코드 제거
4. @RequestMapping의 인자를 /getYoilMVC로 변경(하나의 프로젝트 내에 똑같은 경로가 존재하면 안됨)
위 과정을 통해 입력 부분을 분리할 수 있었다.
출력 부분 분리
그 다음에는 출력 부분(View)을 분리하기 위해 jsp 파일을 하나 만들어 주어야 한다.
- jsp 파일이 출력을 담당하는 파일이다.
해당 파일의 위치는 아래와 같다.
- src>main>webapp>WEB-INF>views>yoil.jsp
만약 이 과정에서 저장을 하는데 에러가 발생한다면 에러가 발생한 부분을 잘라내기(ctrl+x)를 한 다음 저장을 하고 다시 붙여넣고 저장하면 된다.(가끔씩 이런 에러가 발생한다고 한다.)
지금은 model 객체의 데이터가 들어갈 자리만 만들어 놓았고 아직 모델을 구현하지 않았기 때문에 코드가 정상적으로 동작하지는 않을 것이다.
위 코드에서처럼 "${변수이름}"과 같이 작성하는 것을을 EL(Expression Language)이라고 한다.
그 다음 error 페이지도 만들어 주어야 한다.
저장을 하는데 위와 같은 warning이 뜨는 경우에는 Save as UTF-8 버튼을 누르면 된다.
이제 YoilTellerMVC 파일로 넘어가서 출력 부분 대신에 이 jsp 파일을 반환하도록 return "yoil"; 코드를 마지막에 추가해 주고 main 메서드의 반환 타입이 원래 코드에선 void였는데 이제는 String을 반환하므로 String으로 바꾸어 준다.
여기서 "yoil"을 리턴한다는 의미는 /WEB-INF/views/yoil.jsp 의 파일을 이용 해서 작업 결과를 보여주라는 뜻인데 앞의 경로와 .jsp와 같은 확장자는 항상 똑같기 때문에 파일 이름만 적으면 알아서 자동으로 매핑이 되게끔 되어 있다.
package com.fastcampus.ch2;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Calendar;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
public class YoilTellerMVC {
@RequestMapping("/getYoilMVC") // http://localhost/ch2/getYoilMVC?year=2021&month=10&day=1
//public void main(HttpServletRequest request, HttpServletResponse response) throws IOException {
public String main(int year, int month, int day, HttpServletResponse response) throws IOException {
// 2. 처리
Calendar cal = Calendar.getInstance();
cal.set(year, month - 1, day);
int dayOfWeek = cal.get(Calendar.DAY_OF_WEEK);
char yoil = " 일월화수목금토".charAt(dayOfWeek); // 일요일:1, 월요일:2, ...
return "yoil"; // /WEB-INF/views/yoil.jsp
}
}
그래서 출력 부분을 제거하니까 코드가 굉장히 단순화 되고 더 직관적으로 된 것을 볼 수 있다.(출력은 html코드이기 때문에 이것만 분리해 줘도 확 줄어든다..) 이처럼 관심사를 분리하면 더 유지보수가 쉽고 OOP 설계 원칙에 맞게 코딩을 할 수 있는 것이다.
- 만약 분리없이 yoil.jsp라는 출력 부분을 여러 군데에서 사용한다면 해당 html 코드를 여기 저기 넣어주어야 하는데 이렇듯 분리시켜주면 return "yoil"만 해도 해당 출력을 반환할 수 있으니 굉장히 간편해 지는 것이다.
그리고 여기서 추가되어야 하는 부분이 하나 있는데 바로 유효성 검사이다.
- main 메서드 처음 부분에 추가해 주면 된다.
유효성 검사를 통해 정상적으로 인자가 왔는지 테스트하여 그렇지 않은 경우에 앞서 만든 error 페이지를 출력하도록 한다.
// 1. 유효성 검사
if (isValid(year, month, day)) {
return "yoilError";
}
처리 부분 분리
그리고 이제 처리하는 부분을 별도의 메소드로 분리하는 부분을 진행할 것이다.
그러기 위해서 해당 부분을 드래그 한 후에 우클릭 > Refactor > Extract Method를 클릭하면 자동으로 해당 과정을 진행해 준다.
그러면 위와 같이 처리 부분에 대한 로직이 getYoil()로 한 줄의 코드로 바뀌고 main 메서드 바깥에 getYoil이라는 메서드를 생성하여 그 안에 로직을 넣어주는 것이다.
다만 여기서 메서드로 분리한 것이기 때문에 getYoil()이 원하는 yoil이라는 변수를 리턴하도록 바꾸어 주고, main 메서드에서 getYoil을 호출하여 반환된 값을 yoil로 바꾸도록 코드를 수정해 준다. 물론 getYoil의 반환 타입을 char로 바꾸어 주는 것도 잊지않고 해 준다.
그러면 일단 아래 코드와 같이 될 것이다.
...
char yoil = getYoil(year, month, day);
return "yoil"; // /WEB-INF/views/yoil.jsp
}
private char getYoil(int year, int month, int day) {
// 2. 처리
Calendar cal = Calendar.getInstance();
cal.set(year, month - 1, day);
int dayOfWeek = cal.get(Calendar.DAY_OF_WEEK);
return " 일월화수목금토".charAt(dayOfWeek); // 일요일:1, 월요일:2, ...
}
그 다음으로 해야 할 일은 앞서 유효성 검사를 위해 if문에서 사용한 isValid() 함수를 구현해 주어야 한다. 해당 부분에서는 유효한 년,월,일이라면 True를 반환하고 그렇지 않으면 False를 반환하는 함수를 만들어 주면 될 것이다.
STS에서는 해당 부분에 커서를 올려 두면 'Create method 'isValid()'라는 버튼을 누르면 알아서 만들어준다.
먼저 해당 메서드의 로직을 구현하기 전에 true인 경우 yoil.jsp를 return 하는지, false인 경우 yoilError.jsp를 리턴하는지를 확인해 보자.
먼저 false를 리턴하도록 한다.
그런데 위와 같이 yoilError.jsp 파일의 한글이 깨져서 나온 모습을 볼 수 있다. 이는 jsp 페이지에 인코딩 정보를 추가해 주면 해결 된다.
가장 첫줄에 <%@ page contentType="text/html;charset=utf-8" %> 를 추가해 주면 된다.
(마찬가지로 yoil.jsp에도 추가해 준다.)
그러면 두 jsp 파일 모두 한글이 깨지지 않고 잘 나오는 것을 확인할 수 있다. 물론 yoil.jsp 에는 Model을 구현하지 않았기 때문에 EL에 해당하는 부분에 아무런 데이터가 들어가지 않아 값들은 나오지 않는다.
View에 모델을 전달해 주기 위해서 main 메서드 매개변수에 Model model을 추가해 주고
요일 계산 한 결과를 받아온 다음 아래 코드처럼 모델 객체를 채워준다.
- 이 때 사용하는 메서드는 addAttribute로 key, value 쌍을 저장해 주면 된다.
// 3. 계산한 결과를 model에 저장
model.addAttribute("year", year);
model.addAttribute("month", month);
model.addAttribute("day", day);
model.addAttribute("yoil", yoil);
이로써 우리가 Model에 저장한 데이터가 View에 잘 전달이 되었고 EL을 잘 채워서 전달한 것을 확인할 수 있다.
Controller - View 자동 매핑 뜯어보기
그렇다면 앞서 "yoil"이라는 string만 전달을 하는데 어떻게 경로와 확장자까지 알아서 매핑이 되었는지 구현 파일을 한 번 살펴보도록 하겠다.
위 경로로 들어가 servlet-context.xml라는 파일이 있는데 해당 파일은 웹 관련된 설정 파일이다.
해당 파일에 중간 부분 코드를 보면 접두사와 접미사에 앞서 말한 부분이 붙어서 처리가 되는 것을 확인할 수 있다.
@RequestMapping("/getYoilMVC") // http://localhost/ch2/getYoilMVC?year=2021&month=10&day=1
//public void main(HttpServletRequest request, HttpServletResponse response) throws IOException {
public String main(int year, int month, int day, Model model) throws IOException {
// 1. 유효성 검사
if (!isValid(year, month, day)) {
return "yoilError";
}
// 2. 요일 계산
char yoil = getYoil(year, month, day);
// 3. 계산한 결과를 model에 저장
model.addAttribute("year", year);
model.addAttribute("month", month);
model.addAttribute("day", day);
model.addAttribute("yoil", yoil);
return "yoil"; // /WEB-INF/views/yoil.jsp
}
다시 한 번 코드에 대해 설명을 하자면, 기본적으로 Spring MVC의 컨트롤러는 이와 같이 구현이 된다.
- 입력 받을 값들과 Model을 매개변수로 받고
- 작업을 처리하는 메서드를 통해 결과를 생성하여
- Model에 작업 결과를 저장한 다음
- 이 작업 결과를 보여줄 View를 반환하면
- DispatcherServlet이 작업결과가 저장된 모델을 해당 View에게 전달을 한다.
- View에는 이 데이터가 담겨있는 Model 객체로부터 EL을 통해 값을 읽어서 최종 결과를 만들어서 보내주게 된다.
몇 가지 변형
여기서 몇 가지 변형을 줄 수도 있다.
- main 메서드의 String 반환 타입을 void로 변경
@RequestMapping("/getYoilMVC") // http://localhost/ch2/getYoilMVC?year=2021&month=10&day=1
//public void main(HttpServletRequest request, HttpServletResponse response) throws IOException {
public void main(int year, int month, int day, Model model) throws IOException {
// 2. 요일 계산
char yoil = getYoil(year, month, day);
// 3. 계산한 결과를 model에 저장
model.addAttribute("year", year);
model.addAttribute("month", month);
model.addAttribute("day", day);
model.addAttribute("yoil", yoil);
}
그러면 Mapping된 URL에 따라 리턴할 View가 달라지게 된다. 이를 확인해 보기 위해 해당 URL과 동일한 이름의 파일(getYoilMVC.jsp)을 만들어 똑같은 내용으로 하면, main 메서드의 반환 값 없이도 가능하게 된다.
보통 이렇게는 잘 쓰지 않지만 이렇게 쓸 수도 있다는 것을 알고 있으면 좋을 것 같아 해당 내용을 포함시켰다.
즉, RequestMapping URL과 동일한 이름의 jsp 파일을 만들면 해당 메서드에서는 return 할 jsp를 지목하지 않으면 해당 jsp를 반환하게 된다는 것이다.
- main 메서드의 반환 타입을 ModelAndView로 설정
ModelAndView는 말 그대로 Model과 View를 합친 것으로 main 메서드의 매개변수로 Model 객체를 받지 않고 안에서 직접 만들고 model.addAttribute 부분을 mv.addObject로 변경하면 된다. 이는 DispatcherServlet이 모델을 생성하는 것이 아니라 우리가 직접 Model을 생성하여 넣어주고 View를 반환하도록 하는 방식이다.
public ModelAndView main(int year, int month, int day) throws IOException {
ModelAndView mv = new ModelAndView();
// 2. 요일 계산
char yoil = getYoil(year, month, day);
// 3. 계산한 결과를 model에 저장
mv.addObject("year", year);
mv.addObject("month", month);
mv.addObject("day", day);
mv.addObject("yoil", yoil);
// 4. 결과를 보여줄 view 지정
mv.setViewName("yoil");
return mv;
}
4번 과정을 통해 결과를 보여줄 view 파일의 이름을 지정하고 해당 mv를 반환하게 되면 똑같은 동작이 이루어 질 수 있다.
이 역시도 잘 사용되지는 않으나 알아두면 좋을 것이다.
유효성 검사를 추가하고 싶으면 아래와 같이 구현하면 된다.
그래서 ModelAndView는 DispatcherServlet이 Model을 생성하는 것이 아니라 Controller에서 직접 생성하게 된다.
컨트롤러 메서드의 반환타입별 모습
11. 관심사의 분리와 MVC 패턴 - 원리 (1)
이제 스프링 MVC 패턴이 어떤 식으로 동작하는지 그 원리에 대해서 알아보자.
[MethodInfo.java]
package com.fastcampus.ch2;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.StringJoiner;
public class MethodInfo {
public static void main(String[] args) throws Exception{
// 1. YoilTeller 클래스의 객체를 생성
Class clazz = Class.forName("com.fastcampus.ch2.YoilTeller");
Object obj = clazz.newInstance();
// 2. 모든 메서드 정보를 가져와서 배열에 저장
Method[] methodArr = clazz.getDeclaredMethods();
for(Method m : methodArr) {
String name = m.getName(); // 메서드의 이름
Parameter[] paramArr = m.getParameters(); // 매개변수 목록
// Class[] paramTypeArr = m.getParameterTypes();
Class returnType = m.getReturnType(); // 반환 타입
StringJoiner paramList = new StringJoiner(", ", "(", ")"); // 구분자, 접두사, 접미사
for(Parameter param : paramArr) {
String paramName = param.getName();
Class paramType = param.getType();
paramList.add(paramType.getName() + " " + paramName);
}
System.out.printf("%s %s%s%n", returnType.getName(), name, paramList);
}
} // main
}
이는 메서드의 정보를 콘솔에 출력하는 코드이다.
실행해 보면 아래과 같은 문구가 출력되는 것을 확인할 수 있다.
void main(javax.servlet.http.HttpServletRequest arg0, javax.servlet.http.HttpServletResponse arg1)
여기서 이상한 점은 YoilTeller에서는 매개 변수 이름을 분명 request와 response로 지정했는데 실제로 메서드의 매개변수가 찍힌 것은 arg0, arg1로 나온다는 점이다.
그 이유는 YoilTeller 클래스를 컴파일 할 때 컴파일러는 오로지 매개변수 타입에만 관심이 있고 그것의 이름은 중요하게 생각하지 않아 따로 저장을 해 두지 않기 때문이다.
그러나 우리는 매개변수 이름을 알아야 하기 때문에 매개변수 이름을 저장하려면 파라미터에 -parameters 라는 옵션을 주고 실행을 해야 한다.(JDK 1.8부터 추가됨)
- javac -parameters
기본값으로 해당 옵션을 주기 위해선 다음 과정을 진행할 수 있다.
void main(javax.servlet.http.HttpServletRequest arg0, javax.servlet.http.HttpServletResponse arg1)
그러나 그렇게 했는데도 여전히 매개변수 이름이 arg0과 arg1로 되어있다. 그 이유는 해당 기능은 JDK1.8부터 지원이 되기 때문인데 ch2 프로젝트는 1.6버전으로 만들어졌으므로 버전을 높여주어야 한다.
그 이상 버전으로 바꿔주려면 디렉터리 내 target > pom.xml 파일을 들어가 <properties> 부분에 <java-version>을 11로 바꾸어 주면 된다.(JDK 버전을 11로 설치했기 때문)
그리고 조금 내리다 보면 <plugin> 부분에 maven-compiler-plugin이라는 artifactId를 가진 부분이 보일텐데 해당 부분의 <source>와 <target> 에 해당하는 값들도 모두 11로 바꾸어 주어야 한다. 이 때 java version을 바꿀 때 마다 해당 값들을 바꾸면 귀찮으니 위처럼 EL을 사용하여 알아서 자동으로 위에서 설정한 java version으로 맵핑되도록 한다.
- 이것이 가능한 이유는 <properties>에서 정의한 값들은 그 이후에서 해당 값을 참조할 수 있도록 하는 것이기 때문이다.
그리고 해당 파일을 수정하게 되면 반드시 해당 프로젝트는 업데이트를 해 주어야 반영이 된다.
우리가 사용하는 프로젝트는 Maven을 사용해서 관리하며, 앞선 pom.xml 파일이 바로 maven 설정 파일이다.
void main(javax.servlet.http.HttpServletRequest request, javax.servlet.http.HttpServletResponse response)
그러고 나서 다시 코드를 실행해 보면 이제서야 매개변수 이름이 제대로 나오는 것을 볼 수 있다.
그래서 매개변수 이름을 얻는 방법에는 두 가지 정도가 존재한다.
1. 방금과 같이 Reflection API를 통해
- 단, -parameters 옵션이 요구 됨
2. Class 파일로부터 직접 얻어옴.
보통 자바 개발자는 -parameters 옵션을 주지 않을 수도 있기 때문에 1번을 먼저 시도해서 안 된다면 2번 방법으로 하면 되는 것이다.
그렇다면 이번엔 2번 방법으로 매개변수 이름을 가져오기 위해서 클래스 파일을 한 번 살펴보겠다.
그 전에 Package Explorer 창 대신 Window > Show view > Others > navi 검색 후 navigator 선택하여 새로운 navigator 창을 열어 준다.
src는 *.java 파일이 들어가 있고 target에는 *.class 파일이 들어가 있다.
그래서 YoilTeller.class 파일에 들어가보면 class 파일의 내용을 해석해서 보여준다.(클래스 파일의 내용은 원래 바이너리 형식)
해당 클래스 파일을 아래로 쭉 내려보다 보면 Local variable table이라는 부분이 존재하고 해당 부분에 매개변수의 이름이 있는 것을 볼 수 있다. 그래서 해당 부분을 긁어와서 매개변수의 이름을 얻어올 수도 있는 것이다.
하지만 이는 클래스 파일을 읽어오고 또 해당 부분을 찾고 해야 하는 과정이 따르기 때문에 난이도가 있어 일반적으로는 Reflection API로 얻어오는 방식을 택하는 것이 좋다.
이번에는 코드를 수정하여 YoilTellerMVC 의 매개변수를 확인해 보자.
char getYoil(int year, int month, int day)
boolean isValid(int year, int month, int day)
org.springframework.web.servlet.ModelAndView main(int year, int month, int day)
위처럼 우리가 원하는 YoilTellerMVC의 메서드들을 얻을 수 있다.
[MethodCall.java]
package com.fastcampus.ch2;
import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Scanner;
import java.util.Set;
class ModelController {
public String main(HashMap map) {
// 작업 결과를 map에 저장
map.put("id", "asdf");
map.put("pwd", "1111");
return "txtView2"; // 뷰이름을 반환
}
}
public class MethodCall {
public static void main(String[] args) throws Exception{
HashMap map = new HashMap();
System.out.println("before:"+map);
ModelController mc = new ModelController();
String viewName = mc.main(map);
System.out.println("after :"+map);
render(map, viewName);
}
static void render(HashMap map, String viewName) throws IOException {
String result = "";
// 1. 뷰의 내용을 한줄씩 읽어서 하나의 문자열로 만든다.
Scanner sc = new Scanner(new File(viewName+".txt"));
while(sc.hasNextLine())
result += sc.nextLine()+ System.lineSeparator();
// 2. map에 담긴 key를 하나씩 읽어서 template의 ${key}를 value바꾼다.
Iterator it = map.keySet().iterator();
while(it.hasNext()) {
String key = (String)it.next();
// 3. replace()로 key를 value 치환한다.
result = result.replace("${"+key+"}", (String)map.get(key));
}
// 4.렌더링 결과를 출력한다.
System.out.println(result);
}
}
위와 같은 구조로 동작하기 때문에 ModelController 클래스의 main에서는 map의 주소를 넘겨받아 해당 주소에 먼저 접근하여 그 map이 가진 값들을 변경시킨 것이기 때문에 model을 다시 반환할 필요가 없이 MethodCall의 main 메서드에서 해당 map의 값을 읽어왔을 때 변경된 값으로 읽어올 수 있게 되는 것이다.
그러고 나서 작업 결과가 담긴 map과 viewName을 render 메서드에게 넘겨주면 map 데이터를 가지고 해당 view를 보여주게 되는 방식이다.
해당 예제는 단순히 동작방식을 알아보기 위한 것으로 response 객체를 통해 httpservlet을 사용하지 않고 콘솔에 출력하도록 한 예제이다.
이제 제대로 동작하는지 보기 전에 txtView.txt 파일과 txtView2.txt 파일을 만들어 주고 아래와 같이 내용을 채워 넣는다.
[txtView1.txt]
id=${id}
pwd=${pwd}
[txtView2.txt]
id=${id}, pwd=${pwd}
[실행 결과]
before:{}
after :{id=asdf, pwd=1111}
id=asdf, pwd=1111
before: map을 생성한 직후
after: ModelController의 main 메소드를 호출하고 난 후
이를 통해 main 메서드를 호출하기만 해도 map이 채워지게 되는 것을 볼 수 있다. 이러한 방식으로 Model 객체가 View와 Contoller에서 사용될 수 있는 것이다.
여기서 ModelController에서 반환하는 뷰이름을 txtView2에서 txtView1으로 바꿔주면 마찬가지로 그것에 해당하는 뷰파일을 보여주게 될 것이다.
12. 관심사의 분리와 MVC 패턴 - 원리 (2)
[MethodCall2.java]
package com.fastcampus.ch2;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.Iterator;
import java.util.Map;
import java.util.Scanner;
import org.springframework.ui.Model;
import org.springframework.validation.support.BindingAwareModelMap;
public class MethodCall2 {
public static void main(String[] args) throws Exception{
// 1. YoilTellerMVC의 객체를 생성
Class clazz = Class.forName("com.fastcampus.ch2.YoilTellerMVC");
Object obj = clazz.newInstance();
// 2. main메서드의 정보를 가져온다.(매개변수를 제대로 적어야함; 메서드 오버라이딩으로 인한 혼동 방지)
Method main = clazz.getDeclaredMethod("main", int.class, int.class, int.class, Model.class);
// 3. Model 생성(인터페이스-객체를 생성할 수 없음)
Model model = new BindingAwareModelMap();
System.out.println("[before] model="+model);
// 4. main 메서드를 호출(Reflection API를 안 썼다면 바로아래 주석처럼 호출해 주었어야 함.)
// String viewName = obj.main(2021, 10, 1, model); // 아래 줄과 동일한 동작
// Reflection Api를 이용한 호출
String viewName = (String)main.invoke(obj, new Object[] { 2021, 10, 1, model });
System.out.println("viewName="+viewName);
// Model의 내용을 출력
System.out.println("[after] model="+model);
// 텍스트 파일을 이용한 rendering
render(model, viewName);
} // main
static void render(Model model, String viewName) throws IOException {
String result = "";
// 1. 뷰의 내용을 한줄씩 읽어서 하나의 문자열로 만든다.
Scanner sc = new Scanner(new File("src/main/webapp/WEB-INF/views/"+viewName+".jsp"), "utf-8");
while(sc.hasNextLine())
result += sc.nextLine()+ System.lineSeparator();
// 2. model을 map으로 변환
Map map = model.asMap();
// 3.key를 하나씩 읽어서 template의 ${key}를 value바꾼다.
Iterator it = map.keySet().iterator();
while(it.hasNext()) {
String key = (String)it.next();
// 4. replace()로 key를 value 치환한다.
result = result.replace("${"+key+"}", ""+map.get(key));
}
// 5.렌더링 결과를 출력한다.
System.out.println(result);
}
}
이번엔 MethodCall 코드를 조금 다르게 작성해 보았다.
[출력 결과(콘솔)]
[before] model={}
viewName=yoil
[after] model={year=2021, month=10, day=1, yoil=금}
<%@ page contentType="text/html;charset=utf-8" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<%@ page session="false" %>
<html>
<head>
<title>Home</title>
</head>
<body>
<P> 2021년 10월 1일은 금입니다.</P>
</body>
</html>
jsp 파일을 긁어서 보여준 건데 before에는 model에 대한 내용이 비어있다가 메서드 호출 이후에 model에 데이터가 제대로 채워졌고, Model에 들어가 있던 결과값을 토대로 jsp 파일에 데이터가 제대로 다 들어가 있는 것을 확인할 수 있다.
String viewName = (String)main.invoke(obj, new Object[] { 2021, 10, 1, model }); // Reflection Api를 이용한 호출
TODO: 지금은 위 코드에서처럼 하드코딩으로 값을 객체 배열에 직접적으로 넣어주었지만 이 이후에는 요청할 때 넘겨준 값을 이용해서 해당 값으로 치환하여 동적으로 구성하도록 해야 할 것이다.
[MethodCall3.java]
package com.fastcampus.ch2;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Scanner;
import org.springframework.ui.Model;
import org.springframework.validation.support.BindingAwareModelMap;
public class MethodCall3 {
public static void main(String[] args) throws Exception{
// 1. 요청할 때 제공된 값 - request.getParameterMap();의 역할
Map map = new HashMap();
map.put("year", "2021");
map.put("month", "10");
map.put("day", "1");
Model model = null;
Class clazz = Class.forName("com.fastcampus.ch2.YoilTellerMVC");
Object obj = clazz.newInstance();
// YoilTellerMVC.main(int year, int month, int day, Model model)
Method main = clazz.getDeclaredMethod("main", int.class, int.class, int.class, Model.class);
Parameter[] paramArr = main.getParameters(); // main 메서드의 매개변수 목록을 가져온다.
Object[] argArr = new Object[main.getParameterCount()]; // 매개변수 갯수와 같은 길이의 Object 배열을 생성
for(int i=0;i<paramArr.length;i++) {
String paramName = paramArr[i].getName();
Class paramType = paramArr[i].getType();
Object value = map.get(paramName); // map에서 못찾으면 value는 null
// paramType중에 Model이 있으면, 생성 & 저장
if(paramType==Model.class) {
argArr[i] = model = new BindingAwareModelMap();
} else if(value != null) { // map에 paramName이 있으면,
// value와 parameter의 타입을 비교해서, 다르면 변환해서 저장
argArr[i] = convertTo(value, paramType);
}
}
System.out.println("paramArr="+Arrays.toString(paramArr));
System.out.println("argArr="+Arrays.toString(argArr));
// Controller의 main()을 호출 - YoilTellerMVC.main(int year, int month, int day, Model model)
String viewName = (String)main.invoke(obj, argArr);
System.out.println("viewName="+viewName);
// Model의 내용을 출력
System.out.println("[after] model="+model);
// 텍스트 파일을 이용한 rendering
render(model, viewName);
} // main
private static Object convertTo(Object value, Class type) {
if(type==null || value==null || type.isInstance(value)) // 타입이 같으면 그대로 반환
return value;
// 타입이 다르면, 변환해서 반환
if(String.class.isInstance(value) && type==int.class) { // String -> int
return Integer.valueOf((String)value);
} else if(String.class.isInstance(value) && type==double.class) { // String -> double
return Double.valueOf((String)value);
}
return value;
}
private static void render(Model model, String viewName) throws IOException {
String result = "";
// 1. 뷰의 내용을 한줄씩 읽어서 하나의 문자열로 만든다.
Scanner sc = new Scanner(new File("src/main/webapp/WEB-INF/views/"+viewName+".jsp"), "utf-8");
while(sc.hasNextLine())
result += sc.nextLine()+ System.lineSeparator();
// 2. model을 map으로 변환
Map map = model.asMap();
// 3.key를 하나씩 읽어서 template의 ${key}를 value바꾼다.
Iterator it = map.keySet().iterator();
while(it.hasNext()) {
String key = (String)it.next();
// 4. replace()로 key를 value 치환한다.
result = result.replace("${"+key+"}", ""+map.get(key));
}
// 5.렌더링 결과를 출력한다.
System.out.println(result);
}
}
그리하여 수정한 코드는 위와 같다.
[출력 결과]
Map map = new HashMap();
map.put("year", "2021");
map.put("month", "10");
map.put("day", "1");
//...
Parameter[] paramArr = main.getParameters(); // main 메서드의 매개변수 목록을 가져온다.
Object[] argArr = new Object[main.getParameterCount()]; // 매개변수 갯수와 같은 길이의 Object 배열을 생성
앞선 MethodCall2와 다른 점은 하드코딩했던 것을 요청할 때 넘어온 값으로 객체 배열을 동적으로 생성했다는 것이다.
이렇게 하면 몇 개가 들어올 지도 모르는 매개변수에 더 들어오거나 덜 들어오더라도 동적으로 자리를 만들어 유동적으로 만들어낼 수 있게 된다.
for(int i=0;i<paramArr.length;i++) {
String paramName = paramArr[i].getName();
Class paramType = paramArr[i].getType();
Object value = map.get(paramName); // map에서 못찾으면 value는 null
// paramType중에 Model이 있으면, 생성 & 저장
if(paramType==Model.class) {
argArr[i] = model = new BindingAwareModelMap();
} else if(value != null) { // map에 paramName이 있으면,
// value와 parameter의 타입을 비교해서, 다르면 변환해서 저장
argArr[i] = convertTo(value, paramType);
}
}
- 예를 들어, 요청을 할 때 쿼리스트링에 매개변수가 담겨서 오는데 해당 매개변수와 value는 각각 map에 저장이 된다.
- 그러고나서 for문을 돌면서 main 메서드의 매개변수를 하나씩 보는데 만약 map에 저장된 매개변수라면 맵에서 value값을 가져와 Object 배열에 저장시켜 주고,
- 타입이 Model이라면 Model객체를 생성해서 Model 객체에 넣어주는 것이다.
- 그러다가 못찾으면 null로 넣어주는데 convertTo가 사용된 이유는 맵을 만들 때 key 값이 String으로 되어있었는데 main 메서드의 매개변수의 타입들은 int인 것이 있었으므로 타입을 String에서 int로 변환시켜 주기 위함이다.
[실행 결과]
paramArr=[int year, int month, int day, org.springframework.ui.Model model]
argArr=[2021, 10, 1, {}]
viewName=yoil
[after] model={year=2021, month=10, day=1, yoil=금}
<%@ page contentType="text/html;charset=utf-8" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<%@ page session="false" %>
<html>
<head>
<title>Home</title>
</head>
<body>
<P> 2021년 10월 1일은 금입니다.</P>
</body>
</html>
마지막으로 MethodCall3.java를 원격프로그램으로 만든 예제를 보면서 마치겠다.
[MyDispatcherServlet.java]
package com.fastcampus.ch2;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.Array;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.Arrays;
import java.util.Iterator;
import java.util.Map;
import java.util.Scanner;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.ui.Model;
import org.springframework.validation.support.BindingAwareModelMap;
@WebServlet("/myDispatcherServlet") // http://localhost/ch2/myDispatcherServlet?year=2021&month=10&day=1
public class MyDispatcherServlet extends HttpServlet {
@Override
public void service(HttpServletRequest request, HttpServletResponse response) throws IOException {
Map map = request.getParameterMap();
Model model = null;
String viewName = "";
try {
Class clazz = Class.forName("com.fastcampus.ch2.YoilTellerMVC");
Object obj = clazz.newInstance();
// 1. main메서드의 정보를 얻는다.
Method main = clazz.getDeclaredMethod("main", int.class, int.class, int.class, Model.class);
// 2. main메서드의 매개변수 목록(paramArr)을 읽어서 메서드 호출에 사용할 인자 목록(argArr)을 만든다.
Parameter[] paramArr = main.getParameters();
Object[] argArr = new Object[main.getParameterCount()];
for(int i=0;i<paramArr.length;i++) {
String paramName = paramArr[i].getName();
Class paramType = paramArr[i].getType();
Object value = map.get(paramName);
// paramType중에 Model이 있으면, 생성 & 저장
if(paramType==Model.class) {
argArr[i] = model = new BindingAwareModelMap();
} else if(paramType==HttpServletRequest.class) {
argArr[i] = request;
} else if(paramType==HttpServletResponse.class) {
argArr[i] = response;
} else if(value != null) { // map에 paramName이 있으면,
// value와 parameter의 타입을 비교해서, 다르면 변환해서 저장
String strValue = ((String[])value)[0]; // getParameterMap()에서 꺼낸 value는 String배열이므로 변환 필요
argArr[i] = convertTo(strValue, paramType);
}
}
// 3. Controller의 main()을 호출 - YoilTellerMVC.main(int year, int month, int day, Model model)
viewName = (String)main.invoke(obj, argArr);
} catch(Exception e) {
e.printStackTrace();
}
// 4. 텍스트 파일을 이용한 rendering
render(model, viewName, response);
} // main
private Object convertTo(Object value, Class type) {
if(type==null || value==null || type.isInstance(value)) // 타입이 같으면 그대로 반환
return value;
// 타입이 다르면, 변환해서 반환
if(String.class.isInstance(value) && type==int.class) { // String -> int
return Integer.valueOf((String)value);
} else if(String.class.isInstance(value) && type==double.class) { // String -> double
return Double.valueOf((String)value);
}
return value;
}
private String getResolvedViewName(String viewName) {
return getServletContext().getRealPath("/WEB-INF/views") +"/"+viewName+".jsp";
}
private void render(Model model, String viewName, HttpServletResponse response) throws IOException {
String result = "";
response.setContentType("text/html");
response.setCharacterEncoding("utf-8");
PrintWriter out = response.getWriter();
// 1. 뷰의 내용을 한줄씩 읽어서 하나의 문자열로 만든다.
Scanner sc = new Scanner(new File(getResolvedViewName(viewName)), "utf-8");
while(sc.hasNextLine())
result += sc.nextLine()+ System.lineSeparator();
// 2. model을 map으로 변환
Map map = model.asMap();
// 3.key를 하나씩 읽어서 template의 ${key}를 value바꾼다.
Iterator it = map.keySet().iterator();
while(it.hasNext()) {
String key = (String)it.next();
// 4. replace()로 key를 value 치환한다.
result = result.replace("${"+key+"}", map.get(key)+"");
}
// 5.렌더링 결과를 출력한다.
out.println(result);
}
}
위 코드를 에디터에 붙여 넣어보면 에러가 하나 발생한 것을 볼 수 있다.
이는 @WebServlet이라는 Annotation을 가지고 있지 않아서 발생한 문제이다.
이는 pom.xml에서 버전을 수정하거나 톰캣에 존재하는 해당 라이브러리를 import하여 해결할 수 있다.
톰캣의 라이브러리를 import하는 방식은 다음과 같다.
프로젝트 우클릭 > Build Path > Configure Build Path...
Libraries 탭에서 Classpath를 클릭 후 Add Library... 버튼 클릭
Server Runtime 선택 후 Next > Tomcat 클릭 후 Finish 클릭
이렇게 되면 우리가 톰캣이 가지고 있는 라이브러리를 가져다 쓸 수 있게 된 것이고 다시 코드로 돌아가 보면 빨간줄이 사라진 것을 볼 수 있고, 이는 톰캣 라이브러리가 새로 생겨 해당 라이브러리에서 @Webservlet을 가져다 쓸 수 있게 되어 그런 것이다.
@WebServlet은 다음 포스팅에서 설명하겠지만 스프링의 @Controller와 @RequestMapping을 합친 것이라고 보면 된다.
스프링에서는 클래스 앞에 @Controller를 붙이고 메서드 앞에 @RequestMapping을 붙였는데 서블릿에서는 메서드 단위로 Mapping이 안되기 때문에 클래스 단위로만 매핑을 해 주어야 한다.
그래서 서블릿은 스프링에 비해 클래스를 많이 만들어야 하는 단점이 있다.
또한 서블릿은 HttpServlet을 반드시 상속을 받아야 하고 비지니스 로직을 다루는 메서드의 이름은 반드시 service로 해야 하며(메서드 오버라이딩), 매개변수역시 request와 response로 해 주어야 한다.
실제로 톰캣 서버를 실행 하고 지정한 URL로 접속을 해 보면 다음과 같이 나올 것이다.
jsp 파일을 아직 수정하지 않아서 위에 문구가 잘못 뜨긴 했지만 그 아래에는 정상적으로 우리가 원하는 정보가 들어간 채로 나온 것을 확인할 수 있다.(model에 제대로 들어왔군!)
※ 코드를 잠깐 살펴보자면
else if(value != null) { // map에 paramName이 있으면,
// value와 parameter의 타입을 비교해서, 다르면 변환해서 저장
String strValue = ((String[])value)[0]; // getParameterMap()에서 꺼낸 value는 String배열이므로 변환 필요
argArr[i] = convertTo(strValue, paramType);
}
위 코드는 for문 안에서 paramType에 따라 분기를 하는 부분인데 getParameterMap에서 꺼낸 value는 String 배열이기 때문에 변환이 필요하여 위와 같이 새로운 코드가 추가가 된 것을 확인할 수 있다.
이렇게 해 주어야 하는 이유는 쿼리 스트링에 "~~?year=2022&year=2023" 과 같이 똑같은 key가 두 개 이상 들어온 경우는 배열에 넣어주어야 하기 때문이다.
'Back-end > Spring' 카테고리의 다른 글
[Spring] HTTP 요청과 응답(feat. AWS 배포, MIME, Base64, 설정파일) (0) | 2023.03.25 |
---|---|
[Spring] Spring 시작하기 (0) | 2023.03.14 |
[Spring] Part 1-5. 나만의 MVC 프레임워크 만들기 | JDBC 프로그래밍(+CRUD) (0) | 2023.01.16 |
[Spring] Part 1-4-3. 나만의 MVC 프레임워크 만들기 | 계산기 서블릿 만들기 (0) | 2023.01.15 |
[Spring] Part 1-4-2. 나만의 MVC 프레임워크 만들기 | CGI 프로그램과 서블릿 (Servlet) (0) | 2023.01.13 |
소중한 공감 감사합니다