한국 관광 공사 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);
}
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();
}
}
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
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