ha-ah

로그, 게으른 로그

심플을 생각한다 표지

새로운 회사에서 필독서라고 나눠준 책.

책 제목처럼 심플하다. 책이 작은데 겉감도 착착 손에 달라붙어서 좋기도 하고, 글씨도 큼지막하니 내가 서너 시간만에 읽을 수준이 된다.

이전 회사의 필독서가 ‘이기는 습관‘였는데, 두 회사의 성향이 그대로 드러나는 게 재밌다.

‘이기는 습관’과 비교해보면 성공과 실패를 두루 겪은 저자가 자신의 성공 비결을 담은 책이라는 공통점이 있다.

나의 경우 소기업에서 1년 반, 긴장감 없었던 중소기업에서 5년, 대기업 스타일의 회사에서 5년을 지내다 스타트업 분위기의 회사를 처음 왔는데,

이 네 회사의 분위기가 천차만별이라 참 재미있다.

이전 회사에서 그토록 바꾸고 싶었고 다들 그건 불가능 하다고 생각하는 많은 것들이 스타트업계에선 이뤄지고 있고,

그나마 난 좋았다고 생각했던 몇가지는 여기서 중요한 가치가 아닌 것 같기도 하다.

어쨌든 이건 독후감이니까 책 이야기를 좀 더 해보면,

싸우지 않는다
차별화는 노리지 않는다
계획은 필요없다
높은 사람은 필요없다
경영은 관리가 아니다

책 표지에 써 있는 이 자극적인 문구는 그냥 그 시절 그 상황에서 선택한 전략일 뿐이라는 생각이 든다.

IT 업계처럼 변화가 빠른 시장에서 성공하려면 그동안 경영이나 업무에서 중요하게 여겨지던 가치를 다시 한번 의심해봐야 한다는 것이 골자인 것 같다.

가장 중요한 것은 고객을 만족시키는 제품이라는 것.

맞는 말이지. 이건 모든 회사가 입버릇처럼 이야기 하지만 진짜 고객 중심으로 일을 하는 회사는 정말 적을 것이다.

이 책이 일본에서 인기가 있었던 이유도 그 관료주의의 일본에서 그런 진보적인 방식으로 성공했다는 것을 보여줬기 때문이 아닌가 싶기도 하고.

어쨌거나 중요한 건 뛰어난 인재를 확보하고 그들이 뛰어다니게 놔두는 것을 여러 번 강조하는데,

그렇게 한다고 다 성공하는 건 아니겠지만, 참 좋은 전략이라고 생각한다.

판단을 참 잘 했고 그게 시장에서 잘 먹혀들어 갔고.

사장은 저런 말을 해도 일본의 라인이나 한게임의 직원들은 불만이 가득하거나 이미 ‘저렇게 해선 성공할 수 없어’라고 외치고 있을 지 모르겠다.

라인과 한게임도 시장에서의 위치나 성과나 목표가 계속 바뀔 것이고 이 책도 결국 늙어버리겠지.

그럼에도 이들에게 얻을 수 있는 교훈이라면,

고객을 만족시키는 제품을 만들기 위해 기존의 가치에 안주하지 않고 변화하거나 변화에 대응할 것.

왠지 낯뜨거운 결말이긴 하지만 그렇게 한번쯤 일 해보고 싶긴 하다. 가족들 얼굴은 보고 살면서 말이다.

얼마전 가비아 DNS 장애가 있었는데, ‘그렇다면 내 블로그는!?’ 하면서 들어가봤더니 역시나 접속이 안됐다.

최근 한달 이상 커뮤니티에 글을 안 올렸으므로 방문자 수는 바닥을 기고 있는데,

집에 두고온 휴대폰에 부재중 하나 없는 상황 같다고 농을 치다 보니 이러면 안되겠다는 생각이 들었다.

마침 퇴사할 때 기슐 뉴스 공유글이라도 좀 보내 달라고 하셨던 분도 계셔서 겸사겸사 기슐뉴스라도 꾸준히 올리자는 생각을 했다.

내가 기술 뉴스를 접하는 곳은

  • 이제 서비스를 접은 다음 LENS에서 제공하던 OPML로부터 RSS 피드를 받고 (여기에 내가 직접 추가한 열댓개의 사이트)
  • 난하님의 READTREND
  • 아웃사이더님의 기술 뉴스
  • B급 프로그래머님의 소식 (이것도 RSS 피드에 있었다)
  • 기타 페이스북 커뮤니티에 올라온 글
  • 엄청난 좋아요와 공유를 하시는 몇몇 페친분들

이쯤 되면 어지간히 이슈가 되는 글은 대충 보게 되는 것 같다. (사실 트위터를 해야 완벽하지만 거긴 내 삶을 바쳐야할 것 같은 느낌이라…)

이 수많은 글을 다 읽는 건 아니고,

내가 관심이 가는 주제만 읽고 링크를 저장하는 수준이라서 매우 편향적이고 그 조차도 일부만 여기에 옮겨 놓으려고 한다.

저장 폴더에 몇달 째 쌓여만 있는 글도 허다하다.

그러니 제대로된 기술 뉴스를 접하시려거든 위에 언급한 방법을 쓰시는 게 적당하다는 생각이다.

이전 회사에서 글을 공유할 때는 회사 문화를 바꾸는 데 도움이 되는 글이나, 회사 기술과 더 밀접한 글을 올리기도 했고, 내 글을 열심히 읽는다고 피드백 해주는 사람들이 좋아할 만한 글을 고르기도 했다. 그러니까 나만 좋아하는 글은 공유 안 했다는 말이다. (그…랬겠지?)

이제 내 개인 블로그니까 내가 재밌었고 내가 기억하고 싶은 글만 남겨봐야겠다.

사실 이렇게 기술 공유 글을 어딘가에 저장해 놓으면 내가 다시 찾아보기도 편해서 에버노트에도 꾸준히 백업해 놨었더랬다.

그럼 기슐 뉴스…아니지 뉴스라고 하기에는 너무 오래된 링크 모음이라서…

그냥 뉴스 아카이브라고 해야겠다.

넷플릭스에서 play를 누르면 일어나는 일

http://highscalability.com/blog/2017/12/11/netflix-what-happens-when-you-press-play.html

아웃사이더님의 기술뉴스에서 본 글인데 매우 길고 영어라서 읽을까 말까 하다가

나는 클라우드 쪽 지식이 미천하므로 출퇴근 시 읽어보기로 했다.

그런데 이 글은 Explain the Cloud Like I’m 10이라는 책에서 온 것이라고 하고, 클라우드를 처음 접한 사람들을 위한 책이었다고 한다. 댓글을 보니 글을 쓴 이후 뭔가 바뀐 게 있어서 몇가지 더 추가/변경한 것으로 보인다.

책 제목처럼(혹은 책의 표지처럼) 굉장히 쉽게 쓴 글이다.

아마 진짜로 10살을 위한 책은 아닐텐데, 용어가 꽤 어려운 편이다. 그냥 클라우드 쪽에만 newby인 사람들을 위한 책 같기도.

그래서 여기서 딱히 나에게 기술적인 욕구를 채웠는가 하면 그건 아니고, 이 양반이 글을 쓰는 방식이 매우 좋았다.

매우 쉽고, 조금 어려워 지는가 싶으면 다시 개념을 짚어준다.

계속 ‘그래, 글은 이렇게 써야 해’ 외치면서 봤다.

넷플릭스의 변화와 시도를 읽는 재미도 있어서 매우 추천하고 싶은 글이다.

한동안 영어로 된 글을 거의 접하지 않았기 때문에 다 읽고 한결 더 뿌듯했다.

지금은 Chrome is turning into the new Internet Explorer 6 이런 글을 읽는 중.

유사과학 탐구영역

http://webtoon.daum.net/webtoon/view/PseudoScience

문과 vs 이과를 다루는 농담처럼 좀 촌스럽거나 이과 부심을 부리는 장면도 보인다만

이과 입장에서야 속이 후련한 좋은 시도라고 생각한다.

(알쓸신잡에서 다루는 내용도 이렇게 좀 짚어주면 좋겠다는 생각을…)

여기서 말하는 것 역시도 틀린 얘기일 수 있는데, 이럴 때는 댓글을 보면 된다. 진지한 사람들 엄청 출동해 계신다.

ECMAScript와 TC39

http://ahnheejong.name/articles/ecmascript-tc39/

예전에 맹선생님이 페북 그룹에 쓰신 글에서 stage에 대해 언급하셨는데,

대충만 알고 있다가 이제 그 의미를 제대로 이해할 수 있게 됐다.

질문해도 되나요?

https://brunch.co.kr/@hyungsukkim/7

(저작권을 보호하기 위해 복사하기를 막은) 브런치에 올라온 글이다.

페이스북에서 진행되는 Mark’s Q&A에 대한 글이고,

배민에서도 진행하는 것으로 알고 있다. 링크를 못 찾겠네.

나를 포함 우리나라 사람들이 질문을 참 못하는 경향이 있는데, 배민에서의 시도는 조금 더 한국적이라는 생각도 든다.

아무런 질문이나 해보는 경험으로 신뢰를 쌓고 의사소통으로 이어지고…

조직에서 질문을 못하는 이유는 부끄러움도 있을 수 있는데 상대방을 신뢰하지 않아서 생기기도 한다. 아마 그 상대는 보통 ‘윗 사람’ 혹은 기득권자겠고.

페이스북이든 배민이든 내부에는 ‘저래 봤자 지들 하고 싶은대로 하겠지’라고 생각하는 사람은 많을 것 같다만,

좋은 시도라고 생각한다.

읽은 책

  • 01월 자바스크립트 패턴과 테스트
  • 02월 헤드퍼스트 디자인패턴
  • 03월 표현의 기술
  • 04월 RealMySQL (~explain까지)
  • 04월 클린 코더
  • 05월 브이 포 벤데타
  • 05월 HTTP 완벽가이드
  • 05월 코드골프 - 중도 포기
  • 05월 모던 웹사이트 디자인의 정석(s68 HTML & CSS)
  • 09월 아이의 사생활
  • 09월 세상 물정의 물리학
  • 09월 클린 소프트웨어
  • 09월 이와 손톱
  • 10월 내 문장이 그렇게 이상한가요?
  • 11월 밀가루 물 소금 이스트
  • 11월 후암동 식빵
  • 11월 객체지향의 사실과 오해
  • 12월 에고라는 적
  • 12월 DDD Start!
  • 12월 AWS 패턴별 구축 운용 가이드

아쉬운 점

계속 뭔가 책을 한권씩 들고는 있었던 것 같은데 이것 밖에 없다니.

여기서 딱 두 배를 더 읽거나 두 번씩 읽거나 해서 늘려봐야겠다.

디자인패턴은 올 초에만 좀 공부하고 특별히 다음 단계로 나아가지 못한 것은 아쉽다.

이것저것 공부를 해야한다는 강박에 ‘코드 골프’를 중간에 포기한 것도 아쉽다. 시간을 너무 뺐긴다는 이유였지만 그건 어려웠기 때문이었고 이 어려운 건 앞으로 계속 어려울텐데 조금의 진전도 없었기 때문에 아쉽다. 그렇다고 앞으로 그런 여유가 주어질 지는 잘 모르겠다.

RealMySQL은 좋은 책인데 역시 중간에 필요한 것만 봐서 아쉽다. 책이 나온 이후로 MySQL도 제법 버전업이 많이 되었는데 책이 더 늙기 전에 마저 읽어야겠다.

회사에서 진행중인 완벽가이드 릴레이 세미나를 미처 끝내지 못한 것도 아쉽다. 5장 서버 만들기는 블로그에 연재한답시고 글만 길어져서 잠시 쉬는 중.

아이의 사생활을 읽고 육아 관련 책을 더 읽어보려 했는데, 또 잊어버리고 석달이 지났다.

좋았던 책

다 좋았다. 제일 좋았던 하나는 커녕 다섯을 꼽기에도 어려울 정도.

물론 ‘아이의 자존감’에서 리더쉽을 이야기하며 반기문과 안철수를 예로 든다거나,

무려 세곳에서 레시피를 잘못 넣은 ‘후암동 식빵’은 절대 추천할 리 없지만,

그래도 개인적으로는 어떤 면에서 도움이 된 책들이다.

공부

공부를 어떻게 해야할 지 조금씩 감을 잡기 시작한 해라고 본다.

절대적인 양도 늘려야 하고 집중력도 더 키워야 하는 건 당연하고, 보는 문서/책에 있는 모든 용어를 대충 그냥 건너 뛰지 말고 알고 지나야 한다. 알지만 잘 못하고 있는 분야인데, 역시 정리를 하면서 봐야겠다는 생각이 들었다.

타임라인에 계속 올라오는 ‘완벽한 공부법’은 시간별로 정리하고 기록하는 것 같은데, 그걸 읽기 전에 우선 내 스스로 시도를 한번 해봐야겠다.

올해부터 주 20시간 이상을 공부에 투자하겠다는 다짐을 했던가? 잘 지켜지는 것 같고 아내가 배려도 해주고 있다.

습관은 잘 만들어나간 해지만 실속은 좀 부족하지 않았나 생각도 해본다.

그나마 블로그를 시작한 것은 꽤 잘한 짓인 것 같다. 처음에는 별로 쓸 게 없어 고민이었는데,

REST 시리즈와 PHP로 서버만들기 시리즈로 뭔가를 깊게 들어가보는 계기가 되기도 했다. 어쩌면 지나치게 많은 시간을 투자한 것도 같지만 특히 REST는 고생한 보람이 있을 정도로 내 지식이 된 것 같다.

이외에도 몰랐던 지식을 조금 깊게 파보는 글이 남기도 많이 남고 남에게도 도움되고 블로그에 가치를 더한다.

지금부터 지켜나갈 것

  • 의도적으로 집중하기
  • 책 읽는 시간 늘리기
  • 정리하기
  • 집중하는 시간 측정하기

베이킹

BakingNote 구글시트

총 12번 빵을 만들었다. 실패는 3번.

한국에서 즐겨 먹는 스타일이 아니어서 그런지 인기가 매우 좋았다. 다른 빵에 도전하기보다 이 빵을 더 잘 만들어야 하는가 싶을 정도로.

덕분에 주말 공부 시간을 조금 뺐기기도 했는데, 대신 아내 방에서 문닫고 공부하는 것도 할 만 하다는 것을 깨닳음. 딸아이가 의외로 규칙을 잘 지키는데, 그래도 왔다갔다 하거나 문 밖에서 소리치는 건 여전해서 역시 이어폰을 껴야할 듯.

회사나 다른 집에 갖다 주기도 했는데, 막 구웠을 때의 감동을 최종 소비자까지 어떻게 전달할 수 있을지 고민이지만 답이 안 나온다.

수입되는 프랑스 밀가루는 세금 문제로 밀가루 외에 이것저것 많이 포함(3.025%)되었는데, 이 때문인지 지나치게 묽어 진다는 사실도 비싼 라우크 밀가루를 써보고 깨닳았다.

함량은 브랜드마다 차이가 있지만 내가 쓰는 밀가루는, ‘밀가루 96.975%, 밀글루텐, 비타민c, 효소(리파아제,지일라나아제,알파 아밀라아제:비세균성)’ 성분으로 되어 있다.

이 재료들이 어떤 역할을 하는지 더 찾아봐야 한다.

그래서 여러가지 테스트를 해보기로 했다

  • 각 추가 성분(밀글루텐, 효소 등)이 어떤 역할을 하는지 찾아보자
  • 다른 밀가루와 섞어 만들어보자
  • 레시피의 물 함량보다 조금 낮추어 만들어보자
  • 한국 밀가루(강력분, 단백질 함량 13% 정도)로 만들어 본다
  • 독일 밀가루(비타민C만 포함된 제품을 판매함)

DDD Start

톡특한 책이다. 1장부터 개념보다는 소스가 훅 들어와서 당혹스러웠다.

1장에 소스가 들어와서 안될 것은 없지만, DDD 입문서인데 DDD에 대한 정의는 없다는 게 의외다.

중간에 이 책의 대상 독자를 다시 찾아봤다. 요컨대 Spring/JPA는 눈에 익고 DDD를 들어는 봤으나 이해가 안되는 3~4년차 개발자라고 보면 되겠다.

또 하나. AGGREGATE를 동사형으로 애그리게이트라고 발음하면 안되고, 명사형은 [ǽgriget](애그리겥)으로 발음 한다면서 애그리거트라고 부르겠다니 완전 황당했다. 애그리거트라는 글자만 보면 자꾸 신경쓰여 찾아보니 책의 발음기호가 잘못 적힌 것이었다. 실제 발음기호는 [ǽgrigət].

어쨌든.

DDD를 아주 기초만 이해한 상태로 정리를 해보면,

클린코드나 객체지향 원칙을 따르면서 역할을 분리해 나가는 것과 크게 다르지 않다고 본다.

마침 ‘The Clean Architecture’의 번역글을 읽게 됐는데, 여기서도 비슷한 이야기를 하고 있는 것이다.

위 아키텍처의 세세한 부분은 모두 다르지만 매우 비슷하기도 하다. 이들은 모두 같은 목적을 갖고 있는데 바로 관심사의 분리다. 소프트웨어를 계층으로 나눔으로써 관심사를 분리한다. 그리고 모두 비즈니스 규칙을 위한 최소 하나 이상의 계층과 인터페이스를 위한 또 다른 계층을 두고 있다.

그러니까 저 다르면서 비슷한 아키텍처는 다들 고통속에 몸부림치다 이제 그만 열반에 들고자 만들어 낸 사리 쯤으로 봐야하는 건가.

나도 회사에서 (가능하면) 역할별로 클래스를 나누고 컨트롤러에서는 도메인 로직을 제거해나가고 있었는데, DDD에서는 그 역할을 보다 더 분명하게 분리해놨다.

표현 - 응용 - 도메인 - 리포지터리

(곧 떠날) 회사에 만들어 놓은(💩) 코드는 표현만 분리했지 나머지를 뭉뚱그려 놓은 모습이다. 책을 읽다보니 이렇게 네가지로 분리하는 것도 괜찮겠다는 생각이 든다. 애그리거트로 도메인 모델을 묶고, 애그리거트는 응용서비스에서 조합하며, 한 애그리거트에서 다루기 어려운 경우 도메인 서비스를 구성한다.

흠…대충 이런 느낌.

그런데 리포지터리와 모델 매핑 쪽이 이해가 잘 안된다.

리포지터리와 관련된 설명에는 (굳이 프레임웍 의존성을 피하지 않아도 된다면서) JPA를 예를 드는데, 어디까지 DDD와 관련있는지 잘 모르겠다.

그리고 PHP에서는 혹은 엘로퀀트에서는 저 예제를 어떻게 구현하게 될지 전혀 감이 안오는 상태. (JPA도 엘로퀀트도 잘 모르겠어서…)

그리고 현실적으로 단일 애그리거트만 다룰 일은 적을 것이고 수도 없는 bounded context간 통신을 해야한다. 그 더러운 꼴을 보자면 DDD로 개발한다고 뭐 더 좋아진다고 볼 수 만도 없겠지. 그냥 잘하는 사람이 DDD로 개발하면 결과가 좋고 잘하는 사람은 DDD로 안 해도 결과가 좋다는 염세적인 결론이 도출되기도 하지만, 다음 유행이 돌아 또 시스템을 갈아엎기 전까지 어떻게든 제품을 만들고 유지해야 입에 풀칠이라도 하고 살지 않겠는가.

아직 ACL, OHS가 나오는 도표라던가, PHP에서 AOP는 다들 얼마나 쓰고 있는지 궁금한 게 계속 꼬리를 문다.

응용서비스와 도메인서비스의 차이 중에 애그리거트 수정이 발생하면 도메인 서비스에 위치시킨다 정도는 기억해둘만 하다.(아니 겨우 이거 하나 기억했다는 말인가!)

가장 큰 질문은 이거다.

그러니까 이 책을 보고 DDD 감을 익힐 수 있단 말인가? 그 감은 내가 익힌 감이랑 같은 감인가?

그런데 DDD가 뭐더라?

(검색하다 깜짝 놀람. EXID의 노래 ‘덜덜덜’이라니…그러면서 나는 김혜림이란 이름도 기억하고 있다니…)

어쨌든 그동안 내가 희미하게나마 들어왔던 DDD는 이런 느낌이었는데,

이 책의 내용은 거대한 숲 안에서 작은 군락 정도만 표현한 것 같은 느낌을 지울 수 없다.

개발자들이 키보드에 손 올리고 신나게 개발할 준비를 하고 있을 때, ‘업무전문가’는 어디쯤 위치해야 하는가…

에고라는 적

이 책도 역시 컴퓨터 vs 책에서 소개되어 알게 됐고,

서점에서 잠깐 보고 나서 ‘좋은 책인 것 같으나 나에겐 필요 없을 듯’과 같은 식으로 건방진 메모를 하고 있다가,

딱 이 시점에 읽기 좋은 책이라 생각하며 사서 34일 정도에 후다닥 읽어버렸다. (내 최고 기록은 23일 만에 읽은 ‘의뢰인’이었던 것 같다)

그렇다고 이게 엄청 재밌는 건 아닌데, 자신을 돌아보며 적극적으로 읽어나가야 공감이 많이 될 것이다.

맨 처음에 언급한 독후감에 좋은 문구와 동영상과 감상평을 많이 추려 놓으셨는데, 이 분의 결론에 격한 공감을 한다.

결론: 에고 때문에 혼쭐이 나신 분이라면(과연 안 그런 분이 계실까?) 이 책을 읽으면서 여러 가지 감상에 젖을 것이다. 강력 추천!

희대의 잔소리꾼 라이언 홀리데이(작가의 이름)는 위인전에서 위인들의 내면을 다루지 않고 그 사람들의 인생의 단면만을 나열한다고 불만인 것 같으나, 그가 에고가 튼실한 위인을 다루는 방식도 크게 다르지 않은 것 같다.

수많은 명언과 예시를 쏟아내며 잔소리에 잔소리를 거듭하지만 그가 옳다는 근거를 찾기는 힘들다.

그런데 그 잔소리가 꽤 설득력은 있어서 내 과거, 내 선택, 지금 내 생각을 하나하나 짚어가며 들여다보는 재미가 있기도 했다.

나는 에고가 뭔지 정확히 정의를 못 내리겠지만, 이 사람은 에고를 ‘모든 마음의 소리’ 정도로 해석하는 것 같다.

내가 해야 할 올바른 선택을 방해하는 모든 마음의 소리.

그 올바르다는 기준은 미래 시점에 알고 보니 옳았던 선택이 아니고, 합리적인 선택을 의미한다. 충분한 근거로. 더 큰 목표를 갖고. 자신을 객관적으로 바라보면서.

항상 명심하기 위해 작가처럼 ‘에고가 나쁜 놈이네’라고 문신을 새길 순 없지만, 허리를 펼 때면 ‘에고 허리야’ 소리라도 낼까.

아니면 가끔이라도 내 마음의 소리가 내 선택, 내 행동을 잘못 이끌고 있는지 돌아보는 용도로 이 책을 다시 읽으면 좋을 것 같다.

과한 자기 확신으로 책 한권을 채운 잔소리를 3일 내내 읽다보니, (책 뒤표지나 추천사에서) 왜 그토록 많은 뛰어난 인물들이 책을 추천하는 지 알 것 같았다.

읽고 추천한 사람들이 참 뛰어났기 때문이다.

이상한 결론이지만, 나도 이 책을 추천하면서 끝을 내기로…

역할, 책임, 협력 관점에서 본 객체지향

객체지향의 사실과 오해

그동안 객체지향을 오해한 기억이 별로 없어서 그런지 특별히 재밌고 감동적이고 그렇지는 않았다.
(난 오해한 적은 없고 이해한 적이 없었으니..)

비교적 얇은 임에도 불구하고 설명이 꽤나 장황하다.

학교 다닐 때 과제 분량 맞추려고 문장을 늘려쓰곤 했던 사람이라면 의심의 눈초리를 거둘 수 없을텐데,

지루함을 견디고 끝까지 읽었을 때 비로소 이해가 갔다.

용어를 최대한 정확하게 표현하려 애쓴 흔적이 역력하다.

표현의 근거도 일일이 제시하고 내 수준에서는 특별히 틀렸다고 생각할 만한 지점이 없었다.

쉬운 이야기를 썼지만 저자의 내공이 매우 깊은가보다 생각이 든다.

하지만 이 책을 추천하는지 질문을 받는다면 뜻밖의 지점에서 고민하게 될 터인데,

좀…아재 감성이다.

그 감성을 풀어내는 방식도 좀 장황하기도 하고, 젠더 감수성을 운운할 정도까지인지는 모르겠으나 가끔 공대 개그 같은 묘한 냄새가 난다(smell).

내가 명확하게 정의 내려 입 밖에 내 본 적이 없는 수많은 개념과 용어를 한번 더 정리하는 기분으로 읽어냈고,

그 경험이 나쁘진 않았다.

조금 더 짧은 호흡으로 쓰였다면, 종종 뒤적거리며 개념 정리하기 좋았을 것 같다.

결론은,

추천하긴 하지만 선뜻 다시 손이 안 갈 것 같다.

연재글 전체 보기


지난 시간에 우리는..

이전 글에서 HTTP 메시지를 줄단위로 끊고 첫 요소에서 시작줄을 파싱하는 것까지 진행했다.

이제 슬슬 길어져 보기 힘드니까…여기서 확인

테스트 코드는 멀쩡한 코드인가?

여기까지는 최소한으로 받아들일 수 있는 HTTP 메시지의 형식을 제한했다.

그런데 이게 테스트 케이스로써 어떤 가치가 있는지 생각해보게 됐다.

스스로에게 이번 프로젝트에 TDD를 도입한 이유를 물어본다면

  • 자동화된 테스트는 내가 방금 수정한 코드에 문제가 있는지 매우 빠르게 피드백을 준다
  • 테스트로부터 테스트 대상을 어떻게 사용할 수 있는지 알 수 있기 때문에 그 자체로 살아있는 기능 정의서가 된다

크게 이정도 생각난다(사실 어디서 주워들었다). 더 있다면 누군가 댓글을 달아주겠지.

이외에도 단위테스트를 작성하면서 얻을 수 있는 효과는 많지만, 저 둘을 가장 근본적인 목적으로 꼽겠다, 나는.

자동화된 테스트가 즉각 피드백을 주는 것은 앞서 꾸준히 증명이 됐다고 본다.

그럼,

지금까지의 테스트 코드가 HttpMessageParser의 사용법을 올바르게 노출하고 있을까?

HttpMessageParser를 사용하는 곳은 아직까지 2곳으로 예상된다. 이들은 HttpMessageParser의 서비스를 이용하는 클라이언트가 된다.

일단 테스트 코드가 첫번째 손님이 됐고, 앞으로 테스트 코드가 충분히 cover하게 되면 Request도 곧 클라이언트가 된다. (기억이…나시는가?)

당신이 Request든 뭐든 HTTP 메시지를 파싱해서 지지고 볶는 기능을 만든다고 생각하고, 막 테스트 코드를 열었을 때 어떤 모양인지 살펴보자.

HttpMessageParserHttpMessageParserTest의 테스트 코드로 미루어 보건대, 어떤 기능을 담고 있는가?

<?php

use PHPUnit\Framework\TestCase;

class HttpMessageParserTest extends TestCase
{
public function testReturnValue()
{
$goodMsg = 'GET / HTTP/1.1';
$parser = new HttpMessageParser($goodMsg);
$result = $parser->getResult();
$this->assertEquals(true, is_array($result));

$requiredFields = [
'start_line',
'method',
'uri',
'version',
'headers',
'body',
];
foreach ($requiredFields as $field) {
$this->assertArrayHasKey($field, $result, 'key "' . $field . '" does not exist');
}
}

public function badMessages()
{
return [
[''],
['haha'],
['GET 둘 셋'],
['POST 둘 셋'],
['HEAD 둘 셋'],
['HEAD / 셋'],
['HEAD /index 셋'],
];
}

/**
* @dataProvider badMessages
* @expectedException Exception
*/
public function testInvalidMessageCausesException($badMsg)
{
new HttpMessageParser($badMsg);
}
}
  1. ‘GET / HTTP/1.1’라는 문자열을 전달한 후, getResult()를 호출하면
  • 그 결과물은 반드시 배열이다
  • ‘start_line’, ‘method’, ‘uri’, ‘version’, ‘headers’, ‘body’의 키가 반드시 존재한다
  1. 7개의 불량한 문자열을 넘기면 예외가 발생한다
  • ‘’, ‘haha’, ‘GET 둘 셋’, ‘POST 둘 셋’, ‘HEAD 둘 셋’, ‘HEAD / 셋’, ‘HEAD /index 셋’

1번 기능을 통해 우리가 원했던 대로 리턴이 되는 것을 확인할 수는 있지만, ‘GET / HTTP/1.1’이라는 문자열이 왜 통과하는 지는 알 수가 없다.

이게 통과하는 이유는 2번 테스트를 통해 유추가 가능한데, 7개의 패턴에 속하지 않기 때문이다.

테스트를 열심히 만들 때는 그 과정이 매우 자연스럽다고 생각했는데, 만들어 놓은 테스트를 보니 구멍이 많아 보인다.

누군가 OPTIONS라는 메소드를 허용하도록 HttpMessageParser를 변경했다고 치자.

이때 $supportMethods에 ‘OPTIONS’라고 추가하면 검증 기능은 잘 동작할 것이다.

그리고 테스트 코드를 수정하지 않아도 당연히 테스트는 통과한다.

하지만 나중에 ‘OPTIONS’에 관한 기능을 수정할 땐 테스트의 가호를 받을 수 없다.

이에 문제를 인식한 그 누군가는 ‘OPTIONS’를 testReturnValue()쪽에 넣어 테스트를 돌려볼 수도 있고, badMessages()쪽에 몇가지 케이스를 추가할 수도 있다.

역시 테스트 코드는 잘 돌아갈 것이다.

그리고 여전히 테스트 코드만 봐선 OPTIONS를 쓸 수 있는 지 한 눈에 보이지 않는다. 지원 가능한 옵션이 늘어날 수록 테스트코드는 복잡해지고 유지보수하기 어려워진다.

열심히만 살아온 것 같은데, 왜 이런 시련이 왔을까?

Blacklist vs Whitelist

우리(내가 작성해놓고 우리라니…)의 테스트에 어떤 문제가 있었는지 돌아보자.

우선 시작줄의 세 영역을 한번에 테스트하는 것이 마음에 걸린다.

badMessages()라는 메소드엔 불량한 시작줄만 들어있으므로 적당한 메소드명도 아닌 것 같다.

그리고 테스트의 대부분이 blacklist에 기반한다.

즉, 어떤 패턴을 사용할 수 있는지보다는 어떤 걸 쓰면 문제가 생기는지 알 수 있다.

(아이를 키우다보면 하지말라는 말만 하게 되는데, 그 영향인가…)

이런 테스트로 지탱하는 시스템이라면 새로운 기능이 생겼을 때 하지 말아야 할 패턴을 만들어야 하는 난관에 봉착한다.

그래서 whitelist를 기반으로 HttpMessageParser에서 뭘 할 수 있는지 테스트를 바꿔보려 한다.

여전히 이해할 수 없는 구문이 들어오면 예외를 던지는 기능은 유지가 되어야 한다.

이제부터 해야할 일은,

  • badMessages()의 이름을 바꾼다
  • Whitelist 기반으로, HttpMessageParser를 어떻게 사용할 수 있는지 표현하자
    • 시작줄의 세영역은 따로따로 테스트하자

아직 시작줄에 관한 정책만 테스트하고 있으므로 dataProvider의 이름은 badStartLines라고 바꾸고, 이에 맞춰 테스트명도 변경했다.

/**
* @dataProvider badStartLines
* @expectedException Exception
* @param $badMsg
*/
public function testInvalidStartLineCausesException($badMsg)
{
new HttpMessageParser($badMsg);
}

이제 화이트리스트 기반의 테스트를 작성할텐데, 그 전에 또 하나의 고민이 생긴다.

HttpMessageParser가 서버에서 지원 가능한 메소드의 종류를 알아야 하는가?

RequestHttpMessageParser에 어떤 역할을 해주기 기대할까?

나는 아직까지 Request가 필요로 하는 기능 이상으로는 생각하고 싶지 않다.

RequestHttpMessageParser에게 문자열을 던져주고 특정 구조에 맞춰 결과를 돌려주길 바랄 뿐이다.

해당 메소드를 지원하는지 안 하는지 여부는 꼭 파서가 보유할 지식일까?

최소한 Request는 메소드에 따른 분기처리를 위해 지원하는 메소드의 종류를 알아야 한다.

두 클래스에서 보유할 필요는 없으니, 이 지식이 (나중에야 어찌되든) 현재는 Request 쪽에서 보유하는 게 좋겠다고 생각이 든다.

그럼 HttpMessageParser에서는 정상적인 메소드가 넘어왔는지 테스트 안해도 되나?

(안해도 된다고 생각은 하지만) HTTP 표준에 존재하는 모든 메소드 정도는 파서에서 알고 있는 것도 의미가 있을 것 같다.

그럼 HTTP 스펙에서 정의하는 메소드는 무엇이 있을까?

RFC 7231, section 4: Request methods에서 GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE를 정의하고 있고, RFC 5789, section 2: Patch method에서 PATCH를 추가로 정의하고 있다.

HttpMessageParser에서 허용 가능한 메소드를 수정해주자.

$HTTPMethods라는 변수도 PHPStorm의 Refactor > Rename을 이용해 변경해주자.

이 클래스만 독립적으로 보면 크게 잘못된 이름은 아니라고 보지만,

우리의 서버가 제공하는 메소드가 아닌 HTTP에 정의된 메소드라는 의미로 바꾸어보자.

//HttpMessageParser.php

private $HTTPMethods = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE',];

(못난 테스트라도) 테스트는 잘 돌아간다.

이왕 고치는 김에 기존에 만들었던 testReturnValue() 메소드 명도 바꿔보자. 이름이 마음에 안든다.

testReturnValueHasRequiredFields()라고 좀 더 하는 일을 정확하게 표현해주자.

드디어 (이 글의 본론인…) whitelist 기반의 테스트를 작성한다!

public function testMessageParsedAndPlacedIntoRequiredFields()
{
$goodMethod = 'GET';
$goodUri = '/';
$goodVersion = 'HTTP/1.1';

$goodMsg = implode(' ', [$goodMethod, $goodUri, $goodVersion]);
$parser = new HttpMessageParser($goodMsg);
$result = $parser->getResult();

//TODO $result의 형식은 올바른지 확인

$this->assertEquals($goodMethod, $result['method']);
}

여기선 어떤 메소드를 쓸 수 있는지 확인할텐데, 사용할 메소드마다 또 loop를 돌거나 dataProvider를 사용하게 될 것 같다.

시작줄의 요소를 하나씩 테스트하기 위해선 시작줄의 나머지 요소는 확실히 문제없는 문자열을 넣어줘야 한다.

그래서 완벽한 시작줄을 정의하고 각 요소를 하나씩 교체해가면서 문제가 없는지 확인하려고 한다.

반복되는 구문은 별도의 메소드로 빼놓는 것도 잊지 말고.

변경 후의 코드를 보자.

<?php

use PHPUnit\Framework\TestCase;

class HttpMessageParserTest extends TestCase
{
const PERFECT_START_LINE = 'GET / HTTP/1.1';

public function testReturnValueHasRequiredFields()
{
$parser = new HttpMessageParser(self::PERFECT_START_LINE);
$result = $parser->getResult();
$this->assertEquals(true, is_array($result));

$requiredFields = [
'start_line',
'method',
'uri',
'version',
'headers',
'body',
];
foreach ($requiredFields as $field) {
$this->assertArrayHasKey($field, $result, 'key "' . $field . '" does not exist');
}
}

public function testMessageParsedAndPlacedIntoRequiredFields()
{
$possibleHTTPMethod = [
'GET',
'HEAD',
'POST',
'PUT',
'DELETE',
'CONNECT',
'OPTIONS',
'TRACE',
];
foreach ($possibleHTTPMethod as $method) {
$this->assertValidStartLine($method, null, null);
}

$uriStartsWithSlash = [
'/',
'/index',
'/index.html',
];
foreach ($uriStartsWithSlash as $uri) {
$this->assertValidStartLine(null, $uri, null);
}

$possibleHTTPVersions = [
'HTTP/0.9',
'HTTP/1.0',
'HTTP/1.1',
'HTTP/1000.9999',
];
foreach ($possibleHTTPVersions as $version) {
$this->assertValidStartLine(null, null, $version);
}
}

private function assertValidStartLine($method = null, $uri = null, $version = null)
{
list($goodMethod, $goodUri, $goodVersion) = explode(' ', self::PERFECT_START_LINE);

$goodMsg = implode(' ', [
$method ?? $goodMethod,
$uri ?? $goodUri,
$version ?? $goodVersion,
]);

$parser = new HttpMessageParser($goodMsg);
$result = $parser->getResult();

if (isset($method)) $this->assertEquals($method, $result['method']);
if (isset($uri)) $this->assertEquals($uri, $result['uri']);
if (isset($version)) $this->assertEquals($version, $result['version']);
}

public function badStartLines()
{
return [
[''],
['haha'],
['GET 둘 셋'],
['POST 둘 셋'],
['HEAD 둘 셋'],
['HEAD / 셋'],
['HEAD /index 셋'],
];
}

/**
* @dataProvider badStartLines
* @expectedException Exception
* @param $badMsg
*/
public function testInvalidStartLineCausesException($badMsg)
{
new HttpMessageParser($badMsg);
}
}

반드시 통과하는 완벽한 시작줄의 포맷을 ‘GET / HTTP/1.1’으로 정했는데,

이는 위의 테스트에서도 사용하기 때문에 중복이라 PERFECT_START_LINE라는 상수로 뺐다.

testMessageParsedAndPlacedIntoRequiredFields()에는,

  • 현재 8개의 HTTP 메소드이면
  • 슬래시(/)로 시작하는 문자열은
  • HTTP/정수.정수 형태의 포맷이면

통과한다.

이제 애초에 문제가 있다고 진단했던 testInvalidStartLineCausesException()을 손보자.

원하지 않는 포맷이 들어올 때 예외를 발생시키는 것도 엄연히 기능이므로 몇가지 주요 정책은 간단히 남겨두겠다.

/**
* @expectedException Exception
*/
public function testInvalidMethodCausesException()
{
$this->assertValidStartLine('NotAMethod', null, null);
}

/**
* @expectedException Exception
*/
public function testInvalidURICausesException()
{
$this->assertValidStartLine(null, 'StartsWithChar', null);
}

/**
* @expectedException Exception
*/
public function testInvalidHTTPVersionFormatCausesException()
{
$this->assertValidStartLine(null, null, 'HTTP/2');
}

header 모으기

시작줄 다음에 등장하는 건 헤더 영역인데, 헤더의 끝이 어디냐 하면 공백이 나올 때다.

GET / HTTP/1.1
헤더1
헤더2

메시지 본문

잘린 메시지 본문에서 시작줄 이후 공백이 등장할 때까지 모두 헤더로 넣으면 된다.

HttpMessageParserparse() 메소드에서 전체 메시지를 줄단위로 나누는 일을 한다.

그 뒤로 시작줄을 검증하는 메소드가 있는데…어…이상하다.

시작줄을 자르는 행위가 보여야 할 것 같은데?

parse() 메소드의 흐름은

  1. 줄단위로 자른다
  2. 시작줄을 공백으로 나눈다
  3. 헤더를 모은다
  4. 메시지 바디를 넣는다

정도로 요약되어야 할 것 같은데 뜬금없이 시작줄을 검증하고 앉았다.

splitStartLineInto3Components()parse() 안으로 옮기고, 검증 기능은 그 내부로 옮기자.

HttpMessageParser를 재구성한 버전으로 새출발하자.

<?php

class HttpMessageParser
{
private $message = '';
private $messageLines = [];
private $result = [
'start_line' => '',
'method' => '',
'uri' => '',
'version' => '',
'headers' => '',
'body' => '',
];

private $HTTPMethods = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE',];

public function __construct(string $message)
{
$this->message = $message;
$this->parse();
}

private function parse(): void
{
$this->splitMessageIntoLines();

$this->splitStartLineInto3Components();
}

/**
* 메시지를 \r\n 혹은 \n으로 나누기
*/
private function splitMessageIntoLines()
{
$this->messageLines = preg_split('/(\r\n|\n)/', $this->message);
//각 줄의 양 끝 공백은 제거한다
array_walk($this->messageLines, function (&$item) {
$item = trim($item);
});
}

private function splitStartLineInto3Components(): void
{
//시작줄은 있어야 한다
if (empty($this->messageLines) || empty($this->messageLines[0])) {
$this->throwInvalidMessageFormat();
}

//시작줄을 whitespace로 나누기
$startLine = $this->messageLines[0];
$startLineSplit = preg_split('/\s/', $startLine);

if (empty($startLineSplit) || count($startLineSplit) !== 3) {
$this->throwInvalidMessageFormat();
}

$this->result['method'] = $startLineSplit[0];
$this->result['uri'] = $startLineSplit[1];
$this->result['version'] = $startLineSplit[2];

$this->verifyStartLine();
}

private function throwInvalidMessageFormat(): void
{
throw new Exception('Invalid message format.');
}

private function verifyStartLine(): void
{
$this->verifyStartLineMethod();
$this->verifyStartLineURI();
$this->verifyStartLineVersion();
}

private function verifyStartLineMethod(): void
{
if (empty($this->result['method']) || !in_array($this->result['method'], $this->HTTPMethods)) {
$this->throwInvalidMessageFormat();
}
}

private function verifyStartLineURI(): void
{
if (empty($this->result['uri']) || strpos($this->result['uri'], '/') !== 0) {
$this->throwInvalidMessageFormat();
}
}

private function verifyStartLineVersion(): void
{
if (empty($this->result['version']) || !preg_match('/HTTP\/\d+.\d+/', $this->result['version'])) {
$this->throwInvalidMessageFormat();
}
}

public function getResult(): array
{
return $this->result;
}
}

다시 실패-성공-리팩토링의 궤도 안으로

여차저차 정리됐으니 진짜 헤더를 모으는 작업을 시작하자. 테스트부터.

public function testHeadersMayExistAfterStartLineAndBeforeEmptyLine()
{
$msg = self::PERFECT_START_LINE . PHP_EOL;
$oneHeader = 'IAmAHeader';

$msg .= $oneHeader;
$parser = new HttpMessageParser($msg);
$result = $parser->getResult();

$this->assertEquals(1, count($result['headers']));
}

실패하려고 만든 테스트가 성공하고 말았다!

result에 headers가 빈 문자열로 들어가있었기 때문. result 내 headers의 기본값을 빈 배열로 넣어주자.

Failed asserting that 0 matches expected 1.

Yes! 싪패했으니 테스트가 통과할 정도만 메소드를 만들어본다.

parse()parseHeaders()라는 메소드 추가.

private function parseHeaders()
{
if (isset($this->messageLines[1])) {
$this->result['headers'][] = $this->messageLines[1];
}
}

모든 테스트가 성공하니 또 실패하는 테스트 작성. 이번엔 헤더 두개.

public function testHeadersMayExistAfterStartLineAndBeforeEmptyLine()
{
$msg = self::PERFECT_START_LINE;

$msg .= PHP_EOL . 'firstHeader';
$parser = new HttpMessageParser($msg);
$result = $parser->getResult();
$this->assertEquals(1, count($result['headers']));

$msg .= PHP_EOL . 'secondHeader';
$parser = new HttpMessageParser($msg);
$result = $parser->getResult();
$this->assertEquals(2, count($result['headers']));
}

또 통과하도록 메소드 수정.

private function parseHeaders()
{
if (isset($this->messageLines[1])) {
for ($i = 1; $i < count($this->messageLines); $i++) {
$this->result['headers'][] = $this->messageLines[$i];
}
}
}

헤더가 전혀 없을 수도 있겠지?

헤더가 없을 때 실패하는 구문 추가:

$msg = self::PERFECT_START_LINE . PHP_EOL . PHP_EOL . 'endOfMessage';
$parser = new HttpMessageParser($msg);
$result = $parser->getResult();
$this->assertEquals(0, count($result['headers']));

이를 통과하는 코드:

private function parseHeaders()
{
if (isset($this->messageLines[1])) {
for ($i = 1; $i < count($this->messageLines); $i++) {
if (empty($this->messageLines[$i])) {
break;
}
$this->result['headers'][] = $this->messageLines[$i];
}
}
}

사실 여기서 각 헤더도 첫번째 콜론(:)을 기준으로 key, value를 나눠야 한다.

아직 그걸 어디다 쓸 지는 모르는 척을 하기 위해 이대로 두기로 한다.

  • 아마도 가장 먼저 쓰일 곳은 브라우저의 캐시를 지원할 때일 것이다. 이 속도라면 50회 정도는 되어야겠군…

Message Body

메시지 본문은 가져오기 쉽다. 헤더까지 가져온 다음 빈줄하나가 존재하면 나머지는 모두 메시지 본문이다.

다만 줄단위로 나눴던 나머지 메시지를 다시 붙여서 넣어야 한다.

이때 문제가 발생하겠지만, 걱정은 나중에 하고 메시지 본문을 result에 채워보자.

실패하는 테스트를 만들기 전 기초를 만들어 두자.

public function testBodyMayExistAfterHeaders()
{
$msgBeforeBody = self::PERFECT_START_LINE . PHP_EOL . 'firstHeader';
$result = (new HttpMessageParser($msgBeforeBody))->getResult();
$this->assertEmpty($result['body']);
}

메시지가 헤더 파싱까지만 존재하기 때문에 메시지 본문은 비어있는 게 당연하다. 테스트는 통과한다.

이제 본문이 존재할 때 실패하는 테스트 작성.

public function testBodyMayExistAfterHeaders()
{
$msgBeforeBody = self::PERFECT_START_LINE . PHP_EOL . 'firstHeader';
$result = (new HttpMessageParser($msgBeforeBody))->getResult();
$this->assertEmpty($result['body']);

$msgBeforeBody .= PHP_EOL . 'secondHeader';
$msgBeforeBody .= PHP_EOL;//empty line

$bodyText = 'somebody to love';
$msg = $msgBeforeBody . PHP_EOL . $bodyText;
$result = (new HttpMessageParser($msg))->getResult();
$this->assertEquals($bodyText, $result['body']);
}

parse() 메소드에

$this->parseBody();

한줄 추가하고 테스트를 통과시키는 코드 작성.

private function parseBody()
{
//시작줄 + 헤더
$emptyLineIndex = 1 + count($this->result['headers']);

if (!isset($this->messageLines[$emptyLineIndex]) ||
$this->messageLines[$emptyLineIndex] !== ''
) {
return;
}

$bodyIndex = $emptyLineIndex + 1;
if (isset($this->messageLines[$bodyIndex])) {
for ($i = $bodyIndex; $i < count($this->messageLines); $i++) {
$this->result['body'] = $this->messageLines[$i];
}
}
}

가운데 쯤 빈줄을 확인하는 코드는 사실 필요 없다. 빈줄이 없으면 다 헤더로 들어갔을테니까.

하지만 메시지 본문 앞에 공백이 있고 그래서 시작줄, 헤더 다음 1을 추가하는 코드($bodyIndex = $emptyLineIndex + 1;)를 이해하기 쉽도록 남겨두기로 했다.

이제 다 끝인가 싶겠지만 몇가지 문제가 있다.

첫번째로, 본문에 개행이 들어가 있을 경우 제대로 동작하지 않는다.

테스트로 확인해보면,

$bodyText .= PHP_EOL . 'Help! I need somebody';
$bodyText .= PHP_EOL . 'Help! Not just anybody';
$msg = $msgBeforeBody . PHP_EOL . $bodyText;
$result = (new HttpMessageParser($msg))->getResult();
$this->assertEquals($bodyText, $result['body']);

통과하기.

$bodyIndex = $emptyLineIndex + 1;
if (isset($this->messageLines[$bodyIndex])) {
$partsOfBody = [];
for ($i = $bodyIndex; $i < count($this->messageLines); $i++) {
$partsOfBody[] = $this->messageLines[$i];
}
$this->result['body'] = implode(PHP_EOL, $partsOfBody);
}

또 하나의 문제는 애초에 메시지 본문을 \r\n 혹은 \n으로 줄단위로 나눴기 때문에

$this->messageLines = preg_split('/(\r\n|\n)/', $this->message);

이 놈을 다시 붙여줄 때는 \r\n으로 붙여야 할지 \n으로 붙여야 할지 모른다는 점이다.

이를 해결하려면 뭐로 나눴는지 기억하는 것도 방법이지만 메시지 본문 안에 \r\n 혹은 \n가 섞여 있다면 이 또한 문제다.

결국 전체 메시지에서 공백줄이 나타난 이후를 모두 잘라내는 쪽으로 코드를 수정해야 한다.

가독성 때문에 정규식을 쓰기 싫었지만, 쉽게 해결된다.

private function parseBody()
{
$this->result['body'] = preg_match('/(?:.+?)(?:\r\n|\n){2}(.*)/s', $this->message, $matches) ? $matches[1] : '';
}
/(?:.+?)(?:\r\n|\n){2}(.*)/s

이 정규식을 간단히

/(아무 문자열)(개행){2개}(나머지문자열)/개행을포함

정도로 표현할 수 있겠다.

괄호 안의 ?:는 캡쳐를 하지 않는다는 의미로, 만약 정규식을

/(.+?)(\r\n|\n){2}(.*)/s

처럼 쓴다면 $matches[1]가 아니라 $matches[3]으로 변경해야 할 것이다.

결론

어떻게든 돌아가는 코드를 만들어 놓기는 했지만 썩 마음에 들진 않는다.

무조건 전체 HTTP 메시지를 받아 한번에 파싱한 다음 결과를 리턴하는 구조인데,

나중에 스트리밍을 고려한다면 서버에 전송되는 메시지를 그때 그때 파서에 전달하고 파서에서는 받은 문자열을 버퍼에 쌓는 동시에 파싱을 진행해야할 것이다.

아직 해당 요구사항이 발견되지 않았다 치더라도,

분리하는 로직에서 개행문자로 한번에 나눠놓지 말고 개행이 나타나는 곳까지 읽은 후 적당한 위치에 넣어주기만 해도 훨씬 빠른 처리가 될 것이다.

하지만 난 그대로 둘 것이다.

다시 개발하기 싫으니까!


Season 1, 끝.

연재글 전체 보기


지난 시간에 우리는..

HTTP 메시지를 파싱하는 용도로 HttpMessageParser라는 역할을 분리하고 테스트를 만들기 시작했다.

Server <-> Request <-> HttpMessageParser

HttpMessageParserRequest가 필요로하는 최소한의 데이터 구조로 리턴을 하게 되어있고, 각각의 키가 반드시 존재(set)할 것을 테스트로 보장한다.

  • 소스가 약간 바뀌었으나 동일하게 동작한다
public function testReturnValue()
{
$parser = new HttpMessageParser('');
$result = $parser->getResult();
$this->assertEquals(true, is_array($result));

$requiredFields = [
'start_line',
'method',
'uri',
'version',
'headers',
'body',
];
foreach ($requiredFields as $field) {
$this->assertArrayHasKey($field, $result, 'key "' . $field . '" does not exist');
}
}
<?php

class HttpMessageParser
{
private $message = '';

/**
* HttpMessageParser constructor.
*/
public function __construct(string $message)
{
$this->message = $message;
}

public function getResult() : array
{
return [
'start_line' => '',
'method' => '',
'uri' => '',
'version' => '',
'headers' => '',
'body' => '',
];
}
}

예외 발생

(이 시리즈 초반에 테스트 용으로 만든 것과 같은) 엉망진창인 클라이언트가 존재하는 것을 감안하여 비정상 요청부터 감지하고자 한다.

어떤 요청 메시지가 비정상인가?

다시 한번 HttpMessageParser의 역할을 상기시키는 겸, 이 녀석이 분석해야할 정상 메시지의 구조는 아래와 같다.

  • start_line : 시작줄
    • method : 메소드
    • uri : URI
    • version : HTTP 버전
  • headers : 헤더는 배열로(0개 이상)
  • body : 본문(없어도 OK)

여기서 다시 HTTP 스펙으로 돌아가야 한다.

사실상 필수 요소라면 시작줄 뿐이지만, CRLF가 두개는 나와야 정상 메시지라고 볼 수 있다.

하지만 우리의 HTTP 프로토콜은 개떡같이 말해도 찰떡같이 이해하는 게 일상인 관대한 생태계가 받들고 있다.

나도 또한 관대하므로 시작줄만 3요소로 멀쩡히 들어온다면 문법 자체는 문제없는 요청이라 생각할 것이다.

하지만 이 조차도 지키지 못한다면 클라이언트에게 400 Bad Request라는 응답을 줄 것이다.

그럼 테스트부터 시작하자.

  1. 예외가 발생했는지 확인하는 테스트를 만들고
  2. 예외를 발생시킨다

기존 만들었던 테스트는 성공하는 케이스로, 새로 만드는 테스트는 실패하는 케이스로 바꿔준다:

public function testReturnValue()
{
$goodMsg = 'GET / HTTP/1.1';
$parser = new HttpMessageParser($goodMsg);
$result = $parser->getResult();
$this->assertEquals(true, is_array($result));

$requiredFields = [
'start_line',
'method',
'uri',
'version',
'headers',
'body',
];
foreach ($requiredFields as $field) {
$this->assertArrayHasKey($field, $result, 'key "' . $field . '" does not exist');
}
}

/**
* @expectedException Exception
*/
public function testInvalidMessageCausesException()
{
$badMsg = '';
new HttpMessageParser($badMsg);
}

결과 배열은 멤버 변수로 옮기고, parse() 메소드를 추가하고, 공백이 넘어오면 Exception 발생:

<?php

class HttpMessageParser
{
private $message = '';
private $result = [
'start_line' => '',
'method' => '',
'uri' => '',
'version' => '',
'headers' => '',
'body' => '',
];

public function __construct(string $message)
{
$this->message = $message;
$this->parse();
}

private function parse()
{
if ($this->message == '') {
throw new Exception('Invalid message format.');
}
}

public function getResult(): array
{
return $this->result;
}
}

테스트를 그지같이 짜 놨더니 불안하다. 어서 비정상 메시지를 구분하는 코드를 더 넣어보자.

  1. CRLF 단위로 문자열을 잘라내고 첫번째 요소만 신경쓴다
  2. 공백으로 나누면 3개 이상의 요소가 되는지
  3. 이해할 수 있는 METHOD인지
  4. URL은 /로 시작하는지
  5. 프로토콜이 HTTP/정수.정수 형식인지
  6. …and?

잠깐. 그럼 통과 못하는 문자열 여러개를 준비하고 이것들이 통과하는 테스트를 만들어야 하는데,

Exception 발생을 확인하는 메소드는 그 안의 여러개의 Exception이 발생한다 하더라도 첫번째 Exception 이후 동작하지 않는다.

그럼 비정상 문자열이 생각날 때마다 새로운 메소드를 만들어야 하는가?

꼼수가 있다.

Data Providers

Data Providers는 테스트 메소드의 인자로 다량의 데이터를 밀어넣어줄 수 있는 annotation이다.

자세한 건 매뉴얼을 확인하도록 하고, 우선 Data Providers를 사용하도록 리팩토링, 그리고 실패하는 테스트를 하나 만든다.

public function badMessages()
{
return [
[''],
['haha'],
];
}

/**
* @dataProvider badMessages
* @expectedException Exception
*/
public function testInvalidMessageCausesException($badMsg)
{
new HttpMessageParser($badMsg);
}

동작하는 테스트가 갖춰졌으니, 이제 HttpMessageParser 클래스의 parse() 메소드에서 놀면 된다.

parse()

사실 실패하는 테스트를 만들기 전에 CRLF로 나누는 리팩토링 먼저 됐으면 좋았겠지만…

이왕 이렇게 된 거 시작줄이 3개의 요소로 되어 있는지를 확인하는 구문까지 진도를 쫙 빼본다.

private function parse()
{
//메시지를 \r\n 혹은 \n으로 나누기
$messageSplit = preg_split('/(\r\n|\n)/', $this->message);
//각 줄의 양 끝 공백은 제거한다
array_walk($messageSplit, function(&$item){
$item = trim($item);
});

//시작줄은 있어야 한다
if (empty($messageSplit) || empty($messageSplit[0])) {
throw new Exception('Invalid message format.');
}

//시작줄을 whitespace로 나누기
$startLine = $messageSplit[0];
$startLineSplit = preg_split('/\s/', $startLine);

if (empty($startLineSplit) || count($startLineSplit) !== 3) {
throw new Exception('Invalid message format.');
}
}

TDD의 기본을 되새김해보면

  • Red : 실패하는 테스트를 작성하고, 실패하는 것을 확인
  • Green : 이를 통과하는 최소한의 코드를 작성하고
  • Refactor : 리팩토링하는 과정

지금은 Refactoring 타임.

테스트가 통과하는 코드를 만들었지만 벌써 중복이 발생했다.

Custom Exception을 만들어야 할 것만 같은 충동에 휩싸였지만 이내 정신을 차리고 메소드로 분리하는 전략을 선택했다.

만약, 이건 만약인데,

당신이 PHPStorm을 쓴다면..

Refactor 기능으로 중복 코드를 발라낼 수 있다.

추출할 영역을 선택 > 우클릭 > Refactor > Extract > Method…
add_unittest_1

Parameter가 필요하다면 이를 설정할 수도 있지만 이건 단순한 메소드니까.
add_unittest_2

동일한 코드가 발견되면 같이 발라낼 것인지 물어보기도 한다.
add_unittest_3

짜잔!

private function parse(): void
{
//메시지를 \r\n 혹은 \n으로 나누기
$messageSplit = preg_split('/(\r\n|\n)/', $this->message);
//각 줄의 양 끝 공백은 제거한다
array_walk($messageSplit, function(&$item){
$item = trim($item);
});

//시작줄은 있어야 한다
if (empty($messageSplit) || empty($messageSplit[0])) {
$this->throwInvalidMessageFormat();
}

//시작줄을 whitespace로 나누기
$startLine = $messageSplit[0];
$startLineSplit = preg_split('/\s/', $startLine);

if (empty($startLineSplit) || count($startLineSplit) !== 3) {
$this->throwInvalidMessageFormat();
}
}

private function throwInvalidMessageFormat(): void
{
throw new Exception('Invalid message format.');
}

쉽다.

  • 당신이 PHPStorm을 쓴다면..

또다시 실패하는 테스트 케이스 추가.

public function badMessages()
{
return [
[''],
['haha'],
['하나 둘 셋'],
];
}

(어떤 바보가 ‘시 작 줄’과 같이 요청하겠냐마는) 이 테스트는 실패한다.

왜냐면 지금까지 구현한 기능은 이를 완벽한 요청 시작줄로 인식하고 Exception을 뱉지 않기 때문이다.

그럼 공백으로 나눈 시작줄 요소가 각각 다음을 만족하는 지 확인하는 코드를 짜야 한다.

  • method : 메소드
  • uri : URI
  • version : HTTP 버전

또다시 실패하는 테스트 케이스 추가.

public function badMessages()
{
return [
[''],
['haha'],
['GET 둘 셋'],
['POST 둘 셋'],
['HEAD 둘 셋'],
['HEAD / 셋'],
['HEAD /index 셋'],
];
}

성공하는 코드 추가.

private function parse(): void
{
//메시지를 \r\n 혹은 \n으로 나누기
$messageSplit = preg_split('/(\r\n|\n)/', $this->message);
//각 줄의 양 끝 공백은 제거한다
array_walk($messageSplit, function(&$item){
$item = trim($item);
});

//시작줄은 있어야 한다
if (empty($messageSplit) || empty($messageSplit[0])) {
$this->throwInvalidMessageFormat();
}

//시작줄을 whitespace로 나누기
$startLine = $messageSplit[0];
$startLineSplit = preg_split('/\s/', $startLine);

if (empty($startLineSplit) || count($startLineSplit) !== 3) {
$this->throwInvalidMessageFormat();
}

$method = $startLineSplit[0];
$uri = $startLineSplit[1];
$version = $startLineSplit[2];

//check Method
if (empty($method) || !in_array($method, $this->supportMethods)) {
$this->throwInvalidMessageFormat();
}

//check URI
if (empty($uri) || strpos($uri, '/') !== 0) {
$this->throwInvalidMessageFormat();
}

//check version
if (empty($version) || !preg_match('/HTTP\/\d+.\d+/', $version)) {
$this->throwInvalidMessageFormat();
}
}

테스트 케이스는 충분한 것 같은데(그렇다고 하자) 마지막에 추가한 버전 체크가 잘 동작하는 게 맞는지 불안할 수 있다.

나는 badMessages쪽에 살짝 넣어보고 안심하긴 했지만, 정상적인 시작줄은 whitelist에서 다뤄야 할 것 같다.

그 전에 리팩토링할 것이 있나 찾아보자.

또 refactoring

클래스가 바뀌어야 하는 이유는 단 한가지여야 한다는 단일 책임의 원칙을 메소드까지 확장해서 생각해보자.

parse() 메소드가 바뀌는 상황을 상상해보면

  • 우리는 시작줄을 대충 whitespace(\s)로 나누었지만 \s\n도 포함이라서 이를 좀 더 정교하게 만들 수도 있다
  • 지원하는 HTTP 메소드 혹은 표준에서 정의하는 메소드 종류는 계속 늘어날 수 있다
  • URI 규칙을 좀 더 구체적으로 정의한다면? Directory traversal attack은?
  • 지원하는 HTTP 버전에 따른 응답을 처리하게될 지도 모른다

아직 생기지도 않은 요구 사항을 미리 구현하는 건 반대지만, 대비하는 것은 필요하다.

게다가 이미 parse() 메소는 그 안에 다루고 있는 지식이 꽤나 많고, 13인치 맥북에선 한 화면에 볼 수 없을 만큼 길어지고 있다.

따라서 누군가(혹은 6개월 후의 당신이) 유지보수를 위해 server.php에서부터 관련 클래스를 타고타고 들어와 parse()까지 도달했을 때는 이 안에서 어떤 일을 하고 있는지 한눈에 볼 수 없다.

parse() 메소드에서 하는 일을 말로 풀어보자.

  1. 메시지를 줄 단위로 나눈다
  2. 시작줄을 검사한다
    1. 시작줄을 공백으로 나눈다
    2. 메소드를 확인한다
    3. URI를 확인한다
    4. 버전을 확인한다

이 걸 그대로 private 메소드로 분리해본다. 당연히 하나하나 분리할 때마다 테스트가 동작하는지는 확인해야 한다.

class HttpMessageParser
{
private $message = '';
private $messageLines = [];
private $result = [
'start_line' => '',
'method' => '',
'uri' => '',
'version' => '',
'headers' => '',
'body' => '',
];
private $supportMethods = ['GET', 'HEAD', 'POST',];

public function __construct(string $message)
{
$this->message = $message;
$this->parse();
}

private function parse(): void
{
$this->splitMessageIntoLines();

$this->verifyStartLine();
}

private function throwInvalidMessageFormat(): void
{
throw new Exception('Invalid message format.');
}

/**
* 메시지를 \r\n 혹은 \n으로 나누기
*/
private function splitMessageIntoLines()
{
$this->messageLines = preg_split('/(\r\n|\n)/', $this->message);
//각 줄의 양 끝 공백은 제거한다
array_walk($this->messageLines, function (&$item) {
$item = trim($item);
});
}

private function verifyStartLine(): void
{
$this->splitStartLineInto3Components();

$this->verifyStartLineMethod();
$this->verifyStartLineURI();
$this->verifyStartLineVersion();
}

private function splitStartLineInto3Components(): void
{
//시작줄은 있어야 한다
if (empty($this->messageLines) || empty($this->messageLines[0])) {
$this->throwInvalidMessageFormat();
}

//시작줄을 whitespace로 나누기
$startLine = $this->messageLines[0];
$startLineSplit = preg_split('/\s/', $startLine);

if (empty($startLineSplit) || count($startLineSplit) !== 3) {
$this->throwInvalidMessageFormat();
}

$this->result['method'] = $startLineSplit[0];
$this->result['uri'] = $startLineSplit[1];
$this->result['version'] = $startLineSplit[2];
}

private function verifyStartLineMethod(): void
{
if (empty($this->result['method']) || !in_array($this->result['method'], $this->supportMethods)) {
$this->throwInvalidMessageFormat();
}
}

private function verifyStartLineURI(): void
{
if (empty($this->result['uri']) || strpos($this->result['uri'], '/') !== 0) {
$this->throwInvalidMessageFormat();
}
}

private function verifyStartLineVersion(): void
{
if (empty($this->result['version']) || !preg_match('/HTTP\/\d+.\d+/', $this->result['version'])) {
$this->throwInvalidMessageFormat();
}
}

public function getResult(): array
{
return $this->result;
}
}

결론

이제 겨우 최소한으로 허용하는 메시지에 대한 규칙이 정의됐다.

다음 글에서는 나머지 항목을 채우는 과정이 진행될 것 같다.

연재글 전체 보기


이제 요청 들어온 메시지를 적당히 분리하는 작업을 하려고 한다.

지난 시간에 우리는..

마지막으로 커밋된 소스는 여기에서 볼 수 있다.

요청 분석 클래스인 Request.php만 보자면

<?php
class Request
{
private $requestMessage;

public function __construct(string $requestMessage)
{
//하지만 아직 쓰진 않지
$this->requestMessage = $requestMessage;
}

public function getResourcePath()
{
return '/index.html';
}
}

테스트 코드는

//RequestTest.php

// ...생략...
public function testGetResourcePath(): void
{
$request = new Request('');
$this->assertEquals('/index.html', $request->getResourcePath());
}

고백하자면 Request 클래스의 생성자에 string 형식만 올 수 있게 조금 고쳐놨다.

요구 사항 정리

이제 생성자에 다양한 패턴의 문자열을 던지고 이를 적당한(?) 구조에 넣어줄 것이다.

개발을 시작하기 전에 요구 사항(HTTP의 문법)부터 면밀히 살펴보자.

(그런데 사실 이미 HTTP 메시지 파서는 널리고 널려서 굳이 직접 구현할 필요는 없다. 공부나 하자는 것이지..)

HTTP 메시지에 관한 표준은 RFC 7230, HTTP/1.1: Message Syntax and Routing에 정의되어 있다.

  • 아직도 RFC2616으로 설명하는 글이 많은데, 바뀐지 3년 지났다

귀찮다면 뭐…이전에 정리한 3장 정리 자료를 봐도 된다.

기본적인 HTTP 메시지의 문법을 다시 한번 살펴보자.

HTTP-message   = start-line
*( header-field CRLF )
CRLF
[ message-body ]

HTTP 메시지

  • 시작줄
  • 0개 이상의 헤더
  • (헤더의 끝을 인식하기 위해) 아무 것도 없는 빈 줄
  • (생략 가능한) 본문

각 항목은 CRLF로 구분하도록 정의되어 있다.

  • 하지만 받는 쪽에서는 CR은 무시하고 LF만 인식할 수도 있다.
  • CRLF를 제외하고 앞뒤로 붙는 공백은 제거한다

시작줄에는 공백(8bit 문자)으로 구분되는 세가지 정보가 들어간다.

  • 요청 시 : HTTP 메소드, URI, HTTP 버전
  • 응답 시 : HTTP 버전, 상태 코드, 상태 메시지

요청 메시지에서, HTTP는 URI(Uniform Resource Identifier)를 표현하는데 RFC3986​​표준을 사용한다.

  • 그런데 이 표준을 보고 올바른 URI인지 판별할 생각은 없으므로 상식으로만 알아두자

이정도를 기본 문법으로 정의하고, 이외의 문법으로 요청이 들어오면 서버가 이해하지 못한다는 의미로 400 Bad Request로 응답한다.

이를 기초로 기능을 간단히 정리해보면

CRLF로 문자열을 나눈다
- 첫 요소를 시작줄로
- 이후 빈 문자열이 들어올 때까지 헤더로
- 그 다음 요소가 존재하면 본문으로 인식한다
- 각 요소를 저장할 땐 trim
시작줄은 공백으로 3파트로 나뉘어야 한다
- 메소드
- URI
- HTTP 버전
이 파싱 기준을 조금이라도 벗어나면 가차없이 400!!

parseMessage() 메소드 추가

파싱을 시작하라고 요청하는 메소드는 parseMessage()라는 이름이 적당할 것 같고, 이제 테스트를 만들어 나갈 타이밍인데 바로 고민이 생긴다.

생성자에 문자열을 넣는 동시에 파싱을 할 것인가?

public function __construct(string $requestMessage)
{
$this->requestMessage = $requestMessage;
$this->parseMessage();
}

아니면 getResourcePath()를 실행할 때 파싱을 실행할 것인가?

public function getResourcePath()
{
$this->parseMessage();
return $this->resourcePath;
}

아니면 parseMessage() 메소드를 public으로 만들어 외부에서 명시적으로 호출하고, 이어 getResourcePath()로 리소스 경로를 얻어와야 하나?

//server.php
$request = new Request($clientSentData);
$request->parseMessage();
$response = (new Response())->getResponse($request->getResourcePath());

아직 server.php 이외에 이 Request 클래스를 사용하는 곳이 없으므로 파싱 기능을 굳이 외부에 노출 시킬 필요가 없기 때문에, 우선은 parseMessage() 메소드를 private으로 묶어두기로 했다.

외부에 노출할 경우, 이 분석 클래스를 바라보는 클라이언트가 늘어갈수록 수정하는데 눈치가 보인다. 공개 API가 덕지덕지 버전을 붙여가는 이유랄까.

외부 노출은 최소한으로 줄이고, 내부 구현을 마음껏 리팩토링하자.

getResourcePath() 안에서 parseMessage() 메소드를 호출하도록 바꿔보자.

server.php 입장에선 Request 클래스를 사용하는 방법이 바뀐 게 아니므로 추가 테스트는 작성 안한다.

//Request.php
public function getResourcePath()
{
$this->parseMessage();
return $this->resourcePath;
}

테스트는 바로 실패하고

Call to undefined method Request::parseMessage()

테스트를 통과하도록 수정한다.

<?php
Request.php
class Request
{
private $requestMessage;
private $resourcePath;

public function __construct(string $requestMessage)
{
//하지만 아직 쓰진 않지
$this->requestMessage = $requestMessage;
}

public function getResourcePath()
{
$this->parseMessage();
return $this->resourcePath;
}

private function parseMessage()
{
$this->resourcePath = '/index.html';
}
}

테스트는 성공한다.

중간 점검

앞으로는 뭘 해야할까?

parseMessage() 안에서 resourcePath를 CRLF로 잘라 분리해놓고,

/라는 URI로 요청이 왔으면 /index.html을 리턴하는 로직도 들어가야 하고,

이해 안되는 구문이면 외부에 알려야 한다.

  • “이건 Bad Bad Request야”

그런데 그 과정에서 몇개의 private 메소드가 더 생길 것이고(메소드 하나에는 하나의 기능을 담는 것이 좋으니)

모두 완료될 때까지 외부(server.php)에 노출되는 건 계속 parseMessage() 뿐일 것이다.

바로 이전 글에서 비공개(private) 메소드 테스트에 관한 글을 링크했었다.

비공개 메서드의 모든 코드를 비공개 메서드에 독립적인 방법으로 테스트 하라

어떻게든 다양한 문자열을 던져주면서 parseMessage()의 모든 코드를 거쳐갈 수 있게 테스트를 만들면 될 것이다.

그런데 또 다른 관점으로도 생각해보자.

단일 책임 원칙

단일 책임 원칙은 한 클래스에서 하나의 책임을 갖는다는 것(…정도로 얼버무리고).

Request 클래스를 구현해나가다 보니,

  • 앞서 언급한 룰에 따라 HTTP 메시지의 각 요소로 나누는 역할이 하나.
  • 그 나뉜 요소를 읽고 어떤 판단을 하는 로직(예를 들어 //index.html)이 하나.
  • 아직 어떻게 구현할지는 생각 안했지만 외부에 비정상 요청임을 알리는 기능 하나.

이쯤되면 Request 클래스의 역할을 분명히 하고, 하나의 독립된 책임으로 캡슐화할 수 있는 건 별도의 클래스로 빼는 걸 고려할 법 하다.

우리는 파싱을 다루고 있었으니

Server <-> Request <-> HttpMessageParser

정도로 우선 분리해본다.

서버는 클라이언트로부터 HTTP 메시지를 수신한 후,

이 문자열을 Request에 전달하면 그 내부에서 뭘 어쩌는 지 서버는 관심없고 단지 그 분석 결과(정상 여부, 리소스 경로 등)을 받아온다.

(나중에 어찌 변할 지 몰라도) 서버 입장에서 Request 클래스의 역할은 문자열을 받아 분석 결과를 리턴한다로 정하겠다.

Request 클래스는 HttpMessageParser에게 HTTP 메시지의 각 요소로 나눠달라고 요청한다.

그럼 HttpMessageParser를 분리하기 위해 테스트부터.

HttpMessageParser 클래스

HttpMessageParser 클래스는 두 군데서 사용하게 된다. Request 클래스와 단위 테스트.

이 클래스의 역할은 문자열을 받아, 특정 구조로 데이터를 리턴하는 것이다. 우선 연관 배열을 리턴하는 것부터 시작해볼까?

첫 단위 테스트에서는 리턴된 배열에서 각 항목이 set되어 있는지만 확인한다.

PHPStorm을 사용한다면 손쉽게 테스트 파일을 추가할 수 있다.

libs 디렉토리에 HttpMessageParser.php를 생성하고,

우클릭 > New > PHPUnit > HttpMessageParserTest 선택

add_unittest_1

테스트 대상 클래스, 테스트 파일명과 위치할 디렉토리를 써주면

add_unittest_2

테스트가 생성된다.

add_unittest_3

리턴되는 값에는 기본적으로 아래와 같은 항목이 필요할 것 같다.

  • start_line : 시작줄 전체
  • method : 메소드
  • uri : URI
  • version : HTTP 버전
  • headers : 헤더는 배열로
  • body : 본문

테스트를 만들자.

테스트 클래스(tests/HttpMessageParserTest.php) 안에서도 (PHPStorm을 쓴다면!) Generate라는 기능을 메소드를 빨리 추가할 수 있다. 약간의 타이핑을 줄여주는 수준이지만 이런 게 생산성을 높여주는 법이다.

<?php

use PHPUnit\Framework\TestCase;

class HttpMessageParserTest extends TestCase
{

public function testReturnValue()
{
$parser = new HttpMessageParser('');
$result = $parser->getResult();
$this->assertEquals(true, is_array($result));

$this->assertArrayHasKey('start_line', $result, 'key start_line not exists');
$this->assertArrayHasKey('method', $result, 'key method not exists');
$this->assertArrayHasKey('uri', $result, 'key uri not exists');
$this->assertArrayHasKey('version', $result, 'key version not exists');
$this->assertArrayHasKey('headers', $result, 'key headers not exists');
$this->assertArrayHasKey('body', $result, 'key body not exists');
}
}

Github에서 커밋 로그를 보면 알겠지만, 하나하나 테스트부터 만들고 이를 통과하는 기능을 구현했다.

add_unittest_3

  • 당신이 PHPStorm을 쓴다면..

결론

다음 글에서는 Request 클래스에서 HttpMessageParser를 가져다 쓰기 전에, 기본 파싱에 필요한 기능을 테스트와 함께 작성하려고 한다.

결론은 당신이 PHPStorm을 쓴다면 좀 더 편하게 할 수 있다.
(이제 그만 말할까…)

여기가 개발 블로그인 줄 아시는 분은 당황스러우시겠지만…

베이킹의 세계로

베이킹 준비 완료 이미지

토비 이일민 님이 추천하신 책 밀가루 물 소금 이스트라는 책을 읽고 진짜로 한번 해봐야겠다는 생각이 들어 장비를 구입하기 시작.

책이 정말 정말 좋다. 처음에 너무 몰라서 ‘클린 소프트웨어’처럼 느껴지던 책이, (아직 기본빵까지 읽었지만) 두번 세번 읽으니 잘 이해가 된다. 이러다보니 내가 어렵다고 생각했던 책도 두 세번 읽으면 이해가 잘 되었을텐데 왜 그렇게 지쳤었나 모르겠다. 스터디라서 (어쩔 수 없이) 이해하려고 몇번이나 읽었던 책은 확실히 이해도가 높았는데…좋은 책 몇 권 골라서 다시 읽어야 할 듯.

자, 이제 주물 냄비 시즈닝도 마치고, 금요일 첫 베이킹을 앞두고 머릿속으로 쉐도우 복싱 중이다. 유럽식 빵과 우유 식빵 두가지 도전하기로…

유럽식 빵은 밀가루 물 소금 이스트의 레시피를 보고 만들 생각이고
밀가루 물 소금 이스트 이미지

식빵은 후암동 식빵의 레시피를 참고할 것이다.
후암동 식빵 이미지

둘 다 사전 발표를 충분히 해서 만드는 게 공통점이나, 역시 설명은 밀가루 물 소금 이스트만 한 게 없다.

이제 우리 집은 주말엔 빵이 주식이다! (단호)

₩163,500

재료 소개를 하자면, 일부는 방산 시장에서 건져오고, 인터넷으로 산 건 오늘의 로켓 배송으로 모두 도착했다. 진짜 최소한으로 필요한 것만 샀다.

라바 주물 냄비(24cm)는 쿠팡에 87,000원에 뜬 걸 보고 얼른 구매했고, 요리용 온도계와 저울은 드레텍 사의 것으로 각각 12,000원, 21,000원. 오븐용 온도계 8,000원. 1/8까지 있는 계량스푼 4,400원. 실리콘패드 11,250원. 사프인스턴트 드라이이스트 125g 1,900원. 큐원 강력 밀가루 1kg 1,550원 3개, 프랑스 밀가루 T55, T65 각각 1kg 2,500원, 2,900원 2개씩.

총 163,500원. 선방했다. 밀가루 하나는 덧가루용으로 쓰고, 저 6kg 안에서 제대로 하나라도 맛있게 구우면 성공이라고 생각.


(애드센스로 돈 벌 생각 하느니 주위에 빵을 만들어 파는 게 더 빠르겠다)

₩204,600

글을 올리자 마자 혹시나 하고 찾아본 Cambro RFSCW12 모델. 책에서 추천해준 바로 그 반죽통을 아마존보다 싸게 파는 곳이 있는 게 아닌가!

이건 정말 필수라고 볼 수는 없는데, 큰 반죽통이 필요하긴 해서…

많이 고민해봤는데 저만한 스댕 반죽통도 2만원은 하기 때문에…

그래서 꼭 필요하진 않아도 꽤 필요한데…

여보…

여보…

0%