1. 어려웠던 부분 : 백엔드 팀원들과 음성채팅과 화상채팅 기능에 대해 알아보다 메시지브로커/ Openvidu 에 대한 의문이 생겼다. 먼저 메시지 브로커의 경우, 왜 굳이 Stomp 프로토콜 In Memory Broker가 아니라 RabbitMQ, ActiveMQ 와 같은 외부 브로커를 사용하는 걸까? - 이부분은 오늘 공부한 부분에 담아놓았다 (맨 아래 링크 참조) 그리고 몇몇 레퍼런스를 찾아보니 Openvidu 라는 것을 사용하던데 백엔드 쪽에선 어떻게 사용하는건지 자세히 나와있는 예제가 별로 없어서 https://docs.openvidu.io/en/2.25.0/ 이렇게 공식 사이트까지 들어가서 찾아보게 되었다. 알고보니 프론트엔드쪽에서 사용하는 기술이라는 것을 알 수 있었다. 배워본적 없는 새로운 기술을 사용하려다보니 여러모로 시작이 쉽지는 않은 것 같다.

 

[ 참고 자료 ] - Openvidu vs Kurento

 

화상 미팅을 간단하게 구현할 수 있는 Kurento와 Openvidu 프레임워크

https://2jinishappy.tistory.com/248?category=948597 WebRTC - 웹 브라우저 간 실시간 미디어 통신 기술 WebRTC: Web Real-Time Communication 웹 브라우저 간에 플러그인의 도움 없이 서로 통신할 수 있도록 설계된 API 2020

2jinishappy.tistory.com

 

2. 느낀 점 : 막막하긴 하지만 팀원들과 서로 이해한 부분을 공유하고 다시 개념을 잡아보는 과정이 힘들면서 재밌기도 하다. 이번에도 얼마나 성장할 수 있을지 기대가 된다.

 

3. 새로 알게 된 내용 : 팀원들과 이번에 사용할 WebSoket, WebRTC, Redis, RabbitMQ, SOCKJS + STOMP 에 대한 자료를 서로 공유하면서 공부하는 시간을 가졌다. 특히 유튜브 노마드코더의 웹소켓 관련 강의와 우아한테크 아론님의 웹소켓 강의가 개념을 이해하는데 큰 도움이 된 것 같다. 사실 오픈채팅 화상채팅과 관련해선 생각보다 백엔드에서 크게 어려운 부분이 없을 것 같아 다음주 부턴 게임의 알고리즘을 짜는데 더 시간을 투자해야 할 것 같다.

https://www.notion.so/Backend-Key-Features-Technical-Stack-1cfdea06bb2f433592d688d79dfc309e

 

4. 셀프칭찬 (오늘 잘한 일) : 막막하지만 그래도 차근차근 개념을 공부하고 비슷한 프로젝트 레퍼런스를 깃헙에서 찾아 공부하고 있는 나를 칭찬한다! 

 

5. 내일 할 일 (일요일) : 일주일 공부 정리하기 

 


[오늘 공부한 부분] 

[33] 실전 프로젝트 기술 개념이해(1) WebSocket/WebRTC

 

[33] 실전 프로젝트 기술 개념이해(1) WebSocket/WebRTC

실전프로젝트에 실시간 채팅과 음성 채팅 기능을 구현해야 한다. Spring 채팅기능에 관련해 찾아보니 WebSocket과 WebRTC 두가지 키워드가 등장했다. 다음은 WebSoket과 WebRTC에 대한 간략한 정리이다. Web

leejincha.tistory.com

[34] 실전 프로젝트 기술 개념이해(2) SockJS / Stomp /Redis

 

[34] 실전 프로젝트 기술 개념이해(2) SockJS / Stomp /Redis

WebSocket은 클라이언트 서버간 양방향 통신이 가능하지만, 다음과 같은 이슈가 있다. 1. websocket미지원 웹 브라우저가 있다는 점 2. 웹 브라우저 이외의 클라이언트 지원(서버 입장에서는 클라이언

leejincha.tistory.com

[36] WebSocket - In Memory 대신 외부 브로커 사용하는 이유

 

[36] WebSocket - In Memory 대신 외부 브로커 사용하는 이유

Message Broker란? Publisher로부터 전달받은 메세지를 Subscriber로 전달해줄 때 중간에서 메세지를 주고 받게 해주는 중간 역할 https://docs.spring.io/spring-framework/docs/4.3.x/spring-framework-reference/html/websocket.html

leejincha.tistory.com

[37] 웹소켓 강의 추천 - 우아한테크 영상

 

[37] 웹소켓 강의 추천 - 우아한테크 영상

1. 웹소켓 기술 전이중 통신 - 실시간성 보장하는 서비스 (채팅, 게임, 주식거래 사이트 등)에 주로 사용 HTTP에도 실시간성 보장하는 기법이 존재하지만, 서버에 부하가 많이 가기 때문에 웹소켓

leejincha.tistory.com

 

1. 프로젝트 소개

 

 

 

2. 주요 기능

  • Spring Security, JWT를 이용한 회원가입/로그인
  • 이메일 인증을 통한 로그인 구현
  • AWS S3를 이용한 다중 이미지 업로드
  • JPA Pageable을 이용한 페이지 무한 스크롤
  • 타입별, 가격별 필터링 기능
  • 키워드 검색 기능
  • 숙소 좋아요 기능
  • swagger 적용
  • 다중 이미지 업로드 CRUD(조회 시 이미지 preview)

 

3. 서비스 아키텍쳐

 

4. 기술 스택

🎨 Front-end Stack

  • React , javascript
  • Redux
  • Redux Toolkit
  • mui , styled-components
  • axios

🛠 Back-end Stack

  • Spring boot
  • Spring Security, JWT
  • AWS S3, RDS(MySQL)
  • OAuth 2.0

🌐 Infrastructure

  • AWS EC2
  • AWS S3

🗂 Dev tools

  • Swagger
  • Git, Github

 

5. 트러블 슈팅

 

6. 팀 노션

7. 깃허브

8. 다음에 사용해보고싶은 기술

  • S3를 이용한 다중 이미지 업로드의 개별 이미지 수정
  • refresh 토큰
  • https 적용
  • logging
  • 소셜 로그인 ( google, naver )
  • bucket4j
  • 지도 api
  • 주소 찾기 api 기능
  • grid속성을 조절해 카드 컴포넌트 크기 키우기
  • 웹소캣 채팅 기능

@Enablejpaauditing

문제

  • 게시글을 수정할 때, CreatedAt/ModifiedAt 값이 null로 반환되는 문제

해결

  • @Enablejpaauditing 어노테이션 추가
@EnableJpaAuditing
@SpringBootApplication
public class HanghaebnbApplication {

    public static void main(String[] args) {
        SpringApplication.run(HanghaebnbApplication.class, args);
    }

}

+추가 자료)

  • Jpa Auditing 를 활성화시키는 annotation 입니다.
  • Spring Audit 기능 : Spring Data JPA 에서 시간에 대해서 자동으로 값을 넣어주는 기능입니다. 도메인을 영속성 컨텍스트에 저장하거나 조회를 수행한 후에 update를 하는 경우 매번 시간 데이터를 입력하여 주어야 하는데, audit을 이용하면 자동으로 시간을 매핑하여 데이터베이스의 테이블에 넣어주게 됩니다.

 

createdAt, modifiedAt 값이 String 타입이 아니라 배열로 반환됨

문제

  • createdAt, modifiedAt 값이 String 타입이 아니라 배열로 반환됨

해결

  • ResponseDto에 @JsonFormat(shape = JsonFormat.Shape.*STRING*, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul") 추가

기술 매니저님 피드백)

경우에 따라 배열로 반환해 주는게 프론트엔드에서 값을 처리하기 편할 수도 있다. 따라서 이 부분은 상황에 맞게 반환타입을 주는게 좋다.

 

필터링 + 페이징 (with Spring Data JPA)

문제

  • 페이징과 카테고리별/가격별 조회를 한 번에 하려다보니 쿼리가 복잡해져서 Spring Data JPA의 Query Method만으로는 조회가 어려운 상황이었다.
  • @Query 어노테이션과 native query를 이용하여 해결하려 하였으나 native query와 페이징을 함께 사용하기가 까다로웠다.

해결

  • countQuery를 이용하여 query문을 작성하고, @Param 어노테이션을 함께 사용하여 메서드를 생성하여 해결하였다.
@Query(countQuery = "select count(*) from room r where (r.price between :minPrice and :maxPrice) and r.type = :type", nativeQuery = true)
Page<Room> findByPriceBetweenAndType(@Param("minPrice") int minPrice,
                                     @Param("maxPrice") int maxPrice,
                                     @Param("type") String type,
                                     Pageable pageable);

 

데이터 전달 시 타입 지정

문제

  • String type으로 매개변수를 받아올 때 공백문자가 섞이는 에러발생
  • 포스트맨에서 body - text로 놓고 {}없이 그냥 String 썼어야 함. 여태까지는 왜 이런 에러가 발생 안 했는지 생각해보니 여태까지는 dto로 받았었음.

해결

  • 이 문제를 해결하기 위해 제네릭스를 사용해서 해결했다가, 코드의 통일성위해 dto로 responsebody로 json형식으로 받아오는 방식으로 바꿈.

 

if-else와 try-catch

문제

  • if-else문 내부 throw → 특정 조건에서만 던져지는 exception

해결

  • try-catch문으로 변경.
  • catch시 try코드에서 어떤 exception이 터질지 알고있으니 그게 맞게 작성해주면 된다.
  • ? → 지금은 IOException이나 직접만든 CustomException 두개로 catch를 하고있지만 에러가 더욱 많아지면 계속 해서 catch문을 추가해서 해당하는 에러를 잡아야하나?
  • ! → 자바가 기본 제공하는 Exception중 해당 exception이 상속받는 상위 상위 상위 exception이 존재한다 초기에는 적절한 exception을 catch문으로 사용하여 잡으면 넓은 범위의 catch로 핸들링 할 수 있으며 이 범위는 최적화 과정에서 줄여나가면 된다.

CustomException

package com.cloneweek.hanghaebnb.util.exception;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class CustomException extends RuntimeException{
    private final StatusMsgCode statusMsgCode;
}

 

StatusMsgCode

package com.cloneweek.hanghaebnb.util.exception;

import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter
@AllArgsConstructor
public enum StatusMsgCode {

    /* 400 BAD_REQUEST : 잘못된 요청 */
    USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "회원을 찾을 수 없습니다."),
    INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."),
    BAD_ID_PASSWORD(HttpStatus.BAD_REQUEST, "아이디나 비밀번호 패턴이 맞지 않습니다."),
    ROOM_NOT_FOUND(HttpStatus.BAD_REQUEST, "숙소를 찾을 수 없습니다."),
    COMMENT_NOT_FOUND(HttpStatus.BAD_REQUEST, "댓글을 찾을 수 없습니다."),
    INVALID_USER(HttpStatus.BAD_REQUEST, "작성자만 삭제/수정할 수 있습니다."),
    FILE_UPLOAD_FAILED(HttpStatus.BAD_REQUEST, "파일 업로드 실패"),
    FILE_DELETE_FAILED(HttpStatus.BAD_REQUEST, "파일 삭제 실패"),


    /* 409 CONFLICT : Resource의 현재 상태와 충돌, 보통 중복된 데이터 존재 */
    DUPLICATE_RESOURCE(HttpStatus.CONFLICT, "데이터가 이미 존재합니다."),
    ALREADY_CLICKED_LIKE(HttpStatus.CONFLICT, "이미 좋아요를 눌렀습니다"),
    ALREADY_CANCEL_LIKE(HttpStatus.CONFLICT, "이미 좋아요 취소를 눌렀습니다"),
    EXIST_USER(HttpStatus.CONFLICT, "중복된 이메일입니다."),
    EXIST_NICK(HttpStatus.CONFLICT, "중복된 닉네임입니다."),
    UNMATCH_CODE(HttpStatus.BAD_REQUEST, "코드가 일치하지 않습니다."),


    /* 200 SUCCESS */
    SIGN_UP(HttpStatus.OK, "회원가입에 성공했습니다."),
    LOG_IN(HttpStatus.OK, "로그인에 성공했습니다"),
    LIKE(HttpStatus.OK, "좋아요 성공"),
    CANCEL_LIKE(HttpStatus.OK, "좋아요 취소"),
    DELETE_POST(HttpStatus.OK, "숙소를 삭제하였습니다"),
    DONE_POST(HttpStatus.OK, "숙소 등록 완료"),
    DELETE_COMMENT(HttpStatus.OK, "댓글을 삭제하였습니다"),
    NICKNAME(HttpStatus.OK, "사용 가능한 닉네임입니다."),
    EMAIL(HttpStatus.OK, "사용 가능한 이메일입니다."),
    EMAIL_CONFIRM(HttpStatus.OK, "해당 이메일로 회원가입 가능합니다."),
    UPDATE(HttpStatus.OK, "게시글 수정 완료"),
    MATCH_CODE(HttpStatus.OK, "코드가 일치합니다.");


    /* 401 UNAUTHORIZED : 인증되지 않은 사용자 */
//    INVALID_AUTH_TOKEN(HttpStatus.BAD_REQUEST, "토큰이 유효하지 않습니다."),
//    INVALID_AUTH_TOKEN(HttpStatus.UNAUTHORIZED, "권한 정보가 없는 토큰입니다."),
//    UNAUTHORIZED_USER(HttpStatus.UNAUTHORIZED, "존재하지 않는 유저입니다."),

    /* 404 NOT_FOUND : Resource를 찾을 수 없음 */
//    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 유저 정보를 찾을 수 없습니다."),
//    REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "로그아웃 한 유저입니다."),
//    NOT_FOLLOW(HttpStatus.NOT_FOUND, "팔로우 중이지 않습니다"),

    /* 500 INTERNAL_SERVER_ERROR */
//    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버가 없습니다.");


    private final HttpStatus httpStatus;
    private final String detail;
}

 

GlobalExceptionHandler

package com.cloneweek.hanghaebnb.util.exception;

import lombok.extern.slf4j.Slf4j;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import static com.cloneweek.hanghaebnb.util.exception.StatusMsgCode.BAD_ID_PASSWORD;
import static com.cloneweek.hanghaebnb.util.exception.StatusMsgCode.DUPLICATE_RESOURCE;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(value = { ConstraintViolationException.class, DataIntegrityViolationException.class})
    protected ResponseEntity<ErrorResponse> handleDataException() {
        log.error("handleDataException throw Exception : {}", DUPLICATE_RESOURCE);
        return ErrorResponse.toResponseEntity(DUPLICATE_RESOURCE);
    }

    @ExceptionHandler(value = { CustomException.class })
    protected ResponseEntity<ErrorResponse> handleCustomException(CustomException e) {
        log.error("handleCustomException throw CustomException : {}", e.getStatusMsgCode());
        return ErrorResponse.toResponseEntity(e.getStatusMsgCode());
    }

    @ExceptionHandler(value = {MethodArgumentNotValidException.class})
    protected ResponseEntity<ErrorResponse> processValidationException(MethodArgumentNotValidException e) {
        log.error("handleCustomException throw CustomException : {}", e.getMessage());
        return ErrorResponse.toResponseEntity(BAD_ID_PASSWORD);
    }

}

 

ErrorResponse

package com.cloneweek.hanghaebnb.util.exception;

import lombok.Builder;
import lombok.Getter;
import org.springframework.http.ResponseEntity;

@Getter
@Builder
public class
ErrorResponse {

    private final int statusCode;
    private final String message;

    public static ResponseEntity<ErrorResponse> toResponseEntity(StatusMsgCode statusMsgCode) {
        return ResponseEntity
                .status(statusMsgCode.getHttpStatus())
                .body(ErrorResponse.builder()
                        .statusCode(statusMsgCode.getHttpStatus().value())
                        .message(statusMsgCode.getDetail())
                        .build());
    }
}

 

Swagger란?

API에 대한 정보를 전달하기 위해 일일이 문서화하는 것은 매우 번거로운 작업이다. 매번 Rest API를 개발하고 수정하면서 API문서를 변경하는 것은 개발자의 생산성 또한 떨어뜨린다.

Swagger는 이러한 API문서를 자동으로 생성하여 HTML로 만들어주는 오픈 소스 프레임워크이다.

 

build.gradle

// Swagger
implementation 'io.springfox:springfox-swagger2:3.0.0'
implementation 'io.springfox:springfox-swagger-ui:3.0.0'
implementation 'io.springfox:springfox-boot-starter:3.0.0'

 

SwaggerConfig

package com.cloneweek.hanghaebnb.util.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

@Configuration
@EnableSwagger2
@EnableAsync
@EnableWebMvc
public class SwaggerConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("swagger-ui.html")
                .addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**")
                .addResourceLocations("classpath:/META-INF/resources/webjars/");
    }

    @Bean
    public Docket swagger() {
        return new Docket(DocumentationType.SWAGGER_2)
                .select()
                .apis(RequestHandlerSelectors.any())
                .paths(PathSelectors.any())
                .build()
                .apiInfo(apiInfo())
                .useDefaultResponseMessages(false);
    }

    private ApiInfo apiInfo() {
        ApiInfo apiInfo =
                new ApiInfo("항해bnb API", "항해 10기 B반 클론프로젝트 3조 API 명세서 입니다", "진짜 최종 ver", "https://github.com/hanghaebnb/BE", "contact", "3조 항해bnb 노션", "https://www.notion.so/eunsolan/3-bnb-a8edbe218a684cd2977937a5fc45fc7f");
        return apiInfo;
    }
}

 

application 실행 후 아래 링크에서 API명세 조회 가능 :) 

http://localhost:8080/swagger-ui/index.html#/


[ 참고 자료 ]

 

+ Recent posts