[미소 클론코딩] 회원, 비회원 도메인 구현

2023. 8. 21. 00:31초기 과업/BackEnd

작성자알 수 없는 사용자

728x90
반응형

안녕하세요. 기깔나는 사람들에서 백엔드를 맡고있는 선호입니다.

이번 포스팅에서는 청소업체 미소를 클론코딩을 진행하면서 구현한 회원과 협력업체 도메인 객체를 어떻게 설계했는지 살펴보겠습니다.


BaseTimeEntity

@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public abstract class BaseTimeEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime lastModifiedAt;

}
  • 먼저 모든 엔티티에서 사용할 생성, 수정 시간을 기록하는 BaseTimeEntity 입니다.
  • JPA Auditing 기능을 사용하여 엔티티 생성, 수정시 자동으로 값이 생성/변경 되어 일반적으로 위와 같이 많이 쓰입니다.
@EnableJpaAuditing
@SpringBootApplication
public class MisoApplication {

    public static void main(String[] args) {
        SpringApplication.run(MisoApplication.class, args);
    }

}
  • JPA Auditing 을 사용하기 위해서는 위와 같이 @SpringBootApplication 이 있는 애플리케이션 클래스에 @EnableJpaAuditing 어노테이션을 붙여주면 됩니다.

Member

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member extends BaseTimeEntity {

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

    @Embedded
    private AuthInfo authInfo;

    @Embedded
    private PersonalInfo personalInfo;

    @Enumerated(value = EnumType.STRING)
    private BlacklistStatus blacklistStatus;

    @Builder
    private Member(AuthInfo authInfo, PersonalInfo personalInfo, BlacklistStatus blacklistStatus) {
        this.authInfo = authInfo;
        this.personalInfo = personalInfo;
        this.blacklistStatus = blacklistStatus;
    }

    public static Member defaultOf(AuthInfo authInfo, PersonalInfo personalInfo) {
        return Member.builder()
                .authInfo(authInfo)
                .personalInfo(personalInfo)
                .blacklistStatus(BlacklistStatus.DISABLED)
                .build();
    }

    public void changePassword(String password) {
        this.authInfo = authInfo.withNewPassword(password);
    }

}
  • 회원 도메인 객체인 엔티티는 이렇게 작성해봤습니다. 여러 엔티티에서 사용될 수 있는 회원의 기본 정보 등은 값 타입으로 설계했습니다.
  • 생성자가 아닌 팩토리 메서드로 객체를 생성하는 이유는 메서드명으로 의도를 드러낼 수 있고, 원하는 파라미터를 선택적으로 받을 수 있기 때문입니다.
    • 위 코드에서 defaultOf() 로 명명한 것은, 회원 객체가 생성될 때 기본적으로 블랙리스트 상태를 지정할 필요가 있기 때문입니다.
      • 생성자에서 기본적으로 지정하면 되지 않을까? 라고 생각할 수 있지만, 생성자는 일반적으로 인자로 넘어온 값을 필드에 그대로 적용되는 것을 기대하기에, 팩토리 메서드에서 ‘기본적으로 Member 가 생성될 때 블랙리스트가 아니다.’ 라는 비즈니스 로직을 드러내고 싶었습니다.
  • 빌더를 사용한 이유는, 어떤 필드를 어떤 순서로 넣어야하는지 고민하지 않아도 되기 때문입니다. 또한 어떻게 필드를 할당해야하는지 메서드를 통해서 하다보니 실수할 확률도 줄여준다고 생각합니다.
    • 물론 코드양은 좀 늘지만, 코드가 느는 것에 비해 장점이 더 뛰어나다고 생각해 트레이드 오프를 고려해서 선택했습니다.

BlacklistStatus

@RequiredArgsConstructor
public enum BlacklistStatus {

    ENABLED("활성화"),
    DISABLED("비활성화");

    private final String description;

}
  • 회원/비회원의 블랙리스트 상태는 Enum 을 사용하여 표현했습니다.
  • boolean flag 를 사용하지 않은 것은, 이후에 상태가 추가 될 때 flag 로는 전부 표현할 수 없기 때문입니다.
    • 반면에 Enum으로 관리하면 필드만 추가하면 되므로, 추가적으로 드는 리소스가 적습니다. 즉, 유지보수를 고려하다 보니 이렇게 설계하게 되었습니다.

PersonalInfo

@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class PersonalInfo {

    @Column(nullable = false)
    private String name;

    private String phoneNumber;

    private String email;

    @Embedded
    private Signature signature;

    @Builder
    private PersonalInfo(String name, String phoneNumber, String email, Signature signature) {
        this.name = name;
        this.phoneNumber = phoneNumber;
        this.email = email;
        this.signature = signature;
    }

    public static PersonalInfo of(String name, String phoneNumber, String email, Signature signature) {
        return PersonalInfo.builder()
                .name(name)
                .phoneNumber(phoneNumber)
                .email(email)
                .signature(signature)
                .build();
    }

}
  • 값 타입으로 name, phone, email, signature 를 한 번에 관리하도록 했습니다.
    • 연관성이 높은 필드들이고, 비회원 도메인에서도 사용할 정보들이라 재사용성을 위해 값타입으로 설계했습니다.
  • 동일한 personalInfo 레퍼런스를 사용할 때 생길 수 있는 ‘의도치 않은 멤버변수 변경’ 문제를 방지하기 위해 변경사항이 생길 경우 새로운 객체를 만들어 리턴하도록 구현했습니다.
    • ‘의도치 않은 멤버변수 변경’이란?
      • A와 B가 같은 personalInfo 레퍼런스를 사용한다고 가정해봅시다.
      • 이 때 B의 멤버변수를 변경하게 되면 같은 레퍼런스를 갖는 A 역시 멤버변수가 바뀌는 형태가 됩니다. 물론 같은 레퍼런스를 갖으니까 당연한 결과입니다.
      • 이 같은 문제를 미연에 방지하기 위해 값타입을 불변으로 설계하는 과정에서 값이 변경되면 새로운 객체를 만들어 리턴해주는 구현방식이 필요합니다.

Signature

@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Signature {

    @Column(name = "signature_image_url")
    private String imageUrl;

    @Builder
    private Signature(String uploadUrl) {
        this.imageUrl = uploadUrl;
    }

    public static Signature from(String uploadUrl) {
        return Signature.builder()
                .uploadUrl(uploadUrl)
                .build();
    }

    public Signature withNewUploadUrl(String uploadUrl) {
        return from(uploadUrl);
    }

}
  • Signature 역시 여러 엔티티에서 사용가능하고, 엔티티로 관리될 필요가 없으므로 값 타입으로 구현했습니다.
  • @Column(name = "signature_image_url") 처럼 컬럼명을 별도로 지정해준 것은 값타입으로 사용될 때 서명 이미지 라는 것을 명시적으로 드러내기 위함입니다.
    • 그렇게 하지 않으면 어떤 이미지 URL 인지 파악하기 어려워 가독성을 저해합니다.

Guest

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Guest extends BaseTimeEntity {

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

    @Embedded
    private PersonalInfo personalInfo;

    @Enumerated(value = EnumType.STRING)
    private BlacklistStatus blacklistStatus;

    @Builder
    private Guest(PersonalInfo personalInfo, BlacklistStatus blacklistStatus) {
        this.personalInfo = personalInfo;
        this.blacklistStatus = blacklistStatus;
    }

    public Guest defaultOf(PersonalInfo personalInfo) {
        return Guest.builder()
                .personalInfo(personalInfo)
                .blacklistStatus(BlacklistStatus.DISABLED)
                .build();
    }

}
  • 비회원은 회원과 다른 도메인 객체로 구성했습니다.
    • 비즈니스 정책 상 행위가 명확히 구분되며, 비회원은 인증 정보를 갖지 않기 때문에 회원과 같은 엔티티를 사용하게 된다면 불필요한 필드를 가지게 되기 때문입니다.
  • 회원과 마찬가지로 팩토리 메서드를 이용하여 처음 비회원 등록 시 블랙리스트가 아닌 상태로 등록하게 두었습니다.

회고

기초적이지만 중요한 도메인 설계시 고려사항들을 고민해봤습니다. 특히 불변, 도메인의 행위에 대해서 고민해봤는데 더 좋은 설계를 할 수 없었는지 구현하며 계속 고민해봐야겠습니다.


728x90
반응형