이번 글에서는 프로젝트를 진행하면서 재사용성에 대해 생각한 내용을 끄적여보려고 해요.
개발을 처음 시작할 때 받은 조언 중에 중복 코드를 최대한 없애라는 조언이 있었어요. 이는 코드의 재사용성을 높이라고 받아들였어요.
그래서 저는 뷰를 그릴 때도 형태가 비슷하면 하나의 컴포넌트로 만들어서 재사용을 하려고 하는 편이에요. 재사용성이 높으면 다음과 같은 장점이 있어요
아래의 이미지에서 월을 보여주는 두 컴포넌트는 월을 변경할 수 있는 기능의 유무만 달라요.


그래서 canChangeMonth 같이 해당 기능의 유무를 나타내는 변수 한개를 옵션으로 전달하는 방식으로 재사용했어요. 하지만 달을 변경하려면 바꾸고자 하는 달을 선택하기 위한 뷰가 필요했으며 이를 플로팅 방식으로 보여주려면 달을 변경하는 뷰가 보여지고 있는지 상태를 가진 변수가 추가로 필요해졌죠. 결과적으로 calendarHeaderComponent(month: $month, canChangeMonth: true of false, isShowingChangeMonthComponent: $isShowingChangeMonthComponent)처럼 옵션을 2개나 추가로 전달되게 됐는데 여기까지는 상관이 없었어요.
문제는 개발을 계속하면서 해당 뷰가 커지며 발생했어요.
처음 개발할 때 크게 펼친 월간 달력, 접힌 월간 달력, 주간 달력 3개의 케이스로 나뉘게 됐는데 하나의 뷰에서 조건을 나누며 그에 맞는 컴포넌트를 보여주려다 보니 하나의 컴포넌트마다 3개의 조건을 사용하게 됐고 하나의 파일에 코드가 너무 길어져서 유지보수가 어려워졌어요.
그래서 기능이 비슷한 펼친 월간 달력과 접힌 월간 달력을 묶어서 월간 달력 뷰에서 조건을 1개만 사용해서 구분해서 보여줬습니다. 그리고 상위 뷰에서 월간 달력을 보여주는지 주간 달력을 보여주는지 구분해서 보여주도록 했으며, 파일들도 주간 달력용과 월간 달력용으로 나눴어요.

이로 인해 수정하려는 뷰를 찾을 때도 하나의 폴더에 모두 모아놨을 때보다 찾기 쉬워졌죠. 하지만 처음 보여준 컴포넌트를 보여주기 위해 .sheet(isPresented:)를 사용했는데 이 곳에 들어갈 변수는 Binding이어야 했습니다. @Observable을 사용한 뷰모델에서 선언하면 안 됐고 상위 뷰에서 @State로 선언해서 하위뷰로 전달하는 방식이어야 했습니다.
그 결과 MyCalendarTapView - 월간 달력 뷰 or 주간 달력 뷰 - calendarHeaderComponent처럼 전달하는 방식이 됐어요. 주간 달력 뷰에서는 월을 변경할 수 없지만 calendarHeaderComponent의 인자로 위의 두 변수가 필요했기 때문에 사용 안 하는 변수를 주간 달력뷰가 들고 있게 됐죠. 이는 테스트할 때도 사용 안 하는 변수를 선언해서 프로퍼티로 주입해줘야 됐죠.
이건 아니다 싶어서 두 개의 컴포넌트로 나누게 됐습니다. 이 경험을 하며 어떤 기준으로 옵션을 추가하여 재사용성 할지, 컴포넌트를 분리할지 고민을 하게 됐어요. 하지만 고민을 하면 할수록 어떤 기준을 딱 정해두는 것은 안 좋은 거 같다고 생각했어요.
처음에는 기능이 조금 달라서 옵션이 1~2개를 추가해 재사용 할 수 있으면 재사용하자였는데 위 과정을 돌아보니 그때 그때 상황에 맞춰서 하는게 좋다고 생각이 들었어요.
아래는 제가 사용했던 코드입니다.
// MonthCalendarWeekView
struct MonthCalendarWeekView: View {
let myCalendarVM: MyCalendarTapViewModel
let monthCalendarVM: MonthCalendarViewModel
let weeklyDate: [Date]
var body: some View {
LazyVGrid(columns: Array(repeating: GridItem(), count: 7), spacing: 0) {
ForEach(weeklyDate, id:\.self) { date in
VStack {
Text("\(fetchDay(date: date))")
.font(.system(size: 14))
.foregroundStyle(fetchColor(date: date))
.background {
// 접힌 달력에서 날짜가 같은 경우에만 표시
if (monthCalendarVM.isOutspread == false && isSameDate(date1: date, date2: myCalendarVM.selectedDate)) {
Circle()
.frame(width: 40, height: 40)
.foregroundStyle(Color(uiColor: .accent))
}
}
}
.frame(height: 40)
.onTapGesture {
myCalendarVM.changeMonth(newMonth: date)
if monthCalendarVM.isOutspread {
myCalendarVM.changeSelectedWeek(newSelectedWeek: weeklyDate)
// 선택한 주가 위로 올라가는 애니메이션을 위해 0.5초의 딜레이 주기
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
myCalendarVM.changeSelectedDate(newSelectedDate: date)
}
} else {
myCalendarVM.changeSelectedDate(newSelectedDate: date)
}
}
}
}
}
}
// WeekCalendarWeekView
struct WeeklyCalendarWeekView: View {
let myCalendarVM: MyCalendarTapViewModel
let weeklyDate: [Date]
var body: some View {
LazyVGrid(columns: Array(repeating: GridItem(), count: 7), spacing: 0) {
ForEach(weeklyDate, id:\.self) { date in
VStack {
Text("\(fetchDay(date: date))")
.font(.system(size: 14))
.foregroundStyle(fetchColor(date: date))
.background {
if (isSameDate(date1: date, date2: myCalendarVM.selectedDate)) {
Circle()
.frame(width: 40, height: 40)
.foregroundStyle(Color(uiColor: .accent))
}
}
}
.frame(height: 40)
.onTapGesture {
myCalendarVM.changeMonth(newMonth: date)
myCalendarVM.changeSelectedDate(newSelectedDate: date)
}
}
}
}
}
위의 두 코드는 각각 주간 달력과 월간 달력에서 날짜를 보여주는 코드입니다. 하지만 아래 코드처럼 어떤 달력을 보여주고 있는지 상위뷰에서도 사용하는 변수를 1개만 추가하면 monthCalendarVM 변수가 필요 없어지고 상위 뷰를 테스트 할 때 사용 안하는 변수를 주입하지 않아도 되죠. 이럴 때는 옵션을 추가하여 재사용성을 높이는 것이 좋다고 생각해요
struct CalendarWeekView: View {
let myCalendarVM: MyCalendarTapViewModel
let weeklyDate: [Date]
var body: some View {
LazyVGrid(columns: Array(repeating: GridItem(), count: 7), spacing: 0) {
ForEach(weeklyDate, id:\.self) { date in
VStack {
Text("\(fetchDay(date: date))")
.font(.system(size: 14))
.foregroundStyle(fetchColor(date: date))
.background {
// 펼친 달력이 아닌 뷰에서 날짜가 같은 경우에만 표시
if (monthCalendarVM.calendarState != .spreadMonthCalendar && isSameDate(date1: date, date2: myCalendarVM.selectedDate)) {
Circle()
.frame(width: 40, height: 40)
.foregroundStyle(Color(uiColor: .accent))
}
}
}
.frame(height: 40)
.onTapGesture {
myCalendarVM.changeMonth(newMonth: date)
// 펼친 월간 달력의 경우
if myCalendarVM.calendarState == .spreadMonthCalendar {
myCalendarVM.changeSelectedWeek(newSelectedWeek: weeklyDate)
// 선택한 주가 위로 올라가는 애니메이션을 위해 0.5초의 딜레이 주기
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
myCalendarVM.changeSelectedDate(newSelectedDate: date)
}
} else {
myCalendarVM.changeSelectedDate(newSelectedDate: date)
}
}
}
}
}
}
반대로 상위 뷰에서 안 쓰이는 변수를 가지고 있어서 최상위 뷰 - 상위 뷰 - 하위 뷰 이런식으로 전달해야하는 방식이라면 뷰를 분리하면 좋겠다고 생각 합니다. 예시는 중간에 뷰가 1개뿐이지만 만약 늘어나게 된다면 뷰를 테스트 할 때마다 사용 안 하는 변수를 억지로 추가해야하기 때문입니다.
옵션이 추가되서 if문이 많아질 때도 분리하면 좋다고 생각을 하는데 if문이 많아지면 코드가 복잡해져서 가독성이 안 좋아지기 때문입니다. if문이 적게 들어간다 해도 조건이 너무 많아지면 나중에 코드를 이해할 때 어려워지므로 분리하는게 좋겠죠?
또한 본인이 유지보수를 할 때 수정할 내용을 찾기 어려울 때도 분리하는게 좋다고 생각해요. 코드를 작성한 본인이 찾기 어려울 정도면 다른 사람이 봤을 때는 더욱 어렵기 때문이에요
크게 생각하면 테스트에 영향이 안가고 가독성이 나빠지지 않으면 재사용을 해도 된다인거 같은데 이거 또한 사람마다 기준이 다르기 때문에 정답은 모르겠어요. 그냥 저는 이렇게 생각했다 정도로 봐주시면 되겠습니다.
나중에 알게된 내용인데 아래 처럼 계산 프로퍼티를 사용하면 @Observable 매크로를 사용한 클래스에서 선언한 변수도 isPresented에 사용할 수 있다고 합니다.
Binding(
get: { viewModel.isSheetPresented },
set: { viewModel.isSheetPresented = $0 }
)