ShoppingMall

유요한·2023년 1월 21일
1

Spring Boot

목록 보기
14/25
post-thumbnail

프로젝트 세팅

Gradle 기준입니다.

   // JPA
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    // oauth2
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    // security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    // jwt
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    // 서버를 재실행 안해줘도 바로 처리가능하게 만드는 라이브러리
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    // MySQL
    runtimeOnly 'com.mysql:mysql-connector-j'
    // 룸북
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    // 유효성 검사 라이브러리
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    // swagger
    implementation 'io.springfox:springfox-boot-starter:3.0.0'
    // thymeleaf
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    // AWS S3
    implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

외부 설정 파일

spring:
  profiles:
    include:
      - jwt
      - oauth
      - s3
      - test

  devtools:
    livereload:
      enabled: true
    restart:
      enabled: true

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/shopping
    username: root
    password: 1234

  jpa:
    database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
    open-in-view: true
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true
        default_batch_fetch_size: 500

  mvc:
    pathmatch:
      matching-strategy: ant_path_matcher

logging:
  level:
    org.hibernate.SQL: debug

SQL은 표준 SQL과 DBMS 벤더에서 제공하는 SQL이 존재합니다. 각 공급업체에서 만든 SQL을 방언(Dialect)라고 생각하면 됩니다. spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect는 우리가 사용하는 데이터베이스는 MySQL이어서 JPA에 MySQL8Dialect를 사용하라고 알려줍니다. 만약 데이터를 오라클로 교체하더라도, 오라클의 Dialect를 설정해준다면 문제없이 애플리케이션을 구동할 수 있습니다.

spring.jpa.hibernate.ddl-auto=create는 데이터베이스 초기화 전략입니다.

데이터베이스 초기화 전략 - DDL AUTO 옵션

application.properties에 추가한 jpa 옵션 중 주의 깊게 봐야할 설정은 DDL AUTO 옵션입니다. spring.jpa.hibernate.ddl-auto옵션을 통해 애플리케이션 구동 시 JPA의 데이터베이스 초기화 전략을 설정할 수 있습니다. 총 5가지 옵션이 있습니다.

  • none : 사용하지 않음
  • create : 기존 테이블 삭제 후 테이블 생성
  • create-drop : 기존 테이블 삭제 후 테이블 생성. 종료 시점에 테이블 삭제
  • update : 변경된 스키마 적용
  • validate : 엔티티와 테이블 정상 매핑 확인

update 옵션에서 컬럼 삭제는 엄청난 문제를 발생시킬 수 있습니다. 그렇기 때문에 컬럼 추가만 반영됩니다. 개발 초기에는 create 또는 update 옵션을 이용해서 익숙해지는데 집중하고 추후에 validate 옵션을 설정해주는 것이 좋습니다.

스테이징, 운영환경에서는 절대로 create, create-drop, update를 사용하면 안됩니다. 지금 같은 경우는 운영환경이 아니라 공부이므로 create를 사용하는 겁니다. 스테이징과 운영 서버에서는 테이블 생성 및 컬럼 추가, 삭제, 변경은 데이터베이스에서 직접하며, none을 사용하거나 validate를 이용하여 정상적인 매핑 관계만 확인합니다.

스테이징 환경과 운영환경의 의미

스테이징 환경이란 운영환경과 거의 동일한 환경으로 구성하여 운영환경에 배포하기 전 여러 가지 기능(성능, 장애 등)을 검증하는 환경입니다. 운영환경은 실제 서비스를 운영하는 환경입니다.


상품 엔티티 설계하기

쇼핑몰을 만들기 위해서는 상품 등록, 조히, 수정, 삭제가 가능해야 합니다.

상품 엔티티 설계하기

엔티티란 데이터베이스의 테이블에 대응하는 클래스라고 생각하면 됩니다. @Entity가 붙은 클래스는 JPA에서 관리하며 엔티티라고 합니다. 상품 엔티티를 만들기 위해서는 상품 테이블에 어떤 데이터가 저장되어야 할지 설게해야 합니다. Lombok 어노테이션을 이용한다면 getter, setter 등을 자동으로 만들어주기 때문에 코드를 깔끔하게 짤 수 있습니다.

entity 패키지는 그안에 entity 클래스들을 모아두고 constant패키지 안에는 enum 타입을 모아둡니다. 상품이 현재 판매 중인지 품절 상태인지 나타내는 enum 타입의 클래스입니다. enum 클래스를 사용하면 연관된 상수들을 모아둘 수 있으며 enum에 정의한 타입만 값을 가지도록 컴파일 시 체크할 수 있다는 장점이 있습니다.

ItemSellStatus

package com.example.shopping_.constant;

public enum ItemSellStatus {
    SELL, SOLD_OUT
}

Item

package com.example.shoppingmall.entity.item;

import com.example.shoppingmall.entity.base.BaseEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.*;

@Entity
@Getter
@ToString
@NoArgsConstructor
public class ItemEntity extends BaseEntity {
    @Id @GeneratedValue
    @Column(name = "item_id")
    private Long id;            // 상품 코드
    @Column(nullable = false, length = 50)
    private String itemNum;     // 상품 명
    @Column(name = "price", nullable = false)
    private int price;          // 가격
    @Column(nullable = false)
    private int stockNumber;    // 재고수량
    // BLOB, CLOB 타입 매핑
    // CLOB이란 사이즈가 큰 데이터를 외부 파일로 저장하기 위한 데이터입니다.
    // 문자형 대용량 파일을 저장하는데 사용하는 데이터 타입이라고 생각하면 됩니다.
    // BLOB은 바이너리 데이터를 DB외부에 저장하기 위한 타입입니다.
    // 이미지, 사운드, 비디오 같은 멀티미디어 데이터를 다룰 때 사용할 수 있습니다.
    @Lob
    @Column(nullable = false)
    private String itemDetail;  // 상품 상세 설명
    private ItemSellStatus itemSellStatus;  // 상품 판매 상태

    @Builder
    public ItemEntity(Long id,
                      String itemNum,
                      int price,
                      int stockNumber,
                      String itemDetail,
                      ItemSellStatus itemSellStatus) {
        this.id = id;
        this.itemNum = itemNum;
        this.price = price;
        this.stockNumber = stockNumber;
        this.itemDetail = itemDetail;
        this.itemSellStatus = itemSellStatus;
    }
}

상품 정보로 상품코드, 가격, 상품명, 상품 상세 설명, 판매 상태를 만들어줍니다. 판매 상태의 경우 재고가 없거나 상품을 미리 등록해 놓고 나중에 '판매 중' 상태로 바꾸거나 재고가 없을 때는 프론트에 노출시키지 않기 위해서 판매 상태를 코드로 갖고 있겠습니다. 또한 상품을 등록한 시간과 수정한 시간을 상품 테이블에 기록하기 위해서 BaseEntity를 상속받아서 사용하고 있습니다.

생성한 Item 클래스는 상품의 가장 기본적인 정보들을 담고 있습니다. 실제로는 1개의 상품에 여러가지 옵션 및 옵션 상품의 가격, 재고, 배송 방법에 대한 정보까지 관리해야지만 최대한 단순하게 만들겠습니다.

Item 클래스를 entity로 선언합니다. 또한 @Table 어노테이션을 통해 어던 테이블과 매핑될지를 지정합니다. item 테이블과 매핑되도록 name을 item으로 지정합니다.

entity로 선언한 클래스는 반드시 기본키를 가져야 합니다. 기본키가 되는 멤버변수에 @Id 어노테이션을 붙여줍니다. 그리고 테이블에 매핑될 컬럼의 이름을 @Column어노테이션을 통해 설정해줍니다. item 클래스의 id 변수와 item 테이블의 item_id 컬럼이 매핑되도록 합니다. 마지막으로 @GeneratedValue 어노테이션을 통해 기본키 생성 전략을 auto_increment로 DB에서 자동으로 올려줄 겁니다.

@Column 어노테이션의 nullable 속성을 이용해서 항상 값이 있어야 하는 필드는 not null 설정을 합니다. String 필드는 default 값으로 255가 설정되어 있습니다. 각 String 필드마다 필요한 길이를 length 속성에 default 값을 세팅합니다.

엔티티 매핑 관련 어노테이션

어노테이션설명
@Entity클래스를 엔티티로 선언
@Table엔티티와 매핑할 테이블을 지정
@Id테이블의 기본키에 사용할 속성을 지정
@GeneratedValue키 값을 생성하는 전략 명시
@Column필드와 컬럼 매핑
@LobBLOB, CLOB 타입 매핑
@CreationTimestampinsert시 시간 자동 저장
@UpdateTimestampupdate시 시간 자동 저장
@Enumeratedenum 타입 매핑
@Transient해당 필드 데이터베이스 매핑 무시
@Tmporal날짜 타입 매핑
@CreateDate엔티티가 생성되어 저장될 때 시간 자동 저장
@LastModifiedDate조회한 엔티티의 값을 변경할 때 시간 자동 저장

CLOB과 BLOB의미

CLOB이란 사이즈가 큰 데이터를 외부 파일로 저장하기 위한 데이터입니다. 문자형 대용량 파일을 저장하는데 사용하는 데이터 타입이라고 생각하면 됩니다.

BLOB은 바이너리 데이터를 DB외부에 저장하기 위한 타입입니다. 이미지, 사운드, 비디오 같은 멀티미디어 데이터를 다룰 때 사용할 수 있습니다.

@Column 속성

테이블을 생성할 때 컬럼에는 다양한 조건들이 들어갑니다. 예를 들면 문자열을 저장하는 VARCHAR 타입은 길이를 설정할 수 있고, 테이블에 데이터를 넣을 때 데이터가 항상 존재해야 하는 Not Null 조건 등이 있습니다. @Column 어노테이션의 속성을 사용하면 테이블에 매핑되는 컬럼의 이름, 문자열의 최대 저장 길이 등 다양한 제약 조건들을 추가할 수 있습니다.

속성설명기본값
name필드와 매핑할 컬럼의 이름 설정객체의 필드이름
unique(DDL)유니크 제약 조건 설정
insertableinsert 기능 여부true
updatableupdate 기능 여부true
lengthString 타입의 문자 길이 제약조건 설정255
nullable(DDL)null 값의 허용 여부 설정, false 설정 시 DDL 생성 시에 not null 제약 조건 추가
columnDefinition데이터베이스 컬럼 정보 직접 기술

예)
@Column(columnDefinition = "varchar(5) default '10' not null")
precisionBigDecimal 타입에서 사용(BigInteger 가능) precision은 소수점을 포함한 전체 자리수이고, scale은 소수점 자리수, Double과 float 타입에는 적용되지 않음

DDL이란?

DDL(Data Definition Language)이란 테이블, 스키마, 인덱스, 뷰, 도메인을 정의, 변경, 제거할 때 사용하는 언어입니다. 가령, 테이블을 생성하거나 삭제하는 CREATE, DROP 등이 이에 해당됩니다.

@Entity 어노테이션은 클래스의 상단에 입력하면 JPA에 엔티티 클래스라는 것을 알려줍니다. Entity클래스는 반드시 기본키를 가져야 합니다. @Id 어노테이션을 이용하여 id 멤버 변수를 상품 테이블의 기본키로 설정합니다. @GeneratedValue 어노테이션을 통한 기본키를 생성하는 전략은 총 4가지 전략이 있습니다.

생성전략

  • GenerationType.AUTO(default)
    JPA 구현체가 자동으로 생성 전략 결정

  • GenerationType.IDENTITY
    기본키 생성을 데이터베이스에 위임

    예) MySQL 데이터베이스의 경우 AUTO_INCREMENT를 사용하여 기본키 생성

  • GenerationType.SEQUENCE
    데이터베이스 시퀀스 오브젝트를 이용한 기본키 생성
    @SequenceGenerator를 사용하여 시퀀스 등록 필요

  • GenerationType.TABLE
    키 생성용 테이블 사용.
    @TableGenerator 필요

전략은 기본키를 생성하는 방법이라고 이해하면 됩니다. MySQL에서 AUTO_INCREMENT를 이용해 데이터베이스에 INSERT 쿼리문을 보내면 자동으로 기본키 값을 증가 시킬 수 있습니다.

기본키와 데이터베이스 시퀀스 오브젝트의 의미

기본키(primary key)는 데이터베이스에서 조건을 만족하는 튜플을 찾을 때 다른 튜플들과 유일하게 구별할 수 있도록 기준을 세워주는 속성입니다. 예를 들어서, 상품 데이터를 찾을 때 상품의 id를 통해서 다른 상품들과 구별을 할 수 있습니다. 여기서 기본키는 id입니다.

데이터베이스 시퀀스 오브젝트에서 시퀀스란 순차적으로 증가하는 값을 반환해주는 데이터베이스 객체입니다. 보통 기본키의 중복값을 방지하기 위해서 사용합니다.

4가지의 생성 전략 중에서 @GenerationType.AUTO를 사용해서 기본키를 생성하겠습니다. 데이터베이스에 의존하지 않고 기본키를 할당하는 방법으로, JPA 구현체가 IDENTITY, SEQUENCE, TABLE 생성 전략 중 하나를 자동으로 선택합니다. 따라서 데이터베이스가 변경되더라도 코드를 수정할 필요가 없습니다.

Item 클래스를 entity로 선언합니다. 또한 @Table 어노테이션을 통해 어던 테이블과 매핑될지를 지정합니다. item 테이블과 매핑되도록 name을 item으로 지정합니다.

entity로 선언한 클래스는 반드시 기본키를 가져야 합니다. 기본키가 되는 멤버변수에 @Id 어노테이션을 붙여줍니다. 그리고 테이블에 매핑될 컬럼의 이름을 @Column어노테이션을 통해 설정해줍니다. item 클래스의 id 변수와 item 테이블의 item_id 컬럼이 매핑되도록 합니다. 마지막으로 @GeneratedValue 어노테이션을 통해 기본키 생성 전략을 AUTO로 지정합니다.

@Column 어노테이션의 nullable 속성을 이용해서 항상 값이 있어야 하는 필드는 not null 설정을 합니다. String 필드는 default 값으로 255가 설정되어 있습니다. 각 String 필드마다 필요한 길이를 length 속성에 default 값을 세팅합니다.

위에꺼를 적용하고 실행하면 다음과 같은 쿼리문을 확인할 수 있습니다.

여기까지는 엔티티 매니저를 이용해 item 엔티티를 저장하는 예제입니다. 하지만 Spring Data JPA에서는 엔티티 매니저를 직접 이용해 코드를 작성하지 않아도 된다. 그 대신에 Data Access Object의 역할을 하는 Repository 인터페이스를 설계한 후 사용하는 것만으로 충분합니다. 그럼 왜 앞서 엔티티 매니저를 직접 이용한 코드를 작성했을까? JPA 엔티티를 어떻게 관리하는지 보여주기 위함입니다.

package com.example.shopping_.repository;

import com.example.shopping_.entity.Item;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ItemRepository extends JpaRepository<Item, Long> {

}

JpaRepository를 상속받는 ItemRepository를 작성했습니다. JpaRepository는 2개의 제네릭 타입을 사용하는데 첫 번째는 엔티티 타입 클래스를 넣어주고 두 번째는 기본키 타입을 넣어줍니다. Item 클래스는 기본키 타입이 Long이므로 long을 넣어줍니다. JpaRepository는 기본적으로 CRUD 및 페이징 처리를 위한 메소드가 정의되어 있습니다. 메소드 및 몇 가지를 살펴보면 엔티티를 저장하거나, 삭제, 또는 엔티티의 개수 출력 등의 메소드를 볼 수 있습니다. 이번 예제에서 작성할 테스트 코드는 엔티티를 저장하는 save() 메소드입니다.


Repository 설계하기

JpaRepository에서 지원하는 메소드 예시

메소드기능
<S extends T> save(S entity)엔티티 저장 및 수정
void delete(T entity)엔티티 삭제
count()엔티티 총 개수 반환
Iterable<T> findAll()모든 엔티티 조회

개발을 하다보면 기획 요건이 변경돼 코드를 수정하거나, 기존의 소스코드를 수정해야 하는 상황이 많이 발생합니다. 로직이 복잡하지 않다면 기존 소스를 금방 해석해서 코드를 추가할 것입니다. 하지만 로직이 복잡할 때 코드 수정 이후 코드가 버그 없이 제대로 동작하는지 테스트하는 것은 매우 중요합니다. 테스트 코드도 유지보스를 해야하기 때문에 비용이 발생합니다. 다라서 의미 있는 테스트 케이스를 작성하고 결과가 예상과 맞는지 검사하는 로직을 작성해야 합니다. 가능한 테스트 케이스가 여러 개라면 애플리케이션을 실행하고 테스트하는 것도 시간이 많이 소요되며 테스트 케이스를 놓칠 수 있습니다. 잘 만들어진 테스트 케이스는 유지보수 및 소스코드의 안전성을 위해 중요합니다.

package com.example.shoppingmall.repository.item;

import com.example.shoppingmall.entity.item.ItemEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface ItemRepository extends JpaRepository<ItemEntity, Long> {
}

테스트

통합 테스트를 위해 스프링 부트에서 제공하는 어노테이션입니다. 실제 애플리케이션을 구동할 때처럼 모든 Bean을 IoC 컨테이너에 등록합니다. 애플리케이션의 규모가 크면 속도가 느려질 수 있습니다.

테스트 코드 실행 시 application.properties에 설정해둔 값보다 application-test.properties에 같은 설정이 있다면 더 높은 우선순위를 부여합니다. 기존에는 MySQL을 사용햇지만 테스트 코드 실행시에는 H2 데이터베이스를 사용하게 됩니다.

ItemRepository를 사용하기 위해서 @Autowired 어노테이션을 이용하여 Bean을 주입합니다.


테스트할 메소드 위에 선언하여 해당 메소드를 테스트 대상으로 여깁니다.


Junit5에 추가된 어노테이션으로 테스트 코드 실행시 @DisplayName에 지정한 테스트 명이 노출됩니다.

이제 테스트를 실행하면 콘솔창에 실행되는 쿼리문을 확인할 수 있습니다.

우리는 insert query문을 따로 작성하지 않았는데 ItemRepository 인터페이스를 작성한 것만으로 상품 테이블에 데이터를 insert 할 수 있었습니다. Spring Data JPA는 이렇게 인터페이스만 작성하면 런타임 시점에 자바의 Dynamic Proxy를 이용해서 객체를 동적으로 생성해줍니다. 따로 Data Access Object(Dao)와 xml 파일에 쿼리문을 작성하지 않아도 됩니다.

package com.example.shoppingmall.entity.item;

import com.example.shoppingmall.repository.item.ItemRepository;
import groovy.util.logging.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;

@SpringBootTest
@Slf4j
@TestPropertySource(locations = "classpath:application-test.properties")
class ItemEntityTest {

    // Bean 주입
    @Autowired
    ItemRepository itemRepository;

    @Test
    @DisplayName("상품 저장 테스트")
    public void createItemTest() {
        ItemEntity itemEntity = ItemEntity.builder()
                .itemNum("테스트 상품")
                .price(10000)
                .itemDetail("테스트 상품 상세 설명")
                .itemSellStatus(ItemSellStatus.SELL)
                .stockNumber(100)
                .build();

        ItemEntity save = itemRepository.save(itemEntity);
        System.out.println(save.toString());

        Assertions.assertThat(save).isEqualTo(itemEntity);
    }

}

쿼리 메소드

애플리케이션을 개발하려면 데이터를 조회하는 기능은 필수입니다. 쿼리 메소드는 스프링 데이터 JPA에서 제공하는 핵심 기능 중 하나로 Repository 인터페이스에 간단한 네이밍 룰을 이용하여 메소드를 작성하면 원하는 쿼리를 실행할 수 있습니다.

쿼리 메소드를 이용할 때 가장 많이 사용하는 문법으로 find를 사용합니다. 엔티티 이름은 생략이 가능하며, By 뒤에는 검색할 때 사용할 변수의 이름을 적어준다.

find + (엔티티 이름) + By + 변수 이름

상품의 이름을 이용하여 데이터를 조회하는 예제를 살펴보겠습니다.
기존에 작성햇던 ItemRepository에 findByItemNm 메소드를 추가합니다.

@Repository
public interface ItemRepository extends JpaRepository<ItemEntity, Long> {
    // 상품명으로 데이터를 조회하기 위해서 By 뒤에 ItemNum을 메소드의 이름에 붙여 줍니다.
    List<ItemEntity> findByItemNum(String itemNum);
}

itemNm(상품명)으로 데이터를 조회하기 위해서 By뒤에 필드명인 ItemNm을 메소드의 이름에 붙여줍니다. 엔티티명은 생략이 가능하므로 findItemByItemNm 대신에 findByItemNm으로 메소드명을 만들어줍니다. 매개 변수로는 검색할 때 사용할 상품명 변수를 넘겨줍니다.

그리고 기존에 작성했던 ItemRepositoryTest 클래스에 테스트 코드를 추가합니다.

  @Test
    @DisplayName("상품리스트 테스트")
    public void createItemList() {
        for (int i = 1; i < 10; i++) {
            Item item = new Item();
            item.setItemNm("테스트 상품" + i);
            item.setPrice(10000 + i);
            item.setItemDetail("테스트 상품 상세 설명" + i);
            item.setItemSellStatus(ItemSellStatus.SELL);
            item.setStockNumber(100);
            item.setRegTime(LocalDateTime.now());
            item.setUpdateTime(LocalDateTime.now());
            Item savedItem = itemRepository.save(item);
        }
    }

테스트 코드 실행 시 데이터베이스에 상품 데이터가 없으므로 테스트 데이터 생성을 위해서 10개의 상품을 저장하는 메소드를 작성하여 findByItemNmTest()에서 실행해줍니다.

    @Test
    @DisplayName("상품명 조회 테스트")
    public void findByItemNmTest() {
        this.createItemList();
        List<Item> itemList = itemRepository.findByItemNm("테스트 상품 1"); ← ①
        for (Item item: itemList
             ) {
            System.out.println(item.toString()); ← ②
        }
    }

① ItemRepository 인터페이스에 작성했던 findByItemNm 메소드를 호출합니다. 파라미터로는 "테스트 상품1"이라는 상품명을 전달하겠습니다.

② 조회 결과를 얻은 item 객체들을 출력합니다.

or조건 처리

    // 상품을 상품명과 상품 상세 설명을 OR 조건을 이용하여 조회하는 쿼리 메소드입니다.
    List<Item> findByItemNmOrItemDetail(String itemNm, String itemDetail);
@Test
    @DisplayName("상품명, 상품상세설명 or 테스트")
    public void findByItemNmOrItemDetailTest() {
        // 기존에 만들었던 테스트 상품을 만드는 메소드를 실행하여 조회할 대상 만듬
        this.createItemList();
        // 상품명이 "테스트 상품1" 또는 상품 상세 설명이 "테스트 상품 상세 설명5"이면
        // 해당 상품을 itemList에 할당합니다.
        // 테스트 코드를 실행하면 조건대로 2개의 상품이 출력됩니다.
        List<Item> itemList = itemRepository.findByItemNmOrItemDetail("테스트 상품1", "테스트 상품 상세 설명5");
        for (Item item: itemList
             ) {
            System.out.println(item.toString());
        }
    }

LessThen 조건 처리하기

  // 파라미터로 넘어온 price 변수보다 값이 작은 상품 데이터를 조회하는 쿼리 메소드
   List<Item> findByPriceLessThan(Integer price);
    @Test
   @DisplayName("가격 LessThan 테스트")
   public void findByPriceLessThanTest() {
       this.createItemList();
       // 현재 데이터베이스에 저장된 가격이 10001 ~ 10010입니다.
       // 테스트 코드 실행 시 10개의 상품을 저장하는 로그가 콘솔에 나타나고
       // 맨 마지막에 가격이 10005보다 작은 4개의 상품을 출력해줍니다.
       List<Item> itemList = itemRepository.findByPriceLessThan(10005);
       for (Item item: itemList
            ) {
           System.out.println(item.toString());
       }
   }

콘솔을 보시면 가격 데이터가 10001 ~ 10004까지 차례대로 출력됩니다. 출력 결과를 OrderBy 키워드를 이용한다면 오름차순 또는 내림차순으로 조회할 수 있습니다. 오름 차순의 경우 OrderBy + 속성명 + Asc 키워드를 이용하고, 내림차순에서는 OrderBy + 속성명 + Desc 키워드를 이용해 데이터의 순서를 처리할 수 있습니다.

  // OrderBy
    List<Item> findByPriceLessThanOrderByPriceDesc(Integer price);
    @Test
    @DisplayName("가격 내림차순 조회 테스트")
    public void findByPriceLessThanOrderByPriceDescTest() {
        this.createItemList();
        List<Item> itemList = itemRepository.findByPriceLessThanOrderByPriceDesc(10005);
        for (Item item: itemList
             ) {
            System.out.println(item.toString());
        }
    }

출력 결과를 보시면 가격이 높은 순으로 출력이 됩니다.


Spring DATA JPA : @Query

Spring DATA JPA에서 제공하는 @Query 어노테이션을 이용하면 SQL과 유사한 JPQL이라는 객체지향 쿼리 언어를 통해 복잡한 쿼리도 가능합니다. SQL과 문법 자체가 유사하기 때문에 기존에 SQL을 사용하던 사람들은 쉽게 사용할 수 있습니다. SQL의 경우 데이터베이스의 테이블을 대상으로 쿼리를 수행하고, JPQL은 엔티티 객체를 대상으로 쿼리를 수행합니다. 테이블이 아닌 객체를 대상으로 검색하는 객체지향 쿼리입니다. JPQL은 SQL을 추상화해서 사용하기 때문에 특정 데이터베이스 SQL에 의존하지 않습니다. 즉, JPQL로 작성을 했다면 데이터베이스가 변경되어도 애플리케이션에 영향을 받지 않습니다.

@Query 어노테이션을 이용하여 상품 데이터를 조회하는 예제를 진행해보겠습니다. 상품 상세 설명을 파라미터로 받아 해당 내용을 상품 상세 설명에 포함하고 있는 데이터를 조회하며, 정렬 순서는 가격이 높은 순으로 조회합니다.

    // @Query 어노테이션 안에 JPQL로 작성한 쿼리문을 넣어줍니다.
    // from 뒤에는 엔티티 클래스로 작성한 Item을 지정해주었고, Item으로부터 데이터를 select하겟다는 의미입니다. 
    @Query("select i from Item i where i.itemDetail like %:itemDetail% order by i.price desc")
    // 파라미터 @Param 어노테이션을 이용하여 파라미터로 넘어온 값을 JPQL에 들어갈 변수로 지정해줄 수 있습니다.
    // 현재는 itemDetail 변수를 "like % %" 사이에 ":itemDetail"로 값이 들어가도록 작성했습니다.
    List<Item> findByItemDetail(@Param("itemDetail") String itemDetail);

@Param 어노테이션을 이용하여 변수를 JPQL에 전달하는 대신 파라미터의 순서를 이용해 전달해줄수도 있습니다. 그럴 경우 ":itemDetail"대신 첫 번째 파라미터를 전달하겠다는 ?1이라는 표현을 사용하면 됩니다. 하지만 파라미터의 순서가 달라지면 해당 쿼리문이 제대로 동작하지 않을 수 있기 때문에 좀 더 명시적인 방법인 @Param 어노테이션을 이용하는 방법이 좋습니다.

    @Test
   @DisplayName("@Query를 이용한 상품 조회 테스트")
   public void findByItemDetailTest() {
       this.createItemList();
       List<Item> itemList = itemRepository.findByItemDetail("테스트 상품 상세 설명");
       for (Item item: itemList
            ) {
           System.out.println(item.toString());
       }
   }

테스트 코드 실행 결과 상품 상세 설명에 '테스트 상품 상세 설명'을 포함하고 있는 상품 데이터 10개가 가격이 높은 순부터 조회되는 것을 확인할 수 있습니다. 복잡한 쿼리의 경우 @Query 어노테이션을 사용해서 조회하면 됩니다.

만약 기존의 데이터베이스에서 사용하던 쿼리를 그대로 사용해야 할 때는 @Query의 nativeQuery 속성을 사용하면 기존 쿼리를 그대로 활용할 수 있습니다. 하지만 특정 데이터베이스에 종속되는 쿼리문을 사용하기 때문에 데이터베이스에 대해 독립적이라는 장점을 잃어버립니다. 기존에 작성한 통계용 쿼리처럼 복잡한 쿼리를 그대로 사용해야 하는 경우 활용할 수 있습니다.

    // value 안에 네이티브 쿼리문을 작성하고 "nativeQuery=true"를 작성한다.
    @Query(value = "select * from Item i where i.itemDetail like %:itemDetail% order by i.price desc", nativeQuery = true)
    List<Item> findByItemDetailByNative(@Param("itemDetail") String itemDetail);

Spring DATA JPA : Querydsl

@Query 어노테이션을 이용한 방법에도 단점이 있습니다. @Query 어노테이션 안에 JPQL 문법으로 문자열을 입력하기 때문에 잘못 입력하면 컴파일 시점에 에러를 발견할 수 없습니다. 에러는 가능한 빨리 발견하는 것이 좋습니다. 이를 보완할 수 있는 방법으로 Querydsl을 알아보겠습니다.

만약, ItemRepository에서 작성한 쿼리 중 where을 써야는데 wheere이라는 오타가 나면 실행하기 전에는 오류가 있는것을 알 수 없습니다. 애플리케이션을 실행하면 로딩 시점에 파싱 후 에러를 잡아줍니다. 이때 도움을 주는 것이 Querydsl입니다. Querydsl은 JPQL을 코드로 작성할 수 있도록 도와주는 빌더 API입니다. Querydsl은 소스코드로 SQL문을 문자열이 아닌 코드로 작성하기 때문에 컴파일러의 도움을 받을 수 있습니다. 소스 작성시 오타를 발생하면 개발자에게 오타가 있음을 바로 알려줍니다. 또한 동적으로 쿼리를 생성해주는게 진짜 큰 장점입니다. JPQL은 문자를 계속 더해야기 때문에 작성이 힘듭니다.

Querydsl 장점

  • 고정된 SQL 문이 아닌 조건에 맞게 동적으로 쿼리를 생성할 수 있습니다.
  • 비슷한 쿼리를 재사용 할 수 있으며 제약 조건 조립 및 가독성을 향상시킬 수 있습니다.
  • 문자열이 아닌 자바 소스코드로 작성하기 때문에 컴파일 시점에 오류를 발견할 수 없습니다.
  • IDE의 도움을 받아서 자동 완성 기능을 이용할 수 있기 때문에 생산성을 향상시킬 수 있습니다.

Querydsl을 사용하기 위해서는 몇 가지 설정을 추가해야 합니다.

 // Querydsl 추가 시작
    implementation 'com.querydsl:querydsl-jpa'
    implementation "com.querydsl:querydsl-core"
    implementation "com.querydsl:querydsl-collections"
    // querydsl JPAAnnotationProcessor 사용 지정
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa"
    // java.lang.NoClassDefFoundError (javax.annotation.Generated) 대응 코드
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    // java.lang.NoClassDefFoundError (javax.annotation.Entity) 대응 코드
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
    
    
 // 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)
}   

gradle 전체

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

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
    all {
        //logback과의 충돌 방지
        exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    // JUnit4 제외시키기
    testImplementation ('org.springframework.boot:spring-boot-starter-test') {
        exclude group : 'org.junit.vintage', module: 'junit-vintage-engine'
    }
    // JUnit5 가져오기
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
    testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
    testImplementation 'org.junit.jupiter:junit-jupiter-params:5.8.2'
    // log4j2
    implementation 'org.springframework.boot:spring-boot-starter-log4j2'
    // Querydsl 추가 시작
    implementation 'com.querydsl:querydsl-jpa'
    implementation "com.querydsl:querydsl-core"
    implementation "com.querydsl:querydsl-collections"
    // querydsl JPAAnnotationProcessor 사용 지정
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa"
    // java.lang.NoClassDefFoundError (javax.annotation.Generated) 대응 코드
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    // java.lang.NoClassDefFoundError (javax.annotation.Entity) 대응 코드
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

tasks.named('test') {
    useJUnitPlatform()
}
// 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)
}
  // 영속성 컨텍스트를 이용하기 위해 @PersistenceContext 어노테이션을 이용해
    // EntityManager 빈을 주입합니다.
    @PersistenceContext
    // Entity를 관리하는 역할을 수행하는 클래스
    // EntityManager 내부에 영속성 컨텍스트를 이용하여 관리한다.
    // Transaction 단위를 수행할 때마다 생성된다.
    // 요청 시 생성되며 Transaction 후에는 close()되어야 한다.
    EntityManager em;
    @Test
    @DisplayName("Querydsl 조회 테스트1")
    public void queryDslTest() {
        this.createItemList();
        // JPAQueryFactory를 이용하여 쿼리를 동적으로 생성합니다.
        // 생성자의 파라미터로는 EntityManager 객체를 넣어줍니다.
        JPAQueryFactory queryFactory = new JPAQueryFactory(em);
        // Querydsl을 통해 쿼리를 생성하기 위해 플러그인을 통해 자동으로 생성된 QItem 객체를 이용합니다.
        QItem qItem = QItem.item;
        // 자바 소스코드지만 SQL문과 비슷하게 소스를 작성할 수 있습니다.
        JPAQuery<Item> query = queryFactory.selectFrom(qItem)
                .where(qItem.itemSellStatus.eq(ItemSellStatus.SELL))
                .where(qItem.itemDetail.like("%" + "테스트 상품 상세 설명" + "%"))
                .orderBy(qItem.price.desc());

        // JPAQuery 메소드 중 하나인 fetch를 이용해서 쿼리 결과를 리스트로 반환합니다.
        // fetch() 메소드 실행 시점에 쿼리문이 실행됩니다.
        List<Item> itemList = query.fetch();

        for (Item item: itemList
             ) {
            System.out.println(item.toString());
        }
    }

JPAQuery에서 결과를 반환하는 메소드는 아래 표를 참고하세요

메소드기능
List<T> fetch()조회 결과 리스트 반환
T fetchOne조회 대상이 1건인 경우 제네릭으로 지정한 타입 반환
T fetchFirst()조회 대상 중 1건만 반환
Long fetchCount()조회 대상 개수 반환
QueryResult<T> fetchResults()조회한 리스트와 전체 개수를 포함한 QueryResults 반환

실행된 쿼리문을 확인해보면 JPAQuery에 추가한 판매상태 코드와 상품 상세 설명이 where 조건에 추가돼어 있고, 상품의 가격이 내림차순으로 정렬돼 데이터를 조회합니다. 이렇게 자바 코드를 이용해서 고정된 쿼리문이 아닌 비즈니스 로직에 따라서 동적으로 쿼리문을 생성할 수 있습니다.

다음 예제는 QuerydslPredicateExecutor를 이용한 상품조회 예제입니다. predicate란 이 조건이 맞다고 판단하는 근거를 함수로 제공하는 것입니다. Repository에 Predicate를 파라미터로 전달하기 위해서 QueryDslPredicateExecutor 인터페이스를 상속받습니다.

// QueryDslPredicateExecutor 인터페이스 상속을 추가합니다.
public interface ItemRepository extends JpaRepository<Item, Long>, QuerydslPredicateExecutor<Item> {

QueryDslPredicateExecutor 인터페이스는 다음 메소드들이 선언되어 있습니다.

메소드기능
long count(Predicate)조건에 맞는 데이터의 총 개수 반환
boolean exists(Predicate)조건에 맞는 데이터 존재 여부 반환
Iterable findAll(Predicate)조건에 맞는 모든 데이터 반환
Page<T> findAll(Predicate, Pageable)조건에 맞는 페이지 데이터 반환
Iterable findAll(Predicate, Sort)조건에 맞는 정렬된 데이터 반환
T findOne(Predicate)조건에 맞는 데이터 1개 반환
// 상품 데이터를 만드는 새로운 메소드를 하나 만듭니다.
    // 1번부터 5번 상품은 상품의 판매상태를 SELL(판매 중)으로 지정하고
    // 6번부터 10번까지는 판매상태를 SOLD_OUT(품절)로 세팅해 생성합니다.
    public void createItemList2() {
        for (int i = 1; i < 5; i++) {
            Item item = new Item();
            item.setItemNm("테스트 상품" + i);
            item.setPrice(10000 + i);
            item.setItemDetail("테스트 상품 상세 설명 " +i);
            item.setItemSellStatus(ItemSellStatus.SELL);
            item.setStockNumber(100);
            item.setRegTime(LocalDateTime.now());
            item.setUpdateTime(LocalDateTime.now());
            itemRepository.save(item);
        }

        for (int i = 6; i < 10; i++) {
            Item item = new Item();
            item.setItemNm("테스트 상품" + i);
            item.setPrice(10000 + i);
            item.setItemDetail("테스트 상품 상세 설명 " +i);
            item.setItemSellStatus(ItemSellStatus.SOLD_OUT);
            item.setStockNumber(0);
            item.setRegTime(LocalDateTime.now());
            item.setUpdateTime(LocalDateTime.now());
            itemRepository.save(item);
        }
    }
@Test
    @DisplayName("상품 Querydsl 조회 테스트2")
    public void queryDslTest2() {
        this.createItemList2();

        // BooleanBuilder는 쿼리에 들어갈 조건을 만들어주는 빌더라고 생각하면 됩니다.
        // Predicate를 구현하고 있으며 메소드 체인 형식으로 사용할 수 있습니다.
        BooleanBuilder booleanBuilder = new BooleanBuilder();
        QItem item = QItem.item;
        String itemDetail = "테스트 상품 상세 설명";
        int price = 10003;
        String itemSellStat ="SELL";

        // 필요한 상품을 조회하는데 필요한 "and" 조건을 추가하고 있습니다.
        // 아래 소스에서 상품의 판매상태가 SELL일 때만 booleanBuilder에 판매상태 조건을 동적으로 추가흔 것을 볼수 있다.
        booleanBuilder.and(item.itemDetail.like("%" + itemDetail +"%"));
        booleanBuilder.and(item.price.gt(price));

        if(StringUtils.equals(itemSellStat, ItemSellStatus.SELL)) {
            booleanBuilder.and(item.itemSellStatus.eq(ItemSellStatus.SELL));
        }

        // 데이터를 페이징해 조회하도록 PageRequest.of() 메소드를 이용해 Pageble 객체를 생성
        // 첫 번째 인자는 조회활 페이지 정보, 두 번째 인자는 한 페이지당 조회할 데이터 개수를 넣어줍니다.
        Pageable pageable = PageRequest.of(0, 5);

        // QueryDslPredicateExecutor 인터페이스에서 정의한 findAll() 메소드를 이용해 조건에 맞는 데이터를
        // Page 객체로 받아옵니다.
        Page<Item> itemPagingResult = itemRepository.findAll(booleanBuilder, pageable);
        System.out.println("total elements : " + itemPagingResult.getTotalElements());

        List<Item> resultItemList = itemPagingResult.getContent();

        for (Item resultItem: resultItemList
             ) {
            System.out.println(resultItem.toString());
        }
    }

이제는 화면을 알아보고자 합니다. 서버 사이드 템플릿 엔진으로는 Thymeleaf, JSP, Freemarker, Groovy 등이 있습니다. 스프링이 권장하는 것은 Thymeleaf입니다. 타임리프의 장점은 natural templates입니다. 타임리프를 사용할 때 타임리프 문법을 포함하고 있는 html 파일을 서버 사이드 렌더링을 하지않고 브라우저에 띄어도 정상적인 화면을 볼 수 있습니다. 타임리프의 확장자명은 .html이며, 타임리프 문법은 html 태그 안쪽에 속성으로 사용됩니다.


스프링 시큐리티

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

스프링 시큐리티를 추구하였다면 이제 모든 요청은 인증을 필요로 합니다. 이 상태만으로는 정상적인 서비스를 할 수 없지만 의존성을 추가하는 것만으로 모든 요청에 인증을 요구한다는 점이 인상적입니다.

URL을 치면 다음과 같은 페이지가 뜹니다.

스프링에서 기본적으로 제공하는 아이디는 user고 비밀번호는 인텔리제이 콘솔창에 출력된 값을 입력 후 Sign in 클릭하면 됩니다.

  Using generated security password: 1792250f-6183-4684-bd98-42c266ae06e9

지금은 user 계정 밖에 없으며 애플리케이션이 실행할 때마다 비밀번호도 바뀝니다. 상품을 주문하려면 회원 가입을 해야하고 각 페이지마다 필요한 권한도 다릅니다. 우선 회원가입 기능을 먼저 만들어 보겠습니다.

  • 인증이 필요 없는 경우 : 상품 상세 페이지 조회
  • 인증이 필요한 경우 : 상품 주문
  • 관리자 권한이 필요한 경우 : 상품 등록

스프링 시큐리티 설정하기

SecurityConfig 소스를 작성하겠습니다. 현재는 모든 요청에 인증을 필요로 하지만 SecurityConfig.java의 configure 메소드에 설정을 추가하지 않으면 요청에 인증을 요구하지 않습니다.

여기서는 책과 다릅니다. 변경 상황이 있기 때문입니다.

기존에는 WebSecurityConfigurerAdapter를 상속받아 설정을 오버라이딩 하는 방식이었는데 바뀐 방식에서는 상속받아 오버라이딩하지 않고 모두 Bean으로 등록을 합니다.

package com.example.shoppingmall.config.security;

import com.example.shoppingmall.config.jwt.JwtAccessDeniedHandler;
import com.example.shoppingmall.config.jwt.JwtAuthenticationEntryPoint;
import com.example.shoppingmall.config.jwt.JwtProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

import java.util.HashMap;
import java.util.Map;

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
// @EnableGlobalMethodSecurity 어노테이션은 Spring Security에서 메서드 수준의 보안 설정을 활성화하는데 사용되는 어노테이션입니다.
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig {

    private final JwtProvider jwtProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .csrf().disable()
                .formLogin().disable()
                .logout().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http
                .authorizeRequests()
                    .antMatchers("/api/v1/boards/**")
                        .access("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
                    .antMatchers("/api/v1/admin/**")
                        .access("hasRole('ROLE_ADMIN')")
                    .antMatchers("/api/v1/items/**")
                        .access("hasRole('ROLE_ADMIN')")
                    // /success-oauth 엔드포인트에 대해 인증된 사용자만 접근 가능하도록 설정
//                  .antMatchers("/success-oauth").authenticated()
                    .antMatchers("/swagger-resources/**").permitAll()
                    .antMatchers("/swagger-ui/**").permitAll()
                    .antMatchers("/api/v1/users/**").permitAll();

        http
                .apply(new JwtSecurityConfig(jwtProvider));

        http
                .exceptionHandling()
                .authenticationEntryPoint(new JwtAuthenticationEntryPoint())
                .accessDeniedHandler(new JwtAccessDeniedHandler());

        return http.build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        String idForEncode = "bcrypt";
        Map<String, PasswordEncoder> encoders = new HashMap<>();
        encoders.put(idForEncode, new BCryptPasswordEncoder());

        return new DelegatingPasswordEncoder(idForEncode, encoders);
    }
}
package com.example.shoppingmall.config.security;

import com.example.shoppingmall.config.jwt.JwtAuthenticationFilter;
import com.example.shoppingmall.config.jwt.JwtProvider;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

public class JwtSecurityConfig  extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private JwtProvider jwtProvider;

    public JwtSecurityConfig(JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    @Override
    public void configure(HttpSecurity builder) throws Exception {
        JwtAuthenticationFilter customFilter = new JwtAuthenticationFilter(jwtProvider);
        builder.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

JWT 적용

package com.example.shoppingmall.config.jwt;

import com.example.shoppingmall.dto.jwt.TokenDTO;
import com.example.shoppingmall.dto.member.Role;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import javax.xml.bind.DatatypeConverter;
import java.security.Key;
import java.util.*;
import java.util.stream.Collectors;

@Slf4j
@Component
public class JwtProvider {

    private static final String AUTHORITIES_KEY = "auth";

    @Value("${jwt.access.expiration}")
    private long accessTokenTime;

    @Value("${jwt.refresh.expiration}")
    private long refreshTokenTime;

    private Key key;

    public JwtProvider(@Value("${jwt.secret_key}") String secret_key) {
        byte[] secretKey = DatatypeConverter.parseBase64Binary(secret_key);
        this.key = Keys.hmacShaKeyFor(secretKey);
    }

    // JWT 생성
    public TokenDTO createToken(Authentication authentication, List<GrantedAuthority> authorities) {
        //  UsernamePasswordAuthenticationToken
        //  [Principal=zxzz45@naver.com, Credentials=[PROTECTED], Authenticated=false, Details=null, Granted Authorities=[]]
        // 여기서 Authenticated=false는 아직 정상임
        // 이 시점에서는 아직 실제로 인증이 이루어지지 않았기 때문에 Authenticated 속성은 false로 설정
        // 인증 과정은 AuthenticationManager와 AuthenticationProvider에서 이루어지며,
        // 인증이 성공하면 Authentication 객체의 isAuthenticated() 속성이 true로 변경됩니다.
        log.info("authentication in JwtProvider : " + authentication);

        // userType in JwtProvider : ROLE_USER
        log.info("userType in JwtProvider : " + authorities);

        // 권한 가져오기
        //  authentication 객체에서 권한 정보(GrantedAuthority)를 가져와 문자열 형태로 변환한 후,
        //  쉼표로 구분하여 조인한 결과를 authorities 변수에 저장합니다. 따라서 authorities는 권한 정보를 문자열 형태로 가지게 됩니다.
        // 권한 정보를 문자열로 변환하여 클레임에 추가하는 방식
//        String authorities = authentication.getAuthorities().stream()
//                .map(GrantedAuthority::getAuthority)
//                .collect(Collectors.joining(","));

        Map<String, Object> claims = new HashMap<>();
        claims.put(AUTHORITIES_KEY, authorities.stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()));
        claims.put("sub", authentication.getName());
        log.info("claims : " + claims);

        long now = (new Date()).getTime();
        Date now2 = new Date();

        // AccessToken 생성
        Date accessTokenExpire = new Date(now + this.accessTokenTime);
        String accessToken = Jwts.builder()
                // 내용 sub : 유저의 이메일
                // 토큰 제목
                // JWT의 "sub" 클레임을 설정하는 메서드입니다.
                // "sub" 클레임은 일반적으로 사용자를 식별하는 용도로 사용되며,
                // 이메일과 같은 사용자의 고유한 식별자를 담고 있을 수 있습니다.
                .setIssuedAt(now2)
                // 클레임 id : 유저 ID
//                .claim(AUTHORITIES_KEY, authorities)
                .setClaims(claims)
                // 내용 exp : 토큰 만료 시간, 시간은 NumericDate 형식(예: 1480849143370)으로 하며
                // 항상 현재 시간 이후로 설정합니다.
                .setExpiration(accessTokenExpire)
                // 서명 : 비밀값과 함께 해시값을 ES256 방식으로 암호화
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();


        Claims claims2 = Jwts.parser().setSigningKey(key).parseClaimsJws(accessToken).getBody();
        String subject = claims2.getSubject();
        // claims subject 확인 in JwtProvider : zxzz45@naver.com
        log.info("★claims subject 확인 in JwtProvider : " + subject);


        // accessToken in JwtProvider : eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ6eHp6NDVAbmF2ZXIuY2
        // 9tIiwiaWF0IjoxNjg5OTk1MzM3LCJhdXRoIjoiUk9MRV9VU0VSIiwiZXhwIjoxNjkzNTk1MzM3fQ.2_2PR-A
        // X9N0jKDyA7LpK7xRRBZBYZ17_f8Jq2TY4ny8
        log.info("accessToken in JwtProvider : " + accessToken);

        // claim에서 auth 확인 in JwtProvider : ROLE_USER
        log.info("claim에서 accessToken에 담김 auth 확인 in JwtProvider : " + claims);

        // RefreshToken 생성
        Date refreshTokenExpire = new Date(now + this.refreshTokenTime);
        String refreshToken = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now2)
                .setExpiration(refreshTokenExpire)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        Claims claims3 = Jwts.parser().setSigningKey(key).parseClaimsJws(refreshToken).getBody();
        String subject2 = claims3.getSubject();
        // claims subject 확인 in JwtProvider : zxzz45@naver.com
        log.info("★claims subject 확인 in JwtProvider : " + subject2);

        log.info("refreshToken in JwtProvider : " + refreshToken);
        log.info("claim에서 refreshToken에 담긴 auth 확인 in JwtProvider : " + claims);

        TokenDTO tokenDTO= TokenDTO.builder()
                .grantType("Bearer ")
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .accessTokenTime(accessTokenExpire)
                .refreshTokenTime(refreshTokenExpire)
                // principalDeatails에서 getUserName 메소드가 반환한 것을 담아준다.
                // 이메일을 반환하도록 구성했으니 이메일이 반환됩니다.
                .userEmail(authentication.getName())
                .build();

        log.info("tokenDTO in JwtProvider : " + tokenDTO);

        return tokenDTO;
    }

    // access token 만료시 refresh token으로 access token 발급
    public TokenDTO createAccessToken(String userEmail, List<GrantedAuthority> authorities) {
        Long now = (new Date()).getTime();
        Date now2 = new Date();
        Date accessTokenExpire = new Date(now + this.accessTokenTime);

        log.info("authorities : " + authorities);

        Map<String, Object> claims = new HashMap<>();
        claims.put(AUTHORITIES_KEY, authorities.stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()));
        // setSubject이다.
        // 클레임에 subject를 넣는것
        claims.put("sub", userEmail);

        log.info("claims : " + claims);

        String accessToken = Jwts.builder()
                .setIssuedAt(now2)
                .setClaims(claims)
                .setExpiration(accessTokenExpire)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        log.info("accessToken in JwtProvider : " + accessToken);

        Claims claims2 = Jwts.parser().setSigningKey(key).parseClaimsJws(accessToken).getBody();
        String subject = claims2.getSubject();
        // claims subject 확인 in JwtProvider : zxzz45@naver.com
        log.info("★claims subject 확인 in JwtProvider : " + subject);

        TokenDTO tokenDTO = TokenDTO.builder()
                .grantType("Bearer ")
                .accessToken(accessToken)
                .userEmail(userEmail)
                .accessTokenTime(accessTokenExpire)
                .build();

        log.info("tokenDTO in JwtProvider : " + tokenDTO);
        return tokenDTO;
    }

    // JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 코드
    // 토큰으로 클레임을 만들고 이를 이용해 유저 객체를 만들어서 최종적으로 authentication 객체를 리턴
    // 인증 정보 조회
    public Authentication getAuthentication(String token) {
        // 토큰 복호화 메소드
        Claims claims = parseClaims(token);
        log.info("claims in JwtProvider  : " + claims);

        if(claims.get("auth") == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        Object auth = claims.get("auth");
        // [ROLE_USER]
        log.info("auth in JwtProvider : " + auth);

        // 클레임 권한 정보 가져오기
        List<String> authorityStrings = (List<String>) claims.get(AUTHORITIES_KEY);
        // [ROLE_USER]
        log.info("authorityStrings in JwtProvider : " + authorityStrings);

        Collection<? extends GrantedAuthority> authorities =
                authorityStrings.stream()
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // [ROLE_USER]
        log.info("authorities in JwtProvider : " + authorities);

        // UserDetails 객체를 만들어서 Authentication 리턴
//        User principal = new User(claims.getSubject(), "", authorities);
//        log.info("principal in JwtProvider : " + principal);

        return new UsernamePasswordAuthenticationToken(claims.getSubject(), token, authorities);
    }

    private Claims parseClaims(String token) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch (ExpiredJwtException e) {
            log.info("ExpiredJwtException : " + e.getMessage());
            log.info("ExpiredJwtException : " + e.getClaims());

            return e.getClaims();
        }
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            log.error("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.error("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.error("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.error("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }
}

authorityStrings에 있는 각 권한들을 SimpleGrantedAuthority로 변환하고, 이를 authorities라는 Collection으로 저장합니다. SimpleGrantedAuthority는 Spring Security에서 사용되는 GrantedAuthority 인터페이스의 구현체입니다. GrantedAuthority는 사용자의 권한을 나타내는 인터페이스로, Spring Security에서 인증과 권한 검사를 위해 사용됩니다.

package com.example.shoppingmall.config.jwt;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
    public static final String HEADER_AUTHORIZATION = "Authorization";
    private final JwtProvider jwtProvider;

    @Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;

        // 헤더에서 jwt 추출
        String jwt = resovleToken(httpServletRequest);
        String requestURI = httpServletRequest.getRequestURI();

        if(StringUtils.hasText(jwt) && jwtProvider.validateToken(jwt)) {
            // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장
            Authentication authentication = jwtProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            log.info("Security Context에 '{}' 인증 정보를 저장했습니다., uri : {}",
                    authentication.getName(), requestURI);
        } else {
            log.debug("유효한 JWT 토큰이 없습니다. uri : {}", requestURI);
        }
        chain.doFilter(request, response);
    }

    private String resovleToken(HttpServletRequest httpServletRequest) {
        String token = httpServletRequest.getHeader(HEADER_AUTHORIZATION);

        if(StringUtils.hasText(token) && token.startsWith("Bearer ")) {
            return token.substring(7);
        } else {
            return null;
        }
    }
}
package com.example.shoppingmall.config.jwt;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}
package com.example.shoppingmall.config.jwt;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}
package com.example.shoppingmall.config.auth;

import com.example.shoppingmall.entity.member.MemberEntity;
import com.example.shoppingmall.repository.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
@Slf4j
public class PrincipalDetailsService implements UserDetailsService {

    private MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        MemberEntity member = memberRepository.findByUserEmail(username);
        log.info("user in PrincipalDetailsService : " + member);
        return new PrincipalDetails(member);
    }
}
package com.example.shoppingmall.config.auth;

import com.example.shoppingmall.entity.member.MemberEntity;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;

@Setter
@Getter
@ToString
public class PrincipalDetails implements UserDetails {

    private MemberEntity member;

    // 일반 로그인
    // 여기서는 Oauth2를 사용하지 않고 JWT와 security만 사용할 거임
    public PrincipalDetails(MemberEntity member) {
        this.member = member;
    }

    // 권한을 리턴
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(new SimpleGrantedAuthority("ROLE_" + member.getRole().toString()));
        return collection;
    }

    @Override
    public String getPassword() {
        return member.getUserPw();
    }

    @Override
    public String getUsername() {
        return member.getUserEmail();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

TokenDTO

package com.example.shoppingmall.dto.jwt;


import com.example.shoppingmall.dto.member.Role;
import com.example.shoppingmall.entity.jwt.TokenEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import net.minidev.json.annotate.JsonIgnore;
import org.hibernate.usertype.UserType;

import java.util.Date;

@Getter
@ToString
@NoArgsConstructor
public class TokenDTO {
    @JsonIgnore
    private Long id;
    private String grantType;
    private String accessToken;
    private String refreshToken;
    private String userEmail;
    private String nickName;
    private Long userId;
    private Date accessTokenTime;
    private Date refreshTokenTime;
    private Role role;

    @Builder
    public TokenDTO(Long id,
                    String grantType,
                    String accessToken,
                    String refreshToken,
                    String userEmail,
                    String nickName,
                    Long userId,
                    Date accessTokenTime,
                    Date refreshTokenTime,
                    Role role) {
        this.id = id;
        this.grantType = grantType;
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
        this.userEmail = userEmail;
        this.nickName = nickName;
        this.userId = userId;
        this.accessTokenTime = accessTokenTime;
        this.refreshTokenTime = refreshTokenTime;
        this.role = role;
    }

}

TokenEntity

package com.example.shoppingmall.entity.jwt;

import com.example.shoppingmall.dto.member.Role;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import java.util.Date;

@Entity(name = "token")
@Getter
@NoArgsConstructor
@ToString
public class TokenEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String grantType;
    private String accessToken;
    private String refreshToken;
    private String userEmail;
    private String nickName;
    private Long userId;
    private Date accessTokenTime;
    private Date refreshTokenTime;
    private Role role;


    @Builder
    public TokenEntity(Long id,
                       String grantType,
                       String accessToken,
                       String refreshToken,
                       String userEmail,
                       String nickName,
                       Long userId,
                       Date accessTokenTime,
                       Date refreshTokenTime,
                       Role role) {
        this.id = id;
        this.grantType = grantType;
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
        this.userEmail = userEmail;
        this.nickName = nickName;
        this.userId = userId;
        this.accessTokenTime = accessTokenTime;
        this.refreshTokenTime = refreshTokenTime;
        this.role = role;
    }
}

TokenRepository

package com.example.shoppingmall.repository.jwt;

import com.example.shoppingmall.entity.jwt.TokenEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface TokenRepository extends JpaRepository<TokenEntity, Long> {
    TokenEntity findByRefreshToken(String refreshToken);
    TokenEntity findByUserEmail(String userEmail);
}

MemberController

  // 로그인
    @PostMapping("/api/v1/users/login")
    public ResponseEntity<?> login(@RequestBody MemberDTO memberDTO) throws Exception {
        log.info("member : " + memberDTO);
        try {
            log.info("-----------------");

            ResponseEntity<TokenDTO> login =
                    memberService.login(memberDTO.getUserEmail(), memberDTO.getUserPw());
            log.info("login : " + login);

            return ResponseEntity.ok().body(login);
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("문제가 있습니다");
        }
    }

    // refresh로 access 토큰 재발급
    // @RequsetHeader"Authorization")은 Authorization 헤더에서 값을 추출합니다.
    // 일반적으로 리프레시 토큰은 Authorization 헤더의 값으로 전달되며,
    // Bearer <token> 형식을 따르는 경우가 일반적입니다. 여기서 <token> 부분이 실제 리프레시 토큰입니다
    // 로 추출하면 다음과 같이 문자열로 나온다.
    // Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0IiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
    @PostMapping("/refresh")
    public ResponseEntity<?> refreshToken(@RequestHeader("Authorization") String token) throws Exception {
        try {
            if(token != null) {
                ResponseEntity<TokenDTO> accessToken = refreshTokenService.createAccessToken(token);
                return ResponseEntity.ok().body(accessToken);
            } else {
                return ResponseEntity.notFound().build();
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

MemberSerivce

  // 로그인
    public ResponseEntity<TokenDTO> login(String userEmail, String userPw) throws Exception {

        MemberEntity findUser = memberRepository.findByUserEmail(userEmail);
        log.info("findUser : " + findUser);


        if (findUser != null) {
            // 사용자가 입력한 패스워드를 암호화하여 사용자 정보와 비교
            if (passwordEncoder.matches(userPw, findUser.getUserPw())) {
                // UsernamePasswordAuthenticationToken은 Spring Security에서
                // 사용자의 이메일과 비밀번호를 이용하여 인증을 진행하기 위해 제공되는 클래스
                // 이후에는 생성된 authentication 객체를 AuthenticationManager를 이용하여 인증을 진행합니다.
                // AuthenticationManager는 인증을 담당하는 Spring Security의 중요한 인터페이스로, 실제로 사용자의 인증 과정을 처리합니다.
                // AuthenticationManager를 사용하여 사용자가 입력한 이메일과 비밀번호가 올바른지 검증하고,
                // 인증에 성공하면 해당 사용자에 대한 Authentication 객체를 반환합니다. 인증에 실패하면 예외를 발생시킵니다.
                // 인증은 토큰을 서버로 전달하고, 서버에서 해당 토큰을 검증하여 사용자를 인증하는 단계에서 이루어집니다.
                Authentication authentication = new UsernamePasswordAuthenticationToken(userEmail, userPw);

                //  UsernamePasswordAuthenticationToken
                //  [Principal=zxzz45@naver.com, Credentials=[PROTECTED], Authenticated=false, Details=null, Granted Authorities=[]]
                // 여기서 Authenticated=false는 아직 정상임
                // 이 시점에서는 아직 실제로 인증이 이루어지지 않았기 때문에 Authenticated 속성은 false로 설정
                // 인증 과정은 AuthenticationManager와 AuthenticationProvider에서 이루어지며,
                // 인증이 성공하면 Authentication 객체의 isAuthenticated() 속성이 true로 변경됩니다.
                log.info("authentication in MemberService : " + authentication);

                List<GrantedAuthority> authoritiesForUser = getAuthoritiesForUser(findUser);

//                TokenDTO token = jwtProvider.createToken(authentication, findUser.getUserType());
                TokenDTO token = jwtProvider.createToken(authentication, authoritiesForUser);

                log.info("tokenEmail in MemberService : "+ token.getUserEmail());

                TokenEntity checkEmail = tokenRepository.findByUserEmail(token.getUserEmail());
                log.info("checkEmail in MemberService : " + checkEmail);

                // 사용자에게 이미 토큰이 할당되어 있는지 확인합니다.
                if (checkEmail != null) {
                    log.info("이미 발급한 토큰이 있습니다.");
                    //  // 기존 토큰을 업데이트할 때 사용할 임시 객체로 TokenDTO token2를 생성합니다.
                    token = TokenDTO.builder()
                            .id(checkEmail.getId())
                            .grantType(token.getGrantType())
                            .accessToken(token.getAccessToken())
                            .refreshToken(token.getRefreshToken())
                            .userEmail(token.getUserEmail())
                            .nickName(findUser.getNickName())
                            .userId(findUser.getUserId())
                            .accessTokenTime(token.getAccessTokenTime())
                            .refreshTokenTime(token.getRefreshTokenTime())
                            .role(findUser.getRole())
                            .build();
                    // 기존 토큰을 업데이트할 때 사용할 임시 객체로 TokenEntity tokenEntity2를 생성합니다.
                    TokenEntity updateToken = TokenEntity.builder()
                            .grantType(token.getGrantType())
                            .accessToken(token.getAccessToken())
                            .refreshToken(token.getRefreshToken())
                            .userEmail(token.getUserEmail())
                            .nickName(token.getNickName())
                            .userId(token.getUserId())
                            .accessTokenTime(token.getAccessTokenTime())
                            .refreshTokenTime(token.getRefreshTokenTime())
                            .role(token.getRole())
                            .build();

                    log.info("token in MemberService : " + updateToken);
                    tokenRepository.save(updateToken);
                } else {
                    log.info("발급한 토큰이 없습니다.");
                    token = TokenDTO.builder()
                            .grantType(token.getGrantType())
                            .accessToken(token.getAccessToken())
                            .refreshToken(token.getRefreshToken())
                            .userEmail(token.getUserEmail())
                            .nickName(findUser.getNickName())
                            .userId(findUser.getUserId())
                            .accessTokenTime(token.getAccessTokenTime())
                            .refreshTokenTime(token.getRefreshTokenTime())
                            .role(findUser.getRole())
                            .build();

                    // 새로운 토큰을 DB에 저장할 때 사용할 임시 객체로 TokenEntity tokenEntity를 생성합니다.
                    TokenEntity newToken = TokenEntity.builder()
                            .grantType(token.getGrantType())
                            .accessToken(token.getAccessToken())
                            .refreshToken(token.getRefreshToken())
                            .userEmail(token.getUserEmail())
                            .nickName(token.getNickName())
                            .userId(token.getUserId())
                            .accessTokenTime(token.getAccessTokenTime())
                            .refreshTokenTime(token.getRefreshTokenTime())
                            .role(token.getRole())
                            .build();


                    log.info("token in MemberService : " + newToken);
                    tokenRepository.save(newToken);
                }
                HttpHeaders headers = new HttpHeaders();
                // response header에 jwt token을 넣어줌
                headers.add(JwtAuthenticationFilter.HEADER_AUTHORIZATION, "Bearer " + token);

                return new ResponseEntity<>(token, headers, HttpStatus.OK);
            }
        } else {
            return null;
        }
        return null;
    }

    private List<GrantedAuthority> getAuthoritiesForUser(MemberEntity member) {
        // 예시: 데이터베이스에서 사용자의 권한 정보를 조회하는 로직을 구현
        // member 객체를 이용하여 데이터베이스에서 사용자의 권한 정보를 조회하는 예시로 대체합니다.
        Role role = member.getRole();  // 사용자의 권한 정보를 가져오는 로직 (예시)

        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("ROLE_" +role.name()));
        log.info("role in MemberService : " + role.name());
        log.info("authorities in MemberService : " + authorities);
        return authorities;
    }

RefreshTokenService

이거는 access token이 만료됐을 경우 refresh token을 header에 담아 보낼 때 그거를 받아서 access token을 재발급해주는 메소드

package com.example.shoppingmall.service.jwt;

import com.example.shoppingmall.config.jwt.JwtAuthenticationFilter;
import com.example.shoppingmall.config.jwt.JwtProvider;
import com.example.shoppingmall.dto.jwt.TokenDTO;
import com.example.shoppingmall.dto.member.Role;
import com.example.shoppingmall.entity.jwt.TokenEntity;
import com.example.shoppingmall.entity.member.MemberEntity;
import com.example.shoppingmall.repository.jwt.TokenRepository;
import com.example.shoppingmall.repository.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.usertype.UserType;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
@RequiredArgsConstructor
@Slf4j
public class RefreshTokenService {
    private final TokenRepository tokenRepository;
    private final JwtProvider jwtProvider;
    private final MemberRepository memberRepository;

    public ResponseEntity<TokenDTO> createAccessToken(String refreshToken) {
        if(jwtProvider.validateToken(refreshToken)) {
            TokenEntity byRefreshToken = tokenRepository.findByRefreshToken(refreshToken);
            // 아이디 추출
            String userEmail = byRefreshToken.getUserEmail();
            log.info("userEmail : " + userEmail);

            MemberEntity byUserEmail = memberRepository.findByUserEmail(userEmail);
            log.info("member : " + byUserEmail);

            List<GrantedAuthority> authorities = getAuthoritiesForUser(byUserEmail);

            TokenDTO accessToken = jwtProvider.createAccessToken(userEmail, authorities);
            log.info("accessToken : " + accessToken);


            accessToken = TokenDTO.builder()
                    .grantType(accessToken.getGrantType())
                    .accessToken(accessToken.getAccessToken())
                    .userEmail(accessToken.getUserEmail())
                    .nickName(byRefreshToken.getNickName())
                    .userId(byRefreshToken.getUserId())
                    .accessTokenTime(accessToken.getAccessTokenTime())
                    .build();

            TokenEntity tokenEntity = TokenEntity.builder()
                    .grantType(accessToken.getGrantType())
                    .accessToken(accessToken.getAccessToken())
                    .refreshToken(accessToken.getRefreshToken())
                    .userEmail(accessToken.getUserEmail())
                    .nickName(accessToken.getNickName())
                    .userId(accessToken.getUserId())
                    .accessTokenTime(accessToken.getAccessTokenTime())
                    .build();


            log.info("token : " + tokenEntity);
            tokenRepository.save(tokenEntity);


            HttpHeaders headers = new HttpHeaders();
            headers.add(JwtAuthenticationFilter.HEADER_AUTHORIZATION, "Bearer " + accessToken);

            return new ResponseEntity<>(accessToken, headers, HttpStatus.OK);
        } else {
            throw new IllegalArgumentException("Unexpected token");
        }
    }

    // 주어진 사용자에 대한 권한 정보를 가져오는 로직을 구현하는 메서드입니다.
    // 이 메서드는 데이터베이스나 다른 저장소에서 사용자의 권한 정보를 조회하고,
    // 해당 권한 정보를 List<GrantedAuthority> 형태로 반환합니다.
    private List<GrantedAuthority> getAuthoritiesForUser(MemberEntity byUserEmail) {
        // 예시: 데이터베이스에서 사용자의 권한 정보를 조회하는 로직을 구현
        // member 객체를 이용하여 데이터베이스에서 사용자의 권한 정보를 조회하는 예시로 대체합니다.
        Role role = byUserEmail.getRole();  // 사용자의 권한 정보를 가져오는 로직 (예시)

        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("ROLE_" +role.name()));
        log.info("role : " + role.name());
        log.info("authorities : " + authorities);
        return authorities;
    }
}

회원 가입 기능 구현하기

각각의 멤버는 일반 유저인지 아니면 관리자인지 구분할 수 있는 역할이 있어야 합니다. 이를 구분하기 위해서는 constant 패키지 아래에 Role 클래스를 만들어 줍니다.

Role

  package com.example.shopping_.constant;


public enum Role {
    // Role의 값
    USER, ADMIN
}

MemberDTO

package com.example.shoppingmall.dto.member;

import com.example.shoppingmall.dto.member.embedded.AddressDTO;
import com.example.shoppingmall.entity.member.MemberEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import java.util.Optional;

@ToString
@Getter
@NoArgsConstructor
public class MemberDTO {
    private Long userId;
    @NotNull(message = "이메일은 필수 입력입니다.")
    @Pattern(regexp = "^(?:\\w+\\.?)*\\w+@(?:\\w+\\.)+\\w+$", message = "이메일 형식이 올바르지 않습니다.")
    @Email(message = "이메일 형식에 맞지 않습니다.")
    private String userEmail;

    @NotNull(message = "이름은 필수입력입니다.")
    private String userName;
    private String nickName;

    @NotNull(message = "비밀번호는 필 수 입니다.")
    private String userPw;

    @NotNull(message = "유저 타입은 필 수 입니다.")
    private Role role;

    private AddressDTO addressDTO;

    @Builder
    public MemberDTO(Long userId,
                     String userEmail,
                     String nickName,
                     String userName,
                     String userPw,
                     Role role,
                     AddressDTO addressDTO) {
        this.userId = userId;
        this.userEmail = userEmail;
        this.userName = userName;
        this.userPw = userPw;
        this.nickName = nickName;
        this.role = role;
        this.addressDTO = addressDTO;
    }

    public static MemberDTO toMemberDTO(Optional<MemberEntity> member) {
        MemberDTO memberDTO = MemberDTO.builder()
                .userId(member.get().getUserId())
                .userEmail(member.get().getUserEmail())
                .userName(member.get().getUserName())
                .userPw(member.get().getUserPw())
                .nickName(member.get().getNickName())
                .role(member.get().getRole())
                .addressDTO(AddressDTO.builder()
                        .userAddr(member.get().getAddress().getUserAddr())
                        .userAddrDetail(member.get().getAddress().getUserAddrDetail())
                        .userAddrEtc(member.get().getAddress().getUserAddrEtc())
                        .build())
                .build();

        return memberDTO;
    }
}

회원 가입 화면으로부터 넘어오는 가입정보를 담을 dto를 생성했습니다.

이제 회원 정보를 저장하는 Member 엔티티를 만들겠습니다. 관리할 회원 정보는 이름, 이메일, 비밀번호, 주소, 역할입니다.

MemberEntity

package com.example.shoppingmall.entity.member;

import com.example.shoppingmall.dto.member.Role;
import com.example.shoppingmall.entity.base.BaseEntity;
import com.example.shoppingmall.entity.member.embedded.AddressEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.*;

@Entity(name = "member")
@Getter
@ToString
@NoArgsConstructor
public class MemberEntity extends BaseEntity {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id", nullable = false)
    private Long userId;

    @Column(name = "user_name", nullable = false)
    private  String userName;

    @Column(name = "user_email", nullable = false)
    private String userEmail;

    @Column(name = "user_pw")
    private String userPw;

    @Column(name = "nick_name")
    private String nickName;

    @Enumerated(EnumType.STRING)
    // ROLE_USER, ROLE_ADMIN
    private Role role;

    // OAuth2 가입할 때를 위해서

    @Embedded
    private AddressEntity address;

    @Builder
    public MemberEntity(
            Long userId,
            String userName,
                        String userEmail,
                        String userPw,
                        String nickName,
                        Role role,
                        AddressEntity address) {
        this.userId = userId;
        this.userName = userName;
        this.userEmail = userEmail;
        this.userPw = userPw;
        this.nickName = nickName;
        this.role = role;
        this.address = address;
    }


}

Member 엔티티를 데이터베이스에 저장할 수 있도록 MemberRepository를 만듭니다.

package com.example.shoppingmall.repository.member;

import com.example.shoppingmall.entity.member.MemberEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface MemberRepository extends JpaRepository<MemberEntity, Long> {
  // 회원 가입 시 중복된 회원이 있는지 검사하기 위해서
  // 이메일로 회원을 검사할 수 있도록 쿼리 메소드를 작성
  MemberEntity findByUserEmail(String email);
  MemberEntity deleteByUserId(Long userId);
}

service 패키지를 만들고 MemberService를 만든다.

package com.example.shoppingmall.service.member;

import com.example.shoppingmall.config.jwt.JwtAuthenticationFilter;
import com.example.shoppingmall.config.jwt.JwtProvider;
import com.example.shoppingmall.dto.jwt.TokenDTO;
import com.example.shoppingmall.dto.member.MemberDTO;
import com.example.shoppingmall.dto.member.Role;
import com.example.shoppingmall.entity.jwt.TokenEntity;
import com.example.shoppingmall.entity.member.MemberEntity;
import com.example.shoppingmall.entity.member.embedded.AddressEntity;
import com.example.shoppingmall.repository.jwt.TokenRepository;
import com.example.shoppingmall.repository.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.usertype.UserType;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

@Service
// 비즈니스 로직을 담당하는 서비스 계층 클래스에
// @Transactional 어노테이션을 선언합니다.
// 로직을 처리하다가 에러가 발생하면
// 변경된 데이터 로직을 처리하기 전으로 콜백해줍니다.
@Transactional
// 빈 주입 방법중 한 개인데
// @NonNull 이나 final 붙은 필드에 생성자를 생성
@RequiredArgsConstructor
@Slf4j
// MemberService가 UserDetailsService를 구현합니다.
public class MemberService {

  private final MemberRepository memberRepository;
  private final PasswordEncoder passwordEncoder;
  private final AuthenticationManagerBuilder authenticationManagerBuilder;
  private final JwtProvider jwtProvider;
  private final TokenRepository tokenRepository;

  // 회원가입
  public String signUp(MemberDTO memberDTO) throws Exception {

      try {
          MemberEntity byUserEmail = memberRepository.findByUserEmail(memberDTO.getUserEmail());

          if (byUserEmail != null) {
              return "이미 가입된 회원입니다.";
          } else {
              // 아이디가 없다면 DB에 넣어서 등록 해준다.
              MemberEntity member = MemberEntity.builder()
                      .userEmail(memberDTO.getUserEmail())
                      .userPw(passwordEncoder.encode(memberDTO.getUserPw()))
                      .userName(memberDTO.getUserName())
                      .nickName(memberDTO.getNickName())
                      .role(memberDTO.getRole())
                      .address(AddressEntity.builder()
                              .userAddr(memberDTO.getAddressDTO().getUserAddr())
                              .userAddrDetail(memberDTO.getAddressDTO().getUserAddrDetail())
                              .userAddrEtc(memberDTO.getAddressDTO().getUserAddrEtc())
                              .build())
                      .build();

              log.info("member : " + member);
              memberRepository.save(member);

//            MemberDTO memberDTO1 = MemberDTO.toMemberDTO(Optional.of(save));

              return "회원가입에 성공했습니다.";
          }

      } catch (Exception e) {
          log.error(e.getMessage());
          throw e; // 예외를 던져서 예외 처리를 컨트롤러로 전달
      }

  }

  // 아이디 조회
  public MemberDTO search(Long userId) {
      Optional<MemberEntity> searchId = memberRepository.findById(userId);
      MemberDTO memberDTO = MemberDTO.toMemberDTO(searchId);
      return memberDTO;
  }

  // 회원 삭제
  public String remove(Long userId) {
      MemberEntity member = memberRepository.deleteByUserId(userId);

      if(member == null) {
          return "회원 탈퇴 완료!";
      } else {
          return "회원 탈퇴 실패!";
      }
  }

  // 로그인
  public ResponseEntity<TokenDTO> login(String userEmail, String userPw) throws Exception {

      MemberEntity findUser = memberRepository.findByUserEmail(userEmail);
      log.info("findUser : " + findUser);


      if (findUser != null) {
          // 사용자가 입력한 패스워드를 암호화하여 사용자 정보와 비교
          if (passwordEncoder.matches(userPw, findUser.getUserPw())) {
              // UsernamePasswordAuthenticationToken은 Spring Security에서
              // 사용자의 이메일과 비밀번호를 이용하여 인증을 진행하기 위해 제공되는 클래스
              // 이후에는 생성된 authentication 객체를 AuthenticationManager를 이용하여 인증을 진행합니다.
              // AuthenticationManager는 인증을 담당하는 Spring Security의 중요한 인터페이스로, 실제로 사용자의 인증 과정을 처리합니다.
              // AuthenticationManager를 사용하여 사용자가 입력한 이메일과 비밀번호가 올바른지 검증하고,
              // 인증에 성공하면 해당 사용자에 대한 Authentication 객체를 반환합니다. 인증에 실패하면 예외를 발생시킵니다.
              // 인증은 토큰을 서버로 전달하고, 서버에서 해당 토큰을 검증하여 사용자를 인증하는 단계에서 이루어집니다.
              Authentication authentication = new UsernamePasswordAuthenticationToken(userEmail, userPw);

              //  UsernamePasswordAuthenticationToken
              //  [Principal=zxzz45@naver.com, Credentials=[PROTECTED], Authenticated=false, Details=null, Granted Authorities=[]]
              // 여기서 Authenticated=false는 아직 정상임
              // 이 시점에서는 아직 실제로 인증이 이루어지지 않았기 때문에 Authenticated 속성은 false로 설정
              // 인증 과정은 AuthenticationManager와 AuthenticationProvider에서 이루어지며,
              // 인증이 성공하면 Authentication 객체의 isAuthenticated() 속성이 true로 변경됩니다.
              log.info("authentication in MemberService : " + authentication);

              List<GrantedAuthority> authoritiesForUser = getAuthoritiesForUser(findUser);

//                TokenDTO token = jwtProvider.createToken(authentication, findUser.getUserType());
              TokenDTO token = jwtProvider.createToken(authentication, authoritiesForUser);

              log.info("tokenEmail in MemberService : "+ token.getUserEmail());

              TokenEntity checkEmail = tokenRepository.findByUserEmail(token.getUserEmail());
              log.info("checkEmail in MemberService : " + checkEmail);

              // 사용자에게 이미 토큰이 할당되어 있는지 확인합니다.
              if (checkEmail != null) {
                  log.info("이미 발급한 토큰이 있습니다.");
                  //  // 기존 토큰을 업데이트할 때 사용할 임시 객체로 TokenDTO token2를 생성합니다.
                  token = TokenDTO.builder()
                          .id(checkEmail.getId())
                          .grantType(token.getGrantType())
                          .accessToken(token.getAccessToken())
                          .refreshToken(token.getRefreshToken())
                          .userEmail(token.getUserEmail())
                          .nickName(findUser.getNickName())
                          .userId(findUser.getUserId())
                          .accessTokenTime(token.getAccessTokenTime())
                          .refreshTokenTime(token.getRefreshTokenTime())
                          .role(findUser.getRole())
                          .build();
                  // 기존 토큰을 업데이트할 때 사용할 임시 객체로 TokenEntity tokenEntity2를 생성합니다.
                  TokenEntity updateToken = TokenEntity.builder()
                          .grantType(token.getGrantType())
                          .accessToken(token.getAccessToken())
                          .refreshToken(token.getRefreshToken())
                          .userEmail(token.getUserEmail())
                          .nickName(token.getNickName())
                          .userId(token.getUserId())
                          .accessTokenTime(token.getAccessTokenTime())
                          .refreshTokenTime(token.getRefreshTokenTime())
                          .role(token.getRole())
                          .build();

                  log.info("token in MemberService : " + updateToken);
                  tokenRepository.save(updateToken);
              } else {
                  log.info("발급한 토큰이 없습니다.");
                  token = TokenDTO.builder()
                          .grantType(token.getGrantType())
                          .accessToken(token.getAccessToken())
                          .refreshToken(token.getRefreshToken())
                          .userEmail(token.getUserEmail())
                          .nickName(findUser.getNickName())
                          .userId(findUser.getUserId())
                          .accessTokenTime(token.getAccessTokenTime())
                          .refreshTokenTime(token.getRefreshTokenTime())
                          .role(findUser.getRole())
                          .build();

                  // 새로운 토큰을 DB에 저장할 때 사용할 임시 객체로 TokenEntity tokenEntity를 생성합니다.
                  TokenEntity newToken = TokenEntity.builder()
                          .grantType(token.getGrantType())
                          .accessToken(token.getAccessToken())
                          .refreshToken(token.getRefreshToken())
                          .userEmail(token.getUserEmail())
                          .nickName(token.getNickName())
                          .userId(token.getUserId())
                          .accessTokenTime(token.getAccessTokenTime())
                          .refreshTokenTime(token.getRefreshTokenTime())
                          .role(token.getRole())
                          .build();


                  log.info("token in MemberService : " + newToken);
                  tokenRepository.save(newToken);
              }
              HttpHeaders headers = new HttpHeaders();
              // response header에 jwt token을 넣어줌
              headers.add(JwtAuthenticationFilter.HEADER_AUTHORIZATION, "Bearer " + token);

              return new ResponseEntity<>(token, headers, HttpStatus.OK);
          }
      } else {
          return null;
      }
      return null;
  }

  private List<GrantedAuthority> getAuthoritiesForUser(MemberEntity member) {
      // 예시: 데이터베이스에서 사용자의 권한 정보를 조회하는 로직을 구현
      // member 객체를 이용하여 데이터베이스에서 사용자의 권한 정보를 조회하는 예시로 대체합니다.
      Role role = member.getRole();  // 사용자의 권한 정보를 가져오는 로직 (예시)

      List<GrantedAuthority> authorities = new ArrayList<>();
      authorities.add(new SimpleGrantedAuthority("ROLE_" +role.name()));
      log.info("role in MemberService : " + role.name());
      log.info("authorities in MemberService : " + authorities);
      return authorities;
  }


  // 회원정보 수정
  public MemberDTO update(MemberDTO memberDTO) {

      MemberEntity findUser = memberRepository.findByUserEmail(memberDTO.getUserEmail());

      // 새로 가입
      if(findUser == null) {
          findUser = MemberEntity.builder()
                  .userEmail(memberDTO.getUserEmail())
                  .userPw(passwordEncoder.encode(memberDTO.getUserPw()))
                  .role(memberDTO.getRole())
                  .userName(memberDTO.getUserName())
                  .nickName(memberDTO.getNickName())
                  .address(AddressEntity.builder()
                          .userAddr(memberDTO.getAddressDTO().getUserAddr())
                          .userAddrDetail(memberDTO.getAddressDTO().getUserAddrDetail())
                          .userAddrEtc(memberDTO.getAddressDTO().getUserAddrEtc())
                          .build()).build();

          memberRepository.save(findUser);
          MemberDTO modifyUser = MemberDTO.toMemberDTO(Optional.of(findUser));
          return modifyUser;
      } else {
          // 회원 수정
          findUser = MemberEntity.builder()
                  // id를 식별해서 수정
                  // 이거 없으면 새로 저장하기 됨
                  // findUser꺼를 쓰면 db에 입력된거를 사용하기 때문에
                  // 클라이언트에서 userEmail을 전달하더라도 서버에서 기존 값으로 업데이트가 이루어질 것입니다.
                  // 이렇게 하면 userEmail을 수정하지 못하게 할 수 있습니다.
                  .userId(findUser.getUserId())
                  .userEmail(findUser.getUserEmail())
                  .userPw(passwordEncoder.encode(memberDTO.getUserPw()))
                  .userName(memberDTO.getUserName())
                  .nickName(memberDTO.getNickName())
                  .role(memberDTO.getRole())
                  .address(AddressEntity.builder()
                          .userAddr(memberDTO.getAddressDTO().getUserAddr())
                          .userAddrDetail(memberDTO.getAddressDTO().getUserAddrDetail())
                          .userAddrEtc(memberDTO.getAddressDTO().getUserAddrEtc())
                          .build())
                  .build();

          memberRepository.save(findUser);
          // 제대로 DTO 값이 엔티티에 넣어졌는지 확인하기 위해서
          // 엔티티에 넣어주고 다시 DTO 객체로 바꿔서 리턴을 해줬습니다.
          MemberDTO memberDto = MemberDTO.toMemberDTO(Optional.of(findUser));
          log.info("memberDto : " + memberDto);
          return memberDto;
      }
  }

}

회원가입 로직을 완성했으므로 회원 가입을 위한 페이지를 만들겠습니다. Controller 패키지 아래에 MemberController 클래스를 만듭니다.

  package com.example.shopping_.controller;

import com.example.shopping_.DTO.MemberFormDTO;
import com.example.shopping_.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {
    
    private final MemberService memberService;
    
    @GetMapping("/new")
    private String memberForm(Model model){
        model.addAttribute("memberFormDTO", new MemberFormDTO());
        return "member/memberForm";
    }
}

MemberController

package com.example.shoppingmall.controller.member;

import com.example.shoppingmall.dto.jwt.TokenDTO;
import com.example.shoppingmall.dto.member.MemberDTO;
import com.example.shoppingmall.entity.member.MemberEntity;
import com.example.shoppingmall.service.jwt.RefreshTokenService;
import com.example.shoppingmall.service.member.MemberService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@RestController
@Slf4j
@RequiredArgsConstructor
public class MemberController {

  private final MemberService memberService;
  private final RefreshTokenService refreshTokenService;

  // 회원 가입
  @PostMapping("/api/v1/users/")
  // BindingResult 타입의 매개변수를 지정하면 BindingResult 매개 변수가 입력값 검증 예외를 처리한다.
  public ResponseEntity<?> join(@Validated @RequestBody MemberDTO memberDTO,
                                BindingResult result) throws Exception{

      // 입력값 검증 예외가 발생하면 예외 메시지를 응답한다.
      if(result.hasErrors()) {
          log.info("BindingResult error : " + result.hasErrors());
          return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result.getClass().getSimpleName());
      }

      try {
          String join = memberService.signUp(memberDTO);
          return ResponseEntity.ok().body(join);
      } catch (Exception e) {
          log.error("예외 : " + e.getMessage());
          return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
      }
  }

  // 회원 조회
  @GetMapping("/api/v1/users/{userId}")
  public ResponseEntity<MemberDTO> search(@PathVariable Long userId) throws Exception {
      try {
          MemberDTO search = memberService.search(userId);
          return ResponseEntity.ok().body(search);
      } catch (NullPointerException e) {
          log.info("회원이 존재하지 않습니다.");
          return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
      }
  }

  // 로그인
  @PostMapping("/api/v1/users/login")
  public ResponseEntity<?> login(@RequestBody MemberDTO memberDTO) throws Exception {
      log.info("member : " + memberDTO);
      try {
          log.info("-----------------");

          ResponseEntity<TokenDTO> login =
                  memberService.login(memberDTO.getUserEmail(), memberDTO.getUserPw());
          log.info("login : " + login);

          return ResponseEntity.ok().body(login);
      } catch (Exception e) {
          return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("문제가 있습니다");
      }
  }

  // refresh로 access 토큰 재발급
  // @RequsetHeader"Authorization")은 Authorization 헤더에서 값을 추출합니다.
  // 일반적으로 리프레시 토큰은 Authorization 헤더의 값으로 전달되며,
  // Bearer <token> 형식을 따르는 경우가 일반적입니다. 여기서 <token> 부분이 실제 리프레시 토큰입니다
  // 로 추출하면 다음과 같이 문자열로 나온다.
  // Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0IiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  @PostMapping("/refresh")
  public ResponseEntity<?> refreshToken(@RequestHeader("Authorization") String token) throws Exception {
      try {
          if(token != null) {
              ResponseEntity<TokenDTO> accessToken = refreshTokenService.createAccessToken(token);
              return ResponseEntity.ok().body(accessToken);
          } else {
              return ResponseEntity.notFound().build();
          }
      } catch (Exception e) {
          throw new RuntimeException(e);
      }
  }


  // 로그아웃
  @GetMapping("/logOut")
  public String logOut(HttpServletRequest request,
                       HttpServletResponse response) {
      new SecurityContextLogoutHandler().logout(request,
              response,
              SecurityContextHolder.getContext().getAuthentication());
      return "로그아웃하셨습니다.";
  }

  // 회원정보 수정
  @PutMapping("/api/v1/users/")
  public ResponseEntity<?> update(@RequestBody MemberDTO memberDTO) throws Exception{
      try {
          MemberDTO update = memberService.update(memberDTO);
          return ResponseEntity.ok().body(update);
      } catch (Exception e) {
          return ResponseEntity.badRequest().body("잘못된 요청");
      }
  }

  // 회원 탈퇴
  @DeleteMapping("/api/v1/users/{userId}")
  public String remove(@PathVariable Long userId) {
      String delete = memberService.remove(userId);
      return delete;
  }
}

연관 관계 매핑 종류

연관 관계 매핑의 종류를 기억해야 합니다. 예를들어, 일대일 매핑을 보면 쇼핑몰에서 회원들은 각자 자신의 장바구니를 하나 갖고 있습니다. 장바구니 입장에서 보아도 자신과 매핑되는 한 명의 회원을 갖는 것이죠. 즉, 회원 엔티티와 장바구니 엔티티는 일대일 매핑입니다. 일대다 매핑의 예시로는 하나의 장바구니에는 여러 개의 상품이 들어갈 수 있습니다. 즉, 장바구니 상품 엔티티는 일대다 매핑입니다.

  • 일대일(1:1) : @OneToOne
  • 일대다(1:N) : @OneToMany
  • 다대일(N:1) : @ManyToOne
  • 다대다(N:M) : @ManyToMany

두 번째로 중요한 것은 엔티티를 매핑할 때는 방향성을 고려해야 합니다. 테이블에서 관계는 항상 양 방향이지만, 객체에서는 단방향과 양방향이 존재합니다.

일대일 단방향 매핑하기

package com.example.shoppingmall.entity.item;

import com.example.shoppingmall.entity.base.BaseEntity;
import com.example.shoppingmall.entity.member.MemberEntity;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.*;

@Entity(name ="cart")
@ToString
@Getter
@Table
@NoArgsConstructor
public class CartEntity extends BaseEntity {
    @Id @GeneratedValue
    @Column(name = "cart_id")
    private Long id;
    
    // 일대일 매핑
    @OneToOne
    // 매핑할 외래키를 지정합니다.
    // name 에는 매핑할 외래키의 이름을 설정
    @JoinColumn(name = "member_id")
    private MemberEntity member;
}
package com.example.shoppingmall.dto.item;

import com.example.shoppingmall.dto.member.MemberDTO;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@ToString
@NoArgsConstructor
@Getter
public class CartDTO {
    private Long id;
    private MemberDTO member;

    @Builder
    public CartDTO(Long id, MemberDTO member) {
        this.id = id;
        this.member = member;
    }
}

멤버 엔티티를 보면 회원 엔티티에는 장바구니(Cart) 엔티티와 관련된 소스가 전혀 없다는 것을 확인할 수 있습니다. 즉, 장바구니 엔티티가 일방적으로 회원 엔티티를 참조하고 있습니다. 장바구니와 회원은 일대일로 매핑돼 있으며, 장바구니 엔티티가 회원 엔티티를 참조하는 일대일 단방향 매핑입니다.

장바구니 엔티티와 회원 엔티티의 매핑이 완료됐습니다. 이렇게 매핑을 맺어주면 장바구니 엔티티를 조회하면서 회원 엔티티의 정보도 동시에 가져올 수 있는 장점이 있습니다.

CartRepository

package com.example.shoppingmall.repository.item;

import com.example.shoppingmall.entity.item.CartEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface CartRepository extends JpaRepository<CartEntity, Long> {
}

CartItem

package com.example.shoppingmall.entity.item;

import com.example.shoppingmall.entity.base.BaseEntity;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.*;

@Entity(name = "cartItem")
@ToString
@Getter
@NoArgsConstructor
@Table
public class CartItemEntity extends BaseEntity {
  @Id @GeneratedValue
  @Column(name = "cart_item_id")
  private Long id;

  // 하나의 장바구니에는 여러 개의 상품을 담을 수 있으므로
  // 다대일 관계를 맺어준다.
  @ManyToOne
  @JoinColumn(name = "cart_id")
  private CartEntity cart;

  // 장바구니에 담을 상품의 정보를 알아야 하기 때문에
  // 상품 엔티티에 연결해준다.
  // 하나의 상품은 여러 장바구니의 장바구니 상품으로
  // 담길 수 있으므로 다대일 관계를 맺어준다.
  @ManyToOne
  @JoinColumn(name = "item_id")
  private ItemEntity item;

  // 같은상품을 장바구니에 몇 개 담을지 저장합니다.
  private int count;
}
package com.example.shoppingmall.dto.item;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@ToString
@Getter
@NoArgsConstructor
public class CartItemDTO {
    private Long id;
    private CartDTO cart;
    private ItemDTO item;
    private int count;

    @Builder
    public CartItemDTO(Long id, CartDTO cart, ItemDTO item, int count) {
        this.id = id;
        this.cart = cart;
        this.item = item;
        this.count = count;
    }
}

다대일/일대다 양방향 매핑하기

양방향 매핑이란 단방향 매핑이 2개 있다고 생각하시면 됩니다.

public enum OrderStatus {
  ORDER, CANCEL
}
package com.example.shoppingmall.entity.item;

import com.example.shoppingmall.dto.item.OrderStatus;
import com.example.shoppingmall.entity.base.BaseEntity;
import com.example.shoppingmall.entity.base.BaseTimeEntity;
import com.example.shoppingmall.entity.member.MemberEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity(name = "orders")
@Table
@ToString
@Getter
@NoArgsConstructor
public class OrderEntity extends BaseTimeEntity {
  @Id @GeneratedValue
  @Column(name = "order_id")
  private Long id;

  // 한명의 회원은 여러 번 주문을 할 수 있기 때문에 
  // 주문 엔티티 기준에서 다대일 단방향 매핑을 합니다.
  @ManyToOne
  @JoinColumn(name = "member_id")
  private MemberEntity member;

  private LocalDateTime orderDate;    // 주문일

  @Enumerated(EnumType.STRING)
  private OrderStatus orderStatus;    // 주문 상태

  @Builder
  public OrderEntity(Long id,
                     MemberEntity member,
                     LocalDateTime orderDate,
                     OrderStatus orderStatus) {
      this.id = id;
      this.member = member;
      this.orderDate = orderDate;
      this.orderStatus = orderStatus;
  }
}
package com.example.shoppingmall.dto.item;

import com.example.shoppingmall.dto.member.MemberDTO;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import java.time.LocalDateTime;

@ToString
@NoArgsConstructor
@Getter
public class OrderDTO {
  private Long id;
  private MemberDTO member;
  private LocalDateTime orderDate;
  private OrderStatus orderStatus;

  @Builder
  public OrderDTO(Long id,
                  MemberDTO member,
                  LocalDateTime orderDate,
                  OrderStatus orderStatus) {
      this.id = id;
      this.member = member;
      this.orderDate = orderDate;
      this.orderStatus = orderStatus;
  }
}

OrderItem

package com.example.shoppingmall.entity.item;

import com.example.shoppingmall.entity.base.BaseTimeEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.*;

@Entity(name = "order_item")
@Table
@ToString
@Getter
@NoArgsConstructor
public class OrderItemEntity extends BaseTimeEntity {
    @Id @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;
    
    // 하나의 상품은 여러 주문 상품으로 들어갈 수 있으므로
    // 주문 상품 기준으로 다대일 단방향 매핑을 한다.
    @ManyToOne
    @JoinColumn(name = "item_id")
    private ItemEntity item;
    
    // 한 번의 주문에 여러 개의 상품을 주문할 수 있으므로
    // 주문 상품 엔티티와 주문 엔티티를 다대일 단방향 매핑을 한다.
    @ManyToOne
    @JoinColumn(name = "order_id")
    private OrderEntity order;
    
    private int orderPrice;
    private int count;

    @Builder
    public OrderItemEntity(Long id,
                           ItemEntity item,
                           OrderEntity order,
                           int orderPrice,
                           int count) {
        this.id = id;
        this.item = item;
        this.order = order;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}
package com.example.shoppingmall.dto.item;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import java.time.LocalDateTime;

@ToString
@Getter
@NoArgsConstructor
public class OrderItemDTO {
    private Long id;
    private ItemDTO item;
    private OrderDTO order;
    private int price;
    private int count;
    private LocalDateTime regTime;
    private LocalDateTime updateTime;

    @Builder
    public OrderItemDTO(Long id, 
                        ItemDTO item,
                        OrderDTO order, 
                        int price, 
                        int count,
                        LocalDateTime regTime, 
                        LocalDateTime updateTime) {
        this.id = id;
        this.item = item;
        this.order = order;
        this.price = price;
        this.count = count;
        this.regTime = regTime;
        this.updateTime = updateTime;
    }
}

다대일과 일대다는 반대 관계입니다. 주문 상품 엔티티 기준에서 다대일 매핑이었으므로 주문 엔티티 기준에서는 주문 상품 엔티티와 일대다 관계로 매핑하면 됩니다. 또한 양방향 매핑에서는 연관 관계 주인을 설정해야 한다는 점이 중요합니다.

엔티티는 테이블과 다릅니다. 엔티티를 양방향 연관 관계로 설정하면 객체의 참조는 둘인데 외래키는 하나이므로 둘 중 누가 외래키를 관리할지 정해야 합니다.

  • 연관 관계의 주인은 외래키가 있는 곳으로 설정
  • 연관 관계의 주인이 외래키를 관리(등록, 수정, 삭제)
  • 주인이 아닌 쪽은 연관 관계 매핑 시 mappedBy 속성의 값으로 연관 관계의 주인을 설정
  • 주인이 아닌쪽은 읽기만 가능

OrderEntity

위에 있는 Order 엔티티에서 양방향 매핑을 추가합니다.

package com.example.shoppingmall.entity.item;

import com.example.shoppingmall.dto.item.OrderStatus;
import com.example.shoppingmall.entity.base.BaseEntity;
import com.example.shoppingmall.entity.base.BaseTimeEntity;
import com.example.shoppingmall.entity.member.MemberEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity(name = "orders")
@Table
@ToString
@Getter
@NoArgsConstructor
public class OrderEntity extends BaseTimeEntity {
    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    // 한명의 회원은 여러 번 주문을 할 수 있기 때문에
    // 주문 엔티티 기준에서 다대일 단방향 매핑을 합니다.
    @ManyToOne
    @JoinColumn(name = "member_id")
    private MemberEntity member;

    private LocalDateTime orderDate;    // 주문일

    @Enumerated(EnumType.STRING)
    private OrderStatus orderStatus;    // 주문 상태

    // 외래키(order_id)가 order_item 테이블에 있으므로 연관 관계의 주인은
    // OrderItemEntity 입니다. order 엔티티가 주인이 아니므로 "mappedBy"
    // 속성으로 연관 관계의 주인을 설정합니다.
    // 속성의 값으로 order 를 적어준 이유는 OrderItemEntity 에 있는 order에 의해
    // 관리된다는 의미로 해석하면 됩니다.
    @OneToMany(mappedBy = "order")
    // 하나의 주문이 여러 개의 주문 상품을 갖으므로 List 자료형을 사용해서 매핑합니다.
    private List<OrderItemEntity> orderItems = new ArrayList<>();

    @Builder
    public OrderEntity(Long id,
                       MemberEntity member,
                       LocalDateTime orderDate,
                       OrderStatus orderStatus) {
        this.id = id;
        this.member = member;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
    }
}

무조건 양방향으로 연관 관계를 매핑하면 해당 엔티티는 엄청나게 많은 테이블과 연관 관계를 맺게 되고 엔티티 클래스 자체가 복잡해지기 때문에 연관 관계 단방향 매핑으로 설계 후 나중에 필요할 경우 양방향 매핑을 추가하는 것을 권장합니다.


영속성 전이

영속성 전이 즉, cascade는 엔티티의 상태를 변경할 때 해당 엔티티와 연관된 엔티티의 상태 변화를 전파시키는 옵션입니다. 이때 부모는 One에 해당하고 자식은 Many에 해당합니다.

cascade 종류

  • PERSIST : 부모 엔티티가 영속화될 때 자식 엔티티도 영속화

  • MERGE : 부모 엔티티가 병합될 때 자식 엔티티도 병합

  • REMOVE : 부모 엔티티가 삭제될 때 연관된 자식 엔티티도 삭제

  • REFRESH : 부모 엔티티가 refresh되면 연관된 자식 엔티티도 refresh

  • DETACH : 부모 엔티티가 detach 되면 자식 엔티티도 detach 상태로 변경

  • ALL : 부모 엔티티의 영속성 상태 변화를 자식 엔티티에 모두 전이

    영속성 전이 옵션을 무분별하게 사용할 경우 삭제되지 말아야 할 데이터가 삭제될 수 있으므로 조심해서 사용해야 합니다. 영속성 전이 옵션은 단일 엔티티에 완전히 종속적이고 부모 엔티티와 자식 엔티티 라이프 사이클이 유사할 때 cascade 옵션을 활용하시기를 추천합니다.

    주문 엔티티를 저장하기 위해서 JpaRepository를 상속받는 OrderRepository 인터페이스를 생성합니다.

package com.example.shoppingmall.repository.item;

import com.example.shoppingmall.entity.item.OrderEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface OrderRepository extends JpaRepository<OrderEntity, Long> {
}
   // 부모 엔티티의 영속성 상태 변화를 자식 엔티티에 모두 전이하는 CascadeTypeAll 옵션을 설정
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)

고아 객체 제거하기

부모 엔티티와 연관 관계가 끊어진 자식 엔티티를 고아 객체라고 합니다. 영속성 전이 기능과 같이 사용하면 부모 엔티티를 통해서 자식의 생명 주기를 관리할 수 있습니다.

영속성 전이 기능과 마찬가지로 고아 객체 제거 기능을 사용하기 위해서 주의사항이 있습니다. 고아 객체 제거 기능은 참조하는 곳이 하나일 때만 사용해야 합니다. 다른 곳에서도 참조하고 있는 엔티티인데 삭제하면 문제가 생길 수 있습니다. OrderItem 엔티티를 Order 엔티티가 아닌 다른 곳에서 사용하고 있다면 이 기능을 사용하면 안 됩니다.

고아 객체 제거를 사용하기 위해서 @OneToManyorphanRemoval = true 옵션을 추가합니다.

OrderEntity

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
   // 하나의 주문이 여러 개의 주문 상품을 갖으므로 List 자료형을 사용해서 매핑합니다.
   private List<OrderItemEntity> orderItems = new ArrayList<>();

지연 로딩

지연 로딩이라는 Fetch 전략이 있습니다. 지연 로딩을 나가기전에 OrderItemRepository를 생성하겠습니다.

package com.example.shoppingmall.repository.item;

import com.example.shoppingmall.entity.item.OrderItemEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface OrderItemRepository extends JpaRepository<OrderItemEntity, Long> {
}

일대일, 다대일로 매핑할 경우 기본 전략인 즉시 로딩을 통해 엔티티를 함께 가지고 옵니다. 심지어 Order 엔티티는 자신과 다대일로 매핑된 Member 엔티티도 가지고 오고 있습니다. 작성하고 있는 비즈니스 로직에서 사용하지 않을 데이터도 가지고 오는 겁니다. 실무에서는 더 많은 데이터를 가지고 오기 때문에 실무에서 사용하기 힘듭니다.

즉시 로딩을 사용하는 대신에 지연 로딩 방식을 사용해야 합니다. 위에서 언급한 Fetch전략인 FetchType.LAZY 방식으로 설정하겠습니다.


Auditing을 이용한 엔티티 공통 속성 공통화

실제 서비스를 운영할 때는 보통 등록시간과 수정시간, 등록자, 수정자를 테이블에 넣고 활용을 합니다. 그리고 데이터가 생성되거나 수정될 때 시간을 기록해주고, 어떤 사용자가 등록을 했는지 아이디를 남깁니다. 이 컬럼들은 버그가 있거나 문의가 들어왔을 때 활용이 가능합니다.

Spring Data JPA에서는 Auditing 기능을 제공하여 엔티티가 저장 또는 수정될 때 자동으로 등록일, 수정일, 등록자, 수정자를 입력해줍니다. 엔티티의 생성과 수정을 감시해줍니다.

현재 로그인한 사용자의 정보를 등록자와 수정자로 지정하기 위해서 AuditorAware 인터페이스를 구현한 클래스 생성

package com.example.shoppingmall.config;

import org.springframework.data.domain.AuditorAware;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

import java.util.Optional;

public class AuditorAwareImpl implements AuditorAware<String> {

  @Override
  public Optional<String> getCurrentAuditor() {
      Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

      String userId = "";
      if(authentication != null) {
          // 현재 로그인 한 사용자의 정보를 조회하여 사용자의 이름을 등록자와 수정자로 지정합니다.
          userId = authentication.getName();
      }
      return Optional.of(userId);
  }
}

Auditing 기능을 사용하기 위해서 Config 파일을 생성

package com.example.shoppingmall.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
// JPA의 Auditing 기능을 활성화
@EnableJpaAuditing
public class AuditConfig {

   // 등록자와 수정자를 처리해주는 AuditorAware을 빈으로 등록합니다.
   @Bean
   public AuditorAware<String> auditorProvider() {
       return new AuditorAwareImpl();
   }
}

보통 테이블에 등록일, 수정일, 등록자, 수정자를 모두 넣어주지만 어떤 테이블은 등록자, 수정자를 넣지 않는 테이블도 있을 수 있습니다. 그런 엔티티는 BaseTimeEntity만 상속받을 수 있도록 BaseTimeEntity 클래스 생성

package com.example.shoppingmall.entity;

import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

// Auditing을 적용하기 위해서 @EntityListeners 어노테이션을 추가
@EntityListeners(value = {AuditingEntityListener.class})
// 공통 매핑 정보가 필요할 때 사용하는 어노테이션으로
// 부모 클래스를 상속 받는 자식 클래스에 매핑 정보만 제공합니다.
@MappedSuperclass
@Getter @Setter
public class BaseTimeEntity {

  // 엔티티가 생성되어 저장될 때 시간을 자동으로 저장
  @CreatedDate
  @Column(updatable = false)
  private LocalDateTime regTime;

  // 엔티티의 값을 변경할 때 시간을 자동으로 저장
  @LastModifiedDate
  private LocalDateTime updateTime;
}

BaseEntity는 위에서 만든 BaseTimeEntity를 상속받고 있습니다. 등록일, 수정일, 등록자, 수정자를 모두 갖는 엔티티는 BaseEntity를 상속받으면 됩니다.

  package com.example.shoppingmall.entity;

import lombok.Getter;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;

@EntityListeners(value = {AuditingEntityListener.class})
@MappedSuperclass
@Getter
public class BaseEntity extends BaseTimeEntity{
    
    @CreatedBy
    @Column(updatable = false)
    private String createdBy;
    
    @LastModifiedBy
    private String modifiedBy;
}

Member 엔티티에 Auditing 기능을 적용하기 위해서 BaseEntity 클래스를 상속받도록 하겠습니다.

  public class MemberEntity extends BaseEntity 

모든 엔티티에 똑같이 상속을 해주면 된다.


상품 등록하기

상품(Item) 엔티티를 다루었고, 상품의 이미지를 저장하는 상품 이미지 엔티티를 만들겠습니다. 상품 이미지 엔티티는 이미지 파일명, 원본 이미지 파일명, 이미지 조회 경로, 대표 이미지 여부를 갖도록 설계하겠습니다.

ItemImg

package com.example.shoppingmall.entity.item;

import com.example.shoppingmall.entity.base.BaseEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.*;

@Entity(name = "item_img")
@Table
@ToString
@Getter
@NoArgsConstructor
public class ItemImgEntity extends BaseEntity {
    @Id @GeneratedValue
    @Column(name = "item_img_id")
    private Long id;
    private String uploadImgPath;
    private String uploadImgName;             // 이미지 파일명
    private String oriImgName;          // 원본 이미지 파일명
    private String uploadImgUrl;              // 이미지 조회 경로

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private ItemEntity item;

    // 원본 이미지 파일명, 업데이트할 이미지 파일명, 이미지 경로 파라미터로 입력 받아서
    // 이미지 정보를 업데이트 하는 메소드입니다.
    public void updateItemImg(String oriImgName,
                              String uploadImgName,
                              String uploadImgUrl,
                              String uploadImgPath) {
        this.oriImgName = oriImgName;
        this.uploadImgName = uploadImgName;
        this.uploadImgUrl = uploadImgUrl;
        this.uploadImgPath = uploadImgPath;
    }

    @Builder

    public ItemImgEntity(Long id,
                         String uploadImgPath,
                         String uploadImgName,
                         String oriImgName,
                         String uploadImgUrl,
                         ItemEntity item) {
        this.id = id;
        this.uploadImgPath = uploadImgPath;
        this.uploadImgName = uploadImgName;
        this.oriImgName = oriImgName;
        this.uploadImgUrl = uploadImgUrl;
        this.item = item;
    }
}

다음으로 상품 등록 및 수정에 사용할 데이터 전달용 DTO 클래스를 만들겠습니다. 상품을 등록할 때는 화면으로부터 전달 받은 DTO 객체를 엔티티 객체로 변환하는 작업을 해야 하고, 상품을 조회할 때는 엔티티 객체를 DTO 객체로 바꿔주는 작업을 해야 합니다. 이 작업은 반복적인 작업입니다. 이를 도와주는 것이 modelMapper가 있는데 저는 @Builder을 사용하기 때문에 넘어가겠습니다.

package com.example.shoppingmall.dto.item;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Getter
@ToString
@NoArgsConstructor
public class ItemImgDTO {
    private Long id;
    private String uploadImgName;
    private String oriImgName;
    private String uploadImgUrl;
    private String uploadImgPath;

    @Builder
    public ItemImgDTO(Long id,
                      String uploadImgName,
                      String oriImgName,
                      String uploadImgUrl,
                      String uploadImgPath) {
        this.id = id;
        this.uploadImgName = uploadImgName;
        this.oriImgName = oriImgName;
        this.uploadImgUrl = uploadImgUrl;
        this.uploadImgPath = uploadImgPath;
    }
}
package com.example.shoppingmall.dto.item;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@ToString
@Getter
@NoArgsConstructor
public class ItemDTO {
    private Long id;            // 상품 코드
    @NotBlank(message = "상품명은 필수 입력입니다.")
    private String itemNum;     // 상품 명
    @NotNull(message = "가격은 필수 입력입니다.")
    private int price;          // 가격
    @NotNull(message = "재고 수량은 필수 입력입니다.")
    private int stockNumber;    // 재고수량
    @NotNull(message = "설명은 필수 입력입니다.")
    private String itemDetail;  // 상품 상세 설명
    private ItemSellStatus itemSellStatus;  // 상품 판매 상태
    private LocalDateTime regTime;
    private LocalDateTime updateTime;
    
    // 상품 저장 후 수정할 때 상품 이미지 정보를 저장하는 리스트
    private List<ItemImgDTO> itemImgList = new ArrayList<>();
    
    // 상품의 이미지 아이디를 저장하는 리스트입니다.
    // 상품 등록 시에는 아직 상품의 이미지를 저장하지 않았기 때문에
    // 아무 값도 들어가 있지 않고 수정 시에 이미지 아이디를 담아둘 용도로 사용합니다.
    private List<Long> itemImgIds = new ArrayList<>();

    @Builder

    public ItemDTO(Long id, 
                   String itemNum,
                   int price, 
                   int stockNumber, 
                   String itemDetail, 
                   ItemSellStatus itemSellStatus,
                   LocalDateTime regTime, 
                   LocalDateTime updateTime,
                   List<ItemImgDTO> itemImgList, 
                   List<Long> itemImgIds) {
        this.id = id;
        this.itemNum = itemNum;
        this.price = price;
        this.stockNumber = stockNumber;
        this.itemDetail = itemDetail;
        this.itemSellStatus = itemSellStatus;
        this.regTime = regTime;
        this.updateTime = updateTime;
        this.itemImgList = itemImgList;
        this.itemImgIds = itemImgIds;
    }
}

S3에 이미지 올리기

AWS S3 Bucket 생성

  1. AWS Console > S3 > 버킷 > 버킷 만들기 클릭

  2. 버킷 이름을 입력하고 엑세스 차단 설정을 해제한다.
    (애플리케이션은 파일 조작 권한을 갖게하고, 권한 변경 필요 시에는 애플리케이션에 요청을 보내는 클라이언트의 파일 조작 권한은 스프링 시큐리티 권한 설정으로 하면 될 거 같다.)

IAM 사용자 권한 추가

S3에 접근하기 위해서는 IAM 사용자에게 S3 접근 권한을 주고, 그 사용자의 액세스 키, 비밀 엑세스 키를 사용해야 한다.

  1. AWS console > IAM > 엑세스 관리 > 사용자 > 사용자 추가 클릭

  2. 사용자 이름을 입력하고 다음을 클릭한다.

  3. 직접 정책 연결을 클릭하고, AmozonS3FullAccess 를 선택하고 다음을 클릭한다.

  4. 사용자 생성을 클릭하면 사용자가 생성된다.

엑세스 키 생성

외부에서 접속할 수 있도록 사용자의 엑세스 키를 만들어 주어야 한다.

  1. AWS Console > IAM > 엑세스 관리자 > 사용자 > 생성한 사용자 이름 클릭 > 보안 자격 증명 > 엑세스 키 만들기 클릭

  1. 아무거나 클릭하고 다음을 클릭한다.
    (클릭 하면 엑세스 키 사용사례와 대안을 하단에 띄워주는 기능만 하기때문에 뭘 골라도 상관없다.)

  2. 태그를 입력하고 엑세스 키 만들기를 클릭한다.

  3. 엑세스 키 생성 완료 화면에서 생성된 공개키와 비밀키를 확인할 수 있다.
    (생성 완료 화면이 아니면 비밀 엑세스 키를 볼 수 없기 때문에 .csv 파일로 받아두는 것이 좋다)

Spring Boot로 파일 업로드

build.gradle에 의존성 추가

implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

application.yml/properties 작성하기

application.properties에 다음과 같이 아까 생성했던 s3의 정보와 IAM 사용자에 대한 정보를 등록해준다. 이때 주의해야 하는 점은 해당 정보를 깃허브에 절대 업로드하면 안된다는 점이다!!!

# S3
cloud.aws.credentials.accessKey=
cloud.aws.credentials.secretKey=
cloud.aws.s3.bucket=
cloud.aws.region.static=ap-northeast-2
cloud.aws.stack.auto-=false
cloud:
	aws:
		s3:
      bucket: <버킷이름>
		stack.auto: false
		region.static: ap-northeast-2
		credentials:
			accessKey: <발급받은 accessKey>
			secretKey: <발급받은 secretKey>

cloud.aws.stack.auto=false
EC2에서 Spring Cloud 프로젝트를 실행시키면 기본으로 CloudFormation 구성을 시작하기 때문에 설정한 CloudFormation이 없으면 프로젝트 실행이 되지 않는다. 해당 기능을 사용하지 않도록 false로 설정.

cloud.aws.region.static:ap-northeast-2
지역을 한국으로 고정한다.

S3config 작성하기

package com.example.shoppingmall.config.s3;

import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class S3Config {
    @Value("${cloud.aws.credentials.accessKey}")
    private String accessKey;

    @Value("${cloud.aws.credentials.secretKey}")
    private String secretKey;

    @Value("${cloud.aws.region.static}")
    private String region;

    @Bean
    public AmazonS3 amazonS3Client() {
        BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(accessKey, secretKey);
        return AmazonS3ClientBuilder
                .standard()
                .withCredentials(new AWSStaticCredentialsProvider(basicAWSCredentials))
                .withRegion(region)
                .build();
    }
}

따로 config 디렉토리에서 설정 값을 넣기 위해서 AmazonS3Config 설정 클래스를 만들었다. application/properties 파일에 작성한 값들을 읽어와서 AmazonS3Client 객체를 만들어 Bean으로 주입해주는 것이다.

S3에 올리는 service

package com.example.shoppingmall.service.item;

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.AmazonS3Exception;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.example.shoppingmall.dto.item.ItemImgDTO;
import com.example.shoppingmall.entity.item.ItemImgEntity;
import com.example.shoppingmall.repository.item.ItemImgRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.*;

@Slf4j
@RequiredArgsConstructor
@Service
public class S3UploaderService {

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    private final AmazonS3 amazonS3;

    // MultipartFile을 전달받아 File로 전환한 후 S3에 업로드
    // String fileType는 파일 업로드 시 업로드할 파일들을
    // 어떤 종류 또는 구분으로 분류하고 저장할지를 지정하는 매개변수입니다.
    // 쇼핑몰 프로젝트에서는 보통 상품 이미지들을 업로드하게 되는데,
    // fileType을 사용하여 해당 상품 이미지들을 어떤 카테고리 또는 폴더에 저장할지를 결정할 수 있습니다.
    // 예를 들어, fileType이 "product"인 경우,
    // 상품 이미지들은 "product/년/월/일"과 같은 경로에 업로드될 수 있습니다.
    // 이렇게 파일을 업로드할 경로를 fileType을 기반으로 동적으로
    // 결정하는 것은 이미지 관리 및 구분에 도움이 되며, 폴더를 체계적으로 구성하여 관리할 수 있습니다.
    public List<ItemImgDTO> upload(String fileType, List<MultipartFile> multipartFiles) throws IOException {
        List<ItemImgDTO> s3files = new ArrayList<>();

        String uploadFilePath = fileType + "/" + getFolderName();

        for (MultipartFile multipartFile : multipartFiles) {
            String oriFileName = multipartFile.getOriginalFilename();
            String uploadFileName = getUuidFileName(oriFileName);
            String uploadFileUrl = "";

            ObjectMetadata objectMetadata = new ObjectMetadata();
            objectMetadata.setContentLength(multipartFile.getSize());
            objectMetadata.setContentType(multipartFile.getContentType());

            try (InputStream inputStream = multipartFile.getInputStream()) {
                // ex) 구분/년/월/일/파일.확장자
                String keyName = uploadFilePath + "/" + uploadFileName;

                // S3에 폴더 및 파일 업로드
                amazonS3.putObject(
                        new PutObjectRequest(bucket, keyName, inputStream, objectMetadata));

                // TODO : 외부에 공개하는 파일인 경우 Public Read 권한을 추가, ACL 확인
                 /*amazonS3Client.putObject(
                    new PutObjectRequest(bucket, s3Key, inputStream, objectMetadata)
                        .withCannedAcl(CannedAccessControlList.PublicRead));*/

                // S3에 업로드한 폴더 및 파일 URL
                uploadFileUrl = amazonS3.getUrl(bucket, keyName).toString();
            } catch (IOException e) {
                e.printStackTrace();
                log.error("Filed upload failed", e);
            }

            s3files.add(
                    ItemImgDTO.builder()
                            .oriImgName(oriFileName)
                            .uploadImgName(uploadFileName)
                            .uploadImgPath(uploadFilePath)
                            .uploadImgUrl(uploadFileUrl)
                            .build());
        }
        return s3files;
    }

    // S3에 업로드된 파일 삭제
    public String deleteFile(String uploadFilePath, String uuidFileName) {
        String result = "success";

        try {
            // ex) 구분/년/월/일/파일.확장자
            String keyName = uploadFilePath + "/" + uuidFileName;
            boolean isObjectExist = amazonS3.doesObjectExist(bucket, keyName);

            if (isObjectExist) {
                amazonS3.deleteObject(bucket, keyName);
            } else {
                result = "file not found";
            }
        } catch (AmazonS3Exception e) {
            // S3에서 파일 삭제 실패
            result = "S3 file deletion failed: " + e.getMessage();
            log.error("S3 file deletion failed", e);
        } catch (Exception e) {
            // 기타 예외 처리
            result = "file deletion failed: " + e.getMessage();
            log.error("File deletion failed", e);
        }
        return result;
    }


    // UUID 파일명 반환
    private String getUuidFileName(String oriFileName) {
        String ext = oriFileName.substring(oriFileName.indexOf(".") + 1);
        return UUID.randomUUID().toString() + "." + ext;
    }

    // 년/월/일 폴더명 반환
    private String getFolderName() {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault());
        Date date = new Date();
        String str = sdf.format(date);
        return str.replace("-", "/");
    }
}

상품 CRUD

package com.example.shoppingmall.dto.item;

import com.example.shoppingmall.entity.item.ItemEntity;
import com.example.shoppingmall.entity.item.ItemImgEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@ToString
@Getter
@NoArgsConstructor
public class ItemDTO {
    @Schema(description = "상품 번호")
    private Long itemId;            // 상품 코드

    @Schema(description = "상품 이름")
    @NotBlank(message = "상품명은 필수 입력입니다.")
    private String itemNum;     // 상품 명

    @Schema(description = "상품 가격")
    @NotNull(message = "가격은 필수 입력입니다.")
    private int price;          // 가격

    @Schema(description = "상품 재고 수량")
    @NotNull(message = "재고 수량은 필수 입력입니다.")
    private int stockNumber;    // 재고수량

    @Schema(description = "상품 설명")
    @NotNull(message = "설명은 필수 입력입니다.")
    private String itemDetail;  // 상품 상세 설명

    @Schema(description = "상품 상태")
    private ItemSellStatus itemSellStatus;  // 상품 판매 상태

    @Schema(description = "상품 등록 시간")
    private LocalDateTime regTime;

    @Schema(description = "상품 업데이트 시간")
    private LocalDateTime updateTime;

    // 상품 저장 후 수정할 때 상품 이미지 정보를 저장하는 리스트
    private List<ItemImgDTO> itemImgList = new ArrayList<>();

    @Builder
    public ItemDTO(Long itemId,
                   String itemNum,
                   int price,
                   int stockNumber,
                   String itemDetail,
                   ItemSellStatus itemSellStatus,
                   LocalDateTime regTime,
                   LocalDateTime updateTime,
                   List<ItemImgDTO> itemImgList
                   ) {
        this.itemId = itemId;
        this.itemNum = itemNum;
        this.price = price;
        this.stockNumber = stockNumber;
        this.itemDetail = itemDetail;
        this.itemSellStatus = itemSellStatus;
        this.regTime = regTime;
        this.updateTime = updateTime;
        this.itemImgList = itemImgList;
    }

    public static ItemDTO toItemDTO(ItemEntity item) {
        List<ItemImgEntity> itemImgEntities = item.getItemImgList();
        List<ItemImgDTO> itemDTOList = new ArrayList<>();

        for(ItemImgEntity itemImgEntity : itemImgEntities) {
            ItemImgDTO itemImgDTO = ItemImgDTO.builder()
                    .oriImgName(itemImgEntity.getOriImgName())
                    .uploadImgName(itemImgEntity.getUploadImgName())
                    .uploadImgUrl(itemImgEntity.getUploadImgUrl())
                    .uploadImgPath(itemImgEntity.getUploadImgPath())
                    .repImgYn(itemImgEntity.getRepImgYn())
                    .build();

            itemDTOList.add(itemImgDTO);
        }

        return ItemDTO.builder()
                .itemId(item.getItemId())
                .itemNum(item.getItemNum())
                .price(item.getPrice())
                .stockNumber(item.getStockNumber())
                .itemDetail(item.getItemDetail())
                .itemSellStatus(item.getItemSellStatus())
                .regTime(item.getRegTime())
                .updateTime(item.getUpdateTime())
                .itemImgList(itemDTOList) // 이미지 정보를 추가합니다.
                .build();
    }
}
package com.example.shoppingmall.dto.item;

import com.example.shoppingmall.entity.item.ItemImgEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Getter
@ToString
@NoArgsConstructor
public class ItemImgDTO {
    @Schema(description = "상품 이미지 번호")
    private Long itemImgId;

    @Schema(description = "상품 업로드 이름")
    private String uploadImgName;

    @Schema(description = "원본 상품 이름")
    private String oriImgName;

    @Schema(description = "업로드 이미지 URL")
    private String uploadImgUrl;

    @Schema(description = "업로드 이미지 Path")
    private String uploadImgPath;

    @Schema(description = "대표 이미지 여부")
    private String repImgYn;

    @Schema(description = "상품 정보")
    private ItemDTO item;

    @Builder
    public ItemImgDTO(Long itemImgId,
                      String uploadImgName,
                      String oriImgName,
                      String uploadImgUrl,
                      String uploadImgPath,
                      String repImgYn,
                      ItemDTO item) {
        this.itemImgId = itemImgId;
        this.uploadImgName = uploadImgName;
        this.oriImgName = oriImgName;
        this.uploadImgUrl = uploadImgUrl;
        this.uploadImgPath = uploadImgPath;
        this.repImgYn = repImgYn;
        this.item = item;
    }

    public static ItemImgDTO toItemDTO(ItemImgEntity itemImgEntity) {
        ItemImgDTO itemImgDTO = ItemImgDTO.builder()
                .itemImgId(itemImgEntity.getItemImgId())
                .oriImgName(itemImgEntity.getOriImgName())
                .uploadImgName(itemImgEntity.getUploadImgName())
                .uploadImgUrl(itemImgEntity.getUploadImgUrl())
                .uploadImgPath(itemImgEntity.getUploadImgPath())
                .repImgYn(itemImgEntity.getRepImgYn())
                .item(ItemDTO.builder()
                        .itemId(itemImgEntity.getItem().getItemId())
                        .itemNum(itemImgEntity.getItem().getItemNum())
                        .itemDetail(itemImgEntity.getItem().getItemDetail())
                        .itemSellStatus(itemImgEntity.getItem().getItemSellStatus())
                        .price((itemImgEntity.getItem().getPrice()))
                        .stockNumber(itemImgEntity.getItem().getStockNumber())
                        .build())
                .build();

        return itemImgDTO;
    }
}
package com.example.shoppingmall.entity.item;

import com.example.shoppingmall.dto.item.ItemImgDTO;
import com.example.shoppingmall.dto.item.ItemSellStatus;
import com.example.shoppingmall.entity.base.BaseEntity;
import com.example.shoppingmall.entity.base.BaseTimeEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity(name = "item")
@Getter
@ToString
@NoArgsConstructor
public class ItemEntity extends BaseTimeEntity {
    @Id @GeneratedValue
    @Column(name = "item_id")
    private Long itemId;            // 상품 코드
    @Column(nullable = false, length = 50)
    private String itemNum;     // 상품 명
    @Column(name = "price", nullable = false)
    private int price;          // 가격
    @Column(nullable = false)
    private int stockNumber;    // 재고수량
    // BLOB, CLOB 타입 매핑
    // CLOB이란 사이즈가 큰 데이터를 외부 파일로 저장하기 위한 데이터입니다.
    // 문자형 대용량 파일을 저장하는데 사용하는 데이터 타입이라고 생각하면 됩니다.
    // BLOB은 바이너리 데이터를 DB외부에 저장하기 위한 타입입니다.
    // 이미지, 사운드, 비디오 같은 멀티미디어 데이터를 다룰 때 사용할 수 있습니다.
    @Lob
    @Column(nullable = false)
    private String itemDetail;  // 상품 상세 설명

    @Enumerated(EnumType.STRING)
    private ItemSellStatus itemSellStatus;  // 상품 판매 상태

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "item")
    // 상품 저장 후 수정할 때 상품 이미지 정보를 저장하는 리스트
    private List<ItemImgEntity> itemImgList = new ArrayList<>();

    @Builder
    public ItemEntity(Long itemId,
                      String itemNum,
                      int price,
                      int stockNumber,
                      String itemDetail,
                      ItemSellStatus itemSellStatus,
                      List<ItemImgEntity> itemImgList
                      ) {
        this.itemId = itemId;
        this.itemNum = itemNum;
        this.price = price;
        this.stockNumber = stockNumber;
        this.itemDetail = itemDetail;
        this.itemSellStatus = itemSellStatus;
        this.itemImgList = itemImgList;
    }
}
package com.example.shoppingmall.entity.item;

import com.example.shoppingmall.entity.base.BaseEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.*;

@Entity(name = "item_img")
@Table
@ToString
@Getter
@NoArgsConstructor
public class ItemImgEntity extends BaseEntity {
    @Id @GeneratedValue
    @Column(name = "item_img_id")
    private Long itemImgId;
    private String uploadImgPath;
    private String uploadImgName;               // 이미지 파일명
    private String oriImgName;                  // 원본 이미지 파일명
    private String uploadImgUrl;                // 이미지 조회 경로
    private String repImgYn;                    // 대표 이미지 여부 Y면 대표이미지를 보여줌


    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private ItemEntity item;

    @Builder
    public ItemImgEntity(Long itemImgId,
                         String uploadImgPath,
                         String uploadImgName,
                         String oriImgName,
                         String uploadImgUrl,
                         ItemEntity item,
                         String repImgYn) {
        this.itemImgId = itemImgId;
        this.uploadImgPath = uploadImgPath;
        this.uploadImgName = uploadImgName;
        this.oriImgName = oriImgName;
        this.uploadImgUrl = uploadImgUrl;
        this.item = item;
        this.repImgYn = repImgYn;
    }



}
package com.example.shoppingmall.repository.item;

import com.example.shoppingmall.entity.item.ItemImgEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface ItemImgRepository extends JpaRepository<ItemImgEntity, Long> {
    List<ItemImgEntity> findByItemIdOrderByIdAsc(Long itemId);
    void deleteByItemId(Long itemId);
}
package com.example.shoppingmall.repository.item;

import com.example.shoppingmall.entity.item.ItemEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface ItemRepository extends JpaRepository<ItemEntity, Long> {
    ItemEntity deleteByItemId(Long itemId);
}
package com.example.shoppingmall.service.item;

import com.example.shoppingmall.dto.item.ItemDTO;
import com.example.shoppingmall.dto.item.ItemImgDTO;
import com.example.shoppingmall.entity.item.ItemEntity;
import com.example.shoppingmall.entity.item.ItemImgEntity;
import com.example.shoppingmall.repository.item.ItemImgRepository;
import com.example.shoppingmall.repository.item.ItemRepository;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

import javax.persistence.EntityNotFoundException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

@RequiredArgsConstructor
@Service
@Transactional
@Slf4j
public class ItemService {

    private final ItemRepository itemRepository;
    private final S3UploaderService s3UploaderService;
    private final ItemImgRepository itemImgRepository;

    // 상품을 등록하는 메소드
    public ResponseEntity<?> saveItem(ItemDTO itemDTO, List<MultipartFile> itemFiles) throws Exception {
        // 상품 등록
        ItemEntity item = ItemEntity.builder()
                .itemNum(itemDTO.getItemNum())
                .itemDetail(itemDTO.getItemDetail())
                .price(itemDTO.getPrice())
                .itemSellStatus(itemDTO.getItemSellStatus())
                .stockNumber(itemDTO.getStockNumber())
                .build();

        // S3에 업로드하는 로직
        List<ItemImgDTO> productImg = s3UploaderService.upload("product", itemFiles);

        List<ItemImgEntity> itemImgEntities = new ArrayList<>();
        // 이미지 등록
        List<ItemDTO> savedItem = new ArrayList<>();
        for (int i = 0; i < productImg.size(); i++) {
            ItemImgDTO uploadedImage = productImg.get(i);

            ItemImgEntity itemImg = ItemImgEntity.builder()
                    .item(item)
                    .oriImgName(uploadedImage.getOriImgName())
                    .uploadImgName(uploadedImage.getUploadImgName())
                    .uploadImgUrl(uploadedImage.getUploadImgUrl())
                    .uploadImgPath(uploadedImage.getUploadImgPath())
                    .build();

            // 첫 번째 이미지일 경우 대표 상품 이미지 여부 값을 Y로 세팅합니다.
            // 나머지 상품 이미지는 N으로 설정합니다.
            if (i == 0) {
                itemImg = ItemImgEntity.builder()
                        .repImgYn("Y")
                        .build();
            } else {
                itemImg = ItemImgEntity.builder()
                        .repImgYn("N")
                        .build();
            }
            itemImgEntities.add(itemImg);
            itemImgRepository.save(itemImg);

            item = ItemEntity.builder()
                    .itemImgList(itemImgEntities)
                    .build();

            ItemEntity itemSave = itemRepository.save(item);
            savedItem.add(ItemDTO.toItemDTO(itemSave));
        }

        return ResponseEntity.ok().body(savedItem);
    }

    // 상품 상세정보
    // 상품 데이터를 읽어오는 트랜잭션을 읽기 전용으로 설정합니다.
    // 이럴 경우 JPA가 더티체킹(변경감지)를 수행하지 않아서 성능을 향상 시킬 수 있다.
    @Transactional(readOnly = true)
    public ResponseEntity<ItemDTO> getItem(Long itemId) {

        // 해당 상품의 이미지를 조회합니다.
        // 등록 순으로 가지고 오기 위해서 상품 이미지 아이디 오름차순으로 가지고 옵니다.
        // 근데 의문이 생길 수 있습니다.
        // itemImg 를 찾아오는데 왜 받는 것은 itemId 인가?
        // ItemImgEntity 안에는 ItemEntity 가 있어서
        // itemId에 따라 해당 상품과 연관된 이미지들을 조회합니다.
        List<ItemImgEntity> itemImgList =
                itemImgRepository.findByItemIdOrderByIdAsc(itemId);

        List<ItemImgDTO> itemImgDTOList = new ArrayList<>();

        for (ItemImgEntity itemImg : itemImgList) {
            ItemImgDTO itemImgDTO = ItemImgDTO.toItemDTO(itemImg);
            itemImgDTOList.add(itemImgDTO);
        }
        log.info("itemImgDTOList : " + itemImgDTOList);

        ItemEntity item = itemRepository.findById(itemId)
                .orElseThrow(EntityNotFoundException::new);


        ItemDTO itemDTO = ItemDTO.builder()
                .itemId(item.getItemId())
                .itemNum(item.getItemNum())
                .itemDetail(item.getItemDetail())
                .stockNumber(item.getStockNumber())
                .price(item.getPrice())
                .itemImgList(itemImgDTOList)
                .build();
        log.info("itemDTO : " + itemDTO);

        return ResponseEntity.ok().body(itemDTO);
    }

    // 상품 수정
    public ResponseEntity<?> updateItem(Long itemId,
                                        ItemDTO itemDTO,
                                        List<MultipartFile> itemFiles) throws Exception {

        ItemEntity item = itemRepository.findById(itemId)
                .orElseThrow(EntityNotFoundException::new);

        // 상품 정보 수정
        item = ItemEntity.builder()
                .itemId(item.getItemId())
                .itemNum(itemDTO.getItemNum())
                .itemDetail(itemDTO.getItemDetail())
                .stockNumber(itemDTO.getStockNumber())
                .price(itemDTO.getPrice())
                .build();

        // 기존의 이미지 불러오기
        List<ItemImgEntity> existingItemImgs =
                itemImgRepository.findByItemIdOrderByIdAsc(itemId);

        // 새로운 이미지들 업로드
        List<ItemImgDTO> products = s3UploaderService.upload("product", itemFiles);

        List<ItemDTO> savedItem = new ArrayList<>();

        if (existingItemImgs.isEmpty()) {
            for (int i = 0; i < products.size(); i++) {

                ItemImgDTO itemImgDTO = products.get(i);
                ItemImgEntity itemImg = ItemImgEntity.builder()
                        .item(item)
                        .oriImgName(itemImgDTO.getOriImgName())
                        .uploadImgName(itemImgDTO.getUploadImgName())
                        .uploadImgUrl(itemImgDTO.getUploadImgUrl())
                        .uploadImgPath(itemImgDTO.getUploadImgPath())
                        // 첫 번째 이미지일 경우 대표 상품 이미지 여부 값을 Y로 세팅합니다.
                        // 나머지 상품 이미지는 N으로 설정합니다.
                        .repImgYn(i == 0 ? "Y" : "N") // 대표 이미지 여부 지정
                        .build();

                itemImgRepository.save(itemImg);
                existingItemImgs.add(itemImg);
            }
        } else {
            for (ItemImgDTO product : products) {
                // DB에 등록하기 위헤서 엔티티에 넣어준다.
                ItemImgEntity itemImg = ItemImgEntity.builder()
                        .item(item)
                        .oriImgName(product.getOriImgName())
                        .uploadImgName(product.getUploadImgName())
                        .uploadImgUrl(product.getUploadImgUrl())
                        .uploadImgPath(product.getUploadImgPath())
                        // 대표 이미지 설정
                        // 대표 이미지는 한 개만 있어야 하기 때문에
                        // 기존의 대표 이미지가 있으면 그것을 불러온다.
                        .repImgYn(product.getRepImgYn())
                        .build();

                itemImgRepository.save(itemImg);
                existingItemImgs.add(itemImg);
            }
        }

        // 위에서 작성하지 못한 ItemImgEntity 를 item 엔티티에 넣어준다.
        item = ItemEntity.builder()
                .itemImgList(existingItemImgs)
                .build();

        ItemEntity save = itemRepository.save(item);

        savedItem.add(ItemDTO.toItemDTO(save));
        return ResponseEntity.ok().body(savedItem);
    }

    // 상품 삭제
    public String removeItem(Long itemId, ItemImgDTO itemImg) {
        ItemEntity itemEntity = itemRepository.findById(itemId)
                .orElseThrow(EntityNotFoundException::new);

        // 상품 정보 삭제
        itemRepository.deleteByItemId(itemEntity.getItemId());
        // DB에서 이미지 삭제
        itemImgRepository.deleteByItemId(itemEntity.getItemId());

        String uploadFilePath = itemImg.getUploadImgPath();
        String uuidFileName = itemImg.getUploadImgName();
        // S3에서 이미지 삭제
        String result = s3UploaderService.deleteFile(uploadFilePath, uuidFileName);
        return result;
    }



}

ItemController

package com.example.shoppingmall.controller.item;

import com.amazonaws.services.s3.AmazonS3Client;
import com.example.shoppingmall.dto.item.ItemDTO;
import com.example.shoppingmall.dto.item.ItemImgDTO;
import com.example.shoppingmall.service.item.ItemService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.persistence.EntityNotFoundException;
import java.util.List;

@RestController
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/api/v1/items")
public class ItemController {

    private final ItemService itemService;

    // 상품 등록
    @PostMapping("/")
    public ResponseEntity<?> insertItem(@Validated @RequestBody ItemDTO itemDTO,
                                        List<MultipartFile> itemImages,
                                        BindingResult result) throws Exception {
        if (result.hasErrors()) {
            log.info("BindingResult error : " + result.hasErrors());
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result.getClass().getSimpleName());
        }

        if (itemImages.get(0).isEmpty()) {
            log.info("첫 번째 상품 이미지는 필 수 입력입니다.");
            return ResponseEntity.notFound().build();
        }

        try {
            ResponseEntity<?> image = itemService.saveItem(itemDTO, itemImages);
            return ResponseEntity.ok().body(image);

        } catch (Exception e) {
            log.info("에러 발생했습니다.");
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
        }
    }

    // 상품 상세 정보
    @GetMapping("/{itemId}")
    public ResponseEntity<?> itemDetail(@PathVariable Long itemId) {
        try {
            ResponseEntity<ItemDTO> item = itemService.getItem(itemId);
            return ResponseEntity.ok().body(item);
        } catch (EntityNotFoundException e) {
            log.info("존재하지 않는 상품입니다.");
            return ResponseEntity.notFound().build();
        }
    }


    // 상품 수정
    @PutMapping("/{itemId}")
    public ResponseEntity<?> updateItem(@PathVariable Long itemId,
                                        @RequestBody ItemDTO itemDTO,
                                        List<MultipartFile> itemImages) throws Exception {

        try {
            ResponseEntity<?> responseEntity =
                    itemService.updateItem(itemId, itemDTO, itemImages);
            return ResponseEntity.ok().body(responseEntity);
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
        }
    }

    // 상품 삭제
    @DeleteMapping("/{itemId}")
    public ResponseEntity<?> deleteItem(
            @PathVariable Long itemId,
            @RequestBody ItemImgDTO itemImg
    ) {
        if(itemImg == null) {
            log.info("삭제할 수 없습니다.");
            return ResponseEntity.badRequest().build();
        } else {
            String result = itemService.removeItem(itemId, itemImg);
            return ResponseEntity.ok().body(result);
        }
    }

}

상품 관리하기

상품 관리 화면에서는 상품을 조회하는 조건을 설정 후 페이징 기능을 통해 일정 개수의 상품만 불러오겠습니다.

조건

  • 상품 등록일
  • 상품 판매 상태
  • 상품명 또는 상품 등록자 아이디

조회 조건이 복잡한 화면은 Querydsl을 이용해 조건에 맞는 쿼리를 동적으로 쉽게 생성할 수 있습니다. Querydsl을 사용하면 비슷한 쿼리를 재활용 할 수 있다는 장점이 있습니다. 또한 자바로 작성하기 때문에 IDE의 도움을 받아서 문법 오류를 바로 수정할 수 있습니다.

 // Querydsl 추가 시작
   implementation 'com.querydsl:querydsl-jpa'
   implementation "com.querydsl:querydsl-core"
   implementation "com.querydsl:querydsl-collections"
   // querydsl JPAAnnotationProcessor 사용 지정
   annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa"
   // java.lang.NoClassDefFoundError (javax.annotation.Generated) 대응 코드
   annotationProcessor "jakarta.annotation:jakarta.annotation-api"
   // java.lang.NoClassDefFoundError (javax.annotation.Entity) 대응 코드
   annotationProcessor "jakarta.persistence:jakarta.persistence-api"
package com.example.shoppingmall.dto.item;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Getter
@ToString
@NoArgsConstructor
public class ItemSearchDTO {
    // 현재 시간과 상품 등록일을 비교해서 상품 데이터를 조회합니다.
    // - all    : 상품 등록일 전체
    // - 1d     : 최근 하루 동안 등록된 상품
    // - 1w     : 최근 일주일 동안 등록된 상품
    // - 1m     : 최근 한달 동안 등록된 상품
    // - 6m     : 최근 6개월 동안 등록된 상품
    private String searchDateType;

    // 상품의 판매상태를 기준으로 상품 데이터를 조회합니다.
    private ItemSellStatus searchSellStatus;

    // 상품을 조회할 때 어떤 유형으로 조회할지 조회합니다.
    // - itemNm     : 상품명
    // - createBy   : 상품 등록자 아이디
    private String searchBy;

    // 조회할 검색어 저장할 변수입니다.
    // searchBy가 itemNm일 경우 상품명을 기준으로 검색하고,
    // createBy일 경우 상품 등록자 아이디 기준으로 검색합니다.
    private String searchQuery = "";
}
package com.example.shoppingmall.repository.item.custom;

import com.example.shoppingmall.dto.item.ItemSearchDTO;
import com.example.shoppingmall.entity.item.ItemEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface ItemRepositoryCustom {

    // 상품 조회 조건을 담고 잇는 itemSearchDTO 객채와 페이징 정보를 담고 있는 pageable 객체를
    // 파라미터로 받고 있는 getAdminItemPage 메소드를 정의합니다.
    // 반환 데이터로는 Page<Item> 객체를 반환합니다.
    Page<ItemEntity> getAdminItemPage(ItemSearchDTO itemSearchDTO, Pageable pageable);
}
profile
발전하기 위한 공부

0개의 댓글