Repository: Juinjang/iOS
Pull request #111: [Feature] DropDownView 컴포넌트화
Author: login: yuzzin0121
Labels: name: ✨ 기능
Created: 2025-03-13T14:34:04Z | Merged: 2025-03-14T15:39:51Z

## 👷🏼‍♂️ 변경 사항 (Summary)
아코디언 애니메이션을 가지는 드롭다운 뷰와 관련된 컴포넌트를 만들었습니다.
컴포넌트를 구현하는데 필요한 extension을 추가하였습니다.

### FilterTitleButton
![Frame 26086568](https://github.com/user-attachments/assets/9b1f8eec-6283-4163-9243-3337efffa7c8)
``` 
private lazy var filterTitleButton = FilterTitleButton(title: filterList[0].title)
``` 
- DropDownView 내부에서 타이틀 버튼으로 사용되고 있습니다.
- 초기화 시 매개변수에 타이틀 문자열이 들어가야 합니다.

<br>

### FilterButton
![Frame 26086693](https://github.com/user-attachments/assets/506c9ce0-8827-4e0e-af31-5d2db93a9fb0)

``` 
for (index, filter) in filterList.enumerated() {
    let button = FilterButton(filterType: filter)
    ...
}
```
- DropDownView의 `setFilterView()` 메서드 내부에서 Filter 열거형 리스트를 통해 동적으로 `FilterButton`을 생성합니다.
- 초기화 매개변수로 `LookAroundFilterType`이 필요합니다.
- DropDownView에서 마지막 FilterButton의 경우 button의 bottom 모서리를 둥글게 처리합니다.

<br>

### DropDownView
![Dropdown](https://github.com/user-attachments/assets/ca0a7c0c-e9fb-477d-aecf-592b4b511397)
``` 
private let sortDropDownView = DropDownView(filterList: SortFilter.allCases)
``` 
- `FilterTitleButton`과 `FilterButton`을 활용하여 구현한 `DropDownView` 컴포넌트입니다.
- 초기화 시 `LookAroundFilterType` 프로토콜을 채택하는 열거형 배열이 필요합니다.
- 뷰의 크기가 변경될때의 시점인 `layoutSubviews`() 메서드에서 stackView의 subViews의 height를 합산한 `totalHeight`를 활용하여 아코디언 애니메이션을 구현하였습니다.


<br>


### LookAroundDropDownView
![filter](https://github.com/user-attachments/assets/294895da-e5d5-4891-9ce1-9575e019a00b)
- 정렬, 거래, 매물 관련 드롭다운뷰를 합친 컴포넌트 입니다.

``` 
let sortActionRelay = PublishRelay<SortAction>()
let transactionTypeActionRelay = PublishRelay<TransactionTypeAction>()
let saleTypeActionRelay = PublishRelay<SaleTypeAction>()
``` 
- 각 드롭다운뷰의 옵션 클릭 이벤트를 감지하는 Relay를 프로퍼티로 가지고있습니다.

**옵션 클릭 이벤트 구독 예시**
``` 
filterDropDownView.sortActionRelay
    .bind(with: self) { owner, action in
        print(action)
    }
    .disposed(by: disposeBag)

filterDropDownView.transactionTypeActionRelay
    .bind(with: self) { owner, action in
        print(action)
    }
    .disposed(by: disposeBag)

filterDropDownView.saleTypeActionRelay
    .bind(with: self) { owner, action in
        print(action)
    }
    .disposed(by: disposeBag)
``` 
- LookAroundDropDownView의 각 드롭다운의 클릭 옵션을 구독하여 활용 가능합니다.

<br>

### UIView+Extension - roundCorners(cornerRadius: CGFloat, corner: Corner)
- UIView의 특정 모서리를 둥글게 만드는 메서드입니다.

``` 
enum Corner {
    case top
    case bottom
    case all
    
    var cornerMasks: CACornerMask {
        switch self {
        case .top: [.layerMinXMinYCorner, .layerMaxXMinYCorner]
        case .bottom: [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
        case .all: [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
        }
    }
}
``` 
- Corner 열거형 데이터를 정의하여 특정 모서리를 편하게 사용하도록 구성하였습니다.

<br>

### UILabel+Extension - setAttribute(text: ...)
``` 
func setAttribute(text: String?, color: UIColor = .gray, font: UIFont? = .pretendard(size: 14, weight: .regular), lineHeightMutliple: CGFloat = 1.35, charSpacing: CGFloat = -0.02, alignment: NSTextAlignment = NSTextAlignment.left) {
    ...
}
``` 
- Label의 텍스트에 대한 컬러, 폰트, lineHeight, charSpacing, alignment를 설정하는 메서드를 구성하였습니다.

<br><br>
## 🛠️ PR 유형

어떤 변경 사항이 있나요?

- [X] 새로운 기능 추가
- [ ] 버그 수정
- [ ] 사용자 UI 디자인 변경
- [ ] 코드에 영향을 주지 않는 변경사항(오타 수정, 탭 사이즈 변경, 변수명 변경)
- [ ] 코드 리팩토링
- [ ] 주석 추가 및 수정
- [ ] 문서 수정
- [ ] 테스트 추가, 테스트 리팩토링
- [ ] 빌드 부분 혹은 패키지 매니저 수정
- [ ] 파일 혹은 폴더명 수정
- [ ] 파일 혹은 폴더 삭제

## 📸스크린샷 (선택)
|시연 영상|
|:---:|
|<img src="https://github.com/user-attachments/assets/11b34e38-b269-421b-a22c-0e1bb136e84c" width="250">|

## 💬 공유사항 to 리뷰어
- `LookAroundFilterType`과 `LookAroundFilterActionType`이라는 네이밍이 길다고 느껴지는데, 더 좋을 명칭이 있을지 의견 주시면 감사하겠습니다.
- 더 좋은 구조나, 유지보수 쉽게 할 수 있는 방법이 있는지 의견이 있다면 공유 부탁드립니다.



## ✅ PR Checklist

PR이 다음 요구 사항을 충족하는지 확인하세요.

- [X] 커밋 메시지 컨벤션에 맞게 작성했습니다.
- [X] 변경 사항에 대한 테스트를 했습니다.(버그 수정/기능에 대한 테스트).



Diff:
diff --git a/juinjang/Projects/App/Sources/Common/Enum/LookAroundFilter.swift b/juinjang/Projects/App/Sources/Common/Enum/LookAroundFilter.swift
new file mode 100644
index 00000000..9c9ab7eb
--- /dev/null
+++ b/juinjang/Projects/App/Sources/Common/Enum/LookAroundFilter.swift
@@ -0,0 +1,99 @@
+//
+//  LookAroundFilter.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+protocol LookAroundFilterType {
+    var rawValue: String { get }
+    var title: String { get }
+    var action: LookAroundFilterActionType { get }
+}
+protocol LookAroundFilterActionType {
+    
+}
+
+enum SortFilter: String, CaseIterable, LookAroundFilterType {
+    case lateset = "최신순"
+    case purchase = "구매순"
+    
+    var title: String {
+        return self.rawValue
+    }
+    
+    var action: LookAroundFilterActionType {
+        switch self {
+        case .lateset: SortAction.latestAction
+        case .purchase: SortAction.purchaseAction
+        }
+    }
+}
+
+enum TransactionTypeFilter: String, CaseIterable, LookAroundFilterType {
+    case total = "전체"
+    case monthlyRent = "월세"
+    case lease = "전세"
+    case sale = "매매"
+    
+    var title: String {
+        switch self {
+        case .total: "거래 전체"
+        case .monthlyRent, .lease, .sale: self.rawValue
+        }
+    }
+    
+    var action: LookAroundFilterActionType {
+        switch self {
+        case .total: TransactionTypeAction.totalTransaction
+        case .monthlyRent: TransactionTypeAction.monthlyRent
+        case .lease: TransactionTypeAction.lease
+        case .sale: TransactionTypeAction.sale
+        }
+    }
+}
+
+enum SaleTypeFilter: String, CaseIterable, LookAroundFilterType {
+    case totalSale = "전체"
+    case officetel = "오피스텔"
+    case apartment = "아파트"
+    case detachedHouse = "단독 주택"
+    case villa = "빌라"
+    
+    var title: String {
+        switch self {
+        case .totalSale: "매물 전체"
+        case .officetel, .apartment, .detachedHouse, .villa: self.rawValue
+        }
+    }
+    
+    var action: LookAroundFilterActionType {
+        switch self {
+        case .totalSale: SaleTypeAction.totalSale
+        case .officetel: SaleTypeAction.officetel
+        case .apartment: SaleTypeAction.apartment
+        case .detachedHouse: SaleTypeAction.detachedHouse
+        case .villa: SaleTypeAction.villa
+        }
+    }
+}
+
+enum SortAction: LookAroundFilterActionType {
+    case latestAction
+    case purchaseAction
+}
+
+enum TransactionTypeAction: LookAroundFilterActionType {
+    case totalTransaction
+    case monthlyRent
+    case lease
+    case sale
+}
+
+enum SaleTypeAction: LookAroundFilterActionType {
+    case totalSale
+    case officetel
+    case apartment
+    case detachedHouse
+    case villa
+}
diff --git a/juinjang/Projects/App/Sources/Common/Extension/UILabel+Extension.swift b/juinjang/Projects/App/Sources/Common/Extension/UILabel+Extension.swift
index f46ddc36..fb27c96d 100644
--- a/juinjang/Projects/App/Sources/Common/Extension/UILabel+Extension.swift
+++ b/juinjang/Projects/App/Sources/Common/Extension/UILabel+Extension.swift
@@ -49,4 +49,27 @@ extension UILabel {
                                       
         self.attributedText = attributedString
     }
+    
+    func setAttribute(text: String?, color: UIColor = .gray, font: UIFont? = .pretendard(size: 14, weight: .regular), lineHeightMutliple: CGFloat = 1.35, charSpacing: CGFloat = -0.02, alignment: NSTextAlignment = NSTextAlignment.left) {
+        guard let text, let font else { return }
+        
+        let resultText = text.isEmpty ? "" : text
+
+        let desiredLineHeight = font.lineHeight * lineHeightMutliple
+        let style = NSMutableParagraphStyle()
+        style.maximumLineHeight = desiredLineHeight
+        style.minimumLineHeight = desiredLineHeight
+        style.alignment = alignment
+
+        let attributes: [NSAttributedString.Key: Any] = [
+            .paragraphStyle: style,
+            .baselineOffset: (desiredLineHeight - font.lineHeight) / 2,
+            .kern: charSpacing,
+            .font: font,
+            .foregroundColor: color,
+        ]
+
+        let attrString = NSAttributedString(string: resultText, attributes: attributes)
+        self.attributedText = attrString
+    }
 }
diff --git a/juinjang/Projects/App/Sources/Common/Extension/UIView+Extension.swift b/juinjang/Projects/App/Sources/Common/Extension/UIView+Extension.swift
index 452ac244..3c8a692c 100644
--- a/juinjang/Projects/App/Sources/Common/Extension/UIView+Extension.swift
+++ b/juinjang/Projects/App/Sources/Common/Extension/UIView+Extension.swift
@@ -24,6 +24,25 @@ extension UIView {
         // 뷰의 배경으로 그레디언트 레이어 추가
         self.layer.insertSublayer(gradientLayer, at: 0)
     }
+    
+    func roundCorners(cornerRadius: CGFloat, corner: Corner) {
+        clipsToBounds = true
+        layer.cornerRadius = cornerRadius
+        layer.maskedCorners = corner.cornerMasks
+    }
 }
 
 
+enum Corner {
+    case top
+    case bottom
+    case all
+    
+    var cornerMasks: CACornerMask {
+        switch self {
+        case .top: [.layerMinXMinYCorner, .layerMaxXMinYCorner]
+        case .bottom: [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
+        case .all: [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
+        }
+    }
+}
diff --git a/juinjang/Projects/App/Sources/Components/CustomView/DropDownView/DropDownView.swift b/juinjang/Projects/App/Sources/Components/CustomView/DropDownView/DropDownView.swift
new file mode 100644
index 00000000..19deb41b
--- /dev/null
+++ b/juinjang/Projects/App/Sources/Components/CustomView/DropDownView/DropDownView.swift
@@ -0,0 +1,195 @@
+//
+//  DropDownView.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxSwift
+import RxRelay
+import RxCocoa
+
+final class DropDownView<T: LookAroundFilterType & RawRepresentable>: BaseView where T.RawValue == String {
+    private lazy var filterTitleButton = FilterTitleButton(title: filterList[0].title)
+    
+    // 펼칠 목록(필터 메뉴)을 쌓아둘 스택뷰
+    private let filterSelectStackView: UIStackView = {
+        let stackView = UIStackView()
+        stackView.axis = .vertical
+        stackView.alignment = .fill
+        stackView.distribution = .fill
+        stackView.spacing = 0
+        return stackView
+    }()
+    
+    private var filterStackViewContentHeight: CGFloat = 0
+    
+    private var disposeBag = DisposeBag()
+    private var isExpanded = false  // 현재 펼쳐진 상태인지 여부
+    lazy var filterActionRelay = BehaviorRelay<LookAroundFilterActionType>(value: filterList[0].action)
+    private var filterList: [T]
+    
+    init(filterList: [T]) {
+        self.filterList = filterList
+        super.init(frame: .zero)
+        setFilterView()
+        bind()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func bind()  {
+        filterTitleButton.rx.tap
+            .asDriver()
+            .drive(with: self) { owner, _ in
+                owner.dropDownFilterView()
+            }
+            .disposed(by: disposeBag)
+    }
+    
+    override func configureHierarchy() {
+        addSubview(filterTitleButton)
+        addSubview(filterSelectStackView)
+    }
+    
+    override func configureLayout() {
+        filterTitleButton.snp.makeConstraints { make in
+            make.leading.top.bottom.equalToSuperview()
+            make.width.equalTo(0)
+            make.trailing.equalToSuperview()
+        }
+        filterSelectStackView.snp.makeConstraints { make in
+            make.top.equalTo(filterTitleButton.snp.bottom)
+            make.horizontalEdges.equalTo(filterTitleButton)
+            make.height.equalTo(0)
+        }
+    }
+    
+    override func configureView() {
+    }
+    
+    override func layoutSubviews() {
+        super.layoutSubviews()
+        calculateStackViewHeight()
+    }
+    
+    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
+        // 우선, 기본 hitTest 결과를 확인
+        if let hitView = super.hitTest(point, with: event) {
+            return hitView
+        }
+        
+        // DropDownView의 bounds 밖이라도 filterSelectStackView에 터치가 있는지 확인
+        let convertedPoint = filterSelectStackView.convert(point, from: self)
+        if let hitView = filterSelectStackView.hitTest(convertedPoint, with: event) {
+            return hitView
+        }
+        return nil
+    }
+    
+    private func calculateStackViewHeight() {
+        guard filterStackViewContentHeight == 0 else { return }
+        var totalHeight: CGFloat = 0
+        
+        let maxIndex = filterList.indices.max { filterList[$0].title.count < filterList[$1].title.count }
+        for (index,subview) in filterSelectStackView.arrangedSubviews.enumerated() {
+            if index == maxIndex {
+                let maxWidth = subview.intrinsicContentSize.width > filterTitleButton.intrinsicContentSize.width ? subview.intrinsicContentSize.width : filterTitleButton.intrinsicContentSize.width
+                filterTitleButton.snp.updateConstraints { make in
+                    make.width.equalTo(maxWidth)
+                }
+            }
+            totalHeight += subview.intrinsicContentSize.height
+        }
+        
+        filterStackViewContentHeight = totalHeight
+    }
+    
+    private func dropDownFilterView() {
+        isExpanded.toggle()
+        isExpanded ? expandDropDown() : hideDropDown()
+    }
+    
+    private func expandDropDown() {
+        filterTitleButton.roundCorners(cornerRadius: 0, corner: .bottom)
+        filterTitleButton.roundCorners(cornerRadius: 10, corner: .top)
+        
+        filterSelectStackView.snp.updateConstraints { make in
+            make.height.equalTo(filterStackViewContentHeight)
+        }
+        
+        self.filterSelectStackView.subviews.forEach {
+            let button = $0 as! FilterButton
+            button.titleLabel?.alpha = 1
+        }
+
+        // 화살표 아래 방향으로 회전
+        UIView.animate(withDuration: 0.18) {
+            self.filterTitleButton.imageView?.transform = CGAffineTransform(rotationAngle: .pi)
+            self.layoutIfNeeded()
+        }
+    }
+    
+    private func hideDropDown() {
+        filterSelectStackView.snp.updateConstraints { make in
+            make.height.equalTo(0)
+        }
+        
+        filterSelectStackView.subviews.forEach  {
+            let button = $0 as! FilterButton
+            button.titleLabel?.alpha = 0
+        }
+        
+        UIView.animate(withDuration: 0.18) {
+            self.filterTitleButton.imageView?.transform = .identity
+            self.layoutIfNeeded()
+        } completion: { _ in
+            self.filterTitleButton.roundCorners(cornerRadius: 10, corner: .all)
+            self.layoutIfNeeded()
+        }
+    }
+    
+    private func setFilterView() {
+        // 필터 목록 생성
+        filterSelectStackView.subviews.forEach { $0.removeFromSuperview() }
+        
+        for (index, filter) in filterList.enumerated() {
+            let button = FilterButton(filterType: filter)
+            
+            if index == 0 {
+                button.updateColor(isSelected: true)
+            }
+            
+            // 마지막 버튼만 하단 모서리를 둥글게
+            if index == filterList.count - 1 { button.roundCorners(cornerRadius: 10, corner: .bottom) }
+            
+            subscribeTapFilterButton(button, filter: filter)
+            
+            filterSelectStackView.addArrangedSubview(button)
+        }
+    }
+    
+    private func subscribeTapFilterButton(_ button: FilterButton, filter: LookAroundFilterType) {
+        button.rx.tap
+            .asDriver()
+            .drive(with: self) { owner, _ in
+                owner.hideDropDown()
+                owner.clearButtonSelectedColor()
+                button.updateColor(isSelected: true)
+                owner.filterTitleButton.updateTitle(title: filter.title)
+                owner.filterActionRelay.accept(filter.action)
+            }
+            .disposed(by: disposeBag)
+    }
+    
+    private func clearButtonSelectedColor() {
+        filterSelectStackView.subviews.compactMap {
+            $0 as? FilterButton
+        }.forEach { button in
+            button.updateColor(isSelected: false)
+        }
+    }
+}
diff --git a/juinjang/Projects/App/Sources/Components/CustomView/DropDownView/FilterButton.swift b/juinjang/Projects/App/Sources/Components/CustomView/DropDownView/FilterButton.swift
new file mode 100644
index 00000000..c9c994a2
--- /dev/null
+++ b/juinjang/Projects/App/Sources/Components/CustomView/DropDownView/FilterButton.swift
@@ -0,0 +1,54 @@
+//
+//  FilterButton.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxSwift
+import RxRelay
+
+final class FilterButton: UIButton {
+    private var padding = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
+    private var disposeBag = DisposeBag()
+    
+    init(filterType: LookAroundFilterType) {
+        super.init(frame: .zero)
+        configureView(filterType: filterType)
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func configureView(filterType: LookAroundFilterType) {
+        let title = filterType.rawValue
+        setTitle(title, for: .normal)
+        titleLabel?.setAttribute(text: title, color: .gray300, font: .pretendard(size: 14, weight: .medium))
+        setTitleColor(.gray300, for: .normal)
+        backgroundColor = .white
+        contentEdgeInsets = UIEdgeInsets(top: 0, left: padding.left, bottom: 0, right: padding.right)
+        contentHorizontalAlignment = .leading
+        titleLabel?.alpha = 0
+        layoutIfNeeded()
+    }
+    
+    func updateColor(isSelected: Bool) {
+        if isSelected {
+            setTitleColor(.gray450, for: .normal)
+            titleLabel?.setAttribute(text: titleLabel?.text, color: .gray450, font: .pretendard(size: 14, weight: .semiBold))
+        } else {
+            setTitleColor(.gray300, for: .normal)
+            titleLabel?.setAttribute(text: titleLabel?.text, color: .gray300, font: .pretendard(size: 14, weight: .medium))
+        }
+    }
+    
+     override var intrinsicContentSize: CGSize {
+         var contentSize = super.intrinsicContentSize
+         contentSize.height += padding.top + padding.bottom
+         contentSize.width += padding.left + padding.right
+
+         return contentSize
+     }
+}
diff --git a/juinjang/Projects/App/Sources/Components/CustomView/DropDownView/FilterTitleButton.swift b/juinjang/Projects/App/Sources/Components/CustomView/DropDownView/FilterTitleButton.swift
new file mode 100644
index 00000000..ecedc723
--- /dev/null
+++ b/juinjang/Projects/App/Sources/Components/CustomView/DropDownView/FilterTitleButton.swift
@@ -0,0 +1,52 @@
+//
+//  FilterTitleButton.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import SnapKit
+
+final class FilterTitleButton: UIButton {
+    private var padding = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
+    
+    init(title: String) {
+        super.init(frame: .zero)
+        configureView(title: title)
+    }
+    
+    required init(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func configureView(title: String) {
+        setTitle(title, for: .normal)
+        setImage(.ImjangList.arrowDown, for: .normal)
+        titleLabel?.setAttribute(text: title, color: .gray450, font: .pretendard(size: 14, weight: .semiBold))
+        setTitleColor(.gray450, for: .normal)
+        backgroundColor = .white
+    
+        layer.cornerRadius = 10
+        let intervalSpacing = 2.0
+        let halfIntervalSpacing = intervalSpacing / 2
+    
+        semanticContentAttribute = .forceRightToLeft
+        contentEdgeInsets = .init(top: 0, left: halfIntervalSpacing + padding.left, bottom: 0, right: halfIntervalSpacing - padding.right)
+        imageEdgeInsets = .init(top: 0, left: halfIntervalSpacing, bottom: 0, right: -halfIntervalSpacing)
+        titleEdgeInsets = .init(top: 0, left: -halfIntervalSpacing, bottom: 0, right: halfIntervalSpacing)
+        contentHorizontalAlignment = .left
+    }
+    
+    func updateTitle(title: String) {
+        setTitle(title, for: .normal)
+    }
+    
+    override var intrinsicContentSize: CGSize {
+        var contentSize = super.intrinsicContentSize
+        contentSize.height += padding.top + padding.bottom
+        contentSize.width += padding.left + padding.right
+
+        return contentSize
+    }
+}
diff --git a/juinjang/Projects/App/Sources/Components/CustomView/DropDownView/LookAroundDropDownView.swift b/juinjang/Projects/App/Sources/Components/CustomView/DropDownView/LookAroundDropDownView.swift
new file mode 100644
index 00000000..c6964114
--- /dev/null
+++ b/juinjang/Projects/App/Sources/Components/CustomView/DropDownView/LookAroundDropDownView.swift
@@ -0,0 +1,99 @@
+//
+//  LookAroundDropDownView.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxRelay
+import RxSwift
+
+final class LookAroundDropDownView: BaseView {
+    private let sortDropDownView = DropDownView(filterList: SortFilter.allCases)
+    private let transactionTypeDropDownView = DropDownView(filterList: TransactionTypeFilter.allCases)
+    private let saleTypeDropDownView = DropDownView(filterList: SaleTypeFilter.allCases)
+
+    let sortActionRelay = PublishRelay<SortAction>()
+    let transactionTypeActionRelay = PublishRelay<TransactionTypeAction>()
+    let saleTypeActionRelay = PublishRelay<SaleTypeAction>()
+    
+    private var disposeBag = DisposeBag()
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        bind()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func bind() {
+        sortDropDownView.filterActionRelay
+            .bind(with: self) { owner, action in
+                owner.sortActionRelay.accept(action as! SortAction)
+            }
+            .disposed(by: disposeBag)
+        
+        transactionTypeDropDownView.filterActionRelay
+            .bind(with: self) { owner, action in
+                owner.transactionTypeActionRelay.accept(action as! TransactionTypeAction)
+            }
+            .disposed(by: disposeBag)
+        
+        saleTypeDropDownView.filterActionRelay
+            .bind(with: self) { owner, action in
+                owner.saleTypeActionRelay.accept(action as! SaleTypeAction)
+            }
+            .disposed(by: disposeBag)
+    }
+    
+    override func configureHierarchy() {
+        addSubview(sortDropDownView)
+        addSubview(transactionTypeDropDownView)
+        addSubview(saleTypeDropDownView)
+    }
+    override func configureLayout() {
+        sortDropDownView.snp.makeConstraints { make in
+            make.leading.verticalEdges.equalToSuperview()
+        }
+        
+        transactionTypeDropDownView.snp.makeConstraints { make in
+            make.leading.equalTo(sortDropDownView.snp.trailing)
+            make.verticalEdges.equalToSuperview()
+        }
+        
+        saleTypeDropDownView.snp.makeConstraints { make in
+            make.leading.equalTo(transactionTypeDropDownView.snp.trailing)
+            make.trailing.lessThanOrEqualToSuperview()
+            make.verticalEdges.equalToSuperview()
+        }
+    }
+    override func configureView() { }
+    
+    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
+        // 우선, 기본 hitTest 결과를 확인
+        if let hitView = super.hitTest(point, with: event) {
+            return hitView
+        }
+        
+        // DropDownView의 bounds 밖이라도 filterSelectStackView에 터치가 있는지 확인
+        let convertedPoint = sortDropDownView.convert(point, from: self)
+        if let hitView = sortDropDownView.hitTest(convertedPoint, with: event) {
+            return hitView
+        }
+        
+        let convertedPoint2 = transactionTypeDropDownView.convert(point, from: self)
+        if let hitView = transactionTypeDropDownView.hitTest(convertedPoint2, with: event) {
+            return hitView
+        }
+        
+        let convertedPoint3 = saleTypeDropDownView.convert(point, from: self)
+        if let hitView = saleTypeDropDownView.hitTest(convertedPoint3, with: event) {
+            return hitView
+        }
+        
+        return nil
+    }
+}


Reviews:
user: login: kangddong
body: 고생하셨습니다 ! 
bind, drive 연산자 및 논리적으로 as! 사용해도 되는 때에 대해서 의견 남겨두었습니다. 유즈의 설계 의도와 의견도 많이 교환하고싶습니다.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T03:14:24Z
user: login: yuzzin0121
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T08:34:17Z
user: login: yuzzin0121
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T08:36:49Z
user: login: yuzzin0121
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T08:44:33Z
user: login: yuzzin0121
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T08:45:41Z
user: login: yuzzin0121
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T08:48:52Z
user: login: yuzzin0121
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T08:52:35Z
user: login: yuzzin0121
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T08:59:41Z
user: login: ornwoo96
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T13:07:48Z

Review comments:
diff_hunk: @@ -0,0 +1,104 @@
+//
+//  LookAroundDropDownView.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxRelay
+import RxSwift
+
+final class LookAroundDropDownView: UIView {
+    private let sortDropDownView = DropDownView(filterList: SortFilter.allCases)
+    private let transactionTypeDropDownView = DropDownView(filterList: TransactionTypeFilter.allCases)
+    private let saleTypeDropDownView = DropDownView(filterList: SaleTypeFilter.allCases)
+
+    let sortActionRelay = PublishRelay<SortAction>()
+    let transactionTypeActionRelay = PublishRelay<TransactionTypeAction>()
+    let saleTypeActionRelay = PublishRelay<SaleTypeAction>()
+    
+    private var disposeBag = DisposeBag()
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        configureHierarchy()
+        configureLayout()
+        configureView()
+        bind()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func bind() {
+        sortDropDownView.filterActionRelay
path: juinjang/Projects/App/Sources/Components/CustomView/DropDownView/LookAroundDropDownView.swift
user: login: kangddong
body: 각 타입에 맞는 Relay로 시퀀스를 방출하고 있기 때문에 as! 로 캐스팅하는 것은 문제가 없어보이긴하는데요 🤔
compactMap과 bind를 활용한 코드는 어떨까요 ? 선언형을 적극 활용하여 RxSwift 스럽게 사용하는 느낌을 주는거같아요
아래 코드 예시도 남깁니다 !
```swift
sortDropDownView.filterActionRelay
     .compactMap { $0 as? SortAction }
    .bind(to: sortActionRelay)
     .disposed(by: disposeBag)
```
created_at: 2025-03-14T02:29:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+//
+//  LookAroundDropDownView.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxRelay
+import RxSwift
+
+final class LookAroundDropDownView: UIView {
path: juinjang/Projects/App/Sources/Components/CustomView/DropDownView/LookAroundDropDownView.swift
user: login: kangddong
body: `backgroundColor = .mainWhite` 를 사용하지않을 의도 때문에 BaseView 를 상속 받지않으신걸까요 ?
`configureHierarchy()`, `configureLayout()`, `configureView()` 메서드를 사용하셨는데, `BaseView`를 활용하지않으신 이유가 궁금해요 !
created_at: 2025-03-14T02:31:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+//
+//  LookAroundDropDownView.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxRelay
+import RxSwift
+
+final class LookAroundDropDownView: UIView {
+    private let sortDropDownView = DropDownView(filterList: SortFilter.allCases)
+    private let transactionTypeDropDownView = DropDownView(filterList: TransactionTypeFilter.allCases)
+    private let saleTypeDropDownView = DropDownView(filterList: SaleTypeFilter.allCases)
+
+    let sortActionRelay = PublishRelay<SortAction>()
path: juinjang/Projects/App/Sources/Components/CustomView/DropDownView/LookAroundDropDownView.swift
user: login: kangddong
body: 기획적인 부분과도 엮여있을거같긴한데요 ! 필터 선택 전에는 초기 값을 가지고있는 경우는 없을까요 ?
초기 값을 가지는 경우가 일반적이라고 생각이되어서, 확인 후에 초기 값을 가지고 있다면 `BehaviorRelay` 를 사용하면 어떨까요 ?
created_at: 2025-03-14T02:34:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+//
+//  LookAroundDropDownView.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxRelay
+import RxSwift
+
+final class LookAroundDropDownView: UIView {
+    private let sortDropDownView = DropDownView(filterList: SortFilter.allCases)
+    private let transactionTypeDropDownView = DropDownView(filterList: TransactionTypeFilter.allCases)
+    private let saleTypeDropDownView = DropDownView(filterList: SaleTypeFilter.allCases)
+
+    let sortActionRelay = PublishRelay<SortAction>()
+    let transactionTypeActionRelay = PublishRelay<TransactionTypeAction>()
+    let saleTypeActionRelay = PublishRelay<SaleTypeAction>()
+    
+    private var disposeBag = DisposeBag()
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        configureHierarchy()
+        configureLayout()
+        configureView()
+        bind()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func bind() {
+        sortDropDownView.filterActionRelay
+            .bind(with: self) { owner, action in
+                owner.sortActionRelay.accept(action as! SortAction)
+            }
+            .disposed(by: disposeBag)
+        
+        transactionTypeDropDownView.filterActionRelay
+            .bind(with: self) { owner, action in
+                owner.transactionTypeActionRelay.accept(action as! TransactionTypeAction)
+            }
+            .disposed(by: disposeBag)
+        
+        saleTypeDropDownView.filterActionRelay
+            .bind(with: self) { owner, action in
+                owner.saleTypeActionRelay.accept(action as! SaleTypeAction)
+            }
+            .disposed(by: disposeBag)
+    }
+    
+    private func configureHierarchy() {
+        addSubview(sortDropDownView)
+        addSubview(transactionTypeDropDownView)
+        addSubview(saleTypeDropDownView)
+    }
+    private func configureLayout() {
+        sortDropDownView.snp.makeConstraints { make in
+            make.leading.verticalEdges.equalToSuperview()
+        }
+        
+        transactionTypeDropDownView.snp.makeConstraints { make in
+            make.leading.equalTo(sortDropDownView.snp.trailing)
+            make.verticalEdges.equalToSuperview()
+        }
+        
+        saleTypeDropDownView.snp.makeConstraints { make in
+            make.leading.equalTo(transactionTypeDropDownView.snp.trailing)
+            make.trailing.lessThanOrEqualToSuperview()
+            make.verticalEdges.equalToSuperview()
+        }
+    }
+    private func configureView() {
+        
+    }
+    
+    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
+        // 우선, 기본 hitTest 결과를 확인
+        if let hitView = super.hitTest(point, with: event) {
path: juinjang/Projects/App/Sources/Components/CustomView/DropDownView/LookAroundDropDownView.swift
user: login: kangddong
body: 저는 이 hitTest 고려하는게 가장 까다롭더라구요 !  깔끔하게 작성해주셔서 좋은 참고가 되었습니다 :>
created_at: 2025-03-14T02:36:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,99 @@
+//
+//  LookAroundFilter.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
path: juinjang/Projects/App/Sources/Common/Enum/LookAroundFilter.swift
user: login: kangddong
body: POP 스타일을 좋아하는 편인데, protocol 를 활용 잘 해주신거같아요 !  이 부분은 많이 배워갑니다 !
created_at: 2025-03-14T02:40:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -24,6 +24,25 @@ extension UIView {
         // 뷰의 배경으로 그레디언트 레이어 추가
         self.layer.insertSublayer(gradientLayer, at: 0)
     }
+    
+    func roundCorners(cornerRadius: CGFloat, corner: Corner) {
+        clipsToBounds = true
+        layer.cornerRadius = cornerRadius
+        layer.maskedCorners = corner.cornerMasks
+    }
 }
 
 
+enum Corner {
path: juinjang/Projects/App/Sources/Common/Extension/UIView+Extension.swift
user: login: kangddong
body: 저도 부분 라운딩 구현해본 적이 있었는데, 이런식으로도 할 수가 있네요 ..👍
created_at: 2025-03-14T02:41:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,191 @@
+//
+//  DropDownView.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxSwift
+import RxRelay
+import RxCocoa
+
+final class DropDownView<T: LookAroundFilterType & RawRepresentable>: UIView where T.RawValue == String {
+    private lazy var filterTitleButton = FilterTitleButton(title: filterList[0].title)
+    
+    // 펼칠 목록(필터 메뉴)을 쌓아둘 스택뷰
+    private let filterSelectStackView: UIStackView = {
+        let stackView = UIStackView()
+        stackView.axis = .vertical
+        stackView.alignment = .fill
+        stackView.distribution = .fill
+        stackView.spacing = 0
+        return stackView
+    }()
+    
+    private var filterStackViewContentHeight: CGFloat = 0
+    
+    private var disposeBag = DisposeBag()
+    private var isExpanded = false  // 현재 펼쳐진 상태인지 여부
+    let filterActionRelay = PublishRelay<LookAroundFilterActionType>()
+    private var filterList: [T]
+    
+    init(filterList: [T]) {
+        self.filterList = filterList
+        super.init(frame: .zero)
+        configureHierarchy()
+        configureLayout()
+        configureView()
+        setFilterView()
+        bind()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func bind()  {
+        filterTitleButton.rx.tap
+            .bind(with: self) { owner, _ in
path: juinjang/Projects/App/Sources/Components/CustomView/DropDownView/DropDownView.swift
user: login: kangddong
body: UI 요소를 변경하는 메서드를 호출하고 있어서, 바인딩을 하지않고 호출하는 경우라면
메인스레드를 보장해주는 `drive`를 사용하는 것은 어떨까요 ?😊
[스플래시 뷰 리팩토링 코드 리뷰](https://github.com/Juinjang/iOS-MVP/pull/141#discussion_r1958184697)에서 언급드렸듯이 보편적으로 메인 스레드 보장은 drive 만 가능합니다 ! :>
created_at: 2025-03-14T03:01:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,191 @@
+//
+//  DropDownView.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxSwift
+import RxRelay
+import RxCocoa
+
+final class DropDownView<T: LookAroundFilterType & RawRepresentable>: UIView where T.RawValue == String {
+    private lazy var filterTitleButton = FilterTitleButton(title: filterList[0].title)
+    
+    // 펼칠 목록(필터 메뉴)을 쌓아둘 스택뷰
+    private let filterSelectStackView: UIStackView = {
+        let stackView = UIStackView()
+        stackView.axis = .vertical
+        stackView.alignment = .fill
+        stackView.distribution = .fill
+        stackView.spacing = 0
+        return stackView
+    }()
+    
+    private var filterStackViewContentHeight: CGFloat = 0
+    
+    private var disposeBag = DisposeBag()
+    private var isExpanded = false  // 현재 펼쳐진 상태인지 여부
+    let filterActionRelay = PublishRelay<LookAroundFilterActionType>()
+    private var filterList: [T]
+    
+    init(filterList: [T]) {
+        self.filterList = filterList
+        super.init(frame: .zero)
+        configureHierarchy()
+        configureLayout()
+        configureView()
+        setFilterView()
+        bind()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func bind()  {
+        filterTitleButton.rx.tap
+            .bind(with: self) { owner, _ in
+                owner.dropDownFilterView()
+            }
+            .disposed(by: disposeBag)
+    }
+    
+    private func configureHierarchy() {
+        addSubview(filterTitleButton)
+        addSubview(filterSelectStackView)
+    }
+    
+    private func configureLayout() {
+        filterTitleButton.snp.makeConstraints { make in
+            make.leading.top.bottom.equalToSuperview()
+            make.width.equalTo(0)
+            make.trailing.equalToSuperview()
+        }
+        filterSelectStackView.snp.makeConstraints { make in
+            make.top.equalTo(filterTitleButton.snp.bottom)
+            make.horizontalEdges.equalTo(filterTitleButton)
+            make.height.equalTo(0)
+        }
+    }
+    
+    private func configureView() {
+    }
+    
+    override func layoutSubviews() {
+        super.layoutSubviews()
+        calculateStackViewHeight()
+    }
+    
+    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
+        // 우선, 기본 hitTest 결과를 확인
+        if let hitView = super.hitTest(point, with: event) {
+            return hitView
+        }
+        
+        // DropDownView의 bounds 밖이라도 filterSelectStackView에 터치가 있는지 확인
+        let convertedPoint = filterSelectStackView.convert(point, from: self)
+        if let hitView = filterSelectStackView.hitTest(convertedPoint, with: event) {
+            return hitView
+        }
+        return nil
+    }
+    
+    func calculateStackViewHeight() {
+        guard filterStackViewContentHeight == 0 else { return }
+        var totalHeight: CGFloat = 0
+        
+        let maxIndex = filterList.indices.max { filterList[$0].title.count < filterList[$1].title.count }
+        for (index,subview) in filterSelectStackView.arrangedSubviews.enumerated() {
+            if index == maxIndex {
+                let maxWidth = subview.intrinsicContentSize.width > filterTitleButton.intrinsicContentSize.width ? subview.intrinsicContentSize.width : filterTitleButton.intrinsicContentSize.width
+                filterTitleButton.snp.updateConstraints { make in
+                    make.width.equalTo(maxWidth)
+                }
+            }
+            totalHeight += subview.intrinsicContentSize.height
+        }
+        
+        filterStackViewContentHeight = totalHeight
+    }
+    
+    private func dropDownFilterView() {
+        isExpanded.toggle()
+        isExpanded ? expandDropDown() : hideDropDown()
+    }
+    
+    private func expandDropDown() {
+        filterTitleButton.roundCorners(cornerRadius: 0, corner: .bottom)
+        filterTitleButton.roundCorners(cornerRadius: 10, corner: .top)
+        
+        filterSelectStackView.snp.updateConstraints { make in
+            make.height.equalTo(filterStackViewContentHeight)
+        }
+        
+        self.filterSelectStackView.subviews.forEach {
+            let button = $0 as! FilterButton
+            button.titleLabel?.alpha = 1
+        }
+
+        // 화살표 아래 방향으로 회전
+        UIView.animate(withDuration: 0.18) {
+            self.filterTitleButton.imageView?.transform = CGAffineTransform(rotationAngle: .pi)
+            self.layoutIfNeeded()
+        }
+    }
+    
+    private func hideDropDown() {
+        filterSelectStackView.snp.updateConstraints { make in
+            make.height.equalTo(0)
+        }
+        
+        filterSelectStackView.subviews.forEach  {
+            let button = $0 as! FilterButton
+            button.titleLabel?.alpha = 0
+        }
+        
+        UIView.animate(withDuration: 0.18) {
+            self.filterTitleButton.imageView?.transform = .identity
+            self.layoutIfNeeded()
+        } completion: { _ in
+            self.filterTitleButton.roundCorners(cornerRadius: 10, corner: .all)
+            self.layoutIfNeeded()
+        }
+    }
+    
+    private func setFilterView() {
+        // 필터 목록 생성
+        filterSelectStackView.subviews.forEach { $0.removeFromSuperview() }
+        
+        for (index, filter) in filterList.enumerated() {
+            let button = FilterButton(filterType: filter)
+            
+            if index == 0 {
+                button.updateColor(isSelected: true)
+            }
+            
+            // 마지막 버튼만 하단 모서리를 둥글게
+            if index == filterList.count - 1 { button.roundCorners(cornerRadius: 10, corner: .bottom) }
+            
+            button.rx.tap
path: juinjang/Projects/App/Sources/Components/CustomView/DropDownView/DropDownView.swift
user: login: kangddong
body: 이 부분도 스레드 처리 고려가 필요할거같아요 !
코드가 길어진다면 UI 처리와 .accept 호출부를 분리하는 것은 어떨까요 ?
created_at: 2025-03-14T03:04:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,55 @@
+//
+//  FilterButton.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxSwift
+import RxRelay
+
+final class FilterButton: UIButton {
+    private var padding = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
+    private var disposeBag = DisposeBag()
+    
+    init(filterType: LookAroundFilterType) {
+        super.init(frame: .zero)
+        configureView(filterType: filterType)
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func configureView(filterType: LookAroundFilterType) {
+        let title = filterType.rawValue
+        setTitle(title, for: .normal)
+        titleLabel?.setAttribute(text: title, color: .gray300, font: .pretendard(size: 14, weight: .medium))
+        setTitleColor(.gray300, for: .normal)
+        backgroundColor = .white
+        contentEdgeInsets = UIEdgeInsets(top: 0, left: padding.left, bottom: 0, right: padding.right)
+        contentHorizontalAlignment = .leading
+        titleLabel?.alpha = 0
+        layoutIfNeeded()
+    }
+    
+    func updateColor(isSelected: Bool) {
+        print(#function)
path: juinjang/Projects/App/Sources/Components/CustomView/DropDownView/FilterButton.swift
user: login: kangddong
body: 테스트 코드 포함되어있는거같아요 !
created_at: 2025-03-14T03:04:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,191 @@
+//
+//  DropDownView.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxSwift
+import RxRelay
+import RxCocoa
+
+final class DropDownView<T: LookAroundFilterType & RawRepresentable>: UIView where T.RawValue == String {
+    private lazy var filterTitleButton = FilterTitleButton(title: filterList[0].title)
+    
+    // 펼칠 목록(필터 메뉴)을 쌓아둘 스택뷰
+    private let filterSelectStackView: UIStackView = {
+        let stackView = UIStackView()
+        stackView.axis = .vertical
+        stackView.alignment = .fill
+        stackView.distribution = .fill
+        stackView.spacing = 0
+        return stackView
+    }()
+    
+    private var filterStackViewContentHeight: CGFloat = 0
+    
+    private var disposeBag = DisposeBag()
+    private var isExpanded = false  // 현재 펼쳐진 상태인지 여부
+    let filterActionRelay = PublishRelay<LookAroundFilterActionType>()
+    private var filterList: [T]
+    
+    init(filterList: [T]) {
+        self.filterList = filterList
+        super.init(frame: .zero)
+        configureHierarchy()
+        configureLayout()
+        configureView()
+        setFilterView()
+        bind()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func bind()  {
+        filterTitleButton.rx.tap
+            .bind(with: self) { owner, _ in
+                owner.dropDownFilterView()
+            }
+            .disposed(by: disposeBag)
+    }
+    
+    private func configureHierarchy() {
+        addSubview(filterTitleButton)
+        addSubview(filterSelectStackView)
+    }
+    
+    private func configureLayout() {
+        filterTitleButton.snp.makeConstraints { make in
+            make.leading.top.bottom.equalToSuperview()
+            make.width.equalTo(0)
+            make.trailing.equalToSuperview()
+        }
+        filterSelectStackView.snp.makeConstraints { make in
+            make.top.equalTo(filterTitleButton.snp.bottom)
+            make.horizontalEdges.equalTo(filterTitleButton)
+            make.height.equalTo(0)
+        }
+    }
+    
+    private func configureView() {
+    }
+    
+    override func layoutSubviews() {
+        super.layoutSubviews()
+        calculateStackViewHeight()
+    }
+    
+    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
+        // 우선, 기본 hitTest 결과를 확인
+        if let hitView = super.hitTest(point, with: event) {
+            return hitView
+        }
+        
+        // DropDownView의 bounds 밖이라도 filterSelectStackView에 터치가 있는지 확인
+        let convertedPoint = filterSelectStackView.convert(point, from: self)
+        if let hitView = filterSelectStackView.hitTest(convertedPoint, with: event) {
+            return hitView
+        }
+        return nil
+    }
+    
+    func calculateStackViewHeight() {
+        guard filterStackViewContentHeight == 0 else { return }
+        var totalHeight: CGFloat = 0
+        
+        let maxIndex = filterList.indices.max { filterList[$0].title.count < filterList[$1].title.count }
+        for (index,subview) in filterSelectStackView.arrangedSubviews.enumerated() {
+            if index == maxIndex {
+                let maxWidth = subview.intrinsicContentSize.width > filterTitleButton.intrinsicContentSize.width ? subview.intrinsicContentSize.width : filterTitleButton.intrinsicContentSize.width
+                filterTitleButton.snp.updateConstraints { make in
+                    make.width.equalTo(maxWidth)
+                }
+            }
+            totalHeight += subview.intrinsicContentSize.height
+        }
+        
+        filterStackViewContentHeight = totalHeight
+    }
+    
+    private func dropDownFilterView() {
+        isExpanded.toggle()
+        isExpanded ? expandDropDown() : hideDropDown()
+    }
+    
+    private func expandDropDown() {
+        filterTitleButton.roundCorners(cornerRadius: 0, corner: .bottom)
+        filterTitleButton.roundCorners(cornerRadius: 10, corner: .top)
+        
+        filterSelectStackView.snp.updateConstraints { make in
+            make.height.equalTo(filterStackViewContentHeight)
+        }
+        
+        self.filterSelectStackView.subviews.forEach {
+            let button = $0 as! FilterButton
+            button.titleLabel?.alpha = 1
+        }
+
+        // 화살표 아래 방향으로 회전
+        UIView.animate(withDuration: 0.18) {
+            self.filterTitleButton.imageView?.transform = CGAffineTransform(rotationAngle: .pi)
+            self.layoutIfNeeded()
+        }
+    }
+    
+    private func hideDropDown() {
+        filterSelectStackView.snp.updateConstraints { make in
+            make.height.equalTo(0)
+        }
+        
+        filterSelectStackView.subviews.forEach  {
+            let button = $0 as! FilterButton
+            button.titleLabel?.alpha = 0
+        }
+        
+        UIView.animate(withDuration: 0.18) {
+            self.filterTitleButton.imageView?.transform = .identity
+            self.layoutIfNeeded()
+        } completion: { _ in
+            self.filterTitleButton.roundCorners(cornerRadius: 10, corner: .all)
+            self.layoutIfNeeded()
+        }
+    }
+    
+    private func setFilterView() {
+        // 필터 목록 생성
+        filterSelectStackView.subviews.forEach { $0.removeFromSuperview() }
+        
+        for (index, filter) in filterList.enumerated() {
+            let button = FilterButton(filterType: filter)
+            
+            if index == 0 {
+                button.updateColor(isSelected: true)
+            }
+            
+            // 마지막 버튼만 하단 모서리를 둥글게
+            if index == filterList.count - 1 { button.roundCorners(cornerRadius: 10, corner: .bottom) }
+            
+            button.rx.tap
+                .withUnretained(self)
+                .subscribe { owner, _ in
+                    owner.hideDropDown()
+                    owner.clearButtonSelectedColor()
+                    button.updateColor(isSelected: true)
+                    owner.filterTitleButton.updateTitle(title: filter.title)
+                    owner.filterActionRelay.accept(filter.action)
+                }
+                .disposed(by: disposeBag)
+            filterSelectStackView.addArrangedSubview(button)
+        }
+    }
+    
+    private func clearButtonSelectedColor() {
+        filterSelectStackView.subviews.forEach { button in
path: juinjang/Projects/App/Sources/Components/CustomView/DropDownView/DropDownView.swift
user: login: kangddong
body: 이런 코드 작성할 때 뭔가 불편하지않나요 ? 저는 고민이 많아요 ! ㅜㅜ
위에서 FilterButton만 논리적으로 추가했기 때문에 as! 통해 강제로 캐스팅을 해도되는 경우인데
괜한 걱정감에 compactMap를 활용하여 한번 더 변환할 때가 있는거같아요

성능과 안정성에서 트레이드오프가 발생한다고 생각하는데, 유즈는 어떻게 생각하시나요 ?
created_at: 2025-03-14T03:07:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+//
+//  LookAroundDropDownView.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxRelay
+import RxSwift
+
+final class LookAroundDropDownView: UIView {
path: juinjang/Projects/App/Sources/Components/CustomView/DropDownView/LookAroundDropDownView.swift
user: login: yuzzin0121
body: 앗 테스트 프로젝트의 코드를 옮기는 과정에서 BaseView 상속하는 것을 깜빡했네요! 수정하도록 하겠습니다. 체크해주셔서 감사해요!🙏🏻
created_at: 2025-03-14T08:34:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+//
+//  LookAroundDropDownView.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxRelay
+import RxSwift
+
+final class LookAroundDropDownView: UIView {
+    private let sortDropDownView = DropDownView(filterList: SortFilter.allCases)
+    private let transactionTypeDropDownView = DropDownView(filterList: TransactionTypeFilter.allCases)
+    private let saleTypeDropDownView = DropDownView(filterList: SaleTypeFilter.allCases)
+
+    let sortActionRelay = PublishRelay<SortAction>()
+    let transactionTypeActionRelay = PublishRelay<TransactionTypeAction>()
+    let saleTypeActionRelay = PublishRelay<SaleTypeAction>()
+    
+    private var disposeBag = DisposeBag()
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        configureHierarchy()
+        configureLayout()
+        configureView()
+        bind()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func bind() {
+        sortDropDownView.filterActionRelay
path: juinjang/Projects/App/Sources/Components/CustomView/DropDownView/LookAroundDropDownView.swift
user: login: yuzzin0121
body: 저도 코드를 짜면서 강제 타입 캐스팅하는 부분이 가장 걸렸었는데 compactMap을 사용하면 옵셔널 해제해줘서 깔끔하고 안정적이게 되네요. 의견 주셔서 감사합니다. 이제 compactMap을 적극 활용할 수 있을 것 같아요!
created_at: 2025-03-14T08:36:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+//
+//  LookAroundDropDownView.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxRelay
+import RxSwift
+
+final class LookAroundDropDownView: UIView {
+    private let sortDropDownView = DropDownView(filterList: SortFilter.allCases)
+    private let transactionTypeDropDownView = DropDownView(filterList: TransactionTypeFilter.allCases)
+    private let saleTypeDropDownView = DropDownView(filterList: SaleTypeFilter.allCases)
+
+    let sortActionRelay = PublishRelay<SortAction>()
path: juinjang/Projects/App/Sources/Components/CustomView/DropDownView/LookAroundDropDownView.swift
user: login: yuzzin0121
body: 맞습니다. 기획적인 부분도 고려하면서 구현했던 부분입니다! 처음 임장 둘러보기 화면 진입 시 초기(Default) 임장 리스트를 불러오는 걸생각하고, viewDidLoad했을 때는 한 번의 API를 요청한다고 가정하고 구현하게 되었습니다. 
이 부분은 기획팀에 한 번 더 문의를 해봐야겠네요! 짚어주셔서 감사합니다.

BehaviorRelay를 사용해서 구현한다면, skip 오퍼레이터를 활용하여 처음 데이터는 건너뛰도록 하는 방법도 있겠네요!
초기값을 가지면서, skip을 활용하는 방법은 어떻게 생각하시는지 궁금합니다!
created_at: 2025-03-14T08:44:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,191 @@
+//
+//  DropDownView.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxSwift
+import RxRelay
+import RxCocoa
+
+final class DropDownView<T: LookAroundFilterType & RawRepresentable>: UIView where T.RawValue == String {
+    private lazy var filterTitleButton = FilterTitleButton(title: filterList[0].title)
+    
+    // 펼칠 목록(필터 메뉴)을 쌓아둘 스택뷰
+    private let filterSelectStackView: UIStackView = {
+        let stackView = UIStackView()
+        stackView.axis = .vertical
+        stackView.alignment = .fill
+        stackView.distribution = .fill
+        stackView.spacing = 0
+        return stackView
+    }()
+    
+    private var filterStackViewContentHeight: CGFloat = 0
+    
+    private var disposeBag = DisposeBag()
+    private var isExpanded = false  // 현재 펼쳐진 상태인지 여부
+    let filterActionRelay = PublishRelay<LookAroundFilterActionType>()
+    private var filterList: [T]
+    
+    init(filterList: [T]) {
+        self.filterList = filterList
+        super.init(frame: .zero)
+        configureHierarchy()
+        configureLayout()
+        configureView()
+        setFilterView()
+        bind()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func bind()  {
+        filterTitleButton.rx.tap
+            .bind(with: self) { owner, _ in
path: juinjang/Projects/App/Sources/Components/CustomView/DropDownView/DropDownView.swift
user: login: yuzzin0121
body: 기존에 view로 버튼 구현했을 때 RxGesture를 적용했던 코드라서 수정하는 것을 놓쳤네요! 감사합니다. 수정하겠습니다!
created_at: 2025-03-14T08:45:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,191 @@
+//
+//  DropDownView.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxSwift
+import RxRelay
+import RxCocoa
+
+final class DropDownView<T: LookAroundFilterType & RawRepresentable>: UIView where T.RawValue == String {
+    private lazy var filterTitleButton = FilterTitleButton(title: filterList[0].title)
+    
+    // 펼칠 목록(필터 메뉴)을 쌓아둘 스택뷰
+    private let filterSelectStackView: UIStackView = {
+        let stackView = UIStackView()
+        stackView.axis = .vertical
+        stackView.alignment = .fill
+        stackView.distribution = .fill
+        stackView.spacing = 0
+        return stackView
+    }()
+    
+    private var filterStackViewContentHeight: CGFloat = 0
+    
+    private var disposeBag = DisposeBag()
+    private var isExpanded = false  // 현재 펼쳐진 상태인지 여부
+    let filterActionRelay = PublishRelay<LookAroundFilterActionType>()
+    private var filterList: [T]
+    
+    init(filterList: [T]) {
+        self.filterList = filterList
+        super.init(frame: .zero)
+        configureHierarchy()
+        configureLayout()
+        configureView()
+        setFilterView()
+        bind()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func bind()  {
+        filterTitleButton.rx.tap
+            .bind(with: self) { owner, _ in
+                owner.dropDownFilterView()
+            }
+            .disposed(by: disposeBag)
+    }
+    
+    private func configureHierarchy() {
+        addSubview(filterTitleButton)
+        addSubview(filterSelectStackView)
+    }
+    
+    private func configureLayout() {
+        filterTitleButton.snp.makeConstraints { make in
+            make.leading.top.bottom.equalToSuperview()
+            make.width.equalTo(0)
+            make.trailing.equalToSuperview()
+        }
+        filterSelectStackView.snp.makeConstraints { make in
+            make.top.equalTo(filterTitleButton.snp.bottom)
+            make.horizontalEdges.equalTo(filterTitleButton)
+            make.height.equalTo(0)
+        }
+    }
+    
+    private func configureView() {
+    }
+    
+    override func layoutSubviews() {
+        super.layoutSubviews()
+        calculateStackViewHeight()
+    }
+    
+    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
+        // 우선, 기본 hitTest 결과를 확인
+        if let hitView = super.hitTest(point, with: event) {
+            return hitView
+        }
+        
+        // DropDownView의 bounds 밖이라도 filterSelectStackView에 터치가 있는지 확인
+        let convertedPoint = filterSelectStackView.convert(point, from: self)
+        if let hitView = filterSelectStackView.hitTest(convertedPoint, with: event) {
+            return hitView
+        }
+        return nil
+    }
+    
+    func calculateStackViewHeight() {
+        guard filterStackViewContentHeight == 0 else { return }
+        var totalHeight: CGFloat = 0
+        
+        let maxIndex = filterList.indices.max { filterList[$0].title.count < filterList[$1].title.count }
+        for (index,subview) in filterSelectStackView.arrangedSubviews.enumerated() {
+            if index == maxIndex {
+                let maxWidth = subview.intrinsicContentSize.width > filterTitleButton.intrinsicContentSize.width ? subview.intrinsicContentSize.width : filterTitleButton.intrinsicContentSize.width
+                filterTitleButton.snp.updateConstraints { make in
+                    make.width.equalTo(maxWidth)
+                }
+            }
+            totalHeight += subview.intrinsicContentSize.height
+        }
+        
+        filterStackViewContentHeight = totalHeight
+    }
+    
+    private func dropDownFilterView() {
+        isExpanded.toggle()
+        isExpanded ? expandDropDown() : hideDropDown()
+    }
+    
+    private func expandDropDown() {
+        filterTitleButton.roundCorners(cornerRadius: 0, corner: .bottom)
+        filterTitleButton.roundCorners(cornerRadius: 10, corner: .top)
+        
+        filterSelectStackView.snp.updateConstraints { make in
+            make.height.equalTo(filterStackViewContentHeight)
+        }
+        
+        self.filterSelectStackView.subviews.forEach {
+            let button = $0 as! FilterButton
+            button.titleLabel?.alpha = 1
+        }
+
+        // 화살표 아래 방향으로 회전
+        UIView.animate(withDuration: 0.18) {
+            self.filterTitleButton.imageView?.transform = CGAffineTransform(rotationAngle: .pi)
+            self.layoutIfNeeded()
+        }
+    }
+    
+    private func hideDropDown() {
+        filterSelectStackView.snp.updateConstraints { make in
+            make.height.equalTo(0)
+        }
+        
+        filterSelectStackView.subviews.forEach  {
+            let button = $0 as! FilterButton
+            button.titleLabel?.alpha = 0
+        }
+        
+        UIView.animate(withDuration: 0.18) {
+            self.filterTitleButton.imageView?.transform = .identity
+            self.layoutIfNeeded()
+        } completion: { _ in
+            self.filterTitleButton.roundCorners(cornerRadius: 10, corner: .all)
+            self.layoutIfNeeded()
+        }
+    }
+    
+    private func setFilterView() {
+        // 필터 목록 생성
+        filterSelectStackView.subviews.forEach { $0.removeFromSuperview() }
+        
+        for (index, filter) in filterList.enumerated() {
+            let button = FilterButton(filterType: filter)
+            
+            if index == 0 {
+                button.updateColor(isSelected: true)
+            }
+            
+            // 마지막 버튼만 하단 모서리를 둥글게
+            if index == filterList.count - 1 { button.roundCorners(cornerRadius: 10, corner: .bottom) }
+            
+            button.rx.tap
path: juinjang/Projects/App/Sources/Components/CustomView/DropDownView/DropDownView.swift
user: login: yuzzin0121
body: 이 부분도 drive 사용해서 수정하겠습니다!

채드 말씀대로 UI처리와 .accept 호출부를 분리하면 코드가 줄어서 관리하기 쉬울 것 같네요! 분리해보겠습니다 감사합니다 😃
created_at: 2025-03-14T08:48:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,55 @@
+//
+//  FilterButton.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxSwift
+import RxRelay
+
+final class FilterButton: UIButton {
+    private var padding = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
+    private var disposeBag = DisposeBag()
+    
+    init(filterType: LookAroundFilterType) {
+        super.init(frame: .zero)
+        configureView(filterType: filterType)
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func configureView(filterType: LookAroundFilterType) {
+        let title = filterType.rawValue
+        setTitle(title, for: .normal)
+        titleLabel?.setAttribute(text: title, color: .gray300, font: .pretendard(size: 14, weight: .medium))
+        setTitleColor(.gray300, for: .normal)
+        backgroundColor = .white
+        contentEdgeInsets = UIEdgeInsets(top: 0, left: padding.left, bottom: 0, right: padding.right)
+        contentHorizontalAlignment = .leading
+        titleLabel?.alpha = 0
+        layoutIfNeeded()
+    }
+    
+    func updateColor(isSelected: Bool) {
+        print(#function)
path: juinjang/Projects/App/Sources/Components/CustomView/DropDownView/FilterButton.swift
user: login: yuzzin0121
body: 수정하겠습니다.
확인 감사합니다!
created_at: 2025-03-14T08:52:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,191 @@
+//
+//  DropDownView.swift
+//  juinjang
+//
+//  Created by 조유진 on 3/13/25.
+//
+
+import UIKit
+import RxSwift
+import RxRelay
+import RxCocoa
+
+final class DropDownView<T: LookAroundFilterType & RawRepresentable>: UIView where T.RawValue == String {
+    private lazy var filterTitleButton = FilterTitleButton(title: filterList[0].title)
+    
+    // 펼칠 목록(필터 메뉴)을 쌓아둘 스택뷰
+    private let filterSelectStackView: UIStackView = {
+        let stackView = UIStackView()
+        stackView.axis = .vertical
+        stackView.alignment = .fill
+        stackView.distribution = .fill
+        stackView.spacing = 0
+        return stackView
+    }()
+    
+    private var filterStackViewContentHeight: CGFloat = 0
+    
+    private var disposeBag = DisposeBag()
+    private var isExpanded = false  // 현재 펼쳐진 상태인지 여부
+    let filterActionRelay = PublishRelay<LookAroundFilterActionType>()
+    private var filterList: [T]
+    
+    init(filterList: [T]) {
+        self.filterList = filterList
+        super.init(frame: .zero)
+        configureHierarchy()
+        configureLayout()
+        configureView()
+        setFilterView()
+        bind()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func bind()  {
+        filterTitleButton.rx.tap
+            .bind(with: self) { owner, _ in
+                owner.dropDownFilterView()
+            }
+            .disposed(by: disposeBag)
+    }
+    
+    private func configureHierarchy() {
+        addSubview(filterTitleButton)
+        addSubview(filterSelectStackView)
+    }
+    
+    private func configureLayout() {
+        filterTitleButton.snp.makeConstraints { make in
+            make.leading.top.bottom.equalToSuperview()
+            make.width.equalTo(0)
+            make.trailing.equalToSuperview()
+        }
+        filterSelectStackView.snp.makeConstraints { make in
+            make.top.equalTo(filterTitleButton.snp.bottom)
+            make.horizontalEdges.equalTo(filterTitleButton)
+            make.height.equalTo(0)
+        }
+    }
+    
+    private func configureView() {
+    }
+    
+    override func layoutSubviews() {
+        super.layoutSubviews()
+        calculateStackViewHeight()
+    }
+    
+    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
+        // 우선, 기본 hitTest 결과를 확인
+        if let hitView = super.hitTest(point, with: event) {
+            return hitView
+        }
+        
+        // DropDownView의 bounds 밖이라도 filterSelectStackView에 터치가 있는지 확인
+        let convertedPoint = filterSelectStackView.convert(point, from: self)
+        if let hitView = filterSelectStackView.hitTest(convertedPoint, with: event) {
+            return hitView
+        }
+        return nil
+    }
+    
+    func calculateStackViewHeight() {
+        guard filterStackViewContentHeight == 0 else { return }
+        var totalHeight: CGFloat = 0
+        
+        let maxIndex = filterList.indices.max { filterList[$0].title.count < filterList[$1].title.count }
+        for (index,subview) in filterSelectStackView.arrangedSubviews.enumerated() {
+            if index == maxIndex {
+                let maxWidth = subview.intrinsicContentSize.width > filterTitleButton.intrinsicContentSize.width ? subview.intrinsicContentSize.width : filterTitleButton.intrinsicContentSize.width
+                filterTitleButton.snp.updateConstraints { make in
+                    make.width.equalTo(maxWidth)
+                }
+            }
+            totalHeight += subview.intrinsicContentSize.height
+        }
+        
+        filterStackViewContentHeight = totalHeight
+    }
+    
+    private func dropDownFilterView() {
+        isExpanded.toggle()
+        isExpanded ? expandDropDown() : hideDropDown()
+    }
+    
+    private func expandDropDown() {
+        filterTitleButton.roundCorners(cornerRadius: 0, corner: .bottom)
+        filterTitleButton.roundCorners(cornerRadius: 10, corner: .top)
+        
+        filterSelectStackView.snp.updateConstraints { make in
+            make.height.equalTo(filterStackViewContentHeight)
+        }
+        
+        self.filterSelectStackView.subviews.forEach {
+            let button = $0 as! FilterButton
+            button.titleLabel?.alpha = 1
+        }
+
+        // 화살표 아래 방향으로 회전
+        UIView.animate(withDuration: 0.18) {
+            self.filterTitleButton.imageView?.transform = CGAffineTransform(rotationAngle: .pi)
+            self.layoutIfNeeded()
+        }
+    }
+    
+    private func hideDropDown() {
+        filterSelectStackView.snp.updateConstraints { make in
+            make.height.equalTo(0)
+        }
+        
+        filterSelectStackView.subviews.forEach  {
+            let button = $0 as! FilterButton
+            button.titleLabel?.alpha = 0
+        }
+        
+        UIView.animate(withDuration: 0.18) {
+            self.filterTitleButton.imageView?.transform = .identity
+            self.layoutIfNeeded()
+        } completion: { _ in
+            self.filterTitleButton.roundCorners(cornerRadius: 10, corner: .all)
+            self.layoutIfNeeded()
+        }
+    }
+    
+    private func setFilterView() {
+        // 필터 목록 생성
+        filterSelectStackView.subviews.forEach { $0.removeFromSuperview() }
+        
+        for (index, filter) in filterList.enumerated() {
+            let button = FilterButton(filterType: filter)
+            
+            if index == 0 {
+                button.updateColor(isSelected: true)
+            }
+            
+            // 마지막 버튼만 하단 모서리를 둥글게
+            if index == filterList.count - 1 { button.roundCorners(cornerRadius: 10, corner: .bottom) }
+            
+            button.rx.tap
+                .withUnretained(self)
+                .subscribe { owner, _ in
+                    owner.hideDropDown()
+                    owner.clearButtonSelectedColor()
+                    button.updateColor(isSelected: true)
+                    owner.filterTitleButton.updateTitle(title: filter.title)
+                    owner.filterActionRelay.accept(filter.action)
+                }
+                .disposed(by: disposeBag)
+            filterSelectStackView.addArrangedSubview(button)
+        }
+    }
+    
+    private func clearButtonSelectedColor() {
+        filterSelectStackView.subviews.forEach { button in
path: juinjang/Projects/App/Sources/Components/CustomView/DropDownView/DropDownView.swift
user: login: yuzzin0121
body: 채드의 의견을 들어보니 안정성에 대해 깊이 생각해보게 되는 것 같습니다.
혹시라도 팀원이 filterSelectStackView에 다른 타입의 뷰를 추가하거나 유지보수하는 과정에서 충돌이 생길 가능성도 고려해서 구현해야되겠네요!

compactMap을 사용한다고 해서 성능적으로 큰 문제가 되지 않으니,
안정성 쪽에 무게를 더 두면서 구현해도 괜찮을 것 같다는 생각이 들었습니다.

채드의 의견대로 compactMap으로 변환하여 수정하겠습니다.
같이 고민해주셔서 감사드려요!
created_at: 2025-03-14T08:59:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: ornwoo96
created_at: 2025-03-14T13:08:10Z
body: 고생하셨습니다 @yuzzin0121 
author_association: CONTRIBUTOR
user: login: ornwoo96
created_at: 2025-03-14T13:08:40Z
body: hitTest 배워갑니다!
author_association: CONTRIBUTOR