들어가기 앞서..
본 게시물의 내용이 좋은 구현, 성능임을 보장하지 않습니다.
제가 공부했던 기록을 남기기 위한 목적으로 글을 작성합니다.
안녕하세요. 오늘은 게시물에 해시태그 기능을 구현해 본 내용을 작성해보려고 합니다. 앞에서도 언급했지만, 개인적으로 공부하면서 작성한 내용이라 잘못된 점이 있을 수도 있어요. 너그러이 이해해 주시고 댓글로 피드백 주시면 고쳐보겠습니다. 바로 시작해 보겠습니다.
1. 어떤 방식으로 구현할 것인가?
이전에 프로젝트를 진행하면서 처음부터 Optimal 한 구현 방식을 생각하다 보니 아예 구현조차 하지 못한 적이 있습니다. 사실 공부하는 입장에서 최적의 방법이 무엇인지 알기도 어려울뿐더러, 상황에 따라서 달라질 수 있는 것인데도 말이죠. 이때를 기점으로, 저는 구현하기 전에 어떤 방식으로 구현할 수 있을지 여러 방법을 생각하게 되었습니다. 성능이 좋지 못한 코드여도 구현조차 못한 것보다 훨씬 낫기 때문이에요. 이번에도 차근차근 어떤 방법이 있을지 생각해 보았습니다.
1-1. HashSet을 이용하기
처음에 가장 직관적으로 떠오른 방법은 HashSet입니다. 복잡한 로직 필요 없이, 현재 테이블에 HashSet <String> hashtags 추가만으로 쉽게 구현할 수 있을 것 같았습니다. 구현이 쉽다는 장점이 빠르게 떠올랐는데, 더불어서 이 방법으로 구현했을 시의 단점도 빠르게 떠올랐어요.
첫 번째는 메모리 문제입니다. HashSet을 사용하면 모든 데이터를 메모리에 올려서 관리하기 때문에, 데이터의 양이 많아지면 메모리 사용량에 부담이 있을 수도 있겠다는 생각이 들었어요. 두 번째는 속도적인 측면인데요, 데이터베이스에서 제공하는 다양한 연산을 사용할 수 없고, 해시태그에 관한 어떠한 작업을 해야 할 때 모든 테이블의 해시태그를 검사해야 하기 때문에 느릴 것이라고 판단했습니다.
1-2. 해시태그 테이블 만들기(N:M)
첫 번째 방법은 일단 보류해 두고, 다른 방법을 생각해 보았습니다. 그다음으로 생각난 방법은 테이블을 추가하는 방법입니다. 게시글은 여러 해시태그를 가질 수 있고, 해시태그는 여러 게시물에 사용될 수 있기 때문에 N:M 관계의 테이블을 생성하는 방법을 구상했습니다. 하지만, 예전에 N:M관계는 회피하는 것이 좋다는 글을 본 적이 있어서 찾아보았는데, 다음과 같은 단점이 있었습니다. 첫 번째는 N:M 관계를 JPA에서 구현하면, 단순히 양쪽의 PK를 참조하는 형태라서 칼럼을 추가할 수 없기 때문에 요구사항이 바뀌면 구조 자체를 변경해야 한다는 것이고 두 번째는 예상치 못한 쿼리가 발생할 수 있다는 것이었습니다. 그리고 이런 관계에 대해서 중간 테이블을 둬 해결할 수 있다는 방법도 알게 되었습니다.
(글의 목적이 해시태그 구현이므로 자세한 내용은 다른 게시물을 참조하시면 좋을 것 같습니다.)
1-3 해시태그 테이블과 중간 테이블 만들기(1:N)
결론부터 말하면 최종적으로 선택한 방식입니다. 구현은 조금 복잡해질 수 있겠지만, 이전에 구상한 방법의 장점은 취하고 단점은 해소시킨 방법이라고 생각했습니다. 이제 한번 직접 구현해 보겠습니다.
2. 해시태그 기능 구현하기
이제 기능을 구현해 보겠습니다. 우선 엔티티들의 부모가 될 BaseEntity입니다.
BaseEntity.java
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SuperBuilder
public abstract class BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column
private Long id;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
그다음에는 해시태그 테이블과 중간 테이블을 만들어 보겠습니다.
Hashtag.java
@Getter
@NoArgsConstructor
@Entity
public class Hashtag extends BaseEntity {
private String tagName;
@OneToMany(mappedBy = "hashtag", cascade = CascadeType.ALL, orphanRemoval = true)
private List<QuestionHashtag> questionHashtags;
@Builder
public Hashtag(String tagName) {
this.tagName = tagName;
}
}
QuestionHashtag.java
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Entity
public class QuestionHashtag extends BaseEntity {
@ManyToOne
private Question question;
@ManyToOne
private Hashtag hashtag;
}
이렇게 테이블들을 추가해 주었습니다. 다음으로는 글 작성 정보를 받아오는 DTO를 수정해 보겠습니다.
public record QuestionSaveRequestDto(
@NotBlank(message = "제목은 필수 입력 사항입니다.")
@Size(max = 200, message = "제목이 너무 길어요.")
String title,
@NotBlank(message = "내용은 필수 입력 사항입니다.")
String content,
List<String> tagNames
) {
public Question toEntity(Member member) {
return Question.builder()
.title(title())
.content(content())
.member(member)
.build();
}
}
String으로 해시태그를 입력받아서 List를 구성하고, 이를 해시태그로 만들어 줄 계획이기 때문에 List<String> hashtags를 만들어 주었습니다.
혹시, record에 대해서 생소하시다면 https://s7won.tistory.com/2를 참조해 보시면 좋을 것 같습니다 ㅎㅎ
이제, 해시태그 저장하는 로직을 만들어볼게요.
QuestionCommandService.java
public Long save(QuestionSaveRequestDto questionSaveRequestDto, Principal principal) {
Member member = memberService.findMemberByEmail(principal.getName());
Question savedQuestion = questionRepository.save(questionSaveRequestDto.toEntity(member));
questionHashtagService.saveHashtag(savedQuestion, questionSaveRequestDto.tagNames());
return savedQuestion.getId();
}
우선, 받아온 DTO를 통해서 게시글(제 경우에는 Question입니다.)을 저장해 줍니다. 그 뒤에, 해시태그도 저장해 줍니다.
QuestionHasgtagService.java
@RequiredArgsConstructor
@Transactional
@Service
public class QuestionHashtagService {
private final HashtagService hashtagService;
private final QuestionHashtagRepository questionHashtagRepository;
public void saveHashtag(Question question, List<String> tagNames) {
if(tagNames.size() == 0) return;
tagNames.stream()
.map(hashtag ->
hashtagService.findByTagName(hashtag)
.orElseGet(() -> hashtagService.save(hashtag)))
.forEach(hashtag -> mapHashtagToQuestion(question, hashtag));
}
private Long mapHashtagToQuestion(Question question, Hashtag hashtag) {
return questionHashtagRepository.save(new QuestionHashtag(question, hashtag)).getId();
}
public List<QuestionHashtag> findHashtagListByQuestion(Question question) {
return questionHashtagRepository.findAllByQuestion(question);
}
saveHashtag 메서드를 간단하게 설명해 보겠습니다.
1. 먼저, 혹시 모를 상황에 대비해서 빈 객체가 넘어올 경우 그대로 return 하였습니다.
2. 그다음에 입력받은 tagNames List에 대해서 Stream으로 순회하면서 먼저 이미 존재하는 해시태그인지 검사합니다. 만약에 있다면 그것을 반환하고, 아니라면 저장한 후에 반환합니다.
3. 2번 단계에서 얻은 hashtag를 question과 연결해 줍니다.
여러 가지 구현 방법이 있을 수 있겠으나, 저는 이런 방식으로 구현하였습니다.
이제 위에서 사용된 HashtagService에 대해서 알아보겠습니다.
HashtagService.java
@RequiredArgsConstructor
@Transactional
@Service
public class HashtagService {
private final HashtagRepository hashtagRepository;
public Optional<Hashtag> findByTagName(String tagName) {
return hashtagRepository.findByTagName(tagName);
}
public Hashtag save(String tagName) {
return hashtagRepository.save(
Hashtag.builder()
.tagName(tagName)
.build());
}
}
단순한 저장, 읽기 메서드입니다.
여기까지 해서 기본 틀은 구현된 것 같아요. 정말 간단한 테스트코드를 짜보겠습니다.
간단하게 테스트를 짜보았고, 성공했습니다!!
(잠깐 다른 얘기를 하자면, 테스트 코드는 너무 어려운 것 같아요. 좋은 강의나 레퍼런스가 있다면 추천해 주시면 감사하겠습니다!)
이번에는, html코드를 수정하여 해시태그 입력을 받아와 보겠습니다.
<div class="mb-3">
<label for="hashtags" class="form-label">해시태그</label>
<div>
<input type="text" id="hashtags" class="form-control" placeholder="Enter로 추가해보세요.">
<div id="hashtags-container"></div>
<input type="hidden" id="hashtags-hidden" th:field="*{hashtags}" />
</div>
</div>
<input type="submit" value="저장하기" class="btn btn-primary my-2">
</form>
<script>
const hashtagsInput = document.getElementById("hashtags");
const hashtagsContainer = document.getElementById("hashtags-container");
const hiddenHashtagsInput = document.getElementById("hashtags-hidden");
let hashtags = [];
function addHashtag(tag) {
tag = tag.replace(/[\[\]]/g, '').trim();
if(tag && !hashtags.includes(tag)) {
const span = document.createElement("span");
span.innerText = "#" + tag + " ";
span.classList.add("hashtag");
const removeButton = document.createElement("button");
removeButton.innerText = "x";
removeButton.classList.add("remove-button");
removeButton.addEventListener("click", () => {
hashtagsContainer.removeChild(span);
hashtags = hashtags.filter((hashtag) => hashtag !== tag);
hiddenHashtagsInput.value = hashtags.join(",");
});
span.appendChild(removeButton);
hashtagsContainer.appendChild(span);
hashtags.push(tag);
hiddenHashtagsInput.value = hashtags.join(",");
}
}
hashtagsInput.addEventListener("keydown", (event) => {
if (event.key === 'Enter') {
event.preventDefault();
const tag = hashtagsInput.value.trim();
if (tag) {
addHashtag(tag);
hashtagsInput.value = "";
}
}
});
</script>
코드에 대해서 간단하게 설명해 보겠습니다.
1. 사용자는 엔터키를 통해서 해시태그를 입력할 수 있습니다.
2. 입력된 해시태그는 # 과 함께 표시되고, 입력 과정에서 중복을 제거합니다.
3. 입력된 해시태그 옆에는 x가 표시되고 이를 누르면 삭제할 수 있습니다.
4. 최종적으로 글 작성을 한다면, 입력된 해시태그들이 list 형태로 입력됩니다.
설명을 잘하지 못한 것 같아서 사진으로 보여드리겠습니다.
여기에 해시태그를 입력하고 엔터를 누르면
이런 식으로 추가할 수 있습니다. 중복을 제거하기 때문에 JAVA를 다시 입력해도 바뀌지 않습니다.
X키를 눌러서 삭제할 수도 있어요. 이렇게 만들어진 태그들이 아까 수정한 DTO에 List로 전달됩니다.
이제는 이것을 화면에 띄워보겠습니다.
3. 화면에 표시하기
우선은, 기존에 제가 게시글에 대해서 화면에 띄우는 Controller와 Service 코드 일부를 보겠습니다.
저는 thymeleaf를 사용하기 때문에 아래와 같이 작성하였습니다.
위에서 findById 메서드는 QuestionResponseDto를 반환하고, 이를 화면에 띄워주는 형식입니다. DTO에 필드를 추가하는 방식과, 게시글에 맞는 해시태그를 조회해 와서 view에 추가해 주는 방식 중에서 고민하였습니다.
결과적으로 이번에는 게시글에 맞는 해시태그를 조회해 와서 view에 추가해 주는 방식을 택했는데요, 그 이유는
1. 선택한 방식을 사용하면 추가적인 코드 구현 없이 view에 뿌려주기만 하면 된다.
2. 혹시 이후에 요구사항이 변경된다면 조금 더 유연하게 대응할 수 있는 방식인 것 같다.
라는 생각을 바탕으로 view에 직접 뿌려주었습니다.
QuestionController.java
List<QuestionHashtag> hashtags = questionHashtagService.findHashtagListByQuestion(
questionQueryService.findQuestionByQuestionId(id));
model.addAttribute("hashtags", hashtags);
그다음에는 프런트에서 이를 반영해 보겠습니다.
<div>
<a th:each="tag : ${hashtags}"
th:href="@{/usr/doctor-qna(page=0, type=${type}, kw=${kw}, hashtag=${tag.getHashtag().getTagName()})}"
th:text="${'#' + tag.getHashtag().getTagName()}"></a>
</div>
위의 링크 부분은 일단 생각하지 마시고, 이를 반영하면 아래 사진과 같은 결과를 얻을 수 있어요.
여기까지 왔으면 거의 다 온 것 같지만, 조금 더 해보려 합니다.
이번에는 해시태그를 클릭하면 같은 해시태그의 글들을 보여주는 기능을 만들어 보겠습니다.
우선 컨트롤러에서 게시글 목록을 불러오는 부분을 수정해 보겠습니다.
QuestionController.java
@GetMapping("/usr/doctor-qna")
public String findAll(Model model, @RequestParam(value = "page", defaultValue = "0") int page,
String type, String kw, String hashtag) {
Page<Question> paging;
if (hashtag != null && !hashtag.isEmpty()) {
paging = questionHashtagService.findAllByHashtag(page, hashtag);
} else {
paging = questionQueryService.findAll(page, type, kw);
}
model.addAttribute("paging", paging);
model.addAttribute("kw", kw);
model.addAttribute("type", type);
return "doctorqna/doctorQnaList";
}
매개변수로 해시태그를 추가해 주고, paging을 경우에 따라서 나눠주었습니다.
위에서의 th:href는 이것을 위한 것이었어요. 이번에는 findAllByHashtag 메서드에 대해서 알아보겠습니다.
QuestionHashtagService.java
public Page<Question> findAllByHashtag(int page, String hashtag) {
List<Sort.Order> sortsList = new ArrayList<>();
sortsList.add(Sort.Order.desc("createdAt"));
Pageable pageable = PageRequest.of(page, 10, Sort.by(sortsList));
List<Question> questionList = questionHashtagRepository.findAllByHashtagTagName(hashtag)
.stream()
.map(QuestionHashtag::getQuestion)
.toList();
return new PageImpl<>(questionList, pageable, questionList.size());
}
태그 이름을 기준으로, 게시글을 찾아와서 리스트로 반환 후에 페이징 해주었습니다.
여기까지 왔으면, 기본적인 기능 구현은 모두 마쳤습니다.
4. 마무리
지금까지 해시태그 기능을 구현해 보았습니다. 기본적인 구현은 마무리했지만, 아직 추가하고 개선할 여지가 많습니다.
예를 들면 대소문자 비교, 해시태그 수정, 해시태그를 저장하는 부분에 대한 개선과 같은 경우가 있을 것 같아요. 혹시 이 글을 보고 따라 하시는 분이 있다면 본인의 생각을 토대로 잘 개선하고 기능을 추가해 보셨으면 좋을 것 같아요. 저도 추후에 개선해서 글을 다시 작성해 보도록 하겠습니다.
5. 소감
그렇게 어려운 구현은 아닌 것 같았으나, 시간이 굉장히 오래 걸렸어요.
어떤 것이 좋은 구현 방식일까? 에 대한 고민을 거의 모든 부분에서 했기 때문인 것 같습니다.
이러한 고민들을 하면서 스스로 깊게 생각한 것이 있는데요,
'배우는 입장에서 최적의 구현을 하기는 불가능하다. 내가 왜 이렇게 구현했는지 남들에게 설명할 수 있는 방식으로 구현하자' 에요.
위에서 언급한 view에 직접 뿌려줄지, DTO에 담아서 반환할지와 같은 결정 시간 같은 경우도 고민을 오래 했는데요, 결국 성능상 어떤 것이 유리한지는 정확히 알지 못하고 결국 저의 생각을 정리한 후에 결정했어요.
앞으로도 개발하다 보면 이런 상황이 무척 많을 것이라고 생각하는데, 이러한 상황이 있을 때마다 매번 선배님, 동료에게 물어보거나 검색을 하면서 시간을 지체할 수 없다고 생각했습니다. 결국 내가 왜 이렇게 구현했는지를 명확히 설명할 수 있어야 하는 게 중요하다고 느꼈어요.
앞으로도 제가 구현한 코드를 남에게 잘 설명할 수 있게 기본기를 잘 다지고, 설계를 잘하도록 노력해야겠습니다.
물론, 코드를 잘 짜서 남들이 바로 납득할 수 있도록 성장하면 더욱 좋겠지만요.
저번 글을 쓰면서도 그랬지만, 부족한 코드를 같이 올리려고 하니 많이 부끄럽네요 ㅎㅎ
잘못된 정보에 대한 정정, 피드백은 언제나 환영입니다!
'SPRING' 카테고리의 다른 글
[SPRING + REDIS] 유저 정보 캐싱으로 성능 개선해보기 (10) | 2023.08.23 |
---|---|
[SPRING + JPA + SECURITY] 너무 다른 2종류의 회원을 어떻게 설계할까? (2) | 2023.08.21 |