13.11. Convention over configuration 2

이번에는 MVC중에서 M 즉 모델에 적용할 수 있는 Convention을 살펴보겠습니다.

Spring의 컨트롤러에서 모델을 넘겨줄 때 ModelAndView 객체를 사용합니다. 보통은 다음과 같이 사용합니다.

    public ModelAndView list(HttpServletRequest request, HttpServletResponse response){
        return new ModelAndView(“issue/list”, “issueList”, issueService.getAll());
    }

또는 뷰와 모델을 명확하게 구분하기 위해 다음과 같이 사용합니다.

    public ModelAndView list(HttpServletRequest request, HttpServletResponse response){
        return new ModelAndView(“issue/list”)
            .addObject(“issueList”, issueService.getAll());
    }

갑자기 기억나는 이야기지만 객체의 이름은 ModelAndView인데 View먼저 등록하니까 이름을 ViewAndModel로 해야하는 것 아니였나.. 라는 글이 생각납니다.

Anyway addObject 메소드가 오버로딩으로 또 다른 메소드 하나가 더 존재합니다. 그것을 사용하면 코드를 다음과 같이 변경 할 수 있습니다.

    public ModelAndView list(HttpServletRequest request, HttpServletResponse response){
        return new ModelAndView(“issue/list”)
            .addObject(issueService.getAll());
    }

모델의 이름이 생략됐습니다.

바로 이겁니다. 모델의 이름을 생략할 수 있습니다. 생략하고 보내면 View에서는 그럼 이 모델을 어떤 이름으로 찾게 될 것인가?

if(객체타입 == List || Set || 배열){
    객체의 camel-case 맨 뒤에 List를 붙인 이름을 가지게 됩니다.
} else {
    객체의 camel-case 이름을 가지게 됩니다.
}

샘플
x.y.Foo 타입의 객체는 foo 라는 이름을 가지게 됩니다.
x.y.z.FooBar 타입의 객체는 fooBar 라는 이름을 가지게 됩니다.
List<Foo> 타입의 객체는 fooList 라는 이름을 가지게 됩니다.
FooBar[] 타입의 객체는 fooBarList 라는 이름을 가지게 됩니다.

이 메소드를 사용할 때 한 가지 주의할 것이 있습니다.

Note: Empty Collections are not added to the model when using this method because we cannot correctly determine the true convention name. View code should check for null rather than for empty collections as is already done by JSTL tags.

비어있는 콜렉션을 추가할 수 없다는 군요.

13.11. Convention over configuration 1

Spring 2.0에 추가된 기능으로 네이밍으로 규약을 정하고 그것을 지키기만 하면 설정을 대폭 줄일 수 있는 방법이 마련되었습니다.

Request -> 컨트롤러 규약 :: ControllerClassNameHandlerMapping

즉 핸들러 맵핑을 통해 매번 각각의 요청을 처리할 컨트롤러들을 등록했었습니다. 보통 다음과 같은 소스코드를 확인하실 수 있습니다.
사용자 삽입 이미지
ControllerClassNameHandlerMapping 핸들러를 등록하면 다음과 같은 규약을 사용할 수 있습니다.

if(WelcomeController == MultiActionController){
    WelcomeController 는 /welcome/* 요청을 처리하게 됩니다.
} else {
    WelcomeController 는 /welcome* 요청을 처리하게 됩니다.
}

사용하는 방법은 간단합니다. ControllerClassNameHandlerMapping 을 등록해주기만 하면 됩니다.

    <bean id=”handlerMapping” class=”org.springframework.web.servlet.handler.BeanNameUrlHandlerMapping”/>

    <bean class=”org.springframework.web.servlet.mvc.support.ControllerClassNameHandlerMapping”/>

BeanNameUrlHandlerMapping은 CoC를 사용하지 않고 직접 url과 매핑 시켜줄 컨트롤러들을 위해 등록해 줍니다.

이제부터는 요청을 처리하는 컨트롤러를 찾으려고 XXX-sevlet.xml에서 검색하지 않아도 됩니다. 그냥 요청의 이름만 보면 해당 요청을 어디서 처리할 지 알 수 있습니다.

    <bean class=”net.agilejava.nayoung.controller.IssueController” />

컨트롤러를 등록할 때 name이나 id를 주지 않아도 되기 때문에 컨트롤러를 등록할 때 ‘이 컨트롤러가 처리하는 url이 어떤거였더라..’ 라는 고민을 하지 않아도 됩니다.

13.10. Handling exceptions

특정 예외가 발생했을 때 특정 페이지로 이동 시킬 수 있습니다.

먼저 컨트롤러든 서비스 단이든 벨리데이션 할 때든 에러를 발생시킵니다. 예외 처리 할 것도 없기 때문에 RuntimeException이 좋겠네요.

    public void add(MemberInfo memberInfo) {
        if(memberInfo.getConfirmMember().equals(“ajnapaqj”)){
            String email = memberInfo.getEmail();
            if(memberInfoDao.findByEmail(email) == null)
                memberInfoDao.insert(memberInfo);
            else
                throw new AlreadyExistEmailException();
        }
        else
            throw new InvalidMemberCodeException();
    }

저는 서비스 단에 구현했습니다. 사실 고민이 됩니다. 이런 거는 벨리데이터에 넣어야 필드 값 검사할 때 같이 하면 좋을 것 같은데 아직 벨리데이터를 만들지 않았기 때문에 간단하게 서비스 단에 구현해 뒀습니다. 나중에 옮겨야 벨리데이터 만들고 옮겨야 겠습니다.

그리고 사용자 예외를 만들어 줍니다.

public class AlreadyExistEmailException extends RuntimeException {}
public class InvalidMemberCodeException extends RuntimeException {}

그리고 Spring의 ExceptionResolver를 등록해줍니다.

    <!– Exception Resolver –>
    <bean id=”exceptionMapping”
        class=”org.springframework.web.servlet.handler.SimpleMappingExceptionResolver”>
        <property name=”exceptionMappings”>
            <props>
                <prop key=”InvalidMemberCodeException”>
                    /exception/joinFailedByMemberCode
                </prop>
                <prop key=”AlreadyExistEmailException”>
                    /exception/joinFailedByExistingEmail
                </prop>
            </props>
        </property>
    </bean>

이런식으로 Exception과 view 이름을 매핑 시켜 주면 view 리졸버에 의해 view를 찾게 됩니다.

Intercepting requests 예제

어떤 서비스를 로그인 한 유저에게만 제공하고 싶다면…

request를 처리하는 메소드 내에서

if (request.getSession.getAttribute(“user”) != null)
    throw LoginRequiredException();

이런식으로 예외를 발생시키고 이 예외가 발생할 때마다 로그인 페이지로 이동 시키게 할 수 있습니다.

하지만 저런 코드가 여러 부분에서 필요하게 될 것이고 이것을 AOP를 적용하여 Aspect로 빼내고 싶어지는 것이 당연합니다.

그런데 Spring AOP가 조금 복잡하자나열(2.0 에서는 많이 나아졌지만)…pointcut 정의 해줘야 하는데 문법도 좀 희한하고 advice 종류도 다양한데 특정 인자를 받아와야 하는 방법을 써야 하니까 args 표현식을 써야 하고.. 등등 복잡합니다.

그래서.. 간단하게 HadlerMapping과 HandlerInterceptor 를 사용해서 AOP를 적용할 수 있습니다. 요녀석을 사용하면 HttpRequest와 HttpResponse를 인자로 받는 Around Advice를 적용하는 기분이 들 것입니다.

1. Handler Mapping 등록하기
– Handler Mapping을 여러 개 사용할 수 있습니다. 그리고 그 들간의 순서를 order 속성을 사용하여 지정해 줄 수 있습니다. viewResolver를 여러 개 등록하는 거랑 같습니다.

    <bean id=”handlerMapping” class=”org.springframework.web.servlet.handler.BeanNameUrlHandlerMapping”>
        <property name=”order” value=”2″ />
    </bean>

기존에 사용하던 HandlerMapping은 BeanNameUrlHandlerMapping으로 암묵적으로[footnote]명시적으로 handleMapping을 등록하지 않으면 DispacherSevlet이 알아서 이녀석을 만들어서 사용하기 떄문이빈다.[/footnote] 사용하고 있었습니다.

이 녀석을 위에 보시다시피 명시적으로 표기해 주고 순서를 두 번째로 지정합니다. 다음으로는 User를 체크해야 할 request를 처리할 HandlerMapping을 등록합니다.

<bean id=”userCheckHandlerMapping”
          class=”org.springframework.web.servlet.handler.SimpleUrlHandlerMapping”>
        <property name=”order” value=”1″ />
        <property name=”interceptors”>
            <list>
                <ref bean=”memberCheckInterceptor“/>
            </list>
        </property>
        <property name=”mappings”>
            <value>
                /board/createArticle.html = createArticleController
                /board/createReply.html = createReplyController
                /createAuction.html = createLPAuctionController
                /auction/insertAuctionReply.html = insertAuctionReplyController
                /fileUpload.html = uploadFileController
            </value>
        </property>
    </bean>

SimpleUrlHandlerMapping을 사용하여 다섯개의 request를 처리할 것입니다. 저 request 들은 모두 memberCheckInterceptor를 거쳐가게 되어있습니다.

따라서 이제 memberCheckInterceptor만 구현해 주면 구현이 끝납니다.

2. Interceptor 만들기

public class MemberCheckInterceptor extends HandlerInterceptorAdapter {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if(request.getSession().getAttribute(“user”) != null)
            return true;
        else {
            response.sendRedirect(“http://localhost:8080/classicMania/login.html”);
            return false;
        }
    }
}

와.. 간단합니다. user라는 이름의 객체가 세션에 있으면 계속 request를 처리하고 아니면 login 페이지로 넘어가도록 합니다. true는 계속 처리 false는 처리 안하게 합니다. Around Advice에서 해당 메소드를 실행 할지 말지 선택할 수 있었던 것과 유사하지 않나효.

3. 실행


로그인 안 한 상태에서 파일 업로드 페이지로 가려고 하면 => 로그인 페이지로 이동
로그인 한 상태라에서 파일 업로드 페이지로 가려고 하면 => 파일 업로드 페이지 요청을 처리합니다.

MultipartResolver 사용하여 파일 업로드 구현 예제

1. 사용할 Resolver 선택 및 등록
1.1. 필요한 jar 파일 추가

2. 폼 만들기

3. 모델 객체에 파일 다룰 속성 추가
3.1. String, bate[], MultupartFile 중 어떤 타입으로 받을 것인가 선택 및 코딩

4. 컨트롤러 만들기
(4.0. 바인딩 할 꺼면-String, byte[] 사용할 때- PropertyEditor 사용해서 바인딩 하기)
4.1. 디렉토리 설정
4.2. 디렉토리로 원본의 사본을 만들어 넣어 둡니다.

제 나름대로 책보고 예제 만들어 보다가 정리한 순서이지 꼭 저 위에 있는 순서대로 해야 하는건 아닙니다.

1. 사용할 Resolver 선택 및 등록

Cos와 Commons 라이브러리 중에 선택할 수 있습니다. Spring Reference, Pro Spring, Spring MVC 책 모두 Commons 라이브러리를 사용하고 있었습니다. 그래서 저도 그냥 Commons 라이브러리 사용했습니다.

1.1 필요한 Jar 파일 추가

Commons 라이브러리를 사용할 때 필요한 jar 파일은 두 개 입니다. 레퍼런스에는 commons-fileupload.jar 만 필요하다고 나오는데 저 jar 가 commons-io.jar 에 의존성을 가지고 있기 때문에 commons-io.jar도 추가해야 합니다.

2. 폼 만들기

간단합니다. <input type=”file”> 로 해주면 파일 업로드 할 수 있는 버튼이 추가 됩니다. 이 때 폼태그에는 추가해줘야 할 것이 있습니다. request 타입을 multipart를 포함하는 request임을 알려주기 위한 것 같네요.

<form:form commandName=”fileCommand” method=”post” enctype=”multipart/form-data”>
    파일 선택 <input type=”file” name=”file”/><br/>
    <input type=”submit” value=”Submit”/>
</form:form>

3. 모델 객체에 파일 다룰 속성 추가

굳이 모델 객체에 추가하지 않고 커맨드 객체를 사용할 것이라면 커맨드 객체에 속성을 추가해 줍니다. 하지만 전 객체 하나 만드는 것 보다 필드 하나 추가하는 걸 선택했습니다. 따라서 FILE 도메인에 속성 하나를 추가할 것입니다.

3.1. String, bate[], MultupartFile 중 어떤 타입으로 받을 것인가 선택 및 코딩

폼에서 입력되는 파일을 객체의 속성으로 받기 위해 Spring에서 지원해주는 기능으로 사용할 수 있는 타입은 String, byte[], MultipartFile 세가지 입니다. 이 중에서 File 타입으로 받아서 File의 이름과 사이즈 등을 구하려면 역시 MultipartFile을 선택하는 것이 편하겠습니다.

    private String name;
    private Long size;
    private String filePath;
    private MultipartFile file;


4. 컨트롤러 만들기

4.0 String, bytep[], Multipart 로 바인딩 하기

Spring Reference 13.8.3에 보면 예제가 잘 나와있습니다. 저 세가지 중에서 String과 byte[]로 바인딩 할 때는 Spring이 제공하는 별도의 PropertyEditor를 등록해줘야 합니다. 하지만 MultipartFile은 그럴 필요가 없이 그냥 알아서 바인딩 해줍니다.

4.1. 디렉토리 설정

Spring 워크북에는 조금 다른 방식으로 등록했지만 저는 Spring MVC에 나와있는 예제가 사용한 방식을 사용했습니다.
컨트롤러에 File 타입으로 디렉토리를 속성으로 주고 setter injection을 사용합니다.

    <!– MusicFile  –>
    <bean name=”/fileUpload.html”
        class=”com.classicMania.controller.file.UploadFileController”>
        <property name=”destinationDir” value=”D://down” />
    </bean>

4.2. 디렉토리로 원본의 사본을 만들어 넣어 둡니다.

Spring이 제공하는 FileCopyUtils 를 사용하여 간단하게 원본(웹에서 입력하고 있는 파일)을 사본(위에서 설정한 디렉토리로)을 만들 수 있습니다.

컨트롤러의 소스코드를 보면 다음과 같습니다.

public class UploadFileController extends SimpleFormController implements InitializingBean {
    // 4.1
    private File destinationDir;

    public void setDestinationDir(File destinationDir) {
        this.destinationDir = destinationDir;
    }

    public UploadFileController() {
        setCommandName(“fileCommand”);
        setCommandClass(FILE.class);
        setFormView(“file/uploadFile”);
        setSuccessView(“file/viewFileList”);
    }

    public void afterPropertiesSet() throws Exception {
        if (destinationDir == null) {
            throw new IllegalArgumentException(“Must specify destinationDir”);
        }
        else if (!destinationDir.isDirectory() && !destinationDir.mkdir()) {
            throw new IllegalArgumentException(destinationDir + ” is not a ” + “directory, or it couldn’t be created”);
        }
    }

    @Override
    protected ModelAndView onSubmit(HttpServletRequest request, HttpServletResponse response, Object command,
            BindException exception) throws Exception {
        response.setContentType(“text/plain”);
        if (! (request instanceof MultipartHttpServletRequest)) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, “Expected multipart request”);
            return null;
        }
        MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) request;
        MultipartFile file = multipartRequest.getFile(“file”);
        String fileName = file.getOriginalFilename();
        File destination = File.createTempFile(“file”, fileName, destinationDir);
        //4.2
        FileCopyUtils.copy(file.getInputStream(), new FileOutputStream(destination));

        Member member = (Member) request.getSession().getAttribute(“user”);

        FILE newFile = (FILE) command;
        newFile.setMember(member);
        newFile.setFilePath(destination.getAbsolutePath());
        newFile.setName(file.getOriginalFilename());
        newFile.setSize(file.getSize());
        newFile.setFile(file);
        ServiceManager.getMemberService().update(member);
        ServiceManager.getFileService().add(newFile);

        Map<String, Object> model = new HashMap<String, Object>();
        model.put(“files”, ServiceManager.getFileService().getAll());
        model.put(“owner”, member.getName());
        return new ModelAndView(getSuccessView(), model);
    }

}

아~ Spring 좋아라~ 너무 좋아라~

파일 업로드 화면
사용자 삽입 이미지파일 선택 화면
사용자 삽입 이미지파일 업로드 선택
사용자 삽입 이미지파일 업로드 결과
사용자 삽입 이미지
위 컨트롤러에서 설정한 패스(D:\down)에 잘 들어갔습니다.