프로젝트 리펙토링을 하면서 oracle로 구현했던 계층형 게시판을 mysql로 다시 구현해보고자 시도했다가 문제가 발생.

 

기존 Oracle에서 구현할때는 start with ~ connect by 를 활용해 아주 간단하게 처리했다.

이때 완전 착각했던게 '원글 글번호를 토대로 아래에 UpperNo가 동일한 데이터를 찾아 하위에 넣어주면 되겠네. MySQL에서도 그냥 이렇게 하면 되겠다' 라고 생각한점이다.

 

그래서 솔직히 mysql에서 다시 구현 해봐야지 생각만 했지 미루고 있다가 막상 하려 했더니 MySQL에는 start with ~ connect by를 사용을 못하네...?

 

그래서 이것저것 조인도 해보고 컬럼 추가도 해보고 삭제도 해보고 별걸 다해보고 고민해봤지만 방법이 딱 하나 떠올랐다.

step이라는 컬럼을 생성해 소수점 형태로 처리하는 방법.

원글은 0, 첫 답글은 0.1, 두번째 답글은 0.2, 첫답글의 답글은 0.11, 두번째글의 답글은 0.21 이런형태로.

 

이전에도 비슷한 방법으로 구현한적이 있었는데 그때는 아예 step 컬럼이 하위 계층의 순서를 정의하는 역할을 했었다.

그러다보니 중간에 넣어줘야 하는 경우는 뒷번호들을 모두 하나씩 밀어야 하는 경우가 생겨 해놓고도 비효율적이라는 생각을 했었던 방법이다.

 

요즘 사이트들을 보면 계층형태더라도 한 계층 정도만 나오거나 몇계층 안나오는걸 많이 보긴 했는데 그런 경우에는 사용해도 괜찮겠지만 이 경우는 계층에 제한이 없다는 전제하에 만들었던 거라...........

 

이렇게 소수점 형태로 처리하면 그냥 GroupNo만들어서 역순으로 정렬하고 step 순서로 출력하게 하면 잘 나오긴한다.

근데 문제는 글 등록할때마다 그럼 step값을 제대로 넣어줄 수 있어야 하고 그럼 자기 자신의 위치를 찾기 위해 조회를 한번 더 해야하는 경우가 발생할것 이라고 생각했다.

 

한 이틀 내리 그냥 이것저것 쿼리문 계속 만들어보고 돌려보고 하다가 도저히 안되겠어서 검색해봤더니 함수를 이용한 처리와 재귀를 이용한 처리가 있다는 것을 알게 되었다.

 

전에는 못보던 글들인데 아무래도 그때는 'mysql 계층형 게시판' 이렇게만 검색해서 안나왔었던것 같다.

 

그래서 '이방법은 알아두면 좋겠다.' , '써먹을 수 있는 방법이겠다' 라는 생각이 드는 방법이 함수랑 재귀 쿼리다.

 

 

함수를 이용한 처리의 단점으로는 구현이 복잡하다는 것이 단점이고 재귀 쿼리를 사용하는 방법은 mysql 5.7 이하 버전에서는 사용할 수 없다는 것과 테이블의 모든 행 개수만큼 반복하기 때문에 데이터가 많을수록 효율이 떨어진다.

 

나중에 다 까먹었을때 다시 보더라도 빠르게 이해할 수 있도록 최대한 자세하게 작성했으니

혹시나 보시는 분들 있으시면 알고계신 부분들은 넘기시면서 보시는거 추천..

 


데이터는 이렇게 존재한다. 물론 뭐 이 위로도 1~73까지 있긴하지만 계층형 구현을 하는 부분은 이부분 밖에 없기 때문에...

그럼 여기서 순서는 75 -> 76 -> 77 -> 78 -> 79 -> 81 -> 82 -> 84 -> 80 이순서로 계층이 완성되어야 한다.

 

방법 1. 함수

-- 함수

DROP FUNCTION IF EXISTS fnc_hierarchi; -- fnc_hierarchi라는 함수가 존재하면 drop

DELIMITER $$
CREATE FUNCTION fnc_hierarchi() RETURN INT -- fnc_hierarchi() 함수 생성 및 이 함수의 리턴 타입은 int
NOT DETERMINISTIC -- Stored routine을 매번 새로 호출해서 비교. 비교되는 레코드 수 만큼 호출 발생.
READS SQL DATA -- 함수가 데이터를 변경하지 않도록 한다.
BEGIN
  DECLARE v_id int;     -- 글번호(boardNo) 변수
  DECLARE v_parent int; -- 상위글 번호(upperNo) 변수
  DECLARE CONTINUE HANDLER FOR NOT FOUND SET @id = NULL; -- 마지막 레코드에 도달하면 @id를 null로 변환
  
  SET v_parent = @id; -- v_parent값을 @id 값으로 변환
  SET v_id = -1; -- v_id를 -1로 초기화
  
  IF @id IS NULL THEN -- 일반적인 if문과 동일. id가 null일 경우 아래 return을 수행.
    RETURN NULL;
  END IF;
  
  LOOP -- 반복문
  
  SELECT MIN(boardNo)
    INTO @id
    FROM board
    WHERE boardUpperNo = v_parent
      AND boardNo > v_id;         -- 조회 결과를 @id에 복사
  
  IF(@id IS NOT NULL) OR (v_parent = @start_with) THEN
    SET @level = @level + 1;
    RETURN @id;
  END IF;
  
  SET @level := @level - 1;
  
  SELECT boardNo, boardUpperNo
    INTO v_id, v_parent
  FROM board
  WHERE boardNo = v_parent;    -- 조회된 결과를 v_id = boardNo, v_parent = boardUpperNo로 넣어줌.
  
  END LOOP; -- 반복문 끝
  
END $$

DELIMITER;

-- Query

SELECT b.boardNo
  , CASE WHEN LEVEL-1 > 0 THEN CONCAT(CONCAT(REPEAT('   ', level-1),'ㄴ'), b.boardTitle)
      ELSE b.boardTitle
    END AS boardTitle
  , b.boardUpperNo
  , b.boardGroupNo
FROM(
  SELECT fnc_hierarchi() AS id
    , @level AS level
  FROM(
    SELECT @start_with:=0
      , @id=@start_with
      , @level:=0
  ) vars
    JOIN board
  WHERE @id IS NOT NULL
) fnc
  JOIN board b ON fnc.id = b.boardNo
  ;

 

이렇게 작성했다.

mysql에서 함수 작성이 가능한지도 몰랐기 때문에 처음보는 부분이 많았다.

 

일단 문법을 먼저 뜯어봤다.

 

DROP FUNCTION IF EXISTS fnc_hierarchi;

이 부분의 경우는 그냥 보자마자 이해할 수 있는 부분이었는데 fnc_hierarchi라는 함수가 존재한다면 DROP 해준다.

 

DELIMITER $$

보통 쿼리에서 ; 로 쿼리문을 마무리하게 되는데 이것을 $$로 바꾼다는 것이다.

이걸 설정하지 않으면 문장을 구분하기 어렵기 때문에 세미콜론이 아닌 $$로 변경하는 것이고

그래서 함수 마지막에 DELIMITER ; 로 다시 세미콜론을 사용하도록 되돌리는 것이다.

 

CREATE FUNCTION fnc_hierarchi() RETURNS INT

이 부분 역시 그냥 보이는 그대로 fnc_hierarchi라는 함수를 생성할것이고 이 함수의 반환타입은 INT라고 정의하는 부분이다.

이때 ( ) 안에는 파라미터가 들어갈 수도 있다.

여기서는 굳이 필요가 없어서 안넣었는데 fnc_hierarchi(boardNo INT) 이런식으로 사용할 수 있다.

 

NOT DETERMINISTIC

이 옵션의 경우는 Stored routine의 결과값이 계속 달라진다고 가정하고, 비교가 실행되는 레코드마다 이 Stored routine을 매번 새로 호출해 비교를 실행하도록 하는것이다.

기본 default가 NOT DETERMINISTIC이고 DETERMINISTIC 옵션은 동일한 입력 매개변수에 대해 항상 동일한 결과를 생성한다고 가정하고 1번만 함수를 호출하도록 한다.

한 포스팅에서 본것을 그대로 예를 들자면 데이터가 2,702,270개가 존재한다고 했을 때

함수에서는 2,700,000을 리턴하도록 작성했다면

1~ 2,702,700의 값을 갖고 있는 a 컬럼이 이 함수의 리턴값 보다 큰 경우를 count(*)하도록 하는 쿼리를 작성한다고 하자.

 

그럼 NOT DETERMINISTIC은 a가 1일때 함수를 호출해 2,700,000을 리턴받아 a와 비교를 하고,

a가 2일때 함수를 또 호출해 리턴받은 값과 비교하는 방법으로 처리하기 때문에

함수를 레코드 수만큼 호출하게 된다.

 

하지만 DETERMINISTIC 옵션으로 설정한다면 a가 1일때 함수를 호출해 2,700,000을 리턴받아 비교했으면

a가 2일때는 함수를 호출하지 않고 이전 리턴값인 2,700,000과 그대로 비교하게 된다.

그럼 함수는 레코드 수만큼 호출되는 것이 아닌 처음 한번만 호출되고 그 뒤로는 비교처리만 하게 되는것이다.

 

잘 설명해주신 분이 있어서 그건 Reference에서 확인할것.

이 함수에서는 하위 글에 대한 조회를 계속하고 리턴값이 계속 변하기 때문에 NOT DETERMINISTIC으로 모든 레코드수 만큼 호출되도록 해야 한다.

 

READS SQL DATA

함수가 데이터를 변경하지 않는다는 것을 나타내는 것이다.

이 옵션은 CONTAINS SQL, NO SQL, READS SQL DATA, MODIFIES SQL DATA 이렇게 네가지 종류가 존재한다.

함수가 데이터를 읽거나 또는 쓰는 정보를 제공하는 옵션이고 default는 CONTAIN SQL이기 때문에 하나를 명확히 지정해서 사용하는 것이 좋다.

 

이 함수에서는 함수가 데이터를 변경해야 할 이유가 없으므로 READS SQL DATA로 설정한다.

 

그리고 CREATE FUNCION 명령문이 default로 수용되도록 하기 위해서는 DETERMINISTIC 옵션이나 NO SQL 및 READS SQL DATA 중에 한개는 반드시 확실하게 명시해야 한다.

그렇지 않으면 

ERROR 1418 : This function has none of DETERMINISTIC, NO SQL or READS SQL DATA in its declaration and binary logging is enabled

이런 오류가 발생한다.

 

READS SQL DATA가 뭔지 알아보기 위해 검색했을때 다 이 오류에 대한 포스팅 밖에 안나왔다...

 

그나마 포스팅 한개를 찾았으니 자세한건 아래 Reference에서 확인.

 

BEGIN, END

구현부의 시작과 끝을 명시하는것이다.

 

DECLARE

변수를 의미한다.

그래서 이 함수에서는 v_id라는 int형 변수와 v_parent라는 int형 변수를 생성한 것이다.

 

마지막 CONTINUE HANDLER FOR NOT FOUND SET @id = NULL;

이 부분의 경우는 좀 다르다.

 

handler를 명시한건데 CONTINUE는 핸들러의 begin ~ end 부분을 실행하고 남은 본문을 이어서 수행하도록 하는 것이다.

그리고 not found는 커서가 마지막 레코드에 도달해 다음 레코드를 fetch하지 못했을때의 상황을 의미한다.

 

마지막 레코드에 도달했다면 SET @id = NULL; 로 @id 값으로 NULL을 넣어주라는 것이다.

 

여기서 핸들러는 try catch로 exception 처리를 하는것과 동일하다고 볼 수 있다.

 

IF

보이는 그대로 if문과 동일한 조건문이다.

IF로 시작하고 조건이 끝나 처리부분에 들어가려면 THEN으로 맺음을 해준다.

if(@id == null)

  return null;

이 상태와 같다고 보면 되고 if문이 끝나는 부분에서는 END IF로 끝났음을 알려야 한다.

 

LOOP

반복문이다.

LOOP역시 끝나는 부분에서 END LOOP로 끝났음을 알려야 한다.

 

SELECT ~ INTO ~

조회된 결과를 복사한다.

함수 내용으로 보면 MIN(boardNo)로 가장 작은 boardNo를 가져와 @id에 복사해주는 것이다.

함수 마지막 조회쿼리도 보면

SELECT boardNo, boardUpperNo INTO v_id, v_parent

이렇게 되어있는데

그럼 보이는 그대로 v_id에는 boardNo가 v_parent에는 boardUpperNo가 들어가게 된다.

 

사용자 정의 변수

함수 말고 쿼리문에서 확인해보면 @id, @start_with, @level 이런것들을 볼 수 있다.

이게 사용자 정의 변수다.

말그대로 내가 변수를 생성해 사용하는 방법이 되는것이고

여기에 값을 넣어주는 방법으로는 SET과 := 가 있다.

SET은 함수에서처럼 SET @id = 0 이런식으로 사용할 수 있다.

하지만 SET을 제외한 명령문에서는 = 가 비교연산자로 취급을 받기 때문에 :=로 값을 대입해야 한다.

@id:=0  이런식으로.

물론 SET에서도 SET @id:=0 이렇게 사용하는것도 가능하다.

 

여기서 저장하는 값에 의해 자료형이 정해지며 Integer, Decimal, Float, Binary, 문자열타입만 취급할 수 있다.

또한 변수를 초기화 하지 않은 경우의 값은 NULL, 자료형은 String 타입이 된다.

 

 

처리과정

 

SELECT fnc_hierarchi() AS id, @level AS level
FROM(
  SELECT @start_with:=0, @id:=@start_with, @level:=0
) vars
  JOIN board
WHERE @id IS NOT NULL

쿼리문에서 일단 이 부분을 먼저 보자면 제일 안쪽 select 에서 start_with를 0으로 잡고 @id는 @start_with를 그대로 받았으니 동일하게 0, @level 역시 0이다.

그럼 함수에서 보면  SET v_parent = @id; 이런 부분이 있다.

이때 @id가 저기 쿼리문에 있는 @id이다.

 

그래서 처음 함수를 호출했을 때 @id는 0이 되어 들어가게 된다.

중간중간에 있는 @level역시 쿼리문에 존재하는 level의 값이 들어간다.

 

함수 위에서부터 쭉 내려가보면

v_parent과 v_id에 값을 SET 해주고

IF문에서는 @id가 null이 아니기 때문에 넘어가게 된다.

 

그럼 이제 LOOP를 타게 되는데

테이블에서 상위 글번호를 의미하는 boardUpperNo와 v_parent가 동일하고 boardNo가 v_id인 -1보다 큰 데이터를 조건으로 찾고 있으므로 제일 작은 1번 글의 boardNo 가 @id로 복사된다.

 

그럼 여기까지 각 변수들 값을 확인해보면

@id = 1,  v_parent = 0, v_id = -1, level = 0이다.

 

LOOP 안에 있는 IF문을 보면 id가 null이 아니거나 v_parent = @start_with 인 조건인데

id는 null이 아니고 v_parent는 0이기 때문에 조건에 만족한다.

 

그래서 level + 1로 level이 1이 되고 그대로 @id를 리턴한다.

 

여기까지의 값들은 

@id = 1, level = 1 이다.

 

 

2번째 호출

 

다시 함수가 호출이 될때 새로 @id가 0이 들어오는 것이 아닌 1이 들어오게 되고

그럼 v_parent = 1, v_id = -1이 된다.

 

첫 IF문은 역시 뛰어넘게 되고 LOOP에 들어가 select에서 조회를 하는데

boardUpperNo = 1인것이 없기 때문에 null이 되고 @id는 null을 갖게 된다.

 

그럼 다음 if문에서 @id는 null이고 v_parent = 1이니까 0인 @start_with와 다르기 때문에 false로 빠지게 된다.

 

여기서 level을 감소시킨 뒤에 v_id = boardNo, v_parent = boardUpperNo

이렇게 값을 넣어주게 되는데

WHERE boardNo = v_parent이기 때문에 v_id가 v_parent가 되고 해당 데이터의 upperNo가 v_parent가 된다.

그래서 v_id = 1, v_parent는 0이 된다.

 

여기서 LOOP를 끝내는 조건이 없기 때문에 함수 제일 위로 올라가는 것이 아닌 LOOP문 제일 위로 올라가게 되고

select에서 이 값을 그대로 갖고 처리한다.

그럼 UpperNo = v_parent는 0이니 만족하고 boardNo > 1 이기 때문에 바로 다음 데이터인 2번 글의 boardNo가 @id에 복사되게 되고 다음 if문도 만족해 @id = 2, @level = 1이 된다.

 

이렇게 설명한 이유는 처리과정때문인데

처음 받아서 첫 글을 확인해 @id에 복사한 뒤에 다시 함수가 호출되었을때 하위글을 찾는 과정이기 때문이다.

 

만약 이 두번째 호출에서 1번글의 하위글이 있었다면?

LOOP에 들어와 첫 조회쿼리에서 boardUpperNo = 1 and boardNo > -1이기 때문에

하위 글이 @id에 복사되게 되었을 것이다.

 

근데 존재하지 않았기 때문에 null이 되었고 조건문 또한 통과하지 못해 LOOP에 갇히게 되며

그럼 하위 글이 없다고 봐야하므로 level을 다시 감소시키는 것이다.

그리고 마지막에 현재 @id보다 큰 수를 갖는 boardNo를 찾아야 하기 때문에 v_id에 boardNo를 넣어주고

@id가 boardNo인 데이터는 UpperNo가 설계한 제일 default 값일 것이므로 v_parent에 넣어 다음 원글을 찾을 수 있도록 하는것이다.


다시 처리 순서를 정리.

1~84번글까지 존재하고 답글이 달려있는 글은 위 이미지의 데이터만 존재한다고 가정.

그럼 처음 1번글에 대해 조회하고 @id에 boardNo를 넣어준다.

 

boardNo = 1,  @id = 1

 

그리고 하위데이터인 답글이 존재하는지 확인

 

where boardUpperNo = 1       == false

 

존재하지 않기 때문에 다음글을 조회

 

boardNo = 1, @id = 1

boardNo = 2, @id = 2

 

이 데이터 역시 하위데이터가 존재하는지 확인

 

where boardUpperNo = 2        == false

 

다음데이터 조회

 

boardNo = 1, @id = 1

boardNo = 2, @id = 2

boardNo = 3, @id = 3

......

boardNo = 75, @id = 75

 

이렇게 75번글까지 계속 반복.

 

하위데이터 확인

 

where boardUpperNo = 75     == true

이때 조회된 데이터는 75보다 큰 데이터중 가장 작은 데이터이기 때문에 76번글을 조회해 @id에 복사.

 

boardNo = 75, @id = 75

boardNo = 76, @id = 76

 

동일하게 하위 데이터 조회

 

where boardUpperNo = 77     == true

 

 

boardNo = 75, @id = 75

boardNo = 76, @id = 76

boardNo = 77, @id = 77

boardNo = 78, @id = 78

 

78번까지 이런식으로 조회하고 이 하위데이터는 없기 때문에 다시 상위로 이동.

 

where boardUpperNo = 77     == false

 

false이기 때문에 다시 한번 더 상위로 이동

 

where boardUpperNo = 76     == true

 

boardNo = 79, @id = 79

....

 

이런 순서로 계속 쌓아나간다.

 

그래서 @id가

1, 2, 3 ..... 75, 76, 77, 78, 79, 80, 81, 82, 84, 83

이렇게 쌓여나가게 되고 이걸 기준으로 정렬되어 출력되기 때문에 계층형이 완성된다.

 

 

여기까지는 잘 해결했으나 보통의 게시판처럼 마지막에 작성한 글이 제일 위에 있도록 내림차순 정렬을 하게 되면

이 계층형이 틀어지는 문제가 발생했다.

 

추가한건 쿼리문에서 ORDER BY boardGroupNo desc 한줄.

그래서 id도 다시 정렬하게 해줘야 하나? 라는 생각에

ORDER BY boardGroupNo desc, id asc

이렇게 바꿔봤더니

이번에는 80번 글이 중간에 들어가버린다..

너무 자연스러운 위치에 들어가서 속을뻔..........

 

그래서 생각을 조금 바꿔봤다.

아예 순서를 정해놓고 정렬하면 될거같은데??

 

그래서 함수랑 쿼리를 좀 수정했다.

-- 함수

DROP FUNCTION IF EXISTS fnc_hierarchi;

DELIMITER $$
CREATE FUNCTION fnc_hierarchi() RETURN INT 
NOT DETERMINISTIC 
READS SQL DATA 
BEGIN
  DECLARE v_id int;
  DECLARE v_parent int;
  DECLARE CONTINUE HANDLER FOR NOT FOUND SET @id = NULL;
  
  SET v_parent = @id;
  SET v_id = -1;
  
  IF @id IS NULL THEN
    RETURN NULL;
  END IF;
  
  LOOP
  
  SELECT MIN(boardNo)
    INTO @id
    FROM board
    WHERE boardUpperNo = v_parent
      AND boardNo > v_id;
  
  IF(@id IS NOT NULL) OR (v_parent = @start_with) THEN
    SET @level = @level + 1;
    SET @step = @step + 1;
    RETURN @id;
  END IF;
  
  SET @level := @level - 1;
  
  SELECT boardNo, boardUpperNo
    INTO v_id, v_parent
  FROM board
  WHERE boardNo = v_parent;
  
  END LOOP;
  
END $$

DELIMITER;

-- Query

SELECT b.boardNo
  , CASE WHEN LEVEL-1 > 0 THEN CONCAT(CONCAT(REPEAT('   ', level-1),'ㄴ'), b.boardTitle)
      ELSE b.boardTitle
    END AS boardTitle
  , b.boardUpperNo
  , b.boardGroupNo
FROM(
  SELECT fnc_hierarchi() AS id
    , @level AS level, @step AS step
  FROM(
    SELECT @start_with:=0
      , @id=@start_with
      , @level:=0
      , @step:=0
  ) vars
    JOIN board
  WHERE @id IS NOT NULL
) fnc
  JOIN board b ON fnc.id = b.boardNo
  ORDER BY boardGroupNo desc, step asc;

 

@id가 들어갈때마다 step역시 하나씩 증가해 들어가게 되기 때문에

출력 순서를 그대로 잡아주게 된다.

만약 함수를 사용하지 않고 step 컬럼을 만들어 처리한다면 데이터를 넣어줄때마다 수정을 해야하는 상황이 발생하지만 함수는 쿼리문에서 호출하면서 처리해주니까 그런 문제가 없어서 좀 더 낫다고 생각한다.

내림차순으로 조회하지 않는 경우는 step을 굳이 사용할 필요가 없겠지만

내림차순으로 조회해야 한다면 step을 사용하는것이 좋은것 같다.

물론 다른방법도 있겠지만 아직까지는 이 방법 말고는 딱히 생각이 안난다..................................

 

 

방법 2. 재귀

함수 이외의 방법으로 제일 많이 언급된 방법이 아마 재귀쿼리인것 같다.

WITH RECURSIVE board_CTE AS (
  SELECT boardNo
      , boardTitle
      , boardUpperNo
      , boardIndent
      , CAST(boardNo AS CHAR(100)) lvl
      , boardGroupNo
  FROM board
  WHERE boardUpperNo = 0
  UNION ALL
  SELECT b.boardNo
      , b.boardTitle
      , b.boardUpperNo
      , b.boardIndent
      , CONCAT(c.lvl, ',', b.boardNo) lvl
      , b.boardGroupNo
  FROM board b
    INNER JOIN board_CTE c
    ON b.boardUpperNo = c.boardNo
)
SELECT boardNo
    , CONCAT(REPEAT('    ', boardIndent), '', boardTitle) AS boardTitle
    , boardUpperNo
    , boardIndent
    , lvl
    , boardGroupNo
FROM board_CTE
ORDER BY boardGroupNo desc, lvl;

 

이 쿼리문의 경우 시작이 recursive 바깥 select가 시작지점이다.

from board_CTE로 recursive에 들어가게 된다.

 

CTE(common table expression)은 해당 SQL문 내에서만 존재하는 일시적인 테이블(결과의 집합)을 말한다.

WITH는 CTE를 생성하는 문법이고 RECURSIVE CTE는 서브쿼리에서 스스로를 참조하는 CTE이다.

 

이 안에서는 UNION으로 구분된 2파트로 나누어 진다.

첫 SELECT는 최초 행을 반환하고 두번째 SELECT는 추가행을 반환한다.

그리고 두번째 SELECT문이 더이상 행을 생성하지 않을 때 재귀가 끝나게 된다.

 

그럼 여기서 첫 SELECT문은 답글을 제외한 모든 원글의 데이터가 된다.

두번째 SELECT문은 board와 CTE를 조인해 UpperNo = boardNo를 만족하는 데이터를 찾아옴으로써

답글의 원글을 찾게 된다.

 

참고 데이터로 보자면 76번글은 UpperNo로 75를 갖고 있다.

그럼 조인에서 b.boardUpperNo(75) = c.boardNo(75) 이렇게 되고

조회되는 데이터는

 

b.boardNo(76)

, b.boardTitle(RE: 제목 75)

, b.boardUpperNo(75)

, b.boardIndent(1)

, lvl(75,76)

, b.boardGroupNo(75)

 

이렇게 된다.

기존 데이터와 다른부분이 lvl뿐이다.

 

그럼 76의 하위글인 77번글은?

76의 lvl을 그대로 가져와 lvl(75,76,77)

이렇게 된다.

 

이렇게 모든 데이터에 대한 조회를 하게 되는데

원글의 경우 upperNo가 0으로 되어있기 때문에 따로 조인이 일어나지 않아 가져오는 데이터가 없게 되고

답글들만 조회해 참조하고 있는 데이터의 No와 합쳐 lvl 을 생성하게 되는 구조다.

 

그럼 데이터는 아래와 같이 나오게 된다.

lvl에서는 최상위인 원글부터 그 아래 답글까지 다 갖게 해서 이걸로 정렬하게 된다.

그래서 내림차순으로 정렬하는 경우는 ORDER BY boardGroupNo desc, lvl asc로 처리해주면 되고

오름차순의 경우 ORDER BY boardGroupNo, lvl로 처리해주면 된다.

 

 

 

Oracle에서 start with ~ connect by 를 사용해보고 난 뒤여서 그런지

이 두방법 다 복잡해보이고 효율도 좋아보이지 않는다..

그래도 이전에 했던 방법처럼 step 컬럼으로 순서 조정하면서 찾아다가 수정하고 등록하고 하는 방법보다는 나은것 같아서 이렇게 계층에 제한이 없는 조건이라면 이 방법이 더 나은것 같긴 하다.

하지만 요즘 사이트 댓글들 보면 그냥 한계층만 가능하도록 하는 경우도 많아서 그럴때는 또 굳이 이렇게까지는 쓰지 않아도 될것같다.

그때그때 알아서 잘 사용할 수 있게 미리 배워둔다는 느낌으로 알아두면 좋을듯!!!!

 

 

 

Reference

  • stored routine(READS SQL DATA)

 

 

MySQL Functions 생성, Stored 루틴 및 트리거 바이너리 로깅

먼저 function 을 만들려는 데 다음과 같은 에러가 발생했다. ERROR 1418 (HY000): This function has none of DETERMINISTIC, NO SQL, or READS SQL DATA in its declaration and binary logging is enabled (you..

blog.pages.kr

 

  • 함수 문법
 

[Mysql]Function 과 Procedure(함수와 프로시저) -1

안녕하세요. 오늘은 Function과 Procedure을 공부하겠습니다. 1편에서는 함수에 대해 잘 알아볼께요. Fu...

blog.naver.com

 

 

DETERMINISTIC , NOT DETERMINISTIC

함수나 프로시저 같은 Object 생성시 사용할 수 있는 옵션입니다. default값은 NOT DETERMINISTIC입니다. 아무것도 입력하지 않으면 자동으로 NOT DETERMINISTIC이 설정됩니다. 동일한 입력 매개 변수에 대해

bae9086.tistory.com

 

  • WITH RECURSIVE

 

 

MySQL WITH RECURSIVE

CTE와 재귀적 CTE

velog.io

 

 

  • 계층형 쿼리 참고
 

GNUJAVA

MySQL 에는 안타깝게도 Oracle 의 start with, connect by 를 지원하는 함수가 없다...  때문에 아래와 같이 function 을 만들어서 사용한다. 예제 테이블) test.servers_group create table test.servers_group  (  group_idx in

www.gnujava.com

 

JPA 연관관계 정리중에 @OneToMany에서 멘붕왔다...

fetchType부터 시작해서 mappedBy까지....

fetchType은 아직도 찾는중이라 mappedBy 먼저 포스팅.

 

mappedBy에 대해 찾아보다보면 양방향 연관관계가 꼭 따라온다.

연관관계 정리해서 포스팅 해야되니까 여기서는 그냥 간단하게만.......

 

연관관계는 단방향, 양방향이 존재한다.

제일 많이 나오는 예로 팀과 멤버가 있다.

 

하나의 팀이 있다면 그 안에는 여러 멤버들이 존재한다.

그렇다면 팀은 1이 되고 멤버들은 N이 된다.

팀은 하나지만 그 안에 속한 멤버는 여러명이기 때문이다.

그래서 이런 관계를 다대일, N:1, 일대다, 1:N 이라고 한다.

 

보통 데이터베이스 기준에서 보면 N에 외래키를 두게 된다.

매핑은 다대일 먼저 설명.

@Entity
public class User {
  ...
  
  @ManyToOne
  @JoinColumn(name = "tean_id")
  private Team team;
  
  ...
}

@Entity
public class Team {
  ...
  
  @Id
  @GenerataionValue
  @Column(name = "team_id")
  private Long no;
  
  ...
}

다대일 매핑은 이렇게 @ManyToOne을 사용해 처리해준다.

그럼 User가 '다'에 속하기 때문에 User 안에 team의 id값이 존재해야 한다.

이 엔티티들을 erd로 보면 아래와 같다.

 

코드에서 보면 ManyToOne으로 매핑을 해주겠다고 선언을 하고 JoinColumn 어노테이션을 통해 매핑할 엔티티의 Id 컬럼을 명시한다.

그럼 이제 멤버를 조회할때 어느 팀에 속해있는지 찾을 수 있게 된다.

 

이렇게 매핑된 상태를 단방향 연관관계라고 한다.

단방향 연관관계는 이렇게 한 방향으로만 참조할 수 있다.

멤버를 통해 소속 팀을 조회할 수 있지만 팀을 조회했을때는 멤버정보를 알 수 없다.

 

그럼 팀내에 속한 선수들의 목록을 보려면  다시 단방향 매핑으로 연결을 더 해야되나???

 

이걸 양방향으로 만들어주게 되면 팀에서도 멤버를 조회할 수 있게 된다.

근데 말이 양방향이지 두개의 단방향 연관관계가 묶여있다고 볼 수 있다.

 

@Entity
public class User {
  ...
  
  @ManyToOne
  @JoinColumn(name = "team_id")
  private Team team;
  
  ...
}

@Entity
public class Team {
  ...
  
  @Id
  @GenerataionValue
  @Column(name = "team_id")
  private Long no;
  
  @OneToMany(mappedBy = "team")
  private List<User> user = new ArrayList<User>();
  
  ...
}

 

이렇게 팀에서도 OneToMany로 연관관계를 설정해준다.

그럼 User - N:1 - Team,  Team - 1:N - User 이렇게 두개의 단방향 연관관계가 설정된다.

이 두 단방향 연관관계가 묶여 양방향 연관관계가 된다.

 

처음에는 양방향 연관관계는 user <-> Team 형식이라고 생각했지만 그게 아니라 User -> Team, Team -> User 형태로 두개의 단방향 연관관계를 묶어야 한다고 한다.

 

그럼 이제 여기서 mappedBy 속성이 추가되었다.

 

 

mappedBy에 대해 검색해보면 엔티티의 양방향 연관관계에서의 주인이 누구인지를 알려주는 옵션이라고 한다.

 

처음에 이거 보고 뭔소린가 했다...

 

mappedBy를 설명하기 전에 양방향 연관관계를 간단하게 설명한 이유가 여기있다.

위에서 JPA에서의 연관관계를 묶을때 단방향으로 설정하게 되면 한쪽에서는 조회가 되지만 한쪽에서는 조회할 수 없다고 했다.

 

즉, User에서는 속한 Team을 조회할 수 있지만 Team에서는 User를 조회할 수 없다.

 

하지만 데이터베이스에서 보면 Join을 통해 이것을 처리할 수 있다.

 

위에서와 동일하게 이런 테이블이 있다고 할 때

SELECT
  u.user_name
  , t.team_name
FROM
  user u
  inner join
    team t
      on u.team_id = t.team_id
WHERE
  u.user_id = 1;


SELECT
  u.user_name
  , t.team_name
FROM
  team t
  inner join
    user u
      on t.team_id = u.team_id
WHERE
  t.team_id = 1;

이렇게 양쪽 테이블에서 서로에 대한 조회가 가능하다.

 

그래서 데이터베이스에서는 연관관계의 방향성이라는 것이 존재하지 않는다.

 

하지만 JPA에서는 객체를 참조하는 방식으로 두 엔티티의 연관관계를 찾게 된다.

User에서 team을 찾거나 Team에서 user를 찾게 할 수 있다.

아까 언급했듯이 JPA에서의 양방향 관계가 단방향 두개를 묶어놓은것과 마찬가지라는 것이 이런 의미이다.

 

그렇기 때문에 JPA에서는 외래키를 누가 관리할지를 명시해줘야 한다.

위 ERD를 기준으로 user테이블에 team_id라는 외래키를 관리하고 있으므로 user가 갖고 있는 team이 연관관계의 주인이라고 명시해야 한다는 것이다.

 

JPA에서는 mappedBy를 명시하지 않는다면 두 엔티티가 양방향 관계임을 모르게 된다.

 

@Entity
public class User {
  ...
  
  @ManyToOne
  @JoinColumn(name = "team_id")
  private Team team;
  
  ...
}

@Entity
public class Team {
  ...
  
  @Id
  @GenerataionValue
  @Column(name = "team_id")
  private Long no;
  
  @OneToMany(mappedBy = "team")
  private List<User> user = new ArrayList<User>();
  
  ...
}

그래서 이렇게 User에서 team이라는 외래키를 갖고 있게 하고 있으니 mappedBy로 외래키의 객체명을 작성해줘야 한다.

 

 

이제 여기까지는 여기저기 찾아보면서 알게된 내용들이고 이제부터는 인강 보면서 알게된 내용으로 정리.

 

@Entity
@NoArgsConstructor
@AllArgsConstructor
@Data
@ToString(CallSuper = true)
@EqualsAndHashCode(callSuper = true)
public class Book extends BaseEntity {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  
  private String name;
  
  private String category;
  
  private Long authorId;
  
  private Long publisherId;
  
  @OneToOne
  @ToString.Exclude
  private BookReviewInfo bookReviewInfo;
  
}
@Entity
@NoArgsConstructor
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class BookReviewInfo extends BaseEntity {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  
  @OneToOne(optional = false)
  private Book book;
  
  private float averageReviewScore;
  
  private int reviewCount;
  
}
@SpringBootTest
class BookReviewInfoRepositoryTest {

  @Autowired
  private BookReviewInfoRepository bookReviewInfoRepository;
  
  @Autowired
  private BookRepository bookRepository;
  
  @Test
  void crudTest() {
    givenBookReviewInfo();
    
    Book result = bookReviewInfoRepository
          .findById(1L)
          .orElseThrow(RuntimeException::new)
          .getBook();
          
    System.out.println("result : " + result);
    
    BookReviewInfo result2 = bookRepository
          .findById(1L)
          .orElseThrow(RuntimeException::new)
          .getBookReviewInfo();
          
    System.out.println("result2 : " + result2);
  }
  
  private Book givenBook() {
    
    Book book = new Book();
    
    book.setName("new Book");
    book.setAuthorId(1L);
    book.setPublisherId(1L);
    
    return bookRepository.save(book);
    
  }
  
  private void givenBookReviewInfo() {
    
    BookReviewInfo bookReviewInfo = new BookReviewInfo();
    
    bookReviewinfo.setBook(givenBook());
    bookReviewinfo.setAverageReviewScore(4.5f);
    bookReviewinfo.setReviewCount(2);
    
    bookReviewInfoRepository.save(bookReviewInfo);
    
    System.out.println(">>>>>>> " + bookReviewInfoRepository.findAll());
    
  }
  
}

 

공부하다가 @OneToOne 하면서 mappedBy가 나왔기 때문에 다대일이 아닌 일대일로 정리.

일단 위 예제는 mappedBy를 붙이지 않고 테스트했다.

 

이대로 테스트를 하면 나머지는 다 제대로 출력되지만 result2는 null로 출력된다.

 

두 엔티티 모두 OneToOne으로 매핑해줬지만 bookReviewRepository를 통해 조회하는것은 정상 출력되나 bookRepository를 통해 조회하는 것은 null이 출력되는 것이다.

 

그래서 쿼리문을 확인해봤다.

select
        book0_.id as id1_1_0_,
        book0_.created_at as created_2_1_0_,
        book0_.updated_at as updated_3_1_0_,
        book0_.author_id as author_i4_1_0_,
        book0_.book_review_info_id as book_rev8_1_0_,
        book0_.category as category5_1_0_,
        book0_.name as name6_1_0_,
        book0_.publisher_id as publishe7_1_0_,
        bookreview1_.id as id1_2_1_,
        bookreview1_.created_at as created_2_2_1_,
        bookreview1_.updated_at as updated_3_2_1_,
        bookreview1_.average_review_score as average_4_2_1_,
        bookreview1_.book_id as book_id6_2_1_,
        bookreview1_.review_count as review_c5_2_1_,
        book2_.id as id1_1_2_,
        book2_.created_at as created_2_1_2_,
        book2_.updated_at as updated_3_1_2_,
        book2_.author_id as author_i4_1_2_,
        book2_.book_review_info_id as book_rev8_1_2_,
        book2_.category as category5_1_2_,
        book2_.name as name6_1_2_,
        book2_.publisher_id as publishe7_1_2_ 
    from
        book book0_ 
    left outer join
        book_review_info bookreview1_ 
            on book0_.book_review_info_id=bookreview1_.id 
    left outer join
        book book2_ 
            on bookreview1_.book_id=book2_.id 
    where
        book0_.id=?

select 부분도 book이 두번 중복되어 있는 것을 볼 수 있고

조인 부분도 이상하다.

book과 reviewInfo를 left outer join으로 한번 조회하고 book을 book2로 또 만들어 다시 조인한다.

 

그리고 또하나. book 테이블에서도 review_info_id를 조회하고 있고 reviewInfo 테이블에서도 book_id를 조회하고 있다.

DB 테이블 구성을 생각해보면 아무래도 이상하다.

그럼 서로의 기본키를 서로 외래키로 갖고 있다는 얘기가 되니까.

 

그래서 디버그 모드로 돌려봤다.

 

book이 생성된 후와 bookReview가 생성된 후를 break point로 찍어 데이터베이스에 어떻게 들어가는지 봤더니

book이 생성될때는 bookReviewInfo가 존재하지 않으니 당연히 review_info_id가 null이 들어간다.

 

그럼 이 두 데이터를 추가했을때 데이터 상황은 아래와 같다.

Book
id name book_review_info author_id category publisher_id
1 new Book null 1 null 1
book_review_info
id book_id averageReviewScore reviewCount
1 1 4.5 2

그럼 테스트 코드를 봤을때 bookRepository.findById(1L).getBookReviewInfo(); 를 처리한다면

findById를 통해 저 데이터를 찾을 수는 있지만 getBookReviewInfo는 null로 되어있기 때문에 처리가 불가능해진다.

 

그리고 저 쿼리문만 보더라도 book.book_review_info_id = book_review_info.id 이 조인문에서 결과가 나올리가 없다.

그 뒤 조인은 말할것도 없고.

 

 

그래서 Book 엔티티에 mappedBy 속성을 추가해준다.

@Entity
@NoArgsConstructor
@AllArgsConstructor
@Data
@ToString(CallSuper = true)
@EqualsAndHashCode(callSuper = true)
public class Book extends BaseEntity {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  
  private String name;
  
  private String category;
  
  private Long authorId;
  
  private Long publisherId;
  
  @OneToOne(mappedBy = "book")
  @ToString.Exclude
  private BookReviewInfo bookReviewInfo;
  
}

 

그리고 동일하게 테스트를 실행 하면 결과가 달라진다.

 

result2도 잘 출력되고 쿼리문도 바뀐다.

 

select
        book0_.id as id1_1_0_,
        book0_.created_at as created_2_1_0_,
        book0_.updated_at as updated_3_1_0_,
        book0_.author_id as author_i4_1_0_,
        book0_.category as category5_1_0_,
        book0_.name as name6_1_0_,
        book0_.publisher_id as publishe7_1_0_,
        bookreview1_.id as id1_2_1_,
        bookreview1_.created_at as created_2_2_1_,
        bookreview1_.updated_at as updated_3_2_1_,
        bookreview1_.average_review_score as average_4_2_1_,
        bookreview1_.book_id as book_id6_2_1_,
        bookreview1_.review_count as review_c5_2_1_ 
    from
        book book0_ 
    left outer join
        book_review_info bookreview1_ 
            on book0_.id=bookreview1_.book_id 
    where
        book0_.id=?

book 테이블에서 review_info_id도 조회하지 않고 조인 역시 book_id로 조인하는걸로 변경되었다.

 

그럼 이 테스트로 알 수 있는것은 mappedBy를 선언하지 않게되면 양쪽 테이블 모두 서로의 Id를 외래키로 갖게 된다.

서로의 Id를 외래키로 갖게 되면 어느 한쪽은 null이 될 수 밖에 없고 그럼 다시 수정을 통해 넣어줘야 하는 상황이 생기게 된다.

 

mappedBy를 선언함으로서 외래키를 관리할 연관관계의 주인을 명시해줘서 해당 테이블에만 외래키가 존재하도록 하게된다.

 

여기서 관리한다는 것은 외래키를 등록하거나 수정하고 DB에 접속해 그 값을 바꿀 수 있다는 것을 의미한다.

즉, 연관관계의 주인이 아닌 객체에서 아무리 등록 혹은 수정 작업을 하더라도 DB에는 전혀 반영이 되지 않고 오직 읽기만 가능하다.

 

 

 

좀 정리해보자면 mappedBy는 양방향 연관관계의 주인을 명시하는 것이고 주인이란 두 연관관계에서의 외래키에 매핑되는 객체이다.

이 연관관계의 주인은 양방향 연관관계에서 외래키를 등록하거나 수정하고 DB에 접속해 값을 바꿀 수 있는 관리 권한이 있다고 보면 된다.

양방향 연관관계에서 mappedBy를 선언하지 않는다면 양쪽 테이블 모두 서로의 id를 외래키로 갖게 되고 JPA에서는 양방향 관계임을 모르게 되어 조회 처리가 제대로 이루어지지 않거나 단방향 조회만 가능해지게 된다.

 

 

처음에 무슨소린지 도통 모르겠어서 이해하는데도 시간이 엄청 걸리기도 했고 포스팅마다 여러번 읽어보면서 겨우 이해한 내용은 이정도...

분명 아직 모르는 부분이 많을거같은데 기본적인 개념은 이렇게 되는것 같고 여러 방법으로 사용해보면서 더 테스트해보며 알아봐야될것같다...

 

 

 

Reference

  • 패스트캠퍼스 java/spring 초격차 패키지 Spring Data JPA
  • 참고한 블로그

 

 

JPA 양방향 연관관계와 mappedBy

JPA를 공부하면서 가장 헷갈렸던 것 중 하나가 매핑이었다. 연관관계가 있는 두 엔티티를 어떻게 묶어야 하고, 어떤 어노테이션을 써야 했으며 주의해야 할 점은 무엇인지에 대해 간단히 정리해

velog.io

 

 

[JPA] 양방향 연관관계란 - Heee's Development Blog

Step by step goes a long way.

gmlwjd9405.github.io

 

 

[JPA] 양방향 연관관계

양방향 연관관계와 연관관계의 주인 Team을 통해서도 getMemberList()로 특정 팀에 속한 멤버 리스트를 가져오고 싶다. 객체 설계는 위와 같이 Member에서는 Team을 가지고 있고, Team에서는 Members를 가지

ict-nroo.tistory.com

 

'Spring' 카테고리의 다른 글

IoC와 DI(2. IoC Container의 종류와 사용방법)  (0) 2022.08.18
IoC와 DI(1. IoC Container란)  (0) 2022.08.18
JPA Entity Listener 2  (0) 2022.03.17
JPA Entity Listener 1  (0) 2022.03.16
Jpa Entity 기본 Annotation 2 (@Column, @Transient)  (0) 2022.03.15

이전 포스팅에 이어 Entity Listener 정리.

 

예제 환경은 이전 포스팅과 마찬가지로 아래와 같다.

  • Intelli J
  • SpringBoot 2.6.2
  • Lombok
  • Gradle 7.3.2

 

createdAt과 updatedAt을 활용해 계속 Listener를 테스트해보고 있는데 이것들은 많이 사용되는 데이터이기 때문에 스프링에서는 별도의 기본 Listener를 제공하고 있다.

 

이 기본 Listener를 사용하기 위해 Application에 @EnableJpaAuditing 어노테이션을 추가하고

각 엔티티의 @EntityListeners에서 value로 MyEntityListener가 아닌 AuditingEntityListener를 추가해준다.

 

그리고 각 Entity에서 createdAt에는 @CreatedDate, updatedAt에는 @LastModifiedDate 어노테이션을 붙여주면 자동으로 해당 값을 처리해주게 된다.

 

@SpringBootApplication
@EnableJpaAuditing
public class BookmanagerApplication {
  
  public static void main(String[] args) {
    SpringApplication.run(BookmanagerApplication.class, args);
  }
  
}
//User Entity

@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
@EntityListeners(value = { AuditingEntityListener.class, UserEntityListener.class } )
public class User implements Auditable {
  
  @Id
  @GenereatedValue
  private Long id;
  
  @NonNull
  private String name;
  
  @NonNull
  private String email;
  
  @Column(updatable = false)
  @CreatedDate
  private LocalDateTime createdAt;
  
  @LastModifiedDate
  private LocalDateTime updatedAt;
  
  @Enumerated(value = EnumType.STRING)
  private Gender gender;
  
}


//UserHistory Entity

@Data
@NoArgsConstructor
@Entity
@EntityListeners(value = AuditingEntityListener.class)
public class User implements Auditable {
  
  @Id
  @GenereatedValue
  private Long id;
  
  private Long userId;
  
  private String name;
  
  private String email;
  
  @CreatedDate
  private LocalDateTime createdAt;
  
  @LastModifiedDate
  private LocalDateTime updatedAt;
  
}

그리고 테스트를 실행해보면 정상적으로 잘 처리된다.

 

 

이렇게 기본적으로 제공하는 리스너를 사용함으로써 기존에 사용하던 MyEntityListener는 사용하지 않아도 된다.

 

이 AuditingListener에서는 몇가지를 제공해주고 있는데 안에 들어가보면 여러 어노테이션들이 있다.

그중에 createdDate안에 있는 LastModifiedBy가 있는데

이건 생성 또는 수정한 사람의 정보를 함께 저장할 수 있는 기능이다.

누가 언제 생성했는지 누가 언제 수정했는지에 대한 것인데 스프링 시큐리티의 인증정보를 가져와 사용하는 방법으로 활용하면 좀 더 편리하게 사용이 가능하다.

 

 

그럼 여기까지가 이제 Entity Listener를 사용하는 방법들이었고 좀 더 실용적인 방법이 있다.

 

지금은 각 엔티티에 createdAt과 updatedAt이 중복되고 있다. 둘다 같은 형태로 사용하고 있는데 계속 반복해서 엔티티들에 들어가있는 상태다.

이런것 또한 분리해서 처리해주는게 좀 더 실용적이라고 볼 수 있다.

 

지금까지의 정리된 부분들은 이렇게 쓸 수 있구나 정도로 넘어가고 실용적인 방법으로 넘어간다.

 

일단 엔티티에 중복되는 createdAt과 updatedAt을 삭제한다.

그리고 BaseEntity라는 새로운 클래스를 생성해 그 안에 createdAt과 updatedAt을 넣어준다.

 

@Data
@MappedSuperclass
@EntityListeners(value = AuditingEntityListener.class)
public class BaseEntity {

  @Column(updatable = false, columnDefinition = "datetime(6) default now(6)")
  @CreatedDate
  private LocalDateTime createdAt;
  
  @Column(columnDefinition = "default(6) default now(6)")
  @LastModifiedDate
  private LocalDateTime updatedAt;
  
}
//User Entity

@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
@EntityListeners(value = UserEntityListener.class)
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class User extends BaseEntity implements Auditable {
  
  @Id
  @GenereatedValue
  private Long id;
  
  @NonNull
  private String name;
  
  @NonNull
  private String email;
  
  @Enumerated(value = EnumType.STRING)
  private Gender gender;
  
}


//UserHistory Entity

@Data
@NoArgsConstructor
@Entity
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class User extends BaseEntity implements Auditable {
  
  @Id
  @GenereatedValue
  private Long id;
  
  private Long userId;
  
  private String name;
  
  private String email;
  
}

BaseEntity에 붙어있는 @MappedSuperclass 어노테이션은 해당 클래스의 필드를 상속받는 엔티티의 컬럼으로 포함시키겠다는 의미다.

즉, BaseEntity 안에 있는 createdAt과 updatedAt을 상속받고 있는 User 엔티티와 UserHistory 엔티티의 컬럼으로 포함된다는 의미다.

그래서 각 엔티티에서는 createdAt과 updatedAt을 따로 작성하지 않아도 된다.

 

그리고 BaseEntity에서 EntityListener를 명시하고 있기 때문에 상속받는 엔티티에서는 EntityListener를 명시할 필요가 없다.

단, User 엔티티에서는 UserHistory 데이터를 같이 처리하기 위해 UserEntityListener를 처리하도록 해야 하기 때문에 명시 해야한다.

 

각 엔티티에 추가적으로 붙은 어노테이션으로 @EqualsAndHashCode(callSuper = true)와 @ToString(callSuper = true)가 있다.

 

이 두 어노테이션을 붙이지 않은 상태로 테스트코드를 실행하게 되면

Generating equals/hashCode implementation but without a call to superclass, even though this class does not extend java.lang.Object. If this is Intentional, add ‘@EqualsAndHashCode(callSuper=false)’ to your type.

이런 오류가 발생한다.

equals/hashCode 구현을 생성하지만 이 클래스가 java.lang.Object를 확장하지 않더라도 superClass에 대한 호출이 없다. 이것이 의도적인 경우 유형에 @EqualsAndHashCode(callSuper = false)를 추가하라는 내용이다.

 

super class인 BaseEntity에 대한 정보가 처리되지 않는다는 것이다.

callSuper의 경우 이 호출이 제대로 처리되고 있지 않은 상황인데 이걸 의도적으로 한게 맞다면 false를 추가해 내가 의도적으로 이렇게 호출이 제대로 처리되지 않도록 했다 라고 명시하라는 것인데 지금은 의도적으로 한것이 아니기 때문에 true로 선언해 처리해줘야 한다.

 

@ToString(callSuper = ture)의 경우는 누락되면 별다른 오류가 발생하지 않고 createdAt과 updatedAt이 출력되지 않는다.

강의에서도 별다른 언급이 없어서 고민을 좀 해봤다.

 

@EqualsAndHashCode에서 발생한 오류처럼 동일하게 super class인 BaseEntity 호출이 제대로 처리되지 않은 상태라 callSuper = true 라고 명시하지 않는다면 BaseEntity에 대한 ToString을 무시해서 출력이 안되는것이라는 생각이 든다.

 

그래서 callSuper = true로 설정해 상속받고 있는 클래스까지 ToString을 생성하겠다는 것이라고 생각한다.

 

 

 

여기까지 완성된 코드를 전체적으로 보면 각 엔티티에서 중복되는 createdAt과 updatedAt은 BaseEntity라는 하나의 엔티티에 넣어 이것을 상속받는 모든 엔티티에서 컬럼으로 포함시킬 수 있게 되었고

상속받는 엔티티에서 데이터추가나 수정을 한다면 createdAt과 updatedAt에 현재 시간을 바로바로 넣어줄 수 있게 되었다.

 

일반적으로 이렇게 중복되는 컬럼들을 따로 모아 Entity를 작성하고 그 Entity를 상속받도록 하는 형태의 개발방식으로 많이 진행한다고 한다.

 

 

 

Reference

  • 패스트캠퍼스 java/spring 초격차 패키지 Spring Data JPA

 

 

Entity Listener는 엔티티의 변화를 감지하고 테이블의 데이터를 조작하는 일을 한다.

컬럼값이 추가되거나 수정되는 것에 대해 반복적인 코드를 계속 작성해 처리하는 것을 개선할 수 있게 해준다.

 

Entity Listener에는 7가지 이벤트가 있다.

 

  1. @PrePersist
  2. @PostPersist
  3. @PreUpdate
  4. @PostUpdate
  5. @PreRemove
  6. @PostRemove
  7. @PostLoad

 

목록을 대충 봐도 Pre와 Post로 구분이 된것을 볼 수 있다.

Pre의 경우는 메소드 호출되기 전, Post는 메소드가 호출된 이후에 실행되도록 한다.

 

Persist는 insert를 의미한다.

그래서 @PrePersist는 persist 메소드가 호출되어 실행되기 전에 실행되도록 하는 이벤트이고

@PostPersist는 persist 메소드가 호출되어 실행된 직후에 실행하도록 하는 이벤트이다.

 

Update는 의미 그대로 merge 메소드의 호출 시점에 따른 이벤트다.

@PreUpdate는 merge 메소드가 호출되어 실행되기 전에 실행되도록 하는 이벤트이고

@PostUpdate는 merge 메소드가 호출되어 실행된 직후 실행되도록 하는 이벤트다.

 

Remove는 delete 메소드의 호출시점에 따른 이벤트다.

@PreRemove는 delete 메소드가 호출되어 실행되기 전에 실행되도록 하는 이벤트,

@PostRemove는 delete 메소드가 호출되어 실행된 직후 실행되도록 하는 이벤트다.

 

마지막으로 @PostLoad는 select 조회가 일어난 직후에 실행되는 메소드이다.

 

이 시점들을 확인해보기 위해 테스트 코드를 작성.

 

정리전 예제 환경은 아래와 같다.

  • Intelli J
  • SpringBoot 2.6.2
  • Lombok
  • Gradle 7.3.2

 

-- data.sql

call next value for hibernate_sequence;
insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) values(1, 'coco', 'coco@gmail.com', now(), now());

call next value for hibernate_sequence;
insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) values(2, 'mozzi', 'mozzi@gmail.com', now(), now());

call next value for hibernate_sequence;
insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) values(3, 'coco2', 'coco2@gmail.com', now(), now());

call next value for hibernate_sequence;
insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) values(4, 'mozzi2', 'mozzi2@gmail.com', now(), now());

call next value for hibernate_sequence;
insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) values(5, 'coco3', 'coco3@gmail.com', now(), now());
public enum Gender {
  MALE,
  FEMALE
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
public class User {

  @Id
  @GeneratedValue
  private Long id;
  
  @NonNull
  private String name;
  
  @NonNull
  private String email;
  
  @Column(updatable = false)
  private LocalDateTime createdAt;
  
  private LocalDateTime updatedAt;
  
  @Enumerated(value = EnumType.STRING)
  private Gender gender;
  
  
  @Prepersist
  public void prePersist(){
    System.out.println(">>>>>> prePersist");
  }
  
  @PostPersist
  public void postPersist(){
    System.out.println(">>>> postPersist");
  }
  
  @PreUpdate
  public void preUpdate(){
    System.out.println(">>>>> preUpdate");
  }
  
  @PostUpdate
  public void postUpdate(){
    System.out.println(">>>>>> postUpdate");
  }
  
  @PreRemove
  public void preRemove(){
    System.out.println(">>>> preRemove");
  }
  
  @PostRemove
  public void postRemove(){
    System.out.println(">>>>> postRemove");
  }
  
  @PostLoad
  public void postLoad(){
    System.out.println(">>>> postLoad");
  }
  
}
@SpringBootTest
class UserRepositoryTest {
  
  @Autowired
  private UserRepository userRepository;
  
  @Test
  void listenerTest(){
    User user = new User();
    
    user.setEmail("coco1@gmail.com");
    user.setName("coco1");
    
    userRepository.save(user); //persist 동작 시점
    
    
    User user2 = userRepository.findById(1L).orElseThrow(RuntimeException::new); //select 시점
    
    user2.setName("coooocoooo");
    userRepository.save(user2); //merge 동작 시점
    
    userRepository.deleteById(4L); //delete 동작 시점
    
  }
  
}

테스트 코드로 실행 순서를 보면

persist(insert) -> select -> merge(update) -> delete 순서로 진행된다.

그럼 테스트를 실행하기 전 시점을 미리 예상해보자면

PrePersist -> persist -> PostPersist -> select -> PostLoad -> PreUpdate -> merge -> PostUpdate -> PreRemove

-> delete -> PostRemove 순서로 나오게 될것이다.

 

그럼 이제 테스트를 실행해서 보면

제일먼저 prePersist가 출력된다.

그 후 insert 쿼리가 실행이 되고 PostPersist가 찍힌다.

 

그리고 select 쿼리가 실행된 후에 postLoad가 찍히고 select 쿼리가 한번 더 찍히는데 이건 save메소드에서 update 전 존재 여부때문에 찍어보는것이다. 이 쿼리문 이후에 postLoad가 한번 더 찍힌다.

 

다음으로는 preUpdate 출력되고 update 쿼리 실행 이후 postUpdate가 출력되었고

delete 이전 존재여부에 대한 select 조회 이후 postLoad가 한번 더 찍힌 후에 바로 preRemove 출력 후에

delete 쿼리가 실행되고 postRemove가 찍히는 것을 볼 수 있다.

 

이렇게 해당 쿼리 실행을 기준으로 이전 이후로 나누어져 이벤트가 호출되어 실행되는 것을 볼 수 있다.

 

 

그럼 이런 이벤트를 어디서 쓸것이고 어떻게 쓸것인지를 본다.

일단 복잡하진 않지만 간단한 예제로 테스트를 한다.

 

@SpringBootTest
class UserRepositoryTest {
  
  @Autowired
  private UserRepository userRepository;
  
  @Test
  void prePersistTest(){
    User user = new User();
    
    user.setEmail("coco1@gmail.com");
    user.setName("coco1");
    user.setCreatedAt(LocalDateTime.now());
    user.setUpdatedAt(LocalDateTime.now());
    
    userRepository.save(user); 
    
  }
  
}

 

 

보통 회원 정보에 대해 저장을 하게 되면 아이디, 이름, 성별, 이메일 등의 정보는 사용자가 입력한 정보를 받아 추가해주거나 수정하게 된다.

그럼 그 정보들은 set을 통해 처리해야 한다고 볼 수 있다.

 

하지만 추가된 시간과 수정된 시간을 넣어주는 createdAt과 updatedAt은??

이건 사용자가 직접 입력하는 시간이 아닌 이 코드가 처리되는 시점의 현재시간을 그냥 넣어주기만 하면 된다.

 

그럼 매번 처리 코드를 짤때마다 set으로 넣어줘야 한다.

문제는 없지만 중복되는 코드가 너무 많고 시간을 잘못 입력한다거나 빼먹는다거나 하는 실수가 발생할 수도 있다.

 

그래서 이런 상황에서 엔티티 리스너를 활용해 처리하면 편해진다.

 

 

 

@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
public class User {

  @Id
  @GeneratedValue
  private Long id;
  
  @NonNull
  private String name;
  
  @NonNull
  private String email;
  
  @Column(updatable = false)
  private LocalDateTime createdAt;
  
  private LocalDateTime updatedAt;
  
  @Enumerated(value = EnumType.STRING)
  private Gender gender;
  
  
  @Prepersist
  public void prePersist(){
    System.out.println(">>>>>> prePersist");
    this.createdAt = LocalDateTime.now();
    this.updatedAt = LocalDateTime.now();
  }
  
  @PreUpdate
  public void preUpdate(){
    System.out.println(">>>>> preUpdate");
    this.updatedAt = LocalDateTime.now();
  }
  
  
}​

이렇게 처리해주게 되면 persist나 merge 동작전에 createdAt과 updatedAt이 쿼리실행전 현재 시간을 갖고 있도록 할 수 있다.

 

@SpringBootTest
class UserRepositoryTest {
  
  @Autowired
  private UserRepository userRepository;
  
  @Test
  void prePersistTest(){
    User user = new User();
    
    user.setEmail("coco1@gmail.com");
    user.setName("coco1");
    //user.setCreatedAt(LocalDateTime.now());
    //user.setUpdatedAt(LocalDateTime.now());
    
    userRepository.save(user); 
    
    System.out.println(userRepository.findByName("coco1");
    
    user.setName("cococo1");
    
    userRepository.save(user);
    
    System.out.println(userRepository.findByName("cococo1");
    
  }
  
}

 

이렇게 테스트를 실행해보면 첫 출력문에서는 createdAt과 updatedAt이 동일한 시간대가 들어가있는것을 볼 수 있고

마지막 출력문에서는 updatedAt이 수정되어 있는것을 확인할 수 있다.

 

 

다음 문제는 이제 엔티티의 개수가 많은데 모든 엔티티에서 createdAt과 updatedAt을 사용하는 경우다.

지금까지 본 구조로는 엔티티마다 @PrePersist와 @PreUpdate 메소드를 만들어 추가해줘야 한다.

그럼 엔티티마다 중복되는 코드가 발생하게 되고 중간에 놓칠 가능성도 있을 뿐더러 나중에 수정해야 하는 상황이 발생하게 되면 너무 많은 엔티티를 수정해야 한다.

 

이때는 Entity Listener를 지정해서 활용하는 방법으로 처리하면 좋다.

 

그래서 MyEntityListener라는 클래스를 하나 생성하고 MyEntityListener에서는 createdAt과 updatedAt이 존재해야 한다는 것을 알아야 하기 때문에 인터페이스를 하나 생성해 처리해준다.

 

그래서 Auditable 이라는 인터페이스를 생성해 그 안에 createdAt과 updatedAt을 처리할 수 있도록 해주면 된다.

 

public interface Auditable {
  
  LocalDateTime getCreatedAt();
  LocalDateTime getUpdatedAt();
  
  void setCreatedAt(LocalDateTime createdAt);
  void setUpdatedAt(LocalDateTime updatedAt);
  
}
public class MyEntityListener {

  @PrePersist
  public void prePersist(Object o) {
    if(o instanceof Auditable) {
      ((Auditable) o).setCreatedAt(LocalDateTime.now());
      ((Auditable) o).setUpdatedAt(LocalDateTime.now());
    }
  }
  
  @PreUpdate
  public void preUpdate(Obejct o) {
    if(o instanceof Auditable) {
      ((Auditable) o).setUpdatedAt(LocalDateTime.now());
    }
  }
  
}

MyEntityListener에서 두 메소드는 파라미터로 Object를 꼭 받아야 한다.

아무것도 받지 않게 작성하면 Method 'prePersist' should take parameter of type 'object here이라는 오류가 발생한다.

엔티티 객체에서는 파라미터를 받지 않아도 this의 값이기 때문에 object를 확인할 수 있지만 listener는 해당 엔티티를 받아서 처리해야 하고 이 object가 어떤 타입인지 Listener에서 알기 힘들기 때문에 object로 강제화 해야 한다.

 

그래서 대신 object를 받으면 Auditable 객체 타입인지 확인하는 작업만 추가해 Auditable 객체일때는 createdAt과 updatedAt을 now로 설정하도록 추가해준다.

 

Auditable의 createdAt과 updatedAt은 각 엔티티에 implements Auditable을 붙여줘 getter와 setter를 받아 처리하도록 해주고 엔티티의 @PrePersist와 @PreUpdate 메소드들은 지워준다.

그리고 엔티티에서 @EntityListener(value = MyEntityListener.class)를 추가해 해당 리스너 클래스를 지정해준다. 

 

@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
@EntityListener(value = MyEntityListener.class)
public class User implements Auditable{

  @Id
  @GeneratedValue
  private Long id;
  
  @NonNull
  private String name;
  
  @NonNull
  private String email;
  
  @Column(updatable = false)
  private LocalDateTime createdAt;
  
  private LocalDateTime updatedAt;
  
  @Enumerated(value = EnumType.STRING)
  private Gender gender;
  
}

 

그리고 이전 테스트 코드인 prePersistTest 메소드를 실행시켜보면 잘 처리되는것을 확인할 수 있다.

 

이게 이전 코드랑 겹치지는 않았는지 확인을 해보고 싶으면 User 엔티티에서 @EntityListener(value = MyEntityListener.class)를 주석처리하고 테스트를 실행해보면 createdAt과 updatedAt이 null로 나오는것을 볼 수 있다.

 

null로 나오지 않는다면 어딘가 잘못 설정한것...

 

 

 

 

지금까지 테스트들 처럼 추가하는 케이스도 있지만 Hitory Data의 경우에는 DB에 특정 데이터가 수정되면 해당 값의 복사본을 다른 테이블에 저장해두는 경우가 있다.

 

그래서 이 예제는 User 테이블의 데이터가 수정될때 이 복사본을 userHistory 라는 테이블에 저장하도록 할것이다.

 

UserHistory 엔티티와 UserEntityListener를 생성한다.

UserEntityListener를 생성하는 이유는 User가 생성될때마다 userHistory에도 생성해주기 위함이다.

마지막으로 userHistoryRepository도 추가해준다.

 

@Entity
@NoArgsConstructor
@Data
@EntityListener(value = MyEntityListener.class)
public class UserHistory implements Auditable {
  
  @Id
  @GeneratedValue
  private Long id;
  
  private Long userId;
  
  private String name;
  
  private String email;
  
  private LocalDateTime createdAt;
  
  private LocalDateTime updatedAt;
  
}
public interface UserHistoryRepository extends JpaRepository<UserHistory, Long> {

}
@Component
public class UserEntityListener{

  @Autowired
  private UserHistoryRepository userHistoryRepository;
  
  @PreUpdate
  public void prePersistAndPreUpdate(Object o) {
    User user = (User) o;
    
    UserHistory userHistory = new UserHistory();
    
    userHistory.setUserId(user.getId());
    userHistory.setName(user.getName());
    userHistory.setEmail(user.getEmail());
    
    userHistoryRepository.save(userHistory);
    
  }
  
}

 

 

UserEntityListener에서는 User의 정보를 UserHistoryRepository를 통해 userHistory에 저장해야 하기 때문에

@Autowired로 userHistoryRepository를 주입받아야 한다.

 

그리고 아래 테스트 코드를 실행해본다.

@SpringBootTest
class UserRepositoryTest {
  
  @Autowired
  private UserRepository userRepository;
  
  @Autowired
  private UserHistoryRepository userHistoryRepository;
  
  @Test
  void userHistoryTest(){
    User user = new User();
    
    user.setEmail("coco-new@gmail.com");
    user.setName("coco-new");
    
    userRepository.save(user);
    
    user.setName("coco-new-new");
    
    userRepository.save(user);
    
    userHistoryRepository.findAll().forEach(System.out::println);
    
  }
  
}

 

이 테스트를 실행하게 되면 오류가 발생한다.

nullPointerException이 발생하는데 EntityListener는 Spring Bean을 주입받지 못하기 때문이다.

그런데 지금 UserEntityListener에서 UserHistoryRepository를 주입받으려 했는데 주입받지 못해서 nullPointerException이 발생하게 되는것.

 

이 문제를 해결하기 위해 BeanUtils라고 클래스를 하나 생성해서 처리하고

UserEntityListener를 수정한다.

 

@Component
public class BeanUtils implements ApplicationContextAware {

  private static ApplicationContext applicationContext;
  
  @Override
  public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
    BeanUtils.applicationContext = applicationContext;
  }
  
  public static <T> T getBean(Class<T> clazz) {
    return applicationContext.getBean(clazz);
    //해당 클래스에 맞는 applicationContext에서 getBean을 통해 class에 맞는 bean을 리턴해준다.
  }
  
}
import com.fastcampus.jpa.bookmanager.support.BeanUtils;

public class UserEntityListener{

  @PrePersist
  @PreUpdate
  public void prePersistAndPreUpdate(Object o) {

    UserHistoryRepository userHistoryRepository = BeanUtils.getBean(UserHistoryRepository.class);
	//BeanUtils는 새로 생성한 클래스 외에도
    //org.springframework.beans.BeanUtils가 있기 때문에 import 시에 주의.
    User user = (User) o;
    
    UserHistory userHistory = new UserHistory();
    
    userHistory.setUserId(user.getId());
    userHistory.setName(user.getName());
    userHistory.setEmail(user.getEmail());
    
    userHistoryRepository.save(userHistory);
    
  }
  
}

 

User 데이터가 추가될때 history도 같이 추가될 수 있도록 @PrePersist도 같이 붙여준다.

 

그리고 테스트를 실행.

 

그리고 로그를 확인해보면 history insert -> user insert -> select(merge 이전 select) -> history insert -> user update

-> select(출력 조회)

이 순서대로 쿼리가 실행되는 것을 볼 수 있다.

그럼 의도한대로 persist 이전, update이전에 history에 추가해주는 것을 확인할 수 있다.

 

여기서 createdAt과 updatedAt이 User와 UserHistory 엔티티에 중복되어 들어가 있는데 이것도 분리할 수 있다.

 

이건 다음포스팅에....

 

 

 

Reference

  • 패스트캠퍼스 java/spring 초격차 패키지 Spring Data JPA

'Spring' 카테고리의 다른 글

JPA 양방향 연관관계(mappedBy)  (0) 2022.03.18
JPA Entity Listener 2  (0) 2022.03.17
Jpa Entity 기본 Annotation 2 (@Column, @Transient)  (0) 2022.03.15
Jpa Entity 기본 Annotation 1 (@GeneratedValue, @Table)  (0) 2022.03.14
JPA Enum 적용  (0) 2022.02.19

이전 포스팅에 이어 @Column과 @Transient를 정리.

 

정리전 예제 환경은 아래와 같다.

  • Intelli J
  • SpringBoot 2.6.2
  • Lombok
  • Gradle 7.3.2

1. @Column

@Column 어노테이션 속성을 먼저 확인해보면 name, unique, nullable, insertable, updatable, columnDefinition 등등이 존재한다.

name 속성의 경우 해당 컬럼의 이름을 지정해주는 속성이다.

 

처음부터 새로 만드는 프로젝트의 경우 별도로 지정해주지 않는것이 좋지만 DB는 유지하는 상태에서 리빌딩하는 경우 과거 네이밍 규칙으로 인해 가독성이 떨어지는 네이밍에 대해 수정하는 경우 사용하면 좋다.

 

강의에서의 예를 든것으로 createdAt을 예전에는 crtdat 이런 식으로 줄여서 사용하는 경우가 많았다고 한다.

그럼 이 crtdat을 DB에서는 유지하지만 코드에서는 createdAt으로 바꿔 가독성이 좋아지도록 변경할 수 있다는 것이다.

 

@Column(name = "crtdat")
private LocalDateTime createdAt;

이렇게 사용할 수 있고 어노테이션의 name 속성에 DB 컬럼명을 넣어주면 된다.

 

 

그리고 다음으로 많이 사용하는것이 nullable이다.

일반적인 쿼리를 사용하지 않는 경우에는 사전에 걸러주는 validation 역할을 하지 않는다.

DDL 쿼리를 자동으로 생성할 때 not null 필드를 만들어줄때 사용한다.

default 값은 true이기 때문에 따로 설정하지 않는다면 기본적으로 null을 허용하지만 false로 설정하게 되면 DDL 쿼리가 동작해 생성할 때 해당 컬럼을 not null로 생성한다.

@Column(nullable = true)
private LocalDateTime createdAt;

 

 

unique 속성은 @Table 어노테이션의 Unique Constraints와 동일한 역할을 하지만 @Table에서의 UniqueConstraints는 여러 컬럼들을 엮어 Unique 제약조건을 설정하는 것이라고 한다면 @Column에서의 unique 속성은 해당 컬럼 하나에 대한 제약조건을 설정할 때 사용한다.

default는 false로 되어있고 true로 설정하게 되면 unique 제약조건을 생성하게 된다.

하지만 이 경우 제약조건명이 랜덤생성되기 때문에 추천하진 않는다고 한다.

 

@Column(unique = true)
private LocalDateTime createdAt;

 

columnDefinition은 컬럼의 정보를 직접 지정할 수 있는 속성이다.

createdAt은 DDL이 생성될때 timestamp로 생성되고 있다.

 

그런데

@Column(columnDefinition = "datetime")
private LocalDateTime createdAt;

이렇게 columnDefinition을 사용하면 datetime으로 생성되는것을 볼 수 있다.

그리고 하나더.

default 값을 여기서 설정할 수도 있다.

 

createdAt의 경우 데이터가 insert되는 시점마다 현재 시간이 들어가야 한다.

그때마다 코드에서 직접 처리하도록 하게 되면 누락되는 부분도 생길것이고 모든 데이터에 대해 createdAt을 사용한다고 하면 수많은 엔티티의 insert 처리를 할때마다 다 데이터를 넣어 처리해줘야 한다.

 

그래서 DB 를 설계할때도 default 값으로 들어가도록 설정하는 경우가 있는데 이걸 columnDefinition으로 설정할 수 있다.

 

@Column(columnDefinition = "datetime default now()")
private LocalDateTime createdAt;

이렇게 설정하게 되면 DDL에서 쿼리를 확인했을 때 created_at datetime default now()로 생성되는것을 볼 수 있다.

 

여기서 주의해야할 점이 있따.

그럼 columnDefinition을 사용하지 않았을때 timestamp라는 데이터 타입이 기본적으로 생성되었으니까

데이터타입을 굳이 바꿔줄게 아니라면 default만 사용해도 되지 않을까??

 

@Column(columnDefinition = "default now()")
private LocalDateTime createdAt;

그래서 이렇게 테스트를 실행하고 DDL 쿼리를 확인해보면

created_at default now()

이렇게 테이터 타입이 사라진 것을 볼 수 있다.

 

왜 이렇게 되는지에 대해서는 Ejb3Column.java에서 확인할 수 있다.

 

코드가 좀 길게 되어있는데 이부분에서 확인이 가능하다

columnDefinition이 비어있다면 sqlType은 null이 된다.

이때 jpa에서 entity에 명시한 타입에 따라 데이터 타입을 넣어준다고 볼 수 있다.

그럼 columnDefinition이 비어있지 않다면??

columnDefinition의 값이 그대로 applyGlobalQuoting에 인자로 넘어가기 때문에 validaition 없이 값 그대로 들어간다고 볼 수 있다.

 

그래서 아예 작성하지 않는다면 알아서 타입을 잡아주지만 그렇지 않다면 있는 그대로 넘겨버리기 때문에 꼭 데이터 타입을 같이 적어줘야 한다.

 

 

table 속성은 하나의 엔티티를 두개 이상의 테이블에 매핑할 때 사용한다.

이걸 사용하게 되면 해당 필드를 다른 테이블에 매핑할 수 있다.

타입은 String 타입으로 매핑하고자 하는 테이블을 명시하면 된다.

 

하지만 @SecondaryTable 어노테이션을 사용하야 하고 @SecondaryTable은 위에서 말한것 처럼 하나의 엔티티를 두개 이상의 테이블에 매핑하기 위해 사용하는 어노테이션이다.

 

아무래도 하나의 엔티티가 두개의 테이블에 매핑되다보니 엔티티 하나를 조회하는데 테이블 2개를 조회해야 하기 때문에 최적화가 어렵다고 한다.

그리고 잘 사용하지도 않는다고 한다.

 

이 table 속성에 대한것은 레퍼런스에서 확인.

 

 

length 속성은 문자 길이 제약조건이다.

쉽게 보통 DB 설계할때 VARCHAR(255) 이렇게 만드는것처럼 문자열 길이를 명시한다.

default는 255이다.

 

@Column(length = 20)
private String name;

이렇게 실행하게 되면 name 컬럼은 varchar(20)이 된다.

 

 

precision과 scale은 BigDecimal이나 BigInteger에서 사용한다.

precision은 소수점을 포함한 전체 자리수를, scale은 소수의 자리수를 명시한다.

 

 

 

그럼 여기서 아직 확인하지 않은 속성이 insertable과 updatable인데 이 두 속성을 빼둔 이유는 위의 속성들은 DDL 생성시에 적용되지만 이 두 속성은 DML에도 영향을 끼치기 때문이다.

 

의미 그대로 엔티티 저장시, 수정시에 영향을 끼치는 속성으로 insertable은 엔티티 저장시에 이 필드도 같이 저장을 하고자 할때 사용하며 updatable은 엔티티 수정시에 같이 수정하고자 할때 사용한다.

 

둘다 동일하게 false로 설정하게 되면 DB에 적용되지 않고 false는 읽기전용으로 사용하고자 할때만 사용한다.

default는 true로 설정되어 있다.

 

@Column(updatable = false, columnDefinition = "datetime default now()")
@CreatedDate
private LocalDateTime createdAt;

@Column(columnDefinition = "datetime default now()")
@LastModifiedDate
private LocalDateTime updatedAt;

이런 형태로 설정하게 되는데 createdAt은 처음 insert 시에만 저장되어야 하기 때문에 updatable을 false로 설정해 수정이 발생할때는 createdAt은 수정하지 않게 된다.

그리고 updatedAt은 처음 insert시에도 같이 저장되어야 하지만 수정될때마다 계속해서 수정되어야 하기 때문에 아무것도 명시하지 않게 되면 default가 true이기 때문에 insertable = true, updatable = true 상태가 되어 저장시, 수정시 모두 처리하게 된다.

 

 

 

 

1. @Transient

@Transient 어노테이션은 DB에 존재하지 않지만 객체로서 사용하고 싶을 때 붙여 사용한다.

이 어노테이션이 붙어있는 필드는 영속성 처리에서 제외가 되기 때문에 DB 데이터에 반영이 되지 않고 해당 객체와 생명주기를 같이하게 되는 값이 된다.

 

이전에 설명한 insertable과 updatable의 false 기능에 추가적으로 select도 불가능하고 auto-ddl을 수행할때도 DDL에 반영되지 않도록 하는 어노테이션이라고 보면된다.

 

말그대로 DB에 전혀 영향을 주지 않고 쿼리문에도 영향을 끼칠 수 없는 단순한 객체로 만들어주는 어노테이션이다.

 

 

 

Reference

  • 패스트캠퍼스 java/spring 초격차 패키지 Spring Data JPA
  • @Column의 table 속성

 

 

JPA - 하나의 엔티티에 다수 테이블 매핑(@SecondaryTable,@SecondaryTables)

JPA - 하나의 엔티티에 다수 테이블 매핑(@SecondaryTable,@SecondaryTables) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Entity @Table(name = "BOARD") @SecondaryTable(name = "BOARD_DETAIL",        ..

coding-start.tistory.com

 

'Spring' 카테고리의 다른 글

JPA Entity Listener 2  (0) 2022.03.17
JPA Entity Listener 1  (0) 2022.03.16
Jpa Entity 기본 Annotation 1 (@GeneratedValue, @Table)  (0) 2022.03.14
JPA Enum 적용  (0) 2022.02.19
JPA QueryMethod 2 (where 절 조건 추가)  (0) 2022.02.17

Entity내에서 많이 사용할법한 기본 어노테이션을 정리.

정리할 어노테이션은 아래와 같다.

 

  1. @GeneratedValue
  2. @Table
  3. @Column
  4. @Transient

정리전 예제 환경은 아래와 같다.

  • Intelli J
  • SpringBoot 2.6.2
  • Lombok
  • Gradle 7.3.2

 

1. @GeneratedValue

@GeneratedValue는 JPA 기초 포스팅에서도 정리했지만 기본키 생성을 데이터베이스에게 위임해 id값을 따로 할당하지 않아도 데이터베이스가 자동으로 기본키를 생성해주도록 하는 어노테이션이다.

속성으로는 TABLE, SEQUENCE, IDENTITY, AUTO가 존재한다.

타입은 enum 클래스인 GenerationType에서 볼 수 있다.

사용은 아래와 같이 한다.

좀 더 자세한 사용법은 각 전략을 설명하면서 추가.

//AUTO

@Entity
public class User {

  @Id
  @GeneratedValue(strategy = GenerationType.AUTO) //AUTO는 default이기 때문에 strategy를 명시하지 않아도 된다.
  private Long id;
  
  ...
  
}

//IDENTITY

@Entity
public class User {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  
  ...
  
}

//SEQUENCE

@Entity
public class User {

  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE)
  private Long id;
  
  ...
  
}


//TABLE

@Entity
public class User {

  @Id
  @GeneratedValue(strategy = GenerationType.TABLE)
  private Long id;
  
  ...
  
}

 

@GeneratedValue에 들어가서 보면 default가 AUTO로 되어있는데 이 경우 각 DB에 적합한 값을 자동으로 넘겨 처리해주게 된다.

DB 의존성 없이 코딩할 수 있다는 장점때문에 AUTO를 사용하는 경우도 있다.

 

 

IDENTITY는 MySQL에서 주로 많이 사용하는 전략이며 MySQL에서의 Auto Increment라고 볼 수 있다.

 

 

SEQUENCE는 Oracle이나 Postgre, h2에서 사용한다.

SEQUENCE 전략을 사용한다면 @SequenceGenerator 어노테이션이 필요하다.

 

@Entity
@SequenceGenerator(
    name = "USER_SEQ",
    sequenceName = "USER_PK_SEQ",
    initialValue = 1,
    allocationSize = 50
    )
public class User {

  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "USER_SEQ")
  private Long id;
  
  ...
  
}

 

@SequenceGenerator는 이렇게 사용하고 속성으로는 name, sequenceName, initialValue, allocationSize, catalog, schema가 있다.

 

name은 식별자 생성기의 이름.

sequenceName은 데이터베이스에 등록할 sequence명.

initialValue는 sequence 초기값.

allocationSize는 sequence 호출시에 증가하는 수이며 기본값은 50이다. 이 크기를 적당히 설정해야 성능저하를 개선할 수 있다.

catalog, schema는 데이터베이스 catalog, schema 이름이다.

 

그리고 @GeneratedValue에서 generator에 name을 넣어서 사용하게 된다.

 

 

TABLE은 DB 종류에 상관없이 아이디값을 관리하는 별도의 테이블을 만들어두고 그 테이블에서 아이디값을 계속 추출해서 사용할 수 있도록 하고 있다.

Sequence랑 유사하며 @TableGenerator 어노테이션이 필요하다.

@TableGenerator는 @SequenceGenerator와 비슷한 속성을 사용한다.

별도의 테이블을 만들어두고 그 테이블에서 아이디값을 추출한다는 것은 Oracle에서 sequence를 만들어 사용하는것처럼 Sequence 테이블을 만들어 아이디값을 관리하고 이 테이블을 조회해 아이디 값을 가져온 뒤에 업데이트해 다음에 사용할 값을 증가시키는 형태로 운영하는 것을 의미한다.

하지만 최적화 되지 않은 테이블에서 키를 생성하기 때문에 성능 이슈가 발생할 수 있다.

 

@Entity
@TableGenerator(
    name = "USER_SEQ",
    table = "USER_PK_SEQ",
    pkColumnName = "USER_SEQ_NAME",
    pkColumnValue = "USER_SEQ_KEY",
    valueColumnName = "USER_SEQ_VAL",
    initialValue = 0,
    allocationSize = 50
    )
public class User {

  @Id
  @GeneratedValue(strategy = GenerationType.TABLE, generator = "USER_SEQ")
  private Long id;
  
  ...
  
}

@TaleGenerator 속성을 좀 보자면

name은 식별자 생성기의 이름.

table은 아이디값을 관리하는 테이블명.

pkColumnName은 컬럼명.

pkColumnValue는 키로 사용할 값의 이름.

valueColumnName은 Sequence 값의 컬럼명.

initialValue는 시작값. 이건 DDL 생성시에 사용한다.

allocationSize는 호출시 증가하는 수. 위에서 설명한것과 같이 적절한 크기를 잘 설정해야 한다.

 

좀 더 이해하기 편하게 표로 정리.

user_pk_seq(테이블명)
user_seq_name (pkColumnName) user_seq_val (valueColumnName)
user_seq_key (pkColumnValue) 0 (DDL로 생성시 초기 initialValue)

 

 

2. @Table

 

@Table 어노테이션은 테이블에 catalog나 schema를 지정해서 사용할 수 있고 name 역시 지정할 수 있다.

name은 일반적으로 Entity 이름에 맞는 Table name을 자동으로 지정해주기 때문에 따로 사용할 일은 없지만 특별한 경우에 name이나 schema, catalog를 지정할 일이 생긴다.

 

@Entity
@Table(name = "user_legacy")
public class User {

  @Id
  @GeneratedValue
  private Long id;
  
  ...
  
}

 

일반적으로 Entity와 동일한 테이블이 DB에 생성되게 되는데 위 처럼 @Table 어노테이션을 통해 name을 설정하게 되면 user 테이블 대신 user_legacy 테이블이 생성된다.

DB 마이그레이션을 하거나 legacy DB에 대해 적용해야 하는 경우 사용할만한 경우고 일반적으로는 Entity 명과 테이블명이 동일한것이 제일 좋은 방식이다.

 

그리고 @Table 어노테이션에서는 unique Constraints와 indexes를 사용할 수 있다.

@Entity
@Table(name = "user",
       indexes = { @Index(ColumnList = "name") },
       uniqueConstraints = { @UniqueConstraint(columnNames = {"email"}) }
       )
public class User {

  @Id
  @GeneratedValue
  private Long id;
  
  ...
  
}

 

이런식으로 사용하게 되는데 그럼 로그를 확인해보면 create index .... on user(name) 과 

alter table user add constraint ... unique (email) 이런 로그를 확인할 수 있다.

로그를 봤을때 indexes 속성의 경우는 해당 엔티티의 index를 만들어주는데 columnList의 컬럼으로 인덱스를 생성한다는 것을 할 수 있다.

 

그리고 uniqueConstraints 속성으로 columnNames의 컬럼에 unique 제약조건을 걸어주게 된다.

 

좀 더 추가적인 속성들을 보자면 indexes의 경우는 index 인터페이스에서 확인할 수 있으며 name, columnList, unique라는 세가지 속성이 있는 것을 볼 수 있다.

 

name은 생성하고자 하는 index 명이다.

unique의 경우 타입이 boolean으로 되어있는것을 볼 수 있고 default는 false로 되어있다.

unique index를 생성할것인가에 대한 전략인데 기본이 false이기 때문에 따로 설정하지 않는다면

일반적인 index, unique = true로 설정한다면 unique index가 생성된다.

그리고 columnList는 지정할 컬럼이 꼭 있어야 하기 때문에 필수로 넣어야 한다.

 

uniqueConstarint도 name 속성이 존재하며 제약조건명을 적어주면 된다.

 

@Entity
@Table(name = "user",
       indexes = { @Index(ColumnList = "name", name = "user_index", unique = true) },
       uniqueConstraints = { @UniqueConstraint(columnNames = {"email", "name"}, name = "user_unique_constraint") }
       )
public class User {

  @Id
  @GeneratedValue
  private Long id;
  
  ...
  
}

이렇게 사용한다.

 

여기서 주의해야할 점은 이 index나 constriant는 실제 DB에 적용되어있는것과 다를 수 있다는 점이다.

JPAEntity를 활용해 DB DDL을 생성하는 경우에는 어노테이션에 명시한 속성대로 적용이 되지만 일반적으로 많이 사용되는 CRUD 쿼리에 대해서는 아무런 영향을 주지 못한다.

 

즉, 실제 DB에 Index가 적용되어 있지 않은 상태인데 JPA에 index가 적용되어 있다고 해서 index를 활용한 쿼리가 동작하거나 하는 것은 아니라는 것이다.

 

호불호가 있는 편이지만 보편적으로는 index나 contraint들은 DB에 맡기고 JPA에서는 표기하지 않는 경우가 좀 더 많다고 한다.

 

 

 

Reference

  • 패스트캠퍼스 java/spring 초격차 패키지 Spring Data JPA
  • @GenerationValue
 

@GeneratedValue 전략

직접 기본키를 생성하는 방법 @Id 어노테이션 만을 사용하여 기본키를 직접 할당해주는 방법이 있다. 기본키를 자동으로 생성하는 방법 4가지 > 기본키를 자동으로 생성할 때에는 @Id와 @GenerratedVa

velog.io

 

 

JPA - 식별자(@Id) 값 자동 생성

데이터베이스가 관리하는 테이블의 로(ROW)는 기본키(Primary Key)에 의해 식별되고, JPA가 관리하는 엔티티 객체는 @Id로 지정한 식별자 변수를 통해 식별됩니다. JPA는 테이블의 기본 키와 엔티티 식

kdg-is.tistory.com

 

'Spring' 카테고리의 다른 글

JPA Entity Listener 1  (0) 2022.03.16
Jpa Entity 기본 Annotation 2 (@Column, @Transient)  (0) 2022.03.15
JPA Enum 적용  (0) 2022.02.19
JPA QueryMethod 2 (where 절 조건 추가)  (0) 2022.02.17
JPA QueryMethod 1 (기본 키워드)  (0) 2022.02.14

JPA에 Enum을 적용해 처리하는 방법에 대해 정리.

JPA를 사용할때 MALE, FEMALE이나 TRUE, FALSE 형태의 코드로 저장하는 컬럼들을 사용할 때 enum을 사용하면 데이터를 관리하기에 용이하다.

 

강의 내용을 정리하는 포스팅이다보니 굉장히 단순하고 기초적인 내용만 있고 아직 제대로 프로젝트에서 활용해보기 전이기 때문에 좀 더 상세한 내용은 Reference에서 확인.

 

일단 공부한 환경은 아래와 같다.

  • Intelli J
  • SpringBoot 2.6.2
  • Lombok
  • Gradle 7.3.2

 

예제는 MALE, FEMALE을 저장하는 컬럼이라는 가정하에 진행한다.

public enum Gender {

  MALE,
  FEMALE

}
@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
public class User{
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @NonNull
    private String name;
    
    @NonNull
    private String email;
    
    private Gender gender;
}
public interface UserRepository extends JpaRepository<User, Long>{

}

 

DB 테이블은 User 테이블이고 컬럼은 MySQL기준 BIGINT id, VARCHAR name, VARCHAR email, INT gender가 존재한다.

h2를 통해 data.sql을 사용한다면 굳이 gender 값을 insert 하도록 할 필요는 없다.

 

테스트코드는 아래와 같이 작성.

 

@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void enumTest(){
        
        User user = userRepository.findById(1L).orElseThrow(RuntimeException::new);
        
        user.setGender(Gender.MALE);
        
        userRepository.save(user);
        
        userRepository.findAll().forEach(System.out::println);
        
}

 

이렇게 테스트를 실행해보면 첫번째 데이터에 gender=MALE로 값이 잘 들어가있는것을 확인할 수 있다.

근데 여기서 발생할 수 있는 장애포인트가 있다.

 

h2가 아닌 다른 데이터베이스를 사용하고 있다면 데이터를 확인했을 때 gender 에 0이 들어가 있는것을 볼 수 있다.

 

그럼 이제 여기서 수정하고 다시 테스트해볼 부분.

public enum Gender {

  FEMALE,
  MALE

}

enum클래스를 이렇게 순서를 바꿔서 다시 테스트를 해본뒤에 데이터를 확인해보면 1로 바뀐것을 볼 수 있다.

 

만약 h2 In-memory DB를 사용하고 있다면 NativeQuery를 만들어서 확인하면 된다.

 

public interface UserRepository extends JpaRepository<User, Long>{

  @Query(value = "select * from user limit 1;", nativeQuery = true)
  Map<String, object> findRowRecord();

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void enumTest(){
        
        User user = userRepository.findById(1L).orElseThrow(RuntimeException::new);
        
        user.setGender(Gender.MALE);
        
        userRepository.save(user);
        
        userRepository.findAll().forEach(System.out::println);
        
        System.out.println(userRepository.findRowRecord().get("gender"));
        
}

이렇게 테스트해보면 h2 In-memory DB에 data.sql을 사용하는 환경에서도 확인이 가능하다.

 

그럼 다시 테스트 결과로 돌아와서 enum 클래스에 MALE, FEMALE 로 작성했을때는 MALE을 save 해준 데이터에

gender 값이 0이고 순서를 바꿔줬을때는 1이 들어가있는것을 확인할 수 있었다.

 

이유는 @Enumerated에서 확인할 수 있다.

 

user Entity의 gender위에 @Enumerated를 붙여주고 안에 들어가서 확인해보면

이렇게 되어있는것을 확인할 수 있다.

EnumType value의 default는 ORDINAL이라는 것인데 enum에서 ORDINAL은 zeroIndex를 의미한다.

즉, 배열에서의 index값처럼 0부터 시작하는 형태라는것.

 

그렇기 때문에 MALE을 먼저 작성했을때는 0이 들어가고 위치를 바꿨을때는 1이 들어가는것이다.

DB에는 이렇게 숫자형태로 들어가지만 JPA Entity에 의해 가져와 출력될때는 Enum클래스를 통해 MALE 혹은 FEMALE로 출력이 되는것이다.

 

그럼 이제 여기서 발생할 수 있는 장애포인트는 Enum클래스에 상수를 추가하게 된다면 추가 하는 위치에 따른 기존 데이터에서의 오류가 발생하는것이다.

 

예를들어 성별을 선택하지 않은 경우 NONE이라는 값을 준다고 했을때

public enum Gender {

  NONE,
  MALE,
  FEMALE

}

 

이렇게 제일 상단에다가 추가해버리면 기존에 MALE로 선택해 저장했기 때문에 0이라는 값을 갖고 있던 데이터들이 모두 NONE으로 출력되는 문제가 발생한다.

 

그래서 보통 이렇게 default 형태로 저장하는 방법 보다는 상수명 그대로 저장하는 방법을 사용한다고 한다.

물론 포스팅 최 상단에서 언급했듯이 다른 형태로 저장하더라도 직접 명시해 처리하는 방법도 있다.

일단은 그냥 default 상태에서 처리하는 방법만 정리한다.

 

그럼 이제 이걸 이 상수명 그대로 저장하기 위해서 아래와 같이 수정한다.

 

@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
public class User{
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @NonNull
    private String name;
    
    @NonNull
    private String email;
    
    @Enumerated(value = EnumType.STRING)
    private Gender gender;
}

 

그리고 h2 In-memory가 아닌 다른 DB를 사용하는 경우에는 gender 컬럼의 타입을 VARCHAR로 변경해준다.

그리고 원래 들어가있던 숫자형태의 gender 값을 FEMALE로 변경해준다.

gender값을 그대로 유지하거나 삭제만 한 상태로 테스트를 실행하게 되면 IllegalArgumentException이 발생하기 때문.

 

그리고 다시 테스트를 실행해보면 MALE로 변경된것을 볼 수 있다.

 

그럼 결과를 보면 @Enumrated(value = EnumType.STRING)을 붙여줌으로써

enum 클래스에 작성한 상수명 대로 값이 들어간다는것을 확인할 수 있다.

 

강의에서는 이렇기때문에 반드시 String으로 하는것을 추천한다고 하셨지만

아예 추가되거나 삭제될 일이 없는 값들이라면 사용해도 되지 않을까 싶긴 하다.

물론 어떻게 처리되는지에 대한 주석은 필수로 달아야 한다는 조건이 붙을것 같다.

 

포스팅 전 Jpa Enum으로 검색해 여러 포스팅들을 봤는데 보통은 상수명이 아닌 다른 값으로 넣게 된다면

여기처럼 0, 1 같은 숫자형태가 아닌 상수마다 명시해 처리할 수 있는 방법이 있었다.

 

이건 아직 해보지도 못했기 때문에 나중에 추가하거나 따로 포스팅..

 

 

 

Reference

  • 패스트캠퍼스 java/spring 초격차 패키지 Spring Data JPA
  • Jpa Enum 적용
 

JPA Enum Type 적용기~

Enum를 JPA에 적용하며 있었던 문제와 해결한 내용입니다.

velog.io

 

 

지난 포스팅에서는 쿼리메소드의 기본 키워드에 대해 정리했다면

이번에는 where절에 조건을 추가하는 방법에 대해 포스팅.

 

where 절에 조건을 추가할 수 있는 키워드들은 Jpa Document에서

Appendix C: Repository query keywords -> Supported query method predicate keywords and modifiers

에서 확인할 수 있다.

 

일단 테스트 환경은 다음과 같다.

  • Intelli J
  • SpringBoot 2.6.2
  • Lombok
  • Gradle 7.3.2

제일 처음에 해볼건 And와 Or다.

 

테스트는 아래와 같이 진행.

@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
public class User{
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @NonNull
    private String name;
    
    @NonNull
    private String email;
    
    private LocalDateTime createdAt;
    
    private LocalDateTime updatedAt;
    
}
public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByEmailAndName(String email, String name);
  
  List<User> findByEmailOrName(String email, String name);

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
        System.out.println(userRepository.findByEmailAndName("coco@gmail.com", "coco"));
        
        System.out.println(userRepository.findByEmailOrName("coco@gmail.com", "coco"));
        
}

현재 데이터는

insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) 
values(1, 'coco', 'coco@gmail.com', now(), now());

insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) 
values(2, 'mozzi', 'mozzi@gmail.com', now(), now());

insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) 
values(3, 'cococo', 'cococo@gmail.com', now(), now());

insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) 
values(4, 'cocococo', 'cocococo@gmail.com', now(), now());

insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) 
values(5, 'coco', 'coco2@gmail.com', now(), now());

이렇게 들어가있다.

그럼 테스트를 실행했을 때 And 조건에 맞는 1번 데이터만 출력하게 되고 or에서는 1,5번 데이터가 출력되게 된다.

 

로그에서 쿼리문을 확인해보면 where 절에서 email = ? and name = ? 과 email = ? or name = ? 이렇게 실행되고 있는것을 볼 수 있다.

어렵지 않은 부분이라 빠르게 패스.

 

 

다음은 after 와 before다.

위 데이터들을 보면 created_at과 updated_at으로 시간을 받고있는것을 볼 수 있다.

after와 before는 의미 그대로 이후 이전을 의미하고 보통 시간에 대한 조건으로 사용한다고 한다.

그래서 created_at을 통해 조회한다.

 

public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByCreatedAtAfter(LocalDateTime yesterday);
  
  List<User> findByCreatedAtBefore(LocalDateTime tommorow);
  
  List<User> findByIdAfter(Long id);

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
        System.out.println("findByCreatedAtAfter : " + userRepository.findByCreatedAtAfter(LocalDateTime.now().minusDays(1L)));
        
        System.out.println("findByCreatedAtBefore : " + userRepository.findByCreatedAtBefore(LocalDateTime.now().plusDays(1L)));
        
        System.out.println("findByIdAfter : " + userRepository.findByIdAfter(4L));
        
}

이렇게 테스트를 하게 되면 어제날짜의 현재 시간 이후에 저장된 데이터들이 모두 출력되고

before에서는 내일날짜의 현재시간 이전에 저장된 데이터들이 모두 출력된다.

 

쿼리문에서는 after의 경우 created_at > ? 형태로 실행하고 before는 반대로 created_at < ? 형태로 실행된다.

 

마지막에 findByIdAfter의 경우 id 값이 4보다 큰 데이터만 출력한다.

 

after와 before는 시간에 대한 조건으로 사용한다고 했는데 결과를 보면 다른 형태로도 사용이 가능하다.

하지만 after와 before라는 의미의 가독성을 위해 보통 시간에 대한 조건에서만 사용한다고 한다.

 

 

 

다음은 greaterThan과 LessThan이다.

after, before와 동일한 형태의 쿼리가 실행된다.

public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByCreatedAtGreaterThan(LocalDateTime yesterday);
  
  List<User> findByCreatedAtLessThan(LocalDateTime tommorow);
  
  List<User> findByCreatedAtGreaterThanEqual(LocalDateTime yesterday);

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
        System.out.println("findByCreatedAtGreaterThan : " + userRepository.findByCreatedAtGreaterThan(LocalDateTime.now().minusDays(1L)));
        
        System.out.println("findByCreatedAtLessThan : " + userRepository.findByCreatedAtLessThan(LocalDateTime.now().plusDays(1L)));
        
        System.out.println("findByCreatedAtGreaterThanEqual : " + userRepository.findByCreatedAtGreaterThanEqual(LocalDateTime.now().minusDays(1L)));
        
}

테스트를 실행해보면 이전 after와 before테스트때의 쿼리와 완전 동일한것을 볼 수 있다.

단, GreaterThan와 LessThan은 동일하게 > < 형태지만 GreaterThanEqual의 경우 >= 형태로 동일한 값도 포함한 데이터를 조회하는것을 볼 수 있다.

 

의미 그대로 보면 큰것, 작은것이기 때문에 after와 before 처럼 똑같이 사용할 수 있지만 가독성을 위해

after와 before는 시간, GreaterThan, LessThan은 그 외 필드에 사용하는것이 좋다고 한다.

가독성을 생각안하면 어떻게 쓰던 상관은 없다는것.

 

그리고 after와 before에서는 Equals를 포함하지 않는다.

 

 

 

다음은 between이다.

범위 내의 데이터 조회를 하기 위해 사용하기 때문에 변수가 두가지 필요하다.

 

public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByCreatedAtBetween(LocalDateTime yesterday, LocalDateTime tomorrow);

  List<User> findByIdBetween(Long id1, Long id2);

  List<User> findByIdGreaterThanEqualAndIdLessThanEqual(Long id1, Long id2);

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
      System.out.println("findByCreatedAtBetween : " + userRepository.findByCreatedAtBetween(LocalDateTime.now().minusDays(1L), LocalDateTime.now().plusDays(1L)));
      
      System.out.println("findByIdBetween : " + userRepository.findByIdBetween(2L, 4L));
      
      System.out.println("findByIdGreaterThanEqualAndIdLessThanEqual : " + userRepository.findByIdGreaterThanEqualAndIdLessThanEqual(2L, 4L));
        
}

이렇게 테스트를 진행하게 되면 날짜는 어제의 현재 시간 이후에 등록된 데이터라면 모두 출력하게 된다.

첫번째 테스트는 사실상 그냥 날짜도 가능하다 라는것을 보여주기 위함이고

두번째 테스트부터 보면 id 값이 2, 3, 4에 해당하는 데이터를 가져오게 된다.

between은 지정한 범위의 데이터를 모두 가져오기 때문에 지정 범위까지 모두 포함해 가져오게 된다.

 

로그에서 쿼리문을 보게 되면 id between ? and ?  형태가 된다.

 

마지막 3번째 테스트는 between을 사용하지 않고 풀어서 처리하는 경우다.

GreaterThanEqual은 >= ? 를 의미하고 LessThanEqual은 <= ? 를 의미하기 때문에 두개의 조건을 만족한다는것은

해당 범위를 포함한 그 안에 위치하는 데이터들을 의미하는것과 마찬가지다.

그래서 between과 동일한 결과를 출력하게 된다.

이렇게 사용하는경우는 Equal이 꼭 붙어있어야 이상, 이하로 조회되기 때문에 붙여줘야 하고 이부분은 잠재적인 오류 포인트로 자주 나타나는 부분이라고 한다.

개발툴이 많이 발달했어도 아직 쿼리상의 논리적인 오류는 잘 찾지 못하기 때문에 조심해야 한다고 한다.

어차피 같은 결과를 출력해야 한다면 between이 당연히 더 편하다!!!!!

이렇게도 사용이 가능하구나 정도만 알아두면 될듯!

 

 

다음은 Null값에 대한 조회다.

 

제일먼저 볼것은 isNotNull

public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByIdIsNotNull();

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
      System.out.println("findByIdIsNotNull : " + userRepository.findByIdIsNotNull());
        
}

isNotNull은 의미 그대로 null이 아닌것을 조회한다.

primary key인 Id를 기준으로 조회했기 때문에 당연히 모든 데이터가 출력된다.

쿼리문만 잘 보면 되는데 로그에서 보면

where

  user0_.id is not null

이렇게 조회하는것을 볼 수 있다.

 

 

다음은 isNotEmpty다.

public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByIdIsNotEmpty();

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
      System.out.println("findByIdIsNotEmpty : " + userRepository.findByIdIsNotEmpty());
        
}

이렇게 그냥 테스트를 실행하면 오류가 발생한다.

오류 내용을 보면

IsEmpty / IsNotEmpty can only be used on collection properties!

IsEmpty와 isNotEmpty는 Collection 속성에서만 사용할 수 있다는 것이다.

Collection은 객체의 모음, 그룹이라 볼 수 있다.

아직 collection 포스팅은 안해서 좀 더 자세한건 제일 아래에 Reference에서 확인.

Collection을 이해해야 좀 더 보기 편하긴 하다..

Relation이 있어야 가능하다고 보면 될듯.

 

근데 지금 테스트에서는 문자열이 아닌 long타입이기 때문에 괜찮지만 만약 문자열을 사용한 경우에는 오해의 소지가 좀 생길 수 있다.

일반적인 not empty는 notNull이면서 NotEmpty인 경우이기 때문이다. 문자열에서는 ""가 empty를 의미하기때문에 NotEmpty를 사용했을 때 실수할 수도 있다.

 

중요한건 IsNotEmpty 에서의 NotEmpty는 문자열에서의 NotEmpty가 아닌 Collection에서의 NotEmpty를 의미한다.

즉, 비어있지 않으면 가져와! 라는것이 아니라 collection이 비어있지 않으면 가져오라는것이다.

 

그래서 쿼리를 좀 보기 위해 테스트를 하나 더 진행.

 

그리고 User Etity 필드 하나와 Entity하나를 추가해준다.

 

@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
public class User{
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @NonNull
    private String name;
    
    @NonNull
    private String email;
    
    private LocalDateTime createdAt;
    
    private LocalDateTime updatedAt;
    
    @OneToMany(fetch = FatchType.EAGER)
    private List<Address> address;
    
}
@Entity
public class Address {
  
  @Id
  private Long id;
  
}
public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByAddressIsNotEmpty();

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
      System.out.println("findByAddressIsNotEmpty : " + userRepository.findByAddressIsNotEmpty());
        
}

Address는 데이터를 따로 넣지 않는다.

테스트를 실행하면 아무것도 출력되지 않는다.

Address에 데이터가 없으니까.

 

쿼리문만 보면 되니까 로그에서 쿼리문을 확인한다.

select
  user0_.id as id1_2_,
  user0_.created_at as created_2_2_,
  user0_.updated_at as updated_3_2_,
  user0_.email as email4_2_,
  user0_.name as name6_2_ 
from
  user user0_ 
    where
      exists (
        select
          address2_.id 
        from
          user_address address1_,
          address address2_ 
        where
          user0_.id=address1_.user_id 
          and address1_.address_id=address2_.id
      )

isNotEmpty라는 네이밍을 보면 Empty. 즉, 비어있지 않은 값을 조회한다고 생각하기 딱 좋다.

그럼 name을 기준으로 조회한다면 name is Not Null and name != ' ' 이런 형태를 생각하기 쉬운데

쿼리문을 보면 전혀 그런것이 아니라는것을 볼 수 있다.

where 절에서 exists를 통해 조회하는데 User안에 있는 address와 Address의 id를 비교해 동일한 값만 가져오도록 한다.

그럼 쿼리를 봤을때 결과가 나오는것은 isNotNull이랑 전혀 다른 결과가 나온다는것을 예측할 수 있다.

 

 

 

다음은 In와 NotIn.

public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByNameIn(List<String> names);

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
      System.out.println("findByNameIn : " + userRepository.findByNameIn(Lists.newArrayList("coco", "mozzi")));
        
}

In의 경우는 iterator타입인 List가 들어간다. 그리고 조회하는 타입이 제네릭으로 들어가야 하기 때문에

List<String> names 로 인자를 받는다.

 

테스트에서는 그냥 Lists로 테스트했지만 이렇게 하기 보다는 복수의 결과값을 리턴하는 다른 쿼리의 결과값을 이용해 조회한다.

In을 사용할때 조심해야하는 점은 Lists로 넣지 않고 쿼리 결과를 넘기도록 하게 되면 얼마나 많은 데이터가 in절로 넘어가는지 알수가 없다. in절안에 List의 길이가 너무 길어지게 되면 성능이슈가 발생할 수 있기 때문에 데이터가 어느정도 들어갈것인지를 사전에 검토하고 사용하는것이 좋다.

 

NotIn은 동일하게 사용하기 때문에 패스~!

 

 

다음은 containing, ending_with, starting_with.

이 세가지는 한번에 테스트.

public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByNameStartingWith(String name);
  
  List<User> findByNameEndingWith(String name);
  
  List<User> findByNameContains(String name);
  
  List<User> findByNameLike(String name);

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
      System.out.println("findByNameStartingWith : " + userRepository.findByNameStartingWith("mo"));
      
      System.out.println("findByNameEndingWith : " + userRepository.findByNameEndingWith("zi"));
      
      System.out.println("findByNameContains : " + userRepository.findByNameContains("zz"));
      
      System.out.println("findByNameLike : " + userRepository.findByNameLike("%oz%"));
      
}

Like를 추가해서 테스트를 진행했는데

일단 위 세가지만 좀 보자면 로그에서 쿼리를 봤을때는 셋다 동일하게

name like ? escape ? 로 출력되는것을 볼 수 있다.

그럼 차이를 보자면 출력되는 데이터의 name은 mozzi다.

그럼 메소드 네이밍이랑 같이 예측해보면 StartingWith는 mo%, EndingWith는 %zi, contating은 %zz% 형태라는것을

알 수 있다.

 

Like를 추가해서 작성한 이유는 차이를 좀 보기 위해서다.

나머지 세개의 메소드는 원하는 검색 키워드만 딱 입력해주면 되지만 Like는 그렇지 않다는것을 볼 수 있다.

이렇게 직접 입력하는 경우는 크게 어렵지 않을 수 있지만 만약 값을 받아서 검색을 한다고 하면

다른 메소드들은 (keyword) 형태로 작성해주면 되지만 Like의 경우는

("%" + keyword + "%") 이런 형태로 사용해야 하기때문에 아무래도 가독성이 떨어진다.

 

 

 

Reference

  • 패스트캠퍼스 java/spring 초격차 패키지 Spring Data JPA
  • Collection

 

 

[JAVA] Java 컬렉션(Collection) 정리

[JAVA] Java 컬렉션(Collection) 정리 ■ Java Collections Framework(JCF) Java에서 컬렉션(Collection)이란 데이터의 집합, 그룹을 의미하며 JCF(Java Collections Framework)는 이러한 데이터, 자료구조인 컬..

gangnam-americano.tistory.com

 

+ Recent posts