Spring Project

9mond·2023년 11월 2일
0
post-thumbnail

✈️Tourist

한국 관광 공사 API 데이터를 통한 국내 여행지 확인 및 원하는 도시의 여행지와 테마를 선택할 수 있고, 여행 계획을 통해서 일정을 계획하고 국내 여행을 계획 중인 사람들을 위한 복합적인 여행 정보 제공 서비스를 구현 하였습니다.

🏄흐름도

🕸️엔티티 관계도

✨주요 기능 소개

📌관광지 게시판 목록 페이지

📜TouristController.java

public class TouristController {
   @GetMapping("/detail/{contentId}")
   public String touristDetail(@PathVariable("contentId") Long contentId, Model model,
         @AuthenticationPrincipal(
         expression = "#this == 'anonymousUser' ? null : #this") MemberDetails memberDetails)
         throws IOException {
      // contentId : 공공 데이터 관광지의 고유 번호(ID)
      // 공공데이터 포털에서 상세 내용 요청 
      TouristDTO touristDTO = dataPortalService.findOne(contentId);
      // DB 처리 - 저장또는 조회
      // 공공데이터의 모든 데이터를 전부 db에 넣을 수 없기 때문에, 얼마나 있는 지 모르고 너무 많기 때문..
      Board board = boardService.findOne(contentId, touristDTO);
      
      // 좋아요 처리
      // 북마크 처리
      // 댓글

      model.addAttribute("item", touristDTO);
      return "tourist/touristDetail";
   }
}

📜BoardService.java

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BoardService {
   @Transactional
	public Board findOne(Long contentId, TouristDTO touristDTO) {
		Board board = findOne(contentId);
      // 조회가 되면 그대로 쓰고, 조회가 안되면 새로 만든다.
      // 댓글과 좋아요, 북마크와 연관 짓기 위해서..
		if(board == null){
         board = new Board();
         Tourist tourist = Tourist.builder()
            .contentId(contentId)
            .title(touristDTO.getTitle())
            .address(touristDTO.getAddress1())
            .image(touristDTO.getImage1())
            .build();
         board.setTourist(tourist);
         boarderRepository.save(board);
      }
		return boarderRepository.findByContentId(contentId);
	}
}

📜BoarderRepository.java

public interface BoarderRepository extends JpaRepository<Board, Long> {

	@Query("SELECT b FROM Board b WHERE b.tourist.contentId = :contentId")
	public Board findByContentId(@Param("contentId") Long contentId);
	public Board findBoardById(Long contentId);
}

🔒스프링 시큐리티(Spring Security)

  • 스프링 시큐리티는 스프링 프레임워크에서 제공하는 보안 프레임워크.

📜WebSecurityConfig.java

  • 스프링 시큐리트 설정 클래스
/* 
@Configuration
스프링의 설정 정보를 포함하고 있음을 나타냄

@Bean
빈 객체를 생성, 빈 객체는 스프링 컨테이너에 등록되어 다른 빈 객체에서 참조할 수 있음.

@EnableWebSecurity
스프링 시큐리티의 설정 정보를 포함하는 클래스임을 나타냄.
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {

   // 소셜(OAuth2) 로그인 성공 후, 처리 되는 로직을 수행하는 서비스단
   private final CustomOAuthUserService oAuthUserService;

   // 회원의 비밀번호를 DB에 저장하기 전, 해쉬 알고리즘으로 암호화를 시킬 때, 사용되는 객체
   @Bean
   public PasswordEncoder passwordEncoder(){
      return new BCryptPasswordEncoder();
   }

   /* SecurityFilterChain는 스프링 시큐리티에서 사용되는 인터페이스로,
      HttpServletRequest와 매칭되는 필터 체인을 정의한다.
      이 필터 체인은 FilterChainProxy를 구성하는 데 사용된다.
   */
   @Bean
   public SecurityFilterChain configure(HttpSecurity http) throws Exception{
      // HttpSecurity에 빌더 패턴으로 보안 옵션들을 설정한다.
      /* authorizeHttpRequests 유저의 권한에 따라 요청(request)에 대한 접근 가능 여부를 판별.
         .hasAuthority("USER") USER 권한을 가진 회원만 접근 허용
         .anonymous() 비회원인 경우만 접근 허용
         .permitAll() 회원 여부에 상관 없이 접근 허용
      */
      http.authorizeHttpRequests((req) -> req
            .requestMatchers("/member/mypage**", "planner/**").hasAuthority("USER")
            .requestMatchers("/member/join**", "/member/login").anonymous()
            .anyRequest().permitAll())
         // formLogin 로그인 시
         .formLogin(formLogin -> formLogin
            .loginPage("/member/login")
            .defaultSuccessUrl("/"))
         // logout 로그 아웃 시
         .logout((logout) -> logout
            .logoutRequestMatcher(new AntPathRequestMatcher("/member/logout"))
            .logoutSuccessUrl("/")
            .invalidateHttpSession(true))
         // oauth2Login 소셜 로그인 시
         .oauth2Login((oauth) -> oauth
            .loginPage("/member/login")
            .userInfoEndpoint((endpoint)->endpoint
               .userService(oAuthUserService)));
      return http.build();
   }
}

🔑 로그인 - interface UserDetailsService

📜MemberService.java

스프링 Security에 formLogin를 설정할 경우, Controller단에서 로그인 인증을 위한 RequestMapping을 따로 지정하지 않아도, loadUserByUsername(String username) 메소드로 유저 이름을 입력받아 해당 유저의 인증 정보(UserDetails)를 반환합니다.

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {
   @Override
   public UserDetails loadUserByUsername(String username) 
		   throws UsernameNotFoundException {
      Member member = memberRepository.findByUsername(username);
      List<GrantedAuthority> authorities = new ArrayList<>();
      authorities.add(new SimpleGrantedAuthority("USER"));
      return new MemberDetails(member, authorities);
   }
}

📜MemberDetails.java

UserDetails 클래스는 유저의 세부 정보와 권한 정보를 포함하는 유저 세부 정보 클래스.

public class MemberDetails implements UserDetails, OAuth2User {
   // 회원 객체
   private Member member;
   // 권한
   private final Set<GrantedAuthority> authorities;
   // OAuth의 속성
   private Map<String, Object> attributes;

   public MemberDetails(Member member, Collection<? extends GrantedAuthority> authorities){
      this.member = member;
      this.authorities = Set.copyOf(authorities);
   }

   public MemberDetails(Member member,
   Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes){
      this.member = member;
      this.authorities = Set.copyOf(authorities);
      this.attributes = attributes;
   }
}

🟡카카오 소셜 로그인을 위한 설정

📜application.properties

# KaKao OAuth2 설정
spring.security.oauth2.client.registration.kakao.client-id={카카오 REST API 키}
spring.security.oauth2.client.registration.kakao.client-secret={카카오 로그인 Client Secret 키}
spring.security.oauth2.client.registration.kakao.redirect-uri={카카오 로그인 Redirect URI}
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.kakao.client-authentication-method=client_secret_post
spring.security.oauth2.client.registration.kakao.client-name=Kakao
spring.security.oauth2.client.registration.kakao.scope=profile_nickname,profile_image,account_email

# Kakao Provider 등록
spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me
spring.security.oauth2.client.provider.kakao.user-name-attribute=id

🟨 카카오 로그인 시 카카오로 - interface OAuth2UserService

📜CustomOAuthUserService.java

@Service
@RequiredArgsConstructor
public class CustomOAuthUserService extends DefaultOAuth2UserService{

   private final MemberRepository memberRepository;

   @Override
   @Transactional
   public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
      OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
      OAuth2User oAuth2User = delegate.loadUser(userRequest);

      Map<String, Object> attributes = oAuth2User.getAttributes();
      String userId = String.valueOf(attributes.get("id"));

      Member member = memberRepository.findByUsername(userId);
      if(member == null){
         Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
         Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");

         String nickname = String.valueOf(profile.get("nickname"));
         String email = String.valueOf(kakaoAccount.get("email"));

         member = new Member();
         member.setCreateDate(LocalDateTime.now());
         member.setMemberType(MemberType.Kakao);
         member.setUsername(userId);
         member.setRealname(nickname);
         member.setEmail(email);
         memberRepository.save(member);
      }

      List<GrantedAuthority> authorities = new ArrayList<>();
      authorities.add(new SimpleGrantedAuthority("USER"));

      return new MemberDetails(member, authorities, attributes);
   }
   
}

📜oAuth2User.getAttributes() 예시

{
   id=1000000000, 
   connected_at=2023-10-30T02:00:00Z, 
   properties={
      nickname=이름, 
      profile_image=http://k.kakaocdn.net/dn/WTAaw/btssfTs84YC/.../img_640x640.jpg, 
      thumbnail_image=http://k.kakaocdn.net/dn/WTAaw/btssfTs84YC/.../img_110x110.jpg
      }, 
   kakao_account={
      profile_nickname_needs_agreement=false, 
      profile_image_needs_agreement=false, 
      profile={
         nickname=이름, 
         thumbnail_image_url=http://k.kakaocdn.net/dn/WTAaw/btssfTs84YC/.../img_110x110.jpg, 
         profile_image_url=http://k.kakaocdn.net/dn/WTAaw/btssfTs84YC/.../img_640x640.jpg, 
         is_default_image=false
         }, 
      has_email=true, 
      email_needs_agreement=false, 
      is_email_valid=true, 
      is_email_verified=true, 
      email=user@email.com
   }
}

⚙️북마크, 좋아요 설정 및 해제

📜touristDetail.html

<script th:inline="javascript">
   /*<![CDATA[*/
   const contentId = /*[[${item.contentId}]]*/;
   /*]]>*/
   // 북마크
   $("#bookmark").on("click", (e)=>{
      $.ajax({
         type: "GET",
         url: location.origin + "/tourist/detail/" + contentId + "/bookmark",
         success: function (response) {
            console.log("Bookmark", response);
            if(response){
               e.target.className = "btn btn-primary";
            } else {
               e.target.className = 'btn btn-outline-secondary';
            }
         },
         error: function (error) {
            console.error("Error occurred", error);
         }
      });
   });
  
 // 좋아요
   $("#like").on("click", (e)=>{
      $.ajax({
         type: "GET",
         url: location.origin + "/tourist/detail/" + contentId + "/like",
         success: function (response) {
            console.log("Liked", response);
            e.target.innerText = '좋아요 ' + response;
         },
         error: function (error) {
            console.error("Error occurred", error);
         }
      });
   });
</script>

📜TouristController.java

public class TouristController {
   // 북마크
   @ResponseBody
   @GetMapping("/detail/{contentId}/bookmark")
   public boolean bookmark(@PathVariable("contentId") Long contentId, 
         @AuthenticationPrincipal MemberDetails memberDetails) throws IOException{
      Member member = memberDetails.getMember();
      Board board = boardService.findOne(contentId);
      return bookmarkService.bookmarking(board, member);
   }

   // 좋아요
   @ResponseBody // html이 아닌 값 그대로를 응답 메서지 
   @GetMapping("/detail/{contentId}/like")
   public long like(@PathVariable("contentId") Long contentId, 
            @AuthenticationPrincipal MemberDetails memberDetails) throws IOException {
      Member member = memberDetails.getMember();
      Board board = boardService.findOne(contentId);
      boardService.like(board, member);
      return boardService.countLike(board);  // 해당 board의 like의 수를 출력
   }
}

📖북마크

📜BookmarkService.java

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BookmarkService {

   private final BookmarkRepository bookmarkRepository;
   
   @Transactional
	public boolean bookmarking(Board board, Member member) {

		Bookmark bookmark = bookmarkRepository.findByMemberAndBoard(member, board);
      // 북마크가 이미 존재할 경우, 조회된 북마크를 db에서 삭제하여 북마크를 해제한다.
		if (bookmark != null) {
			bookmarkRepository.delete(bookmark);
			return false;
		}
      // 북마크가 존재하지 않을 경우, 북마크를 db 추가하여 북마크를 설정한다.
		bookmark = new Bookmark();
		bookmark.setMember(member);
		bookmark.setBoard(board);
		bookmarkRepository.save(bookmark);
		return true;
	}
}

📜BookmarkReposotory.java

public interface BookmarkRepository extends JpaRepository<Bookmark, Long>{
   Bookmark findByMemberAndBoard(Member member, Board board);
}

👍좋아요

📜BoardService.java

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BoardService {

   private final LikeRepository likeRepository;

   // 좋아요 설정및 삭제
   @Transactional
	public boolean like(Board board, Member member) {

		Like like = likeRepository.findByMemberAndBoard(member, board);
		if (like != null) {
			likeRepository.delete(like);
			return false;
		}

		like = new Like();
		like.setMember(member);
		like.setBoard(board);
		likeRepository.save(like);
		return true;
	}

   // 해당 게시판의 좋아요 개수
	public Long countLike(Board board) {
		return likeRepository.countByBoard(board);
	}
}

📜LikeReposotory.java

public interface LikeRepository extends JpaRepository<Like, Long> {
   Like findByMemberAndBoard(Member member, Board board);
   long countByBoard(Board board);
}

🔎상세 페이지에 댓글, 좋아요 수, 북마크 표시

📜TouristController.java

@GetMapping("/detail/{contentId}")
public String touristDetail(@PathVariable("contentId") Long contentId, Model model,
      @AuthenticationPrincipal(
      expression = "#this == 'anonymousUser' ? null : #this") MemberDetails memberDetails)
      throws IOException {
   
   TouristDTO touristDTO = dataPortalService.findOne(contentId);
   Board board = boardService.findOne(contentId, touristDTO);
   // 좋아요 수 DTO에 값 대입.
   long likeCount = boardService.countLike(board);
   touristDTO.setLike(likeCount);

   // 회원 인증이 되었다면, 북마크 여부를 DTO에 대입.
   if(memberDetails != null){
      Bookmark bookmark = bookmarkService.findOne(memberDetails.getMember(), board);
      touristDTO.setBookmark(bookmark != null);
      model.addAttribute("memberId", memberDetails.getMember().getId());
   }

   // 댓글 목록 가져오기
   List<Comment> comments = commentService.getCommentsByContentId(contentId);
   model.addAttribute("comments", comments);
   return "tourist/touristDetail";
}

⌨️댓글 등록

📜touristDetail.html

<form method="post"
      sec:authorize="isAuthenticated()"
      th:action="@{/detail/{contentId}/comments(contentId=${item.contentId})}">
   <label>댓글 작성</label>
   <textarea name="comment" class="form-control" rows="3"></textarea>
   <button type="submit">등록</button>
</form>

✂️댓글 수정 삭제

📜touristDetail.html

<div>
   <div th:each="comment : ${comments}" th:id="${'comment'+comment.id}">
      <h4 th:text="${comment.member.realname}">이름</h4>
      <div th:text="${comment.commentText}" th:id="${'commentText'+comment.id}"
      th:contenteditable="${comment.member.id == memberId}"></div>
      
      <div sec:authorize="isAuthenticated()" th:if="${comment.member.id == memberId}">
         <button th:onclick="updateComment([[${comment.id}]])">수정</button>
         <button th:onclick="delecteComment([[${comment.id}]])">삭제</button>
      </div>
   </div>
</div>

📅여행 계획 생성

📜PlannerController.java

@Controller
@RequestMapping("/planner")
@RequiredArgsConstructor
public class PlannerController {
   private final PlannerService plannerService;

   // 여행 계획 생성
   @PostMapping
   public String create(
   @RequestParam String title, @AuthenticationPrincipal MemberDetails memberDetails){
      Member member = memberDetails.getMember();
      Planner planner = plannerService.save(title, member);
      
      return "redirect:/planner/" + planner.getId();
   }

   // 여행 계획 작성 페이지로 이동
   @GetMapping("/{plannerId}")
   public String wirtePage(@PathVariable Long plannerId, Model model){
      model.addAttribute("planner", plannerService.findById(plannerId));
      return "/planner/writePlan";
   }
}

// 서비스단과 리포지토리는 코드 설명 생략 - 좋아요, 북마크, 댓글 발표 파트와 구현이 거의 비슷하기 때문.

🗒️여행 계획 내 메모 추가

📜choicePlace.js

// 여행 계획 중복 선택 확인
const choicePlaceCheck = (id)=> {return contents.get(id) !== undefined};

// 장소가 선택될 경우
const choicePlace = (place)=> {
   const placeId = parseInt(place.id);
   kakaoMap.setCenter(new kakao.maps.LatLng(place.y, place.x));
   if(choicePlaceCheck(placeId)) {
      alert('이미 선택한 항목 입니다.');
      return;
   }
   
   let data = {
      memo : null,
      date : null,
      place : {
         id : placeId,
         name : place.place_name,
         address : place.address_name,
         road_address : place.road_address_name,
         url : place.place_url,
         phone : place.phone,
         x : parseFloat(place.x),
         y : parseFloat(place.y),
      },
   }

   $.ajax({
      type: "POST",
      url: location.pathname + "/content",
      data: JSON.stringify(data),
      contentType: "application/json; charset=utf-8",
      success: function(resp) {
         console.table(resp);
         createPlace(resp.place.id, resp);
      },
      error: function(xhr, status, error) {
         console.log(status, error);
         alert("여행지 추가 실패!");
      }
   })
};

🖍️여행 계획 메모 내용 반영 및 수정

// 내용 수정
const updateContent = (e, id) => {
   $.ajax({
      type: "POST",
      url: location.pathname + "/content/" + id,
      data: {
         memo : e.target.memo.value,
         date : e.target.date.value,
      },
      success: function(resp) {
         console.table(resp);
         alert("메모 작성 완료");
      },
      error: function(xhr, status, error) {
         console.log(status, error);
         alert("수정 실패!");
      }
   })
   return false;
};

✂️여행 계획 메모 삭제

// 내용 삭제
const deleteContent = (id, placeId, el) => {
   $.ajax({
      type: "DELETE",
      url: location.pathname + "/content/" + id,
      contentType: "application/json; charset=utf-8",
      success: function(resp) {
         console.table(resp);
         if(resp){
            el.empty();
            contents.delete(placeId);
         }
      },
      error: function(xhr, status, error) {
         console.log(status, error);
         alert("삭제 실패!");
      }
   })
   return false;
};

🧐해당 여행 계획의 메모 내용들 조회

// 페이지 시작 시, 여행 계획 메모 불러오기
window.onpageshow = () => {
   $.ajax({
      type: "GET",
      url: location.pathname + "/content",
      contentType: "application/json; charset=utf-8",
      success: function(resp) {
         console.table(resp);
         for(let i = 0, l = resp.length; i < l; i++){
            createPlace(resp[i].place.id, resp[i]);
         }
      },
      error: function(xhr, status, error) {
         console.log(status, error);
         alert("불러오기 실패!");
      }
   })
};

📜ContentController.java

@Controller
@RequestMapping("/planner/{plannerId}/content")
@RequiredArgsConstructor
public class ContentController {

   private final PlannerService plannerService;
   private final ContentService contentService;

   // 메모 내용 추가
   @ResponseBody
   @PostMapping
   public Content addContent(@PathVariable Long plannerId, @RequestBody Content content) {
      Planner planner = plannerService.findById(plannerId);
      return contentService.save(content, planner);
   }

   // 메모 내용 수정
   @ResponseBody
   @PostMapping("/{placeId}")
   public Content updateContent(@PathVariable Long placeId, 
         @RequestParam String memo, @RequestParam String date) {
      return contentService.update(placeId, memo, date);
   }

   // 메모 내용 삭제
   @ResponseBody
   @DeleteMapping("/{placeId}")
   public boolean deleteContent(@PathVariable Long placeId) {
      return contentService.delete(placeId);
   }

   // 메모 리스트 조회
   @ResponseBody
   @GetMapping
   public List<Content> getContentList(@PathVariable Long plannerId) {
      Planner planner = plannerService.findById(plannerId);
      return contentService.findAll(planner);
   }
}

// 서비스단과 리포지토리는 코드 설명 생략

✍️소감

Spring에 대해 몰랐던 기능들을 많이 알 수 있어서 유익한 시간이었습니다.
특히 Spring Security를 통해 로그인을 간편하게 구현할 수 있었다는 점이 인상 깊었습니다.
팀 내에 조장 역할을 하신 분이 중심이 되어서 팀원들을 잘 이끌어 주셔서 좋은 시너지 효과를 낼 수 있었던 것 같습니다.

Project Github 👉 https://github.com/team-enum/tourist_sites.git

profile
개발자

0개의 댓글

관련 채용 정보