잊지 않겠습니다.

Spring 3.0에서 처음 도입된 Code Base Configuration은 이제 Spring 설정의 대세가 되어가는 기분이 든다. 

그런데, 이상하게도 Spring Security의 경우에는 Code Base Configuration이 적용되는 것이 매우 늦어지고 있다는 생각이 든다. 최신 3.2.0RC2에서의 Code Base Security를 구현하는 방법에 대해서 간단히 알아보기로 한다. 


1. @EnableWebSecurity Annotation

Spring Security를 사용하는 @Configuration은 @EnableWebSecurity annotation을 반드시 가져야지 된다.


2. WebSecurityConfigurerAdapter의 상속

@Configuration을 구성한 객체는 기본적으로 WebSecurityConfigurerAdapter를 상속받아야지 된다. 이 상속은 기본적인 method의 stub을 제공한다. 


@EnableWebSecurity
@Configuration
@Slf4j
@ComponentScan("co.kr.daesung.app.center.api.web.cors")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    public static final String CORS_SUPPORT_FILTER = "corsSupportFilter";
    public static final String ADMIN_ROLE = "ADMIN";

3. configurer 구현

configurer method를 override시켜서, configuration을 구성한다. 기본적으로 configuration xml에서 구성된 내용들은 configurer method에서 구현된다. 


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .formLogin()
                    .loginPage("/login")
                    .usernameParameter("username")
                    .passwordParameter("password")
                    .defaultSuccessUrl("/main")
                    .failureUrl("/login?error=loginFailed")
                .and()
                .authorizeRequests()
                    .antMatchers("/api/admin/**").hasRole(ADMIN_ROLE)
                    .antMatchers("/api/apiKey/**").authenticated()
                    .antMatchers("/api/message/**").authenticated()
                    .antMatchers("/api/auth/**").authenticated();
    }

기본적으로 구성할 수 있는 login form이다. /login에서 username, password를 이용해서 로그인 하게 되며, login success와 failure를 모두 처리하도록 되어있다. 

4. AuthenticationProvider의 구성

AuthenticationProvider는 UserDetailsService와 PasswordEncoder를 반드시 구성해준다. AuthenticationProvider는 인증을 위한 사용자 Pool과 username, password간의 matching을 하는 방법을 제공한다.  주의점은 UserDetailsService이다. 반드시 BeanIds.USER_DETAILS_SERVICE bean name으로 등록이 되어야지 된다. Spring 내부에서 이 Bean Name을 이용해서 UserDetailsService를 구성하기 때문이다.


    /**
     * UserDetailService와 PasswordEncoder를 이용해서, AuthenticationProvider를 구성한다.
     * @return
     * @throws Exception
     */
    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsServiceBean());
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        return daoAuthenticationProvider;
    }

    /**
     * UserDetailsService를 구성해준다. Bean Name은 반드시 BeanIds.USER_DETAILS_SERVICE로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Override
    @Bean(name = BeanIds.USER_DETAILS_SERVICE)
    public UserDetailsService userDetailsServiceBean() throws Exception {
        CommonAppUserDetailService userDetailsService = new CommonAppUserDetailService();
        userDetailsService.setUserService(context.getBean(UserService.class));
        return userDetailsService;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new PasswordEncoder() {
            @Override
            public String encode(CharSequence rawPassword) {
                    return rawPassword.toString();
            }

            @Override
            public boolean matches(CharSequence rawPassword, String encodedPassword) {
                return rawPassword.equals(encodedPassword);
            }
        };
    }


5. AuthenticationManager 구성

AuthenticationProvider를 이용해서 AuthenticationProvider를 구성해준다. 코드는 매우 단순하다. 다만 개인적으로는 Form Login 부분을 customizing을 시키는 경우가 많기 때문에 AuthenticationManager를 Bean으로 등록시켜두면 두고두고 편한 일들이 많다. 


    /**
     * AuthenticationManager를 LoginController에서 사용하기 위해서는 반드시, @Bean으로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }


전체 코드는 다음과 같다. 


/**
 * Created with IntelliJ IDEA.
 * User: ykyoon
 * Date: 11/18/13
 * Time: 1:12 AM
 * To change this template use File | Settings | File Templates.
 */

@EnableWebSecurity
@Configuration
@Slf4j
@ComponentScan("co.kr.daesung.app.center.api.web.cors")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    public static final String CORS_SUPPORT_FILTER = "corsSupportFilter";
    public static final String ADMIN_ROLE = "ADMIN";

    @Autowired
    private ApplicationContext context;
    @Autowired
    private CorsSupportLoginUrlAuthenticationEntryPoint corsEntryPoint;

    @Bean
    public DigestAuthenticationFilter digestAuthenticationFilter() throws Exception {
        DigestAuthenticationFilter digestAuthenticationFilter = new DigestAuthenticationFilter();
        digestAuthenticationFilter.setAuthenticationEntryPoint(corsEntryPoint);
        digestAuthenticationFilter.setUserDetailsService(userDetailsServiceBean());
        return digestAuthenticationFilter;
    }

    @Bean
    public LoginProcessHandler loginProcessHandler() {
        LoginProcessHandler loginProcessHandler = new LoginProcessHandler();
        loginProcessHandler.setObjectMapper(objectMapper());
        return loginProcessHandler;
    }

    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        return objectMapper;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .formLogin()
                    .loginPage("/login")
                    .usernameParameter("username")
                    .passwordParameter("password")
                    .defaultSuccessUrl("/main")
                    .failureUrl("/login?error=loginFailed")
                .and()
                .authorizeRequests()
                    .antMatchers("/api/admin/**").hasRole(ADMIN_ROLE)
                    .antMatchers("/api/apiKey/**").authenticated()
                    .antMatchers("/api/message/**").authenticated()
                    .antMatchers("/api/auth/**").authenticated();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(daoAuthenticationProvider());
    }

    /**
     * AuthenticationManager를 LoginController에서 사용하기 위해서는 반드시, @Bean으로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * UserDetailService와 PasswordEncoder를 이용해서, AuthenticationProvider를 구성한다.
     * @return
     * @throws Exception
     */
    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsServiceBean());
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        return daoAuthenticationProvider;
    }

    /**
     * UserDetailsService를 구성해준다. Bean Name은 반드시 BeanIds.USER_DETAILS_SERVICE로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Override
    @Bean(name = BeanIds.USER_DETAILS_SERVICE)
    public UserDetailsService userDetailsServiceBean() throws Exception {
        CommonAppUserDetailService userDetailsService = new CommonAppUserDetailService();
        userDetailsService.setUserService(context.getBean(UserService.class));
        return userDetailsService;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new PasswordEncoder() {
            @Override
            public String encode(CharSequence rawPassword) {
                    return rawPassword.toString();
            }

            @Override
            public boolean matches(CharSequence rawPassword, String encodedPassword) {
                return rawPassword.equals(encodedPassword);
            }
        };
    }

    @Bean(name = CORS_SUPPORT_FILTER)
    public Filter corsSupportFilter() {
        CorsSupportFilter corsSupportFilter = new CorsSupportFilter();
        return corsSupportFilter;
    }
}

6. no-web.xml인 경우 등록하는 방법


Servlet 3.0에서 지원되는 web.xml이 없는 개발에서 매우 유용한 방법이다. Spring Security는 Web.xml이 없을때, 아주 쉬운 코드로 WebXmlInitializer를 구성해뒀는데, AbstractSecurityWebApplicationInitializer를 상속받은 Class가 class path에 있는 경우, 자동으로 springSecurityFilterChain 이름의 Bean을 이용하는 DelegateProxyFilter를 등록한다. web.xml인 경우에는 기존과 동일하게 사용하면 된다. 다만 root context에 @Configuration이 로드가 먼저 선행되어야지 된다. 


public class SecurityWebXmlInitializer extends AbstractSecurityWebApplicationInitializer {
}


Posted by Y2K
,

Spring MVC를 이용한 개발에서 가장 멋진 일은 MockMvc를 이용한 테스트다. 특히 Controller의 사용자 Scenario를 짜고, 그 Sceneario의 결과를 테스트 해보는것은 너무 재미있는 일이다. 그런데, Spring MVC에 Spring Security를 적용한 후에 인증에 대한 테스트를 하기 위해서는 다음의 간단한 절차를 거쳐야지 된다. 


1. MockMvc에 Spring Security Filter를 적용해야지 된다.

2. Login 절차를 통과한 사용자를 만들어내야지 된다. - MockHttpSession을 이용한다. 


이와 같은 과정을 조금 단순화하기 위해서 간단한 TestSupport 객체를 만들어봤다. Utility 객체이기 때문에, 하는 일들은 매우 단순하다. 

1. MockMvc에 Spring Security Filter를 적용한 후, Return시킨다.

2. Digest 인증키값을 만들어낼 수 있다.

3. Basic 인증키값을 만들어낼 수 있다.

4. Form 인증이 반영된 MockHttpSession 값을 만들어낼 수 있다.


Helper class 코드는 다음과 같다.


/**
 * User: ykyoon
 * Date: 11/18/13
 * Time: 7:13 PM
 * Spring Security Filter 적용 및 인증 지원을 위한 Test Helper Class
 */
public class AuthorizedControllerHelper {

    /**
     * MockMvc 생성 코드
     * @param context WebApplicationContext
     * @return Spring Security Filter가 적용된 MockMvc 객체
     * @throws Exception
     */
    public static MockMvc getSecurityAppliedMockMvc(WebApplicationContext context) throws Exception {
        DelegatingFilterProxy delegateProxyFilter = new DelegatingFilterProxy();
        MockFilterConfig secFilterConfig = new MockFilterConfig(context.getServletContext(),
                BeanIds.SPRING_SECURITY_FILTER_CHAIN);
        delegateProxyFilter.init(secFilterConfig);

        return MockMvcBuilders.webAppContextSetup(context).addFilter(delegateProxyFilter).build();
    }

    public static final String AUTH_HEADER = "Authorization";

    /**
     * Basic 인증 문자열 생성
     * @param username 사용자 이름
     * @param password 비밀번호
     * @return Basic XXXX 형태의 Authorization 문자열
     * @throws Exception
     */
    public static final String buildBasicAuthHeaderValue(String username, String password) throws Exception {
        String authHeaderFormat = "Basic ";
        String encodingRawData = String.format("%s:%s", username, password);
        String encodingData = authHeaderFormat + new String(Base64.encode(encodingRawData.getBytes("utf-8")));
        return encodingData;
    }

    /**
     * Digest 인증 문자열 생성
     * @param mvc MockMvc. Digest의 경우, 한번의 Request를 통해 서버의 nonce값을 얻어내야지 된다.
     *            Spring Security의 EntryEndPoint의 설정이 DigestAuthenticationFilter로 되어있어야지 된다.
     * @param username 사용자 이름
     * @param password 비밀번호
     * @param uri 호출할 URI
     * @param method HttpRequestMethod : GET, POST, PUT, DELETE
     * @return Digest 인증 문자열
     * @throws Exception
     */
    public static String buildDigestAuthenticateion(MockMvc mvc, String username,
                                                    String password,
                                                    String uri, String method) throws Exception {
        MvcResult mvcResult = null;
        if(method.equals("GET")) {
            mvcResult = mvc.perform(get(uri)).andDo(print()).andReturn();
        } else if(method.equals("POST")) {
            mvcResult = mvc.perform(post(uri)).andDo(print()).andReturn();
        } else if(method.equals("PUT")) {
            mvcResult = mvc.perform(put(uri)).andDo(print()).andReturn();
        } else if(method.equals("DELETE")) {
            mvcResult = mvc.perform(delete(uri)).andDo(print()).andReturn();
        }
        String authHeader = mvcResult.getResponse().getHeader("WWW-Authenticate");
        String[] authHeaderItemStrings = authHeader.split(",\\s");
        Map<String, String> authItems = new HashMap<>();
        Pattern keyAndItemPattern = Pattern.compile("(Digest\\s)?(?<key>[^=]+)=\"(?<value>[^\"]+)\"");
        for(int i = 0 ; i < authHeaderItemStrings.length; i++) {
            Matcher matcher = keyAndItemPattern.matcher(authHeaderItemStrings[i]);
            assertThat(matcher.find(), is(true));
            String key = matcher.group("key");
            String value = matcher.group("value");
            authItems.put(key, value);
        }
        Assert.assertNotNull(authItems.get("realm"));
        Assert.assertNotNull(authItems.get("nonce"));
        Assert.assertNotNull(authItems.get("qop"));

        String ha1 = DigestUtils.md5DigestAsHex(String.format("%s:%s:%s", username, authItems.get("realm"), password).getBytes("UTF-8"));
        String ha2 = DigestUtils.md5DigestAsHex(String.format("%s:%s", method, uri).getBytes("UTF-8"));
        String cnonce = calculateNonce();
        String totalString = String.format("%s:%s:00000001:%s:%s:%s",
                ha1, authItems.get("nonce"), cnonce, authItems.get("qop"), ha2);
        String response = DigestUtils.md5DigestAsHex(totalString.getBytes("UTF-8"));

        String clientRequest = String.format("Digest username=\"%s\",", username) +
                String.format("realm=\"%s\",", authItems.get("realm")) +
                String.format("nonce=\"%s\",", authItems.get("nonce")) +
                String.format("uri=\"%s\",", uri) +
                String.format("qop=%s,", authItems.get("qop")) +
                "nc=00000001," +
                String.format("cnonce=\"%s\",", cnonce) +
                String.format("response=\"%s\"", response);

        return clientRequest;
    }

    /**
     * Form인증을 위한 MockHttpSession 반환 함수
     * @param context WebApplicationContext
     * @param username 사용자 이름
     * @return Spring Security Attribute가 적용된 MockHttpSession 값
     * @throws Exception
     */
    public static MockHttpSession buildSecuritySession(WebApplicationContext context, String username) throws Exception {
        MockHttpSession session = new MockHttpSession();
        SecurityContext securityContext = buildFormAuthentication(context, username);
        session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, securityContext);
        return session;
    }

    /**
     * Spring Security Context 얻어내는 내부 함수
     * @param context WebApplicationContext
     * @param username 사용자 이름
     * @return SecurityContext
     * @throws Exception
     */
    private static SecurityContext buildFormAuthentication(WebApplicationContext context, String username) throws Exception {
        UserDetailsService userDetailsService = (UserDetailsService) context
                .getBean(BeanIds.USER_DETAILS_SERVICE);
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                userDetails,
                userDetails.getPassword(),
                userDetails.getAuthorities());
        SecurityContext securityContext = new SecurityContext() {
            private static final long serialVersionUID = 8611087650974958658L;
            private Authentication authentication;

            @Override
            public void setAuthentication(Authentication authentication) {
                this.authentication = authentication;
            }

            @Override
            public Authentication getAuthentication() {
                return this.authentication;
            }
        };
        securityContext.setAuthentication(authToken);
        return securityContext;
    }

    /**
     * Digest인증시에 사용되는 cnonce 값을 생성하는 내부 함수
     * @return
     * @throws UnsupportedEncodingException
     */
    private static String calculateNonce() throws UnsupportedEncodingException {
        Date d = new Date();
        SimpleDateFormat f = new SimpleDateFormat("yyyy:MM:dd:hh:mm:ss");
        String fmtDate = f.format(d);
        Random rand = new Random(100000);
        Integer randomInt = rand.nextInt();
        return DigestUtils.md5DigestAsHex((fmtDate + randomInt.toString()).getBytes("UTF-8"));
    }
}

Spring Security가 반드시 적용된 Test에서만 사용가능하다. Helper를 이용한 Test code는 다음과 같다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { DomainConfiguration.class, SecurityConfiguration.class, ControllerConfiguration.class })
@WebAppConfiguration
public class AdminNoticeControllerTest {
    @Autowired
    private WebApplicationContext context;
    private MockMvc mvc;
    @Autowired
    private ObjectMapper objectMapper;
    @Autowired
    private NoticeService noticeService;

    @Before
    public void setUp() throws Exception {
        assertThat(context, is(not(nullValue())));
        mvc = AuthorizedControllerHelper.getSecurityAppliedMockMvc(context);
    }

    @Test
    public void getAllNotices() throws Exception {
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(get(AdminNoticeController.API_ADMIN_NOTICES)
                .param("pageIndex", "0")
                .param("pageSize", "10").session(session))
                .andExpect(status().isOk())
                .andReturn();
        checkResultData(result);
    }

    @Test
    public void hideNotice() throws Exception {
        final Page<Notice> notices = noticeService.getNotices(0, 10, false);
        Notice notice = notices.getContent().get(0);
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(post(AdminNoticeController.API_ADMIN_NOTICE_HIDE)
                    .param("noticeId", notice.getId().toString())
                    .session(session))
                .andExpect(status().isOk())
                .andDo(print())
                .andReturn();
        checkResultData(result);
    }

    @Test
    public void editNotice() throws Exception {

    }

    @Test
    public void showNotice() throws Exception {
        final Page<Notice> notices = noticeService.getNotices(0, 10, true);
        Notice notice = notices.getContent().get(0);
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(post(AdminNoticeController.API_ADMIN_NOTICE_SHOW)
                .param("noticeId", notice.getId().toString())
                .session(session))
                .andExpect(status().isOk())
                .andDo(print())
                .andReturn();
        checkResultData(result);
    }

    private void checkResultData(MvcResult result) throws Exception {
        ResultData r = objectMapper.readValue(result.getResponse().getContentAsString(), ResultData.class);
        assertThat(r.isOk(), is(true));
    }
}


Posted by Y2K
,

Spring Security를 이용한 REST API를 개발할 때, 외부 사이트와의 데이터 연동을 위해 CORS를 적용하는 것도 매우 좋은 선택이다. 

일반적으로 Cross domain 문제를 해결하기 위해서, JSONP를 이용하는 경우가 많지만 이 방법은 두가지 이유때문에 개인적으로는 추천하지 않는다. 


1. GET method만을 이용 가능 - 데이터를 많이 보내야지 되는 경우가 발생할 수 있고, 무엇보다 REST한 API를 만드는데 제약사항이 발생하게 된다.

2. 인증 문제 - 인증을 받아서 처리를 해줘야지 되는 API의 경우에는 JSONP를 이용할 수 없다. 모든 정보를 API서버에만 위치하고, HTML + javascript로만 동작하는 web application을 작성하는 것이 목적이라면 인증을 처리하기 위해서라도 JSONP가 아닌 CORS를 적용해야지 된다. 


그러나 CORS 역시 많은 제약사항을 가지고 있다. 기본적인 제약사항들은 다음과 같다.  


1. GET, HEAD, POST 만 사용 가능하다.

2. POST의 경우에는 다음과 같은 조건이 경우에만 사용가능하다.

  1) content-type이 application/x-www.form-urlencoded, multipart/form-data, text/plain의 경우에만 사용 가능하다.

  2) customer Header가 설정이 된 경우에는 사용 불가하다. (X-Modified etc...)

3. Server에서 Access-Control-Allow-Origin 안에 허용여부를 결정해줘야지 된다. 


큰 제약사항은 위 3가지지만, 세부적으로는 preflight 문제가 발생하게 된다. preflight란, POST로 외부 site를 call 할때, OPTIONS method를 이용해서 URL에 접근이 가능한지를 다시 한번 확인하는 절차를 거치게 된다. 이때, 주의할 점이 WWW에서 제약한 사항은 분명히 content-type이 application/xml, text/xml의 경우에만 preflight가 발생한다고 되어있으나, firefox나 chrome의 경우에는 text/plain, application/x-www-form-urlencoded, multipart/form-data 모두에서 prefligh가 발생하게 된다. 


위 이론을 먼저 알고, Spring Security를 적용한 REST API Server를 구축하기 위해서는 다음과 같은 절차가 우선 필요하다.


1. Spring Security Form authentication endPoint의 변경

: Spring Security Form authentication은 인증되지 않은 Request가 접근한 경우, login page로 302 redirect를 발생시킨다. 이렇게 되면 API를 이용해서 로그인의 실패등을 확인하기 힘들기 때문에, 인증되지 않은 Request를 처리하는 방법을 달리 해줘야지 된다. 기본적으로 Digest Authentication 역시 www는 지원하기 때문에 Digest 인증 방식의 end point를 이용해서 인증되지 않은 request가 접근한 경우 302가 아니라 401(NotAuthenticated)를 반환할 수 있도록 Spring Configuration을 변경하도록 한다. 


2. CSRF disable

: 다른 domain에서의 API call이 발생하기 때문에 CSRF salt cookie값을 얻어내는 것은 불가능하다. 따라서, CSRF를 disable시켜야지 된다. (이 부분은 해결방법이 다른 것이 있는지 확인이 필요)


3. Login Processing을 재구현

: Spring Security를 이용한 Form Authentication의 경우, login success의 경우에도 마찬가지로 redirect가 발생하게 된다. AuthenticationSuccessHandler interface를 이용해서 변경시켜주거나, Login / Logout을 아애 새로 만들어주는 것이 필요하다. 개인적으로는 Login / Logout을 새로 만들어주는 것을 선호하는 편인데, Controller에 대한 Test code 역시 만들어주는 것이 가능하고, 좀더 깔끔해보이는 느낌이 든다.


4. CORS Filter의 적용

: 모든 response 에 Allow-Origin header를 삽입해주는 Filter 객체가 반드시 필요하다. 


Spring Security의 Form 인증을 새로 만들어주기 위해서는 다음과 같이 Controller를 만들 필요가 있다.


@Controller

public class LoginController {

    public static final String API_LOGIN = "/api/login";

    public static final String API_LOGOUT = "/api/logout";

    @Autowired

    private AuthenticationManager authenticationManager;


    @Autowired

    private UserService userService;


    @RequestMapping(value= API_LOGIN, method = {RequestMethod.GET, RequestMethod.OPTIONS})

    @ResponseBody

    @ResultDataFormat

    public Object getAuthenticationStatus() {

        Authentication auth = SecurityContextHolder.getContext().getAuthentication();

        if (auth != null && !auth.getName().equals("anonymousUser") && auth.isAuthenticated()) {

            User user = userService.findByUsername(auth.getName());

            return new LoginStatus(true, auth.getName(), user.getName());

        } else {

            return new LoginStatus(false, null, null);

        }

    }


    @RequestMapping(value= API_LOGIN, method = RequestMethod.POST)

    @ResponseBody

    @ResultDataFormat

    public Object login(@RequestParam("username") String username, @RequestParam("password") String password) {

        try {

            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);

            User user = userService.findByUsername(username);

            token.setDetails(user);

            Authentication auth = authenticationManager.authenticate(token);

            SecurityContextHolder.getContext().setAuthentication(auth);

            return new LoginStatus(auth.isAuthenticated(), user.getUsername(), user.getName());

        } catch (Exception e) {

            return new LoginStatus(false, null, null);

        }

    }


    @RequestMapping(value = API_LOGOUT)

    @ResponseBody

    @ResultDataFormat

    public Object logout(HttpServletRequest request) {

        HttpSession session = request.getSession(false);

        if(session != null) {

            session.invalidate();

        }

        SecurityContextHolder.clearContext();

        return new LoginStatus(false, null, null);

    }


    @Getter

    public class LoginStatus {

        private final boolean isAuthenticated;

        private final String username;

        private final String name;

        public LoginStatus(boolean loggedIn, String username, String name) {

            this.isAuthenticated = loggedIn;

            this.username = username;

            this.name = name;

        }

    }

}




다음에는 CORS Filter 적용 및 Spring Security Configuration을 이용해서 REST Server 구성을 더 해보도록 하겠다.




Posted by Y2K
,

gradle은 maven과 다르게 provided가 지원되지 않는다. 

지원되지 않기 때문에 다양한 방법으로 provided를 사용하고 있는데, spring.org에서 해결책을 내놓은 plugin으로 해결 가능하다. 


github 주소는 이곳에, 

https://github.com/spring-projects/gradle-plugins/tree/master/propdeps-plugin


사용법은 아래와 같다. 

buildscript {
    repositories {
        maven { url 'http://repo.springsource.org/plugins-release' }
    }
    dependencies {
        classpath 'org.springframework.build.gradle:propdeps-plugin:0.0.1'
    }
}

// ...

configure(allprojects) {
    apply plugin: 'propdeps'
    apply plugin: 'propdeps-maven'
    apply plugin: 'propdeps-idea'
    apply plugin: 'propdeps-eclipse'
}


dependencies {
    compile("commons-logging:commons-logging:1.1.1")
    optional("log4j:log4j:1.2.17")
    provided("javax.servlet:javax.servlet-api:3.0.1")
    testCompile("junit:junit:4.11")
}
기존의 idea, eclipse task역시 반영을 받기 때문에 매우 유용하게 사용 가능하다.


Posted by Y2K
,
기존 maven을 사용할 때, 개인 개발 환경에서 개인적으로 가장 문제를 느낀 것은 개인 property파일의 문제였습니다. 
개인 property 파일을 local로 만들어주고, 그 local을 이용할 때 그 파일들은 따로 svn으로 관리하기가 매우 힘들었던 단점을 가지고 있습니다. 

예를 들어 logback의 설정파일안에 ykyoon은 c:\Logs안에 log 파일을 만들때, 다른 사람들은 d:\Logs에 만드는 등, property 파일들간의 충돌이 가장 큰 문제중 하나였습니다. 

따라서, 이러한 개인 property 파일들의 관리 및 svn이나 git를 이용한 버젼관리까지 같이 해주기 위해서는 사용자의 build system마다 다른 build 경로를 가질 수 있어야지 됩니다. 제가 gradle을 보게 된 가장 큰 이유중 하나입니다. 

그리고, Web Project의 경우에는 jetty 또는 tomcat을 이용해서 local run이 가능한 것이 개발의 속도를 높여줍니다. 
한가지 더 추가를 한다면, ~Web으로 시작되는 project들은 tomcat/jetty를 이용해서 running이 가능하다면 좋습니다. 

이와 같은 조건을 모두 만족하는 build.gradle을 만들어본다면 다음과 같습니다. 

1. 각 개발자 PC 상황에 맞는 resources path 설정

: 개발자들은 자신의 개인 PC를 이용해서 개발을 합니다. 네트워크의 충돌을 막기 위해서 일반적으로 hostname은 모두 다르게 구성됩니다. 따라서, hostname을 기반으로 하는 것이 좋은 것으로 판단됩니다.

hostname을 얻어내기 위해서는 다음과 같은 script를 구성하면 됩니다. 

        String hostname = InetAddress.getLocalHost().getHostName();
        if(hostname.endsWith('.local')) {   //맥의 경우, .local 이 모든 hostname에 추가됩니다.
            hostname.replace(".local", '')
        }

mac에서만 local host의 이름에 쓸데없는 값이 들어갈 수 있기 때문에 일단 제거를 시켜줍니다. 
그리고, 모든 subproject에서 이와 같은 속성을 사용하기 때문에 subproject를 다음과 같은 gradle script를 구성해줍니다. 
아래의 script결과, hostname이 ykyoon인 경우, 기존 src/main/resources, src/main/resources-ykyoon 이 모두 포함이 됩니다. 개발자들은 공통적인 resource의 경우에는 src/main/resources에 넣어주고, 추가적인 개인 환경에 따른 resources를 모두 자신의 호스트 이름이 들어있는 path에 넣어주고 svn이나 git를 통해서 파일을 관리해주면 됩니다. 

또한, 최종 build시에는 -P argument를 이용해서 target을 지정해주면, 배포환경에 따른 resources 역시 처리가 가능하게 됩니다. 

ext {
    javaVersion = 1.7
}

subprojects {
    apply plugin: 'java'
    apply plugin: 'eclipse-wtp'
    apply plugin: 'idea'

    if(project.hasProperty('target')) {
        sourceSets {
            main.resources.srcDirs = ['src/main/resources', "src/main/resources-${project.target}"]
        }
    } else {
        String hostname = InetAddress.getLocalHost().getHostName();
        if(hostname.endsWith('.local')) {   //맥의 경우, .local 이 모든 hostname에 추가됩니다.
            hostname.replace(".local", '')
        }
        sourceSets {
            main.resources.srcDirs = ['src/main/resources', "src/main/resources-" + hostname]
        }
    }

    repositories {
        mavenCentral()
    }

    dependencies {
        compile 'org.slf4j:slf4j-api:1.7.5'
        testCompile "junit:junit:4.11"
    }
   
    sourceCompatibility = rootProject.ext.javaVersion
    targetCompatibility = rootProject.ext.javaVersion

    tasks.withType(Compile) {
        options.encoding = 'UTF-8'
    }
}


2. webProject의 local tomcat / jetty runner 설정

webProject를 구성할 때, 자신의 local server를 쉽게 구현할 수 있다는 것은 큰 장점입니다. javascript나 css, html의 수정을 바로바로 해볼 수 있다는 점에서 개발자의 편의를 증대시킬 수 있는 방법중 하나입니다. 

먼저, 기본적으로 webProject의 경우에는 모든 project가 'Web'으로 끝이 난다고 가정을 하면 좀 더 편하게 접근이 가능합니다. 모든 project들에 따로 설정을 해줄 필요도 없고 보다 직관적인 접근이 가능하기 때문에 개인적으로는 추천하는 방법입니다. 

project의 이름이 'Web'으로 끝나는 project 만을 선택하기 위해서 다음과 같은 configure를 설정합니다. 

configure(subprojects.findAll { it.name.endsWith('Web') }) {
     apply plugin: 'war'
}

이 subproject들은 이제 모두 'war' plugin을 갖습니다. 이제 tomcat과 jetty plug in을 이용해서 tomcat과 jetty를 실행시킬 수 있는 환경을 구축해야 됩니다. 

기본적으로 jetty의 경우, jetty plugin을 gradle에서 기본적으로 지원하고 있으나, jetty의 버젼이 6.0인 관계로 현 상황에서는 그다지 쓸만하지 못합니다. 3rd party plugin을 이용해서 최신의 jetty를 지원할 수 있도록 구성을 변경해야지 됩니다. 

외부 library를 끌어와서 gradle에서 사용해야지 되기 때문에 buildscripts 항목을 다음과 같이 추가합니다. 

    repositories {
        jcenter()
        maven { url = 'http://dl.bintray.com/khoulaiz/gradle-plugins' }
    }
    dependencies {
        classpath (group: 'com.sahlbach.gradle', name: 'gradle-jetty-eclipse-plugin', version: '1.9.+')
    }

이제 jetty를 사용할 수 있는 준비가 모두 마쳐졌습니다. configure에 이제 jetty plugin을 넣어주면, jettyEclipseRun/jettyEclipseWarRun task를 통해서 sub project들을 바로 web 으로  보여주는 것이 가능합니다. 

마지막으로 tomcat입니다. tomcat의 경우에는 gradle에서 제공되는 tomcat plugin이 제공되지 않았으나, 최근에 추가되어 활발히 사용되고 있습니다. 지금 버젼은 1.0이 나왔으며 아주 쉽게 사용이 가능합니다. buildscripts 항목에 다음을 추가합니다. 

classpath 'org.gradle.api.plugins:gradle-tomcat-plugin:1.0'
    
그리고 configure에 다음 항목을 추가합니다. 

    apply plugin: 'tomcat'

    dependencies {
        String tomcatVersion = '7.0.11'
        tomcat "org.apache.tomcat.embed:tomcat-embed-core:${tomcatVersion}"
        tomcat "org.apache.tomcat.embed:tomcat-embed-logging-juli:${tomcatVersion}"
        tomcat("org.apache.tomcat.embed:tomcat-embed-jasper:${tomcatVersion}") {
            exclude group: 'org.eclipse.jdt.core.compiler', module: 'ecj'
        }
    }

위 항목의 추가로 tomcatRun을 실행시킬 수 있습니다. 

3. sonar analysis의 추가

code의 품질을 테스트 하고 code 품질에 대한 지속적인 monitoring을 통해서 project를 발전시키는 것이 가능합니다. 개발자들의 실력 향상에도 도움을 줄 수 있을 뿐 아니라 잠재적인 오류를 찾아내기 위해서라도 sonar analysis는 추가하는 것이 좋습니다. gradle에서는 sonar에 대해서 plugin을 지원하고 있으며, sonar version이 3.4 이하인 경우와 3.5 이상인 경우에 사용되는 plugin이 다름을 주의해야지 됩니다.  이 글은 sonar 3.5이상을 기준으로 구성되었습니다. 

sonar plugin은 언제나 multiple project의 root에 위치해야지 됩니다. 

apply plugin : 'sonar-runner'

그리고, sonar 서버에 대한 설정을 다음과 같이 root project에 넣어줍니다. 

sonarRunner {
    sonarProperties {
        property "sonar.host.url", "http://192.168.13.209:9000"
        property "sonar.jdbc.url", "jdbc:mysql://192.168.13.209:3306/sonar"
        property "sonar.jdbc.driverClassName", "com.mysql.jdbc.Driver"
        property "sonar.jdbc.username", "root"
        property "sonar.jdbc.password", 'qwer12#$'
    }
}

마지막으로 subprojects에 sonar가 해석할 code의 character set을 지정해주면 됩니다. 

    sonarRunner {
        sonarProperties {
            property "sonar.sourceEncoding", "UTF-8"
        }
    }

다만, 이렇게 한 경우 code coverage가 나오지 않습니다. 그 이유는 sonar-runner의 경우, jacoco를 가지고 있지 않습니다. 따라서, jacoco 설정을 추가해야지 됩니다. 

jacoco는 각 subproject들에 설정이 되어야지 되기 때문에 subprojects 안에 다음 plug in을 설정합니다. 

apply plugin : 'jacoco'

그리고, jacoco 설정을 넣습니다. jacoco결과를 sonar에서 해석하기 위한 exec 파일과 classdump 파일의 위치를 결정해야지 됩니다. sonar-runner는 기본적으로 ${projectDir}/target/jacoco.exec 에 있는 output 파일을 읽습니다. 현재 sonar-runner의 output 파일 load 경로를 바꾸는 방법은 없습니다. 따라서 jacoco plugin의 설정을 반드시 ${projectDir}/target/jacoco.exec로 바꿔줘야지 됩니다.  test를 행할때, 반드시 jacoco가 실행이 되도록 다음과 같이 설정합니다. 또한 output 파일 경로를 target/jacoco.exec로 변경시켜줍니다.

    test {
        jacoco {
            append = false
            destinationFile = file("target/jacoco.exec")
            classDumpFile = file("target/classpathdumps")
        }
    }

이제 gradle sonarRunner를 실행하면 sonar에 결과를 모두 표시하는 것을 볼 수 있습니다. 


4. jenkins 등록

gradle project의 경우 jenkins에서는 plugin 형태로 build process를 지원하고 있습니다. plugin 항목에서 gradle을 추가해야지 됩니다.

1) project type은 free-style software project로 등록합니다.

2) build는 invoke Gradle script로 수행합니다. 
* system에 gradle이 이미 설치가 되어 있어야지 됩니다.
* task는 sonarRunner로 등록

3) Test 결과를 jenkins에 등록합니다. sonar를 통해서도 보는 것이 가능하지만, jenkins에서 간단한 정보와 trend는 확인이 가능합니다. 더 자세한 정보를 원한다면 sonar를 통해 확인하면 더 좋습니다. 

a. findbugs analysis results : sonar에서는 ${rootProject}/build/sonar/${subProject}/findbugs-result.xml 로 findbugs result를 기록합니다. 파일 타잎을 **/findbugs-result.xml 로 지정해주면 됩니다. 


b. JUnit test 결과 등록 : sonar의 JUnit test 결과는 ${project}/build/test-reports/TEST-{testClassName}.xml 형태로 저장되어 있습니다. XMLs 항목을 **/TEST-*.xml 로 지정해주면 모든 test결과를 jenkins에 기록합니다.


c. JaCoCo coverage report 등록 : jacoco의 결과값은 위 설정에서 target/jacoco.exec로 이미 지정되어 있습니다. 각각의 project의 source code path만 조정하면 됩니다.
* Path to exec files : **/**.exec
* Path to class directories : **/classes/main
* Path to source directories : **/src/main/java
* Exclusions : **/classes/test


위와 같이 등록하면 이제 jenkins에서 모든 결과를 확인하고 볼 수 있습니다.




5. hot deploy의 지원

작성된 war를 개발자의 PC에서 테스트 환경 또는 배포환경으로 배포하는 과정을 지원하면 java로 만든 web project의 build과정 및 deploy과정이 모두 마쳐지게 됩니다. 
이 부분에 있어서 gradle은 따로 제공되고 있는 것이 없습니다. 대신 cargo plugin을 이용해서 hot deploy를 할 수 있습니다. cargo plugin은 지금 github에서 지원되고 있습니다.

먼저, 외부 library를 사용하기 때문에 buildscript에 다음을 추가해야지 됩니다. 

buildscript {
    repositories {
        mavenCentral()
    }

    dependencies {
        classpath 'org.gradle.api.plugins:gradle-cargo-plugin:0.6.1'
    }
}

다음 war로 묶일 subproject의 configure에 plugin을 추가합니다. 

apply plugin: 'cargo'

dependencies {
    def cargoVersion = '1.3.3'
    cargo "org.codehaus.cargo:cargo-core-uberjar:$cargoVersion",
                "org.codehaus.cargo:cargo-ant:$cargoVersion"
}

여기서, dependencies에 cargo는 cargo에서만 임시로 사용될 dependency입니다. war의 providedCompile과 동일합니다. 

hot deploy될 remote server의 정보를 적어줍니다. 

cargo {
    containerId = 'tomcat7x'
    port = 8080

    deployable {
        context = "${project.name}"
    }

    remote {
        hostname = '192.168.13.209'
        username = 'ykyoon'
        password = 'qwer12#$'
    }
}

cargo는 tomcat, jetty, weblogic 등 유수의 web container를 지원합니다. 지원하는 container마다 Id를 부여하고 있으며, 아이디들은 https://github.com/bmuschko/gradle-cargo-plugin 에서 확인 가능합니다. 

이제 다음 명령어를 통해서 tomat에 deploy가 가능합니다. 

gradle cargoRemoteDeploy

기존에 Deploy가 되었다면 재배포가 이루어져야지 되기 때문에 
gradle cargoRemoteRedeploy
를 이용해서 배포가 가능합니다. 

추가로, cargo는 war로 만드는 작업을 하지 않습니다. war로 묶는 작업이 없기 때문에 war가 없으면 에러를 발생합니다. war task와 dependency를 잡아주기 위해서 다음 코드를 추가하면 더 편합니다. 

    cargoRedeployRemote {
        dependsOn war
    }

    cargoDeployRemote {
        dependsOn war
    }

이제 gradle을 이용한 java web project의 build의 전체 과정을 할 수 있는 script가 완성되었습니다. build를 관리하고, 자동화 하는 것은 개발자들이 보다 더 개발에 집중하고 나은 결과를 내기 위해서입니다. 또한 관리의 목적도 있습니다. 관리 및 공유의 목적을 가장 잘 달성하기 위한 방법으로 저는 개인적으로는 아래와 같이 생각합니다.

1. IDE 관련 파일은 SCM에 upload하지 않습니다. 지금 만들어진 script를 이용하면 eclipse, intelliJ 모두의 IDE load file을 만들어낼 수 있습니다. 각자의 개발환경이 겹치게 되면 개발에 어려움을 가지고 오게 됩니다. 개발에 필요하고 환경에 undependency한 file들만 올리는 것이 필요합니다.
2. jenkins와 같은 CI tool을 svn commit과 연동하지 않습니다. 연동 후, commit이 발생할 때마다 code coverage와 같은 report를 commit당으로 만들 필요는 없습니다. 시간을 정해두고 하는 CI가 더 효율적이라고 생각됩니다.

마지막으로 전체 script는 다음과 같습니다.

apply plugin: 'base'
apply plugin: 'sonar-runner'

import com.sahlbach.gradle.plugins.jettyEclipse.*

buildscript {
    repositories {
        jcenter()
        maven { url = 'http://dl.bintray.com/khoulaiz/gradle-plugins' }
    }
    dependencies {
        classpath (group: 'com.sahlbach.gradle', name: 'gradle-jetty-eclipse-plugin', version: '1.9.+')
        classpath 'org.gradle.api.plugins:gradle-tomcat-plugin:1.0'
        classpath (group: 'org.gradle.api.plugins', name: 'gradle-cargo-plugin', version: '0.6.+')
    }
}

ext {
    javaVersion = 1.7
}

sonarRunner {
    sonarProperties {
        property "sonar.host.url", "http://192.168.13.209:9000"
        property "sonar.jdbc.url", "jdbc:mysql://192.168.13.209:3306/sonar"
        property "sonar.jdbc.driverClassName", "com.mysql.jdbc.Driver"
        property "sonar.jdbc.username", "root"
        property "sonar.jdbc.password", 'qwer12#$'
    }
}

subprojects {
    apply plugin: 'java'
    apply plugin: 'eclipse-wtp'
    apply plugin: 'idea'
    apply plugin: 'jacoco'

    sonarRunner {
        sonarProperties {
            property "sonar.sourceEncoding", "UTF-8"
        }
    }

    test {
        jacoco {
            append = false
            destinationFile = file("target/jacoco.exec")
            classDumpFile = file("target/classpathdumps")
        }
    }

    if(project.hasProperty('target')) {
        sourceSets {
            main.resources.srcDirs = ['src/main/resources', "src/main/resources-${project.target}"]
        }
    } else {
        String hostname = InetAddress.getLocalHost().getHostName();
        if(hostname.endsWith('.local')) {   //맥의 경우, .local 이 모든 hostname에 추가됩니다.
            hostname.replace(".local", '')
        }
        sourceSets {
            main.resources.srcDirs = ['src/main/resources', "src/main/resources-" + hostname]
        }
    }

    repositories {
        mavenCentral()
    }

    dependencies {
        compile 'org.slf4j:slf4j-api:1.7.5'
        testCompile "junit:junit:4.11"
    }
    
    sourceCompatibility = rootProject.ext.javaVersion
    targetCompatibility = rootProject.ext.javaVersion

    tasks.withType(Compile) {
        options.encoding = 'UTF-8'
    }
}

configure(subprojects.findAll { it.name.endsWith('Web') }) {
    apply plugin: 'war'
    apply plugin: 'jettyEclipse'
    apply plugin: 'tomcat'
    apply plugin: 'cargo'

    String configPath = project.rootProject.projectDir.absolutePath.toString() + "/config/jetty/webdefault.xml"

    dependencies {
        String tomcatVersion = '7.0.11'
        tomcat "org.apache.tomcat.embed:tomcat-embed-core:${tomcatVersion}"
        tomcat "org.apache.tomcat.embed:tomcat-embed-logging-juli:${tomcatVersion}"
        tomcat("org.apache.tomcat.embed:tomcat-embed-jasper:${tomcatVersion}") {
            exclude group: 'org.eclipse.jdt.core.compiler', module: 'ecj'
        }

        def cargoVersion = '1.3.3'
        cargo "org.codehaus.cargo:cargo-core-uberjar:$cargoVersion",
                "org.codehaus.cargo:cargo-ant:$cargoVersion"
    }

    jettyEclipse {
        automaticReload = true
        webDefaultXml = file(configPath)
    }

    cargoRedeployRemote {
        dependsOn war
    }

    cargoDeployRemote {
        dependsOn war
    }

    cargo {
        containerId = 'tomcat7x'
        port = 8080

        deployable {
            context = "${project.name}"
        }

        remote {
            hostname = '192.168.13.209'
            username = 'ykyoon'
            password = 'qwer12#$'
        }
    }
}


모두 Happy codiing!!

모든 프로젝트는 GitHub에 올려뒀습니다. 참고하실 분들은 참고해주세요. ^^


Posted by Y2K
,
code의 품질을 높이기 위해서 자주 쓰이는 방법중 하나는 static java code analysis를 통해서 code의 품질을 평가하는 겁니다. 

자주 쓰이는 방법으로는 checkstyle, jdepend, PMD, findbugs 등이 있고, 이러한 방법들을 체계적으로 잘 관리해주는 tool로는 sonar가 있습니다. 

1. checkstyle

apply plugin : 'checkstyle'

을 사용해서 checkstyle을 사용할 수 있습니다. checkstyle은 사용되는 check rule에 대한 xml이 반드시 존재해야지 되며, 이는 다음과 같이 설정이 가능합니다. 

checkstyle {
    configFIle = file('config/checkstyle/sun_checks.xml')
}

그리고, checkstyle의 결과는 xml 파일로 나오게 되며 이 결과는 jenkins, hudson 등의 CI tool에서 결과를 확인하는데 사용하게 됩니다. 
report path는 기본적으로 build/checkstyle 폴더에 위치하게 되며, 이 폴더의 위치와 xml 파일의 이름은 다음과 같이 설정하면 됩니다. 

checkstyle {
    reportDir = file("${buildDir}/checkstyle-output")
}
checkStyleMain {
    reports {
       xml.destination = file("${checkstyle.reportsDir}/checkstyle.xml")
     }
}

gradle에서 사용하기 위해서는 gradle checkStyleMain task를 구동하면 checkStyle 검사를 행하게 됩니다. 

2. PMD

PMD는 code style에서 발생할 수 있는 error들을 검증하는 방법입니다. checkstyle은 coding에 있어서 style만을 본다면 PMD는 io에 대한 close check, exception 등에 대한 정확한 handling이 되었는지 판별하게 됩니다. 대체적으로 PMD는 경험적인 rule이 만들어진 결과입니다. 나중에 보실 findbugs와는 조금 다른 code analysis를 보여줍니다.

PMD는 pmd plugin을 설치해서 사용 가능합니다. 

apply plugin : 'pmd'

pmd의 결과는 기본적으로 html format으로 나오게 됩니다. 또한 결과를 xml로도 볼수 있는데, xml결과는 후에 jenkins와 같은 CI tool에서 이용이 가능합니다. 이에 대한 설정은 다음과 같이 해주면 됩니다. 

pmdMain {
     reports {
          xml.destination = file("${pmd.reportsDir}/pmd.xml")
          html.enabled = false
          xml.enabled = true
     }
}


3. findbugs


findbugs는 정형적인 버그를 발견할 수 있는 tool입니다. findbugs를 이용하면 버그가 발생할 수 있는 상황을 확인하는 것이 가능합니다. type의 변환시에 값의 크기가 작아서 오작동을 일으킨다던지, 무한 loop를 돌 수 있는 pattern을 찾아낼 수 있는 방법들을 제공합니다. 

findbugs는 findbugs plugin을 설치해서 사용합니다. 


apply plugin: 'findbugs'


역시 findbugs도 PMD와 checkstyle과 같이 xml/html의 결과를 같이 보여주게 됩니다. report에 대한 설정은 다음과 같습니다. 

findbugsMain {
     reports {
          xml.enabled = true
          html.enabled = false
          xml.destination = file("${findbugs.reportsDir}/findbugs.xml")
     }
}


multi project에서 checkstyle, PMD, findbugs의 이용

multi project의 경우에는 위의 설정이 모두 subprojects에 들어가면 됩니다. 그렇지만, 위의 설정대로라면 조금 문제가 발생하게 됩니다. 각 subproject들의 checkstyle, PMD, findbugs에 warning이 발견되면 즉시 build가 중지되기 때문입니다. 따라서, 각 plugin들에 failure가 발생되더라도 계속해서 진행하는 option을 걸어줘야지 됩니다. 
또한, 각 static code analysis의 결과를 모아서 볼 필요가 있습니다. 각각의 결과들을 깊은 depth를 갖는 folder명에서 보는 것은 조금 힘듭니다. 따라서 root project가 report 결과를 모으는 작업을 할 필요가 존재합니다. 

output folder에 모든 report의 결과는 다음과 같이 표시하면 됩니다. 


ext {
     checkStylePath = file('config/checkstyle.xml')
     reportPath = file('output/report');
     reportPathA = reportPath.absolutePath.toString()
}

if(!ext.reportPath.exists()) { // 폴더가 없는 경우에 신규 생성
     ext.reportPath.mkdir()
}


subprojects {

     apply plugin: 'java'
     apply plugin: 'checkstyle'
     apply plugin: 'pmd'
     apply plugin: 'findbugs'

     sourceCompatibility = 1.7
     targetCompatibility = 1.7

     checkstyle {
          configFile = rootProject.ext.checkStylePath
          ignoreFailures = true
     }

     pmd {
          ignoreFailures = true
     }

     findbugs {
          ignoreFailures = true
     }

     findbugsMain {
          reports {
               xml.enabled = true
               html.enabled = false
               xml.destination = file("${rootProject.ext.reportPathA}/findbug/${project.name}.xml")
          }
     } 

     pmdMain {
          reports {
               xml.destination = file("${rootProject.ext.reportPathA}/pmd/${project. name}.xml")
               html.enabled = false
          }
     }

     checkstyleMain {
          reports {
               xml.enabled = true
               xml.destination = file("${rootProject.ext.reportPathA}/checkstyle/ ${project.name}.xml")
               // html.enabled = false
          }
     }

}


위와 같은 build script를 이용하면 다음과 같이 한개의 폴더 안에 잘 정리된 코드 분석 결과를 얻어낼 수 있습니다.






Posted by Y2K
,

gradle 정리 - code analysis

Java 2013. 10. 28. 13:07
code의 품질을 높이기 위해서 자주 쓰이는 방법중 하나는 static java code analysis를 통해서 code의 품질을 평가하는 겁니다. 

자주 쓰이는 방법으로는 checkstyle, jdepend, PMD, findbugs 등이 있고, 이러한 방법들을 체계적으로 잘 관리해주는 tool로는 sonar가 있습니다. 

1. checkstyle

apply plugin : 'checkstyle'

을 사용해서 checkstyle을 사용할 수 있습니다. checkstyle은 사용되는 check rule에 대한 xml이 반드시 존재해야지 되며, 이는 다음과 같이 설정이 가능합니다. 

checkstyle {
    configFIle = file('config/checkstyle/sun_checks.xml')
}

그리고, checkstyle의 결과는 xml 파일로 나오게 되며 이 결과는 jenkins, hudson 등의 CI tool에서 결과를 확인하는데 사용하게 됩니다. 
report path는 기본적으로 build/checkstyle 폴더에 위치하게 되며, 이 폴더의 위치와 xml 파일의 이름은 다음과 같이 설정하면 됩니다. 

checkstyle {
    reportDir = file("${buildDir}/checkstyle-output")
}
checkStyleMain {
    reports {
       xml.destination = file("${checkstyle.reportsDir}/checkstyle.xml")
     }
}

gradle에서 사용하기 위해서는 gradle checkStyleMain task를 구동하면 checkStyle 검사를 행하게 됩니다. 

2. PMD

PMD는 code style에서 발생할 수 있는 error들을 검증하는 방법입니다. checkstyle은 coding에 있어서 style만을 본다면 PMD는 io에 대한 close check, exception 등에 대한 정확한 handling이 되었는지 판별하게 됩니다. 대체적으로 PMD는 경험적인 rule이 만들어진 결과입니다. 나중에 보실 findbugs와는 조금 다른 code analysis를 보여줍니다.

PMD는 pmd plugin을 설치해서 사용 가능합니다. 

apply plugin : 'pmd'

pmd의 결과는 기본적으로 html format으로 나오게 됩니다. 또한 결과를 xml로도 볼수 있는데, xml결과는 후에 jenkins와 같은 CI tool에서 이용이 가능합니다. 이에 대한 설정은 다음과 같이 해주면 됩니다. 

pmdMain {
     reports {
          xml.destination = file("${pmd.reportsDir}/pmd.xml")
          html.enabled = false
          xml.enabled = true
     }
}


3. findbugs

findbugs는 정형적인 버그를 발견할 수 있는 tool입니다. findbugs를 이용하면 버그가 발생할 수 있는 상황을 확인하는 것이 가능합니다. type의 변환시에 값의 크기가 작아서 오작동을 일으킨다던지, 무한 loop를 돌 수 있는 pattern을 찾아낼 수 있는 방법들을 제공합니다. 

findbugs는 findbugs plugin을 설치해서 사용합니다. 


apply plugin: 'findbugs'


역시 findbugs도 PMD와 checkstyle과 같이 xml/html의 결과를 같이 보여주게 됩니다. report에 대한 설정은 다음과 같습니다. 

findbugsMain {
     reports {
          xml.enabled = true
          html.enabled = false
          xml.destination = file("${findbugs.reportsDir}/findbugs.xml")
     }
}


multi project에서 checkstyle, PMD, findbugs의 이용

multi project의 경우에는 위의 설정이 모두 subprojects에 들어가면 됩니다. 그렇지만, 위의 설정대로라면 조금 문제가 발생하게 됩니다. 각 subproject들의 checkstyle, PMD, findbugs에 warning이 발견되면 즉시 build가 중지되기 때문입니다. 따라서, 각 plugin들에 failure가 발생되더라도 계속해서 진행하는 option을 걸어줘야지 됩니다. 
또한, 각 static code analysis의 결과를 모아서 볼 필요가 있습니다. 각각의 결과들을 깊은 depth를 갖는 folder명에서 보는 것은 조금 힘듭니다. 따라서 root project가 report 결과를 모으는 작업을 할 필요가 존재합니다. 

output folder에 모든 report의 결과는 다음과 같이 표시하면 됩니다. 

ext {
     checkStylePath = file('config/checkstyle.xml')
     reportPath = file('output/report');
     reportPathA = reportPath.absolutePath.toString()
}

if(!ext.reportPath.exists()) { // 폴더가 없는 경우에 신규 생성
     ext.reportPath.mkdir()
}


subprojects {

     apply plugin: 'java'
     apply plugin: 'checkstyle'
     apply plugin: 'pmd'
     apply plugin: 'findbugs'

     sourceCompatibility = 1.7
     targetCompatibility = 1.7

     checkstyle {
          configFile = rootProject.ext.checkStylePath
          ignoreFailures = true
     }

     pmd {
          ignoreFailures = true
     }

     findbugs {
          ignoreFailures = true
     }

     findbugsMain {
          reports {
               xml.enabled = true
               html.enabled = false
               xml.destination = file("${rootProject.ext.reportPathA}/findbug/${project.name}.xml")
          }
     } 

     pmdMain {
          reports {
               xml.destination = file("${rootProject.ext.reportPathA}/pmd/${project. name}.xml")
               html.enabled = false
          }
     }

     checkstyleMain {
          reports {
               xml.enabled = true
               xml.destination = file("${rootProject.ext.reportPathA}/checkstyle/ ${project.name}.xml")
               // html.enabled = false
          }
     }

}


위와 같은 build script를 이용하면 다음과 같이 한개의 폴더 안에 잘 정리된 코드 분석 결과를 얻어낼 수 있습니다.


Posted by Y2K
,
지금까지 우리는 View를 살펴봤습니다. 지금까지 본 View들은 File upload만을 제외하고, 사용자의 입력이 Controller단으로 전달되는 것이 전혀 없는 View입니다. 
기본적으로 Web은 URL을 통해서, 그리고 POST의 경우에는 Body에 실린 parameter를 통해서 값의 전달이 이루어지게 됩니다. 그리고, 이 값은 Controller를 통해서 전달됩니다. 
이제 Controller의 다른 기능인 사용자 Action을 받는 기능에 대해서 알아보도록 하겠습니다.  

다시 한번 기억하는 것으로 Controller의 기능은 두가지입니다. 

1. Client에게 View를 전달하는 주체
2. Client에서 데이터를 전달받는 주체

먼저, 아주 기본적인 HTML입니다. (Tiles를 사용해서 head 부분은 제거 되었습니다.)

<form method="post" class="example-form">
    <fieldset>Book</fieldset>
    <label>책 제목</label>        
    <input type="text" name="title" placeholder = "책 제목을 입력해주세요."/>
    <label>작가 이름</label>
    <input type="text" name="author" placeholder = "작가 이름을 넣어주세요."/>
    <label>추가 설명</label>
    <input type="text" name="comment" placeholder = "추가 설명을 넣어주세요."/>
    <br/>
    <button type="submit" class="btn">추가</button>
</form>


그리고, 이 form의 데이터를 받는 Controller의 action 코드는 다음과 같이 작성될 수 있습니다. 

    @RequestMapping(value = "/book/add01", method = RequestMethod.POST)
    public String add(String title, String author, String comment) {
        Book book = new Book();
        book.setTitle(title);
        book.setComment(comment);
        book.setAuthor(author);
        bookService.add(book);

        return "redirect:add01";
    }

값의 전달방법은 html의 element의 name이 각각의 parameter의 input으로 들어가게 됩니다. 이 부분의 코드를 보다 더 명확히 해준다면 다음과 같이 작성될 수 있습니다.

    @RequestMapping(value = "/book/add01", method = RequestMethod.POST)
    public String add(@RequestParam(value = "title") String title,
            @RequestParam(value = "author") String author,
            @RequestParam(value = "comment") String comment) {
        Book book = new Book();
        book.setTitle(title);
        book.setComment(comment);
        book.setAuthor(author);
        bookService.add(book);

        return "redirect:add01";
    }

입력값이 되는 title, author, comment를 보다더 명확하게 전달하게 되는 것을 볼 수 있습니다. 그런데, 이와 같은 개발 방법은 많은 코딩양을 가지고 오게 됩니다. 그리고, OOP 개발자들은 이렇게 생각할 수 있습니다.

"객체를 전달할 방법은 없을까?" 

다음과 같은 코드로서 말이지요. 

    @RequestMapping(value = "/book/add02", method = RequestMethod.POST)
    public String add02(Book book) {
        bookService.add(book);
        return "redirect:add01";
    }

Controller code만을 고치고, 테스트 코드를 이용해서 한번 알아보도록 하겠습니다.  값의 return 정상적으로 Book 객체가 생성이 되었는지를 알아보기 위해서 Controller 코드와 Test code를 잠시 수정했습니다. redirect시에는 model을 전달할 수 없기 때문에 redirect 시에 임시로 값을 저장하는 RedirectAttribute를 이용, FlashMap에 값을 저장하는 것으로 Controller code를 변경하였습니다. 변경된 결과와 테스트 코드는 다음과 같습니다. 

//BookController
    @RequestMapping(value = "/book/add02", method = RequestMethod.POST)
    public String add02(Book book, final RedirectAttributes redirectAttribute) {
        bookService.add(book);
        redirectAttribute.addFlashAttribute("book", book);
        return "redirect:add01";
    }
//BookControllerTest
    @Test
    public void add02() throws Exception {
        String bookTitle = "Book Title";
        String bookAuthor = "Book Author";
        String bookComment = "Book Comment";

        MvcResult mvcResult = mvc.perform(post("/book/add02")
                .param("title", bookTitle)
                .param("author", bookAuthor)
                .param("comment", bookComment))
                .andExpect(status().isFound())
                .andExpect(flash().attributeExists("book"))
                .andDo(print())
                .andReturn();
        
        Book book = (Book) mvcResult.getFlashMap().get("book");
        assertThat(book.getTitle(), is(bookTitle));
        assertThat(book.getComment(), is(bookComment));
        assertThat(book.getAuthor(), is(bookAuthor));
        
        System.out.println(mvcResult);
    }

예상대로 객체를 그대로 넘겨서 받을 수 있는 것을 알 수 있습니다. form을 통해서 데이터가 전달되게 되면 다음과 같은 형식으로 데이터가 전달되게 됩니다. 

title=BookTitle&author=BookAuthor&comment=BookComment

만약에 Controller에서 객체를 받게 된다면, property의 이름을 이용해서 값을 자동으로 연결(Bind)하게 됩니다. 이를 annotation을 통해서 보다 더 명확하게 적어주면 다음과 같은 코드로 표현될 수 있습니다. 

    @RequestMapping(value = "/book/add02", method = RequestMethod.GET)
    public String add02(Model model) {
        Book book = new Book();
        model.addAttribute("book", book);
        return "book/add02";
    }

    @RequestMapping(value = "/book/add02", method = RequestMethod.POST)
    public String add02(@ModelAttribute Book book, final RedirectAttributes redirectAttribute) {
        bookService.add(book);
        redirectAttribute.addFlashAttribute("book", book);
        return "redirect:add01";
    }


이 부분은 View에서 Controller에서 값이 전달되는 방법입니다. Form을 통해서 뿐 아니라 javascript를 이용한 ajax call 역시 같은 방법으로 데이터가 전달이 되게 됩니다. 이제 jquery를 통해서 값의 전달을 해보도록 하겠습니다. 

먼저 간단히 ajax에 대해서 알아보도록 하겠습니다. 

Ajax는 Synchronous JavaScript And XML(비동기 자바 스크립트와 XML)의 약자로 서버와의 비동기 통신을 이용해 마치 데스크탑 애플리케이션을 사용하는 것과 같은 사용자와 애플리케이션간의 인터랙티브한 사용자 경험을 가능하게 하는 스크립트 언어입니다.

이전의 동기 통신에서는 웹 애플리케이션이 서버와의 인터랙션을 필요로 할 때에 매번 브라우저가 사용자와의 인터랙션을 멈추고, 서버로부터의 응답이 올때까지 기다려야 했습니다. 서버로부터의 응답이 오기 전까지 사용자는 아무것도 할 수 없었죠.
하지만 비동기 통신에서는 서버로부터의 응답을 기다릴 필요 없이 사용자는 계속해서 애플리케이션에서 원하는 작업을 할 수 있습니다.

Ajax를 이용하는 예로는 구글맵, 검색사이트 검색창에서의 검색어 제시, 네이버 실시간 검색 순위 등이 있습니다.


Ajax의 장점

Ajax의 주요 장점은 아래와 같습니다.

1) 페이지 이동없이 고속으로 화면 전환
: Ajax는 페이지의 이전 없이 필요한 부분의 데이터 송수신만을 자유롭게 행할 수 있으므로, 효율적이고 빠르게 페이지를 전환할 수 있습니다.

2) 서버의 처리를 기다리지 않고 비동기 요청이 가능
: 서버와의 통신시 사용자는 서버로부터의 응답을 기다리지 않고 계속해서 다음 작업을 이어갈 수 있습니다.

3) 서버에서 처리하는 부분을 클라이언트에서 분담 가능
: Ajax로는 최소의 데이터만을 브라우저에 전달하기 위해 서버에서 하는 작업 중 JavaScript에서 수행 가능한 일을
클라이언트에서 분담하는 것이 가능합니다.

4) 수신하는 데이터의 양을 줄일 수 있음
: 기존의 브라우저가 수신하는 데이터는 HTML이나 XHTML과 같은 마크업 언어로 받는 것이 일반적인데 반해, Ajax로는 수신하는 데이터가 HTML이나 XML에 한정되지 않고 최소한의 텍스트 데이터로도 수신이 가능하기 때문에 수신 데이터이 양을 줄일 수 있습니다.

5) 실시간 인터렉티브 성능이 증가
: (1) ~ (4) 까지의 장점을 이용해 Ajax에서는 데스크탑 애플리케이션과 유사한 실시간 인터랙티브 성능을 보여줄 수 있습니다.

Ajax의 단점

Ajax의 단점은 아래와 같습니다.

1) 크로스 브라우저화의 노하우가 필요
: Ajax는 JavaScript 이므로 브라우저에 따른 크로스 브라우저 처리가 필요합니다.

2) Ajax를 지원하지 않는 브라우저에 대한 대책 필요
: Ajax를 지원하지 않는 브라우저에서는 사용이 불가능하므로 이에 대한 대책이 필요합니다. (하지만 현재 Ajax를 지원하지 않는 브라우저는 거의 없다고 볼 수 있습니다.)

3) 보안에 대한 주의가 불가피
: 페이지 이동 없이 서버와 통신하기 때문에 전보다 더욱 신중한 보안상의 주의가 요구됩니다.

4) 현재의 처리 상황에 대한 정보가 필요
: 페이지 전환 없이 처리가 진행되므로 사용자가 처리가 완료되었는데도 이를 모를 수 있습니다. 따라서 처리 중을 나타내는 프로그레시브 바등을 사용하는 것이 요구됩니다.

5) 요청을 남발하면 역으로 서버 부하가 늘 수 있음
: Ajax의 장점은 서버 부하의 감소에 있지만 그 의도와 반대로 요청이 너무 빈번하게 일어나 서버의 부하를 늘려버릴 수 있습니다. 따라서 데이터 전송량, 요청 회수, 서버 부하 등에 대한 종합적인 판단과 튜닝이 필요합니다.

6) Socket open / close의 부하 시간 증가
: 브라우저에서 가장 큰 부하를 주는 http call을 open을 자주 시키게 됩니다. URL이동으로 인하여 한번에 모든 데이터를 들고오는 방식을 ajax로 여러번을 나눠서 받는 경우, socket의 open/close 시간이 http call 횟수만큼 증가하게 됩니다. 적정한 ajax call이 필요합니다.

7) 개발의 어려움
: 1)항목과 연관이 있는 이야기입니다. javascript는 브라우져에 따라 동작이 다르게 움직여집니다. javascript를 테스트하기 위해서는 지금 상태로는 browser를 반드시 실행시켜서 그 결과를 확인해야지 됩니다. 상당한 수작업이 들어가기 때문에 ajax를 통해서 일을 진행을 해야지 된다면, 그에 대한 pattern등을 명확히 지정해야지 됩니다. 필요없이 모든 것을 다 ajax로 하게 되면 개발 시간뿐 아니라 부하역시 무시할 수 없기 때문입니다.


ajax의 장/단점을 한번 알아봤습니다. 아무리 단점이 있다고 해도, 기술적으로 가장 큰 장점인 비동기적 요청과 서버의 부하분산을 위해서 반드시 ajax는 사용해야지 됩니다. Book을 추가하는 code를 한번 ajax로 구현해보도록 하겠습니다. (기본적으로 jquery를 이용하도록 하겠습니다.)

다음은 html과 javascript입니다. 기존과 다른 점은 값의 전달을 form의 submit이 아닌 button에 event를 bind시켜서 사용하고 있다는 점입니다. 

<script>
$(function() {
  $('#add-button').on('click', function() {
      var title = $('form input[name=title]').val();
      var author = $('form input[name=author]').val();
      var comment = $('form input[name=comment]').val();
      addBook(title, author, comment);
  });
});

function addBook(title, comment, author) {
    $.post("add03", 
            {
                "title" : title,
                "author" : author,
                "comment" : comment
            }, 
            function(jsonResult){
                alert(jsonResult);
            }, 'json')
            .done(function(jsonResult) {
                console.log(jsonResult);
            })
            .fail(function(jsonResult) {
                console.log(jsonResult);
            });
            
}
</script>

<form method="post" class="example-form">
    <fieldset>Book</fieldset>
    <label>책 제목</label>        
    <input type="text" name="title" placeholder = "책 제목을 입력해주세요."/>
    <label>작가 이름</label>
    <input type="text" name="author" placeholder = "작가 이름을 넣어주세요."/>
    <label>추가 설명</label>
    <input type="text" name="comment" placeholder = "추가 설명을 넣어주세요."/>
    <br/>
    <button id="add-button" type="button" class="btn">추가</button>
</form>

다음은 Controller 코드입니다.

    @RequestMapping(value = "/book/add03", method = RequestMethod.GET)
    public String add03(Model model) {
        Book book = new Book();
        model.addAttribute("book", book);
        return "book/add03";
    }

    @RequestMapping(value = "/book/add03", method = RequestMethod.POST)
    @ResponseBody
    public Map<String, String> add03(@ModelAttribute Book book) {
        bookService.add(book);
        Map<String, String> bookDto = new HashMap<>();
        bookDto.put("title", book.getTitle());
        bookDto.put("author", book.getAuthor());
        bookDto.put("comment", book.getComment());

        return bookDto;
    }

기본적으로 json을 이용해서 book을 하나 insert 하게 되는 것을 볼 수 있습니다. 
지금까지 book을 추가하는 코드에 대해서 form을 이용하는 방법과 ajax를 이용하는 방법, 두가지 방법을 봤습니다. 하나 더 다른 내용을 보여드리겠습니다. 방금의 예시는 json을 이용해서 book에 대한 값을 다시 받아서 그 값을 이용해서 무언가 작업을 하는 것입니다. 만약에 paging을 해줘야지 되는 일이 발생한다면 이 것은 어떻게 처리를 해줘야지 될까요? 

2가지 방법이 있습니다. 

1. json을 만들어서 html 형식으로 문자열을 만든 다음에 특정 <div> section에 넣어주는 방법
2. Controller에서 html형식의 View를 return 시키고, 그 View를 특정 <div> section에 넣어주는 방법이 있습니다. 

이 두가지 방법에 대해서 알아보도록 하겠습니다. 

Ajax + JSON을 이용한 Paging

JSON을 이용해서 HTML을 만드는것은 HTML code 자체를 만드는 방법입니다. 먼저 Controller code를 살펴보도록 하겠습니다. 

    @RequestMapping(value = "book/list/index")
    public String getBookListIndex() {
        return "book/list/index";
    }

    @RequestMapping(value = "book/list/jsonpage", method = RequestMethod.POST)
    @ResponseBody
    public List<Book> getBookJsonList(@RequestParam(value = "pageIndex") int pageIndex,
            @RequestParam(value = "pageSize") int pageSize) {
        List<Book> books = bookService.listup(pageIndex, pageSize);
        return books;
    }

Controller는 2개의 method를 가지고 있습니다. 1개는 book list의 기본을 표시할 page이고, 2 번째 method는 book에 대한 json 값을 return 하는 method입니다. 이에 대한 HTML code를 살펴보도록 하겠습니다. 

<script type ="text/javascript">
$(function() {
    $('#btn-getbookList').on( 'click', function () {
        var pageIndex = parseInt($('input[name=pageIndex]' ).val());
        var pageSize = parseInt($('input[name=pageSize]' ).val());
        getBookList(pageIndex, pageSize);
    });
});

function getBookList(pageIndex, pageSize) {
    $.post("jsonpage", {
        pageIndex : pageIndex,
        pageSize : pageSize
    }, function(jsonResult) {
        var html = '<table class="table table-striped table-bordered table-hover">';
        for(var i = 0 ; i < jsonResult.length ; i++) {
            html += '<tr>';
            html += '<td>' + jsonResult[i].title + '</td>' ;
            html += '<td>' + jsonResult[i].author + '</td>' ;
            html += '<td>' + jsonResult[i].comment + '</td>' ;
            html += '</tr>';
        }
        html += '</table>';
        $( '#bookPage').html(html);       
    }, 'json');
}

</script>

<label>Page Index</label>
<input name ="pageIndex" type="number" />
<br/>
<label>Page Size</label>
<input name ="pageSize" type="number" value="3"/>
<br/>
<input type ="button" id="btn-getbookList" value="GetBookList"/>

<fieldset>
    <legend>Book List page </legend>
    <div id="bookPage" ></div>
</fieldset>

javascript를 이용해서 button에 click event를 bind시키고 있습니다. 그리고 getBookList function은 json을 통해서 얻어온 BookList의 숫자대로 loop를 돌아서 HTML code를 생성하는 것을 볼 수 있습니다. 최종적으로는 <table> 코드가 만들어져서 bookPagae라는 div tag에 추가 되는 형식으로 동작하게 됩니다. 


Ajax + HTML을 이용한 Paging

이 방법은 Controller에서 HTML View를 이용해서 보여지는 방법입니다. 서버단에서 View를 만들어주고, 그 View를 보여주는 방식으로 동작하게 됩니다. 이 View는 위 JSON으로 만들어지는 HTML과 완전히 동일한 HTML을 보내게 됩니다. 먼저 Controller 코드부터 살펴보도록 하겠습니다. 

    @RequestMapping( value = "book/list/index2" )
    public String getBookListIndex2() {
        return "book/list/index2" ;
    }

    @RequestMapping( value = "book/list/html" , method = RequestMethod.POST )
    public String getBookHtmlList( @RequestParam(value = "pageIndex") int pageIndex,
            @RequestParam(value = "pageSize") int pageSize ,
            Model model ) {
        List<Book > books = bookService. listup(pageIndex , pageSize);
        model.addAttribute ("books", books);
        return "book/list/html" ;
    }

Controller code는 매우 단순합니다. JSON으로 보내는것과 매우 유사합니다. 다만,  getBookHtmlList method에서 View name을 return 하는것만 다릅니다. book/list/html의 view code는 다음과 같이 구성됩니다.  view code는 바뀔 부분에 대한 HTML만을 보내게 됩니다. 

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
<%@ taglib prefix="tiles" uri="http://tiles.apache.org/tags-tiles" %>
<%@ page language= "java" contentType ="text/html; charset=UTF-8" pageEncoding="UTF-8" %>

<table class ="table table-striped table-bordered table-hover">
<c:forEach var="book" items="${books } ">
    <tr>
        <td>${book.title }</td>
        <td>${book.author }</td>
        <td>${book.comment }</td>
    </tr>
</c:forEach>
</table>

위 javascript로 구성되는 html과 완전히 동일한 html을 만들것입니다. 이제 book/list/index2 view code를 살펴보도록 하겠습니다.

<script type="text/javascript">
$(function() {
    $('#btn-getbookList').on( 'click', function () {
        var pageIndex = parseInt($('input[name=pageIndex]' ).val());
        var pageSize = parseInt($('input[name=pageSize]' ).val());
        getBookList(pageIndex, pageSize);
    });
});

function getBookList(pageIndex, pageSize) {
    $.post("html", {
        pageIndex : pageIndex,
        pageSize : pageSize
    }, function(htmlResult) {
        $( '#bookPage').html(htmlResult);       
    });
}

</script >

<label>Page Index</label>
<input name ="pageIndex" type="number" />
<br/>
<label>Page Size</label>
<input name ="pageSize" type="number" value="3"/>
<br/>
<input type ="button" id="btn-getbookList" value="GetBookList"/>

<fieldset>
    <legend>Book List page </legend>
    <div id="bookPage" ></div>
</fieldset>

javascript에서 보내진 데이터를 그대로 div element에 집어넣은것 이외에는 차이가 없습니다. 


두 방법의 장,단점은 다음과 같습니다. 

방법장점단점
Ajax + JSON1. 데이터 전송량이 작다
2. client에서 html을 처리하기 때문에 server의 부하가 작다
1. javascript로 html을 만들어줘야지 되기 때문에 코드 작성에 어려움이 있다.
Ajax + HTML1. HTML + Server 코드이기 때문에 코드 작성이 간편하다

1. 데이터 전송량이 많다.
2. 서버에서 html rendering이 되기 때문에 server의 부하가 발생한다.


이 두 방법이 각각 장,단점을 가지고 있지만 어떤 방법이 더 우월하다고는 보기 힘듭니다. Ajax + HTML의 서버부하는 HTML cache를 통해서 충분히 해결이 될 수 있는 문제이고, javascript의 경우에도 구조화와 객체화를 통해서 충분히 쉬운 코드를 작성할 수 있습니다. 이것은 Style의 문제입니다. 그리고 개발에서 한가지 방법만을 정해두고 사용하기 보다는 주와 부를 나눠서 주는 Ajax + JSON으로 하고, javascript가 너무 복잡해지는 코드의 경우에는 Ajax + HTML로 구성하는것도 충분히 좋은 방법이라고 할 수 있습니다. 


Posted by Y2K
,

* 사내 강의용으로 사용한 자료를 Blog에 공유합니다. Spring을 이용한 Web 개발에 대한 전반적인 내용에 대해서 다루고 있습니다.


View는 다양한 방법으로 표현이 가능합니다. 지금까지 우리는 Html로만 표현되는 View를 알아봤습니다. 그럼 다른 Type의 View는 어떤 것들이 있을까요?

먼저, 가장 자주 쓰이는 Excel과 pdf가 있을수 있습니다. 그리고, json 형태로 표현되어서 다른 이기종간의 데이터 교환으로 사용될 수 있는 json format이 있을 수 있습니다.
마지막으로 View에서 넘길수 있는 특이한 형태로서, 파일을 upload를 알아보도록 하겠습니다. 

1. Excel

excel 파일을 생성하기 위해서는 apache poi jar가 필요합니다. apache poi는 microsoft office 파일을 다루기 위한 java open source library입니다. 
poi에서 다룰 수 있는 파일 종류는 다음과 같습니다. 

# Excel 
# Word
# PowerPoint
# Visio
# Outlook data file
# MS Publisher

2001년부터 시작된 오래된 java project 중 하나입니다. 참고 사이트는 다음과 같습니다. http://poi.apache.org/
poi를 사용하기 위해서 pom.xml에 다음 항목을 추가합니다. 

    <dependency>
      <groupId>org.apache.poi</groupId>
      <artifactId>poi</artifactId>
      <version>3.9</version>
    </dependency>
    <dependency>
      <groupId>net.sourceforge.jexcelapi</groupId>
      <artifactId>jxl</artifactId>
      <version>2.6.12</version>
    </dependency>

Excel 파일을 다루기 위해서는 org.springframework.web.servlet.view.document.AbstractJExcelView 를 상속받는 View를 구성해야지 됩니다. 이 View는 poi를 기반으로 구성이 되어 있으며, 다음 1개의 method만 재 정의하면 excel 파일을 작성할 수 있습니다.  

Spring에서는 Excel에 대해서 2개의 Abstract class를 제공하고 있습니다. AbstractJExcelView와 AbstractExcelView가 바로 그것입니다. 이 둘의 차이는 API의 사용 유무에 따라 다릅니다. JExcelView는 jexcelapi를 기반으로 구성되어 있으며, AbstractExcelView는 poi를 기반으로 구성되어 있습니다. 어느것이나 사용해도 좋지만, 좀 더 사용하기 편한 jexcelapi를 이용해보도록 하겠습니다.


    /**
     * Subclasses must implement this method to create an Excel Workbook
     * document, given the model.
     * @param model the model Map
     * @param workbook the Excel workbook to complete
     * @param request in case we need locale etc. Shouldn't look at attributes.
     * @param response in case we need to set cookies. Shouldn't write to it.
     * @throws Exception in case of failure
     */
    protected abstract void buildExcelDocument(Map<String, Object> model, WritableWorkbook workbook,
            HttpServletRequest request, HttpServletResponse response) throws Exception;

다음은 excel 파일을 만드는 View 코드입니다. 
public class BookExcelView extends AbstractJExcelView {
    @Override
    protected void buildExcelDocument(Map<String, Object> model, WritableWorkbook workbook, HttpServletRequest request, HttpServletResponse response)
            throws Exception {
        String fileName = createFileName();
        setFileNameToResponse(request, response, fileName);

        WritableSheet sheet = workbook.createSheet("책목록", 0);

        sheet.addCell(new Label(0, 0, "제목"));
        sheet.addCell(new Label(1, 0, "저자"));
        sheet.addCell(new Label(2, 0, "설명"));
        sheet.addCell(new Label(3, 0, "출판일"));

        @SuppressWarnings("unchecked")
        List<Book> books = (List<Book>) model.get("books");

        int row = 1;
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
        for (Book book : books) {
            sheet.addCell(new Label(0, row, book.getTitle()));
            sheet.addCell(new Label(1, row, book.getAuthor()));
            sheet.addCell(new Label(2, row, book.getComment()));
            sheet.addCell(new Label(3, row, dateFormat.format(book.getPublishDate())));
            row++;
        }
    }

    private void setFileNameToResponse(HttpServletRequest request, HttpServletResponse response, String fileName) {
        String userAgent = request.getHeader("User-Agent");
        if (userAgent.indexOf("MSIE 5.5") >= 0) {
            response.setContentType("doesn/matter");
            response.setHeader("Content-Disposition", "filename=\"" + fileName + "\"");
        } else {
            response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
        }
    }

    private String createFileName() {
        SimpleDateFormat fileFormat = new SimpleDateFormat("yyyyMMdd");
        return new StringBuilder("책리스트").append("-").append(fileFormat.format(new Date())).append(".xls").toString();
    }
}

그리고 작성된 View는 생성의 비용을 줄이기 위해서 applicationContext에 다음과 같이 등록할 수 있습니다. 

<bean id="bookExcelView" class="com.xyzlast.mvc.bookstore.view.BookExcelView"/>

사용하는 Controller 코드는 다음과 같습니다. 
    @RequestMapping(value = "exceldownload", method=RequestMethod.GET)
    public ModelAndView downloadExcel() {
        List<Book> books = bookService.getAll();
        ModelAndView mv = new ModelAndView();
        mv.addObject("books", books);
        mv.setView(bookExcelView);
        return mv;
    }

Controller를 통해 다운 받은 Excel 파일은 다음과 같습니다. 



2. pdf - iText 2.x ~ 4.x

pdf 파일 포멧은 문서 포멧으로 가장 각광받는 포멧입니다. OS platform에 중립적인 문서 포멧으로 각광을 받고 있습니다. pdf 파일을 만들기 위해서는 iText가 필요합니다. 기본적으로 Spring은 iText 2.x대를 기반으로 구성이 되어있습니다. iText의 내용은 너무나 방대하기 때문에, http://itextpdf.com/book/ 를 참고해주시길 바랍니다. 
기본적으로 org.springframework.web.servlet.view.document.AbstractPdfView 객체를 상속받아서 pdf의 내용을 구현하는 것을 기본으로 하고 있습니다. 먼저, iText를 사용하기 위해서 다음 항목을 pom.xml에 추가합니다. 

<dependency>
    <groupId>com.lowagie</groupId>
    <artifactId>itext</artifactId>
    <version>2.1.7</version>
</dependency>


그리고, Pdf를 만들기 위한 View를 추가합니다. 
public class BookPdfView2 extends AbstractPdfView {

    @Override
    protected void buildPdfDocument(Map<String, Object> model, Document document, PdfWriter writer, HttpServletRequest request, HttpServletResponse response)
            throws Exception {
        String fileName = createFileName();
        setFileNameToResponse(request, response, fileName);

        Chapter chapter = new Chapter(new Paragraph("this is english"), 1);
        chapter.add(new Paragraph("이건 메세지입니다."));
        document.add(chapter);
    }

    private void setFileNameToResponse(HttpServletRequest request, HttpServletResponse response, String fileName) {
        String userAgent = request.getHeader("User-Agent");
        if (userAgent.indexOf("MSIE 5.5") >= 0) {
            response.setContentType("doesn/matter");
            response.setHeader("Content-Disposition", "filename=\"" + fileName + "\"");
        } else {
            response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
        }
    }

    private String createFileName() {
        SimpleDateFormat fileFormat = new SimpleDateFormat("yyyyMMdd_HHmmss");
        return new StringBuilder("설문조사").append("-").append(fileFormat.format(new Date())).append(".pdf").toString();
    }
}

buildPdfDocument를 override 시켜서 문서를 만드는것을 확인해주세요. Controller에서 사용하는 방법은 Excel과 동일하기 때문에 생략하도록 하겠습니다. 꼭 한번 해주시길 바랍니다. 이 코드에는 심각한 버그가 하나 있습니다. ^^


3. pdf - iText 5.x


위에 소개드린것처럼 기본적으로 Spring은 2.x대의 iText를 기반으로 구성되어 있습니다. 그렇지만, iText 5.x대를 사용하도록 View 인터페이스를 구현하면 iText 5.x대의 버젼을 사용할 수 있습니다. iText 5.x를 이용한 새로운 View를 만들어보도록 하겠습니다. 
먼저, iText 5.x를 추가하기 위해서 pom.xml에 다음 설정을 추가합니다. 

<dependency>
    <groupId>com.itextpdf</groupId>
    <artifactId>itextpdf</artifactId>
    <version>5.4.0</version>
</dependency>


iText가 버젼업이 되면서 가장 문제가 되는 것은 package 명이 변경되었다는 점입니다. 사용되는 객체들의 이름은 변경된 것이 없지만, 전체 package 명이 바뀌면서 기존의 AbstractPdfView를 사용할 수가 없게 되어버렸습니다. 새로운 View를 만들어서 iText5를 지원할 수 있도록 해봅시다. 
View를 새로 만들어줄 때는 Spring에서 제공하는 AbstractView를 상속받아서 처리하는 것이 일반적입니다. IText5를 지원하기 위해서, AbstractIText5PdfView 객체를 생성했습니다. 
override된 다음 method들을 살펴볼 필요가 있습니다. 

    protected boolean generatesDownloadContent();
    protected final void renderMergedOutputModel(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception;
    protected void writeToResponse(HttpServletResponse response, ByteArrayOutputStream baos) throws IOException;

# generatesDownloadContents
Download가 이루어질 View인지, 아니면 Html과 같이 Rendering될 View인지를 결정하는 method입니다. true/false로 설정하면됩니다.

# renderMergedOutputModel
실질적으로 View가 생성되는 코드입니다. 여기에서는 iText를 이용한 pdf document를 생성하는 코드가 위치하게 됩니다.

# writeToResponse
HttpResponseServlet에 output을 보내는 코드입니다. 

만들어진 AbstractIText5PdfView의 전체 코드는 다음과 같습니다.

public
abstract class AbstractIText5PdfView extends AbstractView { public AbstractIText5PdfView() { setContentType("application/pdf"); } @Override protected boolean generatesDownloadContent() { return true; } @Override protected final void renderMergedOutputModel(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception { ByteArrayOutputStream baos = createTemporaryOutputStream(); Document document = newDocument(); PdfWriter writer = newWriter(document, baos); prepareWriter(model, writer, request); buildPdfMetadata(model, document, request); document.open(); buildPdfDocument(model, document, writer, request, response); document.close(); writeToResponse(response, baos); } @Override protected void writeToResponse(HttpServletResponse response, ByteArrayOutputStream baos) throws IOException { response.setContentType(getContentType()); ServletOutputStream out = response.getOutputStream(); baos.writeTo(out); out.flush(); } protected Document newDocument() { return new Document(PageSize.A4); } protected PdfWriter newWriter(Document document, OutputStream os) throws DocumentException { return PdfWriter.getInstance(document, os); } protected void prepareWriter(Map<String, Object> model, PdfWriter writer, HttpServletRequest request) throws DocumentException { writer.setViewerPreferences(getViewerPreferences()); } protected int getViewerPreferences() { return PdfWriter.ALLOW_PRINTING | PdfWriter.PageLayoutSinglePage; } //Document가 open되기 전, pdf의 meta 정보를 넣을 때 사용 protected abstract void buildPdfMetadata(Map<String, Object> model, Document document, HttpServletRequest request) throws Exception; //pdf의 내용을 추가하는데 이용 protected abstract void buildPdfDocument(Map<String, Object> model, Document document, PdfWriter writer, HttpServletRequest request, HttpServletResponse response) throws Exception; }


여기에서 중요한 method는 buildPdfMetaData와 buildPdfDocument method입니다. 

# buildPdfMetadata
Pdf 문서의 정보를 지정할 수 있는 method입니다. 이때는 pdf document가 아직 open이 되지 않은 상태이기 때문에, 문서에 내용을 추가하거나 만드는 것이 아닌 문서 자체의 정보를 기록하게 됩니다. 이 method에서 할 수 있는 중요한 일은 암호화와 권한을 설정할 수 있습니다. 이 부분에 대해서는 watermark를 지원하는 pdf 또는 pdf security 부분을 참조해주시면 될 것 같습니다. 

# buildPdfDocument
Pdf 문서를 작성하는 method입니다. pdf document를 작성하는 실질적인 method입니다. IText 2.x를 지원하는 View와 동일한 코드를 작성해주면 됩니다.

아래는 만들어진 AbstractIText5PdfView를 상속받아서 구현된 BookPdfView입니다. 

public class BookPdfView extends AbstractIText5PdfView {
    @Override
    protected void buildPdfDocument(Map<String, Object> model, Document document, PdfWriter writer, HttpServletRequest request, HttpServletResponse response)
            throws Exception {
        String fileName = createFileName();
        setFileNameToResponse(request, response, fileName);
        Chapter chapter = new Chapter(new Paragraph("this is english"), 1);
        chapter.add(new Paragraph("이건 메세지입니다."));
        document.add(chapter);
    }

    private void setFileNameToResponse(HttpServletRequest request, HttpServletResponse response, String fileName) {
        String userAgent = request.getHeader("User-Agent");
        if (userAgent.indexOf("MSIE 5.5") >= 0) {
            response.setContentType("doesn/matter");
            response.setHeader("Content-Disposition", "filename=\"" + fileName + "\"");
        } else {
            response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
        }
    }

    private String createFileName() {
        SimpleDateFormat fileFormat = new SimpleDateFormat("yyyyMMdd_HHmmss");
        return new StringBuilder("pdf").append("-").append(fileFormat.format(new Date())).append(".pdf").toString();
    }

    @Override
    protected void buildPdfMetadata(Map<String, Object> model, Document document, HttpServletRequest request) throws Exception {
        //Meta data 처리는 하지 않습니다.
    }
}

만들어서 사용해보면 이상한점을 발견할 수 있습니다. 영어 메세지는 표시가 되지만, 한글 메세지는 표시가 되지 않습니다. 그건 기본적으로 pdf가 영문 font만을 사용하도록 문서가 구성이 되어있기 때문입니다. pdf에서 영문 이외의 한글/한문/일어 를 보기 위해서는 itext-asian이라는 library를 추가로 사용해야지 됩니다. 

<dependency>
    <groupId>com.itextpdf</groupId>
    <artifactId>itext-asian</artifactId>
    <version>5.2.0</version>
</dependency>

library를 추가를 하고, BookPdfView의 buildPdfDocument를 다음과 같이 수정하도록 합니다. 
    @Override
    protected void buildPdfDocument(Map<String, Object> model, Document document, PdfWriter writer, HttpServletRequest request, HttpServletResponse response)
            throws Exception {
        String fileName = createFileName();
        setFileNameToResponse(request, response, fileName);
        BaseFont cfont = BaseFont.createFont("HYGoThic-Medium", "UniKS-UCS2-H", BaseFont.NOT_EMBEDDED);
        Font objFont = new Font(cfont, 12);

        Chapter chapter = new Chapter(new Paragraph("this is english"), 1);
        chapter.add(new Paragraph("이건 메세지입니다.", objFont));
        document.add(chapter);
    }

한글 font를 지정하고, 그 Font를 사용하는 것을 볼 수 있습니다. 실질적으로 itext-asian은 java code가 하나도 없는 jar 파일입니다. 내부는 Font에 대한 meta data들만 추가되어 있는 상태에서 그 Font를 사용하도록 설정을 조금 변경시킨 것 밖에는 없습니다. 
다음은 만들어진 pdf 파일입니다. 




4. Json

json은 web api의 데이터 Protocol로 주로 사용됩니다. HTML과 atom은 xml을 base로 한 문서용 마크업언어입니다. 그런데, 이 형태는 데이터를 기술하기에는 표기가 너무나 중복이 됩니다. 그래서, 좀 더 단순한 데이터의 포멧이 제안되었고, 그 중에서 가장 각광받고, 자주 사용되고 있는 것이 json format입니다. 
지금 Book 객체에 대한 json 은 다음과 같이 표현됩니다. 

{
  • id335,
  • title"Changed Title > Title : 8",
  • authornull,
  • comment"Changed Comment : Comment : 8",
  • publishDate1362641616000,
  • createDate1362641616000,
  • updateDate1362641616000,
  • status"MISSING",
  • imageUrlnull,
  • rentUser:  {
    • id329,
    • loginId"User Id 8",
    • name"Name 8",
    • password"Password 8",
    • checkSum3069349407,
    • lastLoginTime1362641616000,
    • joinTime1362641616000,
    • pointnull,
    • levelnull,
    • histories: [ ]
    }
},

xml보다 간단한 표시 방법에, 다양한 데이터를 표현할 수 있어서 자주 사용되는 데이터 포멧입니다. 기술적으로는 다음과 같은 장점을 갖습니다. 

1) 데이터의 사이즈가 xml보다 작기 때문에 network 부하가 작습니다.
2) javascript에서 처리가 매우 간단합니다. 
3) 사용하기 쉽고, 직관적입니다. 

java에서 json data format으로 객체를 변경시키는 library는 jackson, gson 등 다양한 library 들이 존재합니다. spring에서는 jackson을 json default converter로 이용합니다. jackson에 대한 jar 설정을 pom.xml에 추가합니다. 

    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-core</artifactId>
      <version>2.1.4</version>
    </dependency>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.1.4</version>
    </dependency>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-annotations</artifactId>
      <version>2.1.4</version>
    </dependency>

또한, messageConvert에 jackson을 사용하도록 spring의 request adapter를 수정해야지 됩니다. 

그리고, controller에 book list를 jackson으로 얻어내는 method를 추가하도록 하겠습니다. 기본적으로 categories, histories는 json으로 얻어낼 필요가 없다고 생각되어서 이 두개의 property를 제외했습니다. 또한 user역시 histories를 얻을 필요가 없어서 property를 제거하도록 하겠습니다. jackson으로 변환되지 않기를 원하면 @JsonIgnore annotaion을 붙여 사용하면 됩니다. 변경된 Book 입니다.

@Entity
@Table(name="books")
public class Book {
    @Id
    @Column(name="id")
    @GeneratedValue(strategy=GenerationType.AUTO)
    private long id;
    @Column(name="title", length=255)
    private String title;
    @Column(name="author", length=255)
    private String author;
    @Column(name="comment", length=255)
    private String comment;
    @Column(name="publishDate")
    private Date publishDate;
    @Column(name="createDate")
    private Date createDate;
    @Column(name="updateDate")
    private Date updateDate;
    @Column(name="status")
    @Enumerated(EnumType.ORDINAL)
    private BookStatus status;
    @Column(name="imageUrl")
    private String imageUrl;
    @ManyToOne
    @JoinColumn(name="rentUserId", nullable=true)
    private User rentUser;
    @JsonIgnore
    @ManyToMany
    @JoinTable(name="books_categories",
               joinColumns = {@JoinColumn(name="bookId")},
               inverseJoinColumns = {@JoinColumn(name="categoryId")}
            )
    private Set<Category> categories = new HashSet<>();
    @JsonIgnore
    @OneToMany(mappedBy="book")
    private Set<History> histories = new HashSet<>();;

그리고, Controller에 다음 method를 추가합니다. 

    @RequestMapping(value="list/json", method=RequestMethod.GET)
    @ResponseBody
    public List<Book> listup() {
        return bookService.getAll();
    }

마지막으로 테스트코드를 통해서 json output이 정상적으로 되는지 확인해보도록  합니다.

    @Test
    public void listupJson() throws Exception {
        MvcResult result = mock.perform(get("/book/list/json")).andExpect(status().isOk()).andReturn();
        System.out.println(result.getResponse().getContentAsString());
    }

그리고 위와 같이 Entity를 바로 넘기게 되는 순환 참조 오류를 해결하기 위해서 다른 DTO를 생성해서 그 DTO를 넘겨주기도 합니다. 간단히 Map 객체를 만들어서 넘기는 Controller 코드는 다음과 같습니다.

    @RequestMapping(value = "book/json")
    @ResponseBody
    public Object convertToJson() {
        List<Book> books = bookService.listup();
        List<Map<String, Object>> maps = new ArrayList<>();

        for (Book book : books) {
            Map<String, Object> item = new HashMap<>();
            item.put("title", book.getTitle());
            item.put("comment", book.getComment());
            maps.add(item);
        }
        return maps;
    }

Controller에서 @ResponseBody를 처음 사용해봤습니다. @ResponseBody는 따로 View를 갖지 않고, html body 자체에 output을 적는 방법입니다. web api를 사용하는 경우에는 대부분 이러한 방법으로 output을 처리합니다. json에 대해서는 자주 사용되기 때문에 꼭 사용법을 익혀두시길 바랍니다.

5. File upload

File upload는 View가 아닙니다. 이번 장에서 설명하는 영역으로 약간 부적절한 면이 있긴 하지만 이 부분은 Controller 영역입니다. Spring에서 제공하는 HttpRequestServlet이 보내는 Multipart file upload 데이터를 Handling하는 방법을 소개하도록 하겠습니다. Multipart file upload를 처리하기 위해서는 다음 jar들이 필요합니다. 

    <dependency>
      <groupId>commons-fileupload</groupId>
      <artifactId>commons-fileupload</artifactId>
      <version>1.2.2</version>
    </dependency>
    <dependency>
      <groupId>commons-io</groupId>
      <artifactId>commons-io</artifactId>
      <version>2.4</version>
    </dependency>

먼저, file을 upload하기 위해서는 반드시 다음과 같은 설정이 applicationContext.xml에 위치해야지 됩니다. 

  <bean id="multipartResolver"
    class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
    <property name="maxUploadSize" value="2000000" />
  </bean>
  <bean id="uploadDirResource" class="org.springframework.core.io.FileSystemResource">
    <constructor-arg>
      <value>C:/upload/</value>
    </constructor-arg>
  </bean>

위 설정은 매우 민감한 설정입니다. bean의 id도 무조건 multipartResolver라는 이름으로 지정이 되어야지 되며, uploadDirResource 역시 bean의 id가 고정되어야지 됩니다. 이 id를 바꿔주는 것은 불가능합니다. 각 bean들은 file upload의 max size와 저장될 위치를 결정하게 됩니다. 반드시 bean id를 위 설정과 동일하게 설정해야지 된다는 것을 잊지 말아주세요. 

upload 할 데이터에 대한 dto를 만들어줍니다. 이 dto 객체는 Request에서 오는 http 데이터를 Controller로 전달하는 역활을 담당하게 됩니다. 

public class UploadItem {
    private String name;

    private CommonsMultipartFile fileData;

    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public CommonsMultipartFile getFileData() {
        return fileData;
    }
    public void setFileData(CommonsMultipartFile fileData) {
        this.fileData = fileData;
    }
}

CommonsMultipartFile 객체를 property로 갖는 것을 확인해보실 수 있습니다. 이제 이 객체와 mapping되는 Html 을 구성하도록 하겠습니다. 
기본적으로 Spring @MVC에서 form을 통한 데이터 전달을 할때는 form안에 위치한 input tag의 이름과 property의 이름간에 1:1로 mapping이 되게 됩니다. UploadItem DTO는 아래와 같은 Html과 mapping이 될 수 있습니다. file upload를 하기 위해서 enctype이 multipart/form-data로 되어있는 것을 확인하시길 바랍니다.

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"><html><head><META http-equiv="Content-Type" content="text/html;charset=UTF-8"><title>Upload Example</title></head><body>
    <form name="uploadItem" method="post" enctype="multipart/form-data">
        <fieldset>
            <legend>Upload Fields</legend>
            <p>
                <label for="name-id">Name</label>
                <input type="text" id="name-id" name="name" />
            </p>
            <p>
                <label for="fileData-id">파일 업로드</label>
                <input type="file" id="fileData-id" name="fileData" type="file" />
            </p>
            <p>
                <input type="submit" />
            </p>
        </fieldset>
    </form></body></html>



이제 이 form data를 받아올 Controller code를 알아보도록 하겠습니다. 지금까지 데이터를 form이나 url을 통해서 언제나 int, String 형태로만 받아오던 데이터를 이제는 DTO를 통해서 받아보도록 하겠습니다. 

    @RequestMapping(value="index", method=RequestMethod.POST)
    public String uploadCompleted(UploadItem uploadItem, BindingResult result) {
        if (result.hasErrors()) {
            for (ObjectError error : result.getAllErrors()) {
                System.err.println("Error: " + error.getCode() + " - " + error.getDefaultMessage());
            }
            return "upload/uploadForm";
        }

        if (!uploadItem.getFileData().isEmpty()) {
            String filename = uploadItem.getFileData().getOriginalFilename();
            String imgExt = filename.substring(filename.lastIndexOf(".") + 1, filename.length());

            // upload 가능한 파일 타입 지정
            if (imgExt.equalsIgnoreCase("JPG") || imgExt.equalsIgnoreCase("JPEG") || imgExt.equalsIgnoreCase("GIF")) {
                byte[] bytes = uploadItem.getFileData().getBytes();
                try {
                    File outFileName = new File(fsResource.getPath() + "_" + filename);
                    FileOutputStream fileoutputStream = new FileOutputStream(outFileName);
                    fileoutputStream.write(bytes);
                    fileoutputStream.close();
                } catch (IOException ie) {
                    System.err.println("File writing error! ");
                }
                System.err.println("File upload success! ");
            } else {
                System.err.println("File type error! ");
            }
        }

받아온 데이터를 이용해서 file을 저장할 수 잇는 것을 알 수 있습니다. 그런데, 지금까지보던 Controller의 input값과는 조금 다른 값이 보입니다. BindingResult가 그 결과인데요. BindingResult는 Request에서 보내지는 응답을 DTO로 변환하게 될 때, 변환과정에서 에러가 발생하는지를 확인하는 객체입니다. 그리고 Request가 DTO로 변환하는 과정을 Binding이라고 합니다. 


Summay

지금까지 5개의 Controller Action에 대해서 알아봤습니다. 

# excel
# pdf (iText 2.x)
# pdf (iText 5.x) - AbstractView를 이용한 신규 View의 생성
# json
# file upload

이 부분에 대해서 직접 돌아가는 web page를 한번 만들어보시길 바랍니다. 그리고 그 동작이 어떻게 일어나는지 확인하는 것이 이쪽까지의 과제입니다. 
다음은 오늘 잠시 나왔던 Binding에 대해서 좀 더 알아보도록 하겠습니다.


Posted by Y2K
,

23. View의 표현법 - HTML

Java 2013. 9. 13. 08:33

* 사내 강의용으로 사용한 자료를 Blog에 공유합니다. Spring을 이용한 Web 개발에 대한 전반적인 내용에 대해서 다루고 있습니다.


크게 View는 Html을 표시시키는 View와 Application을 이용할 때 사용되는 View. 두개로 나눌수 있습니다. 이번 장에서는 Html을 표시하는 View를 알아보도록 하겠습니다.

1. JSP & JSTL

jsp와 jstl을 사용하기 위해서는 특별한 View 설정은 필요없지만, 기본적으로 Spring에서 요구되는 View는 WEB-INF 폴더 안에 view file들을 위치시키고, client에서 직접 접근할 수 없도록 하는 것이 일반적입니다. 이때는 기본적으로 InternalResourceViewResolver를 사용해서 View 파일의 위치를 설정하도록 합니다.

  <bean id="viewResolver" class="org.springframework.web.servlet.view.InternalResourceViewResolver">
    <property name="viewClass" value="org.springframework.web.servlet.view.JstlView" />
    <property name="prefix" value="/WEB-INF/views/" />
    <property name="suffix" value=".jsp" />
    <property name="contentType" value="text/html; charset=UTF-8" />
    <property name="order" value="1" />
  </bean>

code base로는 다음과 같이 설정하면 됩니다. 
    @Bean
    public UrlBasedViewResolver viewResolver() {
        UrlBasedViewResolver jspViewResolver = new UrlBasedViewResolver();
        jspViewResolver.setOrder(4);
        jspViewResolver.setPrefix("/WEB-INF/view/");
        jspViewResolver.setSuffix(".jsp");
        jspViewResolver.setContentType("text/html; charset=UTF-8");
        jspViewResolver.setViewClass(JstlView.class);
        return jspViewResolver;
    }

jsp만으로는 제어문을 구성할 수 없기 때문에 jstl을 이용해서 jsp를 확장해야지 됩니다. jstl을 사용하기 위해서는 pom.xml에 다음 항목을 추가해주세요.

        <dependency>
            <groupId>jstl</groupId>
            <artifactId>jstl</artifactId>
            <version>1.2</version>
        </dependency>

JSTL은 다양한 tag를 지원하고 있습니다. 이는 기존 jsp에서 <%= %>에서 빈번하게 만들어지는 code들을 단순화하고 읽기 편한 간단한 언어로 만들기 위해서 제공되고 있습니다.  jsp는 다음과 같은 tag를 선언함으로서 사용할 수 있습니다. 

<%@taglib prefix="c"uri="http://java.sun.com/jsp/jstl/core"%>

1) c:out  - 객체를 출력한다.
<!--기본 문법--><c:out value="${name}"/><!--  default 속성 : 값이 없을때 기본출력값을 정의-->
<c:out value="${age}" default="Null or empty"/> 
<!-- escapeXml 속성 : 기본적으로 XML 문자를 escape하는데 Escape 하지 않으려면  false로 설정-->
<c:out value="${name}" escapeXml="false"/>

2) c:set  - 객체를 저장(셋팅)한다.
<!--기본문법-->
<c:set var="name" value="홍길동" /><!--Scope 속성 : page | request | session | application 중 1개의 값 page가 기본값-->
<c:set scope="request" target="book" property="isbn" value="300"/><!--target,property속성:  홍길동의 값을 UserModel 객체의 UserName 프로퍼티값을 설정 -->
<c:set value="홍길동" target="UserModel" property="UserName"/>

3) c:remove - 객체를 삭제한다.
<!--기본문법-->  
<c:remove  var="name" scope="request" />  

4) c:if - 조건문
<!--기본문법-->  
<c:if test="${조건}">  
    조건 만족시 이 영역을 수행  
</c:if>  

jstl에서 if문은 조금 문제가 있습니다. 그건 else가 존재하지 않는 문제인데요. 우리가 주로 사용하는 if~else 문을 갖추기 위해서는 아래의 choose문을 사용해야지 됩니다.

5) c:choose, c:when, c:otherwise - switch 문이라 생각하면된다.
<!--기본문법-->   
<c:choose>  
    <c:when test="${value == 1}">  
        value가 1이면 이 영역을 수행  
    </c:when>  
    <c:when test="${value == 2}">  
        value가 2이면 이 영역을 수행  
    </c:when>  
    <c:otherwise>  
        value가 1,2가 아니면 이 영역을 수행(기본값)  
    </c:otherwise>  
</c:choose>  

6) c:foreach - 반복문
<!--기본문법(0~9까지 출력)-->   
<c:forEach begin="0" end="9" var="i">  
    <c:out value="${i}"/>  
</c:forEach>  
<!-- step 속성 : 정의된 수만큼 증가를 시킨다.(1,3,5,7,9출력)-->  
<c:forEach var="test" begin="1" end="10" step="2" >  
     <b>${test }</b>   
</c:forEach>  

7) c:forTokens - 구분자로 반복문
<!--기본문법(변수를 ','로 구분하여 출력한다. )-->   
<c:forTokens var="alphabet" items="a,b,c,d,e,f,g,h,i,j,k" delims="," varStatus="idx" >  
     <b>${alphabet }</b>  
</c:forTokens>  


8) c:url, c:param  - URL을 처리
<!--기본문법-->   
<c:url value="index.jsp"/>  
<!-- value의 속성값이 /로 시작하면 컨텍스트를 포함한다-->  
<!--Context가 Root이라면 /Root/index.jsp로 출력-->  
<c:url value="/index.jsp"/>  
<!--context 속성 : 다른 컨텍스트로 출력하고자할때 사용-->  
<!-- /newRoot/index.jsp로 출력-->  
<c:url value="/index.jsp" context="/newRoot"/>  

9) c:import - JSP파일을 인클루드한다.
<!-- 기본 문법 -->  
<!-- partialView.jsp의 내용을 출력 -->  
<c:import url="partialView.jsp"/>  
<!-- charEncoding 속성:   인코딩에 문제가 있을 시 사용 -->  
<c:import url="UserForm.jsp" charEncoding="UTF-8"/>  

10) c:redirect - 리다이렉션
<!-- 기본 문법 -->  
<c:redirect url="http://kkams.net"/>  


11) c:catch - 예외 발생시 처리
<!-- 기본 문법 -->  
<c:catch var="err">    
    <%=10 / 0%> <!--0으로 나누면 에러 발생-->  
</c:catch>  
<!--에러 출력-->  
<c:out value="${err }" />  


다양해보이지만, 상당히 빈약한 문법과 코드를 가지고 있는 것이 JSTL입니다. 자주 사용되기도 하고, 일단 표준이기 때문에 몰라서는 안되는 View 표현 방법입니다. 앞서 Controller에서 보내지는 Model의 값을 JSTL을 이용해서 표현하게 되는 것이 일반적입니다. 

지금까지 본 jstl을 이용한 book list의 표현과 book add의 표현방법입니다. 

    @RequestMapping("book/list")
    public String listup(Model model) {
        List<Book> books = bookService.listup();
        model.addAttribute("books", books);
        return "book/list";
    }

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Insert title here</title>
<link href="/res/css/bootstrap.min.css" rel="stylesheet" />
<link href="/res/css/sample.css" rel="stylesheet" />
<script src="/res/js/bootstrap.min.js"></script>
</head>
<h2>jstl test code 입니다.</h2><div class="example-form">
    <h3>c:forEach example code</h3>
    <spring:message code="code.message"></spring:message>
    <table class="table table-striped table-bordered table-hover">
        <thead>
            <tr>
                <th>Title</th>
                <th>status</th>
                <th>Description</th>
            </tr>
        </thead>

        <tbody>
            <c:forEach var="book" items="${books}">
                <tr>
                    <td>${book.title}</td>
                    <c:set var="status" value="일반" />
                    <c:choose>
                        <c:when test="${book.status eq 'NORMAL'}">
                            <c:set var="status" value="일반" />
                        </c:when>
                        <c:when test="${book.status eq 'RENTNOW'}">
                            <c:set var="status" value="대여중" />
                        </c:when>
                        <c:otherwise>
                            <c:set var="status" value="분실중" />
                        </c:otherwise>
                    </c:choose>
                    <td>${status }</td>
                    <td>${book.comment}</td>
                </tr>
            </c:forEach>
        </tbody>
    </table>
</div>
</body>
</html>


2. JSTL fmt를 이용한 데이터의 가공

java의 원 데이터를 html로 표시할 때, 우리는 자주 데이터의 모양을 바꿔야지 되는 일들이 발생됩니다. 특히 통화량과 날짜, 소숫점의 표시에서 가장 일반적이라고 할 수 있습니다. 자리수 단락에서 ","를 추가한다던지, 소숫점 이하의 자리수를 어떻게 표시를 하는지에 대한 문제는 Controller와 같은 기술적인 issue와는 별개로 사용자들에게는 '모든 것'이 될 수 있는 문제입니다. 이런 일은 수치데이터를 직접 바꾸는 것이 아니라 수치의 표시를 어떻게 해줄 것인지에 대한 내용입니다. View에서 이런 일들을 해주는 것이 가장 좋습니다. 

이 부분에 대해서는 issue가 있습니다. View에서 특정 데이터를 빨간색으로 보여야지 되는 action에 대한 처리를 무엇으로 보느냐에 대한 논쟁입니다. 이것은 보는 방법을 바꾸는 일이기 때문에 View에서 봐야지 된다는 의견과 보는 방법이 아닌 이것 역시 Business Logic으로 보는 견해도 있습니다. BL이기 때문에 Server Code에서 구동이 되고 테스트가 가능한 방법으로 만들어야지 된다는 것이지요. 가장 좋은 위치는 Controller 영역으로 이야기하고 있습니다. 보여지는 Model을 변경하는 것이기 때문에 가장 좋은 Layer이니까요. 개인적인 의견으로는 View에 대한 영역은 모두 View에 넘기는 것이 좋지 않나. 라는 생각입니다. 저는 개인적으로는 전자의 의견에 좀더 한표를 주고 있는 편입니다. 이 부분에 대해서는 개발자들의 취향과 그 프로젝트의 PM에 따라서 많이 바뀌게 될 내용이라고 생각됩니다. 

지금 소개하는 JSTL fmt의 경우에는 View Layer, 즉 JSP에서 보이는 방법을 바꿔주는 방법입니다. 

선언 방법은 다음과 같습니다. 

<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%>

선언된 데이터는 다음과 같이 사용이 가능합니다. 

<div class="example-form">
    <fieldset>
        <legend>FMT examples - Date</legend>
        <ul>
            <li><fmt:formatDate value="${date}" type="DATE" pattern="yyyy/MM/dd" /></li>
            <li><fmt:formatDate value="${date}" type="DATE" pattern="yyyy년 M월 dd일" /></li>
        </ul>
    </fieldset>
    <br />
    <fieldset>
        <legend>FMT example - Number</legend>
        <ul>
            <li>orginal : ${number}</li>
            <li><fmt:formatNumber value="${number}" groupingUsed="true" currencySymbol=","/></li>
            <li><fmt:formatNumber value="${number}" minFractionDigits="5"/></li>
            <li><fmt:formatNumber value="${number}" type="CURRENCY"/></li>
            <li><fmt:formatNumber value="234.3" pattern="△#,##0.00; ▼#,##0.00" /></li>
            <li><fmt:formatNumber value="-1234.56" pattern="△#,##0.00; ▼#,##0.00" /></li>
            <li><fmt:formatNumber value="0.99" type="percent"/></li>
        </ul>
    </fieldset>
</div>


위 HTML은 다음과 같이 표시가 됩니다. 



3. tiles

Tiles는 기본적으로 JSTL을 이용하지만, JSTL에서 중복되는 html code를 최소화하기 위해서 만들어진 Template Engine입니다. Tile는 기본적인 Html View 가 Composite View Pattern으로 동작할 때, 중복되는 코드들을 공통 요소로 뽑아서 이용가능합니다. 다음은 Tile에 대한 기본 개념의 정의입니다. 




일반적인 Html Page의 기본 구조입니다. 이 구조에서 페이지가 바뀌게 된다면 Body부분의 Content는 계속해서 바뀌게 되지만, Menu, Header, Footer들은 크게 바뀌지 않는 것이 일반적입니다. 
따라서, 각 부분을 재사용할 수 있다면 코드의 양은 매우 줄어들고, 유지보수에 용의함을 알 수 있습니다. 


tiles를 추가하기 위해서는 pom.xml에 다음 항목을 추가해주세요. 

        <dependency>
            <groupId>org.apache.tiles</groupId>
            <artifactId>tiles-jsp</artifactId>
            <version>3.0.1</version>
        </dependency>

tiles는 tag를 제공하고 있으며, tag를 이용해서 tiles page를 작성할 수 있습니다.  먼저 layout page를 알아보도록 하겠습니다. 

<%@ taglib prefix="tiles" uri="http://tiles.apache.org/tags-tiles"%>
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title><tiles:insertAttribute name="title"/></title>
<link href="/res/css/bootstrap.min.css" rel="stylesheet" />
<link href="/res/css/sample.css" rel="stylesheet" />
<script src="/res/js/bootstrap.min.js"></script>
</head>
<body>
    <tiles:insertAttribute name="content"/>
</body>
</html>

기본적인 content의 attribute를 지정하고, 그 attribute에 원하는 값을 넣어주는 것이 가능합니다. attribute는 String 문자열 또는 jsp 파일이 될 수 있습니다.  이러한 attribute와 view name을 설정하기 위해서 tiles는 설정 xml을 가지게 되는데, 이 xml은 다음과 같이 구성될 수 있습니다. 

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE tiles-definitions PUBLIC
       "-//Apache Software Foundation//DTD Tiles Configuration 3.0//EN"
       "http://tiles.apache.org/dtds/tiles-config_3_0.dtd">
<tiles-definitions>
    <definition name="main-layout" template="/WEB-INF/tiles/layouts/main-layout.jsp">
        <put-attribute name="title" value=""/>
        <put-attribute name="content" value=""/>
    </definition>
    <definition name="book/list" extends="main-layout">
        <put-attribute name="title" value="BOOK LIST"/>
        <put-attribute name="content" value="/WEB-INF/tiles/book/list.jsp"/>
    </definition>   
    <definition name="book/add" extends="main-layout">
        <put-attribute name="title" value="Add BOOK"/>
        <put-attribute name="content" value="/WEB-INF/tiles/book/add.jsp"/>
    </definition> 
</tiles-definitions>

여기서 각각의 definition name은 ModelAndView의 view name으로 이용됩니다. extends로 선언된 View를 Template로 이용하고, 각각의 View의 조각(tile)을 붙이는 형식으로 사용할 수 있습니다. 
tiles를 사용하기 위해서는 TilesViewResolver를 applicationContext에 선언해줘야지 됩니다. 일반적으로 controller-servlet.xml에서 구성합니다.  code base configuration은 다음과 같이 설정해줍니다. 

    @Bean
    public TilesConfigurer tilesConfigurer() {
        TilesConfigurer tilesConfigurer = new TilesConfigurer();
        tilesConfigurer.setDefinitions(new String[] { "/WEB-INF/tiles-configs.xml" });
        return tilesConfigurer;
    }

    @Bean
    public TilesViewResolver tilesViewResolver() {
        TilesViewResolver tilesViewResolver = new TilesViewResolver();
        tilesViewResolver.setOrder(1);
        return tilesViewResolver;
    }

2개의 @Bean을 설정해주는 것을 알 수 있습니다. 

보시면 <tiles:insertAttribute> tag로 구성된 부분에 tiles.xml 파일에서 지정한 tile이 들어가서 전체 웹페이지를 구성하게 되는 것을 알 수 있습니다. 매우 자주 쓰이고 있는 View Teamplate입니다. 전체 코드의 양을 줄일 수 있고, 전체 Framework 등의 문제를 쉽게 해결할 수 있는 방법이기도 합니다. 

tiles를 사용하시면 이제 book/list.jsp는 다음과 같이 변경이 될 수 있습니다.

<h2>jstl test code 입니다.</h2><div class="example-form">
    <h3>c:forEach example code</h3>
    <spring:message code="code.message"></spring:message>
    <table class="table table-striped table-bordered table-hover">
        <thead>
            <tr>
                <th>Title</th>
                <th>status</th>
                <th>Description</th>
            </tr>
        </thead>

        <tbody>
            <c:forEach var="book" items="${books}">
                <tr>
                    <td>${book.title}</td>
                    <c:set var="status" value="일반" />
                    <c:choose>
                        <c:when test="${book.status eq 'NORMAL'}">
                            <c:set var="status" value="일반" />
                        </c:when>
                        <c:when test="${book.status eq 'RENTNOW'}">
                            <c:set var="status" value="대여중" />
                        </c:when>
                        <c:otherwise>
                            <c:set var="status" value="분실중" />
                        </c:otherwise>
                    </c:choose>
                    <td>${status }</td>
                    <td>${book.comment}</td>
                </tr>
            </c:forEach>
        </tbody>
    </table></div>

layout에서 body 부분만을 제외하고 나머지 부분들을 모두 처리하고 있기 때문에 중복 코드를 없앨수 있는 것을 볼 수 있습니다. 조금 더 응용할 경우, 보다더 복잡한 UI를 좀 더 깔끔한 코드로 만들어주는 것이 가능합니다. Tiles의 경우에는 현장에서 주로 사용하고 있는 View Template 입니다. 꼭 사용법을 익혀주시길 바랍니다. 

4. velocity

Velocity는 JSTL의 문법의 복잡함을 해결하기 위해서 구성된 View Template 입니다. 기본적으로 제어문의 간편함과 간결한 html을 가능하게 하는 장점을 가지고 있습니다. 
기본적인 velocity로 구성된 html 파일을 한번 알아보도록 하겠습니다. (* velocity로 구성된 html파일은 대체적으로 .vm 확장자를 갖습니다.) velocity를 주로 이용하는 경우에는 eclipse에 velocity plugin을 설치하는 것이 좋습니다. Install New Software를 선택해서 http://veloeclipse.googlecode.com/svn/trunk/update/ 주소에서 veloctiy plugin을 설치해주면 .vm 파일의 syntax highlight가 가능합니다. 

기본적인 velocity 문법은 #으로 시작되는 제어문과 $로 시작되는 변수로 함축할 수 있습니다.  만들어진 book list에 대한 velocity code는 다음과 같습니다. 

<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><title>Velocity View Example</title><link href="/res/css/bootstrap.min.css" rel="stylesheet" /><link href="/res/css/sample.css" rel="stylesheet" /><script src="/res/js/bootstrap.min.js"></script></head><body>
    <h2>freemarker list view</h2>
    <div class="example-form">
        <h3>Book list</h3>
        <spring:message code="code.message"></spring:message>
        <table class="table table-striped table-bordered table-hover">
            <thead>
                <tr>
                    <th>Title</th>
                    <th>status</th>
                    <th>Description</th>
                </tr>
            </thead>
            <tbody>
                #foreach($book in $books)
                <tr>
                    <td>$book.title</td>
                    #if($book.status == "NORMAL")
                        <td>일반</td>
                    #elseif($book.status == "RENTNOW")
                        <td>대여중</td>
                    #else
                        <td>분실</td>
                    #end
                    <td>$book.comment</td>
                </tr>                
                #end
            </tbody>
        </table>
    </div></body></html>

주의해서 보실 것이 #foreach와 #if문입니다.  jstl보다는 문법이 매우 쉬워지는 것을 알 수 있습니다. 좀더 보기도 편한것도 사실이고요. 

ViewResolver를 등록하기 위해서 Spring은 2개의 Bean을 정의하도록 되어 있습니다. 위에 Tiles에서와 완전히 동일합니다. 먼저 ViewConfigurer를 등록하고, ViewResolver를 등록하는 형식입니다. ViewConfigurer의 경우에는 View에 대한 Configuration을 주로 등록을 하고, ViewResolver의 경우에는 Resolver의 세부 설정을 넣는 형식입니다. Velocity의 code base configuration은 다음과 같이 구성됩니다. 

    @Bean
    public VelocityConfigurer velocityConfigurer() throws VelocityException, IOException {
        VelocityConfigurer configurer = new VelocityConfigurer();
        configurer.setResourceLoaderPath("/WEB-INF/vm");
        Properties properties = new Properties();
        properties.put("input.encoding", "UTF-8");
        properties.put("output.encoding", "UTF-8");
        configurer.setVelocityProperties(properties);

        return configurer;
    }

    @Bean
    public VelocityViewResolver velocityViewResolver() {
        VelocityViewResolver viewResolver = new VelocityViewResolver();
        viewResolver.setContentType("text/html; charset=UTF-8");
        viewResolver.setSuffix(".vm");
        viewResolver.setOrder(3);
        return viewResolver;
    }

Configurer에서 encoding을 설정하는 것을 잊지 말아주세요. 한글 데이터가 있는한, 그리고 영문만을 사용하지 않는한 모든 ViewResolver는 어떻게든 UTF-8 설정이 필요합니다. 


5. Freemarker

Freemarker는 기본적으로 velocity와 동일한 컨셉의 View Template Engine입니다. Velocity보다 빠르고, 가독성의 향상을 목적으로 하고 있습니다. 다음은 Freemarker로 만든 Html code입니다. 

<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><title><tiles:insertAttribute name="title"/></title><link href="/res/css/bootstrap.min.css" rel="stylesheet" /><link href="/res/css/sample.css" rel="stylesheet" /><script src="/res/js/bootstrap.min.js"></script></head><body>
    <h2>freemarker list view</h2>
    <div class="example-form">
        <h3>Book list</h3>
        <spring:message code="code.message"></spring:message>
        <table class="table table-striped table-bordered table-hover">
            <thead>
                <tr>
                    <th>Title</th>
                    <th>status</th>
                    <th>Description</th>
                </tr>
            </thead>

            <tbody>
                <#list books as book>
                    <tr>
                    <td>${book.title}</td>                    
                    <#if book.status.name() == "NORMAL">
                        <td>일반</td>
                    <#elseif book.status.name() == "RENTNOW">
                        <td>대여중</td>
                    <#else>
                        <td>분실</td>
                    </#if>
                    <td>${book.comment}</td>
                    </tr>
                </#list>
            </tbody>
        </table>
    </div></body></html>

#을 이용한 간편한 foreach문이 보이시나요? #if 등 문법상으로는 개인적으로는 좀더 편해보이는 것이 freemarker이긴합니다. freemarker를 사용하기 위해서는 velocity와 동일하게 ViewConfiguration과 ViewResolver를 구성해야지 됩니다. 다음은 code base configuration입니다. 

    @Bean
    public FreeMarkerConfigurer freemarkerConfigurer() {
        FreeMarkerConfigurer freemarkerConfigurer = new FreeMarkerConfigurer();
        freemarkerConfigurer.setTemplateLoaderPath("/WEB-INF/fmt");
        Properties properties = new Properties();
        properties.put("default_encoding", "UTF-8");
        freemarkerConfigurer.setFreemarkerSettings(properties);
        return freemarkerConfigurer;
    }

    @Bean
    public FreeMarkerViewResolver freemarkerViewResolver() {
        FreeMarkerViewResolver viewResolver = new FreeMarkerViewResolver();
        viewResolver.setContentType("text/html; charset=UTF-8");
        viewResolver.setSuffix(".ftl");
        viewResolver.setOrder(2);

        return viewResolver;
    }


Summary

지금까지 다양한 Html View Engine을 살펴봤습니다. View Engine은 매우 다양한 형태로 제공되고 있습니다. 지금 소개한 Veloctiy, Freemarker, tiles의 경우에는 Spring에서 공식적으로 제공하고 있는 View Template Engine입니다. 타 View Template Engine의 경우, Library에 Spring의 ViewResolver를 구현하고 있는 것이 일반적입니다. 다양한 View Engine은 Project에서 개발자의 맘 또는 전체 개발팀의 의향으로 정해지는 것이 일반적입니다. 그런데, 여기에서 조금 문제가 되는 것이 여러개의 View Engine을 섞어서 쓰지는 거의 못한다는 겁니다. 물론 섞어서 사용하는 경우도 왕왕 있습니다. 예를 들어, tiles에 freemarker를 섞어서 사용한다던지요. 이 부분에 대해서는 각자 개인의 학습이 좀 더 필요한 것 같습니다. 저도 몇 부분은 좀 더 해봐야지 될 것 같고요. 

가장 대중적인 것은 jstl을 사용하는 것입니다. 모든 View Template의 기본이 되고 있고, 많은 Reference를 가지고 있습니다. Tiles + JSTL의 경우가 호환성이나 code의 편의성이 가장 좋은 것 같습니다. 

숙제입니다. BookList를 jsp, tiles, velocity, freemarker를 이용해서 표시해주세요. 4개의 html page와 4개의 Template engine의 설정이 필요합니다. 반드시 한글 데이터가 나오도록 설정되어야지 됩니다. 또한 각 ViewEngine이 한 web server에서 동작하도록 구성을 해주시길 바랍니다.


Posted by Y2K
,