개발자일지/Spring

게시판 만들기

어쩌다한번 2022. 3. 31. 08:45

그간 배운것들을 활용해서 간단한 게시판을 만든다.

 

요구사항

- 제목, 내용, 작성자, 글종류(셀렉트박스), 사진을 담은 게시글을 등록,조회,수정,삭제 할 수 있다.

- 게시글 등록,수정시 양식을 지키지 않으면 제출되지 않고 수정하라는 메세지가 뜬다.

(필수 : 제목, 작성자, 글종류)

- 메세지는 직접 작성하지않고 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");
}