댓글등록 기능까지 이제 마무리하고

예전부터 적용하고 싶었던 구글 로그인 기능을 해보기로 하였다.

먼저 검색하면 정보가 많이 나오는데 OAuth 클라이언트 ID 를 발급받는것부터 시작하면된다.

 

나는 spring security 의 userdetails를 이미 구현한 상태였으므로

이걸 OAuth 로그인과 어떻게 잘(?) 적용하면 될지 많이 헤맸었다 ..ㅎㅎ

 

구글로그인과 UserDatils 연동을 위해 중요한 부분만 정리하였다!

 

 

CustomOAuth2UserService
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService  {

    private final MemberRepository memberRepository;
    private final HttpSession httpSession;


    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        // 현재 로그인 진행 중인 서비스를 구분하는 코드
        // 이후에 여러가지 추가할 때 네이버인지 구글인지 구분
        String registrationId = userRequest.getClientRegistration().getRegistrationId();

        // oauth2 로그인 진행 시 키가 되는 필드값 (=Primary Key)
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();


        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        Member member = saveOrUpdate(attributes);

        System.out.println(member.getNickname());
        System.out.println(member.getPassword());

        return new OAuthUser(member,  attributes.getAttributes());
    }

    private Member saveOrUpdate(OAuthAttributes attributes) {
        Member member = memberRepository.findByEmail(attributes.getEmail())
        .map(entity -> entity.update(attributes.getName(),attributes.getPicture()))
                .orElse(attributes.toEntity());
        member.setPassword("password");

        return memberRepository.save(member);
    }
}

먼저 CustomOAuth2UserService를 작성한다. 여기서는 일반 OAuth 예제소스와 별로 다른건 없는데 

return해주는 OAuthUser 객체가 핵심이다..

 

 

OAuthUser 
@Getter
public class OAuthUser implements OAuth2User, UserDetails {

    private Collection<? extends GrantedAuthority> authorities;
    private Map<String, Object> attributes;
    private Member member;

    public OAuthUser(Member member) {
        List<GrantedAuthority> authorities = Collections.
                singletonList(new SimpleGrantedAuthority("ROLE_USER"));

        this.member = member;
        this.authorities = authorities;


    }

    public OAuthUser(Member member, Map<String, Object> attributes) {
        this.attributes = attributes;
        List<GrantedAuthority> authorities = Collections.
                singletonList(new SimpleGrantedAuthority("ROLE_USER"));

        this.member = member;
        this.authorities = authorities;
    }

    @Override
    public Map<String, Object> getAttributes() {
        if (this.attributes == null) {
            this.attributes = new HashMap<>();
            this.attributes.put("name", this.getName());
        }
        return attributes;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getName() {
        return member.getNickname();
    }

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return member.getNickname();
    }

    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override
    public boolean isEnabled() {
        return false;
    }


}

UserDatails객체와 연동해주기위해 DefaultOAuth2User 가 아닌 별도로 OAuthUser 클래스를 만들어줬다.

OAuth2User와 UserDetails를 implement 해줘야 한다.

 

 

spring security - loadUserByUsername
	@Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {

        Optional<Member> member = memberRepository.findByEmail(email);
        Member mem = member.get();

        if (mem == null) {
            throw new UsernameNotFoundException(email);
        }

        return new OAuthUser(mem);
    }

로그인 인증을 위해 구현했었던 loadUserByUsername 부분이다.

원래는 UserDetail만 구현한 객체를 리턴해줬었는데 새로 생성한 OAuthUser를 리턴해주도록 변경하였다.

 

 

OAuth2User, UserDetails 를 함께 구현하는것이 중요하였다.!!

댓글 기능을 추가하기 위해 Rest 방식으로 구현하였다.

 

서버쪽 CURD 기능 구현
@Controller
@RequiredArgsConstructor
@RequestMapping("/comment")
public class CommentController {

    private final CommentService commentService;

    // CREATE
    @PostMapping("/write/{itemId}")
    public ResponseEntity<List<Comment>> writeComment(@AuthUser Member member, @PathVariable Long itemId, @RequestBody CommentForm commentForm) {
        commentService.saveComment(member, commentForm, itemId);

        return new ResponseEntity<>(commentService.Listcomment(itemId), HttpStatus.CREATED);
    }

    // READ
    @RequestMapping(value = "/list/{itemId}", method = {RequestMethod.GET})
    public ResponseEntity<List<Comment>> listComment(@PathVariable Long itemId, Model model) {
        Album item = new Album();
        item.setComments(commentService.Listcomment(itemId));

        model.addAttribute("item", item);

        return new ResponseEntity<>(commentService.Listcomment(itemId), HttpStatus.CREATED);

       // return "modal :: comTable";
    }

    //UPDATE
    @PutMapping("/update/{itemId}/{commentId}")
    public ResponseEntity<List<Comment>> modifyComment(@AuthUser Member member, @PathVariable Long itemId, @PathVariable Long commentId, @RequestBody CommentForm commentForm) {

        commentService.updateComment(member, commentForm, itemId, commentId);

        return new ResponseEntity<>(commentService.Listcomment(itemId), HttpStatus.CREATED);
    }

    //DELETE
    @RequestMapping(value = "/delete/{itemId}/{commentId}", method = {RequestMethod.POST})
    public ResponseEntity<List<Comment>> addComment(@AuthUser Member member, @PathVariable Long itemId, @PathVariable Long commentId) {
        commentService.deletecomment(itemId, commentId);

        return new ResponseEntity<>(commentService.Listcomment(itemId), HttpStatus.CREATED);
    }
}
@Service
@Transactional
@RequiredArgsConstructor
public class CommentService {

    private final CommentRepository commentRepository;
    private final AlbumRepository albumRepository;

    public void saveComment(Member member, CommentForm commentForm, Long itemId) {

        Comment comment = new Comment();
        comment.setContent(commentForm.getContent());
        comment.setMember(member);
        comment.setRgstDate(LocalDateTime.now());

        Optional<Album> album = albumRepository.findById(itemId);
        comment.setAlbum(album.get());
        album.get().addOpnCnt();

        commentRepository.save(comment);
    }

    @Transactional(readOnly = true)
    public List<Comment> Listcomment(Long itemId) {
        // Optional<Album> album = albumRepository.findById(itemId);
        List<Comment> comments = commentRepository.findAllByAlbumId(itemId);
        return comments;
    }


    public void deletecomment(Long itemId, Long commentId) {
        commentRepository.deleteById(commentId);
        Optional<Album> album = albumRepository.findById(itemId);
        album.get().deleteOpnCnt();

    }

    public List<Comment> updateComment(Member member, CommentForm client, Long itemId, Long commentId) {
        Optional<Comment> server = commentRepository.findById(commentId);

        server.ifPresent(a -> {
            a.setContent(client.getContent());
            commentRepository.save(a);
        });

        List<Comment> comments = commentRepository.findAllByAlbumId(itemId);
        return comments;

    }
}

먼저 서버쪽 소스코드이다. 

 

 

댓글 작성 폼
<div class="col-12">
    <form>
        <div class="col-12">
            <input type="hidden" id="itemid" name="itemid" th:value="${item.id}"/>
            <div id="comArea" style="width: 88%;display: inline-grid;">
                <textarea id="comment" name="comment" rows="1"  action="#"></textarea>
            </div>
            <input type="button" class="button-sm" id="rgstBtn" onclick="writeComment(this.form);" value="등록"/>
        </div>
    </form>
</div>

 

스크립트
function writeComment(e){
	const comment = e.elements.comment.value;
	const itemid = e.elements.itemid.value;
    
	$.ajax({
		url: '/comment/write/' + itemid,
		contentType : "application/json; charset=utf-8",
		dataType: 'text',
		data : JSON.stringify({'content' : comment, itemid : itemid}),
		type: 'POST',
		success: function onData (data) {
			drawComment(JSON.parse(data), itemid);
			e.elements.comment.value='';
		},
		error: function onError (error) {
			console.error(error);
		}
	});
}


function drawComment(array, itemid) {
    var commentsHtml = "";
    $(array).each(function (idx, com) {

        /*<![CDATA[*/
        var userid= [[${#authentication.principal.member.id}]];
        /*]]>*/

        var itemId = itemid;
        commentsHtml +=
            `<tr>
                    <td id="comNickname">${com.member.nickname}</td>
                    <td id="comContent">${com.content}</td>
                    <td id="comrgstDate" >${moment(com.rgstDate).format('YYYY-MM-DD HH:mm')}</td>`;
       		 if(com.member.id == userid){
         commentsHtml +=
                `<td><a href="javascript:updateComment('${com.id}', '${itemId}');"><i class="fas fa-pencil-alt"></i></a>
                            <a href="javascript:deleteComment('${com.id}', '${itemId}');" style="padding-left:5px"><i class="fas fa-trash-alt"></i></a>
                        </td>`;
        }
        else{
            commentsHtml += '<td></td>';
        }
        commentsHtml +='</tr>';

    });
    $("#veiwAlbumModal" + itemid).find("#comTableTbody").html(commentsHtml);
}

 

글 등록 버튼 클릭시 writeComment()가 실행되어 글이 등록되며 성공시 최신 댓글 목록을 가져온다.

drawComment() 에서는 리턴받은 댓글목록을 다시 그려준다.

 

댓글 기능 실행화면

 

 

 

 

사실 여기서 쫌 더 쉽게 댓글목록을 그려줄수 없을 지 고민을 하였었다.

그러면서 원래는 아래와 같은 방법으로도 했었었다.

 // server
@RequestMapping(value= "/list/{itemId}", method = {RequestMethod.GET})
    public String addComment(@PathVariable Long itemId, Model model) {

        Album item = new Album();
        item.setComments(commentService.Listcomment(itemId));

        model.addAttribute("item", item);

        return "modal :: comTable";
    }
    
 
 // client
 $("#veiwAlbumModal" +itemid ).find("#comTable").replaceWith(data);

server 측에서 return "modal :: comTabel" 을 하고 client 에서 replaceWith() 으로 해줬더니 바로 데이터가 쉽게 그려졌다. ajax 방식으로 특정부분의 데이터만 refresh 할때 위 방법을 쓰면 좋을 것 같다.

 

하지만 이 방식은 rest 형식에 맞지 않기에 직정 table을 그려주는 방식으로 수정하였다.

 

 

수정기능은 액션을 구현해 놓았는데 클라이언트쪽에서 수정화면을 어떻게 보여줄지 고민중이다..!

 

 

=> github

 

 

 

thymeleaf에서fragment를 나눈후 각 페이지에서 javascript를 추가하였는데

무슨 이유인지 실행되지가 않았다.. 찾아보니 레이아웃을 설정할때 footer나 header 뿐만 아니라

스크립트가 들어갈 위치도 설정해주어야 하였다.

 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">

<head th:fragment="configFragment">
	<th:block layout:fragment="script"></th:block>
 </head>
</html>

나같은 경우에는 confing fragment 하단에 다음과 같이 spript 가 들어갈 fragment를 설정해주었다.

 

 

<body>
<div layout:fragment="content">
    <script type="text/javascript">
        $("#btn").click(function(){
            
        });  
    </script>
</div>
</body>

다음 스크립트 내용을 layout:fragment="content" 안쪽에 추가해주어야 한다.

레이아웃 바깥쪽에 추가하여 완전 삽질했었다. 하하..

보통 웹 페이지를 개발할 때 header, footer 를 나누어 개발하곤 한다.

타임리프에서도 마찬가지로 중복된 부분을 fragment로 나누어 사용할 수있다. 

 

        <dependency>
            <groupId>nz.net.ultraq.thymeleaf</groupId>
            <artifactId>thymeleaf-layout-dialect</artifactId>
        </dependency>

thymeleaf 에서 레이아웃을 나누어 사용할때 위와 같은 의존성이 추가로 필요하다.

 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<footer th:fragment="footerFragment">
        <div class="footer">
        </div>
</footer>
</html>
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<header th:fragment="headerFragment" id="header">
</header>

 

th:fragment 를 이용하여 각 fragment 페이지에서 선언하여 footer, header 등으로 나누어 설정한다.

 

 

<!DOCTYPE html>
<html lagn="ko" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
    <head th:replace="fragments/config :: configFragment"></head>
    <header th:replace="fragments/header :: headerFragment"></header>
    <body class="is-preload">
        <div layout:fragment="content"></div>
    </body>
    <footer th:replace="fragments/footer :: footerFragment"></footer>
</html>

다음 layout을 설정해야 하는데 xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout 를 추가하여 설정할 수

있다.

나같은 경우에는 config, header, footer 로 fragment를 설정하였다.

레이아웃페이지에서는  th:replace 를 이용하여 파일경로에 해당하는 fragment를 가져오게 된다.

content 페이지는 layout:fragment 로 설정하였다.

 

 

 

 

thymeleaf를 사용하며 헤맷던것중 하나는 javascript를 사용할 때였다.

javascript를 사용하기 위해 따로 설정해 주어야 할것이 있는데 이건 다음 글로 나누어 정리할 예정이다..!

 

 

 

 

spring jpa 및 security 를 공부하여 tymeleaf도 써보게 되었는

몇개월간 사용해보면서 느꼈던 점 및 헤맸던 내용?을 간단하게 정리해 보려고 한다.

 

바로바로 정리를 하려고 했는데 완벽하게 정리해서 올려야 겠다는 강박감(?) 으로 미루고 미루다보니..

그래서 더욱 글쓰기가 어려워져서 그냥 이제부터 간단하게 라도 바로바로 써보고자 한다.

 

먼저 타임리프의 기본 문법등은 아래 문서를 참조하면 된다.

https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html

 

 

Thymeleaf와 spring security

먼저 타임리프를 사용하며 신기했던점은 

thymeleaf-extras-springsecurity 를 이용하여 타임리프에서 스프링 시큐리티를 쉽게 이용할 수 있다는 점이다.

타임리프에서 인증된 사용자 정보 및 권한 등을 간단하게 화면에 나타낼 수 있었다.

상세한 내용은 아래 문서를 참조하였다.

https://github.com/thymeleaf/thymeleaf-extras-springsecurity

 

        <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-springsecurity5</artifactId>
        </dependency>
<html xmlns:sec="http://www.thymeleaf.org/extras/spring-security">

pom.xml 에 의존성을 추가하고 html에 네임스페이스를 추가해주었다.

 

 

   <nav class="account">
      <ul>
            <li sec:authorize="!isAuthenticated()" data-bs-toggle="modal" data-bs-target="#exampleModal"><a href='javascript:void(0);'>로그인</a></li>
            <li sec:authorize="!isAuthenticated()"><a th:href="@{/sign-up}">회원가입</a></li>
            <li sec:authorize="isAuthenticated()"><a th:href="@{/regeisterAlbum}">글쓰기</a></li>
        </ul>
    </nav>

sec:authorize 를 이용하여 사용자 인증여부에 따라 로그인, 회원가입 및 글쓰기 메뉴가 나타나도록 하였다.

 

 

<div sec:authorize="hasRole('ROLE_ADMIN')">
  Admin Page
</div>

또한, role 조건에 따라 화면을 바꿔줄 수 있도록 hasRole()  기능등을 제공해주고 있다.

 

 

<h3 th:text="${#authentication.name}">name</h3>

${#authentication.name} 을 이용하여 인증된 사용자 이름을 바로 가져올 수 있어 편리했다.

 

 

 

 

WEB서버에 있는 이미지는 <img src='icon.png' /> 와 같이 간단하게 경로로 가져올 수 있지만

WAS에 있는 이미지를 가져오기 위해선 별도의 작업이 필요하기에

업로드한 파일을 보여주기 위해 ByteArray 를 이용하기로 하였다..

 

    @Value("${attachement.repository}")
    private String repository;

    @GetMapping(value = "/image/view", produces = MediaType.IMAGE_PNG_VALUE)
    public @ResponseBody
    byte[] getImage(@RequestParam("path") String path)
            throws IOException {

        FileInputStream fis = null;
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        
        String fileDir = Paths.get(repository, path).toString();

        try {
            fis = new FileInputStream(fileDir);
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }

        int readCount = 0;
        byte[] buffer = new byte[1024];
        byte[] fileArray = null;

        try {
            while ((readCount = fis.read(buffer)) != -1) {
                baos.write(buffer, 0, readCount);
            }
            fileArray = baos.toByteArray();
            fis.close();
            baos.close();
        } catch (IOException e) {
            throw new RuntimeException("File Error");
        }
        return fileArray;
    }

파일경로에 해당하는 이미지를 바이트배열로 변환해주는 간단한 코드이다.

 

 

<img th:src="${'/image/view?path=' + att.path}" />

프론트단에서는 액션을 실행할때 파일경로를 함께 넘겨주게 된다.

스프링부트 및 JPA로 파일 다중업로드 기능을 구현해보면서 정말 많이 헤맸다..! 

구현하면서 고려했던 점들은 아래와 같다.

 

1. 파일을 여러개 선택하여 등록할 수 있는 기능

2. 업로드한 이미지 미리보기 기능

3. 파일을 서버에 전송할 때 처리 등..(파일이름, 저장경로 등)

 

여기서 파일업로드 관련된 여러 플러그인을 소개하고 있다.

http://www.bestdevlist.com/jquery-file-upload-plugins/

 

 

파일을 쉽게 업로드할 수 있고 미리보기 기능 등을 고민하고 찾아보다가

드라그앤드랍기능도 제공하는 jquery upload file 라이브러리를 사용해보고자 하였다!

https://plugins.jquery.com/uploadfile/

http://hayageek.com/docs/jquery-upload-file.php

 

 

jquery upload file
        $(document).ready(function()
        {
            var fileuploader =  $("#fileuploader").uploadFile({
                url:"/upload",
                fileName:'uploadFile',
                multiple:true,
                dragDrop:true,
                showDelete: true,
                showPreview:true,
                previewHeight: "100px",
                previewWidth: "100px",
                autoSubmit : false,
                afterUploadAll: function(obj)
                {
                    var form  = $("#albumForm");
                    var result = new Array();
                    for(var i= 0; i<obj.responses.length; i++){
                     
                        var att = JSON.parse(obj.responses[i])[0];
                        var tmp =new Object();
                        tmp.path =  att.path;
                        tmp.saveName =  att.saveName;
                        tmp.fileName =  att.fileName;
                        result.push(tmp);
                    }

                    var re = new Object();
                    re.attachements = JSON.stringify(result);
                    $("#attachements").val(JSON.stringify(result));

                    var data3 = form.serializeArray();

                    $("#albumForm").submit();
                }
            });

            $("#rgstBtn").click(function()
            {
                fileuploader.startUpload();
            });
        });

먼저 프론트단은 다음과 같이 구현하였다.

글 등록 버튼을 누르면 파일이 먼저 업로드 되고, 업로드된 파일정보를 리턴받아

그 정보와 함께 글 등록 액션이 실행되는 구조이다.

 

 

/upload 파일 처리 action
    private final String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyMMdd"));

    @Value("${attachement.repository}")
    private String repository;

    private final String getRandomString() {
        return UUID.randomUUID().toString().replaceAll("-", "");
    }

    @RequestMapping(value = "/upload")
    public void upload(HttpServletResponse response, HttpServletRequest request, 
          @RequestParam("uploadFile") MultipartFile[] uploadFiles) throws IOException {
        String uploadPath = Paths.get(repository, today).toString();
        JSONArray arr = new JSONArray();

        System.out.println(uploadFiles);
        for (MultipartFile uploadFile : uploadFiles) {

            File dir = new File(uploadPath);
            if (dir.exists() == false) {
                dir.mkdirs();
            }

            String extension = FilenameUtils.getExtension(uploadFile.getOriginalFilename());
            String saveName = getRandomString() + "." + extension;

            File target = new File(uploadPath, saveName);
            JSONObject obj = new JSONObject();

            uploadFile.transferTo(target);
            obj.put("path", today + "/" +saveName);
            obj.put("saveName", saveName);
            obj.put("fileName", uploadFile.getOriginalFilename());
            obj.put("size", uploadFile.getSize());

            arr.add(obj);
        }
        response.getWriter().write(arr.toJSONString());

    }

업로드 액션에서는 디렉토리 생성 및 파일 업로드를 한 후 정보를 리턴해주도록 하였다.

파일을 서버에 업로드시 이름이 겹칠 수 있기 때문에 랜덤문자로 파일이름을 설정해주었다.

 

 

글 등록 action
   @PostMapping("/regeister")
    @ResponseBody
    public String registerAlbum(@AuthUser Member member, AlbumForm albumForm) {

        Album album = new Album();
        album.setAttachements(albumForm.getAttachements());
        for (Attachement att : albumForm.getAttachements()) {
            album.addAttachement(att);
        }
        album.setContent(albumForm.getContent());
        album.setMember(member);
        album.setRgstDate(LocalDateTime.now());


        Album newAlbum = albumRepository.save(album);

        return "redirect:";
    }

다음은 등록버튼을 눌렀을 때 실행되는 액션이다. 파일배열을 받아서 글 정보와 함께 디비에 한번에 저장된다.

 

 

실행했을 때 화면이다.ㅎㅎ

 

 

지금보면 간단해 보이지만 여기서도 많은 삽질이..

파일 정보들을  albumForm.getAttatchements 로 한번에 가져오고 싶었는데 값이 넘어오질 않았다..

AlbumForm 객체 안에 attachements 배열객체를 선언했는데도 말이다...

컨버터를 따로 구현해주어 해결해였다.

JPA는 컨버터가 자동으로 등록되어 있는걸로 알고있는데..

흠...일단 이렇게 해결하였지만 더 알아봐야겠다..!

 

 

 

 

 

https://github.com/nyju/withplant.git

 

 

 

 

+ Recent posts