그간 배운것들을 활용해서 간단한 게시판을 만든다.
요구사항
- 제목, 내용, 작성자, 글종류(셀렉트박스), 사진을 담은 게시글을 등록,조회,수정,삭제 할 수 있다.
- 게시글 등록,수정시 양식을 지키지 않으면 제출되지 않고 수정하라는 메세지가 뜬다.
(필수 : 제목, 작성자, 글종류)
- 메세지는 직접 작성하지않고 messages.properties에서 가져다 쓴다.
- 전체 게시글을 조회할 수 있고 글 번호, 제목, 작성자, 작성시간을 확인할 수 있다.
- 로그인 하지 않은 사용자는 게시글 관련 페이지에 접근할 수 없다.
- 올바르지 못한 요청이나 서버오류시 미리 작성된 오류페이지를 보여준다.
- 데이터베이스는 메모리를 사용하되 다른 데이터베이스를 사용할 수 있도록 유연성을 확보한다.
Thymleaf, Lombok, Validation 사용
회원
id, 로그인id, 사용자이름, 비밀번호를 가지는 회원 엔티티와 생성자
@Data
public class Member {
private long id;
@NotEmpty
private String loginId;
@NotEmpty
private String userName;
@NotEmpty
private String password;
public Member(String loginId, String userName, String password) {
this.loginId = loginId;
this.userName = userName;
this.password = password;
}
}
회원 리포지토리는 인터페이스를 만들어서 구현했다. 메모리를 사용했는데 나중에 DB를 연동할거다.
회원 저장, id로 조회, 로그인 id로 조회, 사용자이름으로 조회, 전체조회가 가능하다.
@Slf4j
@Repository
public class MemoryMemberRepository implements MemberRepository{
private static Map<Long, Member> store = new ConcurrentHashMap<>();
private static long sequence = 0L;
@Override
public Member save(Member member) {
member.setId(++sequence);
log.info("save : member={}",member);
store.put(member.getId(), member);
return member;
}
@Override
public Member findById(Long id) {
return store.get(id);
}
@Override
public Optional<Member> findByLoginId(String loginId) {
return findAll().stream()
.filter(member -> member.getLoginId().equals(loginId))
.findFirst();
}
@Override
public Optional<Member> findByUserName(String userName) {
return findAll().stream()
.filter(member -> member.getUserName().equals(userName))
.findFirst();
}
@Override
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
public void clearStore(){store.clear();}
}
로그인
로그인을 담당하는 로그인 서비스
로그인id와 비밀번호를 받아서 리포지토리에서 확인한 뒤 결과를 반환한다.
@Service
@RequiredArgsConstructor
public class LoginService {
private final MemberRepository memberRepository;
public Member login(String loginId, String password){
return memberRepository.findByLoginId(loginId)
.filter(member -> member.getPassword().equals(password))
.orElse(null);
}
}
로그인 컨트롤러에서 사용할 로그인 폼, 로그인에서는 로그인id와 비밀번호만 필요하기 때문이다.
@Data
public class LoginForm {
@NotEmpty
private String loginId;
@NotEmpty
private String password;
}
로그인 컨트롤러
입력한 로그인 정보를 확인해서 일치하면 세션에 저장 후 '이전에 요청했던 로그인이 필요한 페이지'로 리다이렉트한다
일치하지않으면 오류메세지를 띄우고 다시 로그인폼을 반환한다.
세션정보를 지우는 로그아웃도 구현했다.
@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
private final LoginService loginService;
@GetMapping("/login")
public String loginForm(@ModelAttribute("loginForm") LoginForm form){
return "login/loginForm";
}
@PostMapping("/login")
public String login(@Validated @ModelAttribute LoginForm form, BindingResult result, HttpServletRequest request, @RequestParam(defaultValue = "/") String redirectURL){
//입력오류시
if (result.hasErrors()){
return "login/loginForm";
}
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
//로그인 오류시
if (loginMember==null){
result.reject("loginFail","로그인에 실패했습니다...아이디와 비밀번호를 확인해주세요.");
return "login/loginForm";
}
//성공처리, 세션에 저장
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER,loginMember);
//로그인홈으로
return "redirect:"+ redirectURL;
}
@PostMapping("/logout")
public String logout(HttpServletRequest request){
HttpSession session = request.getSession(false);
if (session!=null){
session.invalidate();
}
return "redirect:/";
}
}
자주 사용되는 단어를 상수로 했다.
public class SessionConst {
public static final String LOGIN_MEMBER = "loginMember";
}
홈 컨트롤러
세션에서 멤버검색 후 멤버가 확인되면 로그인전용 홈으로, 아니면 그냥 홈으로 이동시킨다.
@Controller
public class HomeController {
@GetMapping("/")
public String home(@SessionAttribute(name = SessionConst.LOGIN_MEMBER,required = false)Member loginMember, Model model){
if (loginMember == null){
return "home";
}
model.addAttribute("member",loginMember);
return "loginHome";
}
}
회원 가입
@Controller
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping("/join")
public String joinForm(@ModelAttribute("member")Member member){
return "members/joinForm";
}
@PostMapping("/join")
public String save(@Valid @ModelAttribute Member member, BindingResult result){
if (result.hasErrors()){
return "members/joinForm";
}
memberRepository.save(member);
return "redirect:/";
}
}
게시글
게시글 엔티티. id와 사용자이름, 제목, 내용, 글 종류 그리고 이미지 파일을 가진다.
package mytoy.simpleforum.board;
import lombok.Data;
import java.sql.Time;
@Data
public class Board {
private long id;
private String userName;
private String title;
private String text;
private String type;
private UploadFile imageFile;
}
이미지파일의 정보를 담는 객체이며 업로드시 이름과 저장시 이름 두개를 가진다
@Data
public class UploadFile {
private String uploadFileName;
private String storeFileName;
public UploadFile(String uploadFileName, String storeFileName) {
this.uploadFileName = uploadFileName;
this.storeFileName = storeFileName;
}
}
게시글 리포지토리
회원 리포지토리와 마찬가지로 인터페이스를 통해 구현했다.
저장, 조회, 수정기능을 가진다.
@Slf4j
@Repository
public class MemoryBoardRepository implements BoardRepository{
private static Map<Long, Board> store = new ConcurrentHashMap<>();
private static long sequence = 0L;
@Override
public Board save(Board board) {
board.setId(++sequence);
log.info("save : board={}",board);
store.put(board.getId(), board);
return board;
}
@Override
public Board findById(Long id) {
return store.get(id);
}
@Override
public List<Board> findAll() {
return new ArrayList<>(store.values());
}
@Override
public void update(Long id, Board updateParam) {
Board findBoard = findById(id);
findBoard.setTitle(updateParam.getTitle());
findBoard.setText(updateParam.getText());
findBoard.setType(updateParam.getType());
findBoard.setImageFile(updateParam.getImageFile());
}
@Override
public void delete(Long id) {
store.remove(id);
}
}
파일 저장
파일을 경로에 저장하고 업로드 파일명과 저장용 파일명을 가지는 UploadFile을 반환한다.
multipartFile.transferTo를 통해서 저장용 파일명으로 저장한다.
업로드 파일명으로 올리면 파일명 중복문제가 발생해서 덮어씌워질 수 있기 때문이다.
@Component
public class FileStore {
@Value("${file.dir}")
private String fileDir;
public String getFullPath(String filename){
return fileDir+filename;
}
public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
if (multipartFile.isEmpty()){
return null;
}
String originalFilename = multipartFile.getOriginalFilename();
String storeFileName = createStoreFileName(originalFilename);
multipartFile.transferTo(new File(getFullPath(storeFileName)));
return new UploadFile(originalFilename,storeFileName);
}
public String createStoreFileName(String originFileName){
String uuid = UUID.randomUUID().toString();
String ext = extractedExt(originFileName);
return uuid + "." + ext;
}
public String extractedExt(String originFileName){
int pos = originFileName.lastIndexOf(".");
return originFileName.substring(pos+1);
}
}
게시글 컨트롤러
특이점으로는 LoginArgumentResolver를 만들어서 게시글이 저장될 때 로그인 사용자 이름이 저장되도록 했다
또 그냥 사진만 업로드하면 사진이 보이지 않기 때문에 사진이 보일 수 있도록 사진의 주소도 맵핑했다
@Slf4j
@Controller
@RequiredArgsConstructor
public class BoardController {
private final BoardRepository boardRepository;
private final FileStore fileStore;
@ModelAttribute("textTypes")
public List<BoardType> boardTypes() {
List<BoardType> boardType = new ArrayList<>();
boardType.add(new BoardType("NOTICE", "공지"));
boardType.add(new BoardType("NORMAL", "일반글"));
boardType.add(new BoardType("SECRET", "비밀글"));
return boardType;
}
@GetMapping("/boards")
public String boards(Model model){
List<Board> boards = boardRepository.findAll();
model.addAttribute("boards",boards);
return "/boards/boards";
}
@GetMapping("/boards/{boardId}")
public String board(@PathVariable Long boardId,Model model){
Board board = boardRepository.findById(boardId);
model.addAttribute("board",board);
return "/boards/board";
}
@GetMapping("/boards/add")
public String addForm(@ModelAttribute("board") BoardSaveForm form,@Login Member loginMember){
form.setUserName(loginMember.getUserName());
return "boards/addForm";
}
@PostMapping("/boards/add")
public String addBoard(@Valid @ModelAttribute("board") BoardSaveForm form, BindingResult result, RedirectAttributes redirectAttributes, @Login Member loginMember) throws IOException {
if (result.hasErrors()){
return "boards/addForm";
}
log.info("form={}",form.getTitle());
log.info("form={}",form.getAttachFile());
UploadFile uploadFile = fileStore.storeFile(form.getAttachFile());
Board board = new Board();
//로그인 사용자이름 사용
board.setUserName(loginMember.getUserName());
board.setTitle(form.getTitle());
board.setText(form.getText());
board.setType(form.getType());
board.setImageFile(uploadFile);
boardRepository.save(board);
redirectAttributes.addAttribute("boardId",board.getId());
return "redirect:/boards/{boardId}";
}
@GetMapping("/boards/{boardId}/edit")
public String editForm(@PathVariable Long boardId, Model model) {
Board board = boardRepository.findById(boardId);
model.addAttribute("board", board);
return "boards/editForm";
}
@PostMapping("/boards/{boardId}/edit")
public String editBoard(@PathVariable Long boardId, @ModelAttribute("board") BoardUpdateForm updateBoard) {
Board board = new Board();
board.setTitle(updateBoard.getTitle());
board.setText(updateBoard.getText());
boardRepository.update(boardId, board);
return "redirect:/boards/{boardId}";
}
@ResponseBody
@GetMapping("/images/{filename}")//보안에 취약하므로 체크로직 필수
public Resource showImage(@PathVariable String filename) throws MalformedURLException {
return new UrlResource("file:" + fileStore.getFullPath(filename));
}
}
LoginArgumentResolver
앞으로 @Login을 통해서 로그인정보를 편하게 가져올 수 있다.
Target(ElementType.PARAMETER) : 파라미터에서만 사용가능
Retention(RententionPolicy.RUNTIME) : 런타임까지 사용 가능
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}
supportsParameter : @Login어노테이션이 있고 타입이 Member면 사용 가능
resolveArgument : 컨트롤러 호출 직전에 호출되어서 세션에 있는 로그인 회원 정보인 member를 찾아서 반환
public class LoginArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean hasParameterAnnotation = parameter.hasParameterAnnotation(Login.class);
boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());
return hasParameterAnnotation && hasMemberType;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = (HttpServletRequest)webRequest.getNativeRequest();
HttpSession session = request.getSession(false);
if (session==null){
return null;
}
return session.getAttribute(SessionConst.LOGIN_MEMBER);
}
}
이후 WebConfig를 만들어서 다음을 추가해줘야한다
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LoginArgumentResolver());
}
게시글도 저장용 폼을 따로 사용한다
저장폼에서 MultipartFile 타입으로 파일을 받기 때문에 UploadFile타입을 사용하지 않는다.
@Data
public class BoardSaveForm {
@NotEmpty
private String userName;
@NotEmpty
private String title;
private String text;
@NotEmpty
private String type;
private MultipartFile attachFile;
}
인터셉터
세션에서 정보가 확인되지 않는 사용자는 로그인 페이지로 리다이렉트 시킨다
리다이렉트된 페이지 전에 요청한 URI도 덧붙여서 로그인에 성공하면 접근을 시도했던 페이지로 이동한다
@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER)== null){
response.sendRedirect("/login?redirectURL="+requestURI);
return false;
}
return true;
}
}
WebConfig에 인터셉터를 추가하고 화이트리스트를 작성한다.
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginCheckInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns(
"/", "/members/join", "/login", "/logout",
"/css/**", "/*.ico", "/error");
}
'개발자일지 > Spring' 카테고리의 다른 글
1. Spring Framework의 특징과 싱글톤 패턴 (0) | 2022.06.14 |
---|---|
0. SOLID와 Spring (0) | 2022.06.13 |
Spring MVC 2편 - 검증 (Validation) 1 (0) | 2022.03.21 |
Spring MVC 2편 - Thymeleaf (0) | 2022.03.17 |
Spring MVC 복습 - 간단 게시판 만들기 (0) | 2022.03.15 |