제가 작성할 로그인 구현 설명은
백엔드로 Spring Boot, Spring Data JPA 를,
프론트엔드로 React.js를 사용합니다.

1. 백엔드

application.yml

DB와 JPA 사용을 위한 yml 설정을 해주었습니다.

spring:
  profiles:
    include: oauth
  datasource:
    driverClassName: org.h2.Driver
    password: password
    url: jdbc:h2:mem:testdb;MODE=Mysql;
    username: sa
  h2:
    console:
      enabled: true
      path: /h2-console
  jpa:
    database-platform: org.hibernate.dialect.MariaDB103Dialect
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
    show-sql: true

 

CORS 설정

프론트엔드는 http://localhost:3000, 백엔드는 http://localhost:8080 을 사용할것이기 때문에

CORS 설정을 해주었습니다.

package com.yelim.security.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins("http://localhost:3000")
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .exposedHeaders("location")
            .allowedHeaders("*")
            .allowCredentials(true);
    }
}

 

User

package com.yelim.security.domain;

import com.sun.istack.NotNull;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import lombok.Getter;

@Entity
@Getter
public class User {

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

    @Column(nullable = false, unique = true)
    private String email;

    @NotNull
    private String password;

    public User() {}

    public User(Long id, String email, String password) {
        this.id = id;
        this.email = email;
        this.password = password;
    }
}

 

AuthController

로그인과 회원가입 api 를 껍데기만 마련해뒀습니다.

@RestController
public class AuthController {

    @PostMapping("/sign-up")
    public ResponseEntity<String> signUp() {
        return ResponseEntity.ok("회원가입 완료");
    }

    @GetMapping("/sign-in")
    public ResponseEntity<String> signIn() {
        return ResponseEntity.ok("로그인 완료");
    }
}

 

UserController

내 정보 조회 기능을 껍데기만 마련해두었습니다.

package com.yelim.security.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserController {

    @GetMapping("/users/me")
    public ResponseEntity<String> findMe() {
        return ResponseEntity.ok("me!");
    }
}

 

2. 프론트엔드

MyPage.js

내 정보 조회 결과를 보여줄 페이지입니다.

import React, {useEffect, useState} from 'react';
import findMe from "../api/FindMe";

const MyPage = () => {
  const [result, setResult] = useState("");

  useEffect(() => {
    findMe().then(resultPromise => setResult(resultPromise));
  }, []);

  return (
      <>
        {result}
      </>
  );
};

export default MyPage;

 

SignIn.js

로그인 페이지입니다.

import React from 'react';

const SignIn = () => {
  return (
      <div>
        <input type="text" placeholder="사용자이름"/>
        <input type="password" placeholder="비밀번호"/>
        <button>sign in</button>
      </div>
  );
};

export default SignIn;

 

App.js

import React from "react";
import {BrowserRouter as Router, Route, Switch} from "react-router-dom";
import SignIn from "./sign-in/SignIn";
import MyPage from "./my-page/MyPage";

const App = () => {
  return (
    <Router>
      <Switch>
        <Route path="/sign-in" exact component={SignIn}/>
        <Route path="/" exact component={MyPage}/>
      </Switch>
    </Router>
  );
}

export default App;

 

 

나는 처음에 아무생각없이

프론트엔드 실행 스크립트를 다음과 같이 작성했었다.

### 프로젝트 코드 최신화
cd $public_repo_path
echo $password | sudo -S git pull origin main

# 기존의 yarn start 프로세스 죽이기
FRONTEND_PID=$(ps -ef | grep "yarn start" | awk '{print $2}')

for pid in $FRONTEND_PID
do
    kill -15 $pid
done

### yarn start 재실행
cd $public_repo_path
cd frontend/rush
echo $password | sudo -S nohup yarn install
echo $password | sudo -S nohup yarn start &

이렇게 하면 문제가 있다. 그것은 바로
kill 한 다음 다시 yarn start 하는 사이에 웹 사이트 접속이 안된다는 것! (중단 배포...)

이 문제를 해결해보았다.


1. yarn build 사용

위의 쉘 스크립트를 보면 kill & yarn start를 썼는데...
이렇게 할게 아니라 yarn build를 써야한다.

 

yarn build를 하면
프로젝트 디렉토리 내부의 build 라는 디렉토리에

빌드 결과물인 정적 파일들이 생성된다.

 

이 디렉토리를 가지고
Nginx가 정적 웹 서버 역할을 하게 할 것이다.

 

2. 정적 파일을 내려주는 웹 서버로 Nginx 사용

이전까지 나는 Nginx를 reverse proxy로만 사용했다.

 

이제는
yarn build로 만들어둔 정적파일들을 가지고
Nginx가 브라우저에게 html/css/javascript 등을 내려주게 할 것이다.

 

nginx 설정파일을 수정해보자.

 

▶ Before

server {
  ...
  set $frontend_url 'http://127.0.0.1:3000';

  location / {
    proxy_pass $frontend_url;
  }
  ...
}

▶ After

server {
  ...
  location / {
    root   /home/pi/RUSH-C/frontend/rush/build;
    index  index.html index.htm;
    try_files $uri /index.html;
  }
  ...
}

 

index 란?

직관적으로 알 수 있다시피 / 경로에 매치시킬 정적파일을 말하는 것이다.

프로젝트 내부의 build 폴더에 가보면 index.html 파일이 있다. 이게 index 역할을 할거라는 뜻이다.

 

try_files 란?

(출처 : 참고링크 2번 - React를 Nginx 웹 서버에 배포하기)

nginx를 잘 써보지 않아 이번에 찾아 봤는데, react 프로젝트의 경우 페이지 라우팅을 react-router가 보통 하게 될것이다.
즉 index.html 자체에서 페이지 라우팅을 하는 것이다.

try_files 설정은 일종의 nginx 자체의 라우팅 설정이다. 보통 이 부분에서 특정 패턴의 url에 특정 파일등을 redirct 하는 설정을 한다. 만약 페이지를 못 찾을 경우 404 not found 설정등도 이곳에서 한다.

하지만 react 프로젝트인 경우, 웹서버에서 먼저 리퀘스트 url 을 가로채면 react-router의 기능을 사용할수 없게 된다.
(vue등도 마찬가지일 것이다.)
따라서 위처럼 모든 request를 index.html로 곧장 가게 설정해 줘야 한다.

 


참고

1

서울과기대 17학번 진영이언니 머릿속 (언니 고마워 ♥)

2

React를 Nginx 웹 서버에 배포하기

https://www.hanumoka.net/2019/12/29/react-20191229-react-nginx-deploy/

 

김영한님의 자바 ORM 표준 JPA 프로그래밍 책을 공부하면서

Casecade 의 정확한 동작 정의를 잘 모르겠다고 생각했다.

그래서 실습을 통해 테스트해보면서 기능을 정의해보았다.

 

이 글은 cascade 가 언제 주로 사용되는지와는 무관하게, 어떻게 동작하는지에 대해 정확히 이해하는 것을 목적으로 한다.

책에는 부모 엔티티에 적용하는 예제만 나오는데, 사실 cascade 옵션은 자식 엔티티에도 적용할 수 있다.

 

하지만 Casecade.REMOVE, orphanRemoval의 경우

적용하기 좋은 상황 자체가 거의 항상 부모 엔티티에 적용하는 상황이 되는 것 같다.

그래서 책에서도 부모 엔티티에 적용하는 상황만 소개했다고 생각한다.

 

용어 정의

foreign key 를 갖는 테이블의 엔티티를 자식 엔티티라고 하고, 반대쪽을 부모 엔티티라고 하자.
(자식 엔티티가 부모 엔티티를 참조함)


1. CascadeType.PERSIST

엔티티의 어떤 연관필드에 cascade = CascadeType.PERSIST 가 지정되어있으면,
엔티티를 persist 할 때

연관 필드들도 자동으로 persist 한다.

  • 자식 엔티티에 적용시 : 연관필드 먼저 자동으로 persist 한 뒤 엔티티를 persist 한다.
@Entity
public class User { // Level을 참조하는 자식 엔티티
    ...
    @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
    @JoinColumn(name = "level", nullable = false)
    private Level level;
    ...
}
private static void logic(EntityManager entityManager) {
    Level vip = new Level(null, "normal", 0.0);
    // 부모인 Level 을 persist 하지 않았음

    User user1 = new User(null, "test1@email.com", vip); // 3번째 파라미터에서 부모와 연결
    entityManager.persist(user1); // 부모인 vip 객체를 자동으로 persist 해준 뒤 user1을 persist
}
Hibernate: /* Level 객체가 먼저 insert 된 것을 알 수 있다!! */
    insert 
        into
            Level
            (id, discountRate, name) 
        values
            (null, ?, ?)
Hibernate: 
    insert 
        into
            User
            (id, email, level) 
        values
            (null, ?, ?)
  • 부모 엔티티에 적용시 : 엔티티를 persist 한 다음에 연관필드들도 자동으로 persist 한다. (예제가 책에 자세히 나와있으므로 생략)
@Entity
public class Level {
    ...
    @OneToMany(mappedBy = "level", fetch = FetchType.LAZY, cascade = CasecadeType.PERSIST)
    private List<User> users = new ArrayList<>();
    ...
}

2. CasecadeType.REMOVE

엔티티의 어떤 연관필드에 cascade = CascadeType.REMOVE 가 지정되어있으면,
엔티티를 remove 할 때
연관필드자동으로 remove 된다.

  • 자식 엔티티에 적용 : 엔티티를 remove 한 뒤 연관필드자동으로 remove 한다.
@Entity
public class User { // Level을 참조하는 자식 엔티티
    ...
    @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
    @JoinColumn(name = "level", nullable = false)
    private Level level; 
    ...
}
private static void logic(EntityManager entityManager) {
    Level vip = new Level(null, "VIP", 5.0);
    entityManager.persist(vip);

    User user1 = new User(null, "test2@email.com", vip);
    entityManager.persist(user2);

    entityManager.remove(user1);
    // 레벨 vip 을 지우는 코드가 없음
}
Hibernate: 
    insert 
        into
            Level
            (id, discountRate, name) 
        values
            (null, ?, ?)
Hibernate: 
    insert 
        into
            User
            (id, email, level) 
        values
            (null, ?, ?)
Hibernate: 
    delete 
        from
            User 
        where
            id=?
Hibernate: /* Level에 대한 delete가 실행된 것을 알 수 있다. */
    delete 
        from
            Level
        where
            id=?
  • 부모 엔티티 적용 - 연관 필드들을 자동으로 remove 한 다음 엔티티를 remove 한다. (예제가 책에 자세히 나와있으므로 생략)
@Entity
public class Level {
    ...
    @OneToMany(mappedBy = "level", fetch = FetchType.LAZY, cascade = CasecadeType.REMOVE)
    private List<User> users = new ArrayList<>();
    ...
}

3. orphanRemoval

댓글(Comment) & 글(Article) 로 예를 들어보자.
댓글은 반드시 하나의 글에 속한다고 하자.

즉, Comment 객체가 Article 객체를 반드시 한 개 참조하는 형태의 도메인이다.

 

Article 에서 다음과 같이

comments연관필드에 orphanRemoval = true 를 걸면

어떤 효과가 나타나는지를 정리해보자.

@Entity
public class Article {
    ...
    @OneToMany(mappedBy = "article", fetch = FetchType.LAZY, orphanRemoval = true)
    @Getter(AccessLevel.NONE)
    private List<Comment> comments = new ArrayList<>();
    ...
}

 

1.
개발자가 모종의 이유로
Comment 객체의 Article 참조를 제거하는 로직을 작성했다면?

소속된 글이 없는 댓글을 놔둘것인가??

이 때 고아가 된 Comment 객체를 자동으로 삭제해준다.

 

2.
CascadeType.REMOVE 와 마찬가지로
글이 삭제된 경우 (글 = Article = 부모)
자식이 부모와의 연관을 잃게 된 것과 마찬가지이므로
orphanRemoval = true 사용시 댓글이 자동으로 삭제된다. (=== CascadeType.REMOVE)

클래스패스란

*.java 파일은 컴파일러에 의해 *.class 파일로 변환된다.

 

Main 클래스가 User 클래스를 사용하고 있다고 해보자.

JVM으로 Main.class 파일을 실행해보고자 한다.

 

그러면 JVM은 User.class 파일을 찾아야하는데,
이때 classpath라는 변수에 저장되어있는 경로에서 User.class 파일을 찾는다.

 

찾았다면 잘 실행되고,

찾지 못한다면 java.lang.ClassNotFoundException 예외가 발생한다.

 

이와같이, 클래스패스는 JVM 이 .class 파일을 찾는데 기준이 되는 경로를 말한다.

 

gradle 프로젝트에서 classpath

Gradle 프로젝트에서 dependency를 추가하는 것이
일반적인 자바 어플리케이션에서 클래스패스에 해당 모듈(jar 파일)을 추가하는 것이다.


참고

1
Java 클래스패스

2
자바 클래스패스란?

3
maven, gradle 이해하기

상황

  • NGINX를 reverse proxy로 하여 백엔드&프론트가 80포트로 운영되고있음
  • 가비아에서 500원짜리 도메인을 사서 적용해두었음
  • 라즈베리파이 서버 (우분투와 매우 유사)

Certbot 설치

Lets Encrypt는 웹사이트를 위한 인증을 무료로 해주는 인증기관이다.

Certbot은 LetsEncrypt 인증서를 자동으로 발급/갱신해주는 봇 프로그램이다.

 

다음 명령을 통해서 Certbot을 설치하였다.

sudo apt install certbot

 

인증절차 수행 및 인증서 발급

HTTPS를 적용하기 위해서 인증 기관으로부터 인증을 받아야한다.

인증에 성공하면 인증서를 발급해준다.


인증 절차에는 여러가지 방법이 있다.
webroot 방식, stand alone 방식, dns 방식이 그것인데, 나는 webroot 방식으로 수행했다.

 

webroot 방식은

실제 돌아가고 있는 웹 서버에 특정 파일 쓰기 작업을 통해 인증하는 방식이다.

즉, 접근할 수 있는 특정 디렉터리를 제공해서 접근이 가능한지 확인하는 방식이다.

 

(자세한 내용은 포스트 하단 참고 1, 2번 링크)

 

1

80포트로 운영중인 nginx의 설정파일에
아래와 같은 location 설정을 추가한다.

location ^~ /.well-known/acme-challenge/ {
  default_type "text/plain";
  root /var/www/letsencrypt;
}

 

2

/var/www/letsencrypt/.well-known/acme-challenge 디렉토리 경로를 만든다.

mkdir -p /var/www/letsencrypt
cd /var/www/letsencrypt
mkdir -p .well-known/acme-challenge
  • mkdir p 옵션 : 존재하지 않는 중간 디렉토리 자동생성

앞서 webroot 방식은 접근할 수 있는 특정 디렉터리를 제공해서 접근이 가능한지 확인하는 방식이라고 했는데, 그 디렉터리가 /var/www/letsencrypt/.well-known/acme-challenge 인 것이다.

80 포트 웹 요청을 통해서 이 접근이 가능한지를 확인하는 것.

 

3

NGINX를 reload한다.

 

4

인증 과정(인증서 발급)을 시작한다.

sudo certbot certonly --webroot

이 명령이 시작되면 이메일주소도 쓰라고하고 도메인주소도 쓰라고 하니 잘 써주면 된다.


인증이 성공적으로 끝나면 다음과 같이 인증서가 잘 발급되었다고 나온다.

IMPORTANT NOTES:
 - Congratulations! Your certificate and chain have been saved at:
   /etc/letsencrypt/live/myservername/fullchain.pem
   Your key file has been saved at:
   /etc/letsencrypt/live/myservername/privkey.pem
   Your cert will expire on 2021-10-25. To obtain a new or tweaked
   version of this certificate in the future, simply run certbot
   again. To non-interactively renew *all* of your certificates, run
   "certbot renew"
 - If you like Certbot, please consider supporting our work by:

   Donating to ISRG / Let's Encrypt:   https://letsencrypt.org/donate
   Donating to EFF:                    https://eff.org/donate-le

 

5

발급받은 인증서(fullchain.pem, privkey.pem)를 사용하도록 NGINX 설정파일을 수정한다.

새로 추가되거나 변경된 부분에는 문장 끝에 #을 표시해두었다.

### footprint.conf ###
# http 접속시 https 로 리다이렉트
server { #
  listen 80; #
  return 301 https://$host$request_uri; #
} #

server {
  listen 443; #
  server_name footprint;

  ssl on; #
  # 앞에서 생성한, 지정 도메인에 대한 fullchain.pem 경로
  ssl_certificate /etc/letsencrypt/live/seoultechfootprint.shop/fullchain.pem; #
  # 앞에서 생성한, 지정 도메인에 대한 privatekey.pem 경로
  ssl_certificate_key /etc/letsencrypt/live/seoultechfootprint.shop/privkey.pem; #

  location /api {
    proxy_pass http://localhost:8080;
  }

  location / {
    proxy_pass http://localhost:3000;
  }

  location ^~ /.well-known/acme-challenge/ {
    default_type "text/plain";
    root /var/www/letsencrypt;
  }
}

 

6

CORS, 외부 API 정보를 조정해줬다.

 

 ~ 끝 ~

 


참고

1. webroot, stand alone, dns 방식

https://kscory.com/dev/nginx/https

 

2. Letsencryipt webroot 방식 인증

https://xxcv.tistory.com/6

 

3. 인증서발급 완료 후 nginx conf 파일 수정시 참고

https://jackerlab.com/nginx-https-lets-encrypt/

https://soyoung-new-challenge.tistory.com/116

우리 프로젝트는 여태까지

공유기에서 80 포트 (프론트) 와 8080 포트 (백엔드) 를 모두 열어놓고 사용했다.

 

그러나 이 방식으로는 HTTPS를 제대로 적용할 수 없다.

(HTTPS를 적용하려면 프론트와 백엔드 모두 443 포트가 되어야하니까)

 

따라서 프론트엔드 서버와 백엔드 서버의 포트번호를 통일하기 위해

NGINX 를 Reverse Proxy로 사용하기로 했다.

 

계획

Nginx 가 앞에서 80 포트 요청을 받으면

URI 에 따라 백엔드 (8080 포트) 혹은 프론트 (3000 포트) 로 건내주도록 한다.

이렇게 함으로써 백/프론트 모든 요청이 80 포트로 이루어지도록 한다.

 


1. 백엔드 api 변경

백엔드 api 가 "/api" 로 시작되도록 변경했다.

요청 uri가 /api 로 시작하면 백엔드 프로세스로, 그렇지않으면 프론트 프로세스로 전달하기 위해서다.

# application.yml 에 아래 코드 추가

server:
  servlet:
    context-path: /api

2. nginx 설치

$ sudo apt-get install -y nginx
$ nginx -v
nginx version: nginx/1.14.2
$ sudo find / -name nginx.conf
/etc/nginx/nginx.conf
$ service nginx status
$ service nginx reload

3. Default Configuration 파일 unlink

default configuration 에 의해서 아래와 같이 나오는 듯 하다.

 

이 기본 설정을 없애보자.

 

/etc/nginx/nginx.conf 를 열어보면 다음과 같은 부분이 있다.

http {
        ...

        ##
        # Virtual Host Configs
        ##
        include /etc/nginx/conf.d/*.conf;
        include /etc/nginx/sites-enabled/*;
        ...
}

이 부분에서 우리가 별도로 커스터마이즈한 conf 파일이 포함 된다.

default configuration 의 경우 실제 파일 위치는 다른곳에 있고 /etc/nginx/sites-enabled/ 경로에 심볼릭 링크가 생성되어있다.

고로 /etc/nginx/sites-enabled/ 경로의 default 설정파일 심볼릭 링크를 제거하면 default 설정 적용이 해제된다.

$ sudo unlink /etc/nginx/sites-enabled/default

 

4. 커스텀 conf 파일 작성

1

/etc/nginx/sites-available 에 footprint.conf 를 생성

server {
  listen 80;
  server_name footprint;

  location /api {
    proxy_pass http://localhost:8080;
  }

  location / {
    proxy_pass http://localhost:3000;
  } 
}

 

2

/etc/nginx/sites-enabled 경로에 심볼릭 링크 생성

$ sudo ln -s /etc/nginx/sites-available/footprint.conf /etc/nginx/sites-enabled/footprint.conf

3

nginx 재가동

$ sudo service nginx reload

 

설정파일에 문법적 에러가 존재할 경우 reload 에 실패한다.

이 때는 /var/log/nginx 에 있는 error.log 를 열어보자. 실패 이유가 친절하게 써있을 것이다.

 

 

 

테스트해보니 사이트가 잘 실행되었다.

기존에 공유기에서 열어두었던 8080, 3000 이런 포트들은 이제 모두 닫고

SSH, 80 포트만 열어둔 상태가 되었다!


참고 (출처)

1. nginx 기초 사용법 정리 1 (conf, log, directive, Serving Static Content 의 과정에 대해서)

https://aimaster.tistory.com/11?category=344242

 

2. nginx 기초 사용법 정리 2 (location, proxy)

https://aimaster.tistory.com/12?category=344242

 

3. nginx 재가동 방법 restart VS reload

https://medium.com/sjk5766/nginx-중단-없이-변경된-설정-반영하는-방법-b0cc36a5fe59

 

4. NGINX 에서 Reverse Proxy 셋팅하는 방법

https://phoenixnap.com/kb/nginx-reverse-proxy

(24년 추가)

해당 글은 첫 취직 전 21년도에

프로젝트를 하면서 고민했던 부분에 대한 해결방법을 적은 것입니다.

그런데 막상 현업에 들어가보니, 애초에 join 을 여러번 하는 것 자체가 DB에 부하를 줄 수 있기 때문에

활발히 운영되는 Production 에서 이런 식의 코드는 지양되어야 하겠습니다.

쿼리를 여러번 날리던지 하고 서비스 코드 상으로 해결하는게 낫겠습니다.

 


JPA 책을 쓰신 김영한님의 책과 강의영상을 보면 실무에서 @ManyToMany 를 쓰지 말라는 조언을 강조하신다.
연결 테이블의 컬럼을 커스터마이징하지 못한다는 점이 실무에서 굉장히 크리티컬한 문제인가보다.

 

그런데 막상 연결테이블을 엔티티로 승격시켰더니 조회기능을 구현하기가 까다로웠다.

연결테이블 엔티티로 연결된 다대다 엔티티의 join 은 어떻게 하는걸까?


이를 알아보기 위해, 먼저 SQL 수준에서 다대다 join 문법을 알아보고, 이를 JPQL로 작성하는 방법을 알아보았다.

 

1. SQL 수준에서 다대다 테이블의 JOIN

최적의 SQL 문을 알아보기 위해서, 나는 JPA의 @ManyTo@Many 를 사용해보았다.
JPA가 @ManyToMany 에서의 조회연산을 어떻게 수행하는지 관찰하기 위해서이다.

나의 한미한 두뇌와 인터넷 서핑 실력으로는 최적의 SQL 문을 찾기 어려웠기에 JPA를 만드신 분들의 두뇌를 빌려본 셈?!

 

예제 : 다대다 단방향

User와 Tag가 다대다 관계이며, 다음과 같이 User에 List 필드를 넣었다.

@Entity
public class User {

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

    @Column(nullable = false)
    private String name;

    ...

    @ManyToMany
    @JoinTable(name = "user_tag", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "tag_id"))
    private List<Tag> tags = new ArrayList<>();

    ..
}

 

그리고 User 를 List와 함께 조회하는 패치 조인 연산을 수행해보았다.

String query = "SELECT user FROM User user LEFT JOIN FETCH user.tags "
                + "WHERE user.id = " + userId;

User result = entityManager.createQuery(query, User.class)
  .getSingleResult();

 

그러자 다음과 같은 SQL이 실행되었다.

Hibernate: 
    select
            user.id as id1_1_0_,
            tag.id as id1_0_1_,
            user.age as age2_1_0_,
            user.name as name3_1_0_,
            tag.text as text2_0_1_,
            usertag.user_id as user_id1_1_0__,
            usertag.tag_id as tag_id2_2_0__ 
        from
            User user
        left outer join
            user_tag usertag
                on user.id=usertag.user_id 
        left outer join
            Tag tag
                on usertag.tag_id=tag.id 
        where
            user.id=1

결과를 보면 join 이 두번 나오는 것을 확인할 수 있는데, 나는 이런 문법이 있다는걸 처음 알았다.
그래서 JOIN 을 두번 사용하는 문법에 대해서 공부할 필요가 있었다.

 

영어이긴 하지만 아래 사이트에서 친절하게 설명해주고 있다.

 

How to Join 3 Tables or More in SQL
https://learnsql.com/blog/how-to-join-3-tables-or-more-in-sql/

 

How to Join 3 Tables (or More) in SQL

Have you ever wondered how to join three tables in SQL? It's easy when you know the basics. Joining three tables can be as easy as joining two tables.

learnsql.com

 

2. JPQL 작성하기

이제 SQL이 어떻게 돼야 하는지 알게되었다.
그렇다면 UserTag를 엔티티로 승격시켰을 때 이같은 SQL 문이 나가게 하려면 JPQL 을 어떻게 작성해야할까?

 

먼저 User 에서 List<UserTag> 를 갖도록 양방향 필드를 뚫어주어야 한다.
양방향은 주의해서 사용해야 한다고 하지만 그렇다고 이를 피할수는 없다. JPQL에서 조인을 하려면 연관 필드를 사용해야하기 때문이다.

@Entity
public class User {

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

    @Column(nullable = false)
    private String name;

    @Column(nullable = true)
    private Integer age;

    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<UserTag> userTags = new ArrayList<>();

    ...
}

 

그런 다음 JPQL 은 다음과같이 작성하면 된다.

String query = "SELECT new practice.dto.UserWithTeamDto(user.id, user.name, tag.text) FROM User user "
                + "LEFT JOIN user.userTags usertag "
                + "LEFT JOIN usertag.tag tag "
                + "WHERE user.id = " + userId;

List<UserWithTeamDto> resultList = entityManager.createQuery(query, UserWithTeamDto.class)
  .getResultList();

 

핵심은 FROM User user LEFT JOIN user.userTags usertag LEFT JOIN usertag.tag tag 이 부분이다.

 

이 JPQL 문을 알려준 스택오버플로우 링크를 첨부한다.

 

Join Multiple Tables with One JPQL Query
https://stackoverflow.com/questions/44144693/join-multiple-tables-with-one-jpql-query

 

Join multiple tables with one JPQL query

I got this sql-query I want to create as query in JPQL but I cannot get it right. I got a manytoone relationship between QuestionAnswers and QuizQuestions: SQL: SELECT quizName, question, answer ...

stackoverflow.com

 

 

- 끝 -

+ Recent posts