728x90

이번 프로젝트에서는 AWS의 ElasticBeanstalk 을 이용해 application을 배포하고 

github actions를 이용해 aws에 무중단 배포할 수 있는 환경을 구축하였다. 

 

AWS의 서비스에 관해서는 이미 다룬 적이 있으니 생략하고 

https://youarethebestcoding.tistory.com/119

 

프로덕션 배포 - 서비스 아키텍처

EC2 (Elastic Computer Cloud) : 애플리케이션을 실행할 수 있는 서버 컴퓨터라고 생각하면 된다. 우리는 EC2로 서버를 실행시키고 클라이언트가 애플리케이션을 실행하려면 EC2의 IP나 EC2에서 제공하는

youarethebestcoding.tistory.com

 

이번 포스팅에서는

aws cli로 환경을 생성하는 두가지 방법을 모두 설명하고 

github actions 까지 다뤄본다. 

 

 

#배포를 위한 애플리케이션 환경 설정

 

 

 

- properties(yaml) 파일 분리 

 

배포를 할 때의 환경과 개발의 환경이 다른 경우 

profile을 분리해주어야한다. 

 

스프링부트의 profile은 'application-' 접두어 뒤에 단어로 구분한다. 

 

보통 application- [local, dev, prod...] 와 같이 naming 한다. 

 

본인은 개발환경의 profile을 local로 해주었다. 

 

보통 profile을 분리하는 이유가 개발환경과 배포환경의 설정, 연동할 db 때문에 분리하게 되는데 

 

profile을 분리하고 바로 실행을 하려하면 스프링부트는 어떤 profile로 실행해야할지 알지 못하기 때문에 애플리케이션을 실행하지 못한다. 

 

profile을 설정하는 방법은 

application.properties 파일을 작성해서 

 

<application.yaml>
spring:
  profiles:
    active: local
    
<application.properties>
spring.profiles.active=local

active할 profile을 명시해주거나 

 

이클립스 기준

Run-Run configurations에서 

Profile을 지정해주면 된다. 

profile이 없다면 빈칸으로 되어있을것이고 

 

application.properties를 사용하고 싶다면 Profile에 default를 선택하면 된다. 

만약 application.properties와 -dev, -local이 있고 default에 active profile이 명시되어있다면 

default를 사용하거나 사용할 profile을 직접 선택해주면 된다. 

 

보통 application.properties에 

모든 환경에서 공통으로 설정할 부분을 적고 profile에 각 프로필마다 사용할 설정을 하기 때문에 

 

application.properties에 active할 profile을 지정해주고 default를 run하는게 좋을것 같다. 

 

 

server.port=5000
spring.jpa.database=MYSQL
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=update

spring.datasource.url=jdbc:mysql://${rds.hostname}:${rds.port}/${rds.db.name}
spring.datasource.username=${rds.username}
spring.datasource.password=${rds.password}

[ application-prod.properties ]

 

일단 aws의 nginx는 5000포트를 default로 사용하기 때문에 

포트를 5000번으로 설정하고 

 

dev 환경의 데이터베이스는 학원의 oracle db를 사용했지만 

prod 환경에서는 aws rds의 mysql을 사용할 것이기 때문에 

database를 MYSQL로 변경하고 

url, username, password는 rds 콘솔에서 엔드포인트, 포트를 확인하여 하드코딩할 수 있지만

rds로부터 변수를 받아 입력할 수 있게 

${rds.variable}를 이용한다. 

 

env.properties는 민감한 properties들을 입력해놓은 파일이다. 

 

@Configuration
@PropertySources({@PropertySource("classpath:envs.properties")})
public class PropertyConfig {

}

 

env.properties는 gitignore에 등록하고 jar 파일을 배포하고

이후 github actions을 통해 무중단 배포를 할 때는 git에 올라오지 않기 때문에 

git의 repository variable을 이용해 동적으로 파일을 생성해서 배포할 것이다. 

 

- build.gradle에 mysql을 사용하기 위해 mysql connector를 import 해준다. 

implementation 'com.mysql:mysql-connector-j:8.1.0'

 

 

#cors 을 위한 설정 

// 스프링 서버 전역적으로 CORS 설정
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
        	.allowedOrigins("http://localhost:5000") // 허용할 출처, 이후 배포된 사이트의 도메인을 추가
            .allowedMethods("GET", "POST", "PUT", "DELETE") // 허용할 HTTP method
            .allowCredentials(true) // 쿠키 인증 요청 허용
            .maxAge(3000); // 원하는 시간만큼 pre-flight 리퀘스트를 캐싱
    }

 

 

#AWS CLI 다운로드 및 배포

 

AWS 계정 생성 후 AWS console에 IAM 으로 1로그인 

++IAM ( Identity and Access Management ) 

기업에서 많은 개발자가 접근할 수 있는 계정이 있어야 하는데 만약 기업 계정에 모든 개발자가 아이디와 비밀번호를 알고 접근할 수 있게 하는것은 위험하므로 

사람마다 역할마다 다른 접근 권한을 부여할 수 있게 한다. 

사람에게는 아이디와 비밀번호, 프로그램에게는 액세스 키와 비밀 액세스 키를 제공 

 

 

 

AWS CLI, EB CLI 설치를 위해 python 설치 

AWS CLI, EB CLI는 파이썬 기반으로 작동 

 

파이썬 설치 

https://www.python.org/downloads/

 

Download Python

The official home of the Python Programming Language

www.python.org

 

파이썬 설치 버전 확인 

python --version 커맨드 입력 

 

AWS CLI 설치 

https://aws.amazon.com/ko/cli/

 

Command Line Interface - AWS CLI - AWS

aws-shell은 명령줄 셸 프로그램으로서, AWS 명령줄 인터페이스를 사용하는 새로운 사용자와 고급 사용자 모두에게 도움이 되는 편의 기능 및 생산성 기능을 제공합니다. 주요 기능은 다음과 같습

aws.amazon.com

 

설치 후 터미널 재실행 후 aws --version 확인 

 

콘솔에 로그인한 후 서비스 검색창에 IAM 검색하여 IAM 대시보드로 들어간다. 

 

우리가 사용할 CLI 는 AWS에 접근해 우리의 커맨드를 대신 실행해줄 프로그램이므로 IAM으로부터 액세스 키와 비밀 액세스 키를 발급 받아야 한다. 

 

액세스 관리 - 사용자 - 사용자 생성 

 

 

사용자 이름을 입력하고 IAM 사용자를 생성해 AWS Management Console에 접근 권한을 제공한다.

++ AWS Management Console 접근 권한 제공 체크할 필요 없다. 

aws가 바뀌면서 프로그래밍 방식 액세스 키를 발급 받는 작업은 사용자를 생성한 후 한다. 

AWS Management Console 접근 권한은 접근 비밀번호를 지정하는 사용자가 되는 것이다. 

CLI는 프로그램이라 필요가 없다.

 

직접 정책에 연결을 선택하고 

AdministratorAccess  정책을 선택하고 다음으로 넘어간다. 

태그를 생성하고 싶다면 생성하고 다음으로 넘어간다. 

태그는 사용자에 추가될 수 있는 키-값 페어로 이메일이나 직급 등의 내용을 포함하고 싶을 때 사용할 수 있다. 

 

++ 정책 Policy 

권한을 나열한 문서 

AdministratorAccess는 모든 AWS rss에 접근할 수 있는 모든 권한을 준다는 뜻이다. 

AdministratorAccess-Amplify는 Amplify에 접근할 수 있는 모든 권한을 준다는 뜻이다. 

생성된 사용자를 클릭해 세부 페이지로 들어오면 액세스키가 나오고 액세스 키가 없다면 생성하는 버튼이 나온다. 

CLI를 선택하고 설명 태그 값에는 CLIaccessKey라고 입력했다. (선택사항) 

 

발급받은 액세스키와 비밀 액세스 키를 메모해둔다. 

비밀 액세스 키는 다시 확인할 수 없다. 

 

터미널에서 aws configure 명령어로 aws cli를 설정해준다. 

 

C:\Users\USER>aws configure
AWS Access Key ID [None]:
AWS Secret Access Key [None]: 
Default region name [None]: ap-northeast-1
Default output format [None]: json

 

Default region : AWS 데이터센터가 있는 장소로 서비스를 실제로 사용할 사용자와 서비스가 호스팅되고 있는 데이터 센터가 가까울수록 네트워크 대기 시간이 짧아진다. 사용자가 한국에 거주할 확률이 높다면 ap-northeast-2를 선택하면 된다. 

 

AWS CLI는 AWS의 모든 서비스를 위한 CLI 이고 

EB CLI는 일래스틱 빈스톡만을 위한 CLI이다. 

EB CLI로 일래스틱 빈스톡 환경을 구축하고 설정할 수 있다. 

 

pip 를 이용해 awsebcli 설치 

 

pip install awsebcli --upgrade --user 

 

설치한 후 Warning에 환경변수를 추가하라고 나온다. 

 

환경변수의 하단에 위치한 시스템 변수 Path에서 

%USERPROFILE%\AppData\Roaming\Python\Python11\Scripts를 추가한다.

C:\Users\USER\AppData\Roaming\Python\Python311\Scripts도 추가해준다. 

 

 

파이썬 12는 지금 awsebcli 설치하면 오류가 생긴다. 

버전 차이 때문에 그런거 같은데 진짜 너무 고생했다... 

파이썬 11로 다운그레이드하고 다시 시도해서 겨우 설치했다. 

 

프로젝트의 위치에서 eb init 명령어로 일래스틱 빈스톡 환경 초기화 

 

select default region 

 일래스틱 빈스톡이 어느 리전에 이 애플리케이션의 환경을 생성해야 하는지 물어보는 것 

이후 생성되는 모든 리소스는 지금 선택하는 리전에 생성된다. 

 

do you want to set up SSH for your instance? 

 일래스틱 빈스톡을 이용해 생성된 ec2에 접근하기 위해 ssh를 설정할 것인지 물어보는 것 

일단 이 프로젝트에선 설정하지 않는다. 

 

init을 완료하면 project directory에 .elasticbeanstalk 디렉터리가 생성되고

그 안에 init에서 설정한 내용이 들어있는 config.yml 파일이 생성된다. 

 

 

이제 배포할 애플리케이션을 jar 파일로 build해주어야 한다. 

 

1. cmd cli 

프로젝트의 위치에서 

gradlew build 

 

2. IDE tool 이용하기 

eclipse 기준 

gradle tasks - build - build (또는 bootJar)

 

build 이후에 

디렉토리의 build/llibs에 jar 파일이 생성된다. 

 

다시 .elasticbeanstalk 의 config.yml 파일에 

branch-defaults:
  master:
    environment: Danaga-env
    group_suffix: null
deploy:
  artifact: build/libs/danaga-0.0.1-SNAPSHOT.jar
global:
  application_name: danaga-app
  branch: null
  default_ec2_keyname: null
  default_platform: Corretto 17 running on 64bit Amazon Linux 2023
  default_region: ap-northeast-1
  include_git_submodules: true
  instance_profile: null
  platform_name: null
  platform_version: null
  profile: eb-cli
  repository: null
  sc: git
  workspace_type: Application
deploy:
  artifact: build/libs/danaga-0.0.1-SNAPSHOT.jar

deploy:artifact: jar파일 을 추가한다. 

 

 

eb create --database --elb-type application --instance-type t2.micro 명령어로 환경 생성 

 

--database : 생성하는 환경에 RDS 데이터베이스를 만들기 위한 매개변수, 자동으로 데이터베이스가 생성된다. 

--elb-type : 일래스틱 로드 밸런서 타입 매개변수, application,, classic, network가 있다. 

https://aws.amazon.com/ko/elasticloadbalancing/features/?nc=sn&loc=2 

 

네트워크 트래픽 분산 - Elastic Load Balancing - Amazon Web Services

 

aws.amazon.com

--instance-type : 애플리케이션이 동작할 인스턴스 타입, 프리티어에서 제공하는 t2.micro 사용 

 

eb create --database --elb-type application --instance-type t2.micro

 

DNS CNAME prefix는 url이므로 이미 사용중인 url과 중복될 수 없어 본인이 고유값을 입력해준다. 

 

would you like to enable spot fleet requests for this environment : N

 

 

정상적인 경우라면 성공적으로 배포했을텐데 

 

나는 배포에 실패하고 severe 상태가 되었다. 

 

사이트에 접속하면 502 bad gateway가 뜬다. 

 

이럴때는

로그를 확인하면 된다. 

 

로그 요청을 눌러서 발행된 로그에 다운로드를 클릭해서 로그를 확인할 수 있다. 

 

나같은 경우는 application profile을 설정하지 않아서 배포에 실패했다. 

프로젝트 당시 처음 배포했을 때는 thymeleaf 템플릿에 문제가 있어 로그를 계속 확인하면서 몇번을 고쳤다. 

 

개발환경에서는 타임리프에 아무 문제 없고 사이트의 서비스에 아무 문제가 없었는데 

배포할때는 개발환경에서 문제가 되지 않는 사소한 표현식에서 문제가 되었다. 

 

#Github actions 

 

Github action은 repository에서 workflow를 자동으로 할 수 있게 지원하는 깃헙의 툴이다.

 

workflow는 event, runner, jobs와 steps으로 이루어져있다. 

 

event가 발생하면 runner가 job을 실행하고 job은 step들로 이루어져있다. 

 

하나의 runner는 하나의 job을 실행하고 step은 순차적으로 진행된다. 

그렇기 때문에 step끼리는 데이터가 전달될 수도 있다. 

 

job내의 step들은 순차적으로 진행되지만 job끼리는 기본적으로 독립적으로 진행된다. 

물론 어떤 job이 실행된 이후에 실행되어야할 다른 job이 있다면 packaging 하여 종속시킬수 있다. 

 

 

 

 

https://docs.github.com/ko/actions/learn-github-actions/understanding-github-actions#the-components-of-github-actions

 

GitHub Actions 이해 - GitHub Docs

GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform that allows you to automate your build, test, and deployment pipeline. You can create workflows that build and test every pull request to your repository, or deploy merged

docs.github.com

 

#secrets and variables

 

github엔 민감한 정보를 secret으로 설정하고 변수처럼 사용할 수 있는 기능이 있다.

 

repository의 setting - security - secrets and variables - actions - new repository secret

 

위에 배포환경 profile에 대해 설명할 때 나온 env.properties 와 application.properties를 애플리케이션이 아닌 

secret에 설정하고 workflow에서 배포할 때 파일을 만든 후 애플리케이션을 build 한다. 

 

배포 과정에서 aws access key id와 aws secret access key 가 필요하므로 설정해준다. 

 

이렇게 한번 만들어주면 다시 들어가도 이전에 초기화해둔 값을 확인하지 못하기 때문에 

repository를 공유하는 팀원간에도 민감한 정보의 유출을 막으면서 변수를 사용해 값을 사용할 수 있다. 

 

 

 

#workflow 작성

 

레포지토리의 actions에 들어가면 new workflow를 작성할 수도 있고

깃헙에서 제공하는 템플릿을 이용해 작성할 수도 있다.

 

 

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow will build a package using Gradle and then publish it to GitHub packages when a release is created
# For more information see: https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#Publishing-using-gradle

name: Java CI with Gradle

on:
  push:
    branches:
      - master
      
permissions:
  contents: read

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
      uses: actions/setup-java@v3
      with:
        java-version: '17'
        distribution: 'temurin'

    - name: Run chmod to make gradlew executable
      run: chmod +x ./gradlew
      
    - name: make application.properties
      run: |
          cd ./src/main/resources
          touch ./application.properties
          echo "${{ secrets.APP_PROPERTIES }}" > ./application.properties
     
    - name: make env.properties
      run: |
          cd ./src/main/resources
          touch ./env.properties
          echo "${{ secrets.ENV_PROPERTIES }}" > ./env.properties

    - name: Build with Gradle
      uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0
      with:
        arguments: clean build bootJar

    - name: Get current time
      uses: 1466587594/get-current-time@v2
      id: current-time
      with:
        format: YYYYMMDDTHHmm
        utcOffset: "+09:00"

    - name: Generate deployment package
      run: |
        mkdir -p deployment
        cp build/libs/danaga-0.0.1-SNAPSHOT.jar deployment/danaga-0.0.1-SNAPSHOT.jar
        # cp deployment/Procfile deployment/Procfile
        cd deployment && zip -r danaga-${{steps.current-time.outputs.formattedTime}} .
        ls
        
    - name: Deploy Danaga to EB
      uses: einaregilsson/beanstalk-deploy@v14
      with:
        aws_access_key: ${{secrets.AWS_ACCESS_KEY_ID}}
        aws_secret_key: ${{secrets.AWS_SECRET_ACCESS_KEY}}
        application_name: danaga-app
        environment_name: Danaga-env
        version_label: danaga-${{steps.current-time.outputs.formattedTime}}
        region: ap-northeast-1
        deployment_package: deployment/danaga-${{steps.current-time.outputs.formattedTime}}.zip

 

on:
  push:
    branches:
      - master
      

master 브랜치에 push될 때 workflow가 trigger된다.

 

스프링부트3는 자바17이상을 지원하기 때문에 자바 17로 세팅한다.

 

- name: Run chmod to make gradlew executable
      run: chmod +x ./gradlew
      

그냥 빌드를 하려 했더니 gradle 실행 권한이 없어서 권한을 주는 step을 추가한다.

 

 - name: make application.properties
      run: |
          cd ./src/main/resources
          touch ./application.properties
          echo "${{ secrets.APP_PROPERTIES }}" > ./application.properties
     
    - name: make env.properties
      run: |
          cd ./src/main/resources
          touch ./env.properties
          echo "${{ secrets.ENV_PROPERTIES }}" > ./env.properties

 

빌드를 하기 전에 applciation.properties와 env.properties 파일을 생성하고 빌드한다. 

 

 - name: Generate deployment package
      run: |
        mkdir -p deployment
        cp build/libs/danaga-0.0.1-SNAPSHOT.jar deployment/danaga-0.0.1-SNAPSHOT.jar
        # cp deployment/Procfile deployment/Procfile
        cd deployment && zip -r danaga-${{steps.current-time.outputs.formattedTime}} .
        ls
        
    - name: Deploy Danaga to EB
      uses: einaregilsson/beanstalk-deploy@v14
      with:
        aws_access_key: ${{secrets.AWS_ACCESS_KEY_ID}}
        aws_secret_key: ${{secrets.AWS_SECRET_ACCESS_KEY}}
        application_name: danaga-app
        environment_name: Danaga-env
        version_label: danaga-${{steps.current-time.outputs.formattedTime}}
        region: ap-northeast-1
        deployment_package: deployment/danaga-${{steps.current-time.outputs.formattedTime}}.zip
        

 

jar파일을 zip으로 압축하여 ebs에 배포한다. 

 

master에 workflow를 작성하면 트리거가 작동하여 workflow가 실행된다. 

 

배포 step이 실패가 떠서 확인해본다.....

 

 

배포는 성공적으로 했는데 healthcheck가 잘못되어 실패로 처리되었다...

 

로그를 확인하러 aws 콘솔에 갔는데 

상태 ok  

헬스체크의 일시적인 문제로 보고 

 

 

도메인 접속 결과 이상 없이 실행된다. 

 

이렇게 master branch에 push 될 때마다 자동으로 배포가 되는 환경이 구축되었다. 

728x90

#타임리프 레이아웃 

코드의 재사용이 가능한 부분을 템플릿화할 수 있게 도와주는 타임리프 라이브러리 

 

#레이아웃을 사용하는 이유 

타임리프의 insert나 replace 기능은 많이 사용하지만 이 기능에는 한가지 문제점이 있다. 

 

대부분의 사이트는 탑, 바텀, 네이게이션바 등을 고정적으로 사용한다. 

그래서 매 페이지마다 top(header), bottom, navigationBar를 insert 해주게된다. 

insert를 사용하면 탑에 내용이 바뀐다면 탑만 수정해주면 모든 페이지의 탑이 바뀌게 된다. 

 

그런데 만약 insert된 코드의 내용이 아니라 insert된 코드, 다른 코드들 간의 구조에 변화가 생긴다면 

모든 페이지의 fragment들의 구조를 바꿔줘야 한다. 

이런 한계를 커버해 줄 수 있는 기능이 layout이다. 

기본적인 레이아웃의 사용법을 이미지화한 모습이다.

 

공통요소는 레이아웃에 담아두고 바뀌는 부분만 각각의 페이지에 작성하면 된다. 

 

product.html 페이지로 forwarding, redirect되면 product.html 페이지의 코드들이 레이아웃에 merge되어 렌더링된다.

 

#Thymeleaf layout을 사용하기 위한 setting

 

gradle 추가

implementation group: 'nz.net.ultraq.thymeleaf', name: 'thymeleaf-layout-dialect', version: '3.1.0'

 

 

layout.html

<!DOCTYPE html>

<html xmlns:th="http://www.thymeleaf.org"

xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">

 

layout을 사용할 파일

<!DOCTYPE html>

<html xmlns:th="http://www.thymeleaf.org"

xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"

layout:decorate="layout/layout(title='Recently Viewed')">

<head>

여기서 layout/layout은 사용할 layout의 root이다. 

즉, resources패키지의 layout 패키지 안의 layout.html 파일이라는 뜻이다. 

(title='Recently Viewed')는 부가적인 기능으로 필요하지 않으면 작성하지 않아도 되고 layout의 root만 작성해줘도 된다.

 

 

#layout의 기본적인 사용

 

layout.html

<th:block layout:fragment="content" />

 

layout을 사용할 파일

 

<th:block layout:fragment="content">

<!-- Page Content-->

<div class="container padding-bottom-3x mb-2">

<div class="row">

<!--profile component-->

<div class="col-lg-4" th:insert="~{mypage_component :: mypage(accountPage='recentview')}"></div>

<!--profile component 끝-->

<div class="col-lg-8">

<div class="padding-top-2x mt-2 hidden-lg-up"></div>

<div class="table-responsive wishlist-table margin-bottom-none">

<!-- recentView Table-->

<!-- 하위 태그들은 size==1이면 빈상자 이미지로 대체-->

<table class="table"

th:insert="~{myProduct-component :: product-table(from='recentview')}"></table>

<!-- recentView Table-->

 

</div>

<hr class="mb-4">

</div>

</div>

</div>

 

</th:block>

layout.html에 원하는 위치에 layout:fragment="이름" 속성을 지정해주고

그 위치에 들어갈 코드를 다른 파일에서 

layout:fragment="이름" 속성을 가진 태그의 안에 작성해주면 된다. 

 

*layout은 head body 모두 작성할것이기 때문에 당연히 html 파일의 구조를 제대로 작성할텐데

layout을 사용할 파일은 head가 필요하지 않을 수도 있다. 

하지만 모든 구조를 갖춰서 작성해주는 것이  좋고, 사용하지 않는 fragment들이라도 작성해주는 것이 좋다. 

 

 

예를들어 layout에 fragment로 navigation, contents, recommendation이 있다면 

layout을 사용하는 product.html에 recommendation fragment를 사용하지 않아도 빈 fragment를 작성해준다면 fragment가 아닌 부분의 코드 위치를 잡는데도 도움이 될 것이다. 

 

#layout head merge

레이아웃의 <head> 는 모든 페이지에서 공통으로 사용할 코드를 작성하면 된다. 

다른 페이지에서 그 페이지에서만 사용할 css나 js가 있다면 그 페이지의 head에 작성하면 

그 페이지에 작성된 head만 적용되는 것이 아니라 layout의 head와 merge되어 같이 적용된다. 

 

#layout 변수 초기화 및 사용 

 

<!DOCTYPE html>

<html lang="ko" xmlns:th="http://www.thymeleaf.org">

<head></head>

<body>

<!-- content start -->

<div class="page-title" th:fragment="page-title(page_title)">

<div class="container">

<div class="column">

<h1 th:text="${page_title}">title</h1>

</div>

<div class="column">

<ul class="breadcrumbs">

<li><a href="index">Home</a></li>

<li class="separator">&nbsp;</li>

<li th:text="${page_title}">title</li>

</ul>

</div>

</div>

</div>

<!-- content end -->

</body>

</html>

레이아웃에 변수를 선언하고 레이아웃을 사용하는 각 페이지마다 그 변수를 초기화하여 사용할 수 있다.

 

기존 타임리프에는 fragment명에 (변수 선언)하면 이후에 ${변수명}으로 사용할 수 있다. 

 

레이아웃에서는 

<th:block th:if="${title!=''}">

<div class="page-title"

th:insert="~{fragment/page-title::page-title(${title})}"

th:remove="tag"></div>

</th:block>

layout.html

 

<html xmlns:th="http://www.thymeleaf.org"

xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"

layout:decorate="layout/layout(title='Recently Viewed')">

recentview.html

레이아웃을 사용하는 파일에서 (변수명='value')라고 하면

layout.html의 ${title}에 value값이 대입된다. 

'Java > Spring Boot' 카테고리의 다른 글

JPA Object Relation Mapping  (0) 2023.10.11
JPA 계층형 게시판  (0) 2023.10.10
Spring Data JPA begins  (0) 2023.10.06
Spring CRUD with RestAPI  (0) 2023.09.20
Spring addViewControllers  (0) 2023.09.14
728x90

이번 프로젝트를 하면서 예외 처리에 대해서 고민 많았고 

실제로 작업하면서 몇번씩 수정을 거치게 되었다. 

 

일단 예외가 발생했을 때 발생원인이 명확한 경우 구체적인 예외 사유를 알 수 있게 customException을 만들어서 던지기로 했다. 

 

public class ProductCustomException extends Exception{

private String data;

private ProductExceptionMsg msg;

 

public ProductCustomException(ProductExceptionMsg msg) {

this.msg = msg;

}

 

public ProductCustomException(String data, ProductExceptionMsg msg) {

this.data = data;

this.msg = msg;

}

}

 

ProductCustomException은 

ProductExceptionMsg를 가지고 있다. 

public enum ProductExceptionMsg implements ProductMsgInterface{

FOUND_NO_MEMBER, FOUND_NO_OPTIONSET,FOUND_NO_OPTIONS, FOUND_NO_PRODUCT,

FOUND_NO_CATEGORY, FOUND_NO_RECENTVIEW,FOUND_NO_INTEREST,

ALREADY_EXISTS_RECENTVIEW,ALREADY_EXISTS_INTEREST, IS_NOT_INTERESTEDD, NEED_LOGIN, WRONG_PARAMETER

}

 

ProductMsgInterface를 상속한 ExceptionMsg는 예외명들을 가지고 있다. 

 

이렇게 하면 예외가 발생했을 때 ResponseDto에 ExceptionMsg를 보낼 수도 있고 

예외를 던지거나 잡아야할 때 Msg를 확인하고 Msg에 맞게 처리할 수 있다. 

 

그리고 ProductCustomException을 상속한 커스텀 익셉션을 만들고 

내부 클래스로 그 커스텀을 구체적으로 나눴다. 

FoundNoObjectException만 만들고 ExceptionMsg로 발생한 부분을 구분할 수도 있지만 클래스명으로 

직관적으로 알 수 있게 클래스단위로 분리하였다. 

public class FoundNoObjectException extends ProductCustomException {

 

public FoundNoObjectException(ProductExceptionMsg msg) {

super(msg);

}

public FoundNoObjectException(String data, ProductExceptionMsg msg) {

super(data,msg);

}

public static class FoundNoMemberException extends FoundNoObjectException{

public FoundNoMemberException(String data) {

super(data,ProductExceptionMsg.FOUND_NO_MEMBER);

}

public FoundNoMemberException() {

super(ProductExceptionMsg.FOUND_NO_MEMBER);

}

}

public static class FoundNoOptionSetException extends FoundNoObjectException{

public FoundNoOptionSetException(String data, ProductExceptionMsg msg) {

super(data, ProductExceptionMsg.FOUND_NO_OPTIONSET);

}

public FoundNoOptionSetException() {

super(ProductExceptionMsg.FOUND_NO_OPTIONSET);

}

}

...

}

 

그리고 dao와 service ,controller 단에서 들어오는 데이터를 검증하고 객체를 찾지 못하거나 로그인이 필요하거나 이미 존재하는 등 체크해서 알맞은 예외를 던져주었다. 

 

@Override

public void delete(Long id) throws FoundNoCategoryException {

Category find= repository.findById(id).orElseThrow(() -> new FoundNoCategoryException());

repository.deleteById(find.getId());

}

예를들어

카테고리를 삭제할 때 삭제할 카테고리를 찾지 못하면 FoundNoCategoryExcetpion을 던져주었다. 

 

public ResponseDto<?> deleteHeart(@Valid InterestDto dto) throws FoundNoMemberException, FoundNoOptionSetException {

try {

if(interestDao.isInterested(dto)) {

interestDao.delete(dto);

}else {

return ResponseDto.builder().msg(ProductExceptionMsg.IS_NOT_INTERESTEDD).build();

}

} catch (FoundNoInterestException e) {

e.printStackTrace();

return ResponseDto.builder().msg(e.getMsg()).build();

}

return ResponseDto.builder().msg(ProductSuccessMsg.UNTAP_HEART).build();

}

파라메터로 들어오는 InterestDto에 @Valid 어노테이션으로 검증을 하고 

 

**

implementation 'org.springframework.boot:spring-boot-starter-validation'

검증을 위한 validation gradle 추가 

 

deleteHeart 서비스는 제품의 하트를 눌렀을 때 관심상품에 추가하고 삭제할 때 삭제하는 서비스인데 

일단 관심상품으로 등록되어있는지 확인해야한다.

비동기 방식으로 요청을 보내기 때문에 응답이 가기전에 여러번 중복하여 요청이 오는 경우 

관심상품이 이미 삭제되었는데 삭제 요청이 올 수도 있기 때문에 

if문으로 확인하는 작업을 거치고 

그렇지 않으면 500번 대신 명확한 메시지를 담은 ResponseDto를 보내준다. 

 

public ResponseDto<?> showOptionNameValues(Long categoryId) {

List<OptionNamesValues> optionNameValue = optionDao.findOptionNameValueMapByCategoryId(categoryId);

if(optionNameValue==null||optionNameValue.isEmpty()) {

log.warn("no children categories found");

return ResponseDto.builder().msg(ProductExceptionMsg.FOUND_NO_OPTIONS).build();

}

Map<String, Set<String>> dto = optionNameValue.stream().collect(Collectors.groupingBy(

OptionNamesValues::getName, Collectors.mapping(OptionNamesValues::getValue, Collectors.toSet())));

List<Map<String, Set<String>>> data = new ArrayList<>();

data.add(dto);

return ResponseDto.<Map<String, Set<String>>>builder().data(data).msg(ProductSuccessMsg.FIND_OPTION_NAME_VALUES).build();

}

 

showOptionNameValues 서비스는 카테고리를 선택했을 때 비동기방식으로 요청을 보내

카테고리의 제품들이 보유한 모든 옵션을 가져오는 서비스인데

 

optionNameValue가 없다는건 카테고리에 해당하는 제품이 없거나 옵션이 없다는 뜻이다. 

만약 이런 상황이 발생할 때 예외 페이지로 보내거나 클라이언트가 다른 요청을 보내는데 지장이 생기면 안된다. 

 

카테고리에 해당하는 제품이 없다거나 옵션이 없다면 그 카테고리나 제품에 문제가 있는 것일 수도 있고 

원래 옵셥이 없는 제품들만 있는 카테고리일 수도 있는 것이다.

 

여기서 예외를 처리할 수 있는 최선의 방법은 log로 남기고 나중에 log를 확인하고 제품이나 카테고리를 확인하는 작업을 하는 것이고

만약 진짜 옵션이 없는 상황이라면 그 카테고리를 선택했을 때 선택할 옵션이 없다는 표시를 해주는 것이 최선이라고 생각한다. 

 

 

컨트롤러까지와서 예외를 잡지 못하거나 발생한 예외에 대해 일관된 처리가 필요한 경우는 

ExceptionHandler로 처리했다. 

 

https://youarethebestcoding.tistory.com/24

 

Spring Boot Exception Handler

Local Exception Controller Controller 내에서 발생하는 Exception만 처리한다. @Controller public class LocalExceptionController { @GetMapping("/business1") public String business_method() throws BusinessException1 { boolean b = true; if(b) { thro

youarethebestcoding.tistory.com

 

restController에서 발생한 예외는 클라이언트 쪽에서 처리하기 위해 상태코드와 메세지를 담아 응답을 보내줬다.

@RestControllerAdvice

public class ExceptionRestController {

@ResponseBody

@ExceptionHandler(value = {NeedLoginException.class,FoundNoOptionSetException.class,FoundNoMemberException.class,MethodArgumentNotValidException.class})

protected ResponseEntity<?> defaultRestException(Exception e) {

ProductExceptionMsg errorMsg=null;

if (e instanceof NeedLoginException) {

errorMsg = ((NeedLoginException) e).getMsg();

return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ResponseDto.builder().msg(errorMsg).build());

} else if (e instanceof FoundNoObjectException.FoundNoMemberException) {

errorMsg = ((FoundNoObjectException.FoundNoMemberException) e).getMsg();

return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ResponseDto.builder().msg(errorMsg).build());

} else if (e instanceof FoundNoOptionSetException) {

errorMsg = ((FoundNoOptionSetException) e).getMsg();

return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ResponseDto.builder().msg(errorMsg).build());

} else if (e instanceof MethodArgumentNotValidException) {

errorMsg = ProductExceptionMsg.WRONG_PARAMETER;

}

return ResponseEntity.badRequest().body(ResponseDto.builder().msg(errorMsg).build());

}

 

}

 

예외가 발생하면 

ResponseEntity로 다시 클라이언트에 보내면 

fetch(options.url, options).then((response) => {

if (response.status === 200 || response.status === 201) {

return response.json();

} else if (response.status === 401) {

alert('로그인이 필요한 서비스입니다.');

window.location.href = "/member_login_form";// redirect

} else if (response.stataus === 404) {

window.location.href = "/404.html";

} else if (response.msg == 'WRONG_PARAMETER') {

alert('잘못된 요청입니다. 입력값을 확인해주세요.');

} else {

Promise.reject(response);

throw Error(response);

}

})

fetch 또는 ajax 등 비동기 방식으로 요청하고 받은

response의 status나 msg로 예외를 구분하고

그 예외에 대한 처리를 정의할 수 있다.

 

그리고 컨트롤러에서 발생한 예외는 404 페이지로 보내거나 로그인 폼으로 보냈다. 

@ControllerAdvice

public class ExceptionController {

@ExceptionHandler(value = {FoundNoObjectException.FoundNoOptionSetException.class,NeedLoginException.class

,FoundNoMemberException.class,NoSuchElementException.class})

protected String defaultException(Exception e, HttpSession session) {

if (e instanceof FoundNoOptionSetException) {

return "redirect:404"; //없는 상품 조회

} else if (e instanceof NoSuchElementException) {

return "redirect:404";

} else if (e instanceof NeedLoginException) {

return "redirect:member_login_form";

} else if (e instanceof FoundNoMemberException) {

return "redirect:member_login_form";

}

return "redirect:404"; //없는 상품 조회

}

 

@ExceptionHandler(Exception.class)

@ResponseStatus

protected String exception (Exception e) {

return "redirect:404";

}

 

}

 

 

 

728x90

JPA에 대해서 다룬 포스트가 있다. 

https://youarethebestcoding.tistory.com/115

 

Spring Data JPA begins

http://projects.spring.io/spring-data/ Spring Data Spring Data’s mission is to provide a familiar and consistent, Spring-based programming model for data access while still retaining the special traits of the underlying data store. It makes it easy to us

youarethebestcoding.tistory.com

 

이번 포스팅에서는 프로젝트에서 실제로 활용한 사례와 JPQL까지 다룬다. 

 

 

# 메소드명을 작성할 때 객체를 참조하는 법

ublic interface CategoryRepository extends JpaRepository<Category, Long>{

List<Category> findByParentNull();

List<Category> findByCategorySets_Product_OptionSets_Id(Long optionSetId);

}

 

JpaRepository 인터페이스를 상속받아 목적에 맞게 메소드명을 작성한다. 

 

여기서 주목할 점은 두번째 메소드의 _언더바이다.

 

학원에서 배울때도 그렇고 인터넷에 올라온 대부분의 포스팅은 

객체를 해당 객체의 pk로 찾거나 객체의 다른 프로퍼티로 찾는 방법만 소개하고 있다. 

Category findByName(String name);

 

심지어 공식 api문서에서도 찾기 어려웠다.

https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html

 

JPA Query Methods :: Spring Data JPA

As of Spring Data JPA release 1.4, we support the usage of restricted SpEL template expressions in manually defined queries that are defined with @Query. Upon the query being run, these expressions are evaluated against a predefined set of variables. Sprin

docs.spring.io

 

그래서 실제 프로젝트에서는 다른 객체로 찾게 되는 경우가 많은데 시작부터 난관에 부딪혀 멘붕이었다.

 

그런데 Jpa는 ORM 즉 객체 관계 매핑이다. 

객체를 참조하는 방법이 없다면 ORM이라고 부를 수 없고 메소드명을 정의할때 객체를 참조하는 방법이 존재한다. 

 

List<Category> findByCategorySets_Product_OptionSets_Id(Long optionSetId);

여기서 _언더바는 객체의 참조를 의미한다. 

JpaRepository<Category, Long>

Category 타입을 가지는 JpaRepository를 상속했으므로 

 

Category를 찾을텐데 optionset의 id로 카테고리를 찾고 싶은 경우이다. 

 

public class Category {

 

private List<CategorySet> categorySets = new ArrayList<>();

 

}

category는 categorysets을 프로퍼티로 가지고 

categoryset은 product를 가지고 product는 다시 optionsets을 가진다. 

이렇게 참조에 참조를 더해서 메소드명을 작성할 수도 있다. 

 

이때 category가 가지는 List<CategorySet>의 변수명이 categorySets이므로 

메소드명에서도 CategorySet이 아니라 CategorySets (변수명 그대로) 작성해주어야 한다. 

물론 첫글자는 대분자로 작성한다. 

 

**한가지 주의할 점이 있다. 

메소드명으로 작성할때는 대소문자에 매우 유의해야한다. 

카멜케이스를 사용한다고 mId와 같이 소문자 한글자에 Id를 붙이면 

메소드명에 MId라고 적어줘야할것 같지만 실제로 사용해본 결과 그렇지 않았다. 

어차피 객체에 member라는 의미가 있으므로 불필요하게 m을 붙이는 일을 하지 않는게 좋을것 같다. 

 

#다중조건

public interface InterestRepository extends JpaRepository<Interest, Long> {

 

Boolean existsByMemberIdAndOptionSetId(Long memberId,Long optionSetId);

 

void deleteByOptionSetIdAndMemberId(Long optionSetId, Long memberId);

 

void deleteByMemberId(Long memberId);

}

 

멤버의 pk와 Optionset pk로 해당상품이 멤버의 관심상품인지 체크하기 위한 메서드로 boolean 타입을 반환받는다. 

 

여기서 조건은 memberId와 OptionSetId 두가지를 And로 받고 있다. 

아래는 실제 날아가는 쿼리문이다. 

select

*

from

(select

i1_0.id c0,

rownum rn

from

interest i1_0

where

i1_0.member_id=?

and i1_0.option_set_id=?) r_0_

where

r_0_.rn<=?

order by

r_0_.rn

member_id=? and option_set_id=? 으로 두가지 조건을 모두 적용하고 있다. 

 

delete도 마찬가지로 일단 두가지 조건에 해당하는 interest를 먼저 찾고 삭제하는 모습을 볼 수 있다. 

select

i1_0.id,

i1_0.create_time,

i1_0.member_id,

i1_0.option_set_id,

i1_0.update_time

from

interest i1_0

where

i1_0.option_set_id=?

and i1_0.member_id=?

 

delete

from

interest

where

id=?

 

 

#Projections : 필요한 속성만 조회하는 방법 

 

https://docs.spring.io/spring-data/jpa/reference/repositories/projections.html#projections.interfaces.closed

 

Projections :: Spring Data JPA

Spring Data query methods usually return one or multiple instances of the aggregate root managed by the repository. However, it might sometimes be desirable to create projections based on certain attributes of those types. Spring Data allows modeling dedic

docs.spring.io

 

public interface OptionsRepository extends JpaRepository<Options, Long> {

List<OptionNamesValues> findDistinctByOptionSet_Product_CategorySets_Category_Id(Long id);

}

 

이 기능을 구현하기 위해서

 

나는 카테고리를 선택했을때 

해당 카테고리에 속한 모든 product 그리고 그 product의 모든 optionset들이 가지는 option들의 name과 value가 필요했다. 

 

일반적인 방법으로 Options 객체를 그대로 가져오면 중복되는 option name과 value를 거를 수가 없었고 

Options 객체들을 모아서 그중 name과 value 속성만 골라서 각각 distinct 하는 작업을 하는것은 매우 비효율적이라고 생각했다. 

 

그래서 projections에 관해 찾아보고 interface 방식으로 적용했다. 

 

public interface OptionNamesValues {

String getName();

String getValue();

}

 

인터페이스는 getName, getValue 메소드를 가진다. 

 

찾은 name과 value는 getName, getValue 메소드를 사용해 꺼낸다. 

 

옵션명(ex.운영체제)는 옵션값(ex.윈도우11, 윈도우10, 미포함...) 과 같이 

옵션명을 키 값으로 하고 옵션값을 value로 하는 맵 형식으로 가공되어져야 한다.  

아래 코드는 service에서 name과 value를 map 형태로 가공해 dto 형태로 변환하는 과정이다.

List<OptionNamesValues> optionNameValue = optionDao.findOptionNameValueMapByCategoryId(categoryId);

Map<String, Set<String>> dto = optionNameValue.stream().collect(Collectors.groupingBy(

OptionNamesValues::getName, Collectors.mapping(OptionNamesValues::getValue, Collectors.toSet())));

 

 

 

#EntityManager 사용하기 

검색 기능을 구현하기 위해 신경을 많이 썼다. 

검색을 할때 들어갈 조건으로는 

카테고리, 옵션(다중선택), 가격범위(최소, 최대), 제품명이 있고 정렬기준도 선택이 가능하다. 

 

문제는 모든 검색이 이 모든 조건을 포함하고 있지 않고 옵션의 수에 제한이 없고 

같은 옵션 내에서 다른 옵션값을 다중 선택한 경우와 다른 옵션을 다중 선택한 경우에 논리연산이 달라진다는 것이다.

 

성능을 위해서는 입력된 조건값에 따라서 필요한 조건만 동적으로 쿼리를 생성하는 방법이 최선이라고 생각했다. 

 

처음에는 queryDSL을 적용하려고 했었는데 

eclipse에서 스프링부트3.13의 환경에서 여러 방법으로 세팅을 시도해보았는데 거의 이틀동안 세팅을 하지 못했다...

더이상 팀원들은 슬슬 서비스 들어가는데 아직 queryDSL을 세팅 못해서 repository만 붙잡고 있을 수가 없어서 

결국 jpql로 하기로 했다...ㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠ

 

기본적으로 학원의 오라클 db를 사용하였지만 나중에 배포하고 rds로 mysql을 사용할 것을 생각해서 

nativeQuery는 지양하기로 했고 책 사서 jpql을 좀 보니까 오히려 jpql이 객체 지향형쿼리라 

쿼리를 작성하기 더 쉬웠다. 

 

public class OptionSetSearchQuery {

 

private String searchQuery;

 

public void setOrderType(String orderType) {

this.searchQuery = this.searchQuery.replace(":orderType", orderType);

}

 

public OptionSetSearchQuery() {

this.searchQuery = "SELECT os " + "FROM OptionSet os " + " join fetch os.product p" + " WHERE os.stock >0 "

+ " Order By :orderType ";

setOrderType(OptionSetQueryData.BY_ORDER_COUNT);// default 설정

}

 

public OptionSetSearchQuery(QueryStringDataDto searchDto) {

if (searchDto.getCategory() != null) {

CategoryDto category = searchDto.getCategory();

 

this.searchQuery = "SELECT os " + "FROM OptionSet os " + " join fetch os.product p "

+ " join fetch p.categorySets cs " + " join fetch cs.category c " + " WHERE os.stock >0 ";

categoryFilter(category);

} else {

this.searchQuery = "SELECT os " + "FROM OptionSet os " + " join fetch os.product p" + " WHERE os.stock >0 ";

}

if (searchDto.getOptionset() != null) {

List<OptionDto.OptionNameValueMapDto> optionset = searchDto.getOptionset();

 

for (int i = 0; i < optionset.size(); i++) {

String key = optionset.get(i).getOptionName();

optionFilter(key, optionset.get(i).getOptionValue());

}

}

if (searchDto.getNameKeyword() != null) {

nameKeyword(searchDto.getNameKeyword());

}

if (searchDto.getMinPrice() != null && searchDto.getMaxPrice() != null) {

priceRange(searchDto.getMinPrice(), searchDto.getMaxPrice());

}

if (searchDto.getMinPrice() != null && searchDto.getMaxPrice() == null) {

onlyMinConstraint(searchDto.getMinPrice());

}

if (searchDto.getMaxPrice() != null && searchDto.getMinPrice() == null) {

onlyMaxConstraint(searchDto.getMaxPrice());

}

 

this.searchQuery += " Order By :orderType ";

if (searchDto.getOrderType() != null) {

String orderType = searchDto.getOrderType();

if (orderType.equals("판매순")) {

setOrderType(OptionSetQueryData.BY_ORDER_COUNT);

} else if (orderType.equals("조회순")) {

setOrderType(OptionSetQueryData.BY_VIEW_COUNT);

} else if (orderType.equals("최신순")) {

setOrderType(OptionSetQueryData.BY_CREATE_TIME);

} else if (orderType.equals("최저가순")) {

setOrderType(OptionSetQueryData.BY_TOTAL_PRICE);

} else {// default

setOrderType(OptionSetQueryData.BY_ORDER_COUNT);

}

} else {

setOrderType(OptionSetQueryData.BY_ORDER_COUNT);

}

}

 

public void categoryFilter(CategoryDto category) {

String category_filter = "";

if (category.getName().equals("전체")) {

category_filter = "AND c.parent.id = :categoryFilter ";

category_filter = category_filter.replace(":categoryFilter", "" + category.getId() + "");

} else {

category_filter = "AND c.id = :categoryFilter ";

category_filter = category_filter.replace(":categoryFilter", "" + category.getId() + "");

}

this.searchQuery += category_filter;

}

 

public void optionFilter(String optionName, List<String> optionValue) {

if (optionValue != null) {

String valueString = "o.value= :optionValue";

if (optionValue.size() == 1) {

valueString = valueString.replace(":optionValue", "'" + optionValue.get(0) + "' ");

} else if (optionValue.size() > 1) {

valueString = valueString.replace(":optionValue", "'" + optionValue.get(0) + "' ");

for (int i = 1; i < optionValue.size(); i++) {

valueString += " OR o.value=" + "'" + optionValue.get(i) + "' ";

}

}

String option_filter = "AND EXISTS ( SELECT 1 FROM Options o WHERE o.optionSet = os AND o.name = :optionName AND ("

+ valueString + ") ) ";

option_filter = option_filter.replace(":optionName", "'" + optionName + "'");

this.searchQuery += option_filter;

}

}

 

public void priceRange(int minPrice, int maxPrice) {

String price_range = "AND os.totalPrice between :minPrice and :maxPrice ";

price_range = price_range.replace(":minPrice", String.valueOf(minPrice));

price_range = price_range.replace(":maxPrice", String.valueOf(maxPrice));

this.searchQuery += price_range;

}

 

private void onlyMinConstraint(int minPrice) {

String minConstraint = "AND os.totalPrice >= :minPrice ";

minConstraint = minConstraint.replace(":minPrice", String.valueOf(minPrice));

this.searchQuery += minConstraint;

}

 

private void onlyMaxConstraint(int maxPrice) {

String maxConstraint = "AND os.totalPrice <= :maxPrice ";

maxConstraint = maxConstraint.replace(":maxPrice", String.valueOf(maxPrice));

this.searchQuery += maxConstraint;

}

 

public void nameKeyword(String nameKeyword) {

String name_keyword = "AND LOWER(os.product.name) like LOWER(:nameKeyword) ";

name_keyword = name_keyword.replace(":nameKeyword", "'%" + nameKeyword + "%'");

this.searchQuery += name_keyword;

}

 

public String build() {

return this.searchQuery;

}

 

}

이 클래스는 입력받아 전달받은 데이터를 확인하고 그 데이터에 맞춰 쿼리를 build하는 클래스로 작성하였다. 

 

기본 생성자로

public OptionSetSearchQuery() {

this.searchQuery = "SELECT os " + "FROM OptionSet os " + " join fetch os.product p" + " WHERE os.stock >0 "

+ " Order By :orderType ";

setOrderType(OptionSetQueryData.BY_ORDER_COUNT);// default 설정

}

기본 쿼리를 작성하고 

카테고리가 선택되면 

this.searchQuery = "SELECT os " + "FROM OptionSet os " + " join fetch os.product p "

+ " join fetch p.categorySets cs " + " join fetch cs.category c " + " WHERE os.stock >0 ";

categoryFilter(category);

 

기본쿼리는 필요한 객체들을 join하는 쿼리로 기본 쿼리를 수정하고 카테고리를 수정한다. 

 

String name_keyword = "AND LOWER(os.product.name) like LOWER(:nameKeyword) ";

name_keyword = name_keyword.replace(":nameKeyword", "'%" + nameKeyword + "%'");

 

제품명 키워드가 들어있으면 위와같이 쿼리를 위치에 붙여준다. 

 

public void optionFilter(String optionName, List<String> optionValue) {

if (optionValue != null) {

String valueString = "o.value= :optionValue";

if (optionValue.size() == 1) {

valueString = valueString.replace(":optionValue", "'" + optionValue.get(0) + "' ");

} else if (optionValue.size() > 1) {

valueString = valueString.replace(":optionValue", "'" + optionValue.get(0) + "' ");

for (int i = 1; i < optionValue.size(); i++) {

valueString += " OR o.value=" + "'" + optionValue.get(i) + "' ";

}

}

String option_filter = "AND EXISTS ( SELECT 1 FROM Options o WHERE o.optionSet = os AND o.name = :optionName AND ("

+ valueString + ") ) ";

option_filter = option_filter.replace(":optionName", "'" + optionName + "'");

this.searchQuery += option_filter;

}

}

옵션이 선택되면 실행될 코드이다.

 

valueString은 옵션값이 다중선택된 경우 or 연산을 해야하기 때문에 value의 size만큼 or연산을 반복하여 valueString을 완성하고 

name을 붙여 

exists 쿼리를 작성하고 조건문 위치에 붙여준다. 

 

이렇게 작성한 쿼리를 사용할 repository 를 만들어준다. 

@Repository

@RequiredArgsConstructor

public class OptionSetQueryRepository {

@PersistenceContext

private EntityManager em;

 

public List<OptionSet> findByFilter(QueryStringDataDto dataDto){

String jpql = new OptionSetSearchQuery(dataDto).build();

TypedQuery<OptionSet> query = em.createQuery(jpql,OptionSet.class);

return query.getResultList();

}

public List<ProductListOutputDto> findForMemberByFilter(QueryStringDataDto dataDto, String username){

String mainJpql = new OptionSetSearchQuery(dataDto).build();

TypedQuery<OptionSet> query = em.createQuery(mainJpql,OptionSet.class);

String findHeartJpql = "SELECT i.optionSet.id FROM Interest i WHERE i.member.userName= :username";

TypedQuery<Long> heart = em.createQuery(findHeartJpql,Long.class);

heart.setParameter("username", username);

List<Long> heartOptionSetId = heart.getResultList();

List<OptionSet> searchResult = query.getResultList();

List<ProductListOutputDto> finalResult = searchResult.stream().map(t -> {

ProductListOutputDto productDto = new ProductListOutputDto(t);

productDto.setIsInterested(heartOptionSetId.contains(t.getId()));

return productDto;

}).collect(Collectors.toList());

return finalResult;

}

 

}

로그인한 경우 조회한 아이템에 멤버 아이디로 관심상품 여부도 판별해야하기 때문에 메소드를 두가지로 만든다.

 

일단 jpql 쿼리로 결과를 얻기 위해 entityManager가 필요했다. 

EntityManager에 @PersistenceContext 어노테이션을 붙여준다. 

 

QueryStringDataDto는 카테고리, 가격, 키워드 등 조건 중 입력된 값이 들어있고 

그 값을 받아 

String jpql = new OptionSetSearchQuery(dataDto).build();

jpql 쿼리를 생성했다. 

TypedQuery<OptionSet> query = em.createQuery(jpql,OptionSet.class);

entityManager의 createQuery 메서드에 jpql쿼리와 반환받을 타입을 파라메터로 넣는다. 

TypedQuery는 쿼리를 실행하고 결과를 핸들링할 타입을 받을 인터페이스이다. 

쿼리 실행 결과는 getResultList() 메소드로 얻을 수 있다. 

 

+ Recent posts