세미나허브에 결제서비스 개발해보기

안녕하세요! Seminar-hub( 세미나 허브 ) 프로젝트를 진행하고 있는 PassionFruit200 입니다!

이번 게시물에서는 Seminar-hub(세미나 허브) 프로젝트에 결제 서비스를 추가하는 과정을 담아보려고 합니다.

결제 서비스를 개발하면서 어떤 생각을 가지고서 개발을 진행하였는지를 중점으로 글을 담아보았습니다!

이 글에서는, 실제 결제서비스의 기획적인 요구를 충족시키기보다는 결제서비스의 가장 기본적인 토대를 코딩하는데 중점을 두었습니다. 또, 개발을 진행하면서 글을 쓰다보니 중간에 방향을 틔어서 다시 재작성하는 등 그러한 과정까지 모두 담아내도록 해보았습니다.

 

이번 개발을 진행하면서

  • 주어진 개발사항을 명확히 정의할 수 있게 되었습니다.
  • 부동소수점을 사용할경우 발생할 수 있는 문제점을 알게 되고 그에 대한 해결방법을 알아내었습니다.
  • 각 서비스의 특성을 고려하여 DB 비정규화와 정규화를 할 필요가 있습니다.
  • 테스트 코드 작성을 통해 API 작성이 적합하게 되었는지 하나의 척도가 된다는 걸 느꼈습니다. 
  • Spring의 각 서비스들을 사용하며 DI(Dependency Injection), IOC(Inversion of Control)의 개념에 더 명확히 이해하게 되었습니다. 
  • Spring AOP(Proxy, 대리자 기법)와 @RestControllerAdvice로 인한 에러를 마주치며 스프링의 생애주기와 Proxy 대리자 기법에 대해 명확히 알게되었습니다.
  • @Transaction의 Propagation과 Isolation에 대해 명확히 이해하게 되었습니다

목차

  1. 결제기능 A 시퀀스 다이어그램
  2. DB 구조
  3. 코드 작성
    1. Entity
    2. Repository
    3. Service
      1. 마주쳤던 Problem : Spring AOP와 @RestControllerAdvice의 작동흐름
    4. Controller
  4. Transaction Isolation 적용을 위한 고려
    1. Read UnCommitted : Dirty Read 발생상황
    2. Read Committed : Repeatable Read 발생상황
    3. Repeatable Read : Phantom Read 발생상황
  5. 개선사항 발견 : 세미나 참여인원을 비정규화하자.
    1. DB구조 변경
    2. 코드 수정
      1. Entity
      2. Repository
      3. Service
      4. Controller
    3. Transaction Isolation 적용을 위한 고려
      1. Read UnCommitted : Dirty Read 발생상황
      2. Read Committed : Repeatable Read 발생상황
      3. Repeatable Read : Phantom Read 발생상황
  6. 다중결제기능 B 시퀀스 다이어그램
  7. 코드 작성
    1. Service
    2. Controller
    3. DeadLock 발생에 따른 

기능 A 구현

제가 이번에 구현하려는 기능에 대한 시퀀스 다이어그램입니다!

 

결제서비스를 개발한다고 했을때 사용자 입장에서 가장 먼저 생각나는 흐름도는 위의 그림과 같습니다.

( 일반적 Commerce에서는 휴대폰 결제, 모바일 결제를 사용하여 결제단을 만들어서 실제로 PG사의 결제서비스의 API와 연동하여 진행하겠지만, 논리적 구현이 주 목표이므로 PG사에게 결제요청하는 부분은 생략했습니다. )

 

한번 개발 Flow로도 표현해보았습니다.

DB 구조

 

직접적으로 이번 코드에서 사용할 부분은 member, seminar, member_seminar, payment 로 둘 수 있습니다.

엔티티들에 대한 자세한 설명은 아래의 Entity 코드로 확인해봅니다.

Entity 작성

각 엔티티를 작성합니다.

공통적으로 사용되는 Entity

  • @Entity Annotation  : 해당 클래스가 JPA 엔티티임을 나타냅니다.
  • @Builder Annotation : Lombok의 '@Builder'를 사용하여 객체를 생성할때 빌더 패턴을 사용할 수 있습니다. 
  • @AllArgsConstructor : Lombok의 어노테이션으로, 모든 필드를 포함한 생성자를 생성합니다.
  • @NoArgsConstructor : Lombok의 어노테이션으로, 매개변수가 없는 디폴트 생성자를 자동으로 생성합니다.
  • @Getter Annotation : Lombok의 어노테이션으로, 모든 필드에 대한 Getter 메서드를 생성합니다.
  • @ToString(exclude = {"member_role_set", "member_seminar_list"}) Annotation: Lombok의 @ToString을 사용하여 ToString 메서드를 생성하며 제외할 필드를 명시합니다.
  • @Id : JPA에서 사용되는 엔터티의 기본 키를 정의하는 어노테이션들입니다. @Id는 기본 키를 나타냅니다.
  • @GeneratedValue :  자동 생성 전략을 설정합니다.
  • @Column : 컬럼의 속성을 정의합니다.

1. [com/seminarhub/entity/Member.java] 멤버클래스입니다.

@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = {"member_role_set", "member_seminar_list"})
public class Member extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_no")
    private Long member_no; //고유번호
    @Column(length = 500, unique = true, name = "member_id")
    private String member_id; //회원아이디
    @Column(length = 500, nullable = false)
    private String member_password; //회원 비밀번호
    @Column(length = 500, nullable = false)
    private String member_nickname; //회원닉네임
    @Column(columnDefinition = "BIT default false")
    private boolean member_from_social;
    @Column(columnDefinition = "DECIMAL(19, 2) DEFAULT 0.0")
    private BigDecimal member_charged_money; //회원 충전금액
    @Column()
    private LocalDateTime del_dt; //삭제일
    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    @Builder.Default
    private Set<Member_Role> member_role_set = new HashSet<>();
    @OneToMany(mappedBy = "member")
    private List<Member_Seminar> member_seminar_list;
    public Member(long member_no) {this.member_no = member_no;}
    public void setMember_id(String member_id) {this.member_id = member_id;}
    public void setMember_nickname(String member_nickname) {this.member_nickname = member_nickname;}
    public void setDel_dt(LocalDateTime del_dt) {this.del_dt = del_dt;}
    public void addMemberRole(Member_Role member_role) {member_role_set.add(member_role);}
}
  • @ToString(exclude = {"member_role_set", "member_seminar_list"}) Annotation: 이렇게 toString으로 제외할 필드를 선정하는 이유는 무한참조를 막기 위해서입니다. 예로 들어, member.toString()을 실행하고, member_role_set을 출력했는데 mmber_role_set에서도 다시 member_role_set.toString()을 실행할경우 무한참조에 빠지게 됩니다.
  • @OneToMany(mappedBy = "member",fetch = FetchType.LAZY)
    @Builder.Default
    private Set<Member_Role> member_role_set = new HashSet<>() : Member 엔티티와 Member_Role 엔티티 간의 일대다 관계를 나타냅니다. fetch 속성이 FetchType.LAZY로 설정되어 있으므로, Member 엔티티를 검색할 때 Member_Role 엔티티는 필요한 시점에 가져옵니다. HashSet을 활용한 이유는 회원정보를 검색할떄 조건검색을 O(1) 안에 끝내기 위함이고, @Builder.Default로 Builder로 객체 생성시 기본적으로 빈 HashSet을 설정합니다.
  • @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    private List<Member_Seminar> member_seminar_list;: Member 엔티티와 Member_Seminar 엔티티 간의 일대다 관계를 나타냅니다. FetchType.LAZY 를 적용하여 필요한 시점에 가져옵니다.

또, Member Entity의 member_charged_money를 확인해보면 특이한점이 있습니다. 무엇일까요?

@Column(nullable = false)
private BigDecimal member_charged_money; //회원 충전금액

"BigDecmal Library" 입니다.

여기서 BigDecimal은 자바에서 기본적으로 제공하는 자료형이 아니라, 

import java.math.BigDecimal;

에서 Import해서 사용하는 java.math.BigDecimal 라이브러리입니다.

Float(4 byte)나 Long(8 byte) 같은 실수형을 사용해서 표현하면 안되는 이유가 존재하는데요, 이유는 Float나 Long은 부동소수점으로 표현되어 많은 수를 표현할 수 있지만 부동소수점은 이진법에서 실수를 표현하는 방식으로, 한정된 비트양으로 최대한 많은 데이터를 표현하기 위해 나온 방법입니다. 이러한 부동소수점은 많은 양의 숫자를 효율적으로 표현할 수 있지만, 결국 소수점에서 2진법으로 표현할 수 없는 숫자들이 존재하므로 문제가 발생할 수 밖에 없습니다. 

 

그대신 "BigDecimal Library"는  해당 숫자들을 십진법으로 표현합니다. 예를 들어, 부동소수점 방식으로는 정확히 표현하기 어려운 0.1 같은 값도 BigDecimal을 사용하면 정확히 표현할 수 있습니다. 

 

만약 0.1을 부동소수점을 사용하여 표현하면 어떻게 표현될까요?  발생하는 문제점을 확인해보면, 십진수 0.1 을 이진수로 바꿔보면, 0.1로 표현되는것이 아닌 무한소수로 표현된다는 것 입니다. ( 아래의 코드에서 보면 0.3 이 기대되는 값인데 엉뚱한 값이 나옵니다. )

public class Main { 
	public static void main(String[] args) {
		System.out.println(0.1 + 0.2);
	}
}
-------------------
Exepected Output : 0.3
Real Output : 0.30000000000000004

 

BigDecimal 같은경우에는 아래와 같이 사용합니다.

import java.math.BigDecimal;

public class Main {
    public static void main(String[] args) {
        BigDecimal num1 = new BigDecimal("0.1");
        BigDecimal num2 = new BigDecimal("0.2");
        
        BigDecimal sum = num1.add(num2);
        
        System.out.println("Sum: " + sum); // 출력: Sum: 0.3
    }
}

2. [com/seminarhub/entity/Seminar.java] 세미나 클래스입니다.

@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = {"member_seminar_list"})
public class Seminar extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long seminar_no;
    @Column(length = 100, nullable = false)
    private String seminar_name;
    @Column(length = 500)
    private String seminar_explanation;
    @Column
    private Long seminar_price;
    @Column
    private Long seminar_maxParticipants;
    @Column(nullable=true)
    private LocalDateTime del_dt;
    @OneToMany(mappedBy = "seminar", fetch = FetchType.LAZY)
    private List<Member_Seminar> member_seminar_list;
    public void setDel_dt(LocalDateTime del_dt){ this.del_dt = del_dt; }
    public void setSeminar_name(String seminar_name){
        this.seminar_name = seminar_name;
    }
}
  • @OneToMany(mappedBy = "seminar", fetch = FetchType.LAZY)
    private List<Member_Seminar> member_seminar_list: : 1:N 관계를 매핑하는데 사용되는 어노테이션입니다. 이 경우 Member_Seminar 엔터티와의 일대다 관계를 표현하며, seminar 필드를 통해 매핑된다는 것을 나타냅니다.
  • @ToString(exclude = {"member_seminar_list"}) : 무한참조를 막기 위해 ToString에서 제거필드를 선언합니다.

3. [ com/seminarhub/entity/Member_Seminar.java ] 세미나에 신청한 멤버의 내역을 저장해주는 bridge  테이블입니다.

@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = {"member", "seminar", "member_seminar_payment_history"})
public class Member_Seminar extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long member_seminar_no;
    @ManyToOne(targetEntity = Member.class, fetch = FetchType.LAZY)
    @JoinColumn(name = "member_no")
    private Member member;
    @ManyToOne(targetEntity = Seminar.class, fetch = FetchType.LAZY)
    @JoinColumn(name = "seminar_no")
    private Seminar seminar;
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_seminar_payment_history_no")
    private Member_Seminar_Payment_History member_seminar_payment_history;
    @Column()
    private LocalDateTime del_dt;
    public void setDel_dt(LocalDateTime del_dt){ this.del_dt = del_dt; }
}
  • @ManyToOne(targetEntity = Member.class, fetch = FetchType.LAZY)
    @JoinColumn(name = "member_no")
    private Member member; Member 엔티티와 Many-to-One 관계입니다. member 필드가 Member 엔티티를 참조합니다. fetch = FetchType.LAZY로 설정되어 있으므로, 연관된 Member 엔티티는 필요한 시점에 가져옵니다.
  • @ManyToOne(targetEntity = Seminar.class, fetch = FetchType.LAZY)
    @JoinColumn(name = "seminar_no")
    private Seminar seminar : 엔티티와의 다대일(Many-to-One) 관계입니다. seminar 필드가 Seminar 엔티티를 참조합니다. fetch = FetchType.LAZY로 설정되어 있으므로, 연관된 Seminar 엔티티는 필요한 시점에 가져옵니다.
  • @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_seminar_payment_history_no")
    private Member_Seminar_Payment_History member_seminar_payment_history : Payment 엔티티와의 One-to-One 관계입니다. payment 필드가 Payment 엔티티를 참조합니다. fetch = FetchType.LAZY로 설정되어 있으므로, 연관된 Payment 엔티티는 필요한 시점에 가져옵니다.
  • @ToString(exclude = {"member", "seminar", "member_seminar_payment_history"}) : toString() 메서드가 객체를 문자열로 표현할 때 member, seminar, payment 필드를 제외하도록 설정합니다. 
  • 유의해야할점은 Member_Seminar와 Member_Seminar_Payment_History 는 일대일 관계에서 단방향의 관계입니다. 그러므로 따로 필드를 생성하지 않습니다. 또한 JPA에서 대상 테이블에 대해 MappedBy를 제공하지도 않습니다.

4. [ com/seminarhub/entity/Member_Seminar_Payment_History.java ] 정산을 위한 결제내역 정보입니다.

package com.seminarhub.entity;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString
public class Member_Seminar_Payment_History extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long member_seminar_payment_history_no;
    @Column(nullable = false)
    private Long member_seminar_payment_history_amount;
//    JPA에서 제공하지 않음.
//    @OneToOne(mappedBy = "member_seminar_payment_history")
//    private Member_Seminar member_seminar;
    @Column()
    private LocalDateTime del_dt;
    public void setDel_dt(LocalDateTime del_dt){ this.del_dt = del_dt; }

}
  • 유의해야할점은 Member_Seminar와 Member_Seminar_Payment_History 는 일대일 관계에서 단방향의 관계입니다. 그러므로 따로 필드를 생성하지 않습니다.  또한 JPA에서 MappedBy를 제공하지도 않습니다.

5. [ com/seminarhub/entity/BaseEntity.java ] 공통 필드로 사용되는 inst_dt와 updt_dt를 관리하기 위해 baseEntity를 사용합니다. 각 코드를 보면 BaseEntity를 상속받고 있는 것을 볼 수 있습니다.

@MappedSuperclass
@EntityListeners(value = {AuditingEntityListener.class})
@Getter
public class BaseEntity {
    @CreatedDate
    @Column(name = "inst_dt")
    private LocalDateTime inst_dt;
    @LastModifiedDate
    @Column(name = "updt_dt")
    private LocalDateTime updt_dt;
}

 

JPA 엔터티 클래스들이 공통적으로 사용할 수 있는 필드와 리스너를 포함하고 있습니다.

  • @MappedSuperclass: 해당 클래스가 테이블과 매핑되지 않고, 자식 엔터티 클래스에게 공통 속성을 제공한다는 것을 나타냅니다. 즉, 이 클래스의 필드들은 상속되어 하위 엔터티 클래스에서 재사용될 수 있습니다.
  • @EntityListeners(value = {AuditingEntityListener.class}): 엔터티에서 발생하는 이벤트를 처리하는 리스너를 지정합니다. 여기서는 AuditingEntityListener.class가 사용되어 생성일자와 수정일자를 자동으로 관리하는데 사용됩니다.
  • @CreatedDate: JPA에서 제공되는 어노테이션으로, 엔터티가 생성될 때의 일자 및 시간 정보를 자동으로 관리합니다.
  • @LastModifiedDate: JPA에서 제공되는 어노테이션으로, 엔터티가 마지막으로 수정된 일자 및 시간 정보를 자동으로 관리합니다.

따라서 BaseEntity를 상속하는 다른 JPA 엔터티 클래스들은 자동으로 생성일자와 수정일자를 관리하게 됩니다.

또한 위에서 @EntityListeners(value = {AuditingEntityListener.class}):  를 사용하기 위해서는 

반드시 Main 시작 함수에 @EnableJPAAuditing 을 추가해주어야 변화를 인식 합니다!

@SpringBootApplication
@EnableJpaAuditing
@EnableCaching
public class SeminarHubMonolithic {
    public static void main(String[] args) {
        System.out.println("Hello world!");
        SpringApplication.run(SeminarHubMonolithic.class, args);;
    }
}

 

이로써 이번 프로젝트에 사용할 Entity를 모두 작성했습니다.

 

비즈니스로직 작성

이제 실제로 해당 비즈니스 로직을 Repository, Service, Controller 순서대로 작성해보겠습니다.

공통적으로 사용되는 Annotation

아래 코드에서 공통적으로 사용되는 어노테이션들에 대해 미리 정리했습니다.

  • @SpringBootTest: 스프링 부트 애플리케이션 통합 테스트를 위한 어노테이션으로, 전체 애플리케이션 컨텍스트를 로드합니다.
  • given, when, then 패턴: 테스트의 Given-When-Then 패턴에 따라 테스트의 세 가지 단계를 설명하는 주석입니다.
    • given: 초기 상태를 설정합니다.
    • when: 실제 동작을 수행합니다.
    • then: 예상 결과를 확인합니다.
  • @Service: 해당 클래스가 서비스 빈으로 등록되어야 함을 나타냅니다.
    @Log4j2: Log4j2를 이용하여 로깅을 수행할 수 있도록 합니다.
    @RequiredArgsConstructor: 롬복 어노테이션으로, 필드 주입을 위한 생성자를 생성합니다.
  • @MockBean: 이 어노테이션은 Mock 객체를 주입하는데 사용됩니다. 여기서는 SeminarRepository와 SeminarQuerydslRepository를 Mock으로 주입하고 있습니다.
  • @Autowired: 이 어노테이션은 필드 주입을 통해 실제 SeminarService 빈을 주입받습니다.
  • @DisplayName("Seminar Service Get Test"): 테스트 메서드의 이름을 지정하는 어노테이션입니다.
  • @Test: 이 어노테이션은 해당 메서드가 테스트 메서드임을 나타냅니다.
  • Seminar existingSeminar = Seminar.builder()...: 주어진 세미나 정보를 가지고 있는 객체를 생성합니다.
  • Mockito.when(seminarRepository.findBySeminar_name("SeminarTest"))...: SeminarRepository의 findBySeminar_name 메서드가 호출될 때, 주어진 세미나 이름에 해당하는 결과를 설정합니다.
  • SeminarDTO seminarDTO = seminarService.get("SeminarTest");: SeminarService의 get 메서드를 호출하고 결과를 SeminarDTO 객체에 저장합니다.
  • Assertions.assertEquals(...): 예상 결과와 실제 결과를 비교하여 테스트를 수행합니다.
  • verify(seminarRepository).findBySeminar_name("SeminarTest");: SeminarRepository의 findBySeminar_name 메서드가 주어진 인자로 호출되었는지 확인합니다.
  • @Data: @Data 어노테이션은 @Getter, @Setter, @ToString, @EqualsAndHashCode

1. Repository Layer

1-1. MemberRepository [ com/seminarhub/repository/MemberRepository.java ] : 회원 정보 조회쿼리 작성

public interface MemberRepository extends JpaRepository<Member, Long> {
    //@EntityGraph(attributePaths = {"member_role_set.role"}, type = EntityGraph.EntityGraphType.LOAD)
    @Query("SELECT m From Member m WHERE m.member_id = :member_id AND del_dt is null")
    Optional<Member> findByMember_id(@Param("member_id") String member_id);
}

이 코드는 Spring Data JPA를 사용하여 데이터베이스에서 회원 정보를 조회하는 MemberRepository 인터페이스입니다.

  1. findByMember_id 메서드:
    • findByMember_id 메서드는 회원 아이디(member_id)를 기반으로 회원을 조회하는 쿼리 메서드입니다.
    • 주석처리 된 EntityGraph 어노테이션을 보면, 해당 회원과 연관된 정보들을 즉시로딩하도록 처리하는 코드가 있습니다. 이번 글에서는 회원의 권한은 필요하지 않으므로 주석처리했습니다.
    • del_dt is null 조건은 논리적 삭제(soft delete)를 위해 사용됨으로써, del_dt가 null인 경우에만 조회하도록 설정되어 있습니다.

1-3. MemberRepositoryTests  [ com/seminarhub/repository/MemberRepositoryTests.java ] : 회원정보 조회 함수 테스트

@SpringBootTest
public class MemberRepositoryTests {
    @Autowired
    private MemberRepository memberRepository;
    /**
     * Find Member by member_id
     *         
     *         Hibernate: 
     *     select
     *         m1_0.member_no,
     *         m1_0.del_dt,
     *         m1_0.inst_dt,
     *         m1_0.member_charged_money,
     *         m1_0.member_from_social,
     *         m1_0.member_id,
     *         m1_0.member_nickname,
     *         m1_0.member_password,
     *         m1_0.updt_dt 
     *     from
     *         member m1_0 
     *     where
     *         m1_0.member_id=? 
     *         and m1_0.del_dt is null
     */
    @DisplayName("findByMember_id Test")
    @Test
    public void testFindByMember_id(){
        // given, when
        Optional<Member> member = memberRepository.findByMember_id("passionfruit200@naver.com");
        
        // then
        assertNotNull(member.get());
    }
}

올바르게 작동하고 있습니다.

실행 후 sql 쿼리는 Test 코드의 주석 안에 담아주어, JPA가 원하는대로 표현되었는지 확인합니다.

Repository 테스트 후에는 항상 Query를 확인합니다.

 

1-2. SeminarRepository [ com/seminarhub/repository/SeminarRepository.java ] : 세미나 정보 조회 쿼리

public interface SeminarRepository extends JpaRepository<Seminar, Long> {
    @Query("SELECT s FROM Seminar s WHERE s.seminar_name = :seminar_name AND del_dt is null")
    Optional<Seminar> findBySeminar_name(@Param("seminar_name") String seminar_name);
}

 

  1. findBySeminar_name 메서드:
    • findBySeminar_name 메서드는 세미나 이름(seminar_name)을 기반으로 세미나를 조회하는 쿼리 메서드입니다.
    • del_dt is null 조건은 논리적 삭제(soft delete)를 위해 사용되는 필드인 del_dt가 null인 경우에만 조회하도록 설정되어 있습니다.

이렇게 설정된 findBySeminar_name 메서드를 호출하면 해당 세미나 이름에 대한 세미나 정보를 조회하고 반환합니다. del_dt is null 조건을 통해 논리적 삭제된 세미나는 조회되지 않습니다.

1-3. SeminarRepositoryTests [ com/seminarhub/repository/SeminarRepositoryTests.java ] : SeminarRepository 를 테스트

각 함수가 올바르게 작성하는지 테스트 코드를 작성하겠습니다.

@SpringBootTest
public class SeminarRepositoryTests {
    @Autowired
    private SeminarRepository seminarRepository;
    private final String seminar_name= "SeminarTest";
    /**
     * Description : seminarRepository findBySeminar_name Test
     * Find Seminar by seminar_name
     *
     *     Hibernate:
     *     select
     *         s1_0.seminar_no,
     *         s1_0.del_dt,
     *         s1_0.inst_dt,
     *         s1_0.seminar_explanation,
     *         s1_0.seminar_max_participants,
     *         s1_0.seminar_name,
     *         s1_0.seminar_price,
     *         s1_0.updt_dt
     *     from
     *         seminar s1_0
     *     where
     *         s1_0.seminar_name=?
     *         and s1_0.del_dt is null
     *      Seminar(seminar_no=1, seminar_name=SeminarTest, seminar_explanation=null, del_dt=null)
     */
    @DisplayName("findBySeminar_name Test")
    @Test
    public void testFindBySeminar_name(){
        // given, when
        Optional<Seminar> seminar = seminarRepository.findBySeminar_name(seminar_name);

        // then
        assertNotNull(seminar.get());
        System.out.println(seminar.get());
    }
}

JUnit 을 활용하여 테스트하겠습니다. 코드의 각 부분을 살펴보겠습니다.

  1. Optional<Seminar> seminar = seminarRepository.findBySeminar_name(seminar_name);: 주어진 세미나 이름으로 SeminarRepository의 findBySeminar_name 메서드를 호출하여 결과를 Optional로 받습니다.
  2. assertNotNull(seminar.get());: 결과가 null이 아닌지 확인합니다.

이 테스트는 특정 세미나 이름으로 SeminarRepository의 메서드를 호출하고, 그 결과를 확인하는 간단한 예시입니다. 주어진 세미나 이름에 해당하는 세미나가 존재하는지 테스트합니다.

1-4 SeminarQuerydslRepository [ com/seminarhub/repository/SeminarQuerydslRepository.java ] : 세미나 현재 남은 인원 확인 코드

@RequiredArgsConstructor
@Repository
public class SeminarQuerydslRepository {
    private final JPAQueryFactory queryFactory;
    public Long findCurrentParticipateCount(SeminarDTO seminarDTO){
        QMember_Seminar member_seminar = QMember_Seminar.member_Seminar;

        Long seminar_participant_cnt = queryFactory.select(member_seminar.count())
                .from(member_seminar)
                .where(member_seminar.seminar.seminar_no.eq(seminarDTO.getSeminar_no())
                        .and(member_seminar.del_dt.isNull()))
                .fetchOne();

        return seminar_participant_cnt;
    }

}
  • 현재 세미나에 신청한 인원을 가져옵니다.

2. Service Layer

2-1. SeminarService [ com/seminarhub/service/SeminarService.java ] : 세미나 인터페이스

public interface SeminarService {
    SeminarDTO get(String seminar_name);
    default Seminar dtoToEntity(SeminarDTO seminarDTO){
        Seminar seminar = Seminar.builder()
                .seminar_no(seminarDTO.getSeminar_no())
                .seminar_name(seminarDTO.getSeminar_name())
                .seminar_explanation(seminarDTO.getSeminar_explanation())
                .build();
        return seminar;
    }
    default SeminarDTO entityToDTO(Seminar seminar){
        SeminarDTO seminarDTO = SeminarDTO.builder()
                .seminar_no(seminar.getSeminar_no())
                .seminar_name(seminar.getSeminar_name())
                .seminar_explanation(seminar.getSeminar_explanation())
                .seminar_max_participants(seminar.getSeminar_maxParticipants())
                .seminar_price(seminar.getSeminar_price())
                .build();
        return seminarDTO;
    }
}
  1. get 메서드: get 메서드는 세미나의 이름을 받아 해당 세미나 정보를 조회하고 SeminarDTO 형태로 반환합니다.
  2. dtoToEntity 메서드: dtoToEntity 메서드는 SeminarDTO 객체를 Seminar 엔티티로 변환하는 기능을 수행합니다. 이떄 Lombok의 builder 패턴을 활용하여 손쉽게 생성합니다.
  3. entityToDTO 메서드: entityToDTO 메서드는 Seminar 엔티티를 SeminarDTO 객체로 변환하는 기능을 수행합니다. 마찬가지로 Lombok의 builder 패턴을 활용하여 새로운 SeminarDTO 객체를 생성하고 반환합니다.

2-2.SeminarServiceImpl.class [ com/seminarhub/service/SeminarServiceImpl.java ] :Interface Seminar의 구현부

@Service
@Log4j2
@RequiredArgsConstructor
public class SeminarServiceImpl implements  SeminarService{
    private final SeminarRepository seminarRepository;
    @Override
    public SeminarDTO get(String seminar_name) {
        Optional<Seminar> result = seminarRepository.findBySeminar_name(seminar_name);
        if(result.isPresent()){
            return entityToDTO(result.get());
        }
        return null;
    }
}
  1. get 메서드: 세미나 이름을 받아와서 해당 이름으로 세미나를 조회합니다.

2-2-1. SeminaServiceTests.java [ com/seminarhub/repository/SeminarRepositoryTests.java ] : 세미나 서비스 테스트

@SpringBootTest
public class SeminarServiceTests {
    @MockBean
    private SeminarRepository seminarRepository;
    @MockBean
    private SeminarQuerydslRepository seminarQuerydslRepository;
    @Autowired
    private SeminarService seminarService;
    @DisplayName("Seminar Service Get Test")
    @Test
    public void testGetSeminar(){
        // Given
        Seminar existingSeminar = Seminar.builder()
                .seminar_no((long)123L)
                .seminar_name("SeminarTest")
                .seminar_explanation("SeminarExplanation")
                .build();
        Mockito.when(seminarRepository.findBySeminar_name("SeminarTest")).thenReturn(Optional.of(existingSeminar));

        // when
        SeminarDTO seminarDTO = seminarService.get("SeminarTest");

        // then
        Assertions.assertEquals(seminarDTO.getSeminar_no(), 123L);
        Assertions.assertEquals(seminarDTO.getSeminar_name(), "SeminarTest");
        Assertions.assertEquals(seminarDTO.getSeminar_explanation(), "SeminarExplanation");

        verify(seminarRepository).findBySeminar_name("SeminarTest");
    }
}
  • SeminarService의 get 메서드가 주어진 세미나 이름에 대한 정보를 올바르게 반환하는지 검증합니다. Mock을 사용하여 외부 의존성을 제어하고, 특정 메서드 호출이 예상대로 이루어졌는지 검증하는 방식으로 테스트가 진행되었습니다. 

2-3. MemberService [ com/seminarhub/service/MemberService.java ] : 회원 Service Interface

public interface MemberService {
    MemberDTO get(String member_id);
    default Member dtoToEntity(MemberDTO memberDTO){
        Member member = Member.builder()
                .member_no(memberDTO.getMember_no())
                .member_id(memberDTO.getMember_id())
                .member_password(memberDTO.getMember_password())
                .member_nickname(memberDTO.getMember_nickname())
                .member_from_social(memberDTO.isMember_from_social())
                .build();
        return member;
    }
    default MemberDTO entityToDTO(Member member){
        MemberDTO memberDTO = MemberDTO.builder()
                .member_no(member.getMember_no())
                .member_id(member.getMember_id())
                .member_password(member.getMember_password())
                .member_nickname(member.getMember_nickname())
                .member_from_social(member.isMember_from_social())
                .build();

        return memberDTO;
    }
}

MemberService 인터페이스를 정의하고, 해당 인터페이스에서는 회원 정보를 가져오고, DTO와 엔티티 사이의 변환을 수행하는 메서드들을 제공합니다.

  1. get 메서드: get 메서드는 회원 아이디를 받아 해당 회원 정보를 조회하여 MemberDTO 형태로 반환합니다.
  2. dtoToEntity 메서드: dtoToEntity 메서드는 MemberDTO 객체를 Member 엔티티로 변환하는 기능을 수행합니다. Lombok의 builder 패턴을 활용하여 새로운 Member 객체를 생성하고 반환합니다.
  3. entityToDTO 메서드: entityToDTO 메서드는 Member 엔티티를 MemberDTO 객체로 변환하는 기능을 수행합니다. 마찬가지로 Lombok의 builder 패턴을 활용하여 새로운 MemberDTO 객체를 생성하고 반환합니다.

2-4. MemberServiceImpl .java [ com/seminarhub/service/MemberServiceImpl.java ] : Member Interface 구현코드

@Service
@Log4j2
@RequiredArgsConstructor
public class MemberServiceImpl implements  MemberService{
    private final MemberRepository memberRepository;
    @Override
    public MemberDTO get(String member_id) {
        Optional<Member> result = memberRepository.findByMember_id(member_id);
        if(result.isPresent()){
            return entityToDTO(result.get());
        }
        return null;
    }
}

회원 아이디를 기반으로 회원 정보를 조회하고, 조회된 정보를 MemberDTO로 변환하여 반환하는 메서드가 구현되어 있습니다.

  1. get 메서드: 회원 아이디를 받아와서 해당 아이디로 회원 정보를 조회합니다. memberRepository의 findByMember_id(member_id)를 통해 회원을 조회하고, Optional을 통해 결과를 처리합니다. 만약 회원이 존재하면 entityToDTO 메서드를 사용하여 엔티티를 DTO로 변환하여 반환합니다. 회원이 존재하지 않으면 null을 반환합니다.

2-4-1.MemberServiceTests.java [ com/seminarhub/service/MemberServiceTests.java ] : 회원 서비스 레이어 테스트 

@SpringBootTest
public class MemberServiceTests {
    @MockBean
    private MemberRepository memberRepository;
    @Autowired
    private MemberService memberService;
    @DisplayName("Member Service FindMemberByMember_id Test")
    @Test
    public void getMemberTest(){
        // given
        Member existingMember = Member.builder()
                .member_no((long) 2994)
                .member_id("passionfruit200@naver.com")
                .member_password("member_password")
                .member_nickname("member_nickname")
                .member_from_social(false)
                .build();
        Mockito.when(memberRepository.findByMember_id("passionfruit200@naver.com")).thenReturn(Optional.of(existingMember));

        // when
        MemberDTO memberDTO = memberService.get("passionfruit200@naver.com");

        // then
        Assertions.assertEquals(memberDTO.getMember_no(), 2994L);
        Assertions.assertEquals(memberDTO.getMember_id(), "passionfruit200@naver.com");
        Assertions.assertEquals(memberDTO.getMember_password(), "member_password");
        Assertions.assertEquals(memberDTO.getMember_nickname(), "member_nickname");
        Assertions.assertEquals(memberDTO.isMember_from_social(), false);

        verify(memberRepository).findByMember_id("passionfruit200@naver.com");
    }
}

MemberService의 get 메서드가 주어진 멤버 아이디에 대한 정보를 올바르게 반환하는지 검증합니다. Mock을 사용하여 외부 의존성을 제어하고, 특정 메서드 호출이 예상대로 이루어졌는지 검증하여 테스트합니다..

 

2.5 Member_SeminarService.java [ com/seminarhub/service/Member_SeminarService.java ] : Member_Seminar 서비스 인터페이스

public interface Member_SeminarService {
    Long registerForSeminar(MemberDTO memberDTO, SeminarDTO seminarDTO) throws Exception;
    default Member_Seminar dtoToEntity(Member_SeminarDTO member_seminarDTO){
        Member member = Member.builder()
                .member_no(member_seminarDTO.getMember_no())
                .build();
        Seminar seminar = Seminar.builder()
                .seminar_no(member_seminarDTO.getSeminar_no())
                .build();
        Member_Seminar_Payment_History member_seminar_payment_history = Member_Seminar_Payment_History.builder()
                .member_seminar_payment_history_no(member_seminarDTO.getMember_seminar_payment_history_no())
                .build();
        Member_Seminar member_seminar = Member_Seminar.builder()
                .member_seminar_no(member_seminarDTO.getMember_seminar_no())
                .member(member)
                .seminar(seminar)
                .member_seminar_payment_history(member_seminar_payment_history)
                .build();
        return member_seminar;
    }
    default Member_SeminarDTO entityToDTO(Member_Seminar member_seminar){
        Member_SeminarDTO member_seminarDTO = Member_SeminarDTO.builder()
                .member_seminar_no(member_seminar.getMember_seminar_no())
                .seminar_no(member_seminar.getSeminar().getSeminar_no())
                .member_no(member_seminar.getMember().getMember_no())
                .build();
        return member_seminarDTO;
    }
}

 

2-6. Member_SeminarServiceImpl [ com/seminarhub/service/Member_SeminarServiceImpl.java ] : Member_Seminar Service의 구현부 코드입니다.

@Service
@Log4j2
@RequiredArgsConstructor
public class Member_SeminarServiceImpl implements Member_SeminarService{
    private final Member_SeminarRepository member_seminarRepository;
    private final Member_Seminar_Payment_HistoryRepository member_seminar_payment_historyRepository;
    private final SeminarQuerydslRepository seminarQuerydslRepository;
    @Transactional
    @Override
    public Long registerForSeminar(MemberSeminarRegisterRequestDTO memberSeminarRegisterRequestDTO) throws SeminarRegistrationFullException {
        log.info(memberSeminarRegisterRequestDTO.toString());
        SeminarDTO seminarDTO = seminarService.get(memberSeminarRegisterRequestDTO.getSeminar_name());
        MemberDTO memberDTO = memberService.get(memberSeminarRegisterRequestDTO.getMember_id());
        if (seminarDTO == null || memberDTO == null) {
            // 예외 처리: 세미나나 멤버가 존재하지 않는 경우
            throw new SeminarRegistrationFullException("There are no Info Of Member || Seminar");
        }

        //신청하려는 Seminar가 아직 남아있는지 확인해야하고, Transactional로 격리상태를 유지해야합니다.
        Long currentParticipateCnt = seminarQuerydslRepository.findCurrentParticipateCount(seminarDTO);
        if (seminarDTO.getSeminar_max_participants() < currentParticipateCnt) {
            throw new SeminarRegistrationFullException("SeminarInfo:" + seminarDTO.getSeminar_name() + "is already " + currentParticipateCnt + "/" + seminarDTO.getSeminar_max_participants() + " full. Registration failed.");
        }

        Member_Seminar_Payment_History member_seminar_payment_history = Member_Seminar_Payment_History.builder()
                .member_seminar_payment_history_amount(seminarDTO.getSeminar_price())
                .build();
        member_seminar_payment_historyRepository.save(member_seminar_payment_history);

        Member_SeminarDTO member_seminarDTO = Member_SeminarDTO.builder()
                .member_no(memberDTO.getMember_no())
                .seminar_no(seminarDTO.getSeminar_no())
                .member_seminar_payment_history_no(member_seminar_payment_history.getMember_seminar_payment_history_no())
                .build();
        Member_Seminar member_seminar = dtoToEntity(member_seminarDTO);
        member_seminarRepository.save(member_seminar);
        return member_seminar.getMember_seminar_no();
    }
}

회원이 세미나에 등록하는 비즈니스 로직을 구현하고, 데이터 정합성을 지키기 위해 @Transactional 어노테이션을 사용하고 있습니다.

  1. @Transactional 어노테이션: @Transactional 어노테이션은 트랜잭션을 관리하기 위해 사용됩니다. 해당 메서드 내의 작업은 하나의 트랜잭션으로 묶이게 됩니다.
  2. registerForSeminar 메서드: 회원이 세미나에 등록하는 비즈니스 로직이 구현되어 있습니다. 해당 세미나에 현재 참가한 인원 수를 조회하고, 세미나의 최대 참가 인원을 초과하는지 확인합니다. 만약 초과한다면 등록 실패로 처리하고, 그렇지 않은 경우에는 회원 세미나 결제 이력을 저장하고, 회원 세미나 정보를 저장합니다. 마지막으로 회원 세미나의 번호를 반환합니다.
  3. throw new SeminarRegistrationFullException: 트랜잭션 내에서 예외가 발생하면 롤백이 되도록 구현되어 있습니다.

2-6-1. Member_SeminarServiceTests.java [ com/seminarhub/service/Member_SeminarServiceTests.java ] : Member_Seminar Service 테스트 코드

@SpringBootTest
public class Member_SeminarServiceTests {
    @MockBean
    private SeminarService seminarService;
    @MockBean
    private MemberService memberService;
    @MockBean
    private Member_SeminarRepository member_seminarRepository;
    @MockBean
    private SeminarQuerydslRepository seminarQuerydslRepository;
    @MockBean
    private Member_Seminar_Payment_HistoryRepository member_seminar_payment_historyRepository;
    @Autowired
    private Member_SeminarService memberSeminarService;
    @Transactional
    @DisplayName("Member_Seminar Service RegisterForSeminar Test")
    @Test
    public void testRegisterForSeminar() throws SeminarRegistrationFullException {
        // given
        Member member = Member.builder()
                .member_id("passionfruit200@naver.com")
                .build();
        Seminar seminar = Seminar.builder()
                .seminar_name("SeminarTest")
                .build();
        MemberDTO memberDTO = MemberDTO.builder()
                .member_id("passionfruit200@naver.com")
                .build();
        SeminarDTO seminarDTO = SeminarDTO.builder()
                .seminar_name("SeminarTest")
                .seminar_max_participants(40L) //SET seminar_max_participants = 40;
                .seminar_price(10000L)
                .build();
        Member_Seminar member_seminar = Member_Seminar.builder()
                .member_seminar_no(1L)
                .member(member)
                .seminar(seminar)
                .build();
        Member_Seminar_Payment_History member_seminar_payment_history = Member_Seminar_Payment_History.builder()
                        .member_seminar_payment_history_no(4L)
                        .member_seminar_payment_history_amount(seminarDTO.getSeminar_price())
                        .build();
        MemberSeminarRegisterRequestDTO memberSeminarRegisterRequestDTO= MemberSeminarRegisterRequestDTO.builder()
                .member_id("passionfruit200@naver.com")
                .seminar_name("SeminarTest")
                .build();
        Mockito.when(memberService.get("passionfruit200@naver.com")).thenReturn(memberDTO);
        Mockito.when(seminarService.get("SeminarTest")).thenReturn(seminarDTO);
        Mockito.when(seminarQuerydslRepository.findCurrentParticipateCount(seminarDTO)).thenReturn(10L); //SET Current PArticipant 10
        Mockito.when(member_seminar_payment_historyRepository.save(any(Member_Seminar_Payment_History.class))).thenReturn(member_seminar_payment_history);
        Mockito.when(member_seminarRepository.save(any(Member_Seminar.class))).thenReturn(member_seminar);

        // when
        Long result = memberSeminarService.registerForSeminar(memberSeminarRegisterRequestDTO);

        // then
        verify(memberService).get("passionfruit200@naver.com");
        verify(seminarService).get("SeminarTest");
        verify(seminarQuerydslRepository).findCurrentParticipateCount(seminarDTO);
        verify(member_seminar_payment_historyRepository).save(any(Member_Seminar_Payment_History.class));
        verify(member_seminarRepository).save(any(Member_Seminar.class));
    }
}

Service Layer에서의 단위테스트인 Mocking Test 를 진행합니다. save함수나 반환값을 올바르게 테스트하지는 못하지만, Mock 을 활용한 단위테스트를 통해 어떠한 데이터베이스가 오더라도 테스트를 진행할 수 있습니다.  

verify로 Business Layer안의 함수가 올바르게 실행되었는지 마무리 합니다.

 

테스트 로직입니다.

  1. memberService.get 및 seminarService.get 메서드에 대한 목 객체의 반환값을 설정하고, 이를 통해 MemberDTO 및 SeminarDTO 객체를 생성합니다.
  2. seminarQuerydslRepository.findCurrentParticipateCount 메서드에 대한 목 객체의 반환값을 설정합니다.
  3. member_seminar_payment_historyRepository.save 및 member_seminarRepository.save 메서드에 대한 목 객체의 반환값을 설정합니다.
  4. memberSeminarService.registerForSeminar 메서드를 호출하고, 반환값을 검증합니다.
  5. 목 객체에 대한 메서드 호출이 예상대로 이루어졌는지 verify를 사용하여 확인합니다.

이렇게 테스트를 통해 코드의 핵심 로직이 예상대로 동작하는지를 확인할 수 있습니다. 특히 목 객체를 사용하여 외부 의존성을 가짜로 대체함으로써 테스트의 격리성을 확보하였습니다.

3. Controller

Controller를 작성하기 전에 먼저 Http Request를 JSON 타입으로 보내기 위한 DTO Class를 생성하겠습니다.

1-1. DTO 작성 [ com/seminarhub/dto/MemberSeminarRegisterRequestDTO.java ]

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MemberSeminarRegisterRequestDTO {
    private String member_id;
    private String seminar_name;
}

 

회원이 세미나에 등록할 때 필요한 정보를 가지고 있는 DTO, Data Transfer Object 입니다.

1-2. Member_SeminarController [ com/seminarhub/controller/Member_SeminarController.java ] : Member_Seminar API 생성을 위한 Controller

@RestController
@Log4j2
@RequestMapping("/api/v1/member_seminar")
@RequiredArgsConstructor
@Tag(name = "4. Member_Seminar API")
public class Member_SeminarController {
    private final MemberService memberService;
    private final Member_SeminarService member_seminarService;
    private final SeminarService seminarService;
    @PostMapping(value = "")
    @Operation(summary = "1. Register for Seminar")
    public ResponseEntity<Long> registerForSeminar(@RequestBody MemberSeminarRegisterRequestDTO memberSeminarRegisterRequestDTO) throws Exception {
        Long member_seminarno = member_seminarService.registerForSeminar(memberSeminarRegisterRequestDTO);
        return new ResponseEntity<>(member_seminarno, HttpStatus.OK);
    }
}
  • @RestController: RESTFUL 웹 서비스의 컨트롤러임을 나타냅니다.
  • @RequestMapping("/api/v1/member_seminar"): 이 컨트롤러의 엔드포인트가 "/api/v1/member_seminar"임을 지정합니다.
  • @RequiredArgsConstructor: 롬복 어노테이션으로, 필드 주입을 위한 생성자를 생성합니다.
  • new ResponseEntity<>(member_seminarno, HttpStatus.OK): 클라이언트에게 응답을 제공하는데 사용되는 ResponseEntity 객체를 생성합니다. 회원-세미나 번호를 OK(200) 상태코드와 함께 반환합니다. 이러한 오류 코드 같은경우 직접 CustomExcception을 만들어 상태번호와 함꼐 반환하도록 설정해야합니다. 해당 내용은 이 글에는 포함되어 있지 않습니다. 

일반적으로 FrontEnd에서 JSON Type으로 보내 는것이 수월하므로 JSON Type을 받도록 @RequestBody  어노테이션을 통해 RequestDTO 객체를 생성했습니다. 만약, 추가 파라미터가 있을경우, MemberSeminarRegisterRequestDTO에 필드로 추가합니다. 

@RequestBody 가 아닌 다른 방식으로 받고싶으시다면 @RequestParam으로 직접 변수 하나 하나 받아도 됩니다. 하지만, 객체와 JSON 형태로 받기 위해 @RequestBody 를 활용하는것을 추천드립니다. 

 

추가로, 

  • 만약 @RequiredArgsConstructor가 없다면 어떻게 해야할까요? 아래와 같이 직접 생성자를 작성해야합니다.
@RestController
public class MemberSeminarController {
    private final MemberService memberService;
    private final MemberSeminarService memberSeminarService;
    private final SeminarService seminarService;

    // @RequiredArgsConstructor가 없으므로, 수동으로 생성자를 작성해야 합니다.
    public MemberSeminarController(MemberService memberService, MemberSeminarService memberSeminarService, SeminarService seminarService) {
        this.memberService = memberService;
        this.memberSeminarService = memberSeminarService;
        this.seminarService = seminarService;
    }
}

여기서 어떻게 위의 서비스들에 memberService, memberSeminarService, seminarService에 객체들이 연결되는지 궁금증이 드는데요, 이는 Spring의 DI(Dependency Injection), IOC(Inversion of Control) 으로 인해 서비스가 시작시 자동으로 객체를 연결해줍니다. 그로 인해, 우리는 단순히 해당 코드에서 생성자로 해당 서비스를 사용한다고하면 이미 @Bean (스프링 컨텍스트에서 전역적으로 관리하는 객체) 가 자동으로 주입됩니다.

 

다만, 만약에 생성자가 여러개라면 어느곳에 연결할지 @Autowired를 사용해야합니다. 스프링 컨텍스트에서 어느 생성자에 빈을 넣어주어야할지 판단하지 못하기 떄문입니다.

 

1-3. MemberControllerTests.java  [ com/seminarhub/controller/MemberControllerTests.java ]

@SpringBootTest
@AutoConfigureMockMvc
public class MemberControllerTests {
    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private ObjectMapper objectMapper;
    @MockBean
    private Member_SeminarService memberSeminarService;
    @Test
    @DisplayName("MemberController registerForSeminar Tests")
    void registerForSeminarTest() throws Exception{
        // given
        MemberSeminarRegisterRequestDTO memberSeminarRegisterRequestDTO = MemberSeminarRegisterRequestDTO.builder()
                .member_id("passionfruit200@naver.com")
                .seminar_name("SeminarTest")
                .build();
        Mockito.when(memberSeminarService.registerForSeminar(memberSeminarRegisterRequestDTO)).thenReturn(3412L);

        // when
        mockMvc.perform(post("/api/v1/member_seminar")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(memberSeminarRegisterRequestDTO)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$").value(3412L))
                .andDo(print());

        // then
        verify(memberSeminarService).registerForSeminar(memberSeminarRegisterRequestDTO);
    }
}

이 코드는 Spring Boot와 MockMvc를 사용하여 MemberController의 registerForSeminar 메서드를 테스트하는 코드입니다. 코드의 구성은 다음과 같습니다:.

  1. @AutoConfigureMockMvc: MockMvc를 자동으로 구성하여 컨트롤러를 테스트합니다.
  2. @Autowired private MockMvc mockMvc: MockMvc 인스턴스를 주입받아 사용합니다.
  3. @Autowired private ObjectMapper objectMapper: ObjectMapper를 주입받아 JSON을 처리합니다.
  4. @MockBean private Member_SeminarService memberSeminarService: Member_SeminarService 를 목 객체로 주입합니다.

Mockito.when을 사용하여 memberSeminarService.registerForSeminar 메서드가 호출될 때 반환되는 값을 설정하고, mockMvc.perform을 사용하여 /api/v1/member_seminar 엔드포인트로 POST 요청을 보내고 응답을 확인합니다.

테스트는 예상대로 동작하는지 확인하는데 사용합니다.

1-3. MemberController Test 진행중에 발생한 Spring  AOP 겪었던 오류 [ com/seminarhub/controller/MemberControllerTests.java ]

위의 MemberControllerTests 를 통해서 해당 API가 잘 작동하는지 테스트를 진행하였습니다. Controller에서는 단위테스트를 진행한 후에 실제로 API가 잘작동하는지 PostMan을 통하여 확인이 필요합니다.  그런데, 현재, Spring AOP를 활용하여서 Service Layer에서의 함수들의 실행시간을 기록하고 있습니다. 제가 겪은 문제점은, Service Layer에서 에러 메세지가 발생했을때 발생한 사건입니다. 제가 Spring AOP를 구현한 방식에 대해 궁금하신 분은 [ https://passionfruit200.tistory.com/981 ] 이 글을 참고하시기 바랍니다.

 

 

먼저 문제가 났었던 해당 Target의 Advice 코드입니다.

LogAdvice.java [ com/seminarhub/aop/LogAdvice.java ]

@Aspect
@Log4j2
@Component
public class LogAdvice {
//    @Before( "execution(* com.seminarhub..*(..))")
//    public void logBefore(ProceedingJoinPoint proceedingJoinPoint){
//        log.info("========================");
//    }
    @Around( "execution(* com.seminarhub..*(..))")
    public Object logTime(ProceedingJoinPoint proceedingJoinPoint){
        log.info("========================");
        long start = System.currentTimeMillis();
        log.info("Target: " + proceedingJoinPoint.getTarget());
        log.info("Param: " + Arrays.toString(proceedingJoinPoint.getArgs()));
        //invoke Method
        Object result = null;
        try{
            result = proceedingJoinPoint.proceed();
        } catch (Throwable e){
            e.printStackTrace();
        }
        long end = System.currentTimeMillis();
        log.info("TIME: "+ ( end - start ));
        return result;
    }
    @AfterThrowing(pointcut = "execution(* com.seminarhub..*(..))", throwing = "exception")
    public void logException(Exception exception){
        log.info("Exception Found");
        log.info("exception: "+exception);
    }
}

코드는 Spring AOP(Aspect-Oriented Programming)를 사용하여 메서드 실행 시간을 로깅하고 예외를 처리하는 LogAdvice 클래스입니다.

  1. @Aspect: 이 클래스가 Aspect로 사용됨을 나타냅니다.
  2. @Log4j2: Log4j2를 사용하여 로깅을 수행합니다.
  3. @Component: Spring Bean으로 등록됨을 나타냅니다.
  4. @Around: 해당 어드바이스는 메서드 실행 전후에 로깅을 수행합니다. 메서드 실행 전에 시작 시간을 기록하고, 메서드 실행 후에 종료 시간을 계산하여 실행 시간을 로깅합니다.
  5. proceedingJoinPoint.proceed(): 실제 메서드를 호출하여 어드바이스가 원래 메서드를 실행합니다. 이때 발생하는 예외는 catch 블록에서 처리됩니다.
  6. @AfterThrowing: 메서드 실행 중에 예외가 발생하면 해당 어드바이스가 실행되어 예외를 로깅합니다.

 

저는 위와 같이 LogAdvice를 활용하여 Service Layer에서의 로깅타임을 기록하고 있습니다.

이떄 위의 코드에서 처음에 작성했던 코드는 에러 발생을 고려하지 않고, throw e를 통해 에러를 다시 던져주지 않고, proceedingJoinPoint.proceed()에서 단순히 Exception이 발생하고 종료하도록 처리했습니다.

아래와 같이 수정해야합니다.

try{
    result = proceedingJoinPoint.proceed();
} catch (Throwable e){
    e.printStackTrace();
    throw e; // 다시 예외를 던지면서 상위 호출자에게 전달
}

저는 @RestControllerAdvice와 @ExceptionHandler를 활용하여 각각의 Exception을 구현하여 띄어주는 상황인데요,

기본적으로 @RestControllerAdvice에서 에러가 발생하면 작동되는 프로그램 Flow는 아래와 같습니다.

WAS -> Filter -> Dispatcher Servlet -> Interceptor -> Controller -> Exception -> Interceptor -> Dispatcher Servlet
-> Filter -> WAS -> Filter -> Dispatcher Servlet -> Interceptor -> RestControllerAdvice -> ExceptionHandler

이 구성은 Spring 웹 애플리케이션에서의 요청 처리 흐름을 나타냅니다. 각 구성 요소는 다음과 같은 역할을 수행합니다.

  1. WAS (Web Application Server): 클라이언트의 요청을 받아들이고, 서블릿 컨테이너를 통해 요청을 처리합니다.
  2. Filter: 요청과 응답을 가로채어 가공하는데 사용됩니다. 여러 필터들이 체인을 이루어 순차적으로 적용됩니다.
  3. Dispatcher Servlet: Spring MVC에서 중심적인 역할을 하는 서블릿으로, 요청을 컨트롤러에게 전달하고, 컨트롤러가 반환한 결과를 받아 응답을 생성합니다.
  4. Interceptor: 컨트롤러 호출 전후에 추가적인 처리를 수행하는데 사용됩니다. 여러 인터셉터가 체인을 이루어 적용됩니다.
  5. Controller: 클라이언트의 요청을 처리하고, 비즈니스 로직을 실행한 뒤에 모델을 반환합니다.
  6. Exception: 컨트롤러나 서비스 등에서 예외가 발생한 경우, 예외 처리 로직이 동작합니다.
  7. Rest Controller Advice: 주로 RESTful 웹 서비스에서 발생하는 예외를 전역적으로 처리하는데 사용되는 어노테이션입니다.
  8. ExceptionHandler: RestControllerAdvice 클래스 내에서 예외를 처리하는 메서드를 지정하는 어노테이션으로, 예외가 발생했을 때 실행되어 적절한 응답을 생성합니다.

즉, 에러가 발생할경우 다시 한번 WAS를 실행시키면서 RestControllerAdvice라는 Controller를 찾아가는 것 입니다.

이떄, 저에게 발생햇던 문제는 LogAdvice에서 다시 한번 throw Error을 하도록 만들어 해당 Error가 처음의 WAS로 반환되어 RestControllerAdvice로 진행시키도록 로직을 설정했습니다.

 

수정한코드입니다.

@Aspect
@Log4j2
@Component
public class LogAdvice {
//    @Before( "execution(* com.seminarhub..*(..))")
//    public void logBefore(ProceedingJoinPoint proceedingJoinPoint){
//        log.info("========================");
//    }
    @Around( "execution(* com.seminarhub.service..*(..))")
    public Object logTime(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        log.info("========================");
        long start = System.currentTimeMillis();

        log.info("Target: " + proceedingJoinPoint.getTarget());
        log.info("Param: " + Arrays.toString(proceedingJoinPoint.getArgs()));

        //invoke Method
        Object result = null;

        try{
            result = proceedingJoinPoint.proceed();
        } catch (Throwable e){
            e.printStackTrace();
            throw e; // 다시 예외를 던지면서 상위 호출자에게 전달
        }

        long end = System.currentTimeMillis();
        log.info("TIME: "+ ( end - start ));
        return result;
    }

    @AfterThrowing(pointcut = "execution(* com.seminarhub..*(..))", throwing = "exception")
    public void logException(Exception exception){
        log.info("Exception Found");
        log.info("exception: "+exception);
    }
}

proceedingJoinPoint.proceed()에서 실행한 함수에서 에러가 발생하면 올바르게 에러를 Throw 처리하여 Error Exception이 RestControllerAdvice -> ExceptionHandler로 처리하도록 성공하였습니다.

 

여기까지 코딩을 완료하고, 글을 마무리하려고 하였으나, 문제점이 발생했습니다.

개선해야할점 발견, 1. 세미나 회원 정보수를 비정규화(DeNormalization)를 시키자.

개선해야할점을 찾게된 계기는, 세미나허브의 흐름들을 생각해보며 쿼리들을 직접 실행해보며 시간을 측정해보며 문제를 찾았습니다. 문제점은, 현재 세미나의 인원을 가져오는데 너무 많은 시간이 걸린다는 것 이었습니다.  

 

문제상황과 해결방안을 아래와 같이 정리해보았습니다.

  1. SELECT COUNT(*) 문으로 현재 세미나의 참여인원을 가져오는데, 만약 2만명이 포함된 세미나의 참여인원 count정보를 매번 호출한다면 불필요한 SQL 조회쿼리가 실행될것이고 0.1초 안에 Return받지 못할것임을 알아챘습니다..
  2. 주어진 Seminar Table의 세미나 참가자 수를 비정규화시켜 결제 작업 과정에서 비정규화된 값을 증가/감소 시키도록 수정하려고 합니다.

구체적으로 보면, 

select count(*) 로 데이터들을 조회해보며 트랜잭션 과정에 대해서 생각해보고 있었는데요, 

횟수 count

문제는 member_seminar의 Record 개수를 가져오는 count(*) 쿼리를 실행하였는데, 약 3.3초가 걸렸습니다.

먼저, 이와 같이 count(*) 쿼리를 사용했었던 이유는, 

처음 Entity를 설계할때, 세미나 회원 정보수는 데이터가 없이도 Member_Seminar Entity의 개수만으로도 현재 참여한 회원수를 알 수 있다고 생각하였습니다.

연관관계의 개수로 현재 참여인원을 계산(비효율적)

이렇게 현재 인원수를 Member_Seminar로만 연산함으로써 불필요한 데이터를 줄이고, 데이터의 변화가 일어나더라도 유연하게 수정할 수 있어 일종의 정규화 과정을 진행했었는데요,

만약, 조회를 진행할때 매번 Count(*) Query로 인해 0.3초 이상의 Latency(응답시간)이 소요된다면 해당 부분은 미리 계산해주어 count(*) Query를 대체해야할 필요가 잇다고 느꼈습니다. 

( 처음부터 이런 생각을 가졌다면 좋았겠지만, 코드들을 실행해보며 지금이라도 발견하여 다행이라는 생각이 들었습니다....  )

 

먼저, Seminar 참여인원을 DB의 정규화 과정, 비정규화과정을 진행할경우의 장단점에 대해 정리해보았습니다.

참가자수 정규화를 진행할경우, 각 테이블이 하나의 주제에 집중되었기에 관리하기가 쉽습니다. 단점으로는 조인연산과 각 데이터를 SQL로 해야합니다. 즉, 데이터의 삽입/삭제에 유연합니다. 데이터의 조회에는 Cost가 따라옵니다.

 

참가자수 비정규화는 데이터의 삽입/삭제에 유의해야하고, 데이터의 조회에는 효과적일 것 입니다.

세미나허브처럼 조회중심의 애플리케이션에서는 효과적일 것 이라는 생각이 들어서 비정규화를 사용하도록 하겠습니다.

 

1. 첫번쨰 비정규화 방법 : Seminar 테이블에 seminar_participants 컬럼을 추가하는 방안입니다. ( 이 방안을 채택하여 진행했습니다. ) 

첫번쨰 비정규화 방식으로 진행할경우의 코드를 작성해보겠습니다.

1-1. [com/seminarhub/entity/Seminar.java] 세미나 클래스입니다. participants_cnt를 추가해주었습니다.

@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = {"member_seminar_list"})
public class Seminar extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long seminar_no;
    @Column(length = 100, nullable = false)
    private String seminar_name;
    @Column(length = 500)
    private String seminar_explanation;
    @Column
    private Long seminar_price;
    @Column
    private Long seminar_maxParticipants;
    @Column
    private Long seminar_participants_cnt;
    @Column(nullable=true)
    private LocalDateTime del_dt;
    @OneToMany(mappedBy = "seminar", fetch = FetchType.LAZY)
    private List<Member_Seminar> member_seminar_list;
    public void setDel_dt(LocalDateTime del_dt){ this.del_dt = del_dt; }
    public void setSeminar_name(String seminar_name){
        this.seminar_name = seminar_name;
    }
}

 

이렇게 추가하고서, 또 다시 같은 문제를 피하고자 현재 참여인원(seminar_participants_cnt)를 추가함과 동시에 다른 방법은 없을까?라고 생각해보았습니다.

Seminar에 seminar_participants_cnt 컬럼을 추가하고나니, Seminar_Participants_Cnt를 정규화하는 것 또한 방법이 될 수 있을 것 같다고 느꼈습니다.

그림으로 보여드리면, 

생각났던 두번쨰 방법. 아예 테이블로 분리시키자. ( 이 방법은 사용안했습니다. )

코드로는 아래와 같은 Entity가 생성될 것 입니다.

정규화 과정 예시 : SeminarParticipantsCount Table

@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
public class SeminarParticipantsCount {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "seminar_no")
    private Seminar seminar;
    @Column
    private Long participants_cnt;
}

 

위와 같이 참가자인원을 다른 테이블로 분리하는 정규화를 진행할경우, 위에서 정리했듯이 조회연산에는 별도의 연산이 필요하면서, 데이터의 삽입/삭제에도 신경을 써주어야합니다. 

하지만, 왜 이렇게 하는경우도 있을까요? 

이유는, 참여인원이 모종의 이유로 조회락(Read Lock)이 걸려있다면, 세미나 정보 조회시 해당 레코드를 읽을 수 없기에  그렇습니다. 정규화 과정을 통해 다른 테이블로 분리하여 세미나 정보를 읽어올 수 있습니다.

 

첫번쟤 방법인 Seminar에 seminar_participants_cnt를 비정규화시켜서 확인하는 방법으로 진행합니다.

아쉽지만, 로직을 다시 수정하고 코드를 수정해야합니다. 수정됨에 따라 소스코드를 다시 작성했습니다.

seminar_participants_cnt가 비정규화됨에 따라 추가/수정 해야할 코드들을 정리해보았습니다.

  1. Seminar Entity : seminar_participants_cnt 추가
  2. SeminarDTO : seminar_participants_cnt 추가
  3. SeminarRepository : increaseParticipantsCnt / decreaseParticipantsCnt 추가 및 테스트코드 작성
  4. SeminarService : dtoToEntity, entityToDTO에 seminarParticipants 변환 코드 추가, increaseParticipantsCnt/decreaseParticipantsCnt 함수 추가 및 테스트코드 작성
  5. Member_SeminarService : 등록 성공시에 increaseParticipantsCnt 함수 실행 추가 및 테스트코드 작성
  6. Member_SeminarController : 결제시에 현재 인원 증가/감소 로직 추가 및 테스트코드 작성

1. Seminar Entity [ com/seminarhub/entity/Seminar.java ]

  • seminar_participants_cnt를 추가합니다.
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = {"member_seminar_list"})
public class Seminar extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long seminar_no;
    @Column(length = 100, nullable = false)
    private String seminar_name;
    @Column(length = 500)
    private String seminar_explanation;
    @Column
    private Long seminar_price;
    @Column
    private Long seminar_maxParticipants;
    @Column
    private Long seminar_participants_cnt;
    @Column(nullable=true)
    private LocalDateTime del_dt;
    @OneToMany(mappedBy = "seminar", fetch = FetchType.LAZY)
    private List<Member_Seminar> member_seminar_list;
    public void setDel_dt(LocalDateTime del_dt){ this.del_dt = del_dt; }
    public void setSeminar_name(String seminar_name){
        this.seminar_name = seminar_name;
    }
}

2. SeminarDTO에 seminar_participants_cnt를 추가 [ com/seminarhub/dto/SeminarDTO.java ]

  • seminar_participants_cnt를 추가합니다.
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class SeminarDTO {
    private Long seminar_no;
    private String seminar_name;
    private String seminar_explanation;
    private Long seminar_price;
    private Long seminar_max_participants;
    private Long seminar_participants_cnt;
    private Timestamp inst_dt;
    private Timestamp updt_dt;
    private LocalDateTime del_dt;
}

3. SeminarRepository : increaseParticipantsCnt/decreaseParticipantsCnt 추가

public interface SeminarRepository extends JpaRepository<Seminar, Long> {

    @Query("SELECT s FROM Seminar s WHERE s.seminar_name = :seminar_name AND del_dt is null")
    Optional<Seminar> findBySeminar_name(@Param("seminar_name") String seminar_name);
    
    @Query("SELECT s FROM Seminar s WHERE s.seminar_no = :seminar_no AND del_dt is null")
    Optional<Seminar> findBySeminar_no(@Param("seminar_no") Long seminar_no);

    @Transactional
    @Modifying
    @Query("UPDATE Seminar s SET s.seminar_participants_cnt = s.seminar_participants_cnt + 1 WHERE s.seminar_no = :seminar_no AND del_dt is null")
    void incrementParticipantsCnt(@Param("seminar_no") Long seminar_no);

    @Transactional
    @Modifying
    @Query("UPDATE Seminar s SET s.seminar_participants_cnt = s.seminar_participants_cnt - 1 WHERE s.seminar_no = :seminar_no AND del_dt is null")
    void decreaseParticipantsCnt(@Param("seminar_no") Long seminar_no);

}

3-1. SeminarRepositoryTests  [ com/seminarhub/repository/SeminarRepositoryTests.java ]

  • increaseParticipantsCnt 테스트코드 작성
  • decreaseParticipantsCnt 테스트코드 작성 
@SpringBootTest
public class SeminarRepositoryTests {
    @Autowired
    private SeminarRepository seminarRepository;
    @Autowired
    private SeminarQuerydslRepository seminarQuerydslRepository;
    private final String seminar_name= "SeminarTest";
    private final String seminar_remove_name= "SeminarRemoveTest";
    /**
     * Description : seminarRepository findBySeminar_name Test
     * Find Seminar by seminar_name
     *
     *     Hibernate:
     *     select
     *         s1_0.seminar_no,
     *         s1_0.del_dt,
     *         s1_0.inst_dt,
     *         s1_0.seminar_explanation,
     *         s1_0.seminar_max_participants,
     *         s1_0.seminar_name,
     *         s1_0.seminar_price,
     *         s1_0.updt_dt
     *     from
     *         seminar s1_0
     *     where
     *         s1_0.seminar_name=?
     *         and s1_0.del_dt is null
     *      Seminar(seminar_no=1, seminar_name=SeminarTest, seminar_explanation=null, del_dt=null)
     */
    @DisplayName("findBySeminar_name Test")
    @Test
    public void testFindBySeminar_name(){
        // given, when
        Optional<Seminar> seminar = seminarRepository.findBySeminar_name("SeminarTest");

        // then
        assertNotNull(seminar.get());
        System.out.println(seminar.get());
    }

    /**
     * Description :
     *     update
     *         seminar
     *     set
     *         seminar_participants_cnt=(seminar_participants_cnt+1)
     *     where
     *         seminar_no=?
     *         and del_dt is null
     */
    @DisplayName("incrementParticipantsCnt Test")
    @Test
    public void testIncreaseParticipantsCnt(){
        // given
        Seminar seminar = new Seminar();
        seminar.setSeminar_name("TestSeminarForRepositoryTest");
        seminar.setSeminar_participants_cnt(0); // Initial participant count is 0
        seminarRepository.save(seminar);

        // when
        seminarRepository.incrementParticipantsCnt(seminar.getSeminar_no());

        // then
        Optional<Seminar> updatedSeminar = seminarRepository.findById(seminar.getSeminar_no());
        Assertions.assertTrue(updatedSeminar.isPresent());
        Assertions.assertEquals(1, updatedSeminar.get().getSeminar_participants_cnt());
    }

    /**
     * Description :
     *     update
     *         seminar
     *     set
     *         seminar_participants_cnt=(seminar_participants_cnt-1)
     *     where
     *         seminar_no=?
     *         and del_dt is null
     */
    @DisplayName("decreaseParticipantsCnt Test")
    @Test
    public void testDecreaseParticipantsCnt(){
        // given
        Seminar seminar = new Seminar();
        seminar.setSeminar_name("TestSeminarForRepositoryTest");
        seminar.setSeminar_participants_cnt(2);
        seminarRepository.save(seminar);

        // when
        seminarRepository.decreaseParticipantsCnt(seminar.getSeminar_no());

        // then
        Optional<Seminar> updatedSeminar = seminarRepository.findById(seminar.getSeminar_no());
        Assertions.assertTrue(updatedSeminar.isPresent());
        Assertions.assertEquals(1, updatedSeminar.get().getSeminar_participants_cnt());
    }
}

3. SeminarService.java [ com/seminarhub/service/SeminarService.java ]

  • 추가사항
    • seminar_participants_cnt : 현재 참여자 추가합니다.
    • increaseParticipantCnt(long seminar_no) : 현재 참여자 증가함수 추가합니다.
    • decreaseParticipantCnt(long seminar_no) : 현재 참여자 감소함수 추가합니다.
    • dtoToEntity : seminar_max_participants : Java 8 에 등장한 Default Method로 dto를 Entity로 변환시키는 로직을 추가합니다.
    • entityToDTO :  seminar_max_participants : Java 8 에 등장한 Default Method로  entity를 DTO로 변환시키는 로직을 추가합니다.
public interface SeminarService {
    Long register(SeminarDTO seminarDTO) throws DuplicateSeminarException;
    SeminarDTO get(String seminar_name);
    void modify(SeminarDTO seminarDTO);
    void remove(String seminar_name);
    void increaseParticipantsCnt(long seminar_no);
    void decreaseParticipantsCnt(long seminar_no);
    default Seminar dtoToEntity(SeminarDTO seminarDTO){
        Seminar seminar = Seminar.builder()
                .seminar_no(seminarDTO.getSeminar_no())
                .seminar_name(seminarDTO.getSeminar_name())
                .seminar_explanation(seminarDTO.getSeminar_explanation())
                .seminar_price(seminarDTO.getSeminar_price())
                .seminar_maxParticipants(seminarDTO.getSeminar_max_participants())
                .seminar_participants_cnt(seminarDTO.getSeminar_participants_cnt())
                .build();
        return seminar;
    }
    default SeminarDTO entityToDTO(Seminar seminar){
        SeminarDTO seminarDTO = SeminarDTO.builder()
                .seminar_no(seminar.getSeminar_no())
                .seminar_name(seminar.getSeminar_name())
                .seminar_explanation(seminar.getSeminar_explanation())
                .seminar_max_participants(seminar.getSeminar_maxParticipants())
                .seminar_participants_cnt(seminar.getSeminar_participants_cnt())
                .seminar_price(seminar.getSeminar_price())
                .build();
        return seminarDTO;
    }
}

3-1. SeminarServiceImpl.java [ com/seminarhub/service/SeminarServiceImpl.java ]

  • 추가사항
    • increaseParticipantsCnt함수 추가
    • increaseParticipantsCnt함수 추가
@Service
@Log4j2
@RequiredArgsConstructor
public class SeminarServiceImpl implements  SeminarService{
    private final SeminarRepository seminarRepository;
    @Override
    public SeminarDTO get(String seminar_name) {
        Optional<Seminar> result = seminarRepository.findBySeminar_name(seminar_name);
        if(result.isPresent()){
            return entityToDTO(result.get());
        }
        return null;
    }
    @Override
    public void increaseParticipantsCnt(long seminar_no) {
        seminarRepository.incrementParticipantsCnt(seminar_no);
    }
    @Override
    public void decreaseParticipantsCnt(long seminar_no) {
        seminarRepository.decreaseParticipantsCnt(seminar_no);
    }
}

3-2. SeminarServiceTests.java [ com/seminarhub/service/SeminarServiceTests.java ]

  • 추가사항
    • testIncreaseParticipantsCnt에 대한 테스트 함수 호출
    • testDecreaseParticipantsCnt에 대한 테스트 함수 호출
@SpringBootTest
public class SeminarServiceTests {
    @MockBean
    private SeminarRepository seminarRepository;
    @MockBean
    private SeminarQuerydslRepository seminarQuerydslRepository;
    @Autowired
    private SeminarService seminarService;
    
    @DisplayName("Seminar Service Get Test")
    @Test
    public void testGetSeminar(){
        // Given
        Seminar existingSeminar = Seminar.builder()
                .seminar_no((long)123L)
                .seminar_name("SeminarTest")
                .seminar_explanation("SeminarExplanation")
                .build();
        Mockito.when(seminarRepository.findBySeminar_name("SeminarTest")).thenReturn(Optional.of(existingSeminar));
        // when
        SeminarDTO seminarDTO = seminarService.get("SeminarTest");
        // then
        Assertions.assertEquals(seminarDTO.getSeminar_no(), 123L);
        Assertions.assertEquals(seminarDTO.getSeminar_name(), "SeminarTest");
        Assertions.assertEquals(seminarDTO.getSeminar_explanation(), "SeminarExplanation");

        verify(seminarRepository).findBySeminar_name("SeminarTest");
    }

    @DisplayName("Seminar Service Increase Participants Count Test")
    @Test
    public void testIncreaseParticipantsCnt() {
        // Given
        Seminar seminar = Seminar.builder()
                .seminar_no((long) 123L)
                .seminar_name("SeminarTest")
                .build();
        Mockito.when(seminarRepository.findById(123L)).thenReturn(Optional.of(seminar));
        // When
        seminarService.increaseParticipantsCnt(123L);
        // Then
        verify(seminarRepository).incrementParticipantsCnt(123L);
    }

    @DisplayName("Seminar Service Decrease Participants Count Test")
    @Test
    public void testDecreaseParticipantsCnt() {
        // Given
        Seminar seminar = Seminar.builder()
                .seminar_no((long) 123L)
                .seminar_name("SeminarTest")
                .build();
        Mockito.when(seminarRepository.findById(123L)).thenReturn(Optional.of(seminar));
        // When
        seminarService.decreaseParticipantsCnt(123L);
        // Then
        verify(seminarRepository).decreaseParticipantsCnt(123L);
    }

}

4. Member_SeminarService.java [ com/seminarhub/service/Member_SeminarService.java ]

  • 이 부분은 기존과 동일합니다.
public interface Member_SeminarService {
    Long register(Member_SeminarDTO Member_SeminarDTO);
    Member_SeminarDTO get(long member_seminarno);
    void modify(Member_SeminarDTO Member_SeminarDTO);
    Long registerForSeminar(MemberSeminarRegisterRequestDTO memberSeminarRegisterRequestDTO) throws SeminarRegistrationFullException;
    void remove(long member_seminarno);
    default Member_Seminar dtoToEntity(Member_SeminarDTO member_seminarDTO){
        Member member = Member.builder()
                .member_no(member_seminarDTO.getMember_no())
                .build();

        Seminar seminar = Seminar.builder()
                .seminar_no(member_seminarDTO.getSeminar_no())
                .build();

        Member_Seminar_Payment_History member_seminar_payment_history = Member_Seminar_Payment_History.builder()
                .member_seminar_payment_history_no(member_seminarDTO.getMember_seminar_payment_history_no())
                .build();

        Member_Seminar member_seminar = Member_Seminar.builder()
                .member(member)
                .seminar(seminar)
                .member_seminar_payment_history(member_seminar_payment_history)
                .build();
        return member_seminar;
    }

    default Member_SeminarDTO entityToDTO(Member_Seminar member_seminar){
        Member_SeminarDTO member_seminarDTO = Member_SeminarDTO.builder()
                .member_seminar_no(member_seminar.getMember_seminar_no())
                .seminar_no(member_seminar.getSeminar().getSeminar_no())
                .member_no(member_seminar.getMember().getMember_no())
                .build();
        return member_seminarDTO;
    }

}

4-1. Member_ServiceImpl.java [ com/seminarhub/service/Member_SeminarServiceImpl.java ]

  • 변경사항
    • 기존에는 직접 member_seminar의 연관된 개수를 SQL호출을 통해 구했지만, 비정규화를 통해 현재 참여인원수를 별도의 쿼리 없이 바로 확인할 수 있습니다.
    • 현재 인원수를 세미나에 등록과 함께 현재 인원수를 증가시켜줍니다.
 @Transactional
@Override
public Long registerForSeminar(MemberSeminarRegisterRequestDTO memberSeminarRegisterRequestDTO) throws SeminarRegistrationFullException {
    log.info(memberSeminarRegisterRequestDTO.toString());
    SeminarDTO seminarDTO = seminarService.get(memberSeminarRegisterRequestDTO.getSeminar_name());
    MemberDTO memberDTO = memberService.get(memberSeminarRegisterRequestDTO.getMember_id());
    if (seminarDTO == null || memberDTO == null) {
        // 예외 처리: 세미나나 멤버가 존재하지 않는 경우
        throw new SeminarRegistrationFullException("There are no Info Of Member || Seminar");
    }

    //신청하려는 Seminar가 아직 남아있는지 확인해야하고, Transactional로 격리상태를 유지해야합니다.
    Long currentParticipateCnt = seminarDTO.getSeminar_participants_cnt();
    if (seminarDTO.getSeminar_max_participants() < currentParticipateCnt) {
        throw new SeminarRegistrationFullException("SeminarInfo:" + seminarDTO.getSeminar_name() + "is already " + currentParticipateCnt + "/" + seminarDTO.getSeminar_max_participants() + " full. Registration failed.");
    }

    Member_Seminar_Payment_History member_seminar_payment_history = Member_Seminar_Payment_History.builder()
            .member_seminar_payment_history_amount(seminarDTO.getSeminar_price())
            .build();
    member_seminar_payment_historyRepository.save(member_seminar_payment_history);

    Member_SeminarDTO member_seminarDTO = Member_SeminarDTO.builder()
            .member_no(memberDTO.getMember_no())
            .seminar_no(seminarDTO.getSeminar_no())
            .member_seminar_payment_history_no(member_seminar_payment_history.getMember_seminar_payment_history_no())
            .build();
    Member_Seminar member_seminar = dtoToEntity(member_seminarDTO);
    member_seminarRepository.save(member_seminar);
    return member_seminar.getMember_seminar_no();
}

4-2. Member_SeminarServiceTests

  • 변경사항
    • 세미나에 참석시 참여인원을 1 증가시킵니다. 이때 데이터 정합성을 위해 동기화(Syncrhonization)을 유지시키는것이 매우 중요한데, 아래에 계속해서 다룹니다.
@DisplayName("Member_Seminar Service RegisterForSeminar Test")
@Test
public void testRegisterForSeminar() throws SeminarRegistrationFullException {
    // given
    Member member = Member.builder()
            .member_id("passionfruit200@naver.com")
            .build();

    Seminar seminar = Seminar.builder()
            .seminar_name("SeminarTest")
            .build();

    MemberDTO memberDTO = MemberDTO.builder()
            .member_id("passionfruit200@naver.com")
            .build();

    SeminarDTO seminarDTO = SeminarDTO.builder()
            .seminar_name("SeminarTest")
            .seminar_no(3L)
            .seminar_max_participants(40L) //SET seminar_max_participants = 40;
            .seminar_participants_cnt(23L)
            .seminar_price(10000L)
            .build();

    Member_Seminar member_seminar = Member_Seminar.builder()
            .member_seminar_no(1L)
            .member(member)
            .seminar(seminar)
            .build();

    Member_Seminar_Payment_History member_seminar_payment_history = Member_Seminar_Payment_History.builder()
                    .member_seminar_payment_history_no(4L)
                    .member_seminar_payment_history_amount(seminarDTO.getSeminar_price())
                    .build();

    MemberSeminarRegisterRequestDTO memberSeminarRegisterRequestDTO= MemberSeminarRegisterRequestDTO.builder()
            .member_id("passionfruit200@naver.com")
            .seminar_name("SeminarTest")
            .build();

    Mockito.when(memberService.get("passionfruit200@naver.com")).thenReturn(memberDTO);
    Mockito.when(seminarService.get("SeminarTest")).thenReturn(seminarDTO);
    Mockito.when(member_seminar_payment_historyRepository.save(any(Member_Seminar_Payment_History.class))).thenReturn(member_seminar_payment_history);
    Mockito.doNothing().when(seminarService).increaseParticipantsCnt(seminarDTO.getSeminar_no());
    Mockito.when(member_seminarRepository.save(any(Member_Seminar.class))).thenReturn(member_seminar);

    // when
    Long result = memberSeminarService.registerForSeminar(memberSeminarRegisterRequestDTO);

    // then
    verify(memberService).get("passionfruit200@naver.com");
    verify(seminarService).get("SeminarTest");
    verify(member_seminar_payment_historyRepository).save(any(Member_Seminar_Payment_History.class));
    verify(seminarService).increaseParticipantsCnt(seminarDTO.getSeminar_no());
    verify(member_seminarRepository).save(any(Member_Seminar.class));
}

마무리

이로써 현재 참여인원을 비정규화함으로써 코드를 모두 성공적으로 변경했습니다.

다음 글에서는 현재는 단순히 @Transactional을 적용하여 DB의 기본 수준 격리 레벨을 적용하고 있습니다.

다음 글에서는, 어떤 Transactional 격리레벨(READ_UNCOMITTED, READ_COMMITED, REPEATABLE READ, ISOLATION) 을 시퀀스 다이어그램으로 어느레벨의 Transaction의 격리 수준을 적용할 수 있을지 알아보겠습니다.

+ Recent posts