Home Project Lab 11. 게시판 개발(JPA Audit) - 7
Post
Cancel

Project Lab 11. 게시판 개발(JPA Audit) - 7

JPA Audit

  • 현재 프로젝트 내 Domain은 CommonAudit(일반적인 데이터) 또는 AttachedFileAudit(첨부 파일 데이터)를 상속받는다. 이를 통해서 생성일자, 수정일자, 작성자, 수정자와 같이 모든 Table에서 공통적으로 사용하고 있는 컬럼과 1:1 매칭되도록 하였다.
  • Table에서 공통적으로 사용하고 있는 컬럼에 데이터를 자동으로 넣어주는 기능인 Spring Data JPA의 JPA Audit을 사용하여, CommonAudit(일반적인 데이터) 또는 AttachedFileAudit(첨부 파일 데이터)의 멤버 변수를 자동으로 초기화 구현하였다.

출처: https://velog.io/@conatuseus/2019-12-06-2212-%EC%9E%91%EC%84%B1%EB%90%A8-1sk3u75zo9

JPA Audit 설계

  • Domain 클래스 멤버 필드에 생성일과 수정일을 자동으로 입력하는 @CreatedDate과 @LastModifiedDate 애노테이션을 선언하면 된다.
  • 하지만 생정자 및 수정자를 자동으로 입력하는 @Createdyby, @LastModifiedBy 애노테이션의 경우, 게시글이 Create 및 Update 되는 모듈은 module-app-web이 아니라 module-app-api 모듈이므로 해당 애노테이션을 적용할 수 없다. 즉 사용자는 module-app-web 모듈에서 Spring Security로 로그인하였기에 사용자 데이터를 유지하고 있지만, module-app-api 모듈에서 Spring Security로 로그인되어 있지 않기 때문에 사용자 데이터가 존재하지 않는다. @CreatedBy, @LastModifiedBy 애노태이션을 대신하여, form 페이지의 input 태그를 사용하여 생정자 및 수정자 정보를 전송하도록 구현하였다.
  • 만약 본 프로젝트와 다르게 Spring Security를 적용한 단일 프로젝트인 경우 @Createdyby, @LastModifiedBy을 사용하려고 한다면 AuditorAware 인터페이스를 구현하면 된다. 자세한 내용은 다음 출처를 참고하였다.

출처: https://umanking.github.io/jpa/2019/04/12/jpa-audit.html https://mia-dahae.tistory.com/150

비인증 사용자 접속 차단

  • form 페이지의 input 태그를 사용하여 생정자 및 수정자 정보를 전송하도록 구현하기 위한 전제 조건은, form 페이지에 접근하기 위해서는 인증된 사용자이어야 한다.
  • 따라서 특정 권한이 요구되는 페이지에 비인증 사용자 접근을 차단하는 기능을 Spring Security에 추가하였다.

출처: https://lteawoo.tistory.com/14

Config

  • .antMatchers(“/**/form”).hasAnyAuthority(“root, manager, general”): 권한을 가진 사용자만 form 페이지에 접근할 수 있다.
  • .authenticationEntryPoint(new CustomAuthenticationEntryPoint());: 비인증 사용자가 접근하는 경우 에러가 발생하며, 해당 에러를 CustomAuthenticationEntryPoint 핸들러가 처리한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<module-app-web/src/main/java/kr/ac/univ/config/SecurityConfig.java>

...

@Override
protected void configure(HttpSecurity http) throws Exception {
   http.authorizeRequests()
           // 페이지 권한 설정
           .antMatchers("/user/list").hasAuthority("root")
           .antMatchers("/**/form").hasAnyAuthority("root, manager, general")
           .antMatchers("/h2-console/**").permitAll() // h2-console 접근 허용
           .antMatchers("/**").permitAll()
           .and()
           .csrf().ignoringAntMatchers("/console/**") // h2-console csrf 제외
           .and()
           .headers().addHeaderWriter(new XFrameOptionsHeaderWriter(new WhiteListedAllowFromStrategy(Arrays.asList("localhost")))) // he-console X-Frame-Options 제외
           .frameOptions().sameOrigin()
           .and()
           // 로그인 설정
           .formLogin()
           .loginPage("/user/login")   // login 페이지 URL
           .loginProcessingUrl("/user/login/process")  // login 수행 URL
           // 사용자 정의 handler
           .successHandler(CustomAuthenticationSuccessHandler())
           .failureHandler(CustomAuthenticationFailureHandler())
           .defaultSuccessUrl("/user/index")   // login 성공 URL
           .permitAll()
           .and()
           // 로그아웃 설정
           .logout()
           .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
           .logoutSuccessUrl("/user/logout/success")
           .invalidateHttpSession(true)
           .and()
           // 예외처리
           .exceptionHandling()
           .accessDeniedPage("/user/permission-denied") // 권한이 없는 경우, 403 예외처리 핸들링
           .authenticationEntryPoint(new CustomAuthenticationEntryPoint());

}

...


  • 비인증 사용자가 인증된 사용자(권한을 요구하는) 페이지에 접근하는 경우 발생하는 예외를 처리한다. Http 상태를 401 에러(UnAuthorized)로 지정하고 비인증 사용자 접근 오류 페이지로 forward 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<module-app-web/src/main/java/kr/ac/univ/handler/CustomAuthenticationEntryPoint.java>

package kr.ac.univ.handler;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
   @Override
   public void commence(HttpServletRequest request, HttpServletResponse response,
                        AuthenticationException authException) throws IOException, ServletException {
       // 401(UnAuthorized)에러로 지정
       response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
       // json 리턴 및 한글깨짐 수정.
       response.setContentType("application/json;charset=utf-8");

       request.getRequestDispatcher("/user/anonymous-user-permission-denied").forward(request, response);
   }
}


  • JPA Audit을 활용하기 위한 애노테이션(@EnableJpaAuditing)을 설정 파일에 추가하였다.
1
2
3
4
5
6
7
8
9
10
11
12
<module-domain-core/src/main/java/kr/ac/univ/common/config/JpaAuditConfig.java>

package kr.ac.univ.common.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing
@Configuration
public class JpaAuditConfig {

}

Domain 및 DTO

  • 해당 클래스에서 JPA Audit을 사용하기 위한 애노테이션(@EntityListeners(AuditingEntityListener.class))을 Domain 클래스에 추가하였다.
  • 생성일, 수정일 멤버 필드에 @CreatedDate과 @LastModifiedDate 애노테이션을 선언하였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<module-domain-core/src/main/java/kr/ac/univ/common/domain/CommonAudit.java>

package kr.ac.univ.common.domain;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.*;
import java.time.LocalDateTime;

@MappedSuperclass
@Getter
@Setter
@ToString
@EntityListeners(AuditingEntityListener.class)
public abstract class CommonAudit {
   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Long idx;

   @Column(nullable = false, updatable = false)
   @CreatedDate
   private LocalDateTime createdDate;

   @LastModifiedDate
   private LocalDateTime lastModifiedDate;

   private String createdBy;

   private String lastModifiedBy;
}


  • 첨부 파일의 경우, 생성일 멤버 필드에 @CreatedDate과 애노테이션을 선언하였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<module-domain-core/src/main/java/kr/ac/univ/common/domain/AttachedFileAudit.java>

package kr.ac.univ.common.domain;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.*;
import java.time.LocalDateTime;

@MappedSuperclass
@Getter
@Setter
@ToString
@EntityListeners(AuditingEntityListener.class)
public abstract class AttachedFileAudit {
   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Long idx;

   @CreatedDate
   private LocalDateTime createdDate;

   private String createdBy;
}


  • DTO <-> Domain 변환에 사용되는 Builder 패턴에 작성자 및 수정자 정보를 저장하는 setCreatedBy(createdBy));, setLastModifiedBy (lastModifiedBy); 소스 코드를 추가하였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<module-domain-core/src/main/java/kr/ac/univ/noticeBoard/domain/NoticeBoard.java>

package kr.ac.univ.noticeBoard.domain;

import kr.ac.univ.common.domain.CommonAudit;
import kr.ac.univ.common.domain.enums.ActiveStatus;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.*;

@Getter
@NoArgsConstructor
@Entity
@Table
@ToString
public class NoticeBoard extends CommonAudit {
   @Column
   private String title;

   @Column
   private String content;

   @Column
   @Enumerated(EnumType.STRING)
   private ActiveStatus activeStatus;

   @Column
   private Long viewCount = 0L;

   @Builder
   public NoticeBoard(Long idx, String createdBy, String lastModifiedBy, String title, String content, ActiveStatus activeStatus) {
       setIdx(idx);
       setCreatedBy(createdBy);
       setLastModifiedBy(lastModifiedBy);
       this.title = title;
       this.content = content;
       this.activeStatus = activeStatus;
   }

   public void update(NoticeBoard noticeBoard) {
       this.title = noticeBoard.getTitle();
       this.content = noticeBoard.getContent();
       this.activeStatus = noticeBoard.getActiveStatus();
   }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<module-domain-core/src/main/java/kr/ac/univ/noticeBoard/domain/NoticeBoardAttachedFile.java>

package kr.ac.univ.noticeBoard.domain;

import kr.ac.univ.common.domain.AttachedFileAudit;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;

@Getter
@NoArgsConstructor
@Entity
@Table
@ToString
public class NoticeBoardAttachedFile extends AttachedFileAudit {
   @Column
   private Long noticeBoardIdx;

   @Column
   private String fileName;

   @Column
   private String savedFileName;

   @Column
   private String fileSize;

   @Builder
   public NoticeBoardAttachedFile(String createdBy, Long noticeBoardIdx, String fileName, String savedFileName, String fileSize) {
       setCreatedBy(createdBy);
       this.noticeBoardIdx = noticeBoardIdx;
       this.fileName = fileName;
       this.savedFileName = savedFileName;
       this.fileSize = fileSize;
   }
}

Test

  • 새로 등록된 게시글의 생성일이 JPA Audit에 의해 과거 시간 이후에 등록되었는지 테스트하였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<module-app-web/src/test/java/kr/ac/univ/JpaAuditTest.java>

package kr.ac.univ;

import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.IntStream;

import kr.ac.univ.common.domain.enums.ActiveStatus;
import kr.ac.univ.noticeBoard.domain.NoticeBoard;
import kr.ac.univ.noticeBoard.repository.NoticeBoardRepository;
import kr.ac.univ.noticeBoard.repository.NoticeBoardRepositoryImpl;
import lombok.extern.slf4j.Slf4j;
import org.junit.Assert;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;

@Slf4j
@SpringBootTest
@EnableAutoConfiguration
@ExtendWith(SpringExtension.class)
public class JpaAuditTest {
   @Autowired
   NoticeBoardRepository noticeBoardRepository;

   @Autowired
   NoticeBoardRepositoryImpl noticeBoardRepositoryImpl;

   @BeforeEach
   public void init() {
       IntStream.rangeClosed(1, 200)
               .forEach(index -> noticeBoardRepository.save(NoticeBoard.builder()
                       .title("게시글" + index)
                       .content("컨텐츠")
                       .activeStatus(ActiveStatus.ACTIVE)
                       .build()));
   }

   @Test
   @DisplayName("JPA Auditing 테스트")
   public void Test() {
       List<NoticeBoard> list = noticeBoardRepository.findAll();

       LocalDateTime pastDateTime = LocalDateTime.of(2020,4,26,0,0,0,0);

       // 새로 생성한 게시글이 과거 시간 이후에 생성되어 있는지 확인한다.
       for (NoticeBoard noticeboard : list) {
           Assert.assertEquals(noticeboard.getCreatedDate().isAfter(pastDateTime), true);
       }
   }
}

Service

  • 게시글의 경우 DTO에 데이터가 저장되어 전달되지만, 첨부 파일의 경우 데이터를 객체에 저장하여 전달하지 않기 때문에 작성자 데이터를 부가적으로 전달 받는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<module-domain-core/src/main/java/kr/ac/univ/noticeBoard/service/NoticeBoardAttachedFileService.java>

...

/**
     * 첨부 파일 업로드
     *
     * @param noticeBoardIdx
     * @param files
     */
    public void uploadAttachedFile(Long noticeBoardIdx, String createdBy, MultipartFile[] files) throws Exception {
        NoticeBoardAttachedFile uploadFile = new NoticeBoardAttachedFile();

        for (MultipartFile file : files) {
            String uuid = UUID.randomUUID().toString().replaceAll("-", "");
            String savedFileName = uuid + "_" + file.getOriginalFilename();

            // 대체 가능
            // File savedFile = new File("./upload/", savedFileName);
            // FileCopyUtils.copy(file.getBytes(), savedFile);

            Path path = Paths.get("./upload/" + savedFileName);
            Files.write(path, file.getBytes());

            uploadFile = NoticeBoardAttachedFile.builder()
                    .noticeBoardIdx(noticeBoardIdx)
                    .createdBy(createdBy)
                    .fileName(file.getOriginalFilename())
                    .savedFileName(savedFileName)
                    .fileSize(FileUtil.convertFileSize(file.getSize()))
                    .build();

            insertAttachedFile(uploadFile);
        }
    }

...

Controller

  • 사용자가 로그인 페이지에 접근할 때 두가지 경우가 존재하며, 이에 따른 사용자가 이동하는 페이지를 세분화 하였다.
  • 비인증 사용자: 로그인 페이지로 이동한다.
  • 인증 사용자: index 페이지로 이동한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<module-app-web/src/main/java/kr/ac/univ/controller/UserController.java>

...

//Login Page
@GetMapping("/login")
public String login(@AuthenticationPrincipal UserPrincipal userPrincipal) {
   String returnPage = null;

   // 사용자가 로그인하지 않는 경우 login 페이지로 이동
   if(EmptyUtil.isEmpty(userPrincipal)) {
       returnPage = "/user/login";
   }
   // 사용자가 로그인한 경우 index 페이지로 이동
   else {
       returnPage = "user/index";
   }

   return returnPage;
}


...

RestController

  • 게시글의 경우 DTO에 데이터가 저장되어 전달되지만, 첨부 파일의 경우 데이터를 객체에 저장하여 전달하지 않기 때문에 작성자 데이터를 view에서 부가적으로 전달 받는다.
1
2
3
4
5
6
7
8
9
<module-app-api/src/main/java/kr/ac/univ/controller/NoticeBoardRestController.java>

// 첨부 파일 업로드
@PostMapping("/attachedFile")
public ResponseEntity<?> uploadAttachedFile(Long idx, String createdBy, MultipartFile[] files) throws Exception {
   noticeBoardAttachedFileService.uploadAttachedFile(idx, createdBy, files);

   return new ResponseEntity<>("{}", HttpStatus.CREATED);
}

View

  • 작성자 및 수정자 데이터는 input hidden type에 저장되며, 해당 데이터는 RestController로 전달된다.
  • authentication 객체는 Spring Security에서 인증한 사용자의 세션 데이터가 저장된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<module-app-web/src/main/resources/templates/noticeBoard/form.html>

...

<!-- input type="hidden" -->
<input type="hidden" name="idx" th:value="*{idx}"/>
<input type="hidden" name="createdBy" th:value="*{#strings.isEmpty(createdBy)} ? ${#authentication.principal.username} : *{createdBy}" />
<input type="hidden" name="lastModifiedBy" th:value="${#authentication.principal.username}" />

...

// 파일 업로드
var formData = new FormData();

for (var i = 0; i < insertFileArray.length; i++) {
   formData.append("files", insertFileArray[i]);
}

formData.append("idx", noticeBoardIdx);
formData.append("createdBy", document.getElementsByName("createdBy")[0].value);

...


  • 비인증 사용자가 form 페이지에 접근하는 경우 이동하는 페이지다.
1
2
3
4
5
6
<module-app-web/src/main/resources/templates/user/anonymous-user-permission-denied.html>

<script>
   alert("The login is required to access the page.");
   location.href = "/user/login"
</script>

프로젝트 실행 및 결과

  • 다음과 이미지와 게시글을 새로 등록하면 작성자, 생성일, 수정일 정보가 등록되어 표시되는 것을 확인할 수 있다.

image

This post is licensed under CC BY 4.0 by the author.

Project Lab 10. 로그인 구현(Spring Security) - 1

Project Lab 12. 게시판 개발(댓글) - 8

Comments powered by Disqus.

Trending Tags