Hexagon 하게 코드 디자인 해보기

박우영·2024년 2월 24일
0

디자인 패턴

목록 보기
5/5

Overview

사실 디자인패턴에 대해 어떤것들이 있다, 어떻게 사용한다, 어떤 장단점이 있다, 이론적으로 공부를 많이 하진않았지만 정보처리기사 공부할때나 간간히 면접준비하면서 공부를 조금씩 했지만 잘 와닿지 않아 오히려 코드 복잡도가 증가하는 것 같아 적용하진 않았었는데 직접 기획을 진행하고 변경되는 요구사항에 맞춰 작업을 하다보니 Human Error 를 야기하게 되었고 확장성, 유연하게 대응할 수 있는 코드의 필요성을 느끼게되었습니다.

다른 data 를 갖는 entity 들이 중복된 비즈니스 로직을 Hexagonal Architecture 로 코드 디자인을 어떻게 했는지 공유해보고자 합니다.

예시로, 다른 관심사를 가지는 Member 와 Trainer 라는 Entity 가 있습니다.
member 와 trainer 도메인의 sign-up, sign-in flow 는 다음과 같습니다.

  • sign-up
    • email
      nickname 중복검증 -> email 검증 -> email 인증번호 검증 -> 회원가입
    • 소셜로그인
      OAuth -> 인증 -> 추가 정보 입력 -> 회원가입
  • sign-in
    • email
      email, password 확인 후 jwt return
    • 소셜로그인
      OAuth -> 인증 -> jwt return

사실 이부분을 구현하면서 분명 같은 사용자이고 회원가입, 로그인 로직이 같은데 이렇게 나눠야 하나? 라는 생각이 들었습니다. 우선 제가 생각하는 문제점은

중복코드

  1. 휴먼 에러를 야기할수 있다.
  2. 정책이 변경되면 수정사항이 많아진다.

관심사 분리

  1. 다른 도메인의 같은 관심사가 지속적으로 추가 되고 수정됨
  2. 현재 회원가입 에만 사용되는 email 을 위해 email 도메인 추가
  3. member 가 회원가입시 검증을 위해 비즈니스 로직 간 member->trainer,
    trainer->member 로 의존성을 추가 해야함

저는 위 문제를 전략패턴 + 팩토리 메서드 패턴으로 극복 하고자 했습니다.

Refactoring

AS-IS 코드는 생략하고 TO-BE 코드만 확인해 보겠습니다. 리팩토링 코드 전체는 github 링크에서 확인 하실 수 있습니다.

Member Entity

@Entity
@Table(name = MEMBER_TABLE_NAME)
@SQLDelete(sql = "UPDATE $MEMBER_TABLE_NAME set deleted_at = now() WHERE id = ?")
@DynamicUpdate
class MemberEntity(
    @Column(name = "email")
    var email: String,
    @Column(name = "nickname")
    var nickname: String,
    @Column(name = "password")
    var password: String,
    @Column(name = "provider") // 소셜로그인 시
    var provider: String?,
    @Embedded
    @AttributeOverrides(
        AttributeOverride(name = "tall", column = Column(name = "tall")),
        AttributeOverride(name = "weight", column = Column(name = "weight")),
        AttributeOverride(name = "skeletalMuscleMass", column = Column(name = "skeletal_muscle_mass")),
        AttributeOverride(name = "age", column = Column(name = "age")),
        AttributeOverride(name = "exerciseMonths", column = Column(name = "exercise_months")),
    )
    var memberInfo: MemberInfo,
    @Column(name = "role")
    @Enumerated(EnumType.STRING)
    override var role: Role,
    @Column(name = "gender")
    @Enumerated(EnumType.STRING)
    var gender: Gender,
) : BaseEntity(), UserEntity {
    fun toDomain(): Member {
        return Member(
            id = this.id,
            email = this.email,
            password = this.password,
            provider = this.provider,
            exerciseMonths = this.memberInfo.exerciseMonths,
            tall = this.memberInfo.tall,
            weight = this.memberInfo.weight,
            skeletalMuscleMass = this.memberInfo.skeletalMuscleMass,
            age = this.memberInfo.age,
            gender = this.gender,
            createdAt = this.createdAt,
            deletedAt = this.deletedAt,
            role = this.role,
            nickname = this.nickname,
        )
    }

    override fun toUserEntity(): UserEntity {
        return this
    }
}

Trainer Entity

@Entity
@Table(name = TRAINER_TABLE_NAME)
@DynamicUpdate
@SQLDelete(sql = "UPDATE $TRAINER_TABLE_NAME set deleted_at = now() WHERE id = ?")
class TrainerEntity(
    @Column(name = "nickname")
    var nickname: String,
    @Column(name = "email")
    var email: String,
    @Column(name = "password")
    var password: String,
    @Column(name = "gym_name")
    var gymName: String,
    @Embedded
    @AttributeOverrides(
        AttributeOverride(name = "street", column = Column(name = "street")),
        AttributeOverride(name = "city", column = Column(name = "city")),
        AttributeOverride(name = "country", column = Column(name = "country")),
    )
    var gymAddress: GymAddress,
    @Column(name = "exercise_years")
    var exerciseYears: Int,
    @Column(name = "gender")
    @Enumerated(EnumType.STRING)
    var gender: Gender,
    @Column(name = "introduce")
    var introduce: String,
    @Column(name = "role")
    override var role: Role,
) : BaseEntity(), UserEntity {
    fun changeGym(gymName: String, gymAddress: GymAddress) {
        this.gymName = gymName
        this.gymAddress = gymAddress
    }

    fun changeIntroduce(introduce: String) {
        this.introduce = introduce
    }

    fun toDomain(): Trainer {
        return Trainer(
            id = id,
            nickname = nickname,
            password = password,
            email = email,
            gender = gender,
            gymName = gymName,
            street = gymAddress.street,
            country = gymAddress.country,
            city = gymAddress.city,
            exerciseYears = exerciseYears,
            introduce = introduce,
            role = role,
        )
    }

    override fun toUserEntity(): UserEntity {
        return this
    }
}

@Embeddable
data class GymAddress(
    val street: String,
    val city: String,
    val country: String,
)

위 두 Entity만 봐도 id, nickname, password, gender, email 를 제외하면 전형 다른 데이터를 가지고 있습니다. 하지만 end User 라는 사실은 변하지 않기때문에 User interface 로 Runtime 시 형변환이 자유롭게 이루어질 수 있습니다.

User Entity

interface UserEntity {
    val role: Role
    fun toUserEntity(): UserEntity
}

왜 이게 필요할까요?? 사용자 검색 기능, 회원가입 등 공통의 관심사를 만들어 Query 를 추상화 하게만들기 위해 추가하였습니다.

AbstractSignUpService

abstract class AbstractSignUpService(
    private val userQueryPort: UserQueryPort,
    private val emailVerifyPort: EmailVerifyPort,
) : SignUpUserUseCase {
    abstract override fun saveUser(command: SignUpUserWithEmailCommand)

    @Transactional
    override fun signUpWithEmail(command: SignUpUserWithEmailCommand) {
        verifyNickname(command.nickname)
        verifyEmail(command.toAuthenticationCommand())
        saveUser(command)
    }

    private fun verifyEmail(command: VerifyAuthenticationSuccessCommand) {
        val successCommand = VerifyAuthenticationSuccessCommand(
            email = command.email,
            authenticationString = command.authenticationString,
        )
        emailVerifyPort.verifyAuthenticationSuccess(successCommand)
    }

    private fun verifyNickname(nickname: String) {
        userQueryPort.findByNickname(nickname)?.let {
            throw ServiceException(ErrorCode.DUPLICATE_NICKNAME)
        }
    }
}

이렇게 작성한다면 MemberService, TrainerService 등에서 중복되는 SignUp 알고리즘을 구현할 필요가 없습니다. abstract 되어있는 save 만 구현하면 됩니다.

SignUpMemeberService

@Service
class SignUpMemberService(
    private val userQueryPort: UserQueryPort,
    private val emailVerifyPort: EmailVerifyPort,
    private val memberJpaPort: MemberJpaPort,
) : SignUpMemberUseCase, AbstractSignUpService(userQueryPort, emailVerifyPort) {
    override fun saveUser(command: SignUpUserWithEmailCommand) {
        val memberCommand = command as? SignUpMemberWithEmailCommand
            ?: throw ServiceException(ErrorCode.SIGN_UP_COMMAND_TYPE_CASTING_ERROR)
        memberJpaPort.signUpMember(memberCommand)
    }
}

회원가입에서 중복닉네임이 사용가능 해진다고 한다면? AbstractSignUpService 에서 한줄만 제거하면 됩니다.

회고

scalibility 를 고려한 Hexagonal 한 아키텍처도 좋지만 간단하게 작성할 수 있고 각각의 Command, ResponseDto 들을 정의하다보면 코드의 양도 매우 많아지고 변경이 있을때 오히려 코드 작업이 많아지는 경우가 잦았습니다. (InPort, OutPort 재정의 필요)

특정 Framework 에 종속되지 않고 각 domain, core 와 같은 영역들에 있어 의존성을 관리의 필요가 있는 대규모 프로젝트에선 매우 유용할 수 있지만 정의 되어있는 Interface 들을 자주 사용하는일은 흔치 않았고 프로젝트 시작하는 POC, MVP 단계에서는 오히려 개발속도를 늦추는 오버엔지니어링 일수도 있겠다는 생각이 듭니다.

0개의 댓글