Baeldung의 이 글을 정리 및 추가 정보를 넣은 글입니다.

1. Overview

  • web application의 여러 view들이 동일한 model attribute를 참조하는 일은 흔한 일이다.

  • 예를들어 온라인 쇼핑몰 웹 애플리케이션을 운용하는 경우, 거의 모든 페이지에서 장바구니에 담은 내용물을 전시해야 할 필요가 있다든가 말이다.

  • 그러나 이전 글에서 model attribute는 기본적으로 request scope, 그러니까 요청에 대해서만 유효하다고 했다. 그래서 이 경우에는 session scope, 정확히는 user session scope의 model attribute를 만드는 것이 유용하다.

  • 이를 session attribute라고 하며, spring에서는 2가지 방법으로 handling이 가능한데 이에 대해 알아보도록 하겠다.

2. Maven Setup

  • 아직 제대로 다루지를 않았지만 여기서는 Spring Boot starter들을 사용한다. 종류는
    • Web
    • Thymeleaf
    • Test
    • Parent

이전에 starter이 특정 작업을 하는데 필요한 spring dependency들이랑 spring 버전들을 모아놓은 조합이라고 했었다. 그런데 사실 dependency들을 모은 starter들은 위의 Web, Thymeleaf, Test 같은 애들이고 버전 조합에 대한 정보를 가진 곳이 parent다. 이 점 유의.

gradle의 경우 io.spring.dependency-management plugin이 있으면 parent 설정이 불필요하다. 그 plugin이 자동으로 추가하기 때문. 관련 자료. 나머지는 dependency에 넣으면 된다.

3. Example Use Case

  • 이 글에서 사용할 예시는 'TODO' 애플리케이션이다.

  • TodoItem이라는 instance를 제공하는데 사용되는 form이 있고, 이 TodoItem들을 전부 표시하는 list view가 존재한다.

  • TodoItem이라는 instance를 form을 통해 만들 때마다 저 list view에서 그 TodoItem이 누적되어 추가될 것이다. 이를 통해 session scope model attribute를 활용해 값들을 어떻게 기억하는지 알아볼 것이다.

  • 밑은 TodoItemTodoList에 사용되는 2개의 model class들이다.

public class TodoItem {

    private String description;
    private LocalDateTime createDate;

    // getters and setters
}

public class TodoList extends ArrayDeque<TodoItem>{

}
  • TodoListArrayDeque로 구현되어 있기 때문에 가장 최근에 추가한 아이템을 peekLast라는 method로 참조하는 것이 가능하다.

  • 또 이 글에서는 2개의 controller class를 만들건데 각각이 session scope model attribute를 활용하는 방법 각각을 위해 사용될 예정이다. 아무래도 서로 다른 session attribute 활용 방법을 다루기에 사소한 부분에서 다르지만 기능 면에서는 동일한데, 3가지의 @RequestMapping들을 가진다.

    • @GetMapping("/form") - form initialization, form view 렌더링 담당. TodoList가 비어있지 않는 경우 가장 최근에 더해진 TodoItem으로 form을 미리 채운다.
    • @PostMapping("/form") - TodoItemTodoList에 넣기 및 list URL redirection을 담당한다.
    • @GetMapping("/todos.html") - 디스플레이를 위해 TodoLIstModel에 넣고 list view를 렌더링 한다.

4. Using a Scoped Proxy

4.1 Setup

  • 먼저 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();
}
  • 그리고 위에서 생성한 proxy를 주입받을 controller도 미리 만든다. 참고로 controller이 @RequestMapping("/scopedproxy")를 가지고 있는데, scoped proxy를 설명하기 위한 예제라 구별해서 처리하기 위해 덧붙인것 뿐이지 그 이상의 의미를 가지지는 않는다는 점 참고.
@Controller
@RequestMapping("/scopedproxy")
public class TodoControllerWithScopedProxy {

    private TodoList todos;

    public TodoControllerWithScopedProxy(TodoList todos) {
        this.todos = todos;
    }
    
    //request mappings
}
  • 그리고 위 proxy에 해당하는 bean의 사용은 그냥 필요할때 자연스럽게 사용하면 된다. 그러면 자동으로 session scope로 initialize가 되고 유지가 된다. 밑은 form 전시시 마지막으로 추가된 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";
}

4.2 Unit Testing

  • 위 구현을 테스트해보기 전에 알아야하는 것이 몇가지 있다. 일단 밑 코드를 봐보도록 하자. 이것이 무엇을 하는지에 대해 알아볼거다.
@Configuration
public class TestConfig {

    @Bean
    public CustomScopeConfigurer customScopeConfigurer() {
        CustomScopeConfigurer configurer = new CustomScopeConfigurer();
        configurer.addScope("session", new SimpleThreadScope());
        return configurer;
    }
}

SimpleThreadScopeCustomScopeConfigurer

  • SimpleThreadScope javadoc

  • CustomScopeConfigurer javadoc

  • CustomScopeConfigurerSpring에서 기본으로 제공해주는 scope들 외의 개인적으로 설정한 scope를 만들 때 사용되는 scope다. session, singleton, request 등 많은 bean scope들을 지금까지 배워왔지만 이외의 scope를 프로그래머가 필요로 할 때가 있고, 그 경우 CustomScopeConfigurer을 사용한다. 이때 사용하는 메서드가 addScope로 scope의 이름이랑 Scope instance를 제공해줘야 한다.

  • 여기서는 addScopesession이라는 이름의 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());
    }

}

Spring Boot에서 MVC application 테스팅하기

  • 여기서부터는 글의 주제와는 크게 관련 없지만, 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이 실제로 나오는지를 확인한다.

4.3 Discussion

  • 이 방식의 장점은 request mapping method signature에 아무 영향을 주지 않는다는 것이다. 그래서 가독성이 뒤에 소개하는 @SessionAttributes대비 엄청 좋다.

  • 또 프로그래머들에게 controller들이 기본적으로 singleton scope를 가진다는 것을 자각시키는데도 유용하다. 애초에 이것 때문에 proxy를 사용하고 session scoped bean을 사용하지 못하기 때문이다. 실제로 이를 시도할 경우 예외가 나온다. 당연하지만.

  • 다만 controller을 session scope로 만드는 경우라면 얘기가 달라진다. 다만 이러면 session 시작시마다 controller을 구성해야 하기 때문에 controller instance 구성 비용 자체가 많이 들 경우 효율적이지 않게 된다.

  • 또 위와 같은 경우 TodoList다른 component에 inject를 하는 것도 가능하다. proxy기 때문 이것은 이점이 될 수도, 단점이 될 수도 있는데 다른데서 활용할 수 있다는 이점이 있지만 그만큼 dependency 관계가 복잡해지거나 많이 얽혀있는 관계가 형성될 수 있기 때문이다.

5. Using the @SessionAttributes Annotation

  • 위에서 TodoList가 모두가 주입받을 수 있다는 것이 문제가 될 수 있다고 했는데, 실제로 이 기능이 필요하지 않을 경우 **controller에서만 볼 수 있도록 설정하는 것이 가능하며 여기에 @SessionAttributes가 사용된다.

5.1 Setup

  • 여기서는 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();
}
  • 이러면 매 request마다 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";
}
  • 단 form에 대한 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

  • 일단 이에 대해 설명하기 전에, 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해야하지만 그러면 많이 더러워진다. 이 때 RedirectAttributesaddFlashAttribute를 사용하면 URL encode없이 이를 전달하는 것이 가능하다.
  • 그렇게 전달된 attribute를 기반으로 /todos.htmltodos가 해석이 되고, 거기서 비로소 model.addAttributes를 활용해 todos라는 'model attribute'를 비로소 업데이트하는 것이다. 이후에 결과물을 보여주는 view를 return하면 그대로 전시.

  • 결국 PRG패턴을 그대로 따르고는 있지만 구현의 차이 때문에 사용하는 class가 달라지는 것이라는 점 유의.

5.2 Unit Testing

  • 테스팅하는 것도 나머지는 동일하지만, 저 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 동작으로 자동으로 처리 되는 기능은 아니기 때문에...하하...

5.3 Discussion

  • proxy를 활용하는 방식과 다르게, 따로 attribute에 해당하는 bean을 생성하기 위해 configuration 부분을 건드릴 필요가 없다. 즉 application context 관련 부분을 건드릴 필요가 없다는 것이다.

  • 다만 TodoList@RequestMapping method들의 parameter에 전부 주입시켜야 한다는 것이 좀 불편하다. (@ModelAttribute 활용) 이 때문에 가독성이 조금 더 떨어진다.

  • 또 flash attribute라는 기능을 활용해야 한다는 문제점도 있다.

profile
안 흔하고 싶은 개발자. 관심 분야 : 임베디드/컴퓨터 시스템 및 아키텍처/웹/AI

0개의 댓글