최근 상품권을 대량으로 구매해서 등록해야하는 경우가 있었다. 

여러 개(보통 5만 원권 40개)를 구매해서 pin번호를 하나하나 입력하다 보니 여간 귀찮은 일이 아니었다.
(중간에 가상키보드로 입력해야 하는 영역도 있음… ㅡ.ㅡ) 

 

몇개 입력하다가 그만두고 검색을 해봤다. ‘상품권 번호 자동입력’이라고 하니까 나오더라 (역시.. )

하지만 public 하게 배포되는 프로그램(https://auto.ddart.net/xe/help/239)은 Windows 용이라서 사용할 수 없었고 Mac용으로 찾다가 결국 그냥 내가 만들어보자 라는 결심 하게 되었다.

 


설계한 프로세스는 간단했다.

1. 웹페이지(충전 페이지)를 크롤링.
2. Input 영역(번호입력칸)에 value(번호) 입력.
3. action (충전 버튼 클릭)
4. 1~3 반복

해본 적은 없었지만 python과 selenium 이용하면 구현이 가능해 보였다.


개발기

여러 블로그를 참고했고 작은 step으로 쪼개서 개발을 진행했다.


Step1

필요한 라이브러리가 뭔지 확인하고 환경을 세팅했다.

 

Selenium version은 4.9.1 - default가 최신이라 선택.

Python version은 3.11 - 3.9를 사용하다가 호환성 문제로 최신버전으로 upgrade.

Chromedriver(브라우저를 handling 하는 용도) version은 113 - 내 pc에서 사용 중인 chrome버전에 맞는 것을 선택.


Step2

브라우저에 페이지를 띄워보는 것으로 시작했다.

 

브라우저를 띄우기 위해선 사용하는 브라우저 드라이버를 설치해야 했다.

익숙한 Chrome을 선택했고 현재 pc에서 사용 중인 버전에 맞는 driver를 설치했다. 

Help > About Google Chrome 에서 113 버전을 사용하고 있는 것을 확인할 수 있음

아래와 같은 로직을 작성해서 페이지를띄울 수 있었다.

특정 페이지를 띄운 다음 10초간 sleep 시키는 코드


Step3

번호 입력 기능을 만들기 전, 우선 ID, PW를 입력받아 로그인 성공까지 처리하는 프로세스를 개발해 보았다. 

실제 비밀번호가 아니니 시도하지 않았으면 한다!

하지만, 입력하고 로그인 버튼을 눌렀는데 되지 않았다. 

 

알고 보니 패스워드 부분은 가상키보드를 이용한 입력이 필요했다. (가볍게연습해보려고했는데… 따흑…)

가상키보드 입력 폼

다행히 javascript 속성을 이용해서 처리하는 방법으로 금방 해결할 수 있었다.

 

Img alt 속성 입력문자 매핑시키는 방식으로 작업한 코드는 아래와 같다.

특수문자 입력 때문에 예외처리 로직도 추가했다.

가상키보드 처리 함수

 

정상적으로 동작하는 것을 확인할 수 있었다.

작업한 로직이 동작하는 영상

Step4

다음으로 번호입력 프로세스 개발을 진행했다.

 

로그인을 구현했으니 금방 될 줄 알았는데... 이슈가 나왔다.(쉽게 되면 재미없지 그래...)

가상키보드 alt 속성과 입력값 매핑 규칙이 번호입력 키보드에선 적용 되지 않았다.

 

첫번째 칸의 가상키보드

 

첫번째 칸의 가상키보드 javascript
두번째 칸의 가상키보드
두번째 칸의 가상키보드 javascript

첫 번째 가상키보드에서 2는 alt = 3과 3은 alt = 4와 매핑이 된다.
하지만 두 번째 가상키보드에서는 2는 alt = 2과 3은 alt = 3과 매핑이 된다. 
키 위치가 계속 변하고 alt 값도 고정되지 않다 보니 매핑기준을 찾을 수 없었다. 

 

OCR 라이브러리 도입방법까지 찾아보았지만 가볍지 않은 느낌이라 PASS 했다.

 

여러 삽질(?) 끝에 당장의 해결책을 찾았을 수 있었는데 MoblieWeb 화면에서는 숫자와 alt값을 동일하게 사용할 수 있었다.


Step5

한 개의 상품권 번호 입력을 성공했으니 여러 개 상품권번호 입력은 반복문을 이용해서 구현 하면 끝이라고 생각했다. 근데….

(역시나?) 첫 번째 상품권 입력 후 두 번째 상품권의 번호를 입력할오류가 발생했다.

 

 

다시 한번 가상키보드 입력값 처리 함수에서 원인을 찾아 보았다.

Line1: xPath를 이용해서 target을 alt값으로 가지는 element 찾는다.

Line2~3: 해당 element의 부모의 부모(조부모) element에서 click event 처리를 한다.

 

먼저 element의 innerHtml을 출력했다.

첫번째 번호 입력할때 출력한 innerHtml
두번째 번호 입력할때 출력한 innerHtml

출력 결과를 보니 두 번째 번호 입력할 때 element를 잘못 찾고 있었다.

활성화되지 않은 첫 번째 가상키보드를 선택해서 에러가 발생한 것으로 보였다.

 

그래서 우선 활성화된 가상키보드의 div 영역을 명시해서 찾고, 그 내부에 있는 alt값으로 매핑처리 하도록 수정했다. 

하지만 이 코드 또한 동일하게 오류가 발생했다. 😭😭

 

결과적으로 찾아낸 원인은 xPath 문법 오류 였다.

나는 '//'가 현재 node를 기준으로 matching 되는 것을 찾는 걸로 이해했는데 그게 아니었다.

 

내가 작성한 로직에서 "//img [@alt='{}']" 부분이 전체 document에서 일치하는 node를 찾다 보니 계속 첫 번째 가상키보드를 찾았던 것이다.

 

func_nn = func_p.find_element(By.XPATH, ".//img[@alt='{}']".format(target))

수정하고 나니 원하는 대로 잘 동작하는 것을 확인할 수 있었다

 


마무리

역시 목마른 사람이 우물을 파는구나 싶었다. 필요하니 만들게 되더라.

오랜만에 업무 말고 다른 개발을 하면서 refresh 되는 느낌을 받았다.

 

간단하게 생각했던 것에서 장애물들이 계속 나와 처음엔 당황스러웠지만 나중엔 오기가 생겼고 게임에서 미션 깨는 느낌으로 재밌게 만들었다.

파이썬을 잘 활용하면 여러 곳에서 활용할 만한 서비스를 만들어볼 수 있겠다고 몸소 느낄 수 있었다.

 

다음엔 어떤 우물을 파볼까~


참고 블로그

https://kookhh0827.tistory.com/entry/Development-%EC%BB%AC%EC%B3%90%EB%9E%9C%EB%93%9C-%ED%95%80%EB%B2%88%ED%98%B8-%EC%9E%90%EB%8F%99-%EB%8C%80%EB%9F%89-%EB%93%B1%EB%A1%9D%EA%B8%B0-%EB%A7%8C%EB%93%A4%EA%B8%B0

 

[Coding] 컬쳐랜드 핀번호 자동, 대량 등록기 만들기

컬쳐랜드 핀번호를 대량으로 입력할때 하나하나 입력해야 하는게 너무 귀찮았다. 그래서 컬쳐랜드 핀번호를 입력하면 자동으로 validation을 해주는 프로그램을 만들어 보았다. 만들던 도중 한가

kookhh0827.tistory.com

https://www.softwaretestinghelp.com/selenium-python-tutorial/

들어가며

Chat GPT는 2022년 12월에 OpenAI에서 출시된 이후 지금까지 꾸준히 핫한 키워드인 것 같다.

다들 알겠지만 Chat GPT에 대해 간단히 설명하면 아래와 같다.
(GPT에게 "너에 대해 설명해 줘"라고 물어봐서 얻은 답변)

저는 ChatGPT라고 하며, 인공지능 기술인 GPT-3.5 모델을 기반으로 한 대화형 챗봇입니다. 사람들이 자연어로 질문하면 가능한한 정확하고 유용한 답변을 제공하는 것이 제 목적입니다. 이를 위해 저는 최신 정보와 데이터를 학습하여 사람들에게 도움을 드리고 있습니다. 제가 가진 지식은 2021년 이전부터 2021년까지의 데이터를 바탕으로 학습되었지만, 저는 지속적으로 학습을 하고 있기 때문에, 미래에는 2022년 이후의 데이터도 포함될 수 있습니다.

 

그리고 유료버전은 GPT 4.0을 기반으로 한다고 한다. 좀 더 자세한 내용은 인간지능으로 작성된 나무위키 <Chat GPT> 를 참조하면 좋을 것 같다.


서론

먼저, 이 글은 다양한 Chat GPT를 활용법을 설명하는 글이 아니다.

본인은 아직 구글링이 익숙한 평범한 개발자이고 GPT로는 초기에 단순한 호기심으로 몇 개 질문해 본 게 전부인 사람이다. 

업무에서 Chat GPT를 사용해 보고 인상 깊은 점들이 몇 가지 있어 그 경험과 생각을 공유해보고자 한다.


본론

1. 발단

최근 ktLint를 이용한 코딩 컨벤션을 프로젝트에 적용하는 작업(https://j-louis.tistory.com/14)을 진행중 구글링에 한계를 느꼈다.  

Ktlinst 플러그인에 대한 개념과 이를 활용해서 컨벤션을 적용한 예시와 방법은 알 수 있었지만, 대부분 매우 간단한 튜토리얼 정도 였고 서비스에 적용하기엔 약간 부족했다. 그리고 대부분 영어로 된 많은 정보들을 계속 보다 보니 조금씩 헷갈리게 하더라. (ㅠ.ㅠ)

좀 더 상세한 활용, 그리고 서비스에 어떻게 적용하는 게 좋을지 고민해다가 Chat GPT한테 물어보기로 했다.

GPT! 정답을 알려줘!


2. 전개

처음 질문은 이렇게 시작했다.

그러니 GPT는 ci 설정파일 생성법 & ktlintCheck 적용까지 상세하게 설명해 주더라. 친절했다..! 

이 내용을 바탕으로 build 시점에 컨벤션 체크 방식으로 작업을 하였지만, 아래와 같은 이유로 다른 방식을 고민하게 되었다.


Push 이전에 체크 할 수 있는 게 있는지 다시 물어봤다. 

 

(지금 생각해보면 pre-push 관련 내용을 설명해 줘야 되지 않았나 싶긴 한데...) pre-commit 관련 내용을 설명해 주었다.
pre-push도 있는지 물어보았고 관련 예제 스크립트 알아서 작성해 주었다.

 

스크립트 내용도 내가 원하는 방식으로 수정도 요청할 수 있었기 때문에 문법에 대한 스트레스를 줄일 수 있었다.

        1. 특정 브랜치가 아니라 모든 브랜치에서 검사할 수 있게 스크립트 수정 요청.

        2. ktlint를 시스템 설치해서 사용하는 게 아니라 gradle 라이브러리 적용한 명령어로 수정 요청.

 

하지만 이 방식도 도중에 아래의 이유로 다른 방법을 생각해야 했다.


결국 commit 시점에 컨벤션 체크 하는 방식을 선택했고 다시 한번 gpt한테 도움을 요청했다.

스크립트 작성 & 스크립트 실행 권한 수정까지 섬세하게 알려준다는 느낌을 받았다.
물론 ./gradlew addKtlintCheckGitPreCommitHook    명령어를 이용해서 기본 스크립트를 이용할 수 있었지만 GPT가 작성해 준 script가 훨씬 도움이 되었다.(프로젝트 환경에 맞게 여러 번 수정해서 물어봐야 하는 게 약간 귀찮긴 했음)

 

 

그리고 작업 중 궁금한 사항들에 대해서도 답을 얻을 수 있어서 매우 편리했다. (다른 레퍼런스를 활용해서 했었다면 귀차니즘 때문에 지나쳤을 것 같은 질문들이다.)


3. 위기

중간에 삽질도 많이 했지만 결국 원하는 작업물을 얻을 수 있었다. 

로컬 환경에서 테스트를 진행 후 팀에 공유할 예정이었다.

하지만 곧 문제가 발생하더라. ㅎ_ㅎ

프로젝트 빌드 과정전체 파일을 대상으로 컨벤션 체크 task가 수행되었고 빌드는 실패가 되었다.

 

생각했던 프로세스에는 없던 과정이었다.

 

팀 내 컨벤션 체크를 강제화 하기 위해 해당 플러그인을 적용했던 것이었지만 우선 작업한 소스에 대해서만 컨벤션 강제성을 두고 싶었다.

 

그래서 다시 한번 gpt한테 도움을 요청했다.


4. 절정 - 결말

GPT는 여러 가지 해결책을 제시해 주었다.

그래서 희망을 갖고 시도를 했지만 프로젝트에 적용되지 않았고 오류만 발생했다. ㅠ.ㅠ

 

한 이틀 동안 비슷한 질문과 답변을 주고받았지만 해결책은 찾지 못했다.

(좀 더 시간을 투자했다면 해결책을 찾을 수 있을지 모른다. 하지만 개인 프로젝트가 아니었고 회사 업무였기 때문에 적정선에서 타협했다 ㅠ)  

 

결국 전체 소스에 대해 컨벤션 리펙토링을 진행한 뒤에 ktlint plugin과 pre-commit 작업을 추가함으로써 마무리하는 것으로 했다.

(컨벤션 리펙토링 때문에 생각지 못한 시간을 쏟았지만 전체적으로 컨벤션을 통일하고 나니 기분은 깔끔해서 좋았다.)


결론

업무를 처리하는 과정에 Chat GPT를 사용해 보고 느낀 점을 나열해 봤다.


  1. 생각보다 많이 친절하다.
    단순히 스크립트 작성해 달라고만 했지만 작성 후 간단한 설명까지도 해준다.
    쉽게 놓칠 수 있는 주의사항까지도 알려준다.

  2. 구글링을 해도 잘 나오지 않는 마이너 한 개념, 지식, 적용방법이 궁금할 때 활용하면 좋을 것 같다고 생각했다.
  3. 최대한 상세하게 배경을 설명하면서 질문을 해야 한다.
    배경지식이 1도 없는 기계와 대화하기 위해선 상세한 배경을 설명해 주는 게 필요하다고 느꼈다.
    (여러 번의 질문 & 답변 을 하다 보니 약간 면접과도 비슷하다고 느꼈다. 말 잘하는 ai 부럽다…)

  4. 나는 분명 컴퓨터와 대화를 주고받았지만 이런 경험이 쌓이면 협업에서도 많은 도움이 될 것 같다고 생각했다.
  5. 너무나 즉답이 나오기 때문에 휘발성이 좀 강하다. 의식적으로 기억하려는 노력이 많이 필요할 것 같다.

재밌었다.

삽질도 하긴 했지만 매우 유용한 도구라는 생각이 들었다. 

함께 일을 하는 데 있어서 소프트 스킬이 중요하다고 느끼고 있는 요즘, Chat GPT를 사용하면서 협업 그리고 대화에 대한 스킬을 기를 수 있지 않을까라는 생각도 들었다.

 

아직은 본능적으로 익숙한 구글링을 먼저 하고 있지만 점점 사용할 일이 많아질 같다.

 

곧, GPT를 이용한 업무처리가 자연스러워질 것 같은 느낌적인 느낌이 든다!

 

코드 품질을 향상시키기 위해선 컨벤션, 단위테스트, 정적 분석 등 여러 가지를 고려해야 한다.

이번 글에서는 팀에서 코딩 스타일을 공통으로 관리하기 위해 고민했던 내용, 적용한 사례, 그리고 느낀 점까지 기록해보려 한다.


배경

프로젝트를 시작할 땐 BE 개발자가 3명이었다. 3명의 코딩스타일을 맞추기 위해 깊게 고민할 필요는 없었고 단순히 intelliJ 플러그인 중 하나인 ktlint를 각자의 IDEA에 설치해서 최소한으로 컨벤션을 맞추고 프로젝트를 진행했다.

 

그 후 동료들이 늘어났지만 변한 건 없었다. 각자의 담당영역이 나뉘어 있었고 코드리뷰를 할 때 코딩스타일 컨벤션까지는 세세하게 보지 않았다. 

 

그러다 최근 서비스에 대규모 리뉴얼 작업을 진행하게 되면서 여러 사람들이 엮인 로직을 작업하게 되었는데 내가 작업할 코드가 빨간 줄 투성이거나 혹은 작업한 코드의 코딩스타일이 바뀌어져 있는 경우가 많았다.

 

원인이 무엇일까 고민했고 내린 결론은 다음과 같았다.

 

첫 번째로 '코딩스타일 컨벤션 체크'를 안 하는 사람도 있었다.

하지 않더라도 (분명 ide에선 빨간 줄로 보일 텐데...) 코드가 동작하는데 문제는 없기 때문에 신경 쓰지 않는 건가 싶었다.

 

두 번째는 명확하진 않지만 ide 설정에서 변경했거나 ktlint 버전 차이 때문에 standard rule이 변경되어서 코딩 스타일이 차이가 있는 게 아닐까 싶었다.

 

그렇기 때문에 코딩스타일을 공통관리하고 컨벤션 체크에 강제성을 부여할 필요성을 느꼈다.


Ktlint

ktlint이 뭔지부터 다시 한번 집어봤다. 

ktlint는 kotlin 코드 스타일을 검사하고 적용하기 위한 정적 분석 도구로서 ktlint는 코드의 일관성과 가독성을 높이기 위해 kotlin 코딩 컨벤션을 적용하고 코드에서 발견된 문제점을 지적하고 수정하는 도움을 줍니다.

ktlint는 Gradle, Maven, Ant 및 Command Line Interface(CLI)와 같은 다양한 빌드 시스템과 통합할 수 있으며, IntelliJ IDEA, Android Studio, Visual Studio Code 등 다양한 통합 개발 환경에서도 지원됩니다.

Kotlin 프로젝트에서 ktlint 사용하면 코드의 일관성과 가독성을 향상시키고, 코드 리뷰 과정에서 발견되는 문제를 줄일 있다.

 

위에서 언급한 것과 같이 ktlint는 빌드 환경에서 사용할 수 있도록 libarary 형태가 있고 에디터에서 쉽게 사용할 수 있도록 만든 플러그인 형태가 있었다. 

 

Gradle을 이용해서 build pipeline에 적용한다면 공통의 코딩스타일 컨벤션을 가지고 코드 체크할 있고, 강제성을 추가 있어서 코드의 품질을 유지하는데 효과적일 이라고 생각이 들었다.


도입기

1.  특정 브랜치 (Develop , Master)에 build 할 때 CI에서 ktlint 컨벤션 체크  

방식: develop or master 브랜치에 push 후 build 될 때 컨벤션을 체크해서 실패 처리

결론: 실패된 작업을 수정하기 위해서 작업 브랜치부터 다시 진행을 해야 하는 불편함이 있고 혹시나 다른 사람들이 작업한 소스의 영향도 받을 수 있기 때문에 포기했다.

 

 

2.  작업 브랜치 Push 시점에 체크

방식: git hook pre-push를 이용해서 브랜치 push 시점에 체크, 컨벤션 오류일 경우 push 실패처리

결론: 코딩 스타일 컨벤션을 맞추기 위한 불필요한 commit 로그가 많아질 것으로 보임, 좀 더 빠른 시점에 체크하는 게 나을 것 같다고 판단함.

 

 

3.  Commit 시점에 체크

방식: git hook pre-commit을 이용해서 coomit 시점에 체크, 컨벤션 오류일 경우 commit 실패처리

결론: 결국 가장 작은 단위인 commit 할 때부터 컨벤션 체크를 하는 게 맞다고 생각, 이 방식으로 작업 진행


작업 

1. IntelliJ Plugin: https://plugins.jetbrains.com/plugin/15057-ktlint-unofficial-  (version "0.10.0")

  • 작성한 코드의 코딩 스타일 컨벤션을 실시간으로 확인해서 알려주는 기능.
  • Preferences > Plugins > Ktlint 설치

 

2. gradle 플러그인 https://github.com/JLLeitschuh/ktlint-gradle (version "11.3.1") + git hook pre-commit 세팅 

  • gradle 플러그인: 전체/부분 로직에 대한 코딩스타일 정적검사를 진행할 수 있음
  • pre-commit 스크립트: commit 이전에 진행되는 작업이 적힌 스크립트. - 해당 스크립트에 코딩스타일 컨벤션 검사 로직을 추가

 

  • gradle 플러그인 설치 & build
plugins {
		id("org.jlleitschuh.gradle.ktlint") version "11.3.1”
}



apply(plugin = "org.jlleitschuh.gradle.ktlint")

 

  • pre-commit 작성방법 
    • 1. ./gradlew addKtlintCheckGitPreCommitHook - 기본적으로 컨벤션 체크해 주는 pre-commit 스크립트를 만들어준다.
    • 2. .git/hooks/pre-commit 스크립트를 직접 생성하기. (feat. 킹갓제너럴 ChatGPT )
           컨벤션 불일치하는 라인을 표시하는 작업을 추가함.
#!/bin/sh
######## KTLINT-GRADLE HOOK START ########

CHANGED_FILES="$(git --no-pager diff --name-status --no-color --cached | awk '$1 != "D" && $NF ~ /\.kts?$/ { print $NF }')"

if [ -z "$CHANGED_FILES" ]; then
    echo "No Kotlin staged files."
    exit 0
fi;

echo "Running ktlint over these files:"
echo "$CHANGED_FILES"

diff=.git/unstaged-ktlint-git-hook.diff
git diff --color=never > $diff
if [ -s $diff ]; then
  git apply -R $diff
fi

echo "KTLINT CHECK START"
ktlint_output=$(./gradlew ktlintCheck -PinternalKtlintGitFilter="$CHANGED_FILES")
if [ $? -ne 0 ]; then
  echo "KTLINT CHECK FAIL"
  echo "check these files:"
  while read -r line; do
    if [[ "$line" =~ ^.*\.kt:[0-9]+:[0-9]+.*$ ]]; then
      echo "$line"
    fi
  done <<< "$ktlint_output"
  exit 1
fi


  echo "KTLINT CHECK SUCCESS"


if [ -s $diff ]; then
  git apply --ignore-whitespace $diff
fi
rm $diff
unset diff

  echo "KTLINT CHECK FINISH"
exit 0
#exit $gradleCommandExitCode
######## KTLINT-GRADLE HOOK END ########

 

3. .editorconfig 파일 설정

    • ktlint 기본 컨벤션 외에 프로젝트에 적용되는 예외 규칙들 작성 리스트 
    • 자세한 내용은 EditorConfig 참조. 
    • .editorconfig 파일이 없는 경우 기본룰 Ktlint 버전에 따라서 Standard rules 기본적으로 따르지만 버전이 변경이 되면 Standard rules 변경이 있기 때문에 root 디렉터리에 .editorconfig파일을 설정하는 것을 권장.


결과

commit 요청 클래스가 코딩스타일 컨벤션에 일치하는 경우: 정상적으로 커밋 성공

commit 요청 클래스가 코딩스타일 컨벤션에 일치하지 않는 경우: console에 에러 표시 & 불일치 라인과 이유 표시


추가적으로 고민해 볼 내용

표시되는 코딩 스타일 컨벤션 오류에 대한 고민 

코드 수정이 아니라 컨벤션 수정을 고려해야 할 수도 있음.

max-line-length가 70으로 설정되어 있어서 작성된 코드들을 줄 바꿈 해야 한다고 알려준다.

하지만 제안에 맞게 코드를 바꾸게 된다면 프로젝트 내 다른 코드들도 전부 변경해줘야 하기 때문에 max-line-length을 늘리는 것을 고려해 볼 수 있다.

 

ktlint 버전 차이에 의한 rule 차이

ktlint 버전은 계속 변하면서 kotlin standard rule도 바뀐다.

한 예로 ktlint 0.42.1 버전에선 rule에 걸리지 않았던 내용이 0.47.1 버전에서는 rule에 잡힘.

그렇기 때문에 꾸준한 버전 관리를 통해서 코딩스타일 컨벤션을 꾸준히 Update 해주는 것이 필요하다고 느꼈다.


마무리

간단할 줄 알았던 코딩 스타일 컨벤션 세팅을 작업하고 나니 생각보다 고려할게 많았고, 작업량에 비해서 시간도 많이 소요되었다. (대부분이 고민, 삽질 등..)

또한, 코딩스타일 규칙을 강제했더니 error를 해결하기 위해 코드 리펙토링을 진행할 수 밖에 없는 환경도 생겼다. (물론 제약이 생겨서 답답한면도 많았다.) 

하지만 명확한 정책을 세우고, 나뿐만 아니라 팀원들이 좀 더 나은 품질의 코드를 볼 수 있겠다는 생각에 뿌듯...

지속적으로 팀원들간에 협의를 통해서 개선해 나갈 예정이다.

 

함께 관리해야 하는 코드는 공통으로 관리할 수 있는 규칙과 그 규칙을 지킬 수 있는 환경을 만들어 주는 게 중요하다는 점을 배운 시간이지 않나 싶다.

레디스를 이용한 데이터 캐싱 도입기 - Part1에 이어서 서비스에 레디스 캐시를 적용 후 진행한 성능 테스트에 대해 이야기해보려고 한다.


서론

개발자라면 누구나 DB만 사용했을 때 보다 캐싱을 적용한 로직이 처리가 빠르다는 것을 안다.

하지만 추상적으로 받아들이는 것 말고 구체적으로 검증을 하고 싶었다.

이전과 비교해서 서비스 로직이 성능적으로 얼마나 좋아졌는지 확인하기 위해서 테스트를 진행했다. 

성능 테스트를 지원하는 도구는 여러 가지가 있지만 가장 익숙하고 간편한 nGrinder를 이용했다.


nGrinder

네이버에서 성능 측정을 목적으로 개발된 오픈소스 프로젝트이다. 이를 이용하면 서버의 부하를 테스트해서 성능을 측정할 수 있다.

nGrinder는 Controller와 Agent, 2가지 주요한 컴포넌트들로 이루어져 있다.

 

Controller는 성능테스트를 위한 웹 인터페이스를 제공한다. 테스트 프로세스들을 구성하고, 테스트 통계를 볼 수 있다.

그리고 테스트를 위한 스크립트를 생성하고 수정할 수 있도록 한다.

 

Agent는 타깃 서버에 부하를 주는 프로세스들과 스레드들을 동작시키는 역할을 한다.

그리고 타깃 시스템(서버)의 성능(CPU/Memory 등)을 모니터링할 수 있다. 

 

nGrinder 시스템의 구조

이번 글에서는 nGrinder에 대한 자세한 내용은 생략하고자 한다. 

좀 더 자세히 알고 싶다면 github의 userGuide 또는 다른 블로그들을 참조하는 것을 추천한다.


테스트

이제 테스트를 진행할 시간이다.

 

우선 시나리오는 두 개다.

1. 기존 로직에 대한 테스트 (DB)
2. 새로운 로직에 대한 테스트(Redis + DB) 

 

테스트 설정은 다음과 같이 하였다.

Target 서버: Local 환경, 개발서버
Vuser(virtural user로 동시에 접속하는 유저수를 의미) : 500명
테스트 시간: 10분

테스트 진행 시 겪은 시행착오

테스트를 진행했는데 생각보다 결과에서 차이가 없었다. 문제의 원인을 찾다가 redis를 잘못 적용했나 생각하며 꽤 오랜 시간 삽질을 했었다. 약간 창피하지만 혹시나 나와 같은 실수를 하는 사람을 위해 확인한 내용을 공유해 본다.

 

첫 번째는 테스트 데이터의 양이였다. API가 수행되는데 조회할 데이터 자체가 작다 보니 캐시 적용효과를 테스트에서 확인하기 어려웠다. 그래서 운영환경과 비슷하게 많은 데이터를 모집단으로 설정하는 게 필요하다 판단했고 테스트 관련 데이터는 운영환경에서 가져와서 처리했다. 

 

두 번째는 API 로직 이해 부족이었다. 첫 번째 원인을 수정하였는데도 두 가지 테스트에서 차이가 눈에 띌정도로 보이지 않았다. 확인해 보니 해당 API는 페이징 처리를 해두었는데 request에 paging 관련 값은 default 던지고 있었다. 그래서 계속 같은 데이터만 return 하는 구조였다. 이 원인은 테스트 스크립트의 api 호출 부분을 수정해서 처리하였다.

 

테스트환경, 데이터, 로직에 대한 정확한 이해를 기반으로
테스트를 진행해야겠다는 점을 다시 한번 느꼈던 상황들이었다. 😭😭

 

테스트 스크립트는 기본 template을 약간 수정해서 작성했다. 전체 코드는 아래 더보기로 확인하길 바란다.

  • Redis Test Script
더보기
import static net.grinder.script.Grinder.grinder
import static org.junit.Assert.*
import static org.hamcrest.Matchers.*
import net.grinder.script.GTest
import net.grinder.script.Grinder
import net.grinder.scriptengine.groovy.junit.GrinderRunner
import net.grinder.scriptengine.groovy.junit.annotation.BeforeProcess
import net.grinder.scriptengine.groovy.junit.annotation.BeforeThread
// import static net.grinder.util.GrinderUtils.* // You can use this if you're using nGrinder after 3.2.3
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith

import org.ngrinder.http.HTTPRequest
import org.ngrinder.http.HTTPRequestControl
import org.ngrinder.http.HTTPResponse
import org.ngrinder.http.cookie.Cookie
import org.ngrinder.http.cookie.CookieManager

/**
* A simple example using the HTTP plugin that shows the retrieval of a single page via HTTP.
*
* This script is automatically generated by ngrinder.
*
* @author admin
*/
@RunWith(GrinderRunner)
class TestRunner {

	public static GTest test
	public static HTTPRequest request
	public static Map<String, String> headers = ["x-balcony-id":"BOMTOON_COM"]
	public static Map<String, Object> params = ["groupMenu":"SCHEDULE_ALL","isIncludeAdult":"true","contentsThumbnailType":"MAIN","isIncludeTen":"false","isIncludeTenComplete":"false","sort":"POPULAR"]
	public static List<Cookie> cookies = []
	@BeforeProcess
	public static void beforeProcess() {
		HTTPRequestControl.setConnectionTimeout(300000)
		test = new GTest(1, "http://localhost:8080")
		request = new HTTPRequest()
		grinder.logger.info("before process.")
	}

	@BeforeThread
	public void beforeThread() {
		test.record(this, "test")
		grinder.statistics.delayReports = true
		grinder.logger.info("before thread.")
	}

	@Before
	public void before() {
		headers.put("Authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im1DbnNLTWhGNFZiMGJZK0g0amlwNWVSNHF4QTJwaHhQaDA3SlNwTm1Oc1pCZXJGS2Vkcmpidm05aVY2RzVkZkgiLCJzbnNQcm92aWRlciI6IkgxK3RUdU9EQ1lBQ3ZFKzdwNGJNR1E9PSIsImlzQWR1bHQiOiJjYUZmRU00Z0VIUmZXd215VDNCMXlnPT0iLCJ1c2VySWQiOiJYR3k1c1ByQmxFWmxpZXVNenp2cDZBPT0iLCJ1dWlkIjoiUERnelE3NVR5S3UzQWhpb2gzQTZtRHBheDhmQk10cUdnT3ZOL3hBa0VRVGN3V1QxQ1MyQVk3bmx6WWNtQ3dMQSIsImlhdCI6MTY3OTk2ODEzMywiZXhwIjoxNjgwMDU0NTMzfQ.ezMtHPpfpXEeQlF0aYxfyC8dZFgPGMfYMaeQkT6pl6Q")
		request.setHeaders(headers)
		CookieManager.addCookies(cookies)
		grinder.logger.info("before. init headers and cookies")
	}

	@Test
	public void test() {
		int page = (int)(Math.random()*500)
		params.put("page", Integer.toString(page))
		HTTPResponse response = request.GET("http://localhost:8080/v2/contents/group/new", params)

		if (response.statusCode == 301 || response.statusCode == 302) {
			grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
		} else {
			assertThat(response.statusCode, is(200))
		}
	}
}
  • DB Test Script
더보기
import static net.grinder.script.Grinder.grinder
import static org.junit.Assert.*
import static org.hamcrest.Matchers.*
import net.grinder.script.GTest
import net.grinder.script.Grinder
import net.grinder.scriptengine.groovy.junit.GrinderRunner
import net.grinder.scriptengine.groovy.junit.annotation.BeforeProcess
import net.grinder.scriptengine.groovy.junit.annotation.BeforeThread
// import static net.grinder.util.GrinderUtils.* // You can use this if you're using nGrinder after 3.2.3
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith

import org.ngrinder.http.HTTPRequest
import org.ngrinder.http.HTTPRequestControl
import org.ngrinder.http.HTTPResponse
import org.ngrinder.http.cookie.Cookie
import org.ngrinder.http.cookie.CookieManager

/**
* A simple example using the HTTP plugin that shows the retrieval of a single page via HTTP.
*
* This script is automatically generated by ngrinder.
*
* @author admin
*/
@RunWith(GrinderRunner)
class TestRunner {

	public static GTest test
	public static HTTPRequest request
	public static Map<String, String> headers = ["x-balcony-id":"BOMTOON_COM"]
	public static Map<String, Object> params = ["groupMenu":"SCHEDULE_ALL","isIncludeAdult":"true","contentsThumbnailType":"MAIN","isIncludeTen":"false","sort":"POPULAR"]
	public static List<Cookie> cookies = []
	@BeforeProcess
	public static void beforeProcess() {
		HTTPRequestControl.setConnectionTimeout(300000)
		test = new GTest(1, "http://localhost:8080")
		request = new HTTPRequest()
		grinder.logger.info("before process.")
	}

	@BeforeThread
	public void beforeThread() {
		test.record(this, "test")
		grinder.statistics.delayReports = true
		grinder.logger.info("before thread.")
	}

	@Before
	public void before() {
		headers.put("Authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im1DbnNLTWhGNFZiMGJZK0g0amlwNWVSNHF4QTJwaHhQaDA3SlNwTm1Oc1pCZXJGS2Vkcmpidm05aVY2RzVkZkgiLCJzbnNQcm92aWRlciI6IkgxK3RUdU9EQ1lBQ3ZFKzdwNGJNR1E9PSIsImlzQWR1bHQiOiJjYUZmRU00Z0VIUmZXd215VDNCMXlnPT0iLCJ1c2VySWQiOiJYR3k1c1ByQmxFWmxpZXVNenp2cDZBPT0iLCJ1dWlkIjoiUERnelE3NVR5S3UzQWhpb2gzQTZtRHBheDhmQk10cUdnT3ZOL3hBa0VRVGN3V1QxQ1MyQVk3bmx6WWNtQ3dMQSIsImlhdCI6MTY3OTk2ODEzMywiZXhwIjoxNjgwMDU0NTMzfQ.ezMtHPpfpXEeQlF0aYxfyC8dZFgPGMfYMaeQkT6pl6Q")
		request.setHeaders(headers)
		CookieManager.addCookies(cookies)
		grinder.logger.info("before. init headers and cookies")
	}

	@Test
	public void test() {
		int page = (int)(Math.random()*500)
		params.put("page", Integer.toString(page))
		HTTPResponse response = request.GET("http://localhost:8080/v2/contents/group", params)

		if (response.statusCode == 301 || response.statusCode == 302) {
			grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
		} else {
			assertThat(response.statusCode, is(200))
		}
	}
}

테스트 결과

테스트 결과는 다음과 같았다.

  • DB Test 결과

  • Redis Test 결과

 

TPS 테스트시간만 체크했다.

 

TPS는 초당 트랜잭션 완료수이다. 높을수록 성능이 높다는 것을 의미한다.

테스트 시간은 user가 request 한 시점에서 시스템이 response 한 시점, 즉 API 수행시간이다.

 

두 개의 결과에 대한 차이는 10 이상 것을 확인할 수 있었다.


결론

DB만 사용할 때보다 캐싱을 도입했을 때 성능적인 차이를 구체적인 수치로 확인할 수 있어서 좋았다.

나와 같은 멍청한 실수를 하지 않으려면 몇 가지만 신경 쓰고 테스트를 진행하길 바란다.

 

구현한 로직 프로세스, 모집단의 데이터의 , 서버 성능에 따라서 테스트의 결과는 확연하게 달라질 있다는을 인지하는 게 중요할것 같다. 

이번 글에서는 레디스를 도입할 때 고민했던 내용에 대해 공유해보고자 한다.


일반적으로 캐시는 메모리를 사용하기 때문에 DB 보다 훨씬 빠른 성능을 기대한다. 

하지만 올바르지 않은 방법으로 사용하게 된다면 데이터에 문제가 생길 수도 있고, DB만 사용하는 것보다 더 비효율적이 될 수도 있다. 

효율적으로 사용하기 위해서 캐시에 저장할 데이터의 기준확립 캐싱 전략 중요하다고 생각했다.


캐시 전략 패턴

캐시를 사용하게 되면 데이터의 정합성 문제에 직면하게 된다. 

서로 다른 두 저장소(메모리와 DB)에 데이터를 저장하게 되니 불일치가 발생할 가능성이 높다.

이 문제를 방지, 극복하기 위해선 적절한 캐싱 전략을 사용해야 하기 때문에 먼저 어떤 방법들이 있는지 찾아보았다.

 

크게 캐시는 읽기 전략과 쓰기 전략으로 나뉜다.

 

캐시 읽기 전략(cache read strategy)

1. Look Aside Pattern

  • 캐시 우선 확인 전략, 캐시에 없으면 DB조회.
  • 레디스 장애 시 DB에서 데이터를 가져올 수 있기 때문에 서비스에 문제없이 운영 가능.
  • 캐시 connection이 많이 있던 경우 순간적으로 DB에 부하가 몰릴 수 있음.
  • 반복적인 읽기가 많은 호출에 적합.

 

2. Read Through Pattern

  • 캐시에서만 데이터를 읽는 전략
  • cache miss가 많을 경우 look aside 전략보다 전체적인 성능이 느릴 수 있음.
  • 레디스 다운 시 서비스 이용에 문제가 생김. 이중화 구조 필요.

 

캐시 쓰기 전략(cache write strategy)

1. Write Back Pattern

  • 데이터를 DB에 바로 저장하지 않음.
  • 캐시에 모아놨다가 DB에 저장. (DB 쓰기 비용, 부하 줄일 수 있음)
  • Write가 빈번한 서비스에 적합.
  • 레디스 장애 시 데이터 영구 소실가능.

2. Write Through Pattern

  • 데이터를 캐시와 DB에 함께 저장하는 방식
  • 캐시와 DB의 데이터 일관성 유지 가능.
  • 데이터 유실 발생 가능성 적음.
  • 매 요청마다 두 번의 write 발생.
  • 빈번한 생성, 수정이 있는 서비스에서는 성능 이슈가 발생할 수 있음.

3. Write Around Pattern

  • 모든 데이터를 DB에 저장.
  • Write Through보다 훨씬 빠름.
  • 캐시와 DB 데이터 불일치 가능성 있음.

전략패턴 조합

캐시 읽기 전략과 쓰기 전략을 조합해서 사용한다.

 

ex)

  • Look Aside + Write Around
  • Read through + Write Around
  • Read through + Write Through

나의 도메인에 가장 적합한 방식은 Look Aside + Write Around라고 생각했다.

 

웹툰 서비스의 특성상

읽기가 빈번히 발생하고 레디스 장애 시에도 정상적으로 서비스 제공이 가능하기 때문에 Look Aside를 생각했고

상대적으로 뜸한 쓰기(작품 등록, 수정) 때문에 Write Around 적합하다고 생각했다.

 

 


캐싱 데이터의 기준

두 번째로 아래의 원칙을 가지고 캐싱 데이터를 정리하였다.


  • 자주 사용 되면서 자주 변경되지 않는 데이터를 추출.
  • 중요한 정보, 민감 정보는 저장하지 않음.
  • 휘발성 염두하여 데이터가 유실, 또는 정합성이 맞지 않아도 괜찮은 데이터 저장.

 

(간단하게 데이터를 표현하면 다음과 같다.)

AS-IS 콘텐츠 데이터:

성인작품여부
완결여부
제목
회차수
이벤트여부
오픈기간(시작, 끝)
뷰카운트
작가정보
썸네일정보
태그정보

우선 다른 도메인의 성격을 띠는 썸네일 정보, 태그 정보를 분리하였다.

그다음으로 상대적으로 자주 변경되는 데이터에 속하는 회차수, 이벤트 여부, 뷰카운트 정보를 분리하였다.

 

TO-BE 콘텐츠 데이터:

성인작품여부
완결여부
제목
오픈기간(시작, 끝)
작가정보

크게 변한 것은 없어 보이지만 원칙을 가지고 기준에 맞는 데이터만 남긴 부분이 추후 유지보수 할 때 용이할 것이라는 생각이 들었다.

또한, 캐시 관련 로직을 심플하게 작성할 수 있었다.


마무리

이번 경험을 통해서 수많은 문제 해결 방법 중에 하나의 적절한 방법을 결정하는 게 더 어렵다고 느꼈다.

나의 기준이 다른 도메인에서는 올바르지 않을 수 있다.

그렇기 때문에 문제를 해결하는 데 있어 중요한 것은 도메인에 대한 이해라고 생각했다.

 

Part2 글에서는 도입한 캐시를 가지고 진행한 성능 테스트에 대해 이야기해 보도록 하겠다.

 

참고 블로그

- 레디스를 이용한 캐싱 설계 전략

글을 쓸까 말까 고민했다.

간단한 git 명령어인 pull과 merge를 헷갈려했다는 사실이 창피했기 때문이다. 😅

하지만 기록하지 않으면 금방 잊어버릴 것 같았다. 지금이라도 오래 기억할 수 있도록 기록해두려고 한다.

(틀린 내용이 있을 수도 있으니 언제든 피드백은 환영한다. ^^;;)


배경 설명

팀에서 작업 중인 브랜치들을 분리해야 할 상황이 발생했었다.

작업 시나리오에 Merge와 Pull을 작성했는데 문득 두 개 명령어는 어떤 차이가 있는지 , 무엇이 맞는 건지 궁금해졌다.

작업 시나리오


확인한 내용

책(팀 개발을 위한 Git, Github 시작하기)과 블로그를 참고했고 간단한 테스트를 진행했다.

 

Pull (Fetch + Merge가 합쳐진 작업):   원격 저장소의 변경사항을 로컬 저장소로 가져와서 병합하는 것.

pull에 대한 그림 설명

테스트 내용: 

test1에서 test2를 Pull 했을때 콘솔에 출력된 내용.

콘솔에서는 test2 브랜치를 test1으로 merge 한다는 내용을 마지막에 확인할 수 있다.

 

test1에서 test2를 Pull 했을때 기록된 커밋.

커밋로그에서는 git log에는 새로운 merge로 인한 새로운 commit이 생성된 것을 확인할 수 있었다.


Merge: 서로 다른 브랜치의 변경사항을 합치는 작업.

merge에 대한 그림 설명

테스트 내용:

test1에서 test2를 Merge 했을때 콘솔에 출력된 내용.

콘솔에서 pull과 동일하게 test2 브랜치를 test1으로 merge 한다는 내용을 마지막에 확인할 수 있다.

 

test1에서 test2를 Merge 했을때 기록된 커밋

커밋로그에서도 Pull과 마찬가지로 git log에는 merge로 인한 새로운 commit이 생성된 것을 확인할 수 있었다.


결론

작업 시나리오

적어도 작성한 작업 시나리오에서는 '동일한 작업으로 봐도 된다'라고 결론을 내렸다.


추가 내용

추가로 팀의 Git 컨벤션을 좀 더 상세하게 이해할 수 있었다. 사용하는 방식을 간단하게 설명하면 아래와 같다. 

  1.  master에서 작업 브랜치를 checkout 한다.
  2.  작업이 끝난 뒤, 최신 master에서 작업 브랜치를 pull 한다.
  3.  그 후 배포 브랜치(mgt, front)에 push 한다.

새로운 동료들이 종종 3번 과정에서 push가 아니라 pull로 진행하는 게 문제가 경우가 있었다. (다음에 배포하는 사람은 push 할 때 reject 됨)

 

이럴 때 나는 단순히 commit log가 더 생기기 때문에 mgt에서 pull 하지 말고 master에서 push 부탁한다고 말했다.

틀린 이야기는 아니었지만 상세한 설명이 어려웠는데 이번 기회에 확실하게 이해했고, 좀 더 상세한 설명이 가능해졌다.

 

 

다음에 누군가 같은 실수를 했다면 이렇게 알려주고 싶다. 


배포 브랜치에서 pull을 하게 되면 merge commit이 한번 더 생기기 때문에
이 브랜치들이 master 브랜치보다 더 최신의 commit을 가리키게 된다.

배포 브랜치에서 master를 pull한 상황


이후 배포하는 사람이 master 브랜치의 커밋을 push 하려고 할 때 오류가 발생한 것이다.(master가 old commit을 참조하기 때문)

이후 사람이 push할때 reject 되는 상황

이 상황에서 해결책은 더 최신의 commit을 보는 master_mgt를 master에 push 해야 하는 것이다.

문제가 해결된 상황

 

이렇게 된다면

불필요한 merge commit log가 생기고 결국 두 번 작업을 해야 되기 때문에 기존의 팀 컨벤션이 좀 더 효율적이다.

이라고 말할 수 있다. 


 

과거와 비교해서 상대에게 알려주는 내용은 크게 달라진 게 없다. 하지만 과거에는 내가 명확히 알지 못하는 상태에서 누군가를 알려줬다면 지금은 명확히 아는 것을 자신 있게 알려줄 수 있다. 

 

결론을 얻는데 오래 걸리지 않았고 단순한 내용이었지만 다시 한번 명확하게 개념을 학습했다는 것에 의의를 가졌고 모르는 것은 창피해하지 말고 그때라도 확실히 알아두자라고 생각했다. 

 

애매하게 아는 것보다 명확하게 모르는 게 낫고, 명확하게 모르는 것보단 명확하게 아는 게 낫다!

마지막날 민석님이 주신 캘리그라피와 함께 찍은 단체 사진

 

참여 계기

사람 만나는 걸 좋아하다 보니 여러 커뮤니티를 경험했고 다양한 사람들을 만났다. 

사람들은 보통 ‘회사+사람’으로 본인을 소개하는데 그중 나에게 인상 깊은 인사이트를 준 사람들은 ‘회사’가 없어도 ‘사람’이 누구인지 분명히 알수 있는 사람들이었다. 그들을 보면서 나도 저렇게 되고 싶다고 생각했었지만 막연한 생각은 바쁜 회사업무 속에서 금방 잊혔다.

 

그러던 중 개발자를 위한 교육 플랫폼인 NEXTSTEP에서 ‘개발자 퍼스널 브랜딩 워크숍’이라는 과정의 모집글을 보았다. 소개된 커리큘럼은 ‘회사+나’에서 ‘나’가 되는 방법을 알려주는 수업처럼 보였다. 그리고 깊게 생각해보지 않았던 개발자로서의 향후 N 년을 고민해 볼 수 있는 시간이 될 것 같아서 신청하게 되었다.

 

교육 과정

교육은 리딩해주시는 문수민 님, 김민석 님과 참여자 7명으로 진행되었다. 단방향의 수업이 아니라 참여형 수업이었다. 낯을 가리는 것도 잠시뿐 6주 동안 서로 많은 피드백을 주고받으면서 과정을 즐겼던 것 같다.

 

6주동안 이 과정에서 추상적인 많은 것들을 구체화할 수 있었다. ‘나는 누구인가, 내가 가진 특,장점은 무엇인가, 목표 설정, 목표를 이루기 위한 plan은 어떻게 세울 것인지’ 혼자였다면 막막했을 추상적인 질문들에 대해 함께 이야기하는 시간을 가지면서 구체적이고 최선의 답을 얻을 수 있었다. 

 

답을 얻는 과정을 기억을 더듬어 주차별로 정리해 보았다.

 

1️⃣ 나는 어떤 사람인가 생각해 보기.

첫 번째 시간은 ‘나는 어떤 사람인가’에 대한 브레인스토밍으로 시작했다. 다양한 역할로서의 나를 생각해 보고 이를 조금씩 구체화하는 시간이었다. (사실 개발자로서의 ‘어떤’ 사람인가 보다 그 외적인 면(여행가, 러너, 테린이, 다이빙 마스터 등)이 더 많이 떠올라서 약간 민망, 뜨끔했다. 나는 개발을 별로 안 좋아하는 사람인가 싶은 생각이 들었다.)

수업의 마지막 즈음엔 불완전한 문장이 완성되었다. (불완전하다고 한 이유는 계속 발전시켜야 하기 때문이다.)

‘넓고 다양한 생각과 시야를 가진 백앤드 개발자’
‘문제가 해결될 때까지 집중해서 파고드는 백앤드 개발자’

 

2️⃣ 특장점을 이용해서 나만의 브랜드 문구 만들기.

두 번째 시간에는 나의 특징, 장점을 알아보고 첫 번째 시간에 했던 문장을 조금 더 발전시키는 시간이었다.  특장점을 알아보기 위해 인터넷에서 무료로 할 수 있는 강점 찾기와 주변 지인들에게 나에 대한 질문을 던졌다. 그렇게 정리된 강점은 창의성, 호기심, 학구열, 친절 등이 있었다.
이를 바탕으로 다시 한번 문장을 작성해 보았다. (이번엔 너무 길다고 느꼈다...)

‘동료들과 소통, 공감을 잘하고 가끔은 남들이 생각하지 못한 방향으로 문제를 해결하고 강한 책임감을 가지고 맡은 업무를 하는 백앤드 개발자’

 

3️⃣ 브랜드와 대외 활동에 관련된 특강.

세 번째 시간은 특강이었다. 송요창 님과 이야기를 나눠보는 시간을 가졌다. 먼저 왜(WHY) 개발자가 퍼스널 브랜딩을 해야 하는지 이유를 구체적인 예시(금전적인 도움, 커리어적인 도움 등)와 함께 말씀해 주셨다. 그리고 지금까지 본인이 했던 과정들(토이프로젝트, 블로그, 출판 등등)에 대한 설명과 어떻게(HOW) 브랜딩을 시작하면 좋을지 구체적인 방법까지 제시해 주셨다. 특강을 한 문장으로 표현하자면 ‘Just Do It’ 이였다. 

 

4️⃣ 나만의 단/중/장기 목표를 고민.

네 번째 수업에선 다시 한번 나를 표현하는 문장을 다듬었다. 그리고 커리어적으로 단기, 중기, 장기 의 목표를 설정해 보는 시간과 목표를 위한 활동을 고민해 보는 시간을 가졌다. 생각해 보니 개인적으로도, 커리어적으로도 목표를 세워본 게 오래전 일이라는 것을 하면서 깨달았다. 짧은 시간에 목표를 도출하긴 어려웠다. 떠올린 목표들도 많이 추상적이었다. 함께 나눈 이야기를 바탕으로 구체화하는 시간을 갖기로 했다.

이번 시간엔 수민 님의 피드백을 받아 문장을 다듬어보았다.

‘ 다양한 시각으로 문제를 바라보는 백앤드 개발자.’
‘새로운 해결책을 발견하여 제시하는 것을 좋아하는 문제해결사’

 

5️⃣ 대외 활동에 대해 컨설팅 특강.

다음 수업은 두 번째 특강이었다. 특강의 연사는 임동준 님과 임성현 님이었다.이번시간엔 단/중기 목표를 바탕으로 생각해 본 대외활동을 어떻게 해야 하는지가 주된 내용이었다. 그리고 질의응답 형식으로 진행되었고 두 분의 많은 이야기 중에 기억에 남는 두 가지만 써본다면 다음과 같다.

‘무대가 나를 찾아오게 하라’

무대는 준비된 사람에게 찾아오는데 준비는 수많은 과정 속에서 완성된다고 말씀하셨다. 그렇기 때문에 시행착오의 과정을 기록하는 것은 매우 중요하다고 했던 게 마음에 와닿았다.

‘준비가 덜된 상태에서 받은 피드백이 나를 바꾼다’

스스로 완벽하다고 생각하는 사람은 어떤 피드백도 잘 받아들이지 않기 때문에 불완전한 지금의 모습에서 여러 피드백을 통한 발전이 중요하다고 말씀하셨다. 항상 준비가 덜 된 모습은 창피해서 꼭꼭 숨기기 바빴던 예전의 나를 떠올리면서 내게 필요한 문장이라고 생각했다.

 

 

이것 말고도 현실적인 이야기들도 많이 해주셔서 정말 도움이 많이 되는 시간이었다. 이번 특강도 한 문장으로 표현하자면 ‘Just Do it’이었다. 특강을 듣고 얼마 후 블로그에 짧은 글 한편을 쓰면서 배움을 실천해 보았다. <일단 쓰고 생각하기>

동준님, 성현님과 함께 찍은 단체 사진

 

6️⃣ 올해 나만의 대외 활동 계획 작성.

마지막 시간엔 서로 고민해서 작성해 온 브랜딩 차트, 목표, 그리고 2023년 대외활동 계획을 공유하고 피드백받는 시간을 가졌다.

나는 계획이 추상적이라는 피드백을 받았는데 스스로도 동의하는 부분이었다. 목표가 추상적이다 보니 계획도 추상적인 느낌이었다. 그래서 목표부터 최대한 구체적으로 작성해보려고 했고 다른 분들의 공유내용도 벤치마킹하면서 계획을 다시 세워보았다. (작성한 내용은 다음 글에 공유해 보도록 하겠다.) 

그리고 과정의 마지막에 작성한 문장은 아래와 같다.

창의적이고 다양한 방법을 이용해서 문제를 효율적으로 해결하는 생각하는 개발자
긍정적인 에너지를 가지고 동료들과 협업, 공유를 통해 성장을 도모하는 팀원
배움을 어려워하지 않고 적용해 보는 것에 희열을 느끼고 공유를 할 줄 아는 개발자

 

 

이렇게 작성했다. 처음과 비교하면 많이 구체화된 느낌이 든다. 쓰고 나니 나를 소개하는 문장이기도 하지만 내가 되고 싶은 모습 같기도 했다.

이 문장의 내가 되기 위해 많이 노력해야겠다 생각했다.

 

 

마무리

6주의 과정을 통해 추상적인 솔루션이 아닌 구체적인 액션플랜을 얻었다. 그리고 나에 대해 깊게 생각해 보는 시간을 가지면서 어떤 색으로 브랜딩을 할지 방향성을 세울 수 있었다. 퍼스널 브랜딩이란 ‘긴 연대기적 과정’이기 때문에 액션플랜들을 실천하다 보면 언젠가 나도 ‘회사+나’가 아니라 ‘나’라는 브랜드를 만들 수 있지 않을까? 생각했다. 과정은 끝났지만 이제 시작인 느낌이다 ㅎㅎ

 

 

 

 

마지막 날 민석 님이 이런 질문을 했다. 

‘2023년 마지막에 어떤 사람으로 기억되고 싶나요?’

 

먼저 2023년을 어떻게 기억하고 싶은지부터 정리해 봤다.

 

개발자라는 업을 시작한 뒤 (2017년)
처음으로 
커리어에 대한 깊은 고민을 했고 목표를 세운뒤 
이를 이루기 위해 구체적인 많은 활동을 한 2023년.

 

이를 바탕으로 목표를 위해 열심히 노력 사람으로기억에 남고 싶다. 

민석님의 캘리그라피 선물 🙏

소개하고 싶은 블로그

함께 한 분들과 꾸준히 서로 응원하면서 열심히 실천하는 한 해가 되었으면 좋겠다.

 

조민지 님의 블로그

박이슬 님의 블로그

김정규 님의 블로그

권순규 님의 블로그

김유경 님의 블로그

문수민 님의 블로그

 

 

 

배경

얼마 전 기획팀에서 쿠폰 기능에 개수 제한 기능을 추가하고 싶다는 요청이 들어왔다. 로직 설계는 심플했다. 

갯수에 대한 상태 프로퍼티를 추가한 뒤 요청이 올 때마다 상태 변경한 뒤 쿠폰을 지급 또는 exception 처리를 하면 되었다. 

비즈니스 로직 설계

하지만 이 경우, 동시성 이슈가 발생해서 간헐적으로 설정한 갯수보다 더 많은 쿠폰을 발급할 가능성이 있었다. 

결국 이 요청사항의 핵심은 동시성 이슈를 해결하는 것이었고 이에 대한 방법을 찾아보고 도입해 보기로 했다. 

 

동시성 이슈를 해결하는 방법

1. Synchronized 키워드 

synchronized 키워드

가장 먼저 떠오른 방법이었다. 멀티스레드 환경에서 동기화 처리를 위해 자바에서 제공하는 키워드로서 synchronized 블록 단위로 Lock을 걸기 때문에 동시성을 보장할 수 있었다. 

하지만, 어노테이션이 적용되는 범위가 메서드이다보니 불필요한 로직들도 lock이 잡히고, 성능상 오버헤드가 심하다고 한다.
또한 단일서버가 아닌 멀티서버 환경에서는 동시성을 보장받지 못하는 점
때문에 이 방법은 적용해보지 않았다.

 

2. Optimistic Lock(낙관적 잠금)

낙관적 잠금의 프로세스 ( https://sabarada.tistory.com/175 블로그 참고)

데이터의 버전을 체크하면서 데이터의 정합성을 유지하는 방법이다. 실제로 lock을 잡는 방식이 아니기 때문에 성능적으로도 좋다고 한다.

하지만, 정합성 체크를 커밋 시점에 하기 때문에 충돌이 발생한 경우 해당 트렌젝션에 대한 롤백 처리가 필요하다.

여러 트렌젝션으로 묶인 로직이라면 롤백 처리가 복잡해질 수 있고 충돌이 자주 발생한다면 롤백으로 인한 오버헤드가 크게 발생할 수 도 있다고 한다. 

쿠폰 발급 처리의 경우 보통 이벤트로 인해 요청이 몰리게 되는 경우가 많다. 그렇기 때문에 해당 방식도 적합하지 않다고 판단하였다.

 

3. Pessimistic Lock(비관적 잠금)

비관적 잠금의 프로세스 ( https://sabarada.tistory.com/175 블로그 참고)

비관적 잠금은 트렌젝션이 시작할때 lock을 거는 방식이다.

배타락(Exclusive Lock), 공유락(Shared Lock) 두 가지 옵션이 있다.

  • 배타락은 하나의 쓰레드에서 lock을 획득했으면 해제되기 전까지 다른 스레드에서 읽기/쓰기가 모두 불가능한 방식.  (쿼리로 표현하면 select for update)
  • 공유락은 하나의 쓰레드에서 lock을 획득했으면 해제되기 전까지 다른 스레드에서 읽기는 가능 쓰기는 lock을 획득해야 가능한 방식.

비관적 잠금은 작업 처리 과정 중 데드락이 발생할 가능성이 있다는 단점이 있고 처리시간이 길 경우 대기시간에 따른 성능이 저하될 수 있다고 한다. 또한, JPA와는 달리 R2DBC에서는 낙관적 락만 지원, pessimistic lock은 지원하지 않았기 때문에 다른 방식을 찾아보았다.
 

4. DB Internal Lock

MySQL internal Lock은 데이터의 동시성 처리를 위해 MySQL 서버에서 제공하는 방법이다. 총 3가지로 제공하고 있다.

  • Row-Level Locking
  • Table-Level Locking
  • User-Level Locking

Row-Level Locking은 Row 수준으로 Locking을 하는 것이고, Table-Level Locking은 개별 테이블 단위로 설정되는 Locking이다.
User-Level Locking은 사용자가 지정한 문자열에 대해 키를 생성하고 해당 키로 잠금을 거는 방식이다.  

보통 서비스에서 db 연동은 해두고 쓰기 때문에 초기구축이나 작업 비용이 적다는 장점이 있었다. 하지만 동시에 여러 프로세스가 접근한다면 lock을 얻기위한 시도로 인해 부하 발생이 가능했고 트랜잭션에 대한 connection pool 관리가 필요했다.

 

5. Redis를 활용한 방법

  • lettuce를 활용한 방식

set when not exists(setnx) 방식으로 cache setting을 통해 lock을 거는 방법이다.

만약 spring framework에서 이미 redis를 사용한다면 라이브러리 추가 없이 사용가능하다.

lettuce는 spring-data-redis 라이브러리에서 제공하는 클라이언트이다. 또한 트랜잭션에 대한 connection pool 관리 필요 없다. 

하지만 스핀락 방식이기 때문에 동시에 많은 스레드가 대기 상태라면 redis에 부하를 줄 수 있다고 한다. 

 

  • redisson을 활용한 방식

Pub-Sub 기반이라서 thread의 재시도에 따른 redis 부하가 발생하지 않는 방식이었다.

별도의 라이브러리를 사용해야 한다는 점이 걸리긴 했지만 생각보다 구현이 간단했다.

DB와 redis의 부하를 신경 쓰지 않으면서 비즈니스 로직에 좀 더 집중할 수 있는 이 방식을 선택해서 서비스에 적용해 보았다.

 

Redisson 도입기

Redisson dependency 설정(gradle)

implementation("org.redisson:redisson:3.16.3")

redission client 객체를 생성을 위한 설정 로직

    fun redissonClient(): RedissonClient {
        val config = Config()
        config.useSingleServer().address = "redis://${redisWriteProperties.host}:${redisWriteProperties.port}"
        return Redisson.create(config)
    }

lock 관리 로직

    suspend fun receiveLimitCoupon(userId: Long, couponId: Long): List<CouponUser?> {
        val context = newSingleThreadContext("couponId")
        return withContext(CoroutineScope(context).coroutineContext) {
            val lock = redissonClient.getLock("limitCoupon")
            val maxRetry = 3
            var initRetry = 0

            while (!lock.tryLock(10, 3, TimeUnit.SECONDS)) {
                if (++initRetry == maxRetry) {
                    logger.info("threadID: ${Thread.currentThread().id} lock 획득 실패 Timeout")
                    return@withContext emptyList()
                }
            }

            try {
                logger.info("threadID: ${Thread.currentThread().id} lock 획득 성공")
                receiveCouponWithCouponId(couponId = couponId, userId = userId)
            } finally {
                lock.unlock()
                logger.info("threadID: ${Thread.currentThread().id} lock 반환 성공")
            }
        }
    }

1. "limitCoupon"이라는 name으로 lock을 생성한다.
2. tryLock method를 이용해서 락 획득을 시도하다. 

3. 락을 획득한 상태에서 비즈니스 로직(쿠폰발급)을 수행한다. 

4. 명시적으로 lock을 해제한다.

 

락을 요청하는 횟수를 정할 수 있다. 소스에서는 총 3번 요청하고 그 사이에 획득을 하지 못한다면 emptyList를 return 하도록 했다.


요청할 때 파라미터를 지정할 수 있다. tryLock method의 파라미터를 살펴보면 첫 번째는 waitTime(락을 기다리는 시간)이다. 두 번째는 leaseTime(획득한 락을 자동으로 해제하는 시간)이다. 세 번째는 시간단위를 나타낸다. 

waitTime동안 lock을 획득하지 못한다면 tryLock 함수는 false를 리턴하게 된다. 참고로 waitTime을 명시하지 않으면 즉시 값을 return 하게 되고, leaseTime이 명시되어 있지 않으면 watchDogTimeout값이라는 것을 이용하는데 default로 30초이다.

 

waitTime과 leaseTime을 적절하게 setting 하는 것은 중요하다.

만약 비즈니스 로직이 leaseTime보다 오래 걸린다면 처리과정 중 lock이 해제되고 해당 락은 다른 thread에 의해 선점될 수 있다. 그리고 락이 해제되었기 때문에 unlock() 메서드를 실행할 때 예외가 발생하게 된다.

 

동시성 테스트

 

1. Test1: 동시성을 고려하지 않은 로직에 coroutine을 이용해서 쿠폰 발급받기

쿠폰 제한 수량은 100개로 등록, 유저 100명의 요청을 10개의 worker로 나눠서 동시에 발급 요청했음. 

    test("case1 동시성 고려하지 않은 쿠폰 발급(10개 thread 동시에 처리)") {
        val headerMap = mutableMapOf<String, String>()
        headerMap["x-balcony-id"] = "BOOMTOON_COM"
        val reactorContext = Context.of(
            "BALCONY_CONTEXT",
            HttpHeaders.readOnlyHttpHeaders(LinkedMultiValueMap<String, String>().apply { setAll(headerMap) })
        ).asCoroutineContext()
        val userIds = List(100) { i -> i + 1 }
        
        withContext(reactorContext) {
            serviceOperator.execute {
                userIds.chunked(10).forEach { userIds ->
                    launch {
                        userIds.forEach {
                            couponRedisService.notConcurrentlyReceiveLimitNormalCoupon(it.toLong(), 96)
                        }
                    }
                }
            }
        }
    }

테스트 결과 로그는 위와 같았다.
해당 값은 발급 전 남은 쿠폰의 개수를 출력한 것이다. 
실제로 DB에 저장된 데이터를 확인해 보면 100명 모두 쿠폰을 발급받았지만 남아있는 쿠폰은 81개였다. 

데이터 불일치 문제가 발생한 것이다.

 

 

2. Test2: redisson을 이용한 로직으로 동시에 쿠폰 발급받기

조건은 test1과 동일했다. 단지 발급로직에 redisson을 이용해서 lock 처리가 추가되었다. 

    test("Redis coupon 100번 동시 호출") {
        val headerMap = mutableMapOf<String, String>()
        headerMap["x-balcony-id"] = "BOOMTOON_COM"
        val reactorContext = Context.of(
            "BALCONY_CONTEXT",
            HttpHeaders.readOnlyHttpHeaders(LinkedMultiValueMap<String, String>().apply { setAll(headerMap) })
        ).asCoroutineContext()
        val userIds = List(100) { i -> i + 1 }
        withContext(reactorContext) {
            serviceOperator.execute {
                userIds.chunked(10).forEach { userIds ->
                    launch {
                        userIds.forEach {
                            couponRedisService.receiveLimitCoupon(it.toLong(), 96)
                        }
                    }
                }
            }
        }
    }

테스트 결과는 다음과 같았다.

2개의 쿠폰이 남았길래 확인해 보니까 2명이 lock try timeout으로 받지 못한 것을 확인할 수 있었다.
하지만, 테스트 결과는 정상이었다. 총 98명이 받았다.
테스트를 로컬환경에서 진행 중이었는데 서버의 성능에 영향을 많이 받는 것을 알 수 있었다.

 

waitTime을 약간 늘려서 다시 테스트해 보았을 땐 정상으로 전부 처리된 것을 볼 수 있었다. 
위에서 언급한 'waitTime과 leaseTime을 적절하게 setting 하는 것은 중요하다.'를 다시 한번 느꼈다.

 

100명의 유저가 80개의 쿠폰을 발급받는 테스트도 진행해 보았다.

테스트 결과, 쿠폰 발급 수는 80개였고 20명은 발급받지 못한 것을 확인할 수 있었다.

 

마무리

한 가지 문제를 해결하는 데는 다양한 솔루션이 있다.

동시성 이슈를 해결하는 방법도 위에서 언급한 것처럼 다양하게 있다. 

어떤 솔루션이 자신의 상황에 가장 적절한지 충분히 고민해 본 다음에 적용하고 테스트해 보는 습관을 들이자.

 

참고자료

https://sabarada.tistory.com/175

https://dealicious-inc.github.io/2021/04/05/distributed-locking.html

https://happy-coding-day.tistory.com/entry/%EB%B9%84%EA%B4%80%EC%A0%81-%EC%9E%A0%EA%B8%88-%EB%82%99%EA%B4%80%EC%A0%81-%EC%9E%A0%EA%B8%88-%EA%B7%B8%EB%9F%B0-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0

5주 차 강의 내용 정리

  • 단위 테스트
  • 통합 테스트

단위 테스트

 - 특정 단위(테스트 대상)가 의도한 대로 작동하는지 검증

 

단위란? 

 - 사람마다 정의가 다를 수 있음. 작은부분이라는 점에 초점을 맞추면 됨.

 - 단일 메서드에서 전체 클래스까지 될 수 있음.

 

통합과 고립(Sociable and Solitary)

 - 단위 테스트 작성 시 관계를 맺고 있는 대상(협력 객체)이 있는 경우를 고려해야 함.

 - 협력 객체를 실제 객체로 사용할지, Mock 객체로 사용할지에 따라 테스트 구현이 달라짐.

 

단위의 정의를 논하기 앞서 테스트하는 단위가 통합(Sociable)되어야 하는지 고립(Solitary)되어야 하는지 고려해야 .


Test Double

 - 테스트 목적으로 실제 객체 대신 사용되는 모든 종류의 객체를 표현하는 일반 용어.

 - 즉 실제(클래스, 모듈, 매서드)를 가짜 버전으로 대체한다는 의미.

Stubbing

 - 테스트 동안 호출이 되면 미리 지정된 답변으로 응답.

 - 미리 지정된 것 외의 것에 대해서는 응답 하지 않음.

 

Mockito, MockitoExtension, Spring 등을 활용한 Stubbing 방법이 있다.

 

MockitoExtension을 활용한 예시

@ExtendWith(MockitoExtension.class)
public class AuthServiceTest {
    public static final String EMAIL = "email@email.com";
    public static final String PASSWORD = "password";
    public static final int AGE = 10;

    private AuthService authService;

    @Mock
    private MemberRepository memberRepository;
    @Mock
    private JwtTokenProvider jwtTokenProvider;

    @BeforeEach
    void setUp() {
        authService = new AuthService(memberRepository, jwtTokenProvider);
    }

    @Test
    void login() {
        when(memberRepository.findByEmail(anyString())).thenReturn(Optional.of(new Member(EMAIL, PASSWORD, AGE)));
        when(jwtTokenProvider.createToken(anyString())).thenReturn("TOKEN");

        TokenResponse token = authService.login(new TokenRequest(EMAIL, PASSWORD));

        assertThat(token.getAccessToken()).isNotBlank();
    }
}

 

Test Double은 언제 쓰는가?

 - 테스트 대상이 협력 객체를 가질 때 사용한다.

 

실제 객체를 사용하면 협력 객체의 행위를 협력 객체 스스로가 정의

가짜 객체를 사용하면 협력 객체의 행위를 테스트가 정의


가짜 객체 특징

  • 가짜 객체를 사용 할 경우 테스트 대상을 검증할 때 외부 요인(협력 객체)으로 부터 철저히 격리
  • 하지만 테스트가 협력 객체의 상세 구현을 알아야 함

실제 객체 특징

  • 실제 객체를 사용 할 경우 협력 객체의 상세 구현에 대해서 알 필요가 없음
  • 하지만 협력 객체의 정상 동작 여부에 영향을 받음

테스트 코드를 작성 할 때

  • 가짜 객체를 활용하면 실제 객체를 사용할 때 보다 조금 더 편하게 테스트를 작성할 수 있음
  • 하지만 상세 구현에 의존하는 테스트가 될 수 있음

 


추천하는 방법

  • 우선 TDD를 연습할 때 가급적이면 실제 객체를 활용하는 것을 우선으로 진행
  • 테스트 작성이 어렵거나 흐름이 이어지지 않는다면 테스트 더블을 활용하는 방법으로 접근하시는 것을 추천

통합 테스트

 - 우리가 만드는 대부분의 애플리케이션은 데이터베이스, 파일 시스템, 외부 라이브러리 등과 같이 다른 애플리케이션과 통합되어 개발하는 경우가 많음

 - 적은 비용과 빠른 테스트를 위해 단위 테스트를 주로 사용하지만 실제로 상호 작용하는 내용에 대한 검증도 반드시 필요함

 - 예를 들면 데이터베이스와의 통합을 테스트하는 경우 테스트를 실행할 데이터베이스를 실행해야함

 

외부 라이브러리

테스트의 필요성

 - 라이브러리 기능에 대한 검증은 필요 없음.

 - 단, 그 부분을 사용하는 부분에 대한 검증이 필요.

 

변경할 수 없는 코드 검증 시 실제 객체 사용

 - 작동 원리를 깊이 이해하기 어려움

 - Mock 객체와 실제 객체의 행위를 일치시키기 어려움

@Test
void findPath() {
    // graphService에서 활용하는 Jgrpah라이브러리의 객체를 실제 객체로 사용한다.
    List<Long> stationIds = graphService.findPath(
            Lists.list(line1, line2), 
            station3.getId(), 
            station5.getId(), 
            PathType.DISTANCE
    );

    assertThat(stationIds).hasSize(5);
    assertThat(stationIds.get(0)).isEqualTo(3L);
    assertThat(stationIds.get(1)).isEqualTo(2L);
    assertThat(stationIds.get(2)).isEqualTo(1L);
    assertThat(stationIds.get(3)).isEqualTo(4L);
    assertThat(stationIds.get(4)).isEqualTo(5L);
}

데이터 베이스

 - 테스트를 쉽게 하기 위해 메모리 내 H2 데이터베이스에 연결해서 한다.

@DataJdbcTest
public class StationRepositoryTest {
    @Autowired
    private StationRepository stationRepository;

    @Test
    void saveStation() {
        String stationName = "강남역";
        stationRepository.save(new Station(stationName));

        assertThrows(DbActionExecutionException.class, () -> 
            stationRepository.save(new Station(stationName))
        );
    }
}

 

통합 테스트 vs 단위 테스트

  • 통합 테스트는 통합하고 있는 부분을 실제와 가까운 환경에서 검증하여 기능이 정상적으로 여부를 검증
  • 단위 테스트는 통합하고 있는 부분이 정상적으로 동작한다고 가정하고 단일 기능에 대해서만 검증

참고자료

4주 차 강의 내용 정리

  • ATDD
  • 인수테스트란
  • 4주 차 미션(Lotto) 대한 피드백 

ATDD(인수테스트 주도 개발)

ATDD 프로젝트를 구성하는 인원들과 원할한 협업을 하기위한 소프트웨어 개발 방법론 하나. 기획 단계부터 공통의 목표와 이해를 도모해서 프로젝트를 진행하기 위해 사용됨.

신규 기능 개발시 발생가능한 문제( 도메인에 대한 이해도 부족으로 단위테스트 작성 어려움, 레거시 리팩터링의 어려움 ) 인해 TDD 사이클로 개발이 어려울 경우 인수테스트를 먼저 구현한 이후 단위 테스트를 통해 기능을 완성해 가는 과정 대안이 있다.

 

ATDD 사이클

ATDD 개발 프로세스 

 

  • 예시 1(신규 기능 개발)

요구사항 분석

 - 인수 테스트가 충족해야하는 조건

 - 분석 및 표현에 다양한 방식이 있다.

Ex)시나리오 기반으로 표현하는 방식 (Given/When/Then)

https://www.altexsoft.com/blog/business/acceptance-criteria-purposes-formats-and-best-practices/

 

인수 테스트

 - 분석한 요구사항을 검증하는 단계

 - 실제 요청/응답하는 환경과 유사하게 테스트 환경을 구성

 

문서화

 - Spring Rest Docs를 활용한 API 문서화

 - 문서화를 위해서는 Mock 서버 & DTO 정의가 필요.

  1. 프론트엔드, 다른 백엔드 개발자와 병렬로 작업하는데 유리함
  2. 인수 테스트와는 별개로 API 테스트를 수행
  3. 다른 개발자들과 협업 시 커뮤니케이션에 큰 장점

기능 구현 with TDD

 - 인수테스트 기반으로 하나씩 기능개발을 진행

 - Outside In / Inside Out 방향으로 개발

 

이후 반복적인 리펙터링

 

  • 예시 2(레거시 코드 리펙터링)

리팩터링 대상을 확인

 

리팩터링 방법 선택

 - 먼저 인수 테스트를 작성하여 기존에 구현된 기능을 보호하기

 - 파악이 가능한 부분 부터 단위 테스트를 만들어 기능 검증하기

 

인수 테스트의 보호 속에서 리팩터링 하자!

 

새로운 인수테스트를 작성

 

메서드 분리

 - 인수 테스트 작성 후 메서드 분리 시 사이드 이펙트에 대한 피드백을 바로 받을 수 있음

 - 기능을 구현하다 꼬이거나 잘못 되어도 인수 테스트가 성공한 시점으로 리셋할 수 있음

 - 안심하고 작은 단위로 메서드를 분리

 

단위 테스트 작성

 - 단위가 작아지니 역할이 줄어들고 검증할 대상이 명확해 짐

 - 단위 테스트 하기 쉬운 코드가


인수테스트란

실제 사용자 환경에서 사용자의 입장으로 테스트를 수행하는 것을 말한다. 인수 조건은 개발 용어가 사용되지 않고 일반 사용자들이 이해할 있는 단어를 사용

 

 

인수 테스트 특징

 

전구간 테스트

  • 요청과 응답 기준으로 전 구간을 검증

Black Box 테스트

  • 세부 구현에 영향을 받지 않게 구현하기

 

테스트 만드는 과정

 

테스트 환경 구축

인수 테스트는 시스템 내부 코드를 가능한 직접 호출하지 말고 시스템 구간을 테스트를 하도록 안내하고 있기 때문에 시스템 외부에서 요청하는 방식으로 검증함.

 

인수 테스트 클래스

  • 실제 서버가 아닌 테스트를 위한 서버를 로드하도록 설정
  • 실제 request가 아닌 인수 테스트의 request를 받기 위함
  • @SpringBootTest을 클래스에 붙여주면 테스트를 위한 웹 서버를 사용
  • webEnvironment 설정을 통해 서버의 환경을 지정

 

인수 테스트 객체 설정

  • 테스트를 위한 서버에 요청을 보내기 위한 클라이언트 객체 설정
    • ex. MockMvc, RestAssured, WebTestClient
    • 테스트의 성격에 맞는 클라이언트를 선택해야
MockMvc vs WebTestClient vs RestAssured=> 비교는 다음 포스팅 예정

테스트 서버에 요청 / 응답 결과를 검증

ex)

@DisplayName("지하철역을 생성한다.")
@Test
void createStation() {

    ...
       
    ExtractableResponse<Response> response = RestAssured.given().log().all()
            .body(params)
            .contentType(MediaType.APPLICATION_JSON_VALUE)
            .when()
            .post("/stations")
            .then().log().all()
            .extract();

    // then
    assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value());
    assertThat(response.header("Location")).isNotBlank();
}

@SpringBootTest

  • 테스트에 사용할 ApplicationContext를 쉽게 지정하게 도와줌
  • 기존 @ContextConfiguration의 발전된 기능
  • SpringApplication에서 사용하는 ApplicationContext를 생성해서 작동

 

webEnvironment

@SpringBootTest의 webEnvironment 속성을 사용하여 테스트 서버의 실행 방법을 설정 링크.

  • MOCK: Mocking된 웹 환경을 제공, MockMvc를 사용한 테스트를 진행할 수 있음
  • RANDOM_PORT: 실제 웹 환경을 구성
  • DEFINED_PORT: 실제 웹 환경을 구성, 지정한 포트를 listen
  • NONE: 아무런 환경을 구성하지 않음

 

❗️인수 테스트 작성 팁

인수 테스트 클래스

  • Feature 기준으로 인수 테스트 클래스를 나눌 수 있음
  • Scenario 기준으로 인수 테스트 메서드를 작성할 수 있음
  • 하나의 Feature 내부에 있는 Scenario는 같은 테스트 픽스쳐를 공유하는 것을 추천

간단한 성공 케이스 우선 작성

  • 동작 가능한 가장 간단한 성공 케이스로 시작
  • 테스트가 동작하면 실제 구조에 관해 더 좋은 생각이 떠오를 수 있음
  • 그 과정에서 발생 가능한 실패를 처리하는것과 성공 케이스 사이에서 우선순위를 가늠

실패하는 테스트 지켜보기

  • 코드를 작성하기 전 테스트가 실패하는 것을 지켜본 후 진단 메시지를 확인
  • 테스트가 예상과 다른 식으로 실패하면 뭔가를 잘못 이해했거나 코드가 완성되지 않았다는 뜻

Given / When / Then

  • When -> Then -> Given 순서로 작성하는 것이 자연스러움

🧑‍💻 인수 테스트 리팩토링

 

테스트의 의도를 명확히 드러내기

가독성에 신경쓰기!

  • 가독성이 좋지 않으면 방치되는 테스트가 될 가능성이 높다
  • 변경 사항에 대해서 수정하기 어렵다. -> 방치될 가능성 높다
    • feat. @Ignore or @Disabled
  • 가독성이 좋으면 해당 기능의 스펙을 나타낼 수 있다.

프로덕션 코드의 가독성이 중요한 만큼 테스트 코드의 가독성도 중요함

 

테스트 코드 중복 제거

  • 기능 개발 간 테스트 코드도 중복이 많이 발생함
  • 테스트 가독성을 저해하는 구조가 나올 수 있어 중복제거가 중요함
  • 가독성이 좋지 않은 테스트 코드는 관리가 되지 않는 가능성이 높음

중복 제거 방법

  • 메서드 분리
  • CRUD 추상화
  • Cucumber JBehave 같은 BDD 도구 사용

메서드 분리

  • 반복되는 코드는 메서드로 분리
  • ex) StationAcceptanceTest

다른 인수 테스트에서 재사용

  • 스텝 메서드들을 static 선언
  • 다른 인수 테스트에서 재사용 가능

4주 차 미션 (JPA)

  • JPA 실습 진행. 관련 문법 학습

소스코드: github.com/louisJu/jwp-jpa-ex

느낀 점

  • 김영한님의 JPA 참고서적을 학습하면서 해당 미션을 진행하니 수월하게 느껴졌다.
  • ENTITY들의 관계성을 잘 고려하면서 설계하는것은 어렵구나 다시한번 느낌.

회고 

 : 생각보다 어려운 피드백없이 진행했던 과제. 하지만 JPA는 너무나도 방대하다는 것을 앎.

 


참고자료

우아한테크캠프 4주차 강의.

ATDD 요구사항 분석 방법:

https://www.altexsoft.com/blog/business/acceptance-criteria-purposes-formats-and-best-practices/

 

ATDD방법론 설명:

https://mysoftwarequality.wordpress.com/2013/11/12/when-something-works-share-it/

+ Recent posts