본문으로 바로가기
반응형

본 예제는 Spring 5.x & Spring boot 2.3.0 버전을 사용합니다.

또한 Kotlin (v1.3.72)로 예제를 작성하며 IntelliJ CE를 사용합니다.


스프링의 MVC를 이용하여 웹 어플리케이션을 제작해 봅니다.

맞춤 차를 사기위해 옵션을 고르고 구매정보를 입력하는 형태를 웹 페이지로 구현해 보겠습니다.


프로젝트 생성

앞선글에 프로젝트를 생성했습니다.
동일한 방법으로 STS(Spring Tool Suite) 또는 웹에서 프로젝트를 생성합니다.

이전 예제와 같이 동일한 네개의 library에 validation을 추가하여 생성합니다.
  • Lombok
  • Spring Boot DevTools
  • Spring Web
  • Thymeleaf
  • Validation

패키지가 생성되면 intelliJ에서 import하여 위와같이 프로젝트를 개발할 준비를합니다.

(STS에서 개발해도 상관은 없습니다.)


Library 추가

console에 로그를 찍기위해 pom.xml에 아래 library의 dependency를 추가합니다.- slf4j
 <!-- https://mvnrepository.com/artifact/org.slf4j/slf4j-api -->
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>1.7.30</version>
        </dependency>


시작 페이지 생성

시작페이지에서는 간단하게 그림만 있는 페이지를 생성하여 진입점 역할을 하도록 합니다.
어떠한 추가적인 처리가 필요한게 아니기 때문에 controller가 필요없으므로 간단하게 만들어 처리해 보겠습니다.


view 생성
View 역할을 하는 html을 아래와 같이 templates 폴더 아래 만듭니다. (home.html)
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Buy My Car!!</title>
</head>
<body>
<h1>Welcome to new car world</h1>
<a href="/select.html" th:href="@{/select}">
    <img th:src="@{/images/CarFactoryMain.jpg}"/>
</a>
</body>
</html>


여기서 th:src로 넣은 이미지 링크는 고정된 resource 이므로 아래의 폴더에 넣습니다.

resource/static/images/CarFactoryMain.jpg

또한 th:href = "@{/select}" 를 넣어 클릭시 /select 링크로 이동하도록 합니다.


configuration 지정
웹브라우저에서 localhost:8080으로 접속했을때 생성한 home.html을 보여주기 위해서는 controller를 생성해도 되지만, 이미 어디로 이동할지를 간략하게 html 코드에 넣었기 때문에 WebMvcConfigurer interface를 이용해서 간단하게 설정 합니다.

@Configuration
class WebConfigure : WebMvcConfigurer {
    override fun addViewControllers(registry: ViewControllerRegistry) {
        registry.addViewController("/").setViewName("home")
    }
}


@Configuration annotation을 달아 auto-scan을 통해 spring application context가 알수 있도록 합니다.

또한 WebMvcConfiturer intreface를 상속하고 addViewcontrollers()를 override하여 이 클래스가 view의 컨트롤러 역할을 할수 있도록 만듭니다.


위 와같이 코드를 꾸며 놓으면 root 경로를 만났을때 templates 폴더에 있는 home.html을 호출해 줍니다.

그리고 나서 CarFactoryApplication class의 main 옆 녹색 화살표를 이용해서 실행을 시킵니다.


웹브라우저로 localhost:8080에 접속하여 아래와 같이 추가한 이미지와 문구가 보여집니다.

html에서 사용한 th:xxx 속성은 thymeleaf 템플릿에서 제공하는 속성입니다.

template을 이용하여 view를 좀더 효율적으로 구성하는 방법으로 자세한 사용법은 아래 링크를 참조하시기 바랍니다.

(웹페이지 검색중 잘 설명된곳이 있어 링크를 걸어 둡니다.)

참조: http://progtrend.blogspot.com/2019/05/thymeleaf.html


GET / POST의 처리

위 링크를 누르면 th:href=@{/select} 구문에 따라서 /select 링크로 이동합니다.
select 페이지에서는 원하는 차의 옵션을 고르도록 하며 이를 위해서 /select를 처리 할 수 있는 Model, View, Controller가 필요합니다.

여기서 Model은 데이터를 담는 역할, Controller는 Model의 데이터를 처리하는 역할, view는 화면을 구성하는 역할을 합니다.

Model은 View와 Controller가 서로 연동될때 데이터를 주고받는 매개체가 됩니다.

아래와 같은 페이지를 만들어 봅니다.


Model 생성

Model은 두개를 생성합니다.

- Part.kt : 화면을 구성할 목록을 가진 Model

- CarInfo.kt: 화면에서 선택한 값을 저장할 Model


먼저 화면에 보여줄 정보를 담는 part.kt는 아래와 같이 생성합니다.

data class Part(val id: Int, val partName: String, val category: PartCategory) {
    enum class PartCategory(val strName: String) {
        FUEL("fuel"), // Gasoline, Disel
        ENGINE("engine"), // 1.6cc, 2.0cc, 2.5cc, 3.0cc
        COLOR("color"), // Black, Red, Silver, White
        TYPE("type"), // Sedan, SUV, RV
        PEOPLE("people") // 5인승, 7인승, 9인승
    }
}


data class로 생성했기 때문에 toString(), hashCode(), equals(), copy()등의 기본적인 함수들이 생성되며, kotlin 이기에 멤버변수의 gettersetter가 자동 생성됩니다.

(코드상에 보이진 않지만요..)


그리고 선택된 정보를 담을 CarInfo class 역시 아래에 정의합니다.

import javax.validation.constraints.NotBlank
import javax.validation.constraints.Size

data class CarInfo(
        @NotBlank
        var extraOrder: String? = null,

        @Size(min = 1, message = "Parts have to be selected at least 1")
        var parts: List? = null
)

CarInfo class는 화면에서 그려지는 html의 form tag의 정보를 담습니다.

이때 form에서 제공하는 field의 유효성 검사를 위해서 간단하게 @NotBlank 또는 @Size같은 annotation을 이용하여 체크할수 있습니다.

form check에 대한 자세한 내용은 아래에 따로 언급합니다.


controller의 생성

controller는 두가지 역할을 해야 합니다.

  1. 화면에 보여질 항목들을 생성하여 Model에 담아 view에 넘겨준다.
  2. view에서 선택된 값을 담은 Model을 받아 처리한다.


1번의 역할을 위에서 언급한 part.kt 파일이 담당합니다.

당연히 2번의 역할은 CarInfo.kt가 담당하게 됩니다.


controller는 기본적으로 아래와 같이 구현합니다.

import org.slf4j.LoggerFactory
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.validation.Errors
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import javax.validation.Valid

@Controller
@RequestMapping("/select")
class SelectCarPartsController {
   ...
    @GetMapping
    fun showSelectView(model: Model): String {
       ...
    }

    private fun getSelectionList(): List {
        ...
        )
    }

    @PostMapping
    fun processForm(@ModelAttribute("car_info") @Valid car: CarInfo, errors: Errors): String {
       ...
    }
}


먼저 Controller class를 생성하고, Spring application context가 알수 있도록 @Controller를 붙여 Auto-scan이 가능하도록 합니다.

그리고 나서 @RequestMapping을 이용하여 "/select" 경로로 들어오는 처리를 하겠다는 표시를 합니다.


또한 @GetMapping@PostMapping을 갖는 함수에 지정하고 각각의 처리가 필요할때 해당 함수가 호출되도록 합니다

브라우저에서 /select 경로로 진입시 @GetMapping annotation이 달린 showSelectView() 함수가 호출되며, 내부에서 post 타입으로 전송된 form의 정보는 @PostMapping으로 표기된 processForm() 함수가 호출 됩니다.


정리하면, class에 @RequestMapping을 통하여 어떤 경로에 대한 처리를 할지에 대해 명시합니다.

그리고 내부적으로 해당 경로에서 발생하는 HTTP Method를 각각 어떤 함수가 처리할지에 대해서도 각 annotation으로 함수를 지정합니다.

따라서 여기서는 사용하지 않지만 아래의 annotation도 존재합니다.

  • @PutMapping
  • @DeleteMapping
  • @PatchMapping


Get의 처리

웹브라우저에서 /select 경로로 접근시 아래와 같이 get method에 대한 처리를 하도록 합니다.

...
 companion object {
        private const val SELECT_VIEW = "select"
        private const val ADDRESS_VIEW = "/order/address"
    }
...
 @GetMapping
    fun showSelectView(model: Model): String {
        val selectionList = getSelectionList()

        val selectionMap = selectionList.groupBy { it.category }

        // category 별로 정리한 list를 model에 담는다.
        for ((key, value) in selectionMap) {
            model.addAttribute(key.strName, value)
        }

        model.addAttribute("car_info", CarInfo())
        return SELECT_VIEW
    }

    private fun getSelectionList(): List {
        return listOf(Part(1, "Gasoline", Part.PartCategory.FUEL),
                Part(2, "Disel", Part.PartCategory.FUEL),
                Part(3, "1.6cc", Part.PartCategory.ENGINE),
                Part(4, "2.0cc", Part.PartCategory.ENGINE),
                Part(5, "2.5cc", Part.PartCategory.ENGINE),
                Part(6, "3.0cc", Part.PartCategory.ENGINE),
                Part(7, "Black", Part.PartCategory.COLOR),
                Part(8, "Red", Part.PartCategory.COLOR),
                Part(9, "Silver", Part.PartCategory.COLOR),
                Part(10, "white", Part.PartCategory.COLOR),
                Part(11, "Sedan", Part.PartCategory.TYPE),
                Part(12, "SUV", Part.PartCategory.TYPE),
                Part(13, "RV", Part.PartCategory.TYPE),
                Part(14, "5", Part.PartCategory.PEOPLE),
                Part(15, "7", Part.PartCategory.PEOPLE),
                Part(16, "9", Part.PartCategory.PEOPLE)
        )
    }
...


먼저 화면에 보여줄 option list를 getSelectionList() 함수를 통해 생성합니다.

그리고 각 part에 따라 묶어서 list를 만들어 selectionMap에 저장합니다.

model.addAttribute(key.strName, value)

이후 category명을 key로 같는 속성을 addArrtibute()를 통해 model에 등록 시킵니다.

따라서 fuel, engine, color, type, people란 속성들이 모델에 각각 등록되겠죠?


그리고 나서 화면에서 선택한 값을 다시 controller가 전달받기 위해 "car_info" 라는 key로 빈 CarInfo()를 생성하여 등록합니다.

일단 addAttribute()를 통해서 model에 필요한 정보들을 넣었습니다.


Model에 등록된 이 정보들은 view에서 꺼내 쓸수도 있고, view에서 값을 채워 넣을 수도 있습니다.

마지막으로 showSelectView()의 return값으로 "select"란 string을 반환합니다.


이렇게 되면 templates/select.html 파일이 호출됩니다.

즉 웹브라우저에서 localhost:8080/select 경로로 진입하면 showSelectView() 함수가 호출되고, 각 정보를 Model에 넣어서 select.html view가 보여지게 됩니다.


Post 처리 

select.html에는 유저의 선택사항이 담긴 form이 post로 제출되도록 할 예정입니다.

따라서 이 controller에서 post로 전달된 값을 처리하도록 함수를 구성합니다.


class SelectCarPartsController {
    companion object {
        private const val SELECT_VIEW = "select"
        private const val ADDRESS_VIEW = "/order/address"
    }

    private val log = LoggerFactory.getLogger("SelectCarPartsController")

    @GetMapping
    fun showSelectView(model: Model): String {
     ...
    }

    private fun getSelectionList(): List {
       ...
    }

    @PostMapping
    fun processForm(@ModelAttribute("car_info") @Valid car: CarInfo, errors: Errors): String {
        if (errors.hasErrors()) {
            log.error("Select form has error")
            return SELECT_VIEW
        }

        log.info("Car info: $car")

        return "redirect:$ADDRESS_VIEW"
    }


form에서 넘어온 정보를 처리하기 위해 @PostMapping annotation을 갖는 processForm() 함수를 구현합니다.

(함수명은 어떤걸로 해도 상관이 없습니다.)


추후 아래서 view를 구성할때 form에서 생성하는값을 @GetMapping시 등록해 놓은 CarInfo() 객체에 담도록 할 예정입니다.

여기서는 화면의 정보가 해당 객체에 담겨저 넘어온 상태라고 가정하고 처리하는 코드를 넣습니다.


먼저, 받아오는 객체가 Model에 어떤 key값으로 mapping되어 있었는지를 @ModelAtttribute를 이용해 표기 합니다.

shoSelectView()에서 view에 넘겨줄 Model에 등록할때 사용했던 "car_info"를 넣습니다.


또한 carInfo 클래스 멤버변수에 @NotBlank, @Size 라는 유효성 체크를 위해 @Valid annotation을 붙여 줍니다.

만약 이 유효성을 통과하지 못한다면 해당 error가 그 다음 param인 Errors 객체에 담겨 옵니다.


error없이 잘 넘어 왔다면 간단하게 콘솔에 폼에 담겨진 정보를 print 합니다.

이때 org.slf4j.LoggerFactory를 이용하여 log를 출력합니다.


사실 class에 annotation으로 @Slf4j를 달아 놓으면 컴파일시 자동으로 log라는 변수에 logger를 생성합니다.

다만 intelliJ에서 인식을 못하여 log 사용부분이 컴파일 오류로 인식 되기에 저는 그냥 아래 구문을 직접 생성해서 넣었습니다.

private val log = LoggerFactory.getLogger("SelectCarPartsController")


마지막으로 함수의 return값으로 "redirect:/order/address"값을 넘겼습니다.

에러없이 정상적으로 form이 처리된다면 /order/address가 호출됩니다.

그렇다면 order/address를 위한 view, controller 필요하다면 model을 새로 생성해야 겠죠?  


정리하면,


GetMapping시 view에서 사용할 model을 addAttribute()를 통해서 (key, value) 형태로 등록해 줍니다.

view에서는 model에 담겨진 속성을 html 내부에서 key를 이용하여 접근하고 값을 읽어가거나, 넣어줍니다.

넣어진 정보는 PostMapping시 Param으로 넘겨받아 다시 코드로 처리합니다.



view 생성

controller에서 view에서 사용할 정보를 model에 담고, 또한 유저의 선택 정보를 담을 객체도 model에 담아 view를 호출했습니다.

이를 view에서(html) 어떻게 사용하지는 알아봅니다.

...

<body>
<h1>Select main parts and option</h1>

<form method="POST" th:object="${car_info}">

    <span class="validationError"
          th:if="${#fields.hasErrors('parts')}"
          th:errors="*{parts}">Wrong selection</span>

    <div class="part-group" id="fuel">
        <h3>Select Fuel:</h3>
        <div th:each="part : ${fuel}">
            <input name="parts" type="checkbox" th:value="${part.id}"/>
            <span th:text="${part.partName}">PART NAME</span><br/>
        </div>
    </div>

    <div class="part-group" id="engine">
        <h3>Select Engine:</h3>
        <div th:each="part : ${engine}">
            <input name="parts" type="checkbox" th:value="${part.id}"/>
            <span th:text="${part.partName}">PART NAME</span><br/>
        </div>
    </div>

   ...
        <button>Submit your selection</button>
    </div>
</form>
</body>
</html>


from tag의 정보를 담기위해 th:object="${car_info}로 설정해 줍니다.

"car_info"는 빈 CarInfo 객체를 생성하여 model에 등록할때 사용한 key값 입니다.


body의 form 첫번째로 Model에서 정의한 유효성 체크를 위한 구문이 들어갑니다.

th:if, th:errors의 자세한 설명은 위 링크에서 확인하시면 됩니다.


fuel을 처리하는 구문을 보면 th:each 구문을 이용해서 Model에서 key가 "fuel"인 collection 값을 꺼내와 반복시킵니다.

th:value는 id값으로 치환되며, 보여지는 값은 part 객체의 멤버변수인 partName값이 됩니다.


실제 브라우저에서 접속해 보면 아래와 같이 생성되어 있음을 확인할 수 있습니다.


이처럼 유사한 형태로 페이지를 하나하나 만들어 갈수 있습니다.


Kotlin에서의 유효성 체크.

위에 사용한 model에서 annotation으로 form 값의 유효성을 아주 쉽게 체크해 볼수 있습니다.
하지만 실제 코드를 수행해 보면 유효성 체크가 동작하지 않는걸 확인할수 있는데, 이는 kotlin 이슈입니다.
유효성 체크에 대한 얘기는 다음 글에서 이어서 진행합니다.


반응형