[Spring] 좋아요 기능 만들기 - 블로그 제작 (8)

merci·2023년 2월 19일
2

블로그 제작 V1

목록 보기
8/9
post-thumbnail
post-custom-banner

좋아요 기능을 만들어서 버튼을 눌렀을때 DB 에 내가 버튼을 누른 기록이 남도록 해보자

좋아요 기능 추가


좋아요 테이블 모델링

하나의 게시글과 댓글은 많은 좋아요를 받을 수 있으므로 1:N의 관계가 된다.
N이 되는 좋아요의 테이블을 만들자.

create table love_tb (
    id int auto_increment primary key,
    user_id int not null,
    board_id int not null,
    state number(1)
);

우선적으로 게시글에만 기능을 만들어보자 댓글좋아요는 그대로 따라하면 된다.

state number(1) 은 0 과 1을 저장할 칼럼으로 좋아요 버튼을 누르면 1 다시 눌러서 취소를 하면 0 으로 바뀐다.

더미데이터를 만들어서 테스트 예정

insert into love_tb (user_id, board_id, state) values (2, 2, 1);
insert into love_tb (user_id, board_id, state) values (2, 1, 1);
insert into love_tb (user_id, board_id, state) values (2, 3, 1);
insert into love_tb (user_id, board_id, state) values (3, 4, 1);
insert into love_tb (user_id, board_id, state) values (4, 2, 1);
insert into love_tb (user_id, board_id, state) values (5, 2, 1);

데이터를 담을 모델을 생성

@Getter
@Setter
public class Love {
    private Integer id;
    private Integer userId;
    private Integer boardId;
    private Integer state;
}




화면에 좋아요 버튼 만들기

    <div id="heart-${dto.id}-div">
        <div id="heart-${dto.id}-count" class="d-flex">
            <c:choose>
                <c:when test="${dto.state == 1}">
                    <i id="heart-${dto.id}"
                        class="my-auto my-heart fa-regular fa-solid fa-heart my-xl my-cursor on-Clicked"
                        onclick="heartclick(`${dto.id}`,`${dto.state}`,`${principal.id}`,`${dto.loveId}`)"></i>
                </c:when>
                <c:otherwise>
                    <i id="heart-${dto.id}" class="my-auto fa-regular fa-heart my-xl my-cursor"
                        onclick="heartclick(`${dto.id}`,`${dto.state}`,`${principal.id}`,`${dto.loveId}`)"></i>
                </c:otherwise>
            </c:choose>
            &nbsp <div>${dto.count}</div>
        </div>
    </div>

버튼을 눌렀을때 데이터를 보내고 태그를 지운뒤 다시 그리기 위해서 div 태그에 id 를 달았다.heart-${dto.id}-div

여기서 중요한 점은 여러개의 EL표현식을 파라미터를 보낼때 백틱( ` ) 으로 끊어서 보내지 않는다면 파라미터를 받는 함수에서 값이 undefined 로 들어가게된다. !!!
수많은 삽질의 결과로 파라미터에 EL표현식을 보낼때는 항상 백틱을 이용해야 겠다.

로그인이 되어있을때만 유저가 누른 버튼이 표시가 되도록 <c:choose> 를 이용해서 다른 스타일을 준다.
사용된 스타일은 부트스트랩 스타일을 이용했다.

<style>
    .my-xl {
        color: 000;
    }
    .my-cursor {
        cursor: pointer;
    }
    .my-cursor:hover {
        color: red;
    }
    .on-Clicked {
        color: red;
    }
</style>

버튼을 클릭했을때 등록된 함수에 게시글id, 버튼누른상태, 유저id, 버튼id를 파라미터로 넘겼다.

onclick="heartclick(`${dto.id}`,`${dto.state}`,`${principal.id}`,`${dto.loveId}`)"

버튼을 클릭했을때 하트를 변하게 만드는 자바스크립트 코드는

    function heart() {
        $('#heart-' + boardId).toggleClass("fa-solid");
        $('#heart-' + boardId).toggleClass("on-Clicked");
        $('#heart-' + boardId + '-count').remove();
        render();
    }

클릭시 해당 버튼과 좋아요 숫자를 삭제하고 다시 그리기 위해서 밑에 두줄을 추가했다.

다시 그리는 함수는 render()

    function render() {
        let el ;
        if ( state === 1 ){
            el = `
            <div id="heart-`+boardId+`-count" class="d-flex">
            <i id="heart- `+boardId+` " class="my-auto my-heart fa-regular fa-solid fa-heart my-xl 
			my-cursor on-Clicked"token template-punctuation string">`+boardId+`,`+state+`,`+userId+`,`+loveId+`)" ></i> 
            &nbsp <div>`+count+`</div></div>
            </div>
            `;
        }
        if ( state === 0 ){
            el = `
            <div id="heart-`+boardId+`-count" class="d-flex">
            <i id="heart- `+boardId+` " class="my-auto fa-regular fa-heart my-xl my-cursor" 
			onclick="heartclick(`+boardId+`,`+state+`,`+userId+`,`+loveId+`)" ></i>
            &nbsp <div>`+count+`</div></div>
            </div>
            `;
        }
        $('#heart-'+boardId+'-div').append(el);
    }

버튼을 클릭시 서버에 데이터를 보내고 받아오는 상태 데이터 state 가 0 or 1 인지에 따라 버튼의 스타일에 차이를 두어서 다시 그리게 했다.
클릭시 count도 +- 1을 해서 다시 그리게 된다.

로그인 상태일 때만 버튼 누르기

로그인이 되어 있는 상태일 때만 버튼을 누르게 하고 싶었는데 여러가지 실험 결과 이 방법이 간단해 보인다.

<script>
let boardId;
let userId;
let count;
let state;
let loveId;

function heartclick(boardId1, state1, userId1, loveId1) {
    boardId = boardId1;
    userId = userId1;
    if (userId1 > 0) {
        let data = {
            boardId: boardId1,
            userId: userId1,
            state: state1,
            id: loveId1
        }
        // AJAX 통신 넣을 예정
    }
}
</script>

백틱으로 끊어서 들어온 EL표현식의 값들이 들어오면 전역변수에 저장할 값들은 저장하고 IF조건을 거쳐야 한다.

로그인을 하지 않으면 userId1는 undefined 이므로 !='' / !== null / userId1.length 등 여러가지 조건을 걸어 봤는데 전부 안되서 userId1 > 0 를 걸어 봤다. 깔끔하게 if 조건이 작동해서 이 방법을 사용해야겠다.

입력된 데이터를 자바스크립트 오브젝트에 넣어서 ajax통신을 하면 뷰에서는 할 일이 끝나게 된다.

ajax로 데이터 보내기

            $.ajax({
                type: "post",
                url: "/love/click",
                data: JSON.stringify(data),
                headers: {
                    "content-type": "application/json; charset=utf-8"
                },
                dataType: "json"
            }).done((res) => {
                count = res.data.count;
                state = res.data.state;
                loveId = res.data.id;
                heart(); // 위에서 만들어 놓은 좋아요 지우고 다시 그리기
            }).fail((err) => {
                alert(err.responseJSON.msg);
            });

ajax로 비동기 통신을 해서 데이터를 받으면 전역변수에 값들을 저장하고 heart(); 함수를 호출하면 된다.
heart(); 는 전역변수의 값들을 사용하므로 데이터만 제대로 받았다면 화면에서는 좋아요 버튼이 정상적으로 동작한다.




메인화면에 좋아요 데이터를 전달할 컨트롤러

우선 로그인하지 않았을때 기본적으로 게시글들의 좋아요를 확인할 수 있게 홈화면으로 가는 컨트롤러에서 좋아요 데이터를 함께 가져가자.

데이터를 가져갈 dto에 필드를 추가한다.

    @Getter
    @Setter
    public static class BoardMainListDto{
        private Integer id;
        private String title;
        private String username;
        private String thumbnail;
        // 아래 3개의 필드를 추가
        private Integer count;
        private Integer loveId;
        private Integer state;
    }

게시글 조회에 사용되는 findAllforList 는 파라미터를 받지않고 List<BoardMainListDto> 를 리턴했었는데 로그인하지 않았을때와 로그인을 했을때 동시에 사용하기 위해서 파라미터를 추가한다.

@Mapper
public interface BoardRepository {
	//다른 메소드들..
    
	public List<BoardMainListDto> findAllforList(Integer userId);
}

파라미터가 들어가지 않을때도 있으므로 Integer타입으로 선언한다.

MyBatis에 사용될 쿼리를 수정해보자

    <select id="findAllforList" resultType="shop.mtcoding.blog2.dto.board.BoardResp$BoardMainListDto">
        select b.id, b.title, b.thumbnail,
          ( select username from user_tb where id = b.user_id ) username,
          ( select count(*) from love_tb where board_id = b.id and state = 1 ) count,
        <if test="userId == null">
          ifnull(null,0) state,
        </if>
        <if test="userId != null">
          ifnull( (select id from love_tb where board_id = b.id and user_id = #{userId} ), null) love_id,
          ( select ifnull( ( select state from love_tb where board_id = b.id and user_id = #{userId} ), 0 ) )state ,
        </if>
          from board_tb b 
    </select>

동적 쿼리를 이용해서 userId 의 null 여부에 따라서 다른 쿼리를 실행하게 만들었다.
state 값이 1 or 0 으로 버튼을 눌렀는지 여부를 구분한다.
ifnull 로 로그인한 유저가 좋아요를 하지 않은 버튼의 상태를 0으로 리턴하도록 만든다.

로그인을 하지 않았을때 나오는 결과는

로그인을 했을때 나오는 결과는 ( userId = 2 )

null 은 유저가 좋아요를 누르지 않았을 때의 값이다.

이제 만든 쿼리를 메인화면으로 가는 컨트롤러가 사용하면 된다.

    @GetMapping("/")
    public String  main(Model model){
    Integer num = null;
    User principal = (User) session.getAttribute("principal"); // 세션에 오브젝트가 null 이라면 에러가 나온다 !!!
    if ( principal != null ){
        num = principal.getId();
    }
    List<BoardMainListDto> dtos = boardRepository.findAllforList(num);
    model.addAttribute("dtos", dtos);
    return "board/main";
    }

findAllforList(Integer userId)메소드에 null 을 넣게 되면 동작을 한다.

하지만 아래의 실행문에서

User principal = (User) session.getAttribute("principal");

로그인을 하지 않았을때 세션에 "principal" 이 존재하지 않기 때문에 리턴되는 User의 id필드 타입을 Integer로 해도 findAllforListprincipal.getId()를 파라미터로 넣게 되면 에러가 나오게 된다.

그래서 생각한 해결방법은 처음부터 Integer num = null;을 설정하고 세션의 User 오브젝트가 존재할 경우에만 num의 값에 principal.getId()를 넣어서 이용하면 된다.



이제 메인화면으로 가면 데이터가 잘 전달된 걸 볼 수 있다.




좋아요 버튼을 눌러서 DB에 insert하기

더미데이터의 좋아요들은 확인을 했는데 사용자가 직접 로그인해서 좋아요 버튼을 눌렀을때 해당 DB에 결과가 들어가게 만들어 보자.

json을 받을 컨트롤러 만들기

화면에서 ajax통신을 이용해서 데이터를 전달했으므로 이에 맞춰서 컨트롤러를 만들면 된다.

	type: "post",
    url: "/love/click",
    data: JSON.stringify(data),
    headers: {
        "content-type": "application/json; charset=utf-8"
    },
    dataType: "json"

데이터를 받을 dto - LoveBoardReqDto

    @Getter
    @Setter
    public static class  - `LoveBoardReqDto`{
        private Integer id; // 버튼의 id
        private Integer boardId;
        private Integer userId;
        private Integer state;
    }

좋아요를 한적이 있다면 id가 있지만 없다면 들어오는 id는 null이다.
null일 경우에는 DB에 insert를 하고 null이 아니면 상태를 수정하면 된다.

연결된 컨트롤러

    @PostMapping("/love/click")
    public ResponseEntity<?> loveClick(@RequestBody LoveBoardReqDto lDto){
        User principal = (User) session.getAttribute("principal");
        if( principal == null ){
            throw new CustomApiException("로그인이 필요한 기능입니다.", HttpStatus.UNAUTHORIZED);
        }
        if( lDto.getBoardId() == null ){
            throw new CustomApiException("게시글 아이디가 필요합니다.");
        }
        if( lDto.getUserId() == null ){
            throw new CustomApiException("회원 아이디가 필요합니다.");
        }
        if ( lDto.getState() == 0 ){ // 상태 변환
            lDto.setState(1); 
        }else{
            lDto.setState(0);
        }
        loveService.클릭하기(lDto, principal.getId());
        LoveBoardRespDto loveDto =  loveRepository.findByBoardIdAndUserId(lDto.getBoardId(), principal.getId());
        return new ResponseEntity<>(new ResponseDto<>(1, "성공", loveDto), HttpStatus.OK);
    }

로그인을 했을때만 버튼의 기능이 동작하게 된다.
들어오는 state는 0 or 1 이므로 토글을 시켜준다.
서비스의 메소드를 호출해서 데이터를 insert한뒤에 다시 조회를 해서 버튼의 데이터를 json에 넣어서 리턴한다.

조회의 결과 전달에 사용되는 dto 는 - LoveBoardRespDto

    @Getter
    @Setter
    @ToString
    public static class LoveBoardRespDto{
        private Integer id;
        private Integer count;
        private Integer state;

사용자가 해당 게시글을 눌러 생성된 버튼의 id, +- 1 된 count, 그리고 상태가 들어간다.

호출된 서비스를 살펴보자

    @Transactional
    public void 클릭하기(LoveBoardReqDto lDto, int principalId) {
        // 유저 아이디 세션 아이디 비교
        if ( principalId != lDto.getUserId()){
            throw new CustomApiException("권한이 없습니다.", HttpStatus.FORBIDDEN);
        }
        Board board = boardRepository.findById(lDto.getBoardId());
        if ( board == null ){
            throw new CustomApiException("게시글이 존재하지 않습니다.");
        }

        // 입력전 state 값 바꾸기
        try {
            loveRepository.insertOrUpdate(lDto, principalId);
        } catch (Exception e) {
            // log.info(e.getMessage());
            throw new CustomApiException("서버에 일시적인 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }

권한 검사를 한번하고 insertOrUpdate() 를 호출하게 된다.

앞서 말했듯 들어온 버튼의 아이디가 null인지 여부에 따라 insert or update를 하게 된다.

동적쿼리를 이용해서 만들어보자

    <insert id="insertOrUpdate">
        <if test="lDto.id == null">
            INSERT INTO love_tb (user_id, board_id, state) VALUES (#{userId}, #{lDto.boardId}, 1)
        </if>
        <if test="lDto.id != null">
            UPDATE love_tb SET state = #{lDto.state} WHERE id = #{lDto.id}
        </if>
    </insert>

if 조건을 이용해서 들어온 버튼의 id가 null 인지 여부에 따라 다른 쿼리를 실행하게 된다.

한번도 해당 게시글에 좋아요를 한적이 없다면


레코드가 추가가 되고

누른적이 있는 좋아요를 다시누르면 버튼의 상태가 0으로 비활성화 된다.

이제 컨트롤러에서 이후의 결과를 조회한뒤 자바오브젝트에 전달한다.

	LoveBoardRespDto loveDto =  loveRepository.findByBoardIdAndUserId(lDto.getBoardId(), principal.getId());
    return new ResponseEntity<>(new ResponseDto<>(1, "성공", loveDto), HttpStatus.OK);

조회에 사용되는 쿼리는 findByBoardIdAndUserId

	<select id="findByBoardIdAndUserId" resultType="shop.mtcoding.blog2.dto.love.LoveRespDto$LoveBoardRespDto">
         SELECT 
            (select id from love_tb where board_id = #{boardId} and user_id = #{userId} ) id,
            ( select count(*) from love_tb where board_id = #{boardId} and state = 1 ) count,
            ifnull (( select state from love_tb where user_id = #{userId} and board_id = #{boardId} ),0) state
            FROM LOVE_TB
            group by count

	</select>

만약 2번 유저가 2번 게시글 좋아요를 누른다면


버튼의 상태가 바뀌고 전체 좋아요 수도 -1 이 된다

해당 데이터를 ResponseDto<> 에 넣어서 전달하게 되면 ObjectMapper가 json으로 변환하여 전달해준다.

@AllArgsConstructor
@Getter
@Setter
public class ResponseDto<T> {
    private int code;
    private String msg;
    private T data;
}




좋아요 버튼을 누른 결과

전달받은 화면의 전체 스크립트는 아래와 같다.

<script>
    let boardId;
    let userId;
    let count;
    let state;
    let loveId;

    function heartclick(boardId1, state1, userId1, loveId1) {
        boardId = boardId1;
        userId = userId1;
        if (userId1 > 0) {
            let data = {
                boardId: boardId1,
                userId: userId1,
                state: state1,
                id: loveId1
            }
            $.ajax({
                type: "post",
                url: "/love/click",
                data: JSON.stringify(data),
                headers: {
                    "content-type": "application/json; charset=utf-8"
                },
                dataType: "json"
            }).done((res) => {
                count = res.data.count;
                state = res.data.state;
                loveId = res.data.id;
                heart();
            }).fail((err) => {
                alert(err.responseJSON.msg);
            });
        }
    }
    function heart() {
        $('#heart-' + boardId).toggleClass("fa-solid");
        $('#heart-' + boardId).toggleClass("on-Clicked");
        $('#heart-' + boardId + '-count').remove();
        render();
    }

    function render() {
        let el ;
        if ( state === 1 ){
            el = `
            <div id="heart-`+boardId+`-count" class="d-flex">
            <i id="heart- `+boardId+` " class="my-auto my-heart fa-regular fa-solid fa-heart my-xl 
			my-cursor on-Clicked"token template-punctuation string">`+boardId+`,`+state+`,`+userId+`,`+loveId+`)" ></i> 
            &nbsp <div>`+count+`</div></div>
            </div>
            `;
        }
        if ( state === 0 ){
            el = `
            <div id="heart-`+boardId+`-count" class="d-flex">
            <i id="heart- `+boardId+` " class="my-auto fa-regular fa-heart my-xl my-cursor" 
			onclick="heartclick(`+boardId+`,`+state+`,`+userId+`,`+loveId+`)" ></i>
            &nbsp <div>`+count+`</div></div>
            </div>
            `;
        }
        $('#heart-'+boardId+'-div').append(el);
    }
</script>

좋아요를 눌러보자




누른적이 있는 좋아요 ( id=1 ) 은 상태가 0으로 변했고, 누른적이 없는 좋아요 ( id=7 ) 은 새로운 레코드가 생성되었다.

profile
작은것부터
post-custom-banner

0개의 댓글