-
서론
그 동안 했던 것들을 나열하기전에 회고 수준은 아니고 간단하게 어떤것을 했고, 어떻게 글을써내려 갈 것인지를 먼저 서술을 하는게 좋을 것 같다. 우선 기능은 세션을 이용한 로그인을 할 수 있고, 게시판을 작성, 수정, 삭제 정도의 수준이다. 게시판과 회원들의 정보 관리는 JPA를 이용했다. DB는 H2 database를 이용했고 다음에 MySQL을 연결할 예정이다. 예외처리를 전역화 하였고, 기능들에 대한 front는 타임리프를이용하여 구현한 상태이다.
이번 글에서는 entity와 exception에 관련된 부분을 공유할 예정이고 나머지는 나누어서 글을 올릴 예정이다.
현재 아래의 구성으로 구현되어 있다.
Entity
코드를 최대한 재사용해가며 진행하고 싶었기 때문에 생성일시와 수정일시를 묶을 수 있게 EntityDate라는 앤티티를 만들어 모든 앤티티에 상속했다. 이에 관해서는 이글을 읽어보면 된다.
EntityDate
package spring.postproject.Entity.Common; import lombok.Getter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import javax.persistence.Column; import javax.persistence.EntityListeners; import javax.persistence.MappedSuperclass; import java.time.LocalDateTime; @EntityListeners(AuditingEntityListener.class) @MappedSuperclass @Getter public abstract class EntityDate { @CreatedDate @Column(nullable = false,updatable = false) private LocalDateTime createdAt; @LastModifiedDate @Column(nullable = false,updatable = false) private LocalDateTime modifiedAt; }
@SpringBootApplication @EnableJpaAuditing public class PostProjectApplication { public static void main(String[] args) { SpringApplication.run(PostProjectApplication.class, args); }
Member
package spring.postproject.Member.Entity; import lombok.*; import spring.postproject.Common.EntityDate; import spring.postproject.Comment.Entitiy.Comment; import spring.postproject.Post.Entity.Post; import spring.postproject.Excetion.ExceptionBoard; import javax.persistence.*; import java.util.ArrayList; import java.util.List; @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @Entity public class Member extends EntityDate { private static final int MAX_LENGTH_NICKNAME = 30; private static final int MAX_LENGTH_PASSWORD = 20; @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) @Column(name = "member_id") private Long id; @Column(nullable = false, unique = true) private String userId; @Column(nullable = false, length = 20,unique = true) private String nickname; @Column(nullable = false, length = 20) private String password; @Enumerated(EnumType.STRING) private MemberRoll memberRoll; @OneToMany(mappedBy = "member",cascade = CascadeType.ALL) private List<Post> postList = new ArrayList<>(); public void updateNickName(String nickname){ validationNickname(nickname); this.nickname = nickname; } public void updatePassword(String password){ validationPassword(password); this.password = password; } public void updateRole(MemberRoll memberRoll){ this.memberRoll = memberRoll; } public void validationNickname(String nickName){ if(nickName.isBlank()|| nickName.length() > MAX_LENGTH_NICKNAME){ throw ExceptionBoard.INVALID_LENGTH.getException(); } } public void validationPassword(String password){ if(password.isBlank()|| password.length() > MAX_LENGTH_PASSWORD){ throw ExceptionBoard.INVALID_LENGTH.getException(); } } @Builder public Member(String userId, String nickName, String password){ validationNickname(nickName); validationPassword(password); this.userId = userId; this.nickname = nickName; this.password = password; } }
EntityDate를 상속받아서 Member의 생성, 수정 시간이 저장 될 수 있도록 했다
앤티티 설계에 있어 고민을 많이 한 결과로 @NoArgsConstructor(access = AccessLevel.*PROTECTED*)어노테이션을 썻는데 이에 관해서는 관해서는 이글을 참고
Entity의 id의 배정은 DB에 위임하는 @GeneratedValue(strategy = GenerationType.SEQUENCE) 옵션을 사용 했다.
Enum은 현재 총 2개 NOMAL과 ADMIN 으로 나누어 사용하고 있는데 추후에 등급을 추가할 가능성이 발생할 것 같아서 String 으로 저장하기 위해@Enumerated(EnumType.STRING) 을 사용했다.
Post Entity와 연관관계에서 cascade = CascadeType.ALL 옵션을 이용하여 Member엔티티에 변경이 생겼을때 Post 엔티티에도 영속성이 전이될 수 있도록 설정했다.
@Setter 어노테이션을 사용하여 무분별한 수정하는 것을 막기위해 터티체킹으로만 수정할 수 있게끔 할 수 있는 update함수를 password와 nickname만 적용했다.
validationNickname, validationPassword을 굳이 사용한 이유는 Exception을 글로벌로 처리를 했는데 굳이 추후에 dto에서 @Valid 를 사용하여 처리하기 보다는 묶어서 처리하는 편이 좋을것 같다는 생각이 들어서 Entity 레벨에서 처리하도록 설계했다.
MemberRole
package spring.postproject.Member.Entity; public enum MemberRoll { NORMAL,ADMIN }
멤버의 권한에는 NOMAL과 ADMIN으로 구성되어 있다.
Post
package spring.postproject.Post.Entity; import lombok.*; import spring.postproject.Comment.Entitiy.Comment; import spring.postproject.Common.EntityDate; import spring.postproject.Excetion.ExceptionBoard; import spring.postproject.Member.Entity.Member; import spring.postproject.Post.PostDto.PostDto; import javax.persistence.*; import java.util.List; @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Post extends EntityDate { private final static int MAX_CONTENT_LENGTH = 500; private final static int MAX_TITLE_LENGTH = 50; @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) @Column(name = "post_id") private Long id; @Column(nullable = false) private String title; @Column(nullable = false) private String content; private int count; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") private Member member; //연관관계 매핑 public Long setMember(Member member){ member.getPostList().add(this); this.member = member; return member.getId(); } public void validContent(String content){ if(content.length() >MAX_CONTENT_LENGTH || content.isBlank()){ throw ExceptionBoard.INVALID_LENGTH.getException(); } } public void validTitle(String title){ if (title.length() > MAX_TITLE_LENGTH ||title.isBlank()){ throw ExceptionBoard.INVALID_LENGTH.getException(); } } public Post update(PostDto postDto) { validTitle(postDto.getTitle()); validContent(postDto.getContent()); this.title = postDto.getTitle(); this.content = postDto.getContent(); return this; } @Builder public Post(String title, String content) { validContent(content); validTitle(title); this.title = title; this.content = content; this.count = 0; } public Post counting(){ this.count += 1; return this; } }
Post 앤티티도 대체로 Member 앤티티와 비슷하지만 양방향 관계로 매핑을 했고 연관관계의 주인이기 때문에 연관관계를 맺어주는 setMember 메서드를 만들었다. 그리고 fetch전략은 Lazy 타입으로 @ManyToOne(fetch = FetchType.LAZY)에 설정 해두었다.
Exception
전역화 하여 처리 하였고 이글에서 조금 더 자세한 설명을 볼 수 있습니다.
그리고 현재는 타임리프를 사용하여 개발을 하고 있는데 @RestControllerAdvice를 사용한 이유는 현재는 타임리프쪽에서 에러에 대한 페이지를 따로 처리할 생각이 없고, 추후에 타임리프를 제거한 후 API형태로 발전시킬 예정이라서 어느정도 형태를 잡아둔 것이라고 생각하시면 될 것 같네요.
CustomException
package spring.postproject.Excetion.BaseException; import lombok.Getter; @Getter public class CustomException extends RuntimeException { private final Integer code; public CustomException(String message,Integer code) { super(message); this.code = code; } }
모든 에러를 처리하게될 CustomException은 UnCheckedException의 상위 클래스인 RuntimeException을 상속 받도록 했습니다.
BadRequestException
package spring.postproject.Excetion.BaseException; public class BadRequestException extends CustomException{ public BadRequestException(String message, Integer code) { super(message, code); } }
NotFoundException
package spring.postproject.Excetion.BaseException; public class NotFoundException extends CustomException { public NotFoundException(String message, Integer code) { super(message, code); } }
Custom한 Exception이 무분별하게 많은 이름의 Exception으로 생성했을때 관리하기 힘들것 같고 뚜렷한 기준을 세우는 고민에 대한 판단이 서지 않아 최대한 표준 Exception 명을 사용했다.
ExceptionBoard
package spring.postproject.Excetion; import lombok.Getter; import spring.postproject.Excetion.BaseException.BadRequestException; import spring.postproject.Excetion.BaseException.CustomException; import spring.postproject.Excetion.BaseException.InternalServerException; import spring.postproject.Excetion.BaseException.NotFoundException; @Getter public enum ExceptionBoard { NOT_FOUND_MEMBER(new NotFoundException("사용자를 찾을 수 없습니다.",400)), NOT_FOUND_POST(new NotFoundException("게시글을 찾을 수 없습니다.",400)), INVALID_LENGTH(new BadRequestException("잘못된 길이입니다.",404)), INVALID_PASSWORD(new BadRequestException("잘못된 비밀번호 입니다.",404)), INVALID_CONTENT(new BadRequestException("잘못된 형식의 컨텐츠입니다.",404)), INVALID_TITLE(new BadRequestException("잘못된 형식의 타이틀입니다.",404)), INTERNAL_SERVER(new InternalServerException("서버를 찾을 수 없습니다.", 500)); private final CustomException exception; ExceptionBoard(CustomException e){ this.exception = e; } }
Custom한 Exception들을 enum 타입으로 관리하는 클래스입니다. Code도 현재는 HttpStatus를 따르고 있습니다.
ControllerAdvice
package spring.postproject.Excetion.Controller; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; import spring.postproject.Excetion.BaseException.BadRequestException; import spring.postproject.Excetion.BaseException.NotFoundException; import spring.postproject.Excetion.Dto.ExceptionResponse; @Slf4j @RestControllerAdvice public class ControllerAdvice { //400 @ExceptionHandler(BadRequestException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ExceptionResponse BadRequestExceptionHandler(BadRequestException e) { return new ExceptionResponse(e.getMessage(), e.getCode()); } //404 @ExceptionHandler(NotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public ExceptionResponse handleNotFoundException(NotFoundException e) { return new ExceptionResponse(e.getMessage(), e.getCode()); } //500 @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ExceptionResponse handleException(Exception e) { return new ExceptionResponse(e.getMessage(), e.getCode()); } }
@RestControllerAdvice를 이용하여 모든 컨트롤러에서 발생한 에러를 낚아채어 처리하도록 했습니다. 현재는 Custom한 BadRequestException.class 와 NotFoundException.class 가 발생시키는 예외만 나누어 처리했고, 나머지는 Exception.class 로 링크를 걸어 처리했다. 추후 변경 가능성 다분하기 때문에 훈수 감사합니다.
TestCode
각각의 Entity에 대한 테스트 코드입니다. 테스트 코드는 모든 함수에 대해 한번씩 검증할 수 있도록 했고, 테스트는 추후에 추가될 수 있다.
MemberTest
package spring.postproject.Member.Entity; import static org.assertj.core.api.Assertions.assertThat; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import spring.postproject.Excetion.ExceptionBoard; @DisplayName("Member 클래스") public class MemberTest { private Member member; @BeforeEach void setUp(){ member = createMember("niceId","kingHamzzi","password"); } @DisplayName("Member 객체생성이 빌더 패턴으로 생성된다.") @Test public void constructor(){ assertThat(member).isNotNull(); assertThat(member.getUserId()).isEqualTo("niceId"); assertThat(member.getNickname()).isEqualTo("kingHamzzi"); assertThat(member.getPassword()).isEqualTo("password"); } @DisplayName("닉네임이 변경가능 해야한다") @Test public void canChangeNickName() { String newNickName = "HamzziKing"; member.updateNickName(newNickName); assertThat(member.getNickname()).isEqualTo(newNickName); } @DisplayName("비밀번호가 변경가능 해야한다") @Test public void canChangePassWord() { String newPassWord = "newPassWord"; member.updatePassword(newPassWord); assertThat(member.getPassword()).isEqualTo(newPassWord); } @DisplayName("등급이 변경가능해야한다") @Test public void canChangeRoll() { member.updateRole(MemberRoll.ADMIN); assertThat(member.getMemberRoll()).isEqualTo(MemberRoll.ADMIN); member.updateRole(MemberRoll.NORMAL); assertThat(member.getMemberRoll()).isEqualTo(MemberRoll.NORMAL); } @DisplayName("비밀번호가 비어있으면 에러가 난다") @Test public void validationPassWordBlank() { //비밀번호 변경시 에러 assertThatThrownBy(() -> member.updatePassword("")) .isInstanceOf(ExceptionBoard.INVALID_LENGTH.getException().getClass()); //Member 생성시 에러 assertThatThrownBy(() -> createMember("id", "name", "")) .isInstanceOf(ExceptionBoard.INVALID_LENGTH.getException().getClass()); } @DisplayName("비밀번호 길이가 20자리 이상이면 에러가 난다") @Test public void validationPassWordLengthOver() { //비밀번호 변경시 에러 assertThatThrownBy(() -> member.updatePassword("1234567891011121314151617181920")) .isInstanceOf(ExceptionBoard.INVALID_LENGTH.getException().getClass()); //Member 생성시 에러 assertThatThrownBy(() -> createMember("id", "name", "1234567891012345678910")) .isInstanceOf(ExceptionBoard.INVALID_LENGTH.getException().getClass()); } @DisplayName("닉네임이 비어있으면 에러가 난다") @Test public void validationNickNameBlank() { //닉네임 변경시 에러 assertThatThrownBy(() -> member.updateNickName("")) .isInstanceOf(ExceptionBoard.INVALID_LENGTH.getException().getClass()); //Member 생성시 에러 assertThatThrownBy(() -> createMember("id", "", "password")) .isInstanceOf(ExceptionBoard.INVALID_LENGTH.getException().getClass()); } @DisplayName("닉네임의 길이가 20자리 이상이면 에러가 난다") @Test public void validationNickNameLengthOver() { //닉네임 변경시 에러 assertThatThrownBy(() -> member.updateNickName("123456789101112131564654as65e4151617181920")) .isInstanceOf(ExceptionBoard.INVALID_LENGTH.getException().getClass()); //Member 생성시 에러 assertThatThrownBy(() -> createMember("id", "123456789101112131564654as65e4151617181920", "password")) .isInstanceOf(ExceptionBoard.INVALID_LENGTH.getException().getClass()); } private Member createMember(String userId,String nickName, String passWord){ return Member.builder() .userId(userId) .nickName(nickName) .password(passWord) .build(); } }
PostTest
package spring.postproject.Post.Entity; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import spring.postproject.Excetion.ExceptionBoard; import spring.postproject.Member.Entity.Member; import spring.postproject.Post.PostDto.PostDto; import static org.assertj.core.api.Assertions.*; import static org.junit.Assert.*; public class PostTest { private Post post; private String title = "타이틀 입니다."; private String content = "내용없음"; private Member member = Member.builder() .nickName("nickname") .password("password") .userId("id") .build(); @BeforeEach void setup(){ post = createPost(title,content); post.setMember(member); } @DisplayName("Post 객체 생성이 빌더 패턴으로 생성한다.") @Test public void constructor(){ assertThat(post).isNotNull(); assertThat(post.getTitle()).isEqualTo(title); assertThat(post.getContent()).isEqualTo(content); assertThat(post.getMember()).isEqualTo(member); } @DisplayName("타이틀이 변경 가능하다") @Test public void canChangeTitle(){ String newTitle = "새로운 타이틀"; PostDto postDto = new PostDto(); postDto.setTitle(newTitle); postDto.setContent(content); post.update(postDto); assertThat(post.getTitle()).isNotEqualTo(title); assertThat(post.getTitle()).isEqualTo(newTitle); } @DisplayName("컨텐츠가 변경 가능하다") @Test public void canChangeContent() { String newContent = "새로운 컨텐츠"; PostDto postDto = new PostDto(); postDto.setTitle(title); postDto.setContent(newContent); post.update(postDto); assertThat(post.getContent()).isNotEqualTo(content); assertThat(post.getContent()).isEqualTo(newContent); } @DisplayName("타이틀이 비어있으면 에러가 난다.") @Test public void validationTitleBlank() { String invalidIndex = ""; PostDto postDto = new PostDto(); postDto.setTitle(invalidIndex); postDto.setContent(post.getContent()); //타이틀 변경시 에러 assertThatThrownBy(() -> post.update(postDto)) .isInstanceOf(ExceptionBoard.INVALID_LENGTH.getException().getClass()); //Post 생성시 에러 assertThatThrownBy(() -> createPost(invalidIndex, "content")) .isInstanceOf(ExceptionBoard.INVALID_LENGTH.getException().getClass()); } @DisplayName("타이틀 길이가 50자리 이상이면 에러가 난다") @Test public void validationTitleLengthOver() { String invalidIndex = "lijeqroijewriojowerijowejrojweorjwejrowejorijweoirjweoiroweqqwesad"; PostDto postDto = new PostDto(); postDto.setTitle(invalidIndex); postDto.setContent(post.getContent()); //타이틀 변경시 에러 assertThatThrownBy(() -> post.update(postDto)) .isInstanceOf(ExceptionBoard.INVALID_LENGTH.getException().getClass()); //Post 생성시 에러 assertThatThrownBy(() -> createPost(invalidIndex, "content")) .isInstanceOf(ExceptionBoard.INVALID_LENGTH.getException().getClass()); } @DisplayName("컨텐츠가 비어있으면 에러가 난다") @Test public void validationContentBlank() { String invalidIndex = ""; PostDto postDto = new PostDto(); postDto.setTitle(post.getTitle()); postDto.setContent(invalidIndex); //컨텐츠 변경시 에러 assertThatThrownBy(() -> post.update(postDto)) .isInstanceOf(ExceptionBoard.INVALID_LENGTH.getException().getClass()); //Post 생성시 에러 assertThatThrownBy(() -> createPost("title", invalidIndex)) .isInstanceOf(ExceptionBoard.INVALID_LENGTH.getException().getClass()); } @DisplayName("타이틀 길이가 500자리 이상이면 에러가 난다") @Test public void validationContentLengthOver() { String invalidIndex = "lijeqroijewriojowerijowejrojweorjweasdasdiourhliuojkvjhiuahtlkjwbnthjkasdkuahruloahkwen" + "rlkajsdhkuhvluikzcxkjnfkjasdnkarlahuidlhzskfzkljsdnrkljhsuzkdlrhikulwakjenreaklwejrnlkjrowej" + "alisejoiljeolihurqliweurhikwelirkuhqwluierhliqweuhrlikqwherilkuqwheilruqwhielriorijweoirjweoiroweqqwesad"; PostDto postDto = new PostDto(); postDto.setTitle(invalidIndex); postDto.setContent(post.getContent()); //타이틀 변경시 에러 assertThatThrownBy(() -> post.update(postDto)) .isInstanceOf(ExceptionBoard.INVALID_LENGTH.getException().getClass()); //Post 생성시 에러 assertThatThrownBy(() -> createPost(invalidIndex, "content")) .isInstanceOf(ExceptionBoard.INVALID_LENGTH.getException().getClass()); } @DisplayName("Counting 메소드가 호출되면 count가 +1 된다") @Test public void CountingTest() { int count = post.getCount(); assertThat(count +1).isEqualTo(post.counting().getCount()); } private Post createPost(String title, String content) { return Post.builder() .title(title) .content(content) .build(); } }
다음글에서는 Service와 Controller에 대해 작성하도록 하겠다.
'프로젝트 > 게시판 만들기로 배우는 Spring Data JPA' 카테고리의 다른 글
크게한걸음 - 로그인과 글쓰기 (회고, Form(타임리프)) (0) 2022.06.12 크게 한걸음 - 로그인과 글쓰기 (Service, Controller) (0) 2022.06.12 1. 시작하며 (0) 2022.04.03 댓글