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

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

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

의존관계 주입(DI)

의존관계 주입(DI)

제어의 역전(Ioc)과 의존관계 주입

우리가 배우고 있는 IoC는 너무 폭넓게 사용하는 언어입니다. 그래서 스프링을 IoC 컨테이너 라고만 말하면 스프링이 제공하는 기능의 특징을 명확하게 설명할 수 없습니다.

이렇게 명확하지 않고 너무 폭넓게 사용하기 때문에 스프링이 제공하는 IoC방식의 핵심을 짚어주는 의존관계 주입(Dependency Injection) 이라는 단어를 사용해서 부르기로 약속하였습니다.

스프링 IoC 기능의 대표적인 동작원리는 주로 의존관계 주입이라고 불립니다.


런타임 의존관계 설정

의존관계

우선 의존관계 라는 것은 방향성이라는 것이 있습니다. 의존을 하는 것과 의존을 받는 것 2가지로 나뉘어집니다.

만약 의존한다 라는 것은 의존 대상이 변하면 의존하는 것에 영향을 미친다는 것입니다.

다음과 같이 의존이라는 것은 점선으로 된 화살표로 나타냅니다.

현재는 A가 B에 의존하고 있다는 것을 나타냅니다.

만약, B가 변하면 A에 영향이 그대로 전달됩니다.

의존관계에는 방향성이 있습니다. 서로 의존관계라면 예를들어서 B가 현재 새로운 메서드가 추가되거나 기존 메서드의 형식이 변한다면 A에도 영향을 미쳐서 A도 변화를 시켜야합니다.


UserDao의 의존관계

UserDao 는 현재 ConnectionMaker 라는 인터페이스에 의존하고 있습니다.

그래서 만약, ConnectionMaker 의 메서드가 변하던가 이러한 일들이 일어난다면 UserDao에 영향을 직접적으로 받게 됩니다.

하지만 UserDaoConnectionMaker 를 구현한 클래스인 다른 회사의 Maker 예를들어서 AconnectionMaker 등이 변화할 때는 영향이 없습니다.

원래는 UserDaoConnectionMaker 라는 인터페이스가 없었을 경우는 다른 회사의 ConnectionMaker 로 변화했을 경우에 의존관계 때문에 UserDao 의 코드가 직접적으로 변경이 되어야 했지만 이제는 그렇지 않습니다.

이런식으로 인터페이스를 통해 의존관계를 제한해주면 그만큼 변경에서 자유로워질 수 있습니다.

우리가 이런식으로 ConnecitonMaker 라는 인터페이스를 통해서 새로 만들어서 UserDao 는 어떤 회사의 ConnectionMaker 를 사용하는지 알지도 못한채로 사용을 하고있습니다.

그래서 결론적으로 UserDaoConnectionMaker 라는 인터페이스를 사용하고 있기 때문에 UserDao 의 오브젝트가 런타임 시에 사용할 오브젝트(의존 오브젝트 / Dependent Object)가 어떤 클래스로 만든 것인지 미리 알 수가 없습니다.

결론적으로 의존관계 주입이란 구체적인 의존 오브젝트와 그것을 사용할 사용할 주체인 클라이언트라고 부르는 오브젝트를 런타임 시에 연결해주는 작업을 말합니다.

이러한 의존관계 주입은 다음과 같은 세가지 조건을 충족하는 작업을 말합니다.

  • 클래스 모델이나 코드에는 런타임 시점의 의존관계가 드러나지 않는다. 그러기 위해서는 인터페이스에만 의존하고 있어야합니다.
  • 런타임 시점의 의존관계는 컨테이너나 팩토리 같은 제3의 존재가 결정한다.
  • 의존관계는 사용할 오브젝트에 대한 레퍼런스를 외부에서 제공해줌으로써 만들어진다.

우리는 현재로 DaoFactory 를 사용해서 DI에서 말하는 제 3의 존재를 설정하였습니다.


UserDao의 의존관계 주입

우리는 UserDao에 적용된 의존관계 주입 기술을 다시 살펴볼 것입니다.

우리가 만든 예전의 UserDao 클래스의 생성자는 다음과 같습니다.

public UserDao(){
    connectionMaker = new AConnectionMaker();
}

이러한 UserDao 의 예전 코드는 저런식으로 AConnectionMaker() 라는 구체적인 클래스를 알고있습니다.

이러한 것은 런타임 시의 의존관계가 코드 속에 다 미리 결정되어 있다는 점이 문제점입니다.

이를 우리는 IoC 방식을 사용해서 UserDaoDaoFactory 라는 제3의 존재에 런타임 의존관계 결정 권환을 위임을 시켰습니다.

이런방식으로 우리는 앞서말한 3가지 조건을 충족시켜서 의존관계 주입을 이용했습니다. 이렇게 만들어진 DaoFactory는 다음과 같은 기능을 합니다.

  • 두 오브젝트 사이의 런터임 의존관계를 설정해주는 의존관계 주입 작업을 주도하는 존재입니다.
  • IoC방식으로 오브젝트의 생성과 초기화, 제공 등의 작업을 수행하는 컨테이너입니다.
  • 또한, 이를 의존관계 주입을 담당하는 컨테이너라고 볼 수 있고 DI 컨테이너라고 불러도 됩니다.

그래서 결론적으로 DaoFactoryDI 컨테이너라고 부릅니다.

우리는 DI 컨테이너인 DaoFactory 를 통해서 UserDao를 만드는 시점에서 생성자의 파라미터로 이미 만들어진 AConnectionMaker의 오브젝트를 전달합니다.

이런식으로 파라미터 전달을 생성자를 통해서 해줍니다. 그래서 우리는 결론적으로 다음과 같은 코드로 런타임 의존관계를 두 오브젝트간에 만들었습니다.

public class UserDao{
    private ConnectionMaker connectionMaker;

    public UserDao(ConnectionMaker connectionMaker){
        this.connectionMaker = connectionMaker;
    }
}

이렇게 DI 컨테이너에 의해 런타임 시에 의존 오브젝트를 사용할 수 있도록 그 래퍼런스를 전달받는 과정이 마치 메서드(생성자)를 통해 DI 컨테이너가 UserDao에게 주입해주는 것과 같다. 라고 보이기 때문에 이를 의존관계 주입(DI)라고 합니다.


의존관계 검색과 주입

스프링이 제공하는 IoC 방법에는 의존관계 주입만 있는 것이 아닙니다.

의존관계를 맺는 방법이 외부로부터의 주입이 아니라 스스로 검색을 이용해서 하는 방식도 있습니다.

이러한 것을 의존관계 검색(dependency lookup)이라고 불리는 것도 있습니다. 한번 살펴봅시다.

다음과 같은 UserDao의 생성자 리스트를 만들었다고 합니다.

public UserDao(){
    DaoFactory daoFactory = new DaoFactory();
    this.connectionMaker = daoFactory.connectionMaker();
}

이렇게 만든 생성자는 자신이 어떤 기업의 ConnectionMaker 오브젝트를 사용할지 미리 알지 못합니다.

또한, 의존대상은 ConnectionMaker 인터페이스 뿐입니다.

코드를 보면 DaoFactory 가 만들어서 돌려주는 오브젝트와 다이내믹하게 런타임 의존관계를 맺고있습니다.

그래서 IoC 개념을 잘 따르고 있지만 적용 방법이 생성자에서 DaoFactroy 에게 스스로 요청을 하고 있습니다.

지금은 자바코드지만 스프링에서 Application Context를 사용했다고 생각하면 다음과 같이 코드가 바뀔것 입니다.

public UserDao(){
    AnnotationConfigApplicationContext context =
        new AnnotationConfigApplicationContext(DaoFactory.class);
    this.connectionMaker = context.getBean("connectionMaker", ConnectionMaker.class);
}

getBean() 이라는 메서드를 사용해서 의존관계 검색을 합니다.

이런식으로 ApplicationContext를 사용해서 미리 정해놓은 이름을 전달해서 그 이름에 해당하는 오브젝트를 찾게 하기 때문에 우리는 의존관계 검색이라고 합니다.

이렇게 만들어진 의존관계 검색은 기존 의존관계 주입의 거의 모든 장점을 가집니다. 또한, IoC 원칙에도 잘 들어맞습니다. 하지만 방법만 다릅니다.

결론적으로 어떤것이 더 낫냐라고 본다면 의존관계 주입이 더 깔끔합니다. 또한, 다른 단점들도 존재합니다.

  • 의존관계 검색을 사용하면 코드 안에 오브젝트 팩토리 클래스나 스프링 API가 나타나서 성격이 다른 오브젝트에 의존하게 되어 바람직하지 않습니다.
  • 사용자에 대한 DB 정보를 어떻게 가져올건가에 집중하는 UserDao 에서 스프링이나 오브젝트 팩토리를 만들고 API를 이용하는 코드가 섞여있으면 어색합니다.

그래서 의존관계 주입 방법을 사용하지만 의존관계 검색 방식을 사용해야할 떄도 있습니다.

앞서서 만들었던 UserDaoTest를 다시 봅시다.

public class UserDaoTest{
    public static void main(String[] args) throws ClassNotFoundException,
            SQLException{

                ApplicationContext context = 
                    new AnnotationCongifApplicationContext(DaoFactory.class);

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

확인해보면 의존관계 검색 방식을 사용한 것을 볼 수 있습니다. 이러한 경우는 스프링의 IoC와 DI 컨테이너를 적용했다고 하더라도 애플리케이션의 기동 시점에서 적어도 한 번은 의존관계 검색 방식을 사용해 오브젝트를 가져와야 합니다.

왜냐하면 main() 메서드는 DI를 이용해 오브젝트를 주입받을 방법이 없기 때문에 우리는 서블릿이라는 것을 사용하여 스프링 컨테이너에 담긴 오브젝트를 사용하기 위해서 의존관계 검색 방식을 사용해 오브젝트를 가져와야한다.

우리가 사용하는 의존관계 검색의존관계 주입은 차이점이 있습니다.

의존관계 검색 방식은 검색하는 오브젝트는 자신이 스프링의 빈일 필요는 없습니다.

위에 코드를 살펴보면 UserDao 는 굳이 getBean()이 아닌 new UserDao() 해서 만들어도 사용해도 됩니다.

의존관계 주입에서는 UserDaoConnectionMaker 사이에 DI가 적용되려면 UserDao 도 반드시 컨테이너가 만드는 빈 오브젝트여야 합니다.

DI를 원하는 오브젝트는 먼저 자기 자신이 컨테이너가 관리하는 빈이 돼야 한다는 사실을 잊지 말아야합니다.


의존관계 주입의 응용

런타임 시에 사용 의존관계를 맺을 오브젝트를 주입해준다는 DI 기술의 장점은 무엇인지 확인해봅시다.

우리가 앞에서 오브젝트 팩토리로 구현을 하였는데 이는 DI 방식을 구현한 것입니다. 이러한 DI의 장점을 그대로 얻을 수 있습니다.

  • 코드에는 런타임 클래스에 대한 의존관계가 나타나지 않습니다.
  • 인터페이스를 통해 결합도가 낮은 코드를 만들어 다른 책임을 가진 사용 의존관계에 있는 대상이 바뀌거나 변경되더라도 자신은 영향을 받지 않습니다.
  • 변경을 통한 다양한 확장 방법에는 자유롭습니다.

우리는 이러한 DI 방식을 스프링을 사용하면 99% 혜택을 볼 수 있습니다. 이러한 DI 방식의 여러가지 응용사례를 살펴봅시다.


기능 구현의 교환

실제 운영에 사용할 데이터베이스는 매우 중요한 자원입니다. 이러한 데이터 베이스는 항상 부하가 많이 가해집니다. 그래서 개발 중에는 절대로 사용하지 말아야합니다.

그래서 이제 개발자의 로컬 DB로 사용한다고 생각해봅시다. 우리는 로컬 DB에 대한 기능이 있는 LocalDBConnectionMaker 라는 클래스를 만들고 제일 처음에 만들었던 초난감 DAO와 같이 사용하고 있다고 생각해봅시다.

이렇게 Local에서 사용을 하고나서 다시 서버에 배포할 때는 다시 서버가 제공하는 특별한 DB 연결 클래스를 사용해야한다고 생각해봅시다.

우리는 일단 초난감 DAO방식을 사용하고 있기 때문에 모든 DAO들에는 new LocalDBConnectionMaker() 라는 코드를 사용해서 LocalDBConnectionMaker에 의존을 하고 있을 것입니다.

만약 이제 배포를 해야한다고 생각해봅시다. 그러면 LocalDBConnectionMaker 에서 ProductionDBConnectionMaker 라는 클래스로 변경해줘야 한다고 생각해봅시다. 만약 DAO가 1000개라면 1000개의 코드를 모두 수정해야합니다. 이러한 방식은 매우 끔찍합니다.

만약, 초난감 DAO가 아닌 DI 방식을 적용해서 만들었다고 생각해봅시다.

그러면 DAO는 생성 시점에서 ConnectionMaker 타입의 오브젝트를 컨테이너로부터 제공을 받습니다.

컨테이너에는 구체적으로 사용할 클래스 이름이 들어있습니다.

그래서 @Configuration이 붙은 DaoFactory를 사용한다고 한다면 개발자 PC에서 DaoFactory 코드를 이전에 만든 방식처럼 만들면 됩니다.

@Configuration
public DaoFactory{
    @Bean
    public ConnectionMaker connectionMaker() {
        return new LocalDBConnectionMaker();
    }
}

이런 방식처럼 만든다면 DAO 클래스에서는 어떤 코드도 변경을 안해도 됩니다. 단지 다음과 같이 변경만 해주면 됩니다.

@Configuration
public DaoFactory{
    @Bean
    public ConnectionMaker connectionMaker() {
        return new ProductionDBConnectionMaker();
    }
}

이런식으로 DB가 개발용 배포용이 나눠져있거나 다른 DB들을 여러개 사용할 경우 이런식으로 DI 컨테이너를 만들어서 모든 DAO가 사용할 수 있도록 DI 해줄 수 있습니다.


부가기능 추가

만약에 우리가 DAO가 DB를 얼마나 많이 연결해서 사용하는지 파악하고 싶다고 생각해봅시다.

첫 번째 방법으로는 그냥 단순하게 DAO의 makeConnection() 메서드를 호출하는 부분에 카운터를 증가시키는 코드를 넣을 수 있습니다. 하지만 이런 방식은 너무 비효율적 입니다.

두 번째 방법으로는 DI 컨테이너에서 사용할 수 있는 방법입니다. DI에서는 매우 간단하게 하결할 수 있습니다. 그냥 DAO와 DB 커넥션을 만드는 오브젝트 사이에 연결횟수를 카운팅하는 오브젝트를 하나 더 추가하면 됩니다.

DI 개념을 쉽게 응용한다면 기존코드를 수정하지 않고 그냥 컨테이너가 사용하는 설정정보만 수정해서 런타임 의존관계만 새롭게 정의하면 가능합니다. 우리는 CountingConnectionMaker라는 클래스를 리스트 1-30과 같이 만듭니다. 또한, 이 클래스는 ConnectionMaker 인터페이스를 구현해서 만든다고 합니다.

public class CoutingConnectionMaker implements ConnectionMaker{
    int counter = 0;
    private ConnectionMaker realConnectionMaker;

    public CountingConnectionMaker(ConnectionMaker realConnectionMaker) {
        this.realConnectionMaker = realConnectionMaker;
    }

    public Connection makeConnection() thorws ClassNotFoundException, SQLException {
        this.counter++;
        return realConnectionMaker.makeConnection();
    }

    public int getCounter() {
        return this.counter;
    }
}

CountingConnectionMaker 클래스는 현재 ConnectionMaker 를 인터페이스를 implement해서 구현을 하였지만 직접 DB커넥션을 만들지는 않았습니다.

대신 DAO가 DB를 가져오는 makeConnection()에서 DB 연결횟수 카운터를 증가시킵니다.

CountingConnectionMaker 는 DB 연결횟수 카운팅이 관심사여서 이를 끝낸 뒤에는 DB 커넥션이 정의되어 있는 realConnectionMaker 에 저장된 ConnectionMaker 타입 오브젝트의 makeConnection()을 호출해서 그 결과를 DAO에게 돌려줍니다.

이러한 것은 CountingConnectionMaker도 DI를 받고 이에 대한 오브젝트가 DI를 받을 오브젝트도 ConnectionMaker 인터페이스를 구현한 오브젝트입니다. 결론적으로 우리가 사용하는 AConnectionMaker 같은 실제 DB 커넥션을 돌려주는 클래스의 오브젝트를 받습ㄴ디ㅏ.

이는 CountingConnectionMakerConnectionMakerUserDao 로 가는 것을 볼 수 있습니다.

AConnectionMaker 가 구현하고 있는 ConnectionMaker 라는 인터페이스에만 의존하고 있기 때문에 만약 ConnectionMaker 를 implement 하고 있는 어떤 클래스라도 바꿔치기가 가능합니다. 그래서 CountingConnectionMaker 가 사용이 가능한 것입니다.

우리는 이것만 사용하면 안됩니다. CountingConnectionMaker가 다시 실제 사용할 DB 커넥션을 제공해주는 AConnectionMaker 를 호출하도록 만들어야 합니다. 이럴 때도 DI를 사용하면 됩니다.

다음과 같이 CountingDaoFactory 라는 컨테이너를 만들어서 사용할 수 있게 만들 수 있습니다.

@Configuration
public class CountingDaoFactory {
    @Bean
    public UserDao userDao(){
        return new UserDao(connetionMaker());
    }

    @Bean
    public ConnectionMaker connectionMaker() {
        return new CountingConnectionMaker(realConnectionMaker());
    }

    @Bean
    public ConnectionMaker realConnectionMaker(){
            return new AConnectionMaker();
    }
}

이제 커넥션 카운팅을 위한 실행코드를 만들어 봅시다. DL(의존관계 검색)을 사용해서 CountingConnectionMaker 빈을 가져와서 작성해봅시다.

public class UserDaoConnectionCountingTest {
    public static void main(String[] args) throws ClasNotFoundException, SQLException {
        AnnotaionConfigApplicationContext context = 
            new AnnotationConfigApplicationContext(CountingDaoFactory.class);
        UserDao dao = context.getBean("userDao", UserDao.class);

        // DL 사용
        CountingConnectionMaker ccm = context.getBean("connectionMaker", CountingConnectionMaker.class);
        System.out.println("Connection counter의 수는 : " + ccm.getCounter());
    }

}

이런식으로 DAO가 여러개여도 관심사의 분리를 통해서 얻어지는 높은 응집도에서 DI의 장점이 나옵니다.

이러한 DI는 정말 중요하고 좋은 도구입니다. 그래서 우리는 DI를 어떻게 활용해야 할지를 공부해야합니다.


메서드를 이용한 의존관계 주입

지금까지는 UserDao의 의존관계 주입을 위해서 생성자를 사용하였습니다.

그래서 생성자에 파라미터를 만들어서 DI 컨테이너가 의존할 오브젝트 레퍼런스를 넘겨주었습니다.

현재까지는 생성자에만 사용했지만 이는 일반 메서드를 통해서 의존 오브젝트와의 관계를 주입해줄 수도 있습니다.


수정자(Setter) 메서드를 이용한 주입

수정자는 파라미터로 전달된 값을 내부의 인스턴스 변수에 저장하는 것 입니다. 이러한 수정자는 DI 방식에서 활용하기에 적당합니다.


일반 메서드를 이용한 주입

수정자는 정해진 형태가 있고 파라미터는 한개받게 못받습니다. 만약 이런게 싫다면 일반 메서드를 이용할 수도 있습니다.

하지만 파라미터의 개수가 많아져 비슷한 타입이 여러 개라면 실수하기가 쉽습니다. 하지만, 여러개의 파라미터를 받아서 여러 개의 초기화 메서드도 만들 수 있기 때문에 이런것에 대해서 장점이 존재합니다.

스프링은 보통 메서드를 이용한 DI 방식중에서는 수정자 메서드를 가장 많이 사용했습니다.

수정자 메서드 DI를 사용할 때에는 메서드의 이름을 잘 결정하는 것이 좋습니다.

한번 만들어 봅시다. 우리는 기존 생성자를 제거하고 setConnectionMaker() 라는 메서드를 하나 추가한 뒤 파라미터로 ConnectionMaker 타입의 오브젝트를 받도록 선언합시다.

public class UserDao{
    private ConnectionMaker connectionMaker;

    public void setConnectionMaker(ConnectionMaker connectionMaker) {
        this.connectionMaker = connectionMaker;
    }
}

이런식으로 만들어 주었다면 DaoFactory의 코드도 함꼐 수정해야합니다.

@Bean
public UserDao userDao() {
    UserDao userDao = new UserDao();
    userDao.setConnectionMaker(connetionMaker());
    return userDao;
}

이러면 의존관계를 주입하는 시점과 방법만 달라지고 결과는 동일합니다. 이뿐만이 아니라 다양한 의존관계 주입 방법도 지원합니다.


XML을 이용한 설정

지금까지 DI 컨테이너인 스프링을 도입하면서 애너테이션을 추가해서 DI를 설정해주었습니다.

우리는 DaoFactory와 같은 자바 클래스로만 이용하여 DI 의존관계 설정정보를 만들었지만 다른 방법도 존재합니다.

우리는 그중에서 XML을 이용하는 방법을 살펴 봅시다.

XML의 장점은 다음과 같습니다.

  • 단순한 텍스트 파일이기 떄문에 다루기 쉽습니다.
  • 쉽게 이해할 수 있고 컴파일과 같은 별도의 빌드작업이 필요가 없습니다.
  • 환경이 달라져서 오브젝트의 관계가 바뀌는 경우에도 빠르게 변경사항을 반영할 수 있습니다.
  • 스키마나 DTD를 이용해서 정해진 포맷을 따라 작성되었는지 손쉽게 확인할 수 있습니다.

이제 한번 XML을 통해서 만들어 봅시다.


XML 설정

스프링 Application Context는 XML에 담긴 DI 정보를 활용할 수 있습니다.

우리는 <beans> 를 루트 엘리먼트로 사용해서 사용할 수 있습니다. 이러한 <beans> 안에는 여러개의 <bean> 가 들어갈 수 있습니다.

본래대로면 @Bean 메서드에서는 알 수 있는 빈의 DI 정보는 3가지가 존재합니다.

  • 빈의 이름 : 메서드의 이름입니다.
  • 빈의 클래스 : 빈 오브젝트를 어떤 클래스에 이용해서 만들지 결정합니다.
  • 빈의 의존 오브젝트 : 빈의 생성자나 수정자 메서드 등을 통해서 의존 오브젝트를 넣어줍니다. 이는 하나 이상일 수도 있습니다.

XML에서도 마찬가지로 이러한 3가지 정보를 정의할 수 있습니다.


connectionMaker() 전환

목록 자바 코드 설정정보 XML 설정정보
빈 설정파일 @Configuration <beans>
빈의 이름 @Bean 메서드이름() <bean id=”메서드이름”
빈의 클래스 return new BeanClass(); class=”a.b.c...BeanClass”>

다음처럼 1:1교환으로 만들 수 있습니다. 여기서 <bean> 에 들어간느 class 속성에 지정하는 것은 자바 메서드에서 오브젝트를 만들 때 사용하는 클래스 이름입니다.

그렇기 때문에 메서드의 리턴 타입을 class의 속성에 넣으면 안됩니다.

이를 변경하면 다음과 같습니다.

@Bean
public ConnectionMaker connectionMaker(){
    return new AConnectionMaker();
}

XML은 다음과 같습니다.

<bean id="connectionMaker" class="springbook... AConnectionMaker"/>

DI 컨테이너는 이러한 XML의 <bean> 태그의 정보를 읽어서 우리가 작성했던 connectionMaker() 메서드 와 같은 작업을 진행합니다.


userDao()전환

이번에는 userDao 를 XML로 변환해봅시다. 보통 수정자 메서드를 사용하는게 왜 스프링개발자들이 선호를 했었냐면 XML로 의존관계 정보를 만들 때 편리하기 때문입니다.

만약 수정자 메서드를 사용하면 setConnectionMaker() 가 있다면 set 을 제외한 connectionMaker 라는 프로퍼티를 가집니다.

XML에서는 <property> 태그를 사용해서 의존 오브젝트와의 관계를 정의합니다. 이는 2개의 속성을 가집니다.

  • name : 프로퍼티의 이름 → 이를 통해서 수정자 메서드를 알 수 있습니다.
  • ref : 수정자 메서드를 통해서 주입해줄 오브젝트의 빈 이름입니다. / DI할 오브젝트도 빈입니다. 그 빈의 이름을 지정해주면 됩니다.

그래서 만약에 다음과 같은 방식으로 UserDaoTest 에서 사용을 한다면 하나씩 확인을 해봅시다.

userDao.setConnectionMaker(connectionMaker());

여기서 connectionMaker()userDao 빈의 connectionMaker() 라는 프로퍼티를 이용해서 의존관계 정보를 주입한다는 것입니다. 그래서 ref의 값이 됩니다.

이를 XML로 고치면 다음과 같습니다.

<bean id="userDao" class="springbook.dao.UserDao">
    <property name="connectionMaker" ref="connectionMaker" />
</bean>

XML의 의존관계 주입정보

이를 모두 전환을 해서 적으면 다음과 같습니다.

<beans>
    <bean id="connectionMaker" class="springbook.user.dao.AConnectionMaker" />
    <bean id-="userDao" class="springbook.user.dao.UserDao">
        <property name="connectionMaker" ref="connectionMaker"/>
    </bean>
</beans>

현재는 property 태그의 nameref 가 같지만 이름이 같더라도 어떤 차이가 있는지 구별할 수 있어야 합니다.

  • name 속성은 DI에 사용할 수정자 메서드의 프로퍼티 이름입니다.
  • ref 속성은 주입할 오브젝트를 정의한 빈의 ID 입니다.

보통 둘이 같은 경우가 많습니다. 이러한 프로퍼티의 이름은 보통 주입할 빈 오브젝트의 인터페이스를 따르는 경우가 많습니다.

만약 빈의 이름이 중복되거나 의미를 좀 더 잘 드러낼 수 있는 이름이 있다면 변경을 할때 property 태그의 ref 속성의 값도 함께 변경을 해줘야 합니다.

만약 connectionMaker 에서 myConnectionMaker 로 변경했다고 하면 어떻게 되는지 봅시다.

<beans>
    <bean id="**myConnectionMaker**" class="springbook.user.dao.AConnectionMaker" />
    <bean id-="userDao" class="springbook.user.dao.UserDao">
        <property name="connectionMaker" ref="**myConnectionMaker**"/>
    </bean>
</beans>

만약, 인터페이스를 구현한 의존 오브젝트를 여러개 만들어 놓고 골라서 사용하는 경우도 있습니다. 아까전에 로컬 DB를 사용하는것 혹은 배포할 때DB를 사용하는것 이러한 기능들이 있는 경우도 있습니다. 그럴때는 다음과 같이 작성을 합니다.

<bean>
    <bean id="**localDBConnectionMaker**" class="...LocalDBConnectionMaker" />
    <bean id="testDBConnectionMaker" class="...testDBConnectionMaker"/>
    <bean id="productionDBConnectionMaker" class="...productionDBConnectionMaker"/>

<bean id-="userDao" class="springbook.user.dao.UserDao">
            <property name="connectionMaker" ref="**localDBConnectionMaker**"/>
    </bean>
</bean>

이런식으로 사용하고 ref 만 내가 사용할 것을 놓으면 됩니다. 바뀌면 이것도 바꾸면 됩니다.


XML을 이용하는 애플리케이션 컨텍스트

이제 Application Context가 DaoFactory 대신 XML 설정정보를 활용하도록 만들어 봅시다.

XML을 사용하면 IoC/DI 작업에서는 GenericXmlApplicationContext 를 사용해서 합니다. 이에대한 생성자 파라미터로 XML 파일의 클래스패스를 정하면 됩니다. 클래스패스를 정해야하기 때문에 보통은 클래스패스 최상단에 둡니다.

이러한 설정파일을 만들 떄는 보통 applicationContext.xml 로 만들어 저장합니다.

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframwork.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.springframwork.org/schema/beans
            http://www.springframwork.org/schema/beans/spring-beans-3.0.xsd">

    <bean id="connectionMaker" class="springbook.user.dao.AConnectionMaker"/>

    <bean id="userDao" class="springbook.user.dao.UserDao">
        <property name="connectionMaker" ref="connectionMaker"/>
    </bean>
</beans>

이제 UserDaoTest를 수정해봅시다. 이제 AnnotationConfigApplicationContext 대신에 GenericXmlApplicationContext를 사용합니다.

ApplicationContext context = new GenericXmlApplicationContext(
        "applicationContext.xml");

이런식으로 바꿔서 사용하면 됩니다.

또한, GenericXmlApplicationContext 가 아닌 ClassPathXmlApplicationContext 도 존재합니다 이는 XML 파일을 클래스패스에서 가져올 때 사용할 수 있는 편리한 기능이 추가된 것입니다.

보통 XML 파일의 경로를 가져와야하는데 귀찮으면 자동으로 클래스 오브젝트를 사용해서 다음과 같이 변환을 할 수 있습니다.

new GenericXmlApplicationContexct("springbook/user/dao/daoContext.xml");

new ClassPathXmlApplicationContext("daoContext.xml", UserDao.class);

DataSource 인터페이스로 변환

DataSource 인터페이스 적용

우리는 ConnectionMaker 를 만들어 DB 커넥션을 생성해주는 기능 하나만을 정의한 매우 단순한 인터페이스를 만들었습니다.

하지만 사실 DB 커넥션을 가져오는 오브젝트의 기능을 추상화해서 비슷한 용도로 사용할 수 있게 만들어진 Datasource 라는 인터페이스가 이미 존재하고 있습니다.

그래서 우리는 ConnectionMaker 와 같은 인터페이스를 만들어서 사용할 일은 없습니다.

Datasource 에서 getConnection() 메서드는 우리가 만든 makeConnection() 과 동일한 기능을 하는 메서드입니다. 이를 통해서 DB 커넥션을 가져올 수 있습니다.

DataSource를 이용해서 한번 UserDao를 리펙토링 해봅시다.

import javax.sql.DataSource;

public class UserDao{
    private DataSource dataSource;

    public void SetDataSource(DataSource dataSource){
        this.dataSource = dataSource;
    }

    public void add(User user) throws SQLException{
        Connection c = dataSource.getConnection();
  }
}

구현을 하였는데 우리는 DataSource 구현 클래스가 필요합니다. 우리는 스프링에서 제공하는 테스트환경에서 간단히 사용할 수 있는 SimpleDriverDataSource 라는 것을 사용해서 이 클래스를 사용하도록 DI를 재구성하려고합니다.


자바 코드 설정 방식

먼저 DaoFactory의 설정방식을 이용해봅시다. 기존의 connectionMaker() 메서드를 dataSource()로 변경하고 SimpleDriverDataSource의 오브젝트를 리턴하게 합니다.

// DaoFactory
@Bean
public DataSource dataSource() {
    SimpleDriverDataSource dataSource = new SimpleDriverDataSource();

    dataSource.setDriverClass("com.mysql.jdbc.Driver.class");
    dataSource.setUrl("jdbc:mysql://localhost/springbook");
    dataSource.setUsername("spring");
    dataSource.setPassword("book");

    return dataSource;
}

이제 DaoFactoryuserDao() 메서드도 수정해봅시다.

@Bean
public UserDao userDao() {
    UserDao userDao = new UserDao();
    **userDao.setDataSource(dataSource());**
    return userDao;
}

이런식으로 connectionMaker()dataSource() 로 변경하였습니다.


XML 설정 방식

먼저 id가 connectionMaker<bean> 을 없앤 뒤 dataSource 라는 이름의 <bean>을 등록합니다. 그리고 SimpleDriverDataSource 로 변경합니다.

<bean id="dataSource"
    class="org.springframwork.jdbc.datasource.SimpleDriverDataSource"/>

이런식으로 SimpleDriverDataSource의 오브젝트를 만드는 것까지 가능하지만 datasource() 메서드에서 SimpleDriverDataSource 의 오브젝트의 수정자로 넣어준 DB접속정보는 없습니다.

UserDao 처럼 다른 빈에 의존하면 그냥 property 태그랑 ref 속성을 사용해서 의존할 빈의 이름을 넣어주면 되었는데 datasource() 인경우는 어떤식으로 해결을 해야할까요?


프로퍼티 값의 주입

값 주입

DaoFactorydatasource() 메서드에서 본것 처럼 수정자 메서드에는 다른 빈이나 오브젝트 뿐만 아니라 스트링 같은 단순 값을 넣어줄 수도 있습니다.

@Bean
public DataSource dataSource() {
    SimpleDriverDataSource dataSource = new SimpleDriverDataSource();

    dataSource.setDriverClass(com.mysql.jdbc.Driver.class);
    dataSource.setUrl("jdbc:mysql://localhost/springbook");
    dataSource.setUsername("spring");
    dataSource.setPassword("book");

    return dataSource;
}

이중 setDriverClass() 메서드는 Class 타입의 오브젝트를 넣었지만 다른 빈 오브젝트를 DI 방식으로 가져와서 넣는 것은 아닙니다.

이렇게 사용하는 수정자 또한, <property>를 사용해서 값을 주입할 수 있습니다. 성격은 다르지만 일종의 DI라고도 볼 수 있습니다.

dataSource.setDriverClass("com.mysql.jdbc.Driver.class");
dataSource.setUrl("jdbc:mysql://localhost/springbook");
dataSource.setUsername("spring");
dataSource.setPassword("book");

이렇게 된 코드들을 XML을 사용해서 DB 연결정보를 설정할 수 있습니다.

<property name="driverClass" value="com.mysql.jdbc.Driver"/>
<property name="url" value="jdbc:mysql://localhost/springbook"/>
<property name="username" value="spring"/>
<property name="password" value="book"/>

다음과 같은 ref 대신 value 를 써서 나타낼 수 있습니다.


value 값의 자동 변환

그런데 우리는 이상한 점을 볼 수 잇습니다. url, username, password 들은 모두 스트링 타입이기 때문에 텍스트로 정의되는 value 속성 값을 사용하는 것은 문제가 되지 않습니다.

하지만 현재 driverClass 를 보면 java.lang.Class 타입인것을 볼 수 있습니다.

텍스트로 정의되어 있다고 해쓴ㄴ데 별다른 타입정보가 없이 그냥 클래스이름이 텍스트형태로 현재 value에 들어가 있습니다.

직관적으로만 보면 현재 다음과 같은 상황입니다.

Class driverClass = "com.mysql.jdbc.Driver";

이러면 본래 스트링 값을 Class에 넣는것 이기 때문에 컴파일조차 안되기 마련입니다.

하지만 우리는 컴파일을 하면 아무런 문제 없이 성공하는 것을 볼 수 있습니다. 이러한 이유는

스프링이 프로퍼티의 값을 수정자 메서드의 파라미터 타입을 참고로 해서 적절한 형태로 변환해줍니다.

"com.mysql.jdbc.Driver" 라는 텍스트 값을 오브젝트로 자동 변환을 시켜줍니다. 결론적으로는 다음과 같은 코드로 스프링이 자동적으로 변환작업을 해주는 것을 알 수 있습니다.

Class driverClass = Class.forName("com.mysql.jdbc.Driver");
dataSource.setDriverClass(driverClass);

이런식으로 스프링은 value에 지정한 텍스트 값을 적절한 자바 타입으로 변환해주기도 합니다.

결론적으로 우리는 모두 적용하면 다음과 같은 코드를 얻을 수 있습니다.

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframwork.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.springframwork.org/schema/beans
            http://www.springframwork.org/schema/beans/spring-beans-3.0.xsd">

    <bean id="dataSource"
            class="org.springframwork.jdbc.datasource.SimpleDriverDataSource">
        <property name="driverClass" value="com.mysql.jdbc.Driver"/>
        <property name="url" value="jdbc:mysql://localhost/springbook"/>
        <property name="username" value="spring"/>
        <property name="password" value="book"/>
    </bean>

    <bean id="userDao" class="springbook.user.dao.UserDao">
        <property name="dataSource" ref="dataSource"/>
    </bean>

</beans>

제어의 역전(IOC)

오브젝트 팩토리

현재까지 우리는 DAO를 깜끔한 구조로 리팩토링 하였습니다.

하지만 UserDaoTest는 그냥 넘겼습니다. 현재는 UserDaoTest에서 Connection을 할 때 어떤 클래스를 사용하는지 정의를 하면서 UserDao가 담당하던 기능을 분리하였습니다.

그러나 원래 UserDaoTest를 만든 목적은 UserDao의 기능이 잘 동작하는지를 테스트하기 위해서 만든 것입니다. 그렇긱 때문에 이러한 관심사가 겹치는 것을 분리해보도록 하겠습니다.


팩토리 (Config)

분리시킬 기능을 당담하는 클래스를 만듭니다.

이 클래스는 객체의 생성방법을 결정하고 그렇게 만들어진 Object를 돌려주는 역할을 합니다.

이런것을 우리는 팩토리라고 부릅니다.

이제 DaoFactory라는 클랙스를 생성하고 UserDaoConnectionMaker 관련 생성작업을 옮기도록 합시다.

public class DaoFactory{
    public UserDao userDao(){
        ConnectionMaker connectionMaker = new AConnectionMaker(); // A회사것을 쓰겠다.
        UserDao userDao = new UserDao(connectionMaker);
        return UserDao;
    }
}

이제 DaoFactory 클래스의 userDao() 라는 메서드를 호출한다면 A회사의 AConnectionMaker를 사용해 DB 커넥션을 가져오도록 설계된 UserDao를 반환하게 됩니다.

이제 그러면 UserDaoTest 에서 객체를 설정해주면서 생성해줄 필요가없기 때문에 다음과 같이 고칩니다.

public class UserDaoTest{
    public static void main(String[] args) throws ClassNotFoundException,
            SQLException{
                UserDao dao = new DaoFactory().userDao();
        }
}

설계도로서의 팩토리

결론적으로 이렇게 분리를하게 된다면 DaoFactory 라는 클래스에서 Client가 요청을 한다면

알아서 내가 어떤 것을 Connection으로 사용할지 생성을하고 UserDao에서 그 클래스를 사용하도록 해주게 된다.

이제 회사에 UserDao를 납품할 때 DaoFactory도 함께 납품을 해서 클래스 변경이 일어나면 DaoFactory의 코드만 수정하면 됩니다.


오브젝트 팩토리의 활용

만약, DaoFactory 에서 UserDao 라는 Dao 클래스가 아닌 다른 클래스를 넣는다면 어떻게 될까? 이런식으로 DAO가 늘어나게된다면 Parameter로 넘길 때 ConnectionMaker 의 인스턴스를 생성해서 넘기는 코드가 중복되게 됩니다.

public class DaoFactory{
    public UserDao userDao(){
            return new UserDao(new AConnectionMaker());
    }

    public AccountDao AccountDao(){
            return new AccountDao(new AConnectionMaker());
    }

    public MessageDao messageDao(){
            return new MessageDao(new AConnectionMaker());
    }
}

이런식으로 Parameter에 사용한 ConnectionMaker 를 생성해주는 코드가 중복이 됩니다.

이를 해결하는 제일 좋은 방법은 또 부리하는 것 입니다.

ConnectionMaker의 구현 클래스를 결정하고 오브젝트를 만드는 코드를 별도의 메소드로 뽑아내는 것이 좋은 결정입니다.

public class DaoFactory{
    public UserDao userDao(){
            return new UserDao(connectionMaker());
    }

    public AccountDao AccountDao(){
            return new AccountDao(connectionMaker());
    }

    public MessageDao messageDao(){
            return new MessageDao(connectionMaker());
    }

    public ConnectionMaker connectionMaker(){
        return new AconnectionMaker();
    }
}

제어권의 이전을 통한 제어관계 역전

제어의 역전(IOC)가 우리가 배우고자 하는 목표입니다.

제어의 역전은 간단하게 프로그램의 제어 흐름 구조가 뒤바뀌는 것을 말합니다.

보통으로는 main() 메서드에서 다음과 같은 흐름을 가집니다.

  1. 시작되는 지점에서 다음에 사용할 오브젝트를 결정한다.
  2. 결정한 오브젝트를 생성한다.
  3. 만들어진 오브젝트에 있는 메서드를 호출한다.
  4. 그 오브젝트 메서드 안에서 다음에 사용할 것을 결정한다.
  5. 이 과정을 반복한다.

그래서 우리가 초기에 만든 UserDao의 흐름을 살펴보면 다음과 같습니다.

  1. UserDao 클래스의 오브젝트를 직접 생성합니다.
  2. 만들어진 오브젝트의 메서드를 사용합니다.
  3. UserDao는 자신이 사용할 ConnectionMaker의 구현클래스를 자기가 결정합니다.
  4. 그 오브젝트를 필요한 시점에 생성합니다.
  5. 각 메서드가 이를 사용합니다.

우리는 이러한 제어 흐름의 개념을 거꾸로 뒤집어서 사용할 것 입니다.

이를 사용하면 이제 오브젝트가 자신이 사용할 오브젝트를 스스로 선택하지 않습니다.

왜냐하면 모든 제어 권한을 자신이 아닌 다른 대상에게 위임하기 때문입니다.

현재 우리는 DaoFactory를 만들어서 제어의 역전(IOC)를 도입하였습니다.

원래는 ConnectionMaker의 구현클래스의 결정은 UserDao에서 하였지만 현재는 이런 구현클래스의 결정에 대해서 DaoFactory에 넘겼습니다.

넘김으로써 UserDao는 능동적이 아닌 DaoFactory가 주는 것을 사용하는 수동적인 존재가 되었습니다.

우리는 현재 IOC프레임워크를 사용하지 않고 DaoFactory를 이용해서 IoC를 구현한 셈입니다.

물론 DaoFactory 같이 Ioc를 만들어도 되지만 프레임워크의 도움을 받는 편이 유리하기 때문에 프레임워크를 사용할 예정입니다.


스프링의 IoC

오브젝트 팩토리를 이용한 스프링 IoC

애플리케이션 컨텍스트와 설정정보

우리는 DaoFacotory를 스프링에서 사용이 가능하도록 변신시킬 것 입니다.

일단 들어가기전에 빈(Bean) 이라는 중요한 개념이 존재합니다.

  • (Bean)
    • 스프링이 제어권을 가지고 직접 만들고 관계를 부여하는 오브젝트를 말합니다.
    • 자바 빈 또는 엔터프라이즈 자바빈에서 말하는 빈과 비슷한 오브젝트 단위의 애플리케이션 컴포넌트를 말합니다.
    • 스프링 빈은 스프링 컨테이너가 생성과 관계설정, 사용 등을 제어해주는 제어의 역전이 적용된 오브젝트를 가리키는 말입니다.

또한, 우리는 이러한 빈(Bean)의 생성과 관계설정 같은 제어를 담당하는 IoC 오브젝트를 빈 팩토리(Bean Factory)라고 합니다.

하지만 이러한 빈 팩토리보다 Application context를 자주 사용하게 됩니다. 이것은 IoC 방식을 따라 만들어진 일종의 빈 팩토리 입니다.


DaoFactory를 사용하는 애플리케이션 컨텍스트

DaoFactory를 스프링의 빈 팩토리가 사용할 수 있는 본격적인 설정정보로 만들어봅시다.

우리는 @Configuration 이라는 애노테이션을 사용해서 스프링이 빈 팩토리를 위한 오브젝트 설정을 담당하는 클래스라고 인식 시킵니다.

그리고 @Bean 이라는 애노테이션을 오브젝트를 만들어주는 메서드를 붙여줍니다.

@Configuration // 애플리케이션 컨텍스트 또는 빈 팩토리가 사용할 설정정보라는 표시
public class DaoFactory{

    @Bean // 오브젝트 생성을 담당하는 IoC용 메서드라는 표시
    public UserDao userDao(){
        return new UserDao(connecitonMaker());
    }

    @Bean
    public ConnectionMakerr connectionMaker(){
        return new AConnectionMaker();
    }
}

이제 설정을 완료했으니 DaoFactory 를 설정정보로 사용하는 Application Context를 만들어 볼 것입니다.

@Configuration 이 붙은 자바 코드를 설정정보로 사용하기 위해서는 AnnocationConfigApplicationContext 를 이용하면 됩니다.

public class UserDaoTest{
    public static void main(String[] args) throws ClassNotFoundException,
            SQLException{

                ApplicationContext context = 
                    new AnnotationCongifApplicationContext(DaoFactory.class);

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

우리는 ApplicationContextgetBean() 이라는 메서드를 통해서 UserDao의 오브젝트를 가져올 수 있습니다.

이런식으로 @Bean 이라는 애노테이션을 붙인 매서드가 바로 빈의 이름이 됩니다.

그래서 이름에 따라서 getBean("메서드이름", 사용클래스.class)로 가져올 수 있습니다.


애플리케이션 컨텍스트의 동작방식

기존의 오브젝트 팩토리를 이용한 방식과 스프링 애플리케이션 컨텍스트를 사용한 방식을 비교해봅시다.

스프링에서는 ApplicationContext라는 인터페이스를 통해서 애플리케이션 컨텍스트를 구현합니다.

이러한 ApplicationContextBeanFactory 인터페이스를 상속합니다. 그래서 다음과 같이 불립니다.

  • IoC 컨테이너
  • 스프링 컨테이너
  • 빈 팩토리
  • 스프링

이전에 구현한 DaoFactoryuserDao 를 비롯한 DAO를 생성하고 DB 생성 오브젝트와 관계를 맺어주는 제한적인 역할을 합니다.

하지만 ApplicationContext를 사용하면 DaoFacotory와 달리 직접 오브젝트를 생성하고 관계를 맺어주는 코드가 존재하지 않습니다. 이러한 생성정보와 연관관계 정보를 별도의 설정정보를 통해 얻습니다.

그래서 다음과 같은 순서로 작동하게 됩니다.

  1. Client가 UserDao 를 요청합니다.
  2. ApplicationContext 에서 getBean() 메서드를 통해서 생성을 요청합니다.
  3. @Configuration 이 붙은 DaoFactory 에서 @Bean 으로 등록된 UserDao() 메서드를 호출해서 객체를 생성합니다.
  4. 생성한 객체를 Client에게 넘겨줍니다.

이렇게 구현을한다면 다음과 같은 장점이 생깁니다.

  • 클라이언트는 구체적인 팩토리 클래스를 알 필요가 없다.
    • 애플리케이션 컨텍스트를 사용한다면 오브젝트 팩토리가 아무리 많아져도 이를 알아야하거나 직접 사용할 필요가 없습니다. 또한, XML을 사용해서 컨텍스트가 사용할 IoC 설정정보도 만들 수 있습니다.
  • 애플리케이션 컨텍스트는 종합 IoC 서비스를 제공해줍니다.
    • 애플리케이션 컨텍스트가 단지 오브젝트 생성과 다른 오브젝와의 관계설정을 해준다고 생각할 수도 있다.
    • 오브젝트가 만들어지는 방식 오브젝트에 대한 후처리 등등 다양한 기능을 제공하기도 합니다.
  • 애플리케이션 컨텍스트는 빈을 검색하는 다양한 방법을 제공합니다.
    • 애플리케이션의 getBean() 메서드를 보면 빈의 이름을 이용해서 빈을 찾아 줄 수 있습니다. 또한, 특별한 애노테이션 설정이 되어 있는 빈을 찾을 수도 있습니다.

스프링 IoC의 용어 정리

빈(Bean)

빈은 스프링이 IoC 방식으로 관리하는 오브젝트라는 뜻입니다.

이를 관리 오브젝트(Managed object) 라고도 부릅니다. 하지만 스프링을 사용하는 애플리케이션에서 만들어지는 모든 오브젝트가 아닌 스프링이 직접 그 생성과 제어를 담당하는 오브젝트만을 빈이라고합니다.


빈 팩토리(Bean Factory)

빈 팩토리는 스프링의 IoC를 담당하는 핵심 컨테이너를 가리킵니다

빈 팩토리는 다음과 같은 기능을 합니다.

  • 빈을 등록한다.
  • 빈을 생성한다.
  • 빈을 조회후 반환한다.

보통은 빈 팩토리를 바로 사용하지 않고 상속받은 Application Context를 이용합니다.


애플리케이션 컨텍스트(Application Context)

애플리케이션 컨텍스트는 빈 팩토리를 확장한 IoC컨테이너 입니다.

빈 팩토리와 기본적인 기능은 비슷하지만 추가적인 기능들이 확장되어 있습니다.


설정정보/설정 메타정보 (Configuration metadata)

스프링의 설정정보는 애플리케이션 컨텍스트 또는 빈 팩토리가 IoC를 적용하기 위해서 사용하는 메타정보를 말합니다.

IoC 컨테이너에 의해 관리되는 애플리케이션 오브젝트를 생성하고 구성할 때 사용됩니다.

이를 Blueprints(청사진)이라고도 합니다.


컨테이너 또는 IoC 컨테이너

애플리케이션 컨텍스트나 빈 팩토리를 말합니다.

이는 애플리케이션 컨텍스트보다는 추상적인 표현입니다.


스프링 프레임워크

스프링 프레임워크는 IoC 컨테이너, 애플리케이션 컨텍스트를 포함해서 스프링이 제공하는 모든 기능을 통틀어 말할 떄 주로 사용합니다. 주로, 스프링이라고 주여서 말하기도 합니다.


싱글톤 레지스트리와 오브젝트 스코프

우리는 오브젝트 팩토리를 만든것과 애플리케이션 컨텍스트를 사용한것 2가지를 만들었습니다.

이 둘은 중요한 차이점이 존재합니다.

만약, DaoFactoryuserDao() 메서드를 2번 호출하면 2개의 오브젝트는 같을까? 라는것 입니다.

다음과 같은 코드로 오브젝트를 확인해봅시다.

// 오브젝트 팩토리 방식
DaoFactory factory = new DaoFactory();
UserDao dao1 = factory.userDao();
UserDao dao2 = factory.userDao();

System.out.println(dao1);
System.out.println(dao2);

확인해보면 출력값이 서로 다른것을 확인할 수 있습니다.

이를 통해서 userDao() 를 매번 호출하면 계속해서 새로운 오브젝트가 생성된다는 것을 알 수 있습니다.

하지만 만약, ApplicationContext 를 사용한 것에서는 어떤지 확인해봅시다.

// ApplicationContext 사용
ApplicationContext = context = new AnnotationConfigApplication(DaoFactory.class)

UserDao dao3 = context.getBean("userDao", UserDao.class);
UserDao dao4 = context.getBean("userDao", UserDao.class);

System.out.println(dao3);
System.out.println(dao4);

만약 ApplicationContext 를 실행하면 출력값이 서로 같은 것을 볼 수 있습니다.

결론적으로 여러개의 오브젝트를 생성하려고해도 매번 동일한 오브젝트를 돌려준다는 것 입니다.


싱글톤 레지스트리로서의 애플리케이션 컨텍스트

애플리케이션 컨텍스트는 IoC 컨테이너로 역할을 하지만 동시에 싱글톤을 저장하고 관리하는 싱글톤 레지스트리(Singleton Registry)입니다.

스프링에서는 별다른 설정을 하지 않는 이상 내부에서 생성하는 빈 오브텍트를 싱글톤으로 만듭니다.


서버 애플리케이션과 싱글톤

만약에 우리가 사용하는 UserDao 가 클라이언트에서 요청이 올 때마다 각 로직을 담당하는 오브젝트가 새로만들어진다고 가정한다면

우리는 계속해서 요청에 따라서 수만개의 오브젝트를 생성하고 지우고를 반복해야합니다.

이러면 서버에 많은 부하가 걸리게 됩니다. 그래서 우리는 싱글톤을 사용하게 됩니다.

싱글톤 패턴은 애플리케이션 안에 제한된 수, 대개 한 개의 오브젝트만 만들어서 사용하는 것 입니다.

이러한 싱글톤 패턴은 장점만 있을것 같지만 안티패턴으로 피해야하는 패턴으로 불리기도 합니다.


싱글톤 패턴의 한계

싱글톤을 구현하기 위해서는 다음과 같은 방식으로 구현을 합니다.

  • 클래스 밖에서는 오브젝트를 생성하지 못하게 생성자를 private로 만듭니다.
  • 생성도니 싱글톤 오브젝트를 저장하기 위해서 자기와 같은 타입의 static 필드를 정의합니다.
  • 스태틱 팩토리의 메서드인 getInstance()를 만들고 메서드가 오출되는 시점에서 한번의 오브젝트가 만들어지게 합니다.
  • 한번의 오브젝트가 만들어지고 난 뒤는 getInstance()를 통해서 이미 만들어져 스태틱 필드에 저장해둔 오브젝트를 넘겨줍니다.

이렇게 만든 UserDao의 예제를 살펴봅시다.

public class UserDao{
    private static UserDao INSTANCE;

    public static synchronized UserDao getInstacne(){
        if(INSTANCE == null) INSTANCE = new UserDao(???);
        return INSTANCE;
    }

    private UserDao(ConnectionMaker connectionMaker){
        this.connectionMaker = connectionMaker;
    }
}

이런식으로 만들어진 싱글톤 패턴은 코드가 상당히 지저분해지고 private로 바귄 생성자는 외부에서 호출을 할 수 없기 때문에 DaoFactory 에서 UserDao 를 생성하며 ConnectionMaker 오브젝트를 넣어지는것이 더이상 안된다.

이러한 싱글톤 패턴구현 방식에는 다음과 같은 문제가 존재합니다.

  • private 생성자를 가져 상속할 수 없다.
    • private 를 사용해서 생성자를 제한한다면 객체지향의 장점인 상속과 이를 이용한 다형성을 사용할 수없다. static과 메서드를 사용하는 것도 객체지향의 장점을 사용할 수 없게 한다.
  • 싱글톤은 테스트하기가 힘들다
    • 싱글톤은은 만들어지는 방식이 제한적이여서 테스트에서 사용될 때 목 오브젝트 등으로 대체하기가 힘들어서 테스트하기가 힘듭니다.
  • 서버환경에서는 싱글톤이 하나만 만들어지는 것을 보장하지 못한다.
    • 서버에서 클래스 로더를 어떻게 구성하고 있냐에 따라서 싱글톤 클래스여도 하나이상의 오브젝트가 만들어질 수 있습니다.
  • 싱글톤 사용은 전역 상태를 만들 수 있기 때문에 바람직하지 못하다.
    • 싱글톤은 static 메서드를 이용해서 애플리케이션 어디서든지 사용될 수 있습니다. 이러한 방식은 객체지향 프로그래밍에서 권장되지 않는 프로그램 모델입니다.

싱글톤 레지스트리

자바의 기본적인 싱글톤 패턴 구현 방식은 여러가지 단점이 존재합니다.

그래서 스프링은 싱글톤 레지스트리(Singleton Registry)를 사용해서 직접 싱글톤 형태의 오브젝트를 만들고 관리하는 기능을 제공합니다.

이러한 싱글톤 레지스트리는 싱글톤을 생성하고, 관리하고, 공급하는 싱글톤 관리 컨테이너라고도 볼 수 있습니다.

기존에 사용했던 privatestatic을 사용해서 구현한 클래스가 아닌 기본 클래스에서 싱글톤으로 관리를 해줘서 단점을 보완합니다.

그래서 스프링이 지지하는 객체지향적인 설계 방식과 디자인 패턴등을 적용하는데 아무런 제약이 사라집니다.

이제 주의할점들을 살펴봅시다.


싱글톤 오브젝트와 상태

싱글톤은 멀티스레드 환경에서 여러 스레드가 동시에 접근해서 사용할 수 있습니다.

그렇기 때문에 더욱 상태 관리에 주의를 기울여야합니다.

싱글톤은 우선 멀티스레드 환경에서 서비스 형태의 오브젝트로 사용되면 상태정보를 내부에 갖고 있지 않은 무상태(stateless) 방식으로 만들어져야합니다.

이런것들을 파라미터나 로컬 변수, 리턴 값등을 이용해서 상태정보를 다룹니다.

//인스턴스 변수를 사용하도록한 UserDao
public class UserDao{
    private ConnetcionMaker connectionMaker;
    private Connection c;
    private User user;

    public User get(String id) throws ClassNotFoundException, SQLException{
            this.c = connectionMaker.makeConnection();

            /...
            this.user = new User();
            this.user.setId(rs.getString("id"));
            this.user.setName(rs.getString("name"));
            this.user.setPassword(rs.getString("password"));

            /...
            return this.user;
    }
}

이 코드는 기존가 다른점으로는 원래는 ConnectionUser는 로컬 변수로 각 메서드에서 선언해서 사용했는데 이를 클래스의 인스턴스 필드로 선언했다는 것 입니다.

이런식으로 로컬이 아닌 인스턴스 변수로 정의해서 사용한다면 심각한 문제가 발생합니다.

A가 접근을 할때 B도 동시에 접근을한다면 A의 정보를 추가해야하는데 B의 정보를 잘 못 추가할 수도 있기 때문입니다.

하지만 현재 기존코드에서도 connectionMaker는 인스턴스 변수를 사용을 했습니다.

하지만, 이것은 문제를 일은키지 않습니다. 왜냐하면 읽기전용 정보이기 때문입니다.

또한, 이 변수는 @Bean을 붙여서 싱글톤으로 관리되어 있습니다.

이런식으로 자신이 사용하는 다른 싱글톤 빈을 저장하려는 용도일 경우는 인스턴스 변수를 사용해도 무관합니다.

또한, 읽기전용 속성을 가진 정보라면 싱글톤에서 인스턴스 변수로 사용해도 좋습니다.

그렇지만 단순한 읽기전용이라면 static final 이나 final 을 사용하는 것이 더욱 효율적 입니다.


스프링 빈의 스코프

스프링이 관리하는 오브젝트인, 빈이 생성되고, 적용되는 범위는 10장에서 배우게 됩니다.

보통은 스프링 빈의 기본 스코프는 싱글톤입니다.

이러한 싱글톤 스코프는 컨테이너 내에 한 개의 오브젝트만 만들어져서 강제로 제거하지 않는 이상 스프링 컨테이너가 존재하는 동안 계속 유지됩니다.

처음으로 프론트와 협업중에서 Contorller의 구현이 끝나서 확인하기 위해서 배포를 진행했습니다.

배포를 진행해서 확인을 하려고하는데 프론트에서 다음사진과 같은 문제 때문에 값이 안받아진다고 했습니다...

프론트에서 CORS 오류...

아니 도대체 CORS가 뭔데 오류인건지 확인을 해보니 저 뿐만이 아닌 모든 프론트&백엔드 개발자가 겪는 오류여서 무엇이 문제인지 CORS가 무엇인지 정리하기 위해서 글을 작성해봅니다.


CORS란

CORS(Cross-Origin Resource Sharing)은 언제발생하냐면 내 출처가 아닌 다른 출처에서 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에게 알려주는 체제라고합니다.

뭔 소리인지 자세하게 알아보겠습니다. 우선 내 출처가 아닌 다른 출처 라는 것을 확인해보겠습니다.

만약, http://chan-coding-book.com:8080 이라는 출처가 바로 내 출처라고 생각해봅시다.

uri를 구성하는 요소는 다양하게 존재합니다 포트번호, 도메인, 프로토콜 등이 존재합니다.

  • 포트번호 : 8080
  • 도메인 : chan-coding-book.com
  • 프로토콜 : http

가 다음과 같이 존재를 합니다.

내 출처는 바로 포트번호, 도메인, 프로토콜이 같은 경우를 말합니다.

우리는 SOP(Same-Origin Policy)라는 규칙때문에 같은 출처에서만 리소스를 공유할 수 있습니다.

그렇기 때문에 내 출처가 아닌 다른 출처에서 리소스를 공유할 때는 CORS가 발생합니다.

물론 동일 출처(내 출처)인 경우는 SOP 덕에 리소스를 공유를 할 수 있습니다.

왜 막아두냐하면 CSRF 공격 때문에 그렇습니다.

해커가 마음대로 API를 통해서 웹사이트로 개인 정보를 탈취할 수 있기 때문에 SOP가 존재하는 이유입니다.


문제 발생 이유

그렇기 때문에 문제가 발생한 이유는 현재 프론트는 localhost:3000을 사용하지만 우리는 전혀 다른 도메인과 프로토콜을 사용하고있습니다.

그래서 출처가 달라져서 CORS 문제가 발생했습니다.

이제 그래서 CORS는 어떻게 동작하는지 살펴보겠습니다.


CORS의 동작원리

CORS는 동작이 3가지로 나누어집니다. Preflight requestSimple request, Credentialed Request입니다.

현재 오류코드를 다시 본다면 Preflight request에서 오류가 나왔다고 합니다.

우선 Preflight request가 무엇인지 먼저 살펴보겠습니다.


Preflight Request

Preflight라는 말을 이해하면 이해하기가 쉬울것 같습니다.

Pre는 먼저라는 소리이고 flight는 날아간다는 소리이기 때문에 먼저 날려본다라고 해석하면 좋을것 같습니다.

그래서 확인을 해보자면 우리가 다른 도메인의 리소스로 OPTIONS 메서드를 사용해서 HTTP 요청을 보내서 실제 요청이 전송하기에 안전한지 확인하는 것입니다.

왜냐하면 Cross-origin 요청은 유저 데이터에 영향을 줄 수 있기 때문입니다.

우리가 오류가 생긴이유는 먼저 보내봤는데 오류가 나왔기 때문에 그렇습니다.

Preflight request가 요청된 예제를 한번 살펴봅시다.

OPTIONS /resources/post-here/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: http://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type


HTTP/1.1 204 No Content
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive

Request를 하나하나 확인해 봅시다. 우선, OPTIONS 이라는 메서드는 서버에서 추가 정보를 판별하는 메서드라고 합니다

이 메서드는 2개의 다른 요청 헤더가 전송이 됩니다. 헤더는 다음과 같습니다.

Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

그렇기 때문에 해석을 한번 해봅시다.

  • Access-Control-Request-MethodPOST로 실제 요청을 전송한다는 뜻입니다.
  • Access-Control-Request-HeadersX-PINGOTHERContent-Type이 전송이 된다는 뜻입니다.

그래서 결론적으로는 요청을 전송할 때 POST 메서드와 요청 헤더를 X-PINGOTHER으로 받을 수 있다는 것을 의미합니다,

나머지 것들도 살펴보도록 하겠습니다. 이 값들을 이해해야 Spring에서 설정을 할 수 있습니다.

Access-Control-Allow-Origin: http://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400

Access-Control-Allow 라는 뜻은 직독하면 접근이 허용된것이라고 생각하면 됩니다. 그래서 결론은 다음과 같습니다.

  • Originhttp://foo.example 만 허용한다고 보입니다.
  • MethodPOST, GET, OPTIONS 만 허용하는 것을 볼 수 있습니다.
  • HeadersX-PINGOTHER, Content-Type 만 허용하는 것을 볼 수 있습니다.
  • Max-Age는 최대 캐싱 시간입니다. 86400이므로 24시간을 뜻합니다. 이는 숫자가 클수록 우선순위가 높습니다.

우리는 이러한 preflight request에서 오류가 나왔기 때문에 이를 spring security의 설정을 통해서 해결해보도록 합시다.


해결방안

우선 이러한 문제를 해결하기 위해서 WebSecurityConfigureAdapter 를 상속받는 WebSecurityConfig 라는 클래스를 만들었습니다.

@Configuration // 설정파일 등록
@EnableWebSecurity // 웹보안 활성화
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...
        http.cors();
    }
}

그리고 다음과 같이 cofigure(HttpSecurity http) 라는 메서드를 오버라이드 합니다. 또한, 설정에 대한 @Configuration@EnableWebSecurity에 대한 애너테이션은 꼭 붙여야합니다.

이후에 http.cors() 라는 메서드를 호출합니다.

하지만 이렇게 설정을해도 계속 오류는 발생합니다. 이러한 오류를 해결하기 위해서 Access-Control에 대한 범위를 설정을 해줍니다.

설정을 하기 위해서 WebMvcConfigure 를 구현하는 WebConfig라는 클래스를 다음과 같이 만들것 입니다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry){
        registry.addMapping("/**")
                .allowedOrigins("*")
                .allowedMethods("*")
                .allowedHeaders("*")
                .allowCredentials(false);
    }
}

그 후 addCorsMappings(CorsRegistry registry) 메서드를 오버라이딩 합니다.

이후에 registry에 대해 허용을 설정을 하나씩 합니다.

  • addMapping 을 통해서 어떤 주소에 대해서 설정할지를 결정합니다. "/**"를 명시하면 모든 경로에 대해서 설정입니다.
  • allowedOrigins 를 통해서 허용할 Origin에 대해서 설정합니다.
  • allowedMethod 를 통해서 허용할 메서드를 설정합니다.
  • allowedHeders 를 통해서 허용할 Header들을 설정합니다.
  • allowCredentialsfalse로 설정해야 Origins*이라는 와일드 카드가 들어갈 수 있습니다.

다음과 같은 설정을 한다면 해결이 될 것입니다.


Reference

'Spring > 디버깅' 카테고리의 다른 글

Json이 LocalDateTime에서 잘못 넘겨지는 경우  (0) 2022.02.23

스프링 프로젝트를 만드는 도중에 LocalDateTime 이라는 항목에 대해서 Json으로 반환을 할때 다음 사진과 같이 넘겨지는 오류를 발견했습니다.

image


원래는 다음 그림과 같이 날짜와 시간등이 변환이 되어야합니다.

image

문제점을 알기위해서 확인해보니 다음과 같은 방식으로 해결할 수 있었습니다.

해결방법

기존 LocalDateTime 을 넘겨주는 DTO는 다음과 같은 인스턴스 변수로 생성되어 있습니다.

private LocalDateTime createAt;

이러한 코드에서 @JsonFormat을 통해서 패턴을 넣어서 String 형으로 다시 반환을 할 수 있게 할 수 있습니다.
코드는 다음과 같습니다.

@JsonFormat(shape= JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS")
private LocalDateTime createAt;

그러면 해결이 되는 것을 볼 수 있습니다.

'Spring > 디버깅' 카테고리의 다른 글

[Spring/오류수정] CORS(Cross-Origin Resource Sharing)  (0) 2022.02.25

+ Recent posts