728x90

Vue.js

UI, 라우팅, SSR( Server Side Rendering ) 등의 프론트엔드 개발을 지원하는 프레임워크


Vue.js 공식문서

 

#Vue3 코드 작성 방식

OptionsAPI, CompositionAPI 두가지 방식이 존재하고 

공식문서의 네비게이션바에서 각 방식에 따른 API 설명을 확인할 수 있다. 

 

-OptionsAPI

 

클래스 기반 컴포넌트 인스턴스(this) 구조 

<script>
export default {
  // data()에서 반환된 속성들은 반응적인 상태가 되어 `this`에 노출됩니다.
  data() {
    return {
      count: 0
    }
  },

  // methods는 속성 값을 변경하고 업데이트 할 수 있는 함수.
  // 템플릿 내에서 이벤트 헨들러로 바인딩 될 수 있음.
  methods: {
    increment() {
      this.count++
    }
  },

  // 생명주기 훅(Lifecycle hooks)은 컴포넌트 생명주기의 여러 단계에서 호출됩니다.
  // 이 함수는 컴포넌트가 마운트 된 후 호출됩니다.
  mounted() {
    console.log(`숫자 세기의 초기값은 ${ this.count } 입니다.`)
  }
}
</script>

<template>
  <button @click="increment">숫자 세기: {{ count }}</button>
</template>

 

 

-CompositionAPI

 

재사용성이 좋은 컴포넌트 구조

<script setup>
import { ref, onMounted } from 'vue'

// 반응적인 상태의 속성
const count = ref(0)

// 속성 값을 변경하고 업데이트 할 수 있는 함수.
function increment() {
  count.value++
}

// 생명 주기 훅
onMounted(() => {
  console.log(`숫자 세기의 초기값은 ${ count.value } 입니다.`)
})
</script>

<template>
  <button @click="increment">숫자 세기: {{ count }}</button>
</template>

 

 

#개발 환경

 

Node.js https://nodejs.org/en

 

Node.js

Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.

nodejs.org

 

Visual Studio Code https://code.visualstudio.com/ 

 

Visual Studio Code - Code Editing. Redefined

Visual Studio Code is a code editor redefined and optimized for building and debugging modern web and cloud applications.  Visual Studio Code is free and available on your favorite platform - Linux, macOS, and Windows.

code.visualstudio.com

 

*plugins 

- Vue VSCode Snippets

- Live Server

- Material Icon Theme 

- Night Owl (테마)

- ESLint, TSLint Vue (문법 어시스턴스), Auto Close Tag

- Prettier( Code Formatter ), Project Manager

- GitLens

- Atom Keymap, Jetbrains IDE Keymap ( Shortcut )

[ Vue2 - Vetur / Vue3 - Volar ] 

 

 

 

WindowsOS 선택사항

(cmder https://cmder.app/)

 

Cmder | Console Emulator

Total portability Carry it with you on a USB stick or in the Cloud, so your settings, aliases and history can go anywhere you go. You will not see that ugly Windows prompt ever again.

cmder.app

 

Chrome Extension Devtool

Vue.js devtools

https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd

 

Vue.js devtools

Browser DevTools extension for debugging Vue.js applications.

chrome.google.com

 

 

CDN( Content Delivery Network )

https://ko.vuejs.org/guide/quick-start.html

 

빠른 시작 | Vue.js

 

ko.vuejs.org

<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

 

**

vscode shortcut

#app + tap

tag_name + tap

 

 

기본 예제

- OptionsAPI

<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

<div id="app">{{ message }}</div>

<script>
  const { createApp } = Vue //distructuring 문법

  createApp({ //vue를 사용할 application instance
    data() {
      return {
        message: 'Hello Vue!'
      }
    }
  }).mount('#app') // id에 app인 태그에 mount(부착)
</script>

 

 

SSR도 가능하다더니 thymeleaf 사용하는 느낌이 난다.. 

message라는 프로퍼티와 mount 함수 프로퍼티를 가진 createApp 클래스를 만들고 

html에 표현식으로 createApp 객체의 프로퍼티를 사용해 렌더링 하는 느낌이다. 

 

distructuring 대신 Vue.createApp으로 간단하게 사용할 수 있다. 

<script>
    Vue.createApp({
        data(){
            return {
                message: 'hello vue'
            }
        }
    }).mount('#app');
</script>

 

 

-CompositionAPI

<script>
  const { createApp, ref } = Vue

  createApp({
    setup() {
      const message = ref('Hello vue!')
      return {
        message
      }
    }
  }).mount('#app')
</script>

 

 

#Vue 작동 원리 ( reactivity ) 

 

javascript의 Proxy Api를 사용하여 Vue의 작동원리를 알아본다. 

 

 const data={
    a:10
   }

   const app = new Proxy(data,{
    get() {
        console.log('프록시를 이용해 mocking data의 값에 접근하면 나오는');
    },
    set() {
        console.log('실제 data에는 변경되지 않지만 프록시를 이용해 mocking data의 값을 변경하면 나오는');
    }
   });

실행결과

Proxy는 data를 mocking할 뿐, 실제 data에 접근하는 것이 아니기 때문에 실제 data의 값은 변함 없다.

 

   const data={
    a:10
   }
   function render(newValue){
    let app = document.querySelector('#app');
    app.innerHTML=newValue;
   }
   const app = new Proxy(data,{
    get() {
        console.log('프록시를 이용해 mocking data의 값에 접근하면 나오는');
    },
    set(target,prop, newValue) {
        target[prop] = newValue; //data.a=newValue
        render(newValue); //app이라는 객체의 값이 바뀌면 dom 객체의 html이 바뀐다. 
    }
   });

 

app이란 객체는 app이라는 div 태그의 html을 동적으로 변경해주는 객체가 된다. 

 

즉, 나는 동적으로 바뀔 부분을 컴포넌트로 코드를 작성하고, 그 컴포넌트(App)를 

돔 트리의 특정 객체(div id='App')로 만들면

Vue라는 프록시가 ref의 값을 감시하다가 값이 변경되면

그 돔 객체의 html을 수정해준다. 

 

여기서 주의할 점은 Vue라는 프록시를 통해 값을 변경해야 돔 객체를 수정해주지

직접 ref의 원본인 data에 접근하여 변경하면 render()를 실행하지 않기 때문에 돔 객체를 수정해주지 않는다. 

 

'Vue.js' 카테고리의 다른 글

Vue.js form submit  (0) 2023.11.30
Vue.js 프로젝트 생성하기  (0) 2023.11.29
Vue.js 기초 문법  (2) 2023.11.28
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

 

#무한스크롤

 

기존 페이징 방식에서 벗어나 최근 떠오르는 페이징 방식이다.

 

특정 개수만큼만 로딩하고 특정 뷰포트를 넘어가면 다음 아이템을 로딩하는 방식으로 

비동기 방식으로 html을 누적 생성해낸다. 

 

최근 다양한 sns에서 적용하고 있는 방식이다. 

기존의 페이지네이션은 끝에 도달하면 다음 동작을 해야 다음 컨텐츠를 확인할 수 있는데 

무한 스크롤은 끝에 도달했다고 인지하기도 전에 이미 다음 컨텐츠를 보여주고 있기 때문에 

컨텐츠가 끝없이 쏟아지고 심리적으로 페이지네이션보다 클라이언트를 더 붙잡아두는 경향이 있다. 

 

IntersectionObserver Interface는 

특정 요소를 observe 하면서 해당 요소가 다른 요소와 교차하게 되는 시점을 감지하는 interface이다. 

이 인터페이스를 활용하면 아이템 뷰포트를 옵서브 대상으로 지정하고

뷰포트가 다음 요소와 교차될 때, 혹은 교차되기 이전에 다음 아이템을 렌더링하여 무한 스크롤을 구현할 수 있다.

 

우리가 할 일은 교차를 감지하였을 때 어떤 동작을 할지 콜백함수를 작성해주는 일이다.  

 

#IntersectionObserver 생성자

 

new IntersectionObserver(callback[, options])

 

 

callback(entries, observer) 

콜백함수는 entries와 observer를 매개변수로 받을 수 있다. 

 

observer는 콜백을 호출한 intersectionObserver 객체이다.

 

#intersectionObserverEntry(배열) Properties 

 

- boundingClientRect

타겟 요소의 사각형 바운드

 

- intersectionRect

타켓의 가시 영역

 

- intersectionRatio

boundingClientRect에서 intersectionRect의 교차 비율로 백분율로 표현한다. 

 

- rootBounds

observer의 root 바운드 

 

- isIntersecting

observer의 root를 교차한 상태인지를 boolean 값으로 반환 

 

- target

교차가 발생한 요소

 

- time

교차 시간

 

 

#options

 

- root

 대상 요소의 조상 element

 root의 바운딩을 감지기의 뷰포트로 사용

 

- rootMargin

 교차 영역 계산시 사용할 바운드에 적용할 오프셋 문자열로

 계산할 바운딩 영역을 증가시키거나 감소시킬 수 있다.

 기본값은  "0px 0px 0px 0px"이다. 
 값을 잘못 설정하면 syntax error가 발생한다. 

 

- threshold

 대상의 가시영역과 바운딩 영역의 교차 비율에 대한 역치

0.0~ 1.0 사이의 숫자 값을 갖는다. 

0.0은 root가 가시영역에 조금이라도 보일 때 대상을 볼 수 있는 것으로 감지하고 

1.0은 root가 가시영역에 온전히 보였을 때 대상을 볼 수 있는 것으로 감지한다. 

 

 

threshold를 0.8로 설정하면 

스크롤을 내리다 타겟요소의 0.8을 넘으면 observer가 콜백함수를 호출한다. 

 

threshold의 범위를 초과하여 설정하면 range error가 발생한다. 

 

 

#메소드

 

- disconnect() 

모든 대상의 주시를 해제 

 

- observe()

 주어진 대상 요소를 주시

 

- takeRecords() 

 모든 주시 대상에 대한 entries를 반환

 

- unobserve()

 특정 대상 요소에 대한 주시를 해제

 

https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver

 

IntersectionObserver - Web API | MDN

Intersection Observer API (en-US)의 IntersectionObserver 인터페이스는 대상 요소와 상위 요소, 또는 대상 요소와 최상위 문서의 뷰포트가 서로 교차하는 영역이 달라지는 경우 이를 비동기적으로 감지할 수

developer.mozilla.org

 

 

 

 

 #구현 예시

 

const $result =$("#toObserve");
const $end = document.createElement("div");
$end.id = 'product-list-observed';
$result.append($end);

const callback = (entries, observer) => {
	entries.forEach((entry) => {
		if (entry.isIntersecting) {
			firstResult += 20;
			filterDto.firstResult = firstResult;
			api.continueSearchResult(filterDto, observer);
		}
	});
}
const options = {
	root: null, // 뷰포트를 기준으로 타켓의 가시성 검사
	rootMargin: '0px 0px 0px 0px', // 확장 또는 축소 X
	threshold: 1 // 타켓의 가시성 0%일 때 옵저버 실행
};
const observer = new IntersectionObserver(callback, options);
observer.observe($end);
Product

{{name}}

{{totalPrice}}원

{{optionSetDesc}}
{{updateTime}}

 

'Javascript' 카테고리의 다른 글

SPA AJAX Request With JQuery  (0) 2023.09.25
js 모듈 의존성 줄이기  (0) 2023.09.24
AJAX using JQuery  (0) 2023.09.24
JQuery event  (0) 2023.09.23
Javascript this  (0) 2023.09.23
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() 메소드로 얻을 수 있다. 

 

728x90

클라이언트는 브라우저에서 요청을 하고 응답을 받는다. 

이때 요청은 서버에서 필요한 데이터를 담아야하고 

서버측에서 응답할땐 클라이언트에게 공개할 데이터만 담아서 보내면 된다. 

일반 사용자는 그저 클릭하고 타자를 입력할 뿐 요청하는 방법, 응답 받아서 확인하는 방법은 알지 못한다. 

 

일반 사용자가 클릭, 키보드 입력으로 어떤 데이터를 보내게 할지,

데이터를 응답으로 보냈을 때 어떻게 전달할지는 개발자가 해야할 일이다. 

 

요청을 보내고 응답을 보내는데는 엔티티의 모든 데이터가 필요하지 않다. 

필요한 정보만 받을 수 있고

 

클라이언트에게 공개하기 싫은 정보를 제외하고 응답을 보낼 수도 있고, 

클라이언트에게 보낼 데이터를 가공할 수도 있다. 

 

요청, 응답을 위해 필요한 데이터를 전달할 객체를 DTO Data Transfer Object 라고 한다. 

 

그래서 Presentation Layer와 Business Layer는 DTO로 데이터를 전달하고 

DB 계층에 접근하여 Data를 처리할 때는 Entity를 이용해야한다. 

 

@Data

@Builder

@AllArgsConstructor

@NoArgsConstructor

public class CategoryDto {

 

@NotNull

private Long id;

@NotBlank

private String name;

private Long parentId;

 

public Category toEntity() {

return Category.builder()

.id(id)

.name(name)

.parent(Category.builder().id(getParentId()).build())

.build();

}

 

public CategoryDto(Category entity){

this.id=entity.getId();

this.name=entity.getName();

if(entity.getParent()!=null&&entity.getParent().getId()!=null) {

this.parentId=entity.getParent().getId();

}

}

@AllArgsConstructor

@NoArgsConstructor

@Data

@Builder

public static class CategorySaveDto {

@NotBlank

private String name;

private Long parentId;

 

public Category toEntity() {

return Category.builder()

.name(name)

.parent(Category.builder().id(parentId).build())

.build();

}

CategorySaveDto(Category entity){

this.name=entity.getName();

this.parentId=entity.getParent().getId();

}

}

}

 

카테고리 DTO이다.

카테고리 엔티티는 ParentCategory 객체를 가지지만 DTO는 parentCategoryId를 가진다. 

만약 엔티티 그대로 서비스로직을 진행하거나 응답으로 보낸다면 

서비스 로직을 수행하기 위해 Category 객체를 생성할때 ParentCategory객체도 필요할 수 있고, 

응답을 하는 과정에서 Category 객체의 필드인 ParentCategory 객체에 참조하면서 순환 참조의 늪에 빠질 수 있다. 

 

카테고리 saveDto는 category를 insert 할때 사용할 dto이다. 

id는 pk로 auto increase할 것이기 때문에 필요하지 않고 name과 parentId만 있으면 된다. 

 

카테고리 DTO 내부에 innerclass로 saveDTO를 static으로 만들어두면 

Category 관련 dto를 CategoryDTO 하나의 클래스내에 정리하여 관리할 수 있다. 

 

 

그리고 각 dto에는 toEntity와 dto 생성자가 존재하는 이는 entity와 dto사이의 변환을 위한 맵퍼라고 생각하면 된다. 

 

이를 편하게 해주는 MapStruct라는 라이브러리가 존재한다. 

이에 관해선 나중에 다뤄보기로 한다. 

 

DTO는 응답을 보낼 데이터를 가공할 수도 있다고 하였다. 

 

public class ProductListOutputDto {//리스트,히트상품,관심,최근상품리스트

private String brand;

private String name;

private String updateTime;

private String pImage;

private Integer totalPrice;

private String totalPriceString;

private Long osId;

@Builder.Default

private List<OptionDto.OptionBasicDto> optionSet = new ArrayList<>();

private Boolean isInterested;

private String optionSetDesc;

 

 

public ProductListOutputDto(OptionSet entity) {

this.totalPriceString=new DecimalFormat("#,###").format(entity.getTotalPrice());

this.brand=entity.getProduct().getBrand();

this.name=entity.getProduct().getName();

this.totalPrice = entity.getTotalPrice();

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

this.updateTime = entity.getUpdateTime().format(formatter);

this.pImage=entity.getProduct().getImg();

this.osId=entity.getId();

this.optionSet = entity.getOptions().stream().map(t -> new OptionDto.OptionBasicDto(t)).collect(Collectors.toList());

this.isInterested=false;

StringBuilder sb = new StringBuilder();

for (OptionBasicDto option : this.optionSet) {

sb.append(option.getName()+":"+option.getValue());

sb.append("/"); // 나머지 값은 '/'

}

String result = sb.toString();

if (result.endsWith("/")) {

result = result.substring(0, result.length() - 1); // 마지막 '/' 제거

}

this.optionSetDesc=result;

}

public ProductListOutputDto(OptionSet entity, String username) {

this.totalPrice = entity.getTotalPrice();

this.brand=entity.getProduct().getBrand();

this.name=entity.getProduct().getName();

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

this.updateTime = entity.getUpdateTime().format(formatter);

this.pImage=entity.getProduct().getImg();

this.totalPriceString=new DecimalFormat("#,###").format(entity.getTotalPrice());

this.osId=entity.getId();

this.optionSet = entity.getOptions().stream().map(t -> new OptionDto.OptionBasicDto(t)).collect(Collectors.toList());

this.isInterested=entity.getInterests().stream().anyMatch(t -> t.getMember().getUserName().equals(username));

StringBuilder sb = new StringBuilder();

for (OptionBasicDto option : this.optionSet) {

sb.append(option.getName()+":"+option.getValue());

sb.append("/"); // 나머지 값은 '/'

}

String result = sb.toString();

if (result.endsWith("/")) {

result = result.substring(0, result.length() - 1); // 마지막 '/' 제거

}

this.optionSetDesc=result;

}

}

 

프로덕트 테이블은 brand, name, pImage만을 갖고  

옵션셋 테이블은 totalPrice, osId를 가지고 

옵션 테이블은 options를 가진다. 

 

하지만 제품 전체 조회 페이지에서 제품의 데이터를 뿌릴때는 

이 세 테이블의 컬럼을 가져올뿐만 아니라 날짜 형식을 설정하고

options 의 name과 value들로 Optionset Desc상세설명 String으로 가공한다.

 

그리고 로그인한 상태인 경우에는 username을 받아 해당 제품이 유저의 관심상품인지 여부도 표시해야한다. 

this.isInterested=entity.getInterests().stream().anyMatch(t -> t.getMember().getUserName().equals(username));

스트림을 이용해 제품에 관심상품으로 등록한 username 중에 로그인한 username과 일치하는지를 체크하여 boolean 타입으로 저장한다. 

 

또한 DTO는 다른 DTO들로 구성할 수도 있다. 

public class UploadProductDto {

private ProductSaveDto product;

private List<OptionDto> options;

private OptionSetCreateDto optionSet;

}

 

 

엔티티와 관련 없이도 필요한 데이터를 객체로 모아서 받을 수도 있다. 

 

public class QueryStringDataDto {

 

private String orderType;

@NotEmpty

@Builder.Default

private List<OptionDto.OptionNameValueMapDto> optionset=new ArrayList<OptionDto.OptionNameValueMapDto>();

private Integer minPrice;

private Integer maxPrice;

private String nameKeyword;

@NotBlank

private CategoryDto category;

@Builder.Default

private Integer firstResult=0;

 

}

 

이 객체는 입력값에 따라 필요한 쿼리를 동적으로 생성하기 위해 

정렬기준, 키워드, 카테고리, 옵션, 최소~최대 가격에 관한 데이터를 입력받아 요청할 때 쓰는 DTO 이다. 

 

 

응답 전용 DTO를 만들수도 있다

 

public class ResponseDto<T> { //HTTP 응답으로 사용할 DTO

private ProductMsgInterface msg;

private List<T> data;//다른 모델의 DTO도 담을 수 있게 제네릭

//보통 여러개의 데이터를 리스트에 담아 처리하기 때문에 리스트

}

 

응답을 할때는 기본적으로 Data를 보내고 상태코드나, 상태 메세지를 보낸다.

data는 List<T> 형태로 하면 여러 타입의 객체를 리스트 형태로 받을 수 있어 

응답할 때 공용으로 사용하기 좋게 하였고 

 

ProductMsgInterface라는 msg를 갖고 있는데 

 

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

}

public enum ProductSuccessMsg implements ProductMsgInterface{

ADD_RECENTVIEW, REMOVE_MY_RECENTVIEWS, REMOVE_RECENTVIEW, REMOVE_OLD_RECENTVIEWS, FIND_MY_RECENTVIEWS,

TOP_CATEGORY, CHILDREN_CATEGORY, ADD_CATEGORY, UPDATE_CATEGORY, REMOVE_CATEGORY,

TAP_HEART, UNTAP_HEART, REMOVE_MY_INTERESTS,

FIND_OPTIONSET_BY_ID, UPLOAD_PRODUCT, FIND_OPTION_NAME_VALUES, FOUND_NO_OTHER_OPTIONSETS, FIND_OTHER_OPTIONSETS, SEARCH_PRODUCTS, UPDATE_OPTIONSET, REMOVE_OPTION, REMOVE_OPTIONSET, REMOVE_PRODUCT, IS_MY_INTEREST, MY_INTERESTS, UPDATE_OPTION

}

 

ProductExceptionMsg와 ProductSuccessMsg를 만들어 ProductMsgInterface로 상위 캐스팅하였다. 

 

이렇게 하면 예외가 발생할 때는 예외 메세지를 응답DTO에 넣어줄 수 있고

성공했을 때는 성공메세지를 응답DTO에 넣어줄 수 있다. 

 

 

728x90

Tool : ERDCloud 

https://www.erdcloud.com/

 

ERDCloud

Draw ERD with your team members. All states are shared in real time. And it's FREE. Database modeling tool.

www.erdcloud.com

 

 

프로젝트 전체 테이블

 

 

프로덕트 관련 테이블 

 

Product 테이블 : 제품의 기본 모델에 관한 정보를 담은 테이블

Option 테이블 : 제품이 가질 수 있는 옵션의 정보를 담은 테이블

OptionSet 테이블 : 제품에 옵션들의 정보를 포함하여 하나의 상품으로 간주되어 카트와 주문에 포함될 테이블

Category 테이블 : 카테고리 정보를 담은 테이블

CategorySet 테이블 : 카테고리와 프로덕트를 연결해주는 테이블 

Interest, RecentView 테이블 : 관심상품, 최근 본 상품 테이블 

 

-카테고리 테이블 

카테고리 테이블은 카테고리 이름, 그리고 카테고리 테이블을 셀프 참조하여 부모 카테고리를 가질 수 있어

계층형으로 구성할 수 있게 하였고 

카테고리와 옵션셋 테이블을 다대다 맵핑하여 

하나의 옵션셋은 여러개의 카테고리를 가질 수 있게 하였다. 

 

@Data

@Builder

@AllArgsConstructor

@NoArgsConstructor

@Entity

public class Category {//셀프 참조하는 오너테이블, 카테고리셋과는 종속테이블

@Id

@GeneratedValue(strategy = GenerationType.SEQUENCE)

private Long id; //pk

private String name; //카테고리 이름

 

@JoinColumn(name="parent", nullable = true)

@ManyToOne

@ToString.Exclude

private Category parent; //부모 카테고리

 

@OneToMany(mappedBy = "parent")

@Builder.Default

@ToString.Exclude

private List<Category> childTypes= new ArrayList(); //자식 카테고리들

 

@OneToMany(mappedBy = "category")

@Builder.Default

@ToString.Exclude

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

//다대다 맵핑을 위한 categorySet과 관계 설정

}

 

-프로덕트 테이블 

public class Product extends BaseEntity {//제품의 기본 모델 정보

@Id

@GeneratedValue(strategy = GenerationType.SEQUENCE)

private Long id;//pk

private String name;//제품명

private String brand;//브랜드

private Integer price;//기본 가격

private String descImage;//설명 이미지 파일

private String prevImage;//디테일이미지

private String img;//제품 이미지

 

@OneToMany(mappedBy = "product",fetch = FetchType.EAGER)

@Builder.Default

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

//하나의 제품은 부모카테고리, 자식카테고리 여러개를 가질 수 있다.

//예를 들어, 컴퓨터, 일체형PC, 브랜드PC ...

 

@ToString.Exclude

@OneToMany(mappedBy = "product",cascade = {CascadeType.REMOVE,CascadeType.PERSIST},orphanRemoval = true)

@Builder.Default

private List<OptionSet> optionSets = new ArrayList<>();

 

}

여기서 Product가 상속받은 BaseEntity는 createTime과 updateTime을 가진 엔터티로 

@MappedSuperclass

public class BaseEntity {

@CreationTimestamp

@Column(updatable = false)

@ToString.Exclude

private LocalDateTime createTime;//데이터 생성시간

@UpdateTimestamp

private LocalDateTime updateTime;//데이터 갱신시간

}

데이터의 관리를 위해 필요한 기본적인 정보를 가지는 superclass로 다른 엔터티들이 상속받아 사용할 수 있게 

@MappedSuperclass 어노테이션을 사용해 테이블로 생성되지 않고 상속하는 역할만 할 수 있게 하였다. 

 

 

프로덕트는 제품의 기본 모델에 관한 정보를 담은 테이블로 제조사, 제품 이미지, 기본 가격 등에 관한 정보를 담고 있다. 

프로덕트 테이블에 대해 고민을 많이 했는데 

컴퓨터와 같이 하나의 모델에 대해서도 다양한 옵션이 선택될 수 있고 선택된 옵션에 따라 가격이 변동 될 수 있기에 

기본 모델을 product로 하고 거기에 options을 더한 optionset을 하나의 완전한 상품으로 구상하였다. 

 

그래서 cart, orderitem, 관심상품, 최근 본 상품 등에 들어갈 제품은 product가 아닌 optionset이 되고, 

optionset이 재고, 판매량, 조회수와 같은 정보를 갖게 된다. 

실제로 제품 상세 페이지도 product가 아닌 optionset의 상세 페이지가 된다. 

 

 

public class Options {//옵션셋FK를 가지는 오너테이블

@Id

@GeneratedValue(strategy = GenerationType.SEQUENCE)

private Long id;//pk

 

private String name; //옵션명

private String value; //옵션값

private Integer extraPrice;

//해당 옵션이 옵션셋에 등록될 경우 프로덕트의 총 가격에 추가금

@ManyToOne(fetch = FetchType.EAGER)

@JoinColumn(name = "optionSetId")

@ToString.Exclude

private OptionSet optionSet;//옵션셋 FK

}

 

여기서 옵션과 옵션셋 테이블을 한번 더 분리한 이유는 

컴퓨터만을 product의 대상으로 삼고 있지 않고 다양한 전자제품을 대상으로 하기 때문에 

options가 셀 수 없이 많아질 수밖에 없고 만약 optionset에 그 많은 options들을 nullable 컬럼으로 만들어 두는 것은 매우 비효율적이라고 생각했다. 

그래서 옵션 설정에 자유도를 높이기 위해 options 테이블을 분리하였다. 

 

옵션셋에 totalPrice를 두는 것에 관해서 많은 고민을 했었다. 

원래는 기본 product의 price에 options의 extraPrice들을 모두 더해 나온 값을 표시하면 된다고 생각했는데 

제품 리스트를 조회할 때마다

매 상품마다 options를 모두 뽑아 가격 연산하는 과정을 거치는 작업은 올바른 작업은 아니라고 판단하였고 

옵션셋에 총가격 컬럼을 하나 추가하는 편이 훨씬 경제적이라고 판단하여

제품을 insert 하는 과정에서 extraPrice들을 모두 더해 optionset의 totalPrice의 컬럼에 값을 대입해주기로 하였다. 

 

 

- 관심상품, 최근 본 상품 

관심상품과 최근 본 상품은 멤버와 옵션셋을 참조키로 가진다. 그리고 최근 본 상품에는 30일 이후 자동으로 삭제되는 서비스를 구현한다. 

 

@Entity

@Data

@AllArgsConstructor

@NoArgsConstructor

@Builder

@EqualsAndHashCode(callSuper = true)

@ToString(callSuper = true)

@Table(name = "interest", uniqueConstraints = @UniqueConstraint(columnNames = {"memberId","optionSetId"}))

public class Interest extends BaseEntity{//관심상품

//유저와 옵션셋을 이어주는 중간테이블

@Id

@GeneratedValue(strategy = GenerationType.SEQUENCE)

private Long id;//pk

 

@JoinColumn(name = "memberId")

@ManyToOne

private Member member;// 유저FK

 

@JoinColumn(name = "optionSetId")

@ManyToOne

@ToString.Exclude

private OptionSet optionSet;// 옵션셋FK

 

}

 

관심상품과 최근 본 상품은 멤버와 옵션셋의 복합키로써 유니크 제약조건을 가져야한다.

12345···16

+ Recent posts