SpringMVC를 이용한 게시판 구현 간략 정리

스프링 | 2021.01.30 22:23

나는 스프링 프레임워크를 이용해 3개의 웹어플리케이션을 운영하고 있다.
첫번째는 현재 웹사이트인 innu.pe.kr 이고 두번째는 개인적으로 소장하고 있는 게시판 형태의 도서목록이며 
마지막은 주식 종목별 실시간 가격, 거래량을 수집해 특정한 조건에 부합하면 앱으로 알려주는 주식알림 시스템이다.
프로그램의 기본은 데이터의 입/출력을 정확하게 관리하는 것이며 그것이 효율적이라면 더욱 좋다.
웹환경에서 데이터의 입력, 처리, 저장, 출력의 기본형태는 게시판인데 본인이 웹사이트를 운영하는 게시판 형태의 어플리케이션을 정리하고자 한다.

 

SpringMVC에서 데이터를 처리하는 흐름은 아래와 같다.

출처 : http://egloos.zum.com/springmvc/v/504151

 

Controller 클래스의 원형은 대략 아래와 같다.
파일처리를 위한 목적으로 해당 컨트롤러에서 fileDto 정보를 항상 유지하기 위해 @SessionAttributes("fileDto") 처리를 했다.
setSessionInit 메소드에서는 Session관련 오류를 방지하기 위한 처리를 해준다. 주석참조.

@Controller
@SessionAttributes("fileDto")
public class MainController {
	
	private static final Logger logger = LoggerFactory.getLogger(MainController.class);
	
	@Autowired
	PostService postService;

	// 각종 Service Bean 자동주입


	... 생략 ...

	/**
	 * 컨트롤러의 @SessionAttributes("fileDto")는 항상 클래스 상단에 위치하며 해당 어노테이션이 붙은 컨트롤러는 @SessionAttributes("세션명")
	 * 에서 지정하고 있는 "세션명"을 @RequestMapping으로 설정한 모든 뷰에서 공유하고 있어야 한다는 규칙을 갖고있다.
	 * 예를들어 위와 같이 @SessionAttributes("fileDto") 라는 어노테이션이 붙은 클래스라면 하위의 종속되있는 모든 뷰가 "fileDto"
	 * 라는 모델 값을 공유하고 있어야 한다는 것이다.
	 * 만약, 이 조건을 충족시키지 못하면 다음과 같은 에러가 발생하게 된다.
	 * 
	 * org.springframework.web.HttpSessionRequiredException: Expected session attribute 'fileDto'
	 *  
	 * 이런 불필요한 에러를 보고싶지 않다면 @ModelAttribute 를 붙인 메서드를 이용할 것을 적극 권장한다.
	 * @ModelAttribute 가 붙은 setSessionInit() 메소드를 볼 수 있는데 이 메서드는 해당 컨트롤러로 접근하려는 모든 요청에 
	 * @ModelAttribute 가 붙은 메서드의 리턴값을 설정된 모델명으로 자동 포함해주는 역할을 담당한다.
	 * 물론, 이미 동일한 이름의 모델이 생성되어 있다면 위의 메서드 값은 포함되지 않으며 오로지 설정한 모델명과 일치하는 객체가 존재하지
	 * 않는 경우에만 메서드의 리턴값을 서버의 응답과 함께 클라이언트에 전송한다.
	 * 
	 * 출처 : http://springmvc.egloos.com/535572 @ModelAttribute와 @SessionAttributes의 이해와 한계
	 * 
	 * setSessionInit 는 fileDto를 초기화 한다고 보면된다.
	 * fileDto 객체가 없으면 생성하고 있으면 그대로 둔다. singleton 패턴과 같은 역할을 한다.
	 * 이 메서드는 세션당 한번만 호출된다(브라우저 띄워서 처음 한번 호출되며 이후에는 호출되지 않는다)
	 * 
	 */
	@ModelAttribute("fileDto")
	public FileDto setSessionInit() {
		return new FileDto();
	}

	... 생략 ...
}

Controller에서는 크게 /list, /view, /post로 유입된 Request를 처리한다.
그 외 파일업로드, 댓글처리, uri 처리 등이 있지만 업무로직이 아니므로 각자 적절하게 처리하면 된다.

@RequestMapping({"/list"})
public String list(Model model, @RequestParam(defaultValue="1") int page, 
					@ModelAttribute("postPaging") PostPaging postPaging) {
	
	// 전체 레코드수
	int totalRecord = this.postService.getPostListCount(postPaging);
	
	// 페이지 세팅처리
	postPaging.pageSetting(page, totalRecord);
	
	// 게시글(PostDto)
	List<PostDto> dtos = this.postService.getPostList(postPaging);
	
	// 카테고리(HashMap)
	List<HashMap> categ_list = this.categService.getCategList();
	
	model.addAttribute("list", dtos);
	model.addAttribute("imgRelPath", Constant.IMG_REL_PATH);
	model.addAttribute("pinfo", postPaging);
	model.addAttribute("categ_list", categ_list);
	
	return "list";
}

list() 메소드는 /list로 들어온 요청을 처리한다.
@ModelAttribute("postPaging") PostPaging postPaging 파라미터는 HttpServletRequest 객체에 담긴 PostPaging 멤버를 통으로 관리한다.
즉, HttpServletRequest req 로 선언해서 req.getParameter("searchKwd"), req.getParameter("searchCateg")... 처럼 각각의 속성을 추출해야 하는데
@ModelAttribute 어노테이션을 선언하면 폼에서 넘어온 값을 커맨드 객체로 저장하여 jsp로 값을 전달하거나 받을때 유용하게 사용할 수 있다.

PostPaging은 페이징처리를 하는 클래스로 Paging를 상속받아 구현한다.

public class PostPaging extends Paging {
	private String searchKwd;
	private String searchCateg;

	public void pageSetting(int curPage, int totalRecord) {
		super.curPage = curPage;
		super.totalRecord = totalRecord;

		super.cntPerPage = Constant.cntPerPage;
		super.pagePerBlock = Constant.pagePerBlock;
	
		super.totalPage = ((int)Math.ceil((double)super.totalRecord / super.cntPerPage));
	
		super.offset = ((super.curPage - 1) * super.cntPerPage);
	
		super.totalBlock = ((int)Math.ceil((double)super.totalPage / super.pagePerBlock));
		super.curBlock = ((int)Math.ceil((double)super.curPage / super.pagePerBlock));
	
		super.startPage = ((super.curBlock - 1) * super.pagePerBlock + 1);
		super.endPage = (super.totalBlock <= super.curBlock ? super.totalPage : super.curBlock * super.pagePerBlock);
	    
		super.nextBlockPage = super.curBlock * super.pagePerBlock + 1;
		super.prevBlockPage = (super.curBlock - 1) * super.pagePerBlock;
	    
	
	    //this.prevPage = ((this.curBlock - 1) * this.pagePerBlock);
	    //this.nextPage = (this.curBlock * this.pagePerBlock + 1);
		super.prevPage = curPage - 1;
		super.nextPage = curPage + 1;
	}

	// getter, setter 생략
}

먼저, 전체 레코드수를 구하고 페이지 세팅처리를 한다음 결과 dto를 List에 담는다(게시물 정보)
전체페이지, 현재페이지, 마지막페이지, 키워드 등의 정보(페이징 정보), 카테고리정보, 그 외 필요한 정보들을 Model 에 담아서 view로 전달하면 /list로 요청된 controller의 업무는 끝이다.

@RequestMapping("/view/{keyParam}")
public String view(Model model, @PathVariable String keyParam) {

	String idx = "";
	
	if ( UtilStr.isNumeric(keyParam) ) {
			idx = keyParam;
	} else {
		idx = this.postService.getPostGetIdx(keyParam);
		if ( idx == "0" || idx == null ) {
				return "redirect:/error";
		}
	}
			
	// 게시글(PostDto)
	PostDto postDto = this.postService.getPostView(idx);
	
	if ( postDto == null || postDto.getSubject() == null || "".equals(postDto.getSubject()) ) {
		return "redirect:/error";
	}
	
	// 카테고리(HashMap)
	List<HashMap> categ_list = this.categService.getCategList();
	
	// 같은 카테고리의 글 n개
	List<PostDto> dtos = this.postService.getPostListByCateg(idx, Constant.cntPerCateg);
	
	// 댓글 리스트
	List<CommentDto> commentDtos = this.commentService.getCommentList(idx);
	
	model.addAttribute("postDto", postDto);
	
	// 카테고리 별 글 갯수
	model.addAttribute("categ_list", categ_list);
	
	// 카테고리 관련 글 n개
	model.addAttribute("list", dtos);
	
	// 댓글리스트
	model.addAttribute("comment_list", commentDtos);
	
	return "view";
}

view() 메소드는 /view/1125, /view/spring-bbs 같이 게시물 상세보기 요청을 처리한다.
@RequestMapping("/view/{keyParam}")
keyParam 이 숫자인 경우는 게시판 테이블의 일련번호이고 getPostView 메소드에서 dto를 추출한다.
만약, 문자일 경우엔 매핑된 일련번호를 구하는 작업이 먼저 이뤄진다(getPostView)
그 외 부가정보들(카테고리, 댓글 등)을 Model에 담아 view에 전달한다.

@RequestMapping(value={"/post"}, method={RequestMethod.GET})
public String postfrm(Model model, @RequestParam(value="idx", required=false) String idx,
						@ModelAttribute("postPaging") PostPaging postPaging,
						HttpServletRequest request, HttpSession httpSession) {
	
	PostDto postDto = null;
	if (idx != null) {
		postDto = this.postService.getPostView(idx);
	} else {
		postDto = new PostDto();
	}

	// 카테고리(HashMap)
	List<HashMap> categ_list = this.categService.getCategList();	    
	
	// 카테고리 별 글 갯수
	model.addAttribute("categ_list", categ_list);
	model.addAttribute("postDto", postDto);
	
	return "post";
}

postfrm() 메소드는 GET으로 요청된 /post 를 처리한다.
즉, 게시물 등록이나 수정을 위한 form으로 이동하는 역할을 한다.

@RequestMapping(value={"/post"}, method={RequestMethod.POST})
public String post(Model model, @Valid @ModelAttribute("postDto") PostDto postDto, 
		@SessionAttribute("fileDto") FileDto fileDto, BindingResult bindingResult, SessionStatus sessionStatus,
		HttpServletRequest request ) {
	
	HttpSession httpSession = request.getSession( true );
	UserDto userDto = (UserDto)httpSession.getAttribute( "userDto" );
	
	postDto.setUserid( userDto.getUserid() );
	
	// 폼에서 넘어온 값을 검증해서 통과 못했을경우 되돌림
	if ( bindingResult.hasErrors() ) {
		model.addAttribute("postDto", postDto);
		return "post";
	}
	
	int result = 0;
	
	// 수정일때, 등록일때 나눠서 처리.
	// 코드가 아름답지 못하다.
	if ( postDto.getIdx() > 0 ) {
		// 수정
		result = this.postService.updatePost(postDto);
		fileDto.setP_idx(postDto.getIdx());
		
		logger.info("---- getCKEditorFuncNum : " + fileDto.getCKEditorFuncNum());
		
		result = procFile(fileDto);
		
	} else {
		// 등록
		result = this.postService.insertPost(postDto);
		fileDto.setP_idx(postDto.getIdx());
		
		result = procFile(fileDto);
	}
	
	// SessionAttribute 해제
	sessionStatus.setComplete();
		
	return "redirect:list";
}

post() 메소드는 POST로 요청된 /post 를 처리한다.
FileDto는 첨부파일(이미지) 업로드를 위한 커맨드객체다.
@SessionAttribute("fileDto") FileDto fileDto 파라미터를 받았다(@SessionAttributes 아닌 @SessionAttribute)
이는 컨트롤러에 등록해둔 fileDto 세션을 사용하기 위해서 이다.
업로드 처리가 완료되면 세션해제를 위해 sessionStatus.setComplete(); 호출

전체적인 로직은 유효성검증에 통과한 폼값을 등록이나 수정 한 후 파일처리를 한다.
그 이유는 파일업로드 처리는 ckeditor4를 이용하는데 /post 메소드 들어오기 이전에 /uploadCkeditor 요청에서 실제 물리적인 파일업로드 처리를 끝내고 FileDto에 해당 정보를 세팅한다(DB처리 아직 안함)
그런 다음 실제 글등록을 눌러 본 메소드에 들어왔을때 게시글 정보를 insert 하고 마지막으로 FileDto로 넘어온 값을 DB처리 한다(그런 이유로 FileDto를 세션으로 유지하는 이유다)
FileDto 처리가 끝나면 sessionStatus.setComplete(); 로 세션해제 한다.

@RequestMapping(value={"/uploadCkeditor"}, method={RequestMethod.POST})
public ModelAndView uploadCkeditor(@ModelAttribute("fileDto") FileDto fileDto, ModelAndView mv) {
	
	if ( fileDto.getUpload() == null ) return mv;
	
	HashMap<String, String> map = FileUpload.upload(fileDto.getUpload());
	
	mv.setViewName("ckeditor_result");
	mv.addObject("CKEditorFuncNum", fileDto.getCKEditorFuncNum());
	mv.addObject("imageUrl", Constant.IMG_REL_PATH + map.get("file_re_name"));
	
	// p_idx는 나중에(tblPost 등록/수정) set 한다.
	fileDto.setImg_yn("Y");
	fileDto.setF_orig_name(map.get("file_name"));
	fileDto.setF_type(map.get("file_type"));
	fileDto.setF_size(Integer.parseInt(map.get("file_size")));
	fileDto.setF_re_name(map.get("file_re_name"));
	fileDto.setF_thumb_name(map.get("file_thumb_name"));
	
	return mv;		
}

ckeditor4를 이용한 이미지 업로드 호출의 로직은 대략 아래와 같다.

1. ckeditor4 이미지 버튼 클릭해서 파일 업로드(filebrowserUploadUrl : uploadCkeditor 설정해줌)
2. FileDto 생성. CKEditorFuncNum(콜백), upload(Multipart 객체) 멤버변수는 반드시 있어야 함.
3. FileUpload 에서 실제 리사이즈, 크롭, 업로드 처리
4. 원본파일, re파일(최대 640x480), thumb파일(200x200) 생성
5. 업로드 작업 끝난 후 ckeditor_result.jsp 에서 콜백,이미지url 전달
6. 이미지url은 upload/re_이미지명 인데 upload/ 경로는 추후 바꿔야 함(resources 아래가 아닌 외부 디렉토리. 톰캣 재설정 필요)

Controller에서는 servlet-context.xml 에 정의해둔 Service Bean을 @Autowired로 자동 주입한다.
Service에서는 역시 정의해둔 Dao Bean을 주입하는데 Service와 Dao를 분리할 필요성은 느끼지 않는데 일반적으로 Service와 Dao를 분리해서 관리하므로 나 역시 그대로 따랐다.

업무처리를 위한 Dao는 대략 아래와 같다.

public class PostDaoImpl implements PostDao {
	
	@Autowired
	SqlSessionTemplate sqlSessionTemplate;

	... 생략 ...

	// 리스트
	@Override
	public List<PostDto> getPostListDao(PostPaging postPaging) {
		// TODO Auto-generated method stub
		return this.sqlSessionTemplate.selectList("mapper.postList", postPaging);
	}

	// 상세보기
	@Override
	public PostDto getPostViewDao(String paramString) {
	    PostDto postDto = new PostDto();
	    HashMap<String, String> map = new HashMap<String, String>();
	    
	    map.put("idx", paramString);
	    postDto = (PostDto)this.sqlSessionTemplate.selectOne("mapper.postView", map);

	    return postDto;
	}

	// 등록
	@Override
	public int insertPostDao(PostDto postDto) {
		// TODO Auto-generated method stub
		return this.sqlSessionTemplate.insert("mapper.postInsert", postDto);
	}

	// 수정
	@Override
	public int updatePostDao(PostDto postDto) {
		// TODO Auto-generated method stub
		return this.sqlSessionTemplate.update("mapper.postUpdate", postDto);
	}

	// 삭제
	@Override
	public int deletePostDao(PostDto postDto) {
		// TODO Auto-generated method stub
		HashMap<String, String> map = new HashMap<String, String>();
	    map.put("idx", paramString);
		return this.sqlSessionTemplate.delete("mapper.postDelete", map);
	}

	... 생략 ...
}

mapper는 다음과 같다.

<mapper namespace="mapper">

	<!--  PostDto와 FileDto 정보를 함께 추출하기 위해 ResultMap을 만든다. -->
	
	<resultMap type="kr.pe.innu.dto.PostDto" id="postDto">
		<id property="idx" column="idx" />
		<result property="categ" column="categ" />
		<result property="userid" column="userid" />
		<result property="subject" column="subject" />
		<result property="meta_subject" column="meta_subject" />
		<result property="meta_keywords" column="meta_keywords" />
		<result property="meta_desc" column="meta_desc" />
		<result property="rest_uri" column="rest_uri" />
		<result property="content" column="content" />
		<result property="hit_cnt" column="hit_cnt" />
		<result property="comment_cnt" column="comment_cnt" />
		<result property="reg_dt" column="reg_dt" />
		<result property="use_yn" column="use_yn" />
		<result property="thumb_view" column="thumb_view" />
		<result property="categ_nm" column="categ_nm" />
		<collection property="fileDto" resultMap="fileDto" />
	</resultMap>

	<resultMap type="kr.pe.innu.dto.FileDto" id="fileDto">
		<id property="idx" column="idx" />
		<result property="p_idx" column="p_idx" />
		<result property="img_yn" column="img_yn" />
		<result property="f_orig_name" column="f_orig_name" />
		<result property="f_type" column="f_type" />
		<result property="f_size" column="f_size" />
		<result property="f_re_name" column="f_re_name" />
		<result property="f_thumb_name" column="f_thumb_name" />
		<result property="reg_dt" column="reg_dt" />
	</resultMap>	


	<select id="postList" parameterType="kr.pe.innu.util.PostPaging" resultMap="postDto">
		SELECT (select categ_nm from tblCateg where a.categ = categ) categ_nm, b.f_re_name, b.f_thumb_name, a.* 
		  FROM tblPost a LEFT JOIN tblFile b ON a.idx = b.p_idx
		 WHERE a.use_yn = 'Y'
		 <include refid="search_cond" />
		 ORDER BY a.idx DESC
		 LIMIT #{offset}, #{cntPerPage}
	</select>

	<select id="postListCount" parameterType="kr.pe.innu.util.PostPaging" resultType="int">
		SELECT count(*) AS CNT
		  FROM tblPost a
		 WHERE a.use_yn = 'Y'
		 <include refid="search_cond" />
	</select>	

	<sql id="search_cond">
		<if test="searchKwd != null and searchKwd != ''">
			AND ( a.subject LIKE CONCAT('%', #{searchKwd}, '%') or
				  a.meta_keywords LIKE CONCAT('%', #{searchKwd}, '%') or
			      a.content LIKE CONCAT('%', #{searchKwd}, '%') )  
		</if>
		<if test="searchCateg != null and searchCateg != ''">
			AND a.categ = #{searchCateg}
		</if>
	</sql>

	<select id="postView" parameterType="hashmap" resultMap="postDto">
		SELECT (select categ_nm from tblCateg where a.categ = categ) categ_nm, b.f_re_name, b.f_thumb_name, a.* 
		  FROM tblPost a LEFT JOIN tblFile b ON a.idx = b.p_idx
		 WHERE a.idx = #{idx}
		   and a.use_yn = 'Y'
	</select>

	<update id="postUpdate" parameterType="kr.pe.innu.dto.PostDto">
		UPDATE tblPost SET
			categ = #{categ}, userid = #{userid}, subject = #{subject}, content = #{content}, thumb_view = #{thumb_view}, 
			meta_subject = #{meta_subject}, meta_keywords = #{meta_keywords}, meta_desc = #{meta_desc}, rest_uri = #{rest_uri}
		 WHERE IDX = #{idx} 		 
	</update>

	<delete id="postDelete" parameterType="hashmap">
		DELETE
		  FROM tblPost
		 WHERE IDX = #{idx}
	</delete>

	... 생략 ...

</mapper>

Controller에서 처리한 업무로직 결과를 View단으로 전달해 최종 html를 생성해서 웹서버로 응답을 해야 한다.
이 역할을 하는것이 ViewResolver이며 Controller에서 리턴한 문자열.jsp가 처리한다.

<%@ page language="java" contentType="text/html; charset=utf-8"
    pageEncoding="utf-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="myfn" uri="/WEB-INF/view/tlds/UtilStr.tld" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%>

<!DOCTYPE HTML>
<html>
<head>
	<title>today</title>
	<meta charset="utf-8" />
</head>
<body>
... 생략 ...

<h2>전체 ${pinfo.totalRecord}</h2>

<c:forEach items="${list}" var="postDto" varStatus="status">
제목 : ${postDto.subject}
등록일 : ${myfn:dispDate(postDto.reg_dt, 16)}
이미지 경로 : ${imgRelPath}${postDto.fileDto.f_re_name}
</c:forEach>

... 생략 ...
</body>
</html>

list.jsp의 대략적인 형태이다.
Controller의 list() 메소드에서 전달한 Model을 적당히 추출해서 jsp에서 사용한다.
$pinfo는 페이징 정보이며 $list는 게시물 커맨드객체의 리스트이며 $imgRelPath은 이미지의 경로정보이다.
MyBatis에서 지정한 resultMap에 따라서 제목은 ${postDto.subject}로, 파일명은 ${postDto.fileDto.f_re_name}로 접근할 수 있다.
myfn은 jsp에서 사용하는 사용자함수인데 주로 문자열 처리를 한다.


정리하면 클라이언트로부터 /list, /view/{파라미터}, /post 와 같은 요청을 받으면 DispatcherServlet에서 HandlerMapping을 통해 Controller에 처리를 위임한다.
Controller에서 처리된 업무로직은 ViewResolver를 통해 해당 View로 데이터를 전달해 jsp로 뿌려준다.


"스프링" 카테고리의 다른 글

댓글쓰기

"SpringMVC를 이용한 게시판 구현 간략 정리" 의 댓글 (0)