개발자입니다
[비트캠프] 86일차(18주차4일) - Spring Framework: myapp-64 중간(Client rendering), 프로젝트 절차(Actor 식별, Use-case 식별) 본문
[비트캠프] 86일차(18주차4일) - Spring Framework: myapp-64 중간(Client rendering), 프로젝트 절차(Actor 식별, Use-case 식별)
끈기JK 2023. 3. 9. 14:15
64. Front-end / Back-end
fetch(게시글 조회) 를 ① 호출 하면 AJAX 작업 을 ② 실행 하고 종료된다.
fetch(로그인 사용자 조회) 를 ③ 호출 하면 AJAX 작업 을 ④ 실행 하고 종료된다.
AJAX 작업 두 개가 비동기 실행한다. 순차적으로 실행하는 것이 아니라 서로 독립적으로 병행하여 실행한다.
fetch(게시글 조회) 를 ① 호출 하면 Promise 객체를 리턴한다. 여기서 .then(response) 로 결과를 통지한다. 여기서 Promise 객체를 리턴한다. 여기서 .then(result) 로 결과를 통지한다. 여기서 function checkowner(작성자 번호) { fetch(로그인 사용자 조회) 로 Promise 객체를 리턴한다. 결과를 .then(response) 에 통지한다. 여기서 Promise 객체를 리턴한다. 여기서 결과를 .then(result) 로 통지한다. }
문서화 참고 사이트 : https://cloud.kt.com/docs/open-api-guide/d/guide/how-to-use-openstack
① 예전
Client 가 주로 PC 이었기 대문에 hTML 을 만들어 주면 되었다.
PC 인 Client 가 ① 요청을 Server 로 하면 ② HTML 생성하고 ③ HTML 응답을 Client 로 한다.
② 현재
Client 가 다양한 유형이기 때문에 서버는 XML / JSON 형식으로 data 를 만들어 준다.
PC, Android, iOS Client 가 ① 요청을 Server 로 하면 ② JSON 생성해서 ③ 응답(JSON) 을 PC Android, iOS Client 로 한다. 각 Client 에서 ④ HTML 생성, Android UI 생성, iOS UI 생성 한다.
클라이언트가 자신에 맞게끔 UI 생성
### 64. Back-end 와 Front-end 분리하기: 클라이언트 렌더링 방식으로 전환
util 패키지 생성해서 자주 쓰는 문자열을 상수로 만든다.
static 중첩 클래스는 분류를 위해 사용한다.
클래스 명을 소문자로 사용하는 이유는 필드명처럼 사용하기 위함이다.
package bitcamp.util;
public class ErrorCode {
public static final class rest {
public static final String UNAUTHORIZED = "401";
public static final String NO_DATA = "501";
}
}
package bitcamp.util;
public class RestStatus {
public static final String SUCCESS = "success";
public static final String FAILURE = "failure";
}
결과를 Map 객체로 전달하지 않고 객체를 만들어 메서드 체이닝 기법으로 담아 전달한다.
package bitcamp.util;
public class RestResult {
String status;
String errorCode;
Object data;
public String getStatus() {
return status;
}
public RestResult setStatus(String status) {
this.status = status;
return this;
}
public String getErrorCode() {
return errorCode;
}
public RestResult setErrorCode(String errorCode) {
this.errorCode = errorCode;
return this;
}
public Object getData() {
return data;
}
public RestResult setData(Object data) {
this.data = data;
return this;
}
}
모든 request handler 가 @ResponseBody 를 붙이므로 그 대신 클래스에 @RestController 를 붙인다.
return 으로 RestResult 객체 생성해서 메서드 체이닝 기법으로 값 세팅한다.
Model 객체 사용하지 않으므로 파라미터에서 삭제한다.
"com.fasterxml.jackson.core:jackson-databind" 라이브러리 사용하여 Java 객체 → JSON 문자열 변환시, Content-Type 헤더 값은 "application/json" 으로 설정된다.
package bitcamp.myapp.controller;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpSession;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import bitcamp.myapp.service.BoardService;
import bitcamp.myapp.vo.Board;
import bitcamp.myapp.vo.BoardFile;
import bitcamp.myapp.vo.Member;
import bitcamp.util.ErrorCode;
import bitcamp.util.RestResult;
import bitcamp.util.RestStatus;
@RestController
@RequestMapping("/board")
public class BoardController {
Logger log = LogManager.getLogger(getClass());
{
log.trace("BoardController 생성됨!");
}
@Autowired private ServletContext servletContext;
@Autowired private BoardService boardService;
@PostMapping("insert")
public Object insert(
Board board,
List<MultipartFile> files,
HttpSession session) throws Exception{
Member loginUser = (Member) session.getAttribute("loginUser");
Member writer = new Member();
writer.setNo(loginUser.getNo());
board.setWriter(writer);
List<BoardFile> boardFiles = new ArrayList<>();
for (MultipartFile file : files) {
if (file.isEmpty()) {
continue;
}
String filename = UUID.randomUUID().toString();
file.transferTo(new File(servletContext.getRealPath("/board/upload/" + filename)));
BoardFile boardFile = new BoardFile();
boardFile.setOriginalFilename(file.getOriginalFilename());
boardFile.setFilepath(filename);
boardFile.setMimeType(file.getContentType());
boardFiles.add(boardFile);
}
board.setAttachedFiles(boardFiles);
boardService.add(board);
return new RestResult()
.setStatus(RestStatus.SUCCESS);
}
@GetMapping("list")
public Object list(String keyword) {
log.debug("BoardController.list() 호출됨!");
// MappingJackson2HttpMessageConverter 가 jackson 라이브러리를 이용해
// 자바 객체를 JSON 문자열로 변환하여 클라이언트로 보낸다.
// 이 컨버터를 사용하면 굳이 UTF-8 변환을 설정할 필요가 없다.
// 즉 produces = "application/json;charset=UTF-8" 를 설정하지 않아도 된다.
return new RestResult()
.setStatus(RestStatus.SUCCESS)
.setData(boardService.list(keyword));
}
@GetMapping("view")
public Object view(int no) {
Board board = boardService.get(no);
if (board != null) {
return new RestResult()
.setStatus(RestStatus.SUCCESS)
.setData(board);
} else {
return new RestResult()
.setStatus(RestStatus.FAILURE)
.setErrorCode(ErrorCode.rest.NO_DATA);
}
}
@PostMapping("update")
public Object update(
Board board,
List<MultipartFile> files,
HttpSession session) throws Exception {
Member loginUser = (Member) session.getAttribute("loginUser");
Board old = boardService.get(board.getNo());
if (old.getWriter().getNo() != loginUser.getNo()) {
return new RestResult()
.setStatus(RestStatus.FAILURE)
.setErrorCode(ErrorCode.rest.UNAUTHORIZED)
.setData("권한이 없습니다.");
}
List<BoardFile> boardFiles = new ArrayList<>();
for (MultipartFile file : files) {
if (file.isEmpty()) {
continue;
}
String filename = UUID.randomUUID().toString();
file.transferTo(new File(servletContext.getRealPath("/board/upload/" + filename)));
BoardFile boardFile = new BoardFile();
boardFile.setOriginalFilename(file.getOriginalFilename());
boardFile.setFilepath(filename);
boardFile.setMimeType(file.getContentType());
boardFile.setBoardNo(board.getNo());
boardFiles.add(boardFile);
}
board.setAttachedFiles(boardFiles);
boardService.update(board);
return new RestResult()
.setStatus(RestStatus.SUCCESS);
}
@PostMapping("delete")
public Object delete(int no, HttpSession session) {
Member loginUser = (Member) session.getAttribute("loginUser");
Board old = boardService.get(no);
if (old.getWriter().getNo() != loginUser.getNo()) {
return new RestResult()
.setStatus(RestStatus.FAILURE)
.setErrorCode(ErrorCode.rest.UNAUTHORIZED)
.setData("권한이 없습니다.");
}
boardService.delete(no);
return new RestResult()
.setStatus(RestStatus.SUCCESS);
}
@PostMapping("filedelete")
public Object filedelete(int boardNo, int fileNo, HttpSession session) {
Member loginUser = (Member) session.getAttribute("loginUser");
Board old = boardService.get(boardNo);
if (old.getWriter().getNo() != loginUser.getNo()) {
return new RestResult()
.setStatus(RestStatus.FAILURE)
.setErrorCode(ErrorCode.rest.UNAUTHORIZED)
.setData("권한이 없습니다.");
} else {
boardService.deleteFile(fileNo);
return new RestResult()
.setStatus(RestStatus.SUCCESS);
}
}
}
package bitcamp.myapp.controller;
import javax.servlet.http.HttpSession;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import bitcamp.myapp.service.StudentService;
import bitcamp.myapp.service.TeacherService;
import bitcamp.myapp.vo.Member;
import bitcamp.util.RestResult;
import bitcamp.util.RestStatus;
@RestController
@RequestMapping("/auth")
public class AuthController {
Logger log = LogManager.getLogger(getClass());
{
log.trace("AuthController 생성됨!");
}
@Autowired private StudentService studentService;
@Autowired private TeacherService teacherService;
@PostMapping("login")
public Object login(
String usertype,
String email,
String password,
HttpSession session) {
Member member = null;
switch (usertype) {
case "student":
member = studentService.get(email, password);
break;
case "teacher":
member = teacherService.get(email, password);
break;
}
if (member != null) {
session.setAttribute("loginUser", member);
return new RestResult()
.setStatus(RestStatus.SUCCESS);
} else {
return new RestResult()
.setStatus(RestStatus.FAILURE);
}
}
@GetMapping("logout")
public Object logout(HttpSession session) {
session.invalidate();
return new RestResult()
.setStatus(RestStatus.SUCCESS);
}
@RequestMapping("user")
public Object user(HttpSession session) {
Member loginUser = (Member) session.getAttribute("loginUser");
if (loginUser != null) {
return new RestResult()
.setStatus(RestStatus.SUCCESS)
.setData(loginUser);
} else {
return new RestResult()
.setStatus(RestStatus.FAILURE);
}
}
}
변경, 삭제 버튼은 display: none 으로 안보이게 되어있다가 작성자면 보이게 한다.
버튼은 submit 으로 처리하지 말고 onclick 으로 처리한다.
return false 로 기본 동작인 페이지 이동을 막는다.
<!-- view.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset='UTF-8'>
<title>비트캠프 - NCP 1기</title>
<style>
.guest {
display: none;
}
</style>
</head>
<body>
<h1>게시판</h1>
<div>
<form id='board-form' action='update' method='post' enctype="multipart/form-data">
<table border='1'>
<tr>
<th>번호</th>
<td><input type='text' name='no' readonly></td>
</tr>
<tr>
<th>제목</th>
<td><input type='text' name='title'></td>
</tr>
<tr>
<th>내용</th>
<td><textarea name='content' rows='10' cols='60'></textarea></td>
</tr>
<tr>
<th>작성자</th>
<td><span id="f-writer-name"></span></td>
</tr>
<tr>
<th>등록일</th>
<td><span id="f-created-date"></span></td>
</tr>
<tr>
<th>조회수</th>
<td><span id="f-view-count"></span></td>
</tr>
<tr>
<th>첨부파일</th>
<td>
<input type="file" name='files' multiple>
<ul id="f-files"></ul>
</td>
</tr>
</table>
<div>
<button id='btn-list' type='button'>목록</button>
<button id="btn-update" type="button" class="guest">변경</button>
<button id='btn-delete' type='button' class="guest">삭제</button>
</div>
</form>
</div>
<script>
// http://localhost:8080/web/board/view.html?no=100
const values = location.href.split('?');
if (values.length == 1) {
alert("URL이 옳지 않습니다.");
location.href = "list.html";
}
// no=100
const param = values[1].split("=")
if (param.length == 1 || param[0] != 'no') {
alert("URL이 옳지 않습니다.");
location.href = "list.html";
}
let no = parseInt(param[1]);
if (isNaN(no)) {
alert("URL이 옳지 않습니다.");
location.href = "list.html";
}
fetch("../app/board/view?no=" + no)
.then(response => {
return response.json();
})
.then(result => {
if (result.status == 'failure') {
alert('게시글을 조회할 수 없습니다.');
location.href = "list.html";
return;
}
let board = result.data;
//console.log(board);
document.querySelector("input[name='no']").value = board.no;
document.querySelector("input[name='title']").value = board.title;
document.querySelector("textarea[name='content']").value = board.content;
document.querySelector("#f-writer-name").innerHTML = board.writer.name;
document.querySelector("#f-created-date").innerHTML = board.createdDate;
document.querySelector("#f-view-count").innerHTML = board.viewCount;
let ul = "";
board.attachedFiles.forEach(file => {
console.log(file);
if (file.no == 0) return;
let html = `
<li id="li-${file.no}">
<a href="../download/boardfile?fileNo=${file.no}">${file.originalFilename}</a>
[<a href="#" onclick="deleteFile(${board.no}, ${file.no}); return false;">삭제</a>]
</li>`;
ul += html;
});
document.querySelector("#f-files").innerHTML = ul;
checkOwner(board.writer.no);
});
function checkOwner(writerNo) {
fetch("../app/auth/user")
.then(response => {
return response.json();
})
.then(result => {
console.log(result);
if (result.status == 'success') {
if (result.data.no == writerNo) {
document.querySelector('#btn-update').classList.remove('guest');
document.querySelector('#btn-delete').classList.remove('guest');
}
}
})
.catch(exception => {
alert("로그인 사용자 정보 조회 중 오류 발생!");
console.log(exception);
});
}
function deleteFile(boardNo, fileNo) {
const formData = new FormData();
formData.append("boardNo", boardNo);
formData.append("fileNo", fileNo);
fetch("../app/board/filedelete", {
method: "post",
body: formData
})
.then(response => {
return response.json();
})
.then(result => {
if (result.status == 'success') {
let li = document.querySelector('#li-' + fileNo);
document.querySelector("#f-files").removeChild(li);
} else {
alert('파일 삭제 실패!');
}
})
.catch(exception => {
alert('파일 삭제 중 오류 발생!');
console.log(exception);
});
}
document.querySelector('#btn-list').onclick = function() {
location.href = 'list.html';
}
document.querySelector('#btn-update').onclick = function() {
const form = document.querySelector('#board-form');
const formData = new FormData(form);
fetch("../app/board/update", {
method: "post",
body: formData
})
.then(response => {
return response.json();
})
.then(result => {
if (result.status == 'success') {
location.href = 'list.html';
} else {
alert('변경 실패!');
}
})
.catch(exception => {
alert('변경 중 오류 발생!');
console.log(exception);
});
}
document.querySelector('#btn-delete').onclick = function() {
const formData = new FormData();
formData.append("no", document.querySelector('input[name="no"]').value);
fetch("../app/board/delete", {
method: "post",
body: formData
})
.then(response => {
return response.json();
})
.then(result => {
if (result.status == 'success') {
location.href = 'list.html';
} else {
alert('삭제 실패!');
}
})
.catch(exception => {
alert('삭제 중 오류 발생!');
console.log(exception);
});
}
</script>
</body>
</html>
프로젝트 절차
Actor 식별
시스템을 사용하는 사람, 프로세스, 시스템
Primary Actor 는 시스템을 이용한다.
시스템이 이용하는 것은 Secondary Actor 이다.
은행시스템의 Actor 는 다음과 같다.
사람 : 은행원, 고객
프로세스 : 타이머
시스템 : ATM
은행시스템이 이용하는 Secondary Actor(시스템이 이용하는 외부 시스템)은 다음과 같다.
신용평가시스템, 신원조회시스템
Actor 식별 - 상속 관계
비회원, 회원이 있다.
회원을 상속하는 판매자, 관리자가 있다.
상위 Actor 는 하위 Actor 들의 공통 역할 수행
하위 Actor 는 상위 Actor 의 모든 역할을 상속 받는다.
Use-case 식별
Actor 가 시스템을 이용하여 달성하고자 하는 업무 목표(사용 예 → 사용 사례)
은행원 : 통장개설 하기, 입금 하기, 출금 하기 등 동사구 형태로 이름을 작성
Use-case 시나리오 (시스템을 사용하는 시나리오)
액터 ↔ 시스템
① 출금 계좌번호를 입력한다.
② 출금 계좌의 정보를 출력한다.
③ 출금액을 입력한다.
④ 출금액이 잔액보다 작거나 같으면 잔액에서 출금액을 뺀다. 뺀 결과를 출력한다.
wiki 'use case' 그림
Use-case 식별 - include / extend 관계
회원가입 하다가 전화번호인증으로 갔다가 다시 돌아와서 계속 진행한다.
회원가입하기 ----------> 전화번호 인증 은 《include》 필수 이다.
암호변경 ---------> 전화번호 인증 은 《include》 필수 이다.
전화번호 인증은 여러 Use-case 가 공유하는 기능(시나리오)이다.
회원가입하기를 진행한다.
선택사항으로 우편번호 찾기(extend point, 확장점)를 하면 주소검색 하기로 갔다가 돌아온다.
주소 검색하기 ------------> 회원가입하기(Base Use-case) 는 《extend》 선택 이다.
파일 첨부 ----------> 게시글 등록하기(Base Use-case) 는 《extend》 선택 이다.
모든 관계를 다 표현할 필요가 없다. 중요하고 반드시 명시해야만 알 수 있는 관계를 표현하라!
Use-case 식별 Guide (지침)
① 한 액터가 한 번에 한 순간에 실행하는 업무
→ 상품 구매를 고객이 한다. 주문, 결제를 별도로 할 수 있으므로 나누어서 처리한다.
배송사가 배송을 한다.
전체를 상품 구매로 묶는다.
② 시작과 끝이 명확하여 셀 수 있는 업무
→ 게시글 관리를 관리자가 한다. 게시글 등록, 조회, 변경, 삭제를 몇 건 진행했는지 셀 수 있다.
③ (개발할)시스템이 할 수 있는 업무
→ 팩스 전송 우리가 개발할 시스템이 수행하는 것이 아니다.
⇒ 지침서에 따라서 Use-case 를 식별하면 2주~4주 안에 개발할 수 있는 크기로 시스템 기능을 쪼갤 수 있다. 개발 관리가 용이한 단위로 시스템 기능을 정의한다.
Use-case 는 업무여야 한다. 비밀번호 확인, 로그인 등은 업무가 아니다.
게시글 목록 보기에서 게시글 상세 보기는 따로 빼서 목록 보기와 동일 선상에서 봐야 한다.
Use-case 는 동사구 형태를 띤다.
조언
*
과제
/