Spring JPA 를 사용하여 메모장을 만들어보았다. 프로젝트 구조는 다음과 같다.
1. Repo(Domain, Repository)
- Repo 는 가장 안쪽 부분을 의미하고, DB와 맞닿아 있다.
먼저 Memo.java 파일을 생성한다. 이 클래스는 DB의 테이블 역할을 하는 Domain 클래스이다.
@NoArgsConstructor // 기본생성자 대신 생성
@Getter // Lombok이 getter 자동 생성
@Entity // 테이블과 연계되는 클래스임을 스프링에게 알려줌
public class Memo extends Timestamped { // 생성,수정 시간을 자동으로 생성하도록 상속받음
@GeneratedValue(strategy = GenerationType.AUTO) // ID 생성시 자동으로 증가
@Id // ID 값, PK로 사용하겠다는 의미
private Long id;
@Column(nullable = false) // 컬럼 값이 null이 아니고 반드시 값이 존재해야 함을 나타냄
private String username;
@Column(nullable = false)
private String contents;
// 생성자
public Memo(String username, String contents) {
this.username = username;
this.contents = contents;
}
public Memo(MemoRequestDto requestDto) {
this.username = requestDto.getUsername();
this.contents = requestDto.getContents();
}
public void update(MemoRequestDto requestDto) {
this.username = requestDto.getUsername();
this.contents = requestDto.getContents();
}
}
TimeStamped.java 파일을 생성하여 생성/수정 시간을 자동으로 업데이트 되도록 한다.
@Getter
@MappedSuperclass // 상속했을 때, 컬럼으로 인식하게 함
@EntityListeners(AuditingEntityListener.class) // 생성/수정 시간을 자동으로 업데이트 함
public abstract class Timestamped { // abstract - 직접 생성은 불가하고 상속만 가능함
@CreatedDate
private LocalDateTime createdAt; // 생성 시간
@LastModifiedDate
private LocalDateTime modifiedAt; // 수정 시간
}
생성/수정 시간을 JPA 가 자동으로 업데이트 할 수 있도록 Application 에 어노테이션을 추가해준다.
@EnableJpaAuditing // 데이터 변동이 있으면 JPA가 반영해줌
@SpringBootApplication
public class Week03Application {
public static void main(String[] args) {
SpringApplication.run(Week03Application.class, args);
}
}
MemoRepository.java 파일을 생성한다. 이 파일은 DB의 SQL 역할을 하는 인터페이스이다. JPA를 사용하기 위해 JpaRepository<domain 클래스, id의 type> 을 상속받는다. JPA 가 만들어주는 것 이외의 SQL 문을 사용하고 싶다면 여기에 새로 정의하면 된다.
public interface MemoRepository extends JpaRepository<Memo, Long> {
List<Memo> findAllByOrderByModifiedAtDesc(); // 수정시간 기준으로 내림차순 정렬하여 모든 데이터 조회
}
2. DTO(Data Transfer Object)
- 테이블에 값을 넣을 때 Memo 클래스를 직접 사용하는 것은 좋지 않다. 그래서 완충재로 활용하는 것이 DTO 이다.
MemoRequestDto.java 파일을 생성한다.
@Getter
public class MemoRequestDto {
private String username;
private String contents;
}
3. Service
- Service 는 중간 부분이고, 실제 중요한 동작이 많이 일어나는 부분이다.
MemoService.java 파일을 생성하고 추가적으로 필요한 동작을 정의한다.
@RequiredArgsConstructor // 꼭 필요한 요소(final) 자동 생성
@Service // 이 클래스가 서비스임을 알려줌
public class MemoService {
// final 은 꼭 필요한 요소임을 명시하는 것, 값이 변경 될 수 없음
private final MemoRepository memoRepository;
@Transactional // SQL 쿼리가 일어나야 함을 스프링에게 알려줌, 자동으로 DB에 업데이트 됨
public Long update(Long id, MemoRequestDto requestDto) {
// id로 Memo 객체를 찾음. id에 해당하는 값이 없다면 메시지로 알려줌
Memo memo = memoRepository.findById(id).orElseThrow(
() -> new IllegalArgumentException("아이디가 존재하지 않습니다.")
);
memo.update(requestDto); // 값 업데이트
return memo.getId();
}
}
4. Controller
- Controller 는 가장 바깥 부분이고, 요청/응답을 처리한다.
MemoController.java 파일을 생성한다. Controller는 url로 들어온 요청과 응답을 처리한다.
@RequiredArgsConstructor // 꼭 필요한 요소(final) 자동 생성
@RestController // JSON 으로 응답하기 위한 RestController 라는 의미
public class MemoController {
private final MemoRepository memoRepository;
private final MemoService memoService;
// 신규 메모 생성
@PostMapping("/api/memos")
public Memo createMemo(@RequestBody MemoRequestDto requestDto) {
Memo memo = new Memo(requestDto);
return memoRepository.save(memo); // 값 저장
}
// 등록된 전체 메모 목록 조회
@GetMapping("/api/memos")
public List<Memo> getMemos() {
return memoRepository.findAllByOrderByModifiedAtDesc(); // 수정날짜 기준 최신순으로 모든 데이터 조회
}
// 메모 내용 변경
@PutMapping("/api/memos/{id}") // {id}는 아이디 값이 오는데 유동적인 값이라는 의미
public Long updateMemo(@PathVariable Long id, @RequestBody MemoRequestDto requestDto) { // @PathVariable - {}로 감싸준 값을 가리킴
memoService.update(id, requestDto); // 값 변경
return id;
}
// 메모 삭제
@DeleteMapping("/api/memos/{id}")
public Long deleteMemo(@PathVariable Long id) {
memoRepository.deleteById(id); // 값 삭제
return id;
}
}
5. Front
웹페이지에서 메모를 생성, 조회, 수정, 삭제할 수 있도록 프론트 부분을 만들어준다. index.html 파일을 생성한다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timeline Service</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@500&display=swap" rel="stylesheet">
<style>
@import url(//spoqa.github.io/spoqa-han-sans/css/SpoqaHanSans-kr.css);
body {
margin: 0px;
}
.area-edit {
display: none;
}
.wrap {
width: 538px;
margin: 10px auto;
}
#contents {
width: 538px;
}
.area-write {
position: relative;
width: 538px;
}
.area-write img {
cursor: pointer;
position: absolute;
width: 22.2px;
height: 18.7px;
bottom: 15px;
right: 17px;
}
.background-header {
position: fixed;
z-index: -1;
top: 0px;
width: 100%;
height: 428px;
background-color: #339af0;
}
.background-body {
position: fixed;
z-index: -1;
top: 428px;
height: 100%;
width: 100%;
background-color: #dee2e6;
}
.header {
margin-top: 50px;
}
.header h2 {
/*font-family: 'Noto Sans KR', sans-serif;*/
height: 33px;
font-size: 42px;
font-weight: 500;
font-stretch: normal;
font-style: normal;
line-height: 0.79;
letter-spacing: -0.5px;
text-align: center;
color: #ffffff;
}
.header p {
margin: 40px auto;
width: 217px;
height: 48px;
font-family: 'Noto Sans KR', sans-serif;
font-size: 16px;
font-weight: 500;
font-stretch: normal;
font-style: normal;
line-height: 1.5;
letter-spacing: -1.12px;
text-align: center;
color: #ffffff;
}
textarea.field {
width: 502px !important;
height: 146px;
border-radius: 5px;
background-color: #ffffff;
border: none;
padding: 18px;
resize: none;
}
textarea.field::placeholder {
width: 216px;
height: 16px;
font-family: 'Noto Sans KR', sans-serif;
font-size: 16px;
font-weight: normal;
font-stretch: normal;
font-style: normal;
line-height: 1;
letter-spacing: -0.96px;
text-align: left;
color: #868e96;
}
.card {
width: 538px;
border-radius: 5px;
background-color: #ffffff;
margin-bottom: 12px;
}
.card .metadata {
position: relative;
display: flex;
font-family: 'Spoqa Han Sans';
font-size: 11px;
font-weight: normal;
font-stretch: normal;
font-style: normal;
line-height: 1;
letter-spacing: -0.77px;
text-align: left;
color: #adb5bd;
height: 14px;
padding: 10px 23px;
}
.card .metadata .date {
}
.card .metadata .username {
margin-left: 20px;
}
.contents {
padding: 0px 23px;
word-wrap: break-word;
word-break: break-all;
}
.contents div.edit {
display: none;
}
.contents textarea.te-edit {
border-right: none;
border-top: none;
border-left: none;
resize: none;
border-bottom: 1px solid #212529;
width: 100%;
font-family: 'Spoqa Han Sans';
}
.footer {
position: relative;
height: 40px;
}
.footer img.icon-start-edit {
cursor: pointer;
position: absolute;
bottom: 14px;
right: 55px;
width: 18px;
height: 18px;
}
.footer img.icon-end-edit {
cursor: pointer;
position: absolute;
display: none;
bottom: 14px;
right: 55px;
width: 20px;
height: 15px;
}
.footer img.icon-delete {
cursor: pointer;
position: absolute;
bottom: 12px;
right: 19px;
width: 14px;
height: 18px;
}
#cards-box {
margin-top: 12px;
}
</style>
<script>
// 사용자가 내용을 올바르게 입력하였는지 확인하는 메서드
function isValidContents(contents) {
if (contents == '') {
alert('내용을 입력해주세요');
return false;
}
if (contents.trim().length > 140) {
alert('공백 포함 140자 이하로 입력해주세요');
return false;
}
return true;
}
// 익명의 username을 만드는 메서드
function genRandomName(length) {
let result = '';
let characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let charactersLength = characters.length;
for (let i = 0; i < length; i++) {
let number = Math.random() * charactersLength;
let index = Math.floor(number);
result += characters.charAt(index);
}
return result;
}
// 수정버튼을 눌렀을 때 수행되는 메서드
function editPost(id) {
showEdits(id);
let contents = $(`#${id}-contents`).text().trim();
$(`#${id}-textarea`).val(contents); // 수정 버튼을 눌렀을 때, 기존 작성 내용을 textarea 에 전달한다
}
// 숨길 버튼을 숨기고, 나타낼 버튼을 나타내는 메서드
function showEdits(id) {
$(`#${id}-editarea`).show();
$(`#${id}-submit`).show();
$(`#${id}-delete`).show();
$(`#${id}-contents`).hide();
$(`#${id}-edit`).hide();
}
$(document).ready(function () {
// HTML 문서를 로드할 때마다 실행한다
getMessages();
})
// 모든 메모를 조회하는 메서드
function getMessages() {
// 1. 기존 메모 목록을 지운다
$('#cards-box').empty();
// 2. 메모 목록을 불러와서 HTML로 붙인다
$.ajax({
type: 'GET',
url: '/api/memos',
success: function (response) {
for (let i = 0; i < response.length; i++) {
let message = response[i];
let id = message['id'];
let username = message['username'];
let contents = message['contents'];
let modifiedAt = message['modifiedAt'];
addHTML(id, username, contents, modifiedAt);
}
}
})
}
// 메모 하나를 HTML로 만들어서 body 태그 내 원하는 곳에 붙이는 메서드
function addHTML(id, username, contents, modifiedAt) {
// 1. HTML 태그를 만든다
let tempHtml = `<div class="card">
<!-- date/username 영역 -->
<div class="metadata">
<div class="date">
${modifiedAt}
</div>
<div id="${id}-username" class="username">
${username}
</div>
</div>
<!-- contents 조회/수정 영역-->
<div class="contents">
<div id="${id}-contents" class="text">
${contents}
</div>
<div id="${id}-editarea" class="edit">
<textarea id="${id}-textarea" class="te-edit" name="" id="" cols="30" rows="5"></textarea>
</div>
</div>
<!-- 버튼 영역-->
<div class="footer">
<img id="${id}-edit" class="icon-start-edit" src="images/edit.png" alt="" onclick="editPost('${id}')">
<img id="${id}-delete" class="icon-delete" src="images/delete.png" alt="" onclick="deleteOne('${id}')">
<img id="${id}-submit" class="icon-end-edit" src="images/done.png" alt="" onclick="submitEdit('${id}')">
</div>
</div>`;
// 2. #cards-box 에 HTML을 붙인다
$('#cards-box').append(tempHtml);
}
// 메모를 생성하는 메서드
function writePost() {
// 1. 작성한 메모를 불러온다
let contents = $('#contents').val();
// 2. 작성한 메모가 올바른지 isValidContents 함수를 통해 확인한다
if (isValidContents(contents) == false) {
return;
}
// 3. genRandomName 함수를 통해 익명의 username을 만든다
let username = genRandomName(10);
// 4. 전달할 data JSON으로 만든다
let data = {'username': username, 'contents': contents};
// 5. POST /api/memos 에 data를 전달합니다.
$.ajax({
type: "POST",
url: "/api/memos",
contentType: "application/json",
data: JSON.stringify(data),
success: function (response) {
alert('메시지가 성공적으로 작성되었습니다.');
window.location.reload();
}
});
}
// 메모를 수정하는 메서드
function submitEdit(id) {
// 1. 작성 대상 메모의 username과 contents 를 확인한다
let username = $(`#${id}-username`).text().trim();
let contents = $(`#${id}-textarea`).val().trim();
// 2. 작성한 메모가 올바른지 isValidContents 함수를 통해 확인한다
if (isValidContents(contents) == false) {
return;
}
// 3. 전달할 data JSON으로 만든다
let data = {'username': username, 'contents': contents};
// 4. PUT /api/memos/{id} 에 data를 전달한다
$.ajax({
type: "PUT",
url: `/api/memos/${id}`,
contentType: "application/json",
data: JSON.stringify(data),
success: function (response) {
alert('메시지 변경에 성공하였습니다.');
window.location.reload();
}
});
}
// 메모를 삭제하는 메서드
function deleteOne(id) {
// 1. DELETE /api/memos/{id} 에 요청해서 메모를 삭제한다
$.ajax({
type: "DELETE",
url: `/api/memos/${id}`,
success: function (response) {
alert('메시지 삭제에 성공하였습니다.');
window.location.reload();
}
})
}
</script>
</head>
<body>
<div class="background-header">
</div>
<div class="background-body">
</div>
<div class="wrap">
<div class="header">
<h2>Timeline Service</h2>
<p>
공유하고 싶은 소식을 입력해주세요.
24시간이 지난 뒤에는 사라집니다.
</p>
</div>
<div class="area-write">
<textarea class="field" placeholder="공유하고 싶은 소식을 입력해주세요" name="contents" id="contents" cols="30" rows="10"></textarea>
<img src="images/send.png" alt="" onclick="writePost()">
</div>
<div id="cards-box" class="area-read">
</div>
</div>
</div>
</body>
</html>
```
여기까지 하면 메모장 만들기 프로젝트가 완성된다. 서버를 실행하여 웹페이지에 접속하면 다음과 같은 화면을 볼 수 있고, 메모를 생성/조회/수정/삭제 할 수 있다.
'Back-end > Spring' 카테고리의 다른 글
[SpringBoot] 4주차 스터디 ② (나만의 셀렉샵 만들기, 네이버 쇼핑 API) (0) | 2021.09.09 |
---|---|
[SpringBoot] 4주차 스터디 ① (나만의 셀렉샵 만들기, 네이버 쇼핑 API) (0) | 2021.09.09 |
[SpringBoot] 2주차 스터디 (JPA, domain, repository, DTO, service, controller) (1) | 2021.08.28 |
[SpringBoot] 1주차 스터디 (RestController) (0) | 2021.08.19 |
[SpringBoot] Spring Data JPA로 게시판 만들기(5) - JPA Auditing으로 생성시간/수정시간 자동화 (0) | 2020.11.22 |