프로젝트 관련 공부 정리

유요한·2023년 6월 20일
0

프로젝트

목록 보기
5/5
post-thumbnail

여기서는 프로젝트를 진행하면서 선택했던 기술들을 공부했던 것들을 정리하고자 합니다. 그것뿐만 아니라 연관된 지식도 정리할 생각입니다.

💡가 붙은 것은 질문을 예측해보고 나였으면 무엇을 물어봤을까라며 저 자신에게 질문을 던지고 답변한 것입니다.


프로젝트 세팅

세팅

buildscript {
    ext {
        queryDslVersion = "5.0.0"
    }
}
plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.15'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
    id 'jacoco'
    //querydsl 추가
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}


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

java {
    sourceCompatibility = '11'
}


configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

jacoco {
    // JaCoCo 버전
    toolVersion = '0.8.7'

    //  테스트결과 리포트를 저장할 경로 변경하는 방법
    //  default는 "$/jacoco"
    // customJacocoReportDir이라는 디렉토리를 build 디렉토리 내에 생성하고 결과 리포트를 저장합니다.
//    reportsDirectory = layout.buildDirectory.dir('customJacocoReportDir')
}

test {
    // finalizedBy : 이(test) 작업에 대해 주어진 종료자 작업을 추가
    finalizedBy jacocoTestReport // test 작업이 끝나고 jacocoTestReport를 실행
}
jacocoTestReport {
    // dependsOn : 이 작업에 지정된 종속성을 추가
    dependsOn test // jacocoTestReport 에 test라는 종속성을 추가
}
jacocoTestReport {
    reports {
        // XML 형식의 리포트가 필요하지 않을 경우 false
        xml.required = false
        // CSV 형식의 리포트가 필요하지 않을 경우 false
        csv.required = false
        //  HTML 형식의 리포트를 생성하고 결과를 jacocoHtml 디렉토리에 저장합니다.
        //  여기서 layout.buildDirectory.dir 함수를 사용하여 디렉토리 경로를 지정합니다.
        html.outputLocation = layout.buildDirectory.dir('jacocoHtml')
    }
}
//  코드 커버리지 검증 규칙을 설정
jacocoTestCoverageVerification {
    violationRules {
        // 전체 코드 커버리지의 최소 기준
        rule {
            limit {
                // 전체 커버리지가 최소 50% 이상
                minimum = 0
            }
        }
        //  클래스 수준의 규칙을 설정
        rule {
            enabled = true
            element = 'CLASS'
            // 여기에 있는 패키지만 메서드 커버리지 최소 기준을 설정
            // 여기에 없는 패키지는 제외
            includes = ['com.example.shopping.service.*']

            limit {
                counter = 'METHOD'
                value = 'COVEREDRATIO'
                minimum = 0.5
            }
        }
    }
}

repositories {
    mavenCentral()
}

dependencies {
    // 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'
    // validation(유효성 검사)
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    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'
    testCompileOnly 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    // swagger
    implementation 'io.springfox:springfox-boot-starter:3.0.0'
    // AWS S3
    implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
    // in-memory DB
    runtimeOnly 'com.h2database:h2'
    // StringUtils
    implementation 'org.apache.commons:commons-lang3'
    // thymeleaf
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    // actuator
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    //querydsl 추가
    implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
    implementation "com.querydsl:querydsl-apt:${queryDslVersion}"
    // mail
    implementation 'org.springframework.boot:spring-boot-starter-mail'
    implementation 'org.springframework:spring-context-support'
}
jar {
    // ~~plain.jar파일은 생성되지 않게
    enabled = false
}
tasks.named('test') {
    useJUnitPlatform()
}
//querydsl 추가 시작
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}
sourceSets {
    main.java.srcDir querydslDir
}
configurations {
    querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}

설정 파일

설정파일은 properties를 선택하지 않고 yml을 선택했습니다. 여태 공부할 때 properties를 많이 사용해봤지만 가독성이 안좋았습니다. yml은 들여쓰기 때문에 어디에 속해있는지 구별하기 쉬웠고 가독성이 좋았습니다.

spring:
  profiles:
    group:
      local:
        - jwt
        - s3
        - oauth
        - local
        - mail
      prod:
        - prod
#    active: local
    active: ${profile}
  devtools:
    livereload:
      enabled: true
  restart:
    enabled: true

local

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/shopping_project
    username: root
    password: 1234
  # swagger
  mvc:
    pathmatch:
      matching-strategy: ant_path_matcher

  jpa:
    # 데이터베이스 플랫폼을 지정
    database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
    # JPA Open EntityManager in View 패턴을 활성화 또는 비활성화
    # false하는 경우 : 트래픽이 중요할 때
    # true하는 경우 : 트래픽보다는 성능을 우선하고 싶을 때 (주로 admin - 어드민의 경우 트래픽이 별로 없기 때문)
    open-in-view: false
    hibernate:
      # Hibernate가 데이터베이스 스키마를 자동으로 생성 또는 갱신할 때 사용
      ddl-auto: update
    properties:
      hibernate:
        # SQL 쿼리를 보기 쉽게 형식화할지 여부를 지정
        format_sql: true
        # LAZY 로딩 시 기본적으로 사용되는 배치 크기를 설정
        # fetch join 사용시 최적화
        default_batch_fetch_size: 100
        # SQL 쿼리에 주석을 추가할지 여부를 지정
        use_sql_comments: true

  data:
    web:
      pageable:
        default-page-size: 10
        max-page-size: 2000
        one-indexed-parameters: true

---
management:
  endpoint:
    # true로 설정하면, 애플리케이션의 상태를 나타내는 /actuator/health 엔드포인트가 활성화됩니다.
    # 이 엔드포인트는 일반적으로 애플리케이션의 건강 상태를 나타내는데 사용됩니다.
    health:
      enabled: true
      cache:
        time-to-live: 1d
    # true로 설정하면 애플리케이션의 빈정보를 나타내는 /actuator/beans 엔드포인트 활성화
    beans:
      enabled: false
      cache:
        time-to-live: 1d
      # true로 설정하면 애플리케이션의 캐시 정보를 나타내는 /actuator/caches 엔드포인트가 활성화됩니다.
    caches:
      enabled: false
      # true로 설정하면, /actuator/heapdump 엔드포인트를 통해 힙 덤프를 생성할 수 있습니다.
      # 이는 애플리케이션의 힙 메모리 상태를 분석하기 위해 사용됩니다.
      cache:
        time-to-live: 2s
    heapdump:
      enabled: false
      cache:
        time-to-live: 10s
    # 사용자 지정 정보를 나타내는 엔드포인트입니다.
    info:
      enabled: true
    # 애플리케이션의 메트릭 정보를 제공하는 엔드포인트
    metrics:
      enabled: true
    # 애플리케이션의 환경 속성을 제공하는 엔드포인트
    env:
      enabled: true
    # 애플리케이션의 URL 매핑 정보를 제공하는 엔드포인트
    mappings:
      enabled: true
    # 로깅 설정을 제공하는 엔드포인트
    loggers:
      enabled: true

  # 활성화할 관리 엔드포인트를 명시적으로 지정하는데 사용됩니다.
  endpoints:
    web:
      exposure:
        # *는 모든 엔드포인트를 활성화하고, exclude에 명시된 엔드포인트는 제외됩니다.
        include:
          - health
          - info
          - metrics
          - env
          - mappings
          - events
          - loggers
        exclude:
          - beans
          - caches
          - heapdump



---
logging:
  level:
    org:
      # Hibernate 라이브러리에 속한 org.hibernate.SQL 패키지의 클래스들에 대한 로그 레벨을 설정합니다.
      # 특정 라이브러리나 패키지에 대한 로그 레벨을 따로 조정하고자 할 때 사용됩니다.
      # Hibernate의 SQL 쿼리를 자세히 보고 싶을 때 유용합니다.
      hibernate:
        SQL: debug
    # 이 설정은 애플리케이션 전반적인 로그 레벨을 설정합니다.
    # root는 로깅의 루트 패키지를 나타냅니다.
    # 따라서 root 패키지 이하의 모든 클래스들에 대한 로그 레벨이 debug로 설정됩니다.
    # 이는 애플리케이션 전체에 적용되는 설정이기 때문에 상세한 로그가 많이 생성될 수 있습니다.
#    root: debug

---
management:
  endpoint:
    web:
      cors:
        allowed-origins: http://localhost:3000
      exposure:
        include: "*"
    health:
      show-details: always


---
#multipart upload파일 용량설정
#default : 1MB
spring:
  servlet:
    multipart:
      max-file-size: 5MB
      max-request-size: 5MB

jwt

jwt:
  secret_key: jwt 비번
  access:
    expiration: 3600000000
  refresh:
    expiration: 1209600000000

Oauth2

# google
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: 
            client-secret: 
            redirect-uri: http://localhost:8080/login/oauth2/code/google
            scope:
              - email
              - profile
# naver
          naver:
            client-id: 
            client-secret: 
            client-name: Naver
            redirect-uri: http://localhost:8080/login/oauth2/code/naver
            authorization-grant-type: authorization_code
            scope:
              - name
              - email
        provider:
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response

s3

cloud:
  aws:
    credentials:
      access-key: 
      secret-key:
    s3:
      bucket: shoppingproject
    region:
      # 버킷 생성시 선택한 AWS 리전
      static: ap-northeast-2
      auto: false
    stack:
      # 설정한 CloudFormation 이 없으면 프로젝트 시작이 안되니, 해당 내용을 사용하지 않도록 false 를 등록
      auto: false

배포

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${google_client}
            client-secret: ${google_secret}
            redirect-uri: ${google_uri}/login/oauth2/code/google
            scope:
              - email
              - profile

          # naver
          naver:
            client-id: ${naver_client}
            client-secret: ${naver_secret}
            client-name: Naver
            redirect-uri: http://localhost:8080/login/oauth2/code/naver
            authorization-grant-type: authorization_code
            scope:
              - name
              - email
        provider:
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response
  mvc:
    pathmatch:
      matching-strategy: ant_path_matcher

jwt:
  secret_key: ${jwt_secret}
  access:
    expiration: 3600000000
  refresh:
    expiration: 1209600000000

cloud:
  aws:
    credentials:
      access-key: ${s3_access}
      secret-key: ${s3_secret}
    s3:
      bucket: ${s3_bucket}
    region:
      # 버킷 생성시 선택한 AWS 리전
      static: ap-northeast-2
      auto: false
    stack:
      # 설정한 CloudFormation 이 없으면 프로젝트 시작이 안되니, 해당 내용을 사용하지 않도록 false 를 등록
      auto: false

---
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: ${rds_mysql_url}
    username: ${rds_mysql_name}
    password: ${rds_mysql_secret}

  jpa:
    # 데이터베이스 플랫폼을 지정
    database-platform: org.hibernate.dialect.MySQL8Dialect
    # JPA Open EntityManager in View 패턴을 활성화 또는 비활성화
    open-in-view: true
    # JPA 처리 시에 발생하는 SQL을 보여줄 것인지 결정합니다.
    show-sql: true
    hibernate:
      # 운영시에는 validate나 none으로 해야합니다.
      # 수정되면 안되기 때문입니다.
      ddl-auto: validate
    properties:
      hibernate:
        show_sql: true
        # 실제 JPA의 구현체인 Hibernate 가 동작하면서 발생하는 SQL을 포맷팅해서 출력합니다.
        # 실행되는 SQL의 가독성을 높여 줍니다.
        format_sql: true
        # LAZY 로딩 시 기본적으로 사용되는 배치 크기를 설정
        # fetch join 사용시 최적화
        default_batch_fetch_size: 100

  data:
    web:
      pageable:
        default-page-size: 10
        max-page-size: 2000
        one-indexed-parameters: true

---
logging:
  level:
    org:
      # Hibernate 라이브러리에 속한 org.hibernate.SQL 패키지의 클래스들에 대한 로그 레벨을 설정합니다.
      # 특정 라이브러리나 패키지에 대한 로그 레벨을 따로 조정하고자 할 때 사용됩니다.
      # Hibernate의 SQL 쿼리를 자세히 보고 싶을 때 유용합니다.
      hibernate:
        SQL: debug
    # 이 설정은 애플리케이션 전반적인 로그 레벨을 설정합니다.
    # root는 로깅의 루트 패키지를 나타냅니다.
    # 따라서 root 패키지 이하의 모든 클래스들에 대한 로그 레벨이 debug로 설정됩니다.
    # 이는 애플리케이션 전체에 적용되는 설정이기 때문에 상세한 로그가 많이 생성될 수 있습니다.
    root: info
---
spring:
  session:
    store-type: redis

---
management:
  endpoint:
    # true로 설정하면, 애플리케이션의 상태를 나타내는 /actuator/health 엔드포인트가 활성화됩니다.
    # 이 엔드포인트는 일반적으로 애플리케이션의 건강 상태를 나타내는데 사용됩니다.
    health:
      enabled: true
      cache:
        time-to-live: 1d
    # true로 설정하면 애플리케이션의 빈정보를 나타내는 /actuator/beans 엔드포인트 활성화
    beans:
      enabled: false
      cache:
        time-to-live: 1d
      # true로 설정하면 애플리케이션의 캐시 정보를 나타내는 /actuator/caches 엔드포인트가 활성화됩니다.
    caches:
      enabled: false
      # true로 설정하면, /actuator/heapdump 엔드포인트를 통해 힙 덤프를 생성할 수 있습니다.
      # 이는 애플리케이션의 힙 메모리 상태를 분석하기 위해 사용됩니다.
      cache:
        time-to-live: 2s
    heapdump:
      enabled: false
      cache:
        time-to-live: 10s
    # 사용자 지정 정보를 나타내는 엔드포인트입니다.
    info:
      enabled: true
    # 애플리케이션의 메트릭 정보를 제공하는 엔드포인트
    metrics:
      enabled: true
    # 애플리케이션의 환경 속성을 제공하는 엔드포인트
    env:
      enabled: true
    # 애플리케이션의 URL 매핑 정보를 제공하는 엔드포인트
    mappings:
      enabled: true
    # 로깅 설정을 제공하는 엔드포인트
    loggers:
      enabled: true

  # 활성화할 관리 엔드포인트를 명시적으로 지정하는데 사용됩니다.
  endpoints:
    web:
      exposure:
        # *는 모든 엔드포인트를 활성화하고, exclude에 명시된 엔드포인트는 제외됩니다.
        include:
          - health
          - info
          - metrics
          - env
          - mappings
          - events
          - loggers
        exclude:
          - beans
          - caches
          - heapdump

---
management:
  endpoint:
    web:
      cors:
        allowed-origins: http://blue-bucket-front.s3-website.ap-northeast-2.amazonaws.com/
      exposure:
        include: "*"
    health:
      show-details: always
---
#multipart upload파일 용량설정
#default : 1MB
spring:
  servlet:
    multipart:
      max-file-size: 5MB
      max-request-size: 5MB

Auditing 기능 사용

Swagger 기능

React와 연결

이렇게 했을 때 CORS 에러가 발생하지 않고 제대로 연결되는 것을 확인할 수 있었습니다.

유저

RequestMemberDTO

여기서 사용한 regexp = "^(?:\\w+\\.?)*\\w+@(?:\\w+\\.)+\\w+$"는 정규식 표현으로 이메일 형식에 맞게 요청해달라고 설정을 해둔 것이다. 이렇게 사용한 이유는 서버에서 프론트에서 받아올 데이터를 검증하기 위해서 입니다.

DTO

ResponseMemberDTO

@ToString
@Getter
@NoArgsConstructor
public class ResponseMemberDTO {
    @Schema(description = "유저 번호", example = "1", required = true)
    private Long memberId;

    @Schema(description = "이메일", example = "abc@gmail.com", required = true)
    @NotNull(message = "이메일은 필수 입력입니다.")
    @Pattern(regexp = "^(?:\\w+\\.?)*\\w+@(?:\\w+\\.)+\\w+$", message = "이메일 형식이 올바르지 않습니다.")
    @Email(message = "이메일 형식에 맞지 않습니다.")
    private String email;

    @Schema(description = "회원 이름")
    @NotNull(message = "이름은 필수 입력입니다.")
    private String memberName;

    @Schema(description = "닉네임")
    @NotNull(message = "닉네임은 필수 입력입니다.")
    private String nickName;

    @Schema(description = "회원 비밀번호")
    private String memberPw;

    @Schema(description = "회원 권한")
    @NotNull(message = "권한정보는 필수 입력입니다.")
    private Role memberRole;

    @Schema(description = "회원 주소")
    private AddressDTO memberAddress;

    @Schema(description = "소셜 로그인")
    private String provider;        // ex) google

    @Schema(description = "소셜 로그인 식별 아이디")
    private String providerId;

    @Schema(description = "포인트")
    private int memberPoint;

    @Builder
    public ResponseMemberDTO(Long memberId,
                             String email,
                             String memberName,
                             String nickName,
                             String memberPw,
                             Role memberRole,
                             AddressDTO memberAddress,
                             String provider,
                             String providerId,
                             int memberPoint) {
        this.memberId = memberId;
        this.email = email;
        this.memberName = memberName;
        this.nickName = nickName;
        this.memberPw = memberPw;
        this.memberRole = memberRole;
        this.memberAddress = memberAddress;
        this.provider = provider;
        this.providerId = providerId;
        this.memberPoint = memberPoint;
    }

    // 엔티티를 DTO로 반환
    public static ResponseMemberDTO toMemberDTO(MemberEntity member) {
        return ResponseMemberDTO.builder()
                .memberId(member.getMemberId())
                .email(member.getEmail())
                .memberPw(member.getMemberPw())
                .nickName(member.getNickName())
                .memberName(member.getMemberName())
                .memberRole(member.getMemberRole())
                .memberPoint(member.getMemberPoint())
                .memberAddress(AddressDTO.builder()
                        .memberAddr(member.getAddress() == null
                                ? null : member.getAddress().getMemberAddr())
                        .memberAddrDetail(member.getAddress() == null
                                ? null : member.getAddress().getMemberAddrDetail())
                        .memberZipCode(member.getAddress() == null
                                ? null : member.getAddress().getMemberZipCode())
                        .build()).build();
    }
    // 소셜 로그인시 필요한 정보만 전달하기 위해서
    public static ResponseMemberDTO socialMember(MemberEntity member) {
        return ResponseMemberDTO.builder()
                .provider(member.getProvider())
                .providerId(member.getProviderId())
                .build();
    }
}

회원가입 시 보내줄 DTO

로그인 시 보내줄 DTO

수정 시 보내줄 DTO

엔티티

/*
 *   writer : 유요한
 *   work :
 *          유저 엔티티
 *   date : 2024/01/10
 * */
@Entity(name = "member")
@Getter
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MemberEntity extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id", nullable = false)
    private Long memberId;

    @Column(name = "member_name", nullable = false)
    private String memberName;

    @Column(name = "member_email", nullable = false)
    private String email;

    @Column(name = "member_pw")
    private String memberPw;

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

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    // USER, ADMIN
    private Role memberRole;

    private String provider;
    private String providerId;

    @Embedded
    private AddressEntity address;
    private int memberPoint;

    @Builder
    public MemberEntity(Long memberId,
                        String memberName,
                        String email,
                        String memberPw,
                        String nickName,
                        Role memberRole,
                        String provider,
                        String providerId,
                        AddressEntity address,
                        int memberPoint) {
        this.memberId = memberId;
        this.memberName = memberName;
        this.email = email;
        this.memberPw = memberPw;
        this.nickName = nickName;
        this.memberRole = memberRole;
        this.provider = provider;
        this.providerId = providerId;
        this.address = address;
        this.memberPoint = memberPoint;
    }

    // 저장
    public static MemberEntity saveMember(RequestMemberDTO member, String password) {
        return MemberEntity.builder()
                .email(member.getEmail())
                .memberPw(password)
                .memberName(member.getMemberName())
                .nickName(member.getNickName())
                .memberRole(member.getMemberRole())
                .address(AddressEntity.builder()
                        .memberAddr(member.getMemberAddress().getMemberAddr() == null
                                ? null : member.getMemberAddress().getMemberAddr())
                        .memberAddrDetail(member.getMemberAddress().getMemberAddrDetail() == null
                                ? null : member.getMemberAddress().getMemberAddrDetail())
                        .memberZipCode(member.getMemberAddress().getMemberZipCode() == null
                                ? null : member.getMemberAddress().getMemberZipCode())
                        .build()).build();
    }

    public void updateMember(UpdateMemberDTO updateMember, String encodePw) {
        this.memberPw = updateMember.getMemberPw() == null ? this.memberPw : encodePw;
        this.nickName = updateMember.getNickName() == null ? this.nickName : updateMember.getNickName();

        // 기존 주소 엔티티를 직접 수정
        if (updateMember.getMemberAddress() != null) {
            this.address = AddressEntity.builder()
                    .memberAddr(updateMember.getMemberAddress().getMemberAddr())
                    .memberAddrDetail(updateMember.getMemberAddress().getMemberAddrDetail())
                    .memberZipCode(updateMember.getMemberAddress().getMemberZipCode())
                    .build();
        } else {
            this.address = null;
        }
    }


    public void addPoint(int point) {
        this.memberPoint += point;
    }
}

컨트롤러

/*
 *   writer : YuYoHan
 *   work :
 *          유저의 CRUD 기능과 이메일 조회와 닉네임 조회 기능이 있고
 *          주문 조회와 나의 문의글을 보는 기능이 있습니다.
 *   date : 2024/01/22
 * */
@RestController
// @Slf4j를 사용하지 않고 Log4j2를 사용하는 이유는
// 기능면에서 더 좋기 때문입니다.
@Log4j2
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
@Tag(name = "member", description = "유저 API")
public class MemberController {

    private final MemberService memberService;
    private final TokenService tokenService;
    private final OrderService orderService;
    private final BoardService boardService;

    // 회원가입
    @PostMapping("")
    @Tag(name = "member")
    @Operation(summary = "회원가입", description = "회원가입하는 API입니다")
    // BindingResult 타입의 매개변수를 지정하면 BindingResult 매개 변수가 입력값 검증 예외를 처리한다.
    public ResponseEntity<?> join(@Validated @RequestBody RequestMemberDTO member,
                                  BindingResult result) {
        try {
            // 입력값 검증 예외가 발생하면 예외 메시지를 응답한다.
            if (result.hasErrors()) {
                log.info("BindingResult error : " + result.hasErrors());
                return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result.getClass().getSimpleName());
            }

            ResponseEntity<?> signUp = memberService.signUp(member);
            return ResponseEntity.ok().body(signUp);
        } catch (Exception e) {
            log.error("예외 : " + e.getMessage());
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
        }
    }

    // 회원 조회
    @GetMapping("/{memberId}")
    @Tag(name = "member")
    @Operation(summary = "회원 조회", description = "회원을 검색하는 API입니다.")
    public ResponseEntity<?> search(@PathVariable Long memberId) {
        try {
            ResponseMemberDTO search = memberService.search(memberId);
            return ResponseEntity.ok().body(search);
        } catch (EntityNotFoundException e) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
        }
    }

    // 회원 탈퇴
    @DeleteMapping("/{memberId}")
    @Tag(name = "member")
    @Operation(summary = "삭제 API", description = "유저를 삭제하는 API입니다.")
    @PreAuthorize("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
    public String remove(@PathVariable Long memberId,
                         @AuthenticationPrincipal UserDetails userDetails) {
        try {
            String email = userDetails.getUsername();
            log.info("email : " + email);
            String remove = memberService.removeUser(memberId, email);
            return remove;
        } catch (Exception e) {
            return "회원탈퇴 실패했습니다. :" + e.getMessage();
        }
    }

    // 로그인
    @PostMapping("/login")
    @Tag(name = "member")
    @Operation(summary = "로그인 API", description = "로그인을 하면 JWT를 반환해줍니다.")
    public ResponseEntity<?> login(@RequestBody LoginDTO loginDTO) {
        try {
            String email = loginDTO.getMemberEmail();
            String password = loginDTO.getMemberPw();
            ResponseEntity<?> login = memberService.login(email, password);
            return ResponseEntity.ok().body(login);
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("로그인을 실패했습니다.");
        }
    }

    // 회원 수정
    @PutMapping("/{memberId}")
    @Tag(name = "member")
    @Operation(summary = "수정 API", description = "유저 정보를 수정하는 API입니다.")
    @PreAuthorize("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
    public ResponseEntity<?> update(@PathVariable Long memberId,
                                    @Validated @RequestBody UpdateMemberDTO updateMemberDTO,
                                    @AuthenticationPrincipal UserDetails userDetails) {
        try {
            String email = userDetails.getUsername();
            log.info("email : " + email);
            log.info("수정 정보 체크 : " + updateMemberDTO);
            ResponseEntity<?> responseEntity = memberService.updateUser(memberId, updateMemberDTO, email);
            return ResponseEntity.ok().body(responseEntity);
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }

    // accessToken 만료시 refreshToken으로 accessToken 발급
    @GetMapping("/refresh")
    @Tag(name = "member")
    @Operation(summary = "access token 발급", description = "refresh token을 받으면 access token을 반환해줍니다.")
    public ResponseEntity<?> refreshToken(@AuthenticationPrincipal UserDetails userDetails) throws Exception {
        try {
            String email = userDetails.getUsername();
            log.info("이메일 : " + email);
            ResponseEntity<?> accessToken = tokenService.createAccessToken(email);
            return ResponseEntity.ok().body(accessToken);
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }

    // 중복체크
    @GetMapping("/email/{memberEmail}")
    @Tag(name = "member")
    @Operation(summary = "중복체크 API", description = "userEmail이 중복인지 체크하는 API입니다.")
    public boolean emailCheck(@PathVariable String memberEmail) {
        log.info("email : " + memberEmail);
       return memberService.emailCheck(memberEmail);
    }

    // 닉네임 조회
    @GetMapping("/nickName/{nickName}")
    @Tag(name = "member")
    @Operation(summary = "닉네임 조회", description = "중복된 닉네임이 있는지 확인하는 API입니다.")
    public boolean nickNameCheck(@PathVariable String nickName) {
        log.info("nickName : " + nickName);
        return memberService.nickNameCheck(nickName);
    }

    // 주문 조회
    @GetMapping(value = "/orders")
    @Tag(name = "member")
    @PreAuthorize("hasRole('ROLE_ADMIN') or hasRole('ROLE_USER')")
    @Operation(summary = "주문내역조회", description = "주문내역을 조회하는 API입니다.")
    public ResponseEntity<?> getOrders(Pageable pageable,
                                       @AuthenticationPrincipal UserDetails userDetails) {
        try {
            String email = userDetails.getUsername();
            Page<OrderItemDTO> ordersPage = orderService.getOrdersPage(pageable, email);
            Map<String, Object> response = new HashMap<>();
            // 현재 페이지의 아이템 목록
            response.put("items", ordersPage.getContent());
            // 현재 페이지 번호
            response.put("nowPageNumber", ordersPage.getNumber()+1);
            // 전체 페이지 수
            response.put("totalPage", ordersPage.getTotalPages());
            // 한 페이지에 출력되는 데이터 개수
            response.put("pageSize", ordersPage.getSize());
            // 다음 페이지 존재 여부
            response.put("hasNextPage", ordersPage.hasNext());
            // 이전 페이지 존재 여부
            response.put("hasPreviousPage", ordersPage.hasPrevious());
            // 첫 번째 페이지 여부
            response.put("isFirstPage", ordersPage.isFirst());
            // 마지막 페이지 여부
            response.put("isLastPage", ordersPage.isLast());
            return ResponseEntity.ok().body(response);
        } catch (Exception e) {
            return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST);
        }
    }

    // 나의 문의글 확인
    @GetMapping("/myboards")
    @Tag(name = "member")
    @PreAuthorize("hasRole('ROLE_ADMIN') or hasRole('ROLE_USER')")
    @Operation(summary = "문의글 보기", description = "자신이 문의한 게시글을 보는 API입니다.")
    public ResponseEntity<?> getBoards(
            @AuthenticationPrincipal UserDetails userDetails,
            @PageableDefault(sort = "boardId", direction = Sort.Direction.DESC)
            Pageable pageable,
            String searchKeyword) {
        try {
            String email = userDetails.getUsername();
            log.info("유저 : " + email);

            Page<BoardDTO> boards = boardService.getMyBoards(email, pageable, searchKeyword);
            Map<String, Object> response = new HashMap<>();
            // 현재 페이지의 아이템 목록
            response.put("items", boards.getContent());
            // 현재 페이지 번호
            response.put("nowPageNumber", boards.getNumber()+1);
            // 전체 페이지 수
            response.put("totalPage", boards.getTotalPages());
            // 한 페이지에 출력되는 데이터 개수
            response.put("pageSize", boards.getSize());
            // 다음 페이지 존재 여부
            response.put("hasNextPage", boards.hasNext());
            // 이전 페이지 존재 여부
            response.put("hasPreviousPage", boards.hasPrevious());
            // 첫 번째 페이지 여부
            response.put("isFirstPage", boards.isFirst());
            // 마지막 페이지 여부
            response.put("isLastPage", boards.isLast());
            return ResponseEntity.ok().body(response);
        } catch (Exception e) {
            return ResponseEntity.badRequest().build();
        }
    }


}

여기서 보면 나의 주문 내역을 조회할 수 있고 나의 문의글을 확인할 수 있습니다. 그리고 페이지 정보를 Map에 담아서 프론트에게 반환해주고 있습니다.

response.put("nowPageNumber", boards.getNumber()+1);

현재 페이지를 나타내는 정보에서 +1을 하고 있는데 1페이지 부터 시작을 나타내려고 이렇게 처리했습니다.

 @PageableDefault(sort = "boardId", direction = Sort.Direction.DESC)
            Pageable pageable,

이거는 Spring Data Jpa에서 제공하는 페이징 기능을 사용할 때는 적용되지만 JPQL이나 Querydsl은 별도로 적용해줘야 합니다.

레포지토리

레포지토리는 Spring Data Jpa 방법을 사용했습니다.

서비스

/*
 *   writer : 유요한
 *   work :
 *          유저 서비스
 *          - 유저의 등록, 수정, 삭제, 로그인기능과 이메일 중복체크, 닉네임 중복체크 기능이 있습니다.
 *          이렇게 인터페이스를 만들고 상속해주는 방식을 선택한 이유는
 *          메소드에 의존하지 않고 필요한 기능만 사용할 수 있게 하고 가독성과 유지보수성을 높이기 위해서 입니다.
 *   date : 2024/01/18
 * */
@Service
@RequiredArgsConstructor
@Log4j2
@Transactional
public class MemberServiceImpl implements MemberService {
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtProvider jwtProvider;
    private final TokenRepository tokenRepository;
    private final BoardRepository boardRepository;
    private final CommentRepository commentRepository;
    private final CartJpaRepository cartJpaRepository;
    private final CartJpaRepository cartRepository;


    // 중복체크
    @Override
    public boolean emailCheck(String email) {
        MemberEntity findEmail = memberRepository.findByEmail(email);
        return findEmail == null;
    }

    // 닉네임 체크
    @Override
    public boolean nickNameCheck(String nickName) {
        MemberEntity findNickName = memberRepository.findByNickName(nickName);
        return findNickName == null;
    }

    // 회원가입
    @Override
    public ResponseEntity<?> signUp(RequestMemberDTO memberDTO) {
        log.info("비번 : " + memberDTO.getMemberPw());
        String encodePw = passwordEncoder.encode(memberDTO.getMemberPw());
        log.info("암호화 : " + encodePw);
        try {
            log.info("email : " + memberDTO.getEmail());
            log.info("nickName : " + memberDTO.getNickName());

            // 이메일 중복 체크
            if (!emailCheck(memberDTO.getEmail())) {
                log.error("이미 존재하는 이메일이 있습니다.");
                return ResponseEntity.badRequest().body("이미 존재하는 이메일이 있습니다.");
            }

            // 닉네임 중복 체크
            if (!nickNameCheck(memberDTO.getNickName())) {
                log.error("이미 존재하는 닉네임이 있습니다.");
                return ResponseEntity.badRequest().body("이미 존재하는 닉네임이 있습니다.");
            }

            // 아이디가 없다면 DB에 등록해줍니다.
            MemberEntity member = MemberEntity.saveMember(memberDTO, encodePw);
            MemberEntity saveMember = memberRepository.save(member);
            log.info("member : " + saveMember);

            // 유저 생성시 장바구니를 생성해주기 위해서 작성
            CartEntity cart = CartEntity.createCart(saveMember);
            CartDTO cartDTO = CartDTO.toCartDTO(cart);
            log.info("새로운 장바구니 생성 : " + cartDTO);
            cartRepository.save(cart);

            ResponseMemberDTO coverMember = ResponseMemberDTO.toMemberDTO(saveMember);
            return ResponseEntity.ok().body(coverMember);
        } catch (Exception e) {
            log.error(e.getMessage());
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }
    // 회원 조회
    @Override
    public ResponseMemberDTO search(Long memberId) {
        try {
            MemberEntity findUser = memberRepository.findById(memberId)
                    .orElseThrow(EntityNotFoundException::new);
            return ResponseMemberDTO.toMemberDTO(findUser);
        } catch (EntityNotFoundException e) {
            throw new EntityNotFoundException("회원이 존재 하지 않습니다.");
        }
    }

    // 회원 삭제
    @Override
    public String removeUser(Long memberId, String email) {
        // 회원 조회
        MemberEntity findUser = memberRepository.findByEmail(email);
        log.info("email check : " + email);
        log.info("email check2 : " + findUser.getEmail());

        // 회원이 비어있지 않고 넘어온 id가 DB에 등록된 id가 일치할 때
        if (findUser.getMemberId().equals(memberId)) {
            boardRepository.deleteAllByMemberMemberId(memberId);
            commentRepository.deleteAllByMemberMemberId(memberId);
            cartJpaRepository.deleteAllByMemberMemberId(memberId);
            memberRepository.deleteByMemberId(memberId);
            return "회원 탈퇴 완료";
        } else {
            return "해당 유저가 아니라 삭제할 수 없습니다.";
        }
    }

    // 로그인
    @Override
    public ResponseEntity<?> login(String memberEmail, String memberPw) {
        try {
            // 회원 조회
            MemberEntity findUser = memberRepository.findByEmail(memberEmail);
            log.info("user : " + findUser);

            if (findUser != null) {
                // DB에 넣어져 있는 비밀번호는 암호화가 되어 있어서 비교하는 기능을 사용해야 합니다.
                // 사용자가 입력한 패스워드를 암호화하여 사용자 정보와 비교
                if (passwordEncoder.matches(memberPw, findUser.getMemberPw())) {
                    //  Spring Security의 인증 매커니즘을 통해 사용자를 인증하는데 사용
                    Authentication authentication =
                            new UsernamePasswordAuthenticationToken(memberEmail, memberPw);
                    log.info("authentication : " + authentication);
                    List<GrantedAuthority> authoritiesForUser = getAuthoritiesForUser(findUser);

                    // JWT 생성
                    TokenDTO token = jwtProvider.createToken(authentication, authoritiesForUser, findUser.getMemberId());
                    // 토큰 조회
                    TokenEntity findToken = tokenRepository.findByMemberEmail(token.getMemberEmail());

                    // 토큰이 없다면 새로 발급
                    if (findToken == null) {
                        log.info("발급한 토큰이 없습니다. 새로운 토큰을 발급합니다.");
                        // 토큰 생성과 조회한 memberId를 넘겨줌
                        findToken = TokenEntity.tokenEntity(token);
                        // 토큰id는 자동생성
                    } else {
                        log.info("이미 발급한 토큰이 있습니다. 토큰을 업데이트합니다.");
                        // 이미 존재하는 토큰이니 토큰id가 있다.
                        // 그 id로 토큰을 업데이트 시켜준다.
                        findToken.updateToken(token);
                    }
                    tokenRepository.save(findToken);
                    return ResponseEntity.ok().body(token);
                }
            }
            throw new EntityNotFoundException("회원이 존재하지 않습니다.");
        } catch (EntityNotFoundException e) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
        }
    }

    // 회원의 권한을 GrantedAuthority타입으로 반환하는 메소드
    private List<GrantedAuthority> getAuthoritiesForUser(MemberEntity member) {
        Role memberRole = member.getMemberRole();
        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("ROLE_" + memberRole.name()));
        log.info("role : " + authorities);
        return authorities;
    }

    // 회원정보 수정
    @Override
    public ResponseEntity<?> updateUser(Long memberId, UpdateMemberDTO updateMemberDTO, String memberEmail) {
        try {
            // 회원조회
            MemberEntity findUser = memberRepository.findByEmail(memberEmail);
            log.info("user : " + findUser);

            // 닉네임 중복 체크
            if (!nickNameCheck(updateMemberDTO.getNickName())) {
                throw new UserException("이미 존재하는 닉네임이 있습니다.");
            }

            String encodePw = null;
            if(updateMemberDTO.getMemberPw() != null) {
                encodePw = passwordEncoder.encode(updateMemberDTO.getMemberPw());
            }
            log.info("encodePw : " + encodePw);

            if (findUser.getMemberId().equals(memberId)) {
                findUser.updateMember(updateMemberDTO, encodePw);
                log.info("유저 수정 : " + findUser);

                MemberEntity updateUser = memberRepository.save(findUser);
                ResponseMemberDTO toResponseMemberDTO = ResponseMemberDTO.toMemberDTO(updateUser);
                return ResponseEntity.ok().body(toResponseMemberDTO);
            } else {
                throw new UserException("회원 정보가 일치 하지 않습니다.");
            }

        } catch (EntityNotFoundException e) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
        }
    }
}

서비스 계층이라는 아키텍처 디자인 패턴을 따릅니다.

서비스 계층은 소프트웨어 아키텍처에서 사용되는 디자인 패턴 중 하나로, 비즈니스 로직을 처리하고 관리하는 계층입니다. 이러한 계층은 일반적으로 컨트롤러나 외부 데이터 소스와의 상호 작용을 처리하기 위해 사용됩니다. 아래는 위의 코드에서 사용된 서비스 계층 패턴의 이점과 특징을 설명합니다.

  • 유지보수성 및 가독성 향상
    인터페이스를 사용하여 서비스를 정의하고 구현하는 방식은 코드의 가독성을 높이고 유지보수를 용이하게 만듭니다. 이는 코드의 기능이 명확하게 정의되고 각 메서드가 명확한 기능을 수행하기 때문입니다.

  • 유연성
    인터페이스를 통해 서비스를 정의하면 다양한 구현을 제공할 수 있습니다. 예를 들어, 동일한 인터페이스를 구현하는 여러 클래스를 작성하여 다양한 데이터 소스나 비즈니스 로직에 대한 구현을 제공할 수 있습니다.

  • 테스트 용이성
    서비스 계층은 비즈니스 로직을 캡슐화하고, 이를 통해 단위 테스트와 통합 테스트를 수행하기 쉽습니다. 각각의 서비스 메서드는 독립적으로 테스트할 수 있으며, 의존성을 적절하게 주입하여 테스트할 수 있습니다.

  • 확장성
    서비스 계층은 시스템의 확장성을 높이는 데 도움이 됩니다. 새로운 비즈니스 요구 사항이나 기능이 추가되는 경우 해당 기능을 구현하는 새로운 서비스 클래스를 작성하고 인터페이스를 구현함으로써 시스템을 확장할 수 있습니다.

  • 분리된 책임
    서비스 계층은 비즈니스 로직을 분리하여 관리함으로써 다른 계층과의 강력한 결합을 방지합니다. 이는 관심사의 분리를 촉진하고, 코드의 모듈성과 재사용성을 높입니다.

서비스에 대해 설명하자면,

  • 회원가입
    회원가입시 닉네임 체크와 이메일 체크를 해야합니다. 중복되면 400번을 반환해줍니다. 비밀번호는 암호화하였습니다.

  • 로그인
    로그인을 해주면 JWT를 반환해줍니다. 이미 기존에 토큰을 발급받았으면 토큰을 업데이트 시켜줍니다.

  • 회원 삭제
    회원을 삭제할 때 코드를 보면 다른 테이블들을 삭제하면서 삭제해주고 있는데 이렇게 처리한 이유는 연관관계를 맺지 않았기 때문에 독립적인 테이블을 삭제해주고 유저를 삭제해주고 있습니다.

  • 회원 수정
    수정은 비밀번호, 닉네임, 주소만 변경이 가능합니다.

JWT

package com.example.shopping.config.jwt;

import com.example.shopping.domain.jwt.TokenDTO;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.log4j.Log4j2;
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;

/*
 *   writer : YuYoHan
 *   work :
 *          토큰을 생성하는 역할을 가지고 있습니다.
 *   date : 2024/01/17
 * */

// PrincipalDetails 정보를 가지고 토큰을 만들어준다.
@Log4j2
@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[] secretByteKey = DatatypeConverter.parseBase64Binary(secret_key);
        this.key = Keys.hmacShaKeyFor(secretByteKey);
    }

    // 유저 정보를 가지고 AccessToken, RefreshToken 생성
    public TokenDTO createToken(Authentication authentication,
                                List<GrantedAuthority> authorities,
                                Long memberId) {
        // 여기 authentication에서 인증이 완료된 것은 아니다.
        // 프론트에서 header에 accessToken을 담아서 검증을 거쳐야 인증처리가 완료됨
        // 이 시점에서는 아직 실제로 인증이 이루어지지 않았기 때문에 Authenticated 속성은 false로 설정
        // 인증 과정은 AuthenticationManager와 AuthenticationProvider에서 이루어지며,
        // 인증이 성공하면 Authentication 객체의 isAuthenticated() 속성이 true로 변경됩니다.
        log.info("authentication in JwtProvider : " + authentication);
        // role in JwtProvider : ROLE_USER
        log.info("memberRole in JwtProvider : " + authorities);


        // 권한 가져오기
        // authentication객체에서 권한 정보(GrantedAuthority)를 가져와 문자열로 변환
        Map<String, Object> claims = new HashMap<>();
        claims.put(AUTHORITIES_KEY, authorities.stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()));
        // 클레임에 "sub"라는 key로 등록해줌
        claims.put("sub", authentication.getName());


        // claims in JwtProvider : {auth=[ROLE_USER]}
        log.info("claims in JwtProvider : " + claims);
        // authentication.getName() in JwtProvider : zxzz45@naver.com
        log.info("authentication.getName() in JwtProvider : " + authentication.getName());

        // JWT 시간 설정
        long now = (new Date()).getTime();
        Date now2 = new Date();

        // AccessToken 생성
        // 토큰의 만료시간
        Date accessTokenExpire = new Date(now + this.accessTokenTime);
        String accessToken = Jwts.builder()
                .setIssuedAt(now2)
                .setClaims(claims)
                // 내용 exp : 토큰 만료 시간, 시간은 NumericDate 형식(예: 1480849143370)으로 하며
                // 항상 현재 시간 이후로 설정합니다.
                .setExpiration(accessTokenExpire)
                // 서명 : 비밀값과 함께 해시값을 ES256 방식으로 암호화
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        // subject 확인
        log.info(checkToken(accessToken));

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

        log.info(checkToken(refreshToken));

        TokenDTO tokenDTO = TokenDTO.builder()
                .grantType("Bearer ")
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .accessTokenTime(accessTokenExpire)
                .refreshTokenTime(refreshTokenExpire)
                .memberEmail(authentication.getName())
                .memberId(memberId)
                .build();

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

    // 소셜 로그인 성공시 JWT 발급
    // 위의 코드와 비슷하지만 차이점은
    // 위에서는 accessToken만 발급하지만 여기에서는
    // accessToken과 refreshToken 모두 발급
    public TokenDTO createTokenForOAuth2(String memberEmail,
                                         List<GrantedAuthority> authorities,
                                         Long memberId) {
        log.info("email in JwtProvicer : " + memberEmail);
        log.info("authorities in JwtProvicer : " + authorities);

        // 권한 가져오기
        Map<String, Object> claims = new HashMap<>();
        claims.put(AUTHORITIES_KEY, authorities.stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()));
        claims.put("sub", memberEmail);
        log.info("권한 JwtProvicer : " + claims);
        log.info("claims sub JwtProvicer : " + claims.get("sub"));

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

        // accessToken 생성
        Date accessTokenExpire = new Date(now + this.accessTokenTime);
        String accessToken = Jwts.builder()
                .setIssuedAt(now2)
                .setClaims(claims)
                .setExpiration(accessTokenExpire)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
        log.info("claims subject 확인 in JwtProvider : " + checkToken(accessToken));

        Date refreshTokenExpire = new Date(now + this.refreshTokenTime);
        String refreshToken = Jwts.builder()
                .setIssuedAt(now2)
                .setClaims(claims)
                .setExpiration(refreshTokenExpire)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
        log.info("claims subject 확인 in JwtProvider : " + checkToken(refreshToken));

        return TokenDTO.builder()
                .grantType("Bearer ")
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .accessTokenTime(accessTokenExpire)
                .refreshTokenTime(refreshTokenExpire)
                .memberEmail(memberEmail)
                .memberId(memberId)
                .build();
    }

    // accessToken 만료시 refreshToken으로 accessToken 발급
    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 subject 확인 in JwtProvider : zxzz45@naver.com
        log.info("claims subject 확인 in JwtProvider : " + checkToken(accessToken));

        TokenDTO tokenDTO = TokenDTO.builder()
                .grantType("Bearer ")
                .accessToken(accessToken)
                .memberEmail(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 : " + claims);

        if(claims.get("auth") == null) {
            log.info("권한 정보가 없는 토큰입니다.");
        }
        // 권한 정보 가져오기
        List<String> authority = (List<String>) claims.get(AUTHORITIES_KEY);
        log.info("authority : " + authority);

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

        UserDetails userDetails = new User(claims.getSubject(), "", authorities);
        log.info("subject : " + claims.getSubject());

        // 일반 로그인 시 주로 이거로 인증처리해서 SecurityContext에 저장한다.
        // Spring Security에서 인증을 나타내는 객체로 사용됩니다.
        return new UsernamePasswordAuthenticationToken(userDetails, token, authorities);
    }

    // 토큰 복호화 메소드
    private Claims parseClaims(String token) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch (ExpiredJwtException e) {
            log.error("ExpiredJwtException : " + e.getMessage());
            log.error("ExpiredJwtException : " + e.getClaims());
            return e.getClaims();
        }
    }

    // 토큰을 만들 때 제대로 만들어졌는지 log를 찍어보려고할 때
    // 토큰을 만들 때마다 치면 가독성이 떨어지니
    // 메소드로 만들어줍니다.
    private String checkToken(String token) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

        String subject = claims.getSubject();
        return subject;
    }

    // 토큰 검증을 위해 사용
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            log.error("잘못된 JWT 설명입니다. \n info : " + e.getMessage());
        } catch (ExpiredJwtException e) {
            log.error("만료된 JWT입니다. \n info : " + e.getMessage());
        } catch (UnsupportedJwtException e) {
            log.error("지원되지 않는 JWT입니다. \n info : " + e.getMessage());
        } catch (IllegalArgumentException e) {
            log.error("JWT가 잘못되었습니다. \n info : " + e.getMessage());
        }
        return false;
    }

}

토큰의 생성과 검증하는 코드들이 있습니다. 각각의 코드의 설명은 주석에 기록했으니 패스하겠습니다.

여기서 외부 설정에 JWT에 대한 시간, 비밀번호를 설정하고 @Value()로 가지고 온다. import는 import org.springframework.beans.factory.annotation.Value;이거로 하면 됩니다.

accessToken 시간과 refreshToken시간을 설정했습니다.

여기서 비밀번호 설정한 것을 가지고 Base64로 인코딩된 문자열인 secret_key를 디코딩하여 바이트 배열로 변환합니다. JWT에서는 이 바이트 배열이 서명 키로 사용됩니다.

Keys.hmacShaKeyFor(secretByteKey): Keys 클래스는 Java 키 관리를 도와주는 클래스이며, hmacShaKeyFor 메서드는 HMAC SHA 알고리즘에 사용될 키를 생성합니다. 여기에는 앞서 디코딩한 secretByteKey가 사용됩니다.

이렇게 설정된 서명 키는 JWT의 생성 및 검증 과정에서 사용되어, 토큰의 무결성을 보호하는 데에 필요합니다.

프론트가 기능을 사용하기 위해서 헤더에 accessToken을 보낸다면 검증 후 통과가 되면 인증이 true가 되면서 SecurityContextHolder.getContext().setAuthentication(authentication);에 담아줍니다. 이 정보는 컨트롤러에서 빼와서 사용할 수 있습니다. 이거를 적용하려면 시큐리티에 적용해줘야 합니다. 그러한 작업이 아래의 config를 생성하고

        // JWT
        http
                // JWT Token을 위한 Filter를 아래에서 만들어 줄건데,
                // 이 Filter를 어느위치에서 사용하겠다고 등록을 해주어야 Filter가 작동이 됩니다.
                .apply(new JwtSecurityConfig(jwtProvider));

설정 추가

프론트에서 받아오는 토큰검증

접근권한이 없으면 여기서 잡아줍니다.

사용권한이 없으면 여기서 잡아줍니다.

여기서 프론트가 헤더에 accessToken을 담아서 보내주면 체크해준다.

DTO

엔티티

package com.example.shopping.entity.jwt;

import com.example.shopping.domain.jwt.TokenDTO;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.*;
import java.util.Date;
/*
 *   writer : 유요한
 *   work :
 *          JWT 엔티티
 *   date : 2023/11/15
 * */
@Entity(name = "token")
@Table
@NoArgsConstructor
@Getter
@ToString
public class TokenEntity {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "token_id")
    private Long id;
    private String grantType;
    private String accessToken;
    private String refreshToken;
    private String memberEmail;
    private Date accessTokenTime;
    private Date refreshTokenTime;
    private Long memberId;


    @Builder
    public TokenEntity(Long id,
                       String grantType,
                       String accessToken,
                       String refreshToken,
                       String memberEmail,
                       Date accessTokenTime,
                       Date refreshTokenTime,
                       Long memberId) {
        this.id = id;
        this.grantType = grantType;
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
        this.memberEmail = memberEmail;
        this.accessTokenTime = accessTokenTime;
        this.refreshTokenTime = refreshTokenTime;
        this.memberId = memberId;
    }

    // 토큰 엔티티로 변환
    public static TokenEntity tokenEntity(TokenDTO token) {
        return com.example.shopping.entity.jwt.TokenEntity.builder()
                .grantType(token.getGrantType())
                .accessToken(token.getAccessToken())
                .accessTokenTime(token.getAccessTokenTime())
                .refreshToken(token.getRefreshToken())
                .refreshTokenTime(token.getRefreshTokenTime())
                .memberEmail(token.getMemberEmail())
                .memberId(token.getMemberId())
                .build();
    }

    // 토큰 업데이트
    public void updateToken(TokenDTO tokenDTO) {
        TokenEntity.builder()
                .id(this.id)
                .grantType(tokenDTO.getGrantType())
                .accessToken(tokenDTO.getAccessToken())
                .accessTokenTime(tokenDTO.getAccessTokenTime())
                .refreshToken(this.refreshToken)
                .refreshTokenTime(this.refreshTokenTime)
                .memberEmail(tokenDTO.getMemberEmail())
                .memberId(this.memberId)
                .build();
    }
}

레포지토리

서비스

이제 JWT를 구현할 수 있는 모든 처리는 완료되었습니다. 이제 회원 서비스에서 로그인하면 토큰을 발급해줍니다. 한 가지 더 해야합니다. accessToken이 만료되면 refreshToken으로 발급해주는 기능을 구현해줍니다.

시큐리티 적용

@EnableWebSecurity
위의 SecurityConfig에 붙은 @EnableWebSecurity을 보면 WebSecurityConfiguration.class, SpringWebMvcImportSelector.class, OAuth2ImportSelector.class, HttpSecurityConfiguration.class들을 import해서 실행시켜주는 것을 알 수 있습니다. 해당 annotation을 붙여야지 Securiry를 활성화 시킬 수 있습니다.

이렇게 추가해주는 이유는 회원가입 시 비밀번호를 암호화하기 위해서 빈으로 추가해주는 것입니다.

이 메서드의 목적은 DelegatingPasswordEncoder를 생성하고 반환하는 것입니다. DelegatingPasswordEncoder는 여러 개의 다른 PasswordEncoder 구현을 지원하며, 실제 암호화 작업을 위임합니다. 이 경우에는 BCryptPasswordEncoder가 실제 암호화를 수행하는 구현 중 하나로 등록되어 있습니다.

이렇게 구성된 PasswordEncoder는 주로 Spring Security 설정에서 사용되어 사용자 비밀번호를 안전하게 저장하고 비교하는 데 활용될 것입니다. BCryptPasswordEncoder는 안전한 해시 알고리즘을 사용하여 비밀번호를 저장하며, DelegatingPasswordEncoder는 이런 다양한 알고리즘들을 관리하고 선택할 수 있도록 도와줍니다.

장점

  1. 확장성과 유연성
    DelegatingPasswordEncoder는 여러 암호화 알고리즘을 지원하도록 설계되어 있습니다. 현재는 BCrypt만 사용되지만 나중에 다른 알고리즘으로 전환하거나 여러 알고리즘을 혼합하여 사용할 수 있습니다.

  2. 코드 수정 없이 알고리즘 변경
    DelegatingPasswordEncoder를 사용하면 암호화 알고리즘을 변경해도 클라이언트 코드를 수정할 필요가 없습니다. 단지 빈 구성에서 해당 변경을 수행하면 됩니다.

  3. 간단한 구성
    DelegatingPasswordEncoder를 사용하면 간단하게 여러 알고리즘을 관리할 수 있습니다. encoders 맵에 새로운 알고리즘을 추가하면 간단하게 적용할 수 있습니다.

  4. Spring Security의 통합
    DelegatingPasswordEncoder는 암호 저장 및 인증에 대한 표준적인 메커니즘을 제공합니다. 이를 사용하면 보안적인 측면에서 안전한 방식으로 암호를 다룰 수 있습니다.

이거는 페이징 처리를 위에서 설정을 해뒀습니다. Spring Data의 Pageable을 커스터마이징하기 위한 구성 코드입니다.

  • p.setOneIndexedParameters(true);: 이 메서드는 Pageable의 파라미터를 1부터 시작하도록 설정합니다. 기본적으로 Spring Data는 페이지 번호를 0부터 시작하도록 합니다. 여기서는 1부터 시작하도록 변경되었습니다.

  • p.setMaxPageSize(10);: 이 메서드는 한 페이지에 보여질 아이템의 최대 수를 설정합니다. 여기서는 한 페이지당 10개의 아이템을 설정했습니다.

이러한 설정을 통해 기본적인 페이지 관리 및 요청에 대한 커스터마이징이 이루어지며, 이 코드를 사용하면 컨트롤러에서 Pageable 객체를 매개변수로 받을 때 설정한 값들이 적용됩니다. 페이지 번호가 1부터 시작하고 한 페이지에 10개의 아이템을 갖도록 설정된 Pageable 객체를 받게 될 것입니다.

아니면 yml에서 설정할 수 있습니다.

spring:
	  data:
    	web:
      		pageable:
        		default-page-size: 10
       			max-page-size: 2000
        		one-indexed-parameters: true

이렇게 설정하면 같은 의미이다.

spring security를 보면 @Autowired를 사용하지 않고 @RequiredArgsConstructor를 사용했습니다. 왜 그런걸까?

먼저, 알아야할 부분이 있습니다.

@Autowired 어노테이션을 이용한 의존성 주입은 3가지 방법이 있습니다.

  1. 필드 주입
  2. 수정자 주입
  3. 생성자 주입

이중 생성자 주입이 제일 권고되는 사항이다.

필드 주입

필드 주입방식은 Class에 속한 Field위에 @Autowired 어노테이션을 붙여주면 된다.

수정자 주입(Setter Injection)

이게 스프링할 때 가장 많이 사용했던 방법이였다.

위의 WebProject Class에 아래와 같이 Setter Mehod를 만들어 준 후 @Autowired 어노테이션을 붙여줍니다.

이걸 룸북을 사용한 설정자 주입으로 바꾸면 다음과 같이 된다.

위에 꺼는 setter을 만들어준 다음 @Autowired만 붙여줘서 했다면 아래는 setter도 어노테이션을 사용해서 간단하게 만들 수 있다. 의미는 같지만 코드를 줄일 수 있는 장점이 있다.

생성자 주입(Constructor Injection)

생성자는 빈 생성자가 아닌 클래스의 필드를 파라미터로 사용하는 생성자여야 합니다.

이것을 룸북을 사용하면 줄일 수 있다.

Lombok의 @AllArgsConstructor 어노테이션은 모든 필드를 파라미터로 받는 생성자를 만들어주는 역할을 한다. @Setter 어노테이션과 달리 @Autowired를 붙일 수 있는 속성은 없는데 Developer인스턴스는 정상적으로 생성자의 argument로 주입이 됩니다. Spring 4.3 버전 이후부터 단일 생성자의 경우 묵시적 자동 주입이 가능합니다. 즉, 단일 생성자일 경우 @Autowired 어노테이션을 붙이지 않아도 자동으로 생성자 주입을 해주는 것입니다.

추가적으로 모든 필드를 파라미터로 받는 생성자가 아닌 특정 필드만 파라미터로 받는 생성자를 생성하고 싶을 때는 @RequiredArgsConstructor Class에 붙여주고, 파라미터로 받고 싶은 필드에는 @NonNull어노테이션 혹은 final을 붙여주시면 됩니다.

생성자 주입을 권고하는 이유

1. SRP(단일 책임의 원칙)를 위반할 확률이 줄어든다.
우리가 작성한 비즈니스 로직을 담당하는 클래스는 하나의 책임에 집중되어 있어야 합니다. 이는 객체지향 프로그래밍의 원칙 중 하나입니다. 생성자 주입을 사용하지 않고 필드 주입을 사용하게 될 경우 클래스 내부에 선언된 필드에 그저 @Autowired를 붙이는 것만으로 쉽게 의존성을 사용할 수 있습니다. 쉽게 의존성을 주입할 수 있다는 것은 하나의 클래스가 여러 가지 기능을 담당하게 만들기도 쉽다는 이야기랑 같습니다. 그러나 생성자 주입을 사용하게 되면 생성자 파라미터에 사용하고자 하는 필드를 모두 넣어주어야 하기 때문에 코드가 길어지고 그로 인해 경각심을 가질 수 있습니다.

2. 필드에 final을 선언할 수 있다.
생성자 주입을 제외한 필드, 수정자 주입은 final을 선언할 수 없습니다. 필드에 final을 붙이기 위해서는 클래스의 인스턴스가 생성될 때 final이 붙은 필드를 반드시 초기화해야 합니다. Field /Setter Injection은 우선 인스턴스가 생성된 후에 해당 필드에 의존성 주입이 진행되므로 final을 붙일 수 없습니다. 그러나 생성자 주입은 필드를 파라미터로 받는 생성자를 통해 클래스의 인스턴스가 생성될 때 의존성 주입이 일어나고, 이 때 final 붙은 필드가 초기화됩니다. 우리가 웹 개발을 할 때 Bean객체의 필드 값이 바뀌는 일은 거의 없을 겁니다. 그러므로 필드에 final을 붙여 불변성을 가지도록 하는 것이 좋습니다.

3. DI 컨테이너에 독립적인 테스트 코드를 작성할 수 있다.
개발을 할 때 테스트 코드를 작성하는 것은 매우 중요합니다. 필드 주입을 사용하게 되면 테스트 코드에서 어떻게 내부 필드에 인스턴스를 넣어 줄 수 있을까요??? 아래와 같이 일반적인 JUnit을 사용하는 테스트 코드에서는 불가능합니다. 왜냐하면 일반적으로 필드의 접근 제한자를 public으로 하게 되면 외부에서 필드의 값을 변경할 수 있으므로 대부분은 이를 방지하기 위해 private로 선언합니다. Field Injection일 때 테스트 코드를 작성하기 위해서는 DI컨테이너를 사용하는 테스트 코드를 작성해야 합니다. 그러나 Constructor / Setter Injection을 사용하게 되면 DI컨테이너에 독립적으로 테스트 코드를 작성할 수 있습니다.

4. 순환 참조를 발견할 수 있다.

@Autowired를 사용고 생성자 주입을 할 수 있지만 @RequiredArgsConstructor로 생성자 주입을 하면 코드에 final만 붙이면 생성자 주입이 되서 코드가 간결해지고 가독성이 높아집니다. 그리고 불변성을 유지할 수 있어서 생성자를 통해 필드 값을 초기화하고 나면 해당 필드의 값을 변경할 수 없게 됩니다. 이는 불변성을 강제하여 안정성을 높이는데 도움이 됩니다. @RequiredArgsConstructor를 사용하면 필드를 추가하거나 제거할 때 Lombok이 알아서 생성자를 갱신해주므로 유지보수가 쉬워집니다.

소셜 로그인

package com.example.shopping.config;

import com.example.shopping.entity.member.MemberEntity;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import lombok.extern.log4j.Log4j2;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;

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


// PrincipalDetailsService과 PrincipalOAuth2UserService에서 넘어온
// 유저의 정보와 권한을 받아와서 JWT를 만드는데 도움을 준다.
@Setter
@Getter
@ToString
@Log4j2
@NoArgsConstructor
@Component
public class PrincipalDetails implements UserDetails, OAuth2User {

    // 일반 로그인 정보를 저장하기 위한 필드
    private MemberEntity member;
    // OAuth2 로그인 정보를 저장하기 위한 필드
    // 일반적으로 attributes에는 사용자의 아이디(ID), 이름, 이메일 주소, 프로필 사진 URL 등의 정보가 포함됩니다.
    private Map<String, Object> attributes;

    // 일반 로그인
    public PrincipalDetails(MemberEntity member) {
        this.member = member;
    }

    // OAuth2 로그인
    public PrincipalDetails(MemberEntity member, Map<String, Object> attributes) {
        this.member = member;
        this.attributes = attributes;
    }

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

    // 사용자 패스워드를 반환
    @Override
    public String getPassword() {
        return member.getMemberPw();
    }

    // 사용자 이름 반환
    @Override
    public String getUsername() {
        return member.getEmail();
    }

    // 계정 만료 여부 반환
    @Override
    public boolean isAccountNonExpired() {
        // 만료되었는지 확인하는 로직
        // true = 만료되지 않음
        return true;
    }

    // 계정 잠금 여부 반환
    @Override
    public boolean isAccountNonLocked() {
        // true = 잠금되지 않음
        return true;
    }

    // 패스워드의 만료 여부 반환
    @Override
    public boolean isCredentialsNonExpired() {
        // 패스워드가 만료되었는지 확인하는 로직
        // true = 만료되지 않음
        return true;
    }

    // 계정 사용 가능 여부 반환
    @Override
    public boolean isEnabled() {
        // 계정이 사용 가능한지 확인하는 로직
        // true = 사용 가능
        return true;
    }

    @Override
    public Map<String, Object> getAttributes() {
        log.info("attributes : " + attributes);
        return attributes;
    }

    @Override
    // OAuth2 인증에서는 사용되지 않는 메서드이므로 null 반환
    public String getName() {
        return null;
    }
}

이거는 OAuth2와 일반 로그인 둘 다 포함입니다.

여기서 보면 공통적인 요소는 OAuth2ProviderUser에 넣고 별개의 것만 따로 빼두었습니다. 이를 통해 소셜 로그인의 각 공급자(Provider)에 대한 사용자 정보를 일관된 방식으로 처리할 수 있게 되며, 중복된 코드를 줄이고 유지보수성을 향상시킬 수 있습니다.

package com.example.shopping.config.oauth2;

import com.example.shopping.config.PrincipalDetails;
import com.example.shopping.config.jwt.JwtProvider;
import com.example.shopping.config.oauth2.provider.GoogleUser;
import com.example.shopping.config.oauth2.provider.NaverUser;
import com.example.shopping.config.oauth2.provider.OAuth2UserInfo;
import com.example.shopping.domain.jwt.TokenDTO;
import com.example.shopping.domain.member.Role;
import com.example.shopping.entity.jwt.TokenEntity;
import com.example.shopping.entity.member.MemberEntity;
import com.example.shopping.repository.jwt.TokenRepository;
import com.example.shopping.repository.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
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.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

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

// 소셜 로그인하면 사용자 정보를 가지고 온다.
// 가져온 정보와 PrincipalDetails 객체를 생성합니다.
@Service
@Log4j2
@RequiredArgsConstructor
public class PrincipalOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    private final MemberRepository memberRepository;
    private final JwtProvider jwtProvider;
    private final TokenRepository tokenRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        // userRequest.getClientRegistration()은 인증 및 인가된 사용자 정보를 가져오는
        // Spring Security에서 제공하는 메서드입니다.
        ClientRegistration clientRegistration = userRequest.getClientRegistration();
        log.info("clientRegistration : " + clientRegistration);
        // 소셜 로그인 accessToken
        String socialAccessToken = userRequest.getAccessToken().getTokenValue();
        log.info("소셜 로그인 accessToken : " + socialAccessToken);

        OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService =
                new DefaultOAuth2UserService();
        log.info("oAuth2UserService : " + oAuth2UserService);

        // 소셜 로그인한 유저정보를 가져온다.
        OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest);
        log.info("oAuth2User : " + oAuth2User);
        log.info("getAttribute : " + oAuth2User.getAttributes());

        // 회원가입 강제 진행
        OAuth2UserInfo oAuth2UserInfo = null;
        String registrationId = clientRegistration.getRegistrationId();
        log.info("registrationId : " + registrationId);

        if(registrationId.equals("google")) {
            log.info("구글 로그인");
            oAuth2UserInfo = new GoogleUser(oAuth2User, clientRegistration);
        } else  if(registrationId.equals("naver")) {
            log.info("네이버 로그인");
            oAuth2UserInfo = new NaverUser(oAuth2User, clientRegistration);
        } else {
            log.error("지원하지 않는 소셜 로그인입니다.");
        }

        // 사용자가 로그인한 소셜 서비스를 가지고 옵니다.
        // 예시) google or naver 같은 값을 가질 수 있다.
        String provider = oAuth2UserInfo.getProvider();
        // 사용자의 소셜 서비스(provider)에서 발급된 고유한 식별자를 가져옵니다.
        // 이 값은 해당 소셜 서비스에서 유니크한 사용자를 식별하는 용도로 사용됩니다.
        String providerId = oAuth2UserInfo.getProviderId();
        String name = oAuth2UserInfo.getName();
        // 사용자의 이메일 주소를 가지고 옵니다.
        // 소셜 서비스에서 제공하는 이메일 정보를 사용합니다.
        String email = oAuth2UserInfo.getEmail();
        // 소셜 로그인의 경우 무조건 USER 등급으로 고정이다.
        Role role = Role.USER;

        MemberEntity findUser = memberRepository.findByEmail(email);

        if(findUser == null) {
            log.info("소셜 로그인이 최초입니다.");
            log.info("소셜 로그인 자동 회원가입을 진행합니다.");

            findUser = MemberEntity.builder()
                    .email(email)
                    .memberName(name)
                    .provider(provider)
                    .providerId(providerId)
                    .memberRole(role)
                    .nickName(name)
                    .build();

            log.info("member : " + findUser);
             findUser = memberRepository.save(findUser);
        } else {
            log.info("로그인을 이미 한적이 있습니다.");
        }
        // 권한 가져오기
        List<GrantedAuthority> authorities = getAuthoritiesForUser(findUser);
        // 토큰 생성
        TokenDTO tokenForOAuth2 = jwtProvider.createTokenForOAuth2(email, authorities, findUser.getMemberId());
        // 기존에 이 토큰이 있는지 확인
        TokenEntity findToken = tokenRepository.findByMemberEmail(tokenForOAuth2.getMemberEmail());

        TokenEntity saveToken;
        // 기존의 토큰이 없다면 새로 만들어준다.
        if(findToken == null) {
            TokenEntity tokenEntity = TokenEntity.tokenEntity(tokenForOAuth2);
            saveToken = tokenRepository.save(tokenEntity);
            log.info("token : " + saveToken);
        } else {
            // 기존의 토큰이 있다면 업데이트 해준다.
            tokenForOAuth2 = TokenDTO.builder()
                    .grantType(tokenForOAuth2.getGrantType())
                    .accessToken(tokenForOAuth2.getAccessToken())
                    .accessTokenTime(tokenForOAuth2.getAccessTokenTime())
                    .refreshToken(tokenForOAuth2.getRefreshToken())
                    .refreshTokenTime(tokenForOAuth2.getRefreshTokenTime())
                    .memberEmail(tokenForOAuth2.getMemberEmail())
                    .memberId(tokenForOAuth2.getMemberId())
                    .build();
            TokenEntity tokenEntity = TokenEntity.updateToken(findToken.getId(), tokenForOAuth2);
            saveToken = tokenRepository.save(tokenEntity);
            log.info("token : " + saveToken);
        }

        // 토큰이 제대로 되어 있나 검증
        if(StringUtils.hasText(saveToken.getAccessToken())
                && jwtProvider.validateToken(saveToken.getAccessToken())) {
            Authentication authenticationToken = jwtProvider.getAuthentication(saveToken.getAccessToken());
            log.info("authentication : " + authenticationToken);
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);

            UserDetails userDetails = new User(email, "", authorities);
            log.info("userDetails : " + userDetails);
            Authentication authenticationUser =
                    new UsernamePasswordAuthenticationToken(userDetails, authorities);
            log.info("authentication1 : " + authenticationUser);
            SecurityContextHolder.getContext().setAuthentication(authenticationUser);
        } else {
            log.info("검증 실패");
        }
        // attributes가 있는 생성자를 사용하여 PrincipalDetails 객체 생성
        // 소셜 로그인인 경우에는 attributes도 함께 가지고 있는 PrincipalDetails 객체를 생성하게 됩니다.
        PrincipalDetails principalDetails = new PrincipalDetails(findUser, oAuth2User.getAttributes());
        log.info("principalDetails : " + principalDetails);
        return principalDetails;
    }

    // 권한 가져오기 로직
    private List<GrantedAuthority> getAuthoritiesForUser(MemberEntity findUser) {
        Role role = findUser.getMemberRole();

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

여기서 소셜 로그인에 성공하면 회원가입시켜주고 JWT도 발급해주는 로직입니다.

404로 한 이유는 여기는 소셜 로그인을 성공을 하면 동작하는 곳입니다. 그렇기 때문에 여기서 예외가 발생한다면 DB에서 제대로 정보가 없기 때문에 예외가 발생하므로 404로 해주었습니다.

소셜 로그인을 성공하면 바로 json으로 ProviderId, accessToken, refreshToken, email, memberId, grantType을 body에 보내줍니다.

소셜로그인 실패

SecurityConfig에 다음과 같은 설정을 추가해야합니다.

상품

해당 상품은 이미지는 S3에 올라가고 유저와 단방향 연관관계, 게시글과 양방향 연관관계를 가지고 있습니다.

컨트롤러

package com.example.shopping.controller.item;

import com.example.shopping.domain.Item.*;
import com.example.shopping.service.container.ItemContainerService;
import com.example.shopping.service.item.ItemService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.persistence.EntityNotFoundException;
import javax.validation.Valid;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/*
 *   writer : YuYoHan, 오현진
 *   work :
 *          상품 작성, 삭제, 수정, 전체 가져오기 그리고 검색하는 기능입니다.
 *   date : 2024/01/25
 * */
@RestController
@Log4j2
@RequiredArgsConstructor
@RequestMapping("/api/v1/items")
@Tag(name = "item", description = "상품 API")
public class ItemController {
    private final ItemService itemService;
    private final ItemContainerService itemContainerService;

    // 상품 등록
    @PostMapping("")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    @Tag(name = "item")
    @Operation(summary = "상품 등록", description = "상품을 등록하는 API입니다.")
    public ResponseEntity<?> createItem(
            @Valid @RequestPart("key") CreateItemDTO item,
            @RequestPart(value = "files", required = false) List<MultipartFile> itemFiles,
            BindingResult result,
            @AuthenticationPrincipal UserDetails userDetails
    ) {
        try {
            if (result.hasErrors()) {
                log.error("bindingResult error : " + result.hasErrors());
                return ResponseEntity.badRequest().body(result.getClass().getSimpleName());
            }
            String email = userDetails.getUsername();
            ItemDTO savedItem = itemService.saveItem(item, itemFiles, email);
            return ResponseEntity.ok().body(savedItem);
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }

    // 상품 상세 정보
    @GetMapping("/{itemId}")
    @Tag(name = "item")
    @Operation(summary = "상품 상세 정보 보기", description = "상품의 상세정보를 볼 수 있습니다.")
    public ResponseEntity<?> itemDetail(@PathVariable Long itemId) {
        try {
            ItemDTO item = itemService.getItem(itemId);
            log.info("item : " + item);
            return ResponseEntity.ok().body(item);
        } catch (EntityNotFoundException e) {
            log.error("존재하지 않는 상품입니다.");
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body("존재하지 않는 상품입니다.");
        }
    }

    // 상품 수정
    @PutMapping("/{itemId}")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    @Tag(name = "item")
    @Operation(summary = "상품 수정", description = "상품을 수정하는 API입니다.")
    public ResponseEntity<?> updateItem(@PathVariable Long itemId,
                                        @RequestPart("key") UpdateItemDTO itemDTO,
                                        @RequestPart(value = "files", required = false) List<MultipartFile> itemFiles,
                                        @AuthenticationPrincipal UserDetails userDetails
    ) {
        try {
            String email = userDetails.getUsername();
            String role = userDetails.getAuthorities().iterator().next().getAuthority();
            ItemDTO updateItem = itemService.updateItem(itemId, itemDTO, itemFiles, email, role);
            return ResponseEntity.ok().body(updateItem);
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }

    // 상품 삭제
    @DeleteMapping("/{itemId}")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    @Tag(name = "item")
    @Operation(summary = "상품 삭제", description = "상품을 삭제하는 API입니다.")
    public ResponseEntity<?> deleteItem(@PathVariable Long itemId,
                                        @AuthenticationPrincipal UserDetails userDetails) {
        try {
            String result = itemService.removeItem(itemId, userDetails);
            return ResponseEntity.ok().body(result);
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }

    // 전체 상품을 볼 수 있고 상품조건 여러개의 경우 조건 조회하기
    // http://localhost:8080/api/v1/items/search?name=당&page=1&sort=itemId,asc&place=종로
    @GetMapping("/search")
    @Tag(name = "item")
    @Operation(summary = "상품 전체", description = "모든 상품을 보여주는 API입니다.")
    public ResponseEntity<?> searchItemsConditions(Pageable pageable,
                                                   ItemSearchCondition condition) {
        try {
            log.info("condition : " + condition);
            Page<ItemDTO> items = itemService.searchItemsConditions(pageable, condition);
            log.info("상품 조회 {}", items);

            Map<String, Object> response = new HashMap<>();
            // 현재 페이지의 아이템 목록
            response.put("items", items.getContent());
            // 현재 페이지 번호
            response.put("nowPageNumber", items.getNumber() + 1);
            // 전체 페이지 수
            response.put("totalPage", items.getTotalPages());
            // 한 페이지에 출력되는 데이터 개수
            response.put("pageSize", items.getSize());
            // 다음 페이지 존재 여부
            response.put("hasNextPage", items.hasNext());
            // 이전 페이지 존재 여부
            response.put("hasPreviousPage", items.hasPrevious());
            // 첫 번째 페이지 여부
            response.put("isFirstPage", items.isFirst());
            // 마지막 페이지 여부
            response.put("isLastPage", items.isLast());

            return ResponseEntity.ok().body(response);
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }

    // 상품의 판매 지역을 반환해줍니다.
    @GetMapping("/sellplace")
    @Tag(name = "item")
    @Operation(summary = "상품 판매지역 리스트", description = "모든 상품의 판매지역을 보여주는 API입니다.")
    public ResponseEntity<?> getSellPlaceList(Pageable pageable) {
        return ResponseEntity.ok().body(itemContainerService.getSellPlaceList(pageable));
    }

}

DTO

상품을 생성할 때 사용하는 DTO

응답용 DTO

package com.example.shopping.domain.Item;

import com.example.shopping.domain.board.BoardDTO;
import com.example.shopping.entity.Container.ContainerEntity;
import com.example.shopping.entity.board.BoardEntity;
import com.example.shopping.entity.item.ItemEntity;
import com.example.shopping.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.Collections;
import java.util.List;
import java.util.stream.Collectors;

/*
 *   writer : 유요한, 오현진
 *   work :
 *          상품에 대한 정보를 담은 ResponseDTO
 *   date : 2024/02/06
 * */
@ToString
@Getter
@NoArgsConstructor
public class ItemDTO {
    @Schema(description = "상품 번호")
    private Long itemId;

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

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

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

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

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

    @Schema(description = "회원 닉네임")
    @NotNull(message = "닉네임은 필수로 입력해야합니다.")
    private String memberNickName;

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

    @Schema(description = "판매지역")
    @NotNull(message = "판매지역을 입력해야 합니다.")
    private String sellPlace;

    @Schema(description = "상품 예약자 이메일")
    private String itemReserver;

    @Schema(description = "예약 수량")
    private int itemRamount;

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

    @Schema(description = "상품 판매자 아이디")
    private Long itemSeller;

    @Schema(description = "문의글")
    private List<BoardDTO> boardDTOList = new ArrayList<>();

    @Builder
    public ItemDTO(Long itemId,
                   String itemName,
                   int price,
                   String itemDetail,
                   ItemSellStatus itemSellStatus,
                   LocalDateTime regTime,
                   String memberNickName,
                   int stockNumber,
                   String sellPlace,
                   String itemReserver,
                   int itemRamount,
                   Long itemSeller,
                   List<BoardDTO> boardDTOList,
                   List<ItemImgDTO> itemImgList) {
        this.itemId = itemId;
        this.itemName = itemName;
        this.price = price;
        this.itemDetail = itemDetail;
        this.itemSellStatus = itemSellStatus;
        this.regTime = regTime;
        this.memberNickName = memberNickName;
        this.stockNumber = stockNumber;
        this.sellPlace = sellPlace;
        this.itemReserver = itemReserver;
        this.itemRamount = itemRamount;
        this.boardDTOList = boardDTOList;
        this.itemImgList = itemImgList;
        this.itemSeller = itemSeller;
    }

    public static ItemDTO toItemDTO(ItemEntity item) {
        // 이미지 처리
        List<ItemImgEntity> itemImgEntities =
                (item.getItemImgList() != null) ? item.getItemImgList() : Collections.emptyList();
        List<ItemImgDTO> itemImgDTOList = itemImgEntities.stream()
                .map(ItemImgDTO::toItemImgDTO)
                .collect(Collectors.toList());

        // 문의글 처리
        List<BoardEntity> boardEntityList =
                item.getBoardEntityList() != null ? item.getBoardEntityList() : Collections.emptyList();
        List<BoardDTO> boardDTOS = boardEntityList.stream()
                .map(BoardDTO::toBoardDTO)
                .collect(Collectors.toList());

        // 상품 정보와 이미지 그리고 문의글을 리턴
        return ItemDTO.builder()
                .itemId(item.getItemId())
                .itemName(item.getItemName())
                .price(item.getPrice())
                .stockNumber(item.getStockNumber())
                .itemDetail(item.getItemDetail())
                .itemSellStatus(item.getItemSellStatus())
                .regTime(item.getRegTime())
                .sellPlace(item.getItemPlace().getContainerName() + "/" + item.getItemPlace().getContainerAddr())
                .itemReserver(item.getItemReserver())
                .itemRamount(item.getItemRamount())
                .itemSeller(item.getItemSeller())
                .itemImgList(itemImgDTOList)
                .boardDTOList(boardDTOS)
                .build();
    }

    public void setMemberNickName(String nickName) {
        this.memberNickName = nickName;
    }

    public ItemEntity toEntity() {
        String[] splitPlace = this.sellPlace.split("/");

        return ItemEntity.builder()
                .itemId(this.itemId)
                .itemDetail(this.itemDetail)
                .itemName(this.itemName)
                .itemPlace(ContainerEntity.builder()
                        .containerName(splitPlace[0])
                        .containerAddr(splitPlace[1])
                        .build())
                .itemRamount(this.itemRamount)
                .itemReserver(this.itemReserver)
                .itemSellStatus(this.itemSellStatus)
                .price(this.price)
                .itemSeller(this.itemSeller)
                .stockNumber(this.stockNumber)
                .build();
    }

    public void setSellPlace(String placeName, String placeAddr) {
        this.sellPlace = placeName + "/" + placeAddr;
    }
}

응답용 이미지 DTO

package com.example.shopping.domain.Item;

import com.example.shopping.entity.item.ItemImgEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
/*
 *   writer : 유요한
 *   work :
 *          상품 이미지에 대한 정보를 담은 ResponseDTO
 *   date : 2023/10/05
 * */
@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 toItemImgDTO(ItemImgEntity itemImgEntity) {
        return ItemImgDTO.builder()
                .itemImgId(itemImgEntity.getItemImgId())
                .oriImgName(itemImgEntity.getOriImgName())
                .uploadImgName(itemImgEntity.getUploadImgName())
                .uploadImgUrl(itemImgEntity.getUploadImgUrl())
                .uploadImgPath(itemImgEntity.getUploadImgPath())
                .repImgYn(itemImgEntity.getRepImgYn())
                .build();
    }
}

상품을 조회할 때 사용할 조건들

상품 상태

상품을 수정할 때 사용할 DTO

엔티티

package com.example.shopping.entity.item;

import com.example.shopping.domain.Item.CreateItemDTO;
import com.example.shopping.domain.Item.ItemDTO;
import com.example.shopping.domain.Item.ItemSellStatus;
import com.example.shopping.domain.Item.UpdateItemDTO;
import com.example.shopping.entity.Base.BaseTimeEntity;
import com.example.shopping.entity.Container.ContainerEntity;
import com.example.shopping.entity.board.BoardEntity;
import lombok.*;

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

/*
 *   writer : 유요한, 오현진
 *   work :
 *          상품 엔티티
 *   date : 2024/02/07
 * */
@Entity(name = "item")
@Table
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"itemId", "itemName", "price", "stockNumber","itemDetail", "itemSellStatus","itemPlace","itemSeller"})
public class ItemEntity extends BaseTimeEntity {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "item_id")
    private Long itemId;

    @Column(name = "item_name", nullable = false)
    private String itemName;     // 상품 명
    @Column(name = "item_price", nullable = false)
    private int price;          // 가격
    @Column(name = "stock_number",nullable = false)
    private int stockNumber;    // 재고수량

    // BLOB, CLOB 타입 매핑
    // CLOB이란 사이즈가 큰 데이터를 외부 파일로 저장하기 위한 데이터입니다.
    // 문자형 대용량 파일을 저장하는데 사용하는 데이터 타입이라고 생각하면 됩니다.
    // BLOB은 바이너리 데이터를 DB외부에 저장하기 위한 타입입니다.
    // 이미지, 사운드, 비디오 같은 멀티미디어 데이터를 다룰 때 사용할 수 있습니다.
    @Lob
    @Column(nullable = false, name = "item_detail")
    private String itemDetail;  // 상품 상세 설명

    @Enumerated(EnumType.STRING)
    @Column(name = "item_sell_status", nullable = false)
    private ItemSellStatus itemSellStatus;  // 상품 판매 상태

    @Embedded
    @Column(name = "item_place", nullable = false)
    private ContainerEntity itemPlace;

    @Column(name="item_reserver")
    private String itemReserver;

    @Column(name="item_ramount")
    private int itemRamount;

    // 여기서 보면 cascade = CascadeType.ALL 이렇게 추가한 거는
    // 상품을 수정, 삭제하면 itemImg도 같이 영향을 가게 하기 위해서이다.
    // item 엔티티에 추가하면 연관관계에 있는 itemImg도 영향이 간다.
    // 보통 상품을 삭제하면 이미지도 같이 삭제되기 때문에 이렇게 했다.
    @Column(name = "item_img")
    @OneToMany(mappedBy = "item", cascade = CascadeType.ALL, orphanRemoval = true)
    @OrderBy("itemImgId asc")
    // 상품 저장 후 수정할 때 상품 이미지 정보를 저장하는 리스트
    private List<ItemImgEntity> itemImgList = new ArrayList<>();

    //@ManyToOne(fetch = FetchType.LAZY)
    //@JoinColumn(name = "member_id")
    //private MemberEntity member;
    @Column(name="item_seller")
    private Long itemSeller;

    @OneToMany(mappedBy = "item", cascade = CascadeType.ALL, orphanRemoval = true)
    @OrderBy("boardId desc")
    private List<BoardEntity> boardEntityList = new ArrayList<>();

    @Builder
    public ItemEntity(Long itemId,
                      String itemName,
                      int price,
                      int stockNumber,
                      String itemDetail,
                      ItemSellStatus itemSellStatus,
                      List<ItemImgEntity> itemImgList,
                      ContainerEntity itemPlace,
                      String itemReserver,
                      int itemRamount,
                      Long itemSeller,
                      List<BoardEntity> boardEntityList) {
        this.itemId = itemId;
        this.itemName = itemName;
        this.price = price;
        this.stockNumber = stockNumber;
        this.itemDetail = itemDetail;
        this.itemSellStatus = itemSellStatus;
        this.itemImgList = itemImgList==null ? new ArrayList<>():itemImgList;
        this.itemPlace = itemPlace;
        this.itemRamount = itemRamount;
        this.itemReserver = itemReserver;
        this.boardEntityList = boardEntityList;
        this.itemSeller = itemSeller;
    }
    public static ItemEntity saveEntity(ItemDTO itemDTO,
                                        CreateItemDTO saveItem) {
        return ItemEntity.builder()
                .itemName(itemDTO.getItemName())
                .itemDetail(itemDTO.getItemDetail())
                .itemPlace(ContainerEntity.builder()
                        .containerName(saveItem.getSellPlace().getContainerName())
                        .containerAddr(saveItem.getSellPlace().getContainerAddr())
                        .build())
                .itemSellStatus(itemDTO.getItemSellStatus())
                .stockNumber(itemDTO.getStockNumber())
                .price(itemDTO.getPrice())
                .itemSeller(itemDTO.getItemSeller())
                .itemRamount(itemDTO.getItemRamount())
                .itemReserver(itemDTO.getItemReserver() == null ? null : itemDTO.getItemReserver())
                .build();
    }


    public void updateItem(UpdateItemDTO item) {
        this.itemName = Optional.ofNullable(item.getItemName()).orElse(this.getItemName());
        this.itemDetail = Optional.ofNullable(item.getItemDetail()).orElse(this.getItemDetail());
        this.stockNumber = Optional.of(item.getStockNumber()).orElse(this.stockNumber);
        this.price = Optional.of(item.getPrice()).orElse(this.price);
        this.itemSeller = Optional.ofNullable(item.getItemSeller()).orElse(this.itemSeller);
    }

    // 상품 상태 바꿔주는 메소드
    public void itemSell(int cnt){
        // 아이템 판매 시
        // 재고감소, 상품상태변경, 예약자 및 예약수량 초기화
        this.stockNumber -= cnt;

        if(this.stockNumber == 0){
            this.itemSellStatus = ItemSellStatus.SOLD_OUT;
        }
        else{
            this.itemSellStatus = ItemSellStatus.SELL;
        }

        this.itemReserver = null;
        this.itemRamount = 0;
    }


    // 상태만 바꿔주는 메소드
    public void changeStatus(ItemSellStatus status){
        this.itemSellStatus = status;
    }

    // 상품 구매예약 시 예약정보 셋팅
    public void reserveItem(String itemReserver, int amount){
        this.itemReserver = itemReserver;
        this.itemRamount = amount;
    }

    // ItemEntity에 있는 이미지 리스트에 추가
    public void addItemImgList(ItemImgEntity itemImg){
        this.itemImgList.add(itemImg);
    }


    public void remainImgId(List<Long> remainImgId) {
        for (Long remainImg : remainImgId) {
            // 남겨줄 이미지id와 엔티티안에 있는 이미지 리스트의 id와 비교해준다.
            this.itemImgList.forEach(img -> {
                if(!img.getItemImgId().equals(remainImg)) {
                    // 엔티티 이미지id와 넘겨받은 남겨줄 이미지id가 같지않으면
                    // 기존의 이미지에서 삭제해줍니다.
                    this.itemImgList.remove(img);
                }
            });
        }
    }

}
package com.example.shopping.entity.item;

import com.example.shopping.domain.Item.ItemImgDTO;
import com.example.shopping.entity.Base.BaseEntity;
import lombok.*;

import javax.persistence.*;
import java.util.*;
/*
 *   writer : 유요한
 *   work :
 *          상품 이미지에 대한 엔티티
 *   date : 2023/10/22
 * */
@Entity(name = "item_img")
@Table
@ToString(exclude = "item")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ItemImgEntity extends BaseEntity {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "item_img_id")
    private Long itemImgId;
    @Column(name = "upload_img_path")
    private String uploadImgPath;
    @Column(name = "upload_img_name")
    private String uploadImgName;               // 이미지 파일명
    @Column(name = "ori_img_name")
    private String oriImgName;                  // 원본 이미지 파일명
    @Column(name = "upload_img_url")
    private String uploadImgUrl;                // 이미지 조회 경로
    @Column(name = "rep_img_yn")
    private String repImgYn;                    // 대표 이미지 여부 Y면 대표이미지를 보여줌

    // 다대일 연관관계
    // @JoinColumn(name = "item_id") 이렇게 하는 거는 여기가 주인이라
    // 표시해주는 것이다.
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private ItemEntity item;

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

    public void changeRepImgY(){
        this.repImgYn = "Y";
    }
    public void changeRepImgN(){
        this.repImgYn = "N";
    }
    public static ItemImgEntity toEntity(List<ItemImgDTO> productImg, ItemEntity itemEntity) {
        ItemImgEntity itemImg = null;
        for (int i = 0; i < productImg.size(); i++) {
            ItemImgDTO itemImgDTO = productImg.get(i);
            itemImg = ItemImgEntity.builder()
                    .oriImgName(itemImgDTO.getOriImgName())
                    .uploadImgPath(itemImgDTO.getUploadImgPath())
                    .uploadImgUrl(itemImgDTO.getUploadImgUrl())
                    .uploadImgName(itemImgDTO.getUploadImgName())
                    .item(itemEntity)
                    .repImgYn(i == 0 ? "Y" : "N")
                    .build();
        }
        return itemImg;
    }

}

레포지토리

여기서는 상품을 조회할 때 동적으로 조건을 처리할 일이 있어서 가독성과 오타를 방지할 수 있고 문제가 있을 경우 컴파일 단계에서 알 수 있는 QueryDsl와 Spring Data Jpa 방식 2개를 조합해서 사용하였습니다.

package com.example.shopping.repository.item.support;

import com.querydsl.core.types.EntityPath;
import com.querydsl.core.types.Expression;
import com.querydsl.core.types.dsl.PathBuilder;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.support.JpaEntityInformation;
import org.springframework.data.jpa.repository.support.JpaEntityInformationSupport;
import org.springframework.data.jpa.repository.support.Querydsl;
import org.springframework.data.querydsl.SimpleEntityPathResolver;
import org.springframework.data.support.PageableExecutionUtils;
import org.springframework.stereotype.Repository;
import org.springframework.util.Assert;

import javax.annotation.PostConstruct;
import javax.persistence.EntityManager;
import java.util.List;
import java.util.function.Function;

/*
 *   writer : YuYoHan
 *   work :
 *          스프링 데이터가 제공하는 QuerydslRepositorySupport가 지닌 한계를 극복하기 위해 직접 Querydsl 지원 클래스
 *   date : 2024/01/05
 * */
@Repository
public abstract class Querydsl4RepositorySupport {
    // 이 클래스가 다루는 도메인(엔티티)의 클래스
    private final Class domainClass;
    // 도메인 엔티티에 대한 Querydsl 쿼리를 생성하고 실행
    private Querydsl querydsl;
    // 데이터베이스와의 상호 작용을 담당하는 JPA의 핵심 객체
    private EntityManager entityManager;
    // queryFactory를 통해 Querydsl 쿼리를 생성하고 실행
    private JPAQueryFactory queryFactory;

    public Querydsl4RepositorySupport(Class<?> domainClass) {
        Assert.notNull(domainClass, "도메인 클래스는 null이면 안됩니다.");
        this.domainClass = domainClass;
    }

    // Pageable 안에 있는 Sort를 사용할 수 있도록 설정
    @Autowired
    public void setEntityManager(EntityManager entityManager) {
        Assert.notNull(entityManager, "엔티티메니저는 null이면 안됩니다.");
        // JpaEntityInformation을 얻기 위해 JpaEntityInformationSupport를 사용합니다.
        // 이 정보는 JPA 엔터티에 대한 메타데이터 및 정보를 제공합니다.
        JpaEntityInformation entityInformation =
                JpaEntityInformationSupport.getEntityInformation(domainClass, entityManager);

        // Querydsl에서 엔터티의 경로를 생성하는 데 사용됩니다.
        SimpleEntityPathResolver resolver = SimpleEntityPathResolver.INSTANCE;
        // entityInformation을 기반으로 엔티티의 경로를 생성합니다.
        EntityPath path = resolver.createPath(entityInformation.getJavaType());
        this.entityManager = entityManager;
        // querydsl 객체를 생성합니다.
        // 이 객체는 Querydsl의 핵심 기능을 사용할 수 있도록 도와줍니다.
        // 엔터티의 메타모델 정보를 이용하여 Querydsl의 PathBuilder를 생성하고, 이를 이용하여 Querydsl 객체를 초기화합니다.
        this.querydsl = new Querydsl(entityManager, new PathBuilder<>(path.getType(), path.getMetadata()));
        this.queryFactory = new JPAQueryFactory(entityManager);
    }

    // 해당 클래스의 빈(Bean)이 초기화될 때 자동으로 실행되는 메서드
    @PostConstruct
    public void validate() {
        Assert.notNull(entityManager, "EntityManager must not be null!");
        Assert.notNull(querydsl, "Querydsl must not be null!");
        Assert.notNull(queryFactory, "QueryFactory must not be null!");
    }

    // 이 팩토리는 JPA 쿼리를 생성하는 데 사용됩니다.
    protected JPAQueryFactory getQueryFactory() {
        return queryFactory;
    }

    // 이 객체는 Querydsl의 핵심 기능을 사용하는 데 도움이 됩니다.
    protected Querydsl getQuerydsl() {
        return querydsl;
    }

    // EntityManager는 JPA 엔터티를 관리하고 JPA 쿼리를 실행하는 데 사용됩니다.
    protected EntityManager getEntityManager() {
        return entityManager;
    }

    // Querydsl을 사용하여 쿼리의 SELECT 절을 생성하는 메서드입니다.
    // expr은 선택할 엔터티나 엔터티의 속성에 대한 표현식입니다.
    protected <T> JPAQuery<T> select(Expression<T> expr) {
        return getQueryFactory().select(expr);
    }

    // Querydsl을 사용하여 쿼리의 FROM 절을 생성하는 메서드입니다.
    // from은 엔터티에 대한 경로 표현식입니다.
    protected <T> JPAQuery<T> selectFrom(EntityPath<T> from) {
        return getQueryFactory().selectFrom(from);
    }


    // 이 메서드는 contentQuery와 함께 countQuery를 인자로 받아서 사용합니다.
    // contentQuery를 사용하여 페이징된 결과를 가져오고, countQuery를 사용하여 전체 레코드 수를 얻습니다.
    protected <T> Page<T> applyPagination(Pageable pageable,
                                          Function<JPAQueryFactory, JPAQuery> contentQuery,
                                          Function<JPAQueryFactory, JPAQuery> countQuery) {
        // 1. contentQuery를 사용하여 JPAQuery 객체를 생성
        JPAQuery jpaContentQuery = contentQuery.apply(getQueryFactory());
        // 2. Querydsl을 사용하여 페이징 및 정렬된 결과를 가져옴
        List<T> content = getQuerydsl().applyPagination(pageable,
                jpaContentQuery).fetch();
        JPAQuery<Long> countResult = countQuery.apply(getQueryFactory());
//        Long total = countResult.fetchOne();
        return PageableExecutionUtils.getPage(content, pageable, countResult::fetchOne);
    }

}

이거는 QueryDsl에서 지원하는 기능을 응용하기 위해서 커스텀한 것입니다. 동적으로 정렬과 조건을 처리하기 위해서 사용하였습니다.

package com.example.shopping.repository.item;

import com.example.shopping.domain.Item.ItemSearchCondition;
import com.example.shopping.domain.Item.ItemSellStatus;
import com.example.shopping.entity.item.ItemEntity;
import com.example.shopping.repository.item.support.Querydsl4RepositorySupport;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.Predicate;
import com.querydsl.core.types.dsl.BooleanExpression;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;

import static org.springframework.util.StringUtils.hasText;
import static com.example.shopping.entity.item.QItemEntity.itemEntity;

/*
 *   writer : 유요한
 *   work :
 *          상품을 Querydsl로 동적조건으로 처리하기 위한 레포지토리입니다.
 *          페이징, 정렬, 조건을 동적으로 처리할 수 있습니다.
 *   date : 2024/01/07
 * */
@Repository
public class ItemQuerydslRepository extends Querydsl4RepositorySupport {

    public ItemQuerydslRepository() {
        super(ItemEntity.class);
    }

    // count처리 까지 해줍니다.
    public Page<ItemEntity> itemSearch(ItemSearchCondition condition, Pageable pageable) {
        return applyPagination(pageable, contentQuery -> contentQuery
                        .selectFrom(itemEntity)
                        .where(nameEq(condition.getName()),
                                detailEq(condition.getDetail()),
                                priceEq(condition.getStartP(), condition.getEndP()),
                                placeEq(condition.getPlace()),
                                reserverEq(condition.getReserver()),
                                itemStatusEq(condition.getStatus())),
                countQuery -> countQuery
                        .select(itemEntity.count())
                        .from(itemEntity)
                        .where(nameEq(condition.getName()),
                                detailEq(condition.getDetail()),
                                priceEq(condition.getStartP(), condition.getEndP()),
                                placeEq(condition.getPlace()),
                                reserverEq(condition.getReserver()),
                                itemStatusEq(condition.getStatus())));
    }

    /* 조건을 동정으로 처리하기 위해서 동적 쿼리 적용 */
    private BooleanExpression nameEq(String name) {
        // likeIgnoreCase는 QueryDSL에서 문자열에 대한 대소문자를 무시하고 부분 일치 검색을 수행하는 메서드입니다.
        // 이 메서드는 SQL에서의 LIKE 연산과 유사하지만, 대소문자를 구분하지 않고 비교합니다.
        return hasText(name) ? itemEntity.itemName.likeIgnoreCase("%" + name + "%") : null;
    }

    private BooleanExpression detailEq(String detail) {
        return hasText(detail) ? itemEntity.itemDetail.eq(detail) : null;
    }

    private BooleanBuilder priceEq(Long startP, Long endP) {
        BooleanBuilder builder = new BooleanBuilder();
        if (startP != null) {
            builder.and(priceGoe(startP));
        }
        if (endP != null) {
            builder.and(priceLoe(endP));
        }

        return builder;
    }

    // 이상
    private BooleanExpression priceGoe(Long startP) {
        return startP != null ? itemEntity.price.goe(startP) : null;
    }

    // 이하
    private BooleanExpression priceLoe(Long endP) {
        return endP != null ? itemEntity.price.loe(endP) : null;
    }

    private BooleanBuilder placeEq(String place) {
        BooleanBuilder builder = new BooleanBuilder();
        builder.and(placeNameEq(place));
        builder.or(placeAddrEq(place));
        return builder;
    }

    private BooleanExpression placeNameEq(String place) {
        return hasText(place) ? itemEntity.itemPlace.containerName.likeIgnoreCase("%" + place + "%") : null;
    }

    private BooleanExpression placeAddrEq(String place) {
        return hasText(place) ? itemEntity.itemPlace.containerAddr.likeIgnoreCase("%" + place + "%") : null;
    }

    private BooleanExpression reserverEq(String reserver) {
        return hasText(reserver) ? itemEntity.itemReserver.eq(reserver) : null;
    }

    private Predicate itemStatusEq(ItemSellStatus status) {
        return status != null ? itemEntity.itemSellStatus.eq(status) : null;
    }
}

여기서 조건에 해당되면 그 조건을 찾아오지만 조건이 없다면 null이 반환됩니다. 그러면 전체 상품을 가져오는데 만약 조건을 넣었는데 조건에 맞지 않는 상품밖에 DB에 없다면 fetch()가 빈 리스트를 반환해줍니다.

Service

package com.example.shopping.service.item;

import com.example.shopping.domain.Item.*;
import com.example.shopping.domain.cart.CartItemDTO;
import com.example.shopping.domain.container.ItemContainerDTO;
import com.example.shopping.entity.Container.ContainerEntity;
import com.example.shopping.entity.Container.ItemContainerEntity;
import com.example.shopping.entity.item.ItemEntity;
import com.example.shopping.entity.item.ItemImgEntity;
import com.example.shopping.entity.member.MemberEntity;
import com.example.shopping.exception.item.ItemException;
import com.example.shopping.exception.member.UserException;
import com.example.shopping.repository.cart.CartItemRepository;
import com.example.shopping.repository.container.ItemContainerRepository;
import com.example.shopping.repository.item.ItemImgRepository;
import com.example.shopping.repository.item.ItemQuerydslRepository;
import com.example.shopping.repository.item.ItemRepository;
import com.example.shopping.repository.member.MemberRepository;
import com.example.shopping.service.s3.S3ItemImgUploaderService;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.data.domain.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import javax.persistence.EntityNotFoundException;
import java.io.IOException;
import java.util.Collection;
import java.util.List;

/*
 *   writer : 유요한, 오현진
 *   work :
 *          상품 서비스
 *          - 상품 CRUD기능과 상품의 판매지역을 가져올 수 있고 조건에 맞춰서 검색할 수 있습니다.
 *          이렇게 인터페이스를 만들고 상속해주는 방식을 선택한 이유는
 *          메소드에 의존하지 않고 필요한 기능만 사용할 수 있게 하고 가독성과 유지보수성을 높이기 위해서 입니다.
 *   date : 2024/01/25
 * */
@RequiredArgsConstructor
@Service
@Log4j2
@Transactional
public class ItemServiceImpl implements ItemService {
    private final MemberRepository memberRepository;
    private final ItemRepository itemRepository;
    private final ItemImgRepository itemImgRepository;
    private final S3ItemImgUploaderService s3ItemImgUploaderService;
    private final CartItemRepository cartItemRepository;
    private final ItemContainerRepository itemContainerRepository;
    private final ItemQuerydslRepository itemQuerydslRepository;

    // 상품 등록 메소드
    @Override
    public ItemDTO saveItem(CreateItemDTO item,
                            List<MultipartFile> itemFiles,
                            String memberEmail) throws Exception {
        // 유저 조회
        MemberEntity findUser = memberRepository.findByEmail(memberEmail);

        if (findUser != null) {
            ItemDTO itemInfo = ItemDTO.builder()
                    .itemName(item.getItemName())
                    .price(item.getPrice())
                    .itemDetail(item.getItemDetail())
                    .stockNumber(item.getStockNumber())
                    .sellPlace(item.getSellPlace().getContainerName()
                            + "/" + item.getSellPlace().getContainerAddr())
                    .itemSellStatus(ItemSellStatus.SELL)
                    .itemReserver(null)
                    .itemRamount(0)
                    .itemSeller(findUser.getMemberId())
                    .build();

            // DTO를 엔티티로 변환
            ItemEntity itemEntity = ItemEntity.saveEntity(itemInfo, item);

            if (!itemFiles.isEmpty()) {
                // S3에 업로드
                List<ItemImgDTO> productImg = s3ItemImgUploaderService.upload("product", itemFiles);
                ItemImgEntity itemImg = ItemImgEntity.toEntity(productImg, itemEntity);
                // ItemEntity에 있는 이미지 리스트에 추가
                itemEntity.addItemImgList(itemImg);
            } else {
                itemEntity.addItemImgList(null);
            }
            // container안에 저장해서 테이블로 관리하기 위해서 저장
            ItemContainerEntity saveContainer = ItemContainerEntity.saveContainer(itemEntity);
            itemContainerRepository.save(saveContainer);

            //Cascade특징을 활용하여 ItemRepository.save만 진행해도 ItemImg도 같이 인서트됨
            ItemEntity savedItem = itemRepository.save(itemEntity);
            return ItemDTO.toItemDTO(savedItem);
        } else {
            throw new UserException("회원이 없습니다.");
        }
    }

    // 상품 상세정보
    // 상품의 데이터를 읽어오는 트랜잭션을 읽기 전용으로 설정합니다.
    // 이럴 경우 JPA가 더티체킹(변경감지)를 수행하지 않아서 성능을 향상 시킬 수 있다.
    @Transactional(readOnly = true)
    public ItemDTO getItem(Long itemId) {
        try {
            // 상품 조회
            ItemEntity findItem = itemRepository.findById(itemId)
                    .orElseThrow(() -> new EntityNotFoundException("상품이 없습니다."));

            ItemDTO itemDTO = ItemDTO.toItemDTO(findItem);
            // 상품 컨테이너 조회
            ItemContainerEntity container =
                    itemContainerRepository.findByContainerName(findItem.getItemPlace().getContainerName());
            if (container == null) {
                itemDTO.setSellPlace("폐점된 지점", null);
            } else {
                itemDTO.setSellPlace(container.getContainerName(), container.getContainerAddr());
            }
            return itemDTO;
        } catch (EntityNotFoundException e) {
            throw new ItemException(e.getMessage());
        }
    }

    // 상품 수정
    @Override
    public ItemDTO updateItem(Long itemId,
                              UpdateItemDTO itemDTO,
                              List<MultipartFile> itemFiles,
                              String memberEmail,
                              String role) {
        try {
            // 상품 조회
            ItemEntity findItem = itemRepository.findById(itemId)
                    .orElseThrow(EntityNotFoundException::new);
            log.info("item : " + findItem);
            // 유저 조회
            MemberEntity findMember = memberRepository.findByEmail(memberEmail);
            log.info("member : " + findMember);
            // 이미지 조회
            List<ItemImgEntity> itemImgs = itemImgRepository.findByItemItemId(itemId);

            if (role.equals("ROLE_ADMIN") && findItem.getItemSeller().equals(findMember.getMemberId())) {
                // 상품 정보 수정
                findItem.updateItem(itemDTO);
                // 남겨줄 이미지id를 받지 못한다는 것은 전부 삭제한다는 의미이니
                // 삭제처리
                removeAllImg(itemDTO, itemImgs, findItem);

                // 이미지가 있으면 남겨줄 이미지id를 받고 남겨주고 나머지는 삭제
                // 남겨줄id와 상품 엔티티의 이미지리스트 id를 비교해서 맞지않으면 삭제
                removeImg(itemDTO, itemImgs, findItem);
                // 수정시 추가로 이미지를 넣으려고 하면 s3에 저장하고 엔티티로 변경 후
                // 상품 엔티티의 이미지 리스트에 넣고 썸네일 작업을 해줍니다.
                saveImg(itemFiles, findItem);
                ItemEntity updateItem = itemRepository.save(findItem);
                ItemDTO returnItem = ItemDTO.toItemDTO(updateItem);
                log.info("업데이트 상품 : " + returnItem);
                return returnItem;
            } else {
                throw new UserException("관리자가 아니라 수정작업을 진행할 수 없습니다.");
            }
        } catch (Exception e) {
            throw new ItemException("상품 수정하는 작업을 실패했습니다.\n" + e.getMessage());
        }
    }

    private void removeAllImg(UpdateItemDTO itemDTO, List<ItemImgEntity> itemImgs, ItemEntity findItem) {
        // 남겨줄 이미지가 존재하지 않으면 모두 삭제이니
        if (itemDTO.getRemainImgId().isEmpty()) {
            // 조회한 이미지를 이용해서 s3 삭제
            itemImgs.forEach(itemimg ->
                    s3ItemImgUploaderService.deleteFile(
                            itemimg.getUploadImgPath(),
                            itemimg.getUploadImgName()));
            // 전부 삭제해줄것이니 해당 상품의 이미지 리스트를 삭제
            findItem.getItemImgList().removeAll(itemImgs);
        }
    }

    private void removeImg(UpdateItemDTO itemDTO, List<ItemImgEntity> itemImgs, ItemEntity findItem) {
        // 상품에 이미지가 비어 있지 않으면 true
        if (!itemImgs.isEmpty()) {
            // 남겨줄 이미지id를 넘겨받으면
            if (!itemDTO.getRemainImgId().isEmpty()) {
                // itemDTO.getRemainImgId()에 포함되지 않은
                // 상품 이미지id를 가진 이미지만이 필터링되어 삭제됩니다.
                findItem.getItemImgList().stream()
                        .filter(img -> !itemDTO.getRemainImgId().contains(img.getItemImgId()))
                        .forEach(img -> s3ItemImgUploaderService.deleteFile(img.getUploadImgPath(), img.getUploadImgName()));

                // 넘겨받은 이미지id를 리스트에서 유지하고 나머지를 삭제해줍니다.
                findItem.remainImgId(itemDTO.getRemainImgId());
            }
        }
    }

    private void saveImg(List<MultipartFile> itemFiles, ItemEntity findItem) throws IOException {
        // 추가로 저장할 이미지가 있을 경우
        if (!itemFiles.isEmpty()) {
            // s3에 저장
            List<ItemImgDTO> products = s3ItemImgUploaderService.upload("product", itemFiles);
            // 리스트 이미지 DTO를 리스트 엔티티 이미지로 변경
            ItemImgEntity itemImg = ItemImgEntity.toEntity(products, findItem);
            // 상품의 이미지 리스트에 넣기
            findItem.addItemImgList(itemImg);
            // 추가로 넣은 것과 기존의 것을 합쳤을 때 첫번째인 것을 썸네일로 처리하기 위해서
            // 썸네일 작업
            boolean isFirstImage = true;
            for (ItemImgEntity img : findItem.getItemImgList()) {
                if (isFirstImage) {
                    img.changeRepImgY();
                    isFirstImage = false;
                } else {
                    img.changeRepImgN();
                }
            }
        }
    }

    // 상품 삭제
    @Override
    public String removeItem(Long itemId, UserDetails userDetails) {
        try {
            // 삭제할 권한이 있는지 확인
            // userDetails에서 권한을 가져오기
            Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();

            // 상품 조회
            ItemEntity findItem = itemRepository.findById(itemId)
                    .orElseThrow(EntityNotFoundException::new);
            // 상품 이미지 조회
            List<ItemImgEntity> findItemImg = itemImgRepository.findByItemItemId(itemId);

            // 현재는 권한이 1개만 있는 것으로 가정
            if (!authorities.isEmpty()) {
                // 현재 사용자의 권한(authority) 목록에서 첫 번째 권한을 가져오는 코드입니다.
                // 현재 저의 로직에서는 유저는 하나의 권한을 가지므로 이렇게 처리할 수 있다.
                String role = authorities.iterator().next().getAuthority();
                log.info("권한 : " + role);
                // 존재하는 권한이 관리자인지 체크
                if (role.equals("ADMIN") || role.equals("ROLE_ADMIN")) {
                    // 장바구니 상품을 null로 바꾸고 저장
                    List<CartItemDTO> items = cartItemRepository.findByItemId(itemId);
                    for (CartItemDTO item : items) {
                        item.setItem(null);
                        cartItemRepository.save(item);
                    }
                    // 상품 정보 삭제
                    itemRepository.deleteByItemId(findItem.getItemId());
                    // 삭제하는데 이미지를 풀어놓는 이유는
                    // S3에 삭제할 때 넘겨줘야 할 매개변수때문이다.
                    for (ItemImgEntity itemImgEntity : findItemImg) {
                        String uploadImgPath = itemImgEntity.getUploadImgPath();
                        String uploadImgName = itemImgEntity.getUploadImgName();
                        // S3에서 이미지 삭제
                        String result = s3ItemImgUploaderService.deleteFile(uploadImgPath, uploadImgName);
                        log.info("s3 삭제 : " + result);
                    }
                    return "상품을 삭제 했습니다.";
                }
            }
            return "상품 삭제 권한이 없습니다.";
        } catch (Exception e) {
            return e.getMessage();
        }
    }

    // 상품 검색 - 여러 조건으로 검색하기
    @Transactional(readOnly = true)
    @Override
    public Page<ItemDTO> searchItemsConditions(Pageable pageable, ItemSearchCondition condition) {
        try {
            Page<ItemEntity> findItemConditions = itemQuerydslRepository.itemSearch(condition, pageable);
            log.info("items : {}", findItemConditions.getContent());

            if (findItemConditions.isEmpty()) {
                throw new EntityNotFoundException("조건에 만족하는 상품이 없습니다.");
            }

            Page<ItemDTO> pageItem = findItemConditions.map(ItemDTO::toItemDTO);

            pageItem.forEach(status -> {
                String[] split = status.getSellPlace().split("/");
                ItemContainerEntity container = itemContainerRepository.findByContainerName(split[0]);
                if (container == null) {
                    status.setSellPlace("폐점된 지점", null);
                } else {
                    status.setSellPlace(container.getContainerName(), container.getContainerAddr());
                }
            });
            return pageItem;
        } catch (Exception e) {
            log.error("error : " + e.getMessage());
            throw new EntityNotFoundException("상품 조회에 실패하였습니다.\n" + e.getMessage());
        }
    }

}

S3에 이미지 올리기

AWS S3 Bucket 생성

ACL을 활성화하면 다른 AWS 계정에서 소유권을 갖거나 접속 제어가 가능하게 된다. 내 계정만 소유할 수 있도록 ACL을 비활성화했다.

퍼블릭 액세스 차단을 위한 버킷 설정에 기본적으로 체크가 되어있다. 이 경우 외부에서 버킷에 접근할 수 없다. 이번에는 퍼블릭 접근을 허용하기 위해 체크를 해제했다. 단, 모든 액세스 차단 혹은 ACL을 이용하여 액세스 차단해 주는 것이 보안을 위해 좋다.

IAM 계정 생성

이제 S3에 접근할 수 있는 IAM 계정을 생성한다. 생성된 계정 정보를 통해 우리 서비스에서 해당 S3로 접근할 수 있도록 할 수 있다.

IAM은 AWS 리소스에 대한 액세슬 안전하게 관리할 수 있는 웹 서비스다. IAM을 통해 AWS 사용자, 그룹 및 역할을 생성, 관리하고 AWS 서비스 및 리소스에 액세스 하고 사용할 수 있는 특정 권한을 할당할 수 있다.

스프링부트

cloud:
  aws:
    credentials:
      access-key: ${AWS_ACCESS_KEY}
      secret-key: ${AWS_SECRET_KEY}
    s3:
      bucket: shoppingproject
    region:
      static: ap-northeast-2
      auto: false
    stack:
      auto: false
package com.example.shopping.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;
import org.springframework.context.annotation.PropertySource;

@Configuration
@PropertySource("classpath:application-s3.yml")
public class S3Config {
    @Value("${cloud.aws.credentials.access-key}")
    private String accessKey;
    @Value("${cloud.aws.credentials.secret-key}")
    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();
    }
}
package com.example.shopping.service.s3;

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.AmazonS3Exception;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.example.shopping.domain.Item.ItemImgDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
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.*;

@Log4j2
@RequiredArgsConstructor
@Service
public class S3ItemImgUploaderService {
    @Value("${cloud.aws.s3.bucket}")
    private String bucket;
    private final AmazonS3 amazonS3;

    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));

                // 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("-", "/");
    }
}

문의글

package com.example.shopping.controller.board;


import com.example.shopping.domain.board.BoardDTO;
import com.example.shopping.domain.board.CreateBoardDTO;
import com.example.shopping.service.board.BoardService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import javax.persistence.EntityNotFoundException;
import java.util.HashMap;
import java.util.Map;

/*
 *   writer : YuYoHan
 *   work :
 *          문의글 생성, 삭제, 수정, 페이징 처리한 전체보기와
 *          상세보기 기능입니다.
 *   date : 2023/12/06
 * */
@RestController
@Log4j2
@RequiredArgsConstructor
@RequestMapping("/api/v1/{itemId}/boards")
@Tag(name = "board", description = "상품 문의 API")
public class BoardController {
    private final BoardService boardService;

    // 문의 등록
    @PostMapping("")
    @PreAuthorize("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
    @Tag(name = "board")
    @Operation(summary = "문의 등록", description = "상품에 대한 문의를 등록합니다.")
    public ResponseEntity<?> createBoard(
            @PathVariable Long itemId,
            @RequestBody @Validated CreateBoardDTO board,
            BindingResult bindingResult,
            @AuthenticationPrincipal UserDetails userDetails) {
        try {
            if (bindingResult.hasErrors()) {
                log.error("bindingResult error : " + bindingResult.hasErrors());
                return ResponseEntity.badRequest().body(bindingResult.getClass().getSimpleName());
            }
            String email = userDetails.getUsername();

            ResponseEntity<?> returnBoard = boardService.saveBoard(itemId, board, email);
            return ResponseEntity.ok().body(returnBoard);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    // 문의 삭제
    @DeleteMapping("/{boardId}")
    @PreAuthorize("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
    @Tag(name = "board")
    @Operation(summary = "문의 삭제", description = "상품에 대한 문의를 삭제합니다.")
    public ResponseEntity<?> removeBoard(@PathVariable Long boardId,
                              @AuthenticationPrincipal UserDetails userDetails) {
        try {
            String result = boardService.removeBoard(boardId, userDetails);
            return ResponseEntity.ok().body(result);
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }

    // 문의 수정
    @PutMapping("/{boardId}")
    @PreAuthorize("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
    @Tag(name = "board")
    @Operation(summary = "문의 수정", description = "상품에 대한 문의를 수정합니다.")
    public ResponseEntity<?> updateBoard(@PathVariable Long boardId,
                                         @RequestBody CreateBoardDTO modifyDTO,
                                         @AuthenticationPrincipal UserDetails userDetails) {
        try {
            String email = userDetails.getUsername();
            log.info("이메일 : " + email);
            return boardService.updateBoard(boardId, modifyDTO, email);
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }

    // 문의 상세 보기
    // 상품 안에 있는 문의글은 게시글 형태로 되어 있기 때문에  상세보기로 들어가야 한다.
    // 해당 상세보기 기능은 유저를 가리지 않고 그 상품에 관한 문의글이다.
    @GetMapping("/{boardId}")
    @PreAuthorize("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
    @Tag(name = "board")
    @Operation(summary = "문의 상세 보기", description = "상품에 대한 문의를 상세히 봅니다.")
    public ResponseEntity<?> getBoard(@PathVariable Long boardId,
                                      @AuthenticationPrincipal UserDetails userDetails) {
        try {
            String email = userDetails.getUsername();
            log.info("email : " + email);
            ResponseEntity<?> board = boardService.getBoard(boardId, email);
            return ResponseEntity.ok().body(board);
        } catch (EntityNotFoundException e) {
            return ResponseEntity.notFound().build();
        }
    }

    // 상품에 대한 문의글 전체 보기
    @GetMapping("")
    @Tag(name = "board")
    @Operation(summary = "문의글 전체 보기", description = "모든 상품에 대한 문의글을 봅니다.")
    public ResponseEntity<?> getBoards(
            // SecuritConfig에 Page 설정을 한 페이지에 10개 보여주도록
            // 설정을 해서 여기서는 할 필요가 없다.
            // 여기보다 레포지토리에서 적용한것이 우선이 됩니다.
            @PageableDefault(sort = "boardId", direction = Sort.Direction.DESC)
            Pageable pageable,
            @PathVariable(name = "itemId") Long itemId,
            @RequestParam(value = "email", required = false) String email) {
        try {
            log.info("email : " + email);
            // 검색하지 않을 때는 모든 글을 보여준다.
            Page<BoardDTO> boards = boardService.getBoards(pageable, itemId, email);
            Map<String, Object> response = new HashMap<>();
            // 현재 페이지의 아이템 목록
            response.put("items", boards.getContent());
            // 현재 페이지 번호
            response.put("nowPageNumber", boards.getNumber()+1);
            // 전체 페이지 수
            response.put("totalPage", boards.getTotalPages());
            // 한 페이지에 출력되는 데이터 개수
            response.put("pageSize", boards.getSize());
            // 다음 페이지 존재 여부
            response.put("hasNextPage", boards.hasNext());
            // 이전 페이지 존재 여부
            response.put("hasPreviousPage", boards.hasPrevious());
            // 첫 번째 페이지 여부
            response.put("isFirstPage", boards.isFirst());
            // 마지막 페이지 여부
            response.put("isLastPage", boards.isLast());

            return ResponseEntity.ok().body(response);
        } catch (Exception e) {
            return ResponseEntity.badRequest().build();
        }
    }

}

페이징이란?

게시판에 100개의 글이 있는 상황에서는 페이징 처리를 하지 않으면 그냥 보여주면 너무 많다. 이런 상황에서는 페이징 처리가 필요하다. 그러면 한 페이지에 글을 10개씩만 보여줄 수 있다. 예를 들어 1번페이지 1~10개 2번 페이지에는 11~20개 이렇게 보여주는 것이다.

  • 페이징 기능에는 정렬 기능이 포함되어 있다.
    글의 id순, 최신순, 이름순 등으로 정렬하여 출력할 수 있다.

방법

JPA에는 이 페이징 기능을 지원한다. 2가지 방법을 지원한다.

  • Pageable 사용
  • PageRequest 사용
    PageRequest는 Pageable의 구현체

DTO

응답용 DTO

package com.example.shopping.domain.board;

import com.example.shopping.domain.comment.CommentDTO;
import com.example.shopping.entity.board.BoardEntity;
import com.example.shopping.entity.comment.CommentEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

/*
 *   writer : YuYoHan
 *   work :
 *          게시글을 필요한 값을 리턴해줍니다.
 *   date : 2024/01/18
 * */
@Getter
@ToString
@NoArgsConstructor
public class BoardDTO {
    @Schema(description = "문의글 번호", example = "1", required = true)
    private Long boardId;

    @Schema(description = "문의글 제목", required = true)
    @NotNull(message = "문의글 제목은 필 수 입력입니다.")
    private String title;

    @Schema(description = "문의글 본문")
    private String content;

    @Schema(description = "유저 닉네임")
    private String nickName;

    @Schema(description = "문의글 작성 시간")
    private LocalDateTime regTime;

    @Schema(description = "관리자 답변")
    private List<CommentDTO> commentDTOList = new ArrayList<>();

    @Schema(description = "문의글이 본인글인지 확인")
    private BoardSecret boardSecret;

    @Schema(description = "상품 번호")
    private Long itemId;

    @Schema(description = "답글상태")
    private ReplyStatus replyStatus;


    @Builder
    public BoardDTO(Long boardId,
                    String title,
                    String content,
                    String nickName,
                    LocalDateTime regTime,
                    List<CommentDTO> commentDTOList,
                    BoardSecret boardSecret,
                    Long itemId,
                    ReplyStatus replyStatus) {
        this.boardId = boardId;
        this.title = title;
        this.content = content;
        this.nickName = nickName;
        this.regTime = regTime;
        this.commentDTOList = commentDTOList;
        this.boardSecret = boardSecret;
        this.itemId = itemId;
        this.replyStatus = replyStatus;
    }

    // 엔티티를 DTO로 변환하는 작업
    public static BoardDTO toBoardDTO(BoardEntity board) {
        // 게시글 댓글 처리
        List<CommentEntity> commentEntityList =
                board.getCommentEntityList() != null ? board.getCommentEntityList() : Collections.emptyList();

        List<CommentDTO> commentDTO = commentEntityList.stream()
                .map(CommentDTO::toCommentDTO)
                .collect(Collectors.toList());

        return BoardDTO.builder()
                .boardId(board.getBoardId())
                .title(board.getTitle())
                .content(board.getContent())
                .nickName(board.getMember().getNickName())
                .commentDTOList(commentDTO)
                .itemId(board.getItem().getItemId())
                // 답글 미완료 상태로 등록
                .replyStatus(board.getReplyStatus())
                .regTime(board.getRegTime())
                .boardSecret(board.getBoardSecret())
                .build();
    }
}

생성용 DTO

내 문의글이면 확인하고 남의 글은 못읽게 잠그는데 사용

댓글 존재 여부 상태

엔티티

package com.example.shopping.entity.board;

import com.example.shopping.domain.board.BoardDTO;
import com.example.shopping.domain.board.BoardSecret;
import com.example.shopping.domain.board.CreateBoardDTO;
import com.example.shopping.domain.board.ReplyStatus;
import com.example.shopping.domain.comment.CommentDTO;
import com.example.shopping.entity.Base.BaseEntity;
import com.example.shopping.entity.comment.CommentEntity;
import com.example.shopping.entity.item.ItemEntity;
import com.example.shopping.entity.member.MemberEntity;
import lombok.*;

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

/*
 *   writer : 유요한
 *   work :
 *          유저 테이블을 만들어줍니다.
 *   date : 2024/01/06
 * */
@Entity(name = "board")
@Table
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(exclude = {"item", "member", "commentEntityList"})
public class BoardEntity extends BaseEntity {
    @Id
    @GeneratedValue
    @Column(name = "board_id")
    private Long boardId;

    @Column(length = 300, nullable = false)
    private String title;
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private MemberEntity member;

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

    @Enumerated(EnumType.STRING)
    private BoardSecret boardSecret;

    @Enumerated(EnumType.STRING)
    private ReplyStatus replyStatus;


    // 댓글
    // 여기에 적용해야 합니다. 보통 게시물을 삭제해야 이미지가 삭제되므로
    // 게시물이 주축이기 때문에 여기에 cascade = CascadeType.ALL을 추가
    // orphanRemoval = true도 게시글을 삭제하면
    // 댓글도 삭제되므로 여기서 작업을 해야합니다.
    @OneToMany(mappedBy = "board", cascade = CascadeType.ALL, orphanRemoval = true)
    @OrderBy("commentId desc ")
    private List<CommentEntity> commentEntityList = new ArrayList<>();

    @Builder
    public BoardEntity(Long boardId,
                       String title,
                       String content,
                       MemberEntity member,
                       ItemEntity item,
                       BoardSecret boardSecret,
                       List<CommentEntity> commentEntityList,
                       ReplyStatus replyStatus) {
        this.boardId = boardId;
        this.title = title;
        this.content = content;
        this.member = member;
        this.item = item;
        this.boardSecret = boardSecret;
        this.commentEntityList = commentEntityList;
        this.replyStatus = replyStatus;
    }

    // 게시글 DTO를 엔티티로 변환
    public static BoardEntity toBoardEntity(BoardDTO board,
                                            MemberEntity member,
                                            ItemEntity item) {
        BoardEntity boardEntity = BoardEntity.builder()
                .boardId(board.getBoardId())
                .title(board.getTitle())
                .content(board.getContent())
                .member(member)
                .boardSecret(BoardSecret.UN_LOCK)
                .item(item)
                .build();

        // 댓글 처리
        List<CommentDTO> commentDTOList =
                board.getCommentDTOList() != null ? board.getCommentDTOList() : Collections.emptyList();
        for (CommentDTO commentDTO : commentDTOList) {
            CommentEntity commentEntity = CommentEntity.toCommentEntity(commentDTO, member, boardEntity);
            boardEntity.commentEntityList.add(commentEntity);
        }
        return boardEntity;
    }

    /* 비즈니스 로직 */
    // 게시글 작성

    public static BoardEntity createBoard(CreateBoardDTO boardDTO,
                                          MemberEntity member,
                                          ItemEntity item) {
        return BoardEntity.builder()
                .title(boardDTO.getTitle())
                .content(boardDTO.getContent())
                // 본인이 작성한 글은 읽을 수 있어야하기 때문에 UN_ROCK
                .boardSecret(BoardSecret.UN_LOCK)
                .member(member)
                .item(item)
                .replyStatus(ReplyStatus.REPLY_X)
                .build();
    }

    // 게시글 수정
    public void updateBoard(CreateBoardDTO boardDTO) {
        this.title = boardDTO.getTitle() != null ? boardDTO.getTitle() : this.title;
        this.content = boardDTO.getContent() != null ? boardDTO.getContent() : this.content;
    }

    // 답장 상태 변화
    public void changeReply(ReplyStatus replyStatus) {
        this.replyStatus = replyStatus;
    }
    // 댓글 존재여부에 따라 상태 변화
    public void replyCheck() {
        // 댓글이 없으면 답변 미완료, 있으면 완료
       this.replyStatus = this.getCommentEntityList().isEmpty() ? ReplyStatus.REPLY_X : ReplyStatus.REPLY_O;
    }
    // 잠금 상태 변화
    public void changeSecret(BoardSecret boardSecret) {
        this.boardSecret = boardSecret;
    }

}

레포지토리

문의글은 간단한 쿼리이기 때문에 JPQL을 사용했습니다.

package com.example.shopping.repository.board;

import com.example.shopping.entity.board.BoardEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.Optional;
/*
 *   writer : 유요한
 *   work :
 *          게시글 레포지토리
 *          Spring Data JPA 방식을 사용하였고 fetch Join을 위하여
 *          JPQL을 사용했습니다.
 *   date : 2024/01/09
 * */
@Repository
public interface BoardRepository extends JpaRepository<BoardEntity, Long> {
    @Query("select b from board b" +
            " join fetch b.member " +
            " join fetch b.item " +
            " where b.boardId = :boardId")
    Optional<BoardEntity> findByBoardId(@Param("boardId") Long boardId);

    void deleteByBoardId(Long boardId);
    void deleteAllByMemberMemberId(Long memberId);

    @Query(value = "select distinct b from board b" +
            " join fetch  b.member " +
            " join fetch b.item " +
            " where (:searchKeyword is null or b.title like %:searchKeyword%)" +
            " order by b.boardId DESC ",
            countQuery = "select count(b) from board b where (:searchKeyword is null or b.title like %:searchKeyword%)")
    Page<BoardEntity> findByTitleContaining(Pageable pageable,
                                            @Param("searchKeyword") String searchKeyword);

    @Query(value = "select distinct b from board b" +
            " join fetch b.member " +
            " join fetch b.item " +
            " order by b.boardId DESC ",
            countQuery = "select count(b) from board b")
    Page<BoardEntity> findAll(Pageable pageable);


    @Query(value = "select distinct b from board  b " +
            " join fetch b.member " +
            " join fetch b.item " +
            " where b.member.email = :email and (:searchKeyword is null or b.title like %:searchKeyword%)" +
            " order by b.boardId DESC ",
            countQuery = "select count(b) from board b " +
                    "where b.member.email = :email and (:searchKeyword is null or b.title like %:searchKeyword%)")
    Page<BoardEntity> findByMemberEmailAndTitleContaining(@Param("email") String email,
                                                          Pageable pageable,
                                                          @Param("searchKeyword") String searchKeyword);


    @Query(value = "select distinct b from board b " +
            " join fetch b.member " +
            " join fetch b.item " +
            "where b.member.nickName = :nickName and (:searchKeyword is null or b.title like %:searchKeyword%)" +
            " order by b.boardId desc ",
            countQuery = "select count(b) from board b " +
                    "where b.member.nickName = :nickName and (:searchKeyword is null or b.title like %:searchKeyword%)")
    Page<BoardEntity> findByMemberNickNameAndTitleContaining(@Param("nickName") String nickName,
                                                             Pageable pageable,
                                                             @Param("searchKeyword") String searchKeyword);

    @Query(value = "select distinct b from board b " +
            " join fetch b.member " +
            " join fetch b.item " +
            "where b.item.itemId = :itemId" +
            " order by b.boardId desc ",
            countQuery = "select count(b) from board b where b.item.itemId = :itemId")
    Page<BoardEntity> findAllByItemItemId(@Param("itemId") Long itemId, Pageable pageable);

}

Spring Data Jpa를 사용할 때 원래 네이밍 규칙을 지켜줘야 하지만 JPQL로 처리할 때는 지켜줄 필요가 없습니다. 여기서 JPQL을 사용한 이유는 Fetch Join을 사용하여 N+1문제를 해결하기 위해서 입니다.

엔티티를 보면 @ToString을 사용할 때 연관관계 객체를 제한하고 FetchType.LAZY를 사용한 것을 볼 수 있는데

제한한 이유
@ToString을 사용할 때 제한하지 않으면 A와 B가 연관관계가 있으면 B연관관계 엔티티를 타고 들어가 그곳에 있는 A라는 엔티티를 타고 들어가서 계속 순환이 이루어집니다. 그렇기 때문에 제한했습니다.

LAZY 사용 이유
지연로딩은 바로 가져오지 않고 하이브네이트에서는 프록시 라이브러리를 사용해서 프록시 멤버를 넣어준다. 그것이 bytebuddy다. 프록시라는 값이 들어가 있고 필요할 때 조회를 해서 가져오는 것입니다.

우리 눈에는 안보이지만

이런식으로 들어가 있는 것이다. 여기서 문제가 발생하는 이유는 json 라이브러리가 루프를 돌리는 와중에 member가 객체가 아니고 bytebuddy라서 문제가 생기는 것이다.

DTO에 반환할 때 다른 엔티티가 필요없다면 fetch join을 사용하지 않아도 되지만 DTO를 프론트에 반환할 때 다른 엔티티의 정보가 필요하다면 fetch join을 사용하는 것이 좋다.

레포지토리에서 DTO로 반환해주는 방법도 있는데 레포지토리 재사용성이 떨어지고 API 스펙에 맞춘 코드가 레포지토리에 들어가는 단점이 있다.

유지보수는 엔티티를 DTO를 바꿔줘서 리턴해주는 방법이 유지보수에 좋다.

컬렉션 조회 최적화

컬렉션 조회는 일대다조회이다.

현재 문의글 엔티티와 댓글 엔티티와 양방향 연관관계를 맺고 있어서 List로 되어 있습니다. 그래서 컬렉션 조회인데 이럴 경우 데이터가 뻥튀기 될 수 있으니 뻥튀기를 안되게 막으려면 distinct를 해주면 됩니다.

같은 ID이면 중복된 것을 버린다. 즉, 중복된 엔티티를 제거해준다.

  • 페치 조인으로 SQL이 1번만 실행됨

  • distinct를 사용한 이유는 1대다 조인이 있으므로 데이터베이스 row가 증가한다. JPA의 distinct는 SQL에 distinct를 추가하고, 더해서 같은 엔티티가 조회되면, 애플리케이션에서 중복을 걸러준다. 중복처리를 조회되는 것을 막아준다.

  • 페이지 불가능

컬렉션 페치 조인을 사용하면 페이징이 불가능하다. 하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징 해버린다.

하지만 페이징을 할 수 있는 방법이 있습니다. 위의 코드가 적용한 방법인데

페이징 한계 돌파

  • 컬렉션을 페치 조인하면 페이징이 불가능하다.

    • 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
    • 일대다에서 일(1)을 기준으로 페이징을 하는 것이 목적이다. 그런데 데이터는 다(N)를 기준으로 row가 생성된다.
    • 다(N)를 조인하면 이게 기준이 되어 버린다. 하지만 기준은 문의글이라 문제가 발생한다.
  • 이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다. 최악의 경우 장애로 이어질 수 있다.

그렇다면 페이징과 컬렉션 엔티티를 함께 조회하라면 어떻게 해야할까?

  • 먼저, ToOne(OneToOne, ManyToOne) 관계를 모두 페치조인한다. ..ToOne관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.

  • 컬렉션은 지연 로딩으로 조회한다.

  • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize를 적용한다.

    • hibernate.default_batch_fetch_size : 글로벌 설정
    • @BatchSize : 개별 최적화
      이 옵션을 사용하면 컬렉션이나 프록시 객체를 한꺼번에 설정한 size만큼 IN쿼리로 조회한다.

default_batch_fetch_size의 크기는 적당한 사이즈를 골라야 하는데 100~1000 사이를 선택하는 것을 권장합니다. 이 전략을 SQL IN 절을 사용하는데 데이터베이스에 따라 IN절 파라미터를 1000으로 제한하기도 한다. 1000으로 한번에 1000개를 DB에서 애플리케이션으로 불러오므로 DB에 순간 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다. 1000으로 설정하면 성능상 가장 좋지만 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는지로 결정하면 좋다.

여기까지 하고 또 한 작업을 해야합니다. countQuery를 처리해야 합니다. 이거는 컬렉션 페이징이 아니라 Fetch Join을 사용할 경우 페이징 처리입니다. 이렇게 해야 하는 이유는 Fetch Join은 전부 가져오는 쿼리지 Spring Data Jpa에서 지원하는 Page<>에서 날려주는 count 쿼리가 아니라 문제가 발생합니다. 페이징을 처리하기 위해서 전체 페이지를 알아야 하기 때문입니다. 그래서 따로 count 쿼리를 날려주는 겁니다.

서비스

package com.example.shopping.service.board;

import com.example.shopping.domain.Item.ItemDTO;
import com.example.shopping.domain.board.BoardDTO;
import com.example.shopping.domain.board.BoardSecret;
import com.example.shopping.domain.board.CreateBoardDTO;
import com.example.shopping.domain.board.ReplyStatus;
import com.example.shopping.entity.board.BoardEntity;
import com.example.shopping.entity.item.ItemEntity;
import com.example.shopping.entity.member.MemberEntity;
import com.example.shopping.repository.board.BoardRepository;
import com.example.shopping.repository.item.ItemRepository;
import com.example.shopping.repository.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.EntityNotFoundException;
import java.util.Collection;
/*
 *   writer : 유요한
 *   work :
 *          게시글 서비스
 *          - 게시글의 등록, 수정, 삭제, 그리고 작성자의 문의글과 특정 상품에 해당하는 문의글을 확인하는 기능이 있습니다.
 *          이렇게 인터페이스를 만들고 상속해주는 방식을 선택한 이유는
 *          메소드에 의존하지 않고 필요한 기능만 사용할 수 있게 하고 가독성과 유지보수성을 높이기 위해서 입니다.
 *   date : 2024/02/07
 * */
@Service
@RequiredArgsConstructor
@Transactional
@Log4j2
public class BoardServiceImpl implements BoardService {
    private final ItemRepository itemRepository;
    private final MemberRepository memberRepository;
    private final BoardRepository boardRepository;

    // 문의 등록 메소드
    @Override
    public ResponseEntity<?> saveBoard(Long itemId,
                                       CreateBoardDTO boardDTO,
                                       String memberEmail) throws Exception {
        try {
            // 회원 조회
            MemberEntity findUser = memberRepository.findByEmail(memberEmail);
            log.info("user : " + findUser);
            log.info("닉네임 : " + findUser.getNickName());
            // 상품 조회
            ItemEntity findItem = itemRepository.findById(itemId)
                    .orElseThrow(EntityNotFoundException::new);
            log.info("상품 : " + ItemDTO.toItemDTO(findItem));

            if (findUser.getNickName() != null) {
                // 작성할 문의글(제목, 내용), 유저 정보, 해당 상품의 정보를 넘겨준다.
                BoardEntity boardEntity =
                        BoardEntity.createBoard(boardDTO, findUser, findItem);
                BoardEntity saveBoard = boardRepository.save(boardEntity);
                BoardDTO returnBoard = BoardDTO.toBoardDTO(saveBoard);
                log.info("게시글 : " + returnBoard);

                return ResponseEntity.ok().body(returnBoard);
            } else {
                return ResponseEntity.status(HttpStatus.NOT_FOUND).body("회원이 없습니다.");
            }
        } catch (NullPointerException e) {
            e.printStackTrace();
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }

    // 문의 삭제
    @Override
    public String removeBoard(Long boardId, UserDetails userDetails) {
        try {
            String memberEmail = userDetails.getUsername();
            log.info("유저 : " + memberEmail);

            // 권한 가져오기
            Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();

            // 게시글 조회
            BoardEntity findBoard = boardRepository.findByBoardId(boardId)
                    .orElseThrow(EntityNotFoundException::new);
            // 유저 조회
            MemberEntity findUser = memberRepository.findByEmail(memberEmail);
            boolean equalsEmail = findUser.getEmail().equals(findBoard.getMember().getEmail());
            String authority = authorities.iterator().next().getAuthority();
            log.info("권한 : " + authority);

            // 일치하다면 내 글이 맞으므로 삭제할 수 있다.
            // 일치하거나 권한이 ADMIN인 경우 삭제
            if (equalsEmail || authority.equals("ADMIN") || authority.equals("ROLE_ADMIN")) {
                // 게시글 삭제
                boardRepository.deleteById(findBoard.getBoardId());
                return "게시글을 삭제했습니다.";
            } else {
                return "삭제할 수 없습니다.";
            }
        }catch (Exception e) {
            return e.getMessage();
        }
    }

    // 문의글을 상세 보기
    // 내 글이 아닌 경우 읽을 수 없다.
    // 상품 안에 있는 문의글은 클릭해서 들어가야 한다.
    @Transactional(readOnly = true)
    @Override
    public ResponseEntity<?> getBoard(Long boardId, String memberEmail) {
       try {
           // 회원 조회
           MemberEntity findUser = memberRepository.findByEmail(memberEmail);
           // 문의글 조회
           BoardEntity findBoard = boardRepository.findByBoardId(boardId)
                   .orElseThrow(EntityNotFoundException::new);

           // 문의글을 작성할 때 등록된 이메일이 받아온 이메일이 맞아야 true
           if (findUser.getEmail().equals(findBoard.getMember().getEmail())) {
               BoardDTO boardDTO = BoardDTO.toBoardDTO(findBoard);
               return ResponseEntity.ok().body(boardDTO);
           } else {
               return ResponseEntity.badRequest().body("해당 유저의 문의글이 아닙니다.");
           }
       } catch (Exception e) {
           return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
       }
    }

    // 문의 수정
    @Override
    public ResponseEntity<?> updateBoard(Long boardId,
                                         CreateBoardDTO boardDTO,
                                         String memberEmail) {
        try {
            // 게시글 조회
            BoardEntity findBoard = boardRepository.findByBoardId(boardId)
                    .orElseThrow(EntityNotFoundException::new);
            log.info("게시글 닉네임 : " + findBoard.getMember().getNickName());
            log.info("게시글 댓글 확인 : " + findBoard.getCommentEntityList());
            // 유저 조회
            MemberEntity findUser = memberRepository.findByEmail(memberEmail);
            log.info("유저 : " + findUser.getEmail());

            // 받아온 유저를 조회하고 그 유저 정보와 게시글에 담긴 유저가 일치하는지
            boolean equalsEmail = findBoard.getMember().getEmail().equals(memberEmail);
            log.info("equalsEmail : " + equalsEmail);
            if(equalsEmail) {
                // 수정할 내용, 유저정보, 게시글을 작성할 때 받은 상품의 정보를 넘겨준다.
                findBoard.updateBoard(boardDTO);
                log.info("findBoard : " + findBoard);
                BoardEntity updateBoard = boardRepository.save(findBoard);
                log.info("updateBoard : " + updateBoard);
                BoardDTO returnBoard = BoardDTO.toBoardDTO(updateBoard);
                log.info("게시글 : " + returnBoard);
                return ResponseEntity.ok().body(returnBoard);
            } else {
                return ResponseEntity.badRequest().body("일치하지 않습니다.");
            }
        } catch (Exception e) {
            log.error(e.getMessage());
            return ResponseEntity.badRequest().body("수정하는데 실패했습니다. : " + e.getMessage());
        }
    }

    // 작성자의 문의글 보기
    @Transactional(readOnly = true)
    @Override
    public Page<BoardDTO> getMyBoards(String memberEmail, Pageable pageable, String searchKeyword) {

        MemberEntity findUser = memberRepository.findByEmail(memberEmail);

        // 작성자의 문의글을 조회해온다.
        Page<BoardEntity> findAllBoards =
                boardRepository.findByMemberEmailAndTitleContaining(memberEmail, pageable, searchKeyword);
        // 댓글이 있으면 답변완료, 없으면 미완료
        findAllBoards.forEach(BoardEntity::replyCheck);

        // 해당 게시글을 만들때 id와 조회한 id를 체크
        // 그리고 맞다면 읽을 권한주고 없으면 잠가주기
        findAllBoards.forEach(board -> {
            if(board.getMember().getMemberId().equals(findUser.getMemberId())) {
                board.changeSecret(BoardSecret.UN_LOCK);
            } else {
                board.changeSecret(BoardSecret.LOCK);
            }
        });
        return findAllBoards.map(BoardDTO::toBoardDTO);
    }

    // 상품에 대한 문의글 보기
    @Transactional(readOnly = true)
    @Override
    public Page<BoardDTO> getBoards(Pageable pageable,
                                    Long itemId,
                                    String email) {

        // 회원 조회
        MemberEntity findUser = memberRepository.findByEmail(email);
        log.info("유저 : " + findUser);

        // 상품 조회
        ItemEntity findItem = itemRepository.findById(itemId)
                .orElseThrow(EntityNotFoundException::new);
        log.info("상품 : " + findItem);

        // 조회해올 게시글을 넣을 곳
        Page<BoardEntity> findAllBoards = boardRepository.findAllByItemItemId(itemId, pageable);
        // 댓글이 있으면 답변완료, 없으면 미완료
        // 댓글이 존재하는지 아닌지 체크할 수 있게 상태를 바꿔줍니다.
        findAllBoards.forEach(BoardEntity::replyCheck);

        for (BoardEntity boardEntity : findAllBoards) {
            // 파라미터로 받아온 이메일이 있다면
            if (email != null) {
                // 해당 게시글을 만들때 이메일과 조회한 이메일을 체크
                // 그리고 맞다면 읽을 권한주고 없으면 잠가주기
                if (boardEntity.getMember().getEmail().equals(findUser.getEmail())) {
                    boardEntity.changeSecret(BoardSecret.UN_LOCK);
                } else {
                    boardEntity.changeSecret(BoardSecret.LOCK);
                }
            } else {
                boardEntity.changeSecret(BoardSecret.LOCK);
            }
        }
        log.info("조회된 게시글 수 : {}", findAllBoards.getTotalElements());
        log.info("조회된 게시글 : {}", findAllBoards);

        return findAllBoards.map(BoardDTO::toBoardDTO);
    }
}

댓글 등록

여기서 댓글을 생성할 때 문의글 엔티티안에 있는 댓글 리스트안에 양방향 연관관계의 cascade = CascadeType.ALL으로 인해서 자동으로 들어가 준다. orphanRemoval = true고아객체를 삭제해주는 것인데 여기서는 문의글을 삭제하면 댓글은 고아객체로써 삭제가 되는 것입니다.

댓글 수정

댓글 삭제

왜 setter을 사용하지 않고 Builder 패턴을 사용하나?

setter가 빠지고 @Builder 패턴을 사용했는데 setter을 사용하면 해당 클래스의 인스턴스 값들이 언제 어디서 변해야하는지 코드상으로 명확하게 구분할 수가 없어, 차후 기능 변경 시 매우 복잡해집니다.

그래서 Entity 클래스에서는 절대 setter 메소드를 만들지 않고 해당 필드의 값 변경이 필요하면 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가해야만 합니다.

그러면 setter 없이 어떻게 값을 채워 DB에 삽입할 수 있을까?

기본적인 구조는 생성자를 통해 최종값을 채운 후 DB에 삽입하는 것이며, 값 변경이 필요한 경우 해당 이벤트에 맞는 public 메소드를 호출하여 변경하는 것을 전제로 합니다.

생성자를 통해 값을 채우는 것을 @Builder를 사용할 수 있습니다.
장점
1. 가독성과 간결성 향상
@Builder를 사용하면 객체를 생성하는 데 필요한 코드가 매우 간결해집니다. 생성자에 비해 훨씬 더 가독성이 높아지며, 필요한 필드만 설정할 수 있어 코드가 깔끔해집니다.

  1. 불변성 (Immutability) 유지
    @Builder를 사용하면 생성된 객체가 불변성을 유지할 수 있습니다. 빌더 패턴은 객체를 생성한 후 필요한 필드만 수정할 수 있게 해주기 때문에 객체의 상태가 불변성을 유지할 수 있습니다.
  1. 필드 이름을 통한 명시적인 설정
    빌더 패턴을 사용하면 메서드 체인을 통해 필드를 명시적으로 설정할 수 있습니다. 이는 가독성을 높이고, 어떤 필드에 어떤 값이 들어가는지 명확하게 확인할 수 있습니다.

@ExceptionHandler

발생하는 예외의 타입별로 흐름을 나눠줄 때 사용하는 매핑방식

댓글

컨트롤러

package com.example.shopping.controller.comment;

import com.example.shopping.domain.comment.UpdateCommentDTO;
import com.example.shopping.service.comment.CommentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;


/*
 *   writer : YuYoHan
 *   work :
 *          댓글 작성, 삭제, 수정하는 기능입니다.
 *   date : 2024/01/22
 * */
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/{itemId}/boards/{boardId}/comments")
@Log4j2
@Tag(name = "comment", description = "댓글 API")
public class CommentController {
    private final CommentService commentService;

    // 댓글 작성
    @PostMapping("")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    @Tag(name = "comment")
    @Operation(summary = "댓글 등록", description = "댓글을 등록하는 API입니다.")
    public ResponseEntity<?> saveComment(@PathVariable Long boardId,
                                         @RequestBody UpdateCommentDTO commentDTO,
                                         @AuthenticationPrincipal UserDetails userDetails) {
       try {
           String email = userDetails.getUsername();
           log.info("email : " + email);
           ResponseEntity<?> responseComment = commentService.save(boardId, commentDTO, email);
           return ResponseEntity.ok().body(responseComment);
       } catch (Exception e) {
           return ResponseEntity.badRequest().body(e.getMessage());
       }
    }

    // 댓글 삭제
    @DeleteMapping("/{commentId}")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    @Tag(name = "comment")
    @Operation(summary = "댓글 삭제", description = "댓글을 삭제하는 API입니다.")
    public ResponseEntity<?> removeComment(@PathVariable Long boardId,
                                @PathVariable Long commentId,
                                @AuthenticationPrincipal UserDetails userDetails) {
        try {
            String responseComment = commentService.remove(boardId, commentId, userDetails);
            return ResponseEntity.ok().body(responseComment);
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
        }
    }

    // 댓글 수정
    @PutMapping("/{commentId}")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    @Tag(name = "comment")
    @Operation(summary = "댓글 수정", description = "댓글을 수정하는 API입니다.")
    public ResponseEntity<?> updateComment(@PathVariable Long boardId,
                                           @PathVariable Long commentId,
                                           @RequestBody UpdateCommentDTO commentDTO,
                                           @AuthenticationPrincipal UserDetails userDetails) {
       try {
           String email = userDetails.getUsername();
           log.info("email : " + email);
           ResponseEntity<?> update = commentService.update(boardId, commentId, commentDTO, email);
           return update;
       } catch (Exception e) {
           return ResponseEntity.badRequest().body(e.getMessage());
       }
    }
}

DTO

엔티티

package com.example.shopping.entity.comment;

import com.example.shopping.domain.comment.CommentDTO;
import com.example.shopping.domain.comment.UpdateCommentDTO;
import com.example.shopping.entity.Base.BaseEntity;
import com.example.shopping.entity.board.BoardEntity;
import com.example.shopping.entity.member.MemberEntity;
import lombok.*;

import javax.persistence.*;
/*
 *   writer : 유요한
 *   work :
 *          주소에 대한 ResponseDTO
 *   date : 2024/02/07
 * */
@Entity(name = "comment")
@Table
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(exclude = {"member", "board"})
public class CommentEntity extends BaseEntity {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "comment_id")
    private Long commentId;

    private String comment;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private MemberEntity member;

    // 양방향으로 연관관계를 맺을 때 한 곳에서만
    // LAZY를 해도 반대쪽에서도 적용이 되므로
    // 그곳에서는 추가할 필요가 없다.
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "board_id")
    private BoardEntity board;

    @Builder
    public CommentEntity(Long commentId,
                         String comment,
                         MemberEntity member,
                         BoardEntity board) {
        this.commentId = commentId;
        this.comment = comment;
        this.member = member;
        this.board = board;
    }

    // 댓글 DTO를 엔티티로 변환
    public static CommentEntity toCommentEntity(CommentDTO commentDTO,
                                                MemberEntity member,
                                                BoardEntity board) {
        return CommentEntity.builder()
                .commentId(commentDTO.getCommentId())
                .comment(commentDTO.getComment())
                .member(member)
                .board(board)
                .build();
    }

    // 생성
    public static CommentEntity createComment(UpdateCommentDTO commentDTO,
                                              MemberEntity member,
                                              BoardEntity board) {
        return CommentEntity.builder()
                .comment(commentDTO.getComment())
                .member(member)
                .board(board)
                .build();
    }

    // 수정
    public void updateComment(UpdateCommentDTO commentDTO) {
        this.comment = commentDTO.getComment() != null ? commentDTO.getComment() : this.comment;
    }
}

레포지토리

서비스

package com.example.shopping.service.comment;

import com.example.shopping.domain.comment.CommentDTO;
import com.example.shopping.domain.comment.UpdateCommentDTO;
import com.example.shopping.entity.board.BoardEntity;
import com.example.shopping.entity.comment.CommentEntity;
import com.example.shopping.entity.member.MemberEntity;
import com.example.shopping.repository.board.BoardRepository;
import com.example.shopping.repository.comment.CommentRepository;
import com.example.shopping.repository.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

/*
 *   writer : 유요한
 *   work :
 *          댓글 서비스
 *          - 댓글의 생성, 삭제, 업데이트 기능이 있습니다.
 *          이렇게 인터페이스를 만들고 상속해주는 방식을 선택한 이유는
 *          메소드에 의존하지 않고 필요한 기능만 사용할 수 있게 하고 가독성과 유지보수성을 높이기 위해서 입니다.
 *   date : 2024/02/07
 * */
@Service
@RequiredArgsConstructor
@Log4j2
@Transactional
public class CommentServiceImpl implements CommentService{
    private final CommentRepository commentRepository;
    private final MemberRepository memberRepository;
    private final BoardRepository boardRepository;

    // 댓글 등록
    @Override
    public ResponseEntity<?> save(Long boardId,
                                  UpdateCommentDTO commentDTO,
                                  String memberEmail) {
        try {
            // 회원 조회
            MemberEntity findUser = memberRepository.findByEmail(memberEmail);
            log.info("유저 : " + findUser);
            // 게시글 조회
            BoardEntity findBoard = boardRepository.findById(boardId)
                    .orElseThrow(EntityNotFoundException::new);
            log.info("게시글 : {}", findBoard);

            if(findUser != null) {
                // 댓글 생성
                CommentEntity comment = CommentEntity.createComment(commentDTO, findUser, findBoard);

                CommentEntity saveComment = commentRepository.save(comment);
                CommentDTO returnComment = CommentDTO.toCommentDTO(saveComment);
                log.info("댓글 : " + returnComment);

                return ResponseEntity.ok().body(returnComment);
            } else {
                return ResponseEntity.status(HttpStatus.NOT_FOUND).body("회원이 없습니다.");
            }
        } catch (Exception e) {
            return ResponseEntity.badRequest().build();
        }
    }

    // 댓글 삭제
    @Override
    public String remove(Long boardId, Long commentId, UserDetails userDetails) {
        try {
            String memberEmail = userDetails.getUsername();
            // userDetails에서 권한을 가져오기
            Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();

            // 게시물 조회
            BoardEntity findBoard = boardRepository.findById(boardId)
                    .orElseThrow(EntityNotFoundException::new);
            // 댓글 조회
            CommentEntity findComment = commentRepository.findById(commentId)
                    .orElseThrow(EntityNotFoundException::new);
            // 회원 조회
            MemberEntity findUser = memberRepository.findByEmail(memberEmail);

            // 댓글을 작성한 이메일과 유저 이메일이 맞는지 비교
            boolean equalsEmail = findUser.getEmail().equals(findComment.getMember().getEmail());
            // 댓글을 작성한 곳(게시글)의 id와 받아온 게시글 id를 비교
            boolean equalsId = findComment.getBoard().getBoardId().equals(findBoard.getBoardId());

            // 해당 유저인지 체크하고 댓글을 작성한 게시글 id인지 체크
            if(equalsEmail && equalsId) {
                commentRepository.deleteById(findComment.getCommentId());
                return "댓글을 삭제했습니다.";
            } else if(!authorities.isEmpty() && equalsId) {
                String role = authorities.iterator().next().getAuthority();
                log.info("권한 : " + role);
                if (role.equals("ADMIN") || role.equals("ROLE_ADMIN")) {
                    commentRepository.deleteById(findComment.getCommentId());
                    return "관리자가 댓글을 삭제하였습니다.";
                }
            }
            return "조건에 일치한 댓글이 아닙니다.";

        } catch (Exception e) {
            return "댓글을 삭제하는데 실패했습니다.";
        }
    }


    // 댓글 수정
    @Override
    public ResponseEntity<?> update(Long boardId,
                                    Long commentId,
                                    UpdateCommentDTO commentDTO,
                                    String memberEmail) {
        try {
            // 게시물 조회
            BoardEntity findBoard = boardRepository.findById(boardId)
                    .orElseThrow(EntityNotFoundException::new);
            // 댓글 조회
            CommentEntity findComment = commentRepository.findById(commentId)
                    .orElseThrow(EntityNotFoundException::new);
            // 회원 조회
            MemberEntity findUser = memberRepository.findByEmail(memberEmail);

            boolean equalsEmail = findUser.getEmail().equals(findComment.getMember().getEmail());
            boolean equalsId = findComment.getBoard().getBoardId().equals(findBoard.getBoardId());

            // 해당 유저인지 체크하고 댓글을 작성한 게시글 id인지 체크
            if(equalsEmail && equalsId) {
                // 댓글 수정
                findComment.updateComment(commentDTO);
                CommentEntity updateComment = commentRepository.save(findComment);
                log.info("댓글 : " + updateComment);
                CommentDTO returnComment = CommentDTO.toCommentDTO(updateComment);
                return ResponseEntity.ok().body(returnComment);
            } else {
                return ResponseEntity.badRequest().body("일치하지 않습니다.");
            }
        }catch (Exception e) {
            return ResponseEntity.badRequest().body("댓글 수정하는데 실패했습니다.");
        }
    }
}

컨테이너

DTO

엔티티

레포지토리

여기서는 QueryDsl을 커스텀 없이 일반적인 방법으로 사용하고 있습니다.

package com.example.shopping.repository.container;

import com.example.shopping.entity.Container.ItemContainerEntity;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.PathBuilder;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort.Order;
import org.springframework.data.support.PageableExecutionUtils;

import java.util.List;

import static com.example.shopping.entity.Container.QItemContainerEntity.*;
import static com.example.shopping.entity.item.QItemEntity.itemEntity;
import static com.querydsl.core.types.Order.ASC;
import static com.querydsl.core.types.Order.DESC;
/*
 *   writer : 유요한
 *   work :
 *          QueryDsl를 사용하기 위해서 구현한 클래스
 *          정렬은 동적으로 처리하고 있습니다.
 *   date : 2024/01/26
 * */
@RequiredArgsConstructor
public class ItemContainerRepositoryImpl implements ItemContainerRepositoryCustom{

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<ItemContainerEntity> findAllPage(Pageable pageable) {
        JPAQuery<ItemContainerEntity> query = queryFactory
                .selectFrom(itemContainerEntity)
                .join(itemContainerEntity.item, itemEntity).fetchJoin()
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize());

        for (Order order : pageable.getSort()) {
            // PathBuilder는 주어진 엔터티의 동적인 경로를 생성하는 데 사용됩니다.
            PathBuilder pathBuilder = new PathBuilder(
                    // 엔티티의 타입 정보를 얻어온다.
                    itemContainerEntity.getType(),
                    // 엔티티의 메타데이터를 얻어온다.
                    itemContainerEntity.getMetadata()
            );
            // Order 객체에서 정의된 속성에 해당하는 동적 경로를 얻어오게 됩니다.
            // 예를 들어, 만약 order.getProperty()가 "userName"이라면,
            // pathBuilder.get("userName")는 엔터티의 "userName" 속성에 대한 동적 경로를 반환하게 됩니다.
            // 이 동적 경로는 QueryDSL에서 사용되어 정렬 조건을 만들 때 활용됩니다.
            PathBuilder sort = pathBuilder.get(order.getProperty());

            query.orderBy(
                    new OrderSpecifier<>(
                            order.isDescending() ? DESC : ASC,
                            sort != null ? sort : itemContainerEntity.id
                    ));
        }

        List<ItemContainerEntity> result = query.fetch();
        JPAQuery<Long> count = queryFactory
                .select(itemContainerEntity.count())
                .from(itemContainerEntity);
        return PageableExecutionUtils.getPage(result, pageable, count::fetchOne);
    }
}

서비스

관리자

컨트롤러

package com.example.shopping.controller.admin;

import com.example.shopping.domain.Item.ItemDTO;
import com.example.shopping.domain.Item.ItemSearchCondition;
import com.example.shopping.domain.Item.ItemSellStatus;
import com.example.shopping.domain.admin.CodeDTO;
import com.example.shopping.domain.board.BoardDTO;
import com.example.shopping.domain.member.RequestMemberDTO;
import com.example.shopping.domain.order.OrderDTO;
import com.example.shopping.domain.order.OrderMainDTO;
import com.example.shopping.service.admin.AdminServiceImpl;
import com.example.shopping.service.board.BoardService;
import com.example.shopping.service.item.ItemService;
import com.example.shopping.service.item.ItemServiceImpl;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

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

/*
 *   writer : YuYoHan, 오현진
 *   work :
 *          관리자 기능입니다.
 *          관리자가 상품을 삭제, 게시글 삭제, 모든 문의글 볼 수 있고
 *          상품에 대한 문의글, 회원 문의글을 볼 수 있고 예약된 상품을
 *          구매확정시켜 줍니다. 그리고 상품을 조건에 따라 검색할 수 있습니다.
 *   date : 2024/01/10
 * */

@RestController
@Log4j2
@RequiredArgsConstructor
@RequestMapping("/api/v1/admins")
@Tag(name = "admin", description = "관리자 API입니다.")
public class AdminController {
    private final AdminServiceImpl adminService;
    private final ItemService itemService;
    private final BoardService boardService;


    // 게시글 삭제
    @DeleteMapping("/boards/{boardId}")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    @Tag(name = "admin")
    @Operation(summary = "게시글 삭제", description = "관리자가 게시글을 삭제하는 API입니다.")
    public ResponseEntity<?> removeBoard(@PathVariable Long boardId,
                             @AuthenticationPrincipal UserDetails userDetails) {
        try {
            String result = boardService.removeBoard(boardId, userDetails);
            return ResponseEntity.ok().body(result);
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }

    // 모든 문의글 보기
    @GetMapping("/boards")
    @Tag(name = "admin")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    @Operation(summary = "전체 문의글 보기", description = "모든 문의글을 보여주는 API입니다.")
    public ResponseEntity<?> getBoards(
            // SecuritConfig에 Page 설정을 한 페이지에 10개 보여주도록
            // 설정을 해서 여기서는 할 필요가 없다.
            @PageableDefault(sort = "boardId", direction = Sort.Direction.DESC)
            Pageable pageable,
            @RequestParam(value = "searchKeyword", required = false) String searchKeyword,
            @AuthenticationPrincipal UserDetails userDetails) {
        try {
            // 검색하지 않을 때는 모든 글을 보여준다.
            Page<BoardDTO> boards = adminService.getAllBoards(pageable, searchKeyword, userDetails);

            Map<String, Object> response = pageInfo(boards);

            return ResponseEntity.ok().body(response);
        } catch (Exception e) {
            return ResponseEntity.badRequest().build();
        }
    }

    private static Map<String, Object> pageInfo(Page<BoardDTO> boards) {
        Map<String, Object> response = new HashMap<>();
        // 현재 페이지의 아이템 목록
        response.put("items", boards.getContent());
        // 현재 페이지 번호
        response.put("nowPageNumber", boards.getNumber() + 1);
        // 전체 페이지 수
        response.put("totalPage", boards.getTotalPages());
        // 한 페이지에 출력되는 데이터 개수
        response.put("pageSize", boards.getSize());
        // 다음 페이지 존재 여부
        response.put("hasNextPage", boards.hasNext());
        // 이전 페이지 존재 여부
        response.put("hasPreviousPage", boards.hasPrevious());
        // 첫 번째 페이지 여부
        response.put("isFirstPage", boards.isFirst());
        // 마지막 페이지 여부
        response.put("isLastPage", boards.isLast());
        return response;
    }

    // 상품에 대한 문의글 보기
    @GetMapping("/{itemId}/boards")
    @Tag(name = "admin")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    @Operation(summary = "상품의 전체 문의글 보기", description = "상품의 모든 문의글을 보여주는 API입니다.")
    public ResponseEntity<?> getItemBoards(
            @PageableDefault(sort = "boardId", direction = Sort.Direction.DESC)
            Pageable pageable,
            @PathVariable Long itemId,
            @AuthenticationPrincipal UserDetails userDetails
    ) {
        try {
            Page<BoardDTO> itemBoards = adminService.getItemBoards(pageable, itemId, userDetails);
            Map<String, Object> response = pageInfo(itemBoards);

            return ResponseEntity.ok().body(response);
        }catch (Exception e) {
            return ResponseEntity.badRequest().build();
        }
    }

    // 회원 문의글 보기
    @GetMapping("/boards/{nickName}")
    @Tag(name = "admin")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    @Operation(summary = "전체 문의글 보기", description = "모든 문의글을 보여주는 API입니다.")
    public ResponseEntity<?> getUserBoards(
            // SecuritConfig에 Page 설정을 한 페이지에 10개 보여주도록
            // 설정을 해서 여기서는 할 필요가 없다.
            @PageableDefault(sort = "boardId", direction = Sort.Direction.DESC)
            Pageable pageable,
            String searchKeyword,
            @PathVariable(name = "nickName") String nickName,
            @AuthenticationPrincipal UserDetails userDetails) {
        try {
            // 검색하지 않을 때는 모든 글을 보여준다.
            Page<BoardDTO> boards = adminService.getBoardsByNiickName(userDetails,pageable,nickName,searchKeyword);
            Map<String, Object> response = pageInfo(boards);

            return ResponseEntity.ok().body(response);
        } catch (Exception e) {
            return ResponseEntity.badRequest().build();
        }
    }

    // 문의글 확인합니다.
    @GetMapping("/{boardId}")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    @Tag(name = "admin")
    @Operation(summary = "문의글 상세 보기", description = "상품에 대한 문의를 상세히 봅니다.")
    public ResponseEntity<?> getBoard(@PathVariable Long boardId,
                                      @AuthenticationPrincipal UserDetails userDetails) {
        try {
            String email = userDetails.getUsername();
            log.info("email : " + email);
            ResponseEntity<?> board = adminService.getBoard(boardId, userDetails);
            return ResponseEntity.ok().body(board);
        } catch (EntityNotFoundException e) {
            return ResponseEntity.notFound().build();
        }
    }

    // 관리자가 상품을 구매확정하는 API입니다.
    @PostMapping(value = "/orderItem")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    @Tag(name = "admin")
    @Operation(summary = "상품주문", description = "관리자가 상품을 최종 구매확정하는 API입니다.")
    public ResponseEntity<?> order(@RequestBody List<OrderMainDTO> orders, BindingResult result
            , @AuthenticationPrincipal UserDetails userDetails
    ) {
        OrderDTO orderItem;
        try {
            if (result.hasErrors()) {
                log.error("bindingResult error : " + result.hasErrors());
                return ResponseEntity.badRequest().body(result.getClass().getSimpleName());
            }

            String email = userDetails.getUsername();
            orderItem = adminService.orderItem(orders, email);

        } catch (Exception e) {
            return new ResponseEntity<String>(e.getMessage(), HttpStatus.BAD_REQUEST);
        }

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

    // 관리자 기준 상품 조건으로 보기
    @GetMapping("/search")
    @Tag(name = "admin")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    @Operation(summary = "상품 전체 보기(조건)", description = "상품을 조건대로 전체 볼 수 있는 API")
    public ResponseEntity<?> searchItemsConditions(Pageable pageable,
                                                   ItemSearchCondition condition
    ){

        Page<ItemDTO> items = null;

        try{
            items = itemService.searchItemsConditions(pageable, condition);
            Map<String, Object> response = new HashMap<>();
            // 현재 페이지의 아이템 목록
            response.put("items", items.getContent());
            // 현재 페이지 번호
            response.put("nowPageNumber", items.getNumber()+1);
            // 전체 페이지 수
            response.put("totalPage", items.getTotalPages());
            // 한 페이지에 출력되는 데이터 개수
            response.put("pageSize", items.getSize());
            // 다음 페이지 존재 여부
            response.put("hasNextPage", items.hasNext());
            // 이전 페이지 존재 여부
            response.put("hasPreviousPage", items.hasPrevious());
            // 첫 번째 페이지 여부
            response.put("isFirstPage", items.isFirst());
            // 마지막 페이지 여부
            response.put("isLastPage", items.isLast());

            return ResponseEntity.ok().body(response);
        }
        catch (Exception e){
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }

    // 관리자 회원가입
    @PostMapping("")
    @Tag(name = "admin")
    @Operation(summary = "관리자 회원가입", description = "관리자 회원가입 API")
    public ResponseEntity<?> joinAdmin(@Validated @RequestBody RequestMemberDTO admin,
                                       BindingResult result) {
        try {
            // 입력값 검증 예외가 발생하면 예외 메시지를 응답한다.
            if (result.hasErrors()) {
                log.info("BindingResult error : " + result.hasErrors());
                return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result.getClass().getSimpleName());
            }

            ResponseEntity<?> adminSignUp = adminService.adminSignUp(admin);
            log.info("결과 : " + adminSignUp);
            return ResponseEntity.ok().body(adminSignUp);
        }catch (Exception e) {
            log.error("예외 : " + e.getMessage());
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
        }
    }
    // 관리자 회원가입시 이메일 인증
    @PostMapping("/mails")
    @Tag(name = "admin")
    @Operation(summary = "관리자 이메일 인증 신청", description = "관리자 이메일 인증 신청 API")
    public String emailAuthentication(@RequestParam("email") String email) {
        try {
            String code = adminService.sendMail(email);
            log.info("사용자에게 발송한 인증코드 ==> " + code);
            return "이메일 인증코드가 발급완료";
        } catch (Exception e) {
            log.error("예외 : " + e.getMessage());
            return e.getMessage();
        }
    }
    // 인증 코드 검증
    @PostMapping("/verifications")
    @Tag(name = "admin")
    @Operation(summary = "관리자 이메일 인증 검증", description = "관리자 이메일 인증 검증 API")
    public ResponseEntity<?> verificationEmail(@RequestBody CodeDTO code) {
        log.info("코드 : " + code);
        String result = adminService.verifyCode(code.getCode());
        log.info("result : " + result);
        return ResponseEntity.ok().body(result);
    }
}

DTO

package com.example.shopping.domain.admin;

import com.example.shopping.domain.member.AddressDTO;
import com.example.shopping.domain.member.ResponseMemberDTO;
import com.example.shopping.domain.member.Role;
import com.example.shopping.entity.member.MemberEntity;
import io.swagger.v3.oas.annotations.media.Schema;
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;
/*
 *   writer : 유요한
 *   work :
 *          관리자 정보만 Response하고 위한 용도
 *   date : 2024/01/10
 * */
@NoArgsConstructor
@ToString
@Getter
public class ResponseAdminDTO {
    @Schema(description = "유저 번호", example = "1", required = true)
    private Long memberId;

    @Schema(description = "이메일", example = "abc@gmail.com", required = true)
    @NotNull(message = "이메일은 필수 입력입니다.")
    @Pattern(regexp = "^(?:\\w+\\.?)*\\w+@(?:\\w+\\.)+\\w+$", message = "이메일 형식이 올바르지 않습니다.")
    @Email(message = "이메일 형식에 맞지 않습니다.")
    private String email;

    @Schema(description = "회원 이름")
    @NotNull(message = "이름은 필수 입력입니다.")
    private String memberName;

    @Schema(description = "닉네임")
    @NotNull(message = "닉네임은 필수 입력입니다.")
    private String nickName;

    @Schema(description = "회원 비밀번호")
    private String memberPw;

    @Schema(description = "회원 권한")
    @NotNull(message = "권한정보는 필수 입력입니다.")
    private Role memberRole;

    @Schema(description = "회원 주소")
    private AddressDTO memberAddress;

    @Builder
    public ResponseAdminDTO(Long memberId,
                             String email,
                             String memberName,
                             String nickName,
                             String memberPw,
                             Role memberRole,
                             AddressDTO memberAddress) {
        this.memberId = memberId;
        this.email = email;
        this.memberName = memberName;
        this.nickName = nickName;
        this.memberPw = memberPw;
        this.memberRole = memberRole;
        this.memberAddress = memberAddress;
    }
    // 엔티티를 DTO로 반환
    public static ResponseAdminDTO toMemberDTO(MemberEntity member) {
        return ResponseAdminDTO.builder()
                .memberId(member.getMemberId())
                .email(member.getEmail())
                .memberPw(member.getMemberPw())
                .nickName(member.getNickName())
                .memberName(member.getMemberName())
                .memberRole(member.getMemberRole())
                .memberAddress(AddressDTO.builder()
                        .memberAddr(member.getAddress() == null
                                ? null : member.getAddress().getMemberAddr())
                        .memberAddrDetail(member.getAddress() == null
                                ? null : member.getAddress().getMemberAddrDetail())
                        .memberZipCode(member.getAddress() == null
                                ? null : member.getAddress().getMemberZipCode())
                        .build()).build();
    }
}

서비스

package com.example.shopping.service.admin;

import com.example.shopping.domain.Item.ItemSellStatus;
import com.example.shopping.domain.admin.ResponseAdminDTO;
import com.example.shopping.domain.board.BoardDTO;
import com.example.shopping.domain.board.BoardSecret;
import com.example.shopping.domain.cart.CartItemDTO;
import com.example.shopping.domain.cart.CartStatus;
import com.example.shopping.domain.member.RequestMemberDTO;
import com.example.shopping.domain.order.OrderDTO;
import com.example.shopping.domain.order.OrderItemDTO;
import com.example.shopping.domain.order.OrderMainDTO;
import com.example.shopping.entity.board.BoardEntity;
import com.example.shopping.entity.item.ItemEntity;
import com.example.shopping.entity.item.ItemImgEntity;
import com.example.shopping.entity.member.MemberEntity;
import com.example.shopping.entity.order.OrderItemEntity;
import com.example.shopping.exception.item.ItemException;
import com.example.shopping.exception.member.UserException;
import com.example.shopping.exception.service.OutOfStockException;
import com.example.shopping.repository.board.BoardRepository;
import com.example.shopping.repository.cart.CartItemRepository;
import com.example.shopping.repository.cart.CartRepository;
import com.example.shopping.repository.item.ItemImgRepository;
import com.example.shopping.repository.item.ItemRepository;
import com.example.shopping.repository.member.MemberRepository;
import com.example.shopping.repository.order.OrderItemRepository;
import com.example.shopping.repository.order.OrderRepository;
import com.example.shopping.service.member.MemberService;
import com.example.shopping.service.s3.S3ItemImgUploaderService;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.mail.Message;
import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;
import javax.persistence.EntityNotFoundException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Random;

/*
 *   writer : 유요한, 오현진
 *   work :
 *          관리자 서비스
 *          - 게시글 삭제, 상품 삭제, 구매 확정 기능과 모든 문의글을 보고 특정 상품에 대한 문의글을 보는 기능이 있습니다.
 *          이렇게 인터페이스를 만들고 상속해주는 방식을 선택한 이유는
 *          메소드에 의존하지 않고 필요한 기능만 사용할 수 있게 하고 가독성과 유지보수성을 높이기 위해서 입니다.
 *   date : 2024/01/10
 * */
@Service
@Log4j2
@RequiredArgsConstructor
@Transactional
public class AdminServiceImpl implements AdminService {
    // 상품 관련
    private final ItemRepository itemRepository;
    private final ItemImgRepository itemImgRepository;

    // 유저 관련
    private final MemberRepository memberRepository;
    private final MemberService memberService;
    private final PasswordEncoder passwordEncoder;
    // MailConfig에서 등록해둔 Bean을 autowired하여 사용하기
    private final JavaMailSender emailSender;
    // 사용자가 메일로 받을 인증번호
    private String key;
    @Value("${naver.id}")
    private String id;
    // Instant 클래스는 특정 지점의 시간을 나타내기 위한 클래스입니다.
    // 코드 생성 시간을 나타내는 Instant 객체입니다.
    private Instant codeGenerationTime;
    // Duration 클래스는 두 시간 간의 차이를 나타내기 위한 클래스입니다.
    // Duration.ofMinutes(1)을 사용하여 1분으로 설정합니다.
    private Duration validityDuration = Duration.ofMinutes(1);

    // 주문 관련
    private final OrderRepository orderRepository;
    private final OrderItemRepository orderItemRepository;

    // 장바구니 관련
    private final CartRepository cartRepository;
    private final CartItemRepository cartItemRepository;

    private final S3ItemImgUploaderService s3ItemImgUploaderService;
    // 게시글 관련
    private final BoardRepository boardRepository;


    // 회원가입
    @Override
    public ResponseEntity<?> adminSignUp(RequestMemberDTO admin) {
        try {
            log.info("email : " + admin.getEmail());
            log.info("nickName : " + admin.getNickName());

            // 이메일 중복 체크
            if (!memberService.emailCheck(admin.getEmail())) {
                return ResponseEntity.badRequest().body("이미 존재하는 이메일이 있습니다.");
            }

            // 닉네임 중복 체크
            if (!memberService.nickNameCheck(admin.getNickName())) {
                return ResponseEntity.badRequest().body("이미 존재하는 닉네임이 있습니다.");
            }
            // 비밀번호 암호화
            String encodePw = passwordEncoder.encode(admin.getMemberPw());

            // 아이디가 없다면 DB에 등록해줍니다.
            MemberEntity adminId = MemberEntity.saveMember(admin, encodePw);
            log.info("admin in service : " + adminId);
            MemberEntity saveMember = memberRepository.save(adminId);

            ResponseAdminDTO coverMember = ResponseAdminDTO.toMemberDTO(saveMember);
            return ResponseEntity.ok().body(coverMember);
        } catch (Exception e) {
            log.error(e.getMessage());
            return ResponseEntity.badRequest().body("회원 가입중 오류가 발생했습니다.");
        }
    }

    // 메일 전송
    @Override
    public String sendMail(String email) throws Exception {
        // 랜덤 인증 코드 생성
        key = createKey();
        log.info("********생성된 랜덤 인증코드******** => " + key);
        // 메세지 생성
        MimeMessage message = createMessage(email);
        log.info("********생성된 메시지******** => " + message);
        try {
            // 메일로 보냄
            emailSender.send(message);
        } catch (Exception e) {
            throw new IllegalArgumentException();
        }
        // 메일로 사용자에게 보낸 인증코드를 서버로 반환! 인증코드 일치여부를 확인하기 위함
        return key;
    }

    // 랜덤 인증 코드 생성
    private String createKey() throws Exception {
        int length = 6;
        try {
            // SecureRandom.getInstanceStrong()을 호출하여 강력한 (strong) 알고리즘을 사용하는 SecureRandom 인스턴스를 가져옵니다.
            // 이는 예측하기 어려운 안전한 랜덤 값을 제공합니다.
            Random random = SecureRandom.getInstanceStrong();
            // StringBuilder는 가변적인 문자열을 효율적으로 다루기 위한 클래스입니다.
            // 여기서는 생성된 랜덤 값을 문자열로 변환하여 저장하기 위해 StringBuilder를 사용합니다.
            StringBuilder builder = new StringBuilder();
            for (int i = 0; i < length; i++) {
                builder.append(random.nextInt(10));
            }
            return builder.toString();
        } catch (NoSuchAlgorithmException e) {
            log.debug("MemberService.createCode() exception occur");
            throw new UserException(e.getMessage());
        }
    }

    // 메일 내용 작성
    private MimeMessage createMessage(String email) throws MessagingException {
        log.info("메일받을 사용자 : " + email);
        log.info("인증번호 : " + key);
        codeGenerationTime = Instant.now();
        log.info("********코드 생성 시간******** => " + codeGenerationTime);
        log.info("********유효 시간******** => " + validityDuration.toMinutes());

        MimeMessage message = emailSender.createMimeMessage();
        message.addRecipients(Message.RecipientType.TO, email);
        // 이메일 제목
        message.setSubject("관리자 회원가입 인증코드");
        String msgg = "";
        msgg += "<h1>안녕하세요</h1>";
        msgg += "<h1>저희는 BlueBucket 이커머스 플랫폼 입니다</h1>";
        msgg += "<br>";
        msgg += "<br>";
        msgg += "<div align='center' style='border:1px solid black'>";
        msgg += "<h3 style='color:blue'>회원가입 인증코드 입니다</h3>";
        msgg += "<div style='font-size:130%'>";
        msgg += "<strong>" + key + "</strong></div><br/>"; // 메일에 인증번호 ePw 넣기
        msgg += "<p>유효 시간: " + validityDuration.toMinutes() + "분 동안만 유효합니다.</p>";
        msgg += "</div>";
        // 메일 내용, charset타입, subtype
        message.setText(msgg, "utf-8", "html");
        // 보내는 사람의 이메일 주소, 보내는 사람 이름
        message.setFrom(id);
        log.info("********creatMessage 함수에서 생성된 msgg 메시지********" + msgg);
        log.info("********creatMessage 함수에서 생성된 리턴 메시지********" + message);

        return message;
    }

    // 사용자가 입력한 인증번호와 서버에서 생성한 인증번호를 비교하는 메서드
    @Override
    public String verifyCode(String code) {
        try {
            if (codeGenerationTime == null) {
                // 시간 정보가 없으면 유효하지 않음
                return "시간 정보가 없습니다.";
            }
            // 현재 시간과 코드 생성 시간의 차이 계산
            Duration elapsedDuration = Duration.between(codeGenerationTime, Instant.now());
            // 남은 시간 계산: 전체 유효 기간에서 경과된 시간을 뺀다
            long remainDuration = validityDuration.minus(elapsedDuration).getSeconds();

            // 시간이 0보다 높으면 즉, 유효기간이 지나지 않으면
            // 사용자가 입력한 인증번호와 서버에서 생성한 인증번호를 비교해서 맞다면 true
            if (remainDuration > 0) {
                if (code.equals(key)) {
                    return "인증 번호가 일치합니다.";
                }
            } else if (remainDuration < 0) {
                return "인증 번호의 유효시간이 지났습니다.";
            } else if (!code.equals(key)) {
                return "인증 번호가 일치하지 않습니다.";
            }
            return null;
        } catch (NullPointerException e) {
            // 사용자가 정수가 아닌 값을 입력한 경우
            return "유효하지 않는 인증 번호를 입력했습니다.";
        }
    }


    // 구매 확정 메소드
    public OrderDTO orderItem(List<OrderMainDTO> orders, String adminEmail) {

        String mbrEmail = orders.get(0).getItemReserver();
        MemberEntity orderMember = memberRepository.findByEmail(mbrEmail);
        Long memberId = orderMember.getMemberId();
        Long adminId = memberRepository.findByEmail(adminEmail).getMemberId();

        //구매하려는 상품템리스트
        List<OrderItemDTO> itemList = new ArrayList<>();
        //주문셋팅 DTO
        OrderDTO orderInfo = null;
        //최종 주문처리상품 DTO
        OrderDTO savedOrder;

        for (OrderMainDTO order : orders) {
            ItemEntity item = itemRepository.findByItemId(order.getItemId());

            if (item.getItemSellStatus() != ItemSellStatus.RESERVED) {
                //throw 예약된 물품이 아니라 판매 못함
                throw new ItemException("예약된 물품이 아니라 구매처리 할 수 없습니다.");
            }

            if (!item.getItemReserver().equals(orderMember.getEmail())) {
                //throw 구매자와 예약한사람이 달라 판매 못함
                throw new ItemException("예약자와 현재 구매하려는 사람이 달라 구매처리 할 수 없습니다.");
            }

            if (item.getStockNumber() < order.getCount() || item.getStockNumber() == 0) {
                throw new OutOfStockException(item.getItemName() + "의 재고가 부족합니다. 요청수량 : " + order.getCount() +
                        " 재고 : " + item.getStockNumber());
            }

            // 구매처리 하려는 아이템 셋팅
            OrderItemEntity orderItem =
                    OrderItemEntity.setOrderItem(
                            item, memberId, item.getItemSeller(), order.getCount());
            itemList.add(orderItem.toOrderItemDTO());

            orderInfo = OrderDTO.createOrder(adminId, memberId, itemList);
        }
        // 주문처리
        savedOrder = orderRepository.save(orderInfo);

        for (OrderItemDTO savedItem : savedOrder.getOrderItem()) {

            OrderItemDTO savedOrderItem = orderItemRepository.save(savedItem, savedOrder);

            // Member-point 추가
            MemberEntity member = memberRepository.findByEmail(orderMember.getEmail());
            member.addPoint(savedOrderItem.getItemPrice() * savedOrderItem.getItemAmount());
            memberRepository.save(member);

            // Item-status 변경
            ItemEntity updateItem = itemRepository.findById(savedItem.getItemId()).orElseThrow();
            updateItem.itemSell(savedItem.getItemAmount());
            itemRepository.save(updateItem);

            // Cart-status 변경
            Long cartId = cartRepository.findByMemberMemberId(orderMember.getMemberId()).getCartId();
            CartItemDTO cartItem = cartItemRepository.findByCartItemDTO(cartId, updateItem.getItemId());
            cartItem.updateCartStatus(CartStatus.PURCHASED);
            cartItemRepository.save(cartItem);

            //아이템 이미지 삭제처리
            List<ItemImgEntity> findImg = itemImgRepository.findByItemItemId(updateItem.getItemId());
            if(!findImg.isEmpty()) {
                for (ItemImgEntity img : findImg) {
                    String uploadFilePath = img.getUploadImgPath();
                    String uuidFileName = img.getUploadImgName();

                    // DB에서 이미지 삭제
                    itemImgRepository.deleteById(img.getItemImgId());
                    // S3에서 삭제
                    String result = s3ItemImgUploaderService.deleteFile(uploadFilePath, uuidFileName);
                    log.info(result);
                }
            }
        }
        return savedOrder;
    }

    // 모든 문의글 보기
    @Override
    @Transactional(readOnly = true)
    public Page<BoardDTO> getAllBoards(Pageable pageable,
                                       String searchKeyword,
                                       UserDetails userDetails) {
        // userDetails에서 권한을 가져오기
        Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();

        String email = userDetails.getUsername();
        MemberEntity findEmail = memberRepository.findByEmail(email);
        log.info("관리자 정보 : " + findEmail);

        Page<BoardEntity> allBoards;
        // 권한이 있는지 체크
        if (!authorities.isEmpty()) {
            String role = authorities.iterator().next().getAuthority();
            log.info("권한 : " + role);
            // 관리자 권한 체크
            if (role.equals("ADMIN") || role.equals("ROLE_ADMIN")) {
                // 키워드로 페이지 처리해서 검색
                allBoards = boardRepository.findByTitleContaining(pageable, searchKeyword);
                // 댓글이 존재하는지 아닌지 체크할 수 있게 상태를 바꿔줍니다.
                allBoards.forEach(BoardEntity::replyCheck);

                // 관리자라 모두 읽을 수 있으니 UN_LOCK
                allBoards.forEach(board -> board.changeSecret(BoardSecret.UN_LOCK));
                return allBoards.map(BoardDTO::toBoardDTO);
            }
        }
        return null;
    }

    // 관리자가 해당 유저의 모든 문의글 보기
    @Transactional(readOnly = true)
    @Override
    public Page<BoardDTO> getBoardsByNiickName(UserDetails userDetails,
                                               Pageable pageable,
                                               String nickName,
                                               String searchKeyword) {
        // userDetails에서 권한을 가져오기
        Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
        // 현재는 권한이 1개만 있는 것으로 가정
        if (!authorities.isEmpty()) {
            String role = authorities.iterator().next().getAuthority();
            // 존재하는 권한이 관리자인지 체크
            if (role.equals("ADMIN") || role.equals("ROLE_ADMIN")) {
                Page<BoardEntity> allByNickName = boardRepository.findByMemberNickNameAndTitleContaining(nickName, pageable, searchKeyword);
                // 댓글이 존재하는지 아닌지 체크할 수 있게 상태를 바꿔줍니다.
                allByNickName.forEach(BoardEntity::replyCheck);
                // 관리자라 모두 읽을 수 있으니 UN_LOCK
                allByNickName.forEach(board -> board.changeSecret(BoardSecret.UN_LOCK));
                return allByNickName.map(BoardDTO::toBoardDTO);
            }
        }
        return null;
    }

    // 문의글 상세 보기
    @Transactional(readOnly = true)
    @Override
    public ResponseEntity<BoardDTO> getBoard(Long boardId, UserDetails userDetails) {
        try {
            // userDetails에서 권한을 가져오기
            Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();

            // 문의글 조회
            BoardEntity findBoard = boardRepository.findByBoardId(boardId)
                    .orElseThrow(EntityNotFoundException::new);

            // 권한이 있는지 체크
            if (!authorities.isEmpty()) {
                String role = authorities.iterator().next().getAuthority();
                log.info("권한 : " + role);
                if (role.equals("ADMIN") || role.equals("ROLE_ADMIN")) {
                    BoardDTO returnBoard = BoardDTO.toBoardDTO(findBoard);
                    return ResponseEntity.ok().body(returnBoard);
                }
            }
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        } catch (EntityNotFoundException e) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
        }
    }

    // 상품에 대한 문의글 보기
    @Transactional(readOnly = true)
    @Override
    public Page<BoardDTO> getItemBoards(Pageable pageable,
                                    Long itemId,
                                    UserDetails userDetails) {

        // 권한 조회
        Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
        String email = userDetails.getUsername();
        log.info("관리자 정보 : " + email);

        // 상품 조회
        ItemEntity findItem = itemRepository.findById(itemId)
                .orElseThrow(EntityNotFoundException::new);
        log.info("상품 : " + findItem);

        Page<BoardEntity> allItemBoards;

        // 권한 체크
        if (!authorities.isEmpty()) {
            String authority = authorities.iterator().next().getAuthority();
            log.info("권한 : " + authority);
            if (authority.equals("ADMIN") || authority.equals("ROLE_ADMIN")) {
                allItemBoards = boardRepository.findAllByItemItemId(itemId, pageable);
                // 댓글이 존재하는지 아닌지 체크할 수 있게 상태를 바꿔줍니다.
                allItemBoards.forEach(BoardEntity::replyCheck);

                allItemBoards.forEach(board -> board.changeSecret(BoardSecret.UN_LOCK));
                return allItemBoards.map(BoardDTO::toBoardDTO);
            }
        }
        return null;
    }

}

장바구니 상품 예약

서비스

   //장바구니 상품 구매예약
    @Override
    public ResponseEntity<?> orderCart(List<CartOrderDTO> cartOrderList,
                                    String email) {
        try {
            MemberEntity member = memberRepository.findByEmail(email);
            CartEntity findCart = cartJpaRepository.findByMemberMemberId(member.getMemberId())
                    .orElseThrow(() -> new EntityNotFoundException("장바구니가 존재 하지 않습니다."));

            if (findCart.getMember().getMemberId().equals(member.getMemberId())) {
                List<Long> cartItemIds = cartOrderList.stream()
                        .map(CartOrderDTO::getCartItemId)
                        .collect(Collectors.toList());

                // DB에 JPQL로 직접 업데이트를 직접 처리하고 있습니다.
                // 예약 취소할 id를 받아서 조건에 맞는 데이터를 DB에서 전부 수정해줍니다.
                List<CartItemEntity> cartItems =
                        cartItemJpaRepository.updateCartItemsStatus(CartStatus.RESERVED, cartItemIds);

                for (CartItemEntity cartItem : cartItems) {
                    // 장바구니에서 상품을 예약하려고 하는데 상품 자체가 삭제되어 있으면 동작
                    if (cartItem.getItem() == null) {
                        throw new ItemException("예약 하려고 하는 상품이 판매자에 의해 삭제되었습니다.");
                    }

                    if (cartItem.getItem().getItemSellStatus().equals(ItemSellStatus.SOLD_OUT)) {
                        throw new OutOfStockException("상품이 판매 완료되었습니다.");
                    }

                    if (cartItem.getItem().getItemSellStatus().equals(ItemSellStatus.RESERVED)) {
                        throw new CartException("상품(" + cartItem.getItem().getItemId() + ", " +
                                cartItem.getItem().getItemName() + ")은 이미 예약되어 있습니다." +
                                "\n예약자 : " + cartItem.getItem().getItemReserver());
                    }

                    int itemNumber = cartItem.getItem().getStockNumber();
                    int orderCount = cartItem.getCount();
                    if (itemNumber < orderCount) {
                        throw new OutOfStockException("재고가 부족합니다. \n 요청 수량 : " + itemNumber +
                                "\n재고 : " + orderCount);
                    }
                    // 상품의 예약자와 예약 수량 수정
                    // for문을 돌면서 상태 변화를 추적하는데 이 상태 변화는 커밋전에는 반영되지 않고
                    // 트랜잭션이 커밋될 때 반영되서 하나의 쿼리로 모든 변경 사항이 DB에 적용됩니다.
                    cartItem.orderItem(member.getEmail());
                }
                List<CartItemDTO> cartItemDTOList = cartItems.stream()
                        .map(CartItemDTO::toDTO)
                        .collect(Collectors.toList());
                CartDTO cartDTO = CartDTO.toCartDTO(findCart);
                cartDTO.addList(cartItemDTOList);

                return ResponseEntity.ok().body(cartDTO);
            }
            throw new CartException("예약하려고 하는 장바구니 상품이 해당 회원의 장바구니 상품이 아닙니다.");
        } catch (NullPointerException | NoSuchElementException e) {
            throw new CartException("존재하지 않는 장바구니 상품id입니다.");
        } catch (Exception e) {
            throw new CartException("장바구니에서 상품을 예약하는데 실패하였습니다.\n" + e.getMessage());
        }
    }

    // 구매예약상품 취소
    @Override
    public String cancelCartOrder(List<CartOrderDTO> cartOrderList, String email) {
        try {
            MemberEntity findUser = memberRepository.findByEmail(email);
            CartEntity findCart = cartJpaRepository.findByMemberMemberId(findUser.getMemberId())
                    .orElseThrow(() -> new EntityNotFoundException("장바구니가 존재 하지 않습니다."));

            if (findCart.getMember().getMemberId().equals(findUser.getMemberId())) {
                List<Long> cartItemIds = cartOrderList.stream()
                        .map(CartOrderDTO::getCartItemId)
                        .collect(Collectors.toList());

                List<CartItemEntity> cartItems =
                        // DB에 JPQL로 직접 업데이트를 직접 처리하고 있습니다.
                        // 예약 취소할 id를 받아서 조건에 맞는 데이터를 DB에서 전부 수정해줍니다.
                        cartItemJpaRepository.updateCartItemsStatus(CartStatus.WAIT, cartItemIds);

                cartItems.forEach(cartItem -> {
                    // 장바구니에서 상품을 예약하려고 하는데 상품 자체가 삭제되어 있으면 동작
                    if(cartItem.getItem() == null) {
                        throw new ItemException("예약 하려고 하는 상품이 판매자에 의해 삭제되었습니다.");
                    }
                    cartItem.cancelOrderItem();
                });
                return "구매예약을 취소하였습니다.";
            }
            throw new CartException("예약하려고 하는 장바구니 상품이 해당 회원의 장바구니 상품이 아닙니다.");
        } catch (NullPointerException | NoSuchElementException e) {
            throw new CartException("예약취소하려고 하는 장바구니상품id가 해당 회원의 장바구니 상품이 아닙니다.");
        } catch (CartException e) {
            throw e;
        } catch (Exception e) {
            throw new CartException("구매예약 취소 작업이 실패하였습니다.\n" + e.getMessage());
        }
    }

레포지토리

벌크성 수정 쿼리

여기서 보면 @Modifying이 있는데 Spring Data Jpa에서 변경할 때 꼭 넣어줘야 합니다.

@Query 어노테이션을 통해 작성된 변경이 일어나는 쿼리(INSERT, DELETE, UPDATE )를 실행할 때 사용된다. @Modifying을 변경이 일어나는 쿼리와 함께 사용해야 JPA에서 변경 감지와 관련된 처리를 생략하고 더 효율적인 실행이 가능하다. 즉, JPA에서 벌크 연산은 단 건 데이터를 변경(더티 체킹)하는 것이 아닌, 여러 데이터에 변경 쿼리를 날리는 작업을 말한다.

@Modifying 애노테이션은 기본적으로 @Transactional과 함께 사용된다.
변경 작업은 트랜잭션 내에서 실행되어야 하며, 완료되지 않은 변경 작업이 여러 작업에 영향을 줄 수 있기 때문이다. 이를 통해 데이터베이스에 대한 변경 작업을 수행할 때 원자성(Atomicity), 일관성(Consistency), 독립성(Isolation), 지속성(Durability)을 보장할 수 있게 된다.

이걸 사용한 이유는 한번에 조건에 맞는 모든 데이터의 상태를 변경하기 위해서 입니다.

주의점

JPA 에서는 1차 캐시라는 기능이 있다. 1차 캐시를 간단하게 설명하면 영속성 컨텍스트에 있는 1차 캐시를 통해 엔티티를 캐싱하고, DB의 접근 횟수를 줄임으로써 성능 개선 한다.

그런데 @Modifying과 @Query 를 사용한 벌크 연산에서 1차 캐시와 관련하여 문제가 발생한다. JPA 에서 조회를 실행할 시에 1차 캐시를 확인해서 해당 엔티티가 1차 캐시에 존재한다면 DB에 접근하지 않고, 1차 캐시에 있는 엔티티를 반환한다. 하지만 벌크 연산은 1차 캐시를 포함한 영속성 컨텍스트를 무시하고 바로 Query를 실행하기 때문에 영속성 컨텍스트는 데이터 변경을 알 수가 없다. 즉, 벌크 연산 실행 시, 1차 캐시(영속성 컨텍스트)와 DB의 데이터 싱크가 맞지 않게 되는 것이다. (만약 벌크 쿼리를 실행하고 다시 해당 데이터를 조회하면 영속성 컨텍스트에 과거 값이 남아 문제가 발생 )

이 경우 변경된 데이터를 사용하기 전에 영속성 컨텍스트를 비워주는 작업이 필요한데, @Modifying의 clearAutomatically=true 속성을 사용해 변경 후 자동으로 영속성 컨텍스트를 초기화 할 수 있다. 해당 속성을 추가하게 되면, 조회를 실행할 때 1차캐시에 해당 엔티티가 존재하지 않기 때문에 DB 조회 쿼리를 실행하게 된다. ( 데이터 동기화 문제를 해결 )


테스트

package com.example.shopping.service.item;

import com.example.shopping.domain.Item.*;
import com.example.shopping.domain.container.ContainerDTO;
import com.example.shopping.domain.member.Role;
import com.example.shopping.entity.Container.ContainerEntity;
import com.example.shopping.entity.Container.ItemContainerEntity;
import com.example.shopping.entity.item.ItemEntity;
import com.example.shopping.entity.item.ItemImgEntity;
import com.example.shopping.entity.member.AddressEntity;
import com.example.shopping.entity.member.MemberEntity;
import com.example.shopping.exception.member.UserException;
import com.example.shopping.repository.container.ItemContainerRepository;
import com.example.shopping.repository.item.ItemImgRepository;
import com.example.shopping.repository.item.ItemQuerydslRepository;
import com.example.shopping.repository.item.ItemRepository;
import com.example.shopping.repository.member.MemberRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.*;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;

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

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
public class ItemServiceImplTest {

    @Mock
    private MemberRepository memberRepository;
    @Mock
    private ItemContainerRepository itemContainerRepository;
    @Mock
    private ItemRepository itemRepository;
    @Mock
    private ItemImgRepository itemImgRepository;
    @Mock
    private ItemQuerydslRepository itemQuerydslRepository;

    @InjectMocks
    private ItemServiceImpl itemService;


    ContainerEntity container = ContainerEntity.builder()
            .containerName("1지점")
            .containerAddr("서울시 고척동 130-44")
            .build();


    private MemberEntity createMember() {
        MemberEntity member = MemberEntity.builder()
                .memberId(1L)
                .memberPw("dudtjq8990!")
                .memberName("테스터")
                .memberRole(Role.ADMIN)
                .nickName("테스터")
                .email("test@test.com")
                .memberPoint(0)
                .provider(null)
                .providerId(null)
                .address(AddressEntity.builder()
                        .memberAddr("서울시 강남구")
                        .memberZipCode("103-332")
                        .memberAddrDetail("102")
                        .build())
                .build();

        given(memberRepository.findByEmail(anyString())).willReturn(member);
        return member;
    }

    private ItemEntity createItem() {
        return ItemEntity.builder()
                .itemId(1L)
                .itemName("맥북")
                .itemDetail("M3입니다.")
                .itemSeller(1L)
                .itemRamount(0)
                .itemReserver(null)
                .itemImgList(new ArrayList<>())
                .boardEntityList(new ArrayList<>())
                .itemPlace(container)
                .price(1000000)
                .stockNumber(1)
                .build();
    }

    private ItemContainerEntity createContainer() {
        ItemContainerEntity itemContainer = ItemContainerEntity.builder()
                .id(1L)
                .containerName("1지점")
                .containerAddr("서울시 고척동 130-44")
                .item(createItem())
                .build();

        lenient().when(itemContainerRepository.save(any())).thenReturn(itemContainer);
        return itemContainer;
    }


    ItemEntity savedItem1 = ItemEntity.builder()
            .itemId(1L)
            .itemName("테스트")
            .itemDetail("Test Detail")
            .itemSellStatus(ItemSellStatus.SELL)
            .itemReserver("")
            .itemRamount(0)
            .itemPlace(container)
            .price(10000)
            .stockNumber(3)
            .itemImgList(new ArrayList<>())
            .itemSeller(1L)
            .build();


    ItemEntity savedItem2 = ItemEntity.builder()
            .itemId(2L)
            .itemName("스프링")
            .itemDetail("스프링 테스트")
            .itemSellStatus(ItemSellStatus.SELL)
            .itemReserver("")
            .itemRamount(0)
            .itemPlace(container)
            .price(30000)
            .stockNumber(3)
            .itemImgList(new ArrayList<>())
            .boardEntityList(new ArrayList<>())
            .itemSeller(1L)
            .build();


    ItemEntity savedItem3 = ItemEntity.builder()
            .itemId(3L)
            .itemName("마지막")
            .itemDetail("상품 테스트")
            .itemSellStatus(ItemSellStatus.SELL)
            .itemReserver("")
            .itemRamount(0)
            .itemPlace(container)
            .price(30000)
            .stockNumber(1)
            .itemImgList(new ArrayList<>())
            .itemSeller(1L)
            .build();


    @Test
    @DisplayName("상품 작성 서비스 테스트")
    void 상품등록() throws Exception {
        // given
        ItemEntity item = createItem();
        CreateItemDTO createItemDTO = CreateItemDTO.builder()
                .itemName("상품 이름")
                .itemDetail("상품 내용")
                .sellPlace(ContainerDTO.changeDTO(container))
                .price(10000)
                .stockNumber(5)
                .build();

        ItemImgEntity itemImg = ItemImgEntity.builder()
                .itemImgId(1L)
                .item(item)
                .build();

        item.addItemImgList(itemImg);

        MemberEntity member = createMember();
        createContainer();

        given(itemRepository.save(any())).willReturn(item);

        // when
        itemService.saveItem(createItemDTO, new ArrayList<>(), member.getEmail());

        // then
        verify(itemRepository).save(any());
    }

    @Test
    @DisplayName("상품 수정 서비스 테스트")
    void 상품수정() throws Exception {
        // given
        ItemEntity item = createItem();
        ItemImgEntity itemImg = ItemImgEntity.builder()
                .itemImgId(1L)
                .item(item)
                .build();
        // 이미지 생성 후 상품 엔티티에 추가
        item.addItemImgList(itemImg);

        // 이미지 리스트 생성
        List<ItemImgEntity> imgEntityList = new ArrayList<>();
        imgEntityList.add(itemImg);

        // 남겨줄 이미지 id 리스트
        List<Long> remainId = new ArrayList<>();
        remainId.add(1L);

        UpdateItemDTO updateItem = UpdateItemDTO.builder()
                .itemName("테스트")
                .itemDetail("Test Detail - Edit")
                .price(20000)
                .stockNumber(2)
                .itemSeller(1L)
                .remainImgId(remainId)
                .build();

        ItemDTO inputItem = ItemDTO.builder()
                .itemId(item.getItemId())
                .itemName(updateItem.getItemName())
                .itemDetail(updateItem.getItemDetail())
                .price(updateItem.getPrice())
                .stockNumber(updateItem.getStockNumber())
                .itemSeller(updateItem.getItemSeller())
                .itemRamount(item.getItemRamount())
                .itemReserver(item.getItemReserver())
                .itemImgList(new ArrayList<>())
                .sellPlace(container.getContainerName() +"/"+ container.getContainerAddr())
                .boardDTOList(new ArrayList<>())
                .build();

        ItemEntity itemEntity = inputItem.toEntity();

        MemberEntity member = createMember();
        given(itemRepository.findById(anyLong())).willReturn(Optional.of(item));
        given(itemRepository.save(any())).willReturn(itemEntity);

        given(itemImgRepository.findByItemItemId(anyLong())).willReturn(imgEntityList);

        UserDetails userDetails = User.withUsername(member.getEmail())
                .password(member.getMemberPw())
                .authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))
                .build();
        String role = userDetails.getAuthorities().iterator().next().getAuthority();

        // when
        ItemDTO result = itemService.updateItem(1L, updateItem, new ArrayList<>(), member.getEmail(), role);

        // then
        Assertions.assertThat(result.getItemName()).isEqualTo("테스트");
        Assertions.assertThat(result.getStockNumber()).isEqualTo(2);
        Assertions.assertThat(result.getItemDetail()).isEqualTo("Test Detail - Edit");
        Assertions.assertThat(result.getPrice()).isEqualTo(20000);
    }


    @Test
    @DisplayName("상품 단건 조회 서비스 테스트")
    void 상품_세부조회(){
        // given
        ItemEntity item = createItem();
        given(itemRepository.findById(anyLong())).willReturn(Optional.ofNullable(item));
        ItemContainerEntity container = createContainer();
        given(itemContainerRepository.findByContainerName(anyString())).willReturn(container);

        // when
        ItemDTO result = itemService.getItem(1L);

        // then
        Assertions.assertThat(result.getItemId()).isEqualTo(Objects.requireNonNull(item).getItemId());
        Assertions.assertThat(result.getItemName()).isEqualTo(item.getItemName());
        Assertions.assertThat(result.getPrice()).isEqualTo(item.getPrice());
    }

    @Test
    @DisplayName("상품 전체 조회 서비스 테스트")
    void 상품전체조회(){
        // given
        List<ItemEntity> itemList = new ArrayList<>();
        itemList.add(savedItem1);
        itemList.add(savedItem2);
        itemList.add(savedItem3);

        Pageable pageable = PageRequest.of(0, 10, Sort.by("itemId").descending());
        Pageable pageRequest = createPageRequestUsing(pageable.getPageNumber(), pageable.getPageSize(), pageable.getSort());
        int start = (int) pageRequest.getOffset();
        int end = Math.min((start + pageRequest.getPageSize()), itemList.size());

        List<ItemEntity> subItems = itemList.subList(start, end);
        Page<ItemEntity> items = new PageImpl<>(subItems, pageRequest, itemList.size());

        ItemSearchCondition condition = ItemSearchCondition.builder().build();
        ItemContainerEntity container = createContainer();

        given(itemQuerydslRepository.itemSearch(condition, pageable)).willReturn(items);
        given(itemContainerRepository.findByContainerName(anyString())).willReturn(container);

        Page<ItemDTO> itemPages = itemService.searchItemsConditions(pageable, condition);

        Assertions.assertThat(itemPages.getContent().size()).isEqualTo(itemPages.getContent().size());
    }

    private Pageable createPageRequestUsing(int page, int size, Sort sort) {
        return PageRequest.of(page, size, sort);
    }
}
package com.example.shopping.service.board;

import com.example.shopping.domain.board.BoardDTO;
import com.example.shopping.domain.board.BoardSecret;
import com.example.shopping.domain.board.CreateBoardDTO;
import com.example.shopping.domain.board.ReplyStatus;
import com.example.shopping.domain.member.Role;
import com.example.shopping.entity.Container.ContainerEntity;
import com.example.shopping.entity.board.BoardEntity;
import com.example.shopping.entity.item.ItemEntity;
import com.example.shopping.entity.member.AddressEntity;
import com.example.shopping.entity.member.MemberEntity;
import com.example.shopping.repository.board.BoardRepository;
import com.example.shopping.repository.item.ItemRepository;
import com.example.shopping.repository.member.MemberRepository;
import lombok.extern.log4j.Log4j2;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.*;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;

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

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
@Log4j2
class BoardServiceImplTest {

    @Mock
    private BoardRepository boardRepository;
    @Mock
    private ItemRepository itemRepository;
    @Mock
    private MemberRepository memberRepository;

    @InjectMocks
    private BoardServiceImpl boardService;


    private MemberEntity createMember() {
        MemberEntity member = MemberEntity.builder()
                .memberId(1L)
                .memberPw("dudtjq8990!")
                .memberName("테스터")
                .memberRole(Role.USER)
                .nickName("테스터")
                .email("test@test.com")
                .memberPoint(0)
                .provider(null)
                .providerId(null)
                .address(AddressEntity.builder()
                        .memberAddr("서울시 강남구")
                        .memberZipCode("103-332")
                        .memberAddrDetail("102")
                        .build())
                .build();

        given(memberRepository.findByEmail(anyString())).willReturn(member);
        return member;
    }


    ContainerEntity container = ContainerEntity.builder()
            .containerName("1지점")
            .containerAddr("서울시 고척동 130-44")
            .build();

    private ItemEntity createItem() {
        return ItemEntity.builder()
                .itemId(1L)
                .itemName("맥북")
                .itemDetail("M3입니다.")
                .itemSeller(1L)
                .itemRamount(0)
                .itemReserver(null)
                .itemImgList(new ArrayList<>())
                .boardEntityList(new ArrayList<>())
                .itemPlace(container)
                .price(1000000)
                .stockNumber(1)
                .build();
    }


    private BoardEntity createBoard() {
        return BoardEntity.builder()
                .boardSecret(BoardSecret.UN_LOCK)
                .boardId(1L)
                .title("제목")
                .content("내용")
                .member(createMember())
                .commentEntityList(new ArrayList<>())
                .replyStatus(ReplyStatus.REPLY_X)
                .item(createItem())
                .build();
    }
    private BoardEntity createBoard2() {
        return BoardEntity.builder()
                .boardSecret(BoardSecret.UN_LOCK)
                .boardId(2L)
                .title("제목2")
                .content("내용2")
                .member(createMember())
                .commentEntityList(new ArrayList<>())
                .replyStatus(ReplyStatus.REPLY_X)
                .item(createItem())
                .build();
    }

    @Test
    @DisplayName("게시글 작성 서비스 테스트")
    void saveBoard() throws Exception {
        // given
        BoardEntity board = createBoard();
        log.info("board : " + board);
        CreateBoardDTO boardDTO = CreateBoardDTO.builder()
                .title("제목")
                .content("내용")
                .build();
        ItemEntity item = createItem();
        given(itemRepository.findById(anyLong())).willReturn(Optional.of(item));
        given(boardRepository.save(any())).willReturn(board);

        // when
        boardService.saveBoard(1L, boardDTO, "test@test.com");

        // then
        verify(boardRepository).save(any());
    }

    @Test
    @DisplayName("게시글 삭제 서비스 테스트")
    void removeBoard() {
        // given
        BoardEntity board = createBoard();
        UserDetails userDetails =
                User.withUsername(createMember().getEmail())
                        .password(createMember().getMemberPw())
                        // UserDetails 객체에 사용자의 권한 정보를 설정하기 위해 GrantedAuthority 객체를 사용합니다.
                        .authorities(new SimpleGrantedAuthority("ROLE_USER"))
                        .build();
        given(boardRepository.findByBoardId(anyLong())).willReturn(Optional.of(board));
        doNothing().when(boardRepository).deleteById(anyLong());

        // when
        boardService.removeBoard(1L, userDetails);

        // then
        verify(boardRepository).deleteById(1L);
    }

    @Test
    @DisplayName("게시글 단건 조회 서비스 테스트")
    void getBoard() {
        // given
        BoardEntity board = createBoard();
        given(boardRepository.findByBoardId(anyLong())).willReturn(Optional.of(board));

        // when
        ResponseEntity<?> result = boardService.getBoard(1L, createMember().getEmail());
        BoardDTO boardDTO = (BoardDTO) result.getBody();

        // then
        Assertions.assertThat(boardDTO.getTitle()).isEqualTo(board.getTitle());
    }


    @Test
    @DisplayName("게시글 수정 서비스 테스트")
    void updateBoard() {
        // given
        BoardEntity board = createBoard();
        CreateBoardDTO boardDTO = CreateBoardDTO.builder()
                .title("제목(수정)")
                .content("내용(수정)")
                .build();

        BoardDTO inputBoard = BoardDTO.builder()
                .boardId(board.getBoardId())
                .title(boardDTO.getTitle())
                .content(boardDTO.getContent())
                .nickName(board.getMember().getNickName())
                .regTime(board.getRegTime())
                .commentDTOList(new ArrayList<>())
                .boardSecret(board.getBoardSecret())
                .replyStatus(board.getReplyStatus())
                .itemId(board.getItem().getItemId())
                .build();

        MemberEntity member = createMember();
        ItemEntity item = createItem();
        BoardEntity boardEntity = BoardEntity.toBoardEntity(inputBoard, member, item);
        given(boardRepository.findByBoardId(anyLong())).willReturn(Optional.of(board));
        given(boardRepository.save(any())).willReturn(boardEntity);

        // when
        ResponseEntity<?> responseEntity = boardService.updateBoard(1L, boardDTO, "test@test.com");
        log.info(responseEntity);
        // then
        verify(boardRepository, atLeastOnce()).save(any());
    }

    @Test
    @DisplayName("본인 게시글 전체 조회 서비스 테스트")
    void getMyBoards() {
        // given
        List<BoardEntity> boards = new ArrayList<>();
        BoardEntity board = createBoard();
        BoardEntity board2 = createBoard2();
        boards.add(board);
        boards.add(board2);

        // 페이지 번호 1, 페이지 크기 10, 정렬 기준은 "createdAt"을 사용한 Pageable 생성
        Pageable pageable =
                PageRequest.of(1, 10);

        given(boardRepository.findByMemberEmailAndTitleContaining(
                eq("test@test.com"), eq(pageable), any()))
                .will(invocation -> {
                    Pageable actualPageable = invocation.getArgument(1);
                    log.info("Actual pageable: " + actualPageable);
                    return new PageImpl<>(boards, actualPageable, boards.size());
                });


        // when
        Page<BoardDTO> result = boardService.getMyBoards(createMember().getEmail(), pageable, any());

        // then
        assertEquals(boards.size(), result.getContent().size());
        // 정렬 확인: 내림차순 정렬되었는지 확인
        assertEquals(board.getBoardId(), result.getContent().get(0).getBoardId());
        assertEquals(board2.getBoardId(), result.getContent().get(1).getBoardId());
    }

    @Test
    @DisplayName("게시글 전체 조회 서비스 테스트")
    void getBoards() {
        // given
        List<BoardEntity> boards = new ArrayList<>();
        BoardEntity board = createBoard();
        BoardEntity board2 = createBoard2();
        boards.add(board);
        boards.add(board2);

        MemberEntity member = createMember();

        // 페이지 번호 1, 페이지 크기 10
        Pageable pageable = PageRequest.of(1, 10);

        given(boardRepository.findAllByItemItemId(anyLong(), eq(pageable)))
                .will(invocation -> {
                    Pageable actualPageable = invocation.getArgument(1);
                    log.info("Actual pageable: " + actualPageable);
                    return new PageImpl<>(boards, actualPageable, boards.size());
                });

        ItemEntity item = createItem();
        given(itemRepository.findById(anyLong())).willReturn(Optional.of(item));

        // when
        Page<BoardDTO> result = boardService.getBoards(pageable, 1L, member.getEmail());

        // then
        // 페이징 처리에 따른 결과 검증
        assertEquals(boards.size(), result.getContent().size());
        // 정렬 확인: 내림차순 정렬되었는지 확인
        assertEquals(board.getBoardId(), result.getContent().get(0).getBoardId());
        assertEquals(board2.getBoardId(), result.getContent().get(1).getBoardId());
    }
}
package com.example.shopping.service.comment;

import com.example.shopping.domain.board.BoardSecret;
import com.example.shopping.domain.board.ReplyStatus;
import com.example.shopping.domain.comment.CommentDTO;
import com.example.shopping.domain.comment.UpdateCommentDTO;
import com.example.shopping.domain.member.Role;
import com.example.shopping.entity.Container.ContainerEntity;
import com.example.shopping.entity.board.BoardEntity;
import com.example.shopping.entity.comment.CommentEntity;
import com.example.shopping.entity.item.ItemEntity;
import com.example.shopping.entity.member.AddressEntity;
import com.example.shopping.entity.member.MemberEntity;
import com.example.shopping.repository.board.BoardRepository;
import com.example.shopping.repository.comment.CommentRepository;
import com.example.shopping.repository.member.MemberRepository;
import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
@Log4j2
class CommentServiceImplTest {
    @Mock
    private CommentRepository commentRepository;
    @Mock
    private  MemberRepository memberRepository;
    @Mock
    private BoardRepository boardRepository;
    @InjectMocks
    private CommentServiceImpl commentService;

    private MemberEntity createMember() {
        MemberEntity member = MemberEntity.builder()
                .memberId(1L)
                .memberPw("dudtjq8990!")
                .memberName("테스터")
                .memberRole(Role.USER)
                .nickName("테스터")
                .email("test@test.com")
                .memberPoint(0)
                .provider(null)
                .providerId(null)
                .address(AddressEntity.builder()
                        .memberAddr("서울시 강남구")
                        .memberZipCode("103-332")
                        .memberAddrDetail("102")
                        .build())
                .build();

        given(memberRepository.findByEmail(anyString())).willReturn(member);
        return member;
    }

    ContainerEntity container = ContainerEntity.builder()
            .containerName("1지점")
            .containerAddr("서울시 고척동 130-44")
            .build();

    private ItemEntity createItem() {
        return ItemEntity.builder()
                .itemId(1L)
                .itemName("맥북")
                .itemDetail("M3입니다.")
                .itemSeller(1L)
                .itemRamount(0)
                .itemReserver(null)
                .itemImgList(new ArrayList<>())
                .boardEntityList(new ArrayList<>())
                .itemPlace(container)
                .price(1000000)
                .stockNumber(1)
                .build();
    }

    private BoardEntity createBoard() {
        return BoardEntity.builder()
                .boardSecret(BoardSecret.UN_LOCK)
                .boardId(1L)
                .title("제목")
                .content("내용")
                .member(createMember())
                .commentEntityList(new ArrayList<>())
                .replyStatus(ReplyStatus.REPLY_X)
                .item(createItem())
                .build();
    }

    private CommentEntity createComment() {
        return CommentEntity.builder()
                .commentId(1L)
                .comment("내용")
                .member(createMember())
                .board(createBoard())
                .build();
    }

    @Test
    @DisplayName("댓글 생성 서비스 테스트")
    void save() {
        // given
        CommentEntity comment = createComment();
        UpdateCommentDTO updateCommentDTO = UpdateCommentDTO.builder()
                .comment("취업해야해")
                .build();

        MemberEntity member = createMember();
        BoardEntity board = createBoard();
        given(boardRepository.findById(anyLong())).willReturn(Optional.of(board));
        given(commentRepository.save(any())).willReturn(comment);

        // when
        commentService.save(1L, updateCommentDTO, member.getEmail());

        // then
        verify(commentRepository).save(any());
    }

    @Test
    @DisplayName("댓글 삭제 서비스 테스트")
    void remove() {
        // given
        CommentEntity comment = createComment();
        MemberEntity member = createMember();
        UserDetails userDetails = User
                .withUsername(member.getEmail())
                .password(member.getMemberPw())
                .authorities(new SimpleGrantedAuthority("ROLE_ADIN"))
                .build();

        BoardEntity board = createBoard();
        given(boardRepository.findById(anyLong())).willReturn(Optional.of(board));
        given(commentRepository.findById(anyLong())).willReturn(Optional.of(comment));
        doNothing().when(commentRepository).deleteById(anyLong());

        // when
        commentService.remove(comment.getBoard().getBoardId(), comment.getCommentId(), userDetails);

        // then
        verify(commentRepository).deleteById(1L);
    }

    @Test
    @DisplayName("댓글 수정 서비스 테스트")
    void update() {
        // given
        CommentEntity comment = createComment();
        UpdateCommentDTO updateCommentDTO = UpdateCommentDTO.builder()
                .comment("수정된 내용")
                .build();

        CommentDTO inputComment = CommentDTO.builder()
                .commentId(comment.getCommentId())
                .comment(updateCommentDTO.getComment())
                .writeTime(comment.getRegTime())
                .build();

        MemberEntity member = createMember();
        BoardEntity board = createBoard();
        CommentEntity commentEntity = CommentEntity.toCommentEntity(inputComment, member, board);

        given(boardRepository.findById(anyLong())).willReturn(Optional.of(board));
        given(commentRepository.findById(anyLong())).willReturn(Optional.of(comment));
        given(commentRepository.save(any())).willReturn(commentEntity);

        // when
        commentService.update(
                comment.getBoard().getBoardId(),
                comment.getCommentId(),
                updateCommentDTO,
                member.getEmail());

        // then
        verify(commentRepository).save(any());
    }
}
  • @ExtendsWith : 지금처럼 Service 영역에 대한 단위테스트를 위해서 사용하면 된다.

  • @Mock : Mock이란 "실제 객체를 만들어 사용하기에 시간, 비용 등의 Cost가 높거나 혹은 객체 서로간의 의존성이 강해 구현하기 힘들 경우 가짜 객체를 만들어 사용하는 방법이다." 라고 정의 되어 있다. 정의 에서 말하듯이 @Mock은 테스트를 필요한 가짜 객체이다.

  • @Test : 테스트 함수를 지정

가짜 객체임을 명시하기 위해 테스트 클래스에서 Mockito 클래스를 사용함을 알려주기 위해 @ExtendWith(MockitoExtension.class)어노테이션을 붙여준다.
위에서 보면 테스트 클래스명 위에 @ExtendWith(MockitoExtension.class)를 붙였습니다.

@Mock 어노테이션을 통해 Mock 클래스로 생성해야하는 가짜 객체임을 지정한다.

MockMvc

Mock라는 단어를 사전에서 찾아보면 테스트를 위해 만든 모형을 의미한다. 따라서 테스트를 위해 실제 객체와 비슷한 모의 객체를 만드는 것을 모킹(Mocking)이라고 하며, 모킹한 객체를 메모리에서 얻어내는 과정을 목업(Mock-up)이라고 한다. MockMvc는 스프링 mvc의 통합테스트를 위한 라이브러리입니다. 자세히 말하자면, MockMvc는 웹 어플리케이션을 애플리케이션 서버에 배포하지 않고 테스트용 MVC환경을 만들어 요청 및 전송, 응답기능을 제공해주는 유틸리티 클래스입니다.

객체를 테스트하기 위해서는 당연히 테스트 대상 객체가 메모리에 있어야 한다. 하지만 생성하는데 복잡한 절차가 필요하거나 많은 시간이 소요되는 객체는 자주 테스트하기 어렵다. 또는 웹 애플리케이션의 컨트롤러처럼 WAS나 다른 소프트웨어의 도움이 필요한 객체도 있을 수 있다. 따라서 테스트하려는 실제 객체와 비슷한 가짜 객체를 만들어서 테스트에 필요한 기능만 가지도록 모킹을 하면 테스트가 쉬워진다. 그리고 테스트하려는 객체가 복잡한 의존성을 가지고 있을 때, 모킹한 객체를 이용하면, 의존성을 단절시킬 수 있어서 쉽게 테스트할 수 있다.

웹 애플리케이션에서 컨트롤러를 테스트할 때 서블릿 컨테이너를 모킹하기 위해서는 @WebMvcTest를 사용하거나 @AuotoConfigureMockMvc를 사용해야 합니다.

  • MockMvc.perform
    이 메소드는 MockMvcRequestBuilders를 매개변수로 받아 ResultActions를 return하는 메소드입니다. MockMvcRequestBuilders를 반환하는 정적 메소드로는 post(), get(), put(), delete() 등이 존재합니다. 이 메소드들은 HttpRequest를 만들어내기 위한 Builder로써 header, body 등을 지정하는 메소드들이 존재하며 이들은 다시 MockMvcRequestBuilders를 반환하기 때문에 간편하게 테스트를 위한 웹 요청을 만들 수 있습니다. 요청을 전송하는 역할을 합니다. 결과로 ResultActions 객체를 받으며, ResultActions 객체는 리턴 값을 검증하고 확인할 수 있는 andExcpect() 메소드를 제공해줍니다.

    MockMvcRequestBuilders 메서드들은 GET, POST, PUT, DELETE 요청 방식과 매핑되는 get(), post(), put(), delete() 메서드를 제공한다. 해당 메서드들은 MockHttpServletRequestBuilder의 메서들을 통해 MockHttpServletRequestBuilder 객체를 다시 리턴하여 메시지 체인을 구성하여 다양한 요청을 설정 가능하다.

  • get()
    HTTP 메소드를 결정할 수 있습니다. ( get(), post(), put(), delete() ) 인자로는 경로를 보내줍니다. 해당 url로 요청을 한다.
  • ResultActions.andExpect()
    요청의 결과로 예상(원하는) 응답을 지정함으로 실질적으로 테스트를 진행합니다. 즉, 응답을 검증하는 역할을 합니다. 응답코드, 본문에 포함되는 데이터, 헤더, 쿠기, 세션 등 응답에 포함되는 응답에 포함되는 전반적인 데이터들을 테스트할 수 있습니다.

  • contentType(MediaType.APPLICATION_JSON)
    Json 타입으로 지정

  • content()
    json으로 내용 등록

    예시)
    String jjson = "{\"name\": \"부대찌개\"}";
    .content(jjson)

  • ResultActions.andDo()
    요청/응답 전체 메세지를 확인할 수 있습니다. 그리고 mockMvc 요청을 한뒤 행동을 지정하는 메소드 입니다. 결과를 출력한다던지(print()) 로그를 출력하는 등의 행동을 지정할 수 있습니다.

  • .andReturn()
    return 결과를 반환할 때 쓰인다. 당연히 void 메소드를 테스트할 때 사용할 수 없다.

흐름
1. MockMvc를 생성한다.
2. MockMvc에게 요청에 대한 정보를 입력한다.
3. 요청에 대한 응답값을 Expect를 이용하여 테스트한다.
4. Expect가 모두 통과하면 테스트 통과
5. Expect가 1개라도 실패하면 테스트 실패

MockMvc 요청 설정 메소드

  • param / params : 쿼리 스트링 설정

  • cookie : 쿠키 설정

  • requestAttr : 요청 스코프 객체 설정

  • sessionAttr : 세션 스코프 객체 설정

  • content : 요청 본문 설정

  • header / headers : 요청 헤더 설정

  • contentType : 본문 타입 설정

package com.example.shopping.controller.member;

import com.example.shopping.config.jwt.JwtProvider;
import com.example.shopping.domain.Item.ItemDTO;
import com.example.shopping.domain.Item.ItemSellStatus;
import com.example.shopping.domain.jwt.TokenDTO;
import com.example.shopping.domain.member.LoginDTO;
import com.example.shopping.domain.member.RequestMemberDTO;
import com.example.shopping.domain.member.Role;

import static org.mockito.ArgumentMatchers.*;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;

import com.example.shopping.domain.member.UpdateMemberDTO;
import com.example.shopping.domain.order.OrderItemDTO;
import com.example.shopping.entity.Container.ContainerEntity;
import com.example.shopping.entity.item.ItemEntity;
import com.example.shopping.entity.member.AddressEntity;
import com.example.shopping.entity.member.MemberEntity;
import com.example.shopping.service.board.BoardService;
import com.example.shopping.service.jwt.TokenService;
import com.example.shopping.service.member.MemberService;
import com.example.shopping.service.order.OrderService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.MediaType;
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.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@Log4j2
class MemberControllerTest {
    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private ObjectMapper objectMapper;
    @Autowired
    private JwtProvider jwtProvider;

    @MockBean
    private MemberService memberService;
    @MockBean
    private TokenService tokenService;

    private MemberEntity createMember() {
        return MemberEntity.builder()
                .memberId(1L)
                .memberPw("dudtjq8990!")
                .memberName("테스터")
                .memberRole(Role.USER)
                .nickName("테스터")
                .email("test@test.com")
                .memberPoint(0)
                .provider(null)
                .providerId(null)
                .address(AddressEntity.builder()
                        .memberAddr("서울시 강남구")
                        .memberZipCode("103-332")
                        .memberAddrDetail("102")
                        .build())
                .build();
    }


    private TokenDTO createToken() {

        Authentication authentication =
                new UsernamePasswordAuthenticationToken(createMember().getEmail(), createMember().getMemberPw());
        log.info("authentication : " + authentication);
        List<GrantedAuthority> authoritiesForUser = getAuthoritiesForUser(createMember());

        // 토큰 생성
        TokenDTO token = jwtProvider.createToken(authentication, authoritiesForUser, createMember().getMemberId());
        log.info("토큰 : " + token);
        return token;
    }

    // 회원의 권한을 GrantedAuthority타입으로 반환하는 메소드
    private List<GrantedAuthority> getAuthoritiesForUser(MemberEntity member) {
        Role memberRole = member.getMemberRole();
        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("ROLE_" + memberRole.name()));
        log.info("role : " + authorities);
        return authorities;
    }

    @Test
    @DisplayName("회원가입 테스트")
    void join() throws Exception {
        RequestMemberDTO member = RequestMemberDTO.builder()
                .email("test@test.com")
                .memberPw("dudtjq8990!")
                .memberName("테스트중")
                .nickName("테스트중")
                .memberRole(Role.USER)
                .build();

        String request = objectMapper.writeValueAsString(member);

        mockMvc.perform(
                        post("/api/v1/users")
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(request))
                .andExpect(status().isOk())
                .andDo(print());
    }

    @Test
    @DisplayName("회원 조회 테스트")
    void search() throws Exception {
        Long id = 1L;

        mockMvc.perform(
                        get("/api/v1/users/{memberId}", id))
                .andExpect(status().isOk());

        verify(memberService).search(id);
    }

    @Test
    @DisplayName("회원 삭제 테스트")
    @WithMockUser(username = "test@test.com", password = "dudtjq8990!", roles = "USER")
    void remove() throws Exception {
        Long id = 1L;
        String email = "test@test.com";

        mockMvc.perform(delete("/api/v1/users/{memberId}", id)
                        .contentType(MediaType.APPLICATION_JSON)
                        .header("Authorization", "Bearer " + createToken().getAccessToken()))
                .andExpect(status().isOk())
                .andDo(print());

        verify(memberService).removeUser(id, email);
    }

    @Test
    void login() throws Exception {
        LoginDTO login = LoginDTO.builder()
                .memberEmail("test@test.com")
                .memberPw("dudtjq8990!")
                .build();

        mockMvc.perform(
                        post("/api/v1/users/login")
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(objectMapper.writeValueAsString(login)))
                .andExpect(status().isOk())
                .andDo(print());

        verify(memberService).login(login.getMemberEmail(), login.getMemberPw());
    }

    @Test
    @WithMockUser(username = "test@test.com", password = "dudtjq8990!", roles = "USER")
    void update() throws Exception {
        Long id = 1L;
        UpdateMemberDTO update = UpdateMemberDTO.builder()
                .nickName("수정닉네임")
                .memberPw("dudtjq8990!")
                .memberAddress(null)
                .build();

        // when
        mockMvc
                .perform(put("/api/v1/users/{memberId}", id)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(update))
                        .header("Authorization", "Bearer " + createToken().getAccessToken()))
                .andExpect(status().isOk())
                .andDo(print());

        verify(memberService).updateUser(refEq(id), refEq(update), refEq(createMember().getEmail()));
    }

    @Test
    void refreshToken() throws Exception {
        String email = createMember().getEmail();

        mockMvc
                .perform(get("/api/v1/users/refresh")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(email))
                        .header("Authorization", "Bearer " + createToken().getRefreshToken()))
                .andExpect(status().isOk())
                .andDo(print());

        verify(tokenService).createAccessToken(createToken().getRefreshToken());
    }

    @Test
    void emailCheck() throws Exception {
        String email = createMember().getEmail();

        mockMvc
                .perform(get("/api/v1/users/email/{memberEmail}", email))
                .andExpect(status().isOk())
                .andDo(print());
    }

    @Test
    void nickNameCheck() throws Exception {
        String nickName = createMember().getNickName();

        mockMvc
                .perform(get("/api/v1/users/nickName/{nickName}", nickName))
                .andExpect(status().isOk())
                .andDo(print());
    }


}

Junit assert

JUint4.4 부터 assertThat 메서드가 추가됐다. 이 메서드는 hamcrest 라이브러리의 사용을 통합하며 assertions을 작성하는데 있어 더 나은 방법을 제공한다. Junit에서 AssertThat을 활용해 간단히 값을 비교할 수 있다.

선언형태

assertThat(T actual, Macher <? super T> matcher)
Assertions.assertThat(member.getName()).isEqualTo(fineMember.getName());

Junit assert메소드 정리

  • assertArrayEquals(a, b) : 배열 A와 B가 일치함을 확인한다.
  • assertEquals(a, b) : 객체 A와 B가 같은 값을 가지는지 확인한다.
  • assertEquals(a, b, c) : 객체 A와 B가 값이 일치함을 확인한다.( a: 예상값, b:결과값, c: 오차범위)
  • assertSame(a, b) : 객체 A와 B가 같은 객체임을 확인한다.
  • assertTrue(a): 조건 A가 참인지 확인한다.
  • assertNotNull(a) : 객채 A가 null이 아님을 확인한다.

AssertJ

멋진 테스트 코드를 작성하도록 돕는 AssertJ 라이브러리

AssertJ의 장점

  • 메소드 체이닝을 지원하기 때문에 좀 더 깔끔하고 읽기 쉬운 테스트 코드를 작성할 수 있습니다.
  • 개발자가 테스트를 하면서 필요하다고 상상할 수 있는 거의 모든 메소드를 제공합니다.

테스트 대상 지정하기

모든 테스트 코드는 assertThat() 메소드에서 출발합니다. 다음과 같은 포멧으로 AssertJ에서 제공하는 다양한 메소드를 연쇄 호출 하면서 코드를 작성할 수 있습니다.

문자열 테스트

간단한 문자열 테스트 코드를 통해 AssertJ가 얼마나 강력한지 살펴봅시다.

// 주어진 "Hello, world! Nice to meet you."라는 문자열은
assertThat("Hello, world! Nice to meet you.") 
				.isNotEmpty() // 비어있지 않고
				.contains("Nice") // "Nice"를 포함하고
				.contains("world") // "world"도 포함하고
				.doesNotContain("ZZZ") // "ZZZ"는 포함하지 않으며
				.startsWith("Hell") // "Hell"로 시작하고
				.endsWith("u.") // "u."로 끝나며
                // "Hello, world! Nice to meet you."과 일치합니다.
				.isEqualTo("Hello, world! Nice to meet you."); 

숫자 테스트

대소 비교 뿐만 아니라, 오프셋을 이용하여 좀 더 느슨한 비교까지 가능합니다.

assertThat(3.14d) // 주어진 3.14라는 숫자는
				.isPositive() // 양수이고
				.isGreaterThan(3) // 3보다 크며
				.isLessThan(4) // 4보다 작습니다
				.isEqualTo(3, offset(1d)) // 오프셋 1 기준으로 3과 같고
				.isEqualTo(3.1, offset(0.1d)) // 오프셋 0.1 기준으로 3.1과 같으며
				.isEqualTo(3.14); // 오프셋 없이는 3.14와 같습니다

@WebMvcTest vs @SpringBootTest

@WebMvcTest는 MVC 부분 슬라이스 테스트로, 보통 컨트롤러 하나만 테스트하고 싶을 때 사용한다. 그리고 @Controller, @RestController,@ControllerAdivce 같은 어노테이션이 붙은 Controller 관련 bean 들을 대상으로 load해줍니다. 그렇기 때문에 Controller 이외의 Service에 대해서는 MockBean을 통해 가짜객체를 주입해야 합니다. @WebMvcTest()의 프로퍼티로 테스트를 원하는 컨트롤러 클래스를 넣어준다.


이를 통해 특정 컨트롤러만 테스트 가능하도록 하는데, 해당 컨트롤러가 의존하는 빈이 있다면 @MockBean이나 @SpyBean을 사용해주어야 한다. 문제는 이렇게 특정 컨트롤러만을 빈으로 띄우고 @MockBean과 @SpyBean으로 특정 빈을 바꾸는 것은 새로운 애플리케이션 컨택스트를 필요로 한다.

이 어노테이션을 쓰는 경우에는 가끔 NoSuchBeanDefinitionException 오류가 나는데, @WebMvcTest는 @Controller같은 웹과 관련된 빈만 주입되며 @Service와 같은 일반적인 @Component는 생성되지 않는 특성 때문에 해당 컨트롤러를 생성하는 데 필요한 다른 빈을 정의하지 못해 발생한다. 따라서 이런 경우에는 @MockBean을 사용해서 필요한 의존성을 채워주어야 한다.

그러면 @SpringBootTest@WebMvcTest차이는 무엇일까?

SpringBoot에서 JUnit5을 사용하여 테스트 코드를 작성할 때 @SpringBootTest 어노테이션을 자주 쓰게 되는데, 상황에 따라서는 @WebMvcTest를 쓰는게 좋을 때도 있다.

@SpringBootTest는 프로젝트의 전체 컨텍스트를 로드하여 빈을 주입하기 때문에 속도가 느리고, 통합 테스트를 할 때 많이 사용한다. 수많은 스프링 빈을 등록하여 테스트에 필요한 의존성을 추가하기 때문에, 필요한 빈만을 등록하여 테스트를 진행하고자 한다면 슬라이스 테스트 어노테이션인 @WebMvcTest를 사용하는 것이 더 효율적이다.

@SpringBootTest
스프링 컨테이너와 테스트를 함께 실행한다. @SpringBootTest를 사용하면 손쉽게 통합 테스트를 위한 환경을 준비해준다. @SpringBootTest는 모든 빈들을 스캔하고 애플리케이션 컨텍스트를 생성하여 테스트를 실행한다. 스프링부트에서는 @SpringBootTest 어노테이션을 통해 애플리케이션 테스트에 필요한 거의 모든 의존성들을 제공해준다. @SpringBootTest 어노테이션은 Spring Main Application(@SpringBootApplication)을 찾아가 하위의 모든 Bean을 Scan한다. 그 후 Test용 Application Context를 만들면서 빈을 등록해주고, mock bean을 찾아가 그 빈만 mock bean으로 교체해준다. @SpringBootTest의 어노테이션에는 다양한 값을 줄 수 있는데, 이를 살펴보면 다음과 같다.

  • value와 properties: 애플리케이션 실행에 필요한 프로퍼티를 key=value 형태로 추가할 수 있음
  • args: 애플리케이션의 arguments로 값을 전달할 수 있음
  • classes: 애플리케이션을 로딩할 때 사용되는 컴포넌트 클래스들을 정의할 수 있음
  • webEnvironment: 웹 테스트 환경을 설정할 수 있음

ex)

이건 디폴트 값이라서 그냥 @SpringBootTest해도 위와 같은 의미이다.

@WebMvcTest와 @AutoConfigureMockMvc

@RunWith(SpringRunner.class)

Mockito의 Mock 객체를 사용하기 위한 Annotation이다
class 위에 달아준다
@ExtendWith(SpringExtension.class)

Mockito의 Mock 객체를 사용하기 위한 Annotation이다
class 위에 달아준다

JUnit4에서는 RunWith(MockitoJUnitRunner.class)를,
JUnit5에서는 ExtendWith를 쓰도록 되어있다. 스프링부트 2.0 이상은 기본적으로 JUnit5로 버전이 되기때문에 ExtenWith를 사용하면 된다.


질문 예상

💡테스트 코드를 작성 해야하는 이유에 대해 아는대로 설명해주세요.

  • 기능의 추가, 변경, 삭제로 인한 영향도를 쉽게 파악 가능

  • 예상하지 못한 오류에 대한 피드백을 위해

  • 좋은 설계로 작성되게끔 코드를 유도

  • 기능 정의의 문서의 역할

  • 실수를 줄여준다.

  • 개발 과정에서 문제를 미리 발견할 수 있다.

  • 리팩토링의 리스크가 줄어든다.

  • 애플리케이션을 가동해서 직접 테스트하는 것보다 테스트를 빠르게 진행

  • 하나의 명세 문서로서의 기능을 수행

  • 몇 가지 프레임워크에 맞춰 테스트 코드를 작성하면 좋은 코드를 생산할 수 있다.

  • 코드가 작성된 목적을 명확하게 표현할 수 있으며, 불필요한 내용이 추가되는 것을 방지

💡테스트 코드를 어떻게 작성하는지 설명해주세요

Spring Rest Docs 작성을 위해 Mock 객체와 Fixture 데이터를 이용하여 컨트롤러 테스트를 작성합니다. 서비스를 테스트할 때는 @SpringBootTest 이렇게 어노테이션을 붙이고 테스트를 진행하는데 테스트하는 값이 맞는지 확인할 때는 Assertions을 붙이고 테스트한다. 테스트하는 데이터가 실제 운영하는 DB에 섞이면 안되기 때문에 내부 메모리 DB로 진행하는게 좋다.

test폴더에 resource를 만들고 yml이나 properties를 넣어주면 스프링부트가 내부 메모리 DB로 값을 넣어준다. 그리고 여기서 중요한점은 @Transactional을 넣어줘야 테스트하는 값들이 롤백이 된다.

테스트 코드를 작성하는 방법은 다양합니다. 사람들이 많이 사용하는 Given-When-Then패턴과 F.I.R.S.T 전략이 있습니다. Given-When-Then가 BDD에 속합니다.

Given-When-Then 패턴은 간단한 테스트로 여겨지는 단위 테스트에서는 잘 사용하지 않습니다. 그 이유 중 하나는 불필요하게 코드가 길어진다는 것입니다. 하지만 이 패턴을 통해 테스트 코드를 작성한다면 명세 문서의 역할을 수행한다는 측면에서 많은 도움이 됩니다.

💡TDD(Test-Driven-Development)의 개념에 대해 설명해주세요.

테스트 주도 개발의 개발 주가

💡BDD

Behavior Driven Development의 약자로 TDD에서 따왔기 때문에 TDD추구하는 가치가 크게 다르지 않다. BDD를 처음으로 생각한 Danial Terhorst-North가 TDD를 수행하고 있던 도중 아래와 같은 생각을 했다고 합니다.

TDD하다가 해당 코드를 분석하기 위해서 많은 코드들을 분석해야하고 복잡성으로 인해 '누군가가 나에게 이 코드는 어떤식으로 짜여졌어!' 라고 말을 해줬으면 좋았을 텐데 라고 생각을 하다가 보니 행동 중심 개발을 하면 좋겠다고 생각했다.

BDD는 애플리케이션이 어떻게 행동해야 하는지에 대한 공통된 이해를 구성하는 방법입니다.

BDD의 행동

1. Narrative

모든 테스트 문장을 Narrative하게 되어야 한다. 즉, 코드보다 인간의 언어와 유사하게 구성되어야 한다. BDD는 TDD를 수행하려는 어떤한 행동과 기능을 개발자가 더 이해하기 쉽게하는 것이 목적이다. 모든 테스트 문장은 Given When Then으로 나눠서 작성할 수 있어야 한다.

2. Given/When/Then

Given

  • 테스트를 위해 주어진 상태
  • 테스트 대상에게 주어진 조건
  • 테스트가 동작하기 위해 주어진 환경

When

  • 테스트 대상에게 가해진 어떠한 상태
  • 테스트 대상에게 주어진 어떠한 조건
  • 테스트 대상의 상태를 변경시키기 위한 환경

Then

  • 앞선 과정의 결과

즉, 어떤 상태에서 출발(given)하여 어떤 상태이 변화를 가했을 때(when) 기대하는 어떠한 상태가 되어야 한다.(then)

예시

public class Calculator{
  public int plus(int a, int b){
    return a+b;
  }
}
public class CalculatorTest{
  Calculator calc = new Calculator();

  @Test
  void plus(){
    //given
    int a = 10;
    int b = 20;

    //when
    int result = calc.plus(a,b);

    //then
    assertEquals(result, a+b);
  }
}

여기서 보면 "어? 뭔 차이야?"라고 의문이 생길 수 있다.
근데 별 차이가 없다. 당연하다. BDD 자체가 TDD에서 더 새로운 개념이 아니라 TDD를 더 잘, 더 멋지게, 더 협조적으로 사용하기 위한 방법이기 때문이다.

💡좋은 테스트란 어떤 테스트인지 본인의 생각을 얘기해주세요

제가 생각했을 때 좋은 테스트란 하나의 테스트로만 정해져 있는 것이 아니라 각자가 맞는 테스트 방법 즉, 익숙해진 테스트 방법을 사용하는 것이 좋다고 생각합니다. 테스트는 BDD나 TDD 등 여러 방법이 있고 개발할 때도 사람들은 각자의 방법으로 개발하니 테스트도 각자의 테스트 방법으로 기능이 제대로 돌아가는지를 제대로 체크할 수 있다면 그것이 좋은 테스트라고 생각합니다.

💡본인이 사용했던 기술들과 그 기술을 사용했던 이유에 대해 설명하고, 대체 기술도 알고 있다면 얘기해주세요.

제가 사용한 기술들을 나열하자면 다음과 같습니다.

  • JSP
  • Spring Framework
  • Spring Boot
  • MySQL
  • MongoDB
  • JPA
  • QueryDsl
  • Security
  • OAuth2
  • JWT
  • Batch
  • MyBatis
  • EC2
  • RDS
  • S3
  • git action
  • git
  • docker

제가 이 기술들을 공부했던 이유는 백엔드 개발자로 취업을 하고 싶었기 때문입니다. 처음에 공부할 당시에는 node에 대해서 알지 못했고 백엔드 개발자로 조사해봤을 때 스프링 쪽으로 취업했을 경우 실무에서 사용하는 기술들을 알고자 했고 미리 공부를 해서 회사에 들어갔을 때 금방 따라가고 싶었기 때문에 이 기술들을 공부하게 되었습니다. 대체 기술은 제가 말한 기술들이 자바 기반이지만 코틀린으로 대체할 수 있다고 알고 있고 백엔드적으로는 노드로 스프링 기반의 백엔드를 대체할 수 있다고 알고있습니다.

💡하나의 비지니스 로직을 작성할 때 어느 수준으로 작성하는지, 무엇을 중요하게 생각하는지 얘기해주세요

비즈니스 로직을 작성할 때 운영을 한다는 생각으로 작성했습니다. 비록 원하는 만큼의 수준으로 작성하지는 못했지만 저는 비지니스 로직을 작성할 때 중복을 줄이고 코드의 가독성을 중요하게 생각을 했고 기능들이 사용자들이 필요한 기능인가 그리고 어떤 기능이 있으면 좋을까 즉, 사용자 편의성에 대해서 중요하게 생각했습니다.

💡초당 100만개 씩 들어오는 요청에 대해 10000번째로 들어온 요청의 사용자를 어떻게 찾을 것인지 설명해주세요.

10000번째 요청을 찾기 위해 데이터베이스에서 요청을 순차적으로 읽을 수도 있지만 이는 매우 느릴 수 있습니다. 대신, 데이터베이스에서 10000번째 요청을 직접 조회하기 위해 적절한 데이터베이스 쿼리를 작성해야 합니다. 예를 들어, SQL 데이터베이스에서는 LIMIT 및 OFFSET을 사용하여 특정 위치의 요청을 선택할 수 있습니다.

💡프로젝트를 진행하면서 어려웠던 점이 있었다면 설명해주세요.

프로젝트를 진행하면서 어려운 점은 제가 리더의 역할을 맞고 있었는데 팀원 관리가 어려웠습니다. 프로젝트에 참가하고 도망가거나 하는척하고 안하는 사람 그리고 프로젝트를 구현할 때 각자의 생각이 다르니 방향성과 같은 말인데 이해하는 것은 다르게 이해해서 코드를 작성한 경우가 프로젝트를 진행하면서 어려움을 겪었습니다.

도망가거나 하는 경우는 제가 어떻게 할 수 없으니 최대한 사람들을 구할 수 있도록 여러 개발 사이트에 공고를 냈고 안하는 사람과 방향성의 차이, 이해가 달라서 코드를 다르게 작성하는 경우는 지속적으로 의견을 물어보고 제가 듣고 이해한 것이 맞는지 체크하는 이중체크를 했으며 제 의견이 맞다고만 하는 것이 아니라 팀원이 의견을 제시하면 잠시 생각할 시간을 달라고 하고 제시한 의견에 대해서 조사하고 생각을 해봐서 더 좋은 방향으로 생각이 되면 리팩토링을 하는 방법을 취했습니다. 그러다 보니 참여 안하는 분들이 없고 방향성의 차이를 금방 발견하고 고칠 수 있었습니다.

💡앞으로 쌓거나 경험하고 싶은 개발자 커리어가 있다면 얘기해주세요.

저는 기존의 공부한 것들을 실무지향적으로 경험을 쌓고 배운 것을 토대로 프로젝트에 참여하고 위로 올라가는 즉, 성장하는 개발자가 되고 싶습니다. 계속 공부하는 직종을 선택했으니 나태한 개발자가 아니라 발전하는 개발자가 되고싶습니다.

💡본인이 보유한 스킬이나 진행한 프로젝트 위주로 1분 자기소개를 해보세요

제가 진행할 프로젝트의 스킬은 다음과 같습니다.

  • Spring Boot
  • MySQL
  • JPA
  • Security
  • OAuth2
  • JWT
  • Batch
  • MyBatis
  • EC2
  • RDS
  • S3
  • git action
  • git

안녕하세요, 저는 xxx입니다. 제가 유한 스킬과 진행한 프로젝트를 중심으로 1분 자기소개를 해보겠습니다.

제가 진행한 프로젝트는 블로그 그리고 중고마켓 프로젝트 2개입니다. 처음에 진행한 프로젝트는 배포를 경험하고 git으로 협업을 진행해서 REST로 리액트와 프로젝트를 경험하고자 간단한 블로그 형식을 진행하였고 2차에서는 제가 평소에 있었으면 좋겠다고 생각한 중고마켓 프로젝트를 진행했습니다. 1차에서는 MyBatis를 사용하였고 로그인시 쿠키와 세션으로 저장하는 기능을 사용했고 2차에서는 JPA를 사용하고 보안을 강화하기 위해 security와 OAuth2 그리고 JWT를 사용했습니다. 배포는 PuTTy와 MobaXTerm을 사용해서 윈도우 환경에서 배포를 사용했고 또한 Git Action을 사용하여 지속적인 통합 및 배포를 구축했습니다. 또한 AWS EC2 및 S3를 활용하여 안정적인 서비스를 제공했습니다. 이러한 경험과 기술을 활용하여 새로운 프로젝트에서도 효과적인 개발과 관리를 위한 노력을 기울이고 있으며, 끊임없는 학습과 성장을 추구하고 있습니다.

profile
발전하기 위한 공부

0개의 댓글