[이웃사이] Service 관련 개념 및 구현

아양시·2022년 10월 13일
0

이웃사이

목록 보기
5/6

🙋‍♀️ Service

service단에서는 실질적으로 비즈니스 로직을 작성한다. 본 서비스에서는 크게 복잡한 로직은 없었지만, 권한을 어디까지 확인해야 하는지를 가장 많이 고민했던 것 같다. 기본적으로 security 단에서 사용자의 Role을 확인하고 들어오기 때문에 !



📔 개념

1. @Service

  해당 클래스를 Bean 객체로 생성해주는 어노테이션이다. 본 인터페이스를 구성하고 있는 부모 어노테이션들은 @Repository와 동일하다. 즉 둘 다 Bean 객체를 생성해주고 딱히 다른 기능을 넣어주는 것이 아니라서 뭘 써도 상관없고, 부모 어노테이션인 @Component를 붙여줘도 되지만 명시적으로 역할을 구분해주기 위해 분리해서 사용한다고 한다.

  그리고 Bean 객체는 항상 데이터 변경이 없는 객체에 한해서만 사용해야하는 점에 유의하자 ! 헐 그러고 보니 @Entity에는 Bean으로 등록해주는 어노테이션이 포함 되어있지 않구나
즉, 값들을 담는 클래스가 아니라 작업을 하는 클래스를 Bean으로 등록해야 한다.

2. @Transactional

(1) Transaction이란 ?

  트랜잭션은 더 이상 쪼갤 수 없는 업무 처리의 최소 단위이다. 트랜잭션 처리가 정상적으로 완료된 경우 커밋을 하고, 오류가 발생할 경우 원래 상태대로 롤백을 한다. 따라서 한 트랜잭션 내의 작업들은 모두 커밋되거나 모두 롤백되는 동일한 상태를 가진다. (All or Nothing)

(2) ACID

[ 원자성 - Atomicity ]

  • 하나의 트랜잭션은 더 이상 쪼갤 수 없는 최소한의 업무 단위이다. 작업 단위를 일부분만 실행하지 않는다. 한 트랜잭션 내에서 작업이 중간에 끊기게 되면 이후 해당 트랜잭션의 어디서부터 이어서 수행되어야 하는지 모르기 때문에 원자성의 성질을 지니게 되었다.
  • 수행하고 있는 트랜잭션에 의해 변경된 내역을 유지하면서, 이전에 커밋된 상태를 임시 영역(rollback segment)에 따로 저장함으로써 원자성을 보장한다.
  • 확실한 부분에 대해서는 뒤에서 오류가 발생해도 롤백되지 않도록 중간 저장 지점인 save point를 지정할 수 있다.

[ 일관성 - Consistency ]

  • 트랜잭션이 완료된 결과값은 일관적인 DB 상태를 유지한다. 즉, 트랜잭션이 일어난 이후의 데이터베이스는 본래의 제약이나 규칙을 만족해야 한다.
  • 트랜잭션은 외래키와 같은 명시적인 무결성 제약조건 및 서비스의 비명시적인 일관성 조건들도 보존해야 한다.
  • 외래키가 존재하는 경우, 한 테이블에서 이벤트가 발생했을 때 다른쪽 테이블에 자동적으로 해당 동작이 수행되도록 트리거를 설정함으로써 보장할 수 있다.

[ 고립성 - Isolaion ]

  • 한 트랜잭션 수행 시 다른 트랜잭션의 작업이 끼어들지 못하도록 보장한다. 즉, 한 트랜잭션 실행 중 변경한 데이터는 이 트랜잭션 완료 전에는 다른 트랜잭션이 참조하지 못한다.
  • 한 데이터에 대해 동시에 여러 트랜잭션이 이루어질 경우, 두 트랜잭션을 연속으로 실행한 것과 동일한 결과를 나타내야 한다. 각 트랜잭션은 상호 간의 존재를 모른다.
  • lock과 unlock을 통해 고립성을 보정한다.

[ 지속성 - Durability ]

  • 트랜잭션이 정상적으로 종료된 후에는 작업의 결과가 영구적으로 데이터베이스에 저장되어야 한다.

(3) 이점 - 무정지성의 향상

  • 서버 장애가 발생하여 데이터베이스를 재가동할 때 장애 직전까지의 커밋 결과를 손실하지 않고 마칠 수 있다.
  • REDO 로그(데이터베이스에서 수행한 작업을 다시 실행하는 로그)를 이용한 아키텍처로 무정지성을 보장한다.

(4) 동작 원리

  • AOP(Aspect Oriented Programming) :
    흩어진 관심사를 모듈화 할 수 있는 프로그래밍 기법이다. 각 핵심적인 기능을 구성하는 부가적인 기능을 모듈화하여 관리하는 것이다. 여기서 각 모듈을 aspect라고 한다. aspect를 적용하는 곳을 target, 실질적인 부가 기능을 담은 구현체를 advice, 메서드 실행 시점을 join point, 구체적으로 advice가 실행될 시점을 정하는 상세 스펙을 정의한 것을 point cut이라고 한다. 스프링에서는 advice, point cut을 통틀어 advisor라고 부르며, 아주 단순한 형태의 aspect라고 할 수 있다.
  • 프록시 객체 : 원래 객체를 감싸고 있는 객체이다. 원래 객체를 주입받아 인터페이스 추상메서드 내에서 타깃 메서드 호출 전후로 원하는 처리를 추가한다.
  • @Transactional이 붙은 메서드 호출 시 해당 메서드를 트랜잭션으로 감싼 동적 프록시 객체를 생성하여 호출한다.(JDK Dynamic Proxy 방식, Default) 그 후 트랜잭션을 시작하고 커밋 또는 롤백된다.

(5) 주의할 점

  • 반드시 public 메서드에 적용되어야 한다.
  • @Transactional이 적용되지 않은 public 메서드에서 @Transactional이 적용된 public 메서드를 호출할 경우 트랜잭션이 동작하지 않는다. - Dynamic Proxy 방식

(6) 트랜잭션 격리 수준

다른 포스팅에서 따로 다뤄보겠다. 다양한 lock의 종류와 함께 ...



👩‍💻 구현

🤔 @Transactional 사용 방법

  @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 = trueCascadeType.REMOVE를 사용할 수 있었겠지만 그렇지 않아서 직접 구현해주었다.

  • parent 객체의 child 필드를 null로 지정하는 경우
    • orphanRemoval = true : 관계가 끊어진 child를 자동으로 제거한다.
    • CascadeType.REMOVE : 관계가 끊어진 것을 제거로 보지 않기 때문에 child를 제거하지 않는다.

🤔 페이징 유효성 검사

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으로 정의했다.



참고문헌

profile
BE Developer

0개의 댓글