클린 아키텍처 (3) - 도메인과 엔티티가 합쳐진[최소 적용]

itbuddy·2024년 9월 30일

클린아키텍처 자체가 추상적인 개념으로 설명하는 것이다 보니
클린아키텍처의 규칙을 지키면서 구현하는 방법은 많습니다.
회사의 인원, 서비스 성숙도에 따라 적절히 적용하시면 됩니다.

특히 인원이 적어서 개발이 바쁜 와중에 클린아키텍처를 따라서
너무 타이트하게 나누자고 하면 러닝 커브와 많아진 파일로
팀원들의 반발심만 부추길 것입니다.
차근 차근 도입하면서 설득하는 것이 중요합니다.

이번 시간에는 그중 클린아키텍처를 처음 접하는 사람도 쉽게 도입이 가능한
엔티티와 도메인을 합쳐서 처리하는 방법으로 만들어진 프로젝트를 설명드리겠습니다.
(제 프로젝트가 정답은 아니니 참고만 하시고, 틀린게 있다면 지적해주세요)
GitHub dev

패키지 구조 및 설명

상위 패키지 구조

  • common: 각 도메인에서 공통으로 사용하는 클래스 모음
  • config: 설정 관련 클래스 모음
  • menu: 메뉴 도메인
  • order: 주문 도메인
  • user: 사용자 도메인

각 도메인 패키지 하위 구조

  • application: @@Service와 @Service의 리턴 클래스가 위치
  • controller: @Controller와 @Controller의 request, response 객체가 위치해 있습니다.
  • domain: @Repository, @Entity가 위치하며 @Entity 안에 비즈니스 로직이 구현되어 있습니다.
  • event: 이벤트 처리에 필요한 이벤트 객체가 위치

패키지별 의존성 흐름

  • controller는 application을 import, application은 domain을 import 합니다.

User 도메인 설명

다른 도메인의 설명은 클린아키텍처 설명시 중복되니 Order 도메인만 설명하겠습니다.
나머지 코드는 상단 Github링크에서 확인 부탁드립니다.

controller 패키지

~Request

  • Controller의 입력을 표현하는 객체입니다.
  • Validation 과련 어노테이션이 존재합니다.
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(force = true)
@Schema(description = "포인트 충전 요청 객체")
public class PutUserChargePointRequest {

    @Schema(description = "사용자 식별자(아이디)")
    @Positive(message = "사용자 아이디는 양수입니다.")
    @NotNull(message = "사용자 아이디는 필수값입니다.")
    private final Long id;

    @Schema(description = "충전할 포인트")
    @Positive(message = "충전 포인트는 양수입니다.")
    @NotNull(message = "충전 포인트는 필수값입니다.")
    private final Integer point;

}

~Response

  • Controller의 출력을 표현하는 객체입니다.
  • application의 결과값을 ~Response로 변환하는 로직을 가지고 있습니다.
    • 서비스가 심화되면 Mapper class로 분리 가능합니다.
@Getter
@NoArgsConstructor(force = true)
@Schema(description = "포인트 충전 응답 객체")
public class PutUserChargePointResponse {
    @Schema(description = "사용자")
    private final UserDto user;

    public PutUserChargePointResponse(UserDto user){
        this.user = user;
    }

}

~Controller

  • @Valid 어노테이션을 활용하여 입력값을 검증하는 로직을 수행시킵니다.
  • ~Request를 받아 필요한 ~Service를 호출합니다.
  • ~Service의 출력을 받아 ~Response에 존재하는 변환 로직을 호출하여 외부로 출력 값을 내보냅니다.
@RequiredArgsConstructor
@RestController
public class UserController implements UserControllerDoc {

    private final UserService userService;

    public static final String API_PUT_USER_POINT = "/api/user/point";
    @Override
    @PutMapping(API_PUT_USER_POINT)
    public ApiResponse<PutUserChargePointResponse> userPoint(
        @Valid @RequestBody PutUserChargePointRequest putUserChargePointRequest) throws Exception {
        return ApiResponse.ok(
            new PutUserChargePointResponse(
                userService.chargePoint(putUserChargePointRequest.getId(),
                    putUserChargePointRequest.getPoint())
            )
        );
    }
}

application 패키지

~Dto

  • @Service의 메서드별 출력 값입니다.
@Getter
@Schema(description = "사용자")
public class UserDto {

    @Schema(description = "사용자 식별자(아이디)")
    private final Long id;
    @Schema(description = "사용자 이름")
    private String name;
    @Schema(description = "현재 포인트")
    private final Integer point;

    @Builder
    private UserDto(Long id, String name, Integer point) {
        this.id = id;
        this.name = name;
        this.point = point;
    }
}

~Service

  • @Entity class에 존재하는 도메인 로직을 조합하여 비즈니스 로직을 수행합니다.
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final UserPointTransactionRepository userPointTransactionRepository;

    @Transactional
    @DistributedLock("#userId")
    public UserDto chargePoint(Long userId, Integer point) throws Exception{
        final UserEntity user = userRepository.findById(userId).orElseThrow(
            () -> new IllegalArgumentException("존재하지 않는 사용자입니다."));
        user.chargePoint(point);
        userRepository.save(user);
        userPointTransactionRepository.save(UserPointTransactionEntity.createByCharge(user,point));
        return user.toDto();
    }
}

domain

~Entity

  • Entity에 대한 정의가 포함됩니다.
  • 도메인 로직이 포함됩니다. ( chargePoint, userPoint )
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@ToString
@Table(name = "TB_USER")
public class UserEntity extends BaseEntity implements Comparable<UserEntity> {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 30)
    private String name;

    @Column
    @ColumnDefault("0")
    private Integer point = 0;

    @ToString.Exclude
    @OneToMany(mappedBy = "user")
    private SortedSet<UserPointTransactionEntity> userPointTransactions = new TreeSet<>();

    @Builder
    private UserEntity(Long id, String name, Integer point) {
        this.id = id;
        this.name = name;
        this.point = point == null ? 0 : point;
    }


    @Override
    public int compareTo(UserEntity o) {
        return 1;
    }

    public void chargePoint(Integer point) {
        this.point += point;
    }

    public void usePoint(Integer point) {
        if(this.point - point < 0) throw new IllegalArgumentException("포인트가 부족합니다");
        this.point -= point;
    }



    public UserDto toDto() {
        return UserDto.builder()
            .id(this.getId())
            .name(this.getName())
            .point(this.getPoint())
                         .build();
    }
}

소소한 팁

  • 각 계층별 변환 로직은 수정이 잦을 확률이 크기 때문에 ~Controller나 Service에 위치하지 않도록 하는 것이 좋습니다. ex) new ~Repsonse(), ~Dto.builder
  • Service에서는 ~Entity class의 각각 필드에 값을 직접 set하지 않는 것이 좋습니다.

마치며

해당 코드가 마음에 들지 않으실수 있습니다.
다음 시간에는 좀 더 타이트한 구현 프로젝트를 가지고 설명하겠습니다.

profile
프론트도 조금 아는 짱구 같은 서버 프로그래머

0개의 댓글