[ 구현 목표 ]
게시글 상세보기는 글을 보여주는 기능처럼
보이지만 실제 구현에서는 다음과 같은
안정성 이슈를 함께 해결해야 한다.
- 사용자가 새로고침/뒤로가기/재접속을 반복하면
조회수가 무한히 증가하는 문제 - URL에 boardId를 임의로 넣어 접근하는
악성 유입(없는 게시글 접근, 파라미터 변조 등) - 게시글 + 댓글을 한 번에 로딩하여 화면에서
바로 출력할 수 있도록 데이터를 묶어서 전달하는 문제
이번 포스팅에서는 Servlet + JSP + MVC(Action) 구조에서
- 조회수 중복 방지(쿠키) 와
- URL 조작 방어(파라미터/존재여부 검증) 를
포함한 게시글 상세보기 구현을 정리한다.
따라서 구현 과정에서의 핵심은 다음 4가지이다.
- boardId 파라미터를 안전하게 검증한다.
- 게시글이 실제로 존재하는지 확인하여 URL 조작을 방어한다.
- 조회수는 쿠키로 “정책(예: 1일 1회)”을 적용하여 중복 증가를 막는다.
- 게시글 + 댓글 리스트를 조회해 boarddetail.jsp로 forward한다.
[ 처리 흐름 ]
1) 전체 플로우
요청: /boardDetail.do?boardId=123
1) boardId 파라미터 검증 (null/빈값/숫자 아님/0 이하 차단)
2) 조회수 쿠키 검사 (board_view_123)
3) 쿠키가 없으면 조회수 증가(update) 시도
- 성공: 쿠키 생성(1일)
- 실패: 쿠키 생성 X, 강제 종료 X
(조회수만 실패로 처리하고 계속 진행)
4) 게시글 selectOne
(화면 출력용 데이터 조회 + 존재여부 최종 확인)
5) 댓글 selectAll
6) boarddetail.jsp로 forward
update 실패 시에도 selectOne까지 진행하는 이유
조회수 증가(update)는 상세보기의 “부가 기능”에 가깝다.
update가 실패했을 때 곧바로 종료해버리면,
DB 일시 오류/락 같은 이유로 조회수만 실패했는데도
사용자는 글을 못 보는 상황이 될 수 있다.
그래서 다음처럼 처리 흐름을 잡았다.
1) update 실패 시에도 글은 보여준다(=selectOne 진행).
2) 대신 update가 실패했는데 쿠키를 만들면 정책이 꼬이므로,
쿠키는 update 성공 때만 만든다.(조회수가 안 올라가면 “봤음 처리 X”)
[ 핵심 구현 포인트 ]
1) 파라미터 검증이 필요한 이유
request.getParameter("boardId")는
항상 문자열로 들어온다.
따라서 다음 입력을 반드시 방어해야 한다.
- null, "" : 파라미터 자체가 없는 요청
- "abc" : 숫자가 아닌 값
- 0, -1 : 정상 PK가 될 수 없는 값
이 검증이 없으면 NumberFormatException이 발생하거나,
비정상 요청이 그대로 DB로 전달될 수 있다.
2) 조회수 중복 방지 쿠키 설계
조회수 중복 증가를 막기 위해 게시글별 쿠키를 만든다.
- 쿠키 이름: board_view_{boardId}
- 쿠키 값: "Y" (단순 플래그)
- 유효기간: 예) 1일(24시간)
- 정책: 쿠키가 없을 때만 조회수 +1
즉 “해당 브라우저에서 해당 게시글을 이미 봤는지”를
쿠키로 판단하는 방식이다.
3) 쿠키 생성 조건
쿠키는 “봤음 처리”이므로
update 성공 시에만 생성하는 것이 자연스럽다.
update 실패 시 쿠키까지 생성하면 조회수는 안 올랐는데
다음 접근부터는 중복 차단이 걸려 서비스가 꼬이게 된다.
4) 존재 여부 최종 확인은 selectOne에서 한다
쿠키가 있으면 update를 건너뛰기 때문에,
글이 삭제된 경우를 잡기 위해서라도
selectOne은 최종 방어선이 된다.
5) forward를 사용하는 이유
상세 페이지는 서버에서 조회한 데이터를
request에 담아 JSP로 전달해야 한다.
- forward
현재 요청을 유지하여
request.setAttribute() 값이 JSP까지 전달된다.
- redirect
새 요청이 발생하여 request 값이 사라진다.
따라서 상세보기는 forward가 자연스럽다.
[ 최종 코드 ]
public class BoardDetailAction implements Action {
@Override
public ActionForward execute(HttpServletRequest request, HttpServletResponse response) {
ActionForward forward = new ActionForward();
BoardDAO boardDAO = new BoardDAO();
BoardDTO boardDTO = new BoardDTO();
ReplyDAO replyDAO = new ReplyDAO();
ReplyDTO replyDTO = new ReplyDTO();
/*
1. boardId 파라미터 검증
2. 조회수 쿠키 검사
3. (쿠키 없으면) 조회수 증가 시도
- 성공 시 : 쿠키 생성
- 실패 시 : 쿠키 생성 X (조회수만 실패로 처리하고 계속 진행)
4. 게시글 selectOne (존재여부 최종 확인)
5. 댓글 selectAll
6. boarddetail.jsp 이동
*/
// 1) boardId 파라미터 유효성 검사
String boardIdCheck = request.getParameter("boardId");
if (boardIdCheck == null || boardIdCheck.isEmpty()) {
System.out.println("[게시글 상세보기 로그] boardIdCheck 실패 : boardId 유효하지 않음");
request.setAttribute("msg", "잘못된 게시글 접근입니다...");
request.setAttribute("location", "mainPage.do");
forward.setPath("message.jsp");
forward.setRedirect(false);
return forward;
}
int boardId;
try {
boardId = Integer.parseInt(boardIdCheck);
} catch (NumberFormatException e) {
System.out.println("[게시글 상세보기 로그] boardId 변환 실패 : boardId 정수 변환 오류");
request.setAttribute("msg", "잘못된 게시글 접근입니다...");
request.setAttribute("location", "mainPage.do");
forward.setPath("message.jsp");
forward.setRedirect(false);
return forward;
}
if (boardId <= 0) {
System.out.println("[게시글 상세보기 로그] boardId 유효값 아님 : boardId 0이하 오류");
request.setAttribute("msg", "잘못된 게시글 접근입니다...");
request.setAttribute("location", "mainPage.do");
forward.setPath("message.jsp");
forward.setRedirect(false);
return forward;
}
// 2) 조회수 쿠키 검사
boolean isViewed = false;
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie c : cookies) {
if (("board_view_" + boardId).equals(c.getName())) {
isViewed = true;
break;
}
}
}
// 3) 쿠키 없으면 조회수 증가 시도
// - 성공 시에만 쿠키 생성
// - 실패해도 글은 보여주고(최종 존재여부는 selectOne에서 판단), 조회수만 실패로 처리
if (!isViewed) {
boardDTO.setBoardId(boardId);
boardDTO.setCondition("UPDATE_BOARD_VIEWS");
boolean isUpdated = boardDAO.update(boardDTO);
if (isUpdated) {
System.out.println("[게시글 상세보기 로그] 조회수 증가 성공 : boardId=" + boardId);
// update 성공했을 때만 쿠키 생성(정책 꼬임 방지)
Cookie viewCookie = new Cookie("board_view_" + boardId, "Y");
viewCookie.setMaxAge(60 * 60 * 24); // 유효기간 1일
viewCookie.setPath("/");
viewCookie.setHttpOnly(true);
response.addCookie(viewCookie);
} else {
// 조회수 증가 실패 시: 쿠키 생성 X, 강제종료 X
// (DB 오류/락/예외 등으로 조회수만 실패할 수 있으므로 글은 보여주되 로그만 남김)
System.out.println("[게시글 상세보기 로그] 조회수 증가 실패 : boardId=" + boardId);
}
}
// 4) 게시글 selectOne (존재여부 최종 확인)
boardDTO = new BoardDTO();
boardDTO.setBoardId(boardId);
boardDTO.setCondition("BOARD_DETAIL");
BoardDTO boardData = boardDAO.selectOne(boardDTO);
// 존재하지 않는 게시글이면 차단
if (boardData == null) {
System.out.println("[게시글 상세보기 로그] boardData 조회 실패 : 해당 게시글은 없는 게시글");
request.setAttribute("msg", "존재하지 않는 게시글입니다...");
request.setAttribute("location", "mainPage.do");
forward.setPath("message.jsp");
forward.setRedirect(false);
return forward;
}
// 5) 댓글 selectAll
replyDTO.setBoardId(boardId);
replyDTO.setCondition("BOARD_REPLYLIST");
List<ReplyDTO> replyList = replyDAO.selectAll(replyDTO);
// 6) 게시글/댓글 담아서 페이지 이동
request.setAttribute("boardData", boardData);
request.setAttribute("replyList", replyList);
forward.setPath("boarddetail.jsp");
forward.setRedirect(false);
return forward;
}
}
▼ 체크 포인트
1) boardId
null/빈값/숫자 아님/0 이하를 먼저 차단하여
예외와 비정상 접근을 막는 구조
2) 조회수
board_view_{boardId} 쿠키로
중복 증가를 제한하는 구조이며,
쿠키가 없을 때만 update 진행
3) 쿠키 생성
쿠키는 update 성공 시에만 생성하여
정책 꼬임을 방지하는 구조
4) HttpOnly
생성 시 setHttpOnly(true);를 사용하여
쿠키 보안을 강화하는 구조
5) 게시글 존재 여부
selectOne에서 게시글 존재 유무(null체크)를
확인하여 URL 조작/삭제 글 접근을 방어하는 구조
6) 게시글/댓글 전달
request에 담아 forward로 JSP에 전달하여
View는 출력만 담당하게 한 구조
'개주 훈련일지 > 🏋️ 전집중 호흡 훈련' 카테고리의 다른 글
| Generative AI 시대에서 Agentic Workflow 시대로 (0) | 2025.12.20 |
|---|---|
| 게시글 댓글 정렬(최신순/오래된순) 비동기 서블릿 구현 (0) | 2025.12.19 |
| 로그인 로직 설계(관리자 권한 분기와 탈퇴 계정 차단) (0) | 2025.12.17 |
| Hexagonal Architecture 리뷰를 보고 느낀 점 (1) | 2025.12.16 |
| CKEditor5 기본 문법 정리 (0) | 2025.12.14 |