Skip to content

boostcampwm-2022/web17-waglewagle

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Group 18

‘ 여기에 나와 같은 관심사를 가지는 사람들이 있지 않을까_ ‘

50 ~ 300명의 커뮤니티의 소그룹 형성을 돕는 공통 관심사 시각화 서비스

📽️ 데모 영상

default.mp4

🛠️ 기술스택

stack_summary

프론트엔드

Typescript React NextJS ReactQuery Axios Sass ESLint Prettier

Typescript
  • 컴파일 단계에서 자바스크립트의 버그의 일부를 사전 감지하여, 생산성 기대.
  • 협업시에 코드의 가독성을 증가시켜, 코드를 읽는데 낭비되는 시간을 감소.
  • IDE 인텔리센스의 도움을 추가로 받을 수 있음.
  • 리액트와 타입스크립트의 호환성이 좋음.
  • 이런 특성들을 고려하여, 단기간에 협업하여 코드 퀄리티가 유지된 산출물을 만들어야하는 이번 프로젝트에 어울린다고 판단함.
React.js
  • 생태계와 시장성이 매우 커서, 레퍼런스가 많고, 다양한 안정화된 라이브러리가 다수 존재
  • 컴포넌트 단위의 개발로 생산성, 유지보수성 향상 기대.
  • 타 프레임 워크에 비해 JS 친화적 문법을 가지고 있어 팀 내에서 새로운 학습 코스트가 발생하지 않아 생산성 증가.
Next.js
  • SSR, SSG, 코드스플리팅이 간편하게 구현되어 있어 페이지 별 렌더링 최적화 가능.
  • 특정 디렉토리 구조를 강제하여 유연성이 떨어지지만, 팀 프로젝트 내에서 리액트를 사용할 때에 부족한 체계성을 보충.
  • .env 읽어오기, 라우팅, 이미지 최적화 등 편의적인 기능을 다수 제공받을 수 있다.
  • 커뮤니티별로 페이지가 생성되는 서비스 특성상 Next.js의 SSR, SSG, 코드스플리팅을 통한 페이지별 성능 향상과 이에 따른 Lighthouse 점수 향상, SEO 개선 기대.
Tanstack Query
  • 캐시를 통해서 서버 통신을 최소화 할 수 있고, 이에 따라 전역 상태 필요성 감소.
  • 비동기 과정선언적으로 관리할 수 있어 생산성 향상.
  • Infinite Query, Auto Refetch 등의 편의 기능 제공을 통한 생산성 항샹.
  • 이러한 Tanstack Query의 특징들이 short polling을 통해 실시간성을 보장하는 서비스의 성격과 어울리며, 생산성 향상을 기대하여 사용함.
SCSS Module
  • JS 코드의 볼륨을 낮추어 JS에서 스타일 관심사를 분리.
  • CSS in JS에 비해 성능적으로 뛰어남.
  • Module을 통해서 스타일간의 모듈성 보장.

백엔드

java Spring SpringBoot MySQL JPA JUnit5 Mockito ARTILLERY

Java / Spring
  • 순수 객체지향언어를 사용, 객체지향 프로그래밍에 대한 이해도와 숙련도를 높임.
  • Controller 레이어와, Repository 레이어의 의존성 문제를 Spring에 위임하여, 비지니스 로직에 집중할 수 있음.
  • Typescript도 interface를 통해 객체지향을 사용할 수 있으나, 런타임에서 interface를 활용할 수 있는 방법이 없어서 의존성을 주입할 때 항상 구현체를 직접 다루어야 하였음. Spring은 이 문제를 해결함.
JPA
  • 객체 중심의 자바에서 JDBC를 통한 쿼리문 작성은 복잡하고 반복적인 쿼리문과 DAO를 강제하고, 자바 코드를 객체지향이 아닌, 데이터지향의 코드로 변질시킴.
  • JPA를 사용하면 개발자가 데이터의 읽기와 수정, 그리고 저장 과정의 전반에서 데이터를 객체로 바라볼 수 있도록하여 생산성 향상을 기대할 수 있음.
MySQL
  • RDBMS의 경우 관계 설정을 통해 데이터를 중복 저장하지 않기 때문에, NoSQL DBMS보다 더 쉽게 데이터의 정합성을 유지 가능.
Mockito
  • 목킹 작업을 도와주어 Controller-Service-Repository 각각의 레이어 별로 독립된 단위 테스트를 구성하는데 도움.
Artillery
  • 미리 작성한 시나리오에 따라서 부하 테스트 실시 가능.
  • yaml 형식으로 스크립트를 작성할 수 있어 구상한 시나리오를 빠르게 구현 가능.

ETC

NGINX Github GithubActions Naver Postman

Github Actions
  • Jenkins는 다양한 소스코드 저장소에 호환된다는 장점이 있지만, 소규모 프로젝트를 진행할 때 사용하기에는 설정과 서버 호스팅 비용이 발생하며 러닝 커브가 높음.
  • Github action은 github을 사용할 때 사용이 가능하며, marketplace를 활용하여 쉽게 CI/CD 워크플로우를 작성할 수 있고, 구현이 용이할 것이라고 판단함.
  • 짧은 프로젝트 기간과 소규모 프로젝트라는 점을을 고려하여 Github action을 활용하는 것으로 결정함.
Nginx
  • CORS 문제를 쉽게 해결하고, 이후 확장성을 고려하여 리버스 프록시 설정이 필요하였음.
  • Nginx는 Apache Web Server보다 더 많은 커넥션을 더 빠르게 관리할 수 있어서 간단한 리버스 프록시 서버로 사용하기 더 적합하다고 판단하여 결정함.

⛰️ 기술 도전

공통

데이터 기반의 근거있는 성능 개선

🧑‍🔬 대단한 것을 만들고 싶지만, 오버엔지니어링을 경계합니다.

대단한 것을 만들고 싶습니다.

  • 처음 팀원을 모을 때, 팀원이 모여서 다 같이 의견을 나눌 때도 6주는 길지도 짧지도 않은 기간이기에 비전이 필요하다는 생각을 했습니다.
  • 다 같이 즐길 수 있는 것, 그리고 도전적이어서 성취감도 있을만한 주제를 고르고 싶었습니다.
  • 또한 완성도를 신경쓰고 싶었습니다. 이전까지 만들었던 것보다 성장한 모습을 보여줄 수 있는 프로젝트가 되길 바랬습니다.

오버엔지니어링을 경계합니다.

  • 그런 와중에 다들 ‘근거 없이 대단한 것’을 만들고 싶지는 않아했습니다.
  • 모두가 합의가 가능한 대단한 것이어야하며, 단순하더라도 근거가 있기를 바랬습니다.
  • 회의를 진행함에 있어서 ‘우리는 A라고 기획하고, 핵심기능을 B라고 정했는데 정말 지금 거기까지 고려해야할까?’ 라는 내용으로 회의의 흐름을 잡을 수 있게 되었습니다.

📽️ 데모를 꼭 합시다.

실제와 설계는 항상 달랐습니다.

  • 근거를 위해서 데모 배포를 꼭 하자는 이야기가 논의되었습니다.
  • 결국 팀 내에서 논의하고 멋있게 만들어도 유저 반응은 다를 수 있다는 것이었습니다.
  • 프로젝트가 끝나기 전, 꼭 배포를 하고 개선하는 경험이 있기를 희망했습니다.

📐 근거있는 성능 개선을 합시다.

근거있는 성능 개선을 합시다.

  • 단순히 트렌드를 따라가는 것이 아니라, 실제 사용자의 경험을 수집하고 팀 내에서 스스로 문제에 대해서 분석하고 판단하여 성능을 개선하길 바랬습니다.

숫자로 이야기합시다.

  • Bad smell도 중요한 지표이지만, 판단하고 공유하기 좋은 것은 숫자라고 생각했습니다.
  • 단순히 ‘좋아졌다’라는 것이 아니라, 수치로 나눌 수 있기를 희망했습니다.

도구를 사용합시다.

  • 프론트엔드는 크롬 개발자 도구, 라이트하우스, React devtools, Tanstack Query devtools 등 도구를 통해서 문제를 분석하고 성능을 개선합니다.
  • 백엔드는 테스트 코드, mockito, artillery 등을 통해 테스트 하고, 수치를 통해 문제를 분석해서 성능을 개선합니다.

수치화는 생각보다 어려웠습니다.

  • 무엇을 수치로 정해야할지도 모르는 때가 많았습니다. 기준을 정해야하는데, 생겨난 이슈를 해결했다는 지표가 무엇이 되어야하는지 혼란스러웠습니다.
  • 무엇이 이슈가 되는지도 어려웠습니다. 자칫하면 오버엔지니어링이 될 수 있다는 부분이 문제였습니다.

🧘 돌아보며 : 프로젝트가 끝나고

1. 웹소켓 쫑파티

  • Websocket을 통해서 실시간성을 보장하던 것이, 오버엔지니어링이었을 수 있었겠다는 생각이 들었습니다.
  • 이후 발생할 서버 비용과, 확장의 어려움을 생각해서 short polling으로 변경하였습니다.

2. 키워드는 몇 개까지 보여주면 될까요?

  • 처음 물리엔진을 만들 때, 극단적인 상황까지 고려해야한다며 버블을 500개까지 띄워서 성능을 맞추려고 했었습니다. 헌데 50 ~ 300명의 중규모 커뮤니티를 고려하여서 만든 서비스에 키워드 버블 500개를 만드는 것은 초기의 목표가 아니라는 생각이 들었습니다.
  • 따라서 500개의 연산이 가능하도록 만드는 것 대신, 사용될 에너지를 줄여서 SEO나 접근성에 더 투자할 수 있었습니다.

3. 데모 진행 (2022.12.11)

  • 데모를 진행하고, 많은 반응을 얻을 수 있었습니다.
  • 다들 피곤하다는 상황을 고려하여 캠프 기간 동안 부스트캠프 내에서 가입 유저 30명과 50개의 키워드, 키워드 총 가입자 수 100명을 목표로 데모 배포를 진행했고, 배포 첫 날 47명의 유저와 81개의 키워드, 키워드 총 가입자 수 219명으로 많은 자료를 얻을 수 있었습니다.
  • 이를 토대로 기능을 개선하고 병목현상을 예상해볼 수 있었습니다.

4. 어플리케이션 성능 분석 도구 : Jennifer Front

Untitled (3)

  • 프론트엔드에 성능 분석 도구를 심어서 Backend와 Frontend의 성능을 모두 분석하였습니다.
  • 이를 통해서 클라이언트의 페이지 로드 시간과 백엔드의 API 요청 시간, 에러율을 확인하여 더 나은 서비스로 개선할 수 있었습니다.

커뮤니케이션 발전해나가기

👨‍👨‍👧‍👧 Week01. 서로를 알아가기

생각이 많은 사람들

  • undefined가 모인 배경에는 ‘근거있는 선택’이 있었습니다. 주제를 먼저 정한 것이 아니라, 프로젝트에 임하는 마음가짐이 같은 사람들이 모였습니다.

아이디어가 넘쳤습니다.

  • 많은 아이디어가 공유되는 것은 좋았지만, 그로인해 기획동안 회의가 샌다는 의견이 팀 내에 공유되었습니다.
  • 역할과 규칙이 꼭 필요해졌습니다.

역할과 규칙을 만듭시다.

  • 프로젝트 리더는 프로젝트의 전체 리딩을 맡고, 프론트엔드와 백엔드 각각의 파트 리더를 선정하여 각 파트의 진행을 맡았습니다. 또한 일정과 문서 기록 담당을 한 명 두어서 프로젝트 전체 리딩에 빈틈이 생기지 않도록 했습니다.
  • 규칙도 정했습니다. 다만, 규칙이 오버엔지니어링이 되지 않도록 첫주차에는 틀을 정하는데에 중점을 두고 세부사항은 이후에 논의하기로 했습니다.
  • 프로젝트 기간 동안, 평일 코어시간동안 게더타운에 모여서 함께 소통하면서 코딩하기로 결정되었습니다.

개발환경과 코딩 컨벤션을 만듭시다.

  • 개발환경, 개발 도구, 코딩 컨벤션, Github 규칙 같은 것들을 정했습니다. 규칙을 정해야하는 이유에 대해서는 바빠지면 바빠질수록 더 깊게 느끼게 되었습니다.
  • 내가 맡은 부분이 아니더라도, 코드 리뷰를 하여 다양한 분야의 지식 공유를 진행했습니다.

🤼 Week02. 협업에 익숙해지기

파트 분리

  • 각자의 영역에 도전하고 싶은 부분들이 많았기 때문에, 백엔드와 프론트엔드를 나누어서 개발했습니다.
  • 다만 작은 팀에서 일이 너무 분리되지 않도록, 파트별 기록과 진행 상황 공유를 통해서 서로의 진행상황을 꾸준히 공유했습니다.

트러블 슈팅을 정리합시다.

  • 팀 프로젝트 기간 동안은 특히 더, 단순한 구현보다는 과정이 더 중요하다는 의견이 있었습니다.
  • 각자의 트러블 슈팅을 정리하면 단순히 코드 리뷰 때 읽을 수 없는 과정을 이해할 수 있다고 생각이 들어서 트러블 슈팅을 정리하기 시작했습니다.

구현보다 의사결정이 중요합니다 : 마무리 스크럼

  • 시간이 갈수록 협업은 단순한 개인 개발과는 다른 것이라는 걸 느껴갔습니다. 구현보다는 의사결정과 그것의 싱크를 맞추는 것이 중요했습니다.
  • 하루가 종료되고, 마무리 스크럼을 진행하기로 했습니다.
  • 이를 통해서 하루 동안 어떤 작업이 진행되었는지, 내일을 위해 어떤 작업을 추가로 진행할 것인지 나누었습니다.

차량은 생산성을 높여줍니다 : 유머와 여유의 탄생

https://user-images.githubusercontent.com/69471032/202074041-da91a700-e87a-4ce9-a380-41eb96044131.png

  • 개발 주간이 시작되자, 각자 긴장감이 높았습니다.
  • 그러던 중 게더타운에 고카트가 있다는 것을 알게되었는데, 그 뒤로 카트는 저희의 슬리퍼가 되었습니다.
  • 단순히 게임 내의 요소보다는 긴장된 회의가 끝난 이후 서로의 긴장을 푸는 장치가 되어주었습니다.

기술적인 도전이란 무엇일까?

  • 이때부터 팀 내에서 기술적인 도전과 탐구는 무엇인지에 대한 이야기를 나누기 시작했습니다.

💁 Week03. 컨디션 관리, 유머와 여유 챙기기

컨디션 관리의 중요성

  • 3주차가 되자, 컨디션 관리의 중요성이 나타나기 시작했습니다. 다들 긴장이 많이 되었었고, 긴장감은 판단력을 흐리게 만들었습니다.
  • 아침에 일부러 TMI를 나누거나 낮잠시간을 만들기도 했습니다. 단순히 휴식하는 것이 아니라, 충분히 회고하기 위함이었습니다.

Wiki 작성

  • 기획의 싱크를 맞출 때가 한 번 되었다고 생각했습니다.
  • 다 같이 Wiki의 내용을 읽으며, 기획에 대한 생각이 다른 부분이 있다면 나누고 싱크를 맞췄습니다.

Github을 더 적극적으로 활용합시다.

  • Github의 Issue가 Feature만을 관리하기 위해 사용되고 있다는 이슈가 나뉘어졌습니다.
  • Github을 더 적극적으로 활용하면, 서로의 작업 진행상황이 공유되지 않아도 실시간으로 알 수 있다는 의견이 공유되었습니다.
  • 매일 하나의 Issue를 닫고, PR을 날리자는 규칙이 세워졌습니다. 그 정도로 나눌 수 없다면, 조금 더 작업 단위를 나누어서 서로가 작업 상태를 공유하자는 의견이 공유되었습니다.

절반에서 돌아보기 : 포스트 모템

  • 우리는 충분한 기술적 도전을 하고 있는가에 대해서 나뉘어지기도 했습니다.
  • 그렇게 제대로 가지 못하고 있는 부분에 대해서는 남은 시간을 계산하고 조금 되돌아가기도, 더 나아가야할 부분이 있다면 방향을 잡기도 했습니다.

📒 Week04. 문서 레이아웃 개선

문서 레이아웃 수정

  • 컨디션 관리가 어려워지자, 서로의 문서를 읽기 어려워졌습니다. 이를 해결하기 위해서 중요한 문서는 depth를 낮추거나 전체 레이아웃을 수정했습니다.

Github Issue를 더 열심히 쓰기

  • Github Issue를 통해서 서로에게 필요한 트러블을 공유할 수 있다는 생각이 들었습니다.
  • 단순히 전달만으로는 휘발될 수 있는 트러블들을 Issue에 발행하면서 전달하자는 이야기가 공유되었습니다.

문서로 대화하기

  • API 명세와 Figma를 가지고 대화하는 시간이 점점 더 많아졌습니다.
  • 이에 따라 이전까지 있던 워크 플로우에 불필요한 부분을 줄여내기도 했습니다.

의사결정에 대해 다시 알리기

  • 단순히 구현에 매몰되면 안된다는 것이 꾸준히 공유되었습니다.
  • 피곤해지면, 목적과 우선순위를 잃고 구현에 매몰되기도 했습니다.
  • 그런 때가 있다면 낮잠을 자서라도 판단력을 명료하게 만들자는 이야기가 공유되었습니다.

🏃‍♀️ Week05. 열심히 달리기

커뮤니케이션 적응

  • 5주차가 되니, 다들 협업에 조금 익숙해졌습니다.
  • 이전보다 말하는 것이 줄어도 문서와 Github을 통해서 서로의 맥락을 이해할 수 있었고, 지금까지 쌓아놓은 것들 덕분에 커뮤니케이션 비용이 줄어들었습니다.

컨디션과 멘탈관리

  • 할 일은 많았습니다.
  • 프로젝트가 막바지에 이르니 프로젝트 소개와 이력서, 마감기한이 끝나가는 기능들을 마무리 짓는 것에 집중했습니다.
  • 이 과정에서 판단력이 흐려지지 않도록 컨디션과 멘탈 관리에 대한 이야기들이 나뉘어졌습니다.

프론트엔드

UX를 고려한 스크롤 애니메이션

❓ 스크롤 애니메이션 도입기

  • 랜딩페이지에 단순한 설명이 적혀있으면 설명이 읽히지 않을 것이라고 생각했습니다.
  • 캠퍼분들께 데모 사이트를 공유할 때, 가능하다면 어떤 것을 위한 서비스인지 전달할 수 있으면 좋겠다고 생각했습니다.
  • 데모 사이트를 공유하는 글에 서비스 소개가 적히면, 글이 무거워져서 유저가 진입하기 어렵다는 판단이 있었습니다.
  • 사용하는 사람들이 흥미롭게 읽어볼 수 있는 소개 사이트를 만들기 위해 상호작용이 가능한 스크롤 애니메이션을 구현했습니다.
  • 또한 유저가 소개글에 몰입하여 서비스와 유대감이 생길 수 있도록 Parallax 스크롤으로 구현했습니다.

🛤️ 과정 : Intersection Observer, SVG, Parallax

Intersection Observer API

  • Intersection Observer를 통해 설명 섹션의 절반 이상을 지나면 이벤트가 발생할 수 있도록 하였습니다.

SVG path 따라 그리기 (SVG dashoffset과 dasharray)

  • 단순히 글자의 opacity를 바꾼다거나 slide-in 하는 것은 흥미를 끌기 어려웠고, 너무 화려한 애니메이션은 서비스의 성격과 맞지 않았습니다. 글자가 자연스럽게 써지는 효과가 있으면 좋을 것 같았습니다.
  • SVG dashoffset과 dasharray 속성을 이용하여 SVG의 path를 자연스럽게 그릴 수 있었습니다.
  • 이러한 속성과 Intersection Observer를 활용하여 유저의 스크롤에 반응하는 스크롤 애니메이션을 구현하였습니다.

시차 스크롤 (Parallax Scroll)

  • 처음에는 background-attachment 속성을 이용하여 전체 배경 이미지에 parallax를 사용하려했으나, 구현하고나니 서비스 소개와 맞지 않는다는 것을 알게되었습니다. 가벼운 느낌의 서비스 소개와 어울릴 수 있도록 간단한 이미지와 사용할 수 있어야 했습니다. 이를 위해서 시차를 직접 구현했습니다.
  • window scroll 이벤트에 window.scrollY를 리액트의 상태로 저장하고, 이 상태에 따라서 특정 이미지를 transform translateY 하였습니다.
  • 여기에서 window.scrollY와 1:1로 이동하는 것이 아니라 비율을 조정하여 원근감이 있는 것처럼 보이도록 구현하였습니다.

❗결과 : 몰입감 있는 서비스 소개

  • 데모를 배포할 때, 소개글을 따로 추가하지 않아도 되어서 글이 가볍게 공유될 수 있었습니다.
  • 또한 유저분들이 지루할 수 있는 서비스 소개에 대해서 끝까지 읽어주셨고, ‘스크롤이 예뻐요’, ‘서비스 소개가 재미있어요’ 와 같이 스크롤 애니메이션과 서비스 소개에 대해 긍정적인 피드백을 받을 수 있었습니다.

직접 만든 물리엔진으로 데이터 시각화 하기

데이터 시각화 (1)

⚠️ 핵심기능이 외부라이브러리에 의존해도 될까?

  • 데이터 시각화를 위한 라이브러리를 찾던 중, React의 렌더링 방식에 어울리는 방식으로 동작하는 2차원 원형 배치 라이브러리를 찾기가 어려웠습니다.
  • 필요한 동작에 비해 라이브러리가 무겁거나, 렌더링까지 라이브러리에서 맡고 있어서 UI 로직과의 분리가 어려웠습니다.

⚪ 버블차트로 데이터 시각화

  • 처음에는 워드 클라우드를 직접 구현하려고 했었습니다. 문제는 워드 클라우드를 사용할 경우, 유저가 입력한 키워드의 길이에 따라서, 초기 워드 클라우드 모양에 대한 예외처리가 필요하다는 부분이 있었습니다.
  • 또한 워드 클라우드가 충분히 인터랙티브하게 느껴지지 않는다는 단점이 있었습니다.
  • 따라서 버블차트를 통해서 구현하는 것으로 이야기가 나뉘어졌습니다.

🤔 물리엔진으로 직접 만듭시다.

  • 처음에는 버블차트를 2차원 원형 적재 알고리즘을 사용하여 구현하려고 했습니다.
  • 이후 2차원 원형 적재 알고리즘은 인터랙티브한 데이터 시각화가 어렵고 유저 입장에서 지루할 수 있겠다는 생각이 들었습니다.
  • 물리엔진을 통해서 2차원 원형 배치를 구현한다면 문제를 해결할 수 있다는 아이디어를 가지고 중력과 마찰력 충돌력을 중심으로 저희 프로젝트에 알맞은 2차원 원형 배치 물리엔진을 구현하였습니다.

⚛️ 물리엔진 만들기

  • 만들 예정인 버블 차트의 UI를 리액트 컴포넌트로 먼저 만들었습니다. radius, X 좌표, Y 좌표를 상태로 두어서 위치와 크기를 동적으로 변경할 수 있도록 하였습니다.
  • 2차원 원형 배치를 위한 중력과 마찰력, 충돌력을 모델링하고 물리엔진을 구현하였습니다.
  • 중력은 중심점으로 위치를 이동시키려는 힘, 마찰력은 현재의 속력을 잃게 만드는 힘, 충돌력은 겹침이 발생했을 때 겹침을 해소하는 힘이라고 정의하고 물리엔진을 구현하였습니다.
  • 앞서 만든 컴포넌트들을 이 물리엔진으로 연산하여 위치를 정해주었습니다.
  • 이후 setInterval과 transform : translate()로 조정하여 애니메이션 최적화를 할 수 있었습니다.

🧑‍🔬 결과

  • 위치 연산 로직과 UI 렌더링 로직을 완전히 분리, 리액트의 렌더링 방식과 DOM 객체의 정보를 모두 활용할 수 있어서 인터랙티브한 버블 차트를 만들 수 있었습니다.
  • UI 로직을 분리한 덕분에, 같은 동작에서 버블 150개에서 CPU 사용량이 90% 로 측정되었던 렌더링 과정을 CPU 사용량 12% 로 최적화할 수 있었습니다.

Trie 자료구조를 통한 자동완성 검색엔진 구현

Untitled (4)

⚠️ 문제 인식

  • 저희는 소그룹이 비슷한 이름으로 여러 개가 생성되면서 사용자가 분산되는 상황을 문제라고 인식하고, 키워드 자동 완성 기능을 개발하였습니다.

➡️ 개선시키기

  • 처음에는 정규 표현식을 통해서 검색어가 바뀔 때마다 모든 키워드를 탐색하고자 하였으나 성능적으로 비효율적이라고 생각되어서 알고리즘의 변화가 필요하였습니다.
  • 검색 엔진에서 사용되는 알고리즘들을 조사, 비교하여 현재 프로젝트에 가장 적절한 알고리즘을 선정하기로 하였습니다.

🧑‍🔬 결과

  • 정규 표현식과, 이진 탐색, 트라이 등의 방법을 고려하여 시간 복잡도를 분석하였습니다.
N = 단어의 개수, M = 문자열 길이 사전 작업 시간 복잡도 탐색 시간 복잡도
전체 탐색(정규 표현식) X O(M * N)
이진 탐색 O(N * M * logN) (정렬) O(M * logN)
트라이 O(N * M) (트라이 생성) O(M)
  • 사전 생성 시간이 있고, 메모리를 많이 차지하는 단점이 있지만, 커뮤니티 접속 시 한번만 실행하면 되고, 탐색이 빈번하게 발생하는 검색 엔진 특성상 트라이가 가장 효율적이라고 결론을 내렸습니다.
  • 결과적으로 탐색 시간 복잡도를 O(M * N)에서 O(M)까지 감소시켰습니다.(M: 문자열 길이, N: 단어의 개수)

개발자 도구를 활용한 성능 개선

🔦 라이트 하우스 점수 개선

Untitled (5)

  • Next.js의 코드스플리팅, SSR, SSG과 폰트 파일 압축 및 캐싱을 통해 성능 점수를 60점에서 94점으로 개선하였습니다.
  • 스크린 리더에 기본 언어를 명시하고자 lang 태그를 사용하고 배경색과 글자색의 대비를 높여 접근성 점수를 73점에서 100점으로 개선하였습니다.
  • meta 태그와 라이트하우스 SEO 점수를 85점에서 100점으로 개선하였습니다.

🔬 크롬 개발자 도구 성능 측정 및 개선

  • 크롬 개발자 도구를 통해서 버블차트의 특정 상태로 인해 사용되지 않는 EventListener가 지속적으로 쌓이고 있는 버그를 발견, 해당 상태를 제거하는 로직으로 버그를 해결하여 최대 800까지 쌓이던 EventListener를 400 이하로 유지하고 JS Heap 메모리최대 60mb에서 35mb로 감소시켰습니다.

📶 API 데이터 리렌더링 성능 개선

  • 서비스에 맞는 실시간성을 유지하기 위해서 Short Polling을 사용하였습니다. 그리고 이에 따라서 요청 주기마다 API 응답에 의해 리렌더링이 발생했습니다.
  • Tanstack Query의 Caching 기능과 렌더링 최적화를 통해서 1초마다 발생하던 리렌더링데이터가 변경되었을 때만 하도록 개선하였습니다.
  • 이후 UI 로직을 수정하여, 변경된 데이터가 수신되었더라도 전체 버블차트가 리렌더링 되는 것이 아니라 변경된 키워드 버블만 리렌더링 될 수 있도록 최적화를 진행하였습니다.

백엔드

실시간 서비스 도전기

팀 undefined의 실시간 서비스 도전기

undefined의 목표

  • 우리의 목표는 실시간성을 띄는 서버를 제작하여 사용자와 사용자 혹은 사용자와 서비스 사이의 상호작용강화하고자 했다.
  • 상호작용강화하는 방식으로 사용자의 흥미를 유발하고, 더 높은 참여도를 끌어낼 수 있을 것이라 판단했다.

undefined의 고민

  • 실시간성을 띄는 서버는 어떻게 만들어야 할까?
    • 우선, 실시간 서비스를 위한 대표적인 프토로콜로 웹소켓을 고민하였다.
    • 웹 소켓은 일부 구형 브라우저에서 지원하지 않고, disconnect 되었을 때 다시 연결을 시도하는 로직을 클라이언트에서 직접 구현해주어야 한다.
    • 그러나, socket.IO 라이브러리를 사용할 경우, 사용 환경에 따라 폴링을 사용하고, 연결이 해제되었을 때 자동으로 재연결을 시도하는 등 문제 상황에 대한 fallback이 잘 이루어져 있어서 문제가 없을 것으로 판단했다.

undefined의 진행

  • 소켓 서버를 활용하고자 했을 때, 가장 먼저 고민이 되었던 것을 라이브러리 결정이다. Spring 서버에서 웹 소켓 서버를 사용할 경우, 서버와 클라이언트에서 sockjs 라는 새로운 라이브러리를 사용해야 한다는 추가적인 부담이 있었고, socket 서버를 구현하기로 결정하는 과정에서 고려한 라이브러리 또한 socket.IO 였기 때문에 소켓 서버는 typescript와 socket.IO로 구현하는 것으로 결정하였다.
  • 소켓을 활용한다는 결정을 하고, REST API 서버에 몰려 있던 API의 일부를 소켓 서버로 migration 하기로 결정하였다.
    • 실시간성이 필요한 API들을 대상으로 migration을 진행하고자 하였으며, 이에 따라 키워드 생성, 키워드 참여, 유저의 접속 상태 표시, 스레드 생성 및 스레드 삭제 등의 API의 migration이 결정되었다.

첫번째 문제, 관심사 분리

문제 인식

  • 소켓 서버를 활용하면서 가장 먼저 문제로 인식되었던 부분은, 관심사의 분리이다.
    • 소켓 서버는 event driven 방식으로 웹소켓이 연결되었을 때, client에 대해서 각각의 이벤트에 대응하는 callback 함수를 달아주는 방식으로 코드를 작성하게 된다.
    • 이렇게 되면, 소켓이 연결되었을 때 발생할 콜백 함수 내에서 모든 콜백 함수를 달아주게 되고, 모든 관심사가 해당 콜백 함수 내부로 집중되는 문제가 발생할 것으로 판단했다.

문제 접근

  • 관심사 분리라는 목표를 달성하기 위해, 우리는 우리가 지금껏 만들어왔었던 REST API 서버를 참고하였다. REST API 서버의 경우 각각의 도메인 별로 controller와 service, repository 레이어로 구성된 독립된 계층 구조를 가지고 있어 관심사를 쉽게 분리할 수 있었다.
  • 또한, 부스트캠프 과정 중 Express를 활용하여 Controller와 Service, Repository를 분리하려고 시도했던 경험이 있어 이와 유사한 구조로 구현할 수 있을 것이라 판단했다.

문제 해결

두번째 문제, 부하와 서버 간의 관심사 분리

문제 인식

  • 소켓 서버를 구현하면서 마지막으로 문제로 인식되었던 부분은 서버 부하이다. 소켓 서버는 그 특성 상 서버와 클라이언트 사이에서 지속적으로 연결 상태를 유지해야 하고, 이런 연결 자체가 서버에 부하를 줄 수 있다고 생각했다.
  • 또한, 연결에 대한 정보가 메모리에 저장되기 때문에, 추후 스케일 아웃을 고려했을 때 각 소켓 서버들 사이에서 사태를 공유하기 위한 추가적인 조치가 필요할 것으로 판단했다.
  • 마지막으로 실시간성을 띄는 것이 좋을 것이라고 판단한 API들에 대해서 소켓 서버로의 migration을 예상했었지만, migration이 진행되면서 정확히 어떤 API가 실시간성을 띄는 것이 좋은가에 대한 기준이 모호했다는 것을 알게 되었고, 어떤 API를 어떤 서버에 구현해야 하는 지에 대한 의문이 커졌다.

문제 접근

  • 소켓 통신과 불가분한 문제라고 판단을 내렸다. 소켓 서버에서 구현하고자 하는 양방향 실시간 통신을 구현하기 위해서는 서버와 클라이언트 사이의 지속적인 연결이 불가피했다.
  • 실시간성을 띄는 것이 좋은 API가 어떤 것인가에 대해서 팀원들이 모두 함께 고민했고, 우리가 실시간성을 띄는 서비스를 구현하려고 하는 기본 목적에 대해서 새로 고민하게 되었다.
  • 우리가 가진 고민에 대해서 멘토링을 신청했다.

문제 해결

  • 멘토링과 우리가 그간 정리했던 고민들을 바탕으로 내렸던 결론은, 우리에게 소켓 서버가 과연 필요할까 였다.

  • 이 의문을 해소하기 위해서 우리는 이전에 미처 하지 못했던 고민을 마주했고, 이에 대한 과정은 다음 링크로 대체한다.

    😯 WebSocket과 Polling 그리고 SSE

  • 소켓 서버는 더 없이 빠른 실시간성을 보장하지만, 그것을 위한 많은 오버헤드 또한 포함하고 있는 프로토콜이다. 항상 통신이 연결되어 있어야 하므로 그 자체가 부하가 된다.

  • 또한, 소켓 서버는 클라이언트에 연결 상태를 지속적으로 유지하기 때문에 stateless한 통신이 아니다. 즉, 서버에서 클라이언트에 대한 정보를 저장해두고 이를 활용해 통신을 진행하게 된다. 문제는, 서버를 스케일아웃 했을 때 각각의 서버에서 클라이언트에 대한 상태를 공유하거나, 이벤트를 공유해야 하여 추가적인 처리가 필요한 것으로 판단했다.

  • 우리는 이런 문제를 해결하기 위해서 short polling 방식을 활용하여 실시간 통신을 구현하기로 하였다.

undefined의 실시간 서버

  • 현재 우리의 실시간 서버는 short polling을 활용하여 구현된 상태이다.

  • 클라이언트에서는 일정한 시간을 간격으로 서버에 새로운 요청을 보내고, 서버에서 가장 최신의 데이터를 전달하게 된다.

  • 비록 게시글이 생성되자마자 다른 사람에게 바로 전달되지는 못하지만, 그럼에도 불구하고 UX에는 큰 영향이 없을 것으로 판단했다.

    🏦 기술 부채란 무엇일까? (feat. 웹소켓을 떠나 보내며)

웹소켓. 여기 잠들다. 🪦

테스트 코드와 단단한 코드 구조

❗️단단한 코드 구조와 트러블 슈팅

(1) 중구난방 관심사; 혼재된 관심사

서비스 레이어에 대한 단위 테스트를 구성하려고 시도하였다. 그러나, 테스트 코드보다 먼저 컨트롤러와 서비스 레이어가 가지고 있는 코드의 구조적인 문제점을 발견할 수 있었다.

컨트롤러 레이어에서도 데이터 검증에 대한 관심사를 가지고 있었고, 서비스 레이어에서도 데이터 검증에 대한 관심사를 가지고 있었다. 거기에 컨트롤러는 서비스 레이어의 검증 과정에서 발생한 예외 상황에 대한 관심사까지 가지고 있어 테스트 코드 작성이 어려웠던 것이다.

너무 많은 관심사를 가지고 있던 컨트롤러

@ResponseBody
@PostMapping()
public ResponseEntity<KeywordResponse.CreateDTO>
createKeyword(@RequestBody final KeywordRequest.CreateDTO createDTO) {
	
	if (keywordService.isDuplicated(id)) { ...(1): 서비스 메서드 
    return ...
  }

	try {
		Keyword keyword = keywordService.createKeyword(id, ...); ...(2): 서비스 메서드 2
    return ...
	} catch {
    return ...
  }
}
  1. 서비스 메서서들에 대한 단위 테스트를 진행하는 것만으로 비즈니스 로직의 흐름을 보장할 수 있을까?
    • 컨트롤러 레이어에서도 비즈니스 로직에 대한 흐름을 제어하고 있다면, 비즈니스 로직이라는 하나의 관심사를 왜 둘이 나누어 가지게 된걸까?
  2. 검증과 비즈니스 로직은 하나의 트랜젝션 안에서 동작해야 하는 것 아닐까?

➡️ 컨트롤러 레이어에서 가져갔던 비지니스 로직의 흐름 제어라는 역할, 책임서비스 레이어이전해야 한다.

(2) 비즈니스 로직의수많은 분기들 : 컨트롤러 예외 처리

try - catch로 뚱뚱해지는 컨트롤러 레이어

public ResponseEntity<Response> controllerMethod() {

	ResponseEntity res;

	try {
		res = service.method();
		return new ResponseEntity<>(res, HttpStatus.OK); ...(3) 성공 분기 3
	} catch(NoSuchElementException e1) {
		return new ResponseEntity<>("", HttpStatus.NOT_FOUND); ...(1) 실패 분기 1
	} catch(IllegalArgumentException e2) {
		return new ResponseEntity<>("", HttpStatus.BAD_REQUEST); ...(2) 실패 분기 2
	}
	......
}
  1. 데이터를 검증하는 로직을 서비스 레이어로 이동시켜 데이터의 검증과 비스니스 로직 실행이 하나의 트랜젝션 안에서 이루어지게 되었다.
  2. 서비스 레이어에서 데이터 검증 중 정상적으로 비즈니스 로직을 실행하면 안된다는 판단이 섰을 때, Exception을 throw하고 이것을 컨트롤러에서 처리해준다면, 이것 역시 컨트롤러의 역할과 책임에 어울리지 않는다고 판단했다.
    • 이는 첫째로 컨트롤러 레이어의 관심사는 필요한 데이터를 받아서 서비스 레이어로 넘겨주고, 되돌려 받는 반환값을 다시 클라이언트에게 전달해주는 것이라고 생각했기 때문이고, 둘째로는 컨트롤러 레이어에서 서비스 레이어의 메서드가 어떤 Exception을 throw할 지 알고 있어야 하기 때문이다.

➡️ 실패 분기, 예외 처리에 대한 다른 방법이 필요하다!

🔨 해결과정

(1) 관심사 분리

➡️ 컨트롤러 레이어와 서비스 레이어의 역할과 책임 재정의하기

컨트롤러 레이어

  1. API 요청을 인식
  2. 요청 매개변수들을 서비스 레이어로 전달
  3. 서비스 레이어에서 반환한 값을 클라이언트로 전달

서비스 메서드

  1. API 요청에 대한 핵심 비지니스 로직의 실행 및 검증
  2. 레포지토리 레이어에 적절한 데이터를 요청
  3. 실패 케이스에 대해 Exception 발생 처리
  4. 성공 케이스에 대해 적절한 값을 컨트롤러 레이어로 전달

(2) 전역 Exception Handler 도입 (feat. 커스텀 Exception)

➡️ Spring에서 지원하는 전역적 예외처리 장치 RestControlllerAdvice 레이어를 도입

@RestControllerAdvice
public class GlobalExceptionHandler {

	@ExceptionHandler(NoSuchElementException.class) ...(1)
	protected ResponseEntity
	handleNoSuchElementException(final NoSuchElementException e) {
		
		return ResponseEntity.status(HttpStatus.NOT_FOUND);
	}

	@ExceptionHandler(IllegalArgumentException.class) ...(2)
	protected ResponseEntity
	handleIllegalArgumentException(final IllegalArgumentException e) {
		
		return ResponseEntity.status(HttpStatus.BAD_REQUEST);
	}
}

서비스 레이어의 실패 분기에서 발생한 각 예외의 처리를 담당하는 핸들러 메서드를 구현하였다.

public ResponseEntity<Response> controllerMethod() {

	return new ResponseEntity<>(service.method(), HttpStatus.OK);
}

컨트롤러 레이어는 서비스 레이어에서 전달받은 값을 클라이언트에 전달하는 역할만 하게 되면서, 훨씬 코드가 간결해졌다.

❓ 개발자마다 서로 다른 예외 메세지를 사용한다면, 새로운 혼란이 발생하지 않을까?

public ResponseEntity serviceMethod() {
	throw new NoSuchElementException("예외 메세지 직접 입력");
}
  1. 일관적이지 못한 메시지와 다양한 예외 상황에서 일관되지 못하거나 너무 모호한 예외가 발생하게 된다.
  2. 이것을 Exception Handler에서 처리하기 위해선 서비스 레이어에서 발생시키는 모든 Exception에 대해서 알고 있어야 한다.
  3. Exception Handler가 자신의 책임을 다하기 위해서 다른 레이어의 내부를 알아야 한다면, 해당 레이어의 책임이 과중하고 관심사가 제대로 분리된 상태가 아니라고 판단했다.

➡️ Exception 객체가 가지는 메시지통일하고, 구체화해야 한다.

@Getter
@RequiredArgsConstructor
public enum ExceptionMessage {

    NO_SUCH_KEYWORD("키워드를 찾을 수 없습니다."),
    ALREADY_JOINED_KEYWORD("이미 가입한 키워드입니다.");

    private final String message;
}
public class NoSuchKeywordException extends NoSuchElementException {

    public NoSuchKeywordException() {
        super(ExceptionMessage.NO_SUCH_KEYWORD.getMessage());
    }
}
  1. 각 도메인 별로 throw되는 Exception의 역할을 하는 각각의 Exception 클래스를 선언하여 Exception Handler가 가진 과중한 책임을 분리하고자 했다.
  2. Exception message에 대한 enum 클래스를 정의하여 메시지를 통일할 수 있도록 하였다.
    • 물론, 여전히 Java나 Spring이 제공하는 Exception에 제각각의 메시지를 담는 것을 막을 수 없지만 각 도메인의 휘하의 exception 디렉토리에 관련 클래스를 정의하여 그런 일을 최소화 하고자 했다.
  3. 위에서 정의한 enum 클래스를 기반으로 각각의 상황에 맞는 custom exception을 정의하였다.
public EntityDTO serviceMethod() {
	
	if (!isValid())... 검증 과정
		throw new NoSuchKeywordException();
}

결과

스크린샷 2022-12-12 오후 5 13 31

  1. 서비스 레이어는 exception 디렉토리를 참조하여 예외 상황에 맞춰 미리 만들어진 적절한 exception을 throw한다.
  2. Exception Handler 레이어는 exception 디렉토리를 참조하여 서비스 레이어에서 전달할 예외 상황에 대해서만 처리해주면 되는, 관심사가 적절히 분리된 상태이다.

❗️테스트 코드와 트러블 슈팅

(1) 어떤 테스트를 진행해야 할까?

컨트롤러 레이어와 서비스 레이어의 관심사를 분리하기 위해 Exception Handler 레이어와 custom Exception 클래스를 정의하였다.

그 후, 각각의 관심사에 맞게 테스트 코드를 짜려 하였지만, Controller와 Service, Repository 3개의 레이어를 계층적으로 사용하기 있는 현 상태에서 어떤 방식으로 테스트 코드를 작성해야 적절한 지에 대해서 알 수 없었다.

책임도, 실행도 너무 무거운 테스트

  1. 특히, 컨트롤러 레이어에 대한 테스트를 진행할 경우, 해당 요청은 Service 레이어와 Repository 레이어를 모두 거치기 때문에 사실상 postman 등의 툴을 통해 실제 요청을 보내는 것과 차이점이 없었다.
  2. 이 경우, 테스트가 실행 환경에 영향을 받을 수 있어 테스트만을 위한 새로운 환경을 조성해주어야 하며, 모든 테스트에서 적절한 의존성을 주입해야 하기 때문에 하나의 테스트 메서드를 실행할 때에도 모든 의존성을 주입해주어야 해서 테스트 시간이 너무 오래 소요되는 문제가 있었다.
  3. 게다가 컨트롤러 레이어를 테스트하는 과정에서 서비스 레이어와 레포지토리 레이어를 모두 거치기 때문에 테스트 케이스를 실패하더라도 과연 어디서 실패했는 지에 대해서 정확하게 알 수 없는 문제가 있었다.

➡️ 테스트 환경 내에서 관심사분리 하여 각 레이어에 대해 독립적으로 테스트를 수행한다.

(2) 의존성을 관리하는 방법

가장 큰 문제는 각각의 계층이 하위 계층을 의존하고 있다는 것이다. 서비스 레이어는 레포지토리 레이어를 의존하며 레포지토리 레이어가 가진 메서드를 실행하고, 그 결과를 비즈니스 로직 중간중간에 사용하게 된다.

물론 실제 환경에서는 서비스 레이어는 레포지토리 레이어가 반환한 결과값에 따라 서로 다른 처리를 해주어야 하므로 레포지토리 레이어가 반환한 결과값이 서비스 레이어의 관심사이지만, 테스트 환경 내에서는 그런 사실이 문제가 된다.

연쇄적으로 이어지는 의존 관계

  1. 각 레이어에 대해서 다른 레이어, 혹은 다른 도메인의 같은 레이어를 의존하고 있는 것 자체가 문제라고 판단했다.
  2. 의존성 관계가 연쇄적으로 연결되어 있어 하나를 의존하게 된다면 의존성의 의존성까지 모두 의존하게 되어버려 결과적으로 실제 API 요청을 통한 테스트와 차이점이 없어지게 된다.

➡️ 테스트용 구현체를 의존성으로 주입하자.

  1. Spring은 DI 컨테이너를 활용하여 interface에 대해 의존하도록 하고, 구현체를 매핑하는 방식으로 구현체를 주입한다는 있다는 것을 알고 있었다.
  2. 즉, 테스트 환경에서는 테스트 환경에 걸맞는 의존성의 구현체를 주입해주는 방식으로 해당 문제를 해결할 수 있을 것으로 판단했다.

(3) 과투자

테스트를 위한 구현체를 따로 구현하여 테스트 환경에서 이용하는 것으로 의존성의 연쇄를 끊을 수 있게 되었지만, 모든 의존성에 대해서 테스트를 위한 구현체를 따로 작성하는 것은 너무 과한 투자라고 판단했다.

예를 들어서 5개의 도메인들이 각각 컨트롤러, 서비스, 레포지토리 레이어의 객체를 하나씩만 가진다고 하더라도 15개의 객체를 모두 구현해주어야 한다는 뜻으로 가상의 유사 어플리케이션을 하나 추가로 더 구현하는 정도로 시간과 노력이 많이 소요될 것으로 판단했다.

시간인력한계

  1. 우리는 결국 짧은 시간 안에 어떤 결과물을 내놓아야 하는 프로젝트를 수행하고 있는 입장이다.이 상황에서 가상의 어플리케이션 하나를 더 구현하는 것은 과투자라고 판단했다.

➡️ 그때 그때 작은 객체를 만들어주자

  1. 테스트를 위한 환경을 모든 테스트에 일괄적으로 적용하려다 보니 발생한 문제라고 판단했다. 그 때 그 때 내가 사용할 메서드를 따로 따로 구현할 수 있다면, 훨씬 높은 생산성으로 테스트 코드를 작성할 수 있을 것이다.
  2. 이를 위한 프레임워크로 Mockito를 도입하였고, Mockto의 mock 메서드를 활용해 가짜로 만들어낸 객체를 의존성 주입에 사용하고, 해당 객체의 메서드에 대해서 입력값과 출력값을 임의로 조절하는 방식으로 각각의 테스트 케이스에 맞는 작은 객체를 만들 수 있다.

결과와 한계

  1. 가짜 객체를 활용하여 의존성을 주입하기 때문에 각각의 레이어에 대해서 간단하게 의존성의 동작을 모사할 수 있었다.
  2. 그러나, 가짜 객체가 입력값에 대해서 어떤 출력값을 내보내는 지에 대해서 검증이 필요할 것으로 판단된다.
    • 각각의 레이어에서 하위 레이어로 이동하면서 사용했던 입출력 값에 대해서 검증하는 방식으로 진행할 수도 있지만, 이는 점점 더 많은 구체적인 테스트 케이스를 구현해야 하게 되는 결과로 이어지고, 상기 과투자 문제를 다시금 불러일으키게 된다.

ORM과 데이터 정합성 유지하기

😵‍💫 문제발생 : 객체 상태와 데이터베이스 상태 간의 차이 발생

JPA를 통해 상태변경 쿼리문(JPQL)을 작성하고 해당 쿼리문의 정상 동작 여부를 확인하고자 디버깅을 통해 확인해보았는데, 예상하지 못한 상황에 마주치게 되었다.

@Transactional
public void changeMember(Long memberId) {

	Member member = memberRepository.findById(memberId);
	
	//상태변경 쿼리
	memberRepository.delete(member);
	
	//동작 확인(콘솔 출력, 디버깅 중단점)
	System.out.println(memberRepository.findById(memberId)); //member객체 출력!(기대값: null)
}

삭제 쿼리를 요청하고 정상적으로 수행 되었음에도 자바 코드 레벨에서 대상 객체가 생존 해 있는 상황을 확인하였다. 위의 상황을 분석하고 해결하는 과정에서 JPA ‘엔티티 생명주기’, ‘캐시’ 그리고 ‘트랜잭션’ 까지 넓은 범위의 주제에 대해 학습할 수 있었다.

❗️ 문제 원인 : 1차 캐시

가장 먼저, JPA (Hibernate)의 캐시 구조이다.

Hibernate는 두 종류의 캐시를 사용할 수 있다.

  • 1차 캐시
    • 영속성 컨텍스트 내부에 존재하는 엔티티를 보관하는 캐시
    • *트랜잭션 단위 로 존재하고 공유된다.(”트랜잭션이 시작되고 종료될 때까지 캐시가 유효”)
    • 트랜잭션안에서 commit 혹은 flush가 호출되면 1차 캐시의 내용(엔티티의 변경사항)을 데이터베이스에 동기화 한다.
    • 영속성 컨텍스트 자체가 1차 캐시로, 끄고 킬 수 있는 옵션이 아니다.
    • 엔티티 자체를 보관하고 있어 캐시의 반환값이 조회 대상이 되는 객체와 똑같다.(동일성, ==비교)
  • 2차 캐시
    • 영속성 컨텍스트 범위가 아닌, 애플리케이션 범위의 캐시(트랜잭션의 시작과 종료가 아닌, 애플리케이션이 시작되고 종료될 때까지 캐시가 유지된다.)
    • 끄고 킬 수 있는 옵션으로, 2차 캐시 옵션이 켜져있으면, EntityManager를 통해 데이터를 조회할 경우, ‘1차 캐시 → 2차 캐시 → 데이터베이스’순으로 조회를 진행한다.
    • 가지고 있는 “엔티티를 복사하여” 반환한다.(== 비교에 대해 항상 보장되지는 않음)

위에 서술한 우리가 겪었던 문제는 1차 캐시에 관련된 문제로, 엔티티 메니저를 통하지 않고 쿼리를 통해 직접 데이터베이스의 상태를 변경 하여 1차 캐시에 있는 객체 의 상태와 데이터베이스의 데이터 상태 간의 차이가 생긴 것 이었다. (기본적으로 엔티티 매니저를 통한 상태변경은, 트랜잭션 종료 시점에 엔티티 매니저가 영속화된 객체의 상태 변경을 자동으로 감지하고 반영하는 ‘더티체킹’이라고 불리는 기법을 통해 진행한다.)

🔨 문제 해결 : 엔티티 생명주기

엔티티 매니저가 관리하는 객체, “엔티티”의 생명주기(엔티티 상태)는 아래와 같다.

JPA_3_2
  1. 비영속(new/transient): 영속성 컨텍스트와 전혀 관계가 없는 상태
  2. 영속(managed): 영속성 컨텍스트에 저장된 상태(영속성 컨텍스트에 의해 관리되는 상태)
  3. 준영속(detached): 영속성 컨텍스트에 저장되었다가 분리된 상태
  4. 삭제(removed): 삭제된 상태

결국, 데이터베이스의 데이터가 아닌, 영속성 컨텍스트 내의 Managed 상태의 엔티티가 먼저 조회되어 발생한 문제이다. 생태변경 쿼리 이후에 추가적인 쿼리의 정상 동작을 위해서는 1차 캐시(영속성 컨텍스트)의 내용을 비워줄 필요가 있다.

@Modyfying(clearAutomatically = true)
void delete(Member member);

@ModyfyingclearAutomatically 속성을 사용하여, 상태변경 쿼리 이후에 1차 캐시를 명시적으로 비워줄 수 있다.

@Transactional
public void changeMember(Long memberId) {

	Member member = memberRepository.findById(memberId);
	
	//상태변경 쿼리
	memberRepository.delete(member);
	
	//동작 확인(콘솔 출력, 디버깅 중단점)
	System.out.println(memberRepository.findById(memberId)); //null
}

1차 캐시를 비워준 이후, 예상대로 동작함을 확인할 수 있었다.

JPA를 활용한 개발 생산성 증대하기

JPA를 활용한 개발 생산성 증대하기

객체 중심의 자바에서 기존의 JDBC를 통한 쿼리문 작성은 복잡하고 반복적인 쿼리문DAO의 작성을 강제해왔다. 거기에 더불어 자바 코드 자체를 객체지향적 코드가 아닌, 데이터지향적 코드로 변질시켜버리는 문제점까지 존재한다.

자바진영 측에서 객체-관계 매핑, ORM을 정의한 스펙 JPA는 위와 같은 문제점들을 해결해주어 자바 개발자들이 편하게 객체지향에 집중할 수 있도록 하고자 등장했다.

이번 프로젝트에 JPA를 도입하는 과정에서 JPQL, QueryDSL 그리고 Spring Data JPA까지 객체지향 쿼리와 여러 관련 기술들을 사용해보았고, 각 기술 별로 어떤 불편함을 어떻게 해결하였는 지를 경험하게 되어 해당 내용을 기록하게 되었다.

JDBC + Native Query(+ DAO)

JDBC와 Native Query를 사용하여 회원을 조회하려고 하면…”

  1. 회원 객체 정의
public class Member {
	
	private String memberId;
	private String name;
}
  1. 조회용 DAO 작성
public class MemberDAO {

	public Member find(String memberId) {...}
}
  1. 쿼리 작성
SELECT MEMBER_ID, NAME FROM MEMBER M WHERE MEMBER_ID = ?
  1. JDBC API를 통한 SQL 실행
ResultSet rs = stmt.executeQuery(sql);
  1. 조회 결과를 객체로 매핑
public Member find(String memberId) {
	//쿼리 작성
	//JDBC API를 통한 SQL 실행

	//조회 결과 매핑
	String memberId = rs.getString("MEMBER_ID");
	String name = rs.getString("NAME");
	
	Member member = new Member();
	member.setMemberId(memberId);
	member.setName(name);

	return member;
}

문제점

데이터베이스로부터 단순한 조회를 위해서도 위와 같은 복잡한 과정을 거쳐야한다.(반복적인 DB Connection 요청과 반납 과정은 덤) 단순히 복잡하고 번거롭기만 하다면 다행일지도 모른다. JDBC와 NativeQuery문의 조합은 아래와 같은 문제점들을 내재하고 있다.

  • 애플리케이션 레이어와 데이터베이스 레이어간 계층 분할이 제대로 이뤄지지 않는다.

  • SQL에 의존적인 개발을 진행하게 된다.

    Member 클래스와 Member 테이블에 새로운 멤버 변수(칼럼)가 추가 되었다면,

    1. 해당 변수에 관련된 쿼리문들을 전부 새로 작성해줘야한다.
    2. 기존의 많은 쿼리문들에 수정이 발생한다.
    3. 각 쿼리문별로 조회하는 변수와 조회하지 않는 변수들이 서로 달라 비지니스 로직을 어지럽게 한다.
  • 엔티티 객체를 신뢰할 수 없다.

    Member 클래스에 소속된 팀에대한 정보, Team 객체가 멤버 변수로 추가 되었을 때, 아래와 같은 객체 그래프 탐색 코드는 NPE문제가 발생할 위험이 있다.

    Team team = member.getTeam(); //NullPointException 발생 가능!

    위의 코드에서 Team클래스가 null이 아님을 확인하기 위해서는 Member를 조회하는 쿼리문과 DAO를 추가적으로 직접 확인해 주어야만 한다.

    SELECT M.MEMBER_ID, M.NAME, T.TEAM_ID, T.TEAM_NAME
    FROM MEMBER M
    JOIN TEAM T
    	ON M.TEAM_ID = T.TEAM_ID
    ResultSet rs = stmt.executeQuery(sql);
    
    String memberId = rs.getString("MEMBER_ID");
    String memberName = rs.getString("MEMBER_NAME");
    String teamId = rs.getString("TEAM_ID");
    String teamName = rs.getString("TEAM_MEMBER");
    
    Team team = new Team();
    team.setTeamId(teamId);
    team.setTeamName(teamName);
    Member member = new Member();
    member.setMemberId(memberId);
    member.setName(name);
    member.setTeam(team); //***
    ...

한 문장으로 정리해보면, “객체지향 언어와 관계형 데이터베이스 간의 패러다임 불일치 문제가 발생 한다.”

JPA + JPQL

무엇을, 어떻게 해결하였는가 : 패러다임 불일치 문제

JPA_save.png

JPA

JPA는 개발자가 직접 구현해야 했던 아래의 항목들을 대신 해줌으로써 패러다임 불일치 문제를 해결해준다.

  1. 쿼리 결과를 일일이 객체에 매핑

    데이터베이스 테이블과 자동으로 매핑되는 ‘엔티티’ 객체를 지원.

  2. 데이터지향적으로 작성되던 쿼리문들

    엔티티와 엔티티의 멤버 변수를 기반으로 JPA가 NativeQuery를 대신 작성 및 수행

이와 같은 JPA의 지원으로, 자바 개발자는 데이터에 대한 접근 및 조회에 대한 코드를 “객체지향 언어, 자바 스타일로” 작성할 수 있다.

entityManager.persist(member); //저장

Member member = entityManager.findById(memberId); //조회

Team team = member.getTeam();
team.getTeamName(); //team을 직접적으로 사용하는 시점에 "추가적인 쿼리를 날려준다."

개발자는 좀 더 객체지향에 가까운 JPQL을 사용하여 JPA에게 데이터 접근, 조작을 부탁하고, NativeQuery는 JPA가 대신하여 처리해주는 구조인 것이다.

JPQL

JPA에게 데이터 접근, 조작을 부탁하는 쿼리 언어인 JPQL은 다음과 같이 사용할 수 있다.

Member findById(Long memberId) {
	return entityManager.createQuery(
		"SELECT m FROM Member m WHERE m.id = :_memberId, Member.class
	)
	.setParameter("_memberId", memberId)
	.getSingleResult();
}

한계점

하지만 JPQL도 여전히 자바 코드가 아닌, 문자열을 통해서 쿼리문을 작성하기 때문에 쿼리문의 문법적 오류를 컴파일 단계가아닌 런타임 시점에서 밖에 캐치할 수 없고, 다양한 조건의 쿼리문, 즉 동적 쿼리(ex. 복잡하고 다양한 조건의 주문조회)를 작성함에 있어 조건별로 쿼리문을 전부 작성해주어야 하는 불편함이 존재한다. (추가적으로 역시, 문자열로 작성된다는 이유로 IDE의 자동완성 기능, Intellisense의 도움을 받을 수 없다는 점도 단점으로 작용한다.)

QueryDSL

무엇을, 어떻게 해결하였는가 : 컴파일 타임의 문법 검사, 동적 쿼리

QueryDSL은 문자열이 아닌 자바 코드를 통해 쿼리문을 작성할 수 있게 해주어 문법 오류를 런타임이 아닌 컴파일 시점에서 확인할 수 있게 해주고, 동적 쿼리를 편하게 작성할 수 있도록 지원하여 개발자의 생산성을 증가시켜 준다.

public Optinal<Member> findById(Long memberId) {
	return jpqlQueryFactory
					.selectFrom(QMember.member)
					.where(QMember.member.id.eq(memberId))
					.fetchOne();
}
@Getter
@Builder
public class OrderSearchCondition {
	private LocalDate from;
	private LocalDate to;

	//1. null이 아닌 조건 변수에 대해서만 쿼리문의 조건으로 포함시킨다.
	//2. 모든 조건 변수가(from, to) null로 채워져 있다면, 모든 레코드가 조회된다.
	public Predicate makePredicate() {
		BooleanBuilder booleanBuilder = new BooleanBuilder();

		if (from != null) {
			booleanBuilder.and(QOrder.order.orderDate.after(from));
		}
		if (to != null) {
			booleanBuilder.and(QOrder.order.orderDate.before.eq(to));
		}

		return booleanBuilder;
	}
}
...
public List<Order> searchByCondition(OrderSearchCondition searchCondition) {
	return jpaQueryFactory
					.selectFrom(QOrder.order)
					.where(searchCondition.makePredicate())
					.fetch();
}

Spring Data JPA

무엇을, 어떻게 해결하였는가 : 반복되는 기본적인 CRUD

프로젝트를 진행하면 여러 도메인이 생기게 되고, 각각의 도메인들은 그 구조와 역할이 비슷한 CRUD 쿼리문들을 가지게 된다. 이때 Spring Data JPA를 사용하면, 개발자는 기본적인 CRUD 쿼리문 작성의 지루함을 피하면서도, 깔끔한 코드를 통해 더욱 중요한 도메인 쿼리문에 집중할 수 있는 이점을 얻을 수 있다.

  • Spring Data JPA가 만들어놓은 인터페이스를 확장하는 것만으로 기본적인 CRUD 쿼리문을 지원받을 수 있다.

    public interface MemberRepository extends JpaRepository<Member, Long> {
    }
    public boolean isExist(Long memberId) {
    	return memberRepository.findById(memberId); //...Spring Data JPA가 자동으로 만들어준 구현 메서드
    }
  • Spring Data JPA는 또한, 인터페이스에 정의한 메소드의 이름을 기반으로 쿼리문을 자동으로 생성 및 지원해주기 때문에, 기본적인 CRUD이외로 확장하여 사용하기에도 편리하다.

    public interface MemberRepository extends JpaRepository<Member, Long> {
    	List<Member> findByGroupId(Long groupId);
    }

위와 같은 사용법 외로, 직접 JPQL 쿼리문을 작성할 수 있도록 해주는 @Query 을 지원하기도 하며, QueryDSL과 함께 쓰는것이 가능하여(Spring Data JPA의 커스텀 인터페이스 확장 기능), Spring Data JPA의 편리함을 누리면서도 복잡한 쿼리문까지도 쉽게 작성할 수 있는 개발환경을 조성할 수 있다.

결론

JPQL부터 시작해서 QueryDSL, 그리고 Spring Data JPA까지, 순서대로 이번 프로젝트에 적용해 보았는데, 처음부터 남들이 으레 쓴다고 하는, 좋다고 하는 기술들을 무턱대고 적용하지 않고 차례로 적용을 해보았기에 각 단계의 해당 기술들이 ‘어떤 불편함을 어떻게 해소하고자 했는지’, ‘어떤 장점이 있어 어느 상황에 적재적소로 사용해야하는지’ 더 크게 느낄 수 있는 프로젝트 경험이 될 수 있었다.

🤼 팀 소개 : 위키 바로가기

💡 우리 모두가 리더다.

J022_김관경 J026_김대호 J069_문성현 J144_이승민
깃허브 바로가기 깃허브 바로가기 깃허브 바로가기 깃허브 바로가기
프로젝트 리딩 백엔드 진행 리딩 프론트엔드 진행 리딩 전체 일정 관리

About

떠들썩! 떠들썩! 들썩들썩 떠들썩!

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 4

  •  
  •  
  •  
  •