service단에서는 실질적으로 비즈니스 로직을 작성한다. 본 서비스에서는 크게 복잡한 로직은 없었지만, 권한을 어디까지 확인해야 하는지를 가장 많이 고민했던 것 같다. 기본적으로 security 단에서 사용자의 Role을 확인하고 들어오기 때문에 !
해당 클래스를 Bean 객체로 생성해주는 어노테이션이다. 본 인터페이스를 구성하고 있는 부모 어노테이션들은 @Repository와 동일하다. 즉 둘 다 Bean 객체를 생성해주고 딱히 다른 기능을 넣어주는 것이 아니라서 뭘 써도 상관없고, 부모 어노테이션인 @Component를 붙여줘도 되지만 명시적으로 역할을 구분해주기 위해 분리해서 사용한다고 한다.
그리고 Bean 객체는 항상 데이터 변경이 없는 객체에 한해서만 사용해야하는 점에 유의하자 ! 헐 그러고 보니 @Entity에는 Bean으로 등록해주는 어노테이션이 포함 되어있지 않구나
즉, 값들을 담는 클래스가 아니라 작업을 하는 클래스를 Bean으로 등록해야 한다.
트랜잭션은 더 이상 쪼갤 수 없는 업무 처리의 최소 단위이다. 트랜잭션 처리가 정상적으로 완료된 경우 커밋을 하고, 오류가 발생할 경우 원래 상태대로 롤백을 한다. 따라서 한 트랜잭션 내의 작업들은 모두 커밋되거나 모두 롤백되는 동일한 상태를 가진다. (All or Nothing)
[ 원자성 - Atomicity ]
[ 일관성 - Consistency ]
[ 고립성 - Isolaion ]
[ 지속성 - Durability ]
다른 포스팅에서 따로 다뤄보겠다. 다양한 lock의 종류와 함께 ...
@Transactional을 클래스 또는 메서드에 달 수 있는데 본 프로젝트에서는 기본적으로 클래스에 readOnly = true 옵션으로 달아두고, 조회를 제외한 나머지 메서드에 따로 @Transactional을 다는 방식을 선택했다.
readOnly 옵션으로 읽기전용 트랜잭션을 설정해주면 강제로 flush 하기 전에는 flush가 발생하지 않기 때문에 조회만 하는 경우 변경감지를 위한 스냅샷이 발생하지 않아 성능이 향상된다.
public Community findById(Long communityId, Long accountId) {
Community findCommunity = communityRepository.findById(communityId)
.orElseThrow(() -> new NotFoundException("커뮤니티글을 찾을 수 없습니다."));
Account account = accountRepository.findAccountById(accountId)
.orElseThrow(() -> new NotFoundException("계정이 존재하지 않습니다."));
if(findCommunity.getScope() == Scope.LINE) {
if(account.getRole() != Role.ROLE_MANAGER &&
!findCommunity.getWriterLineName().equals(account.getLine().getName())) {
throw new IllegalStateException("접근 권한이 없습니다.");
}
}
return findCommunity;
}
이 서비스 함수는 Id로 해당 커뮤니티 게시글을 상세 조회하는 함수이다. 해당 Id의 글이 (1) 이미 삭제되거나 애초에 생성되지 않아서 존재하지 않거나 (2) 조회하고자 하는 사람이 회원으로 등록되어 있지 않거나 (3) 해당 글에 접근 권한이 없는 경우에는 예외를 발생시켜야 한다.
본 서비스에서 커뮤니티 게시글은 공개 범위(전체 공개 또는 내 라인에만 공개)를 가지는데, 매니저는 공개 범위에 상관없이 모든 글을 조회할 수 있다. 따라서 라인 공개의 게시물에 대해서 매니저도 아니고 해당 라인의 회원이 아닌 경우 예외를 발생시켰다.
모든 서비스 함수에 대해 위와 같은 예외를 설정해 주었다.
public List<Community> findAll(Scope scope, Category category, int page, int count, Long accountId) {
...
Account account = accountRepository.findAccountById(accountId)
.orElseThrow(() -> new NotFoundException("계정이 존재하지 않습니다."));
if(scope.equals(Scope.ALL)) {
if(category.equals(Category.ALL)) return communityRepository.findAllForAll(account.getLine().getName(), page, count, true);
else return communityRepository.findAllForAllWithCategory(category, account.getLine().getName(), page, count, true);
}
if (category.equals(Category.ALL)) return communityRepository.findAllForLine(account.getLine().getName(), page, count, true);
return communityRepository.findAllForLineWithCategory(category, account.getLine().getName(), page, count, true);
}
이 서비스에서 필터링 조건은 공개범위와 카테고리로 두 가지이다. 또한 두 조건을 동시에 적용해서 검색해야 한다.
카테고리의 경우에는 게시글을 쓸 때는 PLAIN, SELLING, BUYING, QNA 중에 선택하게 되고, 조회할 때는 ALL도 선택할 수 있다. 따라서 카테고리 필터링 조건이 ALL이 아닐 때는 해당 카테고리로 검색하면 되지만 ALL일 때는 아예 카테고리 조건을 걸지 않고 공개 범위만으로 검색 해야한다. 그리고 모든 결과는 페이징 된 결과이다.
따라서 결과적으로 각 4가지에 대한 쿼리를 각각 작성하고, 각 경우에 맞는 쿼리를 호출해야 한다. 처음에는 쿼리문 재사용성을 높이기 위해 각 필드의 조합으로 검색하는 쿼리들을 각각 작성했었는데 ...
어차피 본 서비스에서 각 쿼리들을 (적어도 지금은) 한 번씩만 사용하기도 하고, 라인 공개의 경우 scope가 LINE인 것을 찾을 뿐 아니라 lineName으로도 찾아야 해서 전체 공개의 경우와 쿼리 자체가 달라지기 때문에 결과적으로 위 코드처럼 네 가지의 경우로 나누어서 작성했다.
주저리주저리 했지만 결론은 필터링 기능을 구현할 때는 그 서비스의 필터링 조건의 조합을 잘 생각해보고 쿼리를 짜자 ! 라는 것이다. 요번에는 그러지 못해서 수정이 잦았기 때문에 ,,
@Transactional
public void updateCommunity(Long id, CommunityDTO communityDTO, Long profileId) {
Community community = communityRepository.findById(id)
.orElseThrow(() -> new NotFoundException("커뮤니티 글이 존재하지 않습니다."));
if(!community.getWriter().getId().equals(profileId))
throw new IllegalStateException("수정 권한이 없습니다.");
community.changeTitle(communityDTO.getTitle());
community.changeContent(communityDTO.getContent());
community.changeCategory(communityDTO.getCategory());
community.changeScope(communityDTO.getScope());
}
entity 수정에 대해서는 해당 객체를 가져와서 필드를 변경만 해주면 JPA가 변경 전에 찍어둔 스냅샷과 비교해서 다르면 알아서 update 쿼리를 날려준다. persist를 하지 않아도 !
그리고 본 프로젝트에서 데이터 수정 남용으로 인한 데이터 수정 근원지 찾기가 어려워질 것을 예방하기 위해 setter는 닫아두는 것을 원칙으로 했고, 수정해야할 일이 있으면 그 목적이 명확하게 제시되는 함수 네이밍을 하기로 했다. 따라서 수정 기능에 사용되는 함수들은 change라는 네이밍을 사용했다.
@Transactional
public void delete(Long id, Long profileId) {
Community community = communityRepository.findById(id)
.orElseThrow(() -> new NotFoundException("커뮤니티 글이 존재하지 않습니다."));
if(!community.getWriter().getId().equals(profileId))
throw new IllegalStateException("삭제 권한이 없습니다.");
List<CommunityComment> comments = commentRepository.findAllByCommunityId(id);
for(CommunityComment comment : comments) commentRepository.delete(comment);
communityRepository.delete(community);
}
커뮤니티 게시글 하나를 삭제할 때는 먼저 댓글들을 삭제해야 하기 때문에 먼저 커뮤니티 게시글 Id로 모든 댓글들을 가져와서 삭제한 후 게시글을 삭제했다.
Community Entity가 CommunityComment 리스트를 필드로 가지고 있었다면 orphanRemoval = true나 CascadeType.REMOVE를 사용할 수 있었겠지만 그렇지 않아서 직접 구현해주었다.
private void validatePaging(int page, int count, Long num) {
int limit = (page - 1) * count;
if (page != 1 && num <= limit) {
throw new IllegalStateException("게시글 요청 범위를 초과하였습니다.");
}
}
public List<Community> findAll(int page, int count, ...) {
validatePaging(page, count, countAll(scope, category, accountId));
...
}
모든 목록 조회 서비스에는 페이징을 제공해야 하기 때문에 지금 요청하는 범위가 데이터가 존재하는 유효한 범위인지 검사하는 로직을 추가했다. 유효성 검사 함수는 서비스단 내부에서만 사용하기 때문에 private으로 정의했다.
참고문헌
- [@Service] https://codevang.tistory.com/258
- [Bean 등록] https://kim-jong-hyun.tistory.com/97
- [@Transactional] http://wiki.hash.kr/index.php/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98
https://hanamon.kr/%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EC%9D%98-acid-%EC%84%B1%EC%A7%88/- [AOP] https://code-lab1.tistory.com/193