쌩로그

[Refactoring] WanderHub - 동행 생성 - 도메인 패턴 모델 적용, Transactional 수정, Cascade 조금은 제대로 활용 본문

Project/23년 6월의 프로젝트

[Refactoring] WanderHub - 동행 생성 - 도메인 패턴 모델 적용, Transactional 수정, Cascade 조금은 제대로 활용

.쌩수. 2024. 1. 1. 23:23
반응형

목록

  1. 포스팅 개요
  2. 본론
        2-1. Controller 에서의 코드 수정
        2-2. 도메인 모델 패턴 적용
        2-3. 서비스 계층 Transaction 적용 변경
        2-4. Cascade 적극 활용
  3. 요약

1. 포스팅 개요

해당 포스팅은 2023년 6월의 프로젝트에 대한 코드를 Refactoring한 과정입니다.

여행 동행 도메인에서 생성에 대한 부분을 Refactoring 한 과정입니다.

고칠 것이 많지만 일단은 생성 로직만 Refactoring 했습니다.

2. 본론

2-1. Controller 에서의 코드 수정

@Slf4j
@RequiredArgsConstructor
@Validated
@RestController
@RequestMapping("/v1/accompany")
public class AccompanyController {

    private final TokenService tokenService;
    private final AccompanyMapper accompanyMapper;
    private final AccompanyService accompanyService;

  ...

}

위의 코드는 다음과 같이 변경했습니다.

@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/accompany")
public class AccompanyController {

    private final TokenService tokenService;
    private final AccompanyService accompanyService;

    ...
}

변경 포인트는 다음과 같습니다.

  • RequestMapping의 url을 'v1'에서 'api'로 변경했습니다.
  • AccompanyMapper 필드 제거
    • AccompanyMapper는 Entity를 Dto로 바꾸거나 혹은 Dto를 Entity로 바꾸는 용도로 사용되었지만, 서비스 계층이나, Repository 계층에서 바로 DTO로 반환하기 때문에 제거하였습니다.
    • 이 부분에 대해서는 나중에 보다 객체지향적인 설계와 계층 분리에 대한 고민을 좀 더 꼼꼼히 해보고 개선해나가야 될 부분이라고 생각합니다.
  • @Validated를 제거했습니다다.
    • 각 매핑 메서드의 매개변수에 @Validated가 붙은 곳이 있습니다다. @Validated가 붙어있다면, DTO 클래스의 validation을 요소들을 스프링이 알아서 해주기 때문에, 클래스 레벨에 있는 것은 불필요하고, 오히려 코드가 중복되는 부분이라고 생각하여 제거했습니다.
    • 추후 아예 클래스 레벨에 두고, 매개 변수에서 싹 다 제거하던가, 아니면 지금 수정한 이대로 두던가 고민해볼 필요가 있을 거 같습니다.

다음은 동행 생성 메서드를 어떻게 개선했는지 보도록 하겠습니다.

    // 동행 생성
    @PostMapping
    public ResponseEntity postAccompany(HttpServletRequest request, @Validated @RequestBody AccompanyDto.Post post, Principal principal) {
        tokenService.verificationLogOutToken(request); // 블랙리스트 JWT확인
        accompanyService.createAccompany(post, principal.getName());
        return new ResponseEntity(HttpStatus.CREATED);
    }

이 부분을 다음과 같이 변경했습니다.

    // 동행 생성 - 리팩터링
    @PostMapping("/refactoring")
    public ResponseEntity postAccompanyRefactoring(HttpServletRequest request, @Validated @RequestBody AccompanyPostDto accompanyPostDto, Principal principal) {
        tokenService.verificationLogOutToken(request); // 블랙리스트 JWT확인
        accompanyService.createAccompanyRefactoring(accompanyPostDto, principal.getName());
        return new ResponseEntity(HttpStatus.CREATED);
    }

변경 포인트는 다음과 같습니다.

  • DTO를 분리했습니다.

    -

      public class AccompanyDto {
    
        // 생성시
        @Getter
        @AllArgsConstructor
        @NoArgsConstructor
        @Builder
        public static class Post {
            private String local;
            @NotNull
            private Long maxMemberCount;
            @JsonFormat(pattern = "yyyy-MM-dd") // 2022-10-02
            private LocalDate accompanyStartDate;
            @JsonFormat(pattern = "yyyy-MM-dd") // 2022-10-02
            private LocalDate accompanyEndDate;
            @NotBlank
            private String title;
            @Lob
            @NotBlank
            private String content;
            private Double coordinateX;
            private Double coordinateY;
            private String placeName;
    
        }
    
        // Accompany 수정
        @Getter
        @AllArgsConstructor
        @NoArgsConstructor
        public static class Patch {
            private String local;
            private Long maxMemberCount;
            @JsonFormat(pattern = "yyyy-MM-dd") // 2022-10-02
            private LocalDate accompanyStartDate;
            @JsonFormat(pattern = "yyyy-MM-dd") // 2022-10-02
            private LocalDate accompanyEndDate;
            private String title;
            @Lob
            private String content;
            private Double coordinateX;
            private Double coordinateY;
            private String placeName;
        }
      }

    이렇게 기존에는 하나의 Dto 클래스에서 내부 클래스로 생성용, 수정용 DTO를 따로 만들었습니다. 현재는 사용한 코드를 용도별로 따로 분리했습니다.
    현재 생성에 대한 부분이므로, AccompanyDto라는 클래스를 다음과 같이 따로 작성했습니다.

    @Getter
    public class AccompanyPostDto {
    
      private String local;
      @NotNull
      private Long maxMemberCount;
      @JsonFormat(pattern = "yyyy-MM-dd") // 2022-10-02
      private LocalDate accompanyStartDate;
      @JsonFormat(pattern = "yyyy-MM-dd") // 2022-10-02
      private LocalDate accompanyEndDate;
      @NotBlank
      private String title;
      @NotBlank
      private String content;
      private Double coordinateX;
      private Double coordinateY;
      private String placeName;
    
      }

    기존에 있었지만, 불필요한 애너테이션 @Builder@AllArgsConstructor, @NoArgsConstructor를 제거했습니다.
    (방금 @NoArgsConstructor를 제거하더라도 원활하게 돌아가는 것을 확인했는데, 이유를 생각해보니 자바에서는 클래스에 생성자가 없으면 기본 생성자를 자동으로 만들어줍니다.)

    compile - build를 거쳐 out디렉터리 하위에 생성되는 Dto 클래스에 이처럼 기본생성자가 생성되어있습니다.

    이처럼 Dto를 용도별로 분리했습니다.

2-2. 도메인 모델 패턴 적용

이전 프로젝트에선 서비스 계층을 통해서 모든 로직을 처리하는 트랜잭션 스크립트 패턴을 이용했습니다만, 이번 리팩터링에서는 Entity와 관련된 비즈니스 로직은 Entity에서 처리하는 도메인 패턴 모델을 적용햇습니다.

먼저 트랜잭션 스크립트 패턴을 적용했을 때의 코드가 어떠했는가를 살펴보겠습니다.

// 동행 생성
public void createAccompany(AccompanyDto.Post postAccompany, String email) {
    // 이메일을 통해서 사용자의 닉네임이 있는지 없는지 확인한다. // 즉, 사용자 검증을 해준다.
    Member findMember = memberService.findMember(email);
    memberService.verificationMember(findMember);               // 통과시 회원 검증 완료

    Accompany initAccompany = Accompany.builder()
            .accompanyMaker(findMember.getNickname())
            .local(Local.getLocal(postAccompany.getLocal()))
            .maxMemberCount(postAccompany.getMaxMemberCount())
            .accompanyStartDate(postAccompany.getAccompanyStartDate())
            .accompanyEndDate(postAccompany.getAccompanyEndDate())
            .title(postAccompany.getTitle())
            .content(postAccompany.getContent())
            .coordinateX(postAccompany.getCoordinateX())
            .coordinateY(postAccompany.getCoordinateY())
            .placeName(postAccompany.getPlaceName())
            .build();

    accompanyMemberService.createAccompanyMember(initAccompany, findMember);// 동행_멤버 생성
    accompanyRepository.save(initAccompany);
}

이 코드가 트랜잭션 스크립트 패턴을 사용한 코드입니다.
다음은 도메인 모델 패턴을 적용한 코드입니다.

    // 동행 생성 - 리팩
    @Transactional // 생성
    public void createAccompanyRefactoring(AccompanyPostDto accompanyPostDto, String email) {
        // 이메일을 통해서 사용자의 닉네임이 있는지 없는지 확인한다. // 즉, 사용자 검증을 해준다.
        Member findMember = memberService.findMember(email);        // 회원찾기 // 회원 있는지 확인 & 회원 닉네임 & 회원 활동중 => JPQL로
        memberService.verificationMember(findMember);               // 통과시 회원 검증 완료
        Accompany createdAccompaney = Accompany.createAccompany(findMember.getNickname(), accompanyPostDto);
        accompanyMemberService.createAccompanyMemberRefactoring(createdAccompaney, findMember);// 동행_멤버 생성
        accompanyRepository.save(createdAccompaney);
    }

먼저 데이터의 변경이 일어나는 부분이므로 메서드 레벨에 @Transactional을 선언했습니다.

도메인 모델 패턴을 적용한 곳에서는 저장할 Entity를 서비스 클래스에서 만들고 있지 않습니다.

영한님의 JPA 실전 활용편 1편을 들으면서 언급했던 트랜잭션 스크립트 패턴이 있었는데,
이는 서비스 계층에서 모든 비즈니스 로직을 처리하는 패턴이고, 이와는 다른 도메인 모델 패턴이 있었습니다. 보다 객체지향적이고, Entity에서 처리해야하는 것은 Entity에서 처리하도록 하는 것입니다.
따라서 기존의 저장할 Entity를 생성하는 코드를 서비스 클래스가 아닌 Entity 클래스에 작성했습니다.

그럼 이제 Entity 클래스는 어떻게 변경되었는지 보도록 하겠습니다.

@Getter
@Entity
@NoArgsConstructor
public class Accompany extends Auditable {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "ACCOMPANY_ID", updatable = false)
    private Long accompanyId;

    @Column(name = "ACCOMPANY_MAKER",length = 50, updatable = false)
    private String accompanyMaker;

    @Enumerated(value = EnumType.STRING)
    @Column(name = "LOCAL", length = 16)    // ERD상 Not Null이지만, 기본 X(선택없음)로 들어가므로 nullable 표시 안함.
    private Local local;

    @Column(name = "MAX_MEMBER_COUNT", nullable = false)      // 최대인원
    private Long maxMemberCount;

    @Column(name = "ACCOMPANY_START_DATE")
    private LocalDate accompanyStartDate;        // 동행 시작 날짜

    @Column(name = "ACCOMPANY_END_DATE")
    private LocalDate accompanyEndDate;          // 동행 시작 날짜

    @Column(name = "TITLE", length = 100, nullable = false)
    private String title;

    @Lob
    @Column(name = "CONTENT", nullable = false)
    private String content;

    @ColumnDefault("false")     // 기본값 false로 지정
    @Column(name = "RECRUIT_COMPLETE")    // 기본값 false이므로, Table상 Not Null이지만, nullable 포시 안 함.
    private boolean recruitComplete;   // 모집 완료 여부 // 모집 완료 되면 True // boolean 기본 false.

    @Column(name = "COORDINATE_X")
    private Double coordinateX;

    @Column(name = "COORDINATE_Y")
    private Double coordinateY;

    @Column(name = "PLACE_NAME", length = 50)
    private String placeName;

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "accompany", orphanRemoval = true) // orphanRemoval 연관관계가 끊어지면 자동으로 삭제
    private List<AccompanyMember> accompanyMemberList = new ArrayList<>();

    @Builder
    public Accompany(String accompanyMaker, Local local, Long maxMemberCount, LocalDate accompanyStartDate, LocalDate accompanyEndDate, String title, String content, Double coordinateX, Double coordinateY, String placeName) {
        this.accompanyMaker = accompanyMaker;
        this.local = local;
        this.maxMemberCount = maxMemberCount;
        this.accompanyStartDate = accompanyStartDate;
        this.accompanyEndDate = accompanyEndDate;
        this.title = title;
        this.content = content;
        this.coordinateX = coordinateX;
        this.coordinateY = coordinateY;
        this.placeName = placeName;
    }

    ...
}

기존의 Entity는 위와 같습니다.
아래는 도메인 모델 패턴을 적용한 Entity 클래스 코드입니다.
(필드는 중복되는 부분이므로 생략합니다.)

@Getter
@Entity
@NoArgsConstructor(access = PROTECTED)
public class Accompany extends Auditable {

    ...

    // 생성자 Domain Model Pattern 적용
    public static Accompany createAccompany(String accompanyMaker, AccompanyPostDto accompanyPostDto) {
        Accompany accompany = new Accompany();
        accompany.accompanyMaker = accompanyMaker;
        accompany.local = Local.getLocal(accompanyPostDto.getLocal());
        accompany.maxMemberCount = accompanyPostDto.getMaxMemberCount();
        accompany.accompanyStartDate = accompanyPostDto.getAccompanyStartDate();
        accompany.accompanyEndDate = accompanyPostDto.getAccompanyEndDate();
        accompany.title = accompanyPostDto.getTitle();
        accompany.content = accompanyPostDto.getContent();
        accompany.coordinateX = accompanyPostDto.getCoordinateX();
        accompany.coordinateY = accompanyPostDto.getCoordinateY();
        accompany.placeName = accompanyPostDto.getPlaceName();

        return accompany;
    }

  ...
}

변경된 부분은 다음과 같습니다.

  • @NoArgsConstructor(access = PROTECTED)
    • 기본 생성자의 access 레벨을 PROTECTED로 설정했습니다.
    • (앞에서 AccessLevel 키워드가 빠진 것은 static import를 적용했기 때문입니다.)
    • JPA는 기본 생성자의 접근 레벨을 PRIVATE이 아니라, 최소한 PROTECTED로는 허용해야 하기 때문에, PROTECTED로 설정했습니다.
  • 저장할 Entity를 만들 생성자 메서드를 따로 작성했습니다.
    • createAccompany 라는 의미있는 메서드 이름을 두어서 Accompany를 Entity 클래스를 통해서 작성하게 했습니다. 그로 인해서 서비스 계층과의 결합도는 줄이고, Entity 계층에서 응집도를 높여 보다 더욱 객체지향적으로 설계했습니다.

이처럼 도메인 모델 패턴을 적용했습니다.

2-3. 서비스 계층 Transaction 적용 변경

기존의 클래스 레벨의 코드는 다음과 같습니다.

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class AccompanyService {

    private final MemberService memberService;
    private final AccompanyMemberService accompanyMemberService;
    private final AccompanyRepository accompanyRepository;
    private final AccompanySearchRepository accompanySearchRepository;

    ...
}

수정한 것은 다음과 같습니다.

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class AccompanyService {

    private final MemberService memberService;
    private final AccompanyMemberService accompanyMemberService;
    private final AccompanyRepository accompanyRepository;
    private final AccompanySearchRepository accompanySearchRepository;

    ...
}

변경점은 @Transactional(readOnly = true) 이 부분 하나입니다.
readOnly 속성을 주었습니다.

Service 게층의 코드는 대부분 읽기 전용 코드입니다.
그래서 readOnly = true 속성을 주었습니다.
그리고 readOnly = true 속성을 주게 되면 스프링이 알아서 데이터를 읽는 부분에 있어서는 최적화를 해주는 것으로 알고 있습니다.
데이터의 생성이나 수정, 삭제와 같이 데이터의 변경이 일어나는 부분은 그리 많지 않습니다.
따라서 데이터의 변경이 일어나는 부분에 별도로 @Transaction을 추가해주면 됩니다.

이처럼 서비스 계층 Transaction 적용을 변경했습니다.

2-4. Cascade 적극 활용

Accompany와 Accompany는 일대다(1:N)관계입니다.

이전에는 JPA와 DB의 Cascade를 사용하면서도 이해하지 못하여 잘 활용하지 못했습니다.
그로 인해 불필요한 코드를 작성했었습니다만,
이번에는 다(N)에 해당하는 자식의 클래스의 생명주기를 부모에게 맡기도록 했고,
그로 인해 불필요한 코드를 제거하였습니다

동행 Entity를 생성하는 메서드에는 일대다 관계에 있는 AccompanyMember(동행과 멤버의 다대다 관계를 일대다, 다대일로 풀어주는 연결 테이블)를 생성하는 로직도 있습니다.

바로 다음과 같은 코드입니다.

// 동행 생성 - 리팩
@Transactional // 생성
public void createAccompanyRefactoring(AccompanyPostDto accompanyPostDto, String email) {
    // 이메일을 통해서 사용자의 닉네임이 있는지 없는지 확인한다. // 즉, 사용자 검증을 해준다.
    Member findMember = memberService.findMember(email);        // 회원찾기 // 회원 있는지 확인 & 회원 닉네임 & 회원 활동중 => JPQL로
    memberService.verificationMember(findMember);               // 통과시 회원 검증 완료
    Accompany createdAccompaney = Accompany.createAccompany(findMember.getNickname(), accompanyPostDto);
    accompanyMemberService.createAccompanyMemberRefactoring(createdAccompaney, findMember);// 동행_멤버 생성
    accompanyRepository.save(createdAccompaney);
}

위의 코드 중 다음 코드입니다.

accompanyMemberService.createAccompanyMemberRefactoring(createdAccompaney, findMember);// 동행_멤버 생성

그럼 기존에는 어떤 코드였고, 어떻게 개선되었는지 보도록 하겠습니다.

기존 코드는 다음과 같습니다.
(클래스 레벨 @Transaction은 AccompanyService와 같이 적용했다고 보시면 됩니다.)

public void createAccompanyMember(Accompany accompany, Member member) {
    AccompanyMember createdAccomapnyMember = AccompanyMember.builder()
            .accompany(accompany)
            .member(member)
            .build();
    AccompanyMember savedAccomapnyMember = accompanyMemberRepository.save(createdAccomapnyMember);
    accompany.getAccompanyMemberList().add(savedAccomapnyMember);   // Accompany의 Member에 생성

}

역시 Service 계층에서의 도메인 모델 패턴을 적용하기 전의 트랜잭션 스크립트 패턴이 적용된 것과 유사한 코드입니다.

도메인 모델 패턴을 적용하면 다음과 같습니다.

@Transactional
public void createAccompanyMemberRefactoring(Accompany accompany, Member member) {
    AccompanyMember.createAccompanyMember(accompany, member);
}

이처럼 단 한 줄로 끝나게 됩니다.
그리고 Entity 클래스에서는 어떤 코드가 작성되었는지 보도록 하겠습니다.

기존의 AccompanyMember의 Entity 클래스입니다.

@Entity
@Getter
@NoArgsConstructor
public class AccompanyMember extends Auditable {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "ACCOMPANY_MEMBER_ID")
    private Long accompanyMemberId;

    // Accompany가 삭제되면, AccompanyMember도 없어져야 함.
    @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "ACCOMPANY_ID")
    private Accompany accompany;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    @Builder
    public AccompanyMember(Accompany accompany, Member member) {
        this.accompany = accompany;
        this.member = member;
    }
}

여기서 변경 및 수정이 일어난 부분은 다음과 같습니다.

  • @NoArgsConstructor(access = AccessLevel.PROTECTED)
    • 기본 생성자의 접근레벨을 PROTECTED로 주었습니다.

다음과 같은 연관관계 메서드를 작성했습니다.

public static void createAccompanyMember(Accompany accompany, Member member) {
    AccompanyMember accompanyMember = new AccompanyMember();
    accompanyMember.accompany = accompany;
    accompanyMember.member = member;
    accompany.getAccompanyMemberList().add(accompanyMember); // AccompanyMember 생성 + Accompany와의 연관관계 메서드
}

이처럼 도메인 패턴 모델을 적용하여 Entity에서 AccompanyMember 인스턴스를 생성하고, 일(1)에 해당하는 Accompany의 List에 다(N)에 해당하는 생성된 AccompanyMember 인스턴스를 추가했습니다.

다시 기존의 코드로 돌아가보면

public void createAccompanyMember(Accompany accompany, Member member) {
    AccompanyMember createdAccomapnyMember = AccompanyMember.builder()
            .accompany(accompany)
            .member(member)
            .build();
    AccompanyMember savedAccomapnyMember = accompanyMemberRepository.save(createdAccomapnyMember);
    accompany.getAccompanyMemberList().add(savedAccomapnyMember);   // Accompany의 Member에 생성

}

이처럼

AccompanyMember savedAccomapnyMember = accompanyMemberRepository.save(createdAccomapnyMember);
    accompany.getAccompanyMemberList().add(savedAccomapnyMember);   // Accompany의 Member에 생성

accompanyMember를 save하고, accompany의 list에 save한 accompanyMember를 넣어줬습니다.

지금 생각해보면, JPA를 학습하고 적용한답시고 몰라도 한참 몰랐고, 그냥 냅다 싸지른 코드에 불과한 것이었고, JPA에서 제공하는 기능을 제대로 활용하지 못 했다는 느낌이 한참 드네요 ㅎㅎ

계속 얘기하자면, 연관관계 메서드를 통해서 accompany의 list에 save한 accompanyMember를 넣어주고,
accompany의 service로 돌아와서 그대로 accompany만 Repository계층을 통해서 save 했습니다.

accompanyMember는 save하지 않았지만, accompany의 list에 넣어줌으로써 자식 Entity의 생명주기를 부모의 Entity로 맡겼습니다.

이처럼 JPA에서 제공하는 Cascade를 보다 적극적으로 활용하여, 좀 더 객체지향적으로, 보다 개선된 코드로 리팩터링했습니다.

3. 요약

제목과 같이 2023년 6월에 진행했던
WanderHub의 동행 생성에 대하여 다음 세 가지를 적용한 과정을 기록했습니다.

  • 도메인 패턴 모델 적용
  • 서비스 계층에서의 Transactional 수정
  • Cascade 보다 적극적으로 제대로 활용

다음은 수정에 대하여 리팩터링하는 과정을 포스팅하도록 하겠습니다.
더욱 더 클린한 코드가 되도록 점진적으로 진행해보도록 하겠습니다.

728x90
Comments