신선식품 판매 쇼핑몰 http://15.164.244.62:8080/icmall/all/vegetable
- 2021년 9월 18일 ~ 10월 14일
- 팀장: 김종원
- 팀원: 강진, 정재욱, 배서하
- Java 11
- Spring Boot 4.12.1
- MyBatipse 1.2.4
- Jsoup
- MySQL 8.0.26
- Spring Security
- HTML, CSS, Jquery
- Javascript
이 서비스의 핵심 기능 은 기능입니다.
사용자는 단지 컨텐츠의 카테고리를 선택하고, URL만 입력하면 끝입니다.
이 단순한 기능의 흐름을 보면, 서비스가 어떻게 동작하는지 알 수 있습니다.
핵심 기능 설명 펼치기
-
URL 정규식 체크 📌 코드 확인
- Vue.js로 렌더링된 화면단에서, 사용자가 등록을 시도한 URL의 모양새를 정규식으로 확인합니다.
- URL의 모양새가 아닌 경우, 에러 메세지를 띄웁니다.
-
Axios 비동기 요청 📌 코드 확인
- URL의 모양새인 경우, 컨텐츠를 등록하는 POST 요청을 비동기로 날립니다.
-
요청 처리 📌 코드 확인
- Controller에서는 요청을 화면단에서 넘어온 요청을 받고, Service 계층에 로직 처리를 위임합니다.
-
결과 응답 📌 코드 확인
- Service 계층에서 넘어온 로직 처리 결과(메세지)를 화면단에 응답해줍니다.
-
Http 프로토콜 추가 및 trim() 📌 코드 확인
- 사용자가 URL 입력 시 Http 프로토콜을 생략하거나 공백을 넣은 경우,
올바른 URL이 될 수 있도록 Http 프로토콜을 추가해주고, 공백을 제거해줍니다.
- 사용자가 URL 입력 시 Http 프로토콜을 생략하거나 공백을 넣은 경우,
-
URL 접속 확인 📌 코드 확인
- 화면단에서 모양새만 확인한 URL이 실제 리소스로 연결되는지 HttpUrlConnection으로 테스트합니다.
- 이 때, 빠른 응답을 위해 Request Method를 GET이 아닌 HEAD를 사용했습니다.
- (HEAD 메소드는 GET 메소드의 응답 결과의 Body는 가져오지 않고, Header만 확인하기 때문에 GET 메소드에 비해 응답속도가 빠릅니다.)
-
Jsoup 이미지, 제목 파싱 📌 코드 확인
- URL 접속 확인결과 유효하면 Jsoup을 사용해서 입력된 URL의 이미지와 제목을 파싱합니다.
- 이미지는 Open Graphic Tag를 우선적으로 파싱하고, 없을 경우 첫 번째 이미지와 제목을 파싱합니다.
- 컨텐츠에 이미지가 없을 경우, 미리 설정해둔 기본 이미지를 사용하고, 제목이 없을 경우 생략합니다.
- 컨텐츠 저장 📌 코드 확인
- URL 유효성 체크와 이미지, 제목 파싱이 끝난 컨텐츠는 DB에 저장합니다.
- 저장된 컨텐츠는 다시 Repository - Service - Controller를 거쳐 화면단에 송출됩니다.
-
저는 이 서비스가 페이스북이나 인스타그램 처럼 가볍게, 자주 사용되길 바라는 마음으로 개발했습니다.
때문에 페이징 처리도 무한 스크롤을 적용했습니다. -
하지만 무한스크롤, 페이징 혹은 “더보기” 버튼? 어떤 걸 써야할까 라는 글을 읽고 무한 스크롤의 단점들을 알게 되었고,
다양한 기준(카테고리, 사용자, 등록일, 인기도)의 게시물 필터 기능을 넣어서 이를 보완하고자 했습니다. -
그런데 게시물이 필터링 된 상태에서 무한 스크롤이 동작하면,
필터링 된 게시물들만 DB에 요청해야 하기 때문에 아래의 기존 코드 처럼 각 필터별로 다른 Query를 날려야 했습니다.
기존 코드
/**
* 게시물 Top10 (기준: 댓글 수 + 좋아요 수)
* @return 인기순 상위 10개 게시물
*/
public Page<PostResponseDto> listTopTen() {
PageRequest pageRequest = PageRequest.of(0, 10, Sort.Direction.DESC, "rankPoint", "likeCnt");
return postRepository.findAll(pageRequest).map(PostResponseDto::new);
}
/**
* 게시물 필터 (Tag Name)
* @param tagName 게시물 박스에서 클릭한 태그 이름
* @param pageable 페이징 처리를 위한 객체
* @return 해당 태그가 포함된 게시물 목록
*/
public Page<PostResponseDto> listFilteredByTagName(String tagName, Pageable pageable) {
return postRepository.findAllByTagName(tagName, pageable).map(PostResponseDto::new);
}
// ... 게시물 필터 (Member) 생략
/**
* 게시물 필터 (Date)
* @param createdDate 게시물 박스에서 클릭한 날짜
* @return 해당 날짜에 등록된 게시물 목록
*/
public List<PostResponseDto> listFilteredByDate(String createdDate) {
// 등록일 00시부터 24시까지
LocalDateTime start = LocalDateTime.of(LocalDate.parse(createdDate), LocalTime.MIN);
LocalDateTime end = LocalDateTime.of(LocalDate.parse(createdDate), LocalTime.MAX);
return postRepository
.findAllByCreatedAtBetween(start, end)
.stream()
.map(PostResponseDto::new)
.collect(Collectors.toList());
}
- 이 때 카테고리(tag)로 게시물을 필터링 하는 경우,
각 게시물은 최대 3개까지의 카테고리(tag)를 가질 수 있어 해당 카테고리를 포함하는 모든 게시물을 질의해야 했기 때문에 - 아래 개선된 코드와 같이 QueryDSL을 사용하여 다소 복잡한 Query를 작성하면서도 페이징 처리를 할 수 있었습니다.
개선된 코드
/**
* 게시물 필터 (Tag Name)
*/
@Override
public Page<Post> findAllByTagName(String tagName, Pageable pageable) {
QueryResults<Post> results = queryFactory
.selectFrom(post)
.innerJoin(postTag)
.on(post.idx.eq(postTag.post.idx))
.innerJoin(tag)
.on(tag.idx.eq(postTag.tag.idx))
.where(tag.name.eq(tagName))
.orderBy(post.idx.desc())
.limit(pageable.getPageSize())
.offset(pageable.getOffset())
.fetchResults();
return new PageImpl<>(results.getResults(), pageable, results.getTotal());
}
npm run dev 실행 오류
- Webpack-dev-server 버전을 3.0.0으로 다운그레이드로 해결
$ npm install —save-dev [email protected]
vue-devtools 크롬익스텐션 인식 오류 문제
- main.js 파일에
Vue.config.devtools = true
추가로 해결 - vuejs/devtools-v6#190
ElementUI input 박스에서 `v-on:keyup.enter="메소드명"`이 정상 작동 안하는 문제
v-on:keyup.enter.native=""
와 같이 .native 추가로 해결
Post 목록 출력시에 Member 객체 출력 에러
- 에러 메세지(500에러)
- No serializer found for class org.hibernate.proxy.pojo.javassist.JavassistLazyInitializer and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationConfig.SerializationFeature.FAIL_ON_EMPTY_BEANS)
- 해결
- Post 엔티티에 @ManyToOne 연관관계 매핑을 LAZY 옵션에서 기본(EAGER)옵션으로 수정
프로젝트를 git init으로 생성 후 발생하는 npm run dev/build 오류 문제
$ npm run dev
npm ERR! path C:\Users\integer\IdeaProjects\pilot\package.json
npm ERR! code ENOENT
npm ERR! errno -4058
npm ERR! syscall open
npm ERR! enoent ENOENT: no such file or directory, open 'C:\Users\integer\IdeaProjects\pilot\package.json'
npm ERR! enoent This is related to npm not being able to find a file.
npm ERR! enoent
npm ERR! A complete log of this run can be found in:
npm ERR! C:\Users\integer\AppData\Roaming\npm-cache\_logs\2019-02-25T01_23_19_131Z-debug.log
- 단순히 npm run dev/build 명령을 입력한 경로가 문제였다.
태그 선택후 등록하기 누를 때 `object references an unsaved transient instance - save the transient instance before flushing` 오류
- Post 엔티티의 @ManyToMany에 영속성 전이(cascade=CascadeType.ALL) 추가
- JPA에서 Entity를 저장할 때 연관된 모든 Entity는 영속상태여야 한다.
- CascadeType.PERSIST 옵션으로 부모와 자식 Enitity를 한 번에 영속화할 수 있다.
- 참고
JSON: Infinite recursion (StackOverflowError)
H2 접속문제
- H2의 JDBC URL이 jdbc:h2:~/test 으로 되어있으면 jdbc:h2:mem:testdb 으로 변경해서 접속해야 한다.
컨텐츠수정 모달창에서 태그 셀렉트박스 드랍다운이 뒤쪽에 보이는 문제
- ElementUI의 Global Config에 옵션 추가하면 해결
- main.js 파일에
Vue.us(ElementUI, { zIndex: 9999 });
옵션 추가(9999 이하면 안됌)
- main.js 파일에
- 참고
HTTP delete Request시 개발자도구의 XHR(XMLHttpRequest )에서 delete요청이 2번씩 찍히는 이유
-
When you try to send a XMLHttpRequest to a different domain than the page is hosted, you are violating the same-origin policy. However, this situation became somewhat common, many technics are introduced. CORS is one of them.
In short, server that you are sending the DELETE request allows cross domain requests. In the process, there should be a **preflight** call and that is the **HTTP OPTION** call. So, you are having two responses for the **OPTION** and **DELETE** call. see [MDN page for CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS).
이미지 파싱 시 og:image 경로가 달라서 제대로 파싱이 안되는 경우
구글 로그인으로 로그인한 사용자의 정보를 가져오는 방법이 스프링 2.0대 버전에서 달라진 것
-
1.5대 버전에서는 Controller의 인자로 Principal을 넘기면 principal.getName(0에서 바로 꺼내서 쓸 수 있었는데, 2.0대 버전에서는 principal.getName()의 경우 principal 객체.toString()을 반환한다.
- 1.5대 버전에서 principal을 사용하는 경우
- 아래와 같이 사용했다면,
@RequestMapping("/sso/user") @SuppressWarnings("unchecked") public Map<String, String> user(Principal principal) { if (principal != null) { OAuth2Authentication oAuth2Authentication = (OAuth2Authentication) principal; Authentication authentication = oAuth2Authentication.getUserAuthentication(); Map<String, String> details = new LinkedHashMap<>(); details = (Map<String, String>) authentication.getDetails(); logger.info("details = " + details); // id, email, name, link etc. Map<String, String> map = new LinkedHashMap<>(); map.put("email", details.get("email")); return map; } return null; }
- 2.0대 버전에서는
- 아래와 같이 principal 객체의 내용을 꺼내 쓸 수 있다.
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) SecurityContextHolder .getContext().getAuthentication(); Map<String, Object> map = (Map<String, Object>) token.getPrincipal(); String email = String.valueOf(map.get("email")); post.setMember(memberRepository.findByEmail(email));
랭킹 동점자 처리 문제
- PageRequest의 Sort부분에서 properties를 "rankPoint"를 주고 "likeCnt"를 줘서 댓글수보다 좋아요수가 우선순위 갖도록 설정.
- 좋아요 수도 똑같다면..........
프로젝트 개발 회고 글: https://zuminternet.github.io/ZUM-Pilot-integer/