티스토리 뷰

관리자가 등록한 게시글 리스트를 확인하는 화면으로 

로그인하지 않은 사용자, 로그인 사용자 모두 접속이 가능한 화면으로 구성 

 

해당 화면에서는 아래와 같은 기능이 포함되어 있음

- 게시글 리스트 조회

- 게시글 목록 선택 시 상세 내용 조회

- 첨부파일이 포함된 게시글인 경우 첨부파일 다운로드 가능 

* 파일 다운로드의 경우 "[프로젝트] 10. Util 클래스 생성 - 세션 / 파일 업로드 경로 (Week 3)" 블로그에서 작성한 서비스 클래스를 호출하여 사용함

 

[ dataTables api 사용 ] - header.html 파일 내용 수정 

 

표 형태의 데이터 조회를 위해 선택한 api로 

클라이언트 (브라우저) 내에서 페이징 기능을 제공하며 디폴트 스타일로 표 형태를 깔끔하게 그려줌 

 

아래 파일을 src\main\resources\static\css 폴더 아래에 복사하여 넣기

dataTables.css
0.00MB

경로 )
src\main\resources\static\common\header.html
<!-- dataTables -->
<link href="static/css/dataTables.css" rel="stylesheet">

<!-- 중간 생략 -->

<!-- datatables -->
<script type="text/javascript" src="//cdn.datatables.net/1.10.21/js/jquery.dataTables.min.js"></script>

 

 

[ BoardController.java ] - 게시글 조회 화면 컨트롤러 

경로 )
src\main\java\com\info\fastboard\board\controller\BoardController.java
package com.info.fastboard.board.controller;

import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import com.info.fastboard.board.persistence.BoardMapper;
import com.info.fastboard.main.util.StorageService;

@Controller
public class BoardController {
	
	@Autowired
	private StorageService storageService;
	
	@Autowired
	private BoardMapper boardMapper;
	
	@GetMapping("/board")
	public String main() {
		return "board";
	}
	
	/**
     * 게시글 리스트 조회
     * @return
     */
	@ResponseBody
	@PostMapping("/board/findBoardList")
	public List<String[]> findBoardList(@RequestBody Map<String, Object> map) {
		
		List<Map<String, Object>> list = boardMapper.findBoardList(map);
		
		List<String[]> newList = new ArrayList<String[]>();
		int idx = 1;
		for(Map<String, Object> inMap : list) {
			String[] output = new String[inMap.size()];
			output[0] = (idx++) + "";
			output[1] = (String) inMap.get("CODE_NAME");
			output[2] = (String) inMap.get("BOARD_TITLE");
			output[3] = (String) inMap.get("USER_NM");
			output[4] = (String) inMap.get("INPUT_DATETIME");
			output[5] = (String) (inMap.get("BOARD_IDX") + "");
			
			newList.add(output);
		}
		
		return newList;
	}
	
	/**
     * 게시글 상세 조회
     * @return
     */
	@ResponseBody
	@PostMapping("/board/findBoardDetail")
	public List<Map<String, Object>> findBoardDetail(@RequestBody Map<String, Object> map) {
		
		List<Map<String, Object>> list = boardMapper.findBoardDetail(map);
		
		return list;
	}
	
	/**
     * 첨부 파일 다운로드
     * @return
	 * @throws UnsupportedEncodingException 
     */
    @GetMapping("/board/download")
    public ResponseEntity<Resource> serveFile(@RequestParam(value="fileName") String fileName, @RequestParam(value="orgFileName") String orgFileName) throws UnsupportedEncodingException {
        Resource file = storageService.loadAsResource(fileName);
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,
                "attachment; filename=\"" + URLEncoder.encode(orgFileName, "UTF-8") + "\"").body(file);
    }
	
}

 

[ BoardMapper.java ] 

경로 )
src\main\java\com\info\fastboard\board\persistence\BoardMapper.java
package com.info.fastboard.board.persistence;

import java.util.List;
import java.util.Map;

import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface BoardMapper {
	// 게시글 리스트 조회
    List<Map<String, Object>> findBoardList(Map<String, Object> map);
    
    // 게시글 상세 데이터 조회 
    List<Map<String, Object>> findBoardDetail(Map<String, Object> map);
}

 

[ BoardMapper.xml ]

경로 )
src\main\resources\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="com.info.fastboard.board.persistence.BoardMapper">

	<select id="findBoardList" parameterType="hashmap" resultType="hashmap">
		SELECT A.BOARD_IDX
		     , B.CODE_NAME
		     , A.BOARD_TITLE
		     , C.USER_NM
		     , A.BOARD_DESC
		     , A.USE_YN
		     , DECODE(A.DUE_DATE, NULL, '', TO_CHAR(A.DUE_DATE, 'yyyy-mm-dd hh24:mi:ss')) AS DUE_DATE
		     , A.FIXED_YN
		     , A.INPUT_USER_ID
		     , DECODE(A.INPUT_DATETIME, NULL, '', TO_CHAR(A.INPUT_DATETIME, 'yyyy-mm-dd hh24:mi:ss')) AS INPUT_DATETIME
		     , A.MODIFY_USER_ID
		     , DECODE(A.MODIFY_DATETIME, NULL, '', TO_CHAR(A.MODIFY_DATETIME, 'yyyy-mm-dd hh24:mi:ss')) AS MODIFY_DATETIME
		  FROM INFO.CRT_BOARD_INFO A
		  LEFT OUTER JOIN INFO.CRT_CODE_DETAIL B
		    ON A.BOARD_TYPE = B.DETAIL_ID
		   AND B.CODE_ID = 'BOARD_TYPE'
		  LEFT OUTER JOIN INFO.CRT_USER_INFO C
		    ON A.INPUT_USER_ID = C.USER_ID
		 WHERE 1=1
		    AND A.USE_YN = 'Y'
		    AND NVL(TO_CHAR(A.DUE_DATE, 'RRRR-MM-DD'), '99991231') > TO_CHAR(SYSDATE, 'YYYYMMDD')
		 	<if test="!''.equals(searchType)">
		 	AND A.BOARD_TYPE = #{searchType}
		 	</if>
            <if test="!''.equals(searchText)">
            AND A.BOARD_TITLE LIKE '%' || #{searchText} || '%'
            </if>
         ORDER BY A.INPUT_DATETIME DESC
	</select>
	
	<select id="findBoardDetail" parameterType="hashmap" resultType="hashmap">
		SELECT A.BOARD_IDX
		     , B.CODE_NAME
		     , A.BOARD_TITLE
		     , C.USER_NM
		     , A.BOARD_DESC
		     , A.USE_YN
		     , DECODE(A.DUE_DATE, NULL, '', TO_CHAR(A.DUE_DATE, 'yyyy-mm-dd hh24:mi:ss')) AS DUE_DATE
		     , A.FIXED_YN
		     , A.INPUT_USER_ID
		     , DECODE(A.INPUT_DATETIME, NULL, '', TO_CHAR(A.INPUT_DATETIME, 'yyyy-mm-dd hh24:mi:ss')) AS INPUT_DATETIME
		     , A.MODIFY_USER_ID
		     , DECODE(A.MODIFY_DATETIME, NULL, '', TO_CHAR(A.MODIFY_DATETIME, 'yyyy-mm-dd hh24:mi:ss')) AS MODIFY_DATETIME
		     , D.FILE_NAME 
		     , D.ORG_FILE_NAME 
		  FROM INFO.CRT_BOARD_INFO A
		  LEFT OUTER JOIN INFO.CRT_CODE_DETAIL B
		    ON A.BOARD_TYPE = B.DETAIL_ID
		   AND B.CODE_ID = 'BOARD_TYPE'
		  LEFT OUTER JOIN INFO.CRT_USER_INFO C
		    ON A.INPUT_USER_ID = C.USER_ID
		  LEFT OUTER JOIN INFO.CRT_BOARD_FILE D  
		    ON A.BOARD_IDX = D.BOARD_IDX 
		 WHERE 1=1
		   AND A.BOARD_IDX = #{boardIdx}
	</select>

</mapper>

 

백엔드 구성이 완료되었으면

프론트엔드 구성을 진행

앞서 진행한 메뉴들과 동일하게 뷰 단의 html 파일과 스크립트 처리를 위한 js 파일을 생성 

 

[ board.html ] 

경로 )
src\main\resources\templates\board.html
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" lang="ko" class="h-100">
<head>
<meta charset="utf-8">
<!-- 반응형 -->
<meta name="viewport" content="width=device-width, initial-scale=1">

<th:bloc th:replace="../static/common/header::header"></th:bloc>
</head>
<body class="d-flex flex-column h-100">
 
	<!-- 상단 메뉴바 -->
	<th:bloc th:replace="../static/common/menu::menu"></th:bloc>

	<!-- Begin page content -->
	<main class="flex-shrink-0">
		<div class="container">
			<h1 class="mt-5">FAST BOARD 게시판</h1>
			<p class="lead">
				게시글 등록 및 게시글 조회를 위한 화면입니다.
			</p>
			
			<!-- 조회조건 영역 -->
			<div id="panel1" class="row p-5 bg-light border rounded-3 mb-5">
				<div class="col-sm-4 row">
					<label for="txtTitle" class="col-sm-6 col-form-label">제목</label>
					<div class="col-sm-6">
						<input type="text" class="form-control" id="txtTitle" placeholder="제목으로 검색">
					</div>
				</div>
				<div class="col-sm-4 row">
					<label for="txtTitle" class="col-sm-6 col-form-label">카테고리</label>
					<div class="col-sm-6">
						<select id="cmbBoardType" class="form-control"></select>
					</div>
				</div>
				<div class="col-sm-4 text-end">
					<button type="button" class="btn btn-primary" id="btnSearch">조회</button>
				</div>
				
				<div style="clear: both;"></div>
				<br/>
				
				<div class="com-sm-12">
					<table id="tblMain" class="table table-bordered" summary="게시물 목록">
						<thead>
							<tr>
								<th width="8%">NO</th>
								<th width="18%">카테고리</th>
								<th>제목</th>
								<th width="18%">작성자</th>
								<th width="18%">등록일자</th>
							</tr>
						</thead>
					</table>
				</div>
			</div>
			
			<!-- 데이터 영역 -->
			<div id="panel2"  class="row p-5 bg-light border rounded-3 mb-5" style="display: none;">
				<div class="com-sm-12">
					<table id="tblSub" class="table table-bordered">
						<tbody>
							<tr>
								<th width="18%">카테고리</th>
								<td id="tdBoardType"></td>
								<th width="18%">작성자</th>
								<td id="tdInputUser"></td>
								<th width="18%">등록일자</th>
								<td id="tdInputDate"></td>
							</tr>
							<tr>
								<th width="18%">제목</th>
								<td id="tdBoardTitle" colspan="5"></td>
							</tr>
							<tr>
								<th width="18%">내용</th>
								<td colspan="5" style="height: 640px;">
									<pre id="tdBoardDesc" style="height: 100%; overflow-y: auto;"></pre>
								</td>
							</tr>
							<tr>
								<th width="18%">첨부파일</th>
								<td id="tdBoardFiles" colspan="5"></td>
							</tr>
						</tbody>
					</table>
					
					<br/>
					<div class="btn-group">
						<button type="button" id="btnBack" value="목록" title="목록" class="btn_b_s">목록</button>
					</div>
				</div>
			</div>
		</div>
	</main>

	<!-- 하단 푸터 영역 -->
	<th:bloc th:replace="../static/common/footer::footer"></th:bloc>

	<!-- Page Script -->
	<script src="static/views/board.js"></script>

</body>
</html>

 

[ board.js ]

경로 )
src\main\resources\static\views\board.js
/*******************************************************************************
 * 
 * board.js
 * 
 * @author THKIM
 * @since 2022
 * @DESC 게시판 화면 스크립트
 * 
 ******************************************************************************/
(function() {
	
	function Board() {
		
		/* 
		 * private variables
		 */
		 // Korean
		var dataTables = null;
	    var lang_kor = {
	        "decimal" : "",
	        "emptyTable" : "데이터가 없습니다.",
	        "info" : "_START_ - _END_ (총 _TOTAL_ 건)",
	        "infoEmpty" : "0건",
	        "infoFiltered" : "(전체 _MAX_ 건 중 검색결과)",
	        "infoPostFix" : "",
	        "thousands" : ",",
	        "lengthMenu" : "_MENU_ 개씩 보기",
	        "loadingRecords" : "로딩중...",
	        "processing" : "처리중...",
	        "search" : "검색 : ",
	        "zeroRecords" : "검색된 데이터가 없습니다.",
	        "paginate" : {
	            "first" : "첫 페이지",
	            "last" : "마지막 페이지",
	            "next" : "다음",
	            "previous" : "이전"
	        },
	        "aria" : {
	            "sortAscending" : " :  오름차순 정렬",
	            "sortDescending" : " :  내림차순 정렬"
	        }
	    };
		
		/* 
		 * 초기화 메소드
		 */
		
		function _init() {
			// 이벤트 
			bindEvent();
			
			// 게시글 카테고리 콤보박스 데이터 조회
			findCodeList();
			
			// 게시글 리스트 조회 
			findBoardList();
		}
		
		function bindEvent() {
			// 조회 버튼 클릭 이벤트
			$("#btnSearch").on("click", function() {
				toastr.success('데이터 조회중입니다.', '당근게시판');
				findBoardList();
			});
			
			// 목록 버튼 클릭 이벤트 
			$("#btnBack").on("click", function() {
				$("#panel2").hide();
				$("#panel1").show();
			});
		}
		
		/*
		 * 코드 정보 리스트 조회 
		 */
		function findCodeList() {
			var obj = {
				codeId: 'BOARD_TYPE'
			}
			
			var html = "";
			cfFind("/findCodeList", obj, function(data) {
				$.each(data, function(idx, node) {
					html += "<option id='" + node.DETAIL_ID + "'>" + node.CODE_NAME + "</option>";
				});
				
				$("#cmbBoardType").html("<option id=''>전체</option>" + html);
			}, true, "POST");
		}
		
		/*
		 * 게시글 리스트 조회 
		 */
		function findBoardList() {
			var obj = {
				searchText: $("#txtTitle").val(),
				searchType: $("#cmbBoardType option:selected")[0].id
			}
			
			cfFind("/board/findBoardList", obj, function(data) {
				if(dataTables != null) {
					$("#tblMain").DataTable().destroy();
				}
				
				dataTables = $("#tblMain").DataTable({
					data: data,
					columns: [
						{ title: "NO", width: "8%" },
			            { title: "카테고리", width: "18%" },
			            { title: "제목" },
			            { title: "작성자", width: "18%" },
			            { title: "등록일자", width: "18%" }
			        ],
			        language: lang_kor,
			        filter: false,
			        ordering: false,
			        lengthChange: false
				});
				
				// row 클릭 이벤트 
				$("#tblMain tbody").on('click', 'td', function () {
					var rowIdx = $(this)[0].parentNode.childNodes[0].innerText;
					var rowNode = dataTables.row(Number(rowIdx)-1).data();
					var boardIdx = rowNode[5];
					findBoardDetail(boardIdx);
				});
				
			}, true, "POST");
		}
		
		/*
		 * 게시글 상세 조회
		 */
		function findBoardDetail(boardIdx) {
			var obj = {
				boardIdx: boardIdx
			}
			
			cfFind("/board/findBoardDetail", obj, function(data) {
				if(data.length > 0) {
					var fileList = "";
					$.each(data, function(idx, node) {
						if(idx == 0) {
							$("#tdBoardType").html(node.CODE_NAME); // 카테고리 
							$("#tdInputUser").html(node.USER_NM); // 작성자 
							$("#tdInputDate").html(node.INPUT_DATETIME); // 등록일자 
							$("#tdBoardTitle").html(node.BOARD_TITLE); // 제목 
							$("#tdBoardDesc").html(node.BOARD_DESC); // 내용 
						}
						
						if(node.FILE_NAME != "" && node.FILE_NAME != null) {
							fileList += "<a style='display: flex; margin: 5px;' href='/board/download?fileName=" + node.FILE_NAME + "&orgFileName=" + encodeURI(node.ORG_FILE_NAME) + "'>";
							fileList += "<img src='static/images/sub/file.jpg' />";
							fileList += node.ORG_FILE_NAME + "</a>";
						}
					});
					
					$("#tdBoardFiles").html(fileList);
					
					$("#panel1").hide();
					$("#panel2").show();
				} 
				
			}, true, "POST");
		}
		
		function _finalize() {
		}
		
		return {
            init : _init,
            finalize : _finalize
        };
    };
    
    var board = new Board();
    board.init();
    
})();

//# sourceURL=board.js

 

 


상단 메뉴바에 로그인 여부, 관리자로 로그인 했을 경우에만 보여지는 메뉴들을 설정하기 위해

menu.html 파일을 수정하고 시큐리티 설정을 적용함

 

[ menu.html ]

경로 )
src\main\resources\static\common\menu.html
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<meta charset="UTF-8">

<!-- 메뉴 영역을 block으로 묶어서 사용 -->
<th:block th:fragment="menu">
<header>
	<!-- Fixed navbar -->
	<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
		<div class="container-fluid">
			<a class="navbar-brand" href="/">FAST BOARD</a>
			<button class="navbar-toggler" type="button"
				data-bs-toggle="collapse" data-bs-target="#navbarCollapse"
				aria-controls="navbarCollapse" aria-expanded="false"
				aria-label="Toggle navigation">
				<span class="navbar-toggler-icon"></span>
			</button>
			<div class="collapse navbar-collapse" id="navbarCollapse">
				<ul class="navbar-nav me-auto mb-2 mb-md-0">
					<li class="nav-item"><a class="nav-link" href="/">메인</a></li>
					<li class="nav-item"><a class="nav-link" href="/board">게시판</a></li>
					<li class="nav-item"><a class="nav-link" href="/community">커뮤니티 게시판</a></li>
					<li class="nav-item"><a class="nav-link" href="/code" sec:authorize="hasRole('ADMIN')">코드 관리</a></li>
					<li class="nav-item"><a class="nav-link" href="/user" sec:authorize="hasRole('ADMIN')">사용자 관리</a></li>
					<li class="nav-item"><a class="nav-link" href="/admin" sec:authorize="hasRole('ADMIN')">게시글 관리</a></li>
				</ul>
				<form class="d-flex" role="search" style="color: white;">
					<div sec:authorize="isAuthenticated()">
						<span sec:authentication="name"></span>님께서 로그인하셨습니다.
					</div>
					<a class="btn btn-outline-success" href="/login" sec:authorize="isAnonymous()">로그인</a>
					<a class="btn btn-outline-success" href="/logout" sec:authorize="isAuthenticated()">로그아웃</a>
				</form>
			</div>
		</div>
	</nav>
</header>
</th:block>
</html>

 

상단 메뉴바 시큐리티 적용