ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [1단계] Spring MVC 웹 앱 7~8일차. 화면별 기능 구현(MVC +Thymeleaf)
    레거시/레거시-마이 셀파 리부트 2018. 6. 27. 20:49
    반응형

    *본 글은 필자가 진행한 프로젝트 '마이 셀파'의 작업 일기입니다. 재밌게 봐주세요. 코드와 작업 진행 사항은 https://github.com/gurumee92/MySelpaReboot 에서 확인해볼 수 있습니다.

    마이 셀파 리부트 1단계 Spring MVC를 활용한 웹 앱
    7~8일차 화면 별 기능 구현

    지난 월요일과 오늘 총 2일동안 진행했던 작업은 Spring MVC와 Thymeleaf를 이용하여 설계해 두었던 화면별 기능 요구사항을 로그인 화면은 제외하고 모두 구현하기입니다. (화요일은 개인 사정으로 인해서 프로젝트 진행을 잠시 멈췄습니다.) 본격적으로 작업하기 전에 MVC 모델, Spring MVC, Spring Thymleaf에 대해 간단하게 짚고 넘어가는 시간을 가지도록 하죠! 다음 설명들은 위키북스의 책 "스프링 철저 입문"을 참고하였습니다.

    MVC 모델


    MVC 모델이란 흔히 모델 - 뷰 - 컨트롤러 구조를 가지는 소프트웨어 디자인 패턴 중 하나입니다.  이 패턴을 왜 사용하냐면 UI 계층과 비지니스 로직을 분리하여 애플리케이션 개발을 가능하기 때문입니다. 개발 향상성과 이후에 유지보수도 쉬워진다는 장점이 있습니다.





     컴포넌트 명 

     설명 

     모델 

     애플리케이션 상태(데이터) 또는 비지니스 로직을 제공합니다. 

     뷰

     모델이 보유한 애플리케이션 상태(데이터)를 참조하여 클라이언트에 반환할 응답 데이터를 생성합니다. 

     컨트롤러 

     요청을 받아 모델과 뷰의 호출을 제어하는 컴포넌트입니다.  


    간단하게 사용자는 뷰(화면)에 존재하는 컨트롤 요소(버튼, 입력 텍스트 등)를 통해서 앱으로부터 데이터 요청을 합니다. 그러면 이러한 요청에 들어오면 컨트롤러가 사용자 요청에 알맞게 데이터를 참조하여 뷰에게 값을 전달하고 다시 뷰는 이것들을 참조하여 화면에 보여준다고 생각하면 됩니다. 참고로 MVC 구조를 더 진화 혹은 개선시킨 것으로 MVP, MVVM패턴 등이 있습니다.


    Spring MVC


    Spring 프레임워크에서 웹 애플리케이션 구조를 MVC 패턴으로 개발 할 때 쓰이는 모듈입니다. 이 모듈은 다음과 같은 특징이 있습니다.


    1. POJO(Plain Old Java Object)
      컨트롤러, 모델등의 클래스는 POJO 형태로 구현됩니다. 프레임워크에 종속적인 형태가 아니기 때문에 단위테스트가 쉽습니다.
    2. 애너테이션을 이용한 정의 정보 설정
      각종 애너테이션으로 개발을 쉽게 만들어줍니다. @Controller, @RequestMapping등이 바로 이들입니다.
    3. 유연한 메소드 시그니처 정의
      컨트롤러 클래스의 메서드 매개변수에는 처리에 필요한 것만 골라서 정의할 수 있습니다. 후에 살펴보겠습니다.
    4. Servlet API 추상화
      Spring MVC는 서블릿 API를 추상화합니다. 따라서 서블릿 API를 직접 사용할 때보다 코드가 상당부분 제거되기 때문에 컨트롤러 계층의 테스가 더 쉽습니다.
    5. 뷰 구현의 추상화
      Spring MVC의 컨트롤러들은 뷰 이름을 반환합니다. 이에 뷰가 jsp든 html이든 템플릿 엔진인 thymeleaf든 뷰 구현 기술의 구체적인 내용을 몰라도 손쉽게 이용할 수 있습니다.
    6. 스프링 DI 컨테이너와의 연계
      Spring MVC는 DI와 AOP구조를 그대로 활용할 수 있습니다.


    MVC 구조를 체택한 이점으로는 다음과 같습니다.


    1. 풍부한 확장 포인트 제공
    2. 엔터프라이즈 애플리케이션에 필요 기능 제공
    3. 서드 파티 라이브러리 연계 지원


    Spring MVC의 아키텍처는 프런트 컨트롤러 패턴을 체택하고 있습니다. 이 패턴은 클라이언트 요청을 프런트 컨트롤러라는 컴포넌트가 받아 요청 내용에 따라 핸들러를 선택하는 아키텍처입니다. 이 패턴의 장점은 컨트롤러들의 공통적인 처리를 프런트 컨트롤러에 통합할 수 있어 핸들러 처리 내용을 줄일 수 있다는 것입니다. 즉 다음의 기능들은 스프링 MVC가 대신 처리해줍니다.


    1. 클라이언트 요청 접수
    2. 요청 데이터 자바 객체 변환
    3. 입력값 검사 실행(Bean Validation)
    4. 핸들러 호출
    5. 뷰 선택
    6. 클라이언트 요청 결과 응답
    7. 예외 처리


    Spring Thymeleaf


    Spring 프레임워크의 대표적인 웹 템플릿 엔진입니다. 기존 JSP는 디자이너와 프로그래머가 분업하는데 있어 대부분의 디자이너가 자바를 잘 모르기 때문에 애플리케이션 서버에 배포하는데 어려움이 있었다고 합니다. 그래서 디자이너가 작성한 HTML을 바탕으로 개발자가 JSP를 이중으로 관리할 때가 생기곤 했는데 Thymeleaf는 HTML5를 준수하기 때문에 이러한 점을 많이 제거해줍니다. 즉 분업이 쉬워지는 장점이 있습니다.  솔직히 이 부분에 대해서는 의문점이 듭니다만.. 


    이제 본격적으로 제가 작업했던 내용을 이야기하겠습니다. 오늘은 설계 단계에서 각 화면의 기능들을 살펴보고 로그인 화면을 제외한 나머지 화면들의 기능들을 구현하였습니다. 오늘은 이렇다 저렇다 설명보다는 전체적인 코드를 보여주고 설명할 코드를 나눠서 각각 설명해보도록 하겠습니다.


    참고!

    MVC와 타임리프 모듈은 앱을 처음 만들었을 때 설정해두었기 때문에 이번 포스팅에는 설정 작업이 없습니다.


    1. 회원가입 화면

    이 화면의 기능은 다음과 같습니다.


    • ID 중복 체크

    • 회원 가입

    일단은 html파일을 타임리프를 적용시켜 주었습니다.


    src/main/resources/templates/join/join.html

    <!DOCTYPE html>
    <html xmlns:th="http://www.thymeleaf.org">
    <!-- URL : http://localhost:8080/join -->
    <head>
    <meta charset="UTF-8">
    <title>마이 셀파 리부트</title>
    </head>
    <body>
    <div class="title">
    <h1>MySelpa</h1>
    </div>
    <div class="content">
    <form th:action="@{/join}" th:object="${join_form}" method="post">
    <input type="text" th:field="*{id}"/> <br/>
    <input type="password" th:field="*{pw}"/> <br/>
    <input type="tel" th:field="*{phone}"/> <br/>
    <input type="text" th:field="*{carNo}"/> <br/>
    <input type="submit" value="회원 가입"/> <br/>
    </form>
    <a href="/">취소</a>
    </div>
    </body>
    </html>


    코드 설명


    <html xmlns:th="http://www.thymeleaf.org">


    이 부분은 html 파일에 타임 리프를 적용시키는 부분입니다.


    <form th:action="@{/join}" th:object="${join_form}" method="post">
    <input type="text" th:field="*{id}"/> <br/>
    <input type="password" th:field="*{pw}"/> <br/>
    <input type="tel" th:field="*{phone}"/> <br/>
    <input type="text" th:field="*{carNo}"/> <br/>
    <input type="submit" value="회원 가입"/> <br/>
    </form>


     코드

     설명 

     th:action="@{/join}"

     이 부분은 이 form이 어떤 URL로 연결되는지에 대한 부분입니다.  

     th:object=${join_form}

     이 부분은 form 클래스의 적용 부분입니다. 

     th:field="*{id}"

     이 부분은 object 속성으로 지정해두었던 폼 클래스의 대응되는 필드를 나타냅니다. 즉 join_fom.id라고 봐도 무방합니다.


    그 후 JoinController를 다음과 같이 작업해 주었습니다.


    src/main/kotlin/[프로젝트]/app/join/JoinController.kt

    package com.gurumee.myselpa.app.join

    import com.gurumee.myselpa.domain.service.user.UserService
    import org.springframework.beans.factory.annotation.Autowired
    import org.springframework.stereotype.Controller
    import org.springframework.ui.Model
    import org.springframework.web.bind.annotation.ModelAttribute
    import org.springframework.web.bind.annotation.RequestMapping
    import org.springframework.web.bind.annotation.RequestMethod


    data class JoinForm(
    var id: String = "",
    var pw: String = "",
    var phone: String = "",
    var carNo: String = ""
    )

    @Controller
    @RequestMapping("/join")
    internal class JoinController{

    @Autowired
    lateinit var userService: UserService

    @RequestMapping(method=arrayOf(RequestMethod.GET))
    fun getToJoin(model: Model): String{

    val form = JoinForm()
    model.addAttribute("join_form", form)

    return "/join/join.html"
    }

    @RequestMapping(method=arrayOf(RequestMethod.POST))
    fun postToJoin(model: Model,
    @ModelAttribute form: JoinForm): String{

    return when (userService.join(form.id, form.pw, form.phone, form.carNo)){
    true -> "redirect:/"
    false -> "redirect:/join"
    }
    }
    }


    코드 설명

    먼저 form태그에 대응되는 form클래스를 만들어주어야 합니다. 

     join.html

     JoinController.kt 


    <input type="text" th:field="*{id}"/> <br/>
    <input type="password" th:field="*{pw}"/> <br/>
    <input type="tel" th:field="*{phone}"/> <br/>
    <input type="text" th:field="*{carNo}"/> <br/>

    data class JoinForm(
    var id: String = "",
    var pw: String = "",
    var phone: String = "",
    var carNo: String = ""
    )


    html 파일의 form tag의 사용자 입력 속성이 JoinForm 클래스에 대응되는 것이 보이시나요? 이렇게 Form 클래스들은 각 입력 값에 대응되는 필드만 가진 POJO클래스로 만들어주시면 됩니다. 먼저 get으로 접근했을 때 JoinController에서 무엇을 해야 할지 살펴보죠. 


    @Controller
    @RequestMapping("/join")
    internal class JoinController{
    //...
    @RequestMapping(method=arrayOf(RequestMethod.GET))
    fun getToJoin(model: Model): String{


    val form = JoinForm()
    model.addAttribute("join_form", form)


    return "/join/join.html"
    }
    //....
    }


    이전 작업에서 설명했듯이 GET 접근 방식 때는 매핑 되는 컨트롤러에 RequestMapping을 해주면 됩니다. 다만 이전과 달리 컨트롤러에서 /join 의 URL 접근 방식이 GET/POST 둘 다 지원해야 하기 때문에 클래스 위에다 URL을 매핑 시켜주고 GET에 해당하는 함수를 정의하는 식으로 작성해주었습니다. 이 때 페이지와 form 클래스가 대응되도록 빈 폼 클래스를 model 이라는 녀석에게 넣어주면 됩니다. 


    참고!

    Model 클래스는 해당 페이지의 attribute 쉽게 생각해서 컨트롤러가 모델 계층의 데이터를 뷰 계층에게 전달해주는 녀석이라고 생각하면 됩니다. 다만 주의할 점은 컨트롤러에서 다른 컨트롤러로 옮겨갈 때 모델에 넣어주었던 데이터는 사라진다는 것입니다. 이럴때는 쿠키나 세션으로 보내주면 됩니다.


    이제 POST 접근 방식을 했을 때 대응되는 함수를 작성해보도록 하죠.


    @RequestMapping(method=arrayOf(RequestMethod.POST))
    fun postToJoin(model: Model,
    @ModelAttribute form: JoinForm): String{


    return when (userService.join(form.id, form.pw, form.phone, form.carNo)){
    true -> "redirect:/"
    false -> "redirect:/join"
    }
    }


    RequestMethod는 POST로 설정해줍니다. 그리고 같은 컨트롤러에서는 모델이 유지되니까 model을 일단 파라미터로 넣어줍니다. 그리고 뷰의 넣어주었던 폼 클래스를 꺼낼 때는 파라미터로 @ModelAttribute Form 클래스 를 넣어주어야 합니다. 이제 join()이라는 함수를 통해 회원가입 절차가 성공하면 "/" URL로 즉 로그인 화면으로 실패하면 그 화면 그대로 /join으로 리다이렉트하게 해주었습니다.


    참고!

    여기서 return 문은 코틀린을 잘 모르시는 분은 생소할 것입니다. 코틀린은 switch 대신 when이라는 문법으로 대체합니다. 다만 when은 문이 아닌 식, 값이기 때문에 다음처럼 분기문에 값을 적어주어야 합니다. 만약 userService.join()이 true라면 "redirect:/"를 false라면 "redirect:/join"을 반환합니다. 여기서 "redirect:/..."는 Spring 문법입니다. 해당하는 URL에 리다이렉트 시켜준다는 뜻입니다.


    그 후 UserService에 join이라는 함수를 정의해 주었습니다.


    src/main/kotlin/[프로젝트]/domain/service/user/UserService.kt

    //....
    fun join(id: String, pw: String, phone: String, carNo: String): Boolean{
    if (id == "" || pw == "" || phone == "" || carNo == "")
    return false

    val user = userRepository.findById(id)

    return when (user.isPresent){
    true -> false
    false-> {
    userRepository.save(User(id, pw, phone, carNo, false, null))
    true
    }
    }
    }


    참고!

    findById 메서드는 JPARepository 인터페이스가 이미 정의한 메소드입니다. Spring에서 자동으로 구현하는 메소드라고 생각하면 됩니다. 이 메소드는 설정한 JPA Entity 클래스의 @Id로 설정한 필드 즉 id로 db에 있는 데이터를 찾아서 Optional 객체로 반환해주는 함수입니다. 즉 null을 담고 있을 수 있기 때문에 isPresent로 검사해서 만약 그 유저의 id가 존재하면 회원가입을 실패시키고 존재하지 않으면 save()를 통해 DB에다 데이터를 생성해줍니다. save메소드 역시 JPARepository가 이미 정의한 메소드입니다.


    참고! 

    User 클래스는 다음과 같이 변경되었습니다. 왜냐하면 유저 입장에서 봤을 때 예약은 있을 수도 없을 수도 있는 데이터이기 때문입니다. 즉 언젠가는 초기화될 데이터가 아니라는 소리지요


    src/main/kotlin/[프로젝트]/domain/model/User.kt

    package com.gurumee.myselpa.domain.model

    import javax.persistence.*
    import java.io.Serializable

    @Entity
    @Table(name = "user")
    internal data class User(
    @Id
    @Column(name = "id")
    var id: String,

    @Column(name = "pw")
    var pw: String,

    @Column(name = "phone")
    var phone: String,

    @Column(name = "carno")
    var carNo: String,

    @Column(name = "is_master")
    var isMaster: Boolean,

    @OneToOne(fetch = FetchType.LAZY,
    mappedBy = "user",
    cascade = arrayOf(CascadeType.ALL))
    var reservation: Reservation?
    ) : Serializable


    2. 유저 화면

     

    유저 화면의 기능은 다음과 같습니다.


    1. 유저의 예약 상태 표시
    2. 예약 상태에 따라 주차/출차


    원래는 정석적인 형태라면 주차 정보 주차장 이름, 섹터 정보 등의 정보는 컨트롤러에서 따로 뷰에게 전달하는게 맞습니다. 저는 편의상 유저 클래스 내부에서 예약 정보에 대한 내용을 출력하게 만들었습니다. 다음의 함수를 유저 클래스에 추가해주면 됩니다.


    fun getMyCarReservationInfo(): String =
    if (reservation == null)
    "예약 정보가 없습니다."
    else
    """차량 번호 : ${reservation!!.user.carNo}
    주차장 이름 : ${reservation!!.parkingLot.name}
    주차한 공간 : ${reservation!!.sector}"""


    참고!

    코틀린은 if 역시 문이 아닌 식입니다. 하나의 값으로 표현된다는 소리죠. 만약 함수가 별 코드 없이 리턴이 된다면 다음과 같이도 작성할 수 있습니다.


    자 이제 html 코드를 다음과 같이 변경해주었습니다.


    src/main/resources/templates/user/user.html

    <!DOCTYPE html>
    <html xmlns:th="http://www.thymeleaf.org">
    <!-- URL : http://localhost:8080/user-->
    <head>
    <meta charset="UTF-8">
    <title>마이 셀파 리부트</title>
    </head>
    <body>
    <h1> <span th:text="|${user.id}님|">유저 정보</span> </h1>

    <div class="reservation_info">
    <span th:text="|${user.getMyCarReservationInfo()}|">차량 정보</span>
    </div>
    <div class="get_user_area">
    <form th:if="${user.reservation } eq null"
    th:action="@{'/search'}"
    method="get">
    <input type="submit" value="주차하기"/>
    </form>
    <form th:unless="${user.reservation } eq null"
    th:action="@{'/user/' + ${user.reservation.no}}"
    method="post">
    <input type="submit" value="출차하기"/>

    </form>
    </div>

    <div class="post_area">
    <a href="/">로그아웃</a>
    </div>
    </body>
    </html>


    코드 설명


     코드 

     설명 

     th:text="|${user.id} 님|"

     해당 태그의 text를 지정해줍니다. 다만 컨트롤러에서 주입한 객체를 ${ ... } 로 이용할 수 있습니다. 

     th:if="${user.reservation} eq null"

     해당 태그가 유저의 예약 정보가 없을 때 html파일에서 나타내게 해줍니다. 만약 주차 정보가 없으면 유저는 주차하기를 통해 주차 자리를 예약할 수 있습니다.

      th:unless="${user.reservation} eq null"

     if와 달리 unless는 ""로 감싸져있는 식이 false를 반환하면 html파일에서 감싸져 있는 태그가 나타나게 합니다. 즉 예약 정보가 있다면 출차시키게끔 출차 버튼이 나타나게 합니다.

     th:action=@"'/user/' + ${user.reservation.no}"

     다음과 같이 url을 직접 기입하고 주입한 객체의 필드들을 이용해서 정보를 동적으로 만들 수도 있습니다. 이 form 태그는 POST방식으로 /url/예약 번호로 접근합니다. 그래서 이 유저가 예약한 자리를 삭제시키게끔 만듭니다.


    참고!

    유저의 예약 정보가 없으면 주차 버튼이 생기고 이 버튼을 누르면 GET 방식으로 /search에 대응되는 URL로 이동합니다.


    그 후 UserController.kt를 다음과 같이 작업해주었습니다.


    src/main/kotlin/[프로젝트]/app/user/UserController.kt

    package com.gurumee.myselpa.app.user

    import com.gurumee.myselpa.domain.model.User
    import com.gurumee.myselpa.domain.service.reservation.ReservationService
    import com.gurumee.myselpa.domain.service.user.UserService
    import org.springframework.beans.factory.annotation.Autowired
    import org.springframework.stereotype.Controller
    import org.springframework.ui.Model
    import org.springframework.web.bind.annotation.PathVariable
    import org.springframework.web.bind.annotation.RequestMapping
    import org.springframework.web.bind.annotation.RequestMethod

    @Controller
    @RequestMapping("user")
    internal class UserController{

    val user = User("testUser", "", "", "", true, null)

    @Autowired
    lateinit var userService: UserService

    @Autowired
    lateinit var reservationService: ReservationService

    @RequestMapping(method=arrayOf(RequestMethod.GET))
    fun getToUser(model: Model): String{

    model.addAttribute("user", user)

    return "user/user.html"
    }

    @RequestMapping(value="{reservation_no}",
    method= arrayOf(RequestMethod.POST))
    fun postToUser(model: Model,
    @PathVariable(name="reservation_no") reservationNo: Int): String{
    reservationService.deleteReservationByNo(reservationNo)

    return "redirect:/user"
    }
    }


    먼저 클래스 내에 더미 유저를 만들어서 화면별 기능들을 먼저 작업하겠습니다. 다음 작업 시큐리티 적용시에 이 부분들은 사라질 예정입니다. 우선 GET 방식은 다음과 같습니다.

    //...

    val user = User("testUser", "", "", "", true, null)

    //...


    @RequestMapping(method=arrayOf(RequestMethod.GET))
    fun getToUser(model: Model): String{

    model.addAttribute("user", user)
    return "user/user.html"
    }

    그리고 프로그램을 키고 /user에 접속했을 때 다음과 같은 화면이 뜰것입니다.



    자 이제는 예약 정보가 있는 유저로 바꿔보겠습니다. 편의 상 유저 서비스를 이용하여 gurumee3의 데이터를 불러와서 뷰에 넣어주었습니다.


    @RequestMapping(method=arrayOf(RequestMethod.GET))
    fun getToUser(model: Model): String{
    val user = userService.findUser("gurumee3")

    model.addAttribute("user", user)
    return "user/user.html"
    }

    그러면 이제 다음과 같은 화면이 뜰겁니다.



    이제 출차하기 버튼을 누르면 유저의 예약 번호를 POST방식으로 보내주어서 데이터를 삭제해 보도록 하겠습니다.


    @RequestMapping(value="{reservation_no}",
    method= arrayOf(RequestMethod.POST))
    fun postToUser(model: Model,
    @PathVariable(name="reservation_no") reservationNo: Int): String{
    reservationService.deleteReservationByNo(reservationNo)

    return "redirect:/user"
    }

    이전 회원가입 화면에서처럼 폼 클래스는 없지만 url에 예약 번호를 넣는 방식으로 해서 데이터를 접근합니다. 그 후 reservationService의 deleteReservationByNo를 호출하여 데이터를 삭제하는 코드를 작성하였습니다. 그 후 Repository, Service의 메소드를 정의해 주었습니다.


    src/main/kotlin/[프로젝트]/domain/repository/reservation/ReservationRepository.kt

    @Modifying
    @Query("DELETE FROM Reservation r WHERE r.no=:no")
    fun deleteReservationByNo(@Param("no") no: Int)

    참고!

    DB에 데이터가 변경되는 쿼리를 날린다면 @Modifying이라는 애노테이션을 붙여주어야 합니다.


    src/main/kotlin/[프로젝트]/domain/service/reservation/ReservationService.kt


    fun deleteReservationByNo(no: Int){
    reservationRepository.deleteReservationByNo(no)
    }


    그리고 다시 /user로 리다이렉트 하게 만들어두었습니다. 이제 출차 버튼을 누르면 다음과 같은 화면이 뜨게 됩니다.


    예약 데이터가 삭제되었음을 삭제되었음을 알 수 있습니다. 이제 검색 기능을 구현 해보겠습니다. 잠깐 그 전에 검색 화면과 상세 정보 화면에서 유저의 기능들은 마스터 유저의 기능들에 포함됩니다. 따라서 검색과 상세 정보 페이지는 마스터 유저로 기능들을 모두 구현한 후 유저로 접속했을 때 마스터에게만 보이는 기능들을 숨겨주었습니다.



    3. 검색 화면


    검색 화면의 기능은 다음과 같습니다


    1. 검색 기능
    2. 검색된 주차장의 상태 정보들
    3. 검색된 주차장의 예약 정보들


    이 화면과 비슷하게 만들기 위해 search.html을 다음과 같이 작성하였습니다.


    src/main/resources/templates/search/search.html

    <!DOCTYPE html>
    <html xmlns:th="http://www.thymeleaf.org">
    <!-- URL : http://localhost:8080/search -->
    <head>
    <meta charset="UTF-8">
    <title>마이 셀파 리부트</title>
    <style>
    table, th, td {
    border: 1px solid black;
    }
    </style>
    </head>
    <body>
    <h1>주차장 검색 페이지</h1>
    <div id="search_form">
    <form th:action="@{/search}" th:object="${search_form}" method="post">
    <input type="text" th:field="*{text}"/> <br/>
    <input type="submit" value="submit">
    </form>
    </div>
    <div class="lot_list">
    <table>
    <tr>
    <th>주차장 이름</th>
    <th>현재 이용 고객 수</th>
    <th>주차장 총 자리 수</th>
    <th></th>
    </tr>
    <tr th:each="lot : ${lots}">
    <td><span th:text="${lot.name}">주차장 이름</span></td>
    <td><span th:text="${lot.reservationList.size()}">현재 이용 고객 수</span></td>
    <td><span th:text="${lot.totalSectorCnt}">주차장 총 자리 수</span></td>
    <td><a th:href="@{'/search/' + ${lot.no}}">예약하러 가기</a></td>
    </tr>
    </table>
    </div>
    <div th:if="${is_master} eq true"
    class="master_reservation_list">
    <table>
    <tr>
    <th>주차장 이름</th>
    <th>예약한 섹터</th>
    <th>예약한 유저</th>
    <th>차량 번호</th>
    <th></th>
    </tr>
    <tr th:each="reservation : ${reservations}">
    <td><span th:text="${reservation.parkingLot.name}">주차장 이름</span></td>
    <td><span th:text="${reservation.sector}">예약한 섹터</span></td>
    <td><span th:text="${reservation.user.id}">예약한 유저</span></td>
    <td><span th:text="${reservation.user.carNo}">차량 번호</span></td>
    <td>
    <form th:action="@{'/search/delete?resv_no='+${reservation.no}}" method="post">
    <input type="submit" value="예약 삭제"/>
    </form>
    </td>
    </tr>
    </table>
    </div>
    <a href="/">로그아웃</a>
    <a th:if="${is_master} eq false" href="/user">유저 페이지</a>
    </body>
    </html>


    코드 설명

    <tr th:each="reservation : ${reservations}">
    <td><span th:text="${reservation.parkingLot.name}">주차장 이름</span></td>
    <td><span th:text="${reservation.sector}">예약한 섹터</span></td>
    <td><span th:text="${reservation.user.id}">예약한 유저</span></td>
    <td><span th:text="${reservation.user.carNo}">차량 번호</span></td>
    <td>
    <form th:action="@{'/search/delete?resv_no='+${reservation.no}}" method="post">
    <input type="submit" value="예약 삭제"/>
    </form>
    </td>
    </tr>


    th:each는 컨트롤러에서 주입된 객체가 순회가 가능한 객체라면 for-each문처럼 반복시켜주는 속성입니다. 따라서 컨트롤러가 넣어준 예약 리스트의 정보가 들어가서 예약 정보 하나 당 한 행을 구성하게 됩니다.


    SearchController는 다음과 같습니다.


    src/main/kotlin/[프로젝트]/app/search/SearchController.kt

    package com.gurumee.myselpa.app.search

    import com.gurumee.myselpa.domain.model.ParkingLot
    import com.gurumee.myselpa.domain.model.Reservation
    import com.gurumee.myselpa.domain.model.User
    import com.gurumee.myselpa.domain.service.lot.ParkingLotService
    import com.gurumee.myselpa.domain.service.reservation.ReservationService
    import org.springframework.beans.factory.annotation.Autowired
    import org.springframework.stereotype.Controller
    import org.springframework.ui.Model
    import org.springframework.web.bind.annotation.*
    import org.springframework.web.servlet.mvc.support.RedirectAttributes
    import java.util.stream.Collectors
    import javax.servlet.http.HttpServletRequest

    data class SearchForm(
    var text: String=""
    )

    @Controller
    @RequestMapping("search")
    internal class SearchController {

    val master = User("master", "", "", "", true, null)

    @Autowired
    lateinit var reservationService: ReservationService

    @Autowired
    lateinit var parkingLotService: ParkingLotService

    @RequestMapping( method = arrayOf(RequestMethod.GET))
    fun getToSearch(model: Model,
    @RequestParam(name="text") text:String? = null): String{

    val searchForm = SearchForm()

    var parkingLots: List<ParkingLot> = parkingLotService.findParkingLots(text)
    //자바 스트림 이용 병렬 환경에서 안전한 병합 리스트
    var reservations:List<Reservation> = parkingLots.stream()
    .parallel()
    .flatMap{
    it.reservationList.stream().parallel()
    }.collect(Collectors.toList())

    model.addAttribute("is_master", master.isMaster)
    model.addAttribute("lots", parkingLots)
    model.addAttribute("reservations", reservations)
    model.addAttribute("search_form", searchForm)

    return "search/search.html"
    }

    @RequestMapping(method=arrayOf(RequestMethod.POST))
    fun postToSearch(model: Model,
    redirectAttributes: RedirectAttributes,
    @ModelAttribute searchForm: SearchForm): String{
    redirectAttributes.addAttribute("search_text", searchForm.text)

    return "redirect:/search?text={search_text}"
    }

    @RequestMapping(value="delete",
    method= arrayOf(RequestMethod.POST))
    fun deleteReservation(httpServletRequest: HttpServletRequest,
    @RequestParam("resv_no") resvNo: Int): String{
    val oldURL = httpServletRequest.getHeader("referer")

    reservationService.deleteReservationByNo(resvNo)

    return "redirect:${oldURL}"
    }
    }


    /search GET 방식을 구현해보도록 하죠. 먼저 검색이 있을 때 저는 URL에 /search?text=?? 형식으로 정보를 만들도록 하겠습니다. 이때 text를 RequestParameter 리퀘스트 파라미터라고 부릅니다. 그리고 그러한 것을 Spring에서 표현할 때 @RequestParam(name="")으로 표현합니다.


    @RequestMapping( method = arrayOf(RequestMethod.GET))
    fun getToSearch(model: Model,
    @RequestParam(name="text") text:String? = null): String{
    val searchForm = SearchForm()

    var parkingLots: List<ParkingLot> = parkingLotService.findParkingLots(text)
    //자바 스트림 이용 병렬 환경에서 안전한 병합 리스트
    var reservations:List<Reservation> = parkingLots.stream()
    .flatMap{
    it.reservationList.stream()
    }.collect(Collectors.toList())

    model.addAttribute("is_master", master.isMaster)
    model.addAttribute("lots", parkingLots)
    model.addAttribute("reservations", reservations)
    model.addAttribute("search_form", searchForm)

    return "search/search.html"
    }


    참고! 

    text: String = null

    은 디폴트 파라미터를 설정하는 부분입니다. 자바 코드로 바뀌면 파라미터가 있는 메소드와 없는 메소드로 나뉩니다. 


    일단 검색 폼을 넣어주기 위하여 SearchForm을 생성해 주었습니다. SearchForm은 text 필드 하나밖에 없습니다.


    data class SearchForm(
    var text: String=""
    )


    그 후 parkingLotService에 정의해 두었던 findParkingLots메소드를 호출하여 검색된 문자열을 통해 주차장 정보를 받아왔고 주차장 리스트들을 통해서 flatMap을 이용하여 각 주차장이 가지고 있는 예약 리스트들을 하나의 리스트로 합쳐주었습니다. 그 후 뷰를 넣어주었습니다. 이제 앱을 키면 다음과 같이 화면이 뜰겁니다.


    그 후 검색 기능을 구현해주었습니다.


    <form th:action="@{/search}" th:object="${search_form}" method="post">
    <input type="text" th:field="*{text}"/> <br/>
    <input type="submit" value="submit">
    </form>


    검색 폼은 search_form으로 폼 클래스를 받아서 text에 입력된 값을 대칭시킨 후에 /search URL에 POST방식으로 전송시킵니다. 따라서 POST에 대응하는 메소드를 정의해주면 됩니다.


    @RequestMapping(method=arrayOf(RequestMethod.POST))
    fun postToSearch(model: Model,
    redirectAttributes: RedirectAttributes,
    @ModelAttribute searchForm: SearchForm): String{
    redirectAttributes.addAttribute("search_text", searchForm.text)

    return "redirect:/search?text={search_text}"
    }


    이 때 리다이렉트됐을 때 검색 결과가 유지되기 위해서 RedirectAttributes를 파라미터에 넣어주었습니다. 그 후 회원가입때와 마찬가지로 Form 클래스를 @ModelAttribute 애노테이션을 붙여주면 됩니다. 그리고 redirect에 원하는 방식의 URL을 작성해주면 됩니다. 이제 '산'이라는 것을 쳐보겠습니다. 그럼 다음 화면이 뜨게 됩니다.



    이렇게 URL이 바뀌었고 해당하는 텍스트에 따라 검색 결과가 바뀐 것을 볼 수 있습니다. 이제 예약 정보를 삭제하는 기능을 구현해보겠습니다.


    <form th:action="@{'/search/delete?resv_no='+${reservation.no}}" method="post">
    <input type="submit" value="예약 삭제"/>
    </form>


    이 html코드는 아까 예약 리스트를 도는 tr 태그 밑에 존재하는 코드입니다. 각 예약 번호에 따라 /search/delete?resv_no=?에 맞는 URL로 동적으로 매핑시키고 POST 방식으로 데이터를 전송합니다. 그 후 SearchController에 /search/delete에 대응하는 POST 방식의 메소드를 만들어 주었습니다.


    @RequestMapping(value="delete",
    method= arrayOf(RequestMethod.POST))
    fun deleteReservation(httpServletRequest: HttpServletRequest,
    @RequestParam("resv_no") resvNo: Int): String{
    val oldURL = httpServletRequest.getHeader("referer")

    reservationService.deleteReservationByNo(resvNo)
    return "redirect:${oldURL}"
    }


    이번에는 이전 URL로 돌려주기 위해서 HttpServletRequest 객체를 파라미터로 넘겨주었습니다. 이 객체의 getHeader("referer")을 호출하면 이전 URL을 반환해줍니다. 이런 코드를 짠 이유는 상세 정보에서도 삭제를 하는데 삭제가 일어난 후에 각 페이지의 URL을 유지하고 있어야 하기 때문입니다. 이제 삭제를 눌러보면 삭제가 잘 될 것입니다. 그리고 따로 테스트는 하지 않겠지만 고객의 예약 정보가 나타나는 부분에서 th:if 속성 때문에 일반 유저로 접속하면 이 정보가 화면에 뜨지 않습니다.


    <div th:if="${is_master} eq true"
    class="master_reservation_list">
    <table>
    ....
    </table>
    </div> 


    4. 상세 정보 페이지

    이렇게 화면의 기능은 다음과 같습니다.


    1. 해당 주차장의 예약 정보 표시


    근데 유저에게는 예약 정보는 정사각형 모양의 주차 공간이 한 눈에 표시되게끔 만들어야 하며 예약된 곳은 선택이 안되게끔 예약 안된 곳은 선택이 되게끔 만들어야 합니다. 마스터에게는 이 둘 다 선택하지 못하도록 만들었습니다. 그리고 오로지 고객 예약 정보 리스트에서 삭제가 가능하게끔 만들었습니다.


    html은 다음과 같습니다.


    src/main/resources/templates/search/search_info.html

    <!DOCTYPE html>
    <html xmlns:th="http://www.thymeleaf.org">
    <!-- URL : http://localhost:8080/search/{lot_no}-->
    <head>
    <meta charset="UTF-8">
    <title>마이 셀파 리부트</title>
    <style>
    table, th, td {
    border: 1px solid black;
    }
    </style>
    </head>
    <body>
    <h1 th:text="|${lot.name} 주차장 현황|">주차장 상세 정보 페이지</h1>
    <div th:if="${is_master} eq true"
    class="master_reservation_list">
    <table>
    <tr th:each="i : ${#numbers.sequence(1, tableLength)}">
    <td th:each="j : ${#numbers.sequence(1, tableLength)}">
    <form >
    <input th:if="${ sectors.contains( (i-1) * tableLength + j ) }"
    type="button"
    disabled="disabled"
    th:value="${(i-1) * tableLength + j}"/>
    <input th:unless="${ sectors.contains( (i-1) * tableLength + j) }"
    type="button"
    disabled="disabled"
    value="X"/>
    </form>
    </td>
    </tr>
    </table>
    <table>
    <tr>
    <th>예약한 유저</th>
    <th>차량 번호</th>
    <th>예약한 섹터</th>
    <th></th>
    </tr>
    <tr th:each="reservation : ${lot.reservationList}">
    <td><span th:text="${reservation.user.id}">예약한 유저</span></td>
    <td><span th:text="${reservation.user.carNo}">차량 번호</span></td>
    <td><span th:text="${reservation.sector}">예약한 섹터</span></td>
    <td>
    <form th:action="@{'/search/delete?resv_no='+${reservation.no}}" method="post">
    <input type="submit" value="예약 삭제"/>
    </form>
    </td>
    </tr>
    </table>
    </div>
    <div th:unless="${is_master} eq true"
    class="user_reservation_list">
    <table>
    <tr th:each="i : ${#numbers.sequence(1, tableLength)}">
    <td th:each="j : ${#numbers.sequence(1, tableLength)}">
    <form th:action="@{'?sector_no=' + ${(i-1) * 2 + j}}"
    method="post">
    <input th:if="${ sectors.contains( (i-1) * tableLength + j ) }"
    type="button"
    disabled="disabled"
    value="X"/>
    <input th:unless="${ sectors.contains( (i-1) * tableLength + j) }"
    type="submit"
    th:value="${(i-1) * 2 + j}"/>
    </form>
    </td>
    </tr>
    </table>
    </div>
    <a href="/">로그아웃</a>
    <a href="/search">검색 페이지</a>
    </body>
    </html>


    코드 설명 html은 이 부분만 설명하면 될 것 같습니다.


    <tr th:each="i : ${#numbers.sequence(1, tableLength)}">
    <td th:each="j : ${#numbers.sequence(1, tableLength)}">
    <form th:action="@{'?sector_no=' + ${(i-1) * 2 + j}}"
    method="post">
    <input th:if="${ sectors.contains( (i-1) * tableLength + j ) }"
    type="button"
    disabled="disabled"
    value="X"/>
    <input th:unless="${ sectors.contains( (i-1) * tableLength + j) }"
    type="submit"
    th:value="${(i-1) * 2 + j}"/>
    </form>
    </td>
    </tr>


    여기서 2차원 배열을 생성하기 위해서 #numbers.sequence(1, ...)를 썼습니다. 이렇게 하면 원하는 수만큼 반복해줄 수 있습니다. 그리고 각 섹터의 번호를 나타내기 위해서 (i-1 * 테이블 길이 + j)를 써주었습니다. 이 부분은 유저가 이 섹터를 누르면 섹터 번호로 예약 정보를 추가해주는 부분과 연결시켜주는 부분입니다. form 태그의 th:action=@"{'?sector_no=' + ${섹터 번호}}"에 따라서 URL이 이동합니다. 역시 POST 방식이기 때문에 POST를 연결해주면 됩니다. 이 부분의 GET/POST는 이전 작업과 똑같은 원리이므로 설명을 생략합니다.


    @RequestMapping(value="{lot_no}",
    method=arrayOf(RequestMethod.GET))
    fun getToLotDetails(model: Model,
    @PathVariable(name="lot_no") lotNo: Int): String{

    val parkingLot = parkingLotService.findParkingLot(lotNo)
    val sectorList = parkingLot.reservationList.map { it.sector }

    val tableLength= Math.ceil(
    Math.sqrt(
    parkingLot.totalSectorCnt.toDouble()
    )).toInt()

    model.addAttribute("is_master", master.isMaster)
    model.addAttribute("lot", parkingLot)
    model.addAttribute("reservations", parkingLot.reservationList)
    model.addAttribute("sectors", sectorList)
    model.addAttribute("tableLength", tableLength)

    return "search/search_info.html"
    }

    @RequestMapping( value="{lot_no}",
    method = arrayOf(RequestMethod.POST))
    fun insertToLotReservation(model: Model,
    @PathVariable("lot_no") lotNo : Int,
    @RequestParam("sector_no") sectorNo: Int) : String{
    //세션 객체에서 유저를 꺼내온다.


    //lot_no를 이용해 주차장을 가져온다.
    //유저 차, 자동차 번호, 주차장 섹터(resv_no)를 통해 추가한다.
    reservationService.insertReservation(user, lotNo, sectorNo)


    return "redirect:/user"
    }


    이렇게 해서 총 2일에 걸쳐서 화면에 나타나는 모든 기능들을 구현해보았는데요. 이제 프로젝트의 끝이 보이는 것 같습니다. 다음 9일차에는 스프링 시큐리티를 적용해서 로그인에서 유저 정보를 넘겨주는 작업을 구현해보겠습니다. 이상 긴 글 읽느라 고생하셨습니다!

Designed by Tistory.