Spring Example: ToDo List #4 컨트롤러 개발

함형주·2022년 9월 18일
0

Spring Example: ToDo

목록 보기
5/16

질문, 피드백 등 모든 댓글 환영합니다.

지금까지 개발한 핵심 로직을 기반으로 컨트롤러와 html 파일을 작성하겠습니다.
개발 순서는 HomeController -> LoginController -> ToDoController 이며 html은 각 컨트롤러를 개발하며 필요할 때 마다 생성해주겠습니다. 자세한 검증 로직은 전반적인 컨트롤러 개발 후에 진행합니다.
뷰 템플릿 엔진은 타임리프(Thymeleaf)를 사용하였고 기본적인 디자인을 위해 부트스트랩도 포함했습니다.
css 지식이 얕을 뿐더러 이 프로젝트에서 중요한 것이 아니기 때문에 이 블로그에서 설명하지 않겠습니다.

DTO 패키지 위치 고민 // 참고 1 참고 2 참고 3

컨트롤러, DTO 개발에 앞서 DTO 패키지의 위치를 어떻게 설정할 지에 대한 고민이 있었습니다. 참고한 블로그 글을 토대로 Controller -> Service -> Repository 의 의존관계가 무너지지 않는 선에서 DTO의 사용이 자유로워도 된다고 이해했습니다. 이 프로젝트의 규모가 굉장히 작아 Controller 계층에서 DTO 관련 로직을 모두 처리해도 후에 유지보수가 어렵지 않겠다고 생각했습니다. 때문에 DTO의 패키지를 Controller 패키지 안에 두었고 Service에선 DTO를 사용하지 않도록 설계하였습니다.


Annotation

@Controller : 스프링이 컨트롤러 계층으로 인식
@GetMapping : http get 메서드 요청을 처리
@PostMapping : http post 메서드 요청을 처리
@ModelAttribute : http 요청을 받아 각 인자를 객체로 변환, 추가로 이 어노테이션이 달린 필드를 모델에 담아 view로 전송
@PathVariable : Uri 파라미터로 넘어온 값을 매핑
++ Thymeleaf 사용을 위해선 <html xmlns:th="http://www.thymeleaf.org" 필요

HomeController

HomeController

@Controller
@RequiredArgsConstructor
public class HomeController {

    private final MemberService memberService;

    @GetMapping("/")
    public String home() {
        return "/index";
    }

    @GetMapping("/add")
    public String addForm(@ModelAttribute MemberDto memberDto) {
        return "/member/add";
    }

    @PostMapping("/add")
    public String save(@ModelAttribute MemberDto memberDto) {

        Member member = new Member(memberDto.getLoginId(), memberDto.getPassword(), memberDto.getName());
        return memberService.save(member) != null ? "redirect:/" : "/member/add";
    }
}

@PostMapping("/add") 필드에서 MemberDto로 요청값을 매핑하여 Member를 생성

MemberDto

@Getter @Setter
public class MemberDto {

    private String loginId;
    private String password;
    private String checkPassword;
    private String name;
}

home.html

<body>
<div class="container position-absolute top-50 start-50 translate-middle">
    <div class="py-5 text-center">
        <hr class="my-4">
        <h2>홈 화면</h2>
    </div>
    <div class="d-grid gap-2 col-6 mx-auto">
        <button class="w-100 btn btn-secondary btn-lg" type="button"
                th:onclick="|location.href='@{/add}'|">
            회원 가입
        </button>
        <button class="w-100 btn btn-secondary btn-lg"
                th:onclick="|location.href='@{/login}'|" type="button">
            로그인
        </button>
    </div>
    <hr class="my-4">
</div>
</body>

add.html

<body>
<div class="container position-absolute top-50 start-50 translate-middle">
    <hr class="mt-5 my-4">
    <h2 class="text-center">회원 가입</h2>
    <form th:object="${memberDto}" method="post">
        <div class="d-grid gap-2 col-6 mx-auto">
            <label for="name" class="form-label">이름</label>
            <input type="text" id="name" class="w-100 form-control" th:field="*{name}">

            <label for="loginId" class="form-label">로그인 id</label>
            <input type="text" id="loginId" class="w-100 form-control" th:field="*{loginId}">

            <label for="password" class="form-label">비밀번호</label>
            <input type="password" id="password" class="w-100 form-control" th:field="*{password}">

            <label for="checkPassword" class="form-label">비밀번호 확인</label>
            <input type="password" id="checkPassword" class="w-100 form-control" th:field="*{checkPassword}">
        </div>

        <div class="m-5 row">
            <div class="col"></div>
            <div class="col-4">
                <button class="w-100 btn btn-success btn-lg" type="submit">회원 가입</button>
            </div>
            <div class="col-4">
                <button class="w-100 btn btn-outline-secondary btn-lg" type="button"
                        onclick="location.href='index.html'"
                        th:onclick="|location.href='@{/}'|">취소
                </button>
            </div>
            <div class="col"></div>
        </div>
    </form>
    <hr class="my-4">
</div>
</body>

name, loginId, password, checkPassword를 입력받아 컨트롤러로 post요청.

LoginController

(2022-11-29) 수정 사항 : 로그인 후 HttpSession에 엔티티를 저장하지 않고 MemberSessionDto를 저장합니다.
이후 Spring Security를 이용하여 로그인 기능을 구현하기에 예제 코드는 수정하지 않겠습니다. 혹시 예제를 따라하신다면 아래의 MemberSessionDto 을 사용하도록 적절히 변경시켜서 사용하시길 바랍니다.

참고 : 세션에 엔티티를 직접 저장하기 위해선 직렬화가 필요한데 추후에 다른 엔티티와 연관관계를 맺을 시 직렬화 대상에 다른 엔티티까지 포함될 수 있어 성능 이슈, 부수 효과 우려가 생길 수 있습니다.
출처 : 스프링 부트와 AWS로 혼자 구현하는 웹 서비스, 저자 : 이동욱

MemberSessionDto

public class MemberSessionDto {
    private String loginId;
    private String password;
    private String name;
}

LoginController

@Controller
@RequiredArgsConstructor
public class LoginController {

    private final MemberService memberService;
    private final LoginService loginService;

    @GetMapping("/login")
    public String loginForm(@ModelAttribute("loginDto") LoginDto loginDto) {
        return "/form";
    }

    @PostMapping("/login")
    public String login(@ModelAttribute LoginDto loginDto, HttpServletRequest request) {

        Optional<Member> loginMember = loginService.login(loginDto.getLoginId(), loginDto.getPassword());

        loginMember.ifPresent(member -> {
            HttpSession session = request.getSession();
            session.setAttribute("loginMember", member);
        });

        return "redirect:/todo";
    }
}

@PostMapping("/login") 필드에서 LoginDto로 요청값을 매핑하여 로그인 성공 시 세션쿠키 생성

form.html

<body>
<div class="container position-absolute top-50 start-50 translate-middle">
    <hr class="mt-5 my-4">
    <h2 class="text-center">로그인</h2>

    <form th:object="${loginDto}" method="post">
        <div class="d-grid gap-2 col-6 mx-auto">
            <label for="loginId" class="form-label">로그인 ID</label>
            <input type="text" id="loginId" class="w-100 form-control" th:field="*{loginId}">

            <label for="password" class="form-label">비밀번호</label>
            <input type="password" id="password" class="w-100 form-control" th:field="*{password}">
        </div>

        <div class="m-4 row">
            <div class="col"></div>
            <div class="col-4">
                <button class="w-100 btn btn-success btn-lg" type="submit">회원 가입</button>
            </div>
            <div class="col-4">
                <button class="w-100 btn btn-outline-secondary btn-lg" type="button"
                        onclick="location.href='index.html'"
                        th:onclick="|location.href='@{/}'|">취소
                </button>
            </div>
            <div class="col"></div>
        </div>
    </form>
    <hr class="my-4">
</div>

loginId, password 입력받아 컨트롤러로 Post 요청.

ToDoController

ToDoDto

@Getter @Setter
public class ToDoDto {

    private Long id;
    private String title;
    private String description;
    private Boolean isCompleted = false;
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate createdDate;
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate dueDate;

    public ToDoDto(Long id, String title, String description, Boolean isCompleted, LocalDate createdDate, LocalDate dueDate) {
        this.id = id;
        this.title = title;
        this.description = description;
        this.isCompleted = isCompleted;
        this.createdDate = createdDate;
        this.dueDate = dueDate;
    }
}

ToDoController

@Controller
@RequiredArgsConstructor
public class ToDoController {

    private final ToDoService toDoService;
    private final MemberService memberService;

    @ModelAttribute("toDoDtos")
    public List<ToDoDto> toDoDtos(HttpServletRequest request) {
        return getToDoDtos(getSessionMember(request), false);
    }

    @ModelAttribute("completedDtos")
    public List<ToDoDto> completedDtos(HttpServletRequest request) {
        return getToDoDtos(getSessionMember(request), true);
    }

    private static Member getSessionMember(HttpServletRequest request) {
        return (Member) request.getSession().getAttribute("loginMember");
    }

    private List<ToDoDto> getToDoDtos(Member loginMember, Boolean isCompleted) {
        List<ToDo> list = toDoService.findToDoListByMemberIdAndIsCompleted(loginMember.getId(), isCompleted);
        return list.stream().map(toDo ->
                        new ToDoDto(toDo.getId(), toDo.getTitle(), toDo.getDescription(),
                                toDo.getIsCompleted(), toDo.getCreatedDate(), toDo.getDueDate()))
                .collect(Collectors.toList());
    }
    ..
}

메인 페이지에서 ToDo 출력을 위해 세션 쿠키에 담긴 멤버의 Id와 isCompleted 값으로 ToDo를 리스트로 조회 후 ToDoDto로 변환하여 @ModelAttribute를 통해 view에 전달

class ToDoController {..

    @GetMapping("/todo")
    public String todo(@ModelAttribute("toDoDto") ToDoDto toDoDto) {
        return "/todo/main";
    }

    @GetMapping("/todo/add")
    public String addForm(@ModelAttribute("toDoDto") ToDoDto toDoDto) {
        return "/todo/add";
    }
    
    @GetMapping("/todo/update/{id}")
    public String editForm(@PathVariable Long id,  Model model) {
        if (id != null) {
            Optional<ToDo> todo = toDoService.findById(id);
            todo.ifPresent(toDoDto -> model.addAttribute("toDoDto", toDoDto));
        } else {
            return "redirect:/todo";
        }

        return "/todo/edit";
    }
    ..
}

수정 시 Id를 파라미터 값으로 받아 ToDo를 조회 후 Model에 담아서 view로 전달.

class ToDoController {..

    @PostMapping("/todo/add")
    public String addToDo(@ModelAttribute("toDoDto") ToDoDto toDoDto, HttpServletRequest request) {

        Optional<Member> findMember = memberService.findById(getSessionMember(request).getId());
        Optional<ToDo> createToDo = findMember.map(member -> ToDo.createToDo(
                toDoDto.getTitle(), toDoDto.getDescription(), toDoDto.getDueDate(),
                member));
        createToDo.ifPresent(toDo -> toDoService.save(toDo));

        return "redirect:/todo";
    }
    
    @PostMapping("/todo/update/{id}")
    public String update(@PathVariable Long id, @ModelAttribute("toDoDto") ToDoDto toDoDto) {
        if (id != null)
            toDoService.update(id, toDoDto.getTitle(), toDoDto.getDescription(), toDoDto.getDueDate());
        return "redirect:/todo";
    }

    @PostMapping("/todo/change/{id}")
    public String change(@PathVariable Long id) {
        if (id != null) toDoService.changeStatus(id);
        return "redirect:/todo";
    }

    @PostMapping("/todo/delete/{id}")
    public String delete(@PathVariable Long id) {
        if (id != null) toDoService.findById(id).ifPresent(toDo -> toDoService.delete(toDo));
        return "redirect:/todo";
    }
}

main.html

<body>
<div class="container position-absolute top-50 start-50 translate-middle">
    <h1 class="text-center" th:text="|${session.loginMember.name}의 ToDo List|"></h1>
    <br>

    <div class="d-grid gap-2 mx-auto">
        <button type="button" class="btn btn-primary btn-lg"
                th:onclick="|location.href='@{/todo/add}'|">추가
        </button>
        <div class="accordion" id="accordionPanelsStayOpenExample">
            <div class="accordion-item">
                <h2 class="accordion-header" id="panelsStayOpen-headingOne">
                    <button class="accordion-button" type="button" data-bs-toggle="collapse"
                            data-bs-target="#panelsStayOpen-collapseOne" aria-expanded="true"
                            aria-controls="panelsStayOpen-collapseOne">
                        할 일
                    </button>
                </h2>
                <div id="panelsStayOpen-collapseOne" class="accordion-collapse collapse show"
                     aria-labelledby="panelsStayOpen-headingOne">
                    <div class="accordion-body">
                        <table class="table">
                            <thead>
                            <tr>
                                <th>#</th>
                                <th>제목</th>
                                <th>설명</th>
                                <th>생성일</th>
                                <th>마감일</th>
                                <th></th>
                                <th></th>
                                <th></th>
                            </tr>
                            </thead>

                            <tbody>
                            <tr th:each="todo : ${toDoDtos}">
                                <form method="post" th:object="${toDoDto}">
                                    <td th:text="${todoStat.count}"></td>
                                    <td>
                                        <input type="text" readonly name="title"
                                               th:value="${todo.title}">
                                    </td>
                                    <td>
                                        <input type="text" readonly name="description"
                                               th:value="${todo.description}">
                                    </td>
                                    <td>
                                        <input type="date" readonly name="createdDate"
                                               th:value="${todo.createdDate}">
                                    <td>
                                        <input type="date" readonly name="dueDate"
                                               th:value="${todo.dueDate}">
                                    </td>
                                    <td>
                                        <button type="button" class="btn btn-light"
                                                th:onclick="|location.href='@{/todo/update/{id}(id=${todo.id})}'|">수정
                                        </button>
                                    </td>
                                    <td>
                                        <button type="submit" class="btn btn-light"
                                                th:formaction="@{/todo/change/{id} (id=${todo.id})}">완료
                                        </button>
                                    </td>
                                    <td>
                                        <button type="submit" class="btn btn-light"
                                                th:formaction="@{/main/delete/{id} (id=${todo.id})}">삭제
                                        </button>
                                    </td>
                                </form>
                            </tr>
                            </tbody>
                        </table>
                    </div>
                </div>
            </div>

            <div class="accordion-item">
                <h2 class="accordion-header" id="panelsStayOpen-headingTwo">
                    <button class="accordion-button" type="button" data-bs-toggle="collapse"
                            data-bs-target="#panelsStayOpen-collapseTwo" aria-expanded="true"
                            aria-controls="panelsStayOpen-collapseTwo">
                        완료됨
                    </button>
                </h2>
                <div id="panelsStayOpen-collapseTwo" class="accordion-collapse collapse show"
                     aria-labelledby="panelsStayOpen-headingTwo">
                    <div class="accordion-body">
                        <table class="table">
                            <thead>
                            <tr>
                                <th>#</th>
                                <th>제목</th>
                                <th>설명</th>
                                <th>생성일</th>
                                <th>마감일</th>
                                <th></th>
                                <th></th>
                                <th></th>
                            </tr>
                            </thead>

                            <tbody>
                            <tr th:each="todo : ${completedDtos}">
                                <form method="post" th:object="${toDoDto}">
                                    <td th:text="${todoStat.count}"></td>
                                    <td>
                                        <input type="text" readonly name="title"
                                               th:value="${todo.title}">
                                    </td>
                                    <td>
                                        <input type="text" readonly name="description"
                                               th:value="${todo.description}">
                                    </td>
                                    <td>
                                        <input type="date" readonly name="createdDate"
                                               th:value="${todo.createdDate}">
                                    <td>
                                        <input type="date" readonly name="dueDate"
                                               th:value="${todo.dueDate}">
                                    </td>
                                    <td>
                                    </td>
                                    <td>
                                        <button type="submit" class="btn btn-light"
                                                th:formaction="@{/todo/change/{id} (id=${todo.id})}">취소
                                        </button>
                                    </td>
                                    <td>
                                        <button type="submit" class="btn btn-light"
                                                th:formaction="@{/main/delete/{id} (id=${todo.id})}">삭제
                                        </button>
                                    </td>
                                </form>
                            </tr>
                            </tbody>
                        </table>
                    </div>
                </div>
            </div>
        </div>

    </div>
</div>
<script type="text/javascript" src="js/bootstrap.js"></script>
</body>

세션 쿠키에 등록된 회원의 ToDo를 보여줌.
<추가> 버튼을 통해 /todo/add로 이동.
등록된 ToDo는 수정, 완료(취소), 삭제 등을 할 수 있음.

add.html

<body>
<div class="container position-absolute top-50 start-50 translate-middle">
    <div class="d-grid gap-2 col-6 mx-auto">
        <form method="post" th:action="@{/todo/add}" th:object="${toDoDto}">
            <fieldset>
                <legend class="text-center">일정 추가</legend>
                <div class="row">
                    <div class="col">
                        <label class="form-label" th:for="*{title}">제목</label>
                        <input type="text" class="w-100 form-control" th:field="*{title}"><br>
                    </div>
                    <div class="col">
                        <label class="form-label" th:for="*{dueDate}">마감일</label>
                        <input type="date" class="w-100 form-control" th:field="*{dueDate}">
                    </div>
                </div>
                <label class="form-label" th:for="*{description}">설명</label>
                <input type="text" class="w-100 form-control" th:field="*{description}"><br>

                <div class="row">
                    <div class="col-auto me-auto"></div>
                    <div class="col-auto">
                        <button type="submit" class="btn btn-primary">추가</button>
                    </div>
                    <div class="col-auto">
                        <button type="button" class="btn btn-primary" th:onclick="|location.href='@{/todo}'|">취소</button>
                    </div>
                </div>
            </fieldset>
        </form>
    </div>
</div>
</body>

title, description, duedate를 입력받아 컨트롤러로 Post 요청

edit.html

<body>
<div class="container position-absolute top-50 start-50 translate-middle">
    <div class="d-grid gap-2 col-6 mx-auto">
        <form method="post" th:object="${toDoDto}">
            <fieldset>
                <legend class="text-center">수정</legend>
                <div class="row">
                    <div class="col">
                        <label class="form-label" th:for="*{title}">제목</label>
                        <input type="text" class="w-100 form-control" th:field="*{title}"><br>
                    </div>
                    <div class="col">
                        <label class="form-label" th:for="*{dueDate}">마감일</label>
                        <input type="date" class="w-100 form-control" th:field="*{dueDate}">
                    </div>
                </div>
                <label class="form-label" th:for="*{description}">설명</label>
                <input type="text" class="w-100 form-control" th:field="*{description}"><br>

                <div class="row">
                    <div class="col-auto me-auto"></div>
                    <div class="col-auto">
                        <button type="submit" class="btn btn-primary" th:formaction="@{/todo/update/{id} (id=${toDoDto.id})}">수정</button>
                    </div>
                    <div class="col-auto">
                        <button type="button" class="btn btn-primary" th:onclick="|location.href='@{/todo}'|">취소</button>
                    </div>
                </div>
            </fieldset>
        </form>
    </div>
</div>
</body>

다음으로

대략적인 컨트롤러 개발이 끝났습니다. 다음 시간에 회원가입, 로그인, ToDo 생성, 수정 등의 값 검증 로직을 개발하겠습니다.


github , 배포 URL (첫 접속 시 로딩이 걸릴 수 있습니다.)

profile
평범한 대학생의 공부 일기?

0개의 댓글