web application의 여러 view들이 동일한 model attribute를 참조하는 일은 흔한 일이다.
예를들어 온라인 쇼핑몰 웹 애플리케이션을 운용하는 경우, 거의 모든 페이지에서 장바구니에 담은 내용물을 전시해야 할 필요가 있다든가 말이다.
그러나 이전 글에서 model attribute는 기본적으로 request scope, 그러니까 요청에 대해서만 유효하다고 했다. 그래서 이 경우에는 session scope, 정확히는 user session scope의 model attribute를 만드는 것이 유용하다.
이를 session attribute라고 하며, spring에서는 2가지 방법으로 handling이 가능한데 이에 대해 알아보도록 하겠다.
이전에 starter이 특정 작업을 하는데 필요한 spring dependency들이랑 spring 버전들을 모아놓은 조합이라고 했었다. 그런데 사실 dependency들을 모은 starter들은 위의 Web, Thymeleaf, Test 같은 애들이고 버전 조합에 대한 정보를 가진 곳이 parent다. 이 점 유의.
gradle의 경우
io.spring.dependency-management
plugin이 있으면 parent 설정이 불필요하다. 그 plugin이 자동으로 추가하기 때문. 관련 자료. 나머지는 dependency에 넣으면 된다.
이 글에서 사용할 예시는 'TODO' 애플리케이션이다.
TodoItem
이라는 instance를 제공하는데 사용되는 form이 있고, 이 TodoItem
들을 전부 표시하는 list view가 존재한다.
TodoItem
이라는 instance를 form을 통해 만들 때마다 저 list view에서 그 TodoItem
이 누적되어 추가될 것이다. 이를 통해 session scope model attribute를 활용해 값들을 어떻게 기억하는지 알아볼 것이다.
밑은 TodoItem
과 TodoList
에 사용되는 2개의 model class들이다.
public class TodoItem {
private String description;
private LocalDateTime createDate;
// getters and setters
}
public class TodoList extends ArrayDeque<TodoItem>{
}
TodoList
는 ArrayDeque
로 구현되어 있기 때문에 가장 최근에 추가한 아이템을 peekLast
라는 method로 참조하는 것이 가능하다.
또 이 글에서는 2개의 controller class를 만들건데 각각이 session scope model attribute를 활용하는 방법 각각을 위해 사용될 예정이다. 아무래도 서로 다른 session attribute 활용 방법을 다루기에 사소한 부분에서 다르지만 기능 면에서는 동일한데, 3가지의 @RequestMapping
들을 가진다.
@GetMapping("/form")
- form initialization, form view 렌더링 담당. TodoList
가 비어있지 않는 경우 가장 최근에 더해진 TodoItem
으로 form을 미리 채운다.@PostMapping("/form")
- TodoItem
을 TodoList
에 넣기 및 list URL redirection을 담당한다.@GetMapping("/todos.html")
- 디스플레이를 위해 TodoLIst
를 Model
에 넣고 list view를 렌더링 한다.먼저 scoped proxy 형태로 구현하는 법을 알아보자. 이건 bean scope와 proxy를 매우 적극적으로 활용하는 방식이다. 여기서 TodoList
는 proxy를 중간매개체로 둔 sesion-scoped @Bean
의 형태로 존재한다. 예전에 bean scope 관련 내용을 다룰 때 배웠다시피 이 session-scoped @Bean
에 대해 proxy가 존재한다는 것은, 곧 singleton-scope인 @Controller
의 configuration time 때 @Controller
bean에 주입하는 것이 가능하게 해준다는 뜻이다. TodoList
가 아직 instantiate되지 않았어도 말이다.
context initialization이 이루어질 때는 session이 존재하지 않기 때문에 이 proxy의 유무가 존재하다. TodoList
에 대한 proxy가 일단 inject되고 요청을 통해 실제 TodoList
가 필요할 때 비로소 instantiate가 된다.
그래서 위 proxy를 셋업하기 위해 @Configuration
class에 다음과 같은 bena method를 넣는다.
@Bean
@Scope(
value = WebApplicationContext.SCOPE_SESSION,
proxyMode = ScopedProxyMode.TARGET_CLASS)
public TodoList todos() {
return new TodoList();
}
@RequestMapping("/scopedproxy")
를 가지고 있는데, scoped proxy를 설명하기 위한 예제라 구별해서 처리하기 위해 덧붙인것 뿐이지 그 이상의 의미를 가지지는 않는다는 점 참고.@Controller
@RequestMapping("/scopedproxy")
public class TodoControllerWithScopedProxy {
private TodoList todos;
public TodoControllerWithScopedProxy(TodoList todos) {
this.todos = todos;
}
//request mappings
}
TodoItem
으로 initialize하는 것을 보여준다.@GetMapping("/form")
public String showForm(Model model) {
if (!todos.isEmpty()) {
model.addAttribute("todo", todos.peekLast());
} else {
model.addAttribute("todo", new TodoItem());
}
return "scopedproxyform";
}
@ModelAttribute
의 역할은 이전 글 참고.@PostMapping("/form")
public String create(@ModelAttribute TodoItem todo) {
todo.setCreateDate(LocalDateTime.now());
todos.add(todo);
return "redirect:/scopedproxy/todos.html";
}
@GetMapping("/todos.html")
public String list(Model model) {
model.addAttribute("todos", todos);
return "scopedproxytodos";
}
@Configuration
public class TestConfig {
@Bean
public CustomScopeConfigurer customScopeConfigurer() {
CustomScopeConfigurer configurer = new CustomScopeConfigurer();
configurer.addScope("session", new SimpleThreadScope());
return configurer;
}
}
SimpleThreadScope
와 CustomScopeConfigurer
CustomScopeConfigurer
은 Spring에서 기본으로 제공해주는 scope들 외의 개인적으로 설정한 scope를 만들 때 사용되는 scope다. session, singleton, request 등 많은 bean scope들을 지금까지 배워왔지만 이외의 scope를 프로그래머가 필요로 할 때가 있고, 그 경우 CustomScopeConfigurer
을 사용한다. 이때 사용하는 메서드가 addScope
로 scope의 이름이랑 Scope
instance를 제공해줘야 한다.
여기서는 addScope
로 session
이라는 이름의 scope를 SimpleThreadScope
가 되도록 설정하고 있다. 이러면 기존의 session
이라는 scope가 저 SimpleThreadScope
라는 녀석으로 오버라이드가 되어버린다.
그러면 우리의 session scoped bean들은 전부 저 SimpleThreadScope
를 가진다는 것인데 이게 무슨뜻이냐? 사실 간단하다. 그냥 thread랑 동일한 lifecycle을 가지게 된다는 것이다. 그러면 같은 class에 있는 테스트는 보통 같은 thread에서 진행이 되니 밑과 같은 테스트 코드에 대해서 계속 유지되는 model attribute를 미믹하는 것이 가능하다. 이렇게 한 이유는 '세션'이라는 구조를 테스트에서 실제로 구현하지 못하기 때문이다.
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
@Import(TestConfig.class)
public class TodoControllerWithScopedProxyIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private WebApplicationContext wac;
@Before
public void setup() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac)
.build();
}
@Test
public void whenFirstRequest_thenContainsUnintializedTodo() throws Exception {
MvcResult result = mockMvc.perform(get("/scopedproxy/form"))
.andExpect(status().isOk())
.andExpect(model().attributeExists("todo"))
.andReturn();
TodoItem item = (TodoItem) result.getModelAndView().getModel().get("todo");
assertFalse(StringUtils.isEmpty(item.getDescription()));
}
@Test
public void whenSubmit_thenSubsequentFormRequestContainsMostRecentTodo() throws Exception {
mockMvc.perform(post("/scopedproxy/form")
.param("description", "newtodo"))
.andExpect(status().is3xxRedirection())
.andReturn();
MvcResult result = mockMvc.perform(get("/scopedproxy/form"))
.andExpect(status().isOk())
.andExpect(model().attributeExists("todo"))
.andReturn();
TodoItem item = (TodoItem) result.getModelAndView().getModel().get("todo");
assertEquals("newtodo", item.getDescription());
}
}
여기서부터는 글의 주제와는 크게 관련 없지만, MVC 기반 애플리케이션을 어떻게 테스트하느냐랑 관련이 있어서 좀 다뤄보도록 하겠다. JUnit에 대해서 이미 알고 있다고 가정하겠다.
먼저 @RunWith(SpringRunner.class)
는 JUnit에게 Spring 애플리케이션의 테스트를 위한 context가 담긴 SpringRunner.class
를 가지고 테스트를 진행하라는 것을 뜻한다. 이는 Spring application을 가지고 테스트를 진행할 때 사용할 수 있는 spring 지원 annotation이나 class들을 사용하고 싶을 때 꼭 넣어야 하는 annotation이다. 안쓸거면 말고... javadoc
그 다음 @SpringBootTest(webEnv...)
부분은 web application을 위한 servlet container을 실제로 구성하는 대신, 이를 mocking하는 녀석을 생성하고 그 녀석이 사용할 web environment를 지정하는 것이다. 실제로 이를 일일이 mocking으로 구현하는 것이 매우 피곤하기 때문에 아주 유용하다. @SpringBootTest
라는 annotation 자체는 그외의 여러가지 설정을 지원해주니 궁금하면 doc참고. javadoc
@AutoConfigureMockMvc
는 그냥 말그대로 MockMvc
를 자동으로 구성해주는 녀석이다. javadoc
또 구체적으로 추가할 configuration class를 @Import
를 사용해서 넣고 있는데, 위의 TestConfig.class
는 아까 session scope를 바꾸는데 사용한 customScopeConfigurer
이 들어가 있다. 이것도 context에다가 넣기 위해 해당 annotation을 사용 중. javadoc
이제 @Before
부분을 보면 this.mockMvc
, 즉 mockMvc
를 build하고 있는것을 볼 수 있다. 아까 @AutoConfigureMockMvc
로 mockMvc에 대한 configuration이 이미 완료되어 있지 않냐고 할 수 있다. 맞긴하고 그걸 써도 되는데, 우리 애플리케이션에서 Mvc를 직접 구현했고 그것을 사용하고 싶은거라 해당 WebApplicationContext
를 주입한 Mvc를 새로 build하는 것이다. 그거 말고는 자동으로 configure된걸 사용해도 되서 @AutoConfigureMockMvc
를 쓰는거고.
참고로
wac
은 우리 애플리케이션의WebApplicationContext
가 주입된다.
GET
호출후 todo
에 해당하는 model attribute가 존재하는지를 확인하고, 제대로 초기화가 되었는지를 확인한다. 두번째는 form에 POST를 하면 redirection이 발생하고, 이후 다시 form에 get을 하면 방금 post한 item이 실제로 나오는지를 확인한다.이 방식의 장점은 request mapping method signature에 아무 영향을 주지 않는다는 것이다. 그래서 가독성이 뒤에 소개하는 @SessionAttributes
대비 엄청 좋다.
또 프로그래머들에게 controller들이 기본적으로 singleton scope를 가진다는 것을 자각시키는데도 유용하다. 애초에 이것 때문에 proxy를 사용하고 session scoped bean을 사용하지 못하기 때문이다. 실제로 이를 시도할 경우 예외가 나온다. 당연하지만.
다만 controller을 session scope로 만드는 경우라면 얘기가 달라진다. 다만 이러면 session 시작시마다 controller을 구성해야 하기 때문에 controller instance 구성 비용 자체가 많이 들 경우 효율적이지 않게 된다.
또 위와 같은 경우 TodoList
는 다른 component에 inject를 하는 것도 가능하다. proxy기 때문 이것은 이점이 될 수도, 단점이 될 수도 있는데 다른데서 활용할 수 있다는 이점이 있지만 그만큼 dependency 관계가 복잡해지거나 많이 얽혀있는 관계가 형성될 수 있기 때문이다.
@SessionAttributes
AnnotationTodoList
가 모두가 주입받을 수 있다는 것이 문제가 될 수 있다고 했는데, 실제로 이 기능이 필요하지 않을 경우 **controller에서만 볼 수 있도록 설정하는 것이 가능하며 여기에 @SessionAttributes
가 사용된다.여기서는 TodoList
를 @Bean
을 선언하는 대신 @ModelAttribute
로 선언하고, @SessionAttributes
를 사용해 controller만 이를 볼 수 있도록 설정할 것이다. 이전에 배운 @ModelAttribute
를 적극 활용하니 기억이 안나면 이전 글 참고.
이 경우 controller에 처음 접근할 때 TodoList
를 instantiate한 다음에 Model
에 집어넣고, @SessionAttributes
annotation에 의해 그 attribute는 계속 저장이 될 것이다.
먼저, controller annotated class에 @ModelAttribute
메서드를 만들 것이다.
@ModelAttribute("todos")
public TodoList todos() {
return new TodoList();
}
TodoList
가 생성된다. 근데 잠깐만, 우리 request마다 새로 만들고 싶은게 아니잖아... 이를 표기하기 위해 @SessionAttributes
를 사용한다.@Controller
@RequestMapping("/sessionattributes")
@SessionAttributes("todos")
public class TodoControllerWithSessionAttributes {
// ... other methods
}
documentation에 나와있듯이 이 annotation에 들어간 model attribute는 session 내내 해당 controller만 볼 수 있게 계속 저장된다!
마지막으로 해당 parameter에 @ModelAttribute
를 사용하면 끝!
@GetMapping("/form")
public String showForm(
Model model,
@ModelAttribute("todos") TodoList todos) {
if (!todos.isEmpty()) {
model.addAttribute("todo", todos.peekLast());
} else {
model.addAttribute("todo", new TodoItem());
}
return "sessionattributesform";
}
@GetMapping("/todos.html")
public String list(
Model model,
@ModelAttribute("todos") TodoList todos) {
model.addAttribute("todos", todos);
return "sessionattributestodos";
}
POST
의 경우 유의해야하는 부분이 있다.@PostMapping("/form")
public RedirectView create(
@ModelAttribute TodoItem todo,
@ModelAttribute("todos") TodoList todos,
RedirectAttributes attributes) {
todo.setCreateDate(LocalDateTime.now());
todos.add(todo);
attributes.addFlashAttribute("todos", todos);
return new RedirectView("/sessionattributes/todos.html");
}
일단 이에 대해 설명하기 전에, PRG pattern이라는 것에 대해 알아야 한다. POST/Redirect/GET pattern의 약자로, 중복된 form submission 방지를 하면서 사용자 친화적인, 그리고 새로고침이 가능한 page flow를 가능하도록 하기 위해 사용하는 패턴이다.
POST로 form submission이 이루어질때, 그것이 성공적으로 되었습니다~라는 page를 제공하는게 아니라 POST에서 받은 내용물을 반영한 후 따로 다른 page로 redirect하라는 response를 보내고, 그것에 대해서 client가 다시 바로 GET 요청을 해서 결과물이 반영된 page를 확인하는 패턴이다.
이 패턴의 용도는 첫 문단의 용도 그대로다. 정확히는
새로고침을 할 때 새로운 POST request를 보내게 되어 중복된 form submission 요청이 이루어지는 것을 방지하기 위해. 단 POST도 완료가 되지 않았는데 새로고침을 하는 경우는 방지하질 못하며, 이 경우에 대해서는 웹페이지에서 중복 submission을 할 것이냐, 혹은 잠시 기다리라는 팝업을 띄워서 해결하는 편이다.
사용자 친화적이라고도 했는데, 결과물을 볼 수 있다는 점에서의 사용자 친화적인 면도 있지만 정확히는 북마크 등의 URL관련 작업에서의 사용자 편의성을 의미하는 것이다. POST 한 결과물에 대해 북마크를 하려고 한다 해보자. Redirect를 안할경우 form을 submit한 browser address를 그대로 가지고 있을건데 이를 북마크를 하는 경우, 혹은 이 URL을 가지고 다른 사람과 결과물을 공유하려고 하면은... 그냥 form submission page에 들어가게 된다. 물론 POST request에서 URL에다가 이제 결과물을 집어넣는 것도 가능하겠지만 이는 결과물이 비대할수록 URL을 더럽히는 기능을 만든다. 하지만 redirect를 할 경우에는 redirect page 관련 URL을 북마크하거나 공유하기 때문에 결과물의 공유가 가능하다.
앞의 scoped proxy 방식, 그리고 이 @SessionAttributes
방식 모두 PRG 패턴을 사실 사용하고 있다. 그런데 둘의 PRG pattern의 구현 방식이 좀 다르다. 그 이유는 밑에서 설명.
RedirectAttributes
, Redirectview
scoped proxy 형식으로 model attribute를 session 내내 유지하는 경우, model attribute는 Spring 차원에서 관리되고 있는거다. bean의 형태로 관리되고 있기 때문이다. 그리고 우리의 controller은 이 bean을 그대로 주입받은 상태다.
create
에서 todos
를 update할때, 사실 이건 todos
라는 'bean'을 update하는 것이지 todos
라는 Model
내의 model attribute를 update하는 것이 아니다. 해당 model attribute가 실제로 추가되는 시점은 redirect request가 와서 list
라는 controller method가 호출되었을 때 추가된다. 그리고 그 model attribute는 해당 request에서만 유효하다. model attribute는 기본적으로 request scope이고 딱히 우리가 추가로 명시하지 않았으니까 말이다.
이 뭔가 단단히 꼬여있는 방식을 택한 이유는 PRG pattern을 구현해야 하는 것이기 때문이다. 새로고침을 누를때마다 POST request가 처리되는것을 POST request 완료 후에 계속 이루어지지 않도록 하는 것이 PRG pattern 구현의 이유중 한가지라고 했다. 그럴려면 update가 이루어지는 곳은, POST
관련 controller method 에서, 그리고 전시가 이루어지는 곳은 redirect관련 GET
controller method에서 이루어져야 하기 때문에 이렇게 분리한 것이다. 그리고 이를 구현하기 위해 model attribute 자체는 여전히 request scope지만 session 내내 유지되는 관련 beand을 활용해서 우회적으로 구현한 것이다.
자, 그럼 다시 @SessionAttributes
구현에 대해 얘기해보도록 하자. 여기도 똑같이 GET부분에서는 update만 이루어지고, 전시가 이루어지는 곳은 redirect 관련 GET
controller method에서 이루어져야 한다고 한다.
update는 간단하다. todos
를 그냥 update하면 된다. 엄, 그러면 이거 session 내내 유지되니가 그냥 그걸 model에다가 더해서 하면 되는거 아닌가요? 아니다. 왜냐하면 이 업데이트한게 그대로 유지되려면 model에다가 넣어야 하기 때문이다. 그런데 그럴 수 없잖아(...) 그건 redirect GET에서 해야 한다고... 앞에처럼 redirect:...
부분의 return이 불가능해지는 것도 이 때문이다. 그렇게 해서 나온 GET request (/todos.hmtl
) 측에서 todos
에 접근해봤자 아까 model
에다가 update를 하지 않았기 때문에 model에 접근을 해도 update된 todos가 등장하지 않기 때문이다.
그래서 위의 2개의 class가 대신 등장한 것이다.
RedirectAttributes
class는 Spring MVC의 DispatcherServlet
에서 매 request마다 자동으로 inject하는, Model
interface의 확장형이다. javadoc
RedirectView
는 URL에 기입된 view로 redirect를 할 때 사용되는 녀석이다. javadoc
이 둘의 상호작용은 다음과 같다.
RedirectView
는 redirect에 사용할 rul을 이제 response로 전달해준다. 사실 이건 그냥 /todos.html
로 GET를 하도록 유도하는 역할만을 가진다.RedirectAttributes
다. 우리가 여기서 하려는 것은 업데이트된 todos
가 다음 request때도 전달되는 것을 바라는 것이다. 원래 그럴려면 URL에다가 해당 attribute를 encode해야하지만 그러면 많이 더러워진다. 이 때 RedirectAttributes
의 addFlashAttribute
를 사용하면 URL encode없이 이를 전달하는 것이 가능하다.그렇게 전달된 attribute를 기반으로 /todos.html
의 todos
가 해석이 되고, 거기서 비로소 model.addAttributes
를 활용해 todos
라는 'model attribute'를 비로소 업데이트하는 것이다. 이후에 결과물을 보여주는 view를 return하면 그대로 전시.
결국 PRG패턴을 그대로 따르고는 있지만 구현의 차이 때문에 사용하는 class가 달라지는 것이라는 점 유의.
POST
부분만 좀 다르다. 이유는 뭐... 앞에 봤듯이 POST
를 처리하는 방식이 구현 면에서 많이 다르기 때문이다. @Test
public void whenSubmit_thenSubsequentFormRequestContainsMostRecentTodo() throws Exception {
FlashMap flashMap = mockMvc.perform(post("/sessionattributes/form")
.param("description", "newtodo"))
.andExpect(status().is3xxRedirection())
.andReturn().getFlashMap();
MvcResult result = mockMvc.perform(get("/sessionattributes/form")
.sessionAttrs(flashMap))
.andExpect(status().isOk())
.andExpect(model().attributeExists("todo"))
.andReturn();
TodoItem item = (TodoItem) result.getModelAndView().getModel().get("todo");
assertEquals("newtodo", item.getDescription());
}
flashAttribute
가 들어있는 flashMap
을 다음 redirect request때 session attribute 형태로 전달해야 한다는 차이점이 있다. 이게 Spring 동작으로 자동으로 처리 되는 기능은 아니기 때문에...하하...proxy를 활용하는 방식과 다르게, 따로 attribute에 해당하는 bean을 생성하기 위해 configuration 부분을 건드릴 필요가 없다. 즉 application context 관련 부분을 건드릴 필요가 없다는 것이다.
다만 TodoList
를 @RequestMapping
method들의 parameter에 전부 주입시켜야 한다는 것이 좀 불편하다. (@ModelAttribute
활용) 이 때문에 가독성이 조금 더 떨어진다.
또 flash attribute라는 기능을 활용해야 한다는 문제점도 있다.