86일차 내용 정리

채공부·2025년 9월 23일

자주 사용되는 구조

const [params] = useSearchParams();
useEffect(() => {}, [params]);

params.pageNum ➜ 3
qs.toString() ➜ search=writer&keyword=YuJaeSeok
search=writer&keyword=YuJaeSeok&pageNum=3

React 를 하면 좋은 점

					  react js
			↙			⭣			↘
WebBrowser 		   Mobile App			Desktop App
SPA App			   android , ios		window, mac os
				   (react native)		(electron js)

게시글 자세히 보기

  • Board.jsx 에 제목에 링크 추가
<td>
	<NavLink to={`/board/${item.num}`}>{item.title}</NavLink>
</td>
  • pages 폴더 안에 BoardDetail.jsx 생성
function BoardDetail() {
    return <>
        
    </>
}
  • index.jsx 에 BoardDetail 라우트 경로 추가
{path:"/board/:num", element:<BoardDetail/>}
  • useParams 함수 추가
    • 자세히 보여줄 글의 번호 /board/:num 에서 num 경로 변수 얻어내기
const {num} = useParams();
  • useState 함수 추가
    • 글 하나의 정보를 상태값으로 관리
const [dto, setDto] = useState({});
  • useEffect 함수 추가
 useEffect(() => {
	api.get(`/v1/board/${num}`)
	.then(res => {
		setDto(res.data);
	})
	.catch(err => console.log(err));
}, [num]); // 자세히 보여줄 댓글 번호가 변경되면 다시 로딩 되도록
  • BoardController 에 detail 메소드 생성
@GetMapping("/board/{num}")
public BoardDto detail(@PathVariable int num) {
	return boardService.getData(num);
 }
  • Board.jsx 에 getQuery 상수 추가
    • 검색 쿼리가 있다면 해당 문자열을 리턴해주는 함수(없으면 빈 문자열을 리턴)
const getQuery = () => {
	let query = "";
    if(pageInfo.keyword){
        query = `?search=${pageInfo.search}$keyword=${pageInfo.keyword}`;
    }
};
  • 제목 링크 코드 수정
<NavLink to={`/board/${item.num}${getQuery()}`}>{item.title}</NavLink>
  • BoardDetail.jsx 에 useSearchParams 함수 추가
    • 검색어가 있으면 추출하기 위해
const [params] = useSearchParams();
  • BoardDetail.jsx 에 useEffect 함수에 api 코드 수정
    • params.toString() 하면 query 문자열이 리턴된다
      없으면 빈 문자열이 리턴된다
api.get(`/v1/board/${num}?${params.toString()}`)

http://localhost:5173/#/board/120?search=writer&keyword=YuJaeSeok

  • BoardController 에 detail 메소드 수정
@GetMapping("/board/{num}")
public BoardDto detail(@PathVariable int num, BoardDto dto) {
	// dto 의 search 와 keyword 값이 null 일 수도 있고 아닐 수도 있다(검색어가 있다면 null 이 아니다)
	// dto 에 글 번호도 담는다
	dto.setNum(num);
	// BoardDto 를 전달해서 해당글의 자세한 정보를 받아온다
	return boardService.getDetail(dto);
}

  • SecurityConfig 에 화이트 리스트 "/v1/board/**" 추가
.requestMatchers(HttpMethod.GET, "/v1/board", "/v1/board/**").permitAll()

댓글

  • BoardDetail.jsx 에 댓글 관련 useState 함수 추가
    • 댓글 목록도 상태값으로 관리
const [commentList, SetCommentList] = useState([]);

➤ 1번 게시글 요청 경로
GET /baord/1

➤ 1번 게시글에 달린 댓글 목록 요청 경로
GET ?

  • 경로 설정
    /board/1/comments
    /board/2/comments
    /myboard/3/comments
    /yourboard/4/comments

@GetMapping("/{category}/{num}/comments

  • BoardDetail.jsx 에 useEffect 함수 안에 댓글 관련 api 코드 추가
    • 댓글 목록
.get(`/v1/board/${num}/comments`)
.then(res => SetCommentList(res.data))
.catch(err => console.log(err));
  • BoardController 에 commentList 메소드 생성
    • 댓글 목록 요청 처리
@GetMapping("/board/{num}/comments")
public List<CommentDto> commentList(@PathVariable int num){
	return commentService.getComments(num);
}
  • BoardDetail.jsx 에 검색한 결과에 대한 조건부 코드 추가
<h1>게시글 상세보기</h1>
<p className="alert alert-success p-3"></p>
{params.get("search") && 
	<p className="alert alert-info px-3 py-2 rounded-3 shadow-sm">
		<i className="bi bi-search me-2"></i>
		<strong>{params.get("search")}</strong> 조건
		<strong>{params.get("keyword")}</strong> 검색 결과 입니다
	</p>
}
  • useNavigate 함수 추가
const navigate = useNavigate();
  • handleMove 함수 추가
    • 이전글, 다음글 이동
const handleMove = () => {

};
  • 이전글 , 다음글 버튼 추가
<div className="btn-group mb-2">
	<button className="btn btn-outline-secondary btn-sm">    
		<i className="bi bi-arrow-left"></i>
		Prev
	</button>
    <button className="btn btn-outline-secondary btn-sm">    
		<i className="bi bi-arrow-right"></i>
		Next
	</button>
</div>
  • 버튼에 조건부 disabled 속성 추가
<button onClick={movePrev}
	disabled={dto.prevNum === 0}
	className="btn btn-outline-secondary btn-sm">    
	<i className="bi bi-arrow-left"></i>
	Prev
</button>
<button onClick={moveNext}
	disabled={dto.nextNum === 0}
	className="btn btn-outline-secondary btn-sm">    
	<i className="bi bi-arrow-right"></i>
	Next
</button>
  • handelMove 함수 안에 navigate 코드 추가
const handleMove = (targetNum) => {
	navigate(`/board/${targetNum}${params.get("search") ? "?"+params.toString() : ""}`);
};
  • 게시글 이동 버튼에 onClick 이벤트 추가
    • 람다식 사용
<button onClick={()=>handleMove(dto.prevNum)}
	disabled={dto.prevNum === 0}
	className="btn btn-outline-secondary btn-sm">    
	<i className="bi bi-arrow-left"></i>
	Prev
</button>
<button onClick={()=>handleMove(dto.nextNum)}
	disabled={dto.nextNum === 0}
	className="btn btn-outline-secondary btn-sm">    
	<i className="bi bi-arrow-right"></i>
	Next
</button>
  • 게시글을 출력할 테이블 추가
<table className="table table-striped">
            <colgroup>
                <col className="col-2" />
                <col className="col" />
            </colgroup>
            <tr>
                <th>글번호</th>
                <td>{dto.num}</td>
            </tr>
            <tr>
                <th>작성자</th>
                <td></td>
            </tr>
            <tr>
                <th>제목</th>
                <td></td>
            </tr>
            <tr>
                <th>조회수</th>
                <td></td>
            </tr>
            <tr>
                <th>작성일</th>
                <td></td>
            </tr>
        </table>
  • 본문 내용 출력할 코드 추가
<div className="card mt-4">
	<div className="card-header bg-light">
		<strong>본문 내용</strong>
	</div>
	<div className="card-body p-1">
	
  	</div>
</div>
  • 작성자 프로필 이미지 조건부 출력 코드 추가
<td>
	{ !dto.profileImage && <i style={{fontSize:"100px"}} className="bi bi-person-circle"></i>}

	{ dto.profileImage &&
		<img src={`/upload/${dto.profileImage}`}
			style={{width:"100px", height:"100px", borderRadius:"50%"}}/>
	}
</td>
  • 제목, 조회수, 작성일 출력 코드 추가
<tr>
	<th>제목</th>
	<td>{dto.title}</td>
</tr>
<tr>
	<th>조회수</th>
	<td>{dto.viewCount}</td>
</tr>
<tr>
	<th>작성일</th>
	<td>{dto.createdAt}</td>
</tr>
  • 본문 내용 출력 코드 추가
<div className="card mt-4">
	<div className="card-header bg-light">
		<strong>본문 내용</strong>
	</div>
	<div className="card-body p-1" dangerouslySetInnerHTML = {{__html:dto.content}}>
                
    </div>
</div>
  • BoardMapper 에 getByNum & getByDtocreatedAt 코드 수정
b.createdAt
  • spring08 프로젝트에 CommentMapper.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="comment">
	<select id="selectList" parameterType="int" resultType="commentDto">
		<![CDATA[
			SELECT c.num, c.writer, c.targetWriter, c.content, c.deleted, c.groupNum, u.profileImage,
				CASE
					WHEN SYSDATE - c.createdAt < 1/1440 THEN '1분 전'
					WHEN SYSDATE - c.createdAt < 10/1440 THEN '10분 전'
					WHEN SYSDATE - c.createdAt < 365 THEN
						TO_CHAR(TRUNC(SYSDATE - c.createdAt)) || '일 전'
					WHEN SYSDATE - c.createdAt < 730 THEN '1년 전'
					ELSE '2년 이상'
				END AS createdAt,
				(SELECT COUNT(*)
				FROM comments
				WHERE groupNum = c.num AND num != groupNum) AS replyCount				
			FROM comments c
			INNER JOIN users u ON c.writer = u.userName
			WHERE c.parentNum = #{num}
			ORDER BY c.groupNum ASC, c.num ASC
		]]>	
	</select>
	
	<update id="delete" parameterType="int">
		UPDATE comments
		SET deleted = 'yes'
		WHERE num=#{num}
	</update>
	
	<update id="update" parameterType="commentDto">
		UPDATE comments
		SET content = #{content}
		WHERE num=#{num}
	</update>
	
	<insert id="insert" parameterType="commentDto">
		INSERT INTO comments
		(num, writer, targetWriter, content, parentNum, groupNum)
		VALUES(#{num}, #{writer}, #{targetWriter}, #{content}, #{parentNum}, #{groupNum})	
	</insert>
	
	<select id="getSequence" resultType="int">
		SELECT comments_seq.NEXTVAL AS num FROM DUAL
	</select>
	
	<select id="getByNum" parameterType="int" resultType="commentDto">
		SELECT num, writer, targetWriter, content, parentNum, groupNum, createdAt
		FROM comments
		WHERE num=#{num}
	</select>
</mapper>
  • components 폴더 안에 Comment.jsx 생성
function Comment() {
    return <>

    </>
}
  • BoardDetail.jsx 에 Comment 추가
    • 문자열을 직접 만들어서 전달할 때 { }(중괄호) 없이 " "(따옴표)만 해도 된다 ➜ 단순히 문자열만 전달할 때 가능
    • 무언가를 참조할 때는 중괄효 무조건 필요
<Comment category="board" parentNum={dto.num} parentWriter={dto.writer} list={commentList}/>

  • Comment.jsx 에 props 구조 분해 할당 코드 추가
function Comment({category, parentNum, parentWriter, list}) {
    return <>

    </>
}

댓글 입력

<div className="card my-3">
	<div className="card-header bg-primary-subtle text-white">
		댓글을 입력해 주세요
	</div>
	<div className="card-body">
		<form action="/v1/comments" method="post">
			<input type="hidden" name="parentNum" value={parentNum}/>
			<input type="hidden" name="targetWriter" value={parentWriter}/>
			<div className="mb-3">
				<label htmlFor="commentContent" className="form-label">댓글 내용</label>
				<textarea id="commentContent" name="content" rows="5" className="form-control" placeholder="댓글을 입력하세요"></textarea>
			</div>
			<button type="submit" className="btn bg-primary-subtle text-white">등록</button>
		</form>
	</div>
</div>

댓글 목록

  • 클래스를 쉽게 제어하기 위한 유틸리티 import 코드 추가
import cn from 'classnames';
  • map 함수을 이용한 div 코드 추가
<div className="comments">
	{list.map(item => 
		<div key={item.num className={cn("card", "mb-3", {"ms-5 re-re d-none" : item.num !== item.groupNum})}>
                
		</div>
	)}
</div>
  • 삭제된 댓글 표시 조건부 렌더링
{ item.deleted === 'yes' && 
	<div className="card-body bg-light text-muted rounded">삭제된 댓글입니다</div>
}
  • 삭제되지 않은 댓글 표시 조건부 렌더링
{ item.deleted !== 'yes' && 
	<div className="card-body d-flex flex-column flex-sm-row position-relative">
                            
	</div>
}

댓글 표시 안에 코드

  • 답글 버튼 조건부 렌더링
{ item.replyCount !== 0 && item.num === item.groupNum &&
	<button className="dropdown-btn btn btn-outline-secondary btn-sm position-absolute"
		style={{bottom:"16px", right:"16px"}}>
		<i class="bi bi-caret-down"></i>
	답글 {item.replyCount}</button>
}
  • 화살표 표시 조건부 렌더링
{ item.num !== item.groupNum &&
	<i className="bi bi-arrow-return-right position-absolute"
		style={{top:"0", left:"-30px"}}></i>
}
  • useSelector 함수 추가
    • redux store 로 부터 로그인 정보를 얻어낸다
const userInfo = useSelector(state => state.userInfo);
  • 삭제 버튼 조건부 렌더링
{ item.writer === userInfo.userName &&
	<button data-num={item.num}
		className="btn-close position-absolute top-0 end-0 m-1"></button>
}
  • 프로필 이미지 조건부 렌더링
{ item.profileImage === null ?
	<i style={{fontSize:"50px"}}
		className="bi bi-person-circle me-3 align-self-center"></i>			    
:
	<img className="rounded-circle me-3 align-self-center"
		src={`/upload/${item.profileImage}`}
     	alt="프로필 이미지"
     	style={{width:"50px", height:"50px"}}/>
}
  • 댓글 작성자와 댓글 작성 대상, 댓글 작성일 표시
<div className="flex-grow-1">
	<div className="d-flex justify-content-between">
		<div>
			<strong>{item.writer}</strong>
			<span>@{item.targetWriter}</span>
			<small className="text-muted">{item.createdAt}</small>
		</div>
	</div>
</div>
  • pre 요소 추가
<pre>{item.content}</pre>
  • 댓글 수정 폼 조건부 렌더링
{ item.writer === userInfo.userName &&
<>
	<button className="btn btn-sm bg-primary-subtle btn-outline-secondary edit-btn">수정</button>  			         	<div class="d-none form-div">
		<form action="" method="post">
			<input type="hidden" name="num" value={item.num}/>
			<input type="hidden" name="parentNum" value={parentNum}/>
			<textarea name="content" className="form-control mb-2" rows="2">{item.content}</textarea>
			<button type="submit" className="btn btn-sm btn-success">수정 완료</button>
			<button type="reset" className="btn btn-sm btn-secondary cancel-edit-btn">취소</button>
		</form>
	</div>
</>
}
  • 답글 버튼 & 답글 폼 조건부 렌더링
{ item.writer !== userInfo.userName &&
<>
	<button className="btn btn-sm btn-outline-primary show-reply-btn">댓글</button>  
	<div className="d-none form-div">
		<form action="" method="post">
			<input type="hidden" name="parentNum" value={parentNum}/>
			<input type="hidden" name="targetWriter" value={item.writer}/>
			<input type="hidden" name="groupNum" value={item.groupNum}/>
			<textarea name="content" className="form-control mb-2" rows="2" 
				placeholder="댓글을 입력하세요..."></textarea>
			<button type="submit" class="btn btn-sm btn-success">등록</button>
			<button type="reset" class="btn btn-sm btn-secondary cancel-reply-btn">취소</button>
		</form>
	</div>                     
</>
} 
  • userInfo 관련 코드 수정 및 추가
    • constlet
let userInfo = useSelector(state => state.userInfo);

// 만일 로그인하지 않았으면 null 이기 때문에 빈 object 를 넣어준다
if(!userInfo) userInfo={
    
};

Comment.jsx 전체 코드

// src/components/Comment.jsx

//클래스를 쉽게 제어하기 위한 유틸리티
import cn from 'classnames';
import { useSelector } from 'react-redux';

function Comment({category, parentNum, parentWriter, list}) {

    // redux store 로 부터 로그인 정보를 얻어낸다 
    let userInfo = useSelector(state=>state.userInfo);
    // 만일 로그인을 하지 않았으면 null 이기 때문에 빈 object 를 넣어준다
    if(!userInfo)userInfo={};
    
    return <>
    	<div className="card my-3">
		  <div className="card-header bg-primary text-white">
		    댓글을 입력해 주세요
		  </div>
		  <div className="card-body">
		    <form action="/v1/comments" method="post">
		      <input type="hidden" name="parentNum" value={parentNum} />
		      <input type="hidden" name="targetWriter" value={parentWriter} />
		      <div className="mb-3">
		        <label htmlFor="commentContent" className="form-label">댓글 내용</label>
		        <textarea id="commentContent" name="content" rows="5" className="form-control" placeholder="댓글을 입력하세요"></textarea>
		      </div>
		      <button type="submit" className="btn btn-success">등록</button>
		    </form>
		  </div>
		</div>

        <div className="comments">
        {list.map(item=>
            <div key={item.num} className={cn("card","mb-3", {"ms-5 re-re d-none": item.num !== item.groupNum})}>
            { item.deleted === 'yes' && 
                <div className="card-body bg-light text-muted rounded">삭제된 댓글 입니다</div>
            }
            { item.deleted !== 'yes' &&
                <div className="card-body d-flex flex-column flex-sm-row position-relative">
                { item.replyCount !== 0 && item.num === item.groupNum && 
                    <button className="dropdown-btn btn btn-outline-secondary btn-sm position-absolute"
                        style={{bottom:"16px", right:"16px"}}>
                        <i className="bi bi-caret-down"></i>
                        답글 {item.replyCount}</button> 
                }
                { item.num !== item.groupNum &&
                    <i className="bi bi-arrow-return-right position-absolute" 
                        style={{top:0, left:"-30px"}}></i>       
                }
                { item.writer === userInfo.userName && 
                    <button data-num={item.num} 
		            	className="btn-close position-absolute top-0 end-0 m-1"></button>
                }
                { item.profileImage === null ?
                    <i style={{fontSize:"50px"}} className="bi bi-person-circle me-3 align-self-center"></i>
                :
                    <img className="rounded-circle me-3 align-self-center" 
                        src={`/upload/${item.profileImage}`}
                        alt="프로필 이미지"
                        style={{width:"50px", height:"50px"}} />
                }
                    <div className="flex-grow-1">
                        <div className="d-flex justify-content-between">
                            <div>
                                <strong>{item.writer}</strong>
                                <span>@{item.targetWriter}</span>
                                <small className="text-muted">{item.createdAt}</small>
                            </div>
                        </div>
                        <pre>{item.content}</pre>
                        { item.writer === userInfo.userName &&  
                        <>
                            <button className="btn btn-sm btn-outline-secondary edit-btn">수정</button>
                            <div className="d-none form-div">
                                <form action="" method="post">
                                    <input type="hidden" name="num" value={item.num}/>
                                    <input type="hidden" name="parentNum" value={parentNum}/>
                                    <textarea name="content" className="form-control mb-2" rows="2" >{item.content}</textarea>
                                    <button type="submit" className="btn btn-sm btn-success">수정 완료</button>
                                    <button type="reset" className="btn btn-sm btn-secondary cancel-edit-btn">취소</button>
                                </form>
                            </div>
                        </>
                        } 
                        { item.writer !== userInfo.userName &&
                        <>
                            <button className="btn btn-sm btn-outline-primary show-reply-btn">댓글</button>  
                            <div className="d-none form-div">
                                <form action="" method="post">
                                    <input type="hidden" name="parentNum" value={parentNum}/>
                                    <input type="hidden" name="targetWriter" value={item.writer}/>
                                    <input type="hidden" name="groupNum" value={item.groupNum}/>
                                    <textarea name="content" className="form-control mb-2" rows="2" 
                                        placeholder="댓글을 입력하세요..."></textarea>
                                    <button type="submit" className="btn btn-sm btn-success">등록</button>
                                    <button type="reset" className="btn btn-sm btn-secondary cancel-reply-btn">취소</button>
                                </form>
                            </div>                     
                        </>
                        } 
                    </div>
                </div>
            }
            </div>
        )}
        </div>
    </>
}

export default Comment;
profile
학원 공부 내용 정리

0개의 댓글