[ Project · Travel Road ] 해시 태그 로직 구현하기
해시 태그 로직 구현하기
함께 작업하려는 인원을 해시태그로 입력 받아 공유된 회원은 함께 작업 할 수 있는 로직을 구현하려고 합니다.
우선 앤티티 객체는 필요한 게시판과 태그 그리고 두개의 테이블을 연결 시켜주는 해시 태그 객체를 생성했습니다.
1. 서버 구성
1-1. Entity
📑 Entity : 게시판(Category), 해시태그 (Hashtag) , 태그 (Tag)
📑Category.java
package com.example.travel.domain;
import lombok.*;
import org.springframework.data.jpa.repository.Modifying;
import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.List;
@NoArgsConstructor
@AllArgsConstructor
@ToString(exclude = "items")
@Getter
@Builder
@Entity
public class Category extends BaseEntity{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long categoryNo;
private Long userTravelNo; //작성자 No
private String categoryName; // 카테고리 이름
private LocalDateTime dateStart;
private LocalDateTime dateEnd;
private String categoryArea; // 시/도
private String categoryAreaDetails; // 군구
private boolean categorySave; // 임시저장 여부 최종 저장 전에는 false -> true
private boolean categoryOpen; // 카테고리 외부 공개 여부
}
📑Hashtag.java
package com.example.travel.domain;
import lombok.*;
import javax.persistence.*;
import javax.validation.constraints.NotNull;
import java.util.List;
@NoArgsConstructor
@AllArgsConstructor
@ToString(exclude = {"tag"})
@Getter
@Builder
@Entity
public class Hashtag {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long hashId;
@NotNull
private Long categoryId;
@NotNull
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
private List<Tag> tag;
}
📑Tag.java
package com.example.travel.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Builder
@Entity
public class Tag {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "tag_id")
private Long id;
private String name;
}
1-2. Repository
각 앤티티의 저장소 레파지토리를 생성하였습니다.
위와 같이 앤티티를 생성하면 실제 테이블은 4개가 생성이 되는데, hashtag_tag 테이블이 하나가 더 생성됩니다.
테이블을 살펴보면 hashtag 에서는 hesh_id 와 category_id를 받고 있고,
자동으로 생성된 hashtag_tag 테이블을 살펴보면 hashtag_hash_id (hesh_id) 와 tag_tag_id(tag_id)를 저장하고 있습니다.
Table : category, hashtag, hashtag_tag, tag
📑CategoryRepository
package com.example.travel.repository.travel;
import com.example.travel.domain.Category;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface CategoryRepository extends JpaRepository<Category,Long> {
@Query(value = "select c from Category c where c.userTravelNo=:no and c.categorySave = false ORDER BY c.createdAt")
List<Category> getCategoryTemList(@Param(value = "no") Long no);
}
📑HashtagRepository
package com.example.travel.repository.travel;
import com.example.travel.domain.Hashtag;
import com.example.travel.domain.Tag;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface HashtagRepository extends JpaRepository<Hashtag,Long> {
@Modifying
@Query(value = "delete from hashtag_tag h where h.tag_tag_id = :id",nativeQuery = true)
int deleteByHashIdAndTag(@Param(value="id") Long id);
}
📑TagRepository.java
package com.example.travel.repository.travel;
import com.example.travel.domain.Tag;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TagRepository extends JpaRepository<Tag,Long> {
}
1-3. Service
게시판을 생성할때 카테고리, 해시태그, 태그 객체 3개를 저장하게되는데, 제거 시에도 함께 delete 시켜주어야 합니다.
이때 주의 할점은 자동으로 생성되었던 hashtag_tag 에서 tag를 제거 해주어야한다는 것입니다.
위에 레파지토리에서 delete 메서드(deleteByHashIdAndTag)를 생성해두어서 해당 메서드를 통해 제거 시켜주었습니다.
📑CategoryService.java
package com.example.travel.service.travel;
import com.example.travel.domain.Category;
import com.example.travel.domain.Tag;
import com.example.travel.dto.travel.CategoryDTO;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
public interface CategoryService {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
List<CategoryDTO> getCategoryTemList(Long no);
CategoryDTO categorySave(CategoryDTO categoryDTO);
boolean categoryDelete(Long no);
int categoryDays(String start, String end);
default CategoryDTO categoryEntityToDto(Category category){
// 문자열
String startDate = category.getDateStart().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
String endDate = category.getDateEnd().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
CategoryDTO result = CategoryDTO.builder()
.userTravelNo(category.getUserTravelNo())
.categoryNo(category.getCategoryNo())
.categoryName(category.getCategoryName())
.dateStart(startDate)
.dateEnd(endDate)
.categoryArea(category.getCategoryArea())
.categoryAreaDetails(category.getCategoryAreaDetails())
.categorySave(category.isCategorySave())
.categoryOpen(category.isCategoryOpen())
.build();
return result;
}
default Category categoryDtoToEntity(CategoryDTO categoryDTO){
// 문자열
String start = categoryDTO.getDateStart() + " 00:00:00.000";
String end = categoryDTO.getDateEnd() + " 00:00:00.000";
LocalDateTime startDate = LocalDateTime.parse(start, formatter);
LocalDateTime endDate = LocalDateTime.parse(end, formatter);
Category result = Category.builder()
.userTravelNo(categoryDTO.getUserTravelNo())
.categoryNo(categoryDTO.getCategoryNo())
.categoryName(categoryDTO.getCategoryName())
.dateStart(startDate)
.dateEnd(endDate)
.categoryArea(categoryDTO.getCategoryArea())
.categoryAreaDetails(categoryDTO.getCategoryAreaDetails())
.categorySave(categoryDTO.isCategorySave())
.categoryOpen(categoryDTO.isCategoryOpen())
.build();
return result;
}
}
📑CategoryServiceImpl.java
package com.example.travel.service.travel;
import com.example.travel.domain.Category;
import com.example.travel.domain.Hashtag;
import com.example.travel.domain.Tag;
import com.example.travel.dto.travel.CategoryDTO;
import com.example.travel.repository.travel.CategoryRepository;
import com.example.travel.repository.travel.HashtagRepository;
import com.example.travel.repository.travel.TagRepository;
import groovyjarjarpicocli.CommandLine;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Period;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Log4j2
@RequiredArgsConstructor
@Service
public class CategoryServiceImpl implements CategoryService {
final CategoryRepository categoryRepository;
final HashtagRepository hashtagRepository;
final TagRepository tagRepository;
@Override
public List<CategoryDTO> getCategoryTemList(Long no) {
log.info("임시 저장된 내용 전달");
List<CategoryDTO> result = new ArrayList<>();
try{
List<Category> userTravelNo = categoryRepository.getCategoryTemList(no);
if (userTravelNo.isEmpty()){
log.info("없으면 null");
return result;
}
log.info("있으면 리스트 전달");
log.info(userTravelNo);
result = userTravelNo.stream().map(item -> categoryEntityToDto(item)).collect(Collectors.toList()); return result;
}catch (Exception e){
e.printStackTrace();
}
return result;
}
@Transactional
@Override
public CategoryDTO categorySave(CategoryDTO categoryDTO) {
log.info("카테고리 임시저장 로직-----------");
Long userTravelNo = categoryDTO.getUserTravelNo();
log.info(userTravelNo);
// 1. 카테고리저장
// 2. 태그 저장
// 3. 해쉬태그 저장
List<CategoryDTO> categoryTemList = getCategoryTemList(categoryDTO.getUserTravelNo());
int size = categoryTemList.size();
if (size > 4){
log.info("카테고리 임시 저장 리스트 5개 이상 있으면");
return null;
}
categoryDTO.setCategorySave(false); //초기 저장 시 임시저장
Category category = categoryDtoToEntity(categoryDTO);
log.info("저장된 category : {}",category);
Category result = categoryRepository.save(category); // 1. 카테고리저장
//2. 태그저장
if (!categoryDTO.getTags().isEmpty()){
log.info("tag가 있으면 저장");
List<String> tags = categoryDTO.getTags();
List<Tag> tagList = tags.stream().map(i -> Tag.builder().name(i).build()).collect(Collectors.toList());
for(int i=0; i < tagList.size(); i++){
tagRepository.save(tagList.get(i)); // 2. 태그 저장
}
Hashtag hashtag = Hashtag.builder()
.categoryId(result.getCategoryNo())
.tag(tagList)
.build();
hashtagRepository.save(hashtag); // 3. 해쉬태그 저장
}
CategoryDTO dto = categoryEntityToDto(result);
return dto;
}
@Transactional
@Override
public boolean categoryDelete(Long no) {
log.info("카테고리 제거 로직 ---------------");
Optional<Category> categorys = categoryRepository.findById(no);
Category category = categorys.get();
categoryRepository.delete(category); //3. 카테고리제거
// 1. 태그 제거
// 2. 해쉬태그 제거
// 3. 카테고리 제거
log.info(no); // 카테고리 번호
Optional<Hashtag> hashtag = hashtagRepository.findById(no);
log.info("hashtag : {}",hashtag);
if (hashtag.isPresent()){
log.info("카테고리 연관 해시태그, 태그 제거 로직 -------------");
Hashtag hashtagResult = hashtag.get();
Long categoryId = hashtagResult.getCategoryId(); // 카테고리 번호
List<Tag> tag = hashtagResult.getTag();
if (!tag.isEmpty()){ // 리스트가 존재하면 제거
log.info("리스트 존재");
for(int i=0;i<tag.size();i++){
Tag tagItem = tag.get(i);
int result = hashtagRepository.deleteByHashIdAndTag(tagItem.getId()); // 해쉬태그안에 tag
System.out.println(result);
tagRepository.delete(tagItem); // 1. tag 제거
}
hashtagRepository.delete(hashtagResult); // 2. 해쉬태그 제거
} else {
hashtagRepository.delete(hashtagResult); // 3. 해쉬태그 제거
}
return true;
} // 해쉬태그, 태그 제거 end
log.info("카테고리가 없으면 false 반환");
return false;
}
@Override
public int categoryDays(String start, String end) {
log.info("D-day 계산");
// 문자열
start = start + " 00:00:00.000";
end = end + " 00:00:00.000";
LocalDateTime startDate = LocalDateTime.parse(start, formatter);
LocalDateTime endDate = LocalDateTime.parse(end, formatter);
log.info("------------------");
Period period = Period.between(LocalDate.from(startDate), LocalDate.from(endDate)); // 비교
int days = period.getDays();
log.info(days);
days = days + 1;
log.info("days : {}",days);
return days;
}
}
2. 해시태그 리스트 ( 회원 아이디 ) 정보 전달 로직
처음 게시판을 생성할 때 화면에서 해시태그 리스트를 Ajax를 활용해 비동기식으로 전달받아와 사용하고 있습니다.
get 방식으로 데이터를 가져와 whielist(해시 태그 리스트) 에 값을 전달하고 있습니다.
2-1. 게시판 화면 : Ajax 해시태그 리스트
// 회원 아이디 리스트
$(document).ready(function (){
$.ajax({
type : 'get',
url : '/user/tag',
success : function (data) {
whitelist = data;
}
});
});
2-2. 전송을 요청 받은 서버
- Controller
서비스 로직에서 List클래스로 값을 받아오고 있습니다.
📑UserApi.java
@GetMapping("/tag")
public List<String> tagList(){
log.info("회원 아이디 리스트 전달");
List<String> tagList = userService.userList();
return tagList;
}
- Service
📑UserServiceImpl.java
@Override
public List<String> userList() {
List<String> userTravelList = userRepository.getUserTravelList();
return userTravelList;
}
- Repository
📑 UserRepository.java
//전체 회원 아이디 리스트
@Query(value = "select m.user_id from user_travel m", nativeQuery = true)
List<String> getUserTravelList();
2-3. 정보를 전달 받은 프론트 : Tagify.js
비동기 방식으로 정보를 다시 전달 받은 화면에서는 해당 정보를
Tagify.js 라이브러리를 사용해서 태그를 입력시 뿌려주고 선택, 취소 가능한 기능을 보여주고 있습니다.
See the Pen Tagify - Text Input Example by Yair Even Or (@vsync) on CodePen.
Tagify - Tags input Component
In this example, the field is pre-ocupied with 3 tags, and last tag is not included in the whitelist, and will be removed because the enforceWhitelist setting flag is set to true 🚩 This example is very interesting because it shows another layer of compl
yaireo.github.io
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/basic}">
<th:block layout:fragment="content">
<div class="content_nav" th:object="${category}">
<!-- start : navbar -->
<th:block th:replace="layout/navBarTravel::navBarTravel"></th:block>
<div class="content">
<h2 class="tit text-center mb-4">
나만의 여행을 만들어보세요.
</h2>
<form id="categoryForm" th:action="@{/travel/category}" th:method="post" class="box travel_add_box content_box">
<input th:field="*{userTravelNo}" th:id="*{userTravelNo}" th:name="*{userTravelNo}" th:value="*{userTravelNo}" type="hidden">
<table>
<colgroup>
<col style="width:100px">
<col style="width:calc(100% - 100px)">
</colgroup>
<tbody>
<tr>
<th>여행명 <span class="txt_info ck">*</span></th>
<td><input th:field="*{categoryName}" th:id="*{categoryName}" th:name="*{categoryName}" class="form-control" type="text" placeholder="가평 가족여행"></td>
</tr>
<tr>
<th>기간 <span class="txt_info ck">*</span></th>
<td>
<div class="input-group input-daterange">
<input th:field="*{dateStart}" th:id="*{dateStart}" th:name="*{dateStart}" type="text" class="form-control" placeholder="출발 일" readonly>
<input th:field="*{dateEnd}" th:id="*{dateEnd}" th:name="*{dateEnd}" type="text" class="form-control" placeholder="종료 일" readonly>
</div>
</td>
</tr>
<tr>
<th>지역 <span class="txt_info ck">*</span></th>
<td>
...
</td>
</tr>
<tr>
<th>함께</th>
<td>
<!-- 해시 태그 정보를 저장할 input 태그. (textarea도 지원) -->
<input th:field="*{tags} "th:id="*{tags}" th:name="*{tags}" class='some_class_name form-control ' data-blacklist='.NET,PHP' type="text" placeholder="아이디를 입력하세요" />
</td>
</tr>
</tbody>
</table>
<div class="form-check form-switch mt-4 pt-4 border-top">
...
</div>
...
<button id="btnCategorySave" type="button" class="w-100 btn btn-primary mt-4">일정 만들기</button>
</form>
</div>
</div>
<!-- Modal -->
...
<!-- end :Modal -->
<!-- 태그 관련 소스 다운 -->
<script src="https://unpkg.com/@yaireo/tagify"></script>
<!-- 폴리필 (구버젼 브라우저 지원) -->
<script src="https://unpkg.com/@yaireo/tagify/dist/tagify.polyfills.min.js"></script>
<link href="https://unpkg.com/@yaireo/tagify/dist/tagify.css" rel="stylesheet" type="text/css" />
<!-- 해쉬 태그 -->
<script type="text/javascript">
let inputElm = document.querySelector('input[name=tags]')
let whitelist = [];
// 회원 아이디 리스트
$(document).ready(function (){
$.ajax({
type : 'get',
url : '/user/tag',
success : function (data) {
whitelist = data;
}
});
});
// initialize Tagify on the above input node reference
let tagify = new Tagify(inputElm, {
enforceWhitelist: true,
// make an array from the initial input value
whitelist: inputElm.value.trim().split(/\s*,\s*/),
maxTags: 10, // 최대 허용 태그 갯수
dropdown: {
maxItems: 20, // 드롭다운 메뉴에서 몇개 정도 항목을 보여줄지
classname: "tags-look", // 드롭다운 메뉴 엘리먼트 클래스 이름. 이걸로 css 선택자로 쓰면 된다.
enabled: 1, // 단어 몇글자 입력했을떄 추천 드롭다운 메뉴가 나타날지
closeOnSelect: false // 드롭다운 메뉴에서 태그 선택하면 자동으로 꺼지는지 안꺼지는지
}
})
// Chainable event listeners
tagify.on('add', onAddTag) //태그 추가 될때
.on('remove', onRemoveTag) //태그가 제거될때
.on('input', onInput) //태그가 입력되고 있을경우
.on('edit', onTagEdit) // 입력된 태그 수정
.on('invalid', onInvalidTag) // 허용되지 않는 태그일경우
.on('click', onTagClick) // 해시태그 클릭
.on('focus', onTagifyFocusBlur) // 포커스 될경우
.on('blur', onTagifyFocusBlur) // 포커스 잃을 경우
.on('dropdown:hide dropdown:show', e => console.log(e.type))// 드롭다운 메뉴가 사라질경우
.on('dropdown:select', onDropdownSelect) // 드롭다운 메뉴에서 아이템을 선택할 경우
let mockAjax = (function mockAjax(){
var timeout;
return function(duration){
clearTimeout(timeout); // abort last request
return new Promise(function(resolve, reject){
timeout = setTimeout(resolve, duration || 700, whitelist)
})
}
})()
// tag added callback
function onAddTag(e){
console.log("onAddTag: ", e.detail);
console.log("original input value: ", inputElm.value)
tagify.off('add', onAddTag) // exmaple of removing a custom Tagify event
}
// tag remvoed callback
function onRemoveTag(e){
console.log("onRemoveTag:", e.detail, "tagify instance value:", tagify.value)
}
// on character(s) added/removed (user is typing/deleting)
function onInput(e){
console.log("onInput: ", e.detail);
console.log("onInput value: ", e.detail.value);
tagify.settings.whitelist.length = 0; // reset current whitelist
tagify.loading(true).dropdown.hide.call(tagify) // show the loader animation
// get new whitelist from a delayed mocked request (Promise)
mockAjax()
.then(function(result){
// replace tagify "whitelist" array values with new values
// and add back the ones already choses as Tags
tagify.settings.whitelist.push(...result, ...tagify.value)
// render the suggestions dropdown.
tagify.loading(false).dropdown.show.call(tagify, e.detail.value);
})
}
function onTagEdit(e){
console.log("onTagEdit: ", e.detail);
}
// invalid tag added callback
function onInvalidTag(e){
console.log("onInvalidTag: ", e.detail);
}
// invalid tag added callback
function onTagClick(e){
console.log(e.detail);
console.log("onTagClick: ", e.detail);
}
function onTagifyFocusBlur(e){
console.log(e.type, "event fired")
}
function onDropdownSelect(e){
console.log("onDropdownSelect: ", e.detail)
}
</script>
<!-- 폼저장 -->
<script type="text/javascript">
...
</script>
</th:block>
</html>