안녕하세요 킴스캐슬입니다:) 오랜만이네요 ㅎㅎ
오늘은 제가 프로젝트를 진행하는데 있어서 알게된 내용(맞아보고 깨달은 내용)에 대해 아티클을 적어보려합니다
제목은 위에 적은것처럼 async let이 항상 정답
은 아니다 입니다. 아니다!
라고 했지만 아닐수도 있다고 생각합니다~
가 맞을거같네요 ㅎㅎ 개발에 100%는 없으니까요
개발을 하면서 아래와 같은 상황을 마주했습니다
iOS개발을 하던 도중 기획측에서 새로운 기능을 sprint로 제안해줬습니다. 마이페이지기능이고 초기에는 내가 만든 사진들을 볼 수 있는 아주 간단한 기능만 있는 UI를 가지고 있었습니다. 그런데 개발을 막 하고 있던 와중에 기획측에서 해당 UI에 추가 기능을 붙여주기를 요청했습니다. 추가로
유저의 상태
를 확인할 수 있는 기능을 추가해주길 원했습니다. 서버단에서는내가만든사진을 조회하는 API
를 이미 만들었고유저의 상태
를 조회하는 API는 이미 존재합니다
처음에 이런 상황이 발생했을때 가장먼저 떠오른 생각은 이랬습니다
서버팀한테 한번에 내가 만든 사진하고 유저의 상태를 조회할 수 있는 API를 새로 만들어달라고 해야겠다
근데 서버팀은 다른일들이 너무 많은 상황이었고 추가 작업을 요청하기 보다는 "해당 UI에서 필요한 데이터는 총 두 종류고 API가 나눠져있긴 하지만 필요한 데이터를 각각 받아오는 API도 있으니 해당 API를 async let으로 받아오면 효율적으로 받아올 수 있지 않을까?"라는 생각이 들었습니다
간단하게 코드로 요약해보면 아래같은 코드가 나오게될겁니다
async let myImages = 내가 만든 이미지 받아오는 함수( -> 새로추가된 API)
async let myState = 유저의 상태를 조회하는 함수( -> 기존에 있던 API)
return try await (myImages, myState)
기존의 리소스를 활용하면서 추가적인 task를 요청할 필요도 없으며 swift concurrency를 활용한 효율적인 비동기처리를 할 수 있다고 생각했습니다
async let에 대해서 간단하게 설명을 하고 내용을 이어가는게 좋을거같아서 async let에 대한 설명을 잠깐 하고 넘어가겠습니다
async let를 알기 위해서는 swift concurrency의 등장 배경에 대해서 알아야합니다
기존에는 GCD를 통해서 주로 비동기작업을 진행했으나 여러가지 불편함(completion handler로 인한 가독성, thread explosion 등등)으로 인해 swift concurrency가 등장하게됩니다
swift concurrency(그중에서도 async/await)으로 인해서 비동기 코드를 마치 동기적인 코드처럼 읽을수있게되어 가독성이 좋아졌고 suspend라는 개념을 통해, thread가 계속해서 늘어나 이로인한 context switching으로 인한 오버헤드의 위험성 대신 thread의 갯수를 코어수정도로 유지하며 function call정도의 비용으로 비동기작업을 수행할 수 있게됩니다
코드를 읽기도 좋아지고 코드 뒷단에서의 효율성도 좋아졌다고 요약할 수 있습니다
기본적으로 async 코드는 동시(concurrent) 컨텍스트(context)
에서만 실행 가능합니다. 이러한 동시 context를 만들어주는것이 Task입니다. Task자체는 비동기적으로 실행되지만(동기적인 맥락에서 Task를 만나면 Task가 끝나기를 기다리지 않고 다음 코드를 실행시킴) Task 안에서의 작업은 처음부터 끝까지 순차적으로 실행됩니다
위의 말이 조금 헷갈릴 수 있으니 하나하나 차근 설명을 해보겠습니다
print("1")
Task { print("2") }
print("3")
Task자체는 비동기적으로 실행되지만(동기적인 맥락에서 Task를 만나면 Task가 끝나기를 기다리지 않고 다음 코드를 실행시킴)
의 의미는 위의 코드가 1을 출력한다음에 Task를 만나면 바로 task가 끝나기를(2가 출력되기를) 기다리지 않고 바로 다음 코드인 3을 출력한다는 의미입니다. 그래서 결과가 132가 출력됩니다
Task 안에서의 작업은 처음부터 끝까지 순차적으로 실행됩니다
의 의미는 Task내부의 코드는 순서대로 작동된다는 의미입니다
print("1")
Task {
print("2")
print("3")
}
print("4")
이 코드를 실행시키면 1423이라는 결과가 출력될텐데 2가 출력되고나서야 3이 출력됩니다
즉 print("3")이라는 코드는 print("2")가 완료된 후에 실행되는, 동기적으로 실행되게됩니다
이번에는 await을 통해서 상황 설명
에서 말씀드렸던 상황을 가정해봅시다
만약에 Task안에 두가지 비동기처리를 await으로 처리하면 어떻게 될까요?
Task {
let myImages = await 내가 만든 이미지 받아오는 함수 --- 1
let myState = await 유저의 상태를 조회하는 함수 ------- 2
self.state.mypageInfo = (myImages, myState) ----- 3
}
순서대로 살펴봅시다
우선 1번 코드가 실행됩니다 await을 만난 순간 suspend가 되고 OS가 thread의 제어권을 가지고 내가 만든 이미지 받아오는 함수
가 끝날때까지 필요한 작업을 합니다. 그리고 내가 만든 이미지 받아오는 함수
가 끝나면 다시 thread의 제어권을 받아오기를 기다리다가 OS가 다시 thread의 제어권을 주면 해당 데이터를 myImages에 담고 2번째 코드를 실행합니다 똑같이 await을 만나 suspend되고 이후에 다시 제어권을 받아서 myState라는 변수에 값을 할당하고 3번째 코드가 실행됩니다
위의 상황을 타임라인으로 그려보겠습니다
파란색 막대기는 내가 만든 이미지 받아오는 함수
가 완료되는데 걸리는 시간 초록색 막대기는 유저의 상태를 조회하는 함수
가 완료되는데 걸리는시간입니다
노란색 막대기는 함수가 끝나면 즉시 thread제어권을 받는게 아니라 대기상태에있다가 OS가 thread를 줄 순서가 되었다고 판단하면 thread의 제어권을 돌려주기에 약간의 시간이 필요함을 표현했습니다
이 두가지의 API호출시간이 각각 3초
와 2초
라면 5+a초
의 시간이 걸리게됩니다
하지만 async let을 사용하면 어떻게 될까요?
Task {
async let myImages = 내가 만든 이미지 받아오는 함수 --- 1
async let myState = 유저의 상태를 조회하는 함수 ------- 2
self.state.mypageInfo = await (myImages, myState) ----- 3
}
async let은 해당 Task내부에 child Task를 만들어냅니다. child Task도 Task니까 Task자체는 비동기적으로 실행되지만(동기적인 맥락에서 Task를 만나면 Task가 끝나기를 기다리지 않고 다음 코드를 실행시킴)
의 개념이 그대로 적용됩니다
즉, 1번째 코드에서 async let을 만나면 child task를 실행하기에 바로 2번째 코드를 실행시킵니다. 2번째 코드도 child Task를 실행하기에 바로 3번째 코드를 실행시키며 이 두 child task가 완료된 결과가 필요한 3번째 코드에서 await을 만나 두 child task를 기다리게 됩니다
이 코드를 타임라인으로 그려보겠습니다
3+a초
의 시간만으로 두개의 API결과를 받아올 수 있게됩니다. 기존 5초가 걸리던 작업이 3초로 줄어들었고 API가 많아지만 많아질수록 이 효과는 배가될겁니다
async let의 주의할점이 하나있다면 두개의 API를 기준으로 호출한다했을때 두 API가 각각의 API의 결과에 의존하지 않아야합니다. 첫번째 API의 결과가 두번째 API를 호출하는데 필요하다면 await을 연속으로 호출하는것과 똑같아지니까요
자 그럼, 제 상황을 보면 두개의 API가 있고 위의 주의점
을 고려했을때 각각의 API가 서로다른 API의 결과를 필요로 하지 않으니 따로 호출해도 문제가 없으므로
async let myImages = 내가 만든 이미지 받아오는 함수( -> 새로추가된 API)
async let myState = 유저의 상태를 조회하는 함수( -> 기존에 있던 API)
return try await (myImages, myState)
이렇게 코드를 작성하는게 효율적이라는 결론이 나오게됩니다. 실제로도 저는 이러한 결론을 내리고 위와같은 방식으로 마이페이지 코드를 구현했습니다
현재 진행중인 프로젝트에서는 Amplitude를 통한 유저의 action을 실시간으로 tracking하고 있고 오류가 발생하면 어떤 View에서 어떤 오류가 발생했는지도 함께 tracking하고 있습니다. 그런데 이상하게 다른 뷰에서는 아무런 API관련 오류가 발생하지 않았는데 유독 마이페이지쪽 네트워크통신에서 같은 오류가 발생하는걸 확인하게 되었습니다
(마이페이지를 tab했을 때 에러가 발생하는 모습 by Amplitude)
그런데 제가 몇번이고 오류상황을 구현해보려해도 비정기적으로 오류가 발생했습니다 그것도 30번중에 1번꼴로 말이죠...새삼 상황 구현이 안되는 오류를 해결하는게 얼마나 어려운건지 느꼈답니다
그리고 오류가 API호출문제가 아닌 토큰문제로 통일되서 나타난다는것도 로그를 통해 확인할 수 있었습니다
서버분에게 물어보니 특정 상황에서 refresh token이 1초미만의 차이로 갱신되지 않은 이전 토큰을 가지고 토큰 갱신 API를 호출해서 문제가 발생하고있다는걸 알게되었습니다
API통신자체가 잘못된게 아니라 토큰 갱신이 문제라는 리포트를 보고 아주 큰 혼란에 빠졌습니다...
왜냐면 지금까지 토큰이 문제가 된적이 없었거든요
API를 통신할 때마다 Alamofire의 interceptor를 통해서 accesstoken(2시간 만료)이 만료되면 refreshtoken(7일 만료)를 통해 새로운 accesstoken과 refreshtoken을 발급받고 다시 토큰만료이슈로 실패한 API를 다시 호출하기때문에 refreshtoken이 만료되었다는 오류는 이론상
발생하면 안되는 오류였습니다
심지어 당시 문제를 파악했을 때는 앱출시후 7일이 채 안된 시점이었기때문에 refreshtoken이 만료되는 상황자체가 불가능했습니다
- 특정상황일때 무조건 발생하는 오류가 아니라 재현이 어렵다(랜덤으로 발생하는 오류같다고 판단)
- 예상치못한 token문제라는것에 당황
- 다른 API호출부분에서는 발생하지 않고 마이페이지에서만 가끔 발생하는 오류로 확인
3번을 보면서 다른부분과 마이페이지에서 다른 부분이 async let
을 사용한 것 밖에 없다는 결론을 내리고 토큰을 갱신하는 interceptor를 유심히 살펴보기로 했습니다
import Alamofire
final class APIEventInterceptor: RequestInterceptor {
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
...header넣어주기...
}
func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
...accesstoken만료 오류인지 확인...
1. 기존에 가지고 있던 accessToken, refreshToken을 userdefault에서 가져옴
2. 토큰갱신API를 통해 새로운 accessToken, refreshToken을 받아옴
3. 2번에서 받아온 accessToken과 refreshToken을 userdefault에 저장
}
}
한 시간정도 코드를 봐도 대체 이게 무슨 문제인지 전혀 알 수가 없었습니다...
그렇게 두시간정도 코드를 보면서 문제를 찾고있다가
- 기존에 가지고 있던 accessToken, refreshToken을 userdefault에서 가져옴
- 토큰갱신API를 통해 새로운 accessToken, refreshToken을 받아옴
- 2번에서 받아온 accessToken과 refreshToken을 userdefault에 저장
해당 로직에서 약간의 찜찜함을 느꼈습니다
혹시 운영체제를 공부해보신 분들이라면 이 흐름이 어디서 본적이 있을거같은데요. 저도 운영체제를 공부할때 봤던 어떤 흐름을 떠올리게 되었습니다
어떤 변수에 값을 더하는 로직은
1.그 값을 register에서 가져오고
2.그 값에 1을 더하고
3.다시 register에 덮어쓰는 로직순서로 진행된다
결국 이 어떤 변수에 값을 더하는 로직이 atomic하지 않기때문에 data-race가 발생한다. 두개의 thread에서 하나의 변수에 1을 100번더하는 로직을 수행하면 결과가 200이 아닌 200보다 작은수가 나오는데 이는 data-race로 인한 결과이다
순간 이내용을 떠올렸을 때 data race로 인한 토큰 동기화 문제라는 가설을 세우게 되었습니다
차근차근 가설검증을 해봅시다. 우선 토큰 갱신로직의 경우 세가지 파트로 이루어져있고 각각 서버의 토큰과 로컬(userdefault)에서의 토큰의 소유 관계를 나타내봤습니다
토큰을 가져올때는 서버와 로컬 모두 old token을 들고있게 됩니다. 그 토큰을 가지고 토큰 갱신 API를 호출한다면 서버의 토큰은 new token으로 바뀌게 되지만 여전히 로컬의 토큰은 old token입니다. 그리고 API 호출이 완료된 결과인 new token을 local에 새로 갱신해줘야 local과 서버모두 new token을 가지게 됩니다
만약에 async let
으로 동시적으로 호출된 다른 메서드가 빨간색 화살표 시점에 API를 호출하면 어떻게될까요?
새로운 API에서는 local에있는 old token을 가지고 API를 호출하겠지만 이미 서버에는 new token으로 갱신이 되었기 때문에 accesstoken이 만료되었다는 오류를 던져줄거고 interceptor는 old token을 가지고 토큰 갱신 API를 호출하겠지만 이미 서버는 new token으로 갱신이 된 상태이기 때문에 refreshtoken이 이미 만료되었다는 오류를 던져주게 됩니다
100번이면 100번다 이런상황이 아니라 하필이면 두번째 API가 화살표 타이밍에 호출된 경우에만 이런 문제가 발생하기에 그렇지 않은 경우에는 문제가 발생하지 않았던것이었습니다
예상치 못한 타이밍이면서 개발자가 컨트롤 할 수없는 타이밍에 발생하는 오류였다고 판단했습니다
아래 세가지 로직이 atomic하지 않기때문에 발생한 문제라고 결론을 내렸기때문에 이 로직을 atomic하게 만들어주는 방법으로 NSLock을 사용하는 선택지를 먼저 떠올렸습니다
- 기존에 가지고 있던 accessToken, refreshToken을 userdefault에서 가져옴
- 토큰갱신API를 통해 새로운 accessToken, refreshToken을 받아옴
- 2번에서 받아온 accessToken과 refreshToken을 userdefault에 저장
1번로직이 실행되고 3번로직이 끝나기전까지는 lock을 가지고 있어 다른 쪽에서 userdefault에 접근하지 못하게 하는 방법을 떠올렸으나 두가지가 걸렸습니다
2번에서 받아온 accessToken과 refreshToken을 userdefault에 저장
해당로직이 끝나면 lock을 풀어야하고 이후에 다른 로직이 없으니 그냥 API가 끝나기를 기다리는것과 같지 않은가?특이 이중에서도 2번을 생각해보면 API가 끝날때까지 기다리면 await을 통해서 API가 끝나기를 기다리고(토큰이 동기화 되기를 기다렸다가) 다음 API를 호출하는것이 lock을 사용하는것에 비해서 비효율적이라고 말할만큼 느리지 않을거라는 생각이 들었습니다. 또한 await을 통해서 API를 기다리면 thread가 block되지 않고 suspend되는 것이기때문에 1번의 걸리는 부분이 해결이 되기도 했습니다
그래서 token이 필요한 운영서버의 API의 경우엔 async let보다는 await을 통한 연쇄적인 API호출이 조금더 나은 방식이라는 결론을 내리게 되었습니다
결론적으로는 해당 방식으로 코드를 변경하고 나서 마이페이지에서 토큰만료문제는 사라졌습니다(다행입니다...)
어떤 개발자분들은 이러한 상황에서 NSLock을 사용하는것이 맞는 선택이라고 결론을 내릴수도있고 저 처럼 await을 사용하는게 낫겠다고 생각하시는분들도 있을 것 같습니다
정답은 없겠지만 여러 상황을 고려했을 때 저는 후자의 방법을 선택했다고 생각해주시면 될 것 같습니다
이번 글의 주제로 async let
을 선택한 이유는 처음에 swift concurrency
를 공부할때 async let
를 안쓰면 손해라는 느낌이 들정도로 강력한 기능이라고 생각했습니다. await으로 API를 연쇄호출 할 필요 자체가 없을거라고 생각했으니까요. async let
이 있는데 굳이? 같은 느낌이었던거죠
그런데 막상 실제로 사용해보니 항상, 100% 좋은 방법은 없다
라는걸 다시한번 깨달았습니다. 어떤 상황이냐에 따라 await을 연쇄적으로 호출하는 것이 최선의 방법일수도 있다라는걸 알게되었습니다
개발에 정답은 없다
라는걸 다시금 깨달았던 경험이었습니다
그리고 처음으로 data race라는걸 실제로 목격한 경험이었습니다...(이걸 진짜 보네...)
운영체제를 공부하면서 나오는 예시들은 숫자를 하나씩 올린다거나 하는... 적어도 제가 앱을 만들때 사용할만한 로직들의 예시가 아니었기에 개념을 알겠지만 내가 과연 data race를 실제로 보게되는 날이 올까라는 약간은 동떨어진 느낌이 없지 않았던것같습니다
그런데 순간 운영체제에서 배웠던 내용과 내 코드가 겹쳐져서 보이는 그 순간을 느끼고 data race라는 가설을 세우고 해결을 하니 역시 이론이 중요하구나라는걸 알게되기도했습니다
어...? 설마 수업때 봤던 문제인가?
하고 떠올렸던 그 순간이 굉장히 뿌듯하고 잊을 수 없던 순간이었던거같습니다. 그리고 까먹지 않게 꾸준히 공부해야겠다는 다짐을 하게되었습니다 ㅎㅎ...(청년 경찰에 나왔던 이게된다고?와 비슷한 느낌을 받았습니다)
오랜만에 쓰게된 글이라 그런지 좀 오래걸렸네요.. 글쓰기도 습관이라는걸 다시금 깨닫습니다 ㅎㅎ
이번에 글또 10기에 참여하게되었는데 2주에 한번씩 반년동안 꾸준히 글을 업로드해보겠습니다! 파이팅!