안녕하세요 킴스캐슬입니다~
오늘은 2~3주전부터 관심이 있었던 주제에 대해 글을 적어보려합니다
바로 Swift Macro
입니다
제가 아는 매크로는 이런거 밖에 없는데요...?
솔직히 저는 WWDC에서 swift macro라는게 소개 되었는줄도 몰랐었는데요...
https://sujinnaljin.medium.com/swift-%EB%A7%A4%ED%81%AC%EB%A1%9C-5e232b78dc5b
제가 정말 좋아하는 개발자분의 글을 보고 이런 기능이 swift에 추가되었구나
라는 것 정도만 알고 있었습니다
물론 WWDC를 보고 위의 링크에 있는 swift macro에 대한 기술적인 내용을 공부하기도 해야하고 그거에 대한 기술 글도 작성을 하겠지만 만약, swift macro를 사용할 줄 아는 iOS개발자가 된다면 어떤 장점이 생길까에 대한 내용으로 swift macro 시리즈를 열어보려합니다
왜냐면 솔직히 너무 어려웠거든요... swift macro라는 기술 자체에 대한 이해를 하기에는 1차적인 흥미가 떨어지는 느낌이 너무 많이들었습니다. 다만, swift macro를 사용하면 어떤 편리함과 생산성의 향상이 될까를 미리 알면 그 어려운 swift macro를 공부할 동기가 부여되지 않을까라는 생각이 들었습니다
WWDC 영상에서 자주쓰는 전략이기도합니다. 왜 써야하는지, 쓰면 뭐가 좋을지를 먼저 알려주고 그다음에 기술적인 이야기를 들어가는 전략이죠. 저도 한번 그 전략을 써볼까합니다
기술적인 이야기는 다음 포스팅에서 할거니까 오늘은 편안한 마음으로 swift macro를 쓰면 이런게 좋겠구만?
이라는 느낌을 드릴 수 있도록 노력해보겠습니다
(오 쩐다...)
제가 느끼는 편리성
? 중의 하나가 될 수 있는 예시를 들어보려고 합니다
아마도 개인 사이드프로젝트를 제외하면 iOS버전이 높은 실제 프로덕트들이 거의 없지 않을까 싶습니다. 이 이야기는 UIkit으로 시작되었던 프로덕트가 swift버전이 올라가고 iOS버전을 높이면서 swiftUI라던가 swift concurrency를 차츰차츰 도입하고 있는 경우가 훨씬더 많을거라고 생각됩니다
예를들어 회사의 기존 프로젝트가 swift concurrency라는 개념이 생기기전에 시작된 프로덕트라면 대부분의 비동기 코드는 completionHandler기반의 trailing closure형태로 작성되었을겁니다
그런 API코드가 적게는 몇 십개 많게는 몇 백개가 있겠죠?
그리고 신규 기능을 추가해야하는 현 시점의 개발자를 상상해보겠습니다. 신규기능을 개발하기 위해 API를 확인하는데 기존의 API를 재사용해야하는 상황입니다. 근데 저는 swiftUI로 코드를 작성하고 있으며 swift concurrency를 사용하려고 마음먹었다고 해보겠습니다
두가지 선택지가 있을겁니다
- async/await형태의 비동기 코드를 새로짠다
- 기존에 사용하고 있는 completionHandler기반의 코드를 withContinuaion을 활용해 async/await코드로 변환해서 사용한다
사실 1번이나 2번이나 결과적으로는 async/await형태의 API호출 메서드가 추가되는것은 같습니다. 그 내부적으로 기존의 코드를 활용할거냐 아닐거냐의 차이정도만 있을 뿐이죠
자, 이런 상황이 매번 발생된다고 했을때 그때마다 async/await형태의 API호출 메서드
를 계속 해서 추가해줘야할겁니다. 코드 자체도 많아질거고 계속되는 반복 노가다 작업을 해야합니다
(아마도 제일 유명할 노가다 짤.jpg)
우리 이런거 안하려고 개발자하는거잖아요~
func test(arg1: String, completion: (String) -> Void) {...}
이런 코드를 매번 continuation 혹은 새로운 async/await으로 만들어줘야한다는거죠
swift macro를 활용하면 새로운 async/await코드를 추가적으로 작성할 필요없이 함수 위에 @AddAsync
라는 swift macro를 아래처럼 달아주기만 하면
@AddAsync
func test(arg1: String, completion: (String) -> Void) {...}
아래와 같은 코드가 자동적으로 macro로 생성되고
func test(arg1: String) async -> String {
await withCheckedContinuation { continuation in
self.test(arg1: String) { object in
continuation.resume(returning: object)
}
}
}
우리는 그냥 async/await으로 함수를 가져다 쓰기만 하면됩니다
기존 completionHandler형태의 API함수가 많아지면 많아질수록 macro는 더 큰 편리함을 가져다줄겁니다
singleton에 대한 단점을 이야기하는 개발자분이 많지만 여전히 많이 쓰이고 있고 data race의 예방 대책만 명확하다면 근본 디자인패턴인 만큼 꽤나 유용한 녀석입니다
내 프로젝트에 singleton이 전혀 없는 사람보단 하나라도 있는 프로젝트에 기여하고 계신 개발자 분이 많을것같습니다. 오래된 프로젝트에 기여했을수록 말이죠
swift에서는 싱글톤을 위해서 몇가지 코드를 필수적으로 포함시켜야합니다
static let shared = 싱글톤객체()
private init() {}
위 두가지 코드입니다
근데 만약에 우리가 macro를 사용할 줄 알게된다면 이런 패턴적이고(현재 객체를 shared에 할당해주면된다는 패턴) 반복적인 코드를 macro로 해결할 수있게됩니다. 아래같이 말이죠
@Singleton
class 싱글톤객체 {}
또 뭐가 있을까요?
제가 생각했을때는 tableviewcell이나 collectionviewcell에 보통 static으로 cell의 reuseIdentifier로 객체 이름으로 넣어놓고 register할때나 cell을 dequeue할때 쓸텐데요
물론 이걸 NSObject의 상속같은걸로 해결할 수도 있겠지만 매크로를 통해서 무조건 이 매크로를 채택한 객체에는 static let reuseIdentifier = "객체이름"
을 넣어주게 한다면
@ReuseIdentifier
class CarouselCollectionViewCell: UICollectionViewCell {}
이렇게 macro를 채택만해도
collectionView.register(
CarouselCollectionViewCell.self,
forCellWithReuseIdentifier: CarouselCollectionViewCell.reuseIdentifier
)
이렇게 사용할 수 있을겁니다
자~ 상상의 나래를 펼쳐볼까요?
자 그러면 이제는 구글링하면 나오는 예시들 말고 이런거 macro로 할 수있으면 좋지 않을까?라는것들을 상상해보겠습니다
여기서 말하는 예시들은 구현 가능성에 대한 검증은 되지 않은 말그대로
macro로 이런것도 편하게 할 수있으면 좋지 않을까?
에 대한 내용들입니다. 구현 시도는 해보겠지만 100퍼센트 가능한 예시는 아닐 수 있음을 알려드립니다
제가 최근에 개발하면서 API가 실제로 나오기전에 protocol을 활용해서 mock usecase를 먼저만들고 추후에 API가 나오면 실제 usecase를 붙여서 개발을 마무리하는 방식으로 병렬적 개발을 진행하고 있는데요
그때마다 mock데이터 만드는게 너무 뭐랄까... 귀찮았습니다
string이면 의미없는 "목데이터string입니다"를 넣고 Int면 그냥 아무 숫자나 넣고 imageUrl이라면 아무 이미지 url을 넣어서 mock데이터를 만들었습니다. 여러개가 필요하면 mocks라는 변수를 만들어서 list형태로 mock데이터를 하나하나 넣어줬습니다
macro에 대해서 알아보면서 이런거 뭔가 가능하지 않을까?라는 생각이 먼저들었습니다
뭐, 아직 macro에 대해 공부하기 전이니 제 아이디어를 ChatGPT에게 살짝 귀띔해주고 가능할지 여부를 물어나 봅시다
저는 이렇게 물어봤습니다
struct Car {
var name: String
var seats: Int
}
이런 구조체에 매크로를 추가해서
extension Car {
static var mock: Car {
Car(name: "Mock Car", seats: 4)
}
// Defaults to [mock] but we can
// provide a custom array of cars:
static var mocks: [Car] {
[
Car(name: "Mock Car 1", seats: 4),
Car(name: "Mock Car 2", seats: 4)
]
}
}
이렇게 생성되게 swiftMacro를 만들어줄 수 있어???
그랬더니 이런 코드를 쓰면된다고하네요
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
/// `@Mockable` 매크로를 구현
public struct MockMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard let structDecl = declaration.as(StructDeclSyntax.self) else {
throw MacroError.message("@Mockable can only be applied to structs")
}
let structName = structDecl.name.text
let mockProperty = """
static var mock: \(structName) {
\(structName)(name: "Mock \(structName)", seats: 4)
}
"""
let mocksProperty = """
static var mocks: [\(structName)] {
[
\(structName)(name: "Mock \(structName) 1", seats: 4),
\(structName)(name: "Mock \(structName) 2", seats: 4)
]
}
"""
return [
DeclSyntax(SyntaxFactory.makeVariableDecl(mockProperty)),
DeclSyntax(SyntaxFactory.makeVariableDecl(mocksProperty))
]
}
}
그러면 최종적으로는 아래와 같이 사용할 수 있다고 합니다
@Mockable
struct Car {
var name: String
var seats: Int
}
// 자동으로 생성된 프로퍼티 사용
let singleMock = Car.mock
let multipleMocks = Car.mocks
print(singleMock) // Car(name: "Mock Car", seats: 4)
print(multipleMocks) // [Car(name: "Mock Car 1", seats: 4), Car(name: "Mock Car 2", seats: 4)]
근데 이러면 Car일때만 사용할 수있잖아요
그래서 각 변수의 타입마다 기본값을 설정해주고 그값을 넣어서 mock을 만들어달라고 했습니다
그랬더니 아래와같이 수정을 해주네요
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
public struct MockMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard let structDecl = declaration.as(StructDeclSyntax.self) else {
throw MacroError.message("@Mockable can only be applied to structs")
}
let structName = structDecl.name.text
var properties: [String] = []
for member in structDecl.memberBlock.members {
if let varDecl = member.decl.as(VariableDeclSyntax.self),
let binding = varDecl.bindings.first,
let identifier = binding.pattern.as(IdentifierPatternSyntax.self),
let typeAnnotation = binding.typeAnnotation {
let propertyName = identifier.identifier.text
let propertyType = typeAnnotation.type
let defaultValue = defaultValue(for: propertyType)
properties.append("\(propertyName): \(defaultValue)")
}
}
let propertiesString = properties.joined(separator: ",\n ")
let mockProperty = """
static var mock: \(structName) {
\(structName)(
\(propertiesString)
)
}
"""
let mocksProperty = """
static var mocks: [\(structName)] {
[
\(structName)(
\(propertiesString)
),
\(structName)(
\(propertiesString)
)
]
}
"""
return [
DeclSyntax(SyntaxFactory.makeVariableDecl(mockProperty)),
DeclSyntax(SyntaxFactory.makeVariableDecl(mocksProperty))
]
}
}
그럼 아래와같이 사용할 수 있다고 합니다
@Mockable
struct Car {
var name: String
var seats: Int
var isElectric: Bool
var price: Double
var id: UUID
var optionalFeature: String?
}
// 자동 생성된 mock 프로퍼티 사용 예시
print(Car.mock)
/*
Car(
name: "Mock String",
seats: 0,
isElectric: false,
price: 0.0,
id: UUID(),
optionalFeature: nil
)
*/
print(Car.mocks)
/*
[
Car(name: "Mock String", seats: 0, isElectric: false, price: 0.0, id: UUID(), optionalFeature: nil),
Car(name: "Mock String", seats: 0, isElectric: false, price: 0.0, id: UUID(), optionalFeature: nil)
]
*/
여기서 새로운 기능을 추가할 수도 있겠지만 대충 이렇게 하면 될 수도(?)있다고 합니다. 진짜 되는지 안되는지는 macro를 공부해보고 도전해보겠습니다
swiftUI로 개발을 하다보면 정말 많은 Modifier를 만들게됩니다. 그리고 애플에서 추천하는 방법은 Modifier객체를 만들고 View의 Extension을 통해서 modifier메서드를 만드는 방법이죠
근데 이게 문제라면 문제일 수 있는게 누군가는 Modifier객체와 extension을 아얘다른 파일로 만들기도하고 누군가는 한 파일 안에 넣어놓습니다
취향차이일수도있고 생각차이일 수도 있지만 만약에 Modifier를 채택한 객체에 macro를 추가해서 view extension 코드를 자동으로 추가해줄 수 있다면 코드의 파편화 문제를 해결해줄 수 있지 않을까요? 게다가 사실 view extension으로 만드는 코드는 그냥 같은 코드를 추가하는것 뿐이거든요 많이지면 이것도 반복적인 코드를작성해야합니다
이번에도 chatGPT에게 도움을 요청해보겠습니다
swiftUI viewmodifier의 extension코드를 자동으로 macro로 만들어줄순없을까?
이렇게 물어보니 기존에는 어떻게 작업했는지를 말해줍니다. 정확해요
struct MyCustomModifier: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(Color.blue)
.cornerRadius(10)
}
}
extension View {
func myCustomStyle() -> some View {
self.modifier(MyCustomModifier())
}
}
기존에는 ViewModifier를 만든 후, View의 extension을 추가해야 했음.
이런 상황일때 macro를 사용한다면?
@ModifierMacro
struct MyCustomModifier: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(Color.blue)
.cornerRadius(10)
}
}
이렇게 하는것만으로도 사용이 가능하다라는거죠. 실제로 가능한지여부는 아직 공부가 부족해서 모르겠지만 차차 공부해나가다 보면 더 알게되지 않을까 싶네요
개발을 하다보면 문제라고 인식할만한 상황을 자주 마주하게됩니다. 저에게는 생각보다 생산성
이라는 측면에서 문제를 발견하고 그 문제를 해결하기위한 다양한 시도를 하는걸 좋아합니다. 그리고 그 시도의 기본은 상상력이라고 생각합니다. 말 그대로 상상을 해보는거죠 이렇게 하면 어떨까?
, 저렇게 하면 어떨까?
같은 상상들 말이죠. 그리고 그런 상상력들을 실제로 만들어나가는것이 개발자의 역할 중 하나라고 생각합니다. iOS개발자에게 그 상상을 현실로 만들어주는 도구중 하나가 swift macro가 될 수 있다는 생각을 가지게 되었습니다
실제로 우리가 swiftUI로 viewmodel을 만들어서 mvvm으로 사용할때(swiftUI로 mvvm을 해야한다 하지말아야한다의 이야기는 아닙니다~) ObservedObject를 채택한 객체내부의 변수들을 @Published로 감싸는게 불편해서 @Observed라는 macro를 apple에서 만들어서 @Published를 매번 붙이지 않고도 @Published를 사용하게만들어서 코드의 중복, 귀찮은 작업을 없앤것도 그러한 불편함을 해결할 수 있는 여러 상상중에 하나를 macro로 실현했을거니까요
macro의 장점은 여러가지가 있지만 제가 느끼는 공부해볼만 가치가 있다고 여기는 가장 부분은 swift macro는 iOS버전이 아닌 swift버전만 충족한다면 사용이 가능하다는 부분입니다
이게 아무래도 회사 프로젝트는 iOS버전을 보수적으로 설정할 수 밖에 없고 그러다보면 iOS버전이 낮아서 못쓰는 기능들이 정말 많습니다. 다만 iOS버전이 낮아도 swift버전을 충분히 높일 수 있기 때문에 iOS버전이 낮아도 swift버전만 충분하다면 충분히 swift macro를 도입할 수 있습니다
마지막으로 제가 좋아하는 하이큐라는 애니메이션에서 나온 대사를 마지막으로 오늘 글을 마무리해보겠습니다
시시하다고 생각하진 않는데
굳이 안할 이유는 없지 않을까요? 그죠? 할줄 아는게 많아진다는건 개발자로서 기쁜일이잖아요