코드 훔쳐보는 변태 코더
춤 좋아하는 백엔드 개발자(였으면 좋겠다)
분류 전체보기 (95)
트리 자료구조를 직접 구현해보고 이해해보자 🤔

트리구조

  • 트리는 노드와 연결을 할 수 있는 브랜치를 이용해서 사이클을 이루지 않도록(순회하지 않도록) 구성한 데이터 구조이다.
  • 트리는 넓은 범위로 정의를 한 것이고, 그중에서 익혀야 할 구조는 이진트리 구조이다. (탐색 알고리즘을 구현할 때 꼭 사용된다.)

알아둘 용어

  • 노드 : 트리에서 데이터를 저장하는 기본 요소
  • 루트 노드 : 최상단 노드
  • 이진트리 : 노드의 최대 브랜치가 두 개이다. (두 개의 가지를 가지고 있다.)
  • 이진 탐색 트리 : 왼쪽 노드는 윗 노드의 값보다 작은 값, 오른쪽 노드는 윗 노드의 값보다 큰 값이 들어간다.
  • 어떤 노드의 상위 노드 → 부모 노드 / 어떤 노드의 하위 노드 → 자식 노드
  • 최상단에 위치한 노드는 Level 0 , 두 번째는 Level 1 등 각각의 레벨에 번호를 붙인다.
  • Depth : 트리에서 노드가 가질 수 있는 최대 Level (밑으로 얼마나 뻗어있느냐)
  • 형제 노드 : 동일한 부모 노드를 가진 노드
  • Leaf Node : 자식 노드가 하나도 없는 노드

이진 탐색 트리

이진트리와 정렬된 배 열간의 비교?

  • 배열은 원하는 값을 찾으려면 값이 나올 때까지 돌아야 한다.
  • 이진트리는 노드를 탐색해서 효율적으로 값을 검색한다.

이진 탐색 트리 구조/삽입/탐색 구현해보기

public class NodeMgmt{ 
	Node head = null;
	public class Node{ 
		Node left; //각각 오른쪽 왼쪽을 가리키는 브랜치를 가진다.
		Node right;
		int value;
		public Node(int data){
			this.value = data;
			this.left = null;
			this.right = null;
		}
	}
}

데이터 넣는 메서드 구현해보기

트리구조는 생각하지 못한 케이스가 많기 때문에 서브 케이스를 만들어서 각각을 구현하는 방식으로 구현하면 좋다.

public boolean insertNode(int data){
		//CASE1: 노드가 하나도 없을때
		if (this.head==null){
			this.head = new Node(data); //노드를 새로 만들어준다.
				} else{
				//CASE2: Node 가 하나 이상 있을때
				Node findNode = this.head; //노드를 가져와서 브랜치를 연결해준다.
				while(true){
				// CASE 2-1 : 현재 Node의 왼쪽에 Node가 들어가야 할때 (값이 작을때)
				if (data < findNode.value){
					if(findNode.left!=null){
						findNode = findNode.left; //노드가 존재하면 밑으로 점점 내려오면서 값을 넣어준다.
						}else{
					findNode.left = new Node(data);
					break;}
				// CASE 2-2 : 현재 Node 의 오른쪽에 Node가 들어가야 할때
					} else //값이 부모 노드보다 더 클때
						{if(findNode.right != null){
						findNode = findNode.right;
					}else{
					findNode.right = new Node(data);
					break;
					}
				}
			}
		return true;
		}
	}
}
// 밑으로 내려가면서 연결/혹은 삽입 해준다.
			
	
  • 순서를 정리해보자
    • 이진트리를 구현할 때, 최상위 노드를 만들어주고 각 노드별로 왼쪽, 오른쪽을 가리키는 브랜치와 데이터를 넣을 객체 변수를 선언해준다.
    • 값을 저장할 시에는 만약에 루트 노드가 존재하지 않는다면 루트 노드에 새 노드를 참조시켜주고, 존재한다면 밑으로 내려오면서 값을 저장한다.
    • 밑으로 내려오는 로직은 값이 부모 노드보다 작으면 while문으로 각 브랜치로 내려와 노드가 존재한다면 참조시켜주고 존재하지 않는다면 연결해주는 식으로 노드가 존재하지 않을 때까지 Level을 늘리며 내려간다.

탐색 메서드 구현해보기

데이터를 가진 노드가 있는지/없는지를 체크하고 있으면 해당 노드를 리턴하도록 구현한다.

마찬가지로 각각의 케이스를 나눠서 구현하면 좋다.

public Node search(int data){
	if(this.head==null){ //트리가 비어있다면 null을 반환한다.
		return null;
		}else{
			Node findNode = this.head;
			while(findNode !=null){ //노드가 존재한다면 탐색을 실행한다.
				if(findNode.value == data){  //노드의 데이터가 검색을 원하는 데이터와 값이 같다면
					return findNode;
					}else if(data < findNode.value){ //데이터가 작다면 왼쪽 브랜치로 넘어가서 반복한다.
						findNode = findNode.left;
						} else{
							findNode = findNode.right;  //더 크다면 오른쪽 브랜치로 넘어가서 반복한다.
						}
				}
				return null;  //탐색했지만 없다면 null을 반환한다.
			}
		}
	}

이진 탐색 트리의 노드 삭제법

자식 노드가 없는 Leaf Node의 삭제

  • 해당 노드의 부모 노드의 브랜치를 끊어버리면 된다.

자식 노드가 하나인 부모노드 삭제

  • 해당 노드의 부모 노드의 브랜치를 해당 노드의 자식 노드로 연결해준다.

자식 노드가 두 개인 부모 노드 삭제

  • 삭제할 노드의 오른쪽 노드에서 가장 왼쪽에 있는 (가장 값이 작은) 연결한다.

이진 탐색 트리 삭제 구현해보기

각각의 케이스로 나눠서 구현해보자. (삭제할 노드가 존재하는지도 체크해야 한다.)

삭제를 할 노드를 탐색하는 로직을 먼저 구현한다.

public boolean deleteNode(int value){
		boolean searched = false;
		Node currParentNode = this.head;
		Node currNode = this.head;
		// 노드가 하나도 없을때
		if(this.head ==null){
				return false;
				}
		// 노드가 하나만 있고, 해당 노드가 삭제할 노드일때
		else {
		if(this.head.value == value && this.head.left == null && this.head.right==null){
				this.head = null;
				return true;
				}
			while(currNode !=null){
				if(currNode.value = value){
					searched = true;
					break;
					}else if(value< currNode.value){
					currParentNode = currNode; //값이 일치하지 않고 작다면 왼쪽으로 내려간다.
					currNode = currNode.left 
				}else {
					currParentNode = currNode; //크다면 오른쪽으로 간다.
					currNode = currNode.right; 
			}
		}
		if(searched == false){
					return false;
			}
		}
	}
}
// 여기까지 실행된다면 currNode 에는 해당 데이터를 가지고 있는 노드가 담겨있다.
// currParentNode 에는 해당 노드의 부모 노드가 참조된다.

삭제할 노드가 Leaf Node 인 경우

  • 단순히 삭제할 노드의 부모 노드의 자식 노드 연결을 끊어버리면 된다.
if(currNode.left == null && currNode.right) {
			if(value<currParentNode.value){ // 
				currParentNode.left =null; //branch 연결을 끊어준다.
				currNode = null;
				}else{
				currParentNode.right =null;
				currNode = null;
			}
return true;
}

삭제할 노드가 자식 노드를 한 개만 가지고 있을 경우

단순히 부모 노드의 연결을 자식 노드로 바꿔주면 된다.

  • 삭제할 노드의 부모 노드의 데이터와 해당 노드의 데이터를 비교한다.
    • 해당 노드의 자식 노드가 왼쪽/오른쪽에 있는지 판단해 부모 노드의 데이터보다 값이 작다면 왼쪽에 , 크다면 오른쪽에 브랜치를 연결해준다.
// 자식 노드가 왼쪽에 있을경우
else if ( currNode.left!=null && currNode.right==null ){
		if(value < currParentNode.value){ //부모 노드보다 작다면 왼쪽에 연결한다.
		currParentNode.left = currNode.left;
		currNode = null;
			}
		else{
			currParentNode.right = currNode.left;
			currNode = null;
			}
		return true;
// 자식 노드가 오른쪽에 있을경우
else if ( currNode.left ==null && currNode.right !=null ){
		if(value < currParentNode.value){ //단순히 반대로 실행한다.
			currParentNode.left = currNode.right;
			currNode = null;
			}else{
				currParentNode.right = currNode.right;
				currNode = null;
			}
		return true;

삭제할 노드가 자식 노드를 두 개 가지고 있을 경우

  • 삭제할 노드의 오른쪽 자식 중, 가장 작은 값을 삭제할 노드의 부모 노드가 가리키도록 한다.
    • 삭제할 노드가 부모 노드의 왼쪽에 있고, 삭제할 노드의 오른쪽 자식 중 , 가장 작은 값을 가진 노드의 자식 노드가 없을 때
    • 삭제할 노드가 부모 노드의 왼쪽에 있고, 삭제할 노드의 오른쪽 자식 중 , 가장 작은 값을 가진 노드의 오른쪽에 자식 노드가 있을 때
      • 가장 작은 값을 가진 노드의 자식 노드가 왼쪽에 있는 경우는 없다. 왜냐하면 왼쪽 노드가 있다는 것은 해당 노드보다 더 작은 값을 가진 노드가 있다는 뜻이기 때문이다.

이해하기 어렵다면 각각의 케이스를 외워버리는 것도 좋은 방법이다.

해당 부분은 너무 복잡해서 나중에 한번 더 수강할 예정입니다 🤔

시간 복잡도 

  • 트리의 depth를 h라고 한다면 O(h)
  • n개의 노드를 가진다면 o(logn) ⇒ 이진트리가 균형 잡혀 있을 때의 평균 시간 복잡도이다.
    • 한번 실행시마다 50%의 실행할 수도 있는 명령을 제거한다는 의미이다.
  • 최악의 경우에 링크드 리스트 등과 동일한 성능을 보여준다. ( o(n))

 

느낀 점

  • 이진트리가 무엇인지 알고는 있었으나 어떻게 구현하는지는 몰랐는데, 이번 기회가 1차적으로 구조가 이해가 되는 느낌입니다.
  • 확실히 값을 저장하고 탐색할 때는 크게 힘든 게 없지만, 삭제할 때는 다른 자료구조와 달리 트리의 복잡도에 따라서 머리로만 그리는 것도 어려운 느낌입니다. 확실히 케이스가 많은 것 같습니다.
    • 별개로 해당 자료구조를 구현한 자바의 트리 API들이 해당 부분들을 다 구현해놓은 상태인지 궁금합니다. 찾아봐야겠습니다 🤔
  • 단순히 탐색에 좋은 자료구조라고 생각해서 무분별하게 사용하다간 다른 자료구조보다 시간 복잡도가 더 나올 수 있는 상황이 생길 것 같습니다. 특히나 값이 같거나 1차원적인 트리의 사용은 지양해야겠습니다.

 

  Comments,     Trackbacks
22-12-15 TIL

오늘 진행한 것들 🤔

  • 해쉬 자료구조 정리
 

해쉬 자료구조에 대해서 알아보자 🤔

해쉬테이블 🤔 키에 데이터를 맵핑할 수 있는 데이터구조이다. 키와 값을 어떻게 맵핑할까? → 해쉬 펑션에 키를 넣으면 키를 저장할 수 있는 주소를 리턴을 해준다.(인덱스) 해당 주소에 키에

codinghentai.tistory.com

  • 시간 복잡도 정리
 

시간복잡도에 대해서 쉽게 이해해보자 🤔

알고리즘 복잡도 표현 방법 🤔 어떤 문제를 풀이하는데 완벽하게 똑같은 코드는 존재하지 않으나 어떤 풀이가 좋은 풀이냐 ? 라는 기준을 표현하는 방법을 익히는 것이다. → 어느 알고리즘이

codinghentai.tistory.com

  • 해쉬 자료구조 알고리즘 문제 풀이
 

백준 1764번 듣보잡 (자바)풀이

문제 김진영이 듣도 못한 사람의 명단과, 보도 못한 사람의 명단이 주어질 때, 듣도 보도 못한 사람의 명단을 구하는 프로그램을 작성하시오. 입력 첫째 줄에 듣도 못한 사람의 수 N, 보도 못한

codinghentai.tistory.com

 

백준 2910번 빈도 정렬 (자바) 풀이

문제 위대한 해커 창영이는 모든 암호를 깨는 방법을 발견했다. 그 방법은 빈도를 조사하는 것이다. 창영이는 말할 수 없는 방법을 이용해서 현우가 강산이에게 보내는 메시지를 획득했다. 이

codinghentai.tistory.com

  • 스프링 부트 게시판 Article/ArticleComment 도메인 리팩터링
    •  게시글 조회 시에 민감한 정보가 포함되지 않도록 수정 (account dto 제외시킴)

  • Article Service 테스트 코드 작성

  • 그룹스터디 워크숍 준비 (발표자료 수정 및 모의 발표 진행)

 

오늘의 배운 점 🤔

  • 해쉬 자료구조에 대해서 공부했습니다. 왜 프로젝트 강의에서 강사들이 데이터 저장을 위한 자료구조로 해쉬 셋을 사용하는지 알게 되었습니다.
    • 해쉬 맵을 활용하고 싶은 마음이 생겼습니다. 레디스를 연동시켜보면 될까요? 🤔
    • Repository는 어떤 자료구조로 구현되어있는지 궁금해졌습니다. 한번 찾아봐야겠습니다.
  • 시간 복잡도에 대해서 공부했습니다.
    • 사람들이 그렇게 말하던 시간 복잡도가 무엇인지 드디어 알게 되었습니다. 생각보다 어렵지 않았습니다.
    • 시간 복잡도라는 개념을 이해하고 문제를 풀이하니 자연스럽게 해당 코드가 얼마나 오래 걸리지 머릿속으로 그려지게 되었습니다. 🤔
      • 공간 복잡도는 디테일하게 공부하지 못했지만 대충 해쉬 자료구조 같은 것들을 공부하면서 느껴보니 체이닝 기법 같은 오픈 해슁 기법 같은 경우에 메모리 초과가 발생할 수 있겠다..라는 생각이 듭니다. 🤔
  • 테스트 코드를 오랜만에 작성해보았습니다.
    • 전에는 Inject Mock과 Mock 이 무슨 역할인지 몰랐으나 지금은 이해가 됩니다. 제대로 알지 못했지만 꾸준히 테스트 코드를 조금씩이라도 작성해온 보람이 있습니다.
    • 하지만 연관관계 맵핑이 되어있는 엔티티에 대해서 테스트코드를 작성할 때 유연하지 못한 느낌이 듭니다. 해당 서적을 읽어봐야겠습니다.
  • 리팩터링을 진행했습니다.
    • 기존에 문제라고 생각했던 부분이 있었습니다. 프로젝트 내에서 response request dto를 적극적으로 생성하여 사용하지 않고 dto를 통짜로 사용해왔습니다.
      • 해당 부분에 대해서 조회 부분에는 response를, 삽입 부분에는 request를 사용해 확실히 분리를 해뒀습니다.
      • 전체적으로 코드가 더러웠던 부분 중에 공통부분을 빼버리고 핵심 로직은 최대한 코드의 깊이가 깊어지지 않도록 리팩터링을 진행했습니다. 
        • 이제 누구한테 제 프로젝트를 보여줄 수 있겠다 하는 생각이 들었습니다. (물론 작성해야 하는 테스트 코드가 한 무더기입니다. 🤔
  • 그룹스터디 워크숍 발표를 준비했습니다.
    • 자료를 보면서 아직 2달도 안됐지만, 저희 그룹원들이 얼마나 많은 성장을 했는지 , 그리고 저 또한 얼마나 많은 성장을 이뤘는지 다시 한번 짚어보는 시간을 가졌습니다.(물론 제 머릿속으로)
    • 항상 부족하다 생각했는데 되돌아보니 열심히 살아왔고 잘한 거 같아서 좀 쉬어도 되겠다는 생각이 듭니다. 큰일이네요 ;(
    • 모의 발표를 살면서 처음으로 진행해보았습니다. 매번 준비된 거 없이 프리스타일로 춤만 춰오다가 처음으로 가짜 상황극을 해보니 떨렸습니다. 그래도 발표는 잘할 거라 믿습니다. 🤔

 

내일도 파이팅!

'TIL' 카테고리의 다른 글

22-12-26 TIL  (0) 2022.12.26
22-12-22 TIL  (0) 2022.12.22
22-12-21 TIL  (0) 2022.12.21
22-12-20 TIL  (0) 2022.12.20
22-12-19 TIL  (0) 2022.12.19
  Comments,     Trackbacks
백준 2910번 빈도 정렬 (자바) 풀이

문제

위대한 해커 창영이는 모든 암호를 깨는 방법을 발견했다. 그 방법은 빈도를 조사하는 것이다.

창영이는 말할 수 없는 방법을 이용해서 현우가 강산이에게 보내는 메시지를 획득했다. 이 메시지는 숫자 N개로 이루어진 수열이고, 숫자는 모두 C보다 작거나 같다. 창영이는 이 숫자를 자주 등장하는 빈도순대로 정렬하려고 한다.

만약, 수열의 두 수 X와 Y가 있을 때, X가 Y보다 수열에서 많이 등장하는 경우에는 X가 Y보다 앞에 있어야 한다. 만약, 등장하는 횟수가 같다면, 먼저 나온 것이 앞에 있어야 한다.

이렇게 정렬하는 방법을 빈도 정렬이라고 한다.

수열이 주어졌을 때, 빈도 정렬을 하는 프로그램을 작성하시오.

입력

첫째 줄에 메시지의 길이 N과 C가 주어진다. (1 ≤ N ≤ 1,000, 1 ≤ C ≤ 1,000,000,000)

둘째 줄에 메시지 수열이 주어진다.

출력

첫째 줄에 입력으로 주어진 수열을 빈도 정렬한 다음 출력한다.

예제 입력 3 

9 77
11 33 11 77 54 11 25 25 33

예제 출력 3 

11 11 11 33 33 25 25 77 54

 

풀이

 

해당 문제는 해시 자료구조를 이용해 풀이하는 문제이다. 

C의 값이 과연 필요할까? 싶다. 단순 N의 값만 가지고 문제를 풀이했다.

빈도순서대로 정렬해 출력하는 문제이지만 , 가장 신경써야 할 문제는 빈도가 같다면 먼저 등장한 숫자가 앞으로 나오도록 정렬하는 것이다.

 

그래서 순서를 저장하는 linked hashmap 을 사용하면 된다!

 

일단 전체적인 흐름은 Integer,Integer 를 갖는 해시 맵을 만들어주고 각 값을 집어넣는다. 이때 put 메소드에 벨류 자리는 getOrDefault 로 키가 존재한다면 밸류를 가져와 +1를 시켜주고, 존재하지 않는다면 0을 입력해주는 것이다.

 

인덱스를 저장하기 때문에 단순히 다른 작업을 거칠 필요 없이 compareTo 재정의를 벨류값으로 내림차순으로 정의해주면 된다.(빈도가 많을수록 앞으로 오기 때문에)

 

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.*;
import java.util.stream.Collectors;

public class a2910 {
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st= new StringTokenizer(br.readLine());
        int N = Integer.parseInt(st.nextToken());
        int C = Integer.arseInt(st.nextToken());
        HashMap<Integer,Integer> map = new LinkedHashMap<>();
        st = new StringTokenizer(br.readLine());
        for(int i=0; i<N; i++){
            int n = Integer.parseInt(st.nextToken());
            map.put(n,map.getOrDefault(n,0)+1);
        }
        Set<Map.Entry<Integer,Integer>> set = map.entrySet();
        set = set.stream().sorted((o1, o2) -> o2.getValue() - o1.getValue()).collect(Collectors.toCollection(LinkedHashSet::new));
        StringBuilder sb = new StringBuilder();
        set.forEach(o->{
            for(int i=0; i<o.getValue();i++){
                sb.append(o.getKey()).append(" ");
            }
        });
        System.out.println(sb);
    }
}

 

  Comments,     Trackbacks
백준 1764번 듣보잡 (자바)풀이

문제

김진영이 듣도 못한 사람의 명단과, 보도 못한 사람의 명단이 주어질 때, 듣도 보도 못한 사람의 명단을 구하는 프로그램을 작성하시오.

입력

첫째 줄에 듣도 못한 사람의 수 N, 보도 못한 사람의 수 M이 주어진다. 이어서 둘째 줄부터 N개의 줄에 걸쳐 듣도 못한 사람의 이름과, N+2째 줄부터 보도 못한 사람의 이름이 순서대로 주어진다. 이름은 띄어쓰기 없이 알파벳 소문자로만 이루어지며, 그 길이는 20 이하이다. N, M은 500,000 이하의 자연수이다.

듣도 못한 사람의 명단에는 중복되는 이름이 없으며, 보도 못한 사람의 명단도 마찬가지이다.

출력

듣보잡의 수와 그 명단을 사전순으로 출력한다.

예제 입력 1 

3 4
ohhenrie
charlie
baesangwook
obama
baesangwook
ohhenrie
clinton

예제 출력 1 

2
baesangwook
ohhenrie

 

풀이

해당 문제는 해시 자료구조를 이용해 풀이하는 문제이다.

단순히 듣도 보도 못한 사람을 구하는 문제이므로, 듣지 못한 사람이 보지 못한 사람 명단에도 존재할때 해당 이름을 리스트에 넣어놓고 정렬후 출력하면 된다.

 

해시 자료구조는 키를 입력하고 같은 값이 존재한다면 벨류를 바꾸고 존재하지 않는다면 데이터를 해당 인덱스에 저장하지만, 해시 셋은 단순 벨류가 없이 키만 존재하기 때문에 같은 키가 존재한다면 false 를 리턴한다. 

 

set.add(name) 이 false 일시에 배열에 add 하고 출력하도록 풀이했다.

 

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.*;

public class a1764 {
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());
        int N = Integer.parseInt(st.nextToken());
        int M = Integer.parseInt(st.nextToken());
        List<String> arr= new ArrayList<>();
        HashSet<String> set = new HashSet<>();
        for(int i=0; i<N; i++){
            set.add(br.readLine());
        }
        for(int i=0; i<M; i++){
            String str = br.readLine();
            if(!set.add(str)){
                arr.add(str);
            }
        }
       arr.sort(String::compareTo);
        System.out.println(arr.size());
        for (String s : arr) {
            System.out.println(s);
        }
    }
}

시간 복잡도는 단순 상수회만 반복하기 때문에 O(1) 이다.

  Comments,     Trackbacks
해쉬 자료구조에 대해서 알아보자 🤔

해쉬테이블 🤔

  • 키에 데이터를 맵핑할 수 있는 데이터구조이다.
    • 키와 값을 어떻게 맵핑할까? → 해쉬 펑션에 키를 넣으면 키를 저장할 수 있는 주소를 리턴을 해준다.(인덱스) 해당 주소에 키에 해당하는 값을 저장한다.
    • 해시 펑션은 뭘까? → 임의의 데이터를 고정된 길이의 값으로 리턴해주는 함수이다. (데이터가 어떻든간에 고정된 길의의 값으로 리턴해준다.)
  • 해쉬테이블에 저장한다면 어떤 키던간에 해시 펑션을 이용하면 해당 데이터의 위치를 리턴해주기 때문에 빠른 탐색 속도를 보여준다.
  • 해쉬테이블 ⇒ 키 값의 연산에 의해 직접 접근이 가능한 데이터 구조
    • 슬롯 : 해쉬 테이블에서 한 개의 데이터를 저장할 수 있는 공간
  • 전체적인 흐름 ? → 키와 데이터를 입력하면 해쉬 펑션은 입력된 키를 바탕으로 인덱스 번호를 반환해 해당 데이터가 저장될 수 있는 공간을 배열로 할당한 후, 키에 따른 데이터를 저장한다.
    • 탐색시에는 키를 입력하면 해쉬 펑션은 인덱스를 리턴해주기 때문에 불필요한 탐색을 할 필요 없이 데이터의 유무를 판단해 해당 인덱스의 데이터로 바로 접근이 가능하다.

해쉬 테이블의 장단점과 주요 용도 🤔

  • 장점
    • 데이터 저장/읽기 속도가 빠르다.(처음부터 탐색할 필요 없이 해당 인덱스에 바로 접근이 가능하기 때문에)
    • 해쉬는 키에 대한 데이터가 있는지 확인이 쉽다.(중복 확인이 쉽다.)
  • 단점
    • 일반적으로 저장 공간이 좀 더 많이 필요하다.
    • 여러 키에 해당하는 주소가 동일할 경우 충돌을 해결하기 위한 별도 자료구조가 필요하다.
  • 주요 용도
    • 검색이 많이 필요한경우
    • 저장,삭제,읽기가 빈번한경우
    • 캐쉬 구현시 (중복 확인이 쉽기 때문이다.)

해쉬 충돌(컬리젼)을 위한 알고리즘 (키는 다르지만 해쉬 펑션으로 인한 반환값이 같을때) 🤔

체이닝 기법 (순회를 하기 때문에 시간이 좀 걸린다.)

  • 개방 해슁 또는 오픈 해슁 기법중 하나이다. (해쉬 테이블 저장공간 외의 공간을 활용하는 기법 /그래서 오픈이라는 개념을 가진다.)
  • 충돌이 일어나면 링크드 리스트를 사용해서 데이터를 추가로 뒤에 연결시켜서 저장한다.
  • 기존의 슬롯에 키와 연결용 next 객체를 새로 멤버변수로 선언해준다.
  • 이미 해당 슬롯이 있다면? 해당 슬롯을 참조하는 prevSlot / findSlot 객체를 생성해 key가 같다면 벨류 값을 바꾸고, 다르다면 순회를 하면서 null을 찾을때까지 포인터를 연결 후 새로 슬롯을 생성해 데이터를 저장한다.

리니어 프로빙 기법

  • 폐쇄 해슁 기법중 하나이다. (해쉬 테이블 저장공간 안에서 충돌 문제를 해결하는 기법이다. / 해쉬 테이블 저장공간 안에서 해결을 하기 때문에 폐쇄 라는 용어가 붙는다.)
  • 충돌이 일어나면 해당 해쉬 어드래스의 다음 어드래스부터 맨 처음 나오는 빈 공간에 저장하는 기법이다.
    • 저장공간 활용도를 높이기 위한 기법이다.
  • 마찬가지로 key를 함께 저장한다.
  • 해당 슬롯에 데이터가 존재한다면? 계속 뒤 인덱스로 순회를 하면서 null이 나올때까지 탐색 후 null이 존재하지 않는다면 저장을 하지 않는다.

해쉬 테이블 시간복잡도 🤔

  • 일반적인 경우 (컬리젼이 없다면) O(1)
  • 최악의 경우 (컬리전이 모두 발생하는 경우) O(n)
  • 리니어 프로빙, 체이닝 기법 둘 다 동일하다.

해쉬 테이블의 경우는 일반적인 경우를 기대하고 작성한다.

검색에서 해쉬 테이블의 사용 예

  • 배열에 데이털르 저장하고 검색할때는 O(n) 이지만 이상적인 해쉬 테이블 케이스에서 데이터를 검색할때는 O(1)이다. → 배열은 무조건 반복문을 이용하지만 해쉬 테이블은 해쉬펑션이 인덱스를 리턴해주기 때문에 O(1)이다.

마무리 🤔

  • 단순히 아무생각없이 사용하던 해쉬맵이 이름과 밸류를 통으로 저장해버리는 줄 알았는데 기본 원리는 키는 해쉬펑션으로 인하여 인덱스를 반환해주면 밸류를 가져오지만, 충돌이 일어날 시에 대비하기 위해서 키도 같이 저장하는구나! 를 알게되었습니다.
  • 해쉬 테이블이 어떻게 동작하는지 알 수 있게 되었습니다.
  • 단순히 해쉬 테이블 자료구조를 사용하는 API를 이용하는것이 아니라 적절한 사용이 맞는지 판단해 최대한 충돌이 일어나지 않는 상황에서 해쉬 자료구조를 택한다면 좋은 성능을 발휘할 것 같습니다.
  • 체이닝 기법, 리니어 프로빙 기법이 해쉬 충돌 해결방안 이긴 하지만 전체적으로 돌아가는 구조를 보니 그냥 충돌 자체가 안나는게 최적의 상황인것 같습니다. 자주 충돌이 날 수 있는 상황이라면 다른 자료구조를 사용하는게.. 🤔
  • 적절한 해쉬 펑션을 어떻게 만드는지가 가장 중요한것 같습니다. 해당 부분에 대해서 공부할 필요가 있다고 느껴졌습니다.
  Comments,     Trackbacks
시간복잡도에 대해서 쉽게 이해해보자 🤔

Big O 표기법

알고리즘 복잡도 표현 방법 🤔

어떤 문제를 풀이하는데 완벽하게 똑같은 코드는 존재하지 않으나 어떤 풀이가 좋은 풀이냐 ? 라는 기준을 표현하는 방법을 익히는 것이다.

→ 어느 알고리즘이 더 좋은지를 분석하기 위해서 복잡도 라는 것을 정의 해 알고리즘 간에 비교를 한다.

어떻게 정의를 할까? 🤔

  • 시간 복잡도⇒ 알고리즘의 실행 속도를 의미한다.
  • 공간 복잡도⇒ 알고리즘이 사용하는 메모리 사이즈를 의미한다.

(사실상 공간 복잡도 보다는 시간 복잡도로 평가하는 경우가 많다.)

시간 복잡도는 어떻게 계산할까? 🤔

결과적으로는 반복문이 지배한다.

프로그래밍은 변수/조건문/반복문 으로 이루어져 있다.

이중 변수/조건문을 사용한다 해서 프로그램 성능을 좌우하진 않으나 반복문은 영향을 끼친다.

결과적으론 시간 복잡도에는 반복문이 큰 영향을 끼친다.

알고리즘 성능 표기법 🤔

  • Big O (빅-오) 표기법 : O(n)
    • 알고리즘 최악의 실행 시간을 표기한다. (최악의 경우에 이 프로그램이 얼마의 시간이 걸릴것인가? 를 정의한다.)
    • 가장 많이 사용된다.
    • 아무리 최악의 상황이라도, 이정도의 성능은 보장한다는 의미이기 때문이다.
  • 오메가 표기법 : Ω(n)
    • 오메가 표기법은 알고리즘 최상의 실행 시간을 표기한다.
  • 세타 표기법 :
    • 세타 표기법은 알고리즘 평균 실행 시간을 표기한다.

계산 표기는 최악의 시간인 빅오 표기법을 중심으로 익히면 된다.

빅 오 표기법 🤔

  • 무조건 상수회 를 실행한다면 ? ⇒ O(1)
  • 이중 반복문이지만 상위 (밖의 반복문이) 상수로 반복한다면 ? ⇒ O(n) (상수는 안쪽 반복문의 ~번(n)이 무한대가 되면 큰 의미가 없어진다.)
  • 삼중 반복문이지만 상위가 상수번 반복한다면? O(n^2)

참고 → log n 의 베이스는 2-log2n 이다.

실질적으로 프로그램의 각각의 실행 속도는 다를 수 있지만 시간복잡도 비교는 이런식으로 가능하게 된다.

예제로 알아보자. 🤔

반복문으로 1부터 N까지의 합을 구하는 함수 ?

  • 하나의 반복문이 사용된다.

하나의 반복문이기 때문에 무조건 상수회를 실행한다. 따라서 **O(1)**이라고 할 수 있다.

반복문으로 1부터 N까지의 합을 구하고 각 수 마다 또 다시 N까지의 숫자를 한번 더 더 한다면?

  • 상수로 반복하는 반복문 안에 또 다른 반복문이 존재한다.

이중 반복문이지만 상위 반복문이 상수회 반복하기 때문에 **O(n)**이라 할 수 있다.

하지만 이중 반복문과 또 하나의 반복문이 나온다면?

3회 반복하는 반복문 안에 n번 실행하는 반복문이 2개가 존재하고 그 다음은 5회 반복하는 반복문안에 n번 실행하는 반복문이 하나가 존재한다면?

3n^2 + 5n 으로 표기하지 않고, 가장 높은 차수가 제일 중요하고 상수 자체는 큰 영향이 없기 때문에 빅오 표기법으로는 O(n^2)로 표기한다.

느낀점 🤔

  • 시간복잡도에 대한 개념을 전혀 이해하지 못하고 알고리즘 문제를 풀이했었는데 굉장히 쉽게 이해가 되었습니다.
  • 앞으로 알고리즘 문제를 풀때 시간초과가 날 수 있는 상황을 미리 예상하고 최적의 답을 입력할 수 있을것 같습니다.
  • 뿐만 아니라 개발 할때도 적절한 상황에 적합한 코드를 작성하여 성능에도 신경 쓸 수 있겠구나 하는 생각이 들었습니다.🤔

 

  Comments,     Trackbacks
스프링부트 + 타임리프로 동기식 댓글수정 기능 구현하기

혼자서 토이 프로젝트로 게시판을 구현하고 있었는데,

유독 막혔던 부분이 있었다.

바로 댓글 수정 기능이었다.

혼자서 뚝딱 만들어가고 있던건 아니고 참고했던 강의, 책등이 있었는데

거기서 공통적으로 패스했던 부분이 댓글수정 이었다.

게시글 수정 같은 경우에는 동기 식으로 폼을 세터로 기존에 존재했던 내용들을 지정해준 뒤에 객체로 전달하면 폼안에 내용이 그대로 들어있는 상태로 편하게 수정 할 수 있었는데

댓글 같은 경우에는 동기식으로 수정을 구현하기 어려웠고, 내가 알고있는것들 안에서 해결 할 수 있는 방법을 찾고 싶었으나 , 대체적으로 다들 Ajax 를 활용하여 비동기식으로 댓글 기능을 구현 하셨어서.. 내가 당장 쏙 빼먹을만한 무언가를 찾기가 힘들었다.

시작해보기

댓글 리퀘스트 Dto 작성

일단 나는 댓글저장,수정 같은 경우에는 Dto 자체를 넘겨주지 않고 입력 수정용 RequestDto 를 따로 만들어서 넘겨주었다.

public record ArticleCommentRequest( //JPA BUDDY 를 이용해 DTO 생성
        Long articleId
        ,
        String content) implements Serializable {

    public static ArticleCommentRequest of(Long articleId, String content) {
        return new ArticleCommentRequest(articleId, content);
    }


    public ArticleCommentDto toDto(UserAccountDto userAccountDto) { //로그인 세션
        return ArticleCommentDto.of(
                articleId,      
                userAccountDto,
                content
        );
    }

댓글 입력과 수정 삭제 등등은 모두 로그인 된 상태에서만 동작하기 때문에, 현재 로그인 되어있는 계정의 정보를 집어 넣어준다.

계정 Dto 작성

public record BoardPrincipal(
        String username,
        String password,
        Collection<? extends GrantedAuthority> authorities,
        String email,
        String nickname,
        String memo
)  implements UserDetails {
    public static BoardPrincipal of(String username, String password, String email, String nickname, String memo) {
       
        Set<UserAccountRole> roleTypes = Set.of(UserAccountRole.USER);

        return new BoardPrincipal(
                username,
                password,
                roleTypes.stream()
                        .map(UserAccountRole::getValue)
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toUnmodifiableSet())
                ,
                email,
                nickname,
                memo
        );
    }
    public static BoardPrincipal from(UserAccountDto dto) {
        return BoardPrincipal.of(
                dto.userId(),
                dto.userPassword(),
                dto.email(),
                dto.nickname(),
                dto.memo()
        );
    }

    public UserAccountDto toDto() {
        return UserAccountDto.of(username,password, email, nickname, memo);
    }

UserDetails 인터페이스를 구현한 세션용 dto 를 만든다.
저장 데이터는 계정 테이블에 존재하는 컬럼들을 넣어주면 된다. (저 RoleType은 당장 필요 없을듯..)

@RequiredArgsConstructor
@Transactional
@Service
public class UserSecurityService implements UserDetailsService {
    private final UserAccountRepository userAccountRepository;

    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
            Optional<UserAccount> _account = userAccountRepository.findById(userId);
            if (_account.isEmpty()) {
                throw new UsernameNotFoundException("사용자를 찾을수 없습니다.");
            }
            UserAccount account = _account.get();
            List<GrantedAuthority> authorities = new ArrayList<>();
            if ("admin".equals(userId)) {
                authorities.add(new SimpleGrantedAuthority(UserAccountRole.ADMIN.getValue()));
            } else {
                authorities.add(new SimpleGrantedAuthority(UserAccountRole.USER.getValue()));
            }
        PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        return new BoardPrincipal(account.getUserId(), account.getUserPassword(), authorities, account.getEmail(), account.getNickname(), account.getMemo());
        }

저 BoardPrincipal 없이도 단순히 UserDetailsService 구현으로 반환값을 new User 로 주면 될거같긴한데.. 안해봐서 모르겠다. (이 클래스는 로그인 되었을때 로그인된 계정 정보를 넘겨주는 역할을 한다)

컨트롤러와 서비스 클래스 작성

 @GetMapping("/{articleId}")
    public String article(@PathVariable Long articleId, ModelMap map , ArticleCommentRequest dto,
                          CommentForm commentForm){
        ArticleWithCommentResponse article =  ArticleWithCommentResponse.from(articleService.getArticle(articleId));
        map.addAttribute("article",article);
        map.addAttribute("articleComments", article.articleCommentsResponse());
        map.addAttribute("dto",dto);
        return "articles/detail";
    }
    

댓글은 보통 한 페이지에 댓글만 달랑 있는게 아니라 게시글 밑에 댓글이 존재하니 Get 메소드로 게시글 하나를 조회할때 리퀘스트 Dto 도 같이 넘겨준다.
코멘트 폼은 @Valid 전용으로 넣어준 값인데.. 활용이 되는듯 안되는듯 (에러가 생기면 전 페이지로 돌아가고 문구가 출력되어야 하는데 되돌아가기만 한다.)

    @PreAuthorize("isAuthenticated()")
    @PostMapping("/{articleId}")
    public String writeArticleComment(@PathVariable Long articleId,ArticleCommentRequest dto,
                                      @Valid CommentForm commentForm,
                                      BindingResult bindingResult,
                                      @AuthenticationPrincipal BoardPrincipal principal){
        if (bindingResult.hasErrors()) {
            return "redirect:/articles/"+articleId;
        }
        articleCommentService.saveArticleComment(dto.toDto(principal.toDto()));
        return "redirect:/articles/"+articleId;
    } //현재 접속한 사용자의 정보를 받아와 DTO로 넘겨주고 , 댓글을 작성함
    
    
    @PreAuthorize("isAuthenticated()")
    @PostMapping("/update/{articleId}/{articleCommentId}")
    public String updateArticleComment(@PathVariable Long articleCommentId
            ,@PathVariable Long articleId
            ,@Valid CommentForm commentForm, BindingResult bindingResult,ArticleCommentRequest request){
        if (bindingResult.hasErrors()) {
            return "redirect:/articles/"+articleId;
        }
        articleCommentService.updateArticleComment(articleCommentId,request.content());
        return "redirect:/articles/"+articleId;
    }

post 메소드로 댓글을 저장할때 쓸 메소드를 선언해준다. 댓글이 달리고 바로 보고있던 게시글을 다시 출력하게 게시글 아이디와 , 검증용 BindingResult 도 추가해준다.
@AuthenticationPrincipal 어노테이션을 붙여서 UserDetails 서비스에서 아까 리턴해준 값들을 들고온다.

수정은 단순히 입력값만 String 값으로 넣어준다.

    public void saveArticleComment(ArticleCommentDto dto) {
        try {
            Article article = articleRepository.getReferenceById(dto.articleId());
            UserAccount userAccount = userAccountRepository.findById(dto.userAccountDto().userId()).orElseThrow();
            ArticleComment articleComment = ArticleComment.of(article,userAccount,dto.content());
            articleCommentRepository.save(articleComment);
        } catch (EntityNotFoundException e) {
            log.warn("댓글 저장 실패. 댓글 작성에 필요한 정보를 찾을 수 없습니다 - {}", e.getLocalizedMessage());
        }
    }
    
    public void updateArticleComment(Long id,String content) {
        try {
            ArticleComment articleComment = articleCommentRepository.getReferenceById(id);
            if (content != null) { articleComment.setContent(content);}
        } catch (EntityNotFoundException e) {
            log.warn("댓글 업데이트 실패. 게시글을 찾을 수 없습니다 - dto: {}", e.getLocalizedMessage());
        }
    }

ArticleCommentRequest 에서 변환된 Dto에서 엔티티를 뽑아내서 댓글 Repository 에 저장 혹은 기존에 있던 댓글을 뽑아내와서 수정한다.

솔직히 여기까지는 큰 문제가 없다.

타임리프로 뷰에서 기능을 구현하는게 가장 골치아프다고 해야되나 ..

댓글 작성 뷰 작성과 기능 구현

<p class="comment">댓글</p>
        <div>
            <p class="comment2" sec:authorize="isAnonymous()">로그인 후 댓글을 작성할 수 있습니다.</p>
        </div>
        <div>
            <form sec:authorize="isAuthenticated()"
                  th:action="@{|/articles/comments/${article.id}|}" th:object="${dto}" method="post">
                <div class="input-group" style="width:auto">

                    <label for="content" class="form-label mt-4" hidden>댓글 작성</label>
                    <input type="text" th:field="*{content}" class="form-control" id="content" name="content"
                           placeholder="1자에서 100자 이내로 입력해주세요.">
                    <div class="field-error" th:errors="*{content}">
                        오류2
                    </div>
                    <button type="submit" class="btn btn-outline-dark">작성</button>
                    <br>
                </div>
            </form>

순서대로 설명해보자면 .. 로그인 되어있지 않은 상태에서는 로그인 후에 댓글을 작성할 수 있다는 문구를 출력해준다.

로그인 되어있는 상태라면, 댓글 작성 url 을 포스트 메소드로 걸어놓고, 내용 입력 태그에 타임리프 문법으로 content 라는 값에 입력할 필드라는 th:field 를 입력해준다.

 

(그 밑 th:errors 는 Bingding Results 가 에러를 가질때 에러 문구를 출력할 자리인데.. 댓글폼은 동작을 안하더라 ㅠㅠ)

여기까지도 딱히 큰 문제는 없다.. 진짜 문제는 댓글 수정 ..

 

댓글 수정 같은 경우에는 , 타임리프로 each 문을 돌려서, 모델맵으로 넘겨준 댓글 리스트에서 댓글을 하나씩 뽑아와서 출력하게 했고, 그 댓글 닉네임 출력부분 옆에, 로그인 상태이고 댓글 작성자가 로그인된 아이디와 같다면 수정 삭제 버튼이 출력되도록 했다.

 

내가 구현하고 싶었던건 , 수정 버튼을 누르면 댓글 하나가 내용이 폼 입력칸으로 바뀌면서 기존에 입력되있던 값을 갖고와서 원하는부분만 수정하는쪽으로 하고싶었지만,

 

그렇게 하려면 Jquery 를 이용해서 비동기식으로 다들 하시는거 같았다. 근데 나에겐 너무 복잡했고 이미 동기식으로 처리를 다 해놓은 나는 그렇게 하려면 바꿔야할게 너무 많았다고 생각했다.

 

그래서 생각했던게, 부트스트랩 콜랩스를 이용해서 수정 버튼을 누르면 입력칸이 밑으로 뾰로롱~ 펼쳐져서 수정을 하면 좋겠거니~ 라고 생각했다.

그래서 이렇게까지 구현을 했었다.

근데 이렇게 하니까 문제점이 뭘까 ..?

내가 수정이 가능한 모든 댓글에 입력칸이 등장했다...........

문제가 뭘까 하고 온갖 방법을 시도해보았었다. 하지만 결국엔 해결을 못하고 있다가

th:id 라는 문법이 있다는걸 발견했다.

 

그리고 가장 큰 문제는 내가 타임리프 문법속 each 문에 다른것들과 똑같이 콜랩스를 적용시켜도 콜랩스는 그 각각 칸으로 적용되는게 아닌 딱 그 태그 하나만 실행이 되는것 같았다. 결론적으론 프론트 문제였다.

 

온갖 검색을 동원한 결과..

<button th:attr="data-bs-target=|#c${articleComment.id}">
...
<div class="collapse" th:id="c + ${articleComment.id}">

이런 식으로 각 댓글 div 마다 댓글id 를 고유값으로 입력하게 하여서 one to many 에서 one to one 으로 변경을 해주었다.

그 결과 이렇게 각각 칸마다 수정을 할 수 있게 콜랩스 기능이 동작했다!

마무리

타임리프로 이렇게 댓글 수정을 구현 하는 글은 찾지 못했고,

도저히 못해결 하겠어서 고민하다가.. 스택오버플로우에 나처럼 비슷하게 콜랩스를 각 객체마다 넣고싶은데, 그게 안돼서 고민하시던 몇몇분들의 글을 참고해서 해결해보았다.

수정 관련 html 소스는 길고 또 너무 드러워서 .. (가독성이 안좋음..) 개인 기록용으로 글을 남기지만 혹시 필요한 분이 계시면 언제든 도와드리니 댓글 남겨주세용

  Comments,     Trackbacks
스프링부트 + JPA 양방향 맵핑 해시태그 기능으로 구현하기

해시태그

해시태그 하는 걸 생각하면 무엇이 가장 먼저 떠오를까?

그렇다. 인스타그램이나 트위터의 해시태그 기능이 떠오를 것이다. (마찬가지로 슛폼 콘텐츠 등.. 굉장히 여러 곳에서 사용되는 기능이다.)

해시태그를 구현할때 , 단순히 그냥 한 해시태그를 등록하게 설계해 검색으로 해당 해시태그가 등록된 글을 찾을 수 있게 구현을 할 순 있다.

하지만 단건으로 등록을 한다면?

해시태그의 본질은

내가 해당 주제에 대해서 검색을 했을 때, 해당 주제에 대해 쓰인 모든 글을 볼 수 있어야 하고, 마찬가지로 한 가지 주제가 아니라면 해시태그는 두 개 이상이 등록이 가능해야 한다.

단순히 기능 구현만을 위한 목적이라면 단건으로 등록해 게시글 엔티티에서 해시태그를 관리하게 도메인을 설계할 순 있다.

단건으로 설계를 해볼까?

단건으로 설계할 때는 굉장히 간단하다.

단순히 게시글 (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로 변경된 후 새로운 해시태그와 관계가 맺어진 모습이다.

마치며

해시태그 기능 구현은 단순히 방법 정도만 제시해주고 내가 직접 구현할 때 참고할만한 자료가 크게 없었다.

해당 기능을 구현한 글들이 어느 분의 개발 실력 향상에 도움이 되었으면 하는 마음과 내가 해결했던 문제들을 기록하고자 하는 마음에 글을 작성하게 되었다.

 

  Comments,     Trackbacks