게시물 Entity
domain 패키지를 만든다.
도메인이란 게시글, 댓글, 회원 등 소프트웨어에 대한 요구사항 혹은 문제 영역이다.
@Getter
@NoArgsConstructor
@Entity
public class Posts extends BaseTimeEntity {
@Id
@GeneratedValue(strategy= GenerationType.IDENTITY)
@Column(name="post_id")
private Long id;
@ManyToOne(fetch=FetchType.LAZY)
@JoinColumn(name="member_id")
private Member member;
@Column(length = 500, nullable = false)
private String title;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
@Column(length = 100, nullable = false)
private String subject;
@Column(nullable = false)
private String division;
@Column(nullable = false)
private int people_num;
@Column(nullable=false)
private String proceed_way;
@Column(columnDefinition = "TINYINT", length = 1)
private int is_progress;
@Column(columnDefinition = "integer default 0", nullable=false)
private int view_count;
@Column(columnDefinition = "integer default 0", nullable = false)
private int bookmark_count;
@OneToMany(mappedBy = "posts", orphanRemoval = true)
@Where(clause = "parent_id is null")
private List<Comment> commentList = new ArrayList<>();
@Builder
public Posts(String title, Member member, String content, String subject, String division, int people_num, String proceed_way, int is_progress, int view_count) {
this.title = title;
this.member = member;
this.content = content;
this.subject = subject;
this.division = division;
this.people_num = people_num;
this.proceed_way = proceed_way;
this.is_progress = is_progress;
this.view_count = view_count;
}
public void update(String title, String content, String subject, String division, int people_num, String proceed_way, int is_progress){
this.title = title;
this.content = content;
this.subject = subject;
this.division = division;
this.people_num = people_num;
this.proceed_way = proceed_way;
this.is_progress = is_progress;
}
public void increaseBookmarkCount(){
this.bookmark_count += 1;
}
public void decreaseBookmarkCount(){
this.bookmark_count -= 1;
}
}
위의 Posts 클래스는 실제 DB의 테이블과 매칭될 클래스이며 이러한 클래스를 보통 Entity 클래스라고도 한다.
@Entity는 JPA의 어노테이션이며, @Getter와 @NoArgsConstructor는 롬복의 어노테이션이다.
@Entity
테이블과 링크될 클래스임을 나타낸다.
기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭한다.
ex)SalesManager.java -> sales_manager table
@NoArgsConstructor
기본 생성자를 자동으로 추가한다.
@Getter
클래스 내 모든 필드의 Getter 메소드를 자동으로 생성한다.
@Builder
해당 클래스의 빌더 패턴 클래스를 생성한다.
생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함시킨다.
@Id
해당 테이블의 PK 필드를 나타낸다.
Entity의 PK는 Long 타입의 Auto_increment로 하는것이 좋다.(MySQL 기준으로 이렇게하면 bigint 타입이 됨)
@GeneratedValue
PK의 생성 규칙을 나타낸다.
GenerationType.IDENTITY 옵션을 추가하면 auto_increment가 된다.
@Column
테이블의 칼럼을 나타내며 선언을 하지 않아도 해당 클래스의 필드는 모두 칼럼이 된다.
기본값 외의 추가로 변경이 필요한 옵션이 있다면 사용한다. (사이즈 변경, 타입 변경 등)
Entity 클래스에는 절대 Setter 메소드를 만들지 않는다. 클래스의 인스턴스 값들이 언제 어디서 변하는지 코드 상으로 명확히 구분할 수 없기 때문이다.
필드의 값 변경이 필요하다면 메소드를 추가해야 한다.
생성자를 통해 최종값을 채운 후, DB에 삽입하며 값 변경이 필요한 경우 public 메서드를 호출하여 변경하는 방식이 있고,
빌더 클래스를 사용하는 방식이 있다.
빌더를 사용하면 어느 필드에 어떤 값을 채워야 할지 명확하게 인지할 수 있기 때문에 빌더를 사용하도록 한다.
update 함수는 글 수정을 위한 함수, increaseBoomarkCount, decreaseBookmarkCount 는 북마크 수를 증가/감소 하기 위한 함수이다.
회원
한 명의 회원은 여러 게시글을 작성할 수 있다. 즉, 게시물은 회원과 다대일(N:1) 관계이므로 @ManyToOne 이다.
@ManyToOne은 데이터베이스 상에서 외래키의 관계로 연결된 엔티티 클래스에 설정한다. (N쪽에)
일반적으로 외래키를 갖는 쪽이 주인의 역할을 수행하기 때문에 게시글 엔티티가 주인이 된다.
@JoinColumn(name="member_id")
JoinColumn 어노테이션은 외래키를 매핑할 때 사용한다. name 속성에는 매핑할 외래 키 이름을 지정한다.
이렇게 Post와 Member는 단방향 관계를 맺으므로 외래키가 생겼기 때문에 Post에서 Member의 정보들을 가져올 수 있다.
댓글
하나의 게시물에 여러 댓글이 달릴 수 있으므로 댓글과 게시물은 다대일 관계이다. @ManyToOne
또한 한명의 사용자는 여러 개의 댓글을 작성할 수 있으므로 댓글과 사용자도 다대일 관계이다.
게시글 엔티티에서도 @OneToMany로 양방향 관계를 맺어준다.
양방향 매핑을 할 때는 반드시 한쪽의 객체에 mappedBy 옵션을 설정해야 한다. 필요성을 잠시 설명하자면,
만약 양방향 매핑을 적용한다면 Comment 객체 뿐만 아니라 Posts 객체도 commentList 필드를 통해 Comment 테이블에 접근이 가능해지기 때문에 혼란이 생길 수 있다. 그렇기 때문에 두 객체 중 하나의 객체만 테이블을 관리할 수 있도록 정하는 것이 mappedBy 옵션이다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Comment extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="post_id")
private Posts posts;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="member_id")
private Member member;
....
}
회원과 댓글 엔티티와 기능은 이후 포스팅에서 작성하겠다.
CRUD API 만들기
Spring 웹 계층은 다음과 같다.
API를 만들기 위해 총 3개의 클래스가 필요하다.
-Request 데이터를 받을 Dto
-Api 요청을 받을 Controller
-트랜잭션, 도메인 기능 간의 순서를 보장하는 Service
Web Layer
- 컨트롤러(@Controller)와 JSP/Freemarker 등의 뷰 템플릿 영역
- 필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice) 등 외부 요청과 응답에 대한 전반적인 영역
Service Layer
- @Service에 사용되는 서비스 영역
- Controller와 Dao의 중간 영역에서 사용
- @Transactional 이 사용되어야 하는 영역
Repository Layer
- Database와 같이 데이터 저장소에 접근하는 영역(DAO)
Dtos
- Dto(계층 간에 데이터 교환을 위한 객체)의 영역
- 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등
Domain Model
- 도메인이라 불리는 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화 시킨 것
- @Entity가 사용된 영역도 도메인 모델
- 무조건 데이터베이스의 테이블과 관계가 있어야하는 것은 아님
- 택시 앱이라고 하면 배차, 탑승 요금등이 도메인이 될 수 있음
이 다섯가지 레이어 중에서 비지니스 처리를 담당해야 할 곳은 Domain이다.
web 패키지 > PostsApiController
@RequiredArgsConstructor
@RestController
@RequestMapping("/post")
public class PostsApiController {
private final PostsService postsService;
@ApiOperation(value="게시물 등록")
@PostMapping
public ResponseEntity save(@RequestBody PostsDto.Request requestDto, @AuthenticationPrincipal Member member){
return ResponseEntity.ok(postsService.save(requestDto, member.getId()));
}
@ApiOperation(value="게시물 수정")
@PutMapping("/{id}")
public ResponseEntity update(@PathVariable Long id, @RequestBody PostsDto.Request requestDto, @AuthenticationPrincipal Member member) throws Exception {
return ResponseEntity.ok(postsService.update(id, requestDto, member.getId()));
}
@ApiOperation(value="게시물 조회")
@GetMapping("/{id}")
public ResponseEntity read(@PathVariable Long id, @AuthenticationPrincipal Member member){
postsService.updateViewCount(id); // view count ++
return ResponseEntity.ok(postsService.findById(id, member.getId()));
}
@ApiOperation(value="게시물 삭제")
@DeleteMapping("/{id}")
public ResponseEntity delete(@PathVariable Long id, @AuthenticationPrincipal Member member) throws Exception {
postsService.delete(id, member.getId());
return ResponseEntity.ok(id);
}
}
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
private final MemberRepository memberRepository;
private final BookmarkRepository bookmarkRepository;
private final CommentRepository commentRepository;
@Transactional
public Long save(PostsDto.Request dto, Long memberId){
Member member = memberRepository.findById(memberId).orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다."));
dto.setMember(member); // 유저 정보 담기
return postsRepository.save(dto.toEntity()).getId();
}
@Transactional
public Long update(Long id, PostsDto.Request dto, Long memberId) throws Exception {
Posts post = postsRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id="+id));
if(!post.getMember().getId().equals(memberId)){
throw new Exception("해당 게시글 작성자가 아닙니다.");
}
post.update(dto.getTitle(), dto.getContent(), dto.getSubject(), dto.getDivision(), dto.getPeople_num(), dto.getProceed_way(), dto.getIs_progress());
return id;
}
@Transactional(readOnly = true)
public PostsDto.Response findById(Long id, Long memberId){
Posts post = postsRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id="+id));
Member member = memberRepository.findById(memberId).orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다."));
PostsDto.Response resPost = new PostsDto.Response(post, memberId);
return resPost;
}
@Transactional
public void delete(Long id, Long memberId) throws Exception{
Posts post = postsRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id="+id));
if(!post.getMember().getId().equals(memberId)){
throw new Exception("해당 게시글 작성자가 아닙니다.");
}
postsRepository.delete(post);
}
}
Controller와 Service에서 사용되는 @RequiredArgsConstructor에 대해 알아보자.
스프링에선 빈을 세가지 방식(@Autowired, setter, 생성자)으로 주입받을 수 있다.
이 중 가장 권장하는 방식은 생성자이다.
그 이유는 아래 포스팅에 작성해놓았다.
https://yeoncoding.tistory.com/739
생성자로 빈 객체를 받도록 하면 @Autowired와 동일한 효과를 볼 수 있다.
롬복의 @RequiredAargsConstructor를 사용하면, final이 선언된 모든 필드를 인자값으로 하는 생성자를 생성해준다.
생성자를 직접 안쓰고 어노테이션을 사용하는 이유는 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정해야하는 번거로움을 해결하기 위함이다.
그리고 주의깊게 봐야할 부분이 update부분인데,
update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없다.
이게 가능한 이유는 JPA의 영속성 컨텍스트(엔티티를 영구 저장하는 환경) 때문이다.
JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면, 이 데이터는 영속성 컨텍스트가 유지된 상태이다.
이 상태에서 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다.
Entity 객체의 값만 변경하면 별도로 update 쿼리를 날릴 필요가 없는 것이다. 이것을 더티 체킹이라고 한다.
web.dto 패키지 > PostsSaveRequestDto
public class PostsDto {
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public static class Request{
private String title;
private String content;
private Member member;
private String subject;
private String division;
private int people_num;
private String proceed_way;
private int is_progress;
public Posts toEntity(){ // dto -> entity
Posts posts = Posts.builder()
.title(title)
.member(member)
.content(content)
.subject(subject)
.is_progress(is_progress)
.division(division)
.people_num(people_num)
.proceed_way(proceed_way)
.build();
return posts;
}
}
@Getter
@Setter
public static class Response{
private Long id;
private String title;
private String content;
private String writer_nickname;
private String writer_id;
private String subject;
private String division;
private int people_num;
private String proceed_way;
private int is_progress;
private String createdDate;
private String modifiedDate;
private int view_count;
private int bookmark_count;
private List<CommentDto.Response> commentList;
private Boolean isWriter;
private Boolean isBookmarked;
public Response(Posts entity, Long currentMemberId){ // entity -> dto
this.id = entity.getId();
this.title = entity.getTitle();
this.writer_nickname = entity.getMember().getNickname();
this.writer_id = entity.getMember().getStudentId();
this.content = entity.getContent();
this.subject = entity.getSubject();
this.division = entity.getDivision();
this.people_num = entity.getPeople_num();
this.proceed_way = entity.getProceed_way();
this.is_progress=entity.getIs_progress();
this.createdDate = entity.getCreatedDate();
this.modifiedDate = entity.getModifiedDate();
this.view_count = entity.getView_count();
this.bookmark_count = entity.getBookmark_count();
this.commentList = entity.getCommentList().stream().map(c-> new CommentDto.Response(c, currentMemberId, entity.getMember().getId())).collect(Collectors.toList());
}
}
}
절대로 Entity 클래스를 Request/Response 클래스로 사용하면 안된다.
Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스이다. 화면 변경이 일어날 때마다 Entity 클래스를 변경할 수는 없다. 많은 서비스 클래스나 비지니스 로직들이 Entity 클래스를 기준으로 동작하기 때문에, Entity 클래스가 변경되면 여러 클래스에 영향을 끼친다.
View Layer와 DB Layer의 역할 분리를 철저하게 하여 Entity 클래스와 Controller에서 쓸 Dto는 꼭 분리하도록 한다.
DTO는 Inner static class로 관리했다. 한 개의 클래스 파일로 DTO를 inner class로 관리하면 코드의 캡슐화를 증가시키며, 코드의 복잡성을 줄일 수 있다는 장점이 있다.
PostsApiControllerTest
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
@After
public void clear() throws Exception{
postsRepository.deleteAll();
}
@Test
public void 게시글_등록_테스트() throws Exception{
// given
String title = "title";
String content = "content";
PostsDto.Request requestDto = PostsDto.Request.builder()
.title(title)
.content(content)
.subject("subject")
.division("A")
.people_num(3)
.proceed_way("온라인")
.is_progress(0)
.build();
String url = "http://localhost:"+port+"/posts";
// when
ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);
// then
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(responseEntity.getBody()).isGreaterThan(0L);
List<Posts> postsList = postsRepository.findAll();
assertThat(postsList.get(0).getTitle()).isEqualTo(title);
assertThat(postsList.get(0).getContent()).isEqualTo(content);
}
@Test
public void 게시글_수정_테스트() throws Exception{
// given
Posts savedPosts = postsRepository.save(Posts.builder()
.title("title")
.content("content")
.subject("subject")
.division("N")
.people_num(4)
.proceed_way("온라인")
.is_progress(0)
.build());
Long updateId = savedPosts.getId();
String expectedTitle = "title2";
String expectedContent = "content2";
PostsDto.Request requestDto = PostsDto.Request.builder()
.title(expectedTitle)
.content(expectedContent)
.subject("subject")
.division("A")
.people_num(3)
.proceed_way("온라인")
.is_progress(0)
.build();
String url = "http://localhost:"+port+"/posts/"+updateId;
HttpEntity<PostsDto.Request> requestEntity = new HttpEntity<>(requestDto);
// when
ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class);
// then
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(responseEntity.getBody()).isGreaterThan(0L);
List<Posts> postsList = postsRepository.findAll();
assertThat(postsList.get(0).getTitle()).isEqualTo(expectedTitle);
assertThat(postsList.get(0).getContent()).isEqualTo(expectedContent);
}
}
'BACK > SPRING' 카테고리의 다른 글
Spring Boot Project(5) - 게시글 페이징, 필터링하기 (0) | 2023.01.24 |
---|---|
Spring Boot Project(4) - JPA Auditing으로 생성/수정 시간 자동화 (0) | 2023.01.24 |
Spring Boot Project(2) - 프로젝트 세팅, MySQL 연동하기 (0) | 2023.01.24 |
[Spring] 컴포넌트 스캔 / @Autowired (0) | 2023.01.17 |
[Spring Boot] List를 Page로 변환하기 (0) | 2023.01.09 |