2014년 2월 4일 화요일

Spring @SessionAttributes 와 SessionStatus

HTTP 요청에 의해 동작하는 서블릿은 기본적으로 상태를 유지하지 않는다. 따라서 매 요청이 독립적으로 처리된다. 하나의 HTTP 요청을 처리한 후에는 사용했던 모든 리소스를 정리해 버린다. 하지만 애플리케이션은 기본적으로 상태를 유지할 필요가 있다. 사용자가 로그인하면 그 로그인 정보는 계속 유지돼야할 것이고, 여러 페이지에 걸쳐 단계적으로 정보를 입력하는 위저드 폼 같은 경우에도 폼 정보가 하나의 요청을 넘어서 유지돼야 한다. 간단하게는 단순한 폼 처리 중에도 유지해줘야 하는 정보가 있다.

도메인 중심 프로그래밍 모델과 상태 유지를 위한 세션 도입의 필요성

사용자 정보의 수정 기능을 생각해보자. 수정 기능을 위해서는 최소한 두 번의 요청이 서버로 전달돼야 한다. 첫 번째는 수정 폼을 띄워달라는 요청이다. 수정할 사용자 ID를 요청에 함께 전달한다. 서버는 주어진 ID에 해당하는 사용자 정보를 읽어서 수정 가능한 폼을 출력해 준다. 사용자가 폼의 정보를 수정하고 수정 완료 버튼을 누르면 두 번째 요청이 서버로 전달된다. 이때는 수정한 폼의 내용이 서버로 전달된다. 서버는 사용자가 수정한 정보를 받아서 DB에 저장하고 수정 완료 메시지가 담긴 페이지를 보여준다.. 어떻게 보면 수정 작업은 두 번의 요청과 두 개의 뷰 페이지가 있으면 되는 간단한 기능이다. 하지만 좀 더 생각해보면 수정 작업은 생각보다 복잡해질 수 있다. 사용자가 수정한 폼의 필드 값에 오류가 있는 경우에 에러 메시지와 함께 수정 화면을 다시 보여줘야 하기 때문이다. 또, 상태를 유지하지 않고 폼 수정 기능을 만들려면 도메인 오브젝트 중심 방법보다는 계층 간의 결함도가 높은 데이터 중심 방식을 사용하기 쉬워진다. 폼의 검증과 에러 메시지 문제는 이후에 다룰 것이고, 일단 여기서는 폼 처리시 상태유지 문제만 살펴보자. 서버에서 하나의 요청 범위를 넘어서 오브젝트를 유지시키지 않으면 왜 데이터 중심의 코드가 만들어지고 계층간의 결함도가 올라가는지 생각해보자. 사용자 정보의 수정 폼을 띄우는 컨트롤러는 아마도 다음과 같이 만들어질 것이다.
@RequestMapping(value="/user/edit", method=RequestMethod.GET)
public String form(@RequestParam int id, Model model) {
    model.addAttribute("user", userService.getUser(id));
    return "user/edit";
}
사용자 수정 폼에는 사용자 테이블에 담긴 대부분의 정보를 출려해줄 필요가 있다. 그래서 도메인 오브젝트 중심 방식의 DAO 를 사용해서 주어진 id에 대한 사용자 정보를 User 오브젝트에 담아 모두 가져오는 것이 자연스럽다. 서비스 계층에서 사용자 정보를 가져오면서 일부 정보를 활용하거나 가공하는 등의 작업을 해도 상관없다. User 라는 의미 있는 도메인 오브젝트를 사용하기 때문이다. 그런데 문제는 여기서부터다. 사용자 정보 수정 화면이라고 모든 정보를 다 수정하는 것이 아니다. 사용자 스스로 자신의 정보를 수정하는 경우라면 수정할 수 있는 필드는 제한되어 있다. 로그인 아이디나 중요한 가입정보는 특별한 권한이 없으면 수정할 수 없게 해야 한다. 사용자 정보에 포함된 레벨이나 포인트 등도 수정할 수 없다. 마지막 로그인 시간이나, 기타 사용자의 활동정보에 해당하는 내용도 수정할 수 없다. 이런 정보는 아예 수정 폼에 나타나지 않거나, 나타난다고 하더라도 읽기 전용으로 출력만 될 뿐이다. 좀 더 권한이 많은 관리자 모드의 사용자 수정 화면이라고 하더라도 여전히 폼에서 모든 정보를 다 수정할 필요는 없다. 따라서 User 도메인 오브젝트에 담겨서 전달된 내용 중에서 수정 가능한 일부 정보만 폼의 이나 등을 이용한 수정용 필드로 출력된다. 그렇기 때문에 폼을 수정하고 저장 버튼을 눌렀을 때는 원래 User에 담겨 있던 내용 중에서 수정 가능한 필드에 출력했던 일부만 서버로 전송된다는 문제가 발생한다. 폼의 서브밋을 받는 컨트롤러 메소드에서 만약 다음과 같이 User 오브젝트로 폼의 내용을 바인딩하게 했다면 어떻게 될까?
@RequestMapping(value="/user/edit", method=RequestMethod.POST)
public String submit(@ModelAttribute User user) {
    userService.updateUser(User);
    return "user/editsuccess";
}
@ModelAttribute 가 붙은 User 타입 파라미터를 선언했으니 폼에서 POST 를 통해 전달되는 정보는 User 오브젝트의 프로퍼티에 바인딩돼서 들어갈 것이다. 문제는 폼에서 이나 로 정의한 필드의 정보만 들어간다는 점이다. 단순히 화면에 읽기 전용으로 출력했던 loginId 나 가입일자라든가, 아예 출력도 안 했던 포인트나 내부 관리 정보 등은 폼에서 전달되지 않으므로 submit() 메소드의 파라미터로 전달되는 user 오브젝트에는 이런 프로퍼티 정보가 모두 비어 있을 것이다. 일부 필드의 정보가 빠진 이 반쪽짜리 user 오브젝트를 User 도메인 오브젝트를 사용해서 비즈니스 로직을 처리하는 서비스 계층의 UserService 빈을 사용해서 User 도메인 오브젝트를 사용해서 DB에 결과를 업데이트해주는 UserDao 빈에 전달해준다고 해보자. 어떤 일이 일어날까? UserService 에 사용자 정보를 수정할 때마다 사용자 정보를 일부 참조하거나 변경해주는 로직이 포함되어 있거나, 수정이 일어날때 이를 로깅하거나 관리자에게 통보하거나 통계를 내기 위해 다른 서비스 오브젝트를 호출하도록 만들어질 수 있다. 폼에 얼마나 많은 정보를 띄우고 이를 다시 돌려받는지에 따라 다르겠지만, 아마도 서비스 계층의 로직을 처리하는 중에 치명적인 오류가 발생할지도 모른다. 도메인 오브젝트를 이용해 비즈니스 로직을 처리하도록 만든 서비스 계층의 코드는 폼에서 어떤 내용만 다시 돌려지는지 알지 못하기 때문에, User 오브젝트를 받았다면 모든 프로퍼티 값을 다 사용하려고 할 것이다. 따라서 일부 비즈니스 로직에선 잘못된 값이 사용될 수도 있고, 아예 예외가 발생할 수도 있다. DAO 경우는 더 심각하다. 도메인 오브젝트를 사용하도록 만든 DAO 의 업데이트 메소드는 도메인 오브젝트의 수정 가능한 모든 필드를 항상 업데이트한다. 따라서 일부 프로퍼티 값이 null 이거나 0인 상태로 전달돼도 그대로 DB에 반영해버린다. 결국 중요한 데이터를 날려버리는 심각한 결과를 초래할 것이다. 사용자가 자기 정보를 수정하고나면 0이 되는 버그 같은게 만들어 질 수 있다는 뜻이다. 도메인 오브젝트를 사용해 수정 폼을 처리하는 방식에는 이런 심각한 문제점이 있다. 그렇다면 이 문제를 해결할 수 있는 방법을 한번 생각해 보자.

히든필드

수정을 위한 폼에 User 의 모든 프로퍼티가 다 들어가지 않기 때문에 이런 문제가 발생했으니 모든 User 오브젝트의 프로퍼티를 폼에 다 넣어주는 방법을 생각해 볼 수 있다. 물론 사용자가 수정하면 안되는 정보가 있으니 이런 정보는 히든 필드에 넣어줘야 한다. 히든 필드를 사용하면 화면에서는 보이지 않지만 폼을 서브밋하면 다시 서버로 전송된다. 결국 컨트롤러가 받는 User 타입의 오브젝트에는 모든 프로퍼티의 값이 채워져 있을 것이다. 사용자가 수정하도록 노출한 필드는 바뀔 수 있지만, 히든 필드로 감춰둔 것은 원래 상태 그대로 돌아올 것이다. 이 방법은 간단히 문제를 해결한 듯 보이지만 사실 두 가지 심각한 문제가 있다. 첫째, 데이터 보안에 심각한 문제를 일으킨다. 폼의 히든 필드는 브라우저 화면에는 보이지 않지만 HTML 소스를 열어보면 그 내용과 필드 이름까지 쉽게 알아낼 수 있다. 폼을 통해 다시 서버로 전송되는 정보는 간단히 조작될 수 있다. 즉, 사용자가 나쁜 마음을 먹으면 HTML 을 통해 히든 필드 정보를 확인하고 그 값을 임의로 조작해서 서버로 보내버릴 수 있다. 이렇게 히든 필드에 중요 정보를 노출하고, 이를 다시 서버에서 받아서 업데이트 하는 방법은 보안에 매우 취약하다는 단점이 있다. 두 번째 문제는 사용자 정보에 새로운 필드가 추가됐다고 해보자. 그런데 깜빡하고 폼에 이 정보에 대한 히든 필드를 추가해주지 않으면, 추가된 필드의 값은 수정을 거치고 난 후 null 로 바뀌는 현상이 발생할 것이다. 이런 것은 테스트에서도 쉽게 발견되지 않으므로 실수하기 쉽다. 이런 버그 때문에 많은 사용자의 중요한 정보를 다 날린 뒤에 문제를 발견하면 큰일이다. 따라서 히든 필드 방식은 매우 유치한 해결 방법이고 공개된 서비스에서는 사용을 권장할 수 없다.

DB 재조회

두 번째 해결책은 기능적으로 보자면 완벽하다. 폼으로부터 수정된 정보를 받아 User 오브젝트에 넣어줄 때 빈 User 오브젝트 대신 DB에서 다시 읽어온 User 오브젝트를 이용하는 것이다. 이 방식으로 만든 컨트롤러 메소드를 살펴보자.
@RequestMapping(value="/user/edit", method=RequestMethod.POST)
public String submit(@ModelAttribute User formUser, @RequestParam int id) {
    User user = userService.getUser(id);
     
    user.setName(formUser.getName());
    user.setPassword(formUser.getPassword());
    user.setEmail(formUser.getEmail());
     
    ...
     
    userService.updateUser(user);
    return "user/editsuccess";
}
이 방법은 업데이트를 위해 서비스 계층으로 전달할 User 오브젝트를 DB에서 새로 읽어온 것으로 사용한다. DB에서 새로 읽어왔으므로 User 오브젝트에는 모든 프로퍼티의 내용이 다 들어 있다. 그리고 폼에서 전달되는 정보를 담을 User 타입의 formUser 파라미터도 메소드에 추기해 준다. formUser 에는 폼에 사용자가 수정할 수 있도록 출력해둔 필드의 내용만 들어 있을 것이다. 그리고 DB에서 가져온 모든 필드 정보가 담겨 있는 User 오브젝트에, 폼에서 전달 받은 formUser 중 폼에서 전달된 필드 값을 복사해 준다. 그리고 복사가 끝난 User 오브젝트를 서비스 계층으로 전달하는 것이다. 이렇게 전달된 User 오브젝트에는 모든 필드가 다 채워져 있고 폼에서 수정한 내용도 모두 반영되어 있다. 따라서 서비스 계층에서 User 도메인 오브젝트를 어떻게 활용하든 아무런 문제가 없다. 또 DAO 에서 모든 필드를 다 업데이트해도 상관없다. 완벽해 보이긴 하지만 이 방법에는 몇 가지 단점이 있다. 일단 폼을 서브밋할 때마다 DB에서 사용자 정보를 다시 읽는 부담이 있다. 성능에 큰 영향을 줄 가능성이 높진 않더라도 분명 DB의 부담을 증가시키는 것은 사실이다. 성능은 차치하더라도 폼에서 전달되는 필드가 어떤 것인지 정확히 알고 이를 복제해줘야 한다. 복사할 필드를 잘 못 선택하거나 빼먹으면 문제가 발생한다. 얼핏 보면 간단히 문제를 해결한 듯 보이지만 여전히 불편하며 새로운 문제를 초래하는 방법이다.

계층 사이의 강한 결합

세 번째로 생각해볼수 있는 방법은 계층 사이에 강한 결합을 주는 것이다. 강한 결합이라는 의미는 각 계층의 코드가 다른 계층에서 어떤 일이 일어나고 어떤 작업을 하는지를 자세히 알고 그에 대응해서 동작하도록 만든다는 뜻이다. 이 방식은 앞에서 지적한 폼 수정 문제의 전제를 바꿔서 문제 자체를 제거한다. 기본 전제는 서비스 계층의 updateUser() 메소드가 User 라는 파라미터를 받으면 그 User 는 getUser() 로 가져오는 User와 동등하다고 본다는 것이다. User 라는 오브젝트는 한 사용자 정보를 완전히 담고 있고, 그것을 전달받으면 원하는 모든 필드를 참조하고 조작할 수 있다고 여긴다는 말이다. 이렇게 각 계층이 도메인 모델을 따라 만든 도메인 오브젝트에만 의존하도록 만들면 각 계층 사이에 의존성과 결합도를 대폭 줄일 수 있다. 결합도를 줄인다는 의미는 한 계층의 구현 코드를 수정해도 기본적인 전제인 도메인 오브젝트가 바뀌지 않으면 다른 계층의 코드에 영향을 주지 않는다는 뜻이다. UserService 의 updateUser() 메소드는 사용자 레벨을 업그레이드하는 로직에서 결과를 반영하기 위해 호출되든, 관리자의 수정화면을 처리하는 AdminUserController 에서 호출되든, 사용자가 스스로 정보를 수정하는 화면을 처리하는 UserController 에서 호출되든 상관없이 동일하게 만들 수 있다. UserDao 의 update() 메소드도 마찬가지다. 전달되는 User 오브젝트가 폼을 통해 수정된 User인지 상관하지 않고 자신의 기능에만 충실하게 만들 수 있다. 결국 모든 계층의 코드가 서로 영향을 주지 않고 독립적으로 확장하거나 변경할 수 있고, 여러 개의 로직에서 공유할 수 있다. 반면에 계층 사이에 강한 결합을 준다는 건 각 계층의 코드가 특정 작업을 중심으로 긴밀하게 연결되어 있고, 자신을 사용하는 다른 계층의 코드가 어떤 작업을 하는지 구체적으로 알고 있다는 뜻이다. 예를 들어 사용자 자신의 정보를 수정하는 폼을 통해 전달되는 User 오브젝트에는 name, password, email 세 개의 필드만 들어온다는 사실을 UserService 의 updateUserForm() 메소드가 알고 있게 해주면 문제는 간단해진다. 폼에서 세 개의 필드만 수정할 수 있음을 알고 있으니 그 세 개의 필드만 담긴 User 오브젝트가 컨트롤러로 전달되는 것을 알 수 있다. 그에 따라 세 개의 필드 외에는 참조하지 않고 무시하도록 코드를 만들면 된다. DAO 도 마찬가지다, User의 모든 필드를 업데이트 하는 대신 updateForm() 이라는 메소드를 하나 따로 만들어서 name, password, email 세 개의 필드만 수정하도록 SQL을 작성하게 해주면 된다. 관리자 메뉴의 사용자 정보 수정 기능이 있고 거기에는 더 많은 정보를 수정하도록 만들어진 폼이 있다면, 이 폼을 처리하는 컨트롤러는 관리자 사용자 정보 수정을 전담하는 UserService 의 updateAdminUserForm() 을 호출하게 한다. updateAdminUserForm() 은 폼에 어떤 필드가 수정 가능하도록 출력되는지를 알고 있는 메소드다. 따라서 해당 필드만 참조해서 비즈니스 로직을 처리하고, 역시 해당 필드만 DB에 반영해주는 UserDao 의 updateAdminForm() 메소드를 사용한다. 아예 컨트롤러에서 폼의 정보를 받는 모델 오브젝트를 폼에서 수정 가능한 파라미터만 가진 UserForm, UserAdminForm 등을 독립적으로 만들어서 사용하는 방법도 있다. 결국 개별 작업을 전담하는 코드를 각 계층마다 만들어야 한다. 이런식으로 계층 간에 강한 의존성을 주고 결합도를 높이면 처음에는 만들기 쉽다. 대부분의 코드는 화면을 기준으로 해서 각 계층별로 하나씩 독립적으로 만들어질 것이다. DAO 의 메소드와 서비스 계층의 메소드는 각각 하나의 화면을 위해서만 사용된다. 결국 화면을 중심으로 거기에 사용되는 SQL 을 정의하고 하나씩의 메소드를 새롭게 추가하는 것을 선호하는 개발자가 애용하는 방식이다. 이런식의 개발은 코드 생성기를 적용하기도 좋다. 하지만 이렇게 결합도가 높은 코드를 만들 경우, 애플리케이션이 복잡해지기 시작하면 단점이 드러난다. 일단 코드의 중복이 늘어난다. 코드를 재사용하기가 힘들기 때문이다. 수정할 필드가 조금 달라도 다른 메소드를 만들어야 한다. 코드는 자꾸 중복되고 그 때문에 기능을 변경할 때 수정해야 할 곳도 늘어난다. 또한 계층 사이에 강한 결합이 있기 때문에 한쪽을 수정하면 연관된 코드도 함께 수정해줘야 한다. 코드의 중복은 많아지고 결합이 강하므로 그만큼 테스트하기도 힘들다. 테스트 코드에도 중복이 일어난다. 하지만 여전히 이런 방식을 선호하는 경우가 많다. 이런 방식이라면 서비스 계층, DAO 계층을 구분하는 것도 별 의미가 없을 지 모른다. 차라리 2계층이나, 컨트롤러 - 뷰만의 1계층 구조로 가능게 나을 수 있다. 아무튼, 이렇게 계층 사이에 강한 결합을 만들고 수정할 필요가 있는 필드가 어떤 것인지 모든 계층의 메소드가 다 알고 있게 하면 문제는 해결할 수 있다. 이럴 땐 User 와 같은 도메인 오브젝트보다는 차라리 파라미터 맵을 쓰는 것이 편리할 수 있다. 다음과 같이 컨트롤러 메소드의 @RequestParam 이 붙는 Map 타입 파라미터를 사용하면 모든 요청 파라미터를 맵에 담은 것을 전달 받을 수 있다. 맵을 이용하면 필드의 정보를 문자로 된 키로 가져와야 하고, 값도 모두 스트링 타입이기 때문에 필드의 정보를 담을 클래스를 정의하는 수고는 덜지만 한편으로는 코드의 부담이 늘어날 수도 있다.
@RequestMapping(value="/user/edit", method=RequestMethod.POST)
public String submit(@RequestParam Map userMap) {
    userService.updateUserByUserMap(user);
    return "user/editsuccess";
}
어쩔 수 없이 이런 접근 방법을 사용해야 하는 경우가 있다. 폼에서 받은 정보가 특정 도메인 오브젝트에 매핑되지 않는 특별한 정보인 경우도 있다. 하지만 도메인 오브젝트에 연관된 정보라면 가능한 도메인 오브젝트 접근 방법을 사용하는 것을 권장한다. 그래야 스프링이 제공해주는 편리한 기능을 활용할 수 있으며, 좀 더 객체지향적인 코드를 만들 수 있기 때문이다. 마지막으로 이 문제에 대한 스프링의 해결책을 알아보자.

댓글 없음:

댓글 쓰기