OISO

Hvvany·2023년 5월 28일
0

Our time is On

서비스 주소: https://hvvany.github.io/oiso/

깃허브 프론트엔드: https://github.com/hvvany/OISO_FE

깃허브 백엔드 : https://github.com/da010228/OISO_BE

나만의 여행 계획 서비스

Our time is On
국내 여행지를 모바일로 손쉽게 찾을 수 있는 웹 어플리케이션 서비스


웹사이트 주소

👉🏻 https://hvvany.github.io/oiso/


팀 구성


Jun Hwan


Da Eun


프로젝트 수행 도구

프론트엔드

백엔드

데이터 베이스


프로젝트 일정

2023.05.05 ~ 2023.05.26 (3주)


프로젝트 구성

템플릿 구성도 ( Figma )

https://www.figma.com/file/JjtNWTaMerWoLtUOchbd3s/OISO?type=design&node-id=0-1&t=L2JQESTuvP96NJwv-0



Use Case Diagram


모델 구성도 ( mySQL Workbench )

Class Diagram

API 명세서

우선 순위이름메소드요청 URL상세 내용참고사항
1user로그인 페이지GET/로그인 페이지 이동
1user로그인POST/user/login로그인 요청
1user회원가입 페이지GET/user/signup회원가입 페이지 이동
1user회원가입POST/user/signup회원가입 정보 전송
1user마이페이지GET,PUT,DELETE/user/{user_id}내 정보 수정 삭제 조회
1user멤버 관리페이지GET/user/member멤버정보 조회_관리자만 가능admin
1user멤버 정보 수정PUT,DELETE/user/member/{user_id}멤버 정보 수정 삭제 _ 관리자만 가능admin
1trip전체 메인 화면GET/trip여행 메인 화면지역 및 필터 선택 가능한 조회 페이지
1trip지역별 관광정보 화면GET/trip/info지역별 관광정보 검색 화면관광지, 숙박, 음식, 문화시설, 공연, 쇼핑, 여행코스
1trip지역별 관광정보POST/trip/info지역별 관광정보 검색 조회관광지, 숙박, 음식, 문화시설, 공연, 쇼핑, 여행코스
1article핫플레이스 공유 게시판 목록GET/article/hotplace핫플 게시글 리스트
1article핫플레이스 공유 게시판 글작성GET,POST/article/hotplace/new핫플 게시글 작성 페이지
1article핫플레이스 디테일, 수정, 삭제GET,PUT,DELETE/article/hotplace/{article_id}핫플 게시글 디테일, 수정, 삭제
1article핫플레이스 좋아요POST/article/hotplace/{article_id}/like핫플 게시글 좋아요
*********************
2article공유게시판 페이지GET/article/board전체 게시글 보는 페이지
2article공유게시판 글작성GET,POST/article/board/new게시글 작성 페이지
2article공유게시판 글 디테일, 수정, 삭제GET,PUT,DELETE/article/board/{article_id}게시글 디테일, 수정 삭제
2article공유게시판 글 좋아요POST/article/board/{article_id}/like게시글 좋아요
2article공지사항 조회GET/article/bulletin전체 공지사항 보기
2article공지사항 작성GET,POST/article/bulletin/new공지사항 작성admin
2article공지사항 디테일, 수정, 삭제GET,PUT,DELETE/article/bulletin/{article_id}공지사항 디테일, 수정, 삭제admin
2article공지사항 좋아요POST/article/bulletin/{article_id}/like공지사항 좋아요
*********************
3mytrip나의 여행 계획 페이지GET/mytrip/{id}나의 여행계획 페이지처음에 계획 가져오면서 지역 정보 활용하여 날씨 뉴스 정보 첫 번째 지역 기준. 도시 선택 시 아래의 날씨, 뉴스 get 요청을 통해 지역에 맞는 정보 가져오기 요청
3mytrip나의 여행 계획 추가POST/mytrip/plan/{id}나의 여행계획 추가하기
3mytrip나의 여행 계획 수정PUT/mytrip/plan/{id}나의 여행계획 수정하기
3mytrip나의 여행 계획 삭제DELETE/mytrip/plan/{id}나의 여행계획 삭제하기
3mytrip나의 여행 상세 계획 페이지GET/{id}/{sido_code}나의 여행계획 디테일 페이지
3mytrip나의 여행 상세 계획 추가 여부GET/detail/{id}/{contentId}나의 계획 추가 여부
3mytrip나의 여행 상세 계획 추가POST/{id}나의 여행 계획에 추가하기
3mytrip나의 여행 상세 계획 삭제DELETE/{id}/{contentId}나의 여행 계획에서 삭제하기
3mytrip나의 여행 상세 계획 순서 변경PUT/{detatilNo}/{sequence}나의 여행계획 순서 변경하기
*********************
4mytrip관광지 날씨 정보 가져오기GET/mytrip/weather/{sido_code}관심 지역 날씨 정보 가져오기

서비스 소개

주요 기능

  • 여행지 지역별 검색
  • 자유 게시판
  • 핫플레이스 공유
  • 공지사항
  • 나만의 여행 계획
  • openAI API 활용 맞춤 여행지 추천

회원가입



로그인 & 메인페이지

JWT token을 활용하여 로그인 인증을 한다.



공지사항

관리자 권한을 가진 사람은 공지사항 등록 가능



게시판 & 핫플레이스

게시판의 CRUD를 구현하였다. 특히 핫플레이스는 좋아요 수를 기준으로 정렬하여준다. 메인 페이지는 3위까지 보여준다.



OpenAI API 활용

여행지 날씨

내가 등록한 여행지의 날씨를 상단 스와이퍼로 정보를 제공한다.

나만의 여행 계획

관광지 정보 조회

한국관광공사의 API를 활용하여 지역별 관광지, 문화시설, 숙소 등의 다양한 정보를 제공한다.



개발 이슈

1. router는 엥간하면 name 쓰자

router를 사용할 때 name으로 사용하면 충돌 위험이 낮아진다.
경로를 직접 입력할 경우 의도하지 않게 get요청이 보내지는 현상이 일어났다.

2. 스프링 부트 이미지 저장 시 절대경로!

스프링 부트는 서버를 가상으로 돌리기 때문에 경로가 바뀐다. 따라서 진짜 tomcat서버의 경로를 통해 저장하는 방법을 사용하는 등의 절대 경로를 통해 저장해야한다.
가장 좋은 방법은 aws s3 같은 클라우드 서비스를 이용하여 서버에 파일을 바로 올리는 것이 좋다.
다음은 파일 저장을 위해 시도해본 작업들이다.

1. html 에서 input type=”file”로 입력 받기

ArticleBoardNewView

<input type="file" name="files" id="files" multiple="multiple" />

그러면 다음과 같이 input 요소가 생긴다

2. API로 전송 시 JSON 말고 formData로 보낸다

처음에 이미지를 JSON 형태로 고칠려고 했으나 불가능.

JSON을 formData객체에 함께 담아서 보내줘야 한다.

ArticleBoardNewView 의 method 내부 post요청 부분

...
methods: {
    sendArticle() {
      if ((this.content_text != "") & (this.content_title != "")) {
				// 폼 데이터로 보내줘야 하므로 객체 생성
        let formData = new FormData();
				// json형태로 바로 보내던 데이터를 일단 변수에 저장
        let data = {
          id: this.userInfo.userId,
          title: this.content_title,
          content: this.content_text,
        };
				// 그냥 files까지만 보내면 배열이 갈 줄 알았으나 배열 모양을 한 딕셔너리더라...{0:{이름:ㅇㅇ},1:...
	      // 얕은 복사(Array.from)을 통해 배열로 데이터를 바꾸어 저장해준다
				Array.from(document.querySelector("#files").files).forEach((file) => {
          formData.append("files", file);  // files로 같은 이름에 데이터를 append 하면 배열로 들어간다
          console.log(file);
        });

        formData.append(
          "key",
          new Blob([JSON.stringify(data)], { type: "application/json" })
        );

			//  => 위의 과정을 거치면 formData에는 'files'이름의 이미지 배열과 
			//                                 'key'이름의 기존json데이터(이메일,제목 등)담김

				// 아래는 POST 요청 부분
        http
          .post("/article/board/new", formData, {
            headers: {
              "Content-Type": "multipart/form-data",  //기존의 json 대신 formData 설정
            },
            transformRequest: [
              function () {
                return formData;
              },
            ],
          })
          .then(({ status }) => {
            if (status == 200) {
              this.$router.push({ name: "board" });
            }
          });
      } else {
        alert("정보를 입력해 주세요");
      }
    },
  },

...

Blob : Binary Large Object. → javascript에서 이미지, 음성 등 대용량 데이터 다루는 객체

3. 백엔드 Controller 로 넘어온 formData처리

ArticleController의 boardnew post 부분

@PostMapping("/board/new")
	int postBoard(@RequestPart(value = "key") Article article, @RequestPart(value = "files", required = false) MultipartFile[] files) throws Exception {
// formData는 RequestPart 어노테이션을 통해 MultipartFile 클래스로 데이터를 받는다. 여기서 required = false 를 안해주니 에러가 발생하였다.

		System.out.println("article : " + article + ", files : " + files);
// 프린트 : article : Article [articleNo=0, title=asdf, id=ssafy, content=asdf, regTime=null], files : [Lorg.springframework.web.multipart.MultipartFile;@4049d099
		
		String realPath = "/Users/hvvany/Desktop/OISO_BE/last_pjt/trip/src/main/resources/static/imgs";  // 스프링 부트에서 파일 저장 시 상대경로로 하면 경로 못찾음
		String today = new SimpleDateFormat("yyMMdd").format(new Date());
		File folder = new File(realPath);
		if (!folder.exists()) {
			folder.mkdirs();
		}
		List<FileInfo> fileInfos = new ArrayList<FileInfo>();
		for (MultipartFile mfile : files) {
			FileInfo fileInfo = new FileInfo();
			String originalFileName = mfile.getOriginalFilename();
// 파일 경로 없으면 폴더 생성
			if (!originalFileName.isEmpty()) {
				String saveFileName = UUID.randomUUID().toString()  // UUID는 이미지 이름 중복 방지 위해 랜덤하게 생성된 고유값
						+ originalFileName.substring(originalFileName.lastIndexOf('.'));
				fileInfo.setSaveFolder(today);
				fileInfo.setOriginFile(originalFileName);
				fileInfo.setSaveFile(saveFileName);

				mfile.transferTo(new File(folder,saveFileName));
//			FileCopyUtils.copy(mfile.getInputStream(), new FileOutputStream(realPath + Paths.get(saveFileName).toFile()));
			}
			fileInfos.add(fileInfo);
		}
// article 객체에 이미지 파일 정보도 저장해준다 (배열 형태)
		article.setFileInfos(fileInfos);

// Service 단으로 데이터 전송하고 성공 여부는 int로 받는다.
		int cnt = service.postBoard(article);
		return cnt;
	}

이슈 정리

  1. ‘files’를 찾지 못한다

    ⇒ @RequestPart(value = "files", required = false) required = false 를 추가해주니 해결

  2. 상대 경로 문제

    java.io.IOException: java.io.FileNotFoundException: /private/var/folders/c3/hxgrbq710ndfsnm459mczt1c0000gn/T/tomcat.80.2385627806756500432/work/Tomcat/localhost/ROOT/src/main/resources/static/imgs/a14d2d56-c734-40f0-9c06-6d30cf39b48b.png (No such file or directory)

    스프링 부트 내부의 폴더에 저장하기 위해 상대 경로로 static 폴더에 접근해보려 했으나 경로를 찾지 못하고 에러가 발생했다.

    찾아보니 프로젝트 외부에 절대 경로를 사용하여 경로를 지정해주어야 한다고 한다…

  3. 위의 경로 문제로 알게 된 다른 사실

    mfile.transferTo(new File(folder,saveFileName));
    
    FileCopyUtils.copy(mfile.getInputStream(), new FileOutputStream(Paths.get(saveFileName).toFile()));

    둘 다 된다

Article (Dto) 참고하기 _ 수정함

public class Article {

	private int articleNo;
	private String title;
	private String id;
	private String content;
	private String regTime;
	private List<FileInfo> fileInfos;   // 파일 저장 위해서 기존 필드에 추가된 부분
	
	public Article() {
		super();
	}
	public Article(int articleNo, String title, String id, String content, String regTime, List<FileInfo> fileInfos) {
		super();
		this.articleNo = articleNo;
		this.title = title;
		this.id = id;
		this.content = content;
		this.regTime = regTime;
		this.fileInfos = fileInfos;      // 파일 저장 위해서 기존 필드에 추가된 부분
	}

4. Service 단 구현

ArticleService

...
int postBoard(Article article) throws Exception;
...

ArticleServiceImpl

...
@Override
	@Transactional
	public int postBoard(Article article) throws Exception {
		List<FileInfo> file = article.getFileInfos();
		if (file != null && !file.isEmpty()) {
			for (int i = 0; i < file.size(); i++) {
				String fileName = file.get(i).getOriginFile();
				System.out.println("Uploaded file name: " + fileName);
			// 프린트 : 스크린샷 2023-05-17 오후 10.00.26.png   b8c1a584-72fa-4bac-b0d7-be92b2194432.png
			}
			// 게시글 작성하는 매퍼 and 이미지 저장하는 매퍼 동시에 article을 보내주어 처리한다. 결과물은 and 연산으로 둘 다 0 아니면 성공 처리하게 구현했다.
			return articleMapper.postBoard(article) & articleMapper.fileRegister(article);
		}
		return 0;
	}
...

5. Mapper 단 구현

article.xml

...

	<insert id="postBoard">
		insert into board (id, title, content)
		values (#{id}, #{title}, #{content})
		<selectKey resultType="int" keyProperty="articleNo" order="AFTER">
		SELECT LAST_INSERT_ID()
		</selectKey>
	</insert>
	
	<insert id="fileRegister" parameterType="Article">
		insert into file_info (article_no, save_folder, original_file, save_file)
		values
		<foreach collection="fileInfos" item="fileinfo" separator=" , ">
			(#{articleNo}, #{fileinfo.saveFolder}, #{fileinfo.originFile}, #{fileinfo.saveFile})
		</foreach>
	</insert>

...

SELECT LAST_INSERT_ID() : 게시글 등록하면서 자동 생성된 auto increment 값을 받아와서 이미지 저장할 때 article_No에 값을 저장한다.

위의 게시글 명령 후 아직 커밋? 이 안된 상태이므로 정보를 가져와서 파일 업로드에 사용할 수 있다.

6. MySQL 단 구현

use enjoytrips;
drop table `file_info`;
CREATE TABLE `file_info` (
  `article_no` int NOT NULL,
  `save_folder` varchar(100) DEFAULT NULL,
  `original_file` varchar(100) DEFAULT NULL,
  `save_file` varchar(100) DEFAULT NULL
  );

이미지 저장을 위한 테이블 따로 생성

3. swiper swipe 이슈

cityInfoSwiper에서 발생

props로 넘어오는 (or vuex로 가져온) 데이터가 전부 저장되기 전에 swiper가 형성되어서 다음 슬라이드로 넘어가지지 않았다.

vue의 양방향 바인딩을 이용하여 swiper-slide로 사용하는 weather의 크기를 이용해 다음과 같은 옵션을 추가하였다.

:key="weather.length”

length가 증가할 때마다 swiper가 다시 구성요소를 렌더링하고 새 인스턴스를 생성하며 swiper를 다시 로드한다.

프로젝트 후기

  • 김준환 : 1학기 과정을 통해 배운 내용을 직접 서비스를 구현하며 부딪힐 수 있는 좋은 경험이었습니다.
    자바를 처음 배우며 힘든 점도 많았지만 이제 spring boot로 백엔드 서버를 구축하고 vue로 SPA를 제작하는 수준까지 성장할 수 있었습니다. 좋은 교수님과 동기들 덕분에 큰 성장을 이루었습니다. 감사합니다 :)

  • 강다은 : 실습을 통해서 배운 내용을 바로 적용하며 웹 개발에 대한 이해도를 높일 수 있었습니다.
    프론트엔드와 백엔드 전부 내 손을 거쳤다는 생각에 자신감을 끌어 올려 줬습니다.
    마지막으로 갈수록 잘 만들고 싶다는
    생각이 강해져서 점점 의욕이 생겼는데 끝까지 노력해준 페어님께 감사의 인사를… (꾸벅)

profile
Just Do It

0개의 댓글