backtony / SW-Maestro-gjgs

SW 마에스트로 메인 프로젝트

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

gjgs-logo

팀명 : 가지각색

                   고범석                                       최준성                                       김기완                   
- 팀장
- Back-end
- GitHub
- 기획자
- Back-end
- DevOps
- GitHub
- PM
- Front-end
- GitHub
soma-logo

💁‍♂️ Detail Role

  • 고범석
    • 팀장
    • Back-end
    • 그룹, 게시글, 클래스, 쿠폰, 리뷰, 문의, 결제 구현
    • DB 클래스 정보를 Elasticsearch로 옮기는 배치 작업 및 Elasticsearch 구현
    • DB 이중화 구성
    • Swagger, REST docs 문서화

  • 최준성(backtony)
    • 기획자
    • Back-end & DevOps
    • 로그인, 마이페이지, 찜, 매칭, 알림, 공지사항, 리워드, 결제 취소 배치 작업 구현
    • 전체적인 AWS 환경 구축
    • Jenkins CI/CD 구축
    • Redis 클러스터 및 모니터링 구축
    • 로그 모니터링용 ELK 구축
    • Swagger, REST docs 문서화

  • 김기완
    • PM
    • Front-end
    • 모든 Front-end 구현


Languages

HTML5CSS3 JavaScript TypeScript Java


Technologies

GitGitLab Jira AWS Linux Jenkins Docker React TypeScript FCM Spring Boot Spring Batch JPA queryDsl mysql Redis ELK nGrinder


📝 목차


📝 개요

본문 확인 (👈 Click)
취미 생활 및 자기계발 활동에 금전적으로 투자하는 사람들이 지속적으로 증가하고 있으며, 20 ~ 30대 대상 685명 설문조사 결과 사람들은 취미를 혼자보다 함께 즐기고 싶어할뿐만 아니라 전체의 75% 이상이 처음만나는 사람과도 함께 취미를 즐기고 싶다고 답변했습니다. 또한, 유료로 취미생활 및 자기계발 분야 참여시 전체의 63%가 오프라인 방식을 선호하였습니다. 저희는 취미 관련 오프라인 유료 클래스의 수요가 충분하다는 것을 파악하였고, 기존 업체들의 문제점들을 보완하여 오프라인 클래스 중개 플랫폼을 서비스하고자 합니다.

🧐 Pain Point

본문 확인 (👈 Click)

현재 오프라인 취미 클래스 중개 업체들의 문제점

  • 기존 오프라인 취미 클래스 중개 업체의 문제점
    • 개인 중심의 서비스
      • 설문조사 결과 사람들은 취미를 함께 즐기는 것을 선호한다고 했지만, 정작 현재 업체들은 개인 신청위주로 서비스가 진행중
      • 예를 들어, 클래스 수강신청 인원 상태가 0/8 인 상태에서, 취미를 타인과 함께 즐기고자 하는 고객이 클래스에 수강신청을 했을경우 1/8이 되지만, 최종적으로 해당 고객 이외에는 아무도 신청하지 않아 의도와는 다르게 혼자만 신청할 가능성 존재
      • 현재 수강신청 인원 상태가 0/8 인 경우, 함께 클래스를 즐기기 위해 서비스를 이용하는 고객은 심리적으로 신청하기를 꺼리게 됨
      • 클래스 모집 최소 단위가 존재하는 경우, 개인 신청 시 최악의 경우 클래스 자체가 개설 X
    • 취미가 비슷한 사람들을 모아주는 기능 X
    • 사용자간의 소통 기능 X
    • 단체 예약에 대한 할인 정책 X

  • 동호회 모임 어플
    • 전문적인 클래스 연결 X
    • 사람을 모아주는 역할만 하기 때문에 취미를 제대로 배우고 싶은 사람들에게 부적합
    • 30 ~ 50 명의 그룹으로 형성되어 있어 관리가 어려움

💡 Idea / Solution

본문 확인 (👈 Click)
  • 그룹 시스템
    • 클래스 수강 신청 전에 취미가 비슷한 사람들을 찾고, 서로 소통할 수 있는 그룹을 만들 수 있는 기능
    • 그룹원간의 채팅 기능
    • 그룹원 초대 기능
    • 그룹원 프로필 확인 기능 -> 실제 오프라인 클래스 수강 전에 그룹원이 어떤 사람인지 확인 가능

  • 게시글 시스템
    • 그룹 생성 시, 모집글을 통해 특정 클래스를 지정해두고 함께 수강신청할 그룹원 모집 기능

  • 사용자 매칭 시스템
    • 원하는 지역, 취미, 나이, 시간, 인원 등을 입력 시 이를 기반으로 사용자들을 빠르게 하나의 그룹으로 묶어주는 매칭 시스템

  • 인원수에 따른 가격 변동 시스템
    • 단체 신청 시 인원당 가격 할인 폭 증가 -> 단체 신청 이점을 제공함으로써 그룹 서비스 사용을 유도
    • ex) 1인 신청시 인당 50,000원
    • ex) 2인 신청시 인당 48,000원

해결책 요약

그룹 시스템을 통해 클래스에 수강신청하기 전에 취미가 비슷한 사람들을 모을 수 있는 기능을 제공합니다.
이를 통해, 클래스를 함께 즐기고자 하는 고객이 최종적으로 클래스에 혼자 수강신청하게 되는 불상사를 막을 수 있습니다.
또한, 클래스를 수강하기 전에 채팅을 통해 그룹원들 간에 소통이 가능합니다.
그룹원들 모아서 클래스에 수강신청하는 것이 번거롭다면, 매칭시스템을 이용해 빠르게 그룹을 구성할 수 있습니다.
만약 혼자 취미 클래스를 수강해도 상관이 없다면 바로 클래스에 수강신청을 진행할 수 있습니다.
그룹 시스템을 통해 취미가 비슷한 사람들을 클래스 수강신청 전에 사전에 모을 수 있다는 점, 유저간의 소통할 수 있다는 점이 타 중개 서비스와의 가장 큰 차이점이므로 그룹 시스템의 이용을 유도하기 위해 단체로 클래스 수강신청을 진행할 경우, 인원에 따른 가격 할인폭 높여 그룹 시스템을 사용하도록 유도합니다.


📈 아키텍처

본문 확인 (👈 Click)
structure
  • VPC로 논리적으로 격리된 공간을 만들고 외부 접근 제한
    • VPC가 외부와 통신이 가능하도록 Internet Gateway 를 구성하고 라우팅 테이블에서 Public Subnet(10.0.1.0/24, 10.0.2.0/24)과 연결
    • NAT Gateway를 구성하여 나머지 Private Subnet 리소스가 인터넷으로 트래픽이 통할 수 있도록 연결
    • Bastion EC2를 통해 Private Subnet EC2로 접근
  • Jenkins와 CodeDeploy를 사용한 Blue Green 무중단 배포
  • Load Balancer과 Auto Scaling으로 트래픽 분산
  • Redis Cluster 및 Redis stat 모니터링 구축
  • Log Monitor 용 ELK 구축
  • 검색용 ELK Cluster 구축
  • RDS(MySQL) 이중화 구성

보안을 위해 VPC 안에서 전체적인 AWS 환경을 구축하였고, 내부 접근에는 bastion EC2를 통해 접근하도록 설계했습니다.
로드밸런서와 오토스캐일링으로 트래픽을 분산했으며, Jenkins와 CodeDeploy를 통해 blue green 무중단 배포 환경을 구축했습니다.
검색 엔진의 경우 RDS의 데이터를 배치작업을 통해 Elastic Search로 로드하고 ELK 클러스터를 통해 안정적으로 구축했습니다.
Redis 또한 클러스터로 구축하여 Master가 죽어도 FailOver되어 정상 작동하도록 구축했습니다.
RDS의 경우 DB 이중화를 통해 부하를 줄여주었습니다.
모니터링의 경우 Kibana와 Redis-stat를 사용했습니다.


🎁 결과물

본문 확인 (👈 Click)

메인페이지

main
  • 광고배너
  • 검색 버튼
  • 함께 할 친구 찾기 버튼
  • 8가지 카테고리 분류
  • 추천 클래스(회원 가입시 선택한 카테고리 기반)
  • 인기 클래스(조회수 기반)
  • 신규 클래스
  • 하단 네비게이션바 - 홈, 카테고리, 그룹, 찜, 마이

하단 네비게이션바

navigation
  • 카테고리 : 다양한 카테고리분류와 지역별을 통해 필터링하여 클래스 검색 기능
  • 그룹 : 자신이 속한 그룹 목록
    • 내가 찜한 클래스
    • 내가 찜한 게시글
    • 내가 속한 그룹원들이 찜한 클래스
  • 마이 : 사용자 정보
    • 카카오 연동 로그인
    • 보유 리워드
    • 보유 쿠폰
    • 나의 게시글
    • 나의 클래스(결제, 결제 대기)
    • 나의 문의
    • 나의 리뷰

그룹 생성

group-create
  • 클래스에 수강신청하기 전에 같이 수강할 사람을 모으기 위해 그룹을 생성

그룹 상세 페이지

group
  • 그룹에 대한 요약 정보
  • 그룹원들이 찜한 클래스 공유
  • 그룹의 공통 태그 기반 클래스 추천
  • 그룹 리더의 경우, 사용자 초대 기능
  • 그룹원 목록 -> 클릭시 그룹원 프로필 확인 가능
  • 그룹원간의 채팅 기능 -> 채팅 대상 클릭시 프로필 확인 가능

게시글 생성

group-bulletin-create
  • 그룹을 생성했다면, 그룹원들을 모으기 위해 만드는 게시글
  • 마이페이지 나의 게시글에서 확인이 가능하며, 게시글 우측 하단의 버튼으로 활성화, 비활성화 가능

그룹원 모집 게시글 페이지 및 게시글 상세 페이지

bulletin
  • 그룹원 모집 게시글 페이지
    • 특정 클래스를 함께 수강신청할 사람들을 모집하는 곳
    • 게시글을 통해 모집이 번거롭다면, 상단의 있는 함께할 친구 매칭버튼을 클릭하여 빠르게 매칭이 가능
  • 게시글 상세 페이지
    • 게시글 모집 요약
    • 선택한 클래스 정보(함께 수강하고 싶은 클래스)
    • 해당 모집 게시글에 참여하고 있는 그룹원 정보
    • 좌측 하단 하트 버튼으로 게시글 찜
    • 참가신청 기능

매칭 시스템 - 함께 할 친구 매칭하기

matching
  • 게시글을 통해 클래스를 함께 수강신청할 사람들을 모집하기 번거로울 경우, 빠르게 그룹을 형성해주는 기능
  • 매칭을 원하는 지역, 클래스 카테고리, 요일, 시간, 인원을 입력하면 이를 기반으로 빠르게 함께할 사람을 매칭하여 그룹 생성
  • 매칭중에는 '함께할 친구 매칭하기' 버튼 문구가 '함께할 친구 매칭중'으로 변경되고 이때 클릭 시 매칭 중단 가능

클래스 상세 페이지

class
  • 클래스 상세 정보
  • 개인, 그룹 단위로 신청이 가능하며, 인원에 따른 가격 할인폭 변동
  • 하단에 고객에게 비슷한 카테고리 기반 다른 클래스 추천
  • 함께할 사람 찾기 버튼 클릭 시, 해당 클래스를 함께 들을 사람을 모집하는 게시글을 필터링하여 제시
  • 우측 상단에 공유 버튼 클릭시, 카카오톡으로 공유 또는 링크 복사 기능

클래스 검색 필터

filter
  • 다양한 필터링 검색 기능
    • 지역별, 인기순, 금액순 등등

알림

app_alarm
  • 매칭, 이벤트 등을 알려주는 알림 기능

마이페이지

회원가입 및 로그인

sign-up

리워드

reward

쿠폰

coupon

프로필 편집, 나의 게시글, 나의 클래스, 나의문의, 나의 리뷰

mypage

결제

payment

디렉터 전용 웹

로그인 페이지

web_director_login

메인 페이지

web-director-main

디렉터 소개 수정

web-director-edit

공지사항 조회

web-director-notice

클래스 등록

1. 기본 정보 입력

web-director-create-class-1
  • 클래스 개설의 첫번째 단계로 기본적인 정보를 입력하는 페이지

2. 상세 소개

web-director-create-class-2
  • 클래스 개설의 두번째 단계로 클래스의 상세 소개를 입력하는 페이지

3. 커리큘럼

web-director-create-class-3
  • 클래스 개설의 세번째 단계로 커리큘럼을 입력하는 페이지

4. 스케줄

web-director-create-class-4
  • 클래스 개설의 네번째 단계로 클래스의 스케줄 정보를 입력하는 페이지

5. 가격 및 쿠폰

web-director-create-class-5
  • 클래스 개설의 다섯번째 단계로 클래스의 가격 정보 및 할인 쿠폰 정보를 입력하는 페이지

6. 부가 정보

web-director-create-class-6
  • 클래스 개설의 마지막 단계로 약관 동의를 입력받는 페이지

내 클래스 목록

web-director-my-class-list
  • 내가 개설한 클래스의 상태를 볼 수 있는 페이지
    • 상태 : 진행중, 작성중, 검수중, 검수 거절, 종료
    • 검수가 완료된 클래스의 경우, 스케줄 정보만 변경 가능

클래스 관리

web-class-manage
  • 내가 개설한 클래스의 스케줄별 상태 및 정보를 조회할 수 있는 페이지

문의 관리

web-director_question
  • 내가 개설한 클래스에 고객이 남긴 문의글을 확인하고 답글을 달 수 있는 페이지

리뷰 관리

web-director_review
  • 내가 개설한 클래스에 고객이 남긴 리뷰를 확인하는 페이지

채팅

web-director-chat
  • 내가 개설한 클래스에 수강신청한 고객과 채팅하는 페이지

할인 쿠폰 관리

web-director-chat
  • 내가 개설한 클래스에서 제공하고 있는 할인 쿠폰을 관리하는 페이지

백오피스 어드민 전용 웹

로그인 페이지

web-admin-login

알림 보내기

web-admin-alarm
  • 전체 유저에게 또는 특정 유저를 검색하여 해당 유저에게 알림을 보낼 수 있는 페이지

클래스 검수하기

web-admin-check
  • 디렉터가 등록한 클래스에 대해 검수를 진행하는 페이지
    • 적절한 경우, 승인
    • 적절하지 않은 경우, 거부

공지사항

web-admin-notice
  • 공지사항을 생성, 수정, 삭제 할 수 있는 페이지

⏰ 협업 방식 - Jira

본문 확인 (👈 Click)
roadmap
kanban
sprint

저희 팀은 협업 방식으로 Jira를 사용했습니다.
프론트엔드와 백엔드 파트를 나누어서 구현해야할 큰 기능들을 에픽으로 정의하여 일정을 설정했고 하나의 에픽에 필요한 기능들인 task를 세세하게 나누었습니다.
칸반보드를 통해 task들을 개발해야할 모든 기능들, 이번주에 개발해야할 기능, 개발 진행중, 개발 완료된 칸으로 옮기면서 한눈에 볼 수 있도록 진행했습니다.
스프린트는 1주일 단위로 설정하여 Jira 내 Confluence에서 스프린트 주기동안 진행해야할 기능들을 정의하고 마음가짐과 스프린트를 마친 후 회고를 작성하는 방식으로 스프린트를 진행했습니다.


🎈 팀의 개발 문화

본문 확인 (👈 Click)

수정에는 관대하게, 오류 시 질책보다는 배우자.

프로젝트 초기 설계 당시, 김기완 팀원이 인턴을 하면서 백엔드분들께 API를 변경해달라, 어떤 정보가 더 필요하다는 요청을 했을 때 백엔드 분들께서 별로 달갑지 않아 했던 경험을 이야기해 주었습니다.
이 이야기를 듣고 팀원 셋 모두 능숙하지 않고 배워가는 과정이기에 초기 설계했던 API 설계가 잘못되어 수정이 필요한 부분은 필연적으로 발생할 것이라고 예상했고 이에 대해서는 껄끄러움 없이 이야기를 나누기로 했습니다.
또한, 발생하는 오류에 대해서는 질책하기 보다는 함께 해결하고 이유와 해결책을 찾아나가기로 했습니다.
실제로 백엔드에서 API 개발을 완료하고, 프론트로 넘겼을 때 작동하지 않았던 경험이 있는데 이에 대해서는 항상 왜 이런 문제가 발생했고, 어떻게 하여 해결할 수 있었다는 내용을 팀원끼리 공유하는 시간을 가졌습니다.


백엔드 소통

백엔드는 고범석 팀원과 제(최준성)가 함께 담당했기 때문에 서로 많은 이야기를 나눌 수 있었습니다.
기본적으로 저희는 프로젝트 초기에는 주 6일 풀타임, 프로젝트 중후반 부에는 주 5일 풀타임으로 만나서 개발을 진행했기 때문에 개발에 관해서 잘 안되고 있는 부분, 개선하고자 하는 부분, 도입하고자 하는 기술 선택의 기준에 대해서는 바로바로 이야기를 나눴습니다.
따라서 부족한 부분에 대해서는 서로에게 바로 도움을 줄 수 있었고, 도입하고자 하는 기술에 대해서는 서로의 생각과 근거를 명확히 제시하면서 의견을 교환하고 정했습니다. 이에 대한 결과물은 뒤에 나오는 왜 이 기술을 사용했는가?, 리팩토링 & 성능 개선에 정리했습니다.


📈 ERD

본문 확인 (👈 Click)
erd

🔨 테스트 및 모니터링

본문 확인 (👈 Click)

Unit Test

test

nGrinder 부하 테스트

ngrinder

처음 ngrinder을 사용해 테스트했을 때 RDS를 프리티어로 사용해서 TPS가 낮게 나왔습니다.
프로젝트가 끝난 후에 성능을 높이면 어느정도 나올까 궁금하여 조금 높여서 테스트해서 보았습니다.
따라서 위의 TPS가 높은 것들은 RDS 성능을 높였을 때 입니다.


Log Monitor

log
  • ELK를 이용하여 구축한 로그 모니터링 페이지

Redis Monitor

redis-monitor
  • Redis-stat를 사용한 Redis 모니터링 페이지

💎 왜 이 기술을 사용했는가?

본문 확인 (👈 Click)

API 문서화

refac-docs

Jira로 일정관리를 하고 있었기에 프로젝트 초기에는 Jira Confluence를 사용하여 API 문서화를 진행했습니다.
프로젝트 초기 단계가 지나 작성해야 하는 API들이 많아지면서 일일이 Confluence에 작성하고 확인하기가 번거로워졌기에 코드상으로 해결 가능한 Swagger를 적용하여 문서화를 진행했습니다.
이후 프로젝트의 중후반 단계가 되었을 때, 정말 많은 API들을 만들게 되었는데 이 과정에서 Swagger의 단점이 명확하게 보이기 시작했습니다.

  1. 문서화 작업을 위한 Swagger 애노테이션으로 인해 코드의 가독성이 떨어진다.
  2. 테스트 기반이 아니기에 기능이 100% 동작한다고 확신할 수 없다.
  3. 모든 오류에 대한 여러 가지 응답을 문서화할 수 없다.

위와 같은 문제를 Spring REST docs는 모두 해결할 수 있었기에 Spring REST docs로 전환하게 되었습니다.


Querydsl

Spring Data JPA가 기본적으로 제공해주는 CRUD 메서드 및 쿼리 메서드 기능을 사용하더라도, 원하는 조건의 데이터를 수집하기 위해서는 필연적으로 JPQL을 작성하게 됩니다.
간단한 로직을 작성하는데 큰 문제는 없으나, 복잡한 로직의 경우 개행이 포함된 쿼리 문자열이 상당히 길어집니다.
JPQL 문자열에 오타 혹은 문법적인 오류가 존재하는 경우, 정적 쿼리라면 어플리케이션 로딩 시점에 이를 발견할 수 있으나 그 외는 런타임 시점에서 에러가 발생합니다.
이러한 문제를 해결해 주는 것이 Querydsl이기에 Querydsl을 도입했습니다.
Querydsl 도입으로 다음과 같은 이점을 얻었습니다.

  • 문자가 아닌 코드로 쿼리를 작성함으로써, 컴파일 시점에 문법 오류를 쉽게 확인할 수 있다.
  • 자동 완성 등 IDE의 도움을 받을 수 있다.
  • 동적인 쿼리 작성이 편리하다.
  • 쿼리 작성 시 제약 조건 등을 메서드 추출을 통해 재사용할 수 있다.

토근 저장소 MySQL -> Redis

로그인 관련해서는 JWT토큰을 이용해 구현했습니다.
이 과정에서 Access Token과 Refresh Token의 유효시간이 지나게 되면 expire 되도록 처리를 해야했는데 이 과정을 MySQL에서 진행하기에는 부담이 되는 작업이었습니다.
하지만 해당 작업을 Redis의 TTL기능을 사용하여 구현한다면 간단하게 처리할 수 있었기에 토큰 저장소로 Redis를 사용하게 되었습니다.
Redis를 처음 사용해보는 것이기에 초기에는 하나의 EC2에 Redis를 띄워 사용하였으나, 멘토님의 조언을 듣고 조금 더 안전한 설계로 변경하게 되었습니다.

refac-redis

설계 초기처럼 하나의 Redis만 사용할 경우, Redis가 죽어버리면 Redis를 사용하는 로직에 생기기 때문에 Master Redis 3대, Slave Redis 6대를 띄워 클러스터를 구축하였습니다.
따라서 하나의 Master Redis가 죽어도 Failover을 통해 Slave가 Master로 승격되기 때문에 가용성을 높일 수 있었습니다.


RabbitMQ -> Redis Expire Event + Spring batch

refac-message

결제 과정에서 그룹 수강신청의 경우, 그룹이 신청한 스케줄에 대해서는 그룹 인원만큼의 여석은 다른 고객이 신청하지 못하도록 막아서 확보해야 했습니다.
예를 들면, 0/8 인 상태에서 4명이 있는 그룹이 수강신청을 한다면 4/8 인 상태로 변경해야 했습니다. 이 과정에서 그룹원들이 결제할 때까지 시간을 무한정으로 줄 수 없기 때문에 30분으로 제한하도록 비즈니스 로직을 설계했습니다.
따라서 30분이 지난 후에 결제가 완료되지 않았다면 해당 그룹의 수강 신청을 취소시켜야했습니다.
처음에는 이 로직을 구현하기 위해서 RabbitMQ를 사용하여 다음과 같이 구현했습니다.

수강 신청시 RabbitMQ로 메시지를 보내고, RabbitMQ Delayed Message Plugin를 이용해 30분이 지난 후에 처리한다.

RabbitMQ를 처음 사용해 보는 기술이었기에 멘토님께 조언을 구했고 RabbitMQ도 결국 거대한 큐이기 때문에 30분 동안 저장해두고 처리하도록 설계할 경우, 수많은 요청이 몰리면 병목현상이 발생할 것이라는 조언을 받아 다른 방식을 도입해야 했습니다.
프로젝트에서 캐시와 토큰 저장소로 Redis를 사용하고 있기에 'Redis의 TTL을 활용하면 이 문제를 해결할 수 있지 않을까?'라는 생각으로 설계를 다시 하기 시작했습니다.
수강 신청 시 TTL을 30분으로 설정하여 redis에 저장해두고 TTL이 끝나면 pub/sub 방식으로 message를 쏘도록 만들어 준 뒤, Spring에서는 메시지 리스너를 구현해 메시지를 받아 로직을 수행하도록 구현했습니다.
하지만 이 메시지가 100% 리스너에 도착한다고 보장할 수는 없기 때문에 이에 대한 안전 장치로 Spring batch + Quartz 를 사용하여 30분마다 배치 작업을 수행하도록 설계했습니다.


결과적으로 문제를 해결했지만 설계상으로 아직 해결하지 못한 부분이 남아있습니다.
redis key expire이 pub/sub 방식으로 message를 전달하기 때문에 여러 서버에서 구독을 하게 된다면 중복해서 처리하게 되는 문제가 발생합니다. 따라서 프로젝트 구성에서는 하나의 서버가 이 로직을 담당했고 스케일 아웃은 못하고 스케일 업을 해야만 했습니다.
이 문제는 그당시에는 해결하지 못했고 현재 kafka를 공부하면서 컨슈머 그룹을 사용하면 이 문제를 해결할 수 있을 것 같다는 생각이 듭니다.


DB Replication

refac-db

초기에는 하나의 RDS를 가지고 모든 작업을 진행했습니다.
하지만 트래픽이 늘어날 경우, 하나의 DB에서 쿼리를 모두 처리하기에는 병목현상이 발생할 가능성이 있다고 판단했습니다.
따라서 DB 이중화를 도입했습니다.
DB를 이중화할 경우, Master에서는 쓰기/수정/삭제 연산을 처리하고 Slave에서는 읽기 연산만을 처리하여 병목 현상을 줄일 수 있었습니다.


검색 기능 DB -> Elasticsearch

refac-elasticsearch

기존에는 클래스 검색에 AWS RDS에서 데이터를 꺼내오도록 했으나 검색 성능 향상을 위해 Elastic Search로 전환했습니다.


Flyway

refac-flyway

dev 환경에서는 단순히 ddl을 create-drop 또는 update 옵션을 사용하고 있었기에 DB에 대해 고민할 필요가 없었습니다.
하지만 운영환경에서는 ddl을 validate 또는 none 옵션을 사용해야하기 때문에 초기에는 DB script를 뽑아서 별도로 관리를 했습니다.
이후 기능이 추가되면서 script가 변경되는 일이 빈번해졌고, 매번 일일이 스크립트를 관리하는 것이 번거로울 뿐 아니라 실수하기 딱 좋은 부분이라 Flyway를 도입하여 데이터베이스 형상관리를 진행했습니다.


Cloud Watch -> Kibana

monitor

초기 구축에서는 간단하게 Cloud Watch를 사용하고 로그 모니터링 환경을 구축했습니다.
Cloud Watch만으로도 충분히 원하는 목적을 달성할 수 있었지만, 취업을 준비하는 입장에서 AWS 자원을 마음껏 사용할 수 있는 기회는 드물기 때문에 여러 가지를 도전해 보고 싶었습니다.
마침 검색 엔진을 Elastic Search로 변경해서 성능을 높여보자는 의견이 팀에서 있었기에 여러 가지 도전을 해보고자 모니터링도 Kibana로 변경해서 구축하게 되었습니다.
각 EC2에 filebeat를 심어주고 logstash에서 가공하여 elastic search로 보내도록 설계해서 구축하였습니다.


Jenkins

jenkins

CI/CD 구축을 처음 진행해보기에 처음에는 가장 간단한 Travis CI로 구축을 연습하고 실제 프로젝트에 적용을 시도했습니다.
하지만 SW Maestro에서 제공하는 Gitlab 계정으로 Gitlab, Travis CI 연동이 불가능했습니다.(프로젝트 진행 후반부에야 연동이 가능하도록 업데이트 되었습니다.)
따라서 다른 선택지가 없어 Jenkins와 AWS CodeDeploy를 이용해 Blue Green 무중단 배포를 구축했습니다.
프로젝트의 규모를 생각했을 때, 다양한 세팅과 서버를 구축해야하는 Jenkins가 최선의 선택은 아니라고 생각합니다.
하지만, 경험적 측면에서는 서버를 구축하고 Jenkins의 다양한 플러그인을 사용해볼 수 있었다는 점에서 경험적으로 좋은 선택이었던 것 같습니다.


전체적인 AWS 구축 환경 구조 선택

초기에는 간단하게 Jenkins에서 EC2로 jar 파일을 넘겨 서버를 실행하도록 구성했습니다. 그러나 프로젝트가 점점 커지면서 문제가 발생했습니다.

  1. 배포 과정에서 서비스가 중단된다.
  2. 모든 트래픽을 하나의 서버가 받는다.
  3. 보안에 문제가 있다.
  4. java -jar로 서버를 껐다가 키는 명령어를 계속 입력하기 불편하다.

해결 방식 1, 2번 : 오토스케일링과 로드밸런서를 통해 트래픽을 분산시켰고 blue/green 배포 방식을 통해 무중단 배포를 구성했습니다.
3번 : VPC를 구성하여 격리된 네트워크 공간을 만들어 다른 사람들이 접근하는 것을 막았고 bastion EC2를 두어 이를 통해 접근하도록 구성했습니다.
4번 : 도커를 사용해서 서버를 띄워 명령어의 불편한 점을 해결했습니다.

이 과정에서 다시 문제가 발생했는데 EC2를 생성하는 오토스케일링의 기반 AMI에는 서버 파일이 없다는 것입니다.
따라서 배포 시에 Jenkins에서 스프링 빌드 후 도커 파일을 빌드하여 생성된 이미지 파일을 도커 허브에 올리고 배포되는 서버와 오토스케일링으로 생성되는 EC2는 모두 도커 허브에 올라가 있는 이미지 파일을 받아서 서버를 실행하도록 구성했습니다.

위의 문제 상황들을 해결하여 최종적으로 Docker, Jenkins, Auto Scaling, Load Balancer, S3, CodeDeploy blue/green 를 사용한 AWS 환경을 구축하였습니다.

  • 최종 프로젝트 적용 구조 : 링크
  • 구축 포스팅 : 링크

채팅

refac-chat

초기에 채팅기능구현에 Socket.IO, Web RTC 등 다양한 시도를 했습니다. 하지만 앱에서의 최적화되어있지 않아 구현에 어려움이 있었습니다.
그래서 실제 비즈니스에서 많이 활용 중인 Firebase의 데이터베이스를 사용하여 보다 앱 환경에서 최적화된 실시간 데이터 통신 서비스를 구현했습니다.


CORS

refac-cors

백엔드 서버 혹은 외부 API에서 데이터 요청 시 CORS 정책으로 인해 통신이 잘 이루어지지 않는 문제가 있었습니다.
임시적인 방편으로 보편적으로 사용되는 “Access-Control-Allow-Origin” 헤더를 통해 해결을 시도했으나 이 또한 문제가 있어 Proxy서버와 DNS를 통해 해결을 하였습니다.


🚀 리팩토링 & 성능 개선

본문 확인 (👈 Click)

의미있는 이름과 함수

코드를 다시 되돌아보았을 때, 당시에는 이해할 수 있을 정도의 이름으로 지었다고 생각했으나 명확하게 와닿지 않는 네이밍들이 있었습니다.
따라서 주석이 필요 없을 정도로 명확하게 변수명과 함수명을 수정하였고, 함수의 경우 예외를 던진다면 마지막에 OrThrow를 붙여주었습니다.
함수에 대해서는 Clean Code에서 5줄 이내를 권장하고 있었습니다. 코드를 되돌아본 결과 생각보다 함수가 긴 것들이 존재했고 충분히 줄일 수 있는 수준의 내용들이었기에 할 수 있는 한에서 5줄 내외를 지키도록 수정했습니다.


Bulk Query

refac-bulk

코드상 여러 곳에서 이런 문제가 발생했지만 회원가입시 선호하는 카테고리를 입력하는 부분을 예시로 작성하겠습니다.
회원가입 시 선호하는 카테고리를 선택하게 됩니다.

문제점

코드상 cascade를 이용해 따로 save하지 않아도 member를 save하면 같이 member_category가 save되도록 설계했습니다.
또한, 고아 객체 orphanRemoval를 사용하여 삭제 또한 따로 delete 쿼리를 보내지 않아도 동작하도록 설계했습니다.
개발자 입장에서는 위와 같은 설계로 코드를 작성하기가 매우 수월했고, 당연히 한번에 한방 쿼리가 나갈 것으로 예상했습니다.
하지만 쿼리로그를 찍어본 결과 save와 delete 모두 한방 쿼리가 아니라 여러번의 쿼리가 나가는 것을 확인했습니다.

해결책

refac-bulk-solution

결론부터 말씀드리면, cascade를 제거했고 다음과 같이 수정했습니다.

  • Insert의 경우 : JdbcTemplate.batchUpdate() 사용
  • delete의 경우 : queryDsl의 in 쿼리 사용

Insert 해결책

해결책은 2가지가 존재했습니다.

  1. Table Id strategy를 SEQUENCE로 변경하고 Batch 작업
  2. JdbcTemplate.batchUpdate() 사용

MySQL의 Table Id 전략은 대부분이 IDENTITY 전략을 사용하기도 하고, 저희는 이미 Id 전략을 IDENTITY 전략으로 사용하고 있었기에 Id전략을 변경하기에는 무리가 있었습니다. 또한, Jdbc를 사용하는 것이 성능상 더 뛰어나다는 결과를 확인했습니다.

refac-bulk-performance

출처


Delete 해결책

이미 프로젝트에서 queryDsl를 사용하고 있어 이를 이용하는 것이 가장 간단했기 때문에 queryDsl의 delete in 쿼리를 사용하여 해결했습니다.


JPA

JPA에 대해서는 서로 어느 정도 이해하고 있어, 적절한 fetch join을 사용하여 코딩했었기에 N+1 문제는 발생하지 않았습니다.
하지만 연관관계에 대해서 문제가 있었습니다.
가장 좋은 연관관계 설계는 단방향을 기초로 하되 필요하면 양방향 설계를 하는 것입니다.
JPA 프로그래밍의 저자, 김영한 선생님의 의견을 빌리자면 다음과 같습니다.

양방향으로 하면 복잡도가 높아지는 단점이 있지만 성능상 이점을 얻을 수 있습니다.
정말 성능이 너무 중요해서 쿼리 하나를 줄이는게 꼭 필요한 상황이라면 복잡해지더라도 최적화를 해야합니다.
반면에 쿼리가 하나 더 나가더라도 시스템 자원이 충분해서 성능에 영향을 미치는 것이 미미하다면 코드 복잡도를 낮게 유지하는 것이 더 중요합니다.

refac-mapped

기존 코드에는 왼쪽과 같이 무분별한 양방향 관계가 존재했고, 리팩토링 과정에서 불필요한 양방향 관계를 모두 끊어내고 정리했습니다.


QueryDsl 성능 개선

exist 메서드 개선

refac-querydsl-exist

기본적으로 JPA에서 제공하는 exists는 조건에 해당하는 row 1개만 찾으면 바로 쿼리를 종료하기 때문에 전체를 찾아보지 않아 성능상 문제가 없습니다. 하지만 조금이라도 복잡하게 되면 메소드명으로만 쿼리를 표현하기 어렵기 때문에 보통 @Query를 사용합니다.
여기서 문제가 발생합니다. JPQL의 경우 select의 exists를 지원하지 않습니다.(where문의 exists는 지원) 따라서 count쿼리를 사용해야 하는데 이는 총 몇건인지 확인을 위해 전체를 봐야하기 때문에 성능이 나쁠 수 밖에 없습니다.
이를 개선하기 위해서 Querydsl의 selectOne과 fetchFirst(= limit 1)을 사용해서 직접 exists 쿼리를 구현했습니다.


Cross Join 회피

refac-querydsl-cross

queryDsl은 용빼는 재주가 있는 것이 아니고 그저 편리하게 query를 날려주는 도구일 뿐인데 너무 안일하게 코드를 작성한 것이 문제였습니다.
where 문에서 체이닝으로 타고 들어가기 때문에 cross join이 발생하게 되는데 이 부분은 join을 통해 cross join이 발생하지 않도록 수정했습니다.


조회컬럼 최소화하기

refac-querydsl-minimize

엔티티에 수정이 필요한 경우라면, Entity를 꺼내야겠지만 이외의 경우라면 굳이 Entity를 꺼낼 필요가 없습니다.
FK에 들어갈 Id가 필요한 경우라면 위와 같이 Id만을 가져와서 해당 엔티티를 새로 만들어 연관관계를 맞춰줄 수 있습니다.
실제로 DB에서는 FK인 Id값만을 요구하기 때문입니다.


refac-querydsl-minimize-dto

필요한 데이터가 명확하게 한정적이라면, 위와 같이 Member의 reward 총액 데이터값만 필요하다면 Member 엔티티를 꺼내서 찾는 것이 아니라, dto를 이용하여 필요한 데이터만 가져오도록 수정했습니다.


AOP

@AfterReturning(value = "@annotation(CheckLeader)", returning = "team")
public void checkTeamLeader(Team team) {
    team.checkNotLeader(team.getLeader(), Member.from(getLeaderUsername()));
}

리워드 적립, 권한 체크 등 횡단 분리가 가능한 로직들은 AOP로 분리했습니다.

테스트 코드

네이밍

테스트 함수의 이름을 카멜 케이스를 사용했습니다.
하지만 스네이크 케이스가 조금 더 가독성이 좋다고 판단하여 테스트 함수명을 스네이크 케이스로 수정했습니다.


상속

refac-test-extends

테스트에 필요한 중복적인 코드는 상속을 통해 여러 번 작성하지 않아도 되도록 했습니다.


Test Container

refac-testContainer

테스트에서는 H2 DB를 사용하지만, 실제 운영 DB는 MySQL를 사용하고 있기에 서로 문법이 100% 호환되지 않습니다.
H2를 사용할 경우, Bulk Insert 부분에서 쿼리가 정확히 나가는지 확인할 수 없었습니다.
Test 전체에서 MySQL Test Container을 띄워서 사용하기에는 수행 시간이 너무 오래걸리기에 호환되지 않는 문법에 한해서만 Test Container를 적용하여 테스트를 진행했습니다.


📌 성과 및 회고

본문 확인 (👈 Click)

앞서 기술적인 부분에 대해서는 모두 언급했고, 이 부분에서는 비개발적인 측면 에서 저 Backtony(최준성)만의 개인적인 의견을 작성하겠습니다.

이 프로젝트는 실패인가 성공인가?

프로젝트를 보는 관점에 따라서 다를 수 있겠지만, 프로젝트의 상업적인 관점을 기준으로 봤을 때는 '실패' 라고 볼 수 있습니다.
이유는 간단합니다. 배포를 진행할 계획이 없기 때문입니다.
이 프로젝트는 비즈니스 모델 상, 해당 서비스를 배포하기 전에 클래스를 개설해줄 디렉터를 사전에 모집해야 합니다.
이 부분은 개발의 영역과는 별개로 많은 시간이 소요될 것으로 예상됨과 더불어 나머지 두 팀원은 창업의 의사가 없기 때문에 저 혼자만의 힘으로는 진행하기 어려웠습니다.

하지만 개발자로서, 나의 성장 관점에서 보았을 때는 '성공' 이라고 생각합니다.
프로젝트를 진행하면서 다양한 회사에 계신 팀장급 혹은 CTO 개발자 분들, 비슷한 나이대의 뛰어난 신입 개발자분들을 만나면서 인사이트를 얻을 수 있었습니다.
기술적으로는 두말할 것도 없이 완벽한 경험이었습니다.
이 프로젝트를 시작하기 전의 저는 비전공자였고, 프로젝트 경험도 없이 Spring에 대해 기초 지식만 알고있는 수준이었습니다.
따라서 초기 프로젝트 구축 당시에는 Spring, JPA, Querydsl만으로 프로젝트를 구성했으나, 진행하는 과정에서 새롭게 필요한 기술들이 생기고 이에 대한 구축을 제가 담당하게 되면서 새로운 기술들을 익히게 되는 좋은 기회가 되었습니다.
Redis 클러스터 구축, 쿼리 성능 개선 등등 많은 경험을 했지만 그중 가장 인상 깊었던 구축은 AWS의 전체적인 환경 구축이었습니다.
팀이 프론트 1명 백엔드 2명으로 구성되어 있어 인원적으로 여유로운 상황이 아니었기에 한 명이 AWS 환경을 구축을 해야하는 상황이었습니다.
작업을 빨리 끝낸 제가 이 역할을 담당하게 되면서 구축을 진행하게 됐는데 총 3번을 갈아엎으면서 진행한 만큼 그 뒤에 얻는 뿌듯함도 컸습니다.


아쉬운 점

기획적으로..

팀 구성 당시 제가 여러 가지의 아이디어를 제시했고, 그중에서 하나를 선택해서 진행하자는 의견이 조율되면서 제가 기획자로서의 역할을 담당하게 되었습니다.
처음 진행해 보는 프로젝트였기에 아쉬움이 없을 수는 없지만, 아이디어 도출 단계에서 배포를 조금 더 신경 썼더라면 하는 아쉬움이 유독 남습니다.
3명이서 반년 동안 매일같이 만나 아침부터 저녁까지 함께 사무실에서 열심히 노력할 결과물이 배포되어 사용자들에게 평가를 받을 수 있었다면 더할 나위 없이 좋은 경험이 되었을 것 같습니다.
원래 SW 마에스트로를 시작하기 전부터 생각해왔고, SW 마에스트로에 합격하게 되면 꼭 하고 싶었던 주제가 있었는데 SW 마에스트로의 취지가 창업이었기에 수익모델이 불안정하여 우선순위가 밀린 아이디어가 있습니다. 다음에 SW 마에스트로 같이 프로젝트를 진행할 기회가 생긴다면 꼭 이 주제로 프로젝트를 진행해보고 싶다는 생각이 듭니다.


기술적으로..

사실 기술적으로는 정말 많은 경험을 했기에 큰 아쉬운 점은 없습니다.
굳이 뽑자면 프로젝트에 사용되었으나 내가 사용하지 않은 기술이나 사용에 미숙했다고 느껴지는 기술입니다.
ELK를 통한 로그 모니터링은 제가 구축하게 됬는데, ELK의 메인이 되는 검색 기술 구축에 있어서는 고범석 팀원이 담당하게 되었습니다.
프로젝트가 끝난 이후 시간이 된다면 공부할 생각은 있지만, 개인적으로 Elastic Search보다는 Spring의 기초적인 부분을 되돌아보고 다른 기술에 더 관심이 있어 그 기술들을 먼저 공부한 뒤 여유가 생긴다면 그때 공부하고자 합니다.
30분마다 작업이 수행되도록 해야하는 로직을 구현하는 과정에서 Spring batch와 Quartz를 사용하게 되었는데 Spring batch를 사용하면서 개인적으로 이해가 부족하다고 느꼈습니다.
Spring batch에 관해서는 아직 잘 정리된 자료가 없지만, 프로젝트가 끝나고 나서 꼭 다시 공부하고자 합니다.


프로젝트 이후 공부한 내용
바로 앞서 '기술적으로..' 부분에서 아쉬웠던 내용을 프로젝트가 끝난 이후에 공부하고 포스팅했습니다.


시연 영상

본문 확인 (👈 Click)

시연 영상
최종 발표 진행 중에 사용한 시연 영상입니다.


✍️ 프로젝트 종료 이후 혼자서 진행한 리팩토링

본문 확인 (👈 Click)

여기부터 작성하는 내용은 Backtony(최준성)가 프로젝트가 끝난 이후 혼자서 시도하는 내용입니다.
프로젝트가 끝난 후 공부한 내용을 적용하기 위해 시작합니다.
오랜기간 동안 팀원이 같이 진행해온 프로젝트이다 보니 혼자 진행하기에는 양이 너무 방대하여 제가 작성한 코드에 대해서만 적용해보려고 합니다.


테스트 코드 최적화

공통 로직 추상화 및 도메인 특화 언어(DSL)

클린코드 책에서 읽기 쉬운 코드를 강조하는 내용이 많았고 이를 적용하도록 노력했습니다.
서로 밀집한 코드 행은 세로로 가까이 배치시키고 코드를 읽기 쉽도록 명확한 이름의 함수로 로직들을 추상화시켰습니다.
공통된 부분은 추상화하여 중복을 제거하고 상속받도록 수정했습니다.
테스트 코드를 독자가 읽기 쉽도록 도메인 특화 언어(DSL)를 추가했습니다.
위의 모든 내용을 코드로 보이기는 어려우니 간단하게 DSL 부분만 코드로 보이겠습니다.

public class MemberDummy {

  public static Member createTestMember() {
    Member member = Member.builder()
            .username("testUser")
            .authority(Authority.ROLE_USER)
            .nickname("가나다")
            .name("testName")
            .phone("01000000000")
            .imageFileUrl("imageUrl")
            .profileText("profileText")
            .directorText("directorText")
            .age(25)
            .sex(Sex.M)
            .zone(ZoneDummy.createZone())
            .totalReward(3000)
            .build();

    member.setMemberCategories(Arrays.asList(CategoryDummy.createCategory()));
    return member;
  }
  
  ... 생략
}

매번 테스트를 하다 보면 Member를 생성해서 테스트 환경을 구성해야 하는 일이 많습니다.
테스트 보조 클래스를 만들어 정적 메서드를 정의하고 테스트 환경을 구성할 때 사용하여 중복 코드도 제거하고 독자가 읽기 쉽도록 구성했습니다.
이외에도 테스트에서만 사용하는 가독성 좋은 함수들을 만들어 사용했습니다.


Test Container 제거

H2 DB로는 Batch Insert 쿼리가 한 개만 나가는지 확인할 수 없어서 해당 테스트만 MySQL로 확인하기 위해 Test Container를 도입했었습니다.
하지만 실제로 쿼리가 한 개만 나가는것을 확인하는 작업은 로그로 일일이 찾아야 한다는 점에서 효율적이지 않다고 생각되었습니다.
또한, 컨테이너 뜨는 시간이 너무 오래걸리기 때문에 클린코드에서 제시하는 F.I.R.S.T에서 Fast에 어느정도 위반하는 것 같다고 느껴서 결과적으로 제거하게 되었습니다.

Application Context 재활용

현재 테스트 코드가 약 670개 정도 존재합니다.
테스트를 돌렸을 때 인텔리제이에 찍히는 시간은 약 50초였으며, 실제로 걸리는 시간은 3분 중후반에 걸쳐서 진행되었습니다.
이는 매번 테스트마다 Application Context를 새로 만들어 사용하기 때문에 생기는 문제였습니다.
ControllerTest 추상 클래스를 만들어 컨트롤러 테스트에 필요한 공통 부분을 넣어두고 컨트롤러 WebMvcTest는 이를 상속받도록 하여 Application Context를 재활용하도록 수정했습니다.
JpaTest도 마찬가지로 추상 클래스를 만들어 상속받도록 하여 Application Context를 재활용하도록 수정했습니다.
결과적으로 인텔리제이에 찍히는 테스트 시간은 30초 -> 20초, 실제로 걸리는 시간은 3분 50초 -> 50초 정도로 전에 비해 약 3분이 단축되었습니다.

결과적으로 3분이 단축되었지만 설계상으로는 고민이 아직 남아있습니다.

@WebMvcTest({
        BulletinController.class,
        SearchValidator.class,
        MemberCouponController.class,              
        ... 생략
})
public abstract class ControllerTest {

    @MockBean
    protected DirectorEditService directorEditService;

    @MockBean
    protected MatchingService matchingService;

    ... 생략
}

Application Context를 새로 띄우지 않고 재활용하기 위해 한곳에 몰아넣고 사용하다 보니 새로운 테스트를 추가할 때마다 해당 추상 클래스를 수정해야 했습니다. 즉, OCP 원칙을 위반하고 있습니다.
구글링 해본 결과 Controller 테스트의 경우 Spring을 사용하지 않고 Standalone으로 테스트하면 해결할 수 있다고 하여 시도해봤지만 validator에서 문제가 생겼습니다.
직접 만들어 사용하는 Validator는 Standalone으로 띄울 때 등록하면 되지만 Spring에서 제공하는 Validation은 테스트할 수 없게 되었습니다.(@NotBlank, @NotNull 같은 검증 애노테이션)
이렇게 되면 같은 맥락의 Validation 로직인데 테스트를 또 분리해서 관리해야 하는 번거로움이 생기게 됩니다. 이 부분은 조금 더 고민해야할 부분인 것 같습니다.


예외 코드 구조 변경

new MemberException(MemberErrorCodes.MEMBER_NOT_FOUND)

기존 예외처리 방식은 Member 관련 예외라면 모두 MemberException으로 처리하고 Enum으로 ErrorCodes를 만들어 위와 같이 사용했습니다.
클린코드 책에서는 이것을 의존성 자석 문제(무분별한 import)라고 정의하고 OCP를 위반하는 코드라고 설명하고 있었는데, 딱 제가 이렇게 사용하고 있었습니다.
따라서 전체적은 예외처리 구조를 변경했습니다.(제가 작성한 코드만 수정하려고 했으나 이 문제는 너무 방대하게 걸쳐져 있어서 전체를 수정하는 작업을 진행했습니다.)
Member관련 예외 처리 코드만 예시로 작성하겠습니다.

@Getter
public abstract class ApplicationException extends RuntimeException {

    private final String errorCode;
    private final HttpStatus httpStatus;
    private BindingResult errors;

    protected ApplicationException(String errorCode, HttpStatus httpStatus, String message) {
        super(message);
        this.errorCode = errorCode;
        this.httpStatus = httpStatus;
    }

    protected ApplicationException(String errorCode, HttpStatus httpStatus, String message, BindingResult errors) {
        super(message);
        this.errorCode = errorCode;
        this.httpStatus = httpStatus;
        this.errors = errors;
    }
}
--------------------------------------------------------------------------------------------------------------
public abstract class MemberException extends ApplicationException {
    protected MemberException(String errorCode, HttpStatus httpStatus, String message) {
        super(errorCode, httpStatus, message);
    }

    protected MemberException(String errorCode, HttpStatus httpStatus, String message, BindingResult errors) {
        super(errorCode, httpStatus, message, errors);
    }
}
--------------------------------------------------------------------------------------------------------------
@Getter
public class MemberNotFoundException extends MemberException{

    public static final String MESSAGE = "탈퇴했거나 존재하지 않는 회원입니다.";
    public static final String CODE = "MEMBER-401";

    public MemberNotFoundException() {
        super(CODE, HttpStatus.UNAUTHORIZED, MESSAGE);
    }
}

RuntimeException을 상속받아 비즈니스 로직 예외로 사용할 ApplicationException 추상 클래스를 만들었습니다.
이를 각각의 영역별로 상속받습니다.(MemberException, NotificationException ....)
그리고 해당 영역안에 예외는 이를 상속받아 명확한 이름을 갖는 예외로 만듭니다.(MemberNotFoundException, MemberNotAdminException....)
최종적인 예외 처리는 ControllerAdvice를 통해 처리합니다.
결과적으로 예외를 사용하는 곳마다 Enum의 패키지가 무분별하게 import되는 의존성 자석 문제를 해결하였으며, 새로운 예외가 생길 때마다 ErrorCodes에 추가하는 문제(OCP 위반)를 해결하였습니다.

SecurityUtils 통합

// 기존 코드
public class DirectorLectureServiceImpl implements DirectorLectureService {
    
    ... 생략
    
    private String getCurrentUsername() {
        ...
    }
}

기존에는 현재 유저의 정보를 가져오는 기능이 필요한 곳 마다 private 메서드로 만들어서 사용하고 있었습니다.
즉, 여러 곳에서 중복되는 코드로 사용하고 있었습니다.

@Component
@RequiredArgsConstructor
public class SecurityUtil {

  public Member getCurrentUserOrThrow(){
    ...
  }

  public Optional<String> getCurrentUsername() {
    ...
  }

  public Optional<Authority> getAuthority(){
    ...
  }
}

이를 SecurityUtils 클래스의 기능으로 만들고 현재 유저의 정보를 가져오는 기능은 모두 이 클래스를 사용하도록 하여 중복 코드를 제거했습니다.


페이징 쿼리 성능 개선 - NoOffset

@Repository
@RequiredArgsConstructor
public class NotificationQueryRepositoryImpl implements NotificationQueryRepository {

    private final JPAQueryFactory query;

    @Override
    public Slice<NotificationDto> findNotificationDtoListPaginationNoOffsetByUsername(String username, Long lastNotificationId, Pageable pageable) {

        List<NotificationDto> content = query
                .select(new QNotificationDto(
                        notification.id.as("notificationId"),
                        notification.title,
                        notification.message,
                        notification.checked,
                        notification.notificationType,
                        notification.uuid,
                        notification.TeamId,
                        notification.createdDate
                ))
                .from(notification)
                .join(notification.member, member)
                .where(ltNotificationId(lastNotificationId),
                        notification.member.username.eq(username))
                .orderBy(notification.id.desc())
                .limit(pageable.getPageSize()+1)
                .fetch();

        return RepositorySliceHelper.toSlice(content,pageable);
    }

    private BooleanExpression ltNotificationId(Long lastNotificationId) {
        if (lastNotificationId == null){
            return null;
        }
        return notification.id.lt(lastNotificationId);
    }
}

기존에는 Slice 기반 페이징 쿼리에서 Offset을 사용하고 있었습니다.
데이터가 많아질수록 페이징 쿼리에 Offset을 설정하게 되면 쿼리 성능이 나빠지는 것을 확인하고 Offset을 사용하지 않는 방식으로 쿼리를 수정하여 성능을 개선했습니다.
공지사항과 같이 페이지 번호가 필요한 경우에는 NoOffset으로 개선이 불가능하여 커버링 인덱스 방식을 고려했으나, 애초에 페이지 수도 적고 불필요한 인덱싱 작업이 필요하여 과도한 리팩토링이라 생각하여 이 부분은 진행하지 않았습니다.
Querydsl 최적화 관련 내용은 따로 정리하여 포스팅 했습니다.


경계 조건 캡슐화

if문의 조건으로 사용되는 코드가 복잡한 경우 함수로 만들어 가독성 좋게 수정하였습니다.

public class MatchingServiceImpl implements MatchingService {
    
  @Override
  @CheckIsAlreadyMatching
  public void matching(MatchingRequest matchingRequest) {
    ...

    // 기존 코드
    if (matchingMemberList.size() != matchingRequest.getPreferMemberCount() - 1) {
        ...
    }
    
    // 수정한 코드
    if (canMatch(matchingRequest, matchingMemberList)) {
        ...
    }
  }
}

일반 유저와 디렉터의 편집 로직 분리

일반 유저의 편집 로직과 디렉터의 편집 로직을 하나의 컨트롤러와 서비스에서 관리하고 있었는데 이는 해당 코드를 변경해야 하는 이유가 2가지가 된다고 생각이 들었습니다.(SRP 위반)
따라서 둘의 로직을 분리했습니다.


정적 팩터리 메서드

이 부분은 이펙티브 자바를 읽고 적용한 내용입니다.
기존 코드에서도 이미 of와 의미있는 이름을 갖는 정적 팩터리 메서드를 사용하고 있었으나 책을 읽으면서 규약중에 from이 있다는 사실을 처음 알았습니다.
따라서 from과 of를 구분해서 수정했습니다.


PATCH, PUT 구분

수정 요청에 대부분이 무분별하게 @PutMapping 으로 구성되어있었는데 이를 PATCH와 PUT을 구분하도록 수정하였습니다.


gson 제거

gson을 제거하고 내장되어있는 jackson 사용으로 변경했습니다.


로그인

프로젝트 초기 시점에 로그인 파트를 담당해서 구축했었는데 그 당시에는 지식이 부족해서 아쉬운 점이 많았습니다.
이 부분은 현재 프로젝트에서 수정하기보다는 새롭게 만들어 여러 소셜 로그인을 지원하도록 확장성 있게 구현해 보았습니다.
해당 코드는 여기를 확인 부탁드립니다.


About

SW 마에스트로 메인 프로젝트


Languages

Language:Java 58.0%Language:TypeScript 40.1%Language:CSS 1.5%Language:Objective-C 0.1%Language:HTML 0.1%Language:Vim Snippet 0.1%Language:JavaScript 0.0%Language:Ruby 0.0%Language:Shell 0.0%Language:Dockerfile 0.0%Language:Starlark 0.0%Language:Swift 0.0%Language:C 0.0%