[TIL]Day38 - 게시판 CRUD (2)

JIONY·2022년 10월 9일
0

TIL - Web BE - Spring Boot

목록 보기
14/20
post-thumbnail

insert할 때 번호를 못불러와서 계속 실패했는데 dual을 썼어야 하는 거였음 ㅎㅎ 지금까지 배운 거 중간 총정리 하는 느낌으로 만들었는데 제법 빼먹은 게 많다니 아무래도 다시 만들어봐야..더보기


게시글 목록과 게시글 상세화면을 구현함. 이후에 답글/댓글, 조회수, 좋아요 등의 기능을 추가할 예정. 강사님 풀이와 비교해 되짚어 볼 내용만 아래에 정리함


테이블 생성

외래키 설정

  • on delete 조건 함께 작성
    • 작성자 탈퇴 시 null로 설정
board_writer varchar2(20) references member(member_id) on delete set null,

시퀀스

  • 테이블 자료형: number
  • DTO 자료형: long 권장

테스트

  • jsp는 테스트가 어려움. 화면을 보면서 확인하는 방법이 나음

등록, 조회

  • Dao.insert() 테스트 후, 해당 내용을 사전 작업으로 설정
  • 사후 작업으로 전체 데이터 삭제 기능을 설정하면 DB 작업을 다양하게 테스트하기 편해짐

list

검색 기능 리팩토링

필요한 이유

@ModelAttribute로 수신한 데이터는 자동으로 Model에 첨부됨
검색 옵션(ex. 작성자, 제목, 내용)과 키워드를 파라미터로 전달해야 하는 검색 기능일 경우에 유용
[참고] 옵션에 name을 지정하면 해당 이름으로 첨부됨. default=클래스 이름
@ModelAttribute(name="vo") BoardListSearchVo vo


변경 내용

  • 전체 목록 조회와 검색 결과 목록 조회 기능을 Dao에서 오버라이딩으로(selectList(), selectList(String type, String keyword)) 구현한 상태

  • VO를 생성해 검색 옵션과 키워드를 멤버변수로 선언하고 검색인지 판정하는 getter()를 추가

  • 컨트롤러에서 /board/list의 @RequestParam을 @ModelAttribute BoardListSearchVO vo로 대체

  • Dao에서도 매개변수를 BoardListSearchVO vo로 대체

//Dao, DaoImpl
@Override
public List<BoardDto> selectList(BoardListSearchVO vo) {
	String sql = "select * from board "
			+ "where instr(#1, ?) > 0 "
			+ "order by board_no desc";
	sql = sql.replace("#1", vo.getType());
	Object[] param = {vo.getKeyword()};
	return jdbcTemplate.query(sql, mapper, param);
}

//Controller
@GetMapping("/list")
public String list(Model model,
		@ModelAttribute BoardListSearchVO vo) {
	if(vo.isSearch()) {
		model.addAttribute("list", boardDao.selectList(vo));
	}else {
		model.addAttribute("list", boardDao.selectList());
	}
	return "board/list";
}

검색옵션 유지

  • 태그 사이에 조건 추가
    • 옵션이 여러 개일 때 사용하기 좋음
<!-- list.jsp -->
<select name="type" required>
	<option value="board_title" 
		<c:if test="${vo.type == 'board_title'}">selected</c:if>>
		제목
	</option>
    
	<option value="board_writer" 
		<c:if test="${vo.type == 'board_writer'}">selected</c:if>>
		작성자
	</option>
</select>

write

작성자 추가

  • 작성자: 회원기능이므로 세션에서 가져오면 됨(post로 넘기지 않아도 되는 정보)
    • session에 있는 회원 아이디를 작성자로 추가한 뒤 등록해야 함

      //Controller
      String memberId = (String)session.getAttribute(SessionConstant.ID);
      	boardDto.setBoardWriter(memberId);
      boardDao.insert(boardDto);

선택 안함 옵션

  • value를 empty String으로 설정
<!-- write.jsp -->
<select name="boardHead">
	<option value="" selected>선택안함</option>
	<option>공지</option>
	<option>정보</option>
	<option>유머</option>
</select>

redirect:detail

등록 후에 상세를 갈 때는 sql 구문 두 개 써야 함. 글 번호를 알아내야 하기 때문

  • 문제점: 현재 등록된 게시글의 번호를 모름

max 번호 조회

select * from board where 
board_no = (select max(board_no) from board)

글을 여러 명이 동시에 작성한다면 번호가 똑같이 나올 수 있음. 그럴싸해보이지만 이 방식을 사용할 수 없음

  • 등록하고 currentvalue 불러오는 것과 같은 방식으로 볼 수 있음

선조회 후등록

  • 해결책: 번호를 먼저 조회한 다음에 글을 등록
  • queryForObject로 번호만 가져오기
  • insert 구문도 바뀌어야 함
//BoardTest2
@SpringBootTest
public class BoardTest2 {
	@Autowired
	private JdbcTemplate jdbcTemplate;
	
	@Test
	public void test() {
		//번호 생성
		String sql = "select board_seq.nextval from dual";
		int boardNo = jdbcTemplate.queryForObject(sql, int.class);
	//null가능성 없으니까 int / Integer는 null가능
		System.out.println("boardNo = " + boardNo);
		
		sql = "insert into board("
				+ "board_no, board_title, board_content, "
				+ "board_writer, board_head) "
				+ "values(?, ?, ?, ?, ?)";
		
		Object[] param = {
				boardNo, "테스트", "테스트", "eclipse", null
		};
		
		jdbcTemplate.update(sql, param);
	}
}
  • 두 번째 sql문 수행 안하면 번호 날라감
  • 번호 생성과 등록을 각각의 메소드로 따로 만들어도 되고, 합쳐서 만들 수 있음
    → 번호는 등록할 때만 쓰니까 하나로 만들기로 결정
  • dual
    • 찍을 데가 마땅하지 않을 때 임시테이블 사용
//Dao
int insert2(BoardDto boardDto); //번호를 알아야 하니까 반환형 int
	//반환형은 오버로딩 조건에 포함되지 않으므로 이름을 다르게 해야 함
    
//DaoImpl
//시퀀스를 또 생성하는 구문을 넣으면 안됨
@Override
public int insert2(BoardDto boardDto) {
	//번호를 미리 생성한 뒤 등록하는 기능
	//번호 생성
	String sql = "select board_seq.nextval from dual";
	int boardNo = jdbcTemplate.queryForObject(sql, int.class);
				
	//등록
	sql = "insert into board("
			+ "board_no, board_title, board_content, "
			+ "board_writer, board_head) "
			+ "values(?, ?, ?, ?, ?)";
				
	Object[] param = {
						boardNo, boardDto};
				
	jdbcTemplate.update(sql, param);
		
	return boardNo;
}

조회수

  • 조회수 증가시키고 그 정보 불러와
    vs. 조회수 증가시킨 정보 읽어와
//Dao
void viewUp(int boardNo); //조회 수 +
BoardDto read(int boardNo); //조회 수 증가까지 포함해서 단일 조회

//DaoImpl
@Override
public void viewUp(int boardNo) {
	String sql = "update board "
			+ "set board_read = board_read + 1 "
			+ "where board_no = ?";
	Object[] param = {boardNo};
	jdbcTemplate.update(sql, param);
}

@Override
public BoardDto read(int boardNo) {
	this.viewUp(boardNo);
	return this.selectOne(boardNo);
}

//Controller
//1. 조회 수 증가시키고 데이터를 불러오기
//boardDao.viewUp(boardNo);
//model.addAttribute("dto", boardDao.selectOne(boardNo));

//2. 조회 수가 증가한 데이터를 읽어오기
model.addAttribute("dto", boardDao.read(boardNo));

조회수 중복 방지

중복 방지의 중요도에 따라 선택할 수 있는 방법이 달라짐. 중복 방지라는 것은 반드시 어딘가에 기록이 되어 있어야 하는데, 그 저장소에 따라 가성비가 달라지기 때문

  • 저장소 후보: DB, 세션
  • 세션 선택 (실무에서는 대게 쿠키를 이용한다고 함)
    • DB 연결이 가장 느림 - 세션이 아무리 무리가 가도 DB보다 빠름
  1. 세션에 내가 읽은 게시글의 번호를 저장할 수 있는 저장소를 구현
    후보(숫자 여러 개를 저장할 수 있는): int[], List, Set

    • int[]: 게시글 몇 개 읽을지 모르기 때문에 배열 크기 변경이 안되므로 배제
    • List: 순서 -> 읽은 게시글 순서 기억할 때 사용
    • Set: 순서x, 중복x -> 게시글 읽음 여부(중복 확인)를 알고 싶으므로 Set 선택

    정렬은 없어도 되므로 hash/tree set 무관
    세션에 저장할 이름: history로 지정

  2. 현재 history라는 이름이 없을지 모르므로 꺼내서 없으면 생성(최초 1회)
    session에 들어 있는 데이터는 모두 Object 타입 -> downcasting
    +) 컬렉션은 Set까지는 변환해주는데 안에 Integer가 있을지 모르겠으니 감안하라는 에러 뜸. supresswarnings 굳이 할 필요 업음(어쩔 수 없는 부분)

  3. 현재 글 번호를 읽은 적이 있는지 중복 검사
    set은 add, contains 모두 중복 검사를 함
    history에 현재 글 번호를 추가 -> add가 true이면 추가가 된 것(= 처음 읽는 글)

  4. 갱신된 저장소를 세션에 다시 저장

//1. Set 선택
//2. history 생성(최초 1회)
Set<Integer> history = (Set<Integer>)session.getAttribute("history");
if(history == null) {//history가 없다면 신규 생성
	history = new HashSet<>();
}
    		
//3. 현재 글 번호를 읽은 적이 있는지 중복 검사
if(history.add(boardNo)) {//add가 true 처음 읽는 글 
	model.addAttribute("dto", boardDao.read(boardNo));
}else {//추가가 안된 경우 -> 읽은 적이 있는 번호
	model.addAttribute("dto", boardDao.selectOne(boardNo));
}
    		
//4. 갱신된 저장소를 세션에 다시 저장
session.setAttribute("history", history);
return "board/detail";
}

에러 처리

컴퓨터에게는 에러가 아니지만 개발자/사용자 입장에서는 에러일 때 처리 방법

  1. 강제 에러 처리
    • 그냥 throw new Exception()쓰면 에러 원인을 알 수가 없음(내가 만든 에러상황이니까)
  2. 에러 처리 클래스를 따로 생성

사용자 지정 예외 클래스

  • JVM이 인지하지 못하지만 문제가 되는 상황을 알려주기 위한 커스텀 에러 클래스
  • 상속을 통한 자격 획득(Exception)
  • RuntimeException을 상속 받으면 추가 예외처리 생략 가능(Checking Exception)

대상이 없을 때

error 패키지 생성 > TargetNotFoundException 클래스 생성

  • 생성자
  • 기본 생성자는 lombok으로 생성
  • 예외 메시지까지 출력하고 싶다면 생성자 추가
@NoArgsConstructor
public class TargetNotFoundException extends RuntimeException {
	//메시지를 처리할 수 있는 생성자	
	public TargetNotFoundException(String message) {	
		super(message);
	}
}
  • exception을 상속받았다면 아래 delete 메소드에 throw exception 처리를 또 해줘야 함. RuntimeException을 상속받았기 때문에 추가 예외처리 불필요
@GetMapping("/delete")
public String delete(@RequestParam int boardNo) {
	if(boardDao.delete(boardNo)) {
		return "redirect:list";
	}else {//구문은 실행됐지만 대상이 없는(바뀐 게 없는) 경우
		//강제 예외 처리
		throw new TargetNotFoundException();
		//에러메시지 출력하고 싶으면 ()에 "문구" 추가
	}
}
  • edit[GET], edit[POST]에도 동일한 예외 처리 구문 추가

권한 설정

  1. 회원(관리자)만 등록/수정/삭제 페이지 접근
  2. [말머리: 공지]는 관리자만 작성 가능
  3. 자신이 작성한 글만 수정/삭제 가능
  4. 관리자는 모든 글 삭제 가능

1번

회원(관리자)만 등록/수정/삭제 페이지 접근
= 비회원은 조회/상세 제외하고 접근 불가
: 차단이 기본

  • /board/** 를 잠그고
  • /board/list, /board/detail만 허용

2번

공지사항은 관리자만 작성 가능

아래 두 가지 중에 정책을 정해야 함 -- i) 선택

i) 관리자는 모든 글을 다 쓸 수 있는지?
ii) 관리자가 작성한 글은 모두 공지로 처리?

* 기능 구현 이후에 권한 설정을 추가하기 때문에 이전에 넣은 데이터 중에 일반 유저가 공지 글을 쓴 경우가 있다면 문제가 될 수 있음 → 기능을 다 만들고 나면 전체 데이터를 삭제해야 함


jsp 수정

  • write, edit에서 loginGrade=='관리자'인 경우에만 말머리 옵션에 '공지'가 노출되도록 코드 추가

검사코드 위치

/board/write[POST], /board/edit[POST]에서 일반 회원이 ‘공지’글을 작성하는 것을 막아야 함

  1. 해당 컨트롤러 매핑에서 차단하도록 코드 구현
    • OOP(객체지향): 나한테 필요한 걸 내가 구현
    • 두 번 써야 함. 메소드를 만들면 스프링 혜택 못받음. 나중에 지우기도 힘듦
  2. 별도의 검사 인터셉터를 구현해서 적용
    - AOP(관점지향): 붙였다 떼었다 할 수 있음 ex. 인터셉터 다 지우면 모든 페이지 접근 가능
    - 모든 페이지마다 검사했었더라면 로그인 기능 해제해야할 때 하나씩 다 지워야 함
    - 스프링은 AOP: 마음이 바뀌면 새로 만든 것만 지우면 된다 주의

인터셉터

  • 이 검사기에는 회원만 들어오도록 회원 검사기보다 늦게 실행시켜야 함(Configuration에서)
    • 회원 여부를 별도로 검사하지 하지 않음
  • POST 방식이 아니라면 통과
    • 'GET이면 통과'로 코드를 구현하면 POST 뿐만 아니라 모든 방식을 검사하게 됨
    • getMethod()
      • POST 대문자로 써야 함
  • block case: parameter에 boardHead가 공지이면서 grade가 관리자가 아닌 경우(관리자면 통과, 아니면 403)
    • grade가 관리자면 통과
      • SessionConstant.GRADE 가져오기
    • grade가 관리자가 아니면 boardHead가 공지일 때 차단
      • 파라미터를 수동으로 가져와야 함 .getParameter()
//Interceptor
@Component //DB를 위한 도구에만 Repository 붙일 수 있음
public class MemberBoardPermissionCheckInterceptor implements HandlerInterceptor{
	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
			throws Exception {

		//0. POST 방식이 아니라면 통과
		if(!request.getMethod().equals("POST")) {
			return true;
		}
		
		//1. 관리자 여부 검사 - 관리자면 통과
		HttpSession session = request.getSession();
		String memberGrade = (String)session.getAttribute(SessionConstant.GRADE);
		//memberGrade가 null일 가능성이 없음
		if(memberGrade.equals("관리자")) {
			return true;
		}
		
		//2. 1번이 아니라면, boardHead라는 파라미터 값이 '공지'이면 차단
		//파라미터는 사용자가 요청한 정보에 있음
		String boardHead = request.getParameter("boardHead");
		if(boardHead != null && !boardHead.equals("공지")) {
			return true;
		}else {
			response.sendError(403);
			return false;
		}
	}
}

3, 4번

자신이 작성한 글만 수정/삭제 가능
관리자는 모든 글 삭제 가능


검사 페이지

  • /board/edit, /board/delete
  • 자기 자신이 작성한 글 판정
    • <c:when test="${loginId == dto.boardWriter}">
    • 컨트롤러에서 검사해서 결과만 넘겨도 되지만 변하는 값이 아니라서 el이 간편

인터셉터

작성자만 통과시키는 로직으로 구현. 인터셉터에서 DB에 접근하면 성능 저하 이슈가 있지만, 남의 글 수정/삭제가 더 큰 문제이므로 그대로 진행함(DB 접근을 위해 BoardDao 주입)

  1. 세션에서 회원 아이디 꺼내기
  2. boardNo 파라미터 꺼내기
  3. boardNo로 BoardDto 조회하기
  4. BoardDto의 작성자와 회원 아이디가 같은지 판정
  5. 같으면 통과, 다르면 차단
    • 예외: 삭제일 경우 관리자는 통과

관리자 전체 글 삭제 권한

관리자이면서 요청 URI가 /board/delete와 같으면 통과

  • uri: 광범위한 주소, url: 페이지
  • uri 출력 > 콘솔에서 uri 확인 가능
    • System.out.println("uri = " + request.getRequestURI());
//관리자인데 삭제하는 경우
String memberGrade = 
(String)session.getAttribute(SessionConstant.GRADE);
boolean isAdmin = memberGrade.equals("관리자"); 
boolean isDelete = request.getRequestURI().equals("/board/delete");

if(isAdmin && isDelete) {
	return true;
}

0개의 댓글