scribnote5 on Aug 29, 20202020-08-29T00:00:00+09:00
Updated Apr 27, 20212021-04-27T00:00:00+09:00 14 min read
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>
|
프로젝트 실행 및 결과
- 다음과 이미지와 게시글을 새로 등록하면 작성자, 생성일, 수정일 정보가 등록되어 표시되는 것을 확인할 수 있다.
Comments powered by Disqus.