본문 바로가기
HANDS-ON PROJECTS/BACK-END DEV

[BACK-END DEV] Optimized SNS application for Huge Traffic Handling by using Redis, SSE and Kafka

by junthetechguy 2024. 6. 26.
 

TABLE OF CONTENTS

 

    1. Planning project requirements

    백엔드의 개발 시작은 일단 Wireframe(화면기획서; 화면과 함께 기획자가 원하는 요구사항을 정리한것으로 서비스 전체 기능이 담김) & Storyboard를 받은 후 이 화면 기획서에서 어떤 기능의 API가 필요할지 분석해야 한다. 

    즉, 개발 협업 및 안정적인 방향의 코딩 지향 : 화면 기획서와 User Story Board를 분석한 기능 요구사항으로 필요한 API를 listup하여 작성한 Sequence Diagram으로 발생할 수 있는 모든 상황들을 Mocking하여 Controller단과 Service 단에서 발생할 수 있는 모든 Response Status와 Response Code를 담은 ErrorCode를 Enum Class로 미리 만들어 두고, 이것을 잡아서 던지는 Global Advice부터 먼저 구현 후 Controller와 Service 개발을 마친 후 Postman과 localhost web browser로 먼저 API test를 진행한 후 해당 Test case들을 통과시키는 TDD 방식으로 전반적인 프로젝트 진행

     

    우선 첫번째 Page인 Feed Page부터 살펴보자.

    그러면 이러한 Feed Page에 대충 어떤 정보가 들어가 있는지를 확인했으면(response로 내려줄 정보들) 백엔드 개발자로서 언제나 이런 류의 List형 Page에서 반드시 고려해야할 점이 있다.

    그건 바로 “Paging 처리”이다. 즉, 이렇게 리스트를 반환하는 api의 경우 반드시 페이징 처리가 되어서 한번에 전체를 가지고 오는 것이 아니라 일정 개수별로 끊어서 보여 줘야 한다.

    그래야 서버에 부하가 덜 가므로 안정적인 서비스 운영이 가능하다. 만약 페이징 처리가 안된다면 api request가 일어날때마다 모든 유저의 글을 한번에 다 가져오므로 서비스가 장애가 나므로 안된다.

    위의 화면 기획서에는 페이징 처리가 보이지 않는데 이 부분은 아래와 같은 기준으로 프론트와 협의를 해야한다.

     

    다음은 Post를 작성하는 Write Post Page이다.

     

    다음은 회원가입(sign up) 페이지

    다음은 로그인(sign in) 페이지

    다음은 mypost 페이지

    그러면 지금까지 화면기획서들을 보면서 각 화면에서 분석한 requirements들을 바탕으로 필요한 api들을 listup 해보자.

    이제 이러한 API listup으로 필요한 System Architecture를 Design 해주자.

     

    이제 Service 전체의 Architecture를 설계하자

    DB를 선택할때는 개발하고자 하는 Application과 가장 잘맞는 DB의 특징에 맞게 선택해야함

    나는 이번 프로젝트에서 데이터들 간의 관계가 명확하고(User A가 작성한 Post), 질의도 제대로 들어가게 되는(User별로 작성한 포스트 가져오기나 정렬 등) 서비스 요구사항이 존재하므로 RDBMS를 선택한다.

    그 중에서도 PostgreSQL을 선택하자.

     

    그러면 이제 회원가입과 로그인시에 어떤 기술을 활용하여 구현할지 생각해보자.

    이때는 언제나 인증과 인가 방식에 대해 고려를 해줘야 한다.

    Authentication은 그냥 하면 되는거고, Authorization을 할때 매 reqeust마다 어떻게 Authorization을 할지는 아래의 2가지 방법이 존재한다.

    하여 Authorization은 매 Request마다 Header에는 JWT를 명시하고, Payload에는 해당되는 데이터를 적어서 Authroized를 시킨 후 권한에 맞게 동작시키도록 Spring Security를 사용하자.

     

    그러면 아래와 같이 Architecture가 발전한다.

     

    이번에 사용할 프로젝트 기술 스택

    Java와 Spring으로 가장 간단하게 프로젝트를 구성할 수 있는 방법은 Spring Initializer를 사용하는 것이다.

     

    일단 먼저 github repo를 만들어주자. 이후 GitKraken을 다운로드 받고, CLI로 깃헙 레포를 clone을 하지 말고 GitKraken이라는 GUI로 Clone을 해주자.

    이제 IntelliJ로 해당 프로젝트 폴더를 열어주자.

    이후 Spring Initializer로 들어와서 Project로 Maven Project말고 Gradle Project로 설정하고, Language는 Java로 설정하고, Spring Boot는 일단 2.7.0으로 선택한다(추후에 직접 2.6.7로 수동으로 intelliJ에서 변경해주자)

    그리고 Project Metadata 설정은 아래와 같이 설정해주자.

    또한 Dependency 부분은 추후에 프로젝트를 진행하면서 설정해도 되지만 기본적인 Dependency들은 미리미리 설정해주도록 하자.

    Spring으로 Web Application을 만드므로 Spring Web(이 안에 Spring MVC가 포함됨 & Tomcat도 이 안에 embedded container로 포함됨)을 추가하고,

    Java에서 직접 코드를 작성하지 않아도 Annotation으로 설정할 수 있는 library인 Lombok을 추가해주고,

    AA를 구현할 Spring Security를 추가하고,

    DB로 PostgreSQL을 사용할 것이므로 PostgreSQL Driver를 추가하고,

    JPA를 사용할 것이므로 Spring Data JPA도 추가해주자.

    그렇게 프로젝트를 다운로드 받은 후 sns project 폴더에 복붙해주자.

    이후 IntelliJ에서 Gradle로 해당 프로젝트 전체를 초기 build를 진행하면서 gitignore file을 먼저 작성해주자.

    git에 올라가면 안되는 파일들에는 우선 나의 IntelliJ의 custom한 설정을 담은 파일과 local에서만 사용할 파일들이 있다. 하여 gitignore file에 등록을 해줘서 추후에 Github에 올라갈때 그런 파일들이 변경이 되었다 하더라도 이게 실제로 feature나 서비스에 필요한 변경이 아니므로 이러한 변경들이 올라가지 않게 된다.

    따라서 gitignore.io 사이트에 들어가서 IntelliJ 관련 설정들과 Gradle 관련 설정들과 Java관련 설정들을 아래와 같이 추가해서 gitignore을 만들어주자.

     

    이후 sns project root 하단에 .gitignore을 만들어서 복붙해줘서 IDE 아래에 있는 설정들이 전부 다 Ignore가 되어서 Github에 올라가지 않도록 해주자.

     

    이후 psvm 부분을 Run을 시켜보자.

    난 맨처음에 정상적으로 프로젝트 JDK 버전과 내 IDE JDK 버전이 동일한지 체크를 하는 부분에서 오류가 났으므로 Project Structure 부분에서 IDE JDK 버전을 내 프로젝트 JDK 버전으로 수정하고,

    또 Run을 시키면 DataSource URL이 없다고 나오는데 이는 Spring data jpa가 gradle에 설정되어 있으면 자동으로 DB 연결을 하려고 하는데 연결될 DB의 URL이 없으므로 이를 일단 해결하기 위해서 psvm main class에 아래와 같이 datasourceautoconfiguration 하는 부분을 exclude로 설정해주자.

     

     

     

    그리고 build.gradle 파일에서 springboot 버전 부분도 2.6.7로 바꿔준 후 psvm run을 하자.

     

    그러면 Tomcat이 8080으로 제대로 뜨게 된다.

    그리고 localhost:8080으로 접속하면 화면이 아래와 같이 뜨게 되는데

     

    그 이유는 spring security를 설정해놨으므로 모든 path에 대해서 login을 하라고 설정이 자동으로 이루어지게 된다(모든 Path에 대해서 일단 막아놓는게 spring security의 초기 default 설정이다).

     

    그리고 지금까지 진행한 작업은 프로젝트의 초기 설정이므로 따로 branch를 따지 않고 main branch에다가 push를 해줘서(커밋 메시지 ⇒ init: “initalize project”) 앞으로 진행될 모든 브랜치가 이 초기 설정을 따르도록 해주자.

     

     

    이번 프로젝트에서는 프론트엔드 코드도 함께 빌드가 되도록 하자.

    즉, 지금 내 프로젝트를 psvm run을 하게되면 하나의 applicaiton이 뜨게 되는데

    이때 프론트엔드 코드도 하위의 build/resources/main/static에 넣어서 같이 빌드해서 Tomcat WAS로 내 Backend Application을 띄울때 프론트엔드도 함께 띄운다는 의미(즉, nginx(web server) 없이 프론트엔드 코드의 build 결과물을 그냥 바로 WAS에다가 띄워버림)이므로 프론트엔드 코드를 내 프로젝트 하위로 복사해주자. 또한 그러기 위해서는 아래의 plugin이 필요하다. 이 node-gradle plugin은 Node-based의 코드들을 gradle로 build할 수 있는 기능을 가진 plugin이다.

     

    이후 build를 위한 gradle task들을 작성해주자.

     

    먼저 nodemodules Directory 위치 명시(=node_modules를 설치할 장소)와 node version 정보 명시 등의 Node 설정을 해준 후 npmBuild(React로 작성된 프론트엔드 코드를 npm으로 build를 하게 되는 명령어인 run build)를 할 수 있는 Task, 필요한 Frontend Code들을 실제 build가 되게 되면 나오는 build 파일들이 들어가는 static을 내 스프링 프로젝트의 static 폴더로 copy해서 넣어주는 copyFrontEnd Task, 그리고 clean이 동작했을 때 FrontEnd code와 프론트엔드에 있는 Node 모듈들도 함께 clean을 해줄 수 있는 cleanFrontEnd Task를 작성한 후

     

    이러한 Task들을 Java Compile Task 안에 Dependent로 걸어서 Java가 Compile 될때 이러한 작업들이 Chaining이 되는 순서로 진행이 되게 해주자. 또한 Java Clean Task가 실행이 될때 Frontend Code도 clean이 되도록 하는 dependent 설정을 해주자.

     

    그리고 이제 테스트를 할건데 지금 spring security가 걸려있어서 매 reqeust가 Authentication을 받게 되므로 login form이 뜨게 되는데 지금은 프론트엔드 테스트를 하기 위해서 이걸 먼저 build.gradle에서 먼저 block(주석처리)를 해준 후 프론트엔드 코드가 제대로 뜨는지(localhost:8080) 확인해주자.

     

    이후 Heroku(이번 프로젝트에서 사용할 Deployment Tool) 설정을 해주자.

    이제 Github Actions를 이용해서 Heroku 배포 시나리오를 만들어주자.

    위에서 deploy.yml을 다운로드 받아서 내 local project에 /.github/workflows directory를 만들어준 후 여기에 붙여주자.

    이 놈의 정체는 배포 시나리오로 main branch에 push가 되었을때 이 시나리오가 trigger가 되도록 한 후 jobs에는 아래의 사진속 정보들로 heroku library를 사용해서 배포를 하게 된다.

     

     

    그리고 Heroku 배포용 설정을 추가해주기 위해서 Procfile과 system.properties(Heroku에서 사용할 Java Runtime의 version 명시) 파일을 내 Project에 넣어주자.

    이것은 Heroku에서 Application이 떳을때 어떤 명령어를 실행할지를 지정하는 것이 Procfile이기 때문에 이것을 Procfile에 넣어주자. 아래 보이는 이름은 setting.gradle에 들어있던 rootProject의 name이고, 그 뒤에는 version 정보로 이 부분은 build.gradle에 따로 설정할 수 있다.

     

     

    이것은 java application을 jar file로 말아 놓은 것을 Heroku에서 띄우면서 미리 jar file에 설정된 default port와 default Java Option으로 이 application process로 돌리도록 선택하면서 이 application(.jar)을 실행하는 명령어로 설정해주자.

     

    실제로 이 프로젝트를 build를 해주게 되면 /build 부분에 아래와 같이 java applicaiton runtime인 jar file이 생기게 된다.

    이때의 저 build된 jar 파일의 이름은 settings.gradle의 rootProject.name부분에 설정이 되어 있는 이름과 build.gradle에 명시되어 있는 version 정보를 합쳐서 만든 이름이다.

     

    그리고 system.properties에는 내가 사용하는 java버전을 java.runtime.version=11로 명시해줘야 한다. 이렇게 java 버전을 명시를 해줘야 Heroku에서 문제가 발생하지 않음.

     

    이제 git push를 해주게 되면 되는데 지금 변경점으로 잡히는 부분이 아래와 같이 보게 되면 node_ moudles 부분과 static 부분(frontend의 static 부분)이 모두 다 들어가게 되는데 이거는 어차피 클린을 하게 되면 다 사라지는 부분이므로 혹시 클린을 하지 않았을 경우에도 들어가지지 않도록 하기 위해서

    이 부분을 gitignore에 아래와 같이 추가를 해주자.

    이후 git push를 해주자.

    그러면 이제 GitHub Actions가 deploy.yml(이름은 상관 없음; 그냥 .github/workflows에 들어있는 yml 파일을 Github Actions가 가져다가 workflow로 돌린다)에 적힌 deploy 시나리오에 맞게 동작을 하게 되고 이후 Heroku에서도 똑같이 Build가 일어나게 되고(실제로는 이 Heroku는 EC2 Instance가 내부에 존재함)

    완료되면 heroku에서 부여한 URL로 접속할 수 있게 됨.

     

     

    2. Developing basic SNS feature : Sign-up, Sign-in and Post CRUD Feature

    이번 프로젝트에서는 TDD라는 개발 방법을 사용하여 프로젝트를 진행한다. TDD란 요구 사항으로 작성한 시퀀스 다이어그램에 맞춰서 Test를 먼저 작성한 후 이를 통과하기 위한 최소한의 코드를 작성하여 테스트 코드를 작성한 후 그 다음 요구사항에 맞춰서 제대로 동작할 수 있도록 추가적인 스펙을 구현하면서 코드를 리팩토링을 한 후 Postman과 GUI(DB Workbench, Web Browser 등)로 API test가 정상 동작하는지 확인 후 이 테스트 코드를 Run을 하여 통과시키는 방식으로 짧은 개발 cycle을 계속 반복하면서 개발을 진행하는 개발 방법론이다.

    장점은 테스트를 작성함으로서 Debugging 시간이 단축되고 추가적인 spec 구현을 할때 앞서 작성한 test code가 있으므로 보다 안정적으로 리팩토링(추가 구현)을 할 수 있게 된다.

    단점은 테스트 양이 많고, 테스트 코드가 먼저 선행이 되어야 하므로 생산성이 저하될 수 있다.

    TDD의 Test Code의 모든것

    1. Controller 단 Test => 해당 Controller Method에서 사용되는 Service method의 순서대로(Repository 전까지) 진행하면서 발생할 수 있는 문제점들에 대해서 제대로 내가 설정한 Response Status가 뜨는지 검증하는 것 -API를 Test 할 것이므로 @AutoConfigureMockMvc를 달아준다. -Controller 단 Test에서는 MockMvc와 ObjectMapper만을 @Autowired로 받아온다. -해당 Controller에 매칭되는 Service만을 @MockBean으로 받아온다. -항상 controller test code는 throws Exception을 해야한다. -Service method의 순서대로(Repository 전까지) 진행하면서 발생할 수 있는 모든 상황에 대해서 when -> then(return type이 void가 아닐시), doThrow(return type이 void일시)로 Mocking을 해준다. -이후 mockMvc.perform으로 API를 보내면서 발생할 수 있는 Expect status를 적어두자.
    2. Service 단 Test => 해당 Service Method에서 사용되는 Repository method의 순서대로 진행하면서 발생할 수 있는 문제점들에 대해서 제대로 내가 설정한 Response Code가 뜨는지 검증하는 것이다 -API를 Test 할 것이 아니므로 @AutoConfigureMockMvc를 달아줄 필요가 없다. -Service 단 Test에서는 해당되는 Service만을 @Autowired로 받아온다. -해당 Service에 매칭되는 Repository만을 @MockBean으로 받아온다. -항상 service test code는 throws Exception을 할 필요 없다. -Repository method의 순서대로 진행하면서 발생할 수 있는 모든 상황에 대해서 when -> then(모든 Repository는 Return type이 void가 무조건 아니므로 Service 단 Test에서는 when -> then으로만 한다) Mocking을 해준다. -이후 Assertions으로 Exception을 받아와서 내가 예상하는 Errorcode가 맞는지 확인해주자.
    3. fixture -mocking을 할때 mock(*.class)로 mocking을 전부 다 하면 좋겠지만 Test logic 상에서 만약 필드를 초기화 하는 등의 동작이 들어가게 된다면 Test Code가 길어져서 Readability가 떨어지므로 그냥 fixture로 만들어두고 해당 class를 get하는 방식으로 진행한다.
    4. Controller Test Code Naming : 성공하는 경우 + ~하는 경우 에러 반환 Service Test Code Naming : ~하는 경우

     

    어쨋거나 이번 프로젝트에서는 기능 요구사항을 분석한 sequence diagram으로 먼저 test code를 작성한 후 이것이 통과되도록 코드를 작성하면서 TDD를 경험해보도록 하자.

    이제 테스트 코드를 작성해주자(테스트 코드 작성은 언제나 Controller Test → Service Test의 순서로 작성한다)

    항상 test 패키지와 main 패키지는 패키지 명이 동일해야함.

    우선 controller test부터 작성해주자.

    회원가입과 로그인 관련 테스트를 작성할 것이므로 UserControllerTest라는 이름으로 먼저 controller package에 만들어주자.

     

    그러면 이제 회원가입 테스트의 작성은 끝난 셈이므로 이제 로그인 테스트를 구현해보자.

    정상적으로 login이 되는 경우와 아이디가 존재하지 않는 경우와 패스워드가 틀린 경우에 대한 테스트 코드를 각각 작성 한다.

     

    이제 db를 연결시키기 위해서 main class(psvm이 존재하는 class)에서 exclude = DataSourceAutoConfiguration.class 부분을 지운 후 resources package의 application.properties 부분에 db 관련된 설정들을 넣어주자. 먼저 그 전에 applicaiton.properties는 hierarchy 형태의 configuration이 지원이 안되므로 application.yaml로 이름을 변경 후 사용하도록하자.

     

    우선 spring.jpa의 database로 postgresql로 설정하고, hibernate.dialect로 내가 사용하는 jpa method(save 뭐 그런 메소드들)를 실제로 어떻게 sql로 변환시킬지가 dbms마다 다르므로 이번에 사용하는 postgreSQL에 적합한 sql로 변화하도록 hibernate의 org.hibernate.dialect.PostgreSQLDialect library를 사용하자.

     

    hibernate.ddl-auto 같은 경우에는 application이 뜰때 ddl을 자동으로 날려서 내 entity에 맞는 테이블을 자동으로 생성하게 되는 update 옵션을 주자. 물론, 이는 실제 prod 환경에서는 코드에 실수가 있는데 그게 ddl까지 반영되는 경우가 존재할 수도 있어서 절대로 prod 환경에서는 쓰면 안된다.

     

    prod 환경에서는 ddl을 따로 작성해서 ddl 쿼리를 날린 후 이 table이 실제 applicaiton의 entity 코드와 맞는지 validate하는 옵션을 줘서 사용한다. 근데 여기서는 그냥 편하게 update로 써주자.

     

    format_sql은 sql을 이쁘게 나오게 해주는 거고, show-sql은 log부분에 application Runtime동안 사용되는 sql이 나오게 해주는 설정이다.

     

    spring.datasource 부분에는 hikari의 maximum pool size를 설정해서 미리 DBMS와 connection을 맺어 놓도록 하고, heroku에 만들어놓은 내 application에 add-on으로 postgresql을 add한 다음에 이에 대한 메타데이터를 긁어와서 나머지 설정을 채워두자.

    이제 이렇게 만든 코드를 띄워서 API Test를 할건데 API Test tool로는 postman을 사용하도록 하자. 물론 그전에 frontend code를 build하고 clean하도록 설정한 build.gradle 부분은 주석처리 후 build를 시켜주자. 왜냐하면 어차피 Tomcat WAS만 떠도 API Test는 진행할 수 있기 때문이다.

     

    우선 현재 , DB에 PW가 encrypt가 안되고 바로 하드데이터가 들어가게 된다.

    따라서 PW 암호화를 해결하자. configuration package를 하나 만들어준 후 이 안에 SecurityConfiguration class를 만들어주자. 그리고 여기서 BCryptPasswordEncoder를 Bean으로 띄우면 UserService Class에서 private final로 이 bean을 받아다가 PW를 encrypt encoding하는 부분을 service logic에 추가해주자.

    또한 이제 login하는 부분을 UserService에 login method로 구현을 하고, response 부분에 이렇게 로그인된 후 내려보내는 UserLoginResponse 부분을 정의해주자. 이때는 토큰이 내려가게 되는데 이러한 토큰 생성은 util package를 만들어서 JwtTokenUtils class를 만들어주자. 또한 언제나 Util 성 변수, 메서드는 항상 public static으로 설정하므로 JwtTokenUtils에서 token을 만드는 부분 method를 public static으로 생성해주자. 먼저 그전에 Jwt 관련 library를 build.gradle에 선언을 해주자. 이때 필요한 library 버전은 MVNrepository에서 jjwt로 검색해줘서 넣어주자.

    또한 UserService에서 토큰 생성하는 부분에 들어가는 데이터들은 필요에 따라서 나중에 변경할 수 있도록 하드 코딩으로 정의하지말고 config로 따로 빼기 위해 application.yaml에 설정해주자.

     

    이후 postman으로 API Test(Postman UI와 DB Workbench로 확인)를 한 다음 test code(항상 Test Code는 Controller Test Code -> Service Test Code의 순서로 돌림)를 돌려서 성공시키자.

     

     

    그러면 일단 controllertest를 작성해주자. PostControllerTest class로. 그러면서 request package에 post request가 날라오는 형태인 PostCreateReqeust class를 만들어주자.

    이후 PostServiceTest도 당연히 만들어주자. 그러면서 PostService class도 당연히 service package에 만들어주자. 그러면서 PostEntityRepository도 repository package에 만들어주자. 그리고 postentity도 역시 entity package에 만들어주자.

    이후 controller package에 PostController를 만들어주고 AuthenticationConfiguration 부분에서 필터를 하나 만들어서 reqeust로 들어온 Token이 어떤 유저를 가리키는지 체크하는 로직을 짜보자. 이를 위해서 일단 configuration package 아래에 filter라는 package를 만든 후 이 안에 JwtTokenFilter를 만들어주자. 또한 exception 부분에 CustomAuthenticationEntryPoint를 만들어서 authenticationentrypoint에서 Token이 Invalid하다는 error code가 response로 내려오도록 해주자.

    이후 Tomcat WAS에 해당 백엔드 Application을 띄우고 API test를 진행하면서 정상적으로 동작이 되도록 수정한 다음 테스트 코드(PostControllerTest → PostServiceTest)가 다 통과하는지 확인하자.

     

    PostControllerTest와 PostServiceTest에 수정하는 테스트를 추가하고, request package에 PostModifyRequest를 만들어주자. 또한 이때 가짜 PostEntity를 만들어주기 위해 PostEntityFixture를 만들어주자.

    이후 Tomcat WAS에 해당 백엔드 Application을 띄우고 API test를 진행하면서 정상적으로 동작이 되도록 수정한 다음 테스트 코드(PostControllerTest → PostServiceTest)가 다 통과하는지 확인하자.

     

    PostControllerTest와 PostServiceTest에 삭제하는 테스트를 추가해주자.

    이후 Tomcat WAS에 해당 백엔드 Application을 띄우고 API test를 진행하면서 정상적으로 동작이 되도록 수정한 다음 테스트 코드(PostControllerTest → PostServiceTest)가 다 통과하는지 확인하자.

     

     

    화면 기획서를 보게 되면 2가지 타입의 피드목록이 존재한다.

    그냥 바로 모든 유저의 피드목록을 보여주는 피드목록과 My Post 피드목록

     

    당연히 PostControllerTest와 PostServiceTest에 피드목록을 추가해주자.

    이후 Tomcat WAS에 해당 백엔드 Application을 띄우고 API test를 진행하면서 정상적으로 동작이 되도록 수정한 다음 테스트 코드(PostControllerTest와 PostServiceTest)가 다 통과하는지 확인하자.

     

     

    그러면 이제 프론트엔드와 제대로 연결되는지 확인을 하기 위해서 build.gradle에서 block 처리해뒀던 부분을 풀어주도록 하자.

    이후 Tomcat WAS를 띄워서 8080(Local Web Browser)에서 정상적으로 API 호출이 진행되어 내가 개발한 서비스 로직이 정상적으로 동작하는지 확인해보자.

    3. Developing advanced SNS feature : Like, Comment and Alarm Feature

    지금까지만으로도 일단 기본적인 sns application의 기본 동작들(회원가입/로그인, 포스트 작성, 포스트 수정, 포스트 삭제, 피드 목록)은 다 동작하게 되지만 추가적으로 이 sns application을 발전시켜보자.

     

    새로운 화면 기획서를 받았다고 가정하자.

    그럼 위에서 어떤 요구사항이 있을지 분석해보자.

     

    그러면 이를 바탕으로 어떠한 API가 필요한지 listup해보자.

     

    이제 이러한 API listup으로 필요한 System Architecture를 Design 해주자.

     

    전반적인 Architecture는 그대로 가지고 가되 PostgreSQL에 들어가는 Data 부분이 좀 더 추가가 된다.

     

     

    PostControllerTest 부분에 좋아요 기능 테스트 케이스를 추가해주자.

    PostServiceTest 부분에도 실전이라면 테스트 케이스를 추가해줘야 하지만 귀찮으므로 그냥 여기서는 PostControllerTest 부분에만 테스트 케이스를 추가해주자. 실전에서는 당연하게 PostServiceTest에도 서비스의 테스트 케이스를 추가해줘야만 한다.

    2가지 API를 개발한다. like 누르면 counting이 이루어지는 API와 총 like 수를 가지고 와서 뿌려주는 API

    또한 이때 이를 위해서 LikeEntityRepository class와 LikeEntity class를 만들어주자.

    이후 build 과정에 프론트엔드가 있으면 오래걸리므로 block처리를 해준 후 API Test를 완료해준 다음 해당 Test code를 모두 동작시켜 통과가 되는지 확인해보자.

     

    PostControllerTest 부분에 댓글 기능 테스트 케이스를 추가해주자.

    PostServiceTest 부분에도 실전이라면 테스트 케이스를 추가해줘야 하지만 귀찮으므로 그냥 여기서는 PostControllerTest 부분에만 테스트 케이스를 추가해주자. 실전에서는 당연하게 PostServiceTest에 서비스의 테스트 케이스를 추가해줘야만 한다.

    또한 이때 이를 위해서 reqeust package에는 이러한 comment 관련 request인 PostCommentReqeust class와 comment 관련 response인 CommentResponse class를 만들어주자. CommentEntityRepository class와 CommentEntity class와 DTO 용도로 model package에 Comment라는 class를 만들어주자.

    이후 API test로 API 동작 검증을 완료 후 test code를 run을 시켜 정상적으로 테스트가 진행되는지 확인해보자.

     

     

    UserControllerTest 부분에 알람 기능 테스트 케이스를 추가해주자.

    UserServiceTest 부분에도 실전이라면 테스트 케이스를 추가해줘야 하지만 귀찮으므로 그냥 여기서는 UserControllerTest 부분에만 테스트 케이스를 추가해주자. 실전에서는 당연하게 UserServiceTest에 서비스의 테스트 케이스를 추가해줘야만 한다.

    이후 API test로 API 동작 검증을 완료 후 test code를 run을 시켜 정상적으로 테스트가 진행되는지 확인해보자.

    이후 프론트엔드 block 처리를 풀어준 후 WAS를 띄워서 전체 동작을 검증해보자.

     

     

    4. Optimizing SNS application

    언제나 기능을 개발이 끝났다면(지금까지 진행한 거는 기능 개발만 해서 동작이 되도록만 개발이 되어 있다) 언제나 대규모 트래픽일 경우를 고려해야만 한다.

    항상 대용량 트래픽을 고민할때는 언제나 쿼리 최적화나 코드 최적화를 진행하고 나서→ 아키텍쳐 최적화를 진행해야한다.

    따라서 지금은 일단 쿼리 최적화나 코드 최적화를 진행해보자.

    지금까지 개발한 sns application은 기능만 고려하고 트래픽이 증가하게 될 경우를 아예 고려하지 않았다. 따라서 이제는 이 기능 그대로 동작하면서 쿼리 최적화나 코드 최적화를 진행하도록 하자.

    일단 현재 아키텍쳐는 아래와 같이 Spring Boot Application과 DB가 계속 IO를 날리면서 데이터를 가지고 오고 이 데이터를 가지고 비즈니스 로직을 수행하여 response를 날려주는 형태로 되어 있다.

    여기서는 총 5가지 문제가 발생할 수 있다.

    지금은 유저가 회원가입을 하고 로그인을 할 때 토큰을 발급해주고, 매번 API를 call할때마다 그 API가 전부 다 회원일 경우에만 사용할 수 있는 API들이므로 매번 API를 call할때마다 토큰을 한번 인증을 해서 유저가 유효한 유저인지 확인하는 과정이 있다.

    즉, Token을 인증할때 맨 처음에 filter 쪽에서 user를 체크를 한 후 그 후에도 계속하여 user를 조회하는 구조로 지금 되어 있다.

    따라서 user를 조회하는데 두번의 DBIO(=select 문)를 발생시키게 된다.

    일단 filter를 탈때 DBIO(=select 문)가 발생해서 user를 가지고 오게 되고, 그 다음에 API로 들어올때 또 DBIO(=select 문)가 발생해서 user를 가지고 오게 되므로 이미 filter에서 유효한 user인지 체크를 했으므로 절대로 error가 나지 않게되므로 select를 불필요하게 두번 진행하는 셈이므로 지금은 최적화가 안되어 있는 셈이다. 아래의 JwtTokenFilter.java의 코드 일부분을 봐보자.

     

    위의 JwtTokenUtils.getUserName(token, key); 부분에서 Token에서부터 userName을 한번 추출한 다음 loadUserByUserName(userName)에서 아래와 같이 그 userName을 바탕으로 user를 한번 조회를 하게 된다.

    따라서 여기서 이미 DBIO(=select 문)가 한번 발생을 한 것이다.

    그 이후에 Controller 단에 존재하는 API들 중 아무거나 call을 할때 가령, createPost API가 call이 된다면 아래와 같이 무조건 서비스 단에서 또 아래 getUserEntityOrException(userName)으로 DBIO(=select 문)가 한번 더 발생하게 된다.

    근데 사실 JwtTokenFilter에서 이미 앞서서 유효한 user인지 DBIO(=select 문)로 user를 확인 했기 때문에 위의 getUserEntityOrException(userName)에서 절대로 error가 나지 않게 된다.

    하지만 그럼에도 불구하고 이 부분이 필요한 이유는 post를 createPost 할때 반드시 UserEntity도 함께 넣어줘서 Authorization을 진행하기 때문에 저렇게 되어 있는 것이다. 그렇다면 저부분을 좀 더 간단하게 2번의 DBIO(=select 문)가 아니라 단 한번의 DBIO(=select 문)로 할 수 없을까? ⇒ 최적화를 하자!

    ⇒ UserEntity를 가져오는 것은 반드시 createPost할때 필요한 부분이므로 JwtTokenFilter에서 loadUserByUserName에서 Redis로 Caching Hit을 하도록 하여 DBIO를 줄인다.

    따라서 최적화를 생각해볼때는 언제나 코드에 중복된 부분들이 없는지와 특히 이 중복된 부분들이 DBIO(=select문으로 fetch 작업하는 것) 해온다거나 또는 외부 API를 call 해오는 무거운 작업인 경우에는 반드시 중복된 것들을 줄여야만 한다.

     

    문제점 1과 일맥상통한 이야기다.

    매 API request마다 User를 조회하기 때문에 이미 filter를 탄 후에도 API requsest call을 할때마다 항상 User를 조회하게 된다. 즉, User는 그저 로그인을 한 후에 post를 보거나 post를 작성하거나 comment를 쓰기만 할 뿐인데 이러한 동작을 취할때 마다 계속해서 User를 가지고 와야한다.

    헌데 이 User라는 정보 자체는 지금 내 스펙으로는 절대로 변경될 일이 없다. User의 name을 바꾸거나 password를 바꾸거나 하는 기능이 존재하는게 아니므로.

    따라서 지금 스펙상으로는 User라는 정보가 만들어지면 절대로 변화가 되지 않는 정보인데도 이걸 매번 DBIO(=select문)해서 가지고 올 필요는 없게 된다. 그러면 어떻게 하면 매 API 요청시마다 DBIO로 User를 가져오지 않고 좀 더 light하게 가져올 수 있을까?

     

    ⇒ login을 하게 되면 Redis에 Caching을 하고, 매 API Request마다 일단 이 Redis로 가도록 해보자.

     

    코드를 쭉 분석을 할때 어떤 부분이 가장 부하가 많이 가는 무거운 작업(ex. DBIO, 외부 API Call)인지 고려를 해서 이러한 작업들을 줄이는 방향으로 최적화를 진행해야만 한다. 그러면 매 API call마다 DBIO로 user를 조회하는 것을 줄여야만 한다.

     

     

    가령, PostService의 아래 like나 comment method를 보게되면 like 혹은 comment를 save한 후에 Alarm을 저장하게 된다.

    근데 비즈니스적으로 보게되면 만약 유저가 어떤 글을 Like를 했을때 이 동작에서 가장 중요한 거는 유저가 Like를 했다는 것이다. 즉, 이 사람이 Like를 함으로서 즉각적으로 알림이 가는 부분이 동작할 필요가 없는 것이다.

    가령, 코드 한줄 한줄 에러가 났다고 가정해보자. 예를 들어 위에서 DB로 부터 PostEntity와 UserEntity를 가지고 오는 부분에서 갑자기 DB에 부하가 많이 걸려서 느려지게 된다고 생각해보자. 5초가 걸린다고 가정해보자. PostEntity를 가지고 오는 부분은 당연히 5초를 기다려야한다. User도 당연히 5초를 기다려서 가지고 와야 한다. 그리고 한번 Like한 것은 다시는 Like를 할 수 없는 부분도 당연히 business logic이므로 이 부분도 기다려야 한다. 또한 like를 save를 하는 부분을 기다리지 않으면 like save를 했다는 것을 증명할 수 있는 길이 없으므로 당연히 기다려야 한다.

    헌데 alarm을 save하는 것은 5초동안 기다려서 유저에게 바로 alarm을 줄(=save) 할 필요가 없이 조금 나중으로 빼놨다가 유저에게 alarm을 save해도 같은 결과이다.

    user는 단순히 자기가 like했다는게 save 되는 것 까지가 중요한 거고 알람은 바로바로 알람을 받아볼 필요가 없고 급하게 처리할 것도 없고 조금 지연이 되어도 크게 문제가 되지 않는다.

    따라서 만약 지연이 발생한다는 가정 하에 alarm을 save하는 부분을 떼놓으면 좀 더 유저가 자유롭게 이용할 수 있게 되므로 이렇게 강하게 coupled되어 있는 것을 떼어서 분리해서 coupled을 줄이자.

     

    alarm list api를 call 해야만 alarm list가 update가 되므로 즉 알람 페이지를 클라이언트 쪽에서(브라우저 쪽에서)새로고침해야만 갱신되는 알람을 볼 수 있게 되어 있으므로 이것을 해결해야한다.

    그 방법을 처리하기 위해서는 총 4가지 방법이있다.

    2-3초마다 한번씩 찌르는 방법 ⇒ 서버에 부하가 많이 생긴다.

    그러면 이 부분을 조금 더 효율적으로 Alarm List를 가져올 수 있는 방법이 있을까?

     

    과연 JPA가 자동으로 보내는 쿼리(내가 작성한 entity들에 내가 작성한 find같은 메소드들로 바탕으로 쿼리가 생성되서 날라감)가 최적화되어 있을지 확인해봐야 한다.

    과연 내가 만든 entity들의 연관 관계가 내가 원하는대로 index를 타고 있을까?

    select가 잘 되는(=fetch가 잘되는) 쿼리가 날아가고 있을까?

    특히 SQL Query를 MyBatis 같을 걸 사용해서 직접 짜게 되면 이 쿼리가 한눈에 보이므로 과연 이 쿼리가 최적화 되었는지 안되었는지가 명확하게 보이지만 JPA의 경우에는 개체화를 시켜서 쿼리를 하는데 초점을 맞춰져있다보니 언제나 JPA를 사용하는 경우에는 쿼리 자체에 대한 최적화를 한번 체킹을 해줘야만 한다.

     

    따라서 정리를 해보면 지금 아래의 3가지 문제점이 존재한다.

    언제나 모든 프로젝트에서 앞으로 진행하면서 항상 위의 세가지를 언제나 체크를 해보자.

    위의 세가지 여부를 봐야지만이 트래픽이 많아져도 서버가 효율적으로 운영되는지 확인하고 아키텍쳐가 잘 짜여진게 된다.

    그러면 아키텍쳐가 아래와 같이 업그레이드가 된다.

    우선 현 스펙상 절대로 변할 일이 없는 User data이므로 한번만 조회하면 될 것을 연달아 조회하는 부분은 코드의 수정을 통해 고쳐야겠지만

    변하지 않는 데이터를 db에서 매번 가지고 올 필요가 없으므로 Redis로 caching을 해서 DBIO로 가지고 오지 않고 Caching해서 가져와서 DBIO를 줄이자. 굳이 DB까지 가지 않아도 되는 데이터라면 Cache에서 가져오는게 훨씬 효율적이다.

    coupling이 높은 부분(alarm이 Save까지 되어야지만 API가 end되는 부분)은 Kafka(Messaging Queue로 Producer가 message를 produce하면 Subscriber가 Consume을 하게 된다)를 사용해서 해당 API 부분에서는 producing까지만 하고, 실제 이러한 message를 처리하는 부분(alarm save)은 비동기적으로 처리해서 느슨하게 해보자.

    따라서 아래와 같이 alarm 부분은 async하게 동작을 하도록 해주자.

     

    보통 개발자가 API 개발을 할때는 개발하는 나 혼자서 한명이 이 API를 call할때를 가정하고 개발하게 되는 경우가 많은데 언제나 나 외에 여러명이 동시에 이 API를 call할때 발생할 수 잇는 문제점을 사전에 생각 해야만 한다.

     

    즉 나외의 다른 사람들도 동시에 접근해서 사용할때의 알람 구현은 언제나 이처럼 kafka를 이용해서 aync하게 처리한다는 사실과 caching을 붙여서 DBIO를 줄이는 방법은 언제나 template solution처럼 기억해두고 있자.

     

    참고 내용

    page include와 page forward의 차이점 : include는 갔다 돌아오고 forward는 그냥 간다.

    • context와 config는 다르단 거에 주의하자! Context는 WAS 자체의 config이다. Tomcat과 같은 WAS가 죽으면 Application이 끝난다. 즉, WAS(톰켓 등)의 시작부터 끝이 APPLICATION scope이다. Config은 각각의 WAS마다의 config(가령, web.xml에서의 <context-param>), Session 마다의 config(가령, web.xml에서의 <session-config>), page(=JSP/Servlet)(가령, web.xml에서의 <servlet>) 마다의 config가 존재한다. WAS은 여러 개의 JSP/서블렛을 포함하고 있고, 각각의 JSP/서블렛은 Config가 1개씩 존재한다. Config는 그냥 특정 정보들을 포함하고 있는 설정 파일이다. JSP/Servelet마다 별도로 1개의 Config씩 생성되는 것이다.

    Web.xml은 WA마다 1개씩 존재하고 이는 해당 WA에 존재하는 모든 것들을 cofiguration하도록 설계 되어 있다. -page config = page scope(=.jsp마다 1개씩=페이지마다 1개씩)마다 1개씩 -session(시간이 끝날때까지 유지되거나 로그인 정보가 유지되거나 edge와 chrome처럼 서로 다른 web browser가 아닌 같은 web browser로 접근하는경우이거나 동일한 컴퓨터로 접근하는 경우) config = session scope마다 1개씩

     

     

     

    어떤식으로 코드 최적화를 해야할까?

    첫번째는 중복되는 DBIO를 줄이는 방법이고,

    두번째는 Query가 최적화되었는지 확인해보자.

     

    일단 JwtTokenFilter 이후에 한번 더 DBIO를 하는 부분에 대해서는 어차피 SetAuthenticaiton으로 authentication을 넣어주고 Controller 단에서 바로 Authentication을 parameter로 받게 되는데 이때 이 Authenticaiton에 내가 principal로 넣어뒀던 User가 존재하게 되는데 그러므로 그냥 여기서 바로 User를 아래와 같이 뽑아내면 안될까? 이런 생각이 든다.

     

    그러면 위에서 username을 굳이 뽑아서 그걸 가지고 또 service 단에서 해당 userEntity가 있는지 없는지 찾아서 이 userEntity로 alarm을 찾는 2번째 DBIO 코드를 제거할 수 있지 않을까? 해당 alarm controller logic에 주석으로 설명을 달아놨으니 참고하자.

    그러면 일단 도대체 위와 같이 안하고 userEntity로 찾게 2번째 DBIO를 하게 되면 쿼리가 몇개가 날라가는지 Postman으로 alarm list를 조회하는 API를 날려서 log에 찍히는 쿼리 갯수를 봐보자.

    그러면 아래와 같이 UserEntity를 select하는 부분이 동일하게 2번 나오게 된다.

    첫번째 UserEntity를 select하는 부분은 JwtTokenFilter에서 select를 하는거고,

    두번째 UserEntity를 select하는 부분은 service 단에서 해당 userEntity가 있는지 없는지 찾는 부분이다.

     

    이후에 alarm을 아래와 같이 user_id로 가지고 오게 되는데(AlarmEntity에서 user column이 실제로는 ManyToOne으로 JoinColumn이 user_id로 되어 잇으므로 이 말인 즉슨 실제 이 alarm table에는 user_id로 들어가 있어서 이 user_id로 쿼리를 하게 된다)

    이걸 아싸리 Authentication 부분에서 principal에 담긴 user를 그냥 뽑은 후 바로 param으로 넘겨서 바로 JPA select를 하게 된다면 그러면 아래와 같이 UserEntity를 select하는 부분이 1번만 나오게 된다.

    이 문제는 해결되었다.

    이제 그러면 AlarmEntity를 다시 한번 봐보자. 아래와 같이 UserEntity가 결합이 되어 있다는 소리는 결국 alarm table에는 user_id가 존재를 하고, ManyToOne으로 결국 alarm을 가지고 올때 user도 함께 조인을 해서 뒤에 붙인 다음에 가지고 오는 것을 기대한다. 아래와 아래와 같이.

    즉, 내 생각은 알람과 유저의 정보를 두 개 다 가지고 오는게 위와 같이 select * from “alarm” join “user” on “user”.id = “alarm”.user_id; 처럼 동시에 join을 해서 가지고 오는지 아는데

    실제로는

    select * from “alarm”으로 일단 alarm table을 쭉 아래와 같이 가지고 온 후에

    user_id의 결과를 보고 하나씩 하나씩 아래와 같이 존나게 노가다식으로 join을 하는 FK column을 보고 일일히 하나씩 join할 table에다가 query를 날리는 방식으로 row마다 한개씩 조회를 하게 된다.

    이게 바로 n + 1 문제 이다. 한번의 query를 날렸다고 생각을 하지만 사실 alarm으로 날라가는 쿼리 1개 그리고 이 alarm의 결과(user_id)가 n개라고 치면 다시 userEntity로 n번의 쿼리가 날라가게 된다.

     

    이러한 JPA의 n+1 문제는 아래와 같이 AlarmEntity에 @ManyToOne으로 박아놨기 때문에 발생하는건데 이 것을 타고 들어가보면 default fetch type이 Eager이므로 바로 즉시 join 결합으로 한번에 결합을 해서 데이터를 로딩해서 가지고 오겠다는 것이다.

    따라서 이때는 userEntity를 사용하든 사용하지 않든 어쨌든 무조건 alarm entity를 call하고 거기에 user entity도 있으니까 또 다시 call이 일어나게 되어 무조건 n+1 문제가 발생한다.

    이 문제를 확인해보기 위해서 Alarm 부분에 아래와 같은 log를 찍어보자.

    아래를 보자.

    join을 하긴 하되 alarmEntity만 쭉 가지고 오게 된다.

    이후에 곧바로 userEntity를 조회하는 쿼리가 날라간다.

    하여 fetchtype이 eager인 경우에는 일단 전체 entity를 1번의 query로 가져온 다음에 여기에 결과가 되는 N개의 행에 또 query를 날리게 되므로 JPA N+1 문제가 발생하는 것이다.

     

    그러면 fetchtype을 Lazy로 바꿔보자. 그러면 쿼리가 일단 alarm만 나가게 된다.

    그리고 alarm entity를 만들때 User.fromEntity(entity.getUser())가 나와서 user를 쓰게 될때 2번째 쿼리가 나가게 된다.

    즉 fetch type을 lazy로 해서 일단 alarm entity를 한번에 가지고 온 다음에 userentity가 쓰일때 가지고 오도록 해서 N+1 문제가 발생하긴 하지만 일단 억지로 쪼갠 다음에 어차피 이 alarmentity에 굳이 user라는 필드가 없어도 되므로 해당 부분을 아예 삭제를 해줘서 JPA N+1 문제를 해결할 수 있다.

    근데 사실 fetchtype을 lazy로 변경해서 해당 부분을 삭제하는 것은 N+1 문제를 근본적으로 해결해주는 방법이 아니다. 왜냐면 n+1 문제 자체는 lazy로 하면 불필요(1개씩 선택하는) query가 바로 날아가는 것을 막기만 할 뿐이고, 만약 userentity가 alarmentity에서도 실제로 필요하게 된다면 어쩌겠는가? 바로 n+1 문제가 발생한다. 이 경우에는 native query(n+1 문제의 정석적인 해결 방법)을 join으로 직접 작성을 해서 애초에 한번에 join으로 묶어서 fetch를 하여 가지고 오는 방식으로 해결하는게 정석이다.

    여기서는 그냥 lazy fetch type으로 막은 다음에 api 자체에 필요 없는 필드들을 분리하고 삭제를 해줘서 필요가 없는 join은 하지 않아도 되는 방식으로 해결했다. native query만드는 것은 내가 알아서 해보자.

     

     

    JPA의 경우 문제점이 2가지가 있다. n+1 문제와 delete 문제이다.

    일단 n+1 문제의 경우 1가지의 임시 해결방법(lazy fetch 방법 사용 → 필요 없는 필드 삭제)과 1가지의 정석 해결방법(native query로 작성)

    delete 문제의 경우 1가지의 유일한 해결방법(native query로 작성)으로 해결한다.

     

     

    이후 WAS를 뛰우고 API test를 진행하면서 log에 찍히는 쿼리를 보게 되면 실제로 select해서 데이터를 가져오는 쿼리가 아예 없어지게 된다. 그냥 바로 delete query만을 날리게만 된다.

     

     

     

    DBIO를 줄이기 위해서 Caching 사용하는데 가장 대표적인 Caching solution이 Redis와 Local Caching(가령, HashMap으로 그냥 바로 서버 메모리에 생성하는 Caching으로 Network를 타지 않고 그냥 바로 서버의 메모리를 사용하므로 Redis에 비해 졸라게 빠르지만 여러 instance로 구성된 서버의 경우에는 공유가 안되므로 만약 1번 서버에서 reqeust가 와서 Local Caching을 구성했더라도 만약 reqeust가 2번 서버로 가면 거기서는 이 Local cache가 공유되지 않으므로 2번 서버에서 다시 caching을 구성해야한다. 또한 local caching의 경우에는 임시적인 휘발성 데이터이다. 따라서 DB에 Data CRUD를 받게 되면 언제나 그에 대한 반영이 local caching에도 적용이 되어야만 한다. 따라서 status 역시 1번 서버와 2번 서버를 모두 다 DB와 맞춰줘야 하는데 CRUD가 서버 1로 갔다면 2번 서버는 status가 달라지게 되므로 만약 여러 instance로 구성된 server가 하나의 데이터를 봐야될 경우에는 local caching 방식은 적합하지 않음)이 있다.

    Redis는 In-memory DB로 데이터가 일반 DB처럼 디스크가 아니라 메모리에 저장되기 때문에 휘발성이다. 따라서 Redis가 죽게 되면 따로 backup option을 두지 않으면 data가 다 날라가버린다. 그렇게 되면 다시 Redis가 up되면 DB에서 caching시키고자 하는 data를 모두 다 가지고 와야 하므로 순간적으로 DBIO가 존나게 발생해서 DB load가 순간적으로 굉장히 높아진다.

    또한 아예 Redis를 빠른 DB이기 때문에 main DB로 쓰게 될 경우도 있는데 뭐 하여튼 이러한 경우 모두 다 Data 유실에 대응해야하는데 이때 Sentinel option을 사용해서 Redis를 여러개를 구성해서 여러 개의 Redis instance를 하나의 Redis 처럼 사용할 수 있도록 하는 방법이다. 그러한 Sentinel option을 만드는 방식이 Clustering 방식도 있겠고, Master-Slave 방식도 있다.

     

     

    Redis는 공식 문서가 잘되어있으므로 이 문서를 보고 Redis CLI 연습을 하자.

    get (key)로 데이터 가져오기

    set (key) “value” (시간 설정)으로 key-value pair 저장하기

    HSET/HGET (key) (field) “value” : 가령, 유저에 대한 정보를 캐싱을 해놨을때 만약 유저를 저장하는 로직에 문제가 있어서 유저만 날려버리고, 나머지 캐싱된 데이터를 건들고 싶지 않을 때 즉, 이런식으로 단위별로 지워야 할 데이터 같은 경우에는 한단계 depth가 존재하는 H 명령어(=Hashing)로 묶는 방식으로 많이 사용한다.

    MSET같은 M 커멘드는 한번에 여러개의 명령어를 multi로 진행할 수 있지만 부하가 존나게 크므로 왠만하면 사용하지 말자.

    ZADD같은 Z 커멘드는 점수로 해서 뭔가 소팅을 하거나 스코어별로 잘라서 오거나 할때 유용한 커멘드이다.

     

    Heroku Data for Redis를 add-on으로 Heroku Dyno app에 붙이자.

    Redis관련 library를 사용할 수 있는 dependency 추가는 Spring Boot Starter Data Redis를 MVNRepository에서 찾아서 Gradle(short)로 build.gradle에 추가해주자.

    이후 configuration package에 RedisCongifuration을 만들어주자.

    그리고 Heroku Dyno app에 add-on을 붙인 다음에 나오는 URL을 찾아볼때는 언제나 Heroku Console로 보이는 URL이 아니라 Heroku CLI로 URL을 직접 찾아서 사용하자.

    heroku config -a (내가 사용하는 app name)으로 직접 URL을 찾아서 사용하도록 해주자(지금은 굳이 내 Spring Boot Application과 Redis Server가 주고 받는 데이터를 암호화 시킬 필요가 없으므로 REDIS_TLS_URL이 아니라 그냥 REDIS_URL을 사용하도록 해주자) UserCacheRepository class 를 만들어주자.

     

    이후 API Test를 하는데 이때 Heroku CLI로 Redis까지 확인해주자(heroku redis:cli -a (app 이름)으로 Redis 접속 이후 조회하기). 근데 문제는 내가 원하지 않는 부분의 데이터들(JwtTokenFilter에서 user를 가져오기 위해서 UserDetails를 implement한 부분의 정보들)도 다 들어가 있는데 이 부분은 전부 다 어떤 필드들을 바탕으로 값이 만들어지는 것들이므로 이것들을 캐싱하기 보다는 이것들은 캐싱된 다른 필드들을 통해서 메소드를 이용해 구할 수 있으므로 해당 부분은 @JsonIgnore로 막아주고 이것들을 도출할 수 있는 원본 값만 캐싱해줘서 최대한 공간을 효율적으로 사용하자.

     

     

    그리고 다시 API Test를 해줘서 로그인 했을때 DBIO(=select)가 일어나지 않는지 한번 봐주자. DBIO 없이 바로 아래와 같이 Get Data하고 또 다시 Set Data를 해준다.

     

     

    지금은 alarm list api가 한번 call이 되고 또 새로고침이 있을때에만 이 alarm list api call이 일어나서 데이터를 갱신할 수 있는데 만약에 웹 페이지를 새로 고침할때만 데이터 갱신이 일어나는게 아니라 유저가 알람을 받아야 될 상황이 왔을때(가령, 유저의 글에 누군가 좋아요나 댓글을 남겼을때) 그 알람이 엄청난 Realtime으로까지는 아니더라도 그래도 왔으면 좋겠다면(실제 많은 SNS 서비스들의 알람 방식) 4가지 방법이 있다.

     

    1. API를 주기적으로 Call하는 방식 = Polling

    Polling 방식은 주기를 가지고 서버의 API를 Call하는 것이므로 다음 주기가 올때까지 데이터가 업데이트 되지 않으므로 실시간으로 데이터가 업데이트 되지 않으며 데이터가 실제로 업데이트가 안되더라도 다음 주기에는 무조건 업데이트를 하므로 반드시 API Call이 일어나서 불필요한 request가 발생하게 되어 불필요한 Server 부하가 발생하므로 가뜩이나 대규모 서비스라면 1억명이 polling을 하면 서버가 죽으므로 대규모 트래픽의 경우에는 polling 방식은 적합하지 않다.

    헌데 polling은 브라우저 타입에 따라서 변화를 줘야 한다거나 HTTP Method의 버전에 따라서 변화를 줘야 한다거나 할때 단순하게 그냥 API call을 주기적으로 하면 되므로 호환성이 개좋다.

     

    2. Long-Polling

    polling은 data update가 있던 없던 계속 주기적으로 request-response 구조로 통신이 일어나므로 불필요한 request가 계속 발생하게 되는데 이것을 개선한게 Long-Polling 방식이다.

    Web Browser에서 Server에 일단 Request를 보내어 연결을 만든 다음 Server에서 Data Update가 일어날때까지 쭉 대기 타다가 Server에서 Data Update가 일어났을때 response를 보내게 된다.

    하여 Update가 일어난 시점에 response를 받을 수 있어서 실시간 Update를 받을 순 있겠고 request가 polling에 비해선 적으므로 성능상 이득이지만 만약 data update가 존나게 빈번하게 일어난다면 일단 request를 보내자마자 response가 오게될거고 그다음에 다시 또 연결을 위해서 request를 보내게 되고, 보냈는데 또 업데이트가 일어나면 다시 또 올테고 … 하다 보면 polling이랑 유사하게 되므로 데이터 업데이트가 많은 경우에는 적합하지 않다.

    어찌됐든 Polling과 Long-Polling은 어쨌든 웹 브라우저와 서버 간의 request-response flow를 타기 때문에 과연 이 reqeust를 얼마나 자주 보내느냐, response를 어느 주기로 받느냐를 조절하면서 개선을 하는 방식이다.

     

    3. SSE(Server-Sent Event)

    request-response flow가 아니라 그냥 바로 Server에서 Web Browser로 보내주기만 한다.

    이때 Web Browser는 Server에다가 특정 데이터의 업데이트가 일어나면 자기에게 알려달라는 Subsribe를 먼저 걸어둔 다음 Server 쪽에서는 실제로 그 Subscribe 정보를 저장하고 있다가 해당 Data Update가 일어났을때 Web Browser쪽으로 Event를 보내주게 된다. 하여 polling이나 long-polling에 비해서 훨씬 더 트래픽의 부하가 적어지게 되지만 Server에서 Web Browser로만 데이터 전송이 가능하고 Web Browser에서 어떤 Data를 지가 담아서 Server 쪽에 보내는 것은 불가능하고 최대 동시 접속 횟수도 Web Browser마다 정해져 있다. 따라서 SSE는 Chatting에는 적합하지 않고 alarm일 경우에만 적합하다.

     

    4. WebSocket

    그러면 Web Browser와 Server가 동시에 양방향 통신할 수 있는 방법은 없을까? ⇒ Web Socket ⇒ Chatting에도 적합.

    프론트엔드에서 먼저 구독이 일어나므로 프론트엔드의 index.js 코드가 아래와 같이 들어가 있어야 한다.

    “alarm”이라는 Event가 뜨게 되면 handleGetAlarm() method를 실행하게 되는데 이것은 Alarm list api를 최신순으로 paging하도록 api call을 하는 method이다. 근데 사실 이 구조가 효율적인 구조는 아니다. 왜냐하면 그냥 alarm event가 왔을때 다시 한번 페이지를 호출하게 되니까(API Call) 효율적인 구조는 아니고 사실 alarm event로 그냥 바로 받아와서 뿌려주는게 더 적합하지만 이번에는 이렇게 하도록 하자.

    이후 UserController에서 subscribe하는 api를 만들어주자.

    근데 위에 index.js 코드를 보면 보통 token은 request header에 넣어서 request를 보내게 되는데 얘는 request path에다가 token을 넣게 되었다. 왜나하면 EventSource class자체가 header 부분을 세팅하는 것을 지원을 하지 않기 때문에 어쩔 수 없이 path에다가 request param으로 넣게 되었다.

    따라서 이 token을 한번 유효한 유저인지 백엔드 단에서 체크를 해줘야 하므로 UserController에서 Authentication으로 받아오게되는 JwtTokenFilter를 수정해주자. 지금은 HttpHeader에 AUTHORIZATION부분에 담긴 Bearer 부분을 split해서 token을 뽑아오게 되는데 이부분을 특정 API인 경우에는 Reqeust Param에 해당 token이 존재하는지 확인을 해줘야 하므로

    즉, 내가 지정한 API url인 경우에는 param에서 check를 해주도록 하자.

    그 후 API test를 localhost로 웹브라우저로 들어가서 해주자.

     

     

    대규모 트래픽에 적합하도록 alarm 부분을 비동기로 처리하려고 하는데 비동기로 데이터를 처리할 수 있는 방법은 굉장히 많다. Spring에서 제공하는 방법, 따로 Thread를 빼서 처리하는 방법 등 많지만 Kafka(=Message Queue)가 가장 많이 쓰이므로 Kafka를 사용해서 데이터를 비동기로 처리하도록 하자.

    Broker에는 file(JSON 등)로 메시지들이 쭉 적히게 되고, consumer는 이것을 구독하고 있다가 메시지를 받는다.

    producer가 Event(=message)를 broker에 보낼때 반드시 key 값을 설정해둬야 한다. 이것은 이 message(=Event)가 어디에 Partition이 될지를 정하는 값이다. 하여 producer는 topic을 지정해서 이 topic에다가 메시지를 쏴주고, consumer는 그 topic에서 메시지를 읽어오는 방식으로 동작하게 된다.

    그리고 이 쏴진 메시지는 partitioning되서 저장이 되게 된다.

    이게 막 파티셔닝이 되면 절대 안된다. 가령, user가 포스트를 A라고 썻다가 B라고 바꿨다라는 Event가 저장된다고 가정해보자. 그리고 이 user가 B에서 C로 바꿨다가 Event로 또 저장이 된다. 근데 결론적으로 C로 업데이트 한거고 최종 데이터는 C인데 이걸 Consumer에서 A→B→C라는 순서가 꼬이지 않도록 보장하기 위해서는 반드시 순서가 보장이 되기 위해서 Key 값(PostId같은걸로 key 값 설정 후 같은 partition에 쏴준다)이 같아야 한다(kafka는 key 값을 기준으로 partitioing). 그리고 동일한 Partition에서는 나중에 생성된 event는 뒤에 오는 Queue 구조이므로 같은 partition에서는 순서가 보장이 되지만 만약 partition이 달라지게 되면(consumer는 partition 0부터 1→2→… 순서로 partition을 읽는다) 순서가 보장이 되질 않게 된다.

    메시지는 Consumer Group에서 하나의 Topic을 정한 후 이 Consumer Group 안에 있는 Consumer들이 각자 하나의 Partition을 맡아서 메시지를 Consuming한 후 ACK 메시지를 날린다. 그러면 메시지가 처리가 된 것이다.

    ACK를 날리는 방법도 여러가지이다. 단위별로 10개 읽으면 ACK를 날리는 Batch성 ACK가 하나 있고, 하나 읽을때마다 하나씩 날리는 ACK도 있고, 코드상으로 개발자가 직접 ACK라고 쳐서 ACK의 시점을 직접 결정해줄 수도 있다.

     

    일단 Heroku Dyno app에서 Apache Kafka on Heroku(비쌈) 대신 cloudkarafka를 add on으로 추가해준 후 build.gradle에 필요한 dependency(Spring Kafka Support를 MVNRepository에서 찾아서 gradle(short)로 해서 버전 정보는 적지 말고)를 추가해주자.

    이후 application.yaml에 kafka configuration 정보를 적어서 producer가 메시지를 보낼 broker 정보를 여기에 넣어주자.

    이후 producer와 consumer를 만들기 위해서 따로 패키지를 각각 producer와 consumer로 파서 작업해주자.

    이후 API Test는 Web browser로 localhost를 띄워서 sse 테스트를 해주자.

     

     

     

    [Reference]

    1. https://devcenter.heroku.com/articles/heroku-redis

     

    Heroku Data for Redis | Heroku Dev Center

    Last updated April 24, 2024 Heroku Data for Redis is an in-memory key-value data store, run by Heroku, that is provisioned and managed as an add-on. Heroku Data for Redis is accessible from any language with a Redis driver, including all languages and fram

    devcenter.heroku.com

    2. https://github.com/heroku/heroku-kafka-demo-java

     

    GitHub - heroku/heroku-kafka-demo-java: A simple demo app to demonstrate Apache Kafka on Heroku using Java

    A simple demo app to demonstrate Apache Kafka on Heroku using Java - heroku/heroku-kafka-demo-java

    github.com

    3. https://spring.io/projects/spring-data-jpa

     

    Spring Data JPA

    Spring Data JPA, part of the larger Spring Data family, makes it easy to easily implement JPA-based (Java Persistence API) repositories. It makes it easier to build Spring-powered applications that use data access technologies. Implementing a data access l

    spring.io