해시태그
해시태그 하는 걸 생각하면 무엇이 가장 먼저 떠오를까?
그렇다. 인스타그램이나 트위터의 해시태그 기능이 떠오를 것이다. (마찬가지로 슛폼 콘텐츠 등.. 굉장히 여러 곳에서 사용되는 기능이다.)
해시태그를 구현할때 , 단순히 그냥 한 해시태그를 등록하게 설계해 검색으로 해당 해시태그가 등록된 글을 찾을 수 있게 구현을 할 순 있다.
하지만 단건으로 등록을 한다면?
해시태그의 본질은
내가 해당 주제에 대해서 검색을 했을 때, 해당 주제에 대해 쓰인 모든 글을 볼 수 있어야 하고, 마찬가지로 한 가지 주제가 아니라면 해시태그는 두 개 이상이 등록이 가능해야 한다.
단순히 기능 구현만을 위한 목적이라면 단건으로 등록해 게시글 엔티티에서 해시태그를 관리하게 도메인을 설계할 순 있다.
단건으로 설계를 해볼까?
단건으로 설계할 때는 굉장히 간단하다.
단순히 게시글 (Article) 엔티티에 varchar(50) 정도 되는 해시태그 칼럼을 배정해주면 된다.
이미지 출처 - 해시태그 구현하기 / juna-dev.log
이런식으로 하나만 등록해 구현하게 할 수 있다.
이 경우에 우리가 흔히 생각하는 해시태그의 기본 기능인 해당 해시태그가 등록된 게시글을 검색할 수 있다.
하지만 이게 맞을까?
해시태그는 말 그대로해당 주제에 대해서 단어나 문장별로 정리를 해주는 중간 역할이라고 볼 수 있다.
(퍼온 예제이다. 정국님 생일 축하드려요..)
이렇게 어떻게 보면 게시글 <-> 해시태그 가 서로 양방향 관계를 가진다고 볼 수 있다.
JPA에서는 이런 상황들을 위해 OneToOne부터 ManyToMany까지 다양한 양방향 맵핑을 지원해준다.
설계를 바꿔서 연관관계를 재설계해보자.
기존에 설계했던 도메인은 게시글에 하나의 해시태그 가 들어가는 구조였다.
이제 새로 해야 할 것은 다른 Hashtag 테이블을 만들어 Article 테이블과 연관관계를 설정해주는 것이다.
이렇게 단순 기능 구현을 위한 hashtag 엔티티를 새로 생성해주고,
따로 엔티티는 아니지만 JoinTable로 각각 게시글 , 해시태그 엔티티에 서로의 id 값을 FK로 잡을 수 있는 양방향 맵핑 테이블을 설정해준다.
@Entity
@Getter
@ToString
public class hashtag {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Setter
@Column(nullable = false)
private String hashtag;
@ToString.Exclude
@ManyToMany(mappedBy = "hashtags")
private Set<Article> articles = new LinkedHashSet<>();
이렇게 해시태그 도메인을 생성해주고, 한 해시태그에 여러 개의 게시글이 담겨올 수 있으니 HashSet으로 게시글을 담아주도록 ManyToMany 관계를 맺어준다.
(ToString.Exclude 는 무한 참조를 방지하기 위한 어노테이션이다.)
@ToString.Exclude
@JoinTable(
name = "article_hashtag",
joinColumns = @JoinColumn(name = "articleId"),
inverseJoinColumns = @JoinColumn(name = "hashtagId")
)
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private Set<hashtag> hashtags = new LinkedHashSet<>();
마찬가지로 게시글 엔티티에도 해시태그들을 모아 올 수 있게 ManyToMany로 해시태그를 담을 HashSet를 선언해주고 맵핑을 해준다.
@JoinTable은 JPA의 연관관계 맵핑을 위한 어노테이션으로 엔티티가 아닌 별도의 테이블을 생성해줘 연관관계를 맺을 수 있게 도와준다.
(해당 어노테이션 속 name 은 테이블 이름, joinColumns는 각각 연관관계를 맺을 테이블의 요소를 입력해주면 된다.)
여기서 cascad로 영속성 전이 설정을 해주지 않으면 SQL오류가 발생하니 꼭 설정해주자.
Dto 수정하기
기존 Dto 같은 경우에는 게시글에 Hashtag String으로 있기 때문에, 안건으로 게시글을 작성하거나 Response로 받아올 때 String으로 받아오도록 되어있었다.
그렇기 때문에 이제는 hashtag Set을 담아오도록 수정해야 한다.
그전에 HashtagRepository를 생성해주고, JpaRepository를 Extends 해준 뒤에, HashtagDto 부터 만들어보자.
HashtagDto 작성
public record HashtagDto(Long id, String hashtag) {
public static HashtagDto of(String hashtag) {
return new HashtagDto(null, hashtag);
}
public static HashtagDto of(Long id, String hashtag) {
return new HashtagDto(id, hashtag);
}
public static HashtagDto from(Hashtag entity) {
return new HashtagDto(
entity.getId(),
entity.getHashtag()
);
}
public Hashtag toEntity() {
return Hashtag.of(
id,
hashtag
);
}
필요에 따라서 record로 작성할 수 있고, 세터가 필요하다면 class 로 작성할 수 있다.
record 로 편하게 작성하는 쉬운 방법이 있다.
바로 유료 플러그인인 JpaBuddy 를 활용하는 것이다.
JpaBuddy
유료 플러그인이지만 한 달 동안 무료체험이 가능하다. (나 같은 경우에는 필요하다면 유료로 결제할 마음도 있을 만큼 유용하다.)
이렇게 인텔리제이상에 새로 만들기 탭에 강아지 모양 메뉴가 새로 생긴다. 온갖 기능이 있지만 지금 내 레벨에서는 Dto 생성할 때 활용하기 굉장히 좋았다.
이렇게 엔티티를 설정하면 레코드 형식으로 Dto를 생성해준다.
연관된 Dto들 수정해주기
이제 HashtagDto까지 생성해주었으니, 게시글을 등록할 때 전달해줄 RequestDto와 출력할 때 필요한 Response Dto들을 수정해준다.
이경우에는 크게 수정할 것들 없이 기존에 String Hashtag 이렇게 되어있는 것들을 HashtagDto 타입을 가진 HashSet으로 변경해주면 된다.
게시글을 등록할 때 사용하는 RequestDto 같은 경우에는, 여러 가지 방안을 생각할 필요가 있다.
해시태그 복수 등록
단순히 하나만 등록한다고 하면, #안녕 이런 식으로 등록할 수 있지만, 복수 등록할 때는 #안녕 #안녕하 #안녕하세 #안녕하세요 이렇게 입력을 했을 때 4개의 해시태그를 등록할 수 있도록 설계하면 좋을 것이다.
그래서 RequestDto 같은 경우에는 세터를 사용할 수 있게 레코드 형식이 아닌 클래스로 작성을 해줌으로써, String Hashtag를 입력받아오고, StringTokenizer로 '#' 별로 토큰을 생성해 HashtagDto.of(st.nextToken()) 을 활용했고, replaceAll로 공백을 제거해준 뒤에 입력시켜주었다.
비즈니스 로직 설계
해시태그 같은 경우에는 따로 해시태그를 사용하는 곳이 대부분 게시글에서 사용된다고 생각했기 때문에, 따로 hashtagService를 만들어주지 않고 ArticleService에 hashtag 관련 로직들을 설계해주었다.
이 경우에는 사실 ManyToMany로 임시 중간 테이블이 생성되는 상태이기 때문에 , 단순히 따로 HashtagService를 생성해서 해시태그를 저장해 줄 필요 없이 이미 맵핑이 되어있는 상태이기 때문에, 게시글만 저장해도 자동으로 hashtag까지 저장이 된다.
하지만 괜찮을까?
이렇게 순서대로 따라 하다 보면, 오류가 생긴다.
바로 중간 임시 테이블 (게시글이 post고 태그가 tag 면 post_tag라는 테이블이 자동으로 생성될 것이다.) 이 각각 연결관계마다 고유 id를 갖고 있지 않기 때문에 오류가 발생할 것이다.
대표적으로 한 게시물에 해시태그를 두 개 이상 등록하려 시도했을 때 오류가 발생할 것이고, 설계 자체가 잘못되었다고 생각하면 될 것 같다. (글쓴이가 어디서 글 보고 이게 잘못된 설계라고 생각하고 온 게 아니라 글쓴이가 직접 시행착오 겪어보니 구조 자체가 이상했었다..)
해시태그의 본질은 내가 여러 해시태그를 등록했을 때 그 해시태그가 등록된 게시물을 조회할 수 있어야 하는데 복수 등록에 오류가 발생한 는 게 가장 컸고
그다음으로 중간 관계 맵핑이 엔티티가 아닌 ManyToMany 임시 테이블이다 보니, 게시글을 삭제할 때 해시태그도 같이 삭제된다는 게 크다.
거기에 ManyToMany 관계를 맺어주는 것은 실무에서 사용하기엔 한계가 있다는 글을 자주 보았다.
Spring-boot JPA @ManyToMany 실무에서 사용하면 안 되는 이유
바로 위의 이유와 비슷한 것 같다. 세밀하게 테이블을 다룰 방법이 없기 때문에, ManyToMany를 없애고 중간 엔티티를 따로 만들어줘서 맵핑을 관리하도록 하는 방향으로 선택했다.
중간 엔티티 만들어주기
기존에 게시글에선 Hashtag타입이 담긴 hashset, 해시태그에선 Article타입이 담긴 Articles로 ManyToMany 관계를 맺어주는 구조에서,
해당 부분을 지우고, ArticleHashtag 엔티티를 생성해줘 해당 엔티티 필드에 Article과 Hashtag를 @JoinColumn으로 각각 게시글과 해시태그의 PK를 참조하는 방식으로 관계를 맺어주었다.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "article_id")
private Article article;
@ManyToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "hashtag_id")
private Hashtag hashtag;
영속성 설정을 꼭 해주자. 중간 엔티티는 게시글과 해시태그에서 참조를 받기 때문에 ManyToOne으로 설정해주면 된다.
각각 엔티티 연관관계를 수정해주고, Dto 또한 수정해주자
@OneToMany(mappedBy = "article")
@ToString.Exclude
@Setter
private Set<ArticleHashtag> hashtags = new HashSet<>();
@OneToMany(mappedBy = "hashtag")
@ToString.Exclude
private Set<ArticleHashtag> articles = new HashSet<>();
이렇게 기존에 각각 해시태그와 게시글 타입의 HashSet에서 중간 엔티티 타입을 넣어주고 관계를 맺게 해 준다. 중간 엔티티에서 각각의 엔티티를 참조하므로 OneToMany 관계가 된다.
그리고 필요에 따라서 Dto를 수정해주어야 한다. 중간 관계 엔티티를 따로 생성해주었기 때문에, 게시글을 불러올때 따로 hashtagDto 를 가져오도록 구성할 필요 없이
비즈니스 로직에 중간관계 엔티티에서 게시글 아이디를 입력하면 게시글과 해시태그를 같이 가져오도록 추가시킨 후에 map에 해시태그를 addattribute 하는 방식으로 설계하면 된다.
리스폰스 Dto에 불필요하게 껴있는 hashtagDto 타입의 hashSet들을 지워주면 된다.
비즈니스 로직 재설계
일단 게시글을 등록하는 것부터 수정해주자.
public void saveArticle(ArticleDto dto, Set<HashtagDto> hashtagDto) {
Article article =articleRepository.save(dto.toEntity());
for (HashtagDto hashtag : hashtagDto) {
Hashtag hashtag1= hashtagRepository.findByHashtag(hashtag.hashtag())
.orElseGet(()-> hashtagRepository.save(hashtag.toEntity()));
articlehashtagrepository.save(ArticleHashtag.of(article,hashtag1));
}
}
ArticleRequest는 게시글 내용과 문자열 타입의 Hashtag를 입력했을 때, 게시글은 Dto로 가져와주고, Hashtag는 '#'별로 나눠서 Set에 추가해 HashtagDto가 담긴 HashSet으로 반환해준다.
사실 웃긴 건 기존에 내가 작성했던 코드는 이러했다.
ㅋㅋ(할 말 잃음) 이렇게 작성하니 영속성 관련 오류가 발생했었는데 위의 코드로 수정하니 오류가 사라졌다.
일단 게시글 먼저 저장해주고 맵핑을 해주면 된다.
hashtag를 반복문을 통해 이미 존재하는 해시태그라면 해당 엔티티를 가져오고, 존재하지 않는다면 저장한 후 반환시켜 그것을 중간 엔티티에 각각 저장한 후에 중간 엔티티까지 레포지토리에 저장하면
게시글과 해시태그가 등록이 되고, 그 각각 엔티티들을 맵핑한 중간 엔티티가 저장되어 양호한 관계를 맺게 된다.
컨트롤러 수정해주기
게시글을 등록할 때 게시글 DTO와 해시태그 DTO 셋을 가져오도록 변경했기 때문에 컨트롤러 또한 리퀘스트 Dto에서 해시태그 셋과 게시글 Dto를 뽑아 매개 값으로 입력하도록 변경해주어야 한다.
@PostMapping("/post")
public String articleSave(@Valid ArticleForm articleForm,BindingResult bindingResult,
ArticleRequest dto,
@AuthenticationPrincipal BoardPrincipal boardPrincipal) {
if(bindingResult.hasErrors()){
return "articles/post/article_form";
}
articleService.saveArticle(dto.toDto(boardPrincipal.toDto()),dto.getHashtags());
return "redirect:/articles";
}
해시태그 등록을 해보자
이렇게 도메인과 비즈니스 로직을 재설계하고 컨트롤러까지 해당 구조에 맞게 변경을 해주었다면, 내가 직접 뷰에서 입력을 했을 때 DB들이 잘 들어가는지 직접 실행해보자
이렇게 입력하고 저장을 해보자.
잘 저장되는 모습이다. (리스폰스 Dto가 수정된 후 뷰 템플릿 수정은 단순히 해당 해시태그가 출력되는 부분에 th:each로 값을 넣어주면 된다.)
중간 테이블에도 저장이 잘 된다.
이젠 복수 저장을 해볼까?
이렇게 안녕부터 안녕하세요 까지 총 4개의 해시태그를 등록했다.
정상적으로 등록이 되는 모습이다.
이렇게 중간 엔티티 또한 하나의 게시글에 4개의 해시태그가 등록됨으로써 총 4개의 PK가 생성된 모습이다.
게시글 수정/삭제 구현
게시글을 수정하고 삭제할 때는 단순하다. 중간 엔티티를 삭제하지말고 각각 게시글 id와 해시태그 id 값을 null로 변경한 후에 수정할 때는 변경 값을 먼저 저장하고, 또 새 엔티티를 저장해주면 된다.
삭제 시에는 단순하게 중간 엔티티 값을 null로 변경한 후에 게시글을 삭제하면 된다.
또한 수정할 때도 기존에 입력되었던 해시태그 값도 #해시태그 #해시태그 1 같은 패턴으로 받아오면 보기 편하기 때문에 컨트롤러에서 StringBuilder 같은 것을 활용하여 각각 해시태그들을 불러온 후에 #와 공백을 붙여서 addattribute 해주면 된다.
이렇게 해당 글의 수정 버튼을 누르면 값을 완전히 받아오고
이렇게 변경하고 입력하면
수정이 된다.
중간 테이블에는 기존 값들이 null로 변경된 후 새로운 해시태그와 관계가 맺어진 모습이다.
마치며
해시태그 기능 구현은 단순히 방법 정도만 제시해주고 내가 직접 구현할 때 참고할만한 자료가 크게 없었다.
해당 기능을 구현한 글들이 어느 분의 개발 실력 향상에 도움이 되었으면 하는 마음과 내가 해결했던 문제들을 기록하고자 하는 마음에 글을 작성하게 되었다.
'Spring' 카테고리의 다른 글
내가 기억하기 쉬우라고 작성한 단위테스트 (0) | 2022.12.26 |
---|---|
스프링부트 + 타임리프로 동기식 댓글수정 기능 구현하기 (0) | 2022.12.13 |
스프링 부트+ JPA 로 파일 입출력을 쉽게 구현해보자 (Spring Data JPA) (0) | 2022.12.13 |
스프링부트로 프로필 이미지를 변경해보자 🤔 (파일 입출력) (0) | 2022.12.13 |
스프링부트 Toast UI 에디터 / 뷰어 적용 + 이미지 입출력 -2 (0) | 2022.12.13 |