AOP란?

  • Spring에서 AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)는 프로그래밍에서 반복되는 코드를 모듈화하는 프로그래밍 패러다임입니다.
    이는 객체 지향 프로그래밍(OOP)을 보완하는 방식으로, 공통의 관심사(Cross-cutting Concerns)를 한 곳에 모아 관리합니다.
    AOP는 주로 로깅, 보안, 트랜잭션 관리와 같은 기능에서 사용됩니다.

  • AOP의 핵심 개념은 'Aspect'로 여러 객체에 공통으로 적용되는 기능을 의미합니다.
    예를 들어, 로깅은 애플리케이션의 많은 부분에서 필요하지만 각각의 객체나 메소드에 로깅 코드를 직접 삽입하는 것은 비효율적입니다.
    AOP를 사용하면, 공통 기능을 별도의 Aspect로 정의하고, 필요한 객체나 메소드에 독립적으로 적용할 수 있습니다.

AOP 개념 용어

  1. Aspect(관점)
  • AOP의 핵심 요소로, 흩어진 관심사를 모듈화한 것입니다.
  • 예를 들어 로깅, 트랜잭션 관리 또는 보안과 같은 기능을 Aspect로 정의할 수 있습니다.
  1. Join Point
  • Advice가 적용될 수 있는 위치, 예를 들어 메소드 실행, 객체 생성 등 프로그램 실행 중 특정 지점을 의미합니다.
  1. Advice
  • 실제로 Aspect가 어떤 일을 수행되는지 작업(로직)을 정의한 것입니다.
  • @Before: Join Point 전에 실행됩니다.
  • @After Returning: Join Point가 정상적으로 완료된 후에 실행됩니다.
  • @After Throwing: 메소드 실행 중 예외가 발생했을 때 실행됩니다.
  • @After: Joint Point가 실행된 후, 예외 발생 여부에 상관없이 실행됩니다.
  • @Arround: Joint Point 전후로 실행되며, 메소드 실행을 감싸는 형태입니다.
  1. Pointcut
  • Join Points 중에서 실제로 Advice가 적용될 위치를 선별하는 표현식입니다.
  • Pointcut을 통해 특정 메소드나 클래스의 메소드들에 대한 Advice 적용 여부를 정의할 수 있습니다.

@Pointcut

  • @Pointcut 은 Advice가 적용될 메소드나 클래스의 집합을 정의하는 데 사용되는 어노테이션입니다.
  • 쉽게 말해, @Pointcut은 어떤 메소드에 Advice를 적용할지 결정하는 "필터"와 같은 역할을 합니다.

@Pointcut을 사용하지 않은 경우

@Component
@Aspect
@Slf4j
public class MyAspect{
    // Before Advice: com.example 패키지 내 모든 메소드 실행 전에 적용
    @Before("execution(* com.example..*(..))")
    public void beforeAdvice(){
        log.info("Before method execution");
    }

    // After Advice: com.example 패키지 내 모든 메소드 실행 후에 적용
    @After("execution(* com.example..*(..))")
    public void afterAdvice(){
        log.info("After method execution");
    }
}

@Pointcut을 사용한 경우

@Component
@Aspect
@Slf4j
public class MyAspect{
    // Pointcut 정의: com.eample 패키지 내 모든 메소드
    @Pointcut("exectuion(* com.example..*(..))")
    public void myPointcut() {}

    // Before Advice: myPointcut에 정의된 메소드 실행 전에 적용
    @Before("myPointcut()")
    public void beforeAdvice(){
        log.info("Before method execution");
    }

    // After Advice: myPointcut에 정의된 메소드 실행 후에 적용
    @After("myPointcut()")
    public void afterAdvice(){
        log.info("After method execution");
    }
}

@Pointcut을 사용한 경우 이점

  1. 재사용성
  • @Pointcut을 사용하는 경우, @Pointcut을 한 곳에서 정의하고 여러 Advice에서 재사용 할 수 있어 코드의 중복을 줄일 수 있습니다.
  1. 유지보수
  • @Pointcut 을 사용하면, 추후 Pointcut을 변경할 때 한 곳에서만 수정하면 됩니다.
  1. 가독성
  • @Pointcut 을 사용하면, Pointcut의 목적을 이름으로 명확히 할 수 있어 가독성이 좋아집니다.

@Pointcut 사용 방법

execution

execution([수식어][리턴타입][클래스이름][이름]([파라미터])

  • 수식어: public, private 등 수식어를 명시합니다. (생략 가능)
  • 리턴타입: 리턴 타입을 명시합니다.
  • 클래스이름 및 이름: 클래스이름과 메서드 이름을 명시합니다. (클래스 이름은 풀 패키지명으로 명시해야 합니다. 생략 가능)
  • 파라미터: 메서드의 파라미터를 명시합니다.
  • "*": 모든 값을 표현합니다.
  • "..": 0개 이상을 의미합니다.

ex)
execution(public Integer com.example.aop..(*))
-> com.example.aop 패키지의 하위 클래스 중 public 접근자면서 반환타입이 Integer, 한개의 파라미터를 가지는 메소드

execution(com.example...get*(..))
-> com.example 패키지 및 하위 패키지의 클래스 중 이름이 get으로 시작하며 파라미터 수나 타입에 관계없이 모든 메서드

execution(com.example.aop..Service.*(..))
-> com.example.aop 패키지 및 하위 패키지에 속해있고 이름이 Service로 끝나는 파라미터 수나 타입에 관계없이 모든 메서드

execution(com.example.aop.BoardService.(.))
-> com.example.aop 패키지의 BoardService 클래스의 파라미터 수나 타입에 관계없이 모든 메서드

execution(some(,))
-> 메서드 이름이 some으로 시작하고 파라미터가 2개인 모든 메서드

within 명시자

ex)
within(com.example.aop.SomeService)
-> com.example.aop.SomeService 인터페이스의 모든 메서드

within(com.example.aop.*)
-> com.example.aop 패키지의 모든 메서드

within(com.example.aop..*)
-> com.example.aop 패키지 및 하위 패키지의 모든 메서드

bean 명시자

ex)
bean(someBean)
-> 이름이 someBean인 빈의 모든 메서드

bean(some*)
-> 빈의 이름이 some으로 시작하는 빈의 모든 메서드



Advice 사용 방법

  1. @Around
  • 핵심 메서드와 공통 메서드의 실행 시점을 자유롭게 설정할 수 있는
  • 어드바이스를 활용하면 메소드의 실행을 둘러싸 여러 가지 추가 기능을 구현할 수 있으며, 비즈니스 로직에 영향을 주지 않으면서 중요한 관심사를 관리할 수 있습니다.
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class PerformanceAspect {

    // 포인트컷 정의: Service 레이어의 모든 메소드
    @Pointcut("execution(* com.example.service.*.*(..))")
    public void monitor() { }

    // Around 어드바이스 정의
    @Around("monitor()")
    public Object logPerformance(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();

        try {
            // 대상 메소드 실행
            Object result = joinPoint.proceed();
            return result;
        } finally {
            long elapsedTime = System.currentTimeMillis() - startTime;
            System.out.println(joinPoint.getSignature().getName() + " 실행 시간: " + elapsedTime + "ms");
        }
    }
}
  1. @AfterThrowing
  • 메소드 실행 중 예외가 발생한 후에 실행됩니다.
  • 발생한 예외에 대한 정보에 접근할 수 있습니다.
  • 발생한 예외에 따라 적절한 처리 로직을 수행할 수 있습니다.
  • 예외 상황에 대한 로그 기록, 리소스 정리, 사용자에게 오류 정보 전달 등 다양한 예외 처리 로직을 구현하는 데 유용하게 사용됩니다.
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class ErrorHandlingAspect {

    // 포인트컷 정의: Service 레이어의 모든 메소드
    @Pointcut("execution(* com.example.service.*.*(..))")
    public void serviceLayer() { }

    // AfterThrowing 어드바이스 정의
    @AfterThrowing(pointcut = "serviceLayer()", throwing = "ex")
    public void logAfterThrowingAllMethods(Exception ex) throws Throwable {
        System.out.println("Exception caught in AfterThrowing advice: " + ex.getMessage());
    }
}
  1. @AfterReturning
  • 메소드가 성공적으로 실행되고 결과를 반환한 후에 실행됩니다.
  • 대상 메소드의 반환값에 접근하여 처리할 수 있습니다.
  • 메소드 실행의 성공 여부를 로깅하는데 사용될 수 있습니다.
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class LoggingAspect {

    // 포인트컷 정의: Service 레이어의 모든 메소드
    @Pointcut("execution(* com.example.service.*.*(..))")
    public void serviceLayer() { }

    // AfterReturning 어드바이스 정의
    @AfterReturning(pointcut = "serviceLayer()", returning = "result")
    public void logAfterReturning(Object result) {
        System.out.println("Method returned value is : " + result);
    }
}
  1. @Before
  • 대상 메소드 실행 전에 특정 로직을 수행합니다.
  • 메소드가 실행되기 전 필요한 파라미터 검증이나 초기 설정을 수행할 수 있습니다.
  • 메소드 실행 전 정보를 로그로 남길 수 있습니다.
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class LoggingAspect {

    // 포인트컷 정의: Service 레이어의 모든 메소드
    @Pointcut("execution(* com.example.service.*.*(..))")
    public void serviceLayer() { }

    // Before 어드바이스 정의
    @Before("serviceLayer()")
    public void logBefore(JoinPoint joinPoint) {
        System.out.println("Before executing method: " + joinPoint.getSignature().getName());
    }
}
반응형

'개발 > Spring' 카테고리의 다른 글

응집도와 결합도란?  (1) 2023.12.26
[Querydsl] DTO 조회 방법  (0) 2023.12.17
OAuth2란? OAuth2 예시  (1) 2023.12.15
DTO <-> Entity 변환 이유!  (0) 2023.12.14

Docker File 이란?

  • Docker 컨테이너를 생성하기 위한 구성 파일입니다.
  • Dokcer 이미지를 만들기 위한 명령어와 설정을 포함하고 있습니다.
  • Docker File을 사용하면 애플리케이션과 종속성을 코드 형태로 정의할 수 있으며, 이를 통해 일관된 환경에서 애플리케이션을 배포하고 실행할 수 있습니다.

Docker File 기본적인 구성

FROM #운영체제 이미지
RUN #실행할 명령어
CMD #컨테이너 명령 실행

DockerFile 문법

명령어 설명 명령어 설명
FROM 베이스 이미지 지정 RUN 명령어 실행
CMD 컨테이너 실행 명령 EXPOSE 포트 노출
ENV 환경 변수 ADD 파일 / 디렉토리 추가
COPY 파일 복사 ENTRYPOINT 컨테이너 실행 명령
VOLUME 볼륨 마운트 USER 사용자 지정
WORKDIR 작업 디렉토리 ARG Dockerfile 안의 변수
ONBUILD 빌드 완료 후 실행되는 명령 LABEL 라벨 설정
STOPSIGNAL 종료 시그널 설정 HEALTHCHECK 컨테이너 상태 체크
SHELL 기본 쉘 설정
  • 대다수의 Dockerfile을 보면 FROM 부터 시작합니다.
  • 운영체제를 지정하는데 메모리 기반으로 돌아가기 때문에 용량을 적게 설정하는 것이 필요합니다.

명령어 상세 설명

1. RUN

  • 이미지 생성과정에서 애플리케이션 / 미들 웨어 설치, 환경 설정을 위한 명령 등을 정의합니다.

1) Shell 형식

  • 쉘 프롬프트에 명령을 기술하는 방식입니다.
  • 베이스 이미지 위에서 /bin/sh -c를 사용해서 명령을 수행하는 것과 동일합니다.

RUN apt-get update && apt-get install -y nginx

2) exec 형식

  • 쉘을 경유하지 않습니다.
  • 직접 실행이 아니라 $HOME 같은 쉘 환경 변수를 사용할 수 없습니다.
  • 명령어가 단독적으로 실행되는 것이 아니기 때문에 JSON 배열 형식으로 정의합니다.

RUN ["/bin/bash", "apt-get install -y nginx" ]

2. CMD

  • 생성된 이미지를 바탕으로 컨테이너 내부에서 수행될 작업이나 명령을 실행합니다.
  • Dockerfile에 하나의 CMD 명령만 기술 가능하기 때문에, 여러개를 기록한다면 마지막 명령만 유효합니다.

ex) Nginx 이미지를 생성할 때 Nginx를 설치하는 것은 RUN 명령이지만, Nginx Daemon을 실행하는 것은 CMD 명령어 입니다.

  • shell, exec 형식을 지원합니다.
#shell
CMD nginx -g 'daemon o ff;'

#exec
CMD ["nginx", "-g", "daemon o ff;" ]

3. ONBUILD

  • 해당 이미지를 import 하는 곳에서 사용하기 위한 명령어 입니다.
  • Dockerfile 에 ONBUILD 명령을 사용하여 어떤 명령을 실행하도록 설정하여 빌드하고 이미지를 작성하게 되면, 그 이미지를 다른 Dockerfile 에서 베이스 이미지로 설정하여 빌드했을 때 ONBUILD 명령에서 지정한 명령이 실행됩니다.

4. ENV, ARG

  • 둘 모두 환경 변수를 지정해 줍니다.
  • ENV는 컨테이너 내부에서 사용하는 환경 변수, ARG는 빌드 되는 과정에서만 사용하는 환경 변수 입니다.
FROM nginx
ENV FOOD "Chicken" \
Summer Cola Sea Vacation \
ENV Time 1116
CMD ["/bin/bash"]

5. EXPOSE

  • 컨테이너의 공개포트를 지정하고 컨테이너가 대기하고 있는 포트를 알려줍니다.
  • docker run -p 옵션을 통해 호스트의 포트 번호와 매핑할 수 있습니다.
    예를 들면 -p 8080:443처럼 사용할 수 있습니다.
FROM nginx
EXPOSE 443

Image Layer

  • Image layer는 햄버거와 유사합니다. 햄버거가 빵 -> 야채 -> 패티 -> 야채 -> 소스 -> 빵 순서로 하나씩 쌓아가듯 컨테이너도 레이어를 쌓아서 Image Layer를 이룬다고 생각하시면 됩니다.
  • 이미지 전송시간을 줄이기 위해 Read-only image layer 를 공유합니다.

위 그림에서 첫번째 그림의 Container에서 Image 1.0은 Read only 입니다. 여기에 Apache를 추가하여 레이어를 쌓았습니다.
두번째 그림은 첫번째 그림의 Container(Image 1.0, Iamge 1.1) 위에 Git을 추가하여 레이어를 쌓았습니다.
세번째 그림은 두번째 그림의 Container(Image1.0, 1.1, 1.2) 위에 Source를 추가하여 레이어를 쌓았습니다.
이렇게 명령어가 하나씩 실행될 때마다 upper layer(프로그램 설치)가 반복이 됩니다.



이런식으로 끊임없이 레이어가 쌓이게 되면 메모리는 점점 많아져 무거워 질겁니다.



예를 들어 보겠습니다.


1) dockerfile은 아래와 같습니다.

FROM ubuntu
RUN apt-get update && apt-get install -y -q nginx
RUN rm -rf /var/lib/apt/lists/***
COPY index.html /var/www/html
CMD ["nginx", "-g", "daemon off; " ]




2) dockerfile을 실행 후 모습

180MB의 메모리를 지니고 있는 것을 확인할 수 있습니다.



3) dockerfile 내의 RUN 명령어를 한줄로 변경하기

FROM ubuntu
RUN apt-get update && \
    apt-get install -y -q nginx && \
    rm -rf /var/lib/apt/lists/***
COPY index.html /var/www/html
CMD ["nginx", "-g", "daemon off; " ]




4) dockerfile 실행 후 모습

134MB 메모리로 줄어든 것을 확인할 수 있습니다!


java 파일을 Dockerfile을 이용한 예시 입니다.

  • 환경으로는 Springboot 2.5.2, java 11, gradle 6.8.2 에서 실행하였습니다.

build.gradle 파일

buildscript {
    dependencies {
        classpath "io.spring.gradle:dependency-management-plugin:0.5.1.RELEASE"
    }
}

plugins {
    id 'org.springframework.boot'
    id 'java'
    id 'com.palantir.docker' version '0.25.0'
}

compileJava{
    sourceCompatibility = 11
    targetCompatibility = 11
}

apply plugin: 'java'
apply plugin: 'java-library'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group = 'com.pay.membership'
version = '1.0.0'

repositories {
    mavenCentral()
}

dependencies {
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    implementation group: 'io.springfox', name: 'springfox-swagger-ui', version: '2.9.2'
    implementation group: 'io.springfox', name: 'springfox-swagger2', version: '2.9.2'
    testImplementation 'com.tngtech.archunit:archunit:1.0.1'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation group: 'javax.persistence', name: 'javax.persistence-api', version: '2.2'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.jetbrains:annotations:23.0.0'
    testImplementation 'junit:junit:4.13.1'

    runtimeOnly 'mysql:mysql-connector-java'
    implementation project(path: ':common')

}

test {
    useJUnitPlatform()
}

docker{
    println(tasks.bootJar.outputs.files)

    // 이미지 이름
    name rootProject.name+'-'+project.name + ":" + version

    // 어떤 Dockerfile을 사용 할 거냐
    dockerfile file('../Dockerfile')

    // 어떤 파일들을 Dockerfile 에 복사하여 image에 넣을 것이냐
    files tasks.bootJar.outputs.files

    // Dockerfile 에 전달할 인자
    buildArgs(['JAR_FILE': tasks.bootJar.outputs.files.singleFile.name])
}

Docker File

FROM openjdk:11-slim-stretch
EXPOSE 8080
ARG JAR_FILE
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

docker 실행

  • dockerfile 경로에서 아래 명령어를 실행합니다.

./gradlew docker


결과

Docker Compose(도커 컴포즈)

  • Docker Compose는 여러 개의 컨테이너(Container)로 구성된 애플리케이션을 관리하기 위한 간단한 오케스트레이션(Orchestration) 도구 입니다.
  • 또한 여러 개의 컨테이너를 yaml 파일의 코드를 통해 통합 관리하기 위한 툴입니다.

yaml

  • 구조화 데이터나 오브젝트를 문자열로 변환하기 위한 데이터 형식의 한 종류입니다.
  • Space를 사용한 들여쓰기를 통해 데이터 계층을 표시합니다.

Docker Compose 파일의 구조

version: ()
services: (docker container)
volumes: (docker volume)
networks: (docker network)
  • version: 지원 버전
  • services: 컨테이너 설정
  • volumes: 도커 볼륨에 대한 설정
  • networks: 도커 네트워크에 대한 설정

예시

version: '3'
services:
  mysql:
    image: mysql:8.0
    networks:
      - pay_network
    volumes:
      - ./db/conf.d:/etc/mysql/conf.d
      - ./db/data:/var/lib/mysql
      - ./db/initdb.d:/docker-entrypoint-initdb.d
    env_file: .env
    ports:
      - "3307:3306"
    environment:
      - TZ=Asia/Seoul
      - MYSQL_ROOT_PASSWORD=root@@
      - MYSQL_USER=shbae
      - MYSQL_PASSWORD=shbae@@

  membership-service:
    image: msapay-membership-service:1.0.0
    networks:
      - pay_network
    ports:
      - "8081:8080" # 외부에서 8081 port를 통해서 내부의 8080 port로 통할거야. 8080은 실제 어플리케이션 port
    depends_on:
      - mysql
    environment:
      - AXON_AXONSERVER_SERVERS=axon-server:8124
      - AXON_SERIALIZER_EVENTS=jackson
      - AXON_SERIALIZER_MESSAGES=jackson
      - AXON_SERIALIZER_GENERAL=xstream
      - SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/msa_pay?useSSL=false&allowPublicKeyRetrieval=true
      - SPRING_DATASOURCE_USERNAME=shbae
      - SPRING_DATASOURCE_PASSWORD=shbae@@
      - SPRING_JPA_PROPERTIES_HIBERNATE_DIALECT=org.hibernate.dialect.MySQL5InnoDBDialect
      - SPRING_JPA_HIBERNATE_DDL_AUTO=update

  banking-service:
    image: msapay-banking-service:1.0.0
    networks:
      - pay_network
    ports:
      - "8082:8080" # 외부에서 8081 port를 통해서 내부의 8080 port로 통할거야. 8080은 실제 어플리케이션 port
    depends_on:
      - mysql
    environment:
      - AXON_AXONSERVER_SERVERS=axon-server:8124
      - AXON_SERIALIZER_EVENTS=jackson
      - AXON_SERIALIZER_MESSAGES=jackson
      - AXON_SERIALIZER_GENERAL=xstream
      - SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/msa_pay?useSSL=false&allowPublicKeyRetrieval=true
      - SPRING_DATASOURCE_USERNAME=shbae
      - SPRING_DATASOURCE_PASSWORD=shbae@@
      - SPRING_JPA_PROPERTIES_HIBERNATE_DIALECT=org.hibernate.dialect.MySQL5InnoDBDialect
      - SPRING_JPA_HIBERNATE_DDL_AUTO=update

networks:
  pay_network:
    driver: bridge

Docker Compose Commands

  • docker-compose.yaml 파일이 위치한 디렉토리에서 실행

docker-compose up -d # background에서 서비스와 관련된 컨테이너 생성 및 시작

docker-compose down # 서비스와 관련된 컨테이너 종료 후 제거

docker-compose start/stop/restart # 서비스 관련 컨테이너 시작 / 종료 / 재시작

docker-compose kill # 실행 중인 컨테이너에 SIGKH 시그널을 통해서 강제 종료

docker-compose pause / unpause # 컨테이너 정지 / 재가동

반응형

'개발 > Docker' 카테고리의 다른 글

Docker vs 가상머신(Docker와 가상머신의 차이)  (0) 2023.09.27

응집도(Cohesion)란?

  • 응집도는 하나의 모듈이나 클래스 내부의 기능들이 얼마나 밀접하게 연관되어 있는지를 나타냅니다.
  • 즉, 하나의 모듈이 단일 기능이나 목적을 가지고 있는 정도를 말합니다.
  • 응집도가 높다는 것은 모듈 내의 모든 요소가 서로 긴밀하게 연결되어 있다는 것을 의미하며, 유지보수와 이해하기 쉬운 코드를 만드는데 중요한 역할을 합니다.

응집도 예시

  1. 높은 응집도
  • '계산기' 클래스에는 수학적 계산과 관련된 메소드들만 포함되어 있습니다.
  • '더하기', '빼기', '곱하기', '나누기' 등의 기능이 계산기 클래스에 포함됩니다.
  • '더하기', '빼기', '곱하기', '나누기' 각 메소드들은 계산기의 기능에 밀접하게 연관되어 있는 기능들로 응집도가 높습니다.
  1. 낮은 응집도
  • '도구상자' 클래스에는 파일을 읽는 메소드, 그래픽을 그리는 메소드, 데이터베이스 쿼리를 실행하는 메소드 등 서로 다른 기능들이 있습니다.
  • 이런식으로 하나의 기능을 특정 짓는 것이 아닌 관련없는 여러 기능들을 하나의 클래스에 선언할 때 응집도가 낮다라고 합니다.

결합도(Coupling)란?

  • 결합도는 서로 다른 모듈 간의 '의존성' 정도를 나타냅니다.
  • 모듈 간의 결합도가 낮다는 것은 각 모듈이 서로 독립적으로 기능한다는 것을 의미하며, 유지보수성과 확장성에 긍정적인 영향을 미칩니다.

결합도 예시

  1. 낮은 결합도
  • '사용자 인터페이스' 모듈과 '데이터 처리' 모듈이 서로 독립적으로 작동합니다.
  • 이는 하나의 모듈을 수정하더라도 다른 모듈에 영향을 주지 않는다는 것을 의미합니다.
  1. 높은 결합도
  • '주문 처리' 모듈이 '재고 관리' 모듈에 강하게 의존하고 있습니다.
  • 이 경우, '재고 관리' 모듈에 변화가 생기면 '주문 처리' 모듈도 이에 맞춰 변경해야 합니다.

응집도와 결합도의 균형

  • 효과적인 소프트웨어 설계를 위해서는 응집도를 높이고 결합도를 낮추는 것이 중요합니다.
  • 높은 응집도는 모듈이 명확하고 관리하기 쉽게 만들어 주며, 낮은 결합도는 시스템의 다른 부분에 영향을 주지 않고 독립적으로 모듈을 수정하거나 교체할 수 있게 해줍니다.
반응형

'개발 > Spring' 카테고리의 다른 글

AOP란? AOP 내용, AOP 예제  (0) 2024.01.11
[Querydsl] DTO 조회 방법  (0) 2023.12.17
OAuth2란? OAuth2 예시  (1) 2023.12.15
DTO <-> Entity 변환 이유!  (0) 2023.12.14

Github Label 이란?

  • Github label은 issue 와 pull request에서 카테고리를 분류하기 위한 이름표 라고 생각하시면 됩니다.

  • 처음 git repository를 생성하게 되면 label은 아래와 같이 기본적으로 9개의 label 들을 제공합니다.
  1. 상단 탭의 issues 클릭
  2. labels 버튼 클릭

Github Label 사용

  • Issues 작업을 진행할 때 협업하는 사람끼리 각 Issues 에 대해 어떤 작업인지 명확하게 표시하기 위해 사용합니다.
  • Issues 작업에서 Label을 기반으로 필터링 하여 검색할 수 있습니다.

  • Pull requests에서 Label을 기반으로 필터링 하여 검색할 수 있습니다.

Github Label 한번에 적용하는 법

환경

  • windows 10

1. lables.json 파일 준비하기

  • name: 라벨 이름, 이모지 포함 가능
  • coler: Hex 값으로 색상 지정 가능
  • description: 설명, 라벨 설명 지정 가능
[
  {
    "name": "⚙ Setting",
    "color": "e3dede",
    "description": "개발 환경 세팅"
  },
  {
    "name": "✨ Feature",
    "color": "a2eeef",
    "description": "기능 개발"
  },
  {
    "name": "🌏 Deploy",
    "color": "C2E0C6",
    "description": "배포 관련"
  },
  {
    "name": "🎨 Html&css",
    "color": "FEF2C0",
    "description": "마크업 & 스타일링"
  },
  {
    "name": "🐞 BugFix",
    "color": "d73a4a",
    "description": "Something isn't working"
  },
  {
    "name": "💻 CrossBrowsing",
    "color": "C5DEF5",
    "description": "브라우저 호환성"
  },
  {
    "name": "📃 Docs",
    "color": "1D76DB",
    "description": "문서 작성 및 수정 (README.md 등)"
  },
  {
    "name": "📬 API",
    "color": "D4C5F9",
    "description": "서버 API 통신"
  },
  {
    "name": "🔨 Refactor",
    "color": "f29a4e",
    "description": "코드 리팩토링"
  },
  {
    "name": "🙋‍♂️ Question",
    "color": "9ED447",
    "description": "Further information is requested"
  },
  {
    "name": "🥰 Accessibility",
    "color": "facfcf",
    "description": "웹접근성 관련"
  },
  {
    "name": "✅ Test",
    "color": "ccffc4",
    "description": "test 관련(storybook, jest...)"
  }
]

2. npm 설치

npm 설치 사이트로 이동하기 를 클릭하여 download 페이지로 이동해 주세요.

위 그림에서 자신의 환경에 맞는 npm을 설치하시면 됩니다.

3. Github access token 준비하기

해당 링크를 클릭하여 Github access token 발급하기 로 접속하여 access tokens 를 발급 받습니다.

  • Note: token 이름입니다. 각자의 목적에 맞게 작성해주시면 됩니다.
  • repo 카테고리를 체크 해주세요!

4. github-label-sync 설치

npm install -g github-label-sync

5. repository 적용

github-label-sync --access-token [액세스 토큰] --labels labels.json [계정명]/[저장소 이름]

예를 들어 액세스 토큰: abcd, 계정명: test, 저장소 이름: testRepo 라면

github-label-sync --access-token abcd --labels labels.json test/testRepo

Github에 label 한번에 적용하기 결과

  • label.json 에 입력한 label 들이 모두 적용 되었다.
  • 기존 label 들은 삭제 되니 주의!

반응형

Projection 연산이란?

한 Relation의 Attribute들의 부분 집합을 구성하는 연산자입니다.
결과로 생성되는 Relation은 스키마에 명시된 Attribute들만 가집니다.
결과 Relation은 기본 키가 아닌 Attribute에 대해서만 중복된 tuple들이 존재할 수 있습니다.
-> 테이블에서 원하는 컬럼만 뽑아서 조회하는 것

  • Relation 데이터를 원자 값으로 갖는 이차원 테이블
  • Column = Attribute

Querydsl DTO로 조회하는 4가지 방법

  1. Projection.bean
  2. Projection.fields
  3. Projection.constructor
  4. @QueryProjection

사전 작업

  • spring boot3.0

  • build.gradle

    buildscript {
      ext {
          queryDslVersion = "5.0.0"
      }
    }
    

plugins {
id 'java'
id 'org.springframework.boot' version '3.0.0'
id 'io.spring.dependency-management' version '1.1.0'

// querydsl관련 명령어를 gradle탭에 생성해준다. (권장사항)
id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"

}

group = 'com.demo'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
compileOnly {
extendsFrom annotationProcessor
}
}

repositories {
mavenCentral()
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'

// querydsl 디펜던시 추가
// == 스프링 부트 3.0 이상 ==
implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta"
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

}

tasks.named('test') {
useJUnitPlatform()
}

/*

  • queryDSL 설정 추가
  • /
    // querydsl에서 사용할 경로 설정
    def querydslDir = "$buildDir/generated/querydsl"
    // JPA 사용 여부와 사용할 경로를 설정
    querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
    }
    // build 시 사용할 sourceSet 추가
    sourceSets {
    main.java.srcDir querydslDir
    }
    // querydsl 컴파일시 사용할 옵션 설정
    compileQuerydsl{
    options.annotationProcessorPath = configurations.querydsl
    }
    // querydsl 이 compileClassPath 를 상속하도록 설정
    configurations {
    compileOnly {
      extendsFrom annotationProcessor
    }
    querydsl.extendsFrom compileClasspath
    }

// gradle clean 시에 QClass 디렉토리 삭제
clean{
delete file(querydslDir)
}



- Entity

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class Users {
@Id
private Long userNo;
private String userId;
private String userName;
}

@Getter @Setter
@NoArgsConstructor
@RequiredArgsConstructor
public class Board {
@Id
private Long id;
private String title;
private String content;
}


- DTO

@Getter @Setter
@NoArgsConstructor
public class UsersDto {
private Long userNo;

private String userId;

private String userName;

@QueryProjection
public UsersDto(Long userNo, String userId, String userName) {
    this.userNo = userNo;
    this.userId = userId;
    this.userName = userName;
}

}


- JpaQueryFactory Config

@Configuration
@RequiredArgsConstructor
public class QuerydslConfiguration {

private final EntityManager em;

@Bean
public JPAQueryFactory jpaQueryFactory(){
    return new JPAQueryFactory(em);
}

}


#### 1. Projections.bean (Setter 메서드를 이용한 조회 방법)

@Repository
@RequiredArgsConstructor
public class UsersRepositoryCustom {

private final JPAQueryFactory queryFactory;

public void findAll(){
    List<UsersDto> users = queryFactory
        .select(Projections.bean(UsersDto.class,
            users.userNo,
            users.userId,
            users.userName
        ))
        .from(users)
        .fetch();
}

}


- Projection.bean 방법은 setter 메서드를 기반으로 동작합니다.
DTO 객체의 각 필드에 setter 메서드가 있어야 합니다.
만약 영속화된 데이터를 변경하는 책임을 가진 객체라면 문제가 될 수 있기 때문에 권장되는 패턴은 아닙니다.

#### 2. Projections.fields (Reflection을 이용한 조회 방법)

@Repository
@RequiredArgsConstructor
public class UsersRepositoryCustom {

private final JPAQueryFactory queryFactory;

public void findAll(){
    List<UsersDto> users = queryFactory
        .select(Projections.fields(UsersDto.class,
            users.userNo,
            users.userId,
            users.userName.as("userName")
        ))
        .from(users)
        .fetch();
}

}


- Porjections.fields를 이용한 방법은 field에 값을 직접 주입해주는 방식입니다.
Projections.bean 방식과 마찬가지로 Type이 다를 경우 매칭되지 않으며, 컴파일 시점에서는 에러를 잡지 못하고 런타임 시점에서 에러가 잡힙니다.

- 만약 Users Entity의 필드가 userName이 아니라면 users.userName.as("userName") 처럼 alias(별칭)을 사용하여 매핑 문제를 해결할 수 있습니다.

#### 3. Projections.constructor (생성자를 이용한 조회 방법)

@Repository
@RequiredArgsConstructor
public class UsersRepositoryCustom {

private final JPAQueryFactory queryFactory;

public void findAll(){
    List<UsersDto> users = queryFactory
        .select(Projections.constructor(UsersDto.class,
            users.userNo,
            users.userId,
            users.userName
        ))
        .from(users)
        .fetch();
}

}


- Projections.constructor는 생산자 기반 바인딩입니다. 생성자 기반 바인딩이기 때문에 객체의 불변성을 가져갈 수 있다는 장점이 있지만 바인딩 과정에서 문제가 생길 수 있습니다.

public static ConstructorExpression constructor(Class type, Expression... exprs){
return new ConstructorExpression(type, exprs);
}


- Projections의 constructor 메서드입니다. 해당 메서드를 보면 DTO 객체의 생성자에게 직접 바인딩하는 것이 아니라 Expression<?>... exprs 값을 넘기는 방식으로 작동됩니다.
따라서 값을 넘길 때 생성자와 필드의 순서를 일치시켜야 합니다.
필드의 개수가 적을 때는 문제가 되지 않지만 필드의 개수가 많아지는 경우 오류로 이어질 수 있습니다.
하지만 생성자를 이용한 방식은 필드명이 달라도 해당 순서에 위치한 필드의 타입만 서로 일치한다면 정상적으로 동작한다는 특징이 있습니다.


#### 4. @QueryProjection

DTO

@Getter @Setter
@NoArgsConstructor
public class UsersDto {
private Long userNo;

private String userId;

private String userName;

@QueryProjection
public UsersDto(Long userNo, String userId, String userName) {
    this.userNo = userNo;
    this.userId = userId;
    this.userName = userName;
}

}

@Repository
@RequiredArgsConstructor
public class UsersRepositoryCustom {

private final JPAQueryFactory queryFactory;

public void findAll(){
    List<UsersDto> dtos = queryFactory
            .select(new QUsersDto(
                    users.userNo,
                    users.userId,
                    users.userName
            ))
            .from(users)
            .fetch();
}

}

```

  • @QueryProjection을 이용하면 불변 객체 선언, 생성자를 그대로 사용할 수 있기 때문에 권장되는 패턴입니다.
    (정확하게는 DTO의 생성자를 사용하는 것이 아니라 DTO 기반으로 생성된 QDTO 객체의 생성자를 사용하는 것입니다.)

  • 작동 방법은 먼저 DTO 생성자에 @QueryProjection 어노테이션을 추가하여 QType의 클래스를 생성하여 위의 예시 코드(UsersDto)와 같이 사용합니다. 이 방식은 new QDTO로 사용하기 때문에 런타임 에러뿐만 아니라 컴파일 시점에서도 에러를 잡아주고, 파라미터로 확인할 수 있다는 장점이 있습니다.
    반면 해당 기능을 사용하려는 DTO마다 QType의 Class를 추가로 생성해줘야 하는 것도 있지만, DTO는 Repository 계층의 조회 용도뿐만 아니라 Service, Controller 모두 사용되기 때문에 @QueryProjection 어노테이션을 DTO에 적용시키는 순간, 모든 계층에서 쓰이는 DTO가 Querydsl에 의존성을 가지기 때문에 아키텍처적으로 적용을 생각해 볼 필요는 있습니다.

반응형

'개발 > Spring' 카테고리의 다른 글

AOP란? AOP 내용, AOP 예제  (0) 2024.01.11
응집도와 결합도란?  (1) 2023.12.26
OAuth2란? OAuth2 예시  (1) 2023.12.15
DTO <-> Entity 변환 이유!  (0) 2023.12.14

QueryDsl 왜 쓰나요?

JPA가 기본적으로 제공해주는 CRUD 메서드 및 쿼리 메서드 기능을 사용하더라도 원하는 조건의 데이터를 수집하기 위해서는 필연적으로 JPQL을 작성하게 됩니다.
간단한 로직은 큰 문제가 없으나, 복잡한 로직의 경우 쿼리 문자열이 상당히 길어집니다.
JPQL 문자열에 오타 혹은 문법적인 오류가 존재하는 경우, 정적 쿼리라면 어플리케이션 로딩 시점에 이를 발견할 수 있으나 그 외는 런타임 시점에서 에러가 발생하게 됩니다.

이러한 문제를 어느정도 해소하는데 기여하는 프레임워크가 바로 QueryDsl 입니다.
QueryDsl은 정적 타입을 이용해서 Query를 생성해주는 프레임워크입니다.


QueryDsl의 장점과 단점은 무엇인가요?


QueryDsl 장점

1) 문자가 아닌 코드로 쿼리를 작성함으로써 컴파일 시점에 문법 오류를 쉽게 확인할 수 있습니다.

2) IDE의 자동완성으로 도움을 받을 수 있습니다.

3) 동적인 쿼리 작성이 편리합니다.

4) 쿼리 작성시 제약 조건 등을 메서드 추출을 통해 간편하고 재사용할 수 있습니다.


QueryDsl 단점

1) 번거로운 Gradle 설정 및 사용법을 배워야 합니다.



QueryDsl 예제 코드

  • Springboot 2.7.10, java 11, gradle 8.4 기반으로 작성한 예제 코드입니다.
build.gradle
plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.14'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'com.oauth2.resource.server'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '11'
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'

    implementation 'com.querydsl:querydsl-jpa'
    implementation 'com.querydsl:querydsl-apt'

    implementation 'org.springframework.boot:spring-boot-starter-data-jpa:2.7.14'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

    // queryDsl 설정
    implementation "com.querydsl:querydsl-jpa"
    implementation "com.querydsl:querydsl-core"
    implementation "com.querydsl:querydsl-collections"
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa" // querydsl JPAAnnotationProcessor 사용 지정
    annotationProcessor "jakarta.annotation:jakarta.annotation-api" // java.lang.NoClassDefFoundError (javax.annotation.Generated) 대응 코드
    annotationProcessor "jakarta.persistence:jakarta.persistence-api" // java.lang.NoClassDefFoundError (javax.annotation.Entity) 대응 코드
}

// Querydsl 설정부
def generated = 'src/main/generated'

// querydsl QClass 파일 생성 위치를 지정
tasks.withType(JavaCompile) {
    options.getGeneratedSourceOutputDirectory().set(file(generated))
}

// java source set 에 querydsl QClass 위치 추가
sourceSets {
    main.java.srcDirs += [ generated ]
}

// gradle clean 시에 QClass 디렉토리 삭제
clean {
    delete file(generated)
}

//tasks.named('test') {
//    useJUnitPlatform()
//}
사용자 Entity
package com.cafe.be.api.common.entity;

import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import javax.persistence.MappedSuperclass;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import java.sql.Timestamp;
import java.time.LocalDateTime;

@MappedSuperclass
public class BaseEntity {

    // 생성일
    @CreationTimestamp
    private LocalDateTime createDt;

    // 수정일
    @UpdateTimestamp
    private LocalDateTime modifyDt;

}

package com.cafe.be.api.userservice.entity;

import com.cafe.be.api.common.entity.BaseEntity;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Users extends BaseEntity {

    // 사용자 번호
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userNo;

    // 이메일
    private String email;

    // 비밀번호
    private String pw;

    // 닉네임
    private String nick;

    // 사용자 상태
    private String status;

    // 권한
    private String role;

    // 생년월일
    private String birth;

    // 이용약관 동의
    private String agree1;

    // 개인정보 수집 및 이용 동의
    private String agree2;

    // 이벤트, 프로모션, 메일, sms 수신
    private String agree3;

}
사용자 Repository
package com.cafe.be.api.userservice.repo;

import com.cafe.be.api.userservice.entity.Users;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UsersRepository extends JpaRepository<Users, Long> {
}
QueryDsl Config
package com.cafe.be.api.common.config;

import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.persistence.EntityManager;

@Configuration
@RequiredArgsConstructor
public class QuerydslConfig{

    private final EntityManager em;

    @Bean
    public JPAQueryFactory jpaQueryFactory(){
        return new JPAQueryFactory(em);
    }
}
사용자 QueryDsl
package com.cafe.be.api.userservice.querydsl;

import com.cafe.be.api.userservice.dto.QUsersResponse;
import com.cafe.be.api.userservice.dto.UsersResponse;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import static com.cafe.be.api.userservice.entity.QUsers.users;

@Repository
@RequiredArgsConstructor
public class UserQueryDsl {

    private final JPAQueryFactory queryFactory;

    public UsersResponse findByUserNo(Long userNo){
        UsersResponse usersResponse = queryFactory
                .select(
                        new QUsersResponse(
                                users.userNo,
                                users.email,
                                users.pw,
                                users.nick,
                                users.status,
                                users.role,
                                users.birth,
                                users.agree1,
                                users.agree2,
                                users.agree3
                        )
                ).from(users)
                .where(
                        users.userNo.eq(userNo)
                ).fetchFirst();

        return usersResponse;
    }

}
사용자 DTO
package com.cafe.be.api.userservice.dto;

import com.cafe.be.api.userservice.entity.Users;
import com.querydsl.core.annotations.QueryProjection;
import lombok.*;

@Getter
@Setter
@NoArgsConstructor
@Builder
public class UsersResponse {

    private Long userNo;

    // 이메일
    private String email;

    // 비밀번호
    private String pw;

    // 닉네임
    private String nick;

    // 사용자 상태
    private String status;

    // 권한
    private String role;

    // 생년월일
    private String birth;

    // 이용약관 동의
    private String agree1;

    // 개인정보 수집 및 이용 동의
    private String agree2;

    // 이벤트, 프로모션, 메일, sms 수신
    private String agree3;

    @QueryProjection
    public UsersResponse(Long userNo, String email, String pw, String nick, String status, String role, String birth, String agree1, String agree2, String agree3) {
        this.userNo = userNo;
        this.email = email;
        this.pw = pw;
        this.nick = nick;
        this.status = status;
        this.role = role;
        this.birth = birth;
        this.agree1 = agree1;
        this.agree2 = agree2;
        this.agree3 = agree3;
    }


    public static UsersResponse UsersToUsersResponse(Users u){
        UsersResponse usersResponse = UsersResponse.builder()
                .userNo(u.getUserNo())
                .email(u.getEmail())
                .pw(u.getPw())
                .nick(u.getNick())
                .status(u.getStatus())
                .role(u.getRole())
                .birth(u.getBirth())
                .agree1(u.getAgree1())
                .agree2(u.getAgree2())
                .agree3(u.getAgree3())
                .build();

        return usersResponse;

    }

}
사용자 Controller
package com.cafe.be.api.userservice.controller;

import com.cafe.be.api.common.dto.CommonResponse;
import com.cafe.be.api.userservice.dto.UsersResponse;
import com.cafe.be.api.userservice.entity.Users;
import com.cafe.be.api.userservice.repo.UsersRepository;
import com.cafe.be.api.userservice.service.UsersService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
public class UsersController {

    private final UsersService usersService;

    @GetMapping("/{userNo}")
    public CommonResponse<UsersResponse> detail(@PathVariable("userNo") Long userNo){

        return usersService.detail(userNo);

    }

}
사용자 Service
package com.cafe.be.api.userservice.service;

import com.cafe.be.api.common.dto.CommonResponse;
import com.cafe.be.api.common.enums.ResponseCode;
import com.cafe.be.api.common.exception.ApiException;
import com.cafe.be.api.userservice.dto.UsersResponse;
import com.cafe.be.api.userservice.entity.Users;
import com.cafe.be.api.userservice.querydsl.UserQueryDsl;
import com.cafe.be.api.userservice.repo.UsersRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@Slf4j
@RequiredArgsConstructor
public class UsersService {

    private final UsersRepository usersRepository;

    private final UserQueryDsl userQueryDsl;

    public CommonResponse<UsersResponse> detail(Long userNo) {

        log.debug("[UsersService] [detail] request >>> {}", userNo);

        CommonResponse<UsersResponse> result = new CommonResponse<>();

        try{
//            Users users = usersRepository.findById(userNo)
//                    .orElseThrow(() -> new ApiException(ResponseCode.USER_NOT_FOUND));
//
//            UsersResponse usersResponse = UsersResponse.UsersToUsersResponse(users);


            UsersResponse usersResponse = userQueryDsl.findByUserNo(userNo);

            result.setResult(usersResponse);
            result.setCode(ResponseCode.SUCCESS.getCode());
            result.setMsg(ResponseCode.SUCCESS.getMsg());

        }catch (ApiException e){
            result.setCode(e.getCode());
            result.setMsg(e.getMsg());
        }

        return result;
    }
}
반응형

'개발 > JPA' 카테고리의 다른 글

JPA-OSIV(Open Session In View)  (0) 2023.10.14
[JPA] could not initialize proxy - no Session  (0) 2023.09.10
[JPA] GeneratedValue  (0) 2023.09.10
[JPA] Auditing - 공통 도메인 작업  (0) 2023.09.10
[JPA] @MappedSuperclass  (0) 2023.09.10

OAuth2란?

  • OAuth2는 사용자가 하나의 서비스(ex: 웹사이트, Application)에 로그인할 때 다른 서비스의 자격증명을 사용하여 인증할 수 있게 해주는 인증 프로토콜입니다.

ex)
새로운 웹사이트에 가입하려고 할 때, google, naver 로그인을 보셨을 겁니다.
이 경우, 해당 웹사이트는 사용자의 google 또는 naver 계정을 통해 사용자를 인증합니다.
사용자는 별도의 새로운 계정을 만들 필요 없이 기존의 소셜 미디어 계정을 사용하여 쉽게 로그인 할 수 있습니다.
이 과정에서 OAuth2 프로토콜이 사용되며, 사용자의 로그인 정보는 안전하게 보호됩니다.


OAuth2 구조

OAuth2 의 플로우는 위 아래 그림과 같습니다.

기본적으로는

  1. Client -> OAuth Authentication Server 인증코드 받기
  2. OAuth Authentication Server 에서 받은 인증코드를 기반으로 Client에서 OAuth Authentication Server로 token 발급
  3. OAuth Authentication Server 에서 받은 token 기반으로 OAuth Resource Server에서 Resource 할당


OAuth2 말하는 역할 및 주요 용어는 어떤게 있나요?

1) 역할

이름 설명
Resource Owner 리소스(자원) 소유자입니다.
본인의 정보에 접근할 수 있는 자격을 승인하는 주체입니다.
예를 들어보겠습니다.
서비스를 이용하기 위해 해당 서비스에서 제공하는 Google 간편 로그인으로 로그인을 한 경우,
해당 Google 아이디를 가지고 있는 본인이 Resource Owner가 되는 겁니다.
Client Resource Owner의 리소스(자원)를 사용하고자 접근 요청을 하는 서비스(어플리케이션) 입니다.
Resource Server Resource Owner의 정보가 저장되어 있는 서버입니다.
위 예시에서 Google 로그인을 하는 본인이 Resource Owner라고 했었습니다.
이 때, Google이 Resource Server가 되는 겁니다.
Autehorization Server Resource Server에서 리소스를 가져오기 위한 token(권한)을 할당해주는 권한 서버입니다.
인증 / 인가를 수행하는 서버로써 Client의 접근 자격을 확인하고 Access Token을 발급하여 권한을 부여하는 역할을 수행합니다.

2) 주요 용어

이름 설명
Authentication(인증) 인증, 접근 자격이 있는지 검증하는 단계입니다.
Authorization(인가) 자원에 접근할 권한을 부여하고 리소스 접근 권한이 담긴 Access Token을 제공합니다.
Access Token Resource Server 에서 Resource Owner 의 리소스(자원)를 획득 할 때 사용되는 만료기간이 있는 Token 입니다.
Refresh Token Access Token 만료시 이를 재발급 받기위한 용도로 사용하는 Token 입니다.



토큰(Token) 발급 방식

  • 아래에서 사용하는 URL은 기본 oauth2.0 코드 발급 URL 입니다.
  1. code (code를 발급 받고 code로 access token 발급)

1) Client -> Authentication Server 로 code를 발행 합니다.

2) 1번에서 발급받은 code를 기반으로 Client -> Authentication Server로 access token을 발급 받습니다.

  • 발급 받은 code를 가지고 token을 발행 합니다.
  • URL: http://localhost:8080/oauth2/token
  • Parameter: grant_type=code, authorization_code=발급받은 코드, redirect_uri=코드 전달받은 URL
  1. password(id, pw로 access token 발급)
  • id, pw로 token을 바로 발급 받을 수 있습니다.
  • URL: http://localhost:8080/oauth2/token
  • Parameter: grant_type=password, username=아이디, password=비밀번호, scope=read
  1. client_credentials(바로 access token 발행)
  • header의 authorization에 clientId, secret key만 등록된 정보를 기반으로 access token을 발급해 주며, 별다른 인증이 필요 없는 신뢰도가 높은 Client에게만 해당 방식을 허용하도록 합니다.
  • 해당 방식은 별다른 인증을 요하지 않기에 refresh_token은 따로 발행해주지 않습니다.
  • URL: http://localhost:8080/oauth2/token
  • Parameter: grant_type=client_credentials, scope=read
  1. refresh_token
  • refresh token으로 access token 발행합니다.
  • 1, 2번 방식으로 access_token 발행과 동시에 refresh_token 도 같이 발행해줍니다.
  • URL: http://localhost:8080/oauth2/token
  • Parameter: grant_type=refresh_token, refresh_token=이전에 발급받은 refresh token, scope=read



OAuth2 예시 코드

  • Springboot 2.7.10, java 11, gradle 8.4 환경에서 구현하였습니다.
  • 저는 OAuth2의 Resource Server와 Authentication Server를 합쳐서 구현하였습니다.
  • 단순 FrontEnd와 BackEnd 간의 통신에서 사용하기 위하여 구현하였기에 client_credentials 기반으로 구현하였습니다.



build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.14'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'com.oauth2.resource.server'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '11'
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.security:spring-security-oauth2-resource-server:5.7.11'
    implementation 'org.springframework.security:spring-security-oauth2-jose:5.7.11'

    implementation 'org.springframework.security:spring-security-oauth2-authorization-server:0.3.1'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa:2.7.14'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

}

security 설정

package com.cafe.be.security.config;

import com.cafe.be.security.handler.CustomAccessDeniedHandler;
import com.cafe.be.security.handler.CustomAuthenticationEntryPoint;
import com.cafe.be.oauth.handler.JwtAuthenticationEntryPoint;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@EnableWebSecurity
@RequiredArgsConstructor
public class ResourceServerConfig{

    private final CustomAccessDeniedHandler customAccessDeniedHandler;

    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
        http
            .headers().frameOptions().disable()
            .and()
                .csrf().ignoringAntMatchers("/h2-console/**").ignoringAntMatchers("/oauth2/token")
            .and()
                .authorizeRequests()
                    .mvcMatchers("/h2-console/**").permitAll()
                    .mvcMatchers("/oauth2/token").permitAll()
                .anyRequest().authenticated()
            .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
            .and()
                .exceptionHandling()
                .authenticationEntryPoint(customAuthenticationEntryPoint)
                .accessDeniedHandler(customAccessDeniedHandler)
            .and()
                .oauth2ResourceServer()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .jwt();

        return http.build();
    }

}

OAuth2 설정

package com.cafe.be.oauth.config;

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.ProviderSettings;
import org.springframework.security.oauth2.server.authorization.config.TokenSettings;
import org.springframework.security.web.SecurityFilterChain;

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.util.UUID;

@Configuration
@Import(OAuth2AuthorizationServerConfiguration.class) // OAuth2 인증 서버에 필요한 기본 설정을 가져옵니다.
public class AuthorizationServerConfig {

    @Value("${oauth.issuer.url}")
    private String issuerUrl;

    @Bean
    public TokenSettings tokenSettings() {
        return TokenSettings.builder()
                .accessTokenTimeToLive(Duration.ofMinutes(10)) // 액세스 토큰 유효 기간을 10분으로 설정
                .build();
    }

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception{
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        return http.build();
    }

    @Bean
    public JWKSource<SecurityContext> jwkSource() throws NoSuchAlgorithmException {
        RSAKey rsaKey = generateRsa();
        JWKSet jwkSet = new JWKSet(rsaKey);

        return ((jwkSelector, context) -> jwkSelector.select(jwkSet));
    }

    private RSAKey generateRsa() throws NoSuchAlgorithmException {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        return new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();
    }

    private static KeyPair generateRsaKey() throws NoSuchAlgorithmException {
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
        keyPairGenerator.initialize(2048);
        return keyPairGenerator.generateKeyPair();
    }

    @Bean
    public ProviderSettings providerSettings(){
        return ProviderSettings.builder()
                .issuer(issuerUrl)
                .build();
    }

}

OAuth2 관련 Entity

package com.cafe.be.oauth.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;

@Entity
@Table(name = "oauth_client")
@Getter
@AllArgsConstructor
@Builder
public class OAuthClient {

    @Id
    private String clientId;

    private String clientSecret;

    private String scopes;

    private String grantType;

    protected OAuthClient() {}

}

OAuth2 관련 Enums

package com.cafe.be.oauth.enums;

public enum OAuthScopes {

    READ("cafe.read");

    private String scopeName;

    OAuthScopes(String scopeName) {
        this.scopeName = scopeName;
    }
}

OAuth2 관련 Repository

package com.cafe.be.oauth.repo;

import com.cafe.be.oauth.entity.OAuthClient;
import lombok.RequiredArgsConstructor;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.TokenSettings;
import org.springframework.stereotype.Repository;

import java.time.Duration;
import java.util.UUID;

@Repository
@RequiredArgsConstructor
public class CustomRegisteredClientRepository implements RegisteredClientRepository {

    private final OAuthClientRepository oauthClientRepository;

    @Override
    public void save(RegisteredClient registeredClient) {
        // 클라이언트 정보를 데이터베이스에 저장하는 로직을 구현합니다.
        OAuthClient oauthClient = OAuthClient.builder()
                .clientId(registeredClient.getClientId())
                .clientSecret(registeredClient.getClientSecret())
                .scopes(String.valueOf(registeredClient.getScopes()))
                .grantType(registeredClient.getAuthorizationGrantTypes().toString())
                .build();

        oauthClientRepository.save(oauthClient);
    }

    @Override
    public RegisteredClient findById(String id) {
        return oauthClientRepository.findById(id)
                .map(this::toRegisteredClient)
                .orElse(null);
    }

    @Override
    public RegisteredClient findByClientId(String clientId) {
        return oauthClientRepository.findByClientId(clientId)
                .map(this::toRegisteredClient)
                .orElse(null);
    }

    private RegisteredClient toRegisteredClient(OAuthClient oauthClient) {
        // OAuthClient 엔티티를 RegisteredClient 객체로 변환하는 로직
//        String grantType = oauthClient.getGrantType();
//
//        Set<AuthorizationGrantType> grantTypeSet = new HashSet<AuthorizationGrantType>();
//        if(grantType != null && grantType.equals("")){
//            String[] grantTypeSplit = grantType.split(",");
//
//            for (String type : grantTypeSplit) {
//                grantTypeSet.add(new AuthorizationGrantType(type));
//            }
//        }

        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId(oauthClient.getClientId())
                .clientSecret(oauthClient.getClientSecret())
                .scope(oauthClient.getScopes())
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(new AuthorizationGrantType(oauthClient.getGrantType()))
                .tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofHours(1)).build())
//                .authorizationGrantTypes((Consumer<Set<AuthorizationGrantType>>) grantTypeSet)
                .build();

        return registeredClient;
    }
}

package com.cafe.be.oauth.repo;


import com.cafe.be.oauth.entity.OAuthClient;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface OAuthClientRepository extends JpaRepository<OAuthClient, String> {

    Optional<OAuthClient> findByClientId(String clientId);

}
반응형

'개발 > Spring' 카테고리의 다른 글

AOP란? AOP 내용, AOP 예제  (0) 2024.01.11
응집도와 결합도란?  (1) 2023.12.26
[Querydsl] DTO 조회 방법  (0) 2023.12.17
DTO <-> Entity 변환 이유!  (0) 2023.12.14

프로젝트에서 코드를 구현할 때 Entity를 그대로 사용하지 말고 DTO를 사용하라는 말을 들어본적이 있을겁니다.

그런데 왜?? 라고 생각해보신적이 있으신가요?

코드를 구현함에 있어 "그냥 대중적으로 사용하니까 그렇게 사용하는거지" or "일단 복붙"이라고 생각하시면 단순 코딩하는 로봇과 다를빠 없지 않을까요?

하물며.. AI가 판을 치는 세상에 단순 로봇이라니...


오늘 간단하게 DTO와 Entity가 무엇인지, 왜 DTO <-> Entity를 분리해서 쓰고 변환하는지에 대해 내용을 공유하고자 합니다.


DTO? Entity?

1. DTO(Data Transfer Object)란?

  • 클라이언트와 서버 간 데이터 전송을 목적으로 설계된 객체

2. Entity란?

  • 데이터베이스에 저장되는 데이터 객체로, 데이터베이스와 직접적으로 연결되는 객체

왜 분리해서 쓸까???

1. View와 Model 분리

  • DTO는 View와 Controller 간의 인터페이스 역할을 하며, Entity는 Model의 역할을 합니다.

2. 불필요한 데이터 노출 방지

  • Entity를 이용하여 Controller <-> View 구간에서 데이터를 주고 받는다면 해당 Entity(DB) 구조가 노출되기 때문에 필요한 정보값만 DTO로 정보를 노출시키도록 하는 것이 좋습니다.

3. 순환 참조 예방

  • DTO는 엔티티 간의 양방향 참조가 포함되지 않은 간단한 구조를 가지며, 필요한 정보들만 노출될 수 있습니다.
  • Controller <-> View 구간에서 Entity를 사용하여 정보를 전달하는 경우, @ManyToOne, @OneToMany 등의 순환참조들이 연결되어 있는 정보들에서 N+1 등 원하지 않는 문제가 발생할 수 있습니다.

4. Validation 코드와 모델링 코드 분리

  • validation 코드 : @NotNull, @NotEmpty, @NotBlank ...

  • 모델링 코드 : @Column, @JoinColumn, @ManyToOne, @OneToOne, @CasCade ...

  • Entity는 DB의 테이블과 매칭되는 모델링 코드만 구현해야 가독성을 높일 수 있습니다.

  • 각 필요한 데이터만 전달하고 각 데이터에 맞는 Validation 을 추가하면 Entity 클래스의 모델링에 집중할 수 있습니다.

DTO, Entity 사용 방법

구조

  • DTO는 값의 전달의 역할로써 Controller <-> View 구간, Controller <-> Service 구간에서 주로 이루어지며 Entity는 Service <-> Repository 구간에서 사용합니다.
  • Controller <-> Service, Controller <-> View 구간에서 DTO를 사용하는 이유는 해당 구간에서는 단순 값을 전달만 하면 되지만 Service <-> Repository 구간에서 Entity를 사용하는 이유는 Repository에서 DTO를 Entity로 변환하는 작업이 발생하는 경우, 기존 Repository의 역할에서 벗어나는 행위이기 때문에 Service에서 DTO를 Service로 변환 후 사용합니다.
반응형

'개발 > Spring' 카테고리의 다른 글

AOP란? AOP 내용, AOP 예제  (0) 2024.01.11
응집도와 결합도란?  (1) 2023.12.26
[Querydsl] DTO 조회 방법  (0) 2023.12.17
OAuth2란? OAuth2 예시  (1) 2023.12.15

OSIV(Open Session In View)

  • OSIV(Open Session In View)는 영속성 컨텍스트를 뷰까지 열어두는 기능이다.
    영속성 컨텍스트가 유지되면 엔티티도 영속 상태로 유지된다.
    뷰까지 영속성 컨텍스트가 살아있다면 뷰에서도 지연 로딩을 사용할 수 가 있다.

  • JPA에서는 OEIV(Open EntityManager In View), 하이버네이트에선 OSIV(Open Session In View)라고 한다.

OSIV 동작 원리

  • Spring Framework가 제공하는 OSIV는 비즈니스 계층에서 트랜잭션을 사용하는 OSIV다.
    영속성 컨텍스트는 사용자의 요청 시점에서 생성이 되지만, 데이터를 쓰거나 수정할 수 있는 트랜잭션은 비즈니스 계층에서만 사용할 수 있도록 트랜잭션이 일어난다.

업로드중..

  • spring.jpa.open-in-view: true 기본값

  • Spring Boot JPA 의존성을 주입 받아 어플리케이션을 구성할 경우 spring.jpa.open-in-view의 기본값인 true로 지정되어 있어 OSIV가 적용된 상태로 어플리케이션이 구성된다.

동작원리

  • 클라이언트의 요청이 들어오면 서블릿 필터나, 스프링 인터셉터에서 영속성 컨텍스트를 생성한다. 단 이 시점에서 트랜잭션은 시작하지 않는다.
  • 서비스 계층에서 @Transactional로 트랜잭션을 시작할 때 1번에서 미리 생성해둔 영속성 컨텍스트를 찾아와서 트랜잭션을 시작한다.
  • 서비스 계층이 끝나면 트랜잭션을 커밋하고 영속성 컨텍스트를 플러시한다. 이 시점에 트랜잭션은 끝내지만 영속성 컨텍스트는 종료되지 않는다.
  • 컨트롤러와 뷰까지 영속성 컨텍스트가 유지되므로 조회한 엔티티는 영속 상태를 유지한다.
  • 서블릿 필터나, 스프링 인터셉터로 요청이 돌아오면 영속성 컨텍스트를 종료한다. 이때 플러시를 호출하지 않고 바로 종료한다.

서블릿 계층에서 트랜잭션이 끝나면 컨트롤러와 뷰에는 트랜잭션이 유지되지 않는 상태이다.
엔티티를 변경하지 않고 단순히 조회만 할 때는 트랜잭션이 없어도 동작하는데, 이것을 트랜잭션 없이 읽기(Nontransactional reads)라 한다. 하여 만약 프록시를 뷰 렌더링하는 과정에 초기화(Lazy loading)가 일어나게 되어도 조회 기능이므로 트랜잭션이 없이 읽기가 가능하다.

  • 영속성 컨텍스트는 기본적으로 트랜잭션 범위 안에서 엔티티를 조회하고 수정할 수 있다.
  • 영속성 컨텍스트는 트랜잭션 범위 밖에서 엔티티를 조회만 할 수 있다. 이것을 트랜잭션 없이 읽기(Nontransactional reads)라 한다.

만약 트랜잭션 범위 밖인 컨트롤러와 뷰에서 엔티티를 수정하여도 영속성 컨텍스트의 변경 감지에 의한 데이터 수정이 다음 2가지 이유로 동작하지 않는다.

  • 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영하려면 영속성 컨텍스트를 플러시(flush)해야 한다. 스프링이 제공하는 OSIV는 요청이 끝나면 플러시를 호출하지 않고 em.close()로 영속성 컨텍스트만 종료시켜 버린다.
  • 프레젠테이션 계층에서 em.flush()를 호출하여 강제로 플러시해도 트랜잭션 범위 밖이므로 데이터를 수정할 수 없다는 예외가 일어난다.(javax.persistence.TransactionRequiredException)

OSIV 사용시 주의점

  • OSIV 전략은 트랜잭션 시작처럼 최초 데이터베이스 커넥션 시작 시점부터 API 응답이 끝날 때 까지 영속성 컨텍스트와 데이터베이스 커넥션을 유지한다.
    그래서 View Template이나 API 컨트롤러에서 지연 로딩이 가능하다.

  • 지연 로딩은 영속성 컨텍스트가 살아있어야 가능하고 영속성 컨텍스트는 기본적으로 데이터베이스 커넥션을 유지한다.

  • OSIV의 치명적인 단점: 커넥션을 영속성 컨텍스트가 종료될 때까지 1:1로 계속 물고 있다.

OSIV false

업로드중..

  • spring.jpa.open-in-view: false (OSIV 종료)
  • OSIV를 끄면 트랜잭션을 종료할 때 영속성 컨텍스트를 닫고, 데이터베이스 커넥션도 반환한다. 따라서 커넥션 리소스를 낭비하지 않는다.
  • OSIV를 끄면 모든 지연로딩을 트랜잭션 안에서 처리해야 한다. 따라서 지금까지 작성한 많은 지연 로딩 코드를 트랜잭션안으로 넣어야 하는 단점이 있다. 그리고 view template에서 지연로딩이 동작하지 않는다.
    결론적으로 트랜잭션이 끝나기 전에 지연 로딩을 강제로 호출해 두어야 한다.

커맨드와 쿼리 분리

실무에서 OSIV를 끈 상태로 복잡성을 관리하는 좋은 방법이 있다. 바로 Command와 Query를 분리하는 것이다.

보통 비즈니스 로직은 특정 엔티티 몇 개를 등록하거나 수정하는 것이므로 성능이 크게 문제가 되지 않는다. 그런데 복잡한 화면을 출력하기 위한 쿼리는 화면에 맞추어 성능을 최적화 하는 것이 중요하다. 하지만 그 복잡성에 비해 핵심 비즈니스에 큰 영향을 주는 것은 아니다.
그래서 크고 복잡한 애플리케이션을 개발한다면, 이 둘의 관심사를 명확하게 분리하는 선택은 유지보수 관점에서 충분히 의미가 있다.
단순하게 설명해서 다음처럼 분리하는 것이다.

OrderService

  • OrderService: 핷심 비즈니스 로직

  • OrderQueryService: 화면이나 API에 맞춘 서비스(주로 읽기 전용 트랜잭션 사용)

  • 보통 서비스 계층에서 트랜잭션을 유지한다. 두 서비스 모두 트랜잭션을 유지하면서 지연 로딩을 사용할 수 있다.

OSIV 정리

특징

  • OSIV는 클라이언트 요청이 들어올 때 영속성 컨텍스트를 생성해서 요청이 끝날 때까지 같은 영속성 컨텍스트를 유지한다. 하여 한 번 조회된 엔티티는 요청이 끝날 때까지 영속 상태를 유지한다.
  • 엔티티 수정은 트랜잭션이 있는 계층에서만 동작한다. 트랜잭션이 없는 프레젠테이션 계층은 지연 로딩을 포함해 조회만 할 수 있다.

단점

  • 영속성 컨텍스트와 DB 커넥션은 1:1로 물고있는 관계이기 때문에 프레젠테이션 로직까지 DB 커넥션 자원을 낭비하게 된다.
  • OSIV를 적용하면 같은 영속성 컨텍스트를 여러 트랜잭션이 공유하게 될 수도 있다.
  • 프레젠테이션에서 엔티티를 수정하고 비즈니스 로직을 수행하면 엔티티가 수정될 수 있다.
  • 프레젠테이션 계층에서 렌더링 과정에서 지연 로딩에 의해 SQL이 실행된다. 따라서 성능 튜닝시에 확인해야 할 부분이 넓어진다.
반응형

'개발 > JPA' 카테고리의 다른 글

QueryDsl이란? 왜 사용하는가?  (0) 2023.12.15
[JPA] could not initialize proxy - no Session  (0) 2023.09.10
[JPA] GeneratedValue  (0) 2023.09.10
[JPA] Auditing - 공통 도메인 작업  (0) 2023.09.10
[JPA] @MappedSuperclass  (0) 2023.09.10

정규화란?

데이터베이스에서의 정규화는 중복 데이터를 최소화하고 무결성을 최대화하기 위한 설계 기법입니다.
정규화를 통해 효과적으로 설계된 데이터베이스는 업데이트 시 발생할 수 있는 문제를 줄이며, 데이터를 효율적으로 저장하게 됩니다.

무결성이란?

무결성(integrity)은 데이터베이스 관리 시스템(DBMS)에서 데이터의 정확성, 일관성 및 신뢰성을 유지하기 위한 속성을 의미합니다.
데이터베이스의 무결성을 유지하는 것은 중요한 설계 원칙 중 하나로, 데이터의 품질을 보장하며 애플리케이션의 신뢰성을 향상시킵니다.

  1. 도메인 무결성(Domain Integrity)
  • 각 필드에 저장될 수 있는 값의 유형, 형식, 범위 및 가능한 값을 정의하며, 이에 따라 데이터가 저장되는지 확인합니다.
  • 예를 들어, 나이 필드에 문자열이 들어가지 않도록하거나 날짜 형식이 'YYYY-MM-DD' 형식을 따르도록 하는 것입니다.
  1. 개체 무결성(Entity Integrity)
  • 각 테이블의 기본 키(primary key)는 고유해야 하며, NULL 값을 허용하지 않아야 합니다.
  • 이를 통해 테이블의 각 레코드를 고유하게 식별할 수 있습니다.
  1. 참조 무결성(Referential Integrity)
  • 외래 키(foreign key)는 다른 테이블의 기본 키를 참조합니다.
  • 참조 무결성은 외래 키 값이 참조하는 테이블의 기본 키 값 중 하나와 일치하거나 NULL 이어야 함을 보장합니다.
  • 즉, 잘못된 참조나 존재하지 않는 데이터를 방지하는 데 중요합니다.
  1. 사용자 정의 무결성(User-defined Integrity)
  • 데이터베이스 설계자나 애플리케이션 개발자가 특정 비즈니스 규칙에 따라 정의한 무결성 규칙입니다.
  • 예를 들어, "고객의 할인율은 0%에서 50% 사이어야 한다"와 같은 규칙이 될 수 있습니다"

정규화 단계

1. 제1정규형 (1NF)

  • 모든 속성은 원자값(더 이상 분해할 수 없는 값)을 가져야 합니다.
  • 각 속성마다 고유한 값을 가져야 합니다.

1) 복합 속성(Composite Attributes)

  • 속성이 여러 하위 속성으로 구성될 수 있는 경우가 있습니다.
  • 예를 들어, '주소'라는 속성이 '도시', '주', '우편번호'와 같은 여러 하위 속성으로 나뉘어진다면, '주소'는 원자값이 아닙니다.
  • 제 1정규형을 충족하기 위해 이러한 복합 속성을 각각의 독립적인 속성으로 분해해야 합니다.

2) 다중값 속성(Multi-valued Attributes)

  • 하나의 속성이 여러 개의 값을 가질 수 있는 경우, 이 속성은 원자값이 아닙니다.
  • 예를 들어, '전화번호'라는 속성이 사용자의 집, 사무실, 핸드폰 등 여러 번호를 가질 수 있다면, 이는 원자값이 아닌 다중값 속성입니다.
  • 이러한 다중값 속성은 제 1정규형을 만족시키기 위해 별도의 테이블로 분리하거나 다른 방법으로 처리해야 합니다.

3) 중복 속성

  • 하나의 엔터티 내에서 같은 정보를 중복해서 저장하는 것은 피해야 합니다.
  • 예를 들어, '제품명'과 '제품 설명'이라는 두 개의 다른 속성에 같은 내용이 저장된다면, 이는 중복성을 초래하므로 제 1정규형을 위반하는 것입니다.

2. 제2정규형 (2NF)

  • 제 1정규형을 만족해야 합니다.
  • 기본 키가 아닌 모든 속성이 기본 키 전체에 함수적 종속되어야 합니다.
  • 즉, 부분적 종속이 존재하지 않아야 합니다.

1) 함수적 종속(Functional Dependency)

  • 속성 A의 값이 속성 B의 값을 유일하게 결정할 때, B는 A에 함수적으로 종속된다고 합니다(기호로는 A -> B)
  • 예를 들면 "학번"이 "학생 이름"을 유일하게 결정하면 "학생 이름"은 "학번"에 함수적으로 종속되어 있습니다.

2) 부분적 종속(Partial Dependency)

  • 복합 기본 키(둘 이상의 속성으로 구성된 키)를 가진 테이블에서 발생하는 종속 형태입니다.
  • 만약 복합 기본 키의 일부만으로 다른 속성이 결정된다면, 그 속성은 부분적으로 종속되어 있다고 말합니다.

예를 들어 학생수강정보라는 테이블이 아래와 같이 있다고 하겠습니다.




이 테이블에서 기본 키는 '학번'과 '과목코드'의 조합입니다.

그런데 이 테이블을 살펴보면

'학생 이름'(StudentName)은 '학번'(StudentID)에만 종속되어 있습니다.
즉, '학번'만 알면 '학생이름'을 알 수 있습니다.

'과목명'(CourseName)은 '과목코드'(CourseCode)에만 종속되어 있습니다.

이러한 관계로 인해 김철수 학생이 두 과목을 수강하는 경우 '학생이름'이 두 번 중복되어 저장됩니다.

이런 부분적 종속을 해결하기 위해 제2정규형(2NF)을 적용하여 테이블을 분해합니다.

  • 학생테이블


  • 과목테이블


  • 학생수강정보테이블

3. 제3정규형 (3NF)

  • 제2정규형을 만족해야 합니다.
  • 기본 키가 아닌 모든 속성이 기본 키에만 함수적 종속되어야 합니다. 이는 전이적 종속이 존재하면 안 된다는 것을 의미합니다.

전이적 종속(Transitive Dependency)

  • 속성 A가 속성 B에 함수적으로 종속되고, 속성 B가 속성 C에 함수적으로 종속될 때, 속성 A도 속성 C에 함수적으로 종속되는 것을 의미합니다.
  • 기호로 표현하면 A -> B, B -> C 가 동시에 참일 때, A -> C 도 참인 경우입니다.

예시) 고객 주문 정보

이 테이블에서 '제품분류'(Category)는 '제품ID'(ProductID)에 함수적으로 종속되어 있습니다.
그러나 '제품ID'는 '주문ID'에 함수적으로 종속되어 있습니다.
따라서 '제품분류'는 '주문ID'에 전이적으로 종속된 상태입니다.

이를 해결하기 위해 제3정규형을 적용하여 테이블을 분리합니다.

  • 주문 테이블

  • 제품 테이블

이렇게 테이블을 분리하면 전이적 종속 문제가 해결되며, 각 테이블의 역할이 명확해집니다.
또한, 제품의 분류정보를 업데이트할 때는 제품 테이블만 변경하면 되므로 데이터 관리가 보다 효율적입니다.

4. BCNF (Boyce-Codd Normal Form)

  • 제3정규형을 만족해야 합니다.
  • 모든 결정자는 후보 키의 부분집합이어야 합니다.
결정자, 후보키, 기본키 설명

예시) 학생과 강의를 관리하는 테이블

1) 결정자(Determinant)

  • '강의코드'는 '강의명'을 결정합니다. 즉, '강의코드'가 주어지면 해당 강의의 이름을 알 수 있습니다.
  • 따라서 '강의코드'는 강의명'의 결정자입니다('강의코드' -> '강의명')

2) 후보키(Candidate Key)

  • '(학번, 강의코드)'의 조합은 테이블의 각 튜플을 유일하게 식별합니다.
  • 다른 조합으로는 튜플을 유일하게 식별할 수 없습니다.
  • 따라서 '(학번, 강의코드)'의 조합이 후보키입니다.

3) 기본키(Pirmarty Key)

  • 위에서 언급한 후보키 '(학번, 강의코드)' 중에서 우리가 실제 데이터베이스 설계시에 기본키로 선택한 키를 말합니다.
  • 여기서는 '(학번, 강의코드)'만이 유일하게 튜플을 식별할 수 있는 후보키이므로 해당 키만이 기본키로 선택할 수 있습니다.
  • 이 기본키에 의해 각 튜플은 고유하게 식별됩니다.
모든 결정자는 후보 키의 부분집합이어야 합니다.

예시) 대학생과 선택한 전공

이 테이블에서는 '전공명'이 '담당 교수'를 결정합니다.
즉 '전공명 -> 담당 교수' 라는 함수적 종속성이 존재합니다.


하지만, '전공명'은 이 테이블의 후보키가 아닙니다. 왜냐하면 '전공명'만으로는 각 튜플(레코드)을 구별할 수 없기 때문입니다.
'학번' 혹은 '학번 + 전공명' 조합이 후보키가 될 수 있습니다.


이 경우, '전공명' 이 결정자이지만 후보 키의 부분집합이 아닙니다.
따라서 이 테이블은 BCNF를 만족하지 않습니다.


BCNF를 만족시키기 위해 분해

1) 학생과 전공 테이블

2) 전공과 담당 교수 테이블

5. 제4정규형(4NF)

  • BCNF를 만족해야 합니다.
  • 다중값 종속성이 없어야 합니다.
다중값 종속성(MVC, Multi-Valued Dependency)
  • 릴레이션 R의 두 속성 A와 B에 대해, A의 어떤 값을 가지는 튜플들이 B의 값을 독립적으로 가질 수 있을 때 발생합니다.
  • 기호로는 A ->> B 라고 표현합니다.

예시) 학생들이 참여하는 동아리와 스포츠 활동

이 테이블에서 주요 포인트는 '학생이름' 속성에 대한 다른 두 속성 ('동아리'와 '스포츠') 사이의 독립적인 관계입니다.


데이터를 보면 '지민'이 '미술' 동아리와 '음악' 동아리에 동시에 속하고 있으며, 동시에 '농구'와 '축구' 스포츠 활동에 참여하고 있습니다.
이때, '지민'의 '미술' 동아리 참여는 '농구'나 '축구' 활동과 직접적인 연관이 없습니다.
즉, '동아리' 참여와 '스포츠' 활동은 독립적인 관계를 가지고 있습니다.

다중값 종속성은 이런 독립적인 관계 때문에 발생합니다.
'학생이름'에 대해 '동아리'와 '스포츠'의 관계가 독립적으로 존재하므로 '학생이름'이 '동아리'에 대한 다중값 종속성과 '스포츠'에 대한 다중값 종속성을 모두 갖게 됩니다.


다시 말하면, '지민'이 참여하는 '동아리'는 '지민'이 참여하는 '스포츠'에 의해 영향을 받지 않습니다.
이는 '학생이름 ->> 동아리'와 '학생이름 ->> 스포츠'의 두 개의 다중값 종속성을 나타냅니다.


이러한 다중값 종속성을 해결하기 위해 테이블을 분리 하면 다음과 같습니다.

1) 학생과 동아리 테이블

2) 학생과 스포츠 테이블

6. 제5정규형(5NF 또는 PJNF, Project-Join Normal Form)

  • 제4정규형을 만족해야 합니다.
  • 조인 종속을 고려하여 분해하였을 때 원래의 관계를 복원할 수 있어야 합니다.
조인 종속(Join Dependency)란?

조인 종속은 릴레이션 R을 두 개 이상의 릴레이션 R1, R2, ..., Rn 으로 분해한 후, 이들을 다시 조인했을 때 원래의 릴레이션 R을 복원할 수 있을 때 발생합니다.
기호로는 R = R1 ⨝ R2 ⨝ ... ⨝ Rn과 같이 나타낼 수 있습니다.

예시) 학생이 참여하는 프로젝트와 해당 프로젝트에서 사용하는 언어를 나타내는 테이블

이 테이블을 다음과 같이 두 개의 릴레이션으로 분해합니다.

  • 릴레이션 R1

  • 릴레이션 R2

이제 R1과 R2를 조인하면 원래의 릴레이션을 복원할 수 있습니다.
이 경우, 원래의 릴레이션에 대한 조인 종속이 발생하며, 이 릴레이션은 5NF를 만족합니다.

제5정규형의 주요 목적은 릴레이션을 적절하게 분해하여 데이터 중복을 제거하면서도 조인 연산을 통해 원래의 릴레이션을 손실 없이 복원할 수 있도록 하는 것입니다.

반응형

+ Recent posts