룸메이트 매칭 프로젝트 2주차

윤장원·2023년 6월 11일
0
post-thumbnail

2주차엔 기획 내용을 바탕으로 개발을 시작했다. 나는 룸메이트 구하는 게시글 기능 구현을 맡았다.
Article 엔티티는 다음과 같다.

@Entity
@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Article extends BaseEntity {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Integer id;

  @ManyToOne
  @JoinColumn(name = "user_id")
  @ToString.Exclude
  private User user;

  private String title;
  private String content;

  @Enumerated(EnumType.STRING)
  private Seoul region;

  @Enumerated(EnumType.STRING)
  private Period period;

  private Integer price;

  @Enumerated(EnumType.STRING)
  private Gender gender;

  @Column(name = "is_recruit")
  private boolean isRecruiting;
  private boolean isDeleted;
}

지역, 기간, 성별은 Enum 타입으로 설정했고, isDeleted을 설정해서 게시글 삭제는 Soft Delete 방식을 사용하기로 했다. 게시글 등록, 수정, 삭제, 페이징 처리해서 게시글 불러오기, 입력받은 태그로 게시글 검색하기, 게시글 전체 개수 가져오기, 게시글에 찜 등록/삭제하기, 게시글 찜했는지 여부 가져오기 기능 구현을 했다.

페이징 처리해서 게시글 불러오기, 입력받은 태그로 게시글 검색하기, 게시글 전체 개수 가져오기는 Querydsl을 사용해서 구현했다. Querydsl을 사용하면 문자가 아닌 코드로 쿼리를 작성함으로써, 컴파일 시점에 문법 오류를 쉽게 확인할 수 있고, 자동 완성 등 IDE의 도움을 받을 수 있다는 장점이 있다. Querydsl을 사용하기 위해 build.gradle에 다음과 같이 작성했다.

buildscript {
	ext {
		queryDslVersion = "5.0.0"
	}
}

plugins {

	id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

...

dependencies {
	
	//Querydsl
	implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
	implementation "com.querydsl:querydsl-apt:${queryDslVersion}"


}

...

def querydslDir = "$buildDir/generated/querydsl"

querydsl {
	jpa = true
	querydslSourcesDir = querydslDir
}
sourceSets {
	main.java.srcDir querydslDir
}
compileQuerydsl {
	options.annotationProcessorPath = configurations.querydsl
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
	querydsl.extendsFrom compileClasspath
}

clean.doLast {
	file(querydslDir).deleteDir()
}

이렇게 작성을 하고 build를 해주면 build/generated/querydsl에 Q클래스가 생성이 된다.

그 다음 QuerydslConfig 파일을 작성했다.

@Configuration
public class QuerydslConfig {

  @PersistenceContext
  private EntityManager entityManager;

  @Bean
  public JPAQueryFactory jpaQueryFactory() {
    return new JPAQueryFactory(entityManager);
  }
}

ArticleRepositoryCustom, ArticleRepositoryImpl 파일을 생성했다.

ArticleRepositoryCustom

public interface ArticleRepositoryCustom {

  Page<Article> getArticle(Pageable pageable, boolean isRecruiting);

  Page<Article> getArticleByFilter(Pageable pageable, boolean isRecruiting, String region,
      String period, String price, String gender);

  Integer getArticleTotalCnt();
}

ArticleRepositoryImpl

@RequiredArgsConstructor
public class ArticleRepositoryImpl implements ArticleRepositoryCustom {

  private final JPAQueryFactory queryFactory;
  QArticle qArticle = QArticle.article;
  QUser qUser = QUser.user;
  QLikeArticle qLikeArticle= QLikeArticle.likeArticle;
  @Override
  public Page<Article> getArticle(Pageable pageable, boolean isRecruiting) {

    var articleQuery = queryFactory.selectFrom(qArticle)
        .join(qArticle.user, qUser)
        .fetchJoin()
        .orderBy(qArticle.createDate.desc())
        .where(qArticle.isDeleted.eq(false))
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .distinct();

    if (isRecruiting) {
      articleQuery = articleQuery.where(qArticle.isRecruiting.eq(true));
    }

    List<Article> articleList = articleQuery.fetch();

    return new PageImpl<>(articleList);
  }

  public Page<Article> getArticleByFilter(Pageable pageable, boolean isRecruiting, String region,
      String period, String price, String gender) {
    QArticle qArticle = QArticle.article;
    QUser qUser = QUser.user;

    var articleQuery = queryFactory.selectFrom(qArticle)
        .join(qArticle.user, qUser)
        .fetchJoin()
        .orderBy(qArticle.createDate.desc())
        .where(qArticle.isDeleted.eq(false))
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .distinct();

    if (isRecruiting) {
      articleQuery = articleQuery.where(qArticle.isRecruiting.eq(true));
    }

    if (!"상관 없음".equals(region)) {
      articleQuery = articleQuery.where(qArticle.region.eq(Seoul.fromValue(region)));
    }

    if (!"상관 없음".equals(period)) {
      articleQuery = articleQuery.where(qArticle.period.eq(Period.fromValue(period)));
    }

    if (!"상관 없음".equals(price)) {
      articleQuery = articleQuery.where(qArticle.price.loe(Integer.parseInt(price)));
    }

    if (!"상관 없음".equals(gender)) {
      articleQuery = articleQuery.where(qArticle.gender.eq(Gender.fromValue(gender)));
    }

    List<Article> articleList = articleQuery.fetch();

    return new PageImpl<>(articleList);
  }

  @Override
  public Integer getArticleTotalCnt() {
    return Math.toIntExact(queryFactory.select(qArticle.count())
        .from(qArticle)
        .where(qArticle.isDeleted.eq(false))
        .fetchFirst());
  }
}

이렇게 fetchjoin을 사용하거나 여러 개의 where 조건을 쓸 때 Querydsl을 사용하니까 코드 작성하기 더 쉬웠고, Query를 작성할 때 문법적인 실수를 줄일 수 있어서 좋았다.

ArticleController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/articles")
public class ArticleController {

  private final ArticleService articleService;

  @PostMapping
  public ResponseEntity<?> postArticle(
      @AuthenticationPrincipal User user,
      @RequestBody ArticleRegisterForm form
  ) {
    articleService.postArticle(user, ArticleRegisterForm.toDto(form));
    return ApiResponse.builder().code(ResponseCode.RESPONSE_CREATED).toEntity();
  }

  @PutMapping("/{id}")
  public ResponseEntity<?> putArticle(
      @AuthenticationPrincipal User user,
      @PathVariable int id,
      @RequestBody ArticleEditForm form
  ) {
    articleService.putArticle(user, id, ArticleEditForm.toDto(form));
    return ApiResponse.builder().code(ResponseCode.RESPONSE_CREATED).toEntity();
  }

  @DeleteMapping("/{id}")
  public ResponseEntity<?> deleteArticle(
      @AuthenticationPrincipal User user,
      @PathVariable int id
  ) {
    articleService.deleteArticle(user, id);
    return ApiResponse.builder().code(ResponseCode.RESPONSE_SUCCESS).toEntity();
  }

  @GetMapping
  public ResponseEntity<?> getArticleByPageable(
      @RequestParam("page") Integer page,
      @RequestParam("size") Integer size,
      @RequestParam(value = "isRecruiting", defaultValue = "true") boolean isRecruiting
  ) {
    var result = articleService.getArticleByPageable(page, size, isRecruiting);
    return ApiResponse.builder().code(ResponseCode.RESPONSE_SUCCESS).data(result).toEntity();
  }

  @GetMapping("/total")
  public ResponseEntity<?> getArticleTotalCnt() {
    var result = articleService.getArticleTotalCnt();
    return ApiResponse.builder().code(ResponseCode.RESPONSE_SUCCESS).data(result).toEntity();
  }

  @GetMapping("/filter")
  public ResponseEntity<?> getArticleByFilter(
      @RequestParam("page") Integer page,
      @RequestParam("size") Integer size,
      @RequestParam(value = "isRecruiting", defaultValue = "true") boolean isRecruiting,
      @RequestParam("region") String region,
      @RequestParam("period") String period,
      @RequestParam("price") String price,
      @RequestParam("gender") String gender
  ) {
    var result = articleService.getArticleByFilter(page, size, isRecruiting, region, period, price,
        gender);
    return ApiResponse.builder().code(ResponseCode.RESPONSE_SUCCESS).data(result).toEntity();
  }

  @PostMapping("/favorites/{id}")
  public ResponseEntity<?> postArticleFavorite(
      @AuthenticationPrincipal User user,
      @PathVariable int id
  ) {
    var result = articleService.postArticleFavorite(user, id);
    return ApiResponse.builder().code(ResponseCode.RESPONSE_CREATED).data(result).toEntity();
  }

  @GetMapping("/favorites/{id}")
  public ResponseEntity<?> getArticleFavorite(
      @AuthenticationPrincipal User user,
      @PathVariable int id
  ) {
    var result = articleService.getArticleFavorite(user, id);
    return ApiResponse.builder().code(ResponseCode.RESPONSE_SUCCESS).data(result).toEntity();
  }
}

원래 Controller로 User의 정보를 받아올 때 Header에 JWT 토큰을 받아서 토큰으로 User 정보를 조회하는 방식을 사용했는데, @AuthenticationPrincipal 어노테이션을 사용하면 서버에서 User 정보를 조회하는 코드를 사용하지 않아도 User를 받아올 수 있다고 팀원으로부터 설명을 듣고 해당 방식을 적용했다.

ArticleControllerTest

@WebMvcTest(ArticleController.class)
@ExtendWith(MockitoExtension.class)
class ArticleControllerTest {

  @MockBean
  private ArticleService articleService;

  @Autowired
  private MockMvc mockMvc;

  @Autowired
  private ObjectMapper objectMapper;

  @Test
  @DisplayName("글 등록 성공")
  @WithMockUser
  void postArticleSuccess() throws Exception {
    //given
    ArticleRegisterForm form = ArticleRegisterForm.builder()
        .title("글 제목")
        .region("강남")
        .period("1개월 ~ 3개월")
        .price(3000000)
        .gender("남성")
        .content("글 내용")
        .build();

    //when
    //then
    mockMvc.perform(post("/api/articles")
            .with(SecurityMockMvcRequestPostProcessors.csrf())
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(form)))
        .andExpect(status().isCreated())
        .andDo(print());
  }
  
  ...

컨트롤러 테스트 코드를 작성하는 부분에서 많은 어려움이 있었다. 백엔드 Repository에서 CI-CD를 구현해서 main 브랜치로 PR 요청을 날릴 경우 자동으로 테스트 코드를 테스트 돌리게 설정했는데 내가 원래 작성한 컨트롤러 테스트 코드는 @SpringBootTest를 사용했다. 그래서 테스트하는 과정에서 에러가 계속 발생했고, @SpringBootTest 대신 @WebMvcTest를 사용하기로 결정했다. 하지만 우리 프로젝트는 Spring Security를 사용해서 인증을 구현했기 때문에 테스트 코드에 인증 정보를 적용시켜야 했다. 여러 번의 코드 수정과 검색을 통해서 @WithMockUser 어노테이션과 MockMvc로 요청을 보낼 때 .with(SecurityMockMvcRequestPostProcessors.csrf() 를 추가해서 인증을 테스트 코드에 적용시킬 수 있었다.

이번 주에 프론트엔드와 백엔드 기능 구현이 어느정도 진행이 된 후, 프론트엔드와 백엔드 API 연동을 테스트해보기로 했다. 다른 로컬 환경과 분리된 Repository를 사용했기 때문에 프론트엔드 로컬에서 백엔드 서버에 접속할 수 있는 방법이 필요했다. 그래서 예전 프로젝트 때 프론트엔드와 연동하는 데 사용했던 Ngrok을 사용해보기로 했다. Ngrok을 통해 서버용 로컬 호스트를 tunneling을 통해(Secure tunnels to localhost) 외부에서 연결할 수 있다. Ngrok으로 통신을 할 때 CORS 에러가 발생해서 이를 해결하기 위한 방법이 필요했다. 먼저 CorsConfig 파일을 작성했다.

@Configuration
public class CorsConfig implements WebMvcConfigurer {

  @Override
  public void addCorsMappings(CorsRegistry registry) {
    registry.addMapping("/**")
        .allowedOrigins("*")
        .allowedMethods("GET", "POST", "PUT", "DELETE")
        .allowedHeaders("*");
  }
}

그리고 Ngrok 홈페이지에서 나와 있는 방법을 사용했다. Ngrok에 가입을 한 후 authtoken을 받아서 Ngrok 설정 파일에 추가를 했다.

ngrok config add-authtoken "authtoken 값"

그리고 Ngrok이 설치된 폴더에서 터미널을 열고 다음 명령어로 실행했다.

ngrok http --host-header=rewrite 8080

이렇게 설정을 하여 통신을 했을 때 로그인, 회원가입, 글 작성하기 부분은 프론트엔드와 성공적으로 통신할 수 있었다. 하지만 GET 요청을 보내는 API에 대해서는 여전히 CORS에러가 발생했다. 그 원인은 Ngrok을 사용하여 해당 주소에 GET 요청을 보내는 경우 block site에 들어가게 되기 때문이다. 이것을 해결하기 위해 프론트엔드에서 서버에 요청을 보낼 때 헤더에 다음과 같이 Ngrok 설정을 추가했다.

const response = await fetch(
          `${userArticle}?page=1&size=12&isRecruiting=true`,
          {
            method: "GET",
            headers: new Headers({
              "ngrok-skip-browser-warning": "69420",
            }),
          },
        )

해당 내용을 헤더에 추가하고 요청을 보내서 GET 요청에 대해서도 CORS 에러를 해결할 수 있었다.

프론트엔드와 백엔드 협업 프로젝트를 진행하니 프론트엔드의 입장을 고려해서 개발을 진행하고, 좀 더 좋은 서비스를 제공하기 위한 방법을 생각하게 되어서 재밌었다. 그리고 통신을 하는 과정에서도 프론트엔드 화면에 서버에서 보내주는 결과가 눈으로 보여지는 것을 보고 뿌듯했다.

0개의 댓글