오늘 배운 것

요즘의 개발 일상이라 카테고리를 'SWM OneStep'으로 지정은 해 두었지만 살짝 거리가 있는 포스팅을 해 보려고 한다. 그렇다고 다른 데 넣기도 애매한 게, 오늘은 철저히 주관적으로 내가 아는 것들을 한번 슥 훑어볼 예정이기 때문이다. 이유는 바로 오늘 오후에 있을 토스 next 전형에 대해 django 지식을 조금이나마 정리하기 위해서이다. 코테를 갓 끝내고 정신이 비교적 맑을 때 어서 글을 작성해 보자. 참고로 모든 질문과 대답은 내 머릿속에서 나온 것이므로 정확한 질문과 정확한 정답은 없다. 하지만 이렇게 정리하기만 해도 내가 모르는 게 뭔지를 알 수 있어서 해 보려고 한다. 

 

 

django란 무엇일까?

장고(django)는 웹 프레임워크(framework)이다. 프레임워크와 라이브러리의 차이점은 코드의 동작 방식의 제어 유무라고 알고 있다. 자바/스프링에서는 IoC(역전 제어, Inversion of Control)라는 말을 쓰는데, 기존의 코드나 라이브러리에서는 실행의 제어권을 개발자가 갖고 있었다면 프레임워크에서는 이 IoC를 통해 프레임워크가 실행의 제어권을 갖는다. 그렇다. 스프링 강의로 찍먹한 개념인데 스프링도 장고도 모두 프레임워크인 만큼 이 원칙은 둘 모두에게 적용된다고 볼 수 있다. 

 

 

프레임워크를 왜 사용할까?

웹 프레임워크의 경우 웹 개발을 빠르고 편리하게 하기 위해서 사용한다고 알고 있다. 장고의 경우 MVC 패턴(model-view-controller)을 사용해서 웹 개발을 할 수 있는데, 이처럼 프레임워크는 개발자들이 여러 번의 웹 개발을 하면서 반복되는 디자인 패턴 등을 녹여놓았다고 볼 수 있다. 

 

 

그렇다면 프레임워크를 사용하는 이유는 웹 개발을 하면서 반복되는 디자인 패턴 등을 더 편리하게 사용하기 위함인가? 정말 그게 다라면 꼭 프레임워크를 사용할 이유가 있을까?

이 질문을 스스로에게 던진 순간 공부를 더 해야겠다는 생각이 들었다. 나는 '웹 프레임워크'가 웹 개발을 편하게 해 주는 도구임은 어렴풋이 알고 있었지만, 프레임워크의 어떤 것들이 웹 개발을 편리하게 하고, 장고는 그 중에서 어떤 기능들을 제공하는지에 대해서 명확하게 알지 못하고 있었다.

바로 'why do we use framework'로 검색을 해 보았다. 한 사이트에서는 low-level functionality를 제공해서 반복적인 코드 작성을 줄여주는 것도 프레임워크의 역할이라고 하더라. 그랬더니 좋은 예시가 떠올랐다. 

바로 서블릿이었다. 이것도 스프링에서 찍먹한 개념인데, 장고에서도 사용되는 것은 분명했다. 우리는 장고나 스프링에서 뷰나 컨트롤러를 만들 때 요청 객체를 request라는 변수로 바로 받아올 수 있다. 그런데 실제로 웹 서버로 요청이 오면 이는 HTTP 형식으로 온 메시지일 뿐이지 request 타입의 객체가 아니다. 이때 서블릿이 헤더를 파싱하는 등의 작업을 통해 HTTP 형식의 요청 메시지를 우리가 프로그래밍 언어로 다룰 수 있는 Request 타입의 객체로 변환해 준다. 만약 이 작업을 서블릿이 해 주지 않았다면, 매번 핵심 로직을 작성하기 전에 헤더를 파싱하는 등의 반복적인 코드를 계속 작성해야 했을 것이다. 

 

 

스프링에서 서블릿이 위와 같은 역할을 한다면, 장고에서는 무엇이 그런 역할을 한다고 생각하나? 

여기서도 막혔다. 'django servlet'이라고 검색해 보니 나와 같은 고민을 했던 오래된 글이 보였다. 답변자는 장고의 정리된 공식문서를 추천해 주고 있었다. 이 중에서 키워드를 통해서 http 요청이나 servlet과 관련된 것을 찾아봤더니, http 관련 문서가 도움이 될 것 같았다. 그런데 여기서 나오는 URLConf, Middleware, Writing Views는 모두 이미 변환된 request를 사용하는 기능들이었다. 나는 무엇이 http 요청을 request로 바꾸는지를 알고 싶었던 거였는데 말이다. 그래서 GPT 찬스를 써 보았다. 

주의할 점은 이 녀석의 말을 100% 신뢰하기보다는, 여기서 관련된 키워드를 뽑아내는 것이다. 녀석은 Django WAS가 WSGI라는 (비동기 상황에서는 ASGI) 인터페이스를 통해 WS(web server)와 상호작용한다고 알려주었다. 여기까지는 알고 있던 내용이다. 여기서 WSGI는 위 문서에 나오지 않은 키워드였기에 여기에 집중했다. 

녀석은 그렇다고 답변하고 공식 문서 링크도 남겨주었다. WSGI와 관련된 링크였다. 그런데 WSGI는 사실 인터페이스이므로 실제 구현 부분은 WSGI가 아니다. 그렇다면 장고에서 사용하는 WSGI의 구현체들은 어떤 것들이 있을지 궁금해졌다. 

녀석은 Gunicorn, uWSGI와 같은 예시를 들어주었다. 여기서 내가 모르던 지식이 연결되었다. 나는 'gunicorn을 사용하면 WSGI로 통신을 할 수 있다' 까지만 이해하고 있었는데, gunicorn은 WSGI의 구현 서버(WAS)였던 것이다. 그리고 uWSGI는 C로 구현되어서 속도가 빠르다고 한다. 

 

그래서 결론은 WSGI 표준을 구현한 서버들로는 Gunicorn, uWSGI, mod_wsgi 등 다양한 WAS가 있고, 이 WAS와 WS끼리는 WSGI 표준을 통해서 통신하는 것으로 이해했다. 그리고 여러 자료를 찾아보다 이런 유용한 포스트도 발견했다. Apache와 Nginx 모두 웹 서버라는 것은 알고 있었지만 둘의 차이점에 대해서는 크게 생각해보지 않았었다. Apache는 요청이 들어올 때마다 프로세스를 생성하는 방식이고, Nginx는 이벤트 기반의 아키텍처를 사용해서 클라이언트 요청이 폭발적으로 증가할 때 요청을 제대로 처리하지 못하는 문제를 해결했다고 한다. 

 

여기서 워커(worker process) 개념이 나왔다. 한창 gunicorn, uvicorn으로 씨름할 때 나왔던 그 워커가 맞다. 이는 WAS에 있던 프로세스가 맞았다. 나중에 WAS의 구조도 그려보자. 

 

 

아는 디자인 패턴이 있다면 설명해 봐라. 그리고 장고에서 사용하는 예시가 있다면 들어 봐라. 

싱글톤 패턴에 대해서 알고 있다. 처음에는 스프링 컨테이너에서 빈을 기본적으로 싱글톤으로 관리한다는 것을 말하려고 했는데, 장고에서 사용하는 예시는 잘 모르겠었다... 라고 생각했는데, 생각해보니 MVC 패턴이 있었다! 장고에서는 MVT 패턴을 사용한다. 모델(Model)은 장고에서 다뤄지는 데이터이다. 뷰(View)는 모델을 통해 데이터를 처리하는 핵심, 비즈니스 로직이 담겨 있다. 마지막으로 템플릿(Template)은 장고의 템플릿 엔진과 템플릿 문법을 사용해서 화면을 그리는 역할을 한다. 

 

 

MVC 패턴이 왜 유용한가? 왜 모델과 뷰를 분리시켜야 한다고 생각하나?

모델과 뷰를 분리시키지 않으면 로직이 복잡해지기 때문이다. 예를 들면 장고의 models.py에서는 DB에 저장되는 모델을 선언하고, 필요하다면 이와 관련된 메소드를 추가로 선언하는 작업을 한다. 반면 views.py에서는 직접적으로 쿼리셋을 다루지는 않고, 핵심 비즈니스 로직을 다룬다. 이렇게 분리시키지 않으면 로직이 복잡해진다고 생각한다. 

 

 

Fat Models, Skinny View의 원칙이 항상 옳은 것은 아니지만 대개 개발할 때 지향하는 원칙이다. 이유는 뭐라고 생각하나?

뷰에는 핵심 비즈니스 로직이 들어가는 것이 일반적이다. skinny view를 지향하는 이유는 뷰에 핵심 로직만 남겨두기 위함이라고 생각한다. 만약 핵심 로직이 길면 그때는 또 다른 방법을 사용할 수 있겠지만, 적어도 핵심 로직과 관련되지 않은 모델 단의 데이터를 다루는 작업은 모델로 옮기는 것이 바람직하다는 의미라고 이해했다. 

 

 

장고에는 Manager가 있다. 왜 Manager가 필요할까?

Manager를 사용해서 여러 메소드를 정의할 수도 있고, 프록시 모델을 만들 수도 있는 등 여러 기능이 있다고 알고 있다. 그리고 manager는 모델과 DB 사이에 있는 존재이다. 필요에 따라서 모델의 동작 방식을 바꿀 수도 있기 때문에(어떻게 바꾸는지는 모르겠다...) 필요하다고 생각했다. 

 

 

장고에서 Queryset이 필요한 이유는 무엇일까? 

장고 ORM이 필요한 이유와 같다. 쿼리셋이 있기에 장고에서 DB의 데이터를 조회할 때 장고의 문법으로 조회할 수 있다.

 

 

 다음에 답해볼 질문들

적어봤는데 너무 많아서 이번에 다 셀프 문답을 못 했다... 아래의 질문들 및 추가 질문들을 더 꼬리질문으로 이어서 다음에 답해 봐야겠다. 

  1. 이벤트 기반의 아키텍처(event-driven architecture)는 무엇일까?
  2. 아까 IoC 얘기로 돌아가 보자. 스프링에서는 스프링 빈과 컨테이너를 통해서 IoC를 한다. 즉 개발자가 코드의 제어권을 스프링 컨테이너에 넘기고, 스프링 컨테이너에서 스프링 빈을 관리하면서 IoC가 일어난다. 장고에서는 이를 어떻게 할까? 
  3. WSGI 표준을 구현한 WAS들 중 gunicorn의 특징이 뭔지 말해봐라.
  4. WS와 WAS가 어떻게 통신해서 하나의 요청을 처리하는지 그 과정을 설명해 봐라.

 

 궁금한 점

  1. fat models skinny view를 지향하는 현업에서의 이유가 궁금하다. 왜 fat view는 안 괜찮고 fat models은 괜찮은 걸까? 
  2. model에서 manager가 필요한 이유를 정확히는 설명하지 못하겠다. 모델과 DB 사이의 매개 객체가 필요하다고는 생각하는데, 구체적으로 왜 그런 걸까? 

 

 오늘 배운 것

오늘은 의도치 않게 쉬는 날이 되어버렸다. 사실 OneStep 개발은 좀 쉬더라도 내일 있을 토스 next 과제전형에 대비해서 뭐라도 좀 해 놓으려고 했다(내가 알고 있는 django 지식 정리를 해 보려고 했다). 그러나 오늘 점심에 분당으로 먼 길을 떠나 면접을 간 이후로 왜인지 모르게 의욕이나 기운이 잘 안 났고, 이 상태로 뭔가를 하다가 스트레스를 더 받을 것 같다는 생각이 들어 오늘은 좀 쉬어가기로 했다. 

 

사실 오늘 쉬지 않으면 주말에 제법 빡센 일정을 소화해야 하기에 조금 쉬어가야 할 것 같았다. 

 

그래도 기록이 끊기는 건 제법 아쉬운 일인지라 뭘 쓰면 좋을까 하다가, 문득 9월 1일에 중간 회고를 한 지로 한 달이 벌써 지났다는 걸 알았다. 그때는 어떤 마음이었을까 하고 보니 지금과 큰 차이는 없다가도, 그때가 좀 더 취준에 대해서 막연한 불안감은 많이 느꼈었던 것 같다. 왜냐하면 9월은 이제 막 공고가 열리는 시점이었기 때문이다. 

 

그리고 나름 반성되는 점도 생각났다. 분명 그때 회고에 '주 15시간 이상은 취준에 쓰지 않기'라고 되어 있었는데... 지금 하는 걸 보면 취준 반 프로젝트 절반인 것 같아서 팀원들에게 미안한 마음이 들었다. 9월에 폭풍 지원을 하고 난 뒤로 채용 절차의 무서움을 느껴서 지금은 지원 소강 상태이긴 하지만, 그래도 벌여놓은 일이 있어서 수습은 해야 할 것 같다. 그러면서도 내 스스로는 분명 소마 프로젝트에 생각보다 시간을 덜 썼다고 여겼기에 이 부분에 대해서도 팀원들과 얘기해봐야 할 것 같다.

 

그리고 생각보다 채용 프로세스가 길고 복잡한 기업들이 많은 것 같았다. 막상 지원할 땐 어떻게든 되겠지, 붙기만 한다면 뭐든 할 수 있겠다는 마인드였지만 인적성 검사, 코딩테스트, 1차 면접, 컬처핏 면접 등을 겪어보면 이 모든 걸 뚫어야 하나의 기업에 간신히 합격한다는 생각도 많이 든다. 이래서 다른 분들도 지원은 신중하게 해야 한다는 조언을 주셨구나 싶다. 

 

그래도 나는 후회가 덜한 소마 생활을 하고 싶다... '후회가 덜하다'의 기준은 무엇일까? 11월 말까지 나는 무엇을 해야 그래도 나름 내가 이 과정을 잘 마쳤다고 여길까. 답은 지금 내가 상대적으로 소홀해진 소마 생활이었다. 

 

핵심 기능은 개발된 상황이라 할 것이 많지는 않은 것 같다가도, 이제부터가 고도화의 시작이라는 생각이 많이 든다. 세세한 부분을 고치고 발전시키는 일은 얼핏 보면 긴급하지 않아 덜 중요하게 보이다가도, 막상 프로젝트를 하면서 내가 그런 식으로 발전시킨 부분이 많이 없었다는 점에서 항상 부족함을 느껴 왔었다. 지금이 그걸 보완할 기회였던 것이다. 

 

그러면 나는 이제 어떻게 해야 할까? 이제 이걸 알았으니 보완을 해 보고 싶다. 어떻게 하면 지금 내가 벌인 일들을 놓치지 않고 수습하면서도 프로젝트에도 적절히 시간을 쏟을 수 있을까? 다 하려는 것이 욕심일지도 모르겠다. 하지만 욕심을 좀 부려본다면 어떻게 해야 할지 고민이 많다. 어쩌면 멘토님이 말씀하셨던 것처럼 답은 꾸준한 운동과 회고에 있을지도 모르겠다. 우선은 내가 해볼 수 있는 일들을 하나씩 처리해 보자. 

 

 오늘 배운 것

어제 발생했던 이슈를 처리하고 이제 남은 다국어 처리를 마저 진행해보자. 어제 참고한 블로그와 비슷한 방식으로 .json 파일을 작성한 다음 react-i18next의 useTranslation 훅을 가져와서 사용했는데, 다음과 같은 에러가 났다. 

 

i18next 인스턴스를 init한 다음에 사용되는 방법이 잘못된 것 같았다. 그래서 이번에는 /locales/index.js 파일에서 export한 i18n 객체를 import해서 위의 코드의 인자로 넣어주려고 했다. 즉 이전 코드가 다음과 같다면,

import { useTranslation } from 'react-i18next';

const Login = () => {
  const { t } = useTranslation();
  ...

 

이 코드를 다음과 같이 바꿔주려고 했다. 

import i18n from '@/locales/index';
import { useTranslation } from 'react-i18next';

const Login = () => {
  const { t } = useTranslation(i18n);
  ...

 

그랬더니 이번엔 다른 에러가 났다. 

 

npm install로 설치한 'react-native-localize' 라이브러리에서 오류가 나는 것으로 보였다. 그런데 공식문서를 참고해도 그냥 npm install을 하고 사용을 하면 된다고만 나와있어서, 에러가 어디서 났는지 갈피가 잡히지 않았다. 아니면 어제 마주한 것처럼 또 캐시 관련 오류인 걸까?

 

문득 여러 다국어 라이브러리들을 설치하고 사전 조치들을 안 해서 나는 에러인가 싶어서 react-native-i18n의 공식문서도 찾아보았다. 그랬더니 android/ios별로 추가적으로 실행해야 하는 작업이 있다고 나와있었다.

 

 오늘 배운 것

어제 있었던 이슈는 특수 상황이기도 했지만, 프론트 멘토님의 조언을 참고하면 두 가지의 해결 방안이 있는 것으로 보였다.

1. 프로덕션과 개발 DB를 같이 쓰기

2. 개발환경 앱 빌드와 프로덕션 앱 빌드 환경을 분리하기

 

사실 2번의 의미를 정확히는 이해하지 못했다. 빌드 환경을 분리한다는 것은 어떤 의미일까? 아예 다른 환경에서 앱이 실행되는 거라면, 그렇다면 개발 환경에서 사용되는 자원(AsyncStorage 등)과 프로덕션 환경에서의 자원이 분리되는 의미라고 이해했다.

 

이 경우 프로덕션 앱 빌드를 하다가 개발 앱 빌드를 하더라도 둘이 자원을 공유하지 않으니 기존 환경에서의 AsyncStorage에 기존 토큰이 남아있을 리 없다. 

 

사실 1번의 방법도 가능은 하지만, 그래도 원칙상 DB를 서로 분리해두고 싶었다. 그래야 사용자 데이터와 개발하면서 쌓은 데이터를 서로 분리할 수 있을 것 같았다. 

 

여튼 이 문제는 급한 문제가 아니기도 하면서, 여러 가지 해결 방법을 써볼 수 있을 것 같았다. 그래서 일단은 잠시 보류하고 원래 작업하던 사용자 문의 폼을 적용하는 이슈로 돌아와 보자. 


첫 번째 문제는 '구글 폼'이라는 글자를 눌렀는데 구글 폼이 안 열리는 문제였다. 이 문제는 알고보니 RN의 Text 컴포넌트를 누르고 싶다면 onClick이 아니라 onPress 속성을 사용했어야 했다. 즉 속성명을 잘못 사용해서 발생한 문제였다. 

잘 뜬다!

 

두 번째는 문의하기를 누르면 헤더에 'settingsContactView'라는 jsx 파일의 이름이 그대로 뜨는 문제였다. 이 부분은 tabs와 관련이 있을 것 같았다. 

 

'expo router stack.screen' 이라고 검색했더니 나온 공식문서를 참고해봤다. 우리 프론트 프젝에서는 라우팅 라이브러리로 'expo-router'를 사용하기 때문이다. 

 

암튼 그랬더니 <Stack.Screen /> 이라는 컴포넌트 안에 options 이라는 속성값으로 'title' 값을 지정해주면 된다고 나와있었다. expo-router에서는 Stack이라는 컴포넌트를 기준으로 앱에서 navigation을 진행한다. 구현되어 있는 걸 보진 않았지만, 실제 스택처럼 라우팅 기록을 저장하고 필요 시 취소(롤백)하는 식으로 사용하는 것 같았다. 

 

아래와 같은 코드를 _layout.tsx 파일의 Stack 리스트 맨 밑의 원소로 추가해 주었다. 그랬더니 '문의' 화면으로 잘 바뀌었다.

<Stack.Screen
  name="settingsContactView"
  options={{
  headerTitle: '문의',
  headerTitleAlign: 'center',
  }}
/>

 

무사히 PR도 올렸다. 


이제는 다음 이슈인 다국어 지원을 할 차례이다. 

 

'react native 다국어 지원' 이라고만 검색했는데 굉장히 많은 포스트들이 나왔다. 그중 한 개를 클릭해서 보니, 다국어 지원에는 두 가지의 라이브러리가 필요한 것으로 보였다. 

 

첫 번째는 react-native-localize로, 현재 앱이 실행중인 국가의 코드를 얻어오는 라이브러리이다. 사용자의 현 위치에 따라서 자동으로 언어를 설정해 주려면 필요한 라이브러리이다. 두 번째는 i18next, i18next-react로 이것이 다국어 라이브러리라고 한다.

 

공식문서의 Getting Started 가이드와 심화 가이드를 보면서 진행해 보자. 우선 'npm install'을 통해 필요한 i18next, react-i18next 라이브러리를 설치하자. 

npm install react-i18next i18next react-native-localize --save

 

그 다음으로는 앱의 루트 파일에(공식문서에서는 App이 위치한 파일) i18을 초기화하는 코드를 작성해 주어야 했다. 우리는 기본으로 App 파일이 아니라 조금 다른 라우팅 시스템을 사용해서 폴더 구조가 조금은 다른데, 어쨌든 _layout.jsx 파일이 App.js의 역할을 하는 것은 맞다. (위의 이슈에서 Stack.Screen 컴포넌트를 추가한 파일이다.) 

 

해당 파일(_layout.jsx)에다가 다음과 같이 일단 예제 코드를 복붙해 주었다. 

import i18n from 'i18next';
import { getLocales } from 'react-native-localize';

i18n.use(initReactI18next).init({
  resources: {
    en: {
      translation: {
        'Welcome to React': 'Welcome to React and react-i18next',
      },
    },
  },
  lng: getLocales()[0].languageCode,
  fallbackLng: 'en',
  interpolation: {
    escapeValue: false,
  },
});

 

그리고 'resources' 속성 안에는 언어별로 dictionary 형태로 어떤 언어를 사용할지에 대한 정보가 들어가게 되는데, 공식문서에서는 이를 별도의 json 파일로 뺄 것을 추천하고 있었다. 그렇게 해야 코드가 복잡해지지 않을 것 같았다. 'fallbackLng'는 모종의 이유로 다국어 전환이 실패했을 때 어떤 언어를 보여줄지를 의미하는 것 같았다. 

 

그리고 react-native-localize에서 얻은 getLocales() 함수로 현재 접속한 위치에서 사용하는 기본 언어를 띄워서 lng(language) 파라미터 값으로 세팅해 준다. 

 

그리고 다시 실행을 하려는데, 웬걸 본적 없는 에러가 났다. 버전 미스매치 에러인데, 문제는 나는 저 라이브러리를 최근에 손댄 적이 없었다. 아마 직접적인 원인보다는 다른 이유로 난 에러 같았다.

 

에러 페이지깃허브 이슈를 돌아다니며 여러 명령어들을 시도해 보았다. 깃허브 이슈를 참고한 결과 이 이슈는 라이브러리 자체의 의존성 문제보다는 캐시로 인해서 뭔가 데이터가 꼬였을 가능성이 있었다. 

 

아래 명령어를 실행해 보고, 에뮬레이터에 있는 앱을 지웠다가 다시 빌드를 실행하니 그제서야 잘 되더라. 

rm -rf node_modules && npm cache clean --force && npm install && watchman watch-del-all && rm -rf $TMPDIR/haste-map-* && rm -rf $TMPDIR/metro-cache

 

 오늘 배운 것

오늘 팀원들과 모여서 스프린트 회의를 하면서 개발 진행상황을 공유하는데 이상한 점이 있었다.

바로 프론트 팀원이 개발 환경에서 앱을 실행했다가 바로 프로덕션 환경에서 실행했을 때, 전혀 다른 두 아이디로 유저 정보가 나오는 오류가 있었다. 

 

이 문제가 신기했던 것은, 개발 환경 -> 프로덕션 환경으로 실행할 때에 나오는 유저 정보와 프로덕션 환경 -> 개발 환경으로 바꿔서 실행할 때에 나오는 유저 정보가 다르다는 거였다. 

 

알고보니 이 문제의 원인은 다음과 같았다. (개발 환경 -> 프로덕션 환경의 경우)

1. 개발 환경에서 로그인하면서 발급된 액세스토큰을 AsyncStorage에 저장한다. 

2. 프로덕션 환경으로 다시 앱을 실행했을 때 기존에 AsyncStorage에 저장된 액세스토큰이 있는지를 확인하게 된다. 

3. 이때 1번에서 저장한 액세스토큰으로 접근을 시도한다. 

 

여기서 1번에서 AsyncStorage에 저장한 토큰을 디코딩해보면 user_id 값이 저장되어 있었다. 그런데 개발 DB에 해당 user_id로 저장된 유저도 있었고, 프로덕션 DB에 해당 user_id로 저장된 유저도 모두 있었던 것이다. 

 

그래서 두 케이스(개발->프로덕션, 프로덕션->개발)에서 나타나는 이메일이 달랐던 것이다. 

 

그래서 생각해본 해결 방법으로는 다음과 같다. 

1. 프로덕션과 개발 서버의 액세스토큰이 서로 사용 가능해서 생긴 문제이니, 액세스토큰이 호환되지 않도록 한다. 

2. 스태프 계정은 단순 액세스토큰 및 구글로그인으로 접근 불가능하게 한다. 

3. 프로덕션과 개발 서버가 같은 DB를 사용하게 하도록 한다. 

4. 특수한 상황에서 생긴 문제이니 해결하지 않는다. 

 

 오늘 배운 것

어제 만들고 피드백을 받은 폼을 적용하면 된다. '설정' 페이지에서 '문의하기' 버튼을 누르면 폼 링크를 안내하면 되겠다. 

현재 설정 화면은 이렇게 되어 있는데 누르면 아무 변화가 없이 껍데기로 만들어 놓은 상황이다. 이 중에서 '문의' 버튼을 클릭했을 때 폼 링크를 띄워줘야 한다. 

 

그렇게 하기 위해서 기존에는 id 값과 title 값만 있었던 data 배열에 해당 항목을 누르면 호출될 함수를 추가해 주었다. 

const data = [
    {
      title: '내 정보',
      id: 1,
      handlePress: () => {},	// 추가한 속성
    },
    {
      title: '언어 변경',
      id: 2,
      handlePress: () => {},	// 추가한 속성
    },
    {
      title: '문의',
      id: 3,
      handlePress: () => {},	// 추가한 속성
    },
  ];

 

여기서 지금 작업할 부분은 '문의' 부분이다. 이 버튼을 누르면 나타날 페이지 하나를 새로 만들고, 그 페이지로 라우팅시키는 코드를 작성해 보자. 

 

위의 id:3번의 handlePress 함수를 다음과 같이 바꿔주었다. 

handlePress: () => {
  router.push('settingsContactView');
}

 

그리고 'settingsContactView'에 대한 파일도 만들어 주었다. 

const settingsContactView = () => {
  const handleGoogleFormPress = () => {
    Linking.openURL(googleFormUrl);
  };

  return (
    <>
      <IconRegistry icons={EvaIconsPack} />
      <ApplicationProvider {...eva} theme={eva.light}>
        <SafeAreaView style={styles.container}>
          <Layout style={styles.layout} level="1">
            <Text style={styles.text}>
              <Text style={styles.link} onClick={handleGoogleFormPress}>
                구글 폼
              </Text>
              으로 문의해 주세요.
            </Text>
          </Layout>
        </SafeAreaView>
      </ApplicationProvider>
    </>
  );
};

export default settingsContactView;

 

그 결과 위에서의 '문의' 버튼을 누르면 아래와 같이 뜬다.

잘 동작하는 것 같지만 두 가지의 문제가 있다. 

1. 헤더의 'settingsContactView'를 '문의'로 바꿔야 한다. 

2. '구글 폼'을 누르면 아무리 에뮬레이터에서 실행 중이더라도 구글 폼이 웹 상에서 열려야 하는데 열리지 않는다. 

 

이 두 문제를 내일 해결해 보려고 한다. 


아 그리고 앱이 런칭되었다..!! 물론 아직 기능을 붙이고 자잘한 오류가 있으면 고치는 단계지만, 구글 플레이스토어 심사가 오래 걸린다는 말을 많이 들어서 사실 몇 주 전에 개발과 같이 이 런칭을 병행하고 있었다. 

어제 드디어 런칭이 됐다는 소식을 듣고 비록 내가 런칭한 건 아니지만 얼마나 뿌듯하던지... 앱의 링크를 슬쩍 남겨본다. 

https://play.google.com/store/apps/details?id=com.safezone.onestep&pcampaignid=web_share

 

 오늘 배운 것

어제 작업했던 비동기 뷰로 변환하는 일은 1차적으로는 끝냈다. 일단 올려보고 에러가 있다면 수정해보면 되겠다.

 

이제는 버그나 요청사항에 대한 사용자 문의 폼을 만들어보자. 기존에는 직접 프론트에 폼을 만드는 걸 생각했는데 멘토님께서도 그냥 구글폼으로 만들면 더 편하지 않겠냐고 하셨고, 꼭 프론트에서 폼을 직접 구현할 필요가 없었기에 구글폼을 사용하기로 결정했다. 이 부분은 직접적인 개발은 없기에 조금은 기획 쪽에 가깝다고 생각했다. 

 

그렇다면 어떤 내용이 구글폼에 들어가야 할까? 우선 사용자의 신원을 파악할 수 있는 이메일(우리 서비스에 로그인했을 때 사용했던 것)과, 어떤 버그가 있는지, 아니면 어떤 요청사항이 있는지를 적을 수 있게 해야 하겠다. 일단 생각해본 질문들은 다음과 같다. 

 

1. (필수) 로그인할 때 사용했던 이메일을 알려주세요. 

2. (필수) 어떤 종류의 문의를 하시는지 알려주세요. 

3. (선택) 피드백에 대해서 설명해 주시면 감사하겠습니다. 

4. (선택) 관련 스크린샷이나 영상이 있으시다면 첨부해주시면 감사하겠습니다. 

 

일단 생각나는 문항은 이 정도이다. 우선은 팀 공동 이메일로 접속한 다음 예시 폼을 만들어 주었다. 그리고 팀원들에게도 피드백을 받아서 문항을 수정해보면 될 것 같다. 

 

 오늘 배운 것

오늘은 비동기 뷰로 변환하기 위해서, gunicorn을 통해 WSGI 기반으로 동작하는 서버를 uvicorn, gunicorn을 같이 사용하여 ASGI 기반으로 동작하도록 변환해 줄 것이다. 

 

공식문서를 참고해서 기존 커맨드를 다음과 같이 변경해 주었다. 

# 기존 커맨드
gunicorn -w 2 --timeout 300 -b 0.0.0.0:8000 onestep_be.wsgi:application
# 새 커맨드
python -m gunicorn -w 2 -b 0.0.0.0:8000 onestep_be.asgi:application -k uvicorn_worker.UvicornWorker

 

-b는 --bind의 약자로, 0.0.0.0:8000 부분을 추가해주지 않으면 오직 localhost에서 오는 요청만 받는 것이 기본값으로 되어있다. 실제로 그래서 예전에 오류가 있었기에, 꼭 이 -b 옵션을 붙여주자. 

 

또한 기존 커맨드는 gunicorn 기반으로 wsgi.py 코드를 실행하는 반면 새 커맨드는 uvicorn 기반으로 asgi.py 코드를 실행한다. 두 코드는 뭐가 다를까? 

 

# wsgi.py
import os

from django.core.wsgi import get_wsgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'onestep_be.settings')

application = get_wsgi_application()
# asgi.py
import os

from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'onestep_be.settings')

application = get_asgi_application()

 

wsgi.py는 django.core.wsgi에서 get_wsgi_application()을 실행하는 반면 asgi.py는 django.core.asgi에서 get_asgi_application()을 실행하는 것이 유일한 차이였다. 함수 안을 보자. 

 

내부 로직도 비슷하게 둘 다 각각 WSGIHandler, ASGIHandler를 호출하고 있었고, 두 핸들러는 모두 BaseHandler를 상속받고 있었다. BaseHandler의 로직은 복잡해서 다 이해하지는 못했지만, 핸들러가 호출되면 기본적으로 __call__ 메소드가 호출되고, 각각의 두 핸들러는 이걸 오버라이딩 한 것으로 보였다. 그래서 일단은 두 핸들러의 __call__ 메소드 부분만 가져와 보았다. 

# wsgi.py
class WSGIHandler(base.BaseHandler):
    request_class = WSGIRequest

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.load_middleware()

    def __call__(self, environ, start_response):
        set_script_prefix(get_script_name(environ))
        signals.request_started.send(sender=self.__class__, environ=environ)
        request = self.request_class(environ)
        response = self.get_response(request)

        response._handler_class = self.__class__

        status = "%d %s" % (response.status_code, response.reason_phrase)
        response_headers = [
            *response.items(),
            *(("Set-Cookie", c.output(header="")) for c in response.cookies.values()),
        ]
        start_response(status, response_headers)
        if getattr(response, "file_to_stream", None) is not None and environ.get(
            "wsgi.file_wrapper"
        ):
            # If `wsgi.file_wrapper` is used the WSGI server does not call
            # .close on the response, but on the file wrapper. Patch it to use
            # response.close instead which takes care of closing all files.
            response.file_to_stream.close = response.close
            response = environ["wsgi.file_wrapper"](
                response.file_to_stream, response.block_size
            )
        return response
# asgi.py
class ASGIHandler(base.BaseHandler):
    """Handler for ASGI requests."""

    request_class = ASGIRequest
    # Size to chunk response bodies into for multiple response messages.
    chunk_size = 2**16

    def __init__(self):
        super().__init__()
        self.load_middleware(is_async=True)

    async def __call__(self, scope, receive, send):
        """
        Async entrypoint - parses the request and hands off to get_response.
        """
        # Serve only HTTP connections.
        # FIXME: Allow to override this.
        if scope["type"] != "http":
            raise ValueError(
                "Django can only handle ASGI/HTTP connections, not %s." % scope["type"]
            )

        async with ThreadSensitiveContext():
            await self.handle(scope, receive, send)

    async def handle(self, scope, receive, send):
        """
        Handles the ASGI request. Called via the __call__ method.
        """
        # Receive the HTTP request body as a stream object.
        try:
            body_file = await self.read_body(receive)
        except RequestAborted:
            return
        # Request is complete and can be served.
        set_script_prefix(get_script_prefix(scope))
        await signals.request_started.asend(sender=self.__class__, scope=scope)
        # Get the request and check for basic issues.
        request, error_response = self.create_request(scope, body_file)
        if request is None:
            body_file.close()
            await self.send_response(error_response, send)
            await sync_to_async(error_response.close)()
            return

        async def process_request(request, send):
            response = await self.run_get_response(request)
            try:
                await self.send_response(response, send)
            except asyncio.CancelledError:
                # Client disconnected during send_response (ignore exception).
                pass

            return response

        # Try to catch a disconnect while getting response.
        tasks = [
            # Check the status of these tasks and (optionally) terminate them
            # in this order. The listen_for_disconnect() task goes first
            # because it should not raise unexpected errors that would prevent
            # us from cancelling process_request().
            asyncio.create_task(self.listen_for_disconnect(receive)),
            asyncio.create_task(process_request(request, send)),
        ]
        await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
        # Now wait on both tasks (they may have both finished by now).
        for task in tasks:
            if task.done():
                try:
                    task.result()
                except RequestAborted:
                    # Ignore client disconnects.
                    pass
                except AssertionError:
                    body_file.close()
                    raise
            else:
                # Allow views to handle cancellation.
                task.cancel()
                try:
                    await task
                except asyncio.CancelledError:
                    # Task re-raised the CancelledError as expected.
                    pass

        try:
            response = tasks[1].result()
        except asyncio.CancelledError:
            await signals.request_finished.asend(sender=self.__class__)
        else:
            await sync_to_async(response.close)()

        body_file.close()

 

asgi.py는 asyncio라는 비동기 관련 모듈을 이용해서 handle() 함수에서 메인 로직을 실행하는 것으로 보였다. 그리고 응답 body를 여러 개의 메시지로 쪼갤 수 있다는 것을 감안해서(coroutine과 연관이 있는 듯 하다) chunk_size라는 변수도 선언해 준 것으로 보인다. 그리고 기본값으로 ASGI의 경우는 HTTP 요청만 실행하는 것으로 보였다. 이유가 왜인지는 모르겠다. 

 

 궁금한 점

1. 왜 ASGIHandler는 HTTP 요청만 서빙할 수 있도록 해 두었을까

2. asyncio라는 모듈은 장고뿐만 아니라 파이썬 내에서 사용되는 것으로 보이는데 이 모듈은 어떤 역할을 하는지도 알아보자.

 

+ Recent posts