1. 계기

이번에 새로운 프로젝트를 시작하게 되었는데 로그인에 대한 구현이 필요해서 다음과 같이 Spring Security를 사용해서 JWT 방식으로 로그인을 구현해보려고한다.


2. JWT란?

JWT는 JSON Web Token으로 Header, Payload, Signature등으로 나눈 정보를 Base64 URL-safe Encode을 통해서 인코딩해 직렬화 한 방식이다.
사실, 다른 블로그에서 설명을 잘해놨기 때문에 이 정도만 알고 있으면 좋을것 같다.
참고자료


3. 인증방식

인증 방식은 우선 AccessTokenRefreshToken을 사용해서 구현할 예정이다. 그래서 다음 형식을 지닌다.
사실 이 부분은 다른 블로그에도 잘 설명이 되어있기 때문에 참고링크를 통해서 확인해보면 좋을것 같다.

1. 계기

이번에 새로운 프로젝트를 시작하게 되었는데 로그인에 대한 구현이 필요해서 다음과 같이 Spring Security를 사용해서 JWT 방식으로 로그인을 구현해보려고한다.


2. JWT란?

JWT는 JSON Web Token으로 Header, Payload, Signature등으로 나눈 정보를 Base64 URL-safe Encode을 통해서 인코딩해 직렬화 한 방식이다.
사실, 다른 블로그에서 설명을 잘해놨기 때문에 이 정도만 알고 있으면 좋을것 같다.
참고자료


3. 인증방식

인증 방식은 우선 AccessTokenRefreshToken을 사용해서 구현할 예정이다. 그래서 다음 형식을 지닌다.
사실 이 부분은 다른 블로그에도 잘 설명이 되어있기 때문에 참고링크를 통해서 확인해보면 좋을것 같다.


다음과 같은 로직으로 작동하게 만드려고한다. 여기서 세부적으로 설명해야할 부분들을 설명하도록 하겠다.

3-1. Refresh Token의 사용이유

이번 프로젝트에서 고민했던 것이 Refresh 토큰을 사용하는 부분에서 고민이 많았다.

여러가지 자료들을 참고했지만, 살짝씩 다른 말들뿐이었다. 그래서 소신대로 해보자라고 생각하고 다음처럼 구성을 했다.

우선 설명하기 전에 Refresh Token왜 생겨났냐?에 대한 이야기를 해보려고한다. 우선 각 토큰에는 만료시간이라는 것이 존재해, 이 만료시간이 끝난다면, 토큰은 더 이상 사용하지 못한다는 점이 존재한다.

그래서, 보안을 위해서 보통 Access Token의 만료시간을 짧게 주고 자주 재발급 받는 형식을 사용을한다. 하지만 여기에서 문제점이 생긴다. Access Token만 존재한다면 재발급 받는 방법은, 다시 로그인을 하는 방법 뿐이다.

사용자들은 자주 로그인 하는것을 싫어하기 때문에 UX적인 측면을 고려하지 못할 수도 있다. 그렇다고, 만료시간을 길게 잡는다면 만료시간이 길어져서 공격자가 토큰을 탈취하게 된다면, 공격자인지 사용자인지 서버는 모르게되고 사용자의 정보는 탈취당하게 된다.

그래서 Refresh Token이 등장하게 되었다. Refresh Token과 Access Token을 같이 로그인을 할때 발급을 하는 방식을 사용하려고 한다.

이런 Refresh Token을 관리하는 방식도 여러가지가 있다 크게는 다음과 같이 운영하는것 같다.

  1. Local Storage에 프론트가 저장을 한다.
  2. Cookie를 통해서 Refresh Token을 저장한다 (HttpOnly 속성을 넣어서)
  3. 서버가 Refresh Token을 관리한다.

결론적으로

  • Access Token의 만료시간이 길면, 탈취당하고 공격자에 의해 이용당할 수 있음
  • Access Token의 만료시간이 짧으면, UX 측면에서 사용자는 짧은 시간주기로 계속해서 재로그인을 해야함

3-2 Front에서 Local storage에 저장해서 사용하는 방식

XSS 공격등으로, 탈취당할 문제점이 존재합니다. 하지만, 제일 편한 방식입니다.
또한, LocalStorage는 자바스크립트로도 접근이 가능합니다.

window.localStorage.getItem();

3-3 Front에서 Cookie

HttpOnlySecure이나 이런 정보를 넣어 LocalStorage 보다는 안전하게 저장을 할 수 있습니다.
하지만, 이것도 CSRF공격등으로 탈취당할 수 있습니다.

3-4 Server에서 저장하는 방식

Session 또는 DB에 저장할 수 있습니다. 하지만, 이 방식은 Server에 무리를 일으킨다는 단점이 존재합니다. 그래서 현재는 Redis같은 In-Memory DB 방식을 사용해서 사용을 합니다.

4. 프로젝트에 결정한 방식

프로젝트에서는 간단하게 Cookie방식을 통한 방법을 고려하기로 했습니다.
당연히 Security는 안전하게 할 수록 좋지만, 어떤 방식이던지 취약점은 존재하기 때문입니다.
100% 막을 수 있는 보안방법은 없습니다. 또한, 각 방식마다 장단점이 존재하기 때문에 결론적으로는 CookieHttpOnlySecure Flag를 추가해서 배포하기로 마음먹었습니다.


5. 아니 그래서 HttpOnly랑 Secure가 뭔데?

계속해서 HttpOnlySecure를 넣어서 안전하게 사용할것이다라고 하는데 그래서 이게 뭔지에 대한 설명이 적은것 같다.

우선, HttpOnly는 자바스크립트로 쿠키를 조회하는 것을 막는 옵션이라고 보면 좋다.
그리고, Secure는 HTTPS로 통신하는 경우에만 쿠키를 서버로 전송하는 옵션이라고 보면 좋다.

자세한 사항이 궁금하면 해당 링크를 참고하면 좋을것 같다.

6. Refresh Token에 대해서 검증하고 동작하는 로직 결정

내가 가장 고민했던 방식에 대해서 설명하려고 한다.

다른 블로그나 참고자료에서는 이 부분에 대해서 설명한 곳이 별로 없거니와, 사람마다 차이점이 존재하기 때문에 그래서 소신대로 라는 말을 붙여서 제목에 의미를 더했다.

검증 방법은 다음과 같은 종류를 생각했다.

첫 번째로 Access Token에 문제가 있는 경우는 다음과 같은 경우가 있다고 판단하였습니다.

  1. Authorized 헤더에 Access Token 값이 없거나 헤더에 대한 정보가 없는 경우
  2. Access Token이 유효하지 않은 토큰인 경우
  3. Access Token의 시간이 만료된 경우

마지막으로는 Refresh Token에 대해서 문제가 있는 상황은 다음과 같은 경우를 생각했습니다.

  1. Access Token이 만료되지 않았을 때 Refresh Token을 사용해 재발급 받는 경우는 해당 공격자가 탈취를 한경우라고 판단하여 두 토큰을 모두 만료시키는 방식으로 하였습니다.
  2. Refresh Token이 Cookie에 존재하지 않는경우
  3. Refresh Token으로 DB에 유저정보를 검색하는데, 이 토큰을 가지고 있는 사용자가 없는 경우
  4. Refresh Token의 유효시간이 만료된 경우
  5. Refresh Token이 유효하지 않은 토큰인 경우

다음과 같은 상황을 검증하려고 합니다.


7. 마지막으로

이제 다음 부터는 직접 Spring Security를 써서 어떻게 구축을 했는지를 확인하려고 합니다.
다음과 같은 로직으로 작동하게 만드려고한다. 여기서 세부적으로 설명해야할 부분들을 설명하도록 하겠다.

3-1. Refresh Token의 사용이유

이번 프로젝트에서 고민했던 것이 Refresh 토큰을 사용하는 부분에서 고민이 많았다.

여러가지 자료들을 참고했지만, 살짝씩 다른 말들뿐이었다. 그래서 소신대로 해보자라고 생각하고 다음처럼 구성을 했다.

우선 설명하기 전에 Refresh Token왜 생겨났냐?에 대한 이야기를 해보려고한다. 우선 각 토큰에는 만료시간이라는 것이 존재해, 이 만료시간이 끝난다면, 토큰은 더 이상 사용하지 못한다는 점이 존재한다.

그래서, 보안을 위해서 보통 Access Token의 만료시간을 짧게 주고 자주 재발급 받는 형식을 사용을한다. 하지만 여기에서 문제점이 생긴다. Access Token만 존재한다면 재발급 받는 방법은, 다시 로그인을 하는 방법 뿐이다.

사용자들은 자주 로그인 하는것을 싫어하기 때문에 UX적인 측면을 고려하지 못할 수도 있다. 그렇다고, 만료시간을 길게 잡는다면 만료시간이 길어져서 공격자가 토큰을 탈취하게 된다면, 공격자인지 사용자인지 서버는 모르게되고 사용자의 정보는 탈취당하게 된다.

그래서 Refresh Token이 등장하게 되었다. Refresh Token과 Access Token을 같이 로그인을 할때 발급을 하는 방식을 사용하려고 한다.

이런 Refresh Token을 관리하는 방식도 여러가지가 있다 크게는 다음과 같이 운영하는것 같다.

  1. Local Storage에 프론트가 저장을 한다.
  2. Cookie를 통해서 Refresh Token을 저장한다 (HttpOnly 속성을 넣어서)
  3. 서버가 Refresh Token을 관리한다.

결론적으로

  • Access Token의 만료시간이 길면, 탈취당하고 공격자에 의해 이용당할 수 있음
  • Access Token의 만료시간이 짧으면, UX 측면에서 사용자는 짧은 시간주기로 계속해서 재로그인을 해야함

3-2 Front에서 Local storage에 저장해서 사용하는 방식

XSS 공격등으로, 탈취당할 문제점이 존재합니다. 하지만, 제일 편한 방식입니다.
또한, LocalStorage는 자바스크립트로도 접근이 가능합니다.

window.localStorage.getItem();

3-3 Front에서 Cookie

HttpOnlySecure이나 이런 정보를 넣어 LocalStorage 보다는 안전하게 저장을 할 수 있습니다.
하지만, 이것도 CSRF공격등으로 탈취당할 수 있습니다.

3-4 Server에서 저장하는 방식

Session 또는 DB에 저장할 수 있습니다. 하지만, 이 방식은 Server에 무리를 일으킨다는 단점이 존재합니다. 그래서 현재는 Redis같은 In-Memory DB 방식을 사용해서 사용을 합니다.

4. 프로젝트에 결정한 방식

프로젝트에서는 간단하게 Cookie방식을 통한 방법을 고려하기로 했습니다.
당연히 Security는 안전하게 할 수록 좋지만, 어떤 방식이던지 취약점은 존재하기 때문입니다.
100% 막을 수 있는 보안방법은 없습니다. 또한, 각 방식마다 장단점이 존재하기 때문에 결론적으로는 CookieHttpOnlySecure Flag를 추가해서 배포하기로 마음먹었습니다.


5. 아니 그래서 HttpOnly랑 Secure가 뭔데?

계속해서 HttpOnlySecure를 넣어서 안전하게 사용할것이다라고 하는데 그래서 이게 뭔지에 대한 설명이 적은것 같다.

우선, HttpOnly는 자바스크립트로 쿠키를 조회하는 것을 막는 옵션이라고 보면 좋다.
그리고, Secure는 HTTPS로 통신하는 경우에만 쿠키를 서버로 전송하는 옵션이라고 보면 좋다.

자세한 사항이 궁금하면 해당 링크를 참고하면 좋을것 같다.

6. Refresh Token에 대해서 검증하고 동작하는 로직 결정

내가 가장 고민했던 방식에 대해서 설명하려고 한다.

다른 블로그나 참고자료에서는 이 부분에 대해서 설명한 곳이 별로 없거니와, 사람마다 차이점이 존재하기 때문에 그래서 소신대로 라는 말을 붙여서 제목에 의미를 더했다.

검증 방법은 다음과 같은 종류를 생각했다.

첫 번째로 Access Token에 문제가 있는 경우는 다음과 같은 경우가 있다고 판단하였습니다.

  1. Authorized 헤더에 Access Token 값이 없거나 헤더에 대한 정보가 없는 경우
  2. Access Token이 유효하지 않은 토큰인 경우
  3. Access Token의 시간이 만료된 경우

마지막으로는 Refresh Token에 대해서 문제가 있는 상황은 다음과 같은 경우를 생각했습니다.

  1. Access Token이 만료되지 않았을 때 Refresh Token을 사용해 재발급 받는 경우는 해당 공격자가 탈취를 한경우라고 판단하여 두 토큰을 모두 만료시키는 방식으로 하였습니다.
  2. Refresh Token이 Cookie에 존재하지 않는경우
  3. Refresh Token으로 DB에 유저정보를 검색하는데, 이 토큰을 가지고 있는 사용자가 없는 경우
  4. Refresh Token의 유효시간이 만료된 경우
  5. Refresh Token이 유효하지 않은 토큰인 경우

다음과 같은 상황을 검증하려고 합니다.


7. 마지막으로

이제 다음 부터는 직접 Spring Security를 써서 어떻게 구축을 했는지를 확인하려고 합니다.

'Spring > 스프링 시큐리티' 카테고리의 다른 글

스프링 시큐리티 인 액션 (2)  (1) 2023.01.31
스프링 시큐리티 인 액션 (1)  (0) 2023.01.31

스프링 시큐리티의 인증 구현

  • 우리는 사용자 관리를 위해서 UserDetailsServiceUserDetailsManager 인터페이스를 이용합니다.
  • UserDetailsService
    • 사용자의 이름으로 사용자를 검색하는 역할을 함
  • UserDetailsManager
    • 대부분의 애플리케이션에 필요한 사용자 추가, 수정, 삭제 작업을 함

→ 이렇게 인터페이스를 분리해서 유연성을 향상시켰음

사용자 기술하기

UserDetails 인터페이스

  • UserDetails 인터페이스는 다음과 같은 메서드로 이루어져있다. 우리는 이를 구현할 예정이다.
    • getPassword() : 사용자의 패스워드를 반환한다.
    • getUsername() : 사용자의 이름을 반환한다.
    • getAuthorities() : 사용자에게 부여된 권한의 그룹을 반환한다.
    • 나머지들은 사용자가 애플리케이션의 리소스에 접근할 수 있도록 권한을 부여하는 메서드이다.
      • 계정 만료
      • 계정 잠금
      • 자격 증명 만료
      • 계정 비활성화
      • 우리는 이들을 이용해서 true 를 반환하게 재정의 하여, 사용자에 대한 제한을 둘 수 있습니다.
        • 기능을 구현할 필요가 없다면 모두 true 를 반환하게 하면 됩니다.

GrantedAuthority 인터페이스

  • 스프링 시큐리티에서는 GrantedAthority 를 통해서 인터페이스로 권한을 나타냅니다.

  • 권한을 만들기 위해서는 해당 이용 권리의 이름을 찾으면 됩니다.
  • 우리는 부여한 이름을 바탕으로 권한 부여 규칙을 작성합니다.
  • 보통은 람다식을 통해서 이러한 권한을 만듭니다 혹은 SimpleGrantedAuthority 클래스를 이용합니다.
    • 이런 람다식을 사용할때는 @FunctionalInterface 어노테이션으로 인터페이스가 함수형인것을 지정하는것이 좋습니다.
GrantedAuthority g1 = () -> "READ";
GrantedAuthority g2 = new SimpleGrantedAuthority("READ");

각각의 구현을 작성

  • 첫 번째로는 UserDetails 에 대한 구현을 작성해보도록 하겠습니다.
package com.example.example1;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

public class DummyUser implements UserDetails {
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(() -> "READ");
    }

    @Override
    public String getPassword() {
        return "12345";
    }

    @Override
    public String getUsername() {
        return "haechan";
    }

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

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

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

    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • getAuthorities() 에서는 람다식을 통해 READ 를 반환하도록 만듭니다.
  • getUsername()getPassword() 에는 원하는 값을 반환하게 만듭니다.
    • 하지만, 실질적인 User는 다음과 같이 생성자를 통해서 값을 받고 반환하도록 만들어야합니다.

  • 나머지는 모두 true 를 반환하도록 합니다.
  • 이러한 것을 통해서 UserDetail 인스턴스를 생성할 수 있습니다.
User.UserBuilder builder1 = User.withUsername("haechan");
        UserDetails u1 = builder1.password("12345")
                .authorities("read", "write")
                .passwordEncoder(p -> encode(p))
                .accountExpired(false)
                .disabled(true)
                .build();
  • 우리는 이러한 User 를 Entity를 통해서 저장한다고 한다. 그러면 어떻게 해야할까?
    • Entity에 UserDetails 를 구현하는 방법이있다. → 이는 잘못된 사례다 Entity에 여러가지 관계도 추가되오 인터페이스의 메서드도 구현해야하기 떄문에 많이 복잡해진다.
    • 깔끔하게 책임을 가지는것은 Entity를 생성하고 UserDetails 를 구현하는 새로운 클래스에 맴버변수로 엔티티를 가지는것이 가장 좋은 코드라고 볼 수 있다.
    • ublic class DummyUser implements UserDetails { private final User user; public DummyUser(User user) { this.user = user; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return List.of(() -> user.getAuthority()); } @Override public String getPassword() { return this.user.getPassword(); } @Override public String getUsername() { return this.user.getUsername(); } // 나머지 값들 }

스프링 시큐리티가 사용자를 관리하는 방법 지정

  • 스프링 시큐리티는 새 사용자를 추가하거나, 사용자를 관리 할때, UserDetailsService 라는 특정 구성 요소로 인증 프로세스가 사용자의 관리를 위임합니다. 그래서 우리는 이를 구현하는 다양한 방법을 사용할 것 입니다.
  • 그중 JdbcUserDetailsManager 를 이용해서 예제 프로젝트를 작성할 것이다.

UserDetailsService 인터페이스

  • 인증구현은 loadUserByUsername 메서드를 호출해서 주어진 사용자 이름을 가진 사용자의 세부정보를 얻을 수 있다.
    • 만약, 존재하지 않는다면 UsernameNotFoundException 이 발생하게 된다.
    • 이는 RuntimeException 이다.

 

UserDetailsService의 계약 구현

  • 이제 UserDatailsServiceInMememoryDetailsManager 를 사용해서 구현해보도록 하겠습니다.
package com.example.example1;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

public class User implements UserDetails {
    private final String username;
    private final String password;
    private final String authority;

    public User(String username, String password, String authority) {
        this.username = username;
        this.password = password;
        this.authority = authority;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(() -> authority);
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

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

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

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

    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • 다음과 같이 UserDetails 를 정의를 하고 이제 인터페이스를 구현해봅시다.
package com.example.example1;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

import java.util.List;

public class InMemoryUserDetailsService implements UserDetailsService {

    private final List<UserDetails> users;

    public InMemoryUserDetailsService(List<UserDetails> users) {
        this.users = users;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return users.stream()
                .filter(user -> user.getUsername().equals(username))
                .findFirst()
                .orElseThrow(
                        () -> new UsernameNotFoundException("유저를 찾을 수 없습니다.")
                );
    }
}
  • 이제 구성 클래스를 빈으로 추가하고 사용자를 등록해보도록 하겠습니다
package com.example.example1;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

import java.util.List;

@Configuration
public class ProjectConfig {

    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails user = new User("haechan", "12345", "read");
        List<UserDetails> users = List.of(user);
        return new InMemoryUserDetailsService(users);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
}
  • 이후 실행을 해보면 제대로 나오는 것을 알 수 있다.

UserDetailsManager 인터페이스 구현

  • 이제 UserDetailsManager 라는 유저의 수정, 삭제, 추가 등을 관리하는 인터페이스를 구현하는 방법을 살펴보겠습니다.

  • 우리가 위에서 사용한 InMemoryUserDetailsManagerUserDetailsManager 를 구현한 것 입니다. 이제 우리는 JdbcUserDetailsManager를 이용하도록 하겠습니다.

JdbcUserDetailsManager

  • 이는 SQL 데이터베이스에 저장된 사용자를 관리하고, JDBC를 이용해서 데이터베이스에 직접 연결을합니다.

  • 우선 테이블가 데이터를 넣습니다.
    • 외래키 관련은 테스트를 위함으로 뺴서 작성을 하였습니다.
create table if not exists users(
    id int not null auto_increment,
    username varchar(45) not null,
    password varchar(45) not null,
    enabled int not null,
    primary key(id)
);

create table if not exists authorities(
    id int not null auto_increment,
    username varchar(45) not null,
    authority varchar(45) not null,
    primary key (id)
);

insert ignore into authorities values(NULL, 'haechan', 'write');
insert ignore into users values(NULL, 'haechan', '12345', '1');
  • 이후 사용할 의존성을 추가해준다.
<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.15</version>
            <scope>runtime</scope>
        </dependency>
    </dependencies>
  • 의존성을 설치했으면, db정보를 넣는다.
spring.datasource.username=root
spring.datasource.url=jdbc:mysql://localhost/security?useUnicode=true&serverTimezone=Asia/Seoul
spring.datasource.password=root
spring.sql.init.mode=always
  • mysql 8.0 이상부터는 serverTimezone 설정을 깜박하면, 에러가생긴다.
  • 이제 JdbcUserDetailsManager 를 등록한다.
package com.example.example1;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;

import javax.sql.DataSource;
import java.util.List;

@Configuration
public class ProjectConfig {

    @Bean
    public UserDetailsService userDetailsService(DataSource dataSource) {
        return new JdbcUserDetailsManager(dataSource);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
}

  • 이후 실행하면 제대로 된 값이 나오는 것을 알 수 있다.
  • 우리는 이에대한, query 값도 변경이 가능하다. 사실 실생활에서는 username으로 판단할 수도 있지만, email로 판단할수도 있고 여러가지 경우가 존재한다.
@Bean
    public UserDetailsService userDetailsService(DataSource dataSource) {

        String userByUsernameQuery = "SELECT * from users where username = ?";
        String authsByUserQuery = "SELECT * from authorities where username = ?";

        JdbcUserDetailsManager userDetailsManager = new JdbcUserDetailsManager(dataSource);
        userDetailsManager.setUsersByUsernameQuery(userByUsernameQuery);
        userDetailsManager.setAuthoritiesByUsernameQuery(authsByUserQuery);

        return userDetailsManager;
    }
  • 다음처럼, 명시적으로 setter 등을 통해서 query를 변경할 수 있다.

Reference

  • 스프링 시큐리티 인 액션 - 로렌티우 스필카

스프링 시큐리티 공부

사실 프로젝트를 계속하면서 시큐리티를 해야지 해야지 했는데… 제대로 하질 못했던것 같아서 이번에 제대로 하려고 정리하려한다.

그래서 스프링 시큐리티 인 액션 이라는 책의 예제를 바탕으로 까먹지 않도록 계속해서 핵심 내용만 정리하려고 한다.

책을 다 때기까지 화이팅이다!!!

cURL

  • 우리는 HTTP 테스트를 postman등으로 하는 경우도 있지만, 리눅스 혹은 mac 환경이라면 curl 을 통해서도 확인을 할 수 있다.
  • 예제에서 현재는 HelloController 에 다음과 같이 코드를 짠경우입니다.
    • 의존성으로는, Spring-web과 Spring-security를 넣었습니다.
package com.example.example1;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    @GetMapping("/hello")
    public String hello() {
        return "Hello!";
    }
}
  • 이후 실행하고 확인을 하려고한다면 다음과 같은 명령어로 확인이 가능합니다.
    • curl -v http://localhost:8080/hello

  • 확인을 해보면 401 이 반환이 된것을 알 수 있습니다. 이는 Unauthorized 입니다.
  • 이후 스프링에서 나온 값과 함게 -u attribute를 통해서 접근을 한다면 인증이 됩니다.

  • -u 를 사용한다면, <username>:<password> 문자열을 Base64로 인코딩을 하고 Basic이 붙은 Authorization 헤더의 값으로 보냅니다.

스프링 시큐리티의 인증 프로세스

  • 우리는 다음 그림을 통해서 다음과 같은 사실을 알 수 있습니다.
    • 인증 필터는 인증 요청을 인증 관리자에게 위임하고 응답을 바탕으로 보안 컨텍스트를 구성합니다/
    • 인증 관리자는 인증 공급자를 이용해 인증을 처리합니다
    • 인증 공급자는 인증 논리를 구현합니다
    • 인증 공급자는 사용자 관리 책임을 구현하는 사용자 세부 정보 서비스를 인증 논리에 이용합니다.
    • 인증 공급자는 암호 관리를 구현하는 암호 인코더를 인증 논리에 이용합니다.
    • 보안 컨텍스트는 인증 프로세스 후 인증 데이터를 유지합니다.

→ 우리는 위오아 같은 예시에서 자동으로 구성되는 UserDetailService , PasswordEncoder 라는 빈이 생깁니다.

PasswordEncoder

  • 이러한 passwordEncoder 는 다음과 같은 일을 합니다.
    • 암호를 인코딩합니다.
    • 암호가 기존 인코딩과 일치하는지를 확인합니다.
  • 이때 AuthenticationProvider 는 인증 논리를 정의하고 사용자와 암호의 관리를 위임합니다.
    • 이는 UserDetailsService, PsswordEncoder 에 제공된 기본 구현을 이용해서 구현이 되어있습니다.

기본 구성 재정의

  • 이제 기본적으로 구성되어있던, UserDetailServicePasswordEncoder 에 대해서 재정의를 해서 새로운 방법으로 구성하는 방법을 공부할것 입니다.

UserDetailsService 재정의

  • 이를 재정의 하기 위해서 우선 InMemoryUserDetailManager을 이용해서 구현을 해봅니다.
package com.example.example1;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class ProjectConfig {
    @Bean
    public UserDetailsService userDetailsService() {
        var userDetailsService = new InMemoryUserDetailsManager();

        var user = User.withUsername("haechan")
                .password("12345")
                .authorities("read") // 권한 목록
                .build();

        userDetailsService.createUser(user);

        return userDetailsService;
    }
}
  • 우리는 새로운 haechan이라는 이름을 가진 유저를 read 권한을 가지도록 생성을 했습니다.
    • 이후 createUser 를 통해서 유저를 생성합니다.
    • 이대로만 실행하면 PasswordEncoder 가 비어있으므로, 이도 재정의 해야합니다
     

@Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
  • 이때 사용하는 NoOpPasswordEncoder 는 그냥 암호화하지 않고 기본적인 텍스트로 문자열비교만하는것입니다.

  • 이제 값이 제대로 나오게 됩니다.

엔드포인트 권한 부여로 구성 재정의

  • 앞에서는 user를 생성하고 password를 encoding을 해서 인증을 통과하는 방법을 배웠습니다.
  • 이번에는 엔드포인트 권한을 통해서 어떤 엔드포인트에 접근할 때 인증을 허용하는 방법을 배워보겠습니다.
  • 우리는 이를 통해 WebSecurityConfigureAdapter 클래스를 확장해서 configure(HttpSecurity http) 메서드를 재정의해 권한을 허용시킨다
    • 하지만 현재는 @Deprecated 되어있는 것을 알 수 있다.

  • 이를 해결하기 위해서는 이제, SecurityFilterChain을 Bean으로 등록해서 사용을 하도록 변경이 되었습니다.
@Configuration
public class ProjectConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
        http
                .httpBasic(Customizer.withDefaults())
                .authorizeRequests((authz) -> authz
                        .anyRequest().permitAll());
        return http.build();
    }
}

 

  • 이후 실행을 하면, permitAll() 을 해놓았기 때문에 어떤 요청에도 다음과 같이 자격증명없이 모든 엔드포인트를 접근할 수 있는 것을 볼 수 있습니다.

다른 방법으로 구성을 설정하는 법

  • 우리는 위에서 UserDetailsServicePsswordEncoder를 재정의 했었는데, 이에 대해서 ㄱ동일한 구성을 수행하는 다른 방법도 존재합니다.
  • 두 객체를 빈으로 정의하지 않고 원래는 WebSecurityConfigurerAdapter 클래스를 상속받아 configure(AuthenticationManagerBuilder 로 설정이 가능합니다.
    • 하지만, 현재는 Deprecate 되어있는 상황이라 다음과 같은 코드를 다믕ㅁ으로 변경을 합니다.
/*** 수정 전 **/
package com.example.example1;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter{
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
       var userDetailsService = new InMemoryUserDetailsManager(); // 사용자를 메모리에 저장하기 위한 방식

        var user = User.withUsername("haechan")
                .password("12345")
                .authorities("read")
                .build();

        userDetailsService.createUser(user); // 사용자를 추가함

        auth.userDetailsService(userDetailsService) // configure 메서드에서 UserdetailsService와 PasswordEncoder를 설정
                .passwordEncoder(NoOpPasswordEncoder.getInstance());
    }

}
/** 수정 후 **/
package com.example.example1;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class ProjectConfig {
    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
        var user = User.withUsername("haechan")
                .password("{noop}12345")
                .authorities("read")
                .build();

        return new InMemoryUserDetailsManager(user);
    }
}
  • 다음 링크를 참고하면, 변경된 사항이 존재한다.
    • 비밀번호는 {noop} 을 넣어서 작성하면 NoOpPasswordEncoder로 사용이 가능합니다.
/** 최종 코드 **/ 
package com.example.example1;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class ProjectConfig {
    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
        var user = User.withUsername("haechan")
                .password("{noop}12345")
                .authorities("read")
                .build();

        return new InMemoryUserDetailsManager(user);
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests((authz) -> authz.anyRequest().authenticated())
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}
  • 우리는 현재 인-메모리 방식으로 사용자를 구성하였지만, 보통은 데이터베이스에 저장하거나 다른 시스템을 가져와야합니다.
    • 결론적으로 이 방법은 권장하지 않습니다

AuthenticationProvider 구현 재정의

  • 이번은 이 그림에서 인증 관리자를 재정의해서 이번에는 구현해보려고합니다.
  • 우리가 재정의할 AuthenticationProvider 는 인증 논리를 구현하며, AuthenticationManager 에서 요청을 받고 사용자를 찾는 것을 UserDetailsService 에서 암호검증을 PasswordEncoder에 위임을합니다.
package com.example.example1;

import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;

import java.util.Arrays;

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName();
        String password = String.valueOf(authentication.getCredentials());

        if ("haechan".equals(username) && "12345".equals(password)) {
            return new UsernamePasswordAuthenticationToken(username, password, Arrays.asList());
        }
        throw new AuthenticationCredentialsNotFoundException("authentication에서 오류가 발생했습니다");
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class
                .isAssignableFrom(authentication);
    }
} 
  • 우리는 다음과 같이, “haechan”, “12345”를 username과 password로 받으면, 새로운 UsernamePasswordAuthenticationToken을 생성하고 반환을 합니다.
  • 이후 커스텀하는 Configuration은 다음과 같이 작성을 합니다. (스프링 시큐리티 5부터 변경되었습니다.)
package com.example.example1;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;

@Configuration
@EnableWebSecurity
public class ProjectConfig {

    @Autowired
    private CustomAuthenticationProvider authenticationProvider;

    @Bean
    public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
        AuthenticationManagerBuilder authenticationManagerBuilder = 
                http.getSharedObject(AuthenticationManagerBuilder.class);

        authenticationManagerBuilder.authenticationProvider(authenticationProvider);
        return authenticationManagerBuilder.build();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests((authz) -> authz.anyRequest().authenticated())
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}
  • 이 또한, 제대로 되는 것을 볼 수 있습니다.

프로젝트에 여러 구성 클래스 이용

  • 우리는 지금까지 하나의 구성 클래스만 사용을 했지만, 구성 클래스도 책임을 분리하는 것이 좋습니다.
  • 이제 UserManagementConfigWebAuthorizationConfig 로 나누어서 환경을 구성할 예정입니다
    • UserManagementConfigUserDetailsServicePasswordEncoder 의 Bean만 포함을 합니다.
    • 이는, WebSecurityConfigureAdapter를 확장할 수 있기 때문에 두 객체를 빈으로 구성을 했습니다.
      • (현재는 WebSecurityConfigureAdapater 를 상속받지 않습니다. Security 5부터)
package com.example.example1;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class UserManagementConfig {
    @Bean
    public InMemoryUserDetailsManager userDetailsManager() {
        var user = User.withUsername("haechan")
                .password("{noop}12345")
                .authorities("read")
                .build();
        return new InMemoryUserDetailsManager(user);
    }
}
package com.example.example1;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class WebAuthorizationConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests((authz) -> authz.anyRequest().authenticated())
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}

Reference

  • 스프링 시큐리티 인 액션 - 로렌티우 스필카

사실 코딩테스트를 본지는 많이 지났다. 떨리는 마음이 진정이 안된다. 그때의 기억과 나를 돌이켜보기 위해서 쓰려고한다.

결전의 날

12월 17일은 유난히 긴장되는 날이었다. 시험이 끝난지는 3일이나 되었지만, 계속해서 긴장이되었다.

우선 일찍일어나야지 라는 생각에 알람도 5분간격으로 계속 맞췄던것 같다.
추울 수 있으니까 따뜻하게 입고 선릉으로 출발했다. 선릉에서 내리자마자 슬랙메세지에서 스타벅스에서 IDE가 많이보이네요 ㅎㅎ라는 이야기가 들려왔다.

나는 먼저와서 계속해서 미리 노션에서 실수할거리나 정리해놓은것을 보면서 컴포즈에서 커피를 하나먹으면서 다리를 동동구르며 대기를 했었다. 그리고 옆사람이랑 긴장도 풀기위해서 이클립스도 하나사서 빌딩에 들어가게 되었다.

들어가자마자 우아한테크코스 표지판이 보였었다. 신분확인때문에 줄을 길게서있어서 사진을 찍고싶었는데... 눈치가 보여서 찍지못했었다.

시험을 보기전에 아키텍쳐설계를 해야하기 때문에 펜이랑 공책을 가져왔어야하는데 놓고왔었는데 감사하게도 우아한테크코스 측에서 기념품으로 공책이랑 펜을 챙겨주셨었다.

펜이랑 공책

펜을 보고 한참 웃었던것 같았다. 꽤나 긴장이 풀렸었다. 우아한테크코스에서 주던 간식거리랑 함께 노션을 보면서 계속해서 지금까지 공부했던것을 되세김질 했었다. (귤이 좀 맛있었던것 같다... )
그렇게 기다리다 보니까 메일한통이 왔었다 보자마자 이제 시작이구나라는게 실감이 들어 심장이 터질 뻔했었다.

펜이에요 드립이 나한테 적중했다

시험시작

메일을 보면서 생각했던것은 안 돌아가는 프로그램보다 돌아가는 쓰레기를 만들자였다.
돌아가는 쓰래기를 만드는것은 일가견이 있기 때문에 피식 웃기도했고 이 사진이 떠올라서 그래 일단 돌아가게 만들자!!! 라는 생각이었다.

내가 완성한 코드인가...


이게 돌아가는 쓰레기의 코드일까? 라는 생각도 하면서 시험이 시작이 되었다.

열심히 노력하였지만 요구사항을 꼼꼼히 읽지 않아서 1시간 가량을 디버깅에만 허비하게 되었다.

명시된 조건과 다르게 임의로 생각을 하고 코드를 짠것이 문제였다. 그래서 많은 후회가 남게 되었다. 좀 더 빨리 알았다면 리팩토링과 테스트코드를 할 시간이 충분했을 건데 라는 생각에 많이 우울했었다.

지금 돌아가보면 꽤나 좌절했지만, 지금 생각해보면, 이것도 교육의 연장선이 아닐까 생각한다.

지금 까지는 편하게 코드를 짰지만, 분명히 시간제한이 있을수도 있다고 생각한다
그리고 큰 실수를 하였기 때문에 요구 사항을 꼼꼼히 읽고 분석하자 라는 것이 마음속에 새겨졌던것 같다.

회사에서 큰 프로젝트에서 실수를 한것이 아니라 지금 실수를 한것에 대해서 감사하다고도 느낀다.

마치며

시작할때는 눈도 안내리고 가디건을 입던 날씨였지만, 벌써 패딩을 입고있다.
이제 우아한테크코스의 최종 코딩테스트까지 끝마쳤고 2일후면 결과가 발표가 난다.

사실 결과도 크게 중요하겠지만 지금까지 얻어간게 너무 많아서 행복했던 기억만 남아있다.

또한, 성공보다 실패에서 배우는 경험이 크다는것을 알고 있기 때문에 최종코딩테스트에서 많이 아쉬웠지만 이 또한 성장할 수 있는 밑거름이었다.

아마 탈락할 수도 있지만 (사실 가능성이 크지만 ㅎ...) 지금까지 배웠던거를 친구들이랑 나눠서 같이 성장하고 싶은 마음에 내가 만든 학술소모임에서 이번에 우아한테크코스 문제도 풀어보자라고 이야기를 꺼내고 지식공유를 해보려고한다.

오늘도 남은 하루 프리코스를 진행했을때 처럼 그 때 그 열정 잃지 않고 성장해나가려한다.

마지막으로 공부를 할 수 있게 해서, 방법을 알려줘서, 성장할 수 있게 해줘서 감사했습니다.
또한, 같이 공부하고 지식공유를 서슴치 않게 행동했던 모든분 프리코스 분들께도 감사합니다!!!

마지막을 기념하며

우아한테크코스도 벌써 마무리가 되어간다.
가장 크게 느낀점으로는 식상하지만 성장했다라고 할 수 있다.
지금까지는 코드를 적을 때 별생각들이 없었다. 문제나 다른 프로그램을 만들때도 그냥 기능이 성공적으로 동작하는지에만 집중했었다.

하지만, 이제 기능을 구현할 때, 어떤 경우가 있을까? 이렇게 넣으면 괜찮을까? 라고 계속 생각하게 되는 개발자, 아니 무언가를 만들어 내고 제공하는 입장으로서의 성장이 가장 큰 성장이라고 할 수 있다.

개발자로서의 성장도 물론 4주동안 많이 성장했다고 생각을 한다.
기존에는 클래스라는 개념을 배웠을 때 Capsulation에 대해서 getter, setter만 알았다.
캡슐화를 하기 위해서는 단지, 맴버변수만 private하게 선언을 하고, getter, setter를 기계적으로 넣을 뿐, 왜 그렇게 사용하지? 라는 생각이 부족했다.

일급 컬렉션 등을 알게 되면서 지금까지 작성했던 내 코드는 말도 안되는 것이라는 것을 알게 되었다.
final을 통해서 불변하게 하고, getter를 쓸때도 불변이라는 것을 지켜서 원하는 그냥 값 자체만 넘겨주고 하는것들을 통해서 이것이 제대로된 캡슐화구나 라는 것을 알게 되었다.

이뿐 만이 아니다. TDD를 지켜야한다, Refactoring을 읽어야한다., 등등의 말들이 나에게는 "그래 그냥 좋은거지, 나중에 회사가서 그 때부터 지키면 되는거지"등의 안좋은 생각만 가지고 있었다.

많이 말해서 알겠는데 왜 좋은 건지에 대해서는 이번 프리코스에서 많이 느꼈던 것 같다.
테스트 코드를 작성하고 나니 리팩토링과 내 코드에 대한 자신감이 많이 생겨나게 되었다.
그리고 Refactoring을 하고 나서 코드를 읽는게 쉬워지게 되었다.

코딩을 해봤으면 사실 누구나 느꼈을 것이다. 내가 짠코드를 사실 하루만 되도 이해하기 어렵다는 사실을
이런 점들이 해소가 되었다. 코드를 좀 더 잘보이도록 수정할 수 있게 하면서 작성하니까 코드에 대한 이해가 늘어서 금방금방 적응할 수 있었다.

마지막으로 Enum을 왜 이제 알았지? 라는 생각이 들었다.
Enum만큼 좋은 것이 없다고 느낄때가 많아졌다.

매일 매일 내 코드는 하드 코딩이었다. 그러다보니 값을 동시에 바꿀때도 힘들었고 이런일도 있었다.

매도/매수를 각 0, 1의 값에 매핑을 해서 저장을 하였는데, 코드를 볼때마다. 매도가 0이었나? 매수가 0이었나...
하면서, 항상 IA를 뒤져보곤했다.

불편함을 느끼면서도 방법을 몰랐었다. 상수라는 개념을 쓰고나니 되게 편해졌고, 이런 상수를 Enum에서는 상태까지 관리할 수 있다는것이 말도안되게 편하다는 것이 눈앞에 다가왔었다.

이번 프리코스는 사실, 학기중이라는 부담감과 여러가지 할일들에 의해서 바쁜 와중이었다.
하지만, 프리코스를 할 때는 그것들을 잊고 코딩에 몰입할 수 있어서 너무 행복했다.
하루종일 해도 지치지 않고 시간가는줄 모르고 했던적이 한두번이 아니며, 코드작성의 묘미를 느낄 수 있었다.

이런 프리코스를 공짜로 경험한다는것이 행복했었다. 하지만, 이제 프리코스도 마무리할 시간이 다가왔다.
많이 배웠으니까 어느정도 어떤식으로 공부를 해야하는지 감도 잡은것 같다. 더 하고 싶지만, 어떻게 될지는 잘 모르겠다.

4차 다리 건너기 게임

이번 다리 건너기 게임에서는 클래스 분리에 대해서 많은 노력을 무엇보다 들였었다.
사실 좌충우돌 여러가지 사건들이 있었다. 하나하나 적으면서 생각해보려한다.

기능 요구사항은 항상 꼼꼼히

첫 번째는 '기능 요구 사항을 꼼꼼히 읽자'다 이번에 기능 요구사항 중에서 이런 항목이 존재했다.

기능 요구 사항


사실 이번에는 실수 하지 않도록 하기 위해서 CheckList를 다음과 같이 작성을 하였지만 이 부분을 주의깊게 보지 않았다.

Check-List


그래서 인지... BridgeGame을 Controller로 생각하고 기능을 짜기 시작했다.
이후 제출 전날에 알아서 급하게 부랴부랴 리팩토링을 했던 기억이 있다.

하지만, 코수타에서도 말했듯이 미리 테스트 코드를 단단하게 짜놨기 때문에 이 부분에 대해서 그래도 편하게 리팩토링을 하는 경험을 했던 것 같다.

이전 3차에서도 이런 경험을 하였는데, 계속 경험할 수록 테스트 코드는 그저 신이야... 라고 생각을 하게되었다.

MVC 패턴

이번에는 특히나 클래스의 분리에 대해서 많이 생각했던 주차였던 것 같다.
그러면 도대체 어떤식으로 클래스를 분리하면 좋을까? 라는 생각이 들어서 찾아보게 되었다.
항상 이런 생각은 나 뿐만이 하는것이 아니라 대가 들이 이미 정리해둔 개념들이 있기 때문에 쉽게 찾을 수 있었다.

그 중 선택했던 것은 MVC 패턴이다. Spring을 배웠다면, 누구나 익숙한 패턴으로 볼 수 있다.
각각 Model, View, Controller로 나눠서 각각의 의존도를 낮추고 객체지향의 사실과 오해에서 말했던 메세지를 통해서 어떤한 결과를 받을 수 있는 좋은 패턴이라고 볼 수 있다.

그렇기 때문에 적용하려고했는데 사실 막막했었다. 그리고 공부를 하면서 많이 반성을 했던것 같다.
기존 MVC 모델을 너무 대충 아뭐... 클라이언트 요청이 들어오면 대충 Controller에서 처리하고... Model에서 그냥 서비스로직 다 떄려박고... 이러한 생각으로 그냥 기계처럼 짜왔던것 같다.

근데 이제 클라이언트에 응답을 받는것을 내가 어떻게 할지 정했어야했고 대충 MVC에 대해서 이해하고 기계적으로 코드를 짜왔기 때문에 작성하는데 많이 어려움을 느꼈었던것 같다.

Model에 이 기능을 넣어야하나? Controller에 이러한 메서드가 들어가는게 맞나? 라는 고민들이 코드를 짤때마다 많았던것 같다. 그렇기 때문에 좀더 깊숙히 공부할 수 있었고
클래스 분리에 따라서 많은 필요없는 코드들이 각각 정리 될 수 있었다.

Enum

저번 Enum도입때 Enum이 너무 좋았기 때문에 이번에도 Enum을 가차없이 도입하기로 하였다.
총 3개를 넣을 수 있었는데 BridgeCommand, BridgeState, GameState 총 3개로 나눠서 관리를 했는데 상태와 여러가지 변수를 한번에 넣을 수 있다는 장점으로 여러코드에서 많이 사용할 수 있었던 것같다.

이중에서 뭔가 겹치는거는 합치거나 굳이 Enum을 써야 했을까...? 라는것도 있었지만 내 주관에는 필요하다고 해서 막무가내로 생성하고 밀어붙였던 것같다.
그래서 리팩토링에 대해서 Enum은 특히 생각을 많이 했지만 더 좋은 방법이 생각나지 않은 점이 아쉬웠다.

이제 진짜 끝

계속 느끼는 것이 아쉽다... 프리코스가 1년짜리였으면 좋겠다... 라는 생각을 많이한다.

하지만, 생각하는 방법도 알고 공부를 어떻게 해나가야하는지 감을 잡았던것같다.
그리고 더욱 성장하는 개발자가 될 수 있도록 불씨를 집어넣어준것 같았다.

이 불씨가 꺼지지 않도록 계속해서 노력해서 좋은 개발자가 되도록 노력하려고 한다.
이런 성장할 수 있는 기회를 준 코치님들께 감사의 인사를 전하려고 한다.
그리고 같이 성장하고 지식공유를 지속적으로 해준 프리코스를 함께했던 분들께도 감사의 인사를 남기려고한다.

감사합니다😀

3주간의 회고

벌써 우아한테크코스도 3주차가 마무리되었다. 나머지 시간이 2주밖에 안 남았던 것이 아쉬울 뿐이다.
물론 합격하면 계속 성장할 수 있는 계기가 되겠지만 설령 불합격을 하더라도 많이 배웠기 때문에 앞으로 잘 나아갈 수 있을 거라고 믿어 의심치 않는다.

첫번째 프리코스 이메일


첫 메일을 보고 우아한테크코스 사진이 걸린 메일이 누추한 내 메일 보관함에 왔다는 거에 그저 설레었던 것 같다. 그래서 중요 보관함으로 얼른 옮겨놨었던 기억이 난다.

이후 우아한테크코스 1주 차 미션과 함께 다음 내용이 왔었다.

이 시간이 고통이 아니라 즐가운 시간이기를 기대해 봅니다.

처음 이 문장을 읽고 한 줄 사이 문장에서 학업을 병행하고 있는터라 살짝 두려움도 느꼈던 것 같다.
그래도 지금까지 힘들었지만 잘 해낸다는 믿음이 있었기 때문에 매일 밤을 새웠던 예비창업 활동과 같이 재미있을 거야!!라는 생각으로 일단 넘어갔다.

1주 차의 기억

1주 차가 나오는 날은 이제 슬슬 겨울인가?라고 생각이 드는 쌀쌀했던 날씨로 기억한다.
그날은 마지막 시험이 있는 날이라 시험이 망한 채로 집에 터덜터덜들어가서 자고 일어나서 유튜브 오리엔테이션부터 켰던 것 같다.
보고 나니 약간, 고등학교 때 수학여행 전 브리핑을 듣는 그런 느낌이라 설레었었던 기억이 난다.

이제 시험은 이미 끝난 일이기 때문에 바로 1주 차 미션을 보았는데 이름이 온보딩이었다.
말 그대로 배에 올라탄다는 의미 었다. 우아한테크코스 프리코스라는 배에 올라탔지만, 중간에 내려야 할 수도 있지만 폭풍우가 오던 열심히 하자라는 마음가짐으로 시작을 했었다.

1주 차 온보딩을 진행하면서 가장 기억에 남는 행동은 바로 기능 요구사항이라고 생각한다.

우리는 계속해서 명확한 답을 찾기 위한 과정에만 집중을 했었다.
과제도 그렇고, 수능도 그렇고 명확한 답이 정해져 있고 우리는 그대로 풀이를 했으면 되었던 것이다.
문제에서 잘못된 점이 있으면, 내가 잘못된 거겠지?라고 그냥 넘어가던 생활을 살아왔었다.

사실 제일 처음 들었던 생각들은 세부적인 사항이 아니라 너무 불친절해라는 생각이 컸다.
이러한 생각들은 1주 차 마무리 코수타를 듣고 많이 반성하게 되었다.

그뿐만이 아니라 슬랙이 활성화되면서 특히, 테스트 케이스 방이라는 곳에 들어가서 테스트 케이스도 올리면서 지식공유를 했었다.

지식공유를 좋아하던 입장으로는 테스트케이스 방은 맞춤 방이라고 할 수 있었다. 하지만, 이것 또한 코수타를 듣고 많이 반성하게 되었다.

1주 차의 마무리

1주 차가 마무리되고 1시 30분 ~ 4시 15분까지의 수업이 존재해서 수업이 끝나자마자 허겁지겁 코수타 재방송을 봤던 기억이 있다.

나름 지식공유, 문제 오류라고 생각했던 점들이 코수타들 듣고 부끄러워졌다고 생각이 들게 되었다.
이번 코수타를 통해서 고정관념이 깨지게 되었다.

테스트 코드를 올리고 보면서 다른 사람들뿐만이 아닌 나 또한, 문제에 대한 생각을 접어두게 하는 것을 보고 미안함을 느끼기도 하였고, 반성을 많이 했다.

그저 지식공유를 하면 편하지 않을까라고 생각했던 것은 지식공유가 아닌 생각하는 것을 방해하고 성장할 기회를 뻇어버렸다는 생각이 들었다.

또한, 세부적이지 않은 요구사항은 문제의 도메인에 대해서 좀 더 생각하게 되고 여러 생각을 할 수 있는 계기를 위해서 남겨둔 것이었지만, 그러한 것을 생각하지 못했었다.

이러한 것들이 모여서 나를 성장하게 만들어가는 것 같다.

2주 차의 기억

이전 1주 차에서의 실수를 반복하지 않기 위해서 결심을 하고 2주 차 미션에 대해서 집중하기로 했었다,

2주 차 미션 때부터 요구사항에 대한 파악과 함수의 분리, 함수 별로 테스트 작성이라고 밑줄을 그은 부분에 집중하기로 하였다.

2주 차는 그저 리팩토링이 너무 재미있었다. 사실 이전에는 함수에 모든 것을 때려박었었다.
하나의 함수에서는 값도 읽고, 출력도 하고, 검증도 하고, 반복문도 돌리고, 분기도 있고, 다른 함수를 호출하고... 이런 식의 행동이 반복이었다.

왜냐하면, 협업보다는 학교 과제 제출이 우선이었기 때문이다.
이번에 들어서는 계속해서 함수를 분리했다. 심지어는 그냥 출력하는 것까지 계속 분리하려고 시도를 했다.

사실 함수를 계속 분리하는 건 Depth를 줄이고 15줄을 넘지 않기 위함이라고 생각을 했었다.
하지만, 그런 것이 아니라 코드를 완성하고 나서는 왜 쓰는지 알게 되었다.

이 함수에서 모든 동작을 이게 뭐지 왜 동작하지라는 것을 한눈에 알 수 있었다. 기존 반복문과 조건문으로 범벅이 되었던 함수들은 왜 이 조건문을 수행하는지, 왜 이 반복문을 수행하는지의 의문투성이었다.

학교 과제를 제출하고 나서는 다시 코드를 봤을 때 알기 위해서는 디버깅을 하면서 차분히 알아가는 방법뿐이었다.
이러한 점들에 대해서 생각할 수 있게 했다는 점이 너무 좋았다.
내가 계속해서 성장하고 계속 봐도 깔끔해지는 코드를 본다는 것은 설레는 경험이었다.

그리고 테스트 코드도 잘 모르는 나는 AssertThat만 그저 기계처럼 이용하고 있었는데 Junit에 대한 문서도 보면서 다양한 테스트 방법이 있다는 사실에 대해서 알고 성장하고 있는 나를 볼 수 있었다.

특히, 테스트 코드에서는 private한 필드에 대해서 가져와 검증을 하고 싶었는데 private를 어떻게 검증하지 라는 생각이 컸다.

그냥 단위 테스트를 넘어가야 할까? 아니면, 테스트를 위해서 getter를 만들어야 할까?라는 많은 생각들이 들었다. 결국 검색을 통해서 Reflection을 통해서 private값을 가져와서 사용하는 방법을 채택하게 되었다.

3주 차의 시작

이제 겨울이 된 것 같다. 하나둘씩 사람들이 패딩을 꺼내기 시작했고 휴대폰을 하면서 집에 가던 나는 손이 꽁꽁 얼어붙는 느낌을 하나둘씩 느끼고 있다.

벌써 3주 차 미션이 왔다는 것이 실감이 안 났던 것 같다.

사실 계속해서 3주 차 때부터 과제가 밀리는 시점이라서 문제 풀이에 현타가 오게 되었다.
그런 와중에 우테코에 더 집중하는 계기가 된 슬랙이 있었다.

슬랙에서의 대화


나만 이런 고민을 하고 있는 줄 알았는데 많은 사람들도 고민을 하고 있었던 것을 알았다.
내 인생 모토가 후회해서는 늦었기 때문에 하나를 하더라도 최선을 다해서 하자라는 생각을 가지고 있었는데
학업과 우테코의 갈림길에서 많은 고민을 했었는데 성장을 위해서 이렇게나 우테코를 선택하는 사람이 많다는 것을 느끼고 나도 열심히 해야겠다라고 생각이 되었다.

그렇게 3주 차 미션을 시작하게 되었다.
점점 시간이 지날수록 우아한테크코스에 대한 애정이 섞이면서 문제에 대한 관심이 높아지고 있다.
코수타와 프리코스를 통해서 점차 성장을 해서인가? 요새는 붙어서 계속해서 공부하고 싶다는 생각밖에 없어졌다.

3주차 미션은 로또였다. 사실, 로또는 해본 적이 없다.
그저 친구들과 술 마시고 현금이 남아서 로또 사자!!! 하면서 사고 집에 들어가는 길에 하염없이 1등이 되면 뭐하지?라는 여러 가지 이야기를 하면서 들어가던 기억만 존재한다.

2주 차 미션 때는 매일 수업시간에 밥먹듯이 몰래하던 게 숫자야구여서 넘어갔지만 로또는 그냥 넘어가지 못했다.
계속해서 궁금했던 점들이 생겨났고 로또 홈페이지도 들어가고 친구한테 물어보기도 하고 실제로 로또를 사보기도 하였다.

로또 낙첨...


결과는 당연한 낙첨!!!이었다... 이러한 경험을 통해 도메인에 대한 이해로 이번 문제는 시작했던 것 같다.

사실 메일을 받자마자 가장 무서웠던 것은 Enum에 대한 사용이었다.
Enum을 사용해보려고 하지만 계속해서 모르는 점이 투성이었다. 2주 차에서도 썼지만 그저 상수를 담아두는 공간이라고 생각했기 때문에 이번 주차에는 많은 어려움이 존재했었다.

Enum을 몰랐던 이유는 사실 알고는 있지만 왜 쓰지? 귀찮아...라는 것 때문에 익숙하지 않았다고 생각을 한다.
그래서 익숙해지기 위해서 Java Enum 활용기를 보고 많은 공부를 하고 실제로 사용하게 되었다.

동일하게 가리키는 값에 대해서 다음과 같이 사용하면서 많은 정보들을 담을 수 있었다.

Enum Class


원래라면 각각 다른 값을 가져야 한다. 혹은 이럴지도 모른다.

public static final int FIRST_PLACE_COUNT = 6
public static final int FIRST_PLACE_MONEY = 2_000_000_000;
public static final int FIRST_PLACE_PRINT = "6개 일치 (2,000,000,000원)";

/* 생략 *

이러한 값을 가지는 것 보다 Enum을 쓰면 더 깔끔하게 느껴진다는 것을 알게 되었고
또한, 자동으로 Class와 같이 문법적인 체크도 해주면서 편하게 쓸 수 있었다.

평소였으면 평생 Enum에 대한 도입을 생각하지 않았겠지만, 이렇게 쓰게 해 줌으로써 다음부터는 Enum을 활용할 수 있다면 적극적으로 활용할 수 있다는 자신감을 가지게 되었다.

이런 성장이 너무 좋다고 생각한다. 프리 코스만 따라가도 배우는 것이 너무 많아서 좋다.

Enum에 대한 것이 아니라 이번에는 테스트 코드에 대한 것도 많은 고민을 하였다.
테스트 코드에 대한 것은 테스트 커버리지 100%를 보고 많은 생각을 하게 되었다. 누워서 그냥 뒹굴뒹굴하면서 보는데 테스트는 아는데 커버리지는 뭐지?라는 생각을 하게 되었다.

여기서 테스트 커버리지 100% 의 장점에 대해서 이야기하는데
노력해서 이번 주차 미션에서는 나도 테스트 커버리지를 높게 가져가게 짜 보자 라는 생각에 jacoco라는 것도 공부하게 되었다.

이러한 테스트 커버리지 100%에 대한 목표로 작성을 하다 보니까 테스트 코드를 왜 쓰는지 커버리지를 왜 높이려고 하는지에 대한 이해를 할 수 있게 되었다.

모든 기능을 작성하고 마구잡이로 나뉜 기능들을 하나로 모으기 위해서 엄청나게 큰 공사를 하게 되었다.

이전에는 Lotto, Money 등의 클래스에서 Print에 대한 기능도 하고 Validation이 각 함수에 존재하는데 같은 기능에 대한 Validation 기능이 너무 많았다.

이를 Validation이라는 클래스를 만들어서 클래스 메서드를 담기 위해서 분리하는 대공사를 거쳤는데 테스트에 대한 기반이 탄탄하기 때문에 마구잡이로 한 것 같았지만 문제가 별로 발생하지 않았다는 것이 큰 이점으로 다가왔다.

이번 문제에서 가장 고민을 많이 했던 것은 여러 가지가 있지만, 그중에서 일급 컬렉션에 대한 고민이 많았다.

일급 컬렉션은 분명한 Wrapper 클래스다. 멤버 변수로 가지고 있는 클래스는 물론, final로 선언이 되어있어서 함부로 수정이 불가능하게 캡슐화가 되어 있었다.

하지만 우리는 Lotto라는 일급 컬렉션에서 많은 동작들을 하게 했어야 했다.

  1. Lotto안에 있는 정보를 Print 한다.
  2. Lotto 끼리의 값을 비교해서 당첨인지 낙첨인지를 판단해야 한다,.
    등등

이러한 행동들은 분명하게 객체를 꺼내야 하는 방법밖에 기억이 나지 않았다.

그래서 getter를 만들어서 꺼냈지만, 멤버 변수로는 finalnumbers라는 컬랙션은 반환이 된다면 변경이 가능하다는 것을 테스트를 통해 알 수 있었다.

도대체 이러한 것을 어떻게 해결해야 할까?라는 고민을 하루 종일 하게 되었다.
그러기 위해서 여러 가지 가설을 생각해보았다.

  1. 컬렉션을 그냥 넘기면 참조 변수로써 값 변경이 가능하기 때문에 새로 Collection을 만들어서 반환을 한다
  2. 메서드를 만들어서 index를 인자로 받고 index에 해당하는 Integer 값을 반환하는 메서드를 사용한다.
  3. 무시하고 어차피 쓰지 set을 하는 행동이 있지 않을 거라고 생각하고 무시한다.

첫 번째 방법을 하자니 너무 불필요한 연산이 추가되기 때문에 찝찝하였고

두 번째 방법이 그나마 만만하다고 생각했던 방법이었다.

세 번째 방법은 내가 실수하여 값을 변경할 수도 있고, 혹은 내 가짠 프로그램이 다른 사람들과 같이 짜는 프로그램이었다면, 분명히 실수할 수 있었기 때문에 이 방법은 배제했다.

오랫동안 두 번째 방법 이상의 방법은 생각나지 않아서 검색을 통해 결국 가장 마음에 드는 방법을 찾게 되었다.
그것은 반환하는 값을 Immutable로 변경해주는 메서드를 사용하는 것이었다.
이렇게 반환된 값을 수정을 한다면, Exception이 발생되기 때문에 이를 쉽게 해결할 수 있게 되었다.

그래서 마무리?

계속 시간이 지날수록 성장해감을 느낀다. 학업을 조금 빼더라도 프리코스에 투자한 시간이 너무 아깝지가 않다.
더 배우고 싶다.

앞으로 배울 것도 많고 생각할 것도 많은데 이러한 고민들을 미리 해보면서 성장해나갈 수 있어서 요새 너무 행복하다.

모두가 같이 배울 수 있는 이러한 프리코스는 이제 1주밖에 남지 않았다. 마음 같아서는 10주 20주도 하면서 성장하고 싶지만 시간은 정해져 있고 배울 수 있는 사람은 한정되어있다.

온보딩을 타서 우아한테크코스를 통해서 열심히 항해하는 도중 내릴 수도 있지만 배에 올라탄 시점부터 많은 것을 배웠던 것 같다. 남은 2주 차도 열심히 노력해서 더 많은 것을 배워가고 싶다는 생각이 든다😀

원래는 잡담 자체를 안썼는데 이번에 우아한테크코스를 도전하면서 목표가 있으면 좋을것 같아서 시작해봅니다 :)

사실 12월 17일에 다른 학교 시험이 두개나 예정이라 무조건 합격보다는 성장을 우선으로 도전해보려고 해요

사실은 그것도 김치국인게 12월 14일에 우선 프리코스에 대한 1차합격을 통과부터 하고 고민을 하는게 맞으니까 하나씩 해보려고 합니다.

클린코드와 객체지향에는 계속 관심이 많았지만, 사실 써볼 기회도 많이 없었고 계속해서 써보려고해요

내일 프리코스에 관한 오리엔테이션을 진행한다고 하는데 하필이면 시험인지라 아쉽지만, 녹화방송으로보고 내일부터 열심히 써보려고합니다.

계속해서 프리코스에 관한 1일 1블로그로 내가 코드를 짤때 생각 했던것을 올리려고 하는것 만해도 그 당시에 어떤 생각을 가졌는지를 알 수 있기 때문에 노력해보려고 합니다.

이번 프리코스에는 이전 기수와 다르게 코딩테스트가 없어서 많은 사람들이 신청하는데 다같이 성장할 수 있는 기회가 되었으면 좋겠습니다 😀

라고 할뻔했습니다... 안타깝게도 바로바로 블로그 포스팅은 주차가 끝난 뒤에 하면 좋겠다고 우아한테크코스 프리코스 OT에서 언급을 했기 때문에 목표를 새로 정하려고 합니다!!

기능 별로 커밋을 하기 때문에 1일 1커밋을 목표로 달리고, 1주차가 끝나고 그 동안 모았던 블로그글을 Notion으로 정리해놓고 한번에 올릴겁니다!!!

이번 기회로 많은 배움을 얻었으면 좋겠습니다 !! 합격보단 성장을 목표로 달리겠습니다!!

UserDaoTest 다시보기

테스트의 유용성

우리가 현재 만들었던 UserDaoTestadd()get() 메서드를 이용해서 UserDao 에서의 add()get() 을 호출합니다.

이러한 테스트 코드는 내가 예상하고 의도했던 때로 코드가 정확히 동작하는지를 확인해서, 만든 코드를 확신할 수 있게 해주는 작업입니다.

이를 통해서 코드나 설계의 결함을 확인할 수 있습니다.


UserDaoTest의 특징

우리가 이전에 만들었던 main() 메서드로 작성된 테스트코드를 다시 살펴봅시다.

public static void main(String[] args) throws ClassNotFoundException, SQLException{
    UserDao dao = new UserDao();

    User user = new User();
    user.setId("haecahn");
    user.setName("유해찬");
    user.setPassword("1234");

    dao.add(user);

    System.out.println(user.getId() + "제대로 나왔습니다");

    User user2 = dao.get(user.getId());
    System.out.println(user2.getName());
    System.out.println(user2.getPassword());

    System.out.println(user2.getId() + "제대로 나왔습니다");

}

이러한 테스트는 main() 메서드를 사용해서 쉽게 테스트 수행을 가능하게 했다는 점과 테스트할 대상인 UserDao를 직접 호출해서 사용한다는 점이 특징입니다.


웹을 통한 DAO 테스트 방법의 문제점

우리가 보통 DAO를 테스트 하는 방법은 DAO를 만들고 난 뒤에 바로 테스트를 하는 것이 아닌 서비스 계층과 MVC 패턴등을 모두 대충이라도 만든 후에 직접적으로 웹에서 기능들을 실행해보면서 확인을 합니다.

하지만 이러한 방법은 모든 서비스 클래스, 컨트롤러 JSP등을 모두 만들고 나서야 테스트가 가능하는게 큰 문제입니다.

그래서 문제가 발생을 한다면 어디서 발생했는지 못찾는다는 단점이 존재합니다.


작은 단위의 테스트

우리는 테스트하고자 하는 대상이 명확하다면 그 대상에만 집중해서 테스트하는 것이 바람직합니다.
모든 것들을 한꺼번에 몰아서 한다면 테스트 수행과정이 복잡해지고 정확한 원인을 찾기가 힘들어집니다. 이를 통해서 관심사의 분리라는 원리가 적용됩니다.

UserDaoTest는 한 가지 관심에 집중할 수 있게 작은 단위로 만들어진 테스트입니다.

그래서 서비스 오브젝트나 MVC같은 것들이 필요가 없습니다.

이렇게 작은 단위로 테스트 하는 것을 단위 테스트(Unit test) 라고 불립니다.

여기에서 말하는 단위는 크기와 범위가 딱 정해진것이 아니라 충분히 하나의 관심에 집중해서 효율적으로 테스트할 만한 범위라고 보면 됩니다.

일반적으로는 단위는 작을수록 좋습니다. 또한, 단위를 넘어서는 다른 코드들은 신경 쓰지 않고, 참여하지도 않고 테스트가 동작할 수 있으면 좋습니다.


그렇다면 UserDaoTest는 테스트 중에 DB까지 사용이 되는데 이를 단위 테스트라고 봐도 좋은것일까요?

하지만 UserDaoTest는 이를 수행할 때 User 테이블의 내용을 비우고 테스트를 진행 했습니다.

이는 결론적으로 DB의 상태를 Test가 관장하고 있기 때문에 우리는 단위 테스트라고 부를수 있습니다.

단위 테스트가 필요한 이유는 무엇인지 살펴봅시다

우리가 단위 테스트를 하는 이유는 개발자가 설계하고 만든 코드가 원래 의도한 대로 동작하는지 개발자 스스로 빨리 확인 받기 위해서 우리는 단위 테스트를 사용합니다.

만약 개발자가 스스로 테스트를 받지 않느다면 나중에 클라이언트에게 주고 테스트를 받을 떄는 코드가 엄청커져서 오류를 찾기 힘들 것입니다.

이러한 단위 테스트는 우리가 만든 혹은 개선한 코드가 처음 설계하고 의도한 대로 바르게 동작했는지를 확인하기 위해서 개발자 입장에서 만든 것이므로 이를 개발자 테스트라고도 부를 수 있습니다.


자동수행 테스트 코드

UserDaoTest의 한 가지 특징은 테스트할 데이터가 코드를 통해 제공되고 테스트 작업도 코드를 통해 자동으로 실행합니다.

그래서 웹 화면에 폼을 띄우고 버튼을 누르는 일이 없습니다.

현재 우리가 만든 UserDaoTestmain() 메서드를 실행하는 가장 간단한 방법만으로 테스트의 전 과정이 자동으로 진행이 됩니다. 이러한 코드는 우리가 웹 화면에 폼을 띄우고 테스트를 직접하는 방법보다 훨씬 빨리 진행이 될 수 있습니다.

이러한 테스트는 자동으로 수행되도록 코드로 만들어지는 것이 중요합니다.

또한, 테스트 코드는 어플리케이션을 구성하는 클래스 안에 테스트 코드를 포함시키는 것보단 테스트용 클래스를 만들어서 테스트 코드를 넣는 편이 낫습니다.

그래서 우리는 UserDaoTest 라는 별개의 클래스로 분리해서 그 안에 테스트 코드를 넣도록 했습니다.

이러한 자동으로 수행되는 코드의 장점은 자주 반복할 수 있다는 것 입니다. 이를 통해서 빠른 테스트를 할 수 있습니다.


지속적인 개선과 점진적인 개발을 위한 테스트

우리는 지금까지의 과정을 통해서 DAO코드를 → 스프링을 이용한 코드로 만들 때 테스트가 중요한 역할을 했습니다.

우리는 그래서 DAO코드를 작성하고 바로 DAO를 테스트하는 코드를 만들어 주었기 때문에 코드를 개선하는 과정에서 실수를 테스트를 통해 바로 확인할 수 있었습니다.

만약에 그것이 아니라 처음부터 스프링을 적용한 뒤 XML로 설정을 만들고 모든 코드를 검증하려고 했다면 우리는 오류가 난다면 막막할 것입니다.

우리는 이런식으로 기능을 만들면 테스트도 함께 추가하는 방식으로 점진적인 개발이 가능해집니다.

이러한 테스트를 사용한다면 새로운 기능도 기대한 대로 동작하는지 확인을 할 수 있습니다.

또한, 새로운 기능을 추가ㅎ느라 수정한 코드에 영향을 받지 않고 여전히 잘 동작하는지 확인이 가능합니다.


UserDaoTest의 문제점

물론 웹을 통한 Test보다 훌륭하지만 이는 문제점이 존재합니다.

수동 확인 작업의 번거로움

우리는 add()를 통해서 User 정보를 DB에 등록을하고 이 때 넣었던 User의 정보가 get()으로 가져왔을때 같은지는 눈으로 직접확인을 해야합니다. 이를 Test코드는 확인을 해주지 않습니다.

현재는 필드가 별로 많지 않아서 직접 확인해도 많이 번거롭지는 않지만 만약 필드값이 늘어나거나 복잡하다면 이는 물론 복잡한 일입니다.


실행 작업의 번거로움

우리는 DAO가 많아지고 이를 테스트하는 main() 메서드도 많아진다면 전체 기능을 테스트해보기 위해서 main() 메서드를 엄청 많이 실행하는 수고가 필요합니다.

또한, 테스트 결과를 정리하는 것도 큰 작업이 됩니다.


UserDaoTest 개선

테스트 검증의 자동화

우선 첫 번째 문제점인 수동 확인 작업의 번거로움을 해결해 봅시다.

우리는 테스트에 대해서 성공과 실패의 두 가지 결과를 가질 수 있습니다. 또한, 실패에 대해서는 2가지로 나누어집니다.

  • 테스트 진행중 오류가 발생하는 경우
  • 테스트 작업중 에러는 발생하지 않았지만 결과값이 기댓값과 다를 경우

이러한 두 가지로 나뉘어집니다.

테스트에 에러가 발생하는 것은 콘솔에 에러 메시지와 호출 스택 정보를 통해서 확인이 가능합니다.

하지만 테스트가 실패를 한다는 것은 우리는 별도의 확인 작업이 필요합니다. get() 을 통해서 가져온 결과가 add()를 한 값과 다를 때는 “테스트 실패”, 아닌 경우는 “조회 성공”이라는 값을 나타 내도록 수정하면 다음과 같습니다.

//변경 전
System.out.println(user2.getName());
System.out.println(user2.getPassword());
System.out.println(user2.getId() + "조회 성공");

//변경 후
if (!user.getName().equals(user2.getName())){
    System.out.println("테스트 실패 (name)");
}
else if(!user.getPassword().equals(user2.getPassword())){
    System.out.println("테스트 실패 (password)");
}
else{
    System.out.println("테스트 성공");
}

이렇게 가져온 오브젝트가 같은지 equals 라는 메서드를 통해서 확인해서 검증을 할 수 있습니다.

이러한 테스트는 UserDao의 두 가지 기능이 정상적으로 동작하는지를 언제든지 손쉽게 확인할 수 있게 해줍니다.

그렇기 떄문에 이 코드의 동작에 영향을 미칠 수 있는 어떤 변화라도 생기면 언제든 다시 실행해볼 수 있습니다.

만약 스프링이 아닌 다른 프레임워크나 기술로 변화해도 우리가 만든 테스트 한 번이면 충분하게 됩니다.

우리는 만들어진 코드의 기능을 모두 점검할 수 있는 포괄적인 테스트를 만들어서 기존에 수동 테스트로는 당장 수정한 기능의 가장 간단한 케이스를 확인하기도 힘든 상황을 극복할 수 있게 되었습니다.


테스트의 효율적인 수행과 관리

이제 main() 메서드로 만든 테스트는 테스트로서 필요한 기능은 모두 갖추었습니다. 하지만 이러한 main() 메서드로는 한계가 있습니다. 이를 해결하기 위해서 우리는 Junit을 사용할 것 입니다.

Junit 테스트로 전환

Junit은 자바로 단위 테스트를 만들때 사용하는 프레임워크입니다.

프레임워크는 개발자가 만든 클래스에 대한 제어 권한을 넘겨받아서 주도적으로 애플리케이션의 흐름을 제어합니다.

이러한 프레임워크에서 동작하는 코드는 main() 메서드도 필요하지 않고 오브젝트를 만들어서 실행시키는 코드를 만들 필요도 없습니다.


테스트 메서드 전환

우리가 현재 main() 메서드로 테스트가 만들어졌다는 것은 제어권을 직접 갖는다는 의미이므로 이를 일반 메서드로 옮기는 과정을 진행할 것입니다.

이때 우리는 JUnit 프레임워크가 요구하는 조건 2가지를 따라야합니다.

  • 메서드는 public 으로 선언돼야 합니다.
  • 메서드에 @Test 라는 에너테이션을 붙여야합니다.
@Test
public void addAndGet() throws SQLException{
    ApplicationContext context = new
        ClassPathXmlApplicationContext("applicationContext.xml");

        UserDao dao = context.getBena("userDao", UserDao.class); 

        /...
}

검증 코드 전환

우리는 테스트의 결과를 검증하는 if/else를 JUnit 이 제공하는 방법을 이용해서 전환해보려 합니다.

우리는 현재 equals 를 통해서 반환된 객체가 같은지를 확인합니다.

이러한 것을 assertThat 이라는 스태틱 메서드를 이용해서 비교를 하는 것으로 변환을 해줍니다.

assertThat() 은 첫 번째 파라미터의 값을 뒤에 나오는 매처 라고 불리는 조건으로 비교해서 일치하면 넘어가고 아니면 테스트가 실패하도록 합니다. 그래서 이는 “테스트 성공”과 같은 메시지를 굳이 출력할 필요는 없습니다.

@Test
public void addAndGet() throws SQLException{
    ApplicationContext context = new
        ClassPathXmlApplicationContext("applicationContext.xml");

        UserDao dao = context.getBena("userDao", UserDao.class); 

        User user = new User();
        user.setId("haechan");
        user.setName("해찬");
        user.setPassword("1234");

        dao.add(user);

        User user2 = dao.get(user.getId());

        // 검증
        assertThat(user2.getName(), is(user.getName()));
        assertThat(user2.getPassword(), is(user.getPassword()));
}

JUnit 테스트 실행

우리를 테스트 메서드를 시작하기 위해서는 JUnit 프레임 워크를 시작시켜야 합니다.

그러기 위해서는 어디에서든 main() 메서드를 하나 추가하고 그 안에 JUnitCore 클래스의 main() 메서드를 호출해주는 간단한 코드를 넣으면 됩니다.

또한, 메서드 파라미터에는 @Test 테스트 메서드를 가진 클래스의 이름을 넣어줍니다. 예시는 다음과 같습니다.

import org.junit.runner.JUnitCore;

public static void main(String[] args) {

        JUnitCore.main("springbook.user.dao.UserDaoTest");
}

이런식으로 설정을 하면 결과가 나오게 됩니다.

만약 테스트가 실패한다면 총 수행한 테스트중에서 몇 개의 테스트가 실패했는지를 보여줍니다.

우리는 assertThat() 에서 검증한 결과값이 아니면 AssertionError 를 던져서 예외를 발생합니다. 그 뒤에 테스트는 중단이 됩니다.


개발자를 위한 테스팅 프레임워크 JUnit

JUnit은 자바의 표준 테스팅 프레임워크라고 불릴만큼 폭넓게 사용되고 있습니다.

스프링 프레임워크 자체도 JUnit 프레임워크를 이용해서 테스트를 만들어가면서 개발되었습니다. 그래서 스프링도 JUnit을 이용합니다. 그래서 이러한 기능을 배워보도록 하겠습니다.

테스트 결과의 일관성

우리는 현재 JUnit을 적용해 깔끔한 테스트 코드를 만들었습니다. 하지만 불편한 점도 존재합니다.

우리는 테스트를 실행할 때 DB의 USER 테이블의 데이터를 모두 사젝해줘야 합니다. 만약 이전 테스트의 정보가 있으면 에러가 발생할 것입니다.

이를 살펴본다면 외부의 (DB 에) 상태에 따라서 성공하기도 실패하기도 한다는 점입니다.

이러한 코드는 외부요인에 따라서 변하기 때문에 좋은 테스트라고 할 수 없습니다.

만약 DB의 USER 테이블에 문제가 있다면 이를 테스트를 실행하고 난 뒤 마다 테스트를 수행하기 이전 상태로 만들어 주는 것입니다.

이렇게 한다면 테스트를 수행하기 이전 상태로 만들어줄 수 있습니다.


deleteAll()의 getCount() 추가

일관성있는 결과를 만들기 위해서 다음 코드를 추가합니다.

// deleteAll() 메서드
public void deleteAll() thorws SQLException{
    Connection c = dataSource.getConnection();

    PreparedStatement ps = c.prepareStatement("delete from users");
    ps.executeUpdate();

    ps.close();
    c.close();
}

// getCount() 메서드
public int getCount() throws SQLException{
    Connection c = dataSource.getConnection();

    PreparedStatement ps = c.prepareStatement("select count(*) from users");

    ResultSet rs = ps.executeQuery();
    rs.next();
    int count = rs.getInt(1);

    rs.close();
    ps.close();
    c.close();

    return count;
}

deleteAll()과 getCount()의 테스트

우리는 새로운 기능을 추가했기 때문에 추가된 기능에 대한 테스트를 만들어 봅시다.

하지만 새로 만든 deleteAll()getCount()는 독립적으로 자동 실행되는 테스트를 만들기가 애매합니다.

만약, 굳이 테스트를 한다면 수동으로 데이터를 넣은 뒤 deleteAll() 메서드로 삭제를 하고 테이블에 남은 게 있는지 확인해야 하면 됩니다. 하지만 이런 방법은 자동회되서 반복적으로 실행 가능한 테스트 방법은 아닙니다.

그래서 우리는 addAndGet() 테스트를 확정하는 방법을 사용해서 이를 검증합니다.

이에 대한 테스트의 불편한 점은 수동으로 USER 테이블의 내용을 모두 삭제해줘야합니다.

이를 사용하면 매번 USER 테이블의 모든 내용을 삭제할 수 있어 테이블을 수동으로 삭제하지 않아도 됩니다.

하지만 이는 부족합니다. 우선 deleteAll() 의 검증이 되지 않았는데 addAndGet() 의 검증을 진행할 수 없습니다. 그래서 getCount() 를 사용해서 검증을 합니다. 만약 모두 삭제가 되었으면 count = 0 이 될 것입니다.

일단 우리는 getCount()를 검증을 해봅시다.

앞에서 add()는 검증이 완료되었기 때문에 add()를 통해서 만약, 데이터를 넣으면 count가 바뀌는 것을 확인하면 검증이 가능합니다. 이를 통해서 deleteAll()을 하고 count가 0이 되는것을 보고 난 뒤에 이도 검증을 끝마칠수 있습니다.

// addAndGet() 
@Test
public void addAndGet() throws SQLException{

        .../

        dao.deleteAll();
        assertThat(dao.getCount(), is(0));    

        User user = new User();
        user.setId("haechan");
        user.setName("해찬");
        user.setPassword("1234");

        dao.add(user);

        User user2 = dao.get(user.getId());

        // 검증
        assertThat(user2.getName(), is(user.getName()));
        assertThat(user2.getPassword(), is(user.getPassword()));
}

동일한 결과를 보장하는 테스트

이제 테스트를 실행해본다면 몇번을 실행하도 계속 성공할 것입니다. 이러한 addAndGet(0) 을 통해서 수동으로 데이터를 삭제할 일이 사라졌습니다.

우리는 이러한 방식말고도 다른 방식이 존재합니다. addAndGet() 테스트를 마치기 전에 테스트가 변경하거나 추가한 데이터를 모두 원래 상태로 만들어 주는 것도 한가지 방법입니다.

이것도 나쁜방법은 아니지만 addAndGet() 테스트 실행 이전에 다른 이유로 USER 테이블에 데이터가 들어가 있으면 테스트가 실패할수도 있습니다. 그래서 테스트하기 전에 테스트 실행에 문제가 되지 않는 상태를 만들어주는 편이 좋습니다.


포괄적인 테스트

우리는 getCount(0) 를 사용해서 메서드에 테스트를 적용했지만 우리는 테이블이 비어있는 경우와 한번호출하는 경우만 사용이 가능합니다

이러한 우리가 생각하지도 못하는 문제들이 숨어있기 때문에 성의없게 테스트 코드를 작성하면 안됩니다.

getCount() 테스트

우리는 이러한 getCount() 테스트를 좀더 꼼꼼하게 만드려고 합니다.

이번에는 여러 개의 User를 등록해가면서 getCount() 의 결과를 매번확인하려고 합니다.

이를 위해서 addAndGet 메서드를 추가하는 것은 좋은 생각이 아닙니다. 왜냐하면 테스트 메서드는 한 번에 한 가지 검증 목적에만 충실해야 하기 때문입니다.

JUnit은 하나의 클래스 안에 여러 개의 테스트 메서드가 들어가는 것을 허용합니다. 그렇기 때문에 이를 메서드를 여러개 만들어서 사용을합니다.

우선 테스트를 만들기 전에 먼저 User 클래스에 한 번에 모든 정보를 넣을 수 있도록 초기화가 가능한 생성자를 추가합니다.

public User(String id, String name, String password){
    this.id = id;
    this.name = name;
    this.password = password;
}

public User(){ // 기본생성자 만들기

}

이렇게 변경하면 addAndGet() 도 다음과 같이 수정이 가능합니다.

UserDao dao = context.getBean("userDao", UserDao.class);
User user = new User("gyumee", "박성철", "springno1");

이제 getCount() 에 대한 테스트 메서드를 작성해보겠습니다.

@Test
public void count() throws SQLException{
    ApplicationContext context = new GenricXmlApplicationContext("applicationContex.xml");

    UserDao dao = context.getBean("userDao", UserDao.class);
    User user1 = new User("gyumee", "박성철", "springno1");
    User user2 = new User("hachan", "유해찬", "springno2");
    User user3 = new User("chayoo", "찬해유", "springno3");

    dao.deleteAll();
    assertThat(dao.getCount(), is(0));

    dao.add(user1);
    assertThat(dao.getCount(), is(1));

    dao.add(user2);
    assertThat(dao.getCount(), is(2));

    dao.add(user3);
    assertThat(dao.getCount(), is(3));
}

이렇게 작성을 하면 별 문제 없이 실행이 됩니다.

하지만 이는 특정한 테스트 메서드의 실행 순서를 보장해주지는 안씁니다.

만약, 테스트의 결과가 테스트 실행 순서에 영향을 받는다면 테스트를 잘못 만든 것입니다.

그래서 우리는 모든 테스트는 실행 순서에 상관없이 독립적으로 항상 동일한 결과를 낼 수 있도록 해야합니다.


addAndGet() 테스트 보완

이제 addAndGet() 을 보완해봅시다. 이는 우리는 현재 add() 는 여러번 하면서 검증을 완료하였지만 get() 에 대해서는 id를 조건으로만 찾았기 때문에 이는 부족한 감이 있습니다. 그래서 검증이 부족한 감이 있습니다.

이를 User의 id를 파라미터로 전달하여 get()을 실행하게 할 것입니다.

그래서 get()에 대한 테스트 기느이 보완된 addAndGet() 테스트코드를 살펴봅시다.

@Test
public void addAndGet() throws SQLExeption{
    /...
    UserDao dao = context.getBean("userDao", UserDao.class);
    User user1 = new User("haechan", "유해찬", "spring1");
    User user2 = new User("chanyoo", "찬해유", "spring2");

    dao.deleteAll();
    assertThat(dao.getCount(), is(0));

    dao.add(user1);
    dao.add(user2);
    assertThat(dao.getCount(), is(2));

    User userget1 = dao.get(user1.getId());
    assertThat(userget1.getName(), is(user1.getName());
    assertThat(userget1.getPassword(), is(user1.getPassword());

    User userget2 = dao.get(user2.getId());
    assertThat(userget2.getName(), is(user2.getName());
    assertThat(userget2.getPassword(), is(user2.getPassword());

}

이를 통해서 get() 메서드에 대해서 더욱 정확히 확인할 수 있습니다.


get() 예외조건에 대한 테스트

get() 메서드에 전달된 id 값에 해당하는 사용자 정보가 없으면 어떻게 될지 생각해봅시다.

우리는 이를 2가지 방법으로 처리할 수 있습니다.

  • null과 같은 값을 리턴하는 방식
  • id에 해당하는 정보를 찾을 수 없다고 예외를 던지는 방식입니다.

우리는 후자를 사용할 것입니다.

스프링에서는 데이터 액세스 예외 클래스가 있습니다. 우리는 그 예외인 EmptyResultDataAccessException 예외를 사용할 것입니다.

만약, get()을 실행 했을 때 결과에 아무것도 없으면 예외를 던지게 하면됩니다. 만약 예외가 던져지게 되면 테스트 메서드의 실행은 중단되고 실패합니다.

하지만 우리는 현재 EmptyResultDsataAccessException 이라는 예외가 던져져야 우리는 테스트가 성공을 했구나? 라는 것을 알 수 있습니다. 이런 특별한 상황에는 assertThat() 을 사용할 수 없습니다.

그래서 이런경우에는 JUnit의 기능을 사용할 수 있습니다. 확인해 봅시다.

@Test(excepted=EmptyResultDataAccessException.class)
public void getUserFailure() throws SQLException{
    ApplicationContext context = new GenericXmlApplicationContext("appplicationContext.xml");

    UserDao dao = context.getBean("userDao", UserDao.class);
    dao.deleteAll();
    assertThat(dao.getCount(),is(0));

    dao.get("unknown_id");
}

@Test 에서 excepted 엘리먼트를 사용해서 우리는 기대하는 예외클래스를 넣어주면 확이니 가능합니다.

만약, excepted 라는 엘리먼트를 추가하면 예외가 던져지면 성공이고 던져지지 않으면 실패입니다.


테스트를 성공시키기 위한 코드의 수정

이제부터 우리는 get() 메서드 코드를 수정해서 이 테스트가 성공하도록 할 것입니다.

다음과 같이 작성을 하면 만들 수 잇습니다.

// 데이터를 찾지 못하면 예외를 발생시키도록 수정한 get() 메서드
public User get(String id) throws SQLException{
    /...
    ResultSet rs = ps.excuteQuery();

    User user = null;
    if(rs.next()){
        user = new User();
        user.setId(rs.getString("id"));
        user.setName(rs.getString("name"));
        user.setPassword(rs.getString("password"));

    rs.close();
    ps.close();
    c.close();

    if(user == null) throw new EmptyResultDataAccessException(1);

    return user;
    }
}

이런식으로 get() 메서드에서 데이터가 없을 때 예외를 발생시키면 테스트는 성공할 것입니다.


포괄적인 테스트

사실 우리가 작성한 코드들은 간단해서 테스트가 없어도 문제를 살펴볼 수 잇습니다.

하지만 이런식으로 DAO의 메서드에 대해서 포괄적인 테스트를 만들어두는 편이 훨씬 안전하고 유용합니다.

또한, 여러가지 이점이 존재합니다.

개발자들은 이상한 습성이 있습니다. 바로 성공하는 테스트만 골라서 만드는 것입니다. 하지만 이러한 것은 큰 문제점입니다. 스프링의 창시자도 ”항상 네거티브 테스트를 먼저 만들라” 라는 조언을 하고 잇습니다. 그렇기 때문에 부정적인 케이스 부터 먼저 만드는 습관을 들이는 것이 좋습니다.


테스트가 이끄는 개발

우리는 get() 메서드의 예외 테스트를 만들면서 UserDao 코드를 수정을 계속해서 반복하였습니다. 이는 테스트를 통해서 우리는 개발을 진행하였습니다.

물론 테스트할 코드도 없이 테스트 코드를 만드는 것은 이상하지만 이러한 순서를 따라서 개발하는 TDD 방식의 개발도 존재합니다, 우리는 이런 개발 방법을 적극적으로 권장합니다.=


기능설계를 위한 테스트

우리는 가장 먼저 존재하지 않는 id로 get() 메서드를 실행한다면 특정한 예외가 던져져야한다. 라는 방식으로 만들어야 할 기능을 결정하였습니다.

하지만 테스트할 코드도 없는데 어떻게 테스트할까요? 이는 추가하고 싶은 기능을 코드로 표현하려고 했기 때문에 가능합니다.

우리는 이를 조건, 행위, 결과로 나타낼 수 있습니다.

getUserFailure()

타입 단계 내용 코드
조건 어떤 조건을 가지고 가져올 사용자 정보가 존재하지 않는 경우에 dao.deleteAll()
assertThat(dao.getCount()m is(0));
행위 무엇을 할 때 존재하지 않는 id로 get()을 실행하면 get("unknown_id");
단계 어떤 결과가 나온다 특별한 예외가 던져진다. @Test(expected=EmptyResultDataAccessEception.class)

이러한 테스트 코드는 마치 잘 작성된 하나의 기능정의서처럼 보입니다.

테스트가 실패한다면 설계한 대로 코드가 만들어지지 않는다는 것이기 때문에 이를 통해서 코드를 계속 다듬어갑니다.


테스트 주도 개발

우리는 이러한 방식을 테스트 주도 개발(TDD, Test Driven Development)라고 합니다.

또는 테스트 우선 개발 이라고도 부릅니다.

테스트 주도 개발의 중요한 점은 실패한 테스트를 성공시키기 위한 목적이 아닌 코드는 만들지 않는다. 라는 것입니다. 이에 대한 장점은 다음과 같습니다.

  • TDD는 아예 테스트를 먼저 만들고 그 테스트가 성공하도록 하는 코드만 만드는 식으로 진행하기 때문에 테스트를 빼먹지 않고 꼼꼼하게 만들어 낼 수 있습니다.
  • 또한, 코드를 만들고 테스트를 수행할 때까지 걸리는 시간은 0입니다.
  • 매번 테스트가 성공하는 것을 보면서 작성한 코드에 대한 확신을 가질 수 있습ㄴ디ㅏ.

이러한 TDD를 할 때는 테스트를 작성하고 이를 성공시키는 코드를 만드는 작업의 주기를 가능한 짧게 가져가도록 권장합니다.

우리는 머리속에서 예를들어 ‘이런 조건의 값이 들어오면 코드의 흐름과 조건을 따라서 이렇게 진행되서 이런결과의 값이 나오겠다.’ 라는 식의 시뮬레이션을 계속합니다.

현재 우리는 머리속으로만 이를 시뮬레이션을 하는데 그러면 제약도 심하고 오류도많고 다시 반복하기가 힘듭니다.

이러한 아이디어들을 바로 코드로 끄집에 내는 것이 TDD 입니다.


테스트 코드 개선

우리는 3개의 테스트 메서드를 만들었습니다. 이제 테스트 코드에 대해서 리펙토링 해봅시다.

일단 UserDao 를 가져오는 부분이 계속 반복이 됩니다. 이를 메서드를 통해서 분리하는 것 보다는

JUnit 이 제공하는 기능을활용해서 변경해봅시다.

@Before

우리는 @Before 어노테이션을 사용해서 setUp() 이라는 메서드를 만들어서 테스트 메서드에서 반복되는 ㄱ코드를 넣고 이를 로컬로 private 하게 변수를 선언해서 변수에 저장하여 언제든지 쓸수 있게 해봅시다.

import org.junit.Before;

public class UserDaoTest{
    private UserDao dao;

    @Before
    public void setUp(){
        ApplicationContext context = new GenericXmlApplicationContext("applicationContext.xml");
        this.dao = context.getBean("userDao", UserDao.class);
    }

    @Test
    public void addAndGet() throws SQLException{
        /...
    }

    /...
}

이런식으로 작성하게 되면 테스트 코드를 실행 전에 이를 얻게 됩니다.

왜 이렇게 작동하는지에 대해서는 JUnit 이 하나의 테스트 클래스를 가져와 테스트를 수행하는 방식을 살펴봅시다.

  1. 테스트 클래스에서 @Test 가 붙은 public 이고 void 형이며 파라미터가 없는 테스트 메서드를 모두 찾는다.
  2. 테스트 클래스의 오브젝트를 하나 만든다.
  3. @Before 가 붙은 메서드가 있으면 실행한다.
  4. @Test 가 붙은 메서드를 하나 호출하고 테스트 결과를 저장해둔다.
  5. @After 가 붙은 메서드가 있으면 실행합니다.
  6. 나머지 테스트 메서드에 대해 2~5번을 반복합니다.
  7. 모든 테스트의 결과를 종합해서 돌려줍니다.

세부적으로는 더 복잡하지만 크게보면 다음과 같습ㄴ디ㅏ.

그렇기 때문에 @Before 이나 @After 메서드를 자동으로 실행합니다.

하지만, @Before@After 메서드를 테스트 메서드에서 직접 호출하지 않기 때문에 이를 인스턴스 변수를 이용해서 저장하여 테스트 메서드에서 사용하게 만들면 됩니다.

이러한 테스트 메서드를 실행할때마다 새로운 오브젝트를 만들고 있습니다. JUnit은 각 테스트가 서로 영향을 주지 않고 독립적으로 실행됨을 확실히 보장해주기 위해서 매번 새로운 오브젝트들을 만들게 한것이 그 이유입니다

그래서 우리는 인스턴스 변수를 부담없이 사용을 할 수 있습니다. 다음 텍스트 메서드가 실행 될 때는 새로운 오브젝트가 만들어져서 다 초기화 될 것이기 때문입니다.

만약, 테스트 메서드의 일부에서만 공통적으로 사용되는 코드가 있다면 어떻게 해야할까요?

이때는 @Before 보다는 일반적으로 우리가 사용하는 메서드 분리 방식을 이용하는 것이 바람직 합니다.


픽스처

픽스처(fixture)는 테스트를 수행하는 데 필요한 정보나 오브젝트를 말합니다.

이는 여러 테스트에서 반복되어 사용되기 때문에 @Before 를 통해서 메서드를 생성해두면 편합니다.

우리가 짠 코드를 본다면 현재 getUserFailure() 라는 테스트를 제외하고는 User 에 대한 오브젝트가 사용이 되고 있습니다. 우리는 일부에서만 공통적으로 사용하기 때문에 메서드 분리 방식을 이용한다고 생각하지만 다른 테스트들은 거의다 User 에 대해서 사용할 것이기 때문에 @Before 를 통해서 빼주도록 합니다.

그래서 다음과 같은 코드를 생성할 수 있습니다.

public class UserDaoTest{
    private UserDao dao;
    private User user1;
    private User user2;
    private User user3;

    @Before
    public void setUp(){
        /...
        this.user1 = new User("haechan", "유해찬", "spring1");
        this.user2 = new User("chanyoo", "찬유해", "spring2");
        this.user3 = new User("yoohae", "해유찬", "spring3");
    }
} 

+ Recent posts