이전에 회원가입, 로그인까지 만들었다. 나의 경우 간단하게 상세 정보도 만들어 1대 1 매핑을 했다. 이런 간단한 비즈니스들은 본인이 원하는 대로 만들면 될 것 같다.
게시글 Entity를 만들기 전에 게시글은 생성일, 수정일이 필요할 것 같아 해당 기능을 자동으로 하기 위해 JPA Auditing을 사용해보려고 한다.
JPA Auditing
JPA Auditing이란?
JPA에서 시간 값을 자동으로 넣어주는 기능이다. Entity를 영속화 시키거나 조회를 수행한 후에 update를 하는 경우 매번 시간을 읽어와서 저장해 주는 번거로움이 있는데 JPA Auditing을 사용하면 자동으로 시간을 매핑하여 DB 테이블에 넣어준다.
사용하기 전에 이게 정말 좋을까?에 대한 생각이 들었다. 사실 자동으로 다 해준다면 편하겠지만 그런 경우 항상 무언가 당시에 생각하지 못한 문제들이 있었다. 이 기능도 마찬가지로 JPA를 사용하여 Entity를 설계하고 그에 따라 DB 테이블이 적용되기 때문에 API를 통해 데이터를 생성하지 않고 DB에 직접 데이터를 넣으면 생성일, 수정일이 null 값으로 생성되는 문제가 발생할 수 있다. 물론 API만 사용하면 문제가 없지만 실제로는 DB관리도 따로 한다고 하니 직접 건드리는 경우가 존재할 것이다.
해결 방법
JPA Auditing 기능은 영속성 컨텍스트에서’만’ 동작한다. 그래서 DB에 직접 데이터를 넣으면 생성일, 수정일이 null로 들어오는 것이다. 그렇다면 DB에서 생성일과 수정일이 자동으로 생성되도록 만들면 된다. 크게 두 가지 방법이 있는데
- @Column 어노테이션의 DDL 옵션을 사용하는 방법
- 직접 DDL을 작성하여 DB를 생성하고 JPA에서는 spring.jpa.hibernate.ddl-auto = validate로 운영하는 방법
나의 경우 기획부터 완벽하게 하는 것이 아닌 이것 저것 해보려고 만들고 있기에 1번 방법을 사용했다. 하지만 2번 방법이 더 좋다고 생각한다. 프로젝트를 여러 개 해보며 느낀 것은 기획을 탄탄하게 할수록 개발 중간에 발생하는 문제가 적었다. 기획을 대충 하면 프로젝트 진행 중에 DB도 여러 번 수정하고 그에 따라 코드도 계속 변경되는 상황들이 많이 발생했다. 그리고 Entity에서도 수정하고 DB에서도 수정하니 테이블이 원하던 대로 수정된 것인지 헷갈릴 때도 있었다.
Post API 모듈
다른 모듈들과 마찬가지로 생성해주고 기본적인 env설정, 예외 환경 설정 등을 해준다. 이전 API들 구현한 것과 같으니 EntityListener와 관련된 내용만 정리해 보았다.
Post(common-entity 모듈)
@Entity
@Table(name = "post")
@Getter
@EntityListeners(value = AuditingEntityListener.class)
public class Post {
@Id
@Column(name = "post_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long postId;
@Column(name = "post_name")
private String postName; // 게시글 제목
@Lob // DB에서 varchar 사이즈 넘어서는 큰 컨텐츠 넣고 싶을 때 사용
@Column(name = "post_content")
private String postContent;
@CreatedDate // 엔티티 생성 시 자동으로 시간이 들어간다
@Temporal(TemporalType.TIMESTAMP)
@Column(name = "created_at", nullable = false , updatable = false, columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
private LocalDateTime createdAt;
@LastModifiedDate //엔티티 수정 시 자동으로 시간이 들어간다.
@Temporal(TemporalType.TIMESTAMP)
@Column(name = "updated_at", nullable = false , updatable = true, columnDefinition = "TIMESTAMP ON UPDATE CURRENT_TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
private LocalDateTime updatedAt;
// 여러개의 게시글은 각각의 사용자가 있다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_email")
private Member member;
public Post(){};
@Builder
private Post(String postName, String postContent){
this.postName = postName;
this.postContent = postContent;
}
public static Post createPost(String postName, String postContent){
return new Post(postName, postContent);
}
}
기존과 다른 점이 관계 매핑한 것이 member_email로 되어 있다. 이는 기존 Member도 pk를 member_email로 바꿨기 때문이다. 내가 이렇게 한 이유는 로그인하면 pk값을 해당 비즈니스 내에서 유지시키고 싶어서이다. 현재 내 security 구현을 한 것이 DB와 직접적으로 I/O가 발생하지 않기 때문에 persist()해야 생기는 기본 pk인 id를 가져올 수 없기 때문이다. 나중에 기능이 대부분 만들어지면 수정해야겠다.
추가로 @Temporal의 경우 Java 8부터 지원하는 LocalDate, LocalDateTime을 사용할 때는 생략할 수 있다.
Entity위에 있는 @EntityListeners의 EntityListener는 Entity가 삽입, 삭제, 수정, 조회 등의 작업을 할 때 전, 후에 어떠한 작업을 하기 위해 이벤트 처리를 위한 어노테이션이다. 인터셉터와 같은 느낌이다. 이런 EntityListener의 종류는 7가지이다.
- @PrePersist : Persist(Insert) 메서드가 호출되기 전에 실행되는 메서드
- @PreUpdate : Merge(Update) 메서드가 호출되기 전에 실행되는 메서드
- @PreRemove : Remove(Delete) 메서드가 호출되기 전에 실행되는 메서드
- @PostPersist : Persist(Insert) 메서드가 호출된 후에 실행되는 메서드
- @PostUpdate : Merge(Update) 메서드가 호출된 후에 실행되는 메서드
- @PostRemove : Remove(Delete) 메서드가 호울 된 후에 실행되는 메서드
- @PostLoad : Select 조회가 실행된 직후에 실행되는 메서드
@EntityListeners(value = CustomEntityListener.class)
public class CustomEntityListener {
@PrePersist
public void prePersist(Object object) {
if(object instanceof Member) {
((Member) object).setCreatedAt(LocalDateTime.now());
((Member) object).setUpdatedAt(LocalDateTime.now());
}
}
@PreUpdate
public void preUpdate(Object object) {
if(object instanceof Member) {
((Member) object).setUpdatedAt(LocalDateTime.now());
}
}
}
@EntityListeners(value = CustomEntityListener.class)
public class Member {
...
@Column(updatable = false)
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
위의 코드와 같이 사용하면 된다. 즉 데이터가 삽입이나 수정되기 전이나 후 등 원하는 대로 필요한 어노테이션을 해당 메소드에 붙이고 @EntityListeners의 value에 내가 작성한 클래스를 명시해 준다. 이후 적용할 Entity에 @EntityListeners를 붙이고 그 value에 적용할 클래스를 명시해 주면 내가 작성한 기능들이 적용된다. AuditingEntityListener클래스는 직접 EntityListener를 작성할 필요 없이 자주 사용되는 기능들을 이미 스프링에서 구현해 놓은 클래스이다. @CreatedBy(작성자), @CreatedDate(작성일), @LastModifiedDate(수정일), @LastModifiedBy(수정자)를 자동으로 넣어 주는 기능을 제공한다.
여기서 주의할 점은 내가 사용한 @CreatedDate와 같은 어노테이션은 DB에 쿼리문을 보내면 자동으로 작성되는 것이 아니라 DB에 쿼리문이 들어가기 직전에 자동으로 넣어준다. 그리고 default를 설정한 columndefinition과는 상관이 없는 것이다. 이건 DDL일 뿐이다. 그래서 만약 default로 설정한 값에 Entity객체가 null값을 가지고 있다면 계속 null로 값이 들어가는 문제가 발생한다. 이때 Entity에 적용할 수 있는 어노테이션이 @DynamicInsert이다. 이 어노테이션은 Insert 할 때 null값은 빼고 쿼리문을 실행한다.
PostApiApplication(server-post api 모듈)
@SpringBootApplication
@EnableJpaAuditing // Spring Data JPA가 Auditing 사용하게 해주는 어노테이션
public class PostApiApplication {
public static void main(String[] args) {
SpringApplication.run(PostApiApplication.class,args);
}
}
이거 작성하면서 알았는데 Date는 문제가 있어서 java8부터는 LocalDateTime을 사용한다고 한다.
@EnableJpaAuditing은 AuditingEntityListener.class를 사용하기 위해 추가해줘야 한다.
postServiceImpl(server-post api 모듈)
컨트롤러, JpaRepository, DTO 등은 각자 스타일대로 작성해 보고 아래의 코드와 결과를 보자
@Override
public void createPost(PostDto postDto, String memberEmail) {
String postName = postDto.getPostName();
String postContent = postDto.getPostContent();
Post post = Post.createPost(postName, postContent);
Member member = memberRepository.findMemberByMemberEmail(memberEmail);
post.updateMember(member);
postRepository.save(post);
}
게시글을 생성하기 위한 메소드로 post를 새로 생성하고 해당 post의 member를 세팅해 주기 위해 추가로 DB조회를 하였고 그게 아래 결과이다
난 post의 FK인 memberEmail을 가지고 있지만 post에 member를 저장하기 위해 조회를 했다. 실제 DB라면 한 번의 insert에 끝났을 것이다. 그래서 한 번에 하는 방법이 없을까? 하고 찾아보았다.
member객체를 프록시로 조회하면 DB에 쿼리가 나가지 않고 사용할 수 있는데 나에겐 문제가 있다. Security와 토큰을 생성하며 Security에 비즈니스가 흐르는게 싫어서 PK를 email로 설정한 것이다. 프록시 조회 메소드는 getReferenceById가 있는데 이게 Long타입의 id만 받는다. 토큰에 id값도 넣어서 getName()메소드를 호출하면 id가 나오도록 설정하면 될 것 같다.
- Member Entity의 pk를 Long 타입의 memberId로 수정
- 토큰 생성하는 기능에서 claims.put()으로 memberId값을 넣어서 생성
- pk를 사용하는 모든 곳을 memberId를 사용하도록 수정
위 과정에서 redis도 바꿨다. redis객체의 @Id값을 Long으로 바꾸었더니 저장이 안 된다.. 그래서 어쩔 수 없이 String으로 하고 redis관련 작업만 Long → String, String → Long 과 같은 형 변환을 하여 저장했다. 그리고 결과를 보니
똑같았다..
그래서 왜 변화가 없지?라고 생각해 보니 혹시 저 updateMember를 하며 프록시 객체의 값을 사용해서 그런게 아닐까? 라는 생각이 들었다.
Member member = memberRepository.getReferenceById(memberId);
System.out.println("/////////////");
post.updateMember(member);
postRepository.save(post);
그럼 위와 같이 println()을 했을 때 프록시 조회가 잘 안 된다면 println() 이전에 쿼리문이, 프록시는 잘 되었다면 이후에 쿼리문이 찍힐 것이다.
println() 이후에 쿼리문이 찍혔다.
그럼 프록시 조회는 잘 된 것이니 updateMember과정에서 member를 사용하기 때문일 것이다.
public void updateMember(Member member){
this.member = member;
member.updatePosts(this);
}
문제는 저기 member.updatePosts(this)에서 발생했다. 저 메소드를 따라가니
public void updatePosts(Post post){
this.posts.add(post);
}
내가 이렇게 작성을 했다. 영속성 컨텍스트에 있으니 양 쪽 모두 연결된 것을 맞추려고 저렇게 했는데 쿼리문이 날아가버렸다. member에 값을 추가하여 발생한 문제이다. member에는 post정보가 없어도 비즈니스 로직 상 문제는 없으니 해당 메소드를 지우고 다시 실행해 보았다.
원하던 대로 쿼리 한 번에 저장됐다.
나머지 기능들도 붙여봐야겠다.
수정
updateMember()가 잘못되었던 것을 나중에 알게되었다.
public void updateMember(Member member){
this.member = member;
member.getPosts().add(this);
}
저땐 왜 저렇게 짠거지
'Java > Spring boot 프로젝트' 카테고리의 다른 글
[리팩토링 2] 의존성 관리하기 -게시판 만들기(15) (0) | 2023.08.09 |
---|---|
[리팩토링 1] nGrinder삽질 + JMeter 부하 테스트 해보기 - 게시판 만들기(14) (0) | 2023.08.09 |
Spring Security + JWT 적용하기(3) - 게시판 만들기(12) (0) | 2023.04.15 |
Spring Security + JWT 적용하기(2) - 게시판 만들기(11) (0) | 2023.04.06 |
Spring Security + JWT 적용하기(1) - 게시판 만들기(10) (0) | 2023.04.06 |