사실 디자인패턴에 대해 어떤것들이 있다, 어떻게 사용한다, 어떤 장단점이 있다, 이론적으로 공부를 많이 하진않았지만 정보처리기사 공부할때나 간간히 면접준비하면서 공부를 조금씩 했지만 잘 와닿지 않아 오히려 코드 복잡도가 증가하는 것 같아 적용하진 않았었는데 직접 기획을 진행하고 변경되는 요구사항에 맞춰 작업을 하다보니 Human Error 를 야기하게 되었고 확장성, 유연하게 대응할 수 있는 코드의 필요성을 느끼게되었습니다.
다른 data 를 갖는 entity 들이 중복된 비즈니스 로직을 Hexagonal Architecture 로 코드 디자인을 어떻게 했는지 공유해보고자 합니다.
예시로, 다른 관심사를 가지는 Member 와 Trainer 라는 Entity 가 있습니다.
member 와 trainer 도메인의 sign-up, sign-in flow 는 다음과 같습니다.
사실 이부분을 구현하면서 분명 같은 사용자이고 회원가입, 로그인 로직이 같은데 이렇게 나눠야 하나? 라는 생각이 들었습니다. 우선 제가 생각하는 문제점은
저는 위 문제를 전략패턴 + 팩토리 메서드 패턴으로 극복 하고자 했습니다.
AS-IS 코드는 생략하고 TO-BE 코드만 확인해 보겠습니다. 리팩토링 코드 전체는 github 링크에서 확인 하실 수 있습니다.
@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
}
}
@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 시 형변환이 자유롭게 이루어질 수 있습니다.
interface UserEntity {
val role: Role
fun toUserEntity(): UserEntity
}
왜 이게 필요할까요?? 사용자 검색 기능, 회원가입 등 공통의 관심사를 만들어 Query 를 추상화 하게만들기 위해 추가하였습니다.
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 만 구현하면 됩니다.
@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 단계에서는 오히려 개발속도를 늦추는 오버엔지니어링 일수도 있겠다는 생각이 듭니다.