본문 바로가기

Java/Spring

[Spring] 간단한 SNS 만들기 #8

 What to do?

좋아요 기능개발하기

기능구현한 모습

포스팅에 좋아요 누르면 포스팅은 다시 조회했을 때 좋아요가 표시됨
ⓐ 좋아요 누른 후, 좋아요 누름 → 좋아요 취소      ⓑ 좋아요 누른 후, 싫어요 누름  → 좋아요 취소 & 싫어요  

감정표현 유형

 

  • EmotionType
    • 없음 / 좋아요 / 싫어요
    • getOpposite : 반대되는 감정표현을 return
@Getter
@AllArgsConstructor
public enum EmotionType {
    NONE("없음", 0L),
    LIKE("좋아요", 1L),
    HATE("싫어요", 2L);
    private final String description;
    private final Long seq;

    public EmotionType getOpposite(){
        return switch (this){
            case NONE -> NONE;
            case LIKE -> HATE;
            case HATE -> LIKE;
        };
    }
}

 

  • EmotionActionType
    • NEW 
      • NONE → LIKE
      • NONE  → HATE
    • SWITCH
      • HATE → LIKE
      • LIKE → HATE
    • CANCEL
      • LIKE → NONE
      • HATE → NONE
@Getter
@AllArgsConstructor
public enum EmotionActionType {
    NEW("새로운 좋아요/싫어요 요청"),
    SWITCH("좋아요→싫어요 or 싫어요→좋아요"),
    CANCEL("좋아요/싫어요 취소");
    private final String description;
}

Entity, Dto 설계

 

  • EmotionEntity.java
    • 누가(→user) 어떤 포스팅에(→post) 어떤 감정표현을(→emotionType)  했는지 표현할 수 있도록 만듬
@Setter
@Getter
@Entity
@Table(name = "emotion")
@SQLDelete(sql = "UPDATE emotion SET removed_at = NOW() WHERE id=?")
@Where(clause = "removed_at is NULL")
public class EmotionEntity extends AuditingFields {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id")
    private UserEntity user;
    @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id")
    private PostEntity post;
    @Enumerated(EnumType.STRING)
    private EmotionType emotionType;

    private EmotionEntity(UserEntity user, PostEntity post, EmotionType emotionType) {
        this.user = user;
        this.post = post;
        this.emotionType = emotionType;
    }

    protected EmotionEntity(){}

    public static EmotionEntity of(UserEntity user, PostEntity post, EmotionType emotionType) {
        return new EmotionEntity(user, post, emotionType);
    }

    public static EmtionDto dto(EmotionEntity entity){
        return EmtionDto.of(
                entity.getUser().getUsername(),
                entity.getPost().getId(),
                entity.getEmotionType()
        );
    }
}

 

  • EmotionDto.java
@Getter
@Setter
public class EmtionDto {
    private String username;
    private Long postId;
    private EmotionType emotionType;

    private EmtionDto(String username, Long postId, EmotionType emotionType) {
        this.username = username;
        this.postId = postId;
        this.emotionType = emotionType;
    }

    protected EmtionDto(){}

    public static EmtionDto of(String username, Long postId, EmotionType emotionType) {
        return new EmtionDto(username, postId, emotionType);
    }
}

Repository

 

  • findByUserAndPost
    • 어떤 포스팅을 조회했을 때, 내가 예전에 포스팅에 좋아요를 눌렀는지 알아야 함
    • User와 Post를 받아서 EmotionEntity를 조회해야 함
  • countByPostAndEmotionType
    • 어떤 포스팅에 좋아요, 싫어요 개수를 알아야 함
    • Post와 EmotionType을 조회해서 count를 해야 함
@Repository
public interface EmotionRepository extends JpaRepository<EmotionEntity, Long> {
    Optional<EmotionEntity> findByUserAndPost(UserEntity user, PostEntity post);
    Optional<EmotionEntity> findByUserAndPostAndEmotionType(UserEntity user, PostEntity post, EmotionType emotionType);
    // 좋아요/싫어요 개수 가져오기
    Long countByPostAndEmotionType(PostEntity post, EmotionType emotionType);
}

PostService

 

  • getEmotionInfo
    • 좋아요 개수
    • 싫어요 개수
    • 로그인한 유저가 좋아요를 눌렀는지 여부
      • 좋아요 누름 → LIKE
      • 싫어요 누름  → HATE
      • 아무것도 안 누름  → NONE
/**
 * 좋아요 & 싫어요 개수, 포스팅 좋아요 & 싫어요 여부
 * @param postId 좋아요 & 싫어요 포스트 id
 */
@Transactional(readOnly = true)
public Map<String, Object> getEmotionInfo(UserEntity user, Long postId){
    PostEntity post = findByPostIdOrElseThrow(postId);
    EmotionEntity like = emotionRepository.findByUserAndPost(user, post).orElse(EmotionEntity.of(user, post, EmotionType.NONE));
    return Map.of(
            "LIKE", emotionRepository.countByPostAndEmotionType(post, EmotionType.LIKE),
            "HATE", emotionRepository.countByPostAndEmotionType(post, EmotionType.HATE),
            "EMOTION", like.getEmotionType()
    );
}

 

  • handleEmotion
    • emotionType == NEW
      • NONE → LIKE  or  NONE → HATE
      • 기존에 감정표현이 있으면 Error
      • 새로운 감정표현 저장
    • emotionType == SWITCH
      • LIKE → HATE  or HATE → LIKE
      • 기존에 반대되는 emotionType을 가진 emotionEntity를 찾음
      • emotionType수정해서 저장
    • emotionType == NONE
      • LIKE → NONE  or HATE → NONE
      • 기존에 있는 emotionEntity 삭제
/**
 * 좋아요 & 싫어요 요청
 * @param postId 좋아요 & 싫어요 포스트 id
 * @param emotionType LIKE(좋아요), HATE(싫어요)
 * @param user like 누른사람
 */
@Transactional
public void handleEmotion(UserEntity user, Long postId, EmotionType emotionType, EmotionActionType emotionActionType){
    PostEntity post = findByPostIdOrElseThrow(postId);
    switch (emotionActionType){
        case NEW -> {
            emotionRepository.findByUserAndPost(user, post).ifPresent(it->{
                throw CustomException.of(CustomErrorCode.ALREADY_LIKED);
            });
            emotionRepository.save(EmotionEntity.of(user, post, emotionType));
        }
        case SWITCH -> {
            emotionRepository
                .findByUserAndPostAndEmotionType(user, post, emotionType.getOpposite())
                .ifPresent(it ->{
                    it.setEmotionType(emotionType);
                    emotionRepository.save(it);
                });
        }
        case CANCEL -> {
            emotionRepository.findByUserAndPost(user, post).ifPresent(it->{
                emotionRepository.deleteById(it.getId());
            });
        }
    }
}

Controller

 

  • EmotionRequest.java
    • Client가 Server로 좋아요/싫어요 요청할 때 Client가 보내는 데이터 형태
    • emotionActionType : NEW, SWITCH, CANCEL
    • emotionType : LIKE, HATE
@Getter
@AllArgsConstructor
public class EmotionRequest {
    private EmotionActionType emotionActionType;
    private EmotionType emotionType;
}

 

  • EmotionResponse.java
    • Client가 Server로부터 좋아요/싫어요 개수, 유저가 좋아요/싫어요 눌렀는지 여부에 대해서 요청하면, Server가 Client에게 응답하는 데이터 형태
    • likeCount : 좋아요 개수
    • hateCount : 싫어요 개수
    • emotionType : LIKE, HATE, NONE 중 하나
@Getter
public class EmotionResponse {
    private Long likeCount;
    private Long hateCount;
    private String emotionType;

    private EmotionResponse(Long likeCount, Long hateCount, String emotionType) {
        this.likeCount = likeCount;
        this.hateCount = hateCount;
        this.emotionType = emotionType;
    }
    protected EmotionResponse(){}

    public static EmotionResponse of(Long likeCount, Long hateCount, String emotionType){
        return new EmotionResponse(likeCount, hateCount, emotionType);
    }

    public static EmotionResponse from(Map<String, Object> map){
        return EmotionResponse.of((Long) map.get("LIKE"), (Long) map.get("HATE"), map.get("EMOTION").toString());
    }
}

 

  • PostController.java
@RequestMapping("/api/v1")
public class PostController {
    private final PostService postService;
   
    ...

    // 좋아요 & 싫어요 개수 가져오기 & 유저가 해당 포스팅을 좋아하는지 여부
    @GetMapping("/emotion")
    public CustomResponse<EmotionResponse> getEmotionInfo(@RequestParam("pid") Long postId, Authentication authentication){
        UserEntity user = (UserEntity) authentication.getPrincipal();
        return CustomResponse.success(EmotionResponse.from(postService.getEmotionInfo(user, postId)));
    }

    // 좋아요 & 싫어요 요청
    @PostMapping("/emotion")
    public CustomResponse<Void> handleEmotion(
            @RequestParam("pid") Long postId,
            @RequestBody EmotionRequest req,
            Authentication authentication
    ){
        UserEntity user = (UserEntity) authentication.getPrincipal();
        postService.handleEmotion(user, postId, req.getEmotionType(), req.getEmotionActionType());
        return CustomResponse.success();
    }
}

Front-End(React)

 

  • DetailPost.js
import { styled } from '@mui/material/styles';
import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader';
import CardContent from '@mui/material/CardContent';
import CardActions from '@mui/material/CardActions';
import Collapse from '@mui/material/Collapse';
import Avatar from '@mui/material/Avatar';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import { red } from '@mui/material/colors';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import SendIcon from '@mui/icons-material/Send';
import ThumbUpIcon from '@mui/icons-material/ThumbUp';
import ThumbDownIcon from '@mui/icons-material/ThumbDown';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Container } from '@mui/system';
import axios from 'axios';
import { userState } from '../../recoil/user';
import { useRecoilState } from 'recoil';
import { Box, Button, DialogContent, Grid, Pagination, TextField, Tooltip } from '@mui/material';
import FullScreenDialog from './FullScreenDialog';
import ListItemText from '@mui/material/ListItemText';
import ListItem from '@mui/material/ListItem';
import List from '@mui/material/List';
import Divider from '@mui/material/Divider';

const ExpandMore = styled((props) => {
  const { expand, ...other } = props;
  return <IconButton {...other} />;
})(({ theme, expand }) => ({
  transform: !expand ? 'rotate(0deg)' : 'rotate(180deg)',
  marginLeft: 'auto',
  transition: theme.transitions.create('transform', {
    duration: theme.transitions.duration.shortest,
  }),
}));

const DetailPost = ({postId}) => {

    /**
     * States
     * 포스팅 - title, content, author, createAt, hashtags
     * 좋아요, 싫어요 - likeCount, hateCount, emotionType(LIKE, HATE, NONE)
     * 댓글 - comments, userComment
     * 페이지 - totalPage, expand
     */         
    const [user, setUser] = useRecoilState(userState);
    const [title, setTitle] = useState("");
    const [content, setContent] = useState("");
    const [author, setAuthor] = useState("");
    const [createdAt, setCreatedAt] = useState("");
    const [hashtags, setHashtags] = useState([""]);
    const [likeCount, setLikeCount] = useState(0);
    const [hateCount, setHateCount] = useState(0);
    const [emotionType, setEmotionType] = useState(null);
    const [emotionActionType, setEmotionActionType] = useState(null);
    const [comments, setComments] = useState([]);
    const [userComment, setUserComment] = useState("");
    const [currentPage, setCurrentPage] = useState(0);
    const [totalPage, setTotalPage] = useState(1);
    const [expanded, setExpanded] = useState(false);
    const [isLoading, setIsLoading] = useState(false);
    
    // ------- hooks  ------- //
    
    // 포스팅 정보 가져오기
    useEffect(()=>{                                         
        const endPoint = `/api/v1/post/detail?pid=${postId}`;
        axios
            .get(endPoint, {
                headers:{
                    Authorization:user.token??localStorage.getItem("token")
                }
            }).then((res)=>{
                return res.data.result
            }).then((res)=>{
                setTitle(res.title);
                setContent(res.content);
                setAuthor(res.nickname);
                setCreatedAt(res.createdAt);
                setHashtags(res.hashtags);
                handleLikeCount();
            }).catch((err)=>{
                console.log("useEffect 포스팅 정보 가져오기",err);
            })
    }, [])

    // 댓글창 
    useEffect(()=>{                                         
        const endPoint = `/api/v1/comment?pid=${postId}&page=${currentPage}`
        // 댓글창이 열린경우
        if (expanded){
            axios
                .get(endPoint, {
                    headers:{
                        Authorization:user.token??localStorage.getItem("token")
                    }
                }).then((res)=>{
                    return res.data.result;                
                }).then((res)=>{
                    setComments([...res.content]);              // 댓글
                    setCurrentPage(res.pageable.pageNumber);    // 페이지
                    setTotalPage(res.totalPages);               // 전체페이지
                }).catch((err)=>{
                    console.log("useEffect 댓글 정보 가져오기",err);
                });
        }
    }, [expanded, isLoading, currentPage])


    // ------- handeler  -------

    // 댓글창 열기 / 닫기
    const handleExpandClick = () => {
        setExpanded(!expanded);
    };

    // 댓글 최대 500자
    const handleUserComment = (e) => {
        setUserComment(e.target.value.slice(0, 500));
    }

    // 댓글 입력
    const handleSumbitComment = async (e) => {
        const endPoint = `/api/v1/comment`
        setIsLoading(true);
        await axios.post(endPoint, {postId, content:userComment}, {
                headers:{
                    Authorization:user.token??localStorage.getItem("token")
                }
            }).then((res)=>{
                setUserComment("");
            }).catch((err)=>{
                console.log("handleSumbitComment",err);
            }).finally(()=>{
                setIsLoading(false);
            })
    }

    const handleLikeCount = async () => {
        const endPoint = `/api/v1/emotion?pid=${postId}`;
        await axios.get(endPoint, {
            headers:{
                Authorization:user.token??localStorage.getItem("token")
            }
        }).then((res)=>{
            return res.data.result;
        }).then((res)=>{
            setLikeCount(res.likeCount);
            setHateCount(res.hateCount);
            setEmotionType(res.emotionType);
        }).catch((err)=>{
            console.log("감정표현 정보가져오기",err);
        })
    }
 
    const handleLike = (type) => async (e) => {
        let data = {};
        const endPoint = `api/v1/emotion?pid=${postId}`;
        setIsLoading(true);
        if (emotionType === "NONE"){
            data = {emotionType:type, emotionActionType:"NEW"};
            setEmotionType(type);
        } else if (type === emotionType){
            data = {emotionType:type, emotionActionType:"CANCEL"};
            setEmotionType("NONE");
        } else {
            data = {emotionType:type, emotionActionType:"SWITCH"};
            setEmotionType(type==="LIKE"?"HATE":"LIKE");
        }
        await axios.post(endPoint, data, {
            headers:{
                Authorization:user.token??localStorage.getItem("token")
            }
        }).then((res)=>{
            handleLikeCount();
        }).catch((err)=>{
            console.log("handleLike",err);
        }).finally(()=>{
            setIsLoading(false);
        })
    }

    const handleCommentPage = (e) => {
        setCurrentPage((parseInt(e.target.outerText)??1)-1);
    }

    return (
        <Card>
            {/* 작성자 & 작성시간 */}
            <CardHeader
                avatar={
                    <Avatar sx={{ bgcolor: red[500] }} aria-label="recipe">
                        R
                    </Avatar>
                }
                title={author}
                subheader={createdAt}
            />

            {/* TODO : 이미지 삽입기능 */}
            {/* <CardMedia
                component="img"
                height="194"
                image="/static/images/cards/paella.jpg"
                alt="Paella dish"
            /> */}

            {/* 본문 */}
            <CardContent>
                <TextField value={content} sx={{width:"100%"}} multiline variant='filled'/>
                <Divider />
            </CardContent>

            {/* 해쉬태그 */}           
            <Box sx={{display:'flex', width:'100%', padding:'1vh'}} color="primary">
                {hashtags.map((h, i)=>{
                    return(
                        <Typography variant="span" component="span" color="primary" key={i} sx={{marginLeft:"2vh"}}>
                            #{h}
                        </Typography>
                    )
                })}           
                <Divider />   
            </Box>
          
            <CardActions disableSpacing>    
                {/* 좋아요 아이콘 */}
                <IconButton onClick={handleLike("LIKE")}>
                    <ThumbUpIcon sx={{color:(emotionType==="LIKE")?"red":"gray"}}/> {likeCount}
                </IconButton>
                {/* 싫어요 아이콘 */}
                <IconButton onClick={handleLike("HATE")}>
                    <ThumbDownIcon sx={{color:(emotionType==="HATE")?"blue":"gray"}}/> {hateCount}
                </IconButton>

                <ExpandMore
                    expand={expanded}
                    onClick={handleExpandClick}
                    aria-expanded={expanded}>
                    <ExpandMoreIcon />
                </ExpandMore>
            </CardActions>
            
            <Collapse in={expanded} timeout="auto" unmountOnExit>
                <Box sx={{ flexGrow: 1, padding:'1vh' }}>
                    {/* 댓글 입력창 */}
                    <Grid container spacing={2}>
                        <Grid item xs={11}>     
                            <Tooltip title="500자 내외로 댓글을 작성해주세요">
                                <TextField label={userComment?`${userComment.length}/500`:"댓글"} sx={{width:'100%'}}
                                variant="standard" multiline onChange={handleUserComment} value={userComment}/>                          
                            </Tooltip>                 
                        </Grid>
                            {/* 댓글 전송 */}
                        <Grid item xs={1}>
                            <Tooltip title="댓글작성">
                                <IconButton onClick={handleSumbitComment} disabled={isLoading} sx={{color:isLoading?"gray":"blue"}}>
                                    <SendIcon/>
                                </IconButton>
                            </Tooltip>
                        </Grid>
                    </Grid>
                </Box>
                <CardContent>                              
                    {/* 댓글 */}
                        {
                            comments.map((c, i)=>{
                                return (
                                    <Grid container key={i}>
                                        <Typography variant="strong" component="div">
                                            <strong>{c.content}</strong>
                                        </Typography>
                                        <Grid container>
                                            <Grid item xs={4}>
                                                <Typography sx={{ mb: 1.5 }} color="text.secondary">
                                                    by {c.nickname}
                                                </Typography>
                                            </Grid>
                                            <Grid item xs={4}>
                                            </Grid>
                                            <Grid item xs={4}>
                                                <Typography sx={{ mb: 1.5 }} color="text.secondary">
                                                    {c.createdAt}
                                                </Typography>
                                            </Grid>
                                        </Grid>                                          
                                    </Grid>
                                )
                            })
                        } 

                {/* 댓글페이지 */}
                <Box sx={{justifyContent:"center", display:"flex", marginTop:"5vh"}}>
                    <Pagination count={totalPage} defaultPage={currentPage+1} boundaryCount={5} onChange={handleCommentPage}
                        color="primary" size="large" sx={{margin: '2vh'}}/>
                </Box>
                </CardContent>

                
            </Collapse>
        </Card>
  );
}


export default DetailPost;

 

'Java > Spring' 카테고리의 다른 글

[Spring] 간단한 SNS 만들기 #10  (0) 2023.01.24
[Spring] 간단한 SNS 만들기 #9  (0) 2023.01.22
[Spring] 간단한 SNS 만들기 #7  (0) 2023.01.21
[Spring] 간단한 SNS 만들기 #6  (0) 2023.01.15
[Spring] 간단한 SNS 만들기 #5  (0) 2023.01.15