Post

JUnit5 과 은행 어플리케이션

프로젝트 목적

  • JUnit5 를 이용한 은행 거래 로직 단위, 통합 테스트
  • 인프런 강의를 기초로 기능과 로직을 추가해나가는 것이 목적

프로젝트 구조

image

User 와 Account 는 양방향 연관관계 Account 와 Transaction 은 다대일 관계이며, 제약조건이 존재하지 않는다. 보낸 Account, 받는 Account 총 2개의 Account 엔티티를 맵핑하기 위해서 제약조건을 해제한다.

image

ATM 을 통한 입금, 출금은 거래내역 중 한 쪽이 null 로 찍히게 된다.

OpenAPI

image

깃허브 URL

깃허브 URL

H2 데이터베이스 설정

관련 포스팅 참고

해당 프로젝트에서는 테스트 환경으로 간단하게 다음과 같이 설정한다. H2 데이터베이스를 Server 가 아니라 Memory 로 실행한다.

1
2
3
4
5
6
7
8
9
10
spring:
  # h2 데이터베이스 연결
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:mem:test;MODE=MySQL
    username: sa
    password:
  h2:
    console:
      enabled: true

application-dev.yml 은 실제 데이터를 확인하기 위해 아래와 같이 사용했다.

1
2
3
4
5
6
7
spring:
  # h2 데이터베이스 연결
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:tcp://localhost:1521/bank;MODE=MySQL
    username: sa
    password:

Model 설정

BaseTime

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
 * {@code @MappedSuperclass}
 * - JPA Entity 클래스들이 해당 클래스에 선언된 필드를 인식하게 한다.
 * {@code @EntityListeners}
 * - AuditingEntityListener.class 가 콜백 리스너로 지정된다.
 * - Entity 에서 어떤 이벤트가 발생할 때 특정 로직을 수행한다.
 */
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTime implements Serializable {

    @CreatedDate
    @Column(name="createdAt", updatable = false, nullable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(name="updatedAt", nullable = false)
    private LocalDateTime modifiedAt;

}

User

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package com.example.banksample.domain.user;

import com.example.banksample.common.BaseTime;
import com.example.banksample.domain.account.Account;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

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

/**
 * 엔티티로 등록된 User 객체 생성 시, new 를 사용하기 때문에 빈 생성자가 필요하다.
 * 단, DB 에 직접 영향을 미치는 객체기 때문에 임의로 생성되는 것을 막기 위해 Protected 접근 제한자를 부여한다.
 */
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "tbl_user")
@Getter
public class User extends BaseTime {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Column(unique = true, nullable = false, length = 20)
	private String username;

	@Column(nullable = false, length = 60)
	private String password;

	@Column(nullable = false, length = 20)
	private String email;

	@Column(nullable = false, length = 20)
	private String fullname;

	@Column(nullable = false)
	@Enumerated(EnumType.STRING)
	private UserEnum role;

	@OneToMany(mappedBy = "user")
	private List<Account> accounts = new ArrayList<>();

	public void add(Account account) {
		this.accounts.add(account);
	}

	@Builder
	public User(Long id, String username, String password, String email, String fullname, UserEnum role) {
		this.id = id;
		this.username = username;
		this.password = password;
		this.email = email;
		this.fullname = fullname;
		this.role = role;
	}
}


Account

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package com.example.banksample.domain.account;

import com.example.banksample.common.BaseTime;
import com.example.banksample.domain.user.User;
import com.example.banksample.handler.exception.CustomApiException;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "tbl_account")
@Getter
public class Account extends BaseTime {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Column(unique = true, nullable = false, length = 10)
	private Long number;        // 계좌번호

	@Column(nullable = false, length = 6)
	private Long password;      // 계좌 비밀번호

	@Column(nullable = false)
	private Long balance;       // 잔액 (기본값을 1000 으로 부여)

	/*
	 * account.getUser().어떤필드() => 이 시점에 Lazy 로딩 발생!
	 * 즉, @Getter 를 통해서 내가 제어권을 획득한다.
	 */
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "fk_account_to_user"))
	private User user;


	/**
	 * User 와 양방향 맵핑을 맺기 위해,
	 * 영속성 컨텍스트에 저장된 Account 에 User 객체를 주입한다.
	 * User 엔티티의 @Id 값이 맵핑된다. 따로 지정하지 않는다면 user_id 라는 컬럼이 생성 & 맵핑된다.
	 */
	public void setUser(User user) {
		this.user = user;
	}

	@Builder
	public Account(Long id, Long number, Long password, Long balance, User user) {
		this.id = id;
		this.number = number;
		this.password = password;
		this.balance = balance;
		this.user = user;
	}

	/**
	 * 계좌 소유자 일치 여부 확인
	 */
	public void checkOwner(Long userId) {
		// id 를 조회하는 경우 지연로딩 발생하지 않는다.
		if (this.user.getId().longValue() != userId.longValue()) {
			throw new CustomApiException("계좌의 소유자가 아닙니다.");
		}
	}

	/*
	 * 계좌 입금
	 * */
	public void deposit(Long amount) {
		this.balance += amount;
	}

	public void checkPassword(Long password) {
		if (this.password.longValue() != password.longValue()) {
			throw new CustomApiException("계좌 비밀번호가 일치하지 않습니다.");
		}
	}

	public void checkBalance(Long amount) {
		if (this.balance < amount) {
			throw new CustomApiException("계좌 잔액이 부족합니다.");
		}
	}

	public void withdraw(Long amount) {
		checkBalance(amount);
		this.balance -= amount;
	}

}

Transaction

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
package com.example.banksample.domain.transaction;


import com.example.banksample.common.BaseTime;
import com.example.banksample.domain.account.Account;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "tbl_transaction")
@Getter
public class Transaction extends BaseTime {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
	private Account withdrawAccount;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
	private Account depositAccount;

	@Column(name = "amount", nullable = false)
	private Long amount;

	@Column(name = "withdraw_balance")
	private Long withdrawAccountBalance;

	@Column(name = "deposit_balance")
	private Long depositAccountBalance;

	/**
	 * WITHDRAW: 출금
	 * DEPOSIT: 입금
	 * TRANSFER: 이체
	 * LOG: 내역조회
	 */
	@Column(name = "type", nullable = false)
	@Enumerated(EnumType.STRING)
	private TransactionEnum type;

	/**
	 * 계좌가 삭제되는 경우도, 로그는 남아야한다.
	 */
	@Column(name = "tx_sender")
	private String sender;
	@Column(name = "tx_receiver")
	private String receiver;
	@Column(name = "tx_tel")
	private String tel;

	@Builder
	public Transaction(Long id, Account withdrawAccount, Account depositAccount, Long amount, Long withdrawAccountBalance, Long depositAccountBalance, TransactionEnum type, String sender, String receiver, String tel) {
		this.id = id;
		this.withdrawAccount = withdrawAccount;
		this.depositAccount = depositAccount;
		this.amount = amount;
		this.withdrawAccountBalance = withdrawAccountBalance;
		this.depositAccountBalance = depositAccountBalance;
		this.type = type;
		this.sender = sender;
		this.receiver = receiver;
		this.tel = tel;
	}

	@Override
	public String toString() {
		return getClass().getSimpleName() + "(" +
				"id = " + getId() + ", " +
				"createdAt = " + getCreatedAt() + ", " +
				"modifiedAt = " + getModifiedAt() + ", " +
				"amount = " + getAmount() + ", " +
				"withdrawAccountBalance = " + getWithdrawAccountBalance() + ", " +
				"depositAccountBalance = " + getDepositAccountBalance() + ", " +
				"type = " + getType() + ", " +
				"sender = " + getSender() + ", " +
				"receiver = " + getReceiver() + ", " +
				"tel = " + getTel() + ")";
	}
}

SecurityConfig 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@Configuration
@Slf4j
public class SecurityConfig {

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        log.info("[디버그] BCryptPasswordEncoder 빈을 등록합니다.");
        return new BCryptPasswordEncoder();
    }
    /**
     * JWT 필터 등록
     * */

    /**
     * Session 을 사용하지 않고 JWT 를 사용한다.
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity, HandlerMappingIntrospector introspector) throws Exception {
        MvcRequestMatcher.Builder mvcMatcherBuilder = new MvcRequestMatcher.Builder(introspector);
        // iframe 을 허용하지 않는다.
        httpSecurity.headers(authz -> authz.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable));
        // csrf 가 작동하면 postman api 테스트를 할 수 없다.
        httpSecurity.csrf(AbstractHttpConfigurer::disable);
        //
        httpSecurity.cors(authz -> authz.configurationSource(configurationSource()));
        // JSessionID 를 서버에서 관리하지 않는다.
        httpSecurity.sessionManagement(authz -> authz.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        // React 와 같은 클라이언트로 요청한다.
        httpSecurity.formLogin(AbstractHttpConfigurer::disable);
        // httpBasic 은 브라우저가 팝업창을 이용해서 사용자 인증을 진행하는데, 허용하지 않는다.
        httpSecurity.httpBasic(AbstractHttpConfigurer::disable);
        // 서버로 요청을 받는 URL 패턴을 검사한다.
        httpSecurity.authorizeHttpRequests(authz ->
            authz
                .requestMatchers(mvcMatcherBuilder.pattern("/api/**")).authenticated()
                // .requestMatchers("/api/**").authenticated()
                // 더 이상 ROLE_ prefix 를 사용하지 않는다..
                // .requestMatchers(mvcMatcherBuilder.pattern("/api/admin/**")).hasRole(String.valueOf(UserEnum.ADMIN))
                .anyRequest().permitAll()
        );
        /**
         * '인증' 과정 중 에러가 발생하는 상황을 인터셉트 한다.
         * -> 에러 발생 상황을 컨트롤 할 수 있다.
         * */
        httpSecurity.exceptionHandling(authz -> authz.authenticationEntryPoint((request, response, authException) -> CustomResponseUtil.unAuthenticated(response, "로그인이 필요합니다.")));
        return httpSecurity.build();
    }

    public CorsConfigurationSource configurationSource() {
        log.info("[디버그] CorsConfigurationSource 가 SecurityFilterChain 에 등록합니다.");
        CorsConfiguration configuration = new CorsConfiguration();
        // 모든 HTTP 헤더를 허용한다.
        configuration.addAllowedHeader("*");
        // GET, POST, PUT, DELETE, OPTIONS 를 허용한다.
        configuration.addAllowedMethod("*");
        // 모든 IP 주소를 허용한다.
        // TODO: 배포 시, 프론트엔드 IP 만 추가하는 걸로 수정
        configuration.addAllowedOriginPattern("*");
        // 클라이언트에서 쿠키 요청 허용
        configuration.setAllowCredentials(true);

        /**
         * 모든 엔드포인트는 도메인 + '/' 로 시작하기 때문에,
         * '/**' 에 cors 관련 설정을 추가한다.
         * */
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

응답 유틸

인증, 인가 과정에서 에러가 발생하는 경우 사용할 에러 객체 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Slf4j
public class CustomResponseUtil {

    private CustomResponseUtil() {
    }

    public static void unAuthenticated(HttpServletResponse response, String message)  {

        /**
         * 파싱 관련 에러가 나면, 여기선 할 수 있는게 없다.
         * */
        try {
            ObjectMapper objectMapper = new ObjectMapper();
            ResponseDTO<?> responseDTO = new ResponseDTO<>(-1, message, null);
            String responseBody = objectMapper.writeValueAsString(responseDTO);

            response.setContentType("application/json; charset=utf-8");
            response.setStatus(401);
            response.getWriter().println(responseBody);
        }
        // TODO: Logback.xml 파일 설정해서 에러 상황 파일로 남기기
        catch (Exception e) {
            log.error("[에러] -> {}", e.getMessage());
        }

    }
}

SecurityConfig Test 작성

requestMacthers 에 지정한 값에 인증, 인가 과정이 제대로 동작하는지 테스트

  • MockMvc 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
 * {@code @AutoConfigureMockMvc} 를 사용해야 {@code MockMvc} 를 주입받아서 사용할 수 있다.
 */
@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@Slf4j
class SecurityConfigTest {

    @Autowired
    private MockMvc mvc;

    @Test
    void authenticate_test() throws Exception {
        // given
        // when
        ResultActions resultActions = mvc.perform(get("/api/hello"));
        String body = resultActions.andReturn().getResponse().getContentAsString();
        int status = resultActions.andReturn().getResponse().getStatus();
        log.info("body -> {}", body);
        // then
        // 401 에러가 발생한 건지 비교
        Assertions.assertThat(status).isEqualTo(401);
    }

    @Test
    void authorize_test() throws Exception {
        // given
        // when
        ResultActions resultActions = mvc.perform(get("/api/admin/hello"));
        String body = resultActions.andReturn().getResponse().getContentAsString();
        int status = resultActions.andReturn().getResponse().getStatus();
        log.info("body -> {}", body);
        // then
        Assertions.assertThat(status).isEqualTo(401);
    }

}

image

회원가입 서비스 및 테스트

  • JpaRepository 생성
  • 상속받는 CrudRepository 에 이미 단순 CRUD 작업은 정의되어 있기에 테스트 필요 X

회원가입하는 매우 단순한 로직을 작성한다.

  • DTO 를 Entity 로 변환하고
  • 에러 객체를 생성했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 1. UserServiceImplV1.class
@Override
@Transactional
public JoinResponseDTO signUp(JoinRequestDTO joinRequestDTO) {
    // 1. 동일한 사용자 이름이 존재하는지 검사
    Optional<User> userOptional = userRepository.findByUseranme(joinRequestDTO.getUsername());
    // 사용자 이름이 중복된 경우
    if (userOptional.isPresent()) {
        throw new CustomApiException("동일한 사용자 이름이 존재합니다.");
    }
    // 2. 패스워드 인코딩 + 회원가입 진행
    User userPS = userRepository.save(joinRequestDTO.toEntity(bCryptPasswordEncoder));
    // 3. DTO 응답
    return new JoinResponseDTO(userPS);
}

// 2.
@RestControllerAdvice
@Slf4j
public class CustomExceptionHandler {

    @ExceptionHandler(CustomApiException.class)
    public ResponseEntity<?> apiException(CustomApiException ex) {
        log.error("error -> {}", ex.getMessage());
        return new ResponseEntity<>(new ResponseDTO<>(-1, ex.getMessage(), null), HttpStatus.BAD_REQUEST);
    }

}

위 코드를 테스트해본다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
 * Mockito 환경에서 테스트 진행
 */
@ExtendWith(MockitoExtension.class)
@Slf4j
class UserServiceTest {

    @InjectMocks // 가짜 환경을 주입받을 대상을 지정한다.
    private UserServiceImplV1 userServiceV1;

    @Mock // 메모리에 띄울 가짜 환경
    private UserRepository userRepository;

    // @Spy: 가짜 객체를 생성하는 @Mock 과 달리 스프링 컨테이너에 있는 실제 빈을 주입한다.
    @Spy
    private BCryptPasswordEncoder bCryptPasswordEncoder;

    @Test
    @DisplayName("회원가입_테스트")
    void signUp_test() throws Exception {
        // given
        JoinRequestDTO joinRequestDTO = new JoinRequestDTO();
        joinRequestDTO.setUsername("jeongjin");
        joinRequestDTO.setFullname("kim jeongjin");
        joinRequestDTO.setEmail("admin@gmail.");
        joinRequestDTO.setPassword("1234");

        // stub_1
        // repository 에 대한 테스트가 아니므로, any() 를 인자로 넣는다.
        when(userRepository.findByUseranme(any())).thenReturn(Optional.empty());

        // stub_2
        // User 객체가 리턴되도록 한다.
        User user = User.builder()
            .id(1L)
            .username("jeongjin")
            .password("1234")
            .fullname("kim jeongjin")
            .role(UserEnum.CUSTOMER)
            .build();
        when(userRepository.save(any())).thenReturn(user);

        // when
        JoinResponseDTO joinResponseDTO = userServiceV1.signUp(joinRequestDTO);
        log.info("[*] joinResponseDTO -> {}", joinResponseDTO.toString());

        // then
        Assertions.assertThat(joinResponseDTO.getId()).isEqualTo(1L);
        Assertions.assertThat(joinResponseDTO.getUsername()).isEqualTo("jeongjin");
    }
}

image

@Data 무분별한 사용 주의

  1. 무분별한 @Setter 남용 방지
  2. @ToString 으로 인한 양방향 연관관계 시, 무한 순환 참조 예방
  3. @EqualsAndHashCodeMutable 한 객체에 사용하는 경우 문제 발생
    1. 동일한 객체의 필드 값을 변경시키면 hashcode 값이 바뀐다.

UserService 리팩토링

매번 테스트 코드에 가짜 User 객체를 만드는 것은 비효율적이다. Dummy 로 사용할 다음 객체를 생성해서, 상속받아서 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class DummyObject {

  /**
   * 엔티티를 데이터베이스에 저장할 때 사용한다.
   *
   * @param username
   * @param fullname
   * @return
   */
  protected User newUser(String username, String fullname) {
      BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
      String encPassword = passwordEncoder.encode("1234");
      return User.builder()
          .username(username)
          .email("mockuser@nate.com")
          .password(encPassword)
          .fullname(fullname)
          .role(UserEnum.CUSTOMER)
          .build();
  }

  /**
   * 테스트 객체에서 stub 용도로 사용한다.
   *
   * @param id
   * @param username
   * @param fullname
   * @return
   */
  protected User newMockUser(Long id, String username, String fullname) {
      BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
      String encPassword = passwordEncoder.encode("1234");
      return User.builder()
          .id(id)
          .username(username)
          .email("mockuser@nate.com")
          .password(encPassword)
          .fullname(fullname)
          .role(UserEnum.CUSTOMER)
          .build();
  }

}

테스트 코드 환경에서는 stub 을 위해 newMockUser 를 생성해서 사용하면 된다.

1
2
3
// stub_2
User user = newMockUser(1L, "jeongjin", "kim jeongjin");
when(userRepository.save(any())).thenReturn(user);

User컨트롤러

1
2
3
4
5
6
@PostMapping("/signUp")
@ResponseStatus(HttpStatus.CREATED)
public ResponseEntity<?> signUp(@RequestBody JoinRequestDTO joinRequestDTO) {
    JoinResponseDTO joinResponseDTO = userServiceV1.signUp(joinRequestDTO);
    return new ResponseEntity<>(new ResponseDTO<>(1, "회원가입 완료", joinResponseDTO), HttpStatus.CREATED);
}

유효성 검사

AOP 를 적용한다.

  • 컨트롤러에서 파라미터로 받는 DTO 에 springboot-validation 이 지원하는 유효성 검사를 위한 어노테이션을 작성한다.
  • 컨트롤러 파라미터에 @Valid 어노테이션, 그리고 BindingResult 을 HashMap 에 담는다.
  • @Aspect 클래스를 생성한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// CustomValidationAdvice.java
/**
 * 유효성 검사는 body 가 존재하는 곳에서만 진행한다.
 */
@Component
@Aspect
public class CustomValidationAdvice {

    // PostMapping 에 관한 조인포인트
    @Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)")
    public void postMapping() {
    }

    // PutMapping 에 관한 조인포인트
    @Pointcut("@annotation(org.springframework.web.bind.annotation.PutMapping)")
    public void putMapping() {
    }


    /**
     * Advice 에 사용할 수 있는 어노테이션은 다음과 같다.
     *
     * @Before: JoinPoint 이전에 호출
     * @After: JoinPoint 이후 호출
     * @Around: JoinPoint 이전, 이후 호출
     * @AfterThrowing: JoinPoint 가 예외를 던지는 경우 호출
     * @AfterReturing: 메서드가 성공적으로 실행되는 경우 호출
     */
    @Around("postMapping() || putMapping()")
    public Object validationAdvice(ProceedingJoinPoint pjp) throws Throwable {
        Object[] args = pjp.getArgs();

        for (Object arg : args) {
            if (arg instanceof BindingResult) {
                BindingResult bindingResult = (BindingResult) arg;
                if (bindingResult.hasErrors()) {
                    Map<String, String> errorMap = new HashMap<>();
                    for (FieldError fieldError : bindingResult.getFieldErrors()) {
                        errorMap.put(fieldError.getField(), fieldError.getDefaultMessage());
                    }
                    throw new CustomValidationException("유효성 검사에 실패했습니다.", errorMap);
                }
            }
        }
        return pjp.proceed();
    }
}

CustomValidationException

Validation 관련 에러를 담당할 객체를 생성한다.

1
2
3
4
5
6
7
8
9
10
@Getter
public class CustomValidationException extends RuntimeException {

    private Map<String, String> errorMap;

    public CustomValidationException(String message, Map<String, String> errorMap) {
        super(message);
        this.errorMap = errorMap;
    }
}

CustomExceptionHandler

CustomValidationException 를 핸들러에 등록한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestControllerAdvice
@Slf4j
public class CustomExceptionHandler {

    @ExceptionHandler(CustomApiException.class)
    public ResponseEntity<?> apiException(CustomApiException ex) {
        log.error("error -> {}", ex.getMessage());
        return new ResponseEntity<>(new ResponseDTO<>(-1, ex.getMessage(), null), HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(CustomValidationException.class)
    public ResponseEntity<?> apiException(CustomValidationException ex) {
        log.error("error -> {}", ex.getMessage());
        return new ResponseEntity<>(new ResponseDTO<>(-1, ex.getLocalizedMessage(), ex.getErrorMap()), HttpStatus.BAD_REQUEST);
    }

}

postman 테스트

image

image

정규식 추가

정규식을 사용해서 좀 더 세밀하게 유효성 검사를 할 수 있다. 일단, 테스트 코드를 작성해보자.

1
2
3
4
5
6
7
@Test
@DisplayName("한글만 통과")
void only_korean_test() throws Exception {
    String value = "I am 신뢰에요";
    boolean result = Pattern.matches("/^[가-힣]+$/g", value);
    Assertions.assertThat(result).isTrue();
}

image 아래와 같이 다양한 케이스를 등록해서 테스트하여, 특정 로직의 신뢰도를 증가시킬 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
@Slf4j
class RegexTest {

  String value = "I am 신뢰에요 100%";

  @Test
  @DisplayName("한글만 통과")
  void only_korean_test() throws Exception {
      boolean result = Pattern.matches("^[가-힣]+$", value);
      Assertions.assertThat(result).isTrue();
  }

  @Test
  @DisplayName("한글 있으면 실패")
  void never_korean_test() throws Exception {
      boolean result = Pattern.matches("^[^가-힣]+$", value);
      Assertions.assertThat(result).isTrue();
  }

  @Test
  @DisplayName("영어만 통과")
  void only_english_test() throws Exception {
      boolean result = Pattern.matches("^[a-zA-Z]+$", value);
      Assertions.assertThat(result).isTrue();
  }

  @Test
  @DisplayName("영어 있으면 실패")
  void never_english_test() throws Exception {
      boolean result = Pattern.matches("^[^a-zA-Z]+$", value);
      Assertions.assertThat(result).isTrue();
  }

  @Test
  @DisplayName("영어와 숫자만 통과")
  void only_english_and_numbers_test() throws Exception {
      boolean result = Pattern.matches("^[a-zA-Z0-9]+$", value);
      Assertions.assertThat(result).isTrue();
  }

  @Test
  @DisplayName("영어만 통과, 단 길이는 최소2 최소4")
  void only_english_length_test() throws Exception {
      boolean result = Pattern.matches("^[a-zA-Z]{2,4}$", value);
      Assertions.assertThat(result).isTrue();
  }

  @Test
  void username_test() {
      String username = "jeongjin";
      boolean result = Pattern.matches("^\\w{2,20}$", username);
      log.info("[*] -> {}", result);
  }

  @Test
  void fullname_test() {
      String fullname = "최강jeongjin";
      boolean result = Pattern.matches("^[a-zA-Z가-힣]{2,20}$", fullname);
      log.info("[*] -> {}", result);
  }

  @Test
  void email_test() {
      String email = "admin@nate.com";
      boolean result = Pattern.matches("^\\w{2,6}@\\w{2,10}\\.[a-zA-z]{2,3}$", email);
      log.info("[*] -> {}", result);
  }
}

정규 표현식 분리

RequestDTO 에 적용하는 정규식을 분리해서 문제가 발생할 때만 확인할 수 있도록 했다.

1
2
3
4
5
6
7
8
9
10
public abstract class RegexCollection {

    private RegexCollection() {
    }

    public static final String USER_FULL_NAME = "^[a-zA-Z가-힣]{1,10}\\s[a-zA-Z가-힣]{2,20}$";
    public static final String USER_NAME = "^[a-zA-Z0-9가-힣]{1,10}$";
    public static final String USER_EMAIL = "^[a-zA-Z0-9]{2,6}@[a-zA-Z0-9]{2,6}\\.[a-zA-Z]{2,3}$";

}

컨트롤러에서 @RequestBody 로 받는 DTO 에 유효성 검사를 위한 어노테이션을 선언한다.

  • package jakarta.validation.constraints 에 속한 어노테이션
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@NotEmpty
@Pattern(regexp = RegexCollection.USER_NAME,
  message = "한글, 영문, 숫자 1~10자 이내로 작성해주세요"
)
private String username;

@NotEmpty
@Size(min = 4, max = 20)
private String password;

@NotEmpty
@Pattern(regexp = RegexCollection.USER_EMAIL,
  message = "이메일 형식이 맞지 않습니다."
)
private String email;

@NotEmpty
@Pattern(regexp = RegexCollection.USER_FULL_NAME,
  message = "한글과 영문 2~20자 이내로 작성해주세요"
)
private String fullname;

User 컨트롤러 테스트

회원가입이 정상적으로 이루어지는 지 테스트 실패케이스: 이름이 중복된 경우

에러

1
Unique index or primary key violation: "PUBLIC.CONSTRAINT_INDEX_40 ON PUBLIC.TBL_USER(USERNAME NULLS FIRST) VALUES ( /* 1 */ 'bori' )"; SQL statement:

@BeforeEach 에러 발생

컨트롤러 테스트에서, 서비스를 호출하지 않고 리포지토리를 바로 호출하기 때문에 문제 발생

  • 데이터 주입 전, 리포지토리 초기화
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1. before
@BeforeEach
void init() {
  inputTestData();
}

void inputTestData(){
  // Unique index or primary key violation 에러 발생
  userRepository.save(newUser("bori", "kim bori"));
}


// 2. after
void inputTestData() {
  // [해결] Unique index or primary key violation 에러 발생
  userRepository.deleteAll();
  userRepository.save(newUser("bori", "kim bori"));
}

코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@Slf4j
class UserControllerTest extends DummyObject {

	@Autowired
	private MockMvc mockMvc;
	@Autowired
	private ObjectMapper om;
	@Autowired
	private UserRepository userRepository;

	@BeforeEach
	void init() {
		inputTestData();
	}

	@Test
	@DisplayName("회원가입_성공")
	void join_success_test() throws Exception {
		// given
		JoinRequestDTO joinRequestDTO = JoinRequestDTO.builder()
			.username("jeongjin")
			.password("1234")
			.email("admin@nate.com")
			.fullname("kim jeongjin")
			.build();

		String requestBody = om.writeValueAsString(joinRequestDTO);

		// when
		ResultActions resultActions
			= mockMvc.perform(MockMvcRequestBuilders
			.post("/api/signUp")
			.content(requestBody)
			.contentType(MediaType.APPLICATION_JSON)
		);
		String responseBody = resultActions.andReturn().getResponse().getContentAsString();
		log.info("[*] responseBody -> {}", responseBody);

		// then
		resultActions.andExpect(status().isCreated());
	}

	@Test
	@DisplayName("회원가입_실패")
	void join_fail_test() throws Exception {
		// given
		JoinRequestDTO joinRequestDTO = JoinRequestDTO.builder()
			.username("bori")
			.fullname("kim bori")
			.password("1234")
			.email("admin@nate.com")
			.build();

		String requestBody = om.writeValueAsString(joinRequestDTO);

		// when
		ResultActions resultActions
			= mockMvc.perform(MockMvcRequestBuilders
			.post("/api/signUp")
			.content(requestBody)
			.contentType(MediaType.APPLICATION_JSON)
		);
		String responseBody = resultActions.andReturn().getResponse().getContentAsString();
		log.info("[*] responseBody -> {}", responseBody);

		// then
		resultActions.andExpect(status().isBadRequest());
	}

	void inputTestData() {
		// [해결] Unique index or primary key violation 에러 발생
		userRepository.deleteAll();
		userRepository.save(newUser("bori", "kim bori"));
	}
}

image

JWT 토큰 세팅

com.auth0:java-jwt 라이브러리를 사용한다.

1
implementation 'com.auth0:java-jwt:4.4.0'
1
2
3
4
5
<dependency>
  <groupId>com.auth0</groupId>
  <artifactId>java-jwt</artifactId>
  <version>4.4.0</version>
</dependency>

JwtAuthenticationFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
@Slf4j
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

	private AuthenticationManager authenticationManager;

	public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
		super(authenticationManager);
		// default 로 지정되어 있는 POST '/login' 의 url 을 변경한다.
		setFilterProcessesUrl("/api/login");
		this.authenticationManager = authenticationManager;
	}

	// POST /api/login
	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
		log.info("[*] attemptAuthentication 호출되었습니다.");
		try {
			ObjectMapper om = new ObjectMapper();
			LoginRequestDTO loginRequestDTO = om.readValue(request.getInputStream(), LoginRequestDTO.class);

			// 강제 로그인
			UsernamePasswordAuthenticationToken authenticationToken
				= new UsernamePasswordAuthenticationToken(loginRequestDTO.getUsername(), loginRequestDTO.getPassword());

			/*
			 * 아래 authenticate 메서드는 UserDetailsService 의 loadUserByUsername 을 호출한다.
			 * 세션을 강제 생성하는 이유는 jwt 를 사용하는 경우에도, 컨트롤러 진입 시점에
			 * 시큐리티 설정의 authorizeHttpRequest 의 도움을 받는 것이 편하기 때문이다.
			 * 강제 로그인으로 인한 세션의 생명 주기는 짧기 때문에 걱정 할 필요가 없다.
			 * request 시 생성되며, response 시 사라진다.
			 * */
			return authenticationManager.authenticate(authenticationToken);
		}
		// 시큐리티 로그인 과정 중 에러가 발생한 경우
		catch (Exception e) {
			log.error("error -> {}", e.getMessage());
			// 해당 에러 발생 시,
			// unsuccessfulAuthentication 메서드가 실행된다.
			throw new InternalAuthenticationServiceException(e.getMessage());
		}
	}

	/**
	 * InternalAuthenticationServiceException 에러 발생 시,
	 * 해당 메서드가 실행된다.
	 */
	@Override
	protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
		CustomResponseUtil.authFailed(response, "로그인에 실패했습니다.", HttpStatus.UNAUTHORIZED);
	}

	/**
	 * 인증 과정 중, {@code attemptAuthentication} 메서드를 통과하면
	 * 해당 메서드가 호출된다.
	 */
	@Override
	protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
		log.info("[*] successfulAuthentication 호출되었습니다.");

		// 로그인 처리 된 유저 객체를 가져온다.
		LoginUser loginUser = (LoginUser) authResult.getPrincipal();
		// 유저 객체의 정보를 통해 jwt 토큰을 생성한다.
		String jwtToken = JwtProcess.createToken(loginUser);
		// 생성한 토큰을 응답 헤더에 추가한다.
		response.addHeader(JwtTokenVO.TOKEN_HEADER, jwtToken);
		LoginResponseDTO loginResponseDTO = new LoginResponseDTO(loginUser.getUser());

		CustomResponseUtil.loginSuccess(response, loginResponseDTO);
	}

}

JwtAuthorizationFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
 * 토큰을 검증하는 역할을 맡는다.
 */
@Slf4j
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

	public JwtAuthorizationFilter(AuthenticationManager authenticationManager) {
		super(authenticationManager);
	}

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
		// 토큰이 존재하는 경우
		if (isHeaderValid(request, response)) {
			// 헤더에서 토큰을 추출한다.
			String token = request.getHeader(JwtTokenVO.TOKEN_HEADER).replace(JwtTokenVO.TOKEN_PREFIX, "");
			LoginUser loginUser = JwtProcess.verifyToken(token);
			/*
			 * 여기까지 온 경우, 해당 유저는 인증이 된 상태이다.
			 * 임시로 세션을 생성하기 위해 UsernamePasswordAuthenticationToken 객체를 생성한다.
			 * 단, 생성자마다 파라미터가 다르기 때문에 주의가 필요하다.
			 * 그리고 중요한 것은 authorities 이다. authorizeHttpRequest 에 설정한
			 * 여러 조건들을 통과하는지 여부가 중요하기 때문이다.
			 * */
			Authentication authentication = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
			// 생성한 세션을 컨텍스트에 주입한다.
			SecurityContextHolder.getContext().setAuthentication(authentication);
		}
		// doFilter 를 조건문 안에 넣는 실수를 조심하자. 해당 메서드는 반드시 실행되어야 한다.
		chain.doFilter(request, response);
	}

	/**
	 * 토큰 헤더가 {@code Authorization: Bearer ...} 형식이 맞는지 검사한다.
	 */
	public boolean isHeaderValid(HttpServletRequest request, HttpServletResponse response) {
		String header = request.getHeader(JwtTokenVO.TOKEN_HEADER);
		return header != null && header.startsWith(JwtTokenVO.TOKEN_PREFIX);
	}
}

JwtProcess

com.auth0:java-jwt 라이브러리는 jwt 생성, 검증을 손쉽게 하기 위한 강력한 기능을 제공한다. io.jsonwebtoken 라이브러리 사용 시, 검증을 위해서 추가해야 했던 여러 검증 로직이 내장되어 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Slf4j
public class JwtProcess {

	private JwtProcess() {

	}

	public static String createToken(LoginUser loginUser) {
		String jwtToken = JWT.create()
			// 토큰의 이름
			.withSubject("junit-bank-jwt")
			.withIssuer("local")
			.withExpiresAt(new Date(System.currentTimeMillis() + JwtTokenVO.TOKEN_EXP_TIME))
			.withClaim("id", loginUser.getUser().getId())
			.withClaim("role", String.valueOf(loginUser.getUser().getRole()))
			.sign(Algorithm.HMAC512(JwtTokenVO.TOKEN_SECRET));

		log.info("[*] jwtToken -> {}", jwtToken);
		return JwtTokenVO.TOKEN_PREFIX + jwtToken;
	}

	/**
	 * 토큰을 검증한다.
	 * 생성과 검증을 한 곳에서 하기 때문에 대칭키 알고리즘으로 간단하게 구현한다.
	 * 토큰 검증에 성공 시 LoginUser 객체를 반환하고, 해당 객체를
	 * 시큐리티 세션에 직접 주입시킨다.
	 */
	public static LoginUser verifyToken(String token) {
		DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512(JwtTokenVO.TOKEN_SECRET))
			.withIssuer("local")
			.build()
			.verify(token);

		Long id = decodedJWT.getClaim("id").asLong();
		String role = decodedJWT.getClaim("role").asString();
		User user = User.builder().id(id).role(UserEnum.valueOf(role)).build();
		return new LoginUser(user);
	}

}

JWT 뿐만 아니라 민감한 정보 관리를 위해서 resources/env/env.yml 에서 환경변수를 관리해보자.

1
2
3
4
5
jwt:
  secret: 'JWT-SECRET'
  exp_time: 90000000
  token_prefix: 'Bearer '
  header: 'Authorization'

위 정보를 읽기 위해서는 설정을 해주어야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 1. PropertySourceFactory 이용해 빈으로 등록
public class EnvConfig implements PropertySourceFactory {

	@Override
	public PropertySource<?> createPropertySource(String name, EncodedResource resource) throws IOException {
		YamlPropertiesFactoryBean factoryBean = new YamlPropertiesFactoryBean();
		factoryBean.setResources(resource.getResource());
		Properties properties = factoryBean.getObject();
		assert properties != null;
		return new PropertiesPropertySource(Objects.requireNonNull(resource.getResource().getFilename()), properties);
	}

}

// 2. 컴파일 시점에 설정 파일 클래스패스에 등록하기
@SpringBootApplication
@EnableJpaAuditing
@Slf4j
@PropertySource(value = {
  "classpath:env/env.yml"
  }, factory = EnvConfig.class)
public class BankSampleApplication {
  // ...
}

자주 사용되는 변수는 static 하게 선언해주어야 하는데, 아래와 같이 사용하면 null 값이 들어간다.

1
2
@Value("${jwt.token}")
private static String TOKEN;

따라서, 조금 번거롭지만 static 변수로 선언하기 위해서는 @Component 로 등록하는 과정이 필요하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Component
public class JwtTokenVO {

	public static Integer TOKEN_EXP_TIME;
	public static String TOKEN_SECRET;
	public static String TOKEN_PREFIX;
	public static String TOKEN_HEADER;

	@Value("${jwt.exp_time}")
	public void setTokenExpTime(Integer TOKEN_EXP_TIME) {
		this.TOKEN_EXP_TIME = TOKEN_EXP_TIME;
	}

	@Value("${jwt.secret}")
	public void setTokenSecret(String TOKEN_SECRET) {
		this.TOKEN_SECRET = TOKEN_SECRET;
	}

	@Value("${jwt.token_prefix}")
	public void setTokenPrefix(String TOKEN_PREFIX) {
		this.TOKEN_PREFIX = TOKEN_PREFIX;
	}

	@Value("${jwt.header}")
	public void setTokenHeader(String TOKEN_HEADER) {
		this.TOKEN_HEADER = TOKEN_HEADER;
	}


}

Security Config 에 필터 등록

AbstractHttpConfigurer 를 사용해서 필터를 등록하는 객체를 생성 후, 필터 체인에 등록한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 필터를 등록하는 클래스
public class SecurityFilterManager extends AbstractHttpConfigurer<SecurityFilterManager, HttpSecurity> {

    @Override
    public void configure(HttpSecurity builder) throws Exception {
        AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
        builder.addFilter(new JwtAuthenticationFilter(authenticationManager));
        builder.addFilter(new JwtAuthorizationFilter(authenticationManager));
        super.configure(builder);
    }

}

// 2.
// 사용자가 정의한 커스텀 필터를 등록한다.
httpSecurity.apply(new SecurityFilterManager());

에러 핸들링

인증, 인가 과정에 문제가 발생하는 경우 각각에 해당하는 에러 객체를 생성해서 항상 동일한 형태의 응답이 전달되도록 한다.

1
2
3
4
5
6
7
/*
* 인증 및 인과 과정 중 에러가 발생하는 상황에 커스텀 에러 객체를 리턴시킨다.
* */
httpSecurity.exceptionHandling(authz -> authz
  .authenticationEntryPoint((req, res, ex) -> CustomResponseUtil.authFailed(res, "로그인이 필요합니다.", HttpStatus.UNAUTHORIZED))
  .accessDeniedHandler((req, res, ex) -> CustomResponseUtil.authFailed(res, "관리자 권한이 필요합니다.", HttpStatus.FORBIDDEN))
);

관련 util 클래스를 만들어 재사용성을 높인다 😁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void authFailed(HttpServletResponse response, String message, HttpStatus statusCode) {

  /**
   * 파싱 관련 에러가 나면, 여기선 할 수 있는게 없다.
   * */
  try {
    ObjectMapper objectMapper = new ObjectMapper();
    ResponseDTO<?> responseDTO = new ResponseDTO<>(-1, message, null);
    String responseBody = objectMapper.writeValueAsString(responseDTO);

    response.setContentType("application/json; charset=utf-8");
    response.setStatus(statusCode.value());
    response.getWriter().println(responseBody);
  } catch (Exception e) {
    log.error("[에러] -> {}", e.getMessage());
  }

}

postman 테스트

image

image

생성된 토큰을 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
// 토큰 생성
String jwtToken = JWT.create()
  // 토큰의 이름
  .withSubject("junit-bank-jwt")
  .withIssuer("local")
  .withExpiresAt(new Date(System.currentTimeMillis() + JwtTokenVO.TOKEN_EXP_TIME))
  .withClaim("id", loginUser.getUser().getId())
  .withClaim("role", String.valueOf(loginUser.getUser().getRole()))
  .sign(Algorithm.HMAC512(JwtTokenVO.TOKEN_SECRET));

log.info("[*] jwtToken -> {}", jwtToken);

jwt.io 사이트에서 토큰 확인

image

JwtProcess 테스트

로그인 시, 토큰 생성과 검증 과정을 검증한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
 * 환경변수를 읽어오는데, 해당 환경변수는 IOC 생성 이후에 값이 주입된다.
 * 따라서 내키지 않지만 @SpringBootTest 를 통해 빈을 주입받은 채로 테스트를 진행한다.
 */
@Slf4j
@SpringBootTest
class JwtProcessTest {

	@Test
	void create_test() throws Exception {
		// given
		User user = User.builder().id(1L).role(UserEnum.CUSTOMER).build();
		LoginUser loginUser = new LoginUser(user);

		// when
		String jwtToken = JwtProcess.createToken(loginUser);
		log.info("jwtToken -> {}", jwtToken);

		// then
		Assertions.assertThat(jwtToken).startsWith(JwtTokenVO.TOKEN_PREFIX);
	}


	@Test
	void verify_test() throws Exception {
		// given
		String givenToken = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJqdW5pdC1iYW5rLWp3dCIsImlzcyI6ImxvY2FsIiwiZXhwIjoxNzAwMTYxODkxLCJpZCI6MSwicm9sZSI6IkNVU1RPTUVSIn0.EsTQQagIWXVr7eNTWFtEi_M61NIl3RunSaJ2I0TRAbkz2yWGJffMw5ozb7DCaq4MngVKkgdqWQQqqJXcrhRr3g";

		// when
		LoginUser loginUser = JwtProcess.verifyToken(givenToken);
		log.info("id -> {}", loginUser.getUser().getId());
		log.info("role -> {}", loginUser.getUser().getRole());

		// then
		Assertions.assertThat(loginUser.getUser().getId()).isEqualTo(1L);
		Assertions.assertThat(loginUser.getUser().getRole()).isEqualTo(UserEnum.CUSTOMER);
	}

}

JwtAuthenticationFilter 테스트

로그인 시 정상적으로 인증 과정이 진행되는 지 테스트

에러 발생

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
@Slf4j
@ActiveProfiles("test")
class JwtAuthenticationFilterTest extends DummyObject {

	@Autowired
	private ObjectMapper om;
	@Autowired
	private MockMvc mockMvc;
	@Autowired
	private UserRepository userRepository;

	@BeforeEach
	void init() throws Exception {
		userRepository.deleteAll();
		userRepository.save(newUser("jeongjin", "kim jeongjin"));
	}

	@Test
	void successful_test() throws Exception {
		// given
		UserRequestDTO.LoginRequestDTO loginRequestDTO = new UserRequestDTO.LoginRequestDTO();
		loginRequestDTO.setUsername("jeongjin");
		loginRequestDTO.setPassword("1234");

		String requestBody = om.writeValueAsString(loginRequestDTO);
		log.info("requestBody -> {}", requestBody);

		// when
		ResultActions resultActions = mockMvc.perform(post("/api/login").contentType(requestBody).contentType(MediaType.APPLICATION_JSON));
		String responseBody = resultActions.andReturn().getResponse().getContentAsString();
		log.info("responseBody -> {}", responseBody);
	}
}

다음과 같은 에러 로그를 확인했다.

1
2
3
4
5
6
2023-11-16T06:10:16.529+09:00 ERROR 3329 --- [    Test worker] c.e.b.jwt.JwtAuthenticationFilter        : error -> No content to map due to end-of-input
 at [Source: (org.springframework.mock.web.DelegatingServletInputStream); line: 1, column: 0]


org.springframework.security.authentication.InternalAuthenticationServiceException: No content to map due to end-of-input
 at [Source: (org.springframework.mock.web.DelegatingServletInputStream); line: 1, column: 0]

No content 관련 에러가 나는 걸 보니 데이터를 전달할 때 문제가 발생하는 것 같다..라고 생각하고 다시 확인해보니 content 가 아니라 contentType 으로 자동완성이 됐다는 것을 알게됐다.

1
2
3
4
ResultActions resultActions = mockMvc
			.perform(post("/api/login")
			.content(requestBody)
			.contentType(MediaType.APPLICATION_JSON)

서버 모듈 - 서버 모듈, 프론트엔드 - 백엔드 통신 간에 문제가 자주 발생하는데,

  • 주는 타입, 받는 타입 일치하는 지 확인
  • URL 을 허용하고 있는 지 여부 확인
  • header, body 에 제대로 데이터가 담겨 있는지 확인

습관을 들여서 불필요하게 낭비되는 시간을 줄이자.

결과적으로 여기서 알고 싶은 것은

  • 아이디, 비밀번호가 일치하는 경우 스프링 시큐리티가 작동해 로그인이 진행되는 지
  • 유효한 토큰이 생성되는 지
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Test
	void successful_test() throws Exception {
		// given
		UserRequestDTO.LoginRequestDTO loginRequestDTO = new UserRequestDTO.LoginRequestDTO();
		loginRequestDTO.setUsername("jeongjin");
		loginRequestDTO.setPassword("1234");

		String requestBody = om.writeValueAsString(loginRequestDTO);
		log.info("requestBody -> {}", requestBody);

		// when
		ResultActions resultActions = mockMvc
			.perform(post("/api/login")
				.content(requestBody)
				.contentType(MediaType.APPLICATION_JSON)
			);
		String responseBody = resultActions.andReturn().getResponse().getContentAsString();
		String jwtToken = resultActions.andReturn().getResponse().getHeader(JwtTokenVO.TOKEN_HEADER);
		log.info("responseBody -> {}", responseBody);
		log.info("jwtToken -> {}", jwtToken);


		// then

		// 1. 정상 응답을 하는지 여부 확인
		resultActions.andExpect(MockMvcResultMatchers.status().isOk());
		Assertions.assertThat(jwtToken)
			// 2. 생성된 토큰 값이 null 이 아닌지 확인
			.isNotNull()
			// 3. 생성된 토큰 값이 'Bearer ' 인지 확인
			.startsWith(JwtTokenVO.TOKEN_PREFIX);
		// 4. 응답의 data.username 이 테스트 데이터로 입력한 값과 동일한 값인지 확인
		resultActions.andExpect(jsonPath("$.data.username").value("jeongjin"));
	}

잘 진행되는 것을 확인했으니 실패하는 시나리오도 만들어본다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Test
	@DisplayName("로그인 실패")
	void fail_test() throws Exception {
		// given
		UserRequestDTO.LoginRequestDTO loginRequestDTO = new UserRequestDTO.LoginRequestDTO();
		loginRequestDTO.setUsername("jeongjin");
		loginRequestDTO.setPassword("12345");

		String requestBody = om.writeValueAsString(loginRequestDTO);
		log.info("requestBody -> {}", requestBody);

		// when
		ResultActions resultActions = mockMvc
			.perform(post("/api/login")
				.content(requestBody)
				.contentType(MediaType.APPLICATION_JSON)
			);
		String responseBody = resultActions.andReturn().getResponse().getContentAsString();
		String jwtToken = resultActions.andReturn().getResponse().getHeader(JwtTokenVO.TOKEN_HEADER);
		log.info("responseBody -> {}", responseBody);
		log.info("jwtToken -> {}", jwtToken);

		// then
		// 로그인 실패 시, 401 에러 코드를 반환하는 지 여부를 확인한다.
		resultActions.andExpect(status().isUnauthorized());
	}

이전 테스트에서는 데이터가 중복 삽입되는 것을 막기 위해서 repository.deleteAll(); 을 통해 문제를 해결했다. 하지만, 스프링 부트가 제공하는 장점을 활용하자.

import org.springframework.transaction.annotation.Transactional 에서 제공하는 @Transactional 을 사용한다.

jakarta.transactional 과 동일한 역할을 하지만, 제공하는 부가 기능에서 약간 차이가 있다고 한다. 내용을 하나하나 비교하기엔 너무 내용이 방대하여 생략한다. Transactional 은 워낙 중요한 개념이라 따로 정리한다.

JUnit 에서 @Transactional 을 사용하면, 단위 테스트가 끝난 후 롤백한다.

인가 테스트

  • 토큰이 존재하는 경우
    • 권한 일치
    • 권한 불일치
  • 토큰이 존재하지 않는 경우
    • 토큰이 존재하는 경우
    • 토큰이 존재하지 않는 경우

총 4가지 경우를 테스트한다.

먼저, URL 설정을 확인한다.

1
2
3
4
5
6
7
httpSecurity.authorizeHttpRequests(authz ->
			authz
				.requestMatchers(mvcMatcherBuilder.pattern("/api/test/**")).authenticated()
				// 더 이상 ROLE_ prefix 를 사용하지 않는다.
				.requestMatchers(mvcMatcherBuilder.pattern("/api/admin/**")).hasRole(String.valueOf(UserEnum.ADMIN))
				.anyRequest().permitAll()
		);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
@Slf4j
@ActiveProfiles("test")
class JwtAuthorizationFilterTest {

	@Autowired
	private MockMvc mockMvc;

	@Test
	@DisplayName("인증 성공 but 404 (토큰 존재)")
	void authorization_success_test() throws Exception {
		// given
		User user = User.builder().id(1L).role(UserEnum.CUSTOMER).build();
		LoginUser loginUser = new LoginUser(user);
		String jwtToken = JwtProcess.createToken(loginUser);

		// when
		ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.get("/api/test/hello").header(JwtTokenVO.TOKEN_HEADER, jwtToken));

		// then
		resultActions.andExpect(MockMvcResultMatchers.status().isNotFound());
	}

	@Test
	@DisplayName("인증 실패 (토큰이 없는 경우)")
	void authorization_fail_test() throws Exception {
		// given

		// when
		ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.get("/api/test/hello"));

		// then
		resultActions.andExpect(MockMvcResultMatchers.status().isUnauthorized());
	}

	@Test
	@DisplayName("인증 및 관리자 권한 인가 성공")
	void authorization_admin_success_test() throws Exception {
		// given
		User user = User.builder().id(1L).role(UserEnum.ADMIN).build();
		LoginUser loginUser = new LoginUser(user);
		String jwtToken = JwtProcess.createToken(loginUser);

		// when
		ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.get("/api/admin/hello").header(JwtTokenVO.TOKEN_HEADER, jwtToken));

		// then
		resultActions.andExpect(MockMvcResultMatchers.status().isNotFound());
	}

	@Test
	@DisplayName("인증 및 관리자 권한 인가 실패")
	void authorization_admin_fail_test() throws Exception {
		// given
		User user = User.builder().id(1L).role(UserEnum.CUSTOMER).build();
		LoginUser loginUser = new LoginUser(user);
		String jwtToken = JwtProcess.createToken(loginUser);

		// when
		ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.get("/api/admin/hello").header(JwtTokenVO.TOKEN_HEADER, jwtToken));

		// then
		resultActions.andExpect(MockMvcResultMatchers.status().isForbidden());
	}
}

image

계좌 생성

CommandLineRunner

매번 테스트를 위한 회원을 DB 에 postman 으로 넣는 것은 번거롭기 때문에, CommandLineRunner 인터페이스를 사용해서 프로젝트가 시작되는 시점에 테스트 용 데이터를 집어 넣는다.

1
2
3
4
5
6
7
8
9
10
11
12
@Component
@RequiredArgsConstructor
@Profile("dev")
public class DummyDevInit extends DummyObject implements CommandLineRunner {
	private final UserRepository userRepository;

	@Override
	@Transactional
	public void run(String... args) throws Exception {
		userRepository.save(newUser("jeongjin", "kim jeongjin"));
	}
}

이제 등록을 위한 세트를 만든다.

  • Entity
  • DTO
  • Repository
  • Controller
  • Service

js 에서 account_number 처럼 Snake 네이밍 규칙을 자주 사용하기 때문에, @JsonNaming 어노테이션을 사용하여 맵핑이 이루어지도록 했다.

동일한 아이디, 패스워드로 로그인을 시도하면 토큰을 얻을 수 있다. 해당 토큰을 해더에 넣고, 계좌 생성을 시도하면 아래 쿼리문을 확인할 수 있다.

계좌 서비스 테스트

테스트 하기 전에, 작성한 로직을 다시 확인해보자. 어떤 걸 테스트 해야할까? 서비스는 DB 와 통신하며 데이터를 조회하거나 삽입한다.

  • 만약 DB 에 문제가 생긴거라면, 어플리케이션 내에선 조치가 불가능하다.
    • RuntimeException 을 발생시킨다.
    • 하지만 정말 무조건 해결해야 하는 문제라면 try-catch 로 잡는다.
  • DB 에 문제가 없는 경우, CRUD 작업은 DB 를 100% 신뢰하니 굳이 테스트 할 필요가 없다.

계좌 컨트롤러 테스트

계좌 조회 서비스

이쯤에서 LazyLoading 에 대해서 한번 짚고 넘어가자. 1 : N 연관관계에서 주인은 N 쪽임을 기억하자. User 는 1명인데, Account 는 다수 존재할 수 있으므로 연관관계의 주인Account 엔티티이다.

1
2
3
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;

주인 역할을 맡는 Account 객체에 FetchType.LAZY 옵션을 주었다. 이 말은, Account 를 조회하는데 User 객체의 @Getter 를 실행시키지 않는다면 User 관련 쿼리문은 나가지 않는다.

User 엔티티에 존재하는 name, fullname, email 등을 호출하는 순간, join 쿼리문이 나가며 User 를 조회한다. (@Id 값으로 지정한 Long id 값은 프록시를 생성하는 과정에서 실행되기 때문에 제외하고 생각하자.)

image

다시 원하는 바는 정리하면 다음과 같다.

  • User 를 조회한다.
  • 조회한 UserId 를 바탕으로, Account 가 존재하면 조회한다.

User 를 조회할 때 1번이면, 더 이상 Account 조회 시에는 User 를 조회할 필요가 없다. LazyLoading 이 일어나지 않도록 하면된다.

현재 Account 는 단순 조회용으로 User@OneToMany 어노테이션을 설정하지 않았다. 연습이 아니라 실제라고 생각해보면, 유저, 계좌 이런 정보들은 너무 중요하고 빈번한 조회가 이루질 거라고 예상된다.

양방향 연관관계를 맺기 위해서는 다음과 같이 수정하면 된다.

말이 길어졌지만, 결론적으로 계좌 조회 서비스는 다음과 같다.

계좌 조회 서비스 테스트

TBD

계좌 조회 컨트롤러

계좌 조회 컨트롤러 테스트

TBD

계좌 삭제 서비스

계좌 소유자가 맞는지 확인만 하고, .deleteById(Long accountNumber) 하면 된다.

Account 엔티티 안에 간단한 로직을 추가한다.

1
2
3
4
5
6
public void checkOwner(Long userId) {
  // id 를 조회하는 경우 지연로딩 발생하지 않는다.
  if (user.getId() != userId) {
    throw new CustomApiException("계좌의 소유자가 아닙니다.");
  }
}

영속성 컨텍스트

Persistence Context.flush() 전 까지 유지된다. 1차 캐시, 2차 캐시에 저장되어 있다가 .flush() 되는 순간 저장하고 있던 쿼리문을 날리며 사라진다.

그러니, .flush() 시점 전 까지는 데이터가 캐싱된 상태로 존재한다. 따라서.. 영속성 컨텍스트가 사라지기 전에 그 안에 존재하는 데이터에 대해서 조회한다면 영속성 컨텍스트는 새로운 쿼리를 날리는 것이 아니라 기존에 품에 있던 데이터를 제공한다. 즉 쿼리문이 발생하지 않는다.

더불어 GenerationType.IDENTITY 로 설정한 경우 또한 쿼리문이 발생하게 된다. auto_increment 로 동작하며 insert 하기 전에는 id 값을 알 수 없기 때문에 save() 와 동시에 insert 쿼리문이 한번 발생한다.

여기서 지연로딩도 함께 기억해두자. 영속성 컨텍스트에 어떤 값이 존재하고, 그 값을 지연로딩으로 select 하려고 하면 쿼리문이 당연히 나가야할 것 같지만, 그렇지 않다.

영속성 컨텍스트에 존재하는 경우 지연로딩 시 가지고 있던 값을 재활용한다. 따라서 쿼리문이 발생하지 않는다.

따라서, @BeforeEach 에 여러 insert 문을 작성한 경우, 테스트 메서드에서 기대하는 동작을 확인하기 위해서는 .flush() 를 추가해주자!

1
2
3
4
5
6
7
8
@BeforeEach
void init() {
  User jeongjin = userRepository.save(newUser("test", "kim jeongjin"));
  User bori = userRepository.save(newUser("bori", "kim jeongjin"));
  accountRepository.save(newAccount(1001L, jeongjin));
  accountRepository.save(newAccount(1002L, bori));
  em.clear();
}

Transactional 대체

teardown.sql 으로 매 테스트마다 삽입된 데이터를 TRUNCATE 한다. PK 가 겹치는 문제를 막기 위해서, 가장 확실한 방법을 사용한다. 이렇게 함으로서 각 테스트 사이에 영향이 미칠 수 있는 가능성을 차단해서 격리시킬 수 있다.

UserAccount 가 연관관계가 있기 때문에 REFERENTIAL_INTEGRITY 을 꺼두고 제약조건을 무시한다.

1
2
3
4
5
SET REFERENTIAL_INTEGRITY FALSE;
TRUNCATE TABLE tbl_transaction;
TRUNCATE TABLE tbl_account;
TRUNCATE TABLE tbl_user;
SET REFERENTIAL_INTEGRITY TRUE;

결론적으로, 컨트롤러 테스트에 사용되는 어노테이션은 다음과 같다. @SpringBootTest 을 사용하여 실제 DB 에 접근하는 테스트에는 동일한 환경을 세팅한다.

1
2
3
4
5
6
7
8
9
10
@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@Slf4j
// @Transactional
@Sql("classpath:db/teardown.sql")
@Rollback
@ActiveProfiles("test")
class ControllerTest {
  //...
}

그럼 @Sql 은 언제 실행될까? @BeforeEach 직전에 실행된다!

입금 서비스와 컨트롤러

지금까지의 형식은 동일하다. 다만 서로 관계를 맺고 있는 User, Account, Transaction 엔티티들이 엮이는 것을 주의해야한다.

쭉 지키는 원칙은 엔티티는 서비스 레이어에만 머무는 것이다. 서비스 레이어 출입 시 무조건 DTO 를 사용한다.

입금 서비스와 컨트롤러 단위 테스트

TBD

출금 서비스와 컨트롤러

출금 서비스와 컨트롤러 단위 테스트

TBD

이체 서비스와 컨트롤러

이체 서비스와 컨트롤러 단위 테스트

입출금 내역 조회

입금 계좌,출금 계좌 모두를 조회해야 한다.

Outer Join 해야하는 상황

INNER JOIN 시 한쪽에 null 값이 있다면, 조회 대상에서 제외된다.

ATM 기계에서 출금 혹은 입금을 하는 상황을 생각해보면, ATM 기계는 계좌를 가지고 있지 않기 때문에, null 값으로 기록하도록 설정했다.

따라서 입금, 출금, 송금 등의 다양한 거래를 한 사용자 A 의 거래 내역을 조회할 때, 단순 join 문을 사용하면 null 이 찍힌 ATM 에서 입금 혹은 출금한 기록이 누락될 것이다.

IDWITHDRAW_ACCONT_IDDEPOSIT_ACCOUNT_ID
112
213
321
41null
5null3

위와 같은 상황에서 null 값은 ATM 에서 본인의 계좌로 ATM 에서 돈을 인출 혹은 입금한 상황이다.

1
2
3
4
5
6
7
SELECT t FROM Transaction t
JOIN FETCH t.withdrawAccount wa
JOIN FETCH t.depositAccount da
WHERE
t.withdrawAccount.id = :withdrawAccountId
OR
t.depositAccount.id = :depositAccountId

위 쿼리를 날리게 되면, INNER JOIN 을 통해 null 값은 제외되고 조회된다. 만일 기간 내 사용자 A 의 모든 입출금 내역을 합해서 지출금액, 혹은 입금금액을 구하는 쿼리를 작성해야 한다면 당연하게도 오류가 날 가능성이 존재한다.

1
2
3
4
5
6
7
SELECT t FROM Transaction t
LEFT JOIN FETCH t.withdrawAccount wa
LEFT JOIN FETCH t.depositAccount da
WHERE
t.withdrawAccount.id = :withdrawAccountId
OR
t.depositAccount.id = :depositAccountId

한줄요약: LEFT JOIN 을 사용한다.

Fetch Join

한줄요약: JOIN 해서 가져온 데이터를 PROJECTION 하기 위해 사용 한줄요약2: SELECT 문에 포함시킴

JPQL 작성

단순 조회문이지만 입금,출금, 송금 이렇게 3가지 경우가 있으므로 동적 쿼리를 작성해본다.

QueryDSL 를 적용한다면 BooleanBuiler 를 통해서도 동적 쿼리를 작성할 수 있다.

1
2
3
4
5
6
7
8
privte List<Transaction> getTransactions(String typeCond) {
  return queryFactory.selectFrom(transaction).where(typeEq(typeCond)).fetch();
}

private BooleanExpression typeEq(String typeCond) {
  if (typeCond == null) return null;
  return transaction.type.eq(typeCond);
}

테스트 환경에서 트랜잭션의 범위

JUnit5 환경에서 트랜잭션의 범위가 궁금하다. 데이터의 삽입, 삭제 등의 작업은 스프링부트에서 트랜잭션 단위로 동기화가 이루어지기 때문에 이해하기 힘든 쿼리문이 나갈 수 있기 때문에 정리한다.

요약

  • 메서드 단위 생명 주기를 가지는 어노테이션을 사용하는 경우
    • @BeforeEach, @AfterEach
    • 트랜잭션을 공유한다.
  • 클래스 단위 생명 주기를 가지는 어노테이션을 사용하는 경우
    • @BeforeTestClass, @AfterTestClass
    • @Test 과 동일한 트래잭션을 공유하지 않는다.

JUnit 의 롤백 설정

JUnit 환경에서는Exception 이 터지지 않아도 rollback 이 일어난다.

요약 TransactionTestExecutionListener 에 존재하는 메서드 덕분이다.

1
2
3
4
5
6
7
8
protected final boolean isDefaultRollback(TestContext testContext) throws Exception {

  Rollback rollback = TestContextAnnotationUtils.findMergedAnnotation(testClass, Rollback.class);

    if (rollbackPresent) {
      boolean defaultRollback = rollback.value();
    }
}

연관관계

이 내용의 기초가 되는 강의 는 JPA 강의가 아니기 때문에, 엔티티 간 연관관계를 맺지 않았다.

아래와 같은 연관관계를 맺으려고 한다.

erDiagram
  USER {
    long id PK
    string username
  }

  ACCOUNT {
    long id PK
    long number
    long balance
  }

  TRANSACTION {
    long id PK
    long account_id
    long amount
    long account_balance
  }

  USER ||--o{ ACCOUNT : contains
  ACCOUNT ||--o{ TRANSACTION : places

하나의 UserAccount 를 여러 개 가질 수 있다. 하나의 Account 은 여러 Transaction 을 가질 수 있다.

막혔던 점은, TransactionAccount A, Account B 상호간에 이루어지는 거라면, Transaction 안에 동일한 Account 엔티티가 2개 들어가야 한다.

@OneToMany, @ManyToOne 어노테이션을 설정하면, PK - FK 제약조건이 자동으로 생성되기 때문에 2개를 등록할 수가 없다. 그러니 제약조건을 무시하고, 데이터를 삽입해야 한다.

방법은 아래와 같다.

1
2
3
4
5
6
7
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
private Account withdrawAccount;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
private Account depositAccount;

UserAccount 는 제약조건이 자동으로 생성된다. 하지만 랜덤한 문자열로 생성되어 나중에 로그로 확인할 때 찾기가 힘들기 때문에, 제약조건의 이름을 부여할 수 있다.

1
2
3
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "fk_account_to_user"))
private User user;

터미널에서 변경사항을 확인할 수 있다.

1
2
3
4
5
Hibernate:
    alter table if exists tbl_account
       add constraint fk_account_to_user
       foreign key (user_id)
       references tbl_user

거래 내역 조회 서비스, 컨트롤러

Transaction 을 조회하는 서비스, 그리고 응답 DTO 를 동적으로 생성하는 방법

거래 내역 조회 테스트

테스트 코드로 확인할 사항은 아래와 같다.

계좌 1(1001L) 에서 100원

  • 계좌 2(2001L)
  • 계좌 3(3001L)
  • 계좌 4(4001L)

에게 이체한다. 결국 계좌 1에는 1000원에서 시작100원씩 3번 송금 했으니 마지막 거래 내역에 700원이 남아있어야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{
  "code":1,
  "message":"거래내역을 조회했습니다.",
  "data":{
    "transactions":[
      {
        "id":1,
        "type":"TRANSFER",
        "amount":100,
        "sender":"1001",
        "receiver":"2001",
        "tel":"없음",
        "balance":900
      },
      {
        "id":2,
        "type":"TRANSFER",
        "amount":100,
        "sender":"1001",
        "receiver":"3001",
        "tel":"없음",
        "balance":800
      },
      {
        "id":5,
        "type":"TRANSFER",
        "amount":100,
        "sender":"1001",
        "receiver":"4001",
        "tel":"없음",
        "balance":700
      }
    ]
  }
}

기대했던 결과가 잘 나왔다 👋

json 에 포함된 여러 데이터 중, 정확한 비교를 위해서는 jsonPath 메서드를 사용한다.

1
2
3
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;

resultActions.andExpect(jsonPath("$.data.transactions[2].balance").value(700L));

jsonPath 에 대해서 찾아보니, 아주 다양하게 사용할 수 있다 😁 (github 주소는 여기)

샘플 데이터는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
    "store": {
        "book": [
            {
                "category": "reference",
                "author": "Nigel Rees",
                "title": "Sayings of the Century",
                "price": 8.95
            },
            {
                "category": "fiction",
                "author": "Evelyn Waugh",
                "title": "Sword of Honour",
                "price": 12.99
            },
            {
                "category": "fiction",
                "author": "Herman Melville",
                "title": "Moby Dick",
                "isbn": "0-553-21311-3",
                "price": 8.99
            },
            {
                "category": "fiction",
                "author": "J. R. R. Tolkien",
                "title": "The Lord of the Rings",
                "isbn": "0-395-19395-8",
                "price": 22.99
            }
        ],
        "bicycle": {
            "color": "red",
            "price": 19.95
        }
    },
    "expensive": 10
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 배열 book 에 속한 데이터 중, 2번째 부터 시작한 모든 값
$..book[2:]

// 전체
$..*

// 배열 book 의 길이
$..book.lenght()

// store에 속한 배열 book 중에 10 보다 저렴한 데이터
$.store.book[?(@.price < 10)]

// 정규식을 만족하는 모든 배열 book 에 존재하는 데이터
$..book[?(@.author =~/.*REES/i)]

사용 예시를 정리한 블로그 에서는 특히 JUnit 환경에서 jsonPath 를 잘 활용한 예시가 기록되어 있다.

특히, %s 로 파라미터 바인딩이 할 수 있는 점이 인상적이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
public void exists() throws Exception {
    String expectByUsername = "$.[?(@.username == '%s')]";
    String addressByCity = "$..address[?(@.city == '%s')]";

    this.mockMvc.perform(get("/api/test/members"))
            .andExpect(jsonPath(expectByUsername, "Kyeongho Yoo").exists())
            .andExpect(jsonPath(expectByUsername, "Minho Yoo").exists())
            .andExpect(jsonPath(expectByUsername, "Cheolsu Kim").exists())
            .andExpect(jsonPath(addressByCity, "서울").exists())
            .andExpect(jsonPath(addressByCity, "제주").exists())
            .andExpect(jsonPath(addressByCity, "부산").exists())
            .andExpect(jsonPath("$..['username']").exists())
            .andExpect(jsonPath("$[0]").exists())
            .andExpect(jsonPath("$[1]").exists())
            .andExpect(jsonPath("$[2]").exists());
}

// 출처: https://ykh6242.tistory.com/entry/MockMvc를-이용한-REST-API의-Json-Response-검증 [경호의 공부방:티스토리]

여기까지 결론

테스트를 데이터를 다양한 상황을 고려해서 추가해야 한다. 시행횟수를 너무 적게한다면, 예외 처리 빈틈을 그냥 통과하거나 우연히 숫자가 겹쳐서 맞아 떨어지거나 하는 등 헛점을 놓칠 수 있기 때문이다.

물론 아무리 정교하게 고안하더라도 100% 신뢰할 수는 없겠지만 적어도 핵심 로직에 빈틈이 생기는 일은 초반에 막을 수 있으리라고 생각된다. 😁

특히, 매우 큰 숫자가 더해지거나, 소수점 연산이 아주 많이 발생하는 등의 충분히 일어날 수 있는 상황도 고려해야한다. 약간의 오차가 누적되어 아예 나중엔 다른 값이 나오는 상황도 존재하기 때문이다. 😱😱

TDD 가 선호되는 이유를 몸소 체감하였으며

  • [숙제] 관련 블로그 를 정독하고 최종적으로 TDD 에 관해 매끄럽게 정리하며 마무리하겠다.

출처

  1. https://www.nowwatersblog.com/springboot/springstudy/lombok
  2. https://me-analyzingdata.tistory.com/entry/SpringTransactional%EA%B3%BC-JUnit-Test
  3. https://mommoo.tistory.com/92
This post is licensed under CC BY 4.0 by the author.