잊지 않겠습니다.

개인 project를 진행하다가 이상한 에러가 계속해서 발생되었다. 


Transaction update가 발생될때와 Test Rollback 이 발생될 때마다 PlatformTransaction null point exception이 발생되었다.

특이하게 이 exception은 다음과 같은 상황에서만 발생된다. 


1. JUnit을 이용한 test인 경우

2. update, delete query인 경우


발생되는 곳에 @Transactional을 붙이건 뭘하건 계속해서 발생이 되는데.... 이 에러의 원인은 엉뚱하게도 다른곳에 있었다.


hibernate-core와 hibernate-entitymanager 간의 버젼이 다를 경우에 발생하는 에러라는것. -_-;;


gradle로 build script를 짜두고 hibernate version만으로 따로 project property로 관리하고 있었는데, 최신의 hibernate와 예전의 hibernate-entitymanager간의 버젼이 다르기 때문에 발생되는 에러였다. 


그래서 gradle script를 다음과 같이 변경한 후에는 잘 돌아간다.; hibernate에 관련된 library들은 같은 version으로 관리될 수 있도록 해야지 되겠다.;



ext {

    springVersion = '4.0.0.BUILD-SNAPSHOT'

    springDataVersion = '1.4.2.RELEASE'

    javaVersion = 1.7

    slf4jVersion = '1.7.2'

    querydslVersion = '3.2.4'

    hibernateVersion = "4.2.7.Final"

}


        compile "org.hibernate:hibernate-core:${rootProject.ext.hibernateVersion}"

        compile "org.hibernate:hibernate-entitymanager:${rootProject.ext.hibernateVersion}"

        compile "org.hibernate:${rootProject.ext.hibernateVersion}"



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
,
queryDSL을 이용한 java project의 경우에는 gradle로는 조금 과정이 복잡하게 됩니다. 

queryDSL의 경우에는 pre-compile 과정이라고 해서, 기존의 domain entity 들을 이용한 Q class들을 생성해야지 됩니다.
이런 Q class들을 생성하는 build 과정을 먼저 거친 다음에, 기존의 class들을 compile시키는 과정을 거쳐야지 됩니다. 

따라서, compileJava보다 먼저 선행되는 작업이 반드시 필요하게 됩니다. 

먼저, generate될 code가 위치할 곳을 지정해줍니다. 

sourceSets {
    generated {
        java {
            srcDirs = ['src/main/generated']
        }
    }
}

그리고, 새로운 task를 생성시켜줍니다. 
task generateQueryDSL(type: JavaCompile, group: 'build') {
     source = sourceSets.main.java
     classpath = configurations.compile
     options.compilerArgs = [
          "-proc:only",
          "-processor", "com.mysema.query.apt.jpa.JPAAnnotationProcessor"
     ]
     destinationDir = sourceSets.generated.java.srcDirs.iterator().next()
}

위 코드는 compile type의 code라는 것을 먼저 생성시켜주고, 원 source와 Q code를 생성해주는 pre processor를 지정해주는 코드입니다. 마지막줄에서 code를 어느곳에 생성할지를 결정하고 있습니다.
그리고, compileJava에 종속성을 주입해야지 됩니다. 기존에 java plugin을 사용하고 있기 때문에, task를 새로 생성하는 것이 아닌 overwrite 시키는 개념으로 접근하면 됩니다. 

compileJava {
     dependsOn generateQueryDSL
     source generateQueryDSL.destinationDir
}
compileGeneratedJava {
     dependsOn generateQueryDSL
     options.warnings = false
     classpath += sourceSets.main.runtimeClasspath
}

이 두 task를 통해서, 기존의 compileJava의 경우 반드시 generateQueryDSL task를 실행 후, 동작하게 되고 source는 generateQueryDSL.destinationDir을 참고해서 compile을 행하게 됩니다. 또한 compileGeneratedJava task 역시 generateQueryDSL task를 실행하고, compile 된 객체들을 같이 이용해서 jar를 만들어주는 cycle을 통하게 됩니다. 


마지막으로, project를 모두 clean 시키기 위한 코드 역시 수정이 필요합니다. generated 되는 코드들도 같이 지워주기 위해서는 clean task를 다음과 같이 재정의하는 것이 좋습니다. 

clean {
     delete sourceSets.generated.java.srcDirs
}

다음은 build.gradle 파일의 전체입니다. 참고해보시길 바랍니다. 

sourceSets {
    generated {
        java {
            srcDirs = ['src/main/generated']
        }
    }
}

dependencies {
    def querydslVersion = '3.2.4'
    def slf4jVersion = "1.7.2"

    def queryDSL = '3.2.4'
    compile project(':common')
    compile group: 'mysql', name: 'mysql-connector-java', version:'5.1.22'
    compile group: 'com.jolbox', name: 'bonecp', version:'0.7.1.RELEASE'
    compile group: 'com.google.guava', name: 'guava', version:'14.0'
    compile group: 'org.springframework', name: 'spring-core', version:'3.2.3.RELEASE'
    compile group: 'org.springframework', name: 'spring-orm', version:'3.2.3.RELEASE'
    compile group: 'org.springframework', name: 'spring-tx', version:'3.2.3.RELEASE'
    compile group: 'org.springframework', name: 'spring-context', version:'3.2.3.RELEASE'
    compile group: 'org.springframework', name: 'spring-context-support', version:'3.2.3.RELEASE'
    compile group: 'org.hibernate', name: 'hibernate-core', version:'4.1.10.Final'
    compile group: 'org.hibernate.javax.persistence', name: 'hibernate-jpa-2.0-api', version:'1.0.1. Final'
    compile group: 'org.hibernate', name: 'hibernate-entitymanager', version:'4.1.10.Final'
    compile group: 'org.hibernate', name: 'hibernate-validator', version:'4.3.1.Final'

    compile group: 'org.springframework.data', name: 'spring-data-jpa', version:'1.4.2.RELEASE'

    compile group: 'com.mysema.querydsl', name: 'querydsl-core', version:"$queryDSL"
    compile group: 'com.mysema.querydsl', name: 'querydsl-jpa', version:"$queryDSL"
    compile group: 'com.mysema.querydsl', name: 'querydsl-sql', version:"$queryDSL"

    compile "com.mysema.querydsl:querydsl-apt:$queryDSL"
    provided 'org.projectlombok:lombok:0.12.0'

    compile "org.slf4j:jcl-over-slf4j:$slf4jVersion"
    compile "org.slf4j:jul-to-slf4j:$slf4jVersion"

    provided "com.mysema.querydsl:querydsl-apt:$querydslVersion"
    provided 'org.projectlombok:lombok:0.12.0'
}

task generateQueryDSL(type: JavaCompile, group: 'build', description: 'Generates the QueryDSL query types') {
    source = sourceSets.main.java
    classpath = configurations.compile + configurations.provided
    options.compilerArgs = [
            "-proc:only",
            "-processor", "com.mysema.query.apt.jpa.JPAAnnotationProcessor"
    ]
    destinationDir = sourceSets.generated.java.srcDirs.iterator().next()
}

compileJava {
    dependsOn generateQueryDSL
    source generateQueryDSL.destinationDir
}

compileGeneratedJava {
    dependsOn generateQueryDSL
    options.warnings = false
    classpath += sourceSets.main.runtimeClasspath
}

clean {
    delete sourceSets.generated.java.srcDirs
}

idea {
    module {
        sourceDirs += file('src/main/generated')
    }
}


gradle build를 실행하면 이제 Q class들이 모두 compile 되고, jar 안에도 역시 Q class들이 모두 위치한 것을 알 수 있습니다. 



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
,
일반적인 개발환경에서 한개의 Project만으로 구성되는 경우는 거의 없습니다. 
간단히 다음과 같은 구조를 알아보도록 하겠습니다. 

bookstore
- common             : dependency none
- commonTest       : dependenced common
- domain               : dependenced common, test dependenced commonTest
- publicWeb           : dependenced domain, common, test dependenced commonTest
- privateWeb          : dependenced domain, common, test dependenced commonTest

이와 같은 '간단한' 프로젝트 구성이 있다고 할 때, 이런 구성에 있어서 기존 maven은 multiple project를 구성해서, 선행 project들을 local repository 등에 배포 후, dependency를 얻어와야지 되는 문제가 발생한다. 또한 각각의 project를 통해 생성되는 xml, wsdl등을 만약에 다른 project에서 이용한다면 maven에서는 매우 힘든 일을 거쳐야지 된다. (하는 방법은 잘 모르겠습니다.)

Gradle은 이와 같은 문제를 project dependency를 이용해서 쉽게 해결하고 있습니다. 이런 subproject들을 가진 project의 구성은 project의 folder와 동일하게 구성됩니다.


이러한 folder구조를 만들어주고, root project에서 gradle setupBuild 를 실행시켜줍니다.
지금까지 주로 사용하고 있던 build.gradle 파일뿐 아니라, sub project가 있는 부분은 settings.gradle 파일을 이용해서 구성합니다. 

settings.gradle파일안에 다음과 같이 sub projects들을 모두 추가해줍니다.

rootProject.name = 'GradleBookStore'
include 'common', 'commonTest', 'domain', 'privateWeb', 'publicWeb'
include 시에 순서는 상관이 없습니다. 또는 다음과 같은 코드를 이용해서 처리가 가능합니다. 

rootProject.name = 'GradleBookStore'
String[] modules = ['common', 'commonTest', 'domain', 'privateWeb', 'publicWeb']
include modules

GradleBookStore project를 rootProject라고 칭하고, 나머지 project들은 모두 subproject라고 칭합니다. 


1. 공통 설정

rootProject를 비롯한 모든 project에 필요한 설정이 필요할 때가 있습니다. maven repository설정 및 모든 공용 library들이 바로 여기에 속하겠지요. 
모든 project에서 사용될 설정은 다음과 같이 수정합니다. 

allprojects {
    apply plugin: 'java'    
    repositories {
        mavenCentral()
    }

    dependencies {
        compile 'org.slf4j:slf4j-api:1.7.5'
        testCompile "junit:junit:4.11"
    }
}

allprojects로 설정이 들어가게 되면, 모든 project들은 위 속성을 모두 가지게 됩니다. 이는 모든 sub project 폴더 안에 build.gradle 파일을 만들어주고, 위 내용을 적어주는 것과 동일한 효과를 가지고 옵니다. 

2. SubProject 설정

위 공통설정과 비슷하지만 하나의 차이가 있습니다. 공통 설정으로 넣는 경우, root project 역시 java project로 구성이 됩니다. root project 역시 코드를 가질 수 있는 구조가 되게 됩니다. 그런데 이와 같은 구조는 현업에서는 잘 사용되지 않는 편입니다. root project의 경우에는 project의 이름으로 만들어주고, 내부에 subproject들로 구성이 되는 것이 일반적이지요. 

따라서, 대부분의 project들은 allprojects 보다는 subprojects를 주로 사용하게 되는 것이 일반적입니다. 

3. 특정 project 설정

특정 project에 설정을 해주는 방법은 두가지가 있습니다. 

1) subproject의 build.gradle을 작성해주는 것
2) root project의 build.gradle에서 configure를 이용하는 방법

첫번째 방법의 경우, 매우 단순합니다. 그냥 build.gradle 파일을 만들어서 넣어주면 됩니다. 그렇지만, root project의 build.gradle에서 configure를 이용하는 방법은 조금 생각을 해봐야지 됩니다. configure의 경우에는 약간 이름과 다르게, filtering을 지원하는 방법입니다. 특정 project에서 사용할 설정을 넘겨주는 것을 담당합니다. 지금 구성되어 있는 project 중에서 publicWeb, privateWeb의 경우 war plugin을 이용해서 web으로 구성할 예정입니다. 이 경우에는 다음과 같이 구성을 하면 됩니다. 

task webProjects << {
    subprojects.findAll {
        project.project.name.endsWith('Web')
    }
}

configure(webProjects) {
    apply plugin: 'war'
    task isWar << {
        println "${project.name} is web project"
    }
}

project의 이름이 Web으로 끝나는 project에서만 war plugin을 적용한 코드입니다. task를 작성해서, target이 되는 project가 무엇인지를 확인시켜줘야지 됩니다. groovy에서는 마지막에 실행된 내용이 return 값이기 때문에 전체 build가 실행이 될때, subproject중에서 이름이 'Web'으로 끝나는 project만이 configure에 있는 속성이 적용됩니다. 

이 부분을 잘 사용한다면, android project에서 target project의 모든 classes 파일들을 test project의 classes 폴더로 copy 해와서 처리를 하는 것이 가능하게 됩니다. 

4. Project간의 종속성 설정

Gradle에서 가장 강력한 기능중 하나입니다. project간의 종속성을 설정은 기존 maven에 비하여 가장 강력한 기능입니다. 
Project의 종속성은 만들어진 jar를 상대 project에서 이용하게 됩니다. 그리고, 하나의 Project에만 사용되는 설정이기 때문에, 이는 각 subproject의 build.gradle 파일을 이용합니다. 

위에서 이야기드린 것 처럼, 각각의 dependency를 다시 보면 다음과 같습니다. 

bookstore
- common             : dependency none
- commonTest       : dependenced common
- domain               : dependenced common, test dependenced commonTest
- publicWeb           : dependenced domain, common, test dependenced commonTest
- privateWeb          : dependenced domain, common, test dependenced commonTest

* common의 경우에는 dependecy가 없기 때문에 build.gradle의 내용이 없습니다.

domain jar를 gradle :domain:build를 통해 build 할때, 다음과 같은 코드가 동작합니다.

c:\workspace\GradleBookStore>gradle :domain:build
:common:compileJava UP-TO-DATE
:common:processResources UP-TO-DATE
:common:classes UP-TO-DATE
:common:jar UP-TO-DATE
:domain:compileJava UP-TO-DATE
:domain:processResources UP-TO-DATE
:domain:classes UP-TO-DATE
:domain:jar UP-TO-DATE
:domain:assemble UP-TO-DATE
:commonTest:compileJava UP-TO-DATE
:commonTest:processResources UP-TO-DATE
:commonTest:classes UP-TO-DATE
:commonTest:jar UP-TO-DATE
:domain:compileTestJava UP-TO-DATE
:domain:processTestResources UP-TO-DATE
:domain:testClasses UP-TO-DATE
:domain:test UP-TO-DATE

:domain:check UP-TO-DATE
:domain:build UP-TO-DATE


compile시에 필요한 project가 먼저 build가 되고, 그걸 이용해서 새로운 jar가 만들어지고 있는 것을 알 수 있습니다. 
특정 project만을 처리하는 것 역시 가능합니다. 기존 maven에서 문제가 되던 project -> local repository -> another project 의 build문제를 해결하고 있음을 알 수 있습니다.



Posted by Y2K
,

gradle 정리 - single project

Java 2013. 10. 25. 23:54
ava project에 대한 build tool의 최종 완성본

build tool의 역사

1. no build tool
2. ant
3. maven
4. gradle

Gradle을 이용한 java sample project의 개발

gradle setupBuild 를 이용한 초기 project build.gradle 파일 생성
기본적으로 maven way와 동일한 file path를 갖고 있다. 

src/main/java
src/main/resources
src/text/java
src/test/resources

1. targetCompatibility, sourceCompatibility의 설정
: 사용된 java version을 설정한다. 기본값은 1.5다

2. repositories의 설정
: repository를 설정한다. repository는 Maven에서 사용되던 것과 동일하며 Local PC에 maven이 설치된 경우, LocalPC에 대한 maven repository를 먼저 찾아보도록 설정하는 것이 가능하다. 
maven 설정은 다음과 같이 구성한다. 

repositories {
    mavenLocal()              //Local PC maven repository
    mavenCentral()           //mavenrepository site를 참조   
}

3. dependencies의 설정
: dependency를 설정한다. dependency의 경우에는 maven에 비하여 간결한 format을 지원한다.

 dependencies {

     compile 'org.slf4j:slf4j-api:1.7.5'
     testCompile "junit:junit:4.11"
     providedCompile 'javax.servlet:servlet-api:3.1'
     providedRuntime 'webcontainer:logging:1.0'
}


* compile : compile시에 필요한 dependency를 설정한다.

* testCompile : test 시에 필아한 dependency를 설정한다. 

* providedCompile : compile시에는 필요하지만, 배포시에는 제외될 dependency를 설정한다. (war plugin이 설정된 경우에만 사용 가능하다)

* providedRuntime : runtime시에만 필요하고, 실행환경에서 제공되는 dependency를 설정한다. (war plugin이 설정된 경우에만 사용 가능하다)

 

4. gradle build sequence

* compileJava : java compile

* processResources : resources로 지정된 file 처리 - jar를 만들기 위해 build folder로 copy등의 절차를 취한다

* classes : classes directory를 구성한다. compileJava를 통해 compile된 class객체와 resource들을 취합한다.

* jar : 모인 파일들을 이용해서 jar를 구성한다.

* compileTestJava : test code를 구성한다.

* processTestResources : test resources를 구성한다.

* testClasses : 앞 두과정을 통해서 모여진 파일들을 처리한다.

* test : testClass를 실행해서 JUnit/TextNG 결과를 도출시킨다.


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
,