질문, 피드백 등 모든 댓글 환영합니다.
지금까지 개발한 핵심 로직을 기반으로 컨트롤러와 html 파일을 작성하겠습니다.
개발 순서는 HomeController -> LoginController -> ToDoController 이며 html은 각 컨트롤러를 개발하며 필요할 때 마다 생성해주겠습니다. 자세한 검증 로직은 전반적인 컨트롤러 개발 후에 진행합니다.
뷰 템플릿 엔진은 타임리프(Thymeleaf)를 사용하였고 기본적인 디자인을 위해 부트스트랩도 포함했습니다.
css 지식이 얕을 뿐더러 이 프로젝트에서 중요한 것이 아니기 때문에 이 블로그에서 설명하지 않겠습니다.
컨트롤러, DTO 개발에 앞서 DTO 패키지의 위치를 어떻게 설정할 지에 대한 고민이 있었습니다. 참고한 블로그 글을 토대로 Controller -> Service -> Repository 의 의존관계가 무너지지 않는 선에서 DTO의 사용이 자유로워도 된다고 이해했습니다. 이 프로젝트의 규모가 굉장히 작아 Controller 계층에서 DTO 관련 로직을 모두 처리해도 후에 유지보수가 어렵지 않겠다고 생각했습니다. 때문에 DTO의 패키지를 Controller 패키지 안에 두었고 Service에선 DTO를 사용하지 않도록 설계하였습니다.
@Controller : 스프링이 컨트롤러 계층으로 인식
@GetMapping : http get 메서드 요청을 처리
@PostMapping : http post 메서드 요청을 처리
@ModelAttribute : http 요청을 받아 각 인자를 객체로 변환, 추가로 이 어노테이션이 달린 필드를 모델에 담아 view로 전송
@PathVariable : Uri 파라미터로 넘어온 값을 매핑
++ Thymeleaf 사용을 위해선 <html xmlns:th="http://www.thymeleaf.org"
필요
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요청.
(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 요청.
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 생성, 수정 등의 값 검증 로직을 개발하겠습니다.