본 글은 제가 서울시립대에서 동문 및 졸업생들을 위한 커뮤니티 서비스인 시대생에서 개발을 진행하며 있던 일입니다. 당시 시대팅(시대생을 위한 블라인드 소개팅)을 개발하였습니다.
해당 프로젝트의 코드는 여기서 확인할 수 있습니다.
https://github.com/uoslife/server-meeting
본 글은 다음과 같은 순서로 진행됩니다.
시대팅에는 두 가지 상황이 존재했습니다.
1대1로 하는 미팅과, 3대3으로 하는 단체 미팅이 존재했고, 두 종류의 미팅의 플로우는 세부적으로 다르지만 적절히 추상화를 진행할 수 있으리라 진행했습니다.
세부적인 플로우는 다음과 같습니다.
1대1미팅
1대1 미팅 신청 -> 본인 정보 입력 -> 본인의 연애 스타일 입력 -> 선호하는 상대의 연애 스타일 입력 -> 신청 정보 확인하기
3대3미팅
3대3 미팅 신청 -> 팀 참가(팀원) or 팀 생성(리더) -> 본인 정보 입력 -> 본인 팀의 연애 스타일 입력(리더) -> 선호하는 상대 팀의 연애 스타일 입력(리더) -> 신청 정보 확인하기 (팀원, 리더)
1대1과 달리 3대3은 팀원간의 역할이 리더와 팀원으로 나뉘고 이에 따라 리더는 팀 코드 및 팀을 생성하는 API가 추가적으로 필요하며 팀원들을 확인하는 등의 추가적인 API가 필요했습니다.
API 서버는 Kotlin + Spring Boot로 구성하였고 인프라는 EKS k8s에 Istio L7를 통해 Route하고, Argo CD, Kiali, Prometheus 등을 활용했습니다.
사실 이제 API를 Route하는 데 있어서 추가적인 사소한 이슈가 있었으나 이는 다음에 다루도록 하고, 서버를 위해 Spring Boot 3.0.5를 사용하였고 Java 17 환경을 사용하였습니다.
처음으로 고민했던 것은 폴더 구조를 어떻게 잡을 것인지에 대한 것입니다.
프로젝트는 크게 3가지로 파트가 나누어집니다.
- 사용자 본인에 관한 기본적인 입력 등등 User에 관한 것
- Meeting과 관련한 전반적인 것들(팀에 참가 및 생성 및 Meeting에 관한 질문들을 저장 및 확인)
- 1, 2를 활용하여 매칭을 진행하는 것
기본적으로 국밥처럼 사용하는 3Layered Architecture(API, Service, DAO)를 사용하고자 했으나, Domain이 명확하게 나눠지기에 이러한 장점을 살려서 관련 있는 폴더들을 최대한 모을 수 있는 아키텍처를 선정하던 중 DDD와 Hexagonal Architecture가 눈에 들어왔습니다.
Port를 통해 외부 연결 지점 등을 정의하는 등의 이전에도 사용해본 적이 있었습니다. 하지만 대형 어플리케이션이 아니기에 완벽하게 Hexagonal을 적용하기에는 배보다 배꼽이 큰 듯하여 조금 약식의 외국의 Github Guide를 조금 참고하였습니다.
https://github.com/eugenp/tutorials/tree/master/ddd/src/main/java/com/baeldung/dddhexagonalspring
해당 부분에서 Application과 Domain은 살리고 Infrastructure는 제외하였습니다. 해당 Code에서는 Cassandra, Mongo DB 등의 외부 DB를 사용하는 듯 했지만 저희 같은 경우 로그인 처리를 MSA 중 Account API에서 전담하고 있기에 저희 API에서 기본적으로 사용하는 Postgre를 제외하고 추가적인 DB를 사용하지 않아 제외하였습니다.
그리하여 대강 다음과 같이 폴더 구조를 잡았습니다. 이제 미팅으로 들어가 봅시다.
미팅 도메인을 제가 구현했고 굉장히 기뻤습니다. 객체지향의 DIP(의존성 역전 원칙)을 떠올리면,
상위 모듈은 하위 모듈에 의존해서는 안된다
추상화는 세부 사항에 의존해서는 안된다
이를 실현하는 가장 쉬운 방법은 변하지 않는 공통 부분을 묶어서 Inteface로 추상화 시키면 이를 확장하는 등의 변화가 쉬워지게 됩니다.
예를 들면 OAuth에서 다양한 Provider에 맞게 제공하는 것 역시 Interface로 추상화할 수 있겠습니다. 하지만 이전까지 이러한 Interface를 사용한 추상화를 사용할 일이 별로 없었습니다. 위 사례를 제외하고 위처럼 하나에 대해서 다양한 기전이 있는 일이 거의 없었고 그렇다고 비즈니스 적으로 확장할 일이 없는 곳에 Inteface와 구현체를 1대1로 구현하는 일은 코드를 늘릴 뿐이라고 생각하였습니다.
하지만 Meeting 같은 경우 1대1 3대3이 확실한 공통부분을 가지고 있기에 Interface를 활용하기 좋았습니다.
말보다 코드로 보는 게 빠를 것 같습니다.
실제 구현한 코드입니다.
BaseMeetingService
interface BaseMeetingService {
fun createMeetingTeam(userUUID: UUID, name: String? = null): String?
fun joinMeetingTeam(userUUID: UUID, code: String, isJoin: Boolean): MeetingTeamUserListGetResponse?
fun getMeetingTeamUserList(userUUID: UUID, code: String): MeetingTeamUserListGetResponse
fun updateMeetingTeamInformation(
userUUID: UUID,
informationDistance: String,
informationFilter: String,
informationMeetingTime: String,
preferenceDistance: String,
preferenceFilter: String,
)
fun getMeetingTeamInformation(userUUID: UUID): MeetingTeamInformationGetResponse
fun deleteMeetingTeam(userUUID: UUID)
}
SingleMeetingService
@Service
@Qualifier("singleMeetingService")
class SingleMeetingService(
private val userRepository: UserRepository,
private val meetingTeamRepository: MeetingTeamRepository,
private val informationRepository: InformationRepository,
private val preferenceRepository: PreferenceRepository,
private val userTeamDao: UserTeamDao,
private val preferenceUpdateDao: PreferenceUpdateDao,
private val informationUpdateDao: InformationUpdateDao,
@Value("\${app.season}")
private val season: Int,
) : BaseMeetingService {
override fun createMeetingTeam(userUUID: UUID, name: String?): String? {
val user = userRepository.findByIdOrNull(userUUID) ?: throw UserNotFoundException()
if (userTeamDao.findByUser(user) != null) {
throw UserAlreadyHaveTeamException()
}
val meetingTeam = meetingTeamRepository.save(
MeetingTeam(
season = season,
code = "",
),
)
userTeamDao.saveUserTeam(meetingTeam, user, true, TeamType.SINGLE)
return ""
}
override fun joinMeetingTeam(userUUID: UUID, code: String, isJoin: Boolean): MeetingTeamUserListGetResponse? {
throw InSingleMeetingTeamNoJoinTeamException()
}
override fun getMeetingTeamUserList(userUUID: UUID, code: String): MeetingTeamUserListGetResponse {
throw InSingleMeetingTeamOnlyOneUserException()
}
override fun updateMeetingTeamInformation(
userUUID: UUID,
informationDistance: String,
informationFilter: String,
informationMeetingTime: String,
preferenceDistance: String,
preferenceFilter: String,
) {
val user = userRepository.findByIdOrNull(userUUID) ?: throw UserNotFoundException()
val userTeam = userTeamDao.findByUserWithMeetingTeam(user, TeamType.SINGLE) ?: throw UserTeamNotFoundException()
val meetingTeam = userTeam.team
// information and preference 는 하나만 존재해야 함 중복 체크
val information = informationRepository.findByMeetingTeam(meetingTeam)
val preference = preferenceRepository.findByMeetingTeam(meetingTeam)
informationUpSert(information, meetingTeam, informationDistance, informationFilter, informationMeetingTime)
preferenceUpSert(preference, meetingTeam, preferenceDistance, preferenceFilter)
}
override fun getMeetingTeamInformation(userUUID: UUID): MeetingTeamInformationGetResponse {
val user = userRepository.findByIdOrNull(userUUID) ?: throw UserNotFoundException()
val userTeam = userTeamDao.findByUserWithMeetingTeam(user, TeamType.SINGLE) ?: throw UserTeamNotFoundException()
val meetingTeam =
meetingTeamRepository.findByIdOrNull(userTeam.team.id!!) ?: throw MeetingTeamNotFoundException()
val information = informationRepository.findByMeetingTeam(meetingTeam) ?: throw InformationNotFoundException()
val preference = preferenceRepository.findByMeetingTeam(meetingTeam) ?: throw PreferenceNotFoundException()
return toMeetingTeamInformationGetResponse(user, information, preference)
}
override fun deleteMeetingTeam(userUUID: UUID) {
val user = userRepository.findByIdOrNull(userUUID) ?: throw UserNotFoundException()
val userTeam = userTeamDao.findByUserWithMeetingTeam(user, TeamType.SINGLE) ?: throw UserTeamNotFoundException()
val meetingTeam =
meetingTeamRepository.findByIdOrNull(userTeam.team.id!!) ?: throw MeetingTeamNotFoundException()
meetingTeamRepository.deleteById(meetingTeam.id!!)
}
}
TripleMeetingService
@Service
@Qualifier("tripleMeetingService")
class TripleMeetingService(
private val userRepository: UserRepository,
private val meetingTeamRepository: MeetingTeamRepository,
private val informationRepository: InformationRepository,
private val preferenceRepository: PreferenceRepository,
private val userTeamDao: UserTeamDao,
private val preferenceUpdateDao: PreferenceUpdateDao,
private val informationUpdateDao: InformationUpdateDao,
@Value("\${app.season}")
private val season: Int,
) : BaseMeetingService {
override fun createMeetingTeam(userUUID: UUID, name: String?): String? {
val user = userRepository.findByIdOrNull(userUUID) ?: throw UserNotFoundException()
isUserHaveOnlyOneTeam(user)
isTeamNameLeast2Character(name)
val code = getUniqueTeamCode()
val meetingTeam = meetingTeamRepository.save(
MeetingTeam(
season = season,
name = name,
code = code,
),
)
userTeamDao.saveUserTeam(meetingTeam, user, true, TeamType.TRIPLE)
return code
}
override fun joinMeetingTeam(userUUID: UUID, code: String, isJoin: Boolean): MeetingTeamUserListGetResponse? {
val user = userRepository.findByIdOrNull(userUUID) ?: throw UserNotFoundException()
isTeamCodeValid(code)
isUserHaveOnlyOneTeam(user)
val meetingTeam = meetingTeamRepository.findByCode(code) ?: throw MeetingTeamNotFoundException()
val leaderUserTeam = userTeamDao.findByTeamAndisLeader(meetingTeam, true)
?: throw TeamLeaderNotFoundException()
isTeamFull(meetingTeam)
isUserSameGenderWithTeamLeader(user, leaderUserTeam.user!!)
return if (isJoin) {
userTeamDao.saveUserTeam(meetingTeam, user, false, TeamType.TRIPLE)
null
} else {
val userList = userTeamDao.findByTeam(meetingTeam).map { it.user!! }
toMeetingTeamUserListGetResponse(meetingTeam.name!!, userList)
}
}
override fun getMeetingTeamUserList(userUUID: UUID, code: String): MeetingTeamUserListGetResponse {
val user = userRepository.findByIdOrNull(userUUID) ?: throw UserNotFoundException()
isTeamCodeValid(code)
val meetingTeam = meetingTeamRepository.findByCode(code) ?: throw MeetingTeamNotFoundException()
isUserInTeam(user, meetingTeam)
val userList = userTeamDao.findByTeam(meetingTeam).map { it.user!! }
return toMeetingTeamUserListGetResponse(meetingTeam.name!!, userList)
}
override fun updateMeetingTeamInformation(
userUUID: UUID,
informationDistance: String,
informationFilter: String,
informationMeetingTime: String,
preferenceDistance: String,
preferenceFilter: String,
) {
val user = userRepository.findByIdOrNull(userUUID) ?: throw UserNotFoundException()
val userTeam = userTeamDao.findByUserWithMeetingTeam(user, TeamType.TRIPLE) ?: throw UserTeamNotFoundException()
val meetingTeam =
meetingTeamRepository.findByIdOrNull(userTeam.team.id!!) ?: throw MeetingTeamNotFoundException()
// information and preference 는 하나만 존재해야 함 중복 체크
val information = informationRepository.findByMeetingTeam(meetingTeam)
val preference = preferenceRepository.findByMeetingTeam(meetingTeam)
informationUpSert(information, meetingTeam, informationDistance, informationFilter, informationMeetingTime)
preferenceUpSert(preference, meetingTeam, preferenceDistance, preferenceFilter)
}
override fun getMeetingTeamInformation(userUUID: UUID): MeetingTeamInformationGetResponse {
val user = userRepository.findByIdOrNull(userUUID) ?: throw UserNotFoundException()
val userTeam = userTeamDao.findByUserWithMeetingTeam(user, TeamType.TRIPLE) ?: throw UserTeamNotFoundException()
val meetingTeam = userTeam.team
val userList = userTeamDao.findByTeam(meetingTeam).map { it.user!! }
val information = informationRepository.findByMeetingTeam(meetingTeam) ?: throw InformationNotFoundException()
val preference = preferenceRepository.findByMeetingTeam(meetingTeam) ?: throw PreferenceNotFoundException()
return toMeetingTeamInformationGetResponse(user.gender, userList, information, preference)
}
override fun deleteMeetingTeam(userUUID: UUID) {
val user = userRepository.findByIdOrNull(userUUID) ?: throw UserNotFoundException()
val userTeam = userTeamDao.findByUserWithMeetingTeam(user, TeamType.TRIPLE) ?: throw UserTeamNotFoundException()
val meetingTeam = userTeam.team
meetingTeamRepository.deleteById(meetingTeam.id!!)
}
어떻게 추상화를 진행했는지
간단하게 전체 메서드는 다음과 같았습니다.
- 미팅 팀 생성
- 미팅 팀 참가
- 미팅 팀 유저 리스트 조회
- 미팅 팀 선호도 정보 저장
- 미팅 팀 전체 정보(유저 및 선호도 포함) 조회
- 미팅 팀 삭제
물론 이 중 1대1에는 필요하지 않은 Method 역시 존재했지만, 목적이 Controller 단이 실제 구현체가 아닌 Interface 추상화에 의존하도록 하는 것이었기에 Interface에 전체 Method를 포함하도록 했습니다.
공통 부분이 아닌 부분은 어떻게 처리했는지
기본적으로 Controller 단에서 TeamType을 받아서 그에 맞게 Single, Triple MeetingService를 연결해주며 해당 TeamType에 맞지 않는 API 호출 시에 Exception을 호출하도록 했으며, override한 Method에 대해서도 Exception을 호출하도록 했습니다.
이를 어떻게 주입했는지 -> Qualifier
class MeetingApi(
@Qualifier("singleMeetingService") private val singleMeetingService: BaseMeetingService,
@Qualifier("tripleMeetingService") private val tripleMeetingService: BaseMeetingService,
) {
...
fun createMeetingTeam(
@AuthenticationPrincipal userDetails: UserDetails,
@PathVariable teamType: TeamType,
@PathVariable isTeamLeader: Boolean,
@RequestParam(required = false) name: String?,
): ResponseEntity<String?> {
val userUUID = UUID.fromString(userDetails.username)
if (!isTeamLeader) {
throw OnlyTeamLeaderCanCreateTeamException()
}
val code = when (teamType) {
TeamType.SINGLE -> singleMeetingService.createMeetingTeam(userUUID, name)
TeamType.TRIPLE -> tripleMeetingService.createMeetingTeam(userUUID, name)
}
return ResponseEntity.status(HttpStatus.CREATED).body(code)
}
위에서 동일하게 Interface인 BaseMeetingService 타입으로 두 개의 서로 다른 빈을 주입받고 있습니다.
같은 타입의 빈이 여러 개 있을 때, 또는 해당 타입에 해당하는 빈이 등록되어 있지 않을 경우 에러가 발생하는 것을 다들 알고 계실 겁니다. 이 경우 일반적으로 2개의 해결 방법이 존재하는데 @Primary와 @Qualifier입니다.
@Qualifier
@Primary
위의 경우 기본 빈이 아닌 두 동일 타입의 빈을 동시에 사용하기에 @Qualifer 한정자를 지정하여 사용하였습니다.
이전까지 생각보다 객체지향에 대한 고민을 많이 하지 않다 보니 이번에 객체지향을 도입하며 많은 고민을 해야 했습니다.
고민 중 하나는 위에서 언급한 Interface에 대한 문제입니다. Interface를 의존하도록 하기 위해 세부적인 Method가 조금 다른 두 구현체의 Method를 전부 공통 Interface에 추가하였는데 정의만으로 보았을 때, 공통적인 부분만 Interface에 의존하고 세부적인 차분은 각 구현체에 의존하도록 하는 것이 맞지 않나라는 생각을 했었습니다.
한편으로는 위의 문제와 SRP를 동시에 만족시키기 위해서 Interface를 세분화시키는 것이 어떨지 생각했습니다.
위에서는 BaseMeetingService 하나의 Interface를 사용했지만 BaseMeetingService를 MeetingCreationService, MeetingInfoService, MeetingTeamUserService와 같이 세부적으로 하나의 책임씩을 맡도록 구분하면 SingleMeetingService의 경우에는 User가 한 명이기에 User 참가 및 User List 조회를 담당하는 MeetingTeamUserService를 구현하지 않으면 SRP를 만족하면서 이전에 사용하지 않는 Interface의 Method를 Override하여 Exceptiond를 반환하는 일을 맡지 않아도 되지 않을까 라는 생각을 하였습니다.
하지만 이 또한 어느 정도 고민이 되었던게 위처럼 했을 때 과연 SRP를 위해 Interface를 어디까지 쪼개야 하는지, 그리고 그렇게 Interface를 많이 쪼갠다면 Interface가 너무 많아지지 않을지 고민이 되었습니다.
구현체에 의존하는 것보다는 Interface를 SRP에 맞게 분리하는 것이 좋아보여 이에 맞게 리팩토링을 계획 중에 있으나 언제나 여러분이 생각하는 더 좋은 의견이 있다면 알려주시면 참고하여 개선하도록 하겠습니다.
긴 글 읽어주셔서 감사합니다.
인우님 작년에 쓰신 좋은 글 감사합니다 ㅋㅋㅋ