728x90

게시판 댓글을 구현하는 도중 비동기 호출에 대해 알게 됐고, 이를 이용하려면 Ajax를 활용해야 한다는 정보를 얻었다.

비동기란?

  • 비동기의 반대인 동기적 통신의 경우 절차적으로 일을 차례로 하나씩 해 내려가는 것이다.
  • 비동기는 어떤 하나의 일을 하는 도중 기다리는 시간이 필요하다면 그 시간 동안 다른 일을 먼저 처리할 수 있도록 하는 것이다.
  • 즉, 절차적인 동기적 통신.   비절차적인 비동기 통신이라고 생각하면 된다!

Jquery 사용법과 Ajax를 통한 비동기 통신의 내용을 간단하게 정리. (1, 2번은 사용법)

만약 ajax를 사용할 때 동기적으로 실행하고 싶은 경우 코드에 async: false 를 붙여주면된다!

1. Jquery 시작

Jquery를 시작하기 위해 다음과 같은 코드를 html 내부에 작성한다.

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
  • 어디에 적어도 상관없다고 한다.
  • 대부분 </body> 바로 윗 부분 즉, 마지막 부분에 작성하는 것을 많이 볼 수 있었다.

2. (Jquery) javascript 코드 작성

    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script>
        $(document).ready(function() {
            getAlert();
        })

        function getAlert() {
            alert('gd')
        }
    </script>
  • $(document).ready(function() {} : 문서가 준비되면 매개변수를 넣은 콜백함수를 실행하라는 의미
    • 여기서는 html파일이 로드되면 alert로 팝업을 띄우게 했다.

3. Ajax(비동기 통신) 및 자바스크립트(Jquery)

    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script th:inline="javascript">
        $(document).ready(function () {
            getComments();
        })

        function getComments() {
            var loginUsername = [[${ loginUser }]]
            var boardId = $('input[name=boardId]').val()

            $.ajax({
                type: 'GET',
                url: '/board/comment/getCommentList',
                data: { boardId },
                success: function (response) {
                    var a = '';
                    var size = 0;
                    $.each(response, function (key, value) {
                        size = size + 1;
                        a += '<hr /><div>'
                        a += '<input type="hidden" id="commentId" name="commentId" value="' + value.id + '">'
                        a += '<span id="writer" style="font-weight: bold;">' + value.writer + '</span>'
                        if (value.writer == loginUsername) {
                            a += '<ul name="commentChange" class="justify-content-end" style="display: inline;">'
                            a += '<li name="commentUpdate" type="button" style="display: inline; opacity: 0.7; font-size: small; margin-right: 5px" onclick="updateCommentForm(' + value.id + ')">수정</li>'
                            a += '<li name="commentDelete" type="button" style="display: inline; opacity: 0.7; font-size: small;" onclick="deleteComment(' + value.id + ')">삭제</li></ul>'
                        }
                        a += '<pre id="' + value.id + '" name="comment' + value.id + '" style="margin-bottom: 5px; font-size: large;">' + value.content + '</pre>'
                        a += '<p name="createDate' + value.id + '" style="margin-bottom: 5px; opacity: 0.5; font-size: small;">' + value.createDate.substring(0, 10) + ' ' + value.createDate.substring(11, 19) + '</p></div>'
                    });
                    $("#count").html(size)
                    $("#comment").html(a)
                },
                error: function (response) {
                    console.log("error : " + response)
                },
                complete: function () { }
            })
        }

        function updateCommentForm(id) {
            var commentId = id;
            var content = document.getElementById(id).innerText;

            $('ul[name=commentChange]').hide()
            $('pre[name=comment' + commentId + ']').contents().unwrap().wrap('<textarea id="newComment" class="form-control mt-2" name="updateContent" rows="4"></textarea>');
            $('p[name=createDate' + commentId + ']').contents().unwrap().wrap('<input name="update" type="button" class="me-2 mt-2 btn btn-primary" value="수정하기" onclick="updateComment(' + commentId + ')">');
            $('input[name=update]').after("<button class=\"me-2 mt-2 btn btn-primary\" onclick=\"getComments()\">취소</button>")
        }

        function updateComment(id) {
            var commentId = id;
            var content = document.getElementById("newComment").value;

            $.ajax({
                type: 'POST',
                url: '/board/comment/update',
                data: { 
                    commentId:commentId,
                    content:content 
                },
                success: function (response) {
                    getComments()
                },
                error: function (response) {
                    console.log("update error : " + response)
                },
                complete: function () { }
            })
        }

        function deleteComment(id) {
            var commentId = id;

            if (confirm("정말 삭제하시겠습니까?")) {
                $.ajax({
                    type: 'POST',
                    url: '/board/comment/delete',
                    data: { commentId },
                    success: function (response) {
                        getComments()
                    },
                    error: function (response) {
                        console.log("delete error : " + response)
                    },
                    complete: function () { }
                })
            } else {
                return;
            }
        }
    </script>

댓글 [작성, 수정, 삭제]하는 동시에 페이지를 리로드하지 않고 비동기 통신으로 작성한 댓글을 바로 화면에 보여주는 코드이다. 즉, ajax 통신으로 수정이나 삭제 후 success: 에 getComments()를 호출하면 해당 페이지를 리로드 하지 않고 댓글이 최신화 된다. 

  • <script th:inline="javascript"> : 자바스크립에서 타임리프 변수를 쓰기위해 추가
  • [[${loginUser}]] : 컨트롤러에서 가져온 로그인해있는 사용자 아이디이다.
  • $('input[name=id]').val() : input 태그의 name이 id인 값을 가져와서 설정
    • 만약 $('input#idx').val() 이면 input태그의 id값이 idx인것을 가져와서 설정
    •  .val() : form 양식의 값을 설정하거나 가져옴
      • 만약 $('input#idx').val('abc') id가 idx인 input요소의 값을 abc로 설정
  • url : 서버의 컨트롤러에 호출할 url
  • data : 넘겨줄 데이터 (여기선 var id)
    • 여기서 들어가는 data 변수 이름은 컨트롤러의 인자명과 같아야 한다.
  • success : ajax 통신을 성공하고 서버로부터의 결과값을 response에 담아서 함수 실행
  • $.each(response, function(key, value) {}); : 서버에서 응답받은 데이터의 개수만큼 반복 (for or while)
    • 컨트롤러에서 List<Comment> comments 형태의 객체를 리턴했다.
    • key는 0, 1, 2, 3 ..
    • value.content 식으로 데이터 사용 가능. (comments.get(i).getContent와 동일)
    • size : 댓글 개수
    • $().html : 해당 태그의 요소를 수정
    • https://www.codingfactory.net/10324
  • if문을 이용해서 로그인한 사용자와 댓글 작성자가 동일하면 수정과 삭제를 가능하도록 버튼 표시한다.
  • error : ajax 통신을 실패하면 실행 (에러 정보 확인)
  • complete : success나 error가 끝나고 실행 (try catch의 finally와 동일)
  • 이외
    • datatype : 서버에서 받아올 데이터를 해석하는 형태 (xml, json 등)
    • cache : 요청 페이지의 캐시 여부 (true or false)
$('ul[name=commentChange]').hide()
  • name이 commentChange인 ul 태그를 숨김(한 댓글을 수정 중 다른 댓글을 수정, 삭제 못하게 함)
$('pre[name=comment' + commentId + ']').contents().unwrap().wrap('<p></p>')
  • 위 코드는 해당하는 name의 값을 가진 pre 태그를 p태그로 대체
$('input[name=a]').after("<button class=\"me-2 mt-2 btn btn-primary\" onclick=\"getComments()\">취소</button>")
  • 3번(ajax) 자바스크립트(jquery) 코드의 updateCommentForm 함수에 다음 코드를 추가해서 수정 버튼 옆에 취소버튼을 추가
  • 취소버튼 클릭 시 수정사항을 반영하지 않고 다시 댓글 목록을 보여줌
  • 이 외 자바스크립트 코드 https://black-mint.tistory.com/41
 

[JavaScript] 다른 태그 값, 양식 작성값(input, textarea ..)가져오기

1, 다른 태그 값 가져오기 var content = document.getElementById(id).innerText; 2. 사용자가 input 또는 textarea에 작성한 값 가져오기 (버튼 클릭시 실행) var content = document.getElementById("newCommen..

black-mint.tistory.com

4. 화면

                <!-- Comment -->
                <h4>댓글</h4>
                <div class="mb-5">
                    <div id="comment">
                    </div>

                    <form action="#" th:action="@{/board/comment/write}" method="post">
                        <input type="hidden" name="boardId" th:value="${board.id}">
                        <div class="mb-3">
                            <label for="content" class="form-label"></label>
                            <textarea class="form-control" id="content" name="content" rows="4"
                                placeholder="댓글을 작성해주세요."></textarea>
                        </div>
                        <button type="submit" class="me-2 btn btn-primary">write</button>
                    </form>
                </div>
  • div > id=comment : 작성된 댓글을 표시한 div 태그
  • 동적으로 댓글을 추가해주는 비동기 통신을 쓰기 때문에 3번 코드(자바스크립트)에 html이 닮겨 있다.

5. 컨트롤러

@Controller
@RequestMapping("/board/comment")
public class CommentController {

    @Autowired
    private CommentService commentService;

    // 댓글 작성
    @PostMapping("/write")
    @ResponseBody
    public void commentWrite(@RequestParam(name = "boardId", required = false) Long boardId,
                               @RequestParam(name = "content") String content,
                               Principal principal) throws Exception {
        String username = principal.getName();
        commentService.write(boardId, content, username);
    }

    // 댓글 조회
    @GetMapping("/getCommentList")
    @ResponseBody
    public List<Comment> getCommentList(@RequestParam(name = "boardId") Long boardId) throws Exception {
        List<Comment> comments = commentService.getCommentList(boardId);
        return comments;
    }

    // 댓글 수정
    @PostMapping("/update")
    @ResponseBody
    public void updateComment(@RequestParam(name = "commentId") Long commentId,
                              @RequestParam(name = "content") String content) throws Exception {
        commentService.update(commentId, content);
    }

    // 댓글 삭제
    @PostMapping("/delete")
    @ResponseBody
    public void deleteComment(@RequestParam(name = "commentId") Long commentId) throws Exception {
        commentService.delete(commentId);
    }
}
  • 댓글 작성
    • boardId : @RequestParam으로 url을 이용해 받는다. (현재 조회하고 있는 게시글)
    • content : 작성한 댓글 내용
  • 댓글 조회, 수정, 삭제
    • @ResponsBody : http요청의 body에 데이터를 넣어서 반환
    • @RequestParam(name = " ") : name은 ajax에서 보내주는 파라미터의 변수명과 같게 해야한다.

6. 서비스

    // 댓글 작성
    public void write(Long boardId, String content, String username) throws Exception {
        Comment comment = new Comment();
        Member member = memberService.getMember(username);

        comment.setBoardId(boardId);
        comment.setContent(content);
        comment.setWriter(member.getUsername());
        comment.setWriterId(member.getId());
        comment.setCreateDate(Timestamp.valueOf(LocalDateTime.now()));

        commentRepository.insertComment(comment);
    }

    // 댓글 조회
    public List<Comment> getCommentList(Long boardId) throws Exception {
        return commentRepository.selectCommentList(boardId);
    }

    // 댓글 수정
    public void update(Long commentId, String content) throws Exception {
        Comment comment = new Comment();

        comment.setId(commentId);
        comment.setContent(content);

        commentRepository.updateComment(comment);
    }

    // 댓글 삭제
    public void delete(Long commentId) throws Exception {
        commentRepository.deleteComment(commentId);
    }

7. 결과

시연 영상

 

728x90

문제점 : 패스워드 리셋을 위해 본인인증 과정 중 빈칸 양식을 제출하면 500 error를 보이는 현상이 나타남.

해결 : 컨트롤러에 if 문으로 isEmpt를 이용한 예외 처리 진행

 

// PW 찾기 폼 진입
@GetMapping("passwordResetForm")
public String passwordResetForm() {
    return "/account/passwordResetForm";
}

// PW 리셋 & 비밀번호 이메일 전송
@PostMapping("passwordReset")
public String passwordReset(String username, String email) {
    if(username.isEmpty() || email.isEmpty()) {
        return "redirect:/passwordResetForm?error=true";
    } else if(memberService.compareEmailUsername(username, email)) {
        String temporaryPassword = memberService.getRandomPassword(username);
        mailService.sendMail(username, email, temporaryPassword);

        return "redirect:/passwordResetForm?email=" + email;
    } else {
        return "redirect:/passwordResetForm?error=true";
    }
}
728x90

문제점 : 아이디 찾기를 구현하는 도중 View의 form을 제축하면 시큐리티에 의해 login폼으로 이동하였다.

해결 : 컨트롤러에서 사용한 redirect의 경로를 잘못 지정하였던 것을 고쳤다.

프로젝트 구조

  • resources > templates > account > findIdForm이 존재
    // ID 찾기
    @GetMapping("findIdForm")
    public String findIdForm(@RequestParam(required = false) String email, Model model) {
        if(email != null) {
            List<Member> memberList = memberService.getMemberByEmail(email);
            model.addAttribute("memberList", memberList);
        }
        return "/account/findIdForm";
    }

    // ID 찾기
    @PostMapping("/findId")
    public String findId(String email, Model model){ // view의 form->input 의 name과 매핑됨.
        if(memberService.checkEmail(email)) {
            return "redirect:/findIdForm?email=" + email;
        } else {
            return "redirect:/account/findIdForm?error=true";
        }
    }

<고치기 전 코드 (PostMapping의 else 부분)>

    // ID 찾기
    @GetMapping("findIdForm")
    public String findIdForm(@RequestParam(required = false) String email, Model model) {
        if(email != null) {
            List<Member> memberList = memberService.getMemberByEmail(email);
            model.addAttribute("memberList", memberList);
        }
        return "/account/findIdForm";
    }

    // ID 찾기
    @PostMapping("/findId")
    public String findId(String email, Model model){ // view의 form->input 의 name과 매핑됨.
        if(memberService.checkEmail(email)) {
            return "redirect:/findIdForm?email=" + email;
        } else {
            return "redirect:/findIdForm?error=true";
        }
    }

<고친 후 코드 (PostMapping의 else 부분)>

  • 컨트롤러에서 PostMapping을 이용해 아이디 찾기 폼의 양식을 제출.
  • email을 제대로 입력하지 않은 경우 redirect:/findIdForm?error=true를 반환
  • email을 제대로 입력한 경우 redirect:/findIdForm?email= url로 입력한 email을 보내서 View의 타임리프 구문에서 활용

결론 : redirect할 때 경로를 컨트롤러 기준으로 작성하여 고쳤다. 즉, 매핑되는 주소를 적어준다.

728x90

1. 페이징 처리를 위한 필드들 정의 (Pagination.class)

2. 총 컨텐츠 개수, 현재 페이지, 현재 페이지 블록(범위) 를 받아서 처리

3. 화면에 보여주기(View)

 

1. 프로젝트 구조

2. Pagination, Common 코드

@Data
public class Pagination extends Common{

    private int listSize = 10;  // 초기값으로 목록개수를 10으로 셋팅
    private int rangeSize = 10; // 초기값으로 페이지범위를 10으로 셋팅
    private int page;           // 현재 페이지
    private int range;          // 현재 페이지 범위 (1~10 = 1)
    private int listCnt;        // 총 게시물 개수
    private int pageCnt;        // 총 페이지 개수
    private int startPage;      // 각 페이지 범위 중 시작 번호
    private int startList;      // mysql용 게시판 시작 번호 (0, 10, 20..)
    private int endPage;        // 각 페이지 범위 중 마지막 번호
    private boolean prev;       // 이전 페이지 여부
    private boolean next;       // 다음 페이지 여부

    public void pageInfo(int page, int range, int listCnt) {

        this.page = page; // 현재 페이지
        this.listCnt = listCnt; // 게시물 개수 총합

        // 페이지 범위
        this.range = range;

        // 전체 페이지수
        this.pageCnt = (int) Math.ceil((double) listCnt / listSize);

        // 시작 페이지
        this.startPage = (range - 1) * rangeSize + 1 ;

        // 끝 페이지
        this.endPage = range * rangeSize;

        // 게시판 시작번호
        this.startList = (page - 1) * listSize;

        // 이전 버튼 상태
        this.prev = range == 1 ? false : true;

        // 다음 버튼 상태
        this.next = endPage > pageCnt ? false : true;

        if(this.endPage > this.pageCnt) {
            this.endPage = this.pageCnt;
            this.next = false;
        }
    }
}
  • Pagination : 페이지 처리에 필요한 필드 Common을 상속받고 활용할 외부 정보를 저장
package com.min.board.paging;

import lombok.Data;

@Data
public class Common {
    private String searchText;
    private String writer;
    private String type;
}
  • Common : 메인 게시판, 내 글 관리, 휴지통 등 컨텐츠를 보여줄 때 아래 페이지 처리를 해야하는데 이 때 사용 
  • searchText : 검색 기능이 필요한 페이지의 경우 사용
  • writer : 내 글 관리, 휴지통 등에서 사용자 정보를 저장
  • type : 메인 게시판, 내 글 관리, 휴지통 등 DB에서 가져오는 값이 다르므로 Mapper에서 <if> 문으로 동적 쿼리 진행

3. Mapper 코드

@Mapper
public interface BoardMapper {

    // 게시글 개수 반환 (메인 게시글, 글 관리, 휴지통)
    int selectBoardTotalCount(Pagination pagination);

    // 게시글 리스트 (메인 게시글, 글 관리, 휴지통)
    List<Board> selectBoardList(Pagination pagination);
}
<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.min.board.repository.BoardMapper">

    <sql id="boardColumns">
        id
        ,title
        ,content
        ,writer_id
        ,writer
        ,delete_yn
        ,create_date
        ,image
    </sql>

    <!--(페이징) 게시글 모두 조회-->
    <select id="selectBoardList" resultType="Board">
        SELECT
            <include refid="boardColumns"/>
        FROM
            tb_board
        WHERE
            <if test="'list'.equals(type)">
                <include refid="CommonMapper.search"/>
            </if>
            <if test="'myPost'.equals(type)">
                <include refid="CommonMapper.myPost"></include>
            </if>
            <if test="'trash'.equals(type)">
                <include refid="CommonMapper.trash"></include>
            </if>
        ORDER BY
            id DESC,
            create_date
        <include refid="CommonMapper.paging"/>
    </select>

    <!--(페이징을 위한 카운트) 게시글 개수 카운트-->
    <select id="selectBoardTotalCount" resultType="int">
        SELECT
            COUNT(*)
        FROM
            tb_board
        WHERE
            <if test="'list'.equals(type)">
                <include refid="CommonMapper.search"/>
            </if>
            <if test="'myPost'.equals(type)">
                <include refid="CommonMapper.myPost"></include>
            </if>
            <if test="'trash'.equals(type)">
                <include refid="CommonMapper.trash"></include>
            </if>
    </select>
</mapper>

<BoardMapper.xml>

<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="CommonMapper">
    <sql id="paging">
        LIMIT
            #{startList}, #{listSize}
    </sql>

    <sql id="search">
        delete_yn = 'N'
        <if test="searchText != null and searchText !=''">
            AND
            (
            title LIKE CONCAT('%', #{searchText}, '%')
            OR content LIKE CONCAT('%', #{searchText}, '%')
            )
        </if>
    </sql>

    <sql id="myPost">
        delete_yn = 'N'
        <if test="writer != null and writer !=''">
            AND writer = #{writer}
        </if>
    </sql>

    <sql id="trash">
        delete_yn = 'Y'
        <if test="writer != null and writer !=''">
            AND writer = #{writer}
        </if>
    </sql>
</mapper>

<CommonMapper.xml>

  • 코드관리를 위해 CommonMapper로 동적 쿼리 진행할 코드를 분리

 

4.  Service 코드

// 전체 게시글 개수 리턴
public int getBoardListCnt(Pagination pagination) {
    int boardTotalCount = 0;

    try {
        boardTotalCount = boardRepository.selectBoardTotalCount(pagination);
    } catch (Exception e) {
        System.out.println("boardRepository.getBoardListCnt() .. error : " + e.getMessage());
    } finally {
        return boardTotalCount;
    }
}

// 전체 게시글 리스트로 리턴
public List<Board> getBoardList(Pagination pagination) {
    List<Board> boards = Collections.emptyList();

    try {
        boards = boardRepository.selectBoardList(pagination);
    } catch (Exception e) {
        System.out.println("boardRepository.getMyBoardList() .. error : " + e.getMessage());
    } finally {
        return boards;
    }
}

 

5. Controller 코드

// 게시판 리스트 (게시글 페이징 및 검색 리스트)
@GetMapping("/list")
public String list(Model model,
                   @RequestParam(required = false, defaultValue = "1") int page,
                   @RequestParam(required = false, defaultValue = "1") int range,
                   String searchText) {
    int listCount = 0;

    Pagination pagination = new Pagination();
    pagination.setSearchText(searchText);
    pagination.setType("list");
    listCount = boardService.getBoardListCnt(pagination);
    pagination.pageInfo(page, range, listCount);

    List<Board> boards = boardService.getBoardList(pagination);

    model.addAttribute("pagination", pagination);
    model.addAttribute("boardList", boards);

    return "board/list";
}
  • 메인 게시판에 페이징 처리를 위해 page, range, searchText를 받아온다.
  • pagination에 검색어, type(게시판 = list, 내가쓴글 = myPost, 휴지통 = trash 등을 선언해 type에 맞는 동적 쿼리문 실행 유도)을 저장
  • listCount에 pagination 객체를 이용해서 총 게시글 개수를 저장
  • 최종적으로 Pagination 객체에 page, range, listCount를 초기화
  • List 형태의 boards 를 생성해서 현재 페이지에 맞는 컨텐츠를 받아옴.
// 글 관리에서 삭제
@PostMapping("/myPost/delete")
public String boardDelete(@RequestParam(required = false) List<String> boardIdList) {
    if(boardIdList == null) return "redirect:/board/myPost";
    if(boardIdList.size() > 0) {
        for(int i = 0; i < boardIdList.size(); i ++) {
            boardService.temporaryDelete(Long.parseLong(boardIdList.get(i)));
        }
    }

    return "redirect:/board/myPost";
}

 

6. View

            <p> 총 건수 : <span th:text="${pagination.listCnt}"></span></p>
            <form class="row g-3 justify-content-end" method="GET" th:action="@{/board/list}" name="searchForm">
                <div class="col-auto">
                    <input type="hidden" name="page" th:value="${param.page}">
                    <input type="hidden" name="range" th:value="${param.range}">
                    <label for="searchText" class="visually-hidden"></label>
                    <input type="text" class="form-control" id="searchText" name="searchText" th:value=${param.searchText}>
                </div>
                <div class="col-auto">
                    <button type="submit" class="btn btn-outline-secondary mb-3">Search</button>
                </div>
            </form>
            .
            .
            .
                <tbody>
                    <tr th:each="board : ${boardList}">
                        <td class="mt-5 text-center" scope="row" th:text="${board.id}">1</td>
                        <td><a th:text="${board.title}" th:href="@{/board/post(id=${board.id})}" style="text-decoration:none; color:black;">제목</a></td>
                        <td class="text-center" th:text="${#dates.format(board.createDate, 'yyyy/MM/dd HH:mm')}">작성일</td>
                        <td class="text-center" th:text="${board.writer}">작성자</td>
                    </tr>
                </tbody>
            </table>

            <!-- Page -->
            <nav th:replace="fragments/pagingCommon :: pagination(${pagination}, 'list')"></nav>

<list.html>

  • pagination.listCnt : 보여줄 게시글의 총 개수
  • input - hidden : name으로 컨트롤러로 page, range를 전송 (GET 방식이므로 url에 표시)
    • value=${param.range}" : url에 표시되는 range=3 값을 가져와 해당 input태그 값으로 셋팅
  • input - searchText : 컨트롤러로 searchText를 전송해서 결과값을 얻도록 유도 (th:value를 이용해서 검색후에도 해당 칸에 검색어가 그대로 남아있음)
  • <tbody> : 테이블에 게시판 글을 보여주기위한 컨텐츠
    • th:each="board : ${boardList}"> : 컨트롤러에서 받아온 List형태인 boardList를 변수명 board로 정의해서 리스트 값을 꺼내서 사용
    • ${dates.format(board.createDate, 'yyyy/MM/dd HH:mm')} : 날짜 형식을 2021/12/28 19:48 로 변경
 

[Spring Boot] Timestamp로 회원가입 시간 저장 (MySQL)

1. MySQL 시간을 담을 컬럼은 DATETIME으로 설정 2. DTO에 Timestamp 타입으로 선언 private Timestamp createDate; 3. 생성 시점에 시간 셋팅해주기 member.setCreateDate(Timestamp.valueOf(LocalDateTime.now()..

black-mint.tistory.com

  • th:replace="fragments/pagingCommon :: pagination(${pagination}, 'list')" : 프래그먼트를 이용해 분리된 페이징 화면을 불러와 사용
 

[Spring Boot] Thymeleaf 정리

스프링부트에서 타임리프로 프로젝트를 진행 중 2번 이상 다시 찾아본 개념에 대해 정리해놓으려 합니다. 기본 타임리프 문서 : https://www.thymeleaf.org/doc/tutorials/2.1/usingthymeleaf.html#standard-htmlx..

black-mint.tistory.com

list 화면

 

            <!-- contents -->
            <form class="row g-3 justify-content-end" method="post" th:action="@{/board/myPost/delete}">
                <table class="table caption-top table-bordered table-hover">
                    <caption>List</caption>
                    <thead>
                        <tr>
                            <th class="text-center" width="50" scope="col"></th>
                            <th class="text-center" width="50" scope="col">No</th>
                            <th class="text-center" width="950" scope="col">제목</th>
                            <th class="text-center" width="200" scope="col">작성일</th>
                            <th class="text-center" width="180" scope="col">작성자</th>
                        </tr>
                    </thead>

                    <tbody>
                        <tr th:each="board : ${boardList}">
                            <td class="mt-5 text-center" scope="row">
                                <div class="checkbox">
                                    <input type="checkbox" name="boardIdList" th:value="${board.id}">
                                </div>
                            </td>
                            <td class="mt-5 text-center" scope="row" th:text="${board.id}">1</td>
                            <td><a th:text="${board.title}" th:href="@{/board/post(id=${board.id})}"
                                    style="text-decoration:none; color:black;">제목</a></td>
                            <td class="text-center" th:text="${#dates.format(board.createDate, 'yyyy/MM/dd HH:mm')}">작성일
                            </td>
                            <td class="text-center" th:text="${board.writer}">작성자</td>
                        </tr>
                    </tbody>
                </table>
                <div class="nav justify-content-end">
                    <button type="submit" class="btn btn-danger">Delete</button>
                </div>
            </form>

            <!-- Page -->
            <nav th:replace="fragments/pagingCommon :: pagination(${pagination}, 'myPost')"></nav>

<myPost.html>

myPost 화면

  • input - checbox : 내 글 관리에서 체크박스로 삭제할 컨텐츠를 체크하기 위한 체크박스
    • th:value를 이용해서 해당 체크박스의 값을 컨텐츠 id로 지정
    • name을 컨트롤러의 파라미터와 똑같은 이름으로 설정
<body>
	<nav aria-label="Page navigation example" th:fragment="pagination(pagination, menu)">
		<ul class="pagination justify-content-center">
			<th:block th:with="start = ${pagination.startPage}, end = ${pagination.endPage}">
				<li class="page-item" th:classappend="${pagination.prev == false} ? 'disabled'">
					<a class="page-link" th:href="@{'/board/' + ${menu}(page=${start - 1}, range=${pagination.range - 1}, 
					searchText=${param.searchText})}">Previous</a>
				</li>

				<li class="page-item" th:classappend="${i == pagination.page} ? 'disabled'" th:if="${i != 0}"
					th:each="i : ${#numbers.sequence(start, end)}"><a class="page-link" href="#" th:href="@{'/board/' + ${menu}(page=${i}, 
					range=${pagination.range}, searchText=${param.searchText})}" th:text="${i}">1</a></li>

				<li class="page-item" th:classappend="${pagination.next == false} ? 'disabled'">
					<a class="page-link" th:href="@{'/board/'+ ${menu}(page=${end + 1}, range=${pagination.range + 1}, 
					searchText=${param.searchText})}" href="#">Next</a>
				</li>
			</th:block>
		</ul>
	</nav>
</body>

<pagingCommon.html>

  • th:fragment : 위 코드들에서 th:replace 붙은 곳에 보여짐
  • th:block - th:with : 새로운 변수 초기화 후 사용
  • https://black-mint.tistory.com/19의 4번에 정리
 

[Spring Boot] Thymeleaf 정리

스프링부트에서 타임리프로 프로젝트를 진행 중 2번 이상 다시 찾아본 개념에 대해 정리해놓으려 합니다. 기본 타임리프 문서 : https://www.thymeleaf.org/doc/tutorials/2.1/usingthymeleaf.html#standard-htmlx..

black-mint.tistory.com

 

728x90

1. MySQL 시간을 담을 컬럼은 DATETIME으로 설정

2. DTO에 Timestamp 타입으로 선언

private Timestamp createDate;

3. 생성 시점에 시간 셋팅해주기

member.setCreateDate(Timestamp.valueOf(LocalDateTime.now())); // 회원가입 시간
  • Member member
  • Timestamp.valueOf(LocalDateTime.now()) : 해당 로직 실행 당시 시간을 리턴

 

오류 발생 : 생성 시간보다 3시간 추가돼서 DB에 저장되는 것을 발견.

 

해결 : application.properties 파일에서 UTC부분을 Asia/Seoul로 수정

spring.datasource.url=jdbc:mysql://localhost:3306/board?serverTimezone=UTC&characterEncoding=UTF-8
spring.datasource.url=jdbc:mysql://localhost:3306/board?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
728x90

1. MySQL 테이블 상태

CREATE TABLE `tb_board` (
  `id` int NOT NULL AUTO_INCREMENT,
  `title` varchar(255) NOT NULL,
  `content` text NOT NULL,
  `writer_id` int NOT NULL,
  `delete_yn` varchar(1) DEFAULT 'N',
  `image` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `writer_id` (`writer_id`),
  CONSTRAINT `tb_board_ibfk_1` FOREIGN KEY (`writer_id`) REFERENCES `tb_userinfo` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
CREATE TABLE `tb_userinfo` (
  `id` int NOT NULL AUTO_INCREMENT,
  `username` varchar(20) NOT NULL,
  `password` varchar(100) NOT NULL,
  `email` varchar(45) NOT NULL,
  `role` varchar(20) DEFAULT 'MEMBER',
  PRIMARY KEY (`id`),
  UNIQUE KEY `id` (`id`,`username`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

2. build.gradle 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.1.4'

3. application.properties 작성

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/board?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
spring.datasource.username=아이디
spring.datasource.password=비밀번호
spring.devtools.livereload.enabled=true
spring.freemaker.cache=false
spring.thymeleaf.cache=false

# MyBatis
# mapper.xml 위치 src/main/resource/mapper/**/*.xml
mybatis.mapper-locations=classpath:mapper/**/*.xml

# camel case
mybatis.configuration.map-underscore-to-camel-case=true

# 패키지명 alias
mybatis.type-aliases-package=com.min.board.model

# mapper 로그정보
logging.level.com.min.board.model.repository=TRACE

4. 모델 추가 및 Mapper 인터페이스 추가

@Data
public class Board {

    private Long id;

    @NotNull
    @Size(min = 2, max = 30, message = "제목은 2자 이상 30자 이하입니다.")
    private String title;

    @NotNull
    @NotBlank(message = "내용을 입력하세요.")
    private String content;
    private Long writerId;
    private String writer;
    private String deleteYN;
    private Timestamp createDate;

    private String image;
}
@Mapper
public interface BoardMapper {
    // 모든 글 조회
    List<Board> selectAllBoards();

    // 게시글 개수 반환 (메인 게시글, 글 관리, 휴지통)
    int selectBoardTotalCount(Pagination pagination);

    // 삭제된 글 제외 모두 조회 (메인 게시글, 글 관리, 휴지통)
    List<Board> selectBoardList(Pagination pagination);

    // 게시글 수정
    void updateBoard(Board board);

    // 아이디로 글 찾기
    Board findById(Long id);

    // 휴지통으로 이동 (임시삭제)
    void temporaryDeleteById(Long id);

    // 글 작성
    void insertBoard(Board board);

    // 휴지통 비우기
    void permanentlyDeleteById(Long boardId);
}

5. resources/mapper 에 mapper.xml 추가

구조

<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="CommonMapper">
    <sql id="paging">
        LIMIT
            #{startList}, #{listSize}
    </sql>

    <sql id="search">
        delete_yn = 'N'
        <if test="searchText != null and searchText !=''">
            AND
            (
            title LIKE CONCAT('%', #{searchText}, '%')
            OR content LIKE CONCAT('%', #{searchText}, '%')
            )
        </if>
    </sql>

    <sql id="myPost">
        delete_yn = 'N'
        <if test="writer != null and writer !=''">
            AND writer = #{writer}
        </if>
    </sql>

    <sql id="trash">
        delete_yn = 'Y'
        <if test="writer != null and writer !=''">
            AND writer = #{writer}
        </if>
    </sql>
</mapper>
  • <sql> : <include>를 통해 다른 쿼리에 활용 가능
  • refid : 참조
  • (select, update, insert, delete의) id : Mapper인터페이스 함수명과 동일
  • parameterType : #{}에 들어갈 파라미터 타입 (사용 권장하지 않음.)
  • resultType : 쿼리 결과 타입
  • #{} : 파리미터(get을 의미) -> DTO(모델)과 똑같은 필드명 작성
    • #{id} => getId
  • <if> : if문. test="조건"

참조 매퍼

<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="CommonMapper">
    <sql id="paging">
        LIMIT
            #{startList}, #{listSize}
    </sql>

    <sql id="search">
        delete_yn = 'N'
        <if test="searchText != null and searchText !=''">
            AND
            (
            title LIKE CONCAT('%', #{searchText}, '%')
            OR content LIKE CONCAT('%', #{searchText}, '%')
            )
        </if>
    </sql>

    <sql id="myPost">
        delete_yn = 'N'
        <if test="writer != null and writer !=''">
            AND writer = #{writer}
        </if>
    </sql>

    <sql id="trash">
        delete_yn = 'Y'
        <if test="writer != null and writer !=''">
            AND writer = #{writer}
        </if>
    </sql>
</mapper>

6. Service에서 사용

    private final BoardMapper boardMapper;

    @Autowired
    public BoardService(BoardMapper boardMapper) {
        this.boardMapper = boardMapper;
    }

    // id를 이용해서 해당 글 수정
    public Board contentLoad(Long id) {
        Board board = boardMapper.findById(id);
        return board;
    }

 

 

728x90

1. 테이블 생성 시 바로 적용하기

- 참조 테이블의 데이터가 삭제되면 그 데이터와 연관있던 데이터를 함께 삭제하는 내용이다.

예를 들어 A가 게시글을 썼는데 회원탈퇴를 진행했다. 그럼 A가 썼던 게시글은 자동 삭제된다.

 

CREATE TABLE `board`.`tb_userinfo` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `username` varchar(20) NOT NULL,
  `password` varchar(100) NOT NULL,
  `email` varchar(45) NOT NULL,
  `role` varchar(20) DEFAULT 'MEMBER',
  PRIMARY KEY (`id`,`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

CREATE TABLE `board`.`tb_board` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `title` varchar(255) NOT NULL,
  `content` text NOT NULL,
  `writer_id` bigint NOT NULL,
  `writer` varchar(20) NOT NULL,
  `image` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`),
  FOREIGN KEY (`writer_id`, `writer`) REFERENCES `board`.`tb_userinfo` (`id`, `username`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
  • FK 추가 뒤에 ON DELETE CASCADE만 붙여주면 된다.

 

728x90

회원탈퇴를 진행하는 도중 체크박스로 동의 여부를 받기 위해 체크박스 사용법을 기록

<head>
    ...
   <script type="text/javascript">
        // 체크박스 체크여부 확인
        function CheckForm(secession) {
            var chk=document.secessionForm.check.checked;
    
            if(!chk){
                alert("체크박스를 체크해주세요.");
                return false;
            }
        }
    </script>
</head>

<body>
	<form th:action="@{/account/secession}" method="post" name="secessionForm" onsubmit="return CheckForm(this)">
    	...
        	<div class="checkbox mt-3 mb-3">
			<label>
				<input type="checkbox" name="check" value="agreement"> 정말 탈퇴하시겠습니까?
			</label>
    		</div>
	</form>
</body>
  • form 태그에 name과 onsubmit(js 함수)을 설정
  • form > input 태그의 name 설정
  • script태그로 body 부분에서 정의한 name들로 함수를 정의해서 사용.
  • document.[form태그 이름].[input 체크박스 이름].checked
  • alert : 팝업창
728x90

문제점 : 회원탈퇴 기능을 진행 중 다음의 에러를 마주했다.

 

org.springframework.dao.InvalidDataAccessApiUsageException:
Provided id of the wrong type for class com.min.board.model.Member. Expected: class com.min.board.model.MemberID, got class java.lang.Long; nested exception is java.lang.IllegalArgumentException: Provided id of the wrong type for class com.min.board.model.Member. Expected: class com.min.board.model.MemberID, got class java.lang.Long

 

 

findById()를 실행하면 나왔던 에러 내용인데, 에러 내용은 id가 MemberID인데 계속 Long 값을 넣어줘서 그렇다고 한다.

 

public interface JpaMemberRepository extends JpaRepository<Member, Long> {

기존 JPA를 구현한 리포지토리의 내용인데, 기본적으로 상속할 때

  • JpaRepository<엔티티 클래스, 엔티티 클래스의 기본키>

로 상속받아야한다.

 

그런데 프로젝트에서 식별자 클래스로 MemberID를 만들어놨었다.

@Data
@AllArgsConstructor
@NoArgsConstructor
public class MemberID implements Serializable {

    @Column(name = "id")
    private Long id;

    @Column(name = "username")
    private String username;
}

 

해결 : 리포지토리의 [엔티티 클래스의 기본키] 부분을 Long에서 MemberID로 고치고 해결됐다.

public interface JpaMemberRepository extends JpaRepository<Member, MemberID> {

 

728x90

아이디 중복 체크 같은 커스텀이 필요한 검증을 하려면 Validator를 구현해줘야 한다.

@Valid사용으로 기본 검증에 대한 내용: https://black-mint.tistory.com/25

 

[Spring Boot] @Valid로 폼 입력값 검증하기

Entity 클래스에 @Id, @Size, @NotBlank 등을 붙여주고 Controller 클래스에서 @Valid 어노테이션을 추가적으로 사용함으로써 기본적인 검증은 가능하다. 1. 기본 form 입력값 검증하기 @Data @Entity @DynamicUpd..

black-mint.tistory.com

1. Validator 인터페이스 구현

@Component
public class MemeberValidator implements Validator {

    @Autowired
    private MemberService memberService;

    @Override
    public boolean supports(Class<?> clazz) {
        return Member.class.equals(clazz);
    }

    @Override
    public void validate(Object obj, Errors errors) {
        Member member = (Member) obj;

        if(memberService.checkUsername(member.getUsername())) {
            errors.rejectValue("username", "key", "이미 존재하는 아이디입니다.");
        }
    }
}
  • validator 패키지 추가 후 Validator 인터페이스를 구현
  • 검증이 필요한 Entity 클래스를 supports에 작성
  • validate에서 View -> Controller -> MemberValidator로 넘어온 member 객체를 통해 값의 검증을 시작
  • errors.rejectValue("에러 필드명", "키값", "키 값이 없을 때 사용할 메세지") 로 구성된다.

2. Controller 적용

@PostMapping("/join")
public String join(@Valid Member member, BindingResult bindingResult){
    memeberValidator.validate(member, bindingResult);

    if(bindingResult.hasErrors()) {
        return "/account/joinForm";
    }

    String encPwd = memberService.pwdEncoding(member.getPassword());
    member.setPassword(encPwd);

    memberService.join(member);

    return "redirect:/loginForm";
}
  • 컨트롤러에서 구현한 Validator에 Member와 BindingResult를 전달해준다.

3. View 적용

<form th:action="@{/join}" method="post" th:object="${member}">          
          <div class="form-floating mt-4">
            <input type="text" class="form-control" th:field="*{username}" th:classappend="${#fields.hasErrors('username')} ? 'is-invalid'" id="floatingInput" placeholder="Username">
            <label for="floatingInput">Username</label>
            <div th:if="${#fields.hasErrors('username')}" th:errors="*{username}" id="validationServer03Feedback" class="invalid-feedback text-start">
              Username Error
            </div>
          </div>
          <div class="form-floating">
            <input type="password" class="form-control mt-2" th:field="*{password}" th:classappend="${#fields.hasErrors('password')} ? 'is-invalid'" id="floatingPassword" placeholder="Password">
            <label for="floatingPassword">Password</label>
            <div th:if="${#fields.hasErrors('password')}" th:errors="*{password}" id="validationServer03Feedback" class="invalid-feedback text-start" style="margin-bottom: 8px;">
              Password Error
            </div>
          </div>
          <div class="form-floating">
            <input type="email" class="form-control" th:field="*{email}" th:classappend="${#fields.hasErrors('email')} ? 'is-invalid'" id="floatingPassword" placeholder="Email">
            <label for="floatingPassword">example@board.com</label>
            <div th:if="${#fields.hasErrors('email')}" th:errors="*{email}" id="validationServer03Feedback" class="invalid-feedback text-start" style="margin-top: 8px;">
              Email Error
            </div>
          </div>
      
          <div class="checkbox mb-3">
          </div>
          <button class="w-100 btn btn-lg btn-primary mt-2" type="submit">Get started!</button>
          <a type="button" class="w-100 btn btn-lg btn-primary mt-2" th:href="@{/}">exit</a>
        </form>
  • 타임리프의 th:if가 달린 3개의 div 태그에서 에러를 출력해준다.
  • th:if=${#fields.hasErrors('필드명')} : 에러가 발생한 경우 true 값을 반환하여 div태그를 표시해준다.
  • form 태그의 th:object="{member}" : GetMapping 때 전달받은 Member의 키 값을 넣어 전달 받은 객체
  • "*{username}" :  위 설명을 참고해서 Member.getUsername()을 View에서 사용할 수 있도록 한 값이다. -> ${member.username} 과 같다고 생각!
  • th:errors="*{password}" : password에 대한 에러 내용을 표시해준다.

+ Recent posts