SpringBoot

JIHYUN·2023년 8월 20일
0

spring

목록 보기
6/6

resource

application.properties

# 앱이 실행되는 서버 포트 설정: 
server.port=8090

# DataSource 관련 설정:
spring.datasource.driver-class-name=oracle.jdbc.OracleDriver
spring.datasource.url=jdbc:oracle:thin:@localhost:1521:xe
spring.datasource.username=scott
spring.datasource.password=tiger

# Spring Data JPA(Java Persistence API) 관련 설정:
spring.jpa.database=oracle
# DDL(Data Definition Language: create table ...) 자동 실행을 사용하지 않음
spring.jpa.hibernate.ddl-auto=none
# Log4j 기능을 사용해서 JPA가 실행하는 SQL 문장을 로그로 출력
spring.jpa.show-sql=true
# 로그로 출력하는 SQL 문장을 보기 좋게 포맷팅.
spring.jpa.properties.hibernate.format_sql=true

# 로그 레벨 설정:
logging.level.org.hibernate.type.descriptor=trace

templates

layout > base-layout.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
    xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity6"
    xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta charset="UTF-8" />
<title>Spring Boot</title>
<link
    href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
    rel="stylesheet"
    integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM"
    crossorigin="anonymous">
</head>
<body class="container-fluid bg-warning bg-opacity-25" >
    <div>
        <header class="my-2 p-5 text-center">
            <h1 class="text-uppercase fw-bold font-monospace">Spring Boot App</h1>
        </header>

        <nav class="my-2">
            <ul class="nav nav-tab justify-content-center" >
                <li class="nav-item">
                    <a class="text-uppercase font-monospace nav-link link-dark active" aria-current="page" th:href="@{/}">home</a>
                </li>
                <li class="nav-item">
                    <a class="text-uppercase font-monospace nav-link link-dark" th:href="@{/post}">list</a>
                </li>
                <li class="nav-item">
                    <a class="text-uppercase font-monospace nav-link link-dark"  th:href="@{/post/create}">create</a>
                </li>
                
                <!-- 인증된 사용자이면(로그인 상태이면) -->
                <th:block sec:authorize="isAuthenticated()">
                    <li class="nav-item">
                        <a class="text-uppercase font-monospace nav-link link-dark" th:href="@{/logout}">
                        <!-- 로그인한 유저 이름 -->
                        [<span sec:authentication="name"></span>]
                        logout</a> 
                    </li>
                </th:block>
                <!-- 익명 사용자이면(로그아웃 상태이면) -->
                <th:block sec:authorize="isAnonymous()">
                     <li class="nav-item">
                        <a class="text-uppercase font-monospace nav-link link-dark" th:href="@{/member/signup}">sign-up</a> 
                    </li>
                    <li class="nav-item">
                        <a class="text-uppercase font-monospace nav-link link-dark" th:href="@{/login}">log-in</a> 
                    </li>
                </th:block>
            </ul>
        </nav>

        <!-- main 컨텐츠가 삽입될 위치 -->
        <th:block layout:fragment="main" style="margin-left: 100px; margin-right: 100px;"></th:block>

        <footer class="text-center font-monospace my-2 p-5">
            <small>rosybb13@gmail.com</small>
        </footer>
    </div>
    <script
        src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz"
        crossorigin="anonymous"></script>
    <!-- javascript 파일 추가할 위치 -->
    <th:block layout:fragment="myscripts"></th:block>
</body>
</html>

main > index.html

<!DOCTYPE html>
<html xmlns:th="http:/www.thymeleaf.org"
        xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
        layout:decorate="~{layout/base_layout}">
<!-- th 태그 사용했을 때 나오는 경고 표시 제거 -->
<main layout:fragment="main">

    <h1 class="fw-light" th:text="'현재시간: '+${now}">현재 시간</h1>
    <!-- 문자열 붙여줄 때 "'문자열'+ " -->
    
    <h1 class="fw-medium" th:text="|현재시간: ${now}|">현재 시간</h1>

    <!-- thymeleaf 태그 text 속성 value 같은 것으로 우선 적용됨. -->
    <!-- 함수와 함께 쓸 때 "||" 파이프 연산자 사용 -->
    
    <h1 class="fw-bold">현재시간: [[${now}]]</h1>
    <!-- 안에서 직접 출력 -->
    
</main>
</html>

member > signup.html

<!DOCTYPE html>
<html xmlns:th="http:/www.thymeleaf.org"
    xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
    layout:decorate="~{layout/base_layout}">

<main layout:fragment="main">
    <div class="my-2 card border-dark bg-transparent">
        <div class="card-header text-center d-none">
            <h1>회원가입 페이지</h1>
        </div>
        <div class="card-body">
            <form method="post" th:action="@{ /member/signup }">
                <div class="my-2 form-floating">
                    <input class="form-control bg-transparent border-dark" id="username" type="text"
                        name="username" placeholder="Leave a comment here" required autofocus /> 
                    <label for="username">ID</label>
                </div>
                <div class="my-2 form-floating">
                    <input class="form-control bg-transparent border-dark" id="password" type="password"
                        name="password" placeholder="Leave a comment here" required autofocus /> 
                    <label for="password">PASSWORD</label>
                </div>
                <div class="my-2 form-floating">
                    <input class="form-control bg-transparent border-dark" id="email" type="email"
                        name="email" placeholder="Leave a comment here" required autofocus /> 
                    <label for="email">EMAIL</label>
                </div>
                <div class="text-center">
                    <input class="btn btn-warning col-4 font-monospace" type="submit"
                        value="SIGN-UP" />
                </div>
            </form>
        </div>
    </div>
</main>
</html>

post

read.html

<!DOCTYPE html>
<html xmlns:th="http:/www.thymeleaf.org"
    xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
    layout:decorate="~{layout/base_layout}">

<main layout:fragment="main">
    <div>
        <h1 class="d-none">포스트 목록 페이지</h1>
    </div>
    <div class="card bg-transparent border-dark">
        <div class="card-body">
            <table class="table table-hover ">
                <thead>
                    <tr>
                        <th class="text-center bg-transparent" scope="col">No</th>
                        <th class="text-center bg-transparent" scope="col">Title</th>
                        <th class="text-center bg-transparent" scope="col">Author</th>
                        <th class="text-center bg-transparent" scope="col">Modified
                            Time</th>
                    </tr>
                </thead>
                <tbody class="table-group-divider">
                    <tr th:each="post: ${ posts }" class="border-dark">
                        <th scope="row" class="col-1 text-center bg-transparent"
                            th:text="${ post.id }"></th>
                        <td class="col bg-transparent"><a
                            th:href="@{/post/details?id={id} (id=${post.id}) }"
                            th:text="${ post.title }"
                            class="link-dark link-underline-opacity-0"></a>
                        </td>
                        <td class="col-2 text-center bg-transparent"
                            th:text="${ post.author }"></td>
                        <td class="col-3 text-center bg-transparent"
                            th:text="${ #temporals.format(post.modifiedTime, 'yy/MM/dd HH:mm:ss')}"></td>
                    </tr>
                </tbody>
            </table>
            <div class="">
                <form method="get" th:action="@{/post/search}">
                    <div class="input-group">
                        <div class="col-3">
                            <select class="form-select bg-transparent border-dark" name="type">
                                <option value="t">title</option>
                                <option value="c">content</option>
                                <option value="tc">title+content</option>
                                <option value="a">author</option>
                            </select>
                        </div>
                        <input class="form-control bg-transparent border-dark" id="keyword"
                            name="keyword" type="text"
                            placeholder="keyword"
                            aria-describedby="btnSearch" required /> 
                            <input class="btn btn-warning bg-transparent border-dark" type="submit"
                            value="search" id="btnSearch" />
                    </div>
                </form>
            </div>
        </div>
    </div>
</main>
</html>

static>js

post

modify.js

<!DOCTYPE html>
<html xmlns:th="http:/www.thymeleaf.org"
    xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
    layout:decorate="~{layout/base_layout}">

<main layout:fragment="main">
    <div>
        <h1 class="d-none">포스트 목록 페이지</h1>
    </div>
    <div class="card bg-transparent border-dark">
        <div class="card-body">
            <table class="table table-hover ">
                <thead>
                    <tr>
                        <th class="text-center bg-transparent" scope="col">No</th>
                        <th class="text-center bg-transparent" scope="col">Title</th>
                        <th class="text-center bg-transparent" scope="col">Author</th>
                        <th class="text-center bg-transparent" scope="col">Modified
                            Time</th>
                    </tr>
                </thead>
                <tbody class="table-group-divider">
                    <tr th:each="post: ${ posts }" class="border-dark">
                        <th scope="row" class="col-1 text-center bg-transparent"
                            th:text="${ post.id }"></th>
                        <td class="col bg-transparent"><a
                            th:href="@{/post/details?id={id} (id=${post.id}) }"
                            th:text="${ post.title }"
                            class="link-dark link-underline-opacity-0"></a>
                        </td>
                        <td class="col-2 text-center bg-transparent"
                            th:text="${ post.author }"></td>
                        <td class="col-3 text-center bg-transparent"
                            th:text="${ #temporals.format(post.modifiedTime, 'yy/MM/dd HH:mm:ss')}"></td>
                    </tr>
                </tbody>
            </table>
            <div class="">
                <form method="get" th:action="@{/post/search}">
                    <div class="input-group">
                        <div class="col-3">
                            <select class="form-select bg-transparent border-dark" name="type">
                                <option value="t">title</option>
                                <option value="c">content</option>
                                <option value="tc">title+content</option>
                                <option value="a">author</option>
                            </select>
                        </div>
                        <input class="form-control bg-transparent border-dark" id="keyword"
                            name="keyword" type="text"
                            placeholder="keyword"
                            aria-describedby="btnSearch" required /> 
                            <input class="btn btn-warning bg-transparent border-dark" type="submit"
                            value="search" id="btnSearch" />
                    </div>
                </form>
            </div>
        </div>
    </div>
</main>
</html>

reply

reply.js

/**
 * 댓글 영역 보이기/숨기기 토글
 * 댓글 검색, 등록, 수정, 삭제
 */
document.addEventListener('DOMContentLoaded', () => {
    //  로그인한 사용자 이름
    const authName = document.querySelector('input#authName').value;
    
    // 부트스트랩 Collapse 객체를 생성. 초기 상태는 화면에 보이지 않는 상태.
    const bsCollapse = new bootstrap.Collapse('div#replyToggleDiv', {toggle: false});
    
    // 토글 버튼을 찾고, 이벤트 리스너를 등록.
    const btnToggleReply = document.querySelector('#btnToggleReply');
    btnToggleReply.addEventListener('click', (e) => {
        bsCollapse.toggle();
        // console.log(e.target.innerText);
        if (e.target.innerText === 'SHOW') {
            e.target.innerText = 'HIDE';
            
            // 댓글 목록 불러오기:
            getRepliesWithPostId();
        } else {
            e.target.innerText = 'SHOW';
        }
    });
    
    //댓글 삭제 버튼 클릭을 처리하는 이벤트 리스너
    const deleteReply= (e) => {
        //console.log(e.target);
        const result = confirm('DELETE?');
        if(!result){
            return;
        }
        const id = e.target.getAttribute('data-id');
        const reqUrl = `/api/reply/${id}`;
        
        axios
            .delete(reqUrl) // ajax DELETE 요청
            .then((response) => { //then(function ()) 익명함수
                console.log(response);
                
                getRepliesWithPostId();
            }) // 성공 응답일 때 실행할 콜백 등록
            .catch((error) => console.log(error)); // 실패 응답일 때
    }
    
     const updateReply = (e) => {
        //console.log(e.target);
        const replyId = e.target.getAttribute('data-id'); // 수정할 댓글 아이디
        
        const textAreaId = `textarea#replyText_${replyId}`; // 댓글 입력 textarea 아이디
        //console.log(document.querySelector(textAreaId));
        
        // 수정할 댓글 내용
        const replyText = document.querySelector(textAreaId).value;
        if (replyText === '') { // 댓글 내용이 비어있으면
            alert('cannot insert blank');
            return;
        }
        
        const reqUrl = `/api/reply/${replyId}`; // 요청 주소
        const data = { replyText }; // {replyText: replyText}, 요청 데이터(수정할 댓글 내용)
        axios
            .put(reqUrl, data) // PUT 방식의 Ajax 요청을 보냄.
            .then((response) => {
                console.log(response);
                // TODO:
            }) // 성공 응답일 때 동작할 콜백을 등록.
            .catch((error) => console.log(error)); // 에러 응답일 때 동작할 콜백을 등록.
        
    };
    
    const makeReplyElements = (data) => {
        // 댓글 개수를 배열의 원소 개수로 업데이트
        document.querySelector('span#replyCount').innerText = data.length;
        
        // 댓글 목록을 삽입할 div 요소를 찾음.
        const replies = document.querySelector('div#replies');
        
        //div 안에 작성된 기존 내용은 삭제.
        replies.innerHTML = '';
        
        // div 안에 삽입할 HTML 코드 초기화
        let htmlStr = '';
        for(let reply of data) {
            htmlStr += `
            <div class="card mb-2 bg-transparent border-warning">
                <div class="row mt-2">
                    <div class="col">
                        <span class="fw-bold">#${reply.id}</span>
                        <span class="fw-bold">${reply.writer}</span>
                    </div>
                    `;
        // 로그인한 사용자와 댓글 작성자가 같을 때만 삭제, 수정 버튼 보여줌.
        if (reply.writer === authName){
            htmlStr += `
                    <div class="col text-end">
                        <button class="btnModify btn btn-sm btn-warning" data-id="${reply.id}">MOD</button>
                        <button class="btnDelete btn btn-sm btn-outline-warning" data-id="${reply.id}">DEL</button>
                    </div>
                </div>
                    <textarea id="replyText_${reply.id}" class="my-2 form-control border-warning bg-transparent">${reply.replyText}</textarea>
            </div>
        `;
        } else {
            htmlStr += `
                </div>
                    <textarea id="replyText_${reply.id}" class="my-2 form-control border-warning bg-transparent" readonly>${reply.replyText}</textarea>
            </div>
            `;
            }
        }
        
        //작성된 HTML 문자열을 div 요소의 innerHTML로 설정.
        replies.innerHTML = htmlStr;
        
        //모든 댓글 삭제 버튼들에게 이벤트 리스너를 등록
        const btnDeletes = document.querySelectorAll('button.btnDelete');
        for(let btn of btnDeletes){
            btn.addEventListener('click', deleteReply);
        }
        
        // 수정 버튼
        const btnModifies = document.querySelectorAll('button.btnModify');
        for(let btn of btnModifies){
            btn.addEventListener('click', updateReply);
        }
    };
    
    // 포스트 번호에 달려 있는 댓글 목록을 (Ajax 요청으로) 가져오는 함수:
    const getRepliesWithPostId = async () => {
        const postId = document.querySelector('input#id').value; // 포스트 아이디(번호)
        console.log(postId);
        const reqUrl = `/api/reply/all/${postId}`; // Ajax 요청 주소
        
        // Ajax 요청을 보내고 응답을 기다림.
        try {
            const response = await axios.get(reqUrl);
            // console.log(response);
            makeReplyElements(response.data);
        } catch (error) {
            console.log(error);
        }
    };
    
    // 댓글 등록 버튼을 찾고 이벤트 리스너 등록
    const btnReplyCreate = document.querySelector('button#btnReplyCreate');
    btnReplyCreate.addEventListener('click', () => {
        // 포스트 아이디
        const postId = document.querySelector('input#id').value;
        // 댓글 내용
        const replyText = document.querySelector('input#replyText').value;
        // 댓글 작성자(나중에 로그인한 사요ㅕㅇ자)
        const writer = authName;
        
        //alert(`${postId}, ${replyText}, ${writer}`);
        if(replyText === ''){
            alert('cannot input blank');
            return;
        }
        
        //Ajax 요청에서 보낼 데이터: /배열아니고 object, 
        const data = {postId, replyText, writer};
        
        //Ajax 요청을 보낼 URL
        const reqUrl = '/api/reply';
        
         axios
            .post(reqUrl,data)//Ajax POST 방식 요청을 보냄.
            .then((response) => {
                console.log(response);
                // 댓글목록 새로고침
                getRepliesWithPostId();
                // textarea 비우기
                document.querySelector('input#replyText').value = '';
            }) //성공 응답(response)일 때 실행할 콜백 등록
            .catch((error) => console.log(error)); //실패(error)일 때 실행할 콜백 등록.
            
    });
    
});

web

HomeController.java

package com.itwill.spring3.web;

import java.time.LocalDateTime;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Controller
public class HomeController {
    
    @GetMapping("/")
    public String home(Model model) {
        log.info("home");
        
        LocalDateTime now = LocalDateTime.now();
        model.addAttribute("now", now);
        
        return "/main/index"; // View의 이름
    }

}

MemberController.java

package com.itwill.spring3.web;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import com.itwill.spring3.dto.member.MemberSignUpDto;
import com.itwill.spring3.service.MemberService;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequiredArgsConstructor
@Controller
@RequestMapping("/member")
public class MemberController {

    private final MemberService memberService;
    
    @GetMapping("/signup")
    public void signUp() {
        log.info("signUp() GET");
    }
    
    @PostMapping("/signup")
    public String signup(MemberSignUpDto dto) {
        log.info("signUp(dto={}) POST",dto);
        
        // 회원가입 서비스 호출
        Long id = memberService.reqisterMember(dto);
        log.info("회원가입 id = {}", id);
        
        //회원가입 이후에 로그인 화면으로 이동(redirect):
        return "redirect:/login";
    }
}

PostController.java

package com.itwill.spring3.web;

import java.util.List;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import com.itwill.spring3.dto.post.PostCreateDto;
import com.itwill.spring3.dto.post.PostSearchDto;
import com.itwill.spring3.dto.post.PostUpdateDto;
import com.itwill.spring3.repository.post.Post;
import com.itwill.spring3.repository.reply.Reply;
import com.itwill.spring3.service.PostService;
import com.itwill.spring3.service.ReplyService;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Controller
@RequiredArgsConstructor
@RequestMapping("/post")
public class PostController {

    private final PostService postService;
    private final ReplyService replyService;

    @GetMapping
    public String read(Model model) {
        log.info("read()");

        List<Post> list = postService.read();
        model.addAttribute("posts", list);

        return "/post/read";
    }

    @PreAuthorize("hasRole('USER')") // 페이지 접근 이전에 인증(권한, 로그인) 여부를 확인.
    @GetMapping("/create")
    public void create() {
        log.info("create() GET");
    }

    @PreAuthorize("hasRole('USER')")
    @PostMapping("/create")
    public String create(PostCreateDto dto) {
        log.info("create(dto={}) POST", dto);

        postService.create(dto);
        // DB 테이블 insert 후 포스트 목록 페이지로 이동.
        return "redirect:/post";
    }

    // "/details", "/modify" 요청 주소를 처리하는 컨트롤러 메서드
    @PreAuthorize("hasRole('USER')")
    @GetMapping({ "/details", "/modify" })
    public void read(Long id, Model model) {
        log.info("read(id={})", id);

        // POSTS 테이블에서 ID에 해당하는 포스트를 검색.
        Post post = postService.read(id);

        // 결과를 model에 저장.
        model.addAttribute("post", post);

        // Replies 테이블에서 해당 포스트에 달린 댓글 개수를 검색
        long count = replyService.countByPost(post);
        model.addAttribute("replyCount", count);

        // 컨트롤러 메서드의 리턴값이 없는 경우(void인 경우),
        // 뷰의 이름은 요청 주소와 같다!
        // details -> details.html, modify -> modify.html

    }

    @PreAuthorize("hasRole('USER')")
    @PostMapping("/update")
    public String update(PostUpdateDto dto) {
        log.info("update(dto={}, id={})", dto);
        postService.update(dto);
        return "redirect:/post/details?id=" + dto.getId();
    }

    @PreAuthorize("hasRole('USER')")
    @PostMapping("/delete")
    public String delete(long id) {
        log.info("delete(id={})", id);
        postService.delete(id);
        log.info("삭제 결과={}", id);
        return "redirect:/post";
    }

    @GetMapping("/search")
    public String search(PostSearchDto dto, Model model) {
        log.info("search(dto={})", dto);

        // TODO postService의 검색 기능 호출:
        List<Post> list = postService.search(dto);
        model.addAttribute("posts", list);
        return "/post/read";
    }

}

ReplyRestController.java

package com.itwill.spring3.web;

import java.util.List;

import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.itwill.spring3.dto.reply.ReplyCreateDto;
import com.itwill.spring3.dto.reply.ReplyUpdateDto;
import com.itwill.spring3.repository.reply.Reply;
import com.itwill.spring3.service.ReplyService;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/reply")
public class ReplyRestController {
    
    private final ReplyService replyService;
    
    @PreAuthorize("hasRole('USER')")
    @GetMapping("/all/{postId}")
    public ResponseEntity<List<Reply>> all(@PathVariable long postId) {
        log.info("all(postId={})", postId);
        
        List<Reply> list = replyService.read(postId);
        
        // 클라이언트에 댓글 리스트를 응답으로 보냄.
        return ResponseEntity.ok(list);
    }
    
    @PreAuthorize("hasRole('USER')")
    @PostMapping
    public ResponseEntity<Reply> create(@RequestBody ReplyCreateDto dto){
        log.info("create(dto={})", dto);
        
        Reply reply = replyService.create(dto);
        
        return ResponseEntity.ok(reply);
    }
    
    @PreAuthorize("hasRole('USER')")
    @PutMapping("/{id}")
    public ResponseEntity<String> update(@PathVariable long id, @RequestBody ReplyUpdateDto dto) {
        log.info("update(id={}, dto={})", id, dto);
        replyService.update(id, dto);
        return ResponseEntity.ok("Success");
    }
    
    @PreAuthorize("hasRole('USER')")
    @DeleteMapping("/{id}")
    public ResponseEntity<String> delete(@PathVariable long id){
        log.info("delete(id= {})", id);
        replyService.delete(id);
        return ResponseEntity.ok("success");
    }
}

repository

BaseTimeEntity.java

package com.itwill.spring3.repository;

import java.time.LocalDateTime;

import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;

// 여러 테이블에서 공통으로 사용되는 생성시간, 수정시간을 프로퍼티로 갖는 객체를 설계:
@Getter

//-> getter 메서드 자동 생성.
@MappedSuperclass
//-> 다른 도메인(엔터티) 클래스의 상위 클래스로 사용됨.
//-> 상속하는 하위 클래스는 BaseTimeEntity가 정의하는 커럼들을 갖게 됨.

@EntityListeners(AuditingEntityListener.class)
//-> main 메서드를 갖고 있는 메인 클래스에서 JPA Auditing 기능이 활성화되어 있는 경우에
//-> 엔터티가 삽입/수정되는 시간이 자동으로 기록되도록 하기 위해서.
public class BaseTimeEntity {
    // 엔터티 클래스의 필드 이름은 
    // 데이터 베이스 테이블의 컬럼 이름과 같거나, 컬럼 이름을 camel 표기법으로 변환한 이름으로 작성.
    // (예) 테이블 created_time - 클래스 createdTime
    
    @CreatedDate // insert 될 때의 시간이 자동으로 기록됨
    private LocalDateTime createdTime;
    //-> 엔터티 클래스의 필드 이름은 자바의 관습(camel 표기법)으로 작성.
    //-> 테이블의 컬럼 이름은 데이터베이스의 관습(snake 표기법)을 따름.
    
    @LastModifiedDate
    private LocalDateTime modifiedTime;
}

post

Post

package com.itwill.spring3.repository.post;

import com.itwill.spring3.dto.post.PostUpdateDto;
import com.itwill.spring3.repository.BaseTimeEntity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Setter
@ToString
@Entity //JPA 엔터티 클래스- 데이터 베이스 테이블과 매핑되는 클래스
@Table(name = "POSTS") // 엔터티 클래스 이름이 데이터 베이스 테이블 이름과 다른 경우, 테이블 이름을 명시/
@SequenceGenerator(name = "POSTS_SEQ_GEN", sequenceName = "POSTS_SEQ", allocationSize = 1)
public class Post extends BaseTimeEntity {

    @Id //Primary key 제약조건
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "POSTS_SEQ_GEN")
    private Long id;
    
    @Column(nullable = false) // Not Null 제약 조건
    private String title;
    
    @Column(nullable = false)
    private String content;
    
    @Column(nullable = false)
    private String author;
    
    //for Post entity title, content update:
    public Post update(PostUpdateDto dto) {
        this.title = dto.getTitle();
        this.content = dto.getContent();
        return this;
    }
    
}

PostRepository

package com.itwill.spring3.repository.post;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface PostRepository extends JpaRepository<Post, Long> {

    // id 내림차순 정렬:
    // select * from POSTS order by ID desc
    List<Post> findByOrderByIdDesc();
    
    // 제목으로 검색:
    // select * from posts p where lower(p.title) like lower('%' || ? || '%') order by p.id desc
    List<Post> findByTitleContainsIgnoreCaseOrderByIdDesc(String title);
    
    // 내용으로 검색:
    List<Post> findByContentContainsIgnoreCaseOrderByIdDesc(String content);
    
    // 제목과 내용으로 검색:
    // select * 
    // from posts p 
    // where lower(p.title) like lower('%' || ? || '%') 
    //      or lower(p.content) like lower('%' || ? || '%')
    // order by p.id desc
    List<Post> findByTitleContainsIgnoreCaseOrContentContainsIgnoreCaseOrderByIdDesc(String title, String content);
    
    // 작성자로 검색:
    List<Post> findByAuthorContainsIgnoreCaseOrderByIdDesc(String author);
    
    // JPQL(JPA Query Language) 문법으로 쿼리를 작성하고, 그 쿼리를 실행하는 메서드 이름을 설정:
    // JPQL은 Entity 클래스의 이름과 필드 이름들을 사용해서 작성
    // (주의) DB테이블 이름과 컬럼 이름을 사용하지 않음
    @Query(
            "select p from Post p "+
            " where lower(p.title) like lower('%' || :keyword || '%') " +
            " or lower(p.content) like lower('%' || :keyword || '%') " +
            " order by p.id desc"
    )
    List<Post> searchByKeyword(@Param("keyword") String keyowrd);
    
}

reply

Reply

package com.itwill.spring3.repository.reply;

import com.itwill.spring3.dto.reply.ReplyUpdateDto;
import com.itwill.spring3.repository.BaseTimeEntity;
import com.itwill.spring3.repository.post.Post;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.SequenceGenerator;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

@NoArgsConstructor
@Getter
@Setter
@AllArgsConstructor
@Builder
@ToString(exclude = {"post"} )
@Entity
@Table(name = "REPLIES")
@SequenceGenerator(name = "REPLIES_SEQ_GEN", sequenceName = "REPLIES_SEQ", allocationSize = 1 )
public class Reply extends BaseTimeEntity {
    
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "REPLIES_SEQ_GEN")
    private Long id; // Primary Key
    
    @ManyToOne(fetch = FetchType.LAZY) 
    // EAGER(기본값): 즉시 로딩(join 문장 실행), LAZY: 지연 로딩(나중에 필요한 경우 join 문장 실행)
    private Post post; // Foreign Key, 관계를 맺고 있는 엔터티
    
    @Column(nullable = false)
    private String replyText; // 댓 내용
    
    @Column(nullable = false)
    private String writer; // 댓 작성자
    
    public Reply update(String replyText) {
        this.replyText = replyText;
        return this;
    }
}

RepltRepository

package com.itwill.spring3.repository.reply;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;

import com.itwill.spring3.repository.post.Post;

public interface ReplyRepository extends JpaRepository<Reply, Long> {
    
    // Post(id)로 검색하기:
    List<Reply> findByPostId(Long postId);
    
    // Post(post)로 검색하기:
    List<Reply> findByPostOrderByIdDesc(Post post);
    
    // Post에 달린 댓글 개수:
    Long countByPost(Post post);
}

member

Member

package com.itwill.spring3.repository.member;

import java.util.Arrays;
import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import com.itwill.spring3.repository.BaseTimeEntity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
import jakarta.persistence.Table;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@NoArgsConstructor
@Getter
@ToString
@Entity
@Table(name = "MEMBERS")
@SequenceGenerator(name = "MEMBERS_SEQ_GEN", sequenceName = "MEMBERS_SEQ", allocationSize = 1)
// Member IS-A UserDetails
// 스프링 시큐리티는 로그인 처리를 위해서 UserDetails 객체를 사용하기 때문에
// 회원 정보 엔터티는 UserDetails 인터페이스를 구현해야 함.
public class Member extends BaseTimeEntity implements UserDetails{

    @Id //pk
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "MEMBERS_SEQ_GEN")
    private Long id;
    
    @Column(nullable = false, unique = true)
    private String username;
    
    @Column(nullable = false)
    private String password;
    
    @Column(nullable = false)
    private String email;
    
    @Column(nullable = false)
    private Role role;
    
    @Builder
    private Member(String username, String password, String email) {
        this.username = username;
        this.password = password;
        this.email = email;
        this.role = Role.USER; // 회원가입 사용자 권한의 기본값은 USER
    }

    // UserDetails 인터페이스의 추상 메서드들을 구현
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Arrays.asList(new SimpleGrantedAuthority(role.getKey()));
    }

    @Override
    public boolean isAccountNonExpired() {
        return true; // 계정(account)이 non-expired(만료되지 않음).
    }

    @Override
    public boolean isAccountNonLocked() {
        return true; // 계정이 non-lock(잠기지 않음)
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;  // 비밀번호가 non-expried
    }

    @Override
    public boolean isEnabled() {
        return true; // 사용자 상세정보(UserDetails)가 활성화(enable)
    }
}

MemberRepository

package com.itwill.spring3.repository.member;

import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long>{

    Member findByUsername(String Username);
    
}

Role

package com.itwill.spring3.repository.member;

public enum Role {

    USER("ROLE_USER", "USER"),
    ADMIN("ROLE_ADMIN", "ADMIN");
    
    private final String key;
    private final String name;
    
    Role(String key, String name){
        this.key = key;
        this.name = name;
    }
    
    public String getKey() {
        return this.key;
    }
    
}

dto

post

  • PostCreateDto
  • PostSearchDto
  • PostUpdateDto

reply

  • ReplyCreateDto
  • ReplyUpdateDto

member

  • MemberSignUpDto

service

PostService

@Transactional

(1) 메서드에 @Transactional 애너테이션을 설정
(2) DB에서 엔터티를 검색
(3) 검색한 엔터티를 수정
트랜잭션이 끝나는 시점에 DB 업데이트가 자동으로 수행

package com.itwill.spring3.service;

import java.util.ArrayList;
import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.itwill.spring3.dto.post.PostCreateDto;
import com.itwill.spring3.dto.post.PostSearchDto;
import com.itwill.spring3.dto.post.PostUpdateDto;
import com.itwill.spring3.repository.post.Post;
import com.itwill.spring3.repository.post.PostRepository;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequiredArgsConstructor
@Service
public class PostService {
    
    // 생성자를 사용한 의존성 주입:
    private final PostRepository postRepository;
    
    // DB POST 테이블에서 전체 검색한 결과를 리턴
    public List<Post> read() {
        log.info("read()");
        return postRepository.findByOrderByIdDesc();
    }
    
    // DB에 있는 POSTS 테이블에 새 entity를 insert:
    public Post create(PostCreateDto dto) {
        log.info("create(dto={}",dto);
        
        // DTO를 entity로 변환:
        Post entity = dto.toEntity();
        log.info("entity= {}", entity);
        
        // DB 테이블에 저장
        postRepository.save(entity);
        log.info("entity= {}", entity);
        
        return entity;
    }

    public Post read(Long id) {
        return postRepository.findById(id).orElseThrow();
    }

    @Transactional
    public Post update(PostUpdateDto dto) {
    	log.info("update({})", dto);
    	
    	//Post entity = postRepository.findById(id).orElseThrow();
    	//entity.update(dto);
    	
    	Post entity = postRepository.findById(dto.getId()).orElseThrow();
    	entity.update(dto);
    	return postRepository.saveAndFlush(entity);
    }
    
	public void delete(long id) {
		log.info("delete(id={})",id);
		postRepository.deleteById(id);
	}
    
	@Transactional(readOnly = true)
	public List<Post> search(PostSearchDto dto){
        List<Post> list = null;
        switch(dto.getType()) {
        case "t" :
            list = postRepository.findByTitleContainsIgnoreCaseOrderByIdDesc(dto.getKeyword());
            break;
        case "c" :
            list =  postRepository.findByContentContainsIgnoreCaseOrderByIdDesc(dto.getKeyword());
            break;
        case "tc" :
            list =  postRepository.findByTitleContainsIgnoreCaseOrContentContainsIgnoreCaseOrderByIdDesc(dto.getKeyword(), dto.getKeyword());
            break;
        case "a" :
            list =  postRepository.findByAuthorContainsIgnoreCaseOrderByIdDesc(dto.getKeyword());
            break;
        }
        return list;
    }
    
}

ReplyService

package com.itwill.spring3.service;

import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.itwill.spring3.dto.reply.ReplyCreateDto;
import com.itwill.spring3.dto.reply.ReplyUpdateDto;
import com.itwill.spring3.repository.post.Post;
import com.itwill.spring3.repository.post.PostRepository;
import com.itwill.spring3.repository.reply.Reply;
import com.itwill.spring3.repository.reply.ReplyRepository;

import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@AllArgsConstructor
@Service
@Slf4j
public class ReplyService {

    private final ReplyRepository replyRepository;
    private final PostRepository postRepository;
    
    
    public void delete(long id) {
        log.info("delete(id = {})", id);
        
        //DB replies 테이블에서 ID(고유키)로 댓글 삭제
        replyRepository.deleteById(id);
    }
    
    @Transactional(readOnly = true)
    public List<Reply> read(Long postId){
        log.info("read(postId={})", postId);
        
        // 1. postId로 post 검색.
        Post post = postRepository.findById(postId).orElseThrow();
        
        // 2. 찾은 post에 달려 있는 댓글 목록을 검색. 
        List<Reply> list = replyRepository.findByPostOrderByIdDesc(post);
        
        //List<Reply> list = replyRepository.findByPostId(postId);
        
        return list;
    }
    
    @Transactional(readOnly = true)
    public List<Reply> read(Post post) {
        log.info("read(post={}", post);
        List<Reply> list = replyRepository.findByPostOrderByIdDesc(post);
        
        return list;
    }
    
    public Long countByPost(Post post) {
        log.info("countByPost(post={})", post);
        
        return replyRepository.countByPost(post);
    }
    
    public Reply create(ReplyCreateDto dto) {
        log.info("create(dto={})", dto);
        
        //1. Post 엔터티 검색
        Post post = postRepository.findById(dto.getPostId()).orElseThrow();
        
        //2. ReplyCreateDto 객체를 Reply 엔터티 객체로 변환
        Reply entity = Reply.builder()
                .post(post)
                .replyText(dto.getReplyText())
                .writer(dto.getWriter())
                .build();
          
        //3. DB replies 테이블에 insert
        replyRepository.saveAndFlush(entity);
        
        return entity;
    }
    
    @Transactional
    //-> DB에서 검색한 Entity를 수정하면 transaction이 끝나는 시점에 update 쿼리가 자동으로 실횅
    public void update(long id, ReplyUpdateDto dto) {
        log.info("update(dto={})", dto);
        
        //1. 댓글 아이디로 DB에서 엔터티 검색
        Reply entity = replyRepository.findById(id).orElseThrow();
        
        //2. 검색한 엔터티의 프로퍼티 수정
        entity.update(dto.getReplyText());
    }
    
}

MemberService

package com.itwill.spring3.service;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import com.itwill.spring3.dto.member.MemberSignUpDto;
import com.itwill.spring3.repository.member.Member;
import com.itwill.spring3.repository.member.MemberRepository;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequiredArgsConstructor
@Service
// Security Filter Chain에서 UserDetialsService 객체를 사용할 수 있도록 하기 위해서
public class MemberService implements UserDetailsService{

    private final MemberRepository memberRepository;
    
    // SecurityConfig에서 설정한 PasswordEncoder 빈(bean)을 주입해줌.
    private final PasswordEncoder passwordEncoder;
    
    // 회원가입
    public Long reqisterMember(MemberSignUpDto dto) {
        log.info("registerMember(dto={})", dto);
        
        Member entity = Member.builder()
                .username(dto.getUsername())
                .password(passwordEncoder.encode(dto.getPassword()))
                .email(dto.getEmail())
                .build(); 
        log.info("before save: entity={}", entity);
        
        memberRepository.save(entity); // DB insert
        log.info("after save: entity={}", entity);
        
        return entity.getId(); // DB에 저장된 ID(고유키)를 리턴
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("loadUserByUsername(username={})", username);
        
        // DB에서 username으로 사용자 정보 검색(select)
        UserDetails user = memberRepository.findByUsername(username);
        
        if(user != null) {
            return user;
        }
        
        throw new UsernameNotFoundException(username + " not found");
    }
}

config

SecurityConfig.java

package com.itwill.spring3.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@EnableMethodSecurity
@Configuration // 스프링 컨테이너에서 빈(bean)으로 생성, 관리 - 필요한 곳에 의존성 주입.
public class SecurityConfig {
    
    // Spring Security 5 버전부터는 비밀번호는 반드시 암호화를 해야 함.
    // 비밀번호를 암호화하지 않으면 HTTP 403(access denied, 접근 거부) 또는
    // HTTP 500(internal server error, 내부 서버 오류)가 발생함.
    // 비밀번호 인코더(Password encoder) 객체를 bean으로 생성해야 함.
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    // 로그인할 때 사용할 임시 사용자(메모리에 임시 저장) bean 생성
    /*
    @Bean
    public UserDetailsService inMemoryUserDetailsService() {
        // 사용자 상세 정보
        UserDetails user1 = User
                .withUsername("user1") // 로그인할 때 사용할 사용자 이름(아이디)
                .password(passwordEncoder().encode("1111")) // 로그인할 때 사용할 비밀번호
                .roles("USER") // 사용자 권한(USER, ADMIN, ...)
                .build(); // UserDetails 객체 생성.
        
        UserDetails user2 = User
                .withUsername("user2")
                .password(passwordEncoder().encode("2222"))
                .roles("USER", "ADMIN")
                .build();
        
        UserDetails user3 = User
                .withUsername("user3")
                .password(passwordEncoder().encode("3333"))
                .roles("ADMIN")
                .build();
        
        return new InMemoryUserDetailsManager(user1, user2, user3);
    }
    */
    
    // Security Filter 설정 bean:
    // 로그인/로그아웃 설정
    // 로그인 페이지 설정, 로그아웃 이후 이동할 페이지.
    // 페이지 접근 권한 - 로그인해야만 접근 가능한 페이지, 로그인 없이 접근 가능한 페이지.
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // CSRF(Cross Site Request Forgery) 기능 활성화하면,
        // Ajax POST/PUT/DELETE 요청에서 CSRF 토큰을 서버로 전송하지 않으면 403 에러가 발생.
        // -> CSRF 기능 비활성화.
        http.csrf((csrf) -> csrf.disable());
        
        // 로그인 페이지 설정 - 스프링에서 제공하는 기본 로그인 페이지를 사용. 
        http.formLogin(Customizer.withDefaults());
        
        // 로그아웃 이후 이동할 페이지 - 메인 페이지
        http.logout((logout) -> logout.logoutSuccessUrl("/"));
        
        // 페이지 접근 권한 설정
        /*
        http.authorizeHttpRequests((authRequest) -> 
            authRequest // 접근 권한을 설정할 수 있는 객체
            // 권한이 필요한 페이지들을 설정 
            .requestMatchers("/post/create", "/post/details", "/post/modify", 
                    "/post/update", "/post/delete", "/api/reply/**")
//            .authenticated() // 권한여부에 상관없이 아이디/비밀번호가 일치하면
            .hasRole("USER") // 위에서 설정한 페이지들이 USER 권한을 요구함을 설정.
            .anyRequest() // .requestMatchers("/**"). 위 페이지들 이외의 모든 페이지
            .permitAll() // 권한없이 접근 허용.
        );
        */
        // 단점: 새로운 요청 경로, 컨트롤러를 작성할 때마다 Config 자바 코드를 수정해야 함.
        //-> 컨트롤러 메서드를 작성할 때 애너테이션을 사용해서 접근 권한을 설정할 수도 있음.
        // (1) SecurityConfig 클래스에서 @EnableMethodSecurity 애너테이션 설정.
        // (2) 각각의 컨트롤러 메서드에서 @PreAuthorize 또는 @PostAuthorize 애너테이션을 사용.
        
        return http.build();
    }

}

build.gradle

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.1.1'
	id 'io.spring.dependency-management' version '1.1.0'
}

group = 'com.itwill'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '17'
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
	
	// thymeleaf-layout-dialect 기능 사용:
	implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.2.1'
	
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'com.oracle.database.jdbc:ojdbc11'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}

tasks.named('test') {
	useJUnitPlatform()
}

Spring3Application

package com.itwill.spring3;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing //JPA Auditing 기능 활성화
@SpringBootApplication
public class Spring3Application {

	public static void main(String[] args) {
		SpringApplication.run(Spring3Application.class, args);
	}

}
profile
🍋

0개의 댓글