서비스 컴포넌트 전략적 설계 제작 및 수정 가이드
💡 아직 자신만의 설계 및 트러블 슈팅 방법을 찾지 못한 개발자를 위한 내용입니다.
딱히 혁신적인 방법을 소개하는 글은 아니고, 더 나은 방법을 찾을 수 있게 되기 전의 길잡이 역할 정도.
어려운 용어는 최대한 빼고 갈 건데, 예시가 실제로 겪은 예시다 보니 조금 험악할 수 있습니다.
1. Introduction
📌 시작하기 앞서
꾸준히 주에 1개 이상의 포스팅을 게시하고 있었는데, 저번 주에 기록이 깨졌다.
설 연휴 기간에 독감에 걸린 거 같은데, 무시하고 공부하다가 뻗어버렸다.
심지어 지금도 다 안 나아서, 머리가 헤롱헤롱.
글 다 쓰고 보니 문장 구조가 이상한 부분이 보이는데, 제가 지금 환자라서 그렇습니다. 양해부탁드립니다.
여튼 그래서 뭐라도 포스팅하고 하긴 해야겠다 싶었는데, 마침 지난 스프린트 기간에 iOS팀하고 재밌는 주제로 열심히 의논했던 일이 떠올랐다.
상황은 다음과 같았다. (아래 나오는 용어와 상황이 뭔지는 전혀 이해할 필요가 없습니다.)
- JWT 기반의 인증 정책과 Refresh Token Rotation 보안 정책을 도입한 상황에서, refresh가 발생했을 때에 API 서버와 Socket 서버의 인증 갱신을 동기화하는 로직을 iOS팀에게 구현하도록 맡겼다.
- 단, 비동기 요청 시나리오에서 refresh가 발생할 때 생기는 문제점을 없애기 위한 기존의 로직을 활용해야 한다.
- 그리고 소켓 서버 업데이트 단계에서 소켓 서버로 전달되어야 할 메시지들은 대기 상태로 전환해야 하며, 업데이트가 끝나면 401 에러로 인해 전송 실패하여 대기 중인 메시지들을 우선 전송해야 한다.
요구 사항만 봐도 어렵다.
사실 그렇기 때문에 그냥 내가 해결해주고 치울까 싶었는데, 이런 재밌는 트러블 슈팅 기회를 iOS 팀에게서 빼앗고 싶지는 않았다.
하지만 그냥 "해보세요~"하고 던지면 포기할 거 같아서, 어떻게 하면 좋을까 고민하다, "문제 해결에도 일련의 순서를 정해둘 수 있지 않을까?"라는 생각이 들었다.
그렇다고 바로 위 예시를 기반으로 설명해버리면 그 누구에게도 도움이 되지 않을 테니, 알아가는 것도 점진적으로 해보자.
💡 구현은 본인 스스로 할 수 있다는 전제를 두고 진행합니다.
2. 문제 해결을 위한 사고
📌 파인만 알고리즘
파인만 알고리즘이란, 세 단계로 구성된 문제 해결 기술을 말한다.
- 문제를 쓴다.
- 정말 열심히 고민한다.
- 답을 쓴다.
옛날에는 그냥 농담처럼 받아들였는데, 지금 생각해보면 이걸 놓치는 사람들이 제법 많다.
알고리즘에는 순서가 중요하다.
즉, (1)이 실행되기도 전에, (2)가 실행되어서는 안 된다는 말이다.
그런데 지금까지 정말 수많은 사람들이 (1)을 대충 넘기는 경향이 있었다.
문제 정의를 제대로 하지 않으니, 또 다른 문제가 나타나거나, 혹은 과하게 시간 투자를 해서 오버 스펙 기능을 만들어버린다던가.
기술적인 관점을 제외하고, 코딩테스트와 실제 개발의 차이점도 여기가 가장 차이가 크다.
코딩테스트는 문제 상황과 어떻게 개선하고 싶은지를 모두 적어주고, 프로그래머가 이를 해석하는 식으로 접근한다.
하지만 실제 개발은 어떤가?
토스처럼 중간 부서에서 필터 다 거친 후, 개발자에게 해야할 일만 딱딱 지시해주는 환경이 아니라면, 문제 원인을 찾아서 정의하는 것부터 시작해야 한다.
꼭 내가 해결해야 하는 문제를 누가 봐도 이해하기 쉽게 적어놓자.
해결 방법은 그 다음이다.
📌 input과 output
TDD를 들어본 사람도 있을 거고, 모르는 사람도 있을 거 같은데 상관 없다.
TDD는 아래 3단계를 무한 반복하면서, 최종 코드를 구현하는 사이클을 갖는다.
- 일단 오류가 나는 테스트를 작성한다.
- 테스트가 어떻게든 통과하도록 만든다.
- 리팩토링을 한다.
그런데 이 보다 앞서 정의해야 할 것은, input에 대한 output의 정의에 해당한다.
"어떻게" 하는 지는 나중에 고민해도 되지만, "무엇을" 할 지는 명확하게 정의를 해야만 한다는 의미다.
예를 들어, 사용자 인증 기능을 구현하고 싶다고 하자.
이걸 어떻게 할 지 먼저 고민하면 머리가 아파진다.
이럴 땐, 우선 input과 output이 뭔지를 고민해보자.
- input: 사용자 아이디, 비밀번호
- output: 성공하면 JWT 반환, 인증 실패 시 401 예외, 시스템 실패 시 500 예외
위 두 가지만 명확하게 정의하면, 언제나 동일한 input에 대해 동일한 output을 내놓는 로직을 구현해내기만 하면 된다.
📌 문제를 잘게 쪼개기
개발자라면 한 번쯤을 들어봤을 법한 SRP 원칙을 얘기할 건데, 여기선 원론적인 이야기를 하려는 게 아니다.
컴포넌트는 단일 책임만을 가져야 한다.
그래야 설계가 쉽다. (띠옹?)
예를 들어, "사용자 인증"을 생각해보자.
사용자 관점에서 input에 대한 output을 정의했으니, 이제 구현을 하면 될 것 같지만 그저 막막하다.
입력은 어떻게 받을 거고, 상태는 어디서 관리할 것이며, 인증을 위한 유효성 검사 등의 작업은 어떻게 할 것인가?
이럴 땐 먼저, "사용자 인증" 컴포넌트가 가지고 있는 역할을 정리해보자.
- 사용자 요청/응답 상호작용
- 인증 정보 검사
- 상태(사용자 정보) 관리
- 성공(혹은 실패) 시, 추가적인 작업.
이런 온갖 기능을 다 가지고 있는 객체를 보면 SOLID 원칙을 떠나, 대체 무슨 수로 구현할 것이냐는 의문이 들 수밖에 없다.
일단 저 역할들을 모두 나눠보자.
- Read, Write: 가장 low-level에서 I/O 처리를 담당
- Controller: 요청 타입을 변환하여 서비스로 전달하고, 결과에 따른 응답을 반환.
- Service: 인증 정보 검사 로직만을 담당
- Repository: 사용자 정보 상태가 저장된 저장소와 연결 담당
문제를 잘게 쪼개면, 거대한 한 덩어리의 고난이도 문제가, 작은 여러 덩어리의 저난이도 문제들로 바뀐다.
그리고 "사용자 인증"이라는 비즈니스 규칙을 Service 영역에 모아둠으로써, 순수한 로직만을 남겨둘 수 있게 된다.
지금 피곤해서 글을 이렇게밖에 못 쓰겠지만, 약 파는 소리가 아니고, 정말 중요한 내용이다..
📌 일급 객체와 상태 분리
💡 함수형 프로그래밍을 알면 이해하기 쉽습니다.
일급 객체의 핵심 특성은 y = f(x)라는 함수가 있을 때, 언제나 같은 x에 대해서는 같은 y를 반환한다는 의미다.
이는 함수형 프로그래밍의 근간이 되는 불변성(immutability)과 밀접한 이야기다.
문제는 input과 output의 마지막에 언급했던, "언제나 동일한 input에 대해 동일한 output을 내놓는 로직을 구현"한다는 것을 보장하려면,
이 로직이 어떠한 가변적인 상태를 가지지 않는 순수 함수여야 한다는 가정이 필요하다는 점이다.
💡 왜 가변 상태(variable state)를 가지면 순수 함수일 수 없는가?
일급 객체는 언제나 동일한 input에 대해 output을 내놓아야 한다는 점을 기억하자.
이 말은 즉, 사용자가 인증을 위해 id, pw를 전송했을 때, 이전에 100번을 실패했든, 1억 번을 실패했든, 최종적으로 유효한 인증 정보를 보내기만 했다면, 마지막에는 인증 성공을 반환해야 한다.
그런데 만약, "사용자가 특정 시간 내에 5회 이상 실패하면, 계정을 잠근다"는 규칙을 추가했다고 생각해보자.
1. 사용자가 잘못된 인증 정보를 송신할 때 마다, 해당 사용자의 실패 횟수를 증가 시킨다.
2. 사용자가 유효한 인증 정보를 송신했고, 실패 횟수가 5 미만이면 성공 응답을 반환한다.
3. 사용자가 유효한 인증 정보를 송신했지만, 실패 횟수가 5 이상이면 실패 응답을 반환한다.
4. 사용자의 계정 잠금은 1일 이후 자동으로 해제된다.
여기서 문제는 "사용자 인증"이라는 로직에 "이전 로그인 시도 기록"이라는 상태가 현재 인증 결과에 영향을 미친다는 점이다.
이런 상태 의존성은 여러 모로 개발자를 힘들게 만든다.
테스트도 어렵고, 버그를 찾기도 쉽지 않고, 우리가 아는 경합 문제나 동시성 문제도 다 이런 가변 상태들로 인해 비롯한다.
메모리와 디스크가 무한대라는 이상적인 환경에선, 모든 연산을 순수 함수로 표현할 수 있다.
사용자의 모든 로그인 시도 이력을 변경 불가능한 데이터로 저장하고, 매번 처음부터 계산하면 된다.
(이걸 Event Sourcing 패턴이라고 하는데, 쉽게 말해서 최종 통장 잔액만 저장하는 게 아니라, 입/출금 이력을 모두 기록해놨다가 현재 잔액을 구할 때는 처음부터 모든 거래를 더하고, 빼서 계산하는 것이다.)
하지만 실용적인 측면에서 너무 비효율적이라, 가변 영역과 불변 영역을 잘 분리해야 한다.
- [입력 → 인증 로직(로그인 시도 이력 상태) → 결과]
- 인증 컴포넌트가 순수한 인증 로직과 상태 관리 두 가지 역할을 갖는다.
- [입력 → 로그인 시도 이력 확인(로그인 시도 이력 상태) → 인증 로직 → 결과]
- 순후 인증 로직과 상태 관리 영역을 둘로 나누었다.
이런 식으로, 가변 컴포넌트로부터 최대한 많이 불변 컴포넌트를 추출하는 것이 좋다.
🤔 왜 불변 컴포넌트를 최대한 많이 만들어야 하는데요?
가변(variable)은 동일한 입력에 대해 다른 출력을 내놓게 만드는 분기점의 주 요인이다.
단순히 1-bit 크기의 flag라는 상태 하나만으로도, 같은 입력에 대해 다른 출력을 만들 수 있다.
그렇다면 이런 flag가 여러 개 있으면 어떻게 될까?
3개만 있어도, 최악의 경우에 2^3 = 8가지나 된다.
4개가 있으면, 2^4 = 16개의 경우의 수를 내놓을 수도 있다.
만약, flag가 true/false만이 아니라 상수로 더 많은 분기를 표현 가능하다면, 경우의 수는 더 많아진다.
심지어 이런 가변 상태들이 서로 간의 동작 방식에 영향을 줄 수록, 단순한 경우의 수를 넘어서, 예측 불가능한 상황을 발생시키기도 한다.
가변 상태는 시스템의 복잡도를 기하급수적으로 증가시킨다.
그리고 개발자는 사람이기에 실수를 저지를 수밖에 없다.
이런 실수를 최소화하고 싶다면, 경우의 수 자체를 최소한으로 줄이는 것이 좋다.
그러기 위한 가장 좋은 방법은 상태를 제거하는 것이고, 그게 힘들다면 불변 컴포넌트를 분리하는 것이다.
📌 의존 관계 제어하기
💡 객체지향 프로그래밍을 알면 이해하기 쉽습니다.
[iOS] Clean Architecture 쉽게 이해해보기
📕 목차1. What is Clean Architecture?2. 3-Layer Architecure3. Presentation Layer4. Domain Layer5. Data Repostiroy Layer6. Advantage7. MVC? MVVM?1. What is Clean Architecture? 📌 Introduction [Android] Project : DRF API와 MVVM Clean Architecture &
jaeseo0519.tistory.com
이 파트를 쓰려다가, 이미 예전부터 너무 포스팅에서 많이 언급했던 거라 또 쓰기가 귀찮아졌다.
그보다 몸 상태가 실시간으로 다시 안 좋아지는 중이라, 이건 예전에 작성했던 글로 마무리.
다 읽을 필요 없이, 3-Layer Architecture 파트만 읽으면 됩니다.
3. 인증 기능 추가해보기
📌 쉬운 문제부터 해결해보기
수정보다는 추가가 쉽다.
그림을 그릴 때도 백지 상태에 처음부터 그림을 그리는 것이, 이미 그려진 그림을 고치는 것보다 쉽다.
왜냐면, 수정이라는 것은 자칫 지금까지 아무런 이상이 없던 시스템에 장애를 발생시킬 수 있기 때문이다.
그래서 가장 쉬운 예시로 기능을 추가하는 설계를 해보자.
1️⃣ 문제 정의 (Use case)
인증 기능은 범용적인 해결책들이 널리 알려지긴 했지만, 서비스 별 세부 정책이 다르니 조정이 필요하다.
(예를들어, 어떤 서비스는 OAuth 인증만 제공한다거나, 혹은 서버에서 JWT를 쓴다거나 Session을 쓴다거나 등)
우리는 가장 단순한 케이스를 고민해보자.
- 사용자가 클라이언트 앱에 접속하면 로그인 뷰를 보여준다.
- ID/PW를 입력한다.
- 서버는 ID/PW가 유효하면 성공 응답을 반환하고, 유효하지 않으면 실패 응답을 반환한다.
2️⃣ 예상되는 입력과 기대하는 결과 정의
관점을 어떻게 잡느냐에 따라 달라진다.
- 클라이언트 관점
- input: 아이디/비밀번호
- output: 성공하면 메인 화면 이동, 실패 시 팝업으로 사용자에게 이유를 알림
- 서버 관점
- input: 아이디/비밀번호, ip 주소
- output: 성공하면 인증 토큰 반환, 실패 시 사유를 반환.
3️⃣ 현재 구조 파악
기능 수정이 아닌 추가이므로 고려하지 않는 경우가 있는데,
본인 프로젝트에서 사용 중인 컨벤션, 아키텍처 들을 잘 고려해서 설계할 필요가 있다.
우리 프로젝트는 클라이언트(MVVM + Clean Architecture), 서버(MVC + Multi Module Architecture)를 채택하고 있다.
그래서 이 구조를 그대로 따를 것이다.
아, 물론 이게 뭔지는 몰라도 된다.
그냥 만들고 보니 MVVM이고, 멀티 모듈인 느낌이 되도록 설명할 생각이다.
또한, 인증과 관련된 자신들만의 유틸같은 것들이 있다면, 그런 것도 고려하긴 해야 한다.
4️⃣ 클라이언트 점진적 설계
클라이언트를 먼저 설계하든, 서버를 먼저 설계하든 순서는 상관 없다.
대체 그 이유가 뭘까?
가장 단순한 형태로 그리면 위와 같은 이미지가 될 것이다.
그런데 Client 입장에서 따지고 보면 함수 호출이랑 다를 게 없다.
Server가 일급 객체처럼 동작한다는 보장이 있다면, ID/PW를 전송했을 때 언제나 예상되는 결과를 반환할 것이다.
(500번대 에러는 특수한 경우가 아니고서야, 클라이언트가 처리할 수 없는 영역이므로 무시한다 가정하자.)
어차피 대부분 Server는 REST API 혹은 GraphQL을 따를 텐데, 둘다 무상태(stateless)를 지향한다.
그럼 Client는 "일단 된다 치고", 요청 이후 결과에 따른 로직 처리만 고민하면 된다.
Client가 고민해야 하는 것은 크게 3가지
- View: 사용자에게 무엇을, 어떻게 보여줄 것인가?
- Service: 사용자의 로그인 행위에 대한 비즈니스 로직
- Data: 데이터를 어디서/어떻게 가져올 것인가?
- SignIn View: 사용자 입력과 Usecase 결과로 사용자에게 보여줄 UI 상태를 보여준다.
- SignIn Usecase: "사용자 로그인"이라는 행위에 대한 비즈니스 규칙 (ex. 입력값 유효성 검사)
- SignIn Repository: 로그인 과정에서 필요한 데이터를 가져오는 곳 (ex. 서버, 디스크 등등)
🟡 불변 컴포넌트 분리
여기서 View를 보면 "UI 상태"가 나온다.
예를 들어, 사용자가 처음에 로그인 페이지를 볼 때는 보이지 않던 에러 문구가, "에러 발생"이라는 flag가 활성화되면 보이게 되는 것도 UI 상태에 속한다.
결국 이는 가변적인 컴포넌트가 될 수밖에 없다.
하지만, UI를 정의하는 것과 상태를 관리하는 것은 분리가 가능하다.
- UI를 정의하는 것: 에러 문구를 어떤 위치/색상/폰트 등으로 보여줄 것인가?
- UI 상태를 관리하는 것: 어떤 에러 문구를 언제 보여줄 것인가?
에러 문구와 보여줄 시점은 가변적이지만, 그 외의 모든 것들은 불변 컴포넌트로 분리할 수 있다.
🟡 Domain 영역 의존성 역전
비즈니스 규칙은 도메인 영역에 속하는 컴포넌트다.
쉽게 설명하면, 절대 훼손되면 안 되는 영역이므로, 외부 변화에 쉽게 변화해서는 안 된다.
MVVM이기 때문에 해야 하는 게 아니고, 도메인 영역의 정의이기 때문에 MVVM도 그렇게 하는 것이다.
뭐 여튼 이런 식으로 하면, 얼추 위와 같은 전체 컴포넌트를 그릴 수 있을 것이다.
🤔 5XX 예외는 어떻게 처리하시겠어요?
같이 개발해보면 생각보다 500번대 에러를 전혀 제어하지 않는 클라이언트 개발자가 많아서 놀랐다.
(서버 개발자는 무책임하게 전역 예외 처리 장치로 에러 응답을 쏴버리지만)
클라이언트가 어떻게 해결할 수 없는 에러인 경우더라도, 사용자에게는 정보를 알려주어야 한다.
그런데 문제는 이 5XX 예외를 어디서 핸들링할 것인지가 문제가 된다.
Usecase? 아니면 Repository? 혹은 다른 컴포넌트를 사용할 것인지?
로그를 남겨야 한다면, 이는 또 어디에 남길 것인가?
시간이 된다면 이런 걸 고민해보는 것도 좋은 개발자로 성장하는 길이 될 것이다.
5️⃣ 서버 점진적 설계
서버도 일단 Controller, Service, Repository 다 나누고 시작하자.
- Controller
- 클라이언트와의 상호작용하는 역할을 담당한다.
- input: ID/PW
- output: 성공/실패 응답
- Service
- "사용자 인증"에 해당하는 비즈니스 규칙을 수행한다.
- input: ID/PW
- output: 성공 시 인증 정보, 실패 시 실패 사유 반환
- Repository
- 사용자 정보를 가져온다.
- input: ID(고유값)
- output: 사용자 정보 (없으면 null)
사실 이게 끝이라 좀 심심한 감이 있다.
그래서 사용자가 5회 이상 틀리면 예외를 반환하는 규칙을 추가해보자.
🟡 Service에 규칙을 추가할 것인가?
만약 Service에 "사용자 별 로그인 시도 횟수" 상태를 추가한다고 가정해보자.
상태는 간단하게 메모리에 적재한다고 치자.
그럼 아마 SignInService는 다음과 같이 동작할 것이다.
- 사용자 시도 횟수 확인
- 5회 이상이면 실패 응답
- ID로 사용자 정보 조회
- null이면, 실패 응답
- IP를 key로 실패 횟수 카운트 증가
- PW 검증
- 틀리면 실패 응답
- IP, ID에 대해 실패 횟수 카운트 증가
- 성공 응답
- 실패 횟수 초기화
이 방법은 여러 모로 문제가 많다.
- REST API, GraphQL 모두 stateless 원칙을 갖는다. 즉, 서버는 별도의 상태를 지녀서는 안 된다는 원칙이 깨진다.
- 서버가 분산 환경에서 실행된다면, 동일한 클라이언트의 요청이 서버1에서는 횟수 초과가 나오고, 서버2에서는 정상 동작할 우려가 존재한다.
- (1)~(3)의 연산을 원자적으로 처리하지 않으면, 멀티 스레드 환경에서 올바르지 않은 케이스를 통과시킬 우려가 있다.
- 예를 들어, 4회 실패한 시점에 클라이언트의 2가지 요청(하나는 틀린 정보, 하나는 올바른 정보)이 거의 동시에 도달했다고 가정하자.
- 틀린 요청이 미세하게 앞선 상황이라면, 뒤따라오는 올바른 요청은 (1)에 의해 실패가 반환되어야 한다.
- 둘이 거의 동시에 (1)을 통과하거나, DB 조회 등으로 지연되는 시간 동안 올바른 요청이 통과해버린 경우, 서버는 성공 응답을 반환하게 된다.
한 번에 최선의 답을 찾아내려 하지 말고, 근본적인 원인부터 제거해나가는 것이 좋다.
그리고 내가 무슨 말을 할 지 짐작했겠지만, 우선 저 놈의 지난 로그인 이력이라는 상태부터 분리해야 한다.
🟡 상태 분리
지난 로그인 이력을 서버가 갖지 않고, DB에 저장한다면 가장 간단하게 해결할 수 있다.
만약, 원자적 연산 문제를 쉽게 해결하고자 한다면, RDB가 아닌 NoSQL, 특히 Redis를 활용해보면 좋을 것이다.
(이유를 설명하면 너무 딥하게 들어갈 거 같기도 하고, 기술 선택 과정은 포스팅 주제와 맞지 않으므로 생략했다.)
4. 기능 수정 설계
😇 다 쓰고 보니 예시가 너무 악랄했던 거 같습니다. 이해하지 마시고, 흐름만 봐주세요.
📌 문제 상황
QA 과정에서 iOS 팀에게 개선을 요청했던 부분은
"소켓 통신 과정에서 사용자의 인증이 만료되어 갱신(refresh)이 진행되는 동안, 사용자가 새로운 채팅 메시지를 보내려고 하면, 시스템은 메시지를 임시 대기열에 보관한다. 인증이 성공적으로 갱신되면, 시스템은 대기열에 저장해둔 메시지들을 사용자가 원래 보냈던 순서 그대로 소켓 서버에 전송한다. (인증 만료로 인해 전송되지 못한 메시지들이 먼저 전송되어야 한다.)"
이해하기 쉽게 상황을 풀어 설명하자면,
- 클라이언트가 소켓 서버로 메시지1을 전송
- 소켓 서버 인증 정보 갱신이 필요해서, 클라이언트에게 401 에러 메시지 전송
- 클라이언트가 HTTP 서버로 인증 정보 갱신 요청
- 클라이언트가 소켓 서버로 메시지2 전송 시도 -> 보내지 않고, 대기 큐에 저장
- (3)이 성공하면, 클라이언트가 갱신된 인증 정보로 소켓 서버 인증 정보 갱신 요청
- 클라이언트가 소켓 서버로 메시지3 전송 시도 -> 보내지 않고, 대기 큐에 저장
- (5)가 성공하면, 클라이언트가 메시지1, 메시지2, 메시지3 순서로 다시 소켓 서버에 전송
현재의 문제점은 두가지
- (4)에서 소켓 서버 갱신이 끝나지 않았음에도, 메시지2를 소켓 서버로 전송을 시도함.
- (6)에서 메시지3이 메시지1,2를 기다리지 않고, 먼저 소켓 서버로 전송됨.
상당히 복잡한 플로우를 가지고 있긴 한데, 차근차근 해결해보자.
(소켓 서버 만들 거였으면 그냥 세션 쓸 걸 그랬나 싶긴 하다.)
📌 문제 정의
우선, 대기 큐(상태)가 나왔으므로 가변 컴포넌트가 필연적으로 등장하게 될 것이다.
하지만 이 문제를 해결하기 위해서는 고려할 것이 하나 더 있는데, 소켓 서버 인증 갱신을 위한 라이프사이클이 기존의 HTTP 서버 인증 갱신과 다르다는 점이다.
HTTP 서버 응답 혹은 소켓 서버 메시지로 401에러를 수신하면, 클라이언트는 일단 반드시 refresh token으로 access token을 갱신해야 한다.
그리고 이 요청이 성공하면, 소켓 서버의 인증 정보를 갱신해야 한다.
그런데 HTTP 요청의 경우, 소켓 서버의 인증 정보 갱신을 대기하는 것은 비합리적이라고 볼 수 있다.
HTTP 요청의 경우엔 refresh만 성공해도 재시도가 가능한데, 굳이 소켓 인증까지 기다려줄 이유가 없다는 것이다.
반면, 소켓 서버에서 401 메시지를 전송한 경우에는 언제나 JWT 갱신 과정이 사이클 내에 포함되어야 한다.
즉, access token을 refresh하는 컴포넌트와 소켓 서버 인증 갱신을 담당하는 컴포넌트는 라이프사이클이 다르기 때문에 분리가 되어야 할 필요가 있다.
🟡 기대하는 결과
- HTTP 요청은 refresh가 끝나면, 곧바로 재시도를 시작할 수 있어야 한다.
- 인증 정보 갱신 과정에서 사용자가 소켓 메시지를 전송해도, 대기 큐에 순차적으로 저장되어야 한다.
- 인증 정보 갱신이 끝나면, 대기 큐의 메시지를 순차적으로 전송한다.
📌 현재 아키텍처 파악
다시 이야기하지만 처음부터 다시 만드는 것보다, 기존 코드를 수정하는 게 더 어렵다.
심지어 구조가 잘 잡혀있지 않은 상태라면 더더욱.
- HTTP 401 에러 응답: BaseInterceptor가 낚아챈 후, TokenRefreshHandler 호출
- Socket 401 메시지 수신: DefaultChatStompRepo가 수신한 후, TokenRefreshHandler 호출
iOS 팀의 아키텍처를 살펴봤을 때, 가장 큰 아쉬움은 Socket 서버와의 연결 풀을 관리하는 객체가 채팅 Repository 안에 있었다는 점이다.
가장 처음에는 인증 갱신 라이프사이클 불일치 문제를 해소하기 위해서, DefaultChatStomRepo가 TokenRefreshHandler를 곧장 호출하지 않고, 한 단계를 더 거치게 만들려고 했었다.
문제는 이건 HTTP가 아니라 WebSocket이라는 점.
SocketAuthHandler가 소켓 서버 갱신 메시지를 전송한 후 처리 완료 응답을 받아야 하는데, 그걸 소켓 연결 풀을 관리하는 DefaultChatStompRepo가 받는다.
덕분에 순환 참조 문제를 벗어날 방도가 없었다.
그래서 우선 너무 많은 역할을 가지고 있는 DefaultChatStompRepo를 잘게 쪼갠 후에 시작하자.
📌 점진적 개선
1️⃣ 소켓 서버 연결 풀 관리 객체 분리
가장 먼저 해준 건 Repository에서 소켓 서버 연결 풀 관리 객체를 끄집어내서 분리했다.
실제 모든 소켓 메시지 송/수신은 CustomStompClient를 경유하게 된다.
자, 그럼 이제 문제가 해결되었을까?
2️⃣ 의존 관계 제어
애석하게도 소켓 서버 인증을 위한 Repository를 추가하자마자 순환참조에 걸리고 만다.
그래서 이걸 강제로 끊어내기 위해서 event를 pub/sub 하는 더티한 방식으로 해결해야 하나 고민을 했었다.
그런데 나중에 iOS 팀 말을 들어보니, 중간에 protocol을 두니까 순환 참조가 깨진다고 한다.
띠옹?? 아니 어차피 컴파일 타임에는 실제 객체 들어가서, 똑같이 순환참조 걸려야 하는 거 아닌가요?
알고보니 실제 객체를 참조하는 Java와 달리, Swift는 reference count와 existential container의 차이니 뭐니 해서, 프로토콜 참조하면 순환 참조를 회피하게 되는 그런 게 있더라...
여튼 둘 다 마음에 안 드는 방법이긴 한데, 난 개략적인 개선 방법 설계에만 참여한 거라서 그냥 여기까지만 했다.
3️⃣ 메시지 큐 상태 분리하기
현재까진 DefaultChatStompRepo에서 채팅 메시지 버퍼를 관리 중이었는데,
불변 컴포넌트는 최대한 분리시키도록 하자.
우선 메시지 상태와 갱신 중이라는 상태 정보를 MessageQueue라는 컴포넌트로 분리시켰다.
- DefaultChatStompRepo가 input으로 채팅 메시지를 넣으면, MQ는 일단 내부 Queue에 저장
- 인증 갱신 과정이 아니라면, 바로 소켓 서버로 전달
- 인증 갱신 과정 flag가 활성화에서 비활성화로 꺼지면, 대기 중인 모든 메시지를 순차적으로 전송한다.
물론 이 또한 한 번 더 분리가 가능할 것이라 본다.
- 상태 관리자: 채팅메시지 저장, 실제 전송, 인증 정보 갱신 여부에 따른 동작 결정.
- 상태 관리 저장소: 오름차순 정렬을 보장하는 자료구조
이건 iOS 팀에서 해보라고 맡겨버렸다.
5. 마무리
📌 쓰다가 졸았다.
혼자 해보라고 했을 땐, 도중에 포기했었는데
집요하게 붙잡고 같이 개선해주었더니, 끝내 마지막엔 혼자서 구현까지 다 해온 우리 iOS 팀원.
뿌듯하다..
이번 독감이 정말 지독했다고 느낀 점은, 내가 하루를 통채로 아무것도 안 하고 쉰 날이 4년만에 처음인 거 같았다.
(근데 또 저녁에 어떻게 겨우 씻고 회의를 하긴 했다.)
오늘은 조금 나은 거 같아서 포스팅을 썼는데, 쓰는 내내 실시간으로 몸 상태가 다시 안 좋아지고 있다.
그래서 솔직히 지금 내가 포스팅에 뭐라고 썼는 지 기억도 가물가물하다.
내일도 코테 있는데 망했군. 😇