스프링 시큐리티는 막강한 인증(Authentication)과 인가(Authorization) 기능을 가진 프레임워크입니다.  사실상 스프링 기반의 애플리케이션에서는 보안을 위한 표준이라고 보시면 됩니다. 

스프링의 대부분 프로젝트들(Mvc, Data, Batch 등등)처럼 확장성을 고려한 프레임워크다 보니 다양한 요구사항을 손쉽게 추가하고 변경할 수 있습니다. 

 

 

 

스프링 시큐리티와 스프링 시큐리티 oauth2 클라이언트

많은 서비스에서 로그인 방식보다는 구글, 페이스북, 네이버 로그인과 같은 소셜 로그인 기능을 사용합니다.

 

많은 서비스에서 소셜 로그인을 사용하는 이유는 뭘까요?? 로그인 기능을 직접 구현할 경우 배보다 배꼽이 커지는 경우가 많기 때문입니다. 직접 구현하면 다음을 전부 구현해야 합니다. OAuth를 써도 구현해야 하는것은 제외했습니다.

  • 로그인 시 보안
  • 비밀번호 찾기
  • 회원가입 시 이메일 혹은 전화번호 인증
  • 비밀변호 변경
  • 회원정보 변경

OAuth 로그인 구현 시 앞선 목록의 것들을 모두 구글, 페이스북, 네이버 등에 맡기면 되니 서비스 개발에 집중할 수 있씁니다.

 

  • 스프링 부트 1.5 vs 스프링 부트 2.0

스프링 부트 1.5에서의 OAuth2 연동 방법이 2.0에서는 크게 변경되었습니다. 하지만, 인터넷 자료들을 보면 설정 방법에 크게 차이가 없는 경우를 자주 봅니다. 이는 spring-security-oauth2-autoconfigure 라이브러리 덕분입니다.

 

spring-security-oauth2-autoconfigure

spring-security-oauth2-autoconfigure 라이브러리를 사용할 경우 스프링 부트2에서도 1.5에서 쓰던 설정을 그대로 사용할 수 있습니다. 새로운 방법을 쓰기보다는 기존에 안전하게 작동하던 코드를 사용하는 것이 아무래도 더 확실하므로 많은 개발자가 이 방식을 사용해 왔습니다. 

 

하지만 이 책에서는 스프링 부트 2 방식인 Spring Security Oauth2 Client 라이브러리를 사용해서 진행합니다. 이유는 아래와 같습니다.

  • 스프링 팀에서 기존 1.5에서 사용되던 spring-security-oauth 프로젝트는 유지 상태로 결정했으며 더는 신규 기능은 추가하지 않고 버그 수정 정도의 기능만 추가될 예정, 신규 기능은 새 oauth2 라이브러리에서만 지원하겠다고 선언
  • 스프링 부트용 라이브러리(starter) 출시
  • 기존에 사용되던 방식은 확장 포인트가 적절하게 오픈되어 있지 않아 직접 상속하거나 오버라이딩 해야 하고 신규 라이브러리의 경우 확장 포인트를 고려해서 설계된 상태   

그리고 한가지 더 이야기하자면, 이 책 이외에 스프링 부트 2 방식의 자료를 찾고 싶은 경우 인터넷 자료들 사이에서 다음 두 가지만 확인하면 됩니다. 먼저 spring-security-oauth2-autoconfigure 라이브러리를 썻는지를 확인하고 application.properties 혹은 application.yml 정보가 다음과 같이 차이가 있는지 비교해야합니다. 

스프링 부트 1.5와 2.0 설정 차이

스프링 부트 1.5방식에서는 url 주소를 모두 명시해야하지만, 2.0방식에서는 client 인증 정보만 입력하면 됩니다. 1.5 버전에서 직접 입력했던 값들은 2.0버전으로 오면서 모두 enum으로 대체되었습니다.

CommonOAuth2Provider라는 enum이 새롭게 추가되어 구글, 깃허브, 페이스북, 옥타의 기본 설정값을 모두 여기서 제공합니다. 이외의 다른 소셜 로그인(네이버, 카카오 등)을 추가한다면 직접 다 추가해주어야 합니다.

 

 

 

구글 서비스 등록

먼저 구글 서비스에 신규 서비스를 생성합니다. 여기서 발급된 인증 정보(clientId와 clientSecret)를 통해서 로그인 기능과 소셜 서비스 기능을 사용할 수 있으니 무조건 발급받고 시작해야 합니다.

 

 

 

appication-oauth 등록

src/main/resources에 appication-oauth.properties 파일을 생성한 뒤 아래의 코드를 추가합니다. 

spring.security.oauth2.client.registration.google.client-id=구글클라이언트ID
spring.security.oauth2.client.registration.google.client-secret=구글클라이언트시크릿
spring.security.oauth2.client.registration.google.scope=profile,email
  • scpoe=profile,email
    • 기본 값은 openid, profile, email 입니다.
    • 강제로 profile, email을 등록한 이유는 Openid라는 scope가 있으면 Open Id Provider로 인식하기 때문입니다.
    • 이렇게되면 openid Provider인 서비스(구글)와 그렇지 않은 서비스(네이버/카카오 등)로 나눠서 각각 Oauth2Service를 만들어야합니다.

스프링 부트에서는 properties의 이름을 application-xxx.properties로 만들면 xxx라는 이름의 Profiler이 생성되어 이를 통해 관리할 수 있습니다. 즉, profile=xxx라는 식으로 호출하면 해당 properties의 설정들을 가져올 수 있습니다. 호출하는 방식을 여러개지만 이 책에서는 스프링 부트의 기본 설정파일인 application.properties에서 application-oauth.properties를 포함하도록 구성합니다. 

 

application.properties에 아래와 같이 코드를 추가합니다.

spring.profiles.include=oauth

 

 

.gitignore 등록

구글 로그인을 위한 클라이언트 ID와 클라이언트 보안 비밀은 보안이 중요한 정보들입니다. 외부에 노출하면 안되는데 github에 오픈소스를 올려놓으면 노출하게됩니다. 보안을 위해 깃허브에 application-oauth.properties 파일이 올라가는 것을 방지하겠습니다.

 

plugins에 .gitignore을 설치 후 gitignore 파일을 만들어줍니다. 해당 파일에 아래의 코드를 추가해주면 됩니다.

application-oauth.properties

.gradle
.idea

 

커밋 내역의 해당 파일이 없으면 성공입니다.

 

 

 

구글 로그인 연동하기

구글의 로그인 인증정보를 발급 받았으니 프로젝트 구현을 진행해봅시다. 머넞 사용자 정보를 담당할 도메인인 User 클래스를 생성합니다. 패키지는 domain 아래에 user 패키지를 생성합니다.

User 클래스 생성

@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity {

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

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String email;

    @Column
    private String picture;

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

    @Builder
    public User(String name, String email, String picture, Role role) {
        this.name = name;
        this.email = email;
        this.picture = picture;
        this.role = role;
    }

    public User update(String name, String picture) {
        this.name = name;
        this.picture = picture;

        return this;
    }

    public String getRoleKey() {
        return this.role.getKey();
    }
}
  • @Enumerated (EnumType.STRING)
    • JPA로 데이터베이스로 저장할 때 Enum 값을 어떤 형태로 저장할지를 결정합니다.
    • 기본적으로는 int로 된 숫자가 저장됩니다.
    • 숫자로 저장되면 데이터베이스로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수 가 없습니다.
    • 그래서 문자열(EnumType.STRING)로 저장될 수 있도록 선언합니다.

각 사용자의 권한을 관리할 Enum 클래스 Role을 생성합니다.

Role Enum 클래스 생성

@Getter
@RequiredArgsConstructor
public enum Role {

    GUEST("ROLE_GUEST", "손님"),
    USER("ROLE_USER", "일반 사용자");

    private final String key;
    private final String title;

}

스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야만 합니다.

 

마지막으로 User의 CRUD를 책임질 UserRepository도 생성합니다.

UserRepository 인터페이스 생성

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);
}
  • findByEmail
    • 소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메소드입니다.

 

User 엔티티 관련 코드를 모두 작성했으니 본격적으로 시큐리티 설정을 진행하겠습니다.

 

 

스프링 시큐리티 설정

먼저 build.gradle에 스프링 시큐리티 관련 의존성 하나를 추가합니다. 코드는 아래와 같습니다.

    // 스프링 시큐리티
    implementation('org.springframework.boot:spring-boot-starter-oauth2-client')
  • spring-boot-starter-oauth2-client
    • 소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성입니다.
    • spring-security-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리해줍니다.

build.gradle 설정이 끝났으면 OAuth 라이브러리를 이용한 소셜 로그인 설정 코드를 작성합니다.

config.auth 패키지를 앞으로 시큐리티 관련 클래스는 모두 이곳에 담는다고 보면 됩니다.

 

SecurityConfig 클래스를 생성하고 아래와 같이 코드를 작성합니다. CustomOAuth2UserService 클래스를 아직 만들지 않아서 아직 코드를 돌리면 안됩니다.

SecurityConfig 클래스 생성

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .headers().frameOptions().disable()
                .and()
                    .authorizeRequests()
                    .antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
                    .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                    .anyRequest().authenticated()
                .and()
                    .logout()
                        .logoutSuccessUrl("/")
                .and()
                    .oauth2Login()
                        .userInfoEndpoint()
                            .userService(customOAuth2UserService);
    }
}
  • @EnableWebSecurity
    • Spring Security 설정들을 활성화시켜 줍니다.
  • csrf().disable().headers().frameOptions().disable
    • h2-console 화면을 사용하기 위해 해당 옵션들을 disalbe 합니다.
  • authorizeRequests
    • URL 별 권한 관리를 설정하는 옵션의 시작점입니다.
    • authorizeRequests가 선언되어야만 antMatchers 옵션을 사용할 수 있습니다.
  • antMatchers
    • 권한 관리 대상을 지정하는 옵션입니다.
    • URL, HTTP 메소드별로 관리가 가능합니다.
    • "/"등 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 주었습니다.
    • "/api/v1/**" 주소를 가진 API는 USER 권한을 가진 사람만 가능하도록 했습니다.
  • anyRequest
    • 설정된 값들 이외 나머지 URL들을 나타냅니다.
    • 여기서는 authenticated()를 추가하여 나머지 URL들은 모두 인증된 사용자들에게만 허용하게 합니다.
    • 인증된 사용자 즉, 로그인한 사용자들을 이야기합니다.
  • logout().logoutSuccessURL("/")
    • 로그아웃 기능에 대한 여러 설정의 진입점입니다.
    • 로그아웃 성공 시 "/" 주소로 이동합니다.
  • oauth2Login
    • OAuth2 로그인 기능에 대한 여러 설정의 진입점입니다.
  • userInfoEndpoint
    • OAuth2 로그인 성공 이후 사용자 정보를 가져올 때 의 설정들을 담당합니다.
  • userService
    • 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록합니다.
    • 리소스 서버(즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있습니다.

설정 코드 작성이 끝났다면 CustomOAuth2UserService 클래스를 생성합니다. 이 클래스는 구글 로그인 이후 가져온 사용자의 정보(email, name, picture)들을 기반으로 가입 및 정보수정, 세션 저장 등의 기능을 지원합니다.

CustomOAuth2UserService 클래스 생성

@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    private final UserRepository userRepository;
    private final HttpSession httpSession;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserService delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
                .getUserInfoEndpoint().getUserNameAttributeName();

        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        User user = saveOrUpdate(attributes);
        httpSession.setAttribute("user", new SessionUser(user));

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
                attributes.getAttributes(),
                attributes.getNameAttributeKey());
    }


    private User saveOrUpdate(OAuthAttributes attributes) {
        User user = userRepository.findByEmail(attributes.getEmail())
                .map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
                .orElse(attributes.toEntity());

        return userRepository.save(user);
    }
}
  • registrationId
    • 현재 로그인 진행 중인 서비스를 구분하는 코드입니다.
    • 지금은 구글만 사용하는 불필요한 값이지만, 이후 네이버 로그인 연동 시에 네이버 로그인인지, 구글 로그인인지 구분하기 위해 사용합니다.
  • userNameAttributeName
    • OAuth2 로그인 진행 시 키가 되는 필드값을 이야기합니다. Primary Key와 같은 의미입니다.
    • 구글의 경우 기본적으로 코드를 지원하지만, 네이버 카카오 등은 기본 지원하지 않습니다. 구글의 기본 코드는 "sub"입니다.
    • 이후 네이버 로그인과 구글 로그인을 동시 지원할 때 사용됩니다.
  • OAuthAttributes
    • OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스입니다.
    • 이후 네이버 등 다른 소셜 로그인도 이 클래스를 사용합니다.
    • 바로 아래에서 이 클래스의 코드가 나오니 차례로 생성하시면 됩니다.
  • SessionUser
    • 세션에 사용자 정보를 저장하기 위한 Dto 클래스입니다.
    • User 클래스를 쓰지 않고 새로 만들어서 쓰는지 뒤이어서 상세하게 설명하겠습니다.

구글 사용자 정보가 업데이트 되었을 때를 대비하여 update 기능도 같이 구현되었습니다. 사용자의 이름이나 프로필 사진이 변경되면 User 엔티티에도 반영됩니다.

이제 OAuthAttributes 클래스를 생서해줍시다. Dto이기 때문에 auth패키지안에 dto 패키지를 만든뒤 안에 생성해줍시다.

OAuthAttributes 클래스 생성

@Getter
public class OAuthAttributes {
    private Map<String, Object> attributes;
    private String nameAttributeKey;
    private String name;
    private String email;
    private String picture;

    @Builder
    public OAuthAttributes(Map<String, Object> attributes,
                           String nameAttributeKey, String name,
                           String email, String picture) {
        this.attributes = attributes;
        this.nameAttributeKey= nameAttributeKey;
        this.name = name;
        this.email = email;
        this.picture = picture;
    }

    public static OAuthAttributes of(String registrationId,
                                     String userNameAttributeName,
                                     Map<String, Object> attributes) {
        return ofGoogle(userNameAttributeName, attributes);
    }

    private static OAuthAttributes ofGoogle(String userNameAttributeName,
                                            Map<String, Object> attributes) {
        return OAuthAttributes.builder()
                .name((String) attributes.get("name"))
                .email((String) attributes.get("email"))
                .picture((String) attributes.get("picture"))
                .attributes(attributes)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }


    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }
}

 

  • of()
    • OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야만 합니다.
  • toEntity()
    • User 엔티티를 생성합니다.
    • OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때입니다.
    • 가입할 때의 기본 권한을 GUEST로 주기 위해서 role 빌더값에는 Role.GUEST를 사용합니다.

 

SessionUser 클래스 생성

@Getter
public class SessionUser implements Serializable {
    private String name;
    private String email;
    private String picture;

    public SessionUser(User user) {
        this.name = user.getName();
        this.email = user.getEmail();
        this.picture = user.getPicture();
    }
}

SeesionUser에는 인증된 사용자 정보만 필요합니다. 

 

 

로그인 테스트

스프링 시큐리티가 잘 적용되었는지 확인하기 위해 화면에 로그인 버튼을 추가해보겠습니다.

index.mustache 코드 추가

{{>layout/header}}

    <h1>스프링부트로 시작하는 웹 서비스 Ver.2</h1>
    <div class="col-md-12">
        <div class="row">
            <div class="col-md-6">
                <a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
                {{#userName}}
                    Logged in as: <span id="user">{{userName}}</span>
                    <a href="/logout" class="btn btn-info active" role="button">Logout</a>
                {{/userName}}
                {{^userName}}
                    <a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
                {{/userName}}
            </div>
        </div>
        <br>
        <!-- 목록 출력 영역 -->
        <table class="table table-horizontal table-bordered">
            <thead class="thead-strong">
            <tr>
                <th>게시글번호</th>
                <th>제목</th>
                <th>작성자</th>
                <th>최종수정일</th>
            </tr>
            </thead>
            <tbody id="tbody">
            {{#posts}}
                <tr>
                    <td>{{id}}</td>
                    <td><a href="/posts/update/{{id}}">{{title}}</a></td>
                    <td>{{author}}</td>
                    <td>{{modifiedDate}}</td>
                </tr>
            {{/posts}}
            </tbody>
        </table>
    </div>

{{>layout/footer}}
  • {{#userName}}
    • 머스테치는 다른 언어와 같은 if문을 제공하고 있습니다.
    • true/false 여부만 판단할 뿐입니다.
    • 그래서 머스테치에서는 항상 최종값을 넘겨줘야합니다.
    • 여기서도 역시 userName이 있다면 userName을 노출시키도록 구성했습니다.
  • a href="/logout"
    • 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL입니다.
    • 즉, 개발자가 별도로 저 URL에 해당하는 컨트롤러를 만들 필요가 없습니다.
    • securityConfig 클래스에서 URL을 변경할 순 있지만 기본 URL을 사용해도 충분하니 여기서는 그대로 사용합니다.
  • {{^userName}}
    • 머스테치에서 해당 값이 존재하지 않는 경우에는 ^를 사용합니다.
    • 여기서는 userName이 없다면 로그인 버튼을 노출시키도록 구성했습니다.
  • a href="/oauth2/authorization/google"
    • 스프링 시큐리티에서 기본적으로 제공하는 로그인 URL입니다.
    • 로그아웃 URL과 마찬가지로 개발자가 별도의 컨트롤러를 생성할 필요가 없습니다.

 

index.mustache에서 userName을 사용할 수 있게 IndexController에서 userName을 Model에 저장하는 코드를 추가합니다.

IndexController 코드 추가

public class IndexController {
    private final PostsService postsService;
    private final HttpSession httpSession;

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("posts", postsService.findAllDesc());
        SessionUser user = (SessionUser) httpSession.getAttribute("user");

        if (user != null) {
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }
    ...
}
  • (SessionUser) httpSession.getAttribute("user")
    • 앞서 작성된 CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구성했습니다.
    • 즉, 로그인 성공 시 httpSession.getAttribute("user")에서 값을 가져올 수 있습니다.
  • if(user != null)
    • 세션에 저장된 값이 있을 때만 model에 userName으로 등록됩니다.

 

그럼 한번 테스트해 봅시다.

로그인 된 정보는 세션에 저장되어있어 껐다가 다시 들어와도 로그인 상태가 유지됩니다. 로그아웃을 하면 세션 정보가 사라져서 더이상 로그인 정보를 갖고있지 않는 것을 테스트 할 수 있습니다.

 

h2-console을 통해 유저정보가 user 테이블에 저장되있는 모습도 볼 수 있습니다.

 

하지만, 글등록을 하려고하면 글등록을 할 수 없도록 에러가 나옵니다.

이유는 현재 로그인된 사용자의 권한은 GUEST입니다. 이 상태에서는 posts 기능을 전혀 쓸 수 없습니다. 위의 에러를 읽어보면 status가 403으로 권한이 거부된 것을 볼 수 있습니다.

 

그렇다면 권한을 변경해서 다시 해봅시다. h2-console로 가서 role을 USER로 변경해봅시다.

update user set role = 'USER';

 

세션에는 이미 GUEST인 정보로 저장되어있으니 로그아웃한 후 다시 로그인해서 글 등록을 해보겠습니다. 

권한을 부여해준 뒤 글 등록이 되는 모습을 볼 수 있습니다.

 

 

 

어노테이션 기반으로 개선하기

일반적인 프로그래밍에서 개선이 필요한 나쁜 코드 중 대표적인 것은 같은 코드가 반복되는 부분입니다. 같은 코드를 계속 복사 & 붙여넣기 하다보면 수정이 필요할 때 해당 부분들을 모두 수정해야되기 때문에 유지보수에 좋지 못하기 때문입니다. 

 

그럼 앞서 만든 코드에서 개선할만한 것은 무엇이 있을까요? 이동욱님은 IndexController에서 세션값을 가져오는 부분이라고 생각하고 있습니다.

SessionUser user = (SessionUser) httpSession.getAttribute("user");

index 메소드 외에 다른 컨트롤러와 메소드에서 세션값이 필요하면 그 때마다 직접 세션에서 값을 가져와야합니다. 그래서 이 부분을 메소드 인자로 세션값을 바로 받을 수 있도록 변경해보겠습니다.

config.auth 패키지에 @LoginUser 어노테이션을 생성합니다.

@LoginUser 어노테이션 생성

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}
  • @Target(ElementType.PARAMETER)
    • 이 어노테이션이 생성될 수 있는 위치를 지정합니다.
    • PARAMETER로 지정했으니 메소드의 파라미터로 선언된 객체에서만 사용할 수 있습니다.
    • 이 외에도 클래스 선언문에 쓸 수 있는 TYPE 등이 있습니다.
  • @interface
    • 이 파일을 어노테이션 클래스로 지정합니다.
    • LoginUser라는 이름을 가진 어노테이션으로 생성되었다고 보면 됩니다.

그리고 같은 위치에 LoginUserArgumentResolver를 생성합니다. HandlerMethodArgumentResolver라는

인터페이스를 구현한 클래스입니다. HandlerMethodArgumentResolver는 한가지 기능을 지원합니다. 바로 조건에 맞는 경우 메소드가 있다면 HandlerMethodArgumentResolver의 구현체가 지정한 값으로 해당 메소드의 파라미터로 넘길 수 있습니다.

 

LoginUserArgumentReslover 클래스 생성

@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final HttpSession httpSession;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;
        boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());
        return isLoginUserAnnotation && isUserClass;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return httpSession.getAttribute("user");
    }
}
  • supportsParameter()
    • 컨트롤러 메서드의 특정 파라미터를 지원하는지 판단합니다.
    • 여기서는 파라미터에 @LoginUser 어노테이션이 붙어있고, 파라미터 클래스 타입이 SessionUser.class인 경우 true를 반환합니다.
  • resolveArgument()
    • 파라미터에 전달할 객체를 생성합니다.
    • 여기서는 세션에서 객체를 가져옵니다.

@LoginUser를 사용하기 위한 환경은 구성되었습니다. 

 

이제 스프링에서 인식될 수 있도록 WebMvcConfigurer에 추가해줍시다. config 패키지에 WebConfig클래스를 생성합시다.

WebConfig 클래스 생성

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
    private final LoginUserArgumentResolver loginUserArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(loginUserArgumentResolver);
    }
}

 

HandlerMethodArgumentResolver는 항상 WebMvcConfigurer의 addArgumentResolvers()를 통해 추가해야 합니다. 

 

모든 설정이 끝났으니 IndexController의 코드에서 반복되는 부분들을 모두 @LoginUser로 개선합시다.

IndexController 메서드 변경

    @GetMapping("/")
    public String index(Model model, @LoginUser SessionUser user) {
        model.addAttribute("posts", postsService.findAllDesc());
        if (user != null) {
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }
  • @LoginUser SessionUser user
    • 기존에 httpSession.getAttribute("user")로 가져오던 세션 정보 값이 개선되었습니다.
    • 이제는 어느 컨트롤러든지 @LoginUser만 사용하면 세션 정보를 가져올 수 있게 되었습니다.

 

 

 

세션 저장소로 데이터베이스 사용하기

현재 만든 서비스는 애플리케이션을 재실행하면 로그인이 풀립니다. 이는 세션이 내장 톰캣의 메모리에 저장되기 때문입니다. 기본적으로 세션은 실행되는 WAS의 메모리에서 저장되고 호출됩니다. 메모리에 저장되다 보니 내장 톰캣처럼 애플리케이션 실행 시 실행되는 구조에선 항상 초기화가 됩니다. 즉, 배포할 때마다 톰캣이 재시작되는 것 입니다. 

 

이 외에도 한 가지 더 문제가 있는데, 2대 이상의 서버에서 서비스하고 있다면 톰캣마다 세션 동기화 설정을 해야만 합니다. 그래서 실제 현업에서는 세션 저장소에 대해 다음의 3가지 중 한 가지를 선택합니다.

  1. 톰캣 세션을 사용한다.
    • 일반적으로 별다른 설정을 하지 않을 때 기본적으로 선택되는 방식입니다.
    • 이렇게 될 경우 톰캣(WAS)에 세션이 저장되기 때문에 2대 이상의 WAS가 구동되는 환경에서는 톰캣들 간의 세션 공유를 위한 추가 설정이 필요합니다.
  2. MySQL과 같은 데이터베이스를 세션 저장소로 사용합니다.
    • 여러 WAS 간의 공용 세션을 사용할 수 있는 가장 쉬운 방법입니다.
    • 많은 설정이 필요 없지만, 결국 로그인 요청마다 DB IO가 발생하여 성능상 이슈가 발생할 수 있습니다.
    • 보통 로그인 요청이 많이 없는 백오피스, 사내 시스템 용도에서 사용합니다.
  3. Redis, Memcached와 같은 메모리 DB를 세션 저장소로 사용합니다.
    • B2C 서비스에서 가장 많이 사용하는 방식입니다.
    • 실제 서비스로 사용하기 위해서는 Embedded Redis와 같은 방식이 아닌 외부 메모리 서버가 필요합니다.

여기서는 두 번째 방식인 데이터베이스를 세션 저장소로 사용하는 방식을 선택하여 진행하겠습니다. 선택한 이유는 설정이 간단하고 사용자가 많은 서비스가 아니며 비용 절감을 위해서입니다. 

 

이후 AWS에서 이 서비스를 배포하고 운영할 때를 생각하면 레디스와 같은 메모리 DB를 사용하기는 부담스럽습니다. 왜냐하면, 레디스와 같은 서비스(엘라스틱 캐시)에 별도로 사용료를 지불해야 하기 때문입니다. 

 

 

spring-session-jdbc 등록

build.gradle에 아래와 같이 의존성을 등록합니다. 

implementation('org.springframework.session:spring-session-jdbc')

그리고 application.properties에 세션 저장소를 Jdbc로 선택하도록 코드를 추가합시다.

spring.session.store-type=jdbc

모두 변경하였으니 다시 애플리케이션을 실행해서 로그인을 테스트한 뒤, h2-console로 접속해봅시다.

 

h2-console을 보면 세션을 위한 테이블 2개(SPRING_SESSION, SPRING_SESSION_ATTRIBUTES)가 생성된 것을 볼 수 있습니다. JPA로 인해 세션 테이블이 자동 생성되었기 때문에 별도로 해야 할 일은 없습니다. 

로그인을 해주니 세션 테이블에 값이 추가된 것을 볼 수 있습니다.

 

물론 지금은 기존과 동일하게 스프링을 재시작하면 세션이 풀립니다. 이유는 H2 기반으로 스프링이 재실행될 때 H2도 재시작되기 때문입니다. 이후 AWS로 배포하게 되면 AWS의 데이터베이스 서비스인 RDS(Relation Database Service)를 사용하게 되니 읻대부터는 세션이 풀리지 않습니다. 그 기반이 되는 코드를 작성한 것이니 걱정하지 마시길 바랍니다.

 

 

 

네이버 로그인

구글과 같이 네이버로도 로그인할 수 있게 해줍시다!

 

 

네이버 API 등록

먼저 네이버 오픈 API로 이동합니다.

https://developers.naver.com/apps/#/register?api=nvlogin

 

위처럼 만든 후, application-oauth.properties에 등록합시다. 네이버에서는 스프링 시큐리티를 공식 지원하지 않기 때문에 그동안 Common-OAuth2Provider에서 해주던 값들도 전부 수동으로 입력해야 합니다.
application-oauth.properties 코드 추가

# registration
spring.security.oauth2.client.registration.naver.client-id=네이버클라이언트ID
spring.security.oauth2.client.registration.naver.client-secret=네이버클라이언트시크릿
spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name,email,profile_image
spring.security.oauth2.client.registration.naver.client-name=Naver

# provider
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response
  • user_name_attribute=response
    • 기준이 되는 user_name의 이름을 네이버에서는 response로 해야합니다.
    • 이유는 네이버의 회원 조회 시 반환되는 JSON 형태 때문입니다.

네이버 오픈 API의 로그인 회원 결과는 아래와 같습니다.

{
    "resultcode" : "00",
    "message: : "success",
    "response" : {
        "email" : "openapi@naver.com",
        ...
    }
}

스프링 시큐리티에선 하위필드를 명시할 수 없습니다. 최상위 필드들만 user_name으로 지정 가능합니다. 하지만 네이버의 응답값 최상위필드는 resultcode, message, response입니다. 이러한 이유로 3개 중에 골라야합니다. 본문에서 담고있는 response를 user_name으로 지정하고 이후 자바 코드로 response의 id를 user_name으로 지정하겠습니다. 

 

 

스프링 시큐리티 설정 등록

구글 로그인을 등록하면서 대부분 코드를 확장성 있게 작성했다보니 네이버는 쉽게 등록 가능합니다. OAuthAttributes에 아래와 같이 네이버인지 판단하는 코드와 네이버 생성자만 추가해주면됩니다.

OAuthAttributes 메소드 수정, 추가

    public static OAuthAttributes of(String registrationId,
                                     String userNameAttributeName,
                                     Map<String, Object> attributes) {
        if("naver".equals(registrationId)) {
            return ofNaver("id", attributes);
        }

        return ofGoogle(userNameAttributeName, attributes);
    }

    private static OAuthAttributes ofNaver(String userNameAttributeName,
                                           Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuthAttributes.builder()
                .name((String) response.get("name"))
                .email((String) response.get("email"))
                .picture((String) response.get("profile_image"))
                .attributes(response)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

 

마지막으로 index.mustache에 네이버 로그인 버튼을 추가합니다. 

<a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
  • /oauth2/authorization/naver
    • 네이버 로그인 URL은 application-oauth.properties에 등록한 redirect-uri 값에 맞춰 자동으로 등록됩니다.
    • /oauth2/authorization/ 까지는 고정이고 마지막 path만 각 소셜 로그인 코드를 사용하면 됩니다.

 

 

네이버 로그인까지 성공했습니다.

 

 

 

기존 테스트에 시큐리티 적용하기

마지막으로 기존 테스트에 시큐리티 적용으로 문제가 되는 부분들을 해결해보겠습니다. 문제가 되는 부분들은 대표적으로 아래와 같은 이유 때문입니다.

기존에 바로 API를 호출할 수 있어 테스트 코드 역시 바로 API를 호출하도록 구성하였습니다. 하지만, 시큐리티 옵션이 활성화되면 인증된 사용자만 API를 호출할 수 있습니다. 기존의 API 테스트 코드들이 모두 인증에 대한 권한을 받지 못하였으므로, 테스트 코드마다 인증한 사용자가 호출한 것처럼 작동하도록 수정해봅시다.

 

문제 1. CustomOAuth2UserService 찾을 수 없음

CustomOAuth2UserService를 생성하는데 필요한 소셜 로그인 관련 설정값들이 없기 때문에 발생합니다. 난 분명히 application-oauth.properties를 추가했는데 왜 이런 설정값들이 없다고 할까요???

이는 src/main 환경과 src/test 환경의 차이 때문이라고 합니다.  둘은 본인만의 환경 구성을 가집니다. 다만, application.properties가 테스트 코드 수행할 때도 적용되는 이유는 test에 application.properties가 없으면 main의 설정을 그대로 가져오기 때문입니다. 다만, 자동으로 가져오는 옵션의 범위는 application.properties 파일 까지입니다. 

 

이 문제를 해결하기 위해 테스트 환경을 위한 application.properties를 만들어 봅시다. 실제 구글 연동까지 하는 테스트코드는 없기 때문에 가짜 설정값을 등록합니다.

spring.jpa.show_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.h2.console.enabled=true
spring.session.store-type=jdbc

# Test OAuth

spring.security.oauth2.client.registration.google.client-id=test
spring.security.oauth2.client.registration.google.client-secret=test
spring.security.oauth2.client.registration.google.scope=profile,email

해당 application.properties를 추가하면 7개이던 error가 4개로 줄어들 것 입니다.

 

 

문제 2. 302 Status Code

Posts_등록된다 테스트 로그를 확인해보면 302(리다이렉션 응답) Status Code가 옵니다. 이는 스프링 시큐리티 설정 때문에 인증되지 않은 사용자의 요청은 이동시키기 때문입니다. 그래서 이런 API 요청은 임의로 인증된 사용자를 추가하여 API만 테스트해 볼 수 있게 합시다.

 

스프링 시큐리티 테스트를 위한 여러 도구를 지원하는 spring-security-test를 build.gradle에 추가합니다. 

testImplementation("org.springframework.security:spring-security-test")

 

그리고 PostsApiControllerTest의 2개의 테스트 메소드에 아래와 같이 임의 사용자 인증을 추가합니다.

    @Test
    @WithMockUser(roles="USER")
    public void Posts_등록된다() throws Exception { }
    
    @Test
    @WithMockUser(roles="USER")
    public void Posts_수정된다() throws Exception {}
  • @WithMockUser(roles="USER")
    • 인증된 모의 사용자를 만들어서 사용합니다.
    • roles에 권한을 추가할 수 있습니다.
    • 즉, 이 어노테이션으로 인해 ROLE_USER권한을 가진 사용자가 API를 요청하는 것과 동일한 효과를 가집니다.

이정도만하면 될 것 같지만, 실제로 작동하지 않습니다. @WithMockUser가 MockMvc에서만 작동하기 때문입니다. 현재 PostsApiControllerTest는 @SpringBootTest로만 되어있으며 MockMvc를 전혀 사용하지 않습니다. 그래서 @SpringBootTest에서 MockMvc를 사용하는 방법을 알아봅시다. 코드를 아래와 같이 변경합니다.

package com.qazyj.book.springboot.web;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.qazyj.book.springboot.domain.posts.Posts;
import com.qazyj.book.springboot.domain.posts.PostsRepository;
import com.qazyj.book.springboot.web.dto.PostsSaveRequestDto;
import com.qazyj.book.springboot.web.dto.PostsUpdateRequestDto;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.*;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    // 추가 MockMvc
    @Autowired
    private PostsRepository postsRepository;

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @Before
    public void setup() {
        mvc = MockMvcBuilders
                .webAppContextSetup(context)
                .apply(springSecurity())
                .build();
    }

    @After
    public void tearDown() throws Exception {
        postsRepository.deleteAll();
    }

    @Test
    @WithMockUser(roles="USER")
    public void Posts_등록된다() throws Exception {
        //given
        String title = "title";
        String content = "content";
        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author("author")
                .build();

        String url = "http://localhost:" + port + "/api/v1/posts";

        //when
        // mvc로 수정 
        mvc.perform(post(url)
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .content(new ObjectMapper().writeValueAsString(requestDto)))
                .andExpect(status().isOk());

        //then
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }

    @Test
    @WithMockUser(roles="USER")
    public void Posts_수정된다() throws Exception {
        //given
        Posts savedPosts = postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());

        Long updateId = savedPosts.getId();
        String expectedTitle = "title2";
        String expectedContent = "content2";

        PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
                .title(expectedTitle)
                .content(expectedContent)
                .build();

        String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;

        //when
        mvc.perform(put(url)
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .content(new ObjectMapper().writeValueAsString(requestDto)))
                .andExpect(status().isOk());

        //then
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
        assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
    }
}
  • @Before
    • 매번 테스트가 시작되기전에 MockMvc 인스턴스를 생성합니다.
  • mvc.perform
    • 생성된 MockMvc를 통해 API를 테스트 합니다.
    • 본문(Body) 영역은 문자열로 표현하기 위해 ObjectMapper를 통해 문자열 JSON으로 변환합니다.

그리고 전체 테스트를 해봅시다. 2개의 에러만 남게 됩니다.

 

 

문제 3 @WebMvcTest에서 CustomOAuth2UserService를 찾을 수 없음

@WebMvcTest는 CustomOAuth2UserService를 스캔하지 않기 때문에 에러가 발생합니다. @WebMvcTest는 WebSecurityConfigurerAdapter, WebMvcConfigurer를 비롯한 @ControllerAdvice, @Controller를 읽습니다. 즉, @Repository, @Service, @Component는 스캔 대상이 아닙니다. 그렇기 때문에 SecurityConfig를 읽었지만, SecurityConfig를 생성하기 위해 필요한 CustomOAuth2UserService는 읽을 수가 없어서 에러가 발생한 것 입니다. 

 

아래와 같이 스캔 대상에서 SecurityConfig를 제거해줍시다.

@WebMvcTest(controllers = HelloController.class,
        excludeFilters = {
        @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
        }
)

그리고 마찬가지로 @WithMockUser를 사용해서 가짜로 인증된 사용자를 생성해줍시다.

    @WithMockUser(roles="USER")
    @Test
    public void hello가_리턴된다() throws Exception {}
    
    @WithMockUser(roles="USER")
    @Test
    public void helloDto가_리턴된다() throws Exception {}

이렇게 한 뒤 테스트를 돌려보면 추가에러가 발생합니다.

 

이 에러는 @EnableJpaAuditing으로 인해 발생합니다. @EnableJpaAuditing를 사용하기 위해선 최소 하나의 @Entity 클래스가 필요합니다. @WebMvcTest이다 보니 당연히 없습니다.

 

@EnableJpaAuditing가 @SpringBootApplication과 함께 있다보니 @WebMvcTest에서도 스캔하게 되었습니다. 그래서 @EnableJpaAuditing과 @SpringBootApplication 둘을 분리하겠습니다. Application.java에서 @EnableJpaAuditing를 제거해줍시다.

 

그리고 config 패키지에 JpaConfig를 생성하여 @EnableJpaAuditing을 추가합니다.

 

그런뒤 돌려보면 모든 테스트가 정상적으로 완료됩니다.

 

 

 

 

 

 

출처

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

 

머스테치를 통해 화면 영역을 개발해보겠습니다. 이번 기회에 서버 템플릿 엔진과 클라이언트 템플릿 엔진의 차이는 무엇인지, 이 책에서는 왜 JSP가 아닌 머스테치를 선택했는지, 머스테치를 통해 기본적인 CRUD 화면 개발 방법 등을 차례로 진행하며 배워봅시다.

 

 

서버 템플릿 엔진과 머스테치 소개

템플릿 엔진이란, 지정된 템플릿 양식과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어를 이야기합니다. 템플릿 엔진에는 서버 템플릿 엔진과 클라이언트 템플릿 엔진이 있습니다.

 

서버 템플릿 엔진은 서버에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달합니다.

클라이언트 템플릿 엔진은 서버에선 브라우저로 데이터만 직렬화해서 넘겨주고, 브라우저에서 HTML을 생성합니다. 따라서 소스 코드가 브라우저에서 실행됩니다.

 

 

 

머스테치란

머스테치(http://mustache.github.io/)는 수 많은 언어를 지원하는 가장 심플한 템플릿 엔진입니다. 대부분의 언어를 지원하다 보니 자바에서 사용될 때는 서버 템플릿 엔진, 자바스크립트에서 사용될 때는 클라이언트 템플릿 엔진으로 모두 사용할 수 있습니다.

 

이동욱님이 사용하는 템플릿 엔진들의 단점은 아래와 같습니다.

  • JSP, Velocity : 스프링 부트에서는 권장하지 않는 템플릿 엔진입니다.
  • Freemarker : 템플릿 엔진으로는 너무 과하게 많은 기능을 지원합니다. 높은 자유도로 인해 숙련도가 낮을수록 Freemarker 안에 비즈니스 로직이 추가될 확률이 높습니다.
  • Thymeleaf : 스프링 진영에서 적극적으로 밀고 있지만 문법이 어렵습니다. HTML 태그에 속성으로 템플릿 기능을 사용하는 방식이 기존 개발자분들께 높은 허들로 느껴지는 경우가 많습니다. 

머스테치의 장점은 아래와 같습니다.

  • 문법이 다른 템플릿 엔진보다 심플합니다.
  • 로직 코드를 사용할 수 없어 View의 역할과 서버의 역할이 명확하게 분리됩니다.
  • mustache.js와 mustache.java 2가지가 다 있기 때문에 하나의 문법으로 클라이언트/서버 템플릿 모두 사용 가능합니다.

개인적으로 템플릿 엔진은 화면 역할에만 충실해야 한다고 생각합니다. 너무 많은 기능을 제공하면 API와 템플릿 엔진, 자바스크립트가 서로 로직을 나눠 갖게 되어 유지보수하기가 굉장히 어려워 질 것 입니다.

 

 

머스테치 플러그인 설치

아래와 같이 Preferences의 Marketplace에서 설치해주시면 됩니다. 

설치 한 뒤, build.gradle에 의존성을 주입해주면 됩니다. 코드는 아래와 같습니다.

    // 머스테치
    implementation('org.springframework.boot:spring-boot-starter-mustache')

위의 의존성을 보시면 머스테치는 스프링 부트에서 공식 지원하는 템플릿 엔진임을 알 수 있습니다.

 

 

기본 페이지 만들기

머스테치의 파일 위치는 기본적으로 src/main/resources/templates입니다. 해당 위치에 index.mustache를 생성한 뒤, 아래의 코드를 추가해주시면 됩니다.

<!DOCTYPE HTML>
<html>
<head>
	<title>스프링 부트 웹서비스</title>
    <meta http-equiv="Content-Type" content="text/html;charset=UTF-8" />
    
</head>
<body>
	<h1>스프링 부트로 시작하는 웹 서비스</h1>
</body>
</html>

이 머스테치에 URL을 매핑합니다. URL 매핑은 당연하게 Controller에서 진행합니다. web 패키지 안에 IndexController를 생성합니다.

@Controller
public class IndexController {

    @GetMapping("/")
    public String index() {
        return "index";
    }

}

머스테치 스타터 덕분에 컨트롤러에서 문자열을 반환할 때 앞의 경로와 뒤의 파일 확장자는 자동으로 지정됩니다. 즉, 여기선 "index"를 반환하므로, index.mustache로 전환되어 View Resolver가 처리하게 됩니다.

 

테스트 코드로 검증해 봅시다. test 패키지에 web패키지 안에 IndexControllerTest 클래스를 만든 뒤 아래의 코드를 추가해줍시다.

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
public class IndexControllerTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void 메인페이지_로딩() {
        //when
        String body = this.restTemplate.getForObject("/", String.class);

        //then
        assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
    }
}

정상적으로 돌아가는 것을 볼 수 있습니다.

 

 

 

게시글 등록 화면 만들기

오픈소스인 부트스트랩을 이용하여 화면을 만들어 봅시다. 부트스트랩, 제이쿼리 등 프론트엔드 라이브러리를 사용할 수 있는 방법은 크게 2가지가 있습니다. 하나는 외부 CDN을 사용하는 것, 다른 하나는 직접 라이브러리를 받아서 사용하는 방법입니다.

 

여기선 전자은 외부 CDN을 사용합니다. 사용 방법도 HTML/JSP/Mustache에 코드만 한줄 추가하면 되어 굉장히 간단합니다.

2개의 라이브러리 부트스트랩과 제이쿼리를 index.mustache에 추가해야 합니다. 하지만, 여기서는 바로 추가하지 않고 레이아웃 방식으로 추가해보겠습니다. 레이아웃 방식이란 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식을 이야기합니다.

 

src/main/resources/templates 디렉토리에 layout 디렉토리를 생성한 뒤, footer.mustach, header.mustache 파일을 생성합니다.

footer.mustach

<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>

</body>
</html>

header.mustach

<!DOCTYPE HTML>
<html>
<head>
    <title>스프링부트 웹서비스</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />

    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
</head>
<body>

 

코드를 보면 css와 js의 위치가 서로 다릅니다. 이는 페이징 로딩속도를 높이기 위해 css는 header에, js는 footer에 두었습니다. HTML은 위에서부터 코드가 실행되기 때문에 head가 다 실행되고서야 body가 실행됩니다.

 

즉, head가 다 불러지지 않으면 사용자 쪽에선 백지화면만 노출됩니다. 특히 js의 용량이 크면 클수록 body 부분의 실행이 늦어지기 때문에 js는 body 하단에 두어 화면이 다 그려진 뒤에 호출하는 것이 좋습니다.

 

반면 css는 화면을 그리는 역할이므로 head에서 불러오는 것이 좋습니다. 그렇지 않으면 css가 적용되지 않은 깨진 화면을 사용자가 볼 수 있기 때문입니다. 추가로, bootstrap.js의 경우 제이쿼리가 꼭 있어야만 하기 때문에 부트스트랩보다 먼저 호출되도록 코드를 작성했습니다. 보통 앞선 상황을 bootstrap.js가 제이쿼리에 의존한다고 합니다.

 

라이브러리를 비롯해 기타 HTML 태그들이 모두 레이아웃에 추가되니 이제 index.mustache에는 필요한 코드만 남게 됩니다. index.mustache 코드는 아래와 같이 변경됩니다.

{{>layout/header}}

    <h1>스프링부트로 시작하는 웹 서비스</h1>

{{>layout/footer}}
  • {{>layout/header}}
    • {{>}}는 현재 머스테치 파일을 기준으로 다른 파일을 가져옵니다.

 

레이아웃으로 파일을 분리했으니 index.mustache에 글 등록 버튼을 하나 추가해봅시다.

{{>layout/header}}

    <h1>스프링부트로 시작하는 웹 서비스 Ver.2</h1>
    <div class="col-md-12">
        <div class="row">
            <div class="col-md-6">
                <a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
            </div>
        </div>
    </div>

{{>layout/footer}}

여기서는 <a> 태그를 이용해 글 등록 페이지로 이동하는 글 등록 버튼이 생성되었습니다. 이동할 페이지의 주소는 /posts/save 입니다.

 

해당 주소에 해당하는 컨트롤러를 생성해줍시다. 페이지에 관련된 컨트롤러는 모두 IndexController를 사용합니다.

IndexController 메소드 추가

    @GetMapping("/posts/save")
    public String postsSave() {
        return "posts-save";
    }

 

index.mustache와 마찬가지로 /posts/save를 호출하면 연결되는 posts-save.mustache를 만들어 줍시다. templates에 생성해주면됩니다. 코드는 아래와 같습니다.

{{>layout/header}}

<h1>게시글 등록</h1>

<div class="col-md-12">
    <div class="col-md-4">
        <form>
            <div class="form-group">
                <label for="title">제목</label>
                <input type="text" class="form-control" id="title" placeholder="제목을 입력하세요">
            </div>
            <div class="form-group">
                <label for="author"> 작성자 </label>
                <input type="text" class="form-control" id="author" placeholder="작성자를 입력하세요">
            </div>
            <div class="form-group">
                <label for="content"> 내용 </label>
                <textarea class="form-control" id="content" placeholder="내용을 입력하세요"></textarea>
            </div>
        </form>
        <a href="/" role="button" class="btn btn-secondary">취소</a>
        <button type="button" class="btn btn-primary" id="btn-save">등록</button>
    </div>
</div>

{{>layout/footer}}

 

돌려보면, 글 등록 페이지까지 나오지만 아직 글 등록해주는 기능은 없습니다. API를 호출하는 JS가 전혀 없기 때문입니다. 그래서 src/main/resources에 static/js/app 디렉토리를 생성해줍시다. 해당 디렉토리에 index.js 파일을 생성해주고 아래의 코드를 추가해줍시다.

var main = {
    init : function () {
        var _this = this;
        $('#btn-save').on('click', function () {
            _this.save();
        });

    },
    save : function () {
        var data = {
            title: $('#title').val(),
            author: $('#author').val(),
            content: $('#content').val()
        };

        $.ajax({
            type: 'POST',
            url: '/api/v1/posts',
            dataType: 'json',
            contentType:'application/json; charset=utf-8',
            data: JSON.stringify(data)
        }).done(function() {
            alert('글이 등록되었습니다.');
            window.location.href = '/';
        }).fail(function (error) {
            alert(JSON.stringify(error));
        });
    }

};

main.init();
  • window.location.href = '/'
    • 글 등록이 성고하면 메인페이지(/)로 이동합니다.
  • type : 'POST'
    • http 메소드 중 하나인 POST를 선택합니다.
    • REST에서 CRUD는 다음과 같이 HTTP 메소드에 매핑됩니다.
      • 생성 (Create) : POST
      • 읽기 (Read) : GET
      • 수정 (Update) : PUT
      • 삭제 (Delete) : DELETE

자 그럼 생성된 Index.js를 머스테치 파일이 쓸 수 있게 footer.mustache에 추가해줍시다.

<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>

<!--index.js 추가-->
<script src="/js/app/index.js"></script>
</body>
</html>

index.js 호출 코드를 보면 절대 경로(/)로 바로 시작합니다. 스프링 부트는 기본적으로 src/main/resources/static에 위치한 자바스크립트, CSS, 이미지 등 정적 파일들은 URL에서 /로 설정됩니다.

 

실제 프로그램을 돌려 글 등록을 해보시면 글 등록이 되었다는 문구와 함께 h2-console을 통해 테이블에 데이터가 추가된 것을 확인할 수 있을 것 입니다.

 

 

 

전체 조회 화면 만들기

전체 조회를 위해 Index.mustache의 UI를 변경하겠습니다. 코드는 아래와 같습니다.

{{>layout/header}}

    <h1>스프링부트로 시작하는 웹 서비스 Ver.2</h1>
    <div class="col-md-12">
        <div class="row">
            <div class="col-md-6">
                <a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
            </div>
        </div>
        <br>
        <!-- 목록 출력 영역 -->
        <table class="table table-horizontal table-bordered">
            <thead class="thead-strong">
            <tr>
                <th>게시글번호</th>
                <th>제목</th>
                <th>작성자</th>
                <th>최종수정일</th>
            </tr>
            </thead>
            <tbody id="tbody">
            {{#posts}}
                <tr>
                    <td>{{id}}</td>
                    <td>{{title}}</td>
                    <td>{{author}}</td>
                    <td>{{modifiedDate}}</td>
                </tr>
            {{/posts}}
            </tbody>
        </table>
    </div>

{{>layout/footer}}
  • {{#posts}}
    • posts라는 List를 순회합니다.
    • java의 for문과 동일하게 생각하면 됩니다.
  • {{id}} 등의 {{변수명}}
    • List에서 뽑아낸 객체의 필드를 사용합니다.

 

그럼 Controller, Service, Repository 코드를 작성합시다. 먼저 Repository부터 시작합니다. 

기존에 있던 PostsRepository 인터페이스에 쿼리가 추가

public interface PostsRepository extends JpaRepository<Posts, Long> {

    @Query("SELECT p FROM Posts p ORDER BY p.id DESC")
    List<Posts> findAllDesc();
}

SpringDataJap에서 제공하지 않는 메소드는 위처럼 쿼리로 작성해도 되는 것을 보여드리고자 @Query를 사용했습니다. 실제로 앞의 코드는 SpringDataJpa에서 제공하는 기본 메소드만으로 해결할 수 있습니다. 다만 @Query가 훨씬 가독성이 좋으니 선택해서 사용합시다.

 

PostsService 코드 추가

    @Transactional(readOnly = true)
    public List<PostsListResponseDto> findAllDesc() {
        return postsRepository.findAllDesc().stream()
                .map(PostsListResponseDto::new)
                .collect(Collectors.toList());
    }

(readOnly = true)를 주면 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선되기 때문에 CUD 기능이 없을 때 사용하면 좋습니다.

메소드 내부의 코드에서 람다식을 모르시면 조금 생소한 코드가 있을 수 있습니다.

.map(PostsListResponseDto::new)  -> .map(posts -> new PostsListResponseDto(posts))와 같습니다. postsRepository 결과로 넘어온 Posts의 Stream을 map을 통해 PostsListResponseDto 반환 -> List로 변환하는 메소드입니다.

 

PostsListResponseDto 생성

@Getter
public class PostsListResponseDto {
    private Long id;
    private String title;
    private String author;
    private LocalDateTime modifiedDate;

    public PostsListResponseDto(Posts entity) {
        this.id = entity.getId();
        this.title = entity.getTitle();
        this.author = entity.getAuthor();
        this.modifiedDate = entity.getModifiedDate();
    }
}

 

마지막으로 Controller를 수정해줍시다.

IndexController 수정

@RequiredArgsConstructor
@Controller
public class IndexController {
    private final PostsService postsService;

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("posts", postsService.findAllDesc());
        
        return "index";
    }
}
  • Model
    • 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장할 수 있습니다.
    • 여기서는 postsService.findAllDesc()로 가져온 결과를 posts로 index.mustache에 전달합니다.

 

Controller까지 모두 완성이 됐습니다. 실행한 뒤 localhosts:8080에 접속한 뒤 등록화면을 이용해 데이터를 등록해보면 홈화면에 데이터가 추가된 것을 확인할 수 있을 것 입니다.

 

 

 

게시글 수정, 삭제 화면 만들기

게시글 수정 API는 이미 만들었습니다. 해당 API로 요청하는 화면을 개발해봅시다.

 

게시글 수정

게시글 수정 화면 머스테치 파일을 만들어 줍니다.

posts-update.mustache 추가

{{>layout/header}}

<h1>게시글 수정</h1>

<div class="col-md-12">
    <div class="col-md-4">
        <form>
            <div class="form-group">
                <label for="title">글 번호</label>
                <input type="text" class="form-control" id="id" value="{{post.id}}" readonly>
            </div>
            <div class="form-group">
                <label for="title">제목</label>
                <input type="text" class="form-control" id="title" value="{{post.title}}">
            </div>
            <div class="form-group">
                <label for="author"> 작성자 </label>
                <input type="text" class="form-control" id="author" value="{{post.author}}" readonly>
            </div>
            <div class="form-group">
                <label for="content"> 내용 </label>
                <textarea class="form-control" id="content">{{post.content}}</textarea>
            </div>
        </form>
        <a href="/" role="button" class="btn btn-secondary">취소</a>
        <button type="button" class="btn btn-primary" id="btn-update">수정 완료</button>
        <button type="button" class="btn btn-danger" id="btn-delete">삭제</button>
    </div>
</div>

{{>layout/footer}}
  • {{post.id}}
    • 머스테치는 객체의 필드 접근시 점(Dot)으로 구분합니다.
  • readonly
    • Input 태그에 읽기 가능만 허용하는 속성입니다.
    • id와 author는 수정할 수 없도록 읽기만 허용하도록 추가합니다. (실제 웹에서 수정이 불가합니다.)

 

그리고 btn-update 버튼을 클릭하면 update 기능을 호출할 수 있게 index.js파일에도 update function을 추가해줍니다.

var main = {
    init : function () {
        var _this = this;
        $('#btn-save').on('click', function () {
            _this.save();
        });

        $('#btn-update').on('click', function () {
            _this.update();
        });
    },
    save : function () {
        var data = {
            title: $('#title').val(),
            author: $('#author').val(),
            content: $('#content').val()
        };

        $.ajax({
            type: 'POST',
            url: '/api/v1/posts',
            dataType: 'json',
            contentType:'application/json; charset=utf-8',
            data: JSON.stringify(data)
        }).done(function() {
            alert('글이 등록되었습니다.');
            window.location.href = '/';
        }).fail(function (error) {
            alert(JSON.stringify(error));
        });
    },
    update : function () {
        var data = {
            title: $('#title').val(),
            content: $('#content').val()
        };

        var id = $('#id').val();

        $.ajax({
            type: 'PUT',
            url: '/api/v1/posts/'+id,
            dataType: 'json',
            contentType:'application/json; charset=utf-8',
            data: JSON.stringify(data)
        }).done(function() {
            alert('글이 수정되었습니다.');
            window.location.href = '/';
        }).fail(function (error) {
            alert(JSON.stringify(error));
        });
    }

};

main.init();

 

마지막으로 수정 페이지로 이동할 수 있게 페이지 이동 기능을 추가해줍시다. index.mustache 코드를 아래와 같이 살짝만 수정해줍시다.

            {{#posts}}
                <tr>
                    <td>{{id}}</td>
                    <td><a href="/posts/update/{{id}}">{{title}}</a></td>
                    <td>{{author}}</td>
                    <td>{{modifiedDate}}</td>
                </tr>
            {{/posts}}
  • <a href="/posts/update/{{id}}"></a>
    • 타이틀(title)에 a tag를 추가합니다.
    • 타이틀을 클릭하면 해당 게시글의 수정 화면으로 이동합니다.

 

수정 화면을 연결할 Controller 작업을 해줍시다.

IndexController 메소드 추가

    @GetMapping("/posts/update/{id}")
    public String postsUpdate(@PathVariable Long id, Model model) {
        PostsResponseDto dto = postsService.findById(id);
        model.addAttribute("post", dto);

        return "posts-update";
    }

 

이제 프로그램을 돌린 뒤 실행한다음 브라우저에서 테스트해보면 정상적으로 수정이 되는 것을 볼 수 있을 것 입니다.

 

 

 

게시글 삭제

삭제 버튼은 본문을 확인하고 진행할 수 있도록 진행 했습니다. 이미 posts-update.mustache에 삭제 버튼도 추가했습니다. 기능을 구현해봅시다. 삭제 이벤트를 진행할 js코드를 추가합시다.

 

index.js 추가

var main = {
    init : function () {
        var _this = this;
        $('#btn-save').on('click', function () {
            _this.save();
        });

        $('#btn-update').on('click', function () {
            _this.update();
        });

        $('#btn-delete').on('click', function () {
            _this.delete();
        });
    },
    save : function () {
        var data = {
            title: $('#title').val(),
            author: $('#author').val(),
            content: $('#content').val()
        };

        $.ajax({
            type: 'POST',
            url: '/api/v1/posts',
            dataType: 'json',
            contentType:'application/json; charset=utf-8',
            data: JSON.stringify(data)
        }).done(function() {
            alert('글이 등록되었습니다.');
            window.location.href = '/';
        }).fail(function (error) {
            alert(JSON.stringify(error));
        });
    },
    update : function () {
        var data = {
            title: $('#title').val(),
            content: $('#content').val()
        };

        var id = $('#id').val();

        $.ajax({
            type: 'PUT',
            url: '/api/v1/posts/'+id,
            dataType: 'json',
            contentType:'application/json; charset=utf-8',
            data: JSON.stringify(data)
        }).done(function() {
            alert('글이 수정되었습니다.');
            window.location.href = '/';
        }).fail(function (error) {
            alert(JSON.stringify(error));
        });
    },
    delete : function () {
        var id = $('#id').val();

        $.ajax({
            type: 'DELETE',
            url: '/api/v1/posts/'+id,
            dataType: 'json',
            contentType:'application/json; charset=utf-8'
        }).done(function() {
            alert('글이 삭제되었습니다.');
            window.location.href = '/';
        }).fail(function (error) {
            alert(JSON.stringify(error));
        });
    }

};

main.init();

 

이제 삭제 API를 만들어 봅시다. 먼저 서비스 메소드입니다.

 

PostsService 메소드 추가

    @Transactional
    public void delete (Long id) {
        Posts posts = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다. id=" + id));

        postsRepository.delete(posts);
    }
  • postsRepository.delete(posts)
    • JpaRepository에서 이미 delete 메소드를 지원하고 있으니 이를 활용합니다.
    • 엔티티를 파라미터로 삭제할 수도 있고, deleteById 메소드를 이용하면 id로 삭제할 수도 있습니다.
    • 존재하는 Posts인지 확인을 위해 엔티티 조회 후 그대로 삭제합니다.

 

서비스에서 만든 delete 메소드를 컨트롤러가 사용하도록 코드를 추가합시다.

 

PostsApiController 메소드 추가

    @DeleteMapping("/api/v1/posts/{id}")
    public Long delete(@PathVariable Long id) {
        postsService.delete(id);
        return id;
    }

 

컨트롤러까지 생성되었으니 한번 테스트 해봅시다. 실제로 테스트해보면 정상적으로 돌아가는 것을 확인할 수 있습니다.

 

 

 

 

 

출처

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

 

웹 서비스를 개발하고 운영하다 보면 피할 수 없는 문제가 데이터베이스를 다루는 일 입니다. 이동욱님이 스프링을 배울 때는 MyBatis와 같은 SQL 매퍼를 이용해서 데이터베이스의 쿼리를 작성했다고 합니다. 그러다 보니 실제로 개발하는 시간보다 SQL을 다루는 시간이 더 많았습니다. 그러던 중 RDBMS를 이용하는 프로젝트에서 객체지향 프로그래밍을 할 수 있는 JPA라는 자바 표준 ORM기술을 만나게 되었다고 합니다.

 

 


JPA 소개

현대의 웹 애플리케이션에서 RDB는 빠질 수 없는 요소입니다. 그러다 보니 객체를 관계형 데이터베이스에서 관리하는 것이 무엇보다중요합니다. 관계형 데이터베이스가 SQL만 인식할 수 있기 때문에 현업 프로젝트 대부분이 애플리케이션 코드보다 SQL로 가득하게 됬습니다. SQL로만 가능하다 보니 각 테이블마다 기본적인 CRUD(Create, Read, Update, Delete) SQL을 매번 생성해야 합니다. 

 이러한 반복적인 SQL 코드는 실제 현업에서 수십, 수백 개의 테이블이 존재하고, 이런 테이블의 몇 배의 SQL을 만들고 유지보수해야합니다. 이러한 단순 반복 작업 문제 외에도 문제가 한 가지 더 있습니다. 바로 패러다임 불일치 문제입니다. RDB는 어떻게 데이터를 저장할지에 초점이 맞춰진 기술입니다.

 객체지향 프로그래밍의 특징인 추상화, 캡슐화, 다형성, 상속화 등이 있습니다. RDB로 객체지향을 표현할 수 있을까요?? 결론부터 말하자면 쉽지 않습니다. 이유는 사상부터 다른 시작점에서 출발했기 때문입니다. RDB와 객체지향 프로그래밍 언어의 패러다임이 서로 다른데, 객체를 데이터베이스에 저장하려고 하니 여러 문제가 발생하는 것입니다. 이를 패러다임 불일치라고 합니다.

 

예를 들어 봅시다. 객체지향 프로그래밍이 부모가 되는 객체를 가져오려면 어떻게 해야될까요?

User user = findUser();
Group group = user.getGroup();

코드를 보고 누구나 명확하게 User와 Group은 부모-자식 관계임을 알 수 있습니다.  하지만 여기에 데이터베이스가 추가되면 아래와 같이 변경됩니다.

User user = userDao.findUser();
Group group = groupDao.findGroup(user.getGroupId());

User 따로 Group 따로 조회하게 됩니다. User와 Group이 어떤 관계인지 알 수 있을까요?? 상속, 1:N 등 다양한 객체 모델링을 데이터베이스로는 구현할 수 없습니다. 그러다 보니 웹 애플리케이션 개발은 점점 데이터베이스 모델링에만 집중하게 됩니다. JPA는 이런 문제점을 해결하기 위해 등장하게 됩니다.

 

서로 지향하는 바가 다른 2개 영역을 중간에서 패러다임 일치를 시켜주기 위한 기술입니다. 즉, 개발자는 객체지향적으로 프로그래밍하고, JPA가 이를 RDB에 맞게 SQL을 대신 생성해서 실행합니다. 개발자는 항상 객체지향적으로 코드를 표현할 수 있으니 더는 SQL에 종속적인 개발을 하지 않아도 됩니다.

 

 

Spring Data JPA

JPA는 인터페이스로서 자바 표준명세서입니다. 따라서 JPA를 사용하기 위해서는 구현체가 필요합니다. 대표적으로 Hibernate, Eclipse Link 등이 있습니다. 하지만 Spring에서 JPA를 사용할 때는 이 구현체들을 직접 다루진 않습니다. 구현체들을 좀 더 쉽게 사용하고자 추상화시킨 Spring Data JPA라는 모듈을 이용하여 JPA를 다룹니다. 이들의 관계를 보면 아래와 같습니다.

  • JPA  <-  Hibernate  <-  Spring Data JPA

Hibernate를 쓰는 것과 Spring Data JPA를 쓰는 것 사이에는 큰 차이가 없습니다. 그럼에도 스프링 진영에서는 Spring Data JPA를 개발했고, 이를 권장하고 있습니다. 이렇게 한 단계 더 감싸놓은 Spring Data JPA가 등장한 이유는 크게 두가지가 있습니다.

  • 구현체 교체의 용이성
  • 저장소 교체의 용이성

먼저 구현체 교체의 용이성이란 Hibernate 외에 다른 구현체로 쉽게 교체하기 위함입니다. Hibernate가 언젠간 수명이 다해서 새로운 JPA 구현체가 대세로 떠오를 때, Spring Data JPA를 쓰는 중이라면 쉽게 교체할 수 있습니다. 실제로 자바의 Redis 클라이언트가 Jedis에서 Lettuce로 대세가 넘어갈 때 Spring Data Redis를 사용한 사람들은 아주 쉽게 교체를 했다고 합니다.

다음으로 저장소 교체의 용이성이란 RDB 외에 다른 저장소로 쉽게 교체하기 위함입니다. 서비스 초기에는 RDB로 모든 기능을 처리했지만, 점점 트래픽이 많아져 RDB로는 감당이 안 될 때가 올 수도 있습니다. 이때 MongoDB로 교체가 필요하다면 개발자는 Spring Data JPA에서 Spring Data MongoDB로 의존성만 교체하면 됩니다.

 

이는 Spring Data 하위 프로젝트들은 기본적인 CRUD의 인터페이스가 같기 때문입니다. 즉, Spring Data 하위 프로젝트들은 save(), findAll(), findOne() 등을 인터페이스로 갖고 있습니다. 그러다보니 저장소가 교체되어도 기본적인 기능은 변결할 것이 없습니다. 이러한 장점들로 인해 Hibernate를 직접 쓰기보다는 Spring 팀에서 계속해서 Spring Data 프로젝트를 권하고 있습니다.

 

 

실무에서 JPA

실무에서 JPA를 사용하지 못하는 가장 큰 이유로 높은 러닝 커브를 이야기합니다. 이점은 이동욱님도 동의한다고 합니다. JPA를 잘 쓰려면 객체지향 프로그래밍과 관계형 데이터베이스를 둘다 이해해야 합니다.

하지만 그만큼 JPA를 사용해서 얻는 보상은 큽니다. 가장 먼저 CRUD 쿼리를 직접 작성할 필요가 없습니다. 또한, 부모-자식 관계 표현, 1:N관계 표현, 상태와 행위를 한 곳에서 관리하는 등 객체지향 프로그래밍을 쉽게 할 수 있습니다.

속도 이슈에는 없을까 하는 걱정이 있을거라 생각합니다. 이동욱님은 포털 서비스와 이커머스에서 모두 JPA 기술들을 사용해보면서 높은 트래픽과 대용량 데이터 처리를 경험해보았지만 JPA에서는 여러 성능 이슈 해결 책들을 이미 준비해놓은 상태이기 때문에 이를 잘 활용하면 네이티브 쿼리만큼의 퍼포먼스를 낼 수 있다고 합니다. 

 

 

요구사항 분석

게시판의 요구사항은 아래와 같습니다.

  • 게시판 기능
    • 게시글 조회
    • 게시글 등록
    • 게시글 수정
    • 게시글 삭제
  • 회원 기능
    • 구글 / 네이버로 로그인
    • 로그인한 사용자 글 작성 권한
    • 본인 작성 글에 대한 권한 관리

어떤 웹 애플리케이션을 만들더라도 기반이 될 수 있게 보편적이지만 필수 기능들은 모두 구현하게 됩니다.

 

 

 

프로젝트에 Spring Data JPA 적용하기

 

먼저 build.gradle에 아래와 같이 의존성들을 등록합니다.

    // jpa
    implementation('org.springframework.boot:spring-boot-starter-data-jpa')
    implementation('com.h2database:h2')
  • spring-boot-starter-data-jpa
    • 스프링 부트용 Spring Data JPA 추상화 라이브러리입니다.
    • 스프링 부트 버전에 맞춰 자동으로 JPA관련 라이브러리들의 버전을 관리해줍니다.
  • h2
    • 인메모리 관계형 데이터베이스입니다.
    • 별도의 설치가 필요없이 프로젝트 의존성만으로 관리할 수 있습니다.
    • 메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화된다는 점을 이용하여 테스트 용도로 많이 사용됩니다.
    • JPA의 테스트, 로컬 환경에서의 구동에서 사용할 예정입니다.

의존성이 등록되었다면, JPA 기능을 사용해봅시다. main패키지 하위 패키지로 domain이란 패키지를 만듭시다. 해당 패키지는 도메인을 담을 패키지입니다. 여기서 도메인이란 게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제 영역이라고 생각하시면 됩니다.

 

기존에 MyBatis와 같은 쿼리 매퍼를 사용했다면 dao 패키지를 떠올리겠지만, dao 패키지와는 조금 결이 다르다고 생각하면 됩니다. 도메인이란 용어가 조금 어색하겠지만, 과정이 진행될 때마다 어떤 이야기인지 몸으로 느낄 수 있으니 조금만 참아달라고 합니다. (도메인에 대하여 공부하고 싶다면 최범균님이 집필하신 "DDD Start"를 추천합니다.)

 

domain 패키지에 posts 패키지와 Posts클래스를 만듭니다. Posts 클래스 코드는 아래와 같습니다.

@Getter
@NoArgsConstructor
@Entity
public class Posts {

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

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

    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;

    private String author;

    @Builder
    public Posts(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }
}
  • @Entity
    • 테이블과 링크될 클래스임을 나타냅니다.
    • 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭합니다.
      Ex) SalesManager.java -> sales_manager table
  • @Id
    • 해당 테이블의 PK 필드를 나타냅니다.
  • @GeneratedValue
    • PK의 생성 규칙을 나타냅니다.
    • 스프링 부트 2.0 에서는 GenerationType.IDENTITY 옵션을 추가해야만 auto_increment가 됩니다.
    • 스프링 부트 2.0과 1.5 버전의 차이는 https://jojoldu.tistory.com/295에 정리되어있으니 참고하시길 바랍니다.
  • @Column
    • 테이블의 칼럼을 나타내며 굳이 선언하지 않더라도 해당 클래스의 필드는 모두 칼럼이됩니다.
    • 사용하는 이유는 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용합니다.
      Ex) 문자열의 경우 VARCHAR(255)가 기본값인데, 사이즈를 500으로 늘리고 싶거나, 타입을 TEXT로 변경하고싶거나 등의 경우에 사용됩니다.
  • @NoArgsConstructor
    • 롬복의 기능으로 기본 생성자를 자동 추가해주는 어노테이션입니다.
    • 위의 코드에서는 public Posts() {}가 자동생성된다고 보면됩니다.
  • @Builder
    • 롬복의 기능으로 해당 클래스의 빌더 패턴 클래스를 생성합니다.
    • 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함됩니다.

Posts 클래스는 실제 DB의 테이블과 매칭될 클래스이며 보통 Entity 클래스라고 불리기도 합니다. JPA를 사용하시면 DB 데이터에 작업할 경우 실제 쿼리를 날리기보다는, 이 Entity 클래스의 수정을 통해 작업합니다. 서비스 초기 구축단계 에선 테이블 설계(여기선 Entity 설계)가 빈번하게 변경되는데, 이 때 롬복의 어노테이션들은 코드 변경량을 최소화시켜 주기 때문에 적극적으로 사용합니다.

 

@Setter를 두지 않는 이유는 해당 클래스의 인스턴스 값들이 언제 어디서 변해야하는지 코드상으로 명확하게 구분할 수 없기 때문입니다. 따라서, 필드 값 변경이 필요한 경우 파라미터로 set을 해주는 것이 아닌 목적과 의도를 나타낼 수 있는 메소드를 추가하여 사용합니다. 

예를들어, 취소 메소드를 만든다고 가정해봅시다.

잘못된 코드

public class Order {
    public void setStatus(boolean status){
        this.status = status
    }
}

public void 주문서비스의_취소이벤트() {
    order.setStatus(false);
}

올바른 코드

public class Order {
    public void setStatus(){
        this.status = false;
    }
}

public void 주문서비스의_취소이벤트() {
    order.setStatus();
}

 

그렇다면 의문점이 생깁니다. Setter가 없는 상황에서 어떻게 값을 채워 DB에 삽입해야 할까요? 

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

이 책에서는 생성자 대신에 @Builder를 통해 제공되는 빌더 클래스를 사용합니다. 생성자나 빌더나 생성 시점에 값을 채워주는 역할은 똑같습니다. 다만, 생성자의 경우 지금 채워야 할 필드가 무엇인지 명확히 지정할 수가 없습니다.

예를들어,  new Example(a, b)를 new Example(b, a)처럼 a와 b의 위치를 변경해도 코드를 싱행하기 전까지는 문제를 찾을 수가 없습니다. 하지만, 빌더를 사용하게 되면 아래와 같이 어느 필드에 어떤 값을 채워야 할지 명확하게 인지할 수 있습니다.

Example.builder()
    .a(a)
    .b(b)
    .build();

 

Posts 클래스 생성이 끝났다면, Posts 클래스로 Database를 접근하게 해줄 JpaRepository를 생성합니다. 패키지는 Posts 클래스와 같습니다.

public interface PostsRepository extends JpaRepository<Posts, Long> {

    @Query("SELECT p FROM Posts p ORDER BY p.id DESC")
    List<Posts> findAllDesc();
}

보통 Dao라고 불리는 DB Layer 접근자 입니다. JPA에선 Repository라고 부르며 인터페이스로 생성합니다. 단순히 인터페이스를 생성 후, JpaRepository<Entity 클래스, PK 타입>을 상속하면 기본적인 CURD 메소드가 자동으로 생성됩니다.

@Repository를 추가할 필요도 없습니다. 여기서 주의할 점은 Entity 클래스와 기본 Entity Repository는 함께 위치해야 하는 점 입니다. 둘은 아주 밀접한 관계이고, Entity 클래스는 기본 Repository 없이는 제대로 역할을 할 수가 없습니다. 

 

모두 작성되었다면 간단하게 테스트 코드로 기능을 검증해 보겠습니다.

 

 

 

Spring Data JPA 테스트 코드 작성하기

test디렉토리에 domain.posts 패키지를 생성하고, 테스트 클래스는 PostsRepositoryTest란 이름으로 생성합니다. 해당 클래스에서 save, findAll과 같은 기능을 테스트합니다. 코드는 아래와 같습니다.

@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {

    @Autowired
    PostsRepository postsRepository;

    @After
    public void cleanup() {
        postsRepository.deleteAll();
    }

    @Test
    public void 게시글저장_불러오기() {
        //given
        String title = "테스트 게시글";
        String content = "테스트 본문";

        postsRepository.save(Posts.builder()
               .title(title)
               .content(content)
               .author("qazyj@naver.com")
               .build());

        //when
        List<Posts> postsList = postsRepository.findAll();

        //then
        Posts posts = postsList.get(0);
        assertThat(posts.getTitle()).isEqualTo(title);
        assertThat(posts.getContent()).isEqualTo(content);
    }
}
  • @After
    • Junit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정
    • 보통은 배포 전 전체 테스트를 수행할 때 테스트 간 침범을 막기위해 사용합니다.
    • 여러 테스트가 동시에 수행되면 테스트용 데이터베이스인 H2에 데이터가 그대로 남아있어 다음 테스트 실행 시 테스트가 실패할 수 있습니다.
  • postsRepository.save
    • 테이블 posts에 insert/update 쿼리를 실행합니다.
    • id 값이 있다면 update, 없다면 Insert쿼리가 실행됩니다.
  • postsRepository.findAll
    • 테이블에 posts에 있는 모든 데이터를 조회해오는 메소드입니다.

별다른 설정 없이 @SpringBootTest를 사용할 경우 H2 데이터베이스를 자동으로 실행해 줍니다. 이 테스트 역시 실행할 경우 H2가 자동으로 실행됩니다. 테스트를 실행해보면 정상적으로 돌아가는 것을 확인할 수 있습니다.

 

여기서 한 가지 궁금한 것이 있습니다. 실제로 실행된 쿼리는 어떤 형태일까?입니다.

application.properties에 아래 한줄의 코드를 적으면 확인할 수 있습니다.

spring.jpa.show_sql = true

저는 application.properties가 생성되어있지 않아서 생성해주었습니다. 위치는 main의 resources 폴더 안에 file로 만들면됩니다.

그 후 위의 코드를 추가해준 뒤 실행시켜보았습니다.

Hibernate: drop table posts if exists
Hibernate: create table posts (id bigint generated by default as identity, author varchar(255), content TEXT not null, title varchar(500) not null, primary key (id))

Hibernate: insert into posts (id, author, content, title) values (null, ?, ?, ?)
Hibernate: select posts0_.id as id1_0_, posts0_.author as author2_0_, posts0_.content as content3_0_, posts0_.title as title4_0_ from posts posts0_
Hibernate: select posts0_.id as id1_0_, posts0_.author as author2_0_, posts0_.content as content3_0_, posts0_.title as title4_0_ from posts posts0_
Hibernate: delete from posts where id=?

그 후 출력된 것을 자세히보면 위처럼 쿼리 로그가 출력이 되어 나옵니다. 

 

출력되는 로그를 MySQL 버전으로 바꿀 수도 있습니다. application.properties에 아래의 코드를 추가하면 됩니다.

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect

그 후 출력하면 아래와 같이 나옵니다.

Hibernate: create table posts (id bigint not null auto_increment, author varchar(255), content TEXT not null, title varchar(500) not null, primary key (id)) engine=InnoDB

 

 

 

등록/수정/조회 API 만들기

 

API를 만들기 위해 총 3개의 클래스가 필요합니다.

  • Request 데이터를 받을 Dto
  • API 요청을 받을 Controller
  • 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service

Service에서는 비즈니스 로직을 처리하는 것이 아닌 트랜잭션, 도메인 간 순서 보장의 역할만 합니다. 그렇다면 비즈니스 로직은 누가 처리할까요?? 그림을 봅시다.

spring 웹 계층

간단하게 각 영역을 소개하자면 아래와 같습니다.

  • Web Layer
    • 흔히 사용하는 컨트롤러(@Controller), JSP/Freemarker 등의 뷰 템플릿 영역입니다.
    • 이외에도 필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice) 등 외부 요청과 응답에 대한 전반적인 영역을 이야기합니다.
  • Service Layer
    • @Service에 사용되는 서비스 영역입니다.
    • 일반적으로 Controller와 Dao의 중간 영역에서 사용됩니다.
    • @Transactional이 사용되어야하는 영역이기도 합니다.
  • Repository Layer
    • Database와 같이 데이터 저장소에 접근하는 영역입니다.
    • 기존에 개발하셨던 분들이라면 Dao(Database Access Object) 영역으로 이해하시면 쉬울 것입니다.
  • Dtos
    • Dto(Data tranfer Ojbect)는 계층 간에 데이터 교환을 위한 객체를 이야기하며 Dtos는 이들의 영역을 얘기합니다.
    • 예를들어, 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 이들을 이야기합니다.
  • Domain Model
    • 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것을 도메인 모델이라고 합니다.
    • 이를테면 택시 앱이라고 하면 배차 ,탑승, 요금 등이 모두 도메인이 될 수 있습니다.
    • @Entity를 사용해보신 분들은 @Entity가 사용된 영역 역시 도메인 모델이라고 이해해주시면 됩니다.
    • 다만, 무조건 데이터베이스의 테이블과 관계가 있어야만 하는 것은 아닙니다.
    • VO처럼 값 객체들도 이 영역에 해당하기 때문입니다.

Web, Service, Repository, Dto, Domain 이 5가지 레이어에서 비지니스 처리를 담당해야 할 곳은 어디일까요? 바로 Domain입니다. 기존에 서비스로 처리하던 방식을 트랜잭션 스크립트라고 합니다. 주문 취소 로직을 작성한다면 아래와 같습니다.

@Transactional
public Order cancelOrder(int orderId){
    1) 데이터베이스로부터 주문정보 (Orders), 결제정보(Biling), 배송정보(Delivery) 조회
    2) 배송 취소를 해야하는지 확인
    3) if(배송중이라면) {배송 취소로 변경}
    4) 각 테이블에 취소 상태 Update
}

모든 로직이 서비스 클래스 내부에서 처리됩니다. 그러다 보니 서비스 계층이 무의미하며, 객체란 단순히 데이터 덩어리 역할만 하게 됩니다.

 

 

order, biling, delivery가 각자 본인의 취소 이벤트 처리를 하며, 서비스 메소드는 트랜잭션과 모데인 간의 순서만 보장해 줍니다. 이 책에서는 계속 이렇게 도메인 모델을 다루고 코드를 작성합니다.

 

그렇다면 등록, 수정, 삭제 기능을 만들어 보겠습니다. PostsApiController를 web 패키지에, PostsSaveRequestDto를 web.dto 패키지에, PostsService를 service.posts 패키지에 생성합니다. 코드는 아래와 같습니다.

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    private final PostsService postsService;

    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto) {
        return postsService.save(requestDto);
    }

}

 

@RequiredArgsConstructor
@Service
public class PostsService {
    private final PostsRepository postsRepository;

    @Transactional
    public Long save(PostsSaveRequestDto requestDto) {
        return postsRepository.save(requestDto.toEntity()).getId();
    }
}

스프링을 어느 정도 써보셨던 분들이라면 Controller와 Service에서 @Autowired가 없는 것이 어색하게 느껴집니다. 스프링에선 Bean을 주입받는 방식들이 아래와 같습니다.

  • @Autowired
  • setter
  • 생성자

이 중 가장 권장하는 방식이 생성자로 주입받는 방식입니다.(@Autowired는 권장하지 않습니다.) 즉, 생성자로 Bean 객체를 받도록 하면 @Autowired와 동일한 효과를 볼 수 있다는 것 입니다. 위의 코드에서 생성자는 @RequiredArgsConstructor에서 해줍니다. 

생성자를 직접 안쓰고 롬복 어노테이션을 사용한 이유는 간단합니다. 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위함입니다.

 

이제 Controller와 Service에서 사용할 Dto 클래스를 생성합니다.

@Getter
@NoArgsConstructor
public class PostsSaveRequestDto {
    private String title;
    private String content;
    private String author;

    @Builder
    public PostsSaveRequestDto(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }

    public Posts toEntity() {
        return Posts.builder()
                .title(title)
                .content(content)
                .author(author)
                .build();
    }

}

여기서 Entity 클래스와 거의 유사한 형태임에도 Dto 클래스를 추가로 생성했습니다. 하지만, 절대로 Entity 클래스를 Request/Response 클래스로 사용해서는 안 됩니다. Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스입니다. Entity 클래스 기준으로 테이블이 생성되고, 스키마가 변경됩니다. 화면 변경은 아주 사소한 변경인데, 이를 위해 테이블과 연결된 Entity 클래스를 변경하는 것은 너무 큰 변경입니다.

수 많은 서비스 클래스나 비즈니스 로직들이 Entity 클래스를 기준으로 동작합니다. Entity 클래스가 변경되면 여러 클래스에 영향을 끼치지만, Request와 Response용 Dto는 View를 위한 클래스라 정말 자주 변경이 필요합니다.

View Layer와 DB Layer의 역할 분리를 철저하게 하는게 좋습니다. 실제로 Controller에서 결과값으로 여러 테이블을 조인해서 줘야 할 경우가 빈번하므로 Entity 클래스만으로 표현하기가 어려운 경우가 많습니다.

꼭 Entity 클래스와 Controller에서 쓸 Dto는 분리해서 사용해야 합니다. 이제 테스트 코드를 작성하여 검증해 보겠습니다. 테스트 패키지 중 web 패키지에 PostsApiControllerTest를 생성합니다. 코드는 아래와 같습니다.

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private PostsRepository postsRepository;

    @After
    public void tearDown() throws Exception {
        postsRepository.deleteAll();
    }

    @Test
    public void Posts_등록된다() throws Exception {
        //given
        String title = "title";
        String content = "content";
        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author("author")
                .build();

        String url = "http://localhost:" + port + "/api/v1/posts";

        //when
        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);

        //then
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isGreaterThan(0L);
        
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }
}

Api Controller를 테스트하는데 HelloController와 달리 @WebMvcTest를 사용하지 않았습니다. @WebMvcTEst의 경우 JPA 기능이 작동하지 않기 때문인데, Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화 되니 지금 같이 JPA 기능까지 한번에 테스트할 때는 @SpringBootTest와 TestRestTemplate를 사용하면 됩니다.

 

수정/조회 기능도 빠르게 만들어 보겠습니다.

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    ...
    @PutMapping("/api/v1/posts/{id}")
    public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
        return postsService.update(id, requestDto);
    }

    @GetMapping("/api/v1/posts/{id}")
    public PostsResponseDto findById(@PathVariable Long id) {
        return postsService.findById(id);
    }

}
@Getter
public class PostsResponseDto {

    private Long id;
    private String title;
    private String content;
    private String author;

    public PostsResponseDto(Posts entity) {
        this.id = entity.getId();
        this.title = entity.getTitle();
        this.content = entity.getContent();
        this.author = entity.getAuthor();
    }
}
@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {
    private String title;
    private String content;

    @Builder
    public PostsUpdateRequestDto(String title, String content) {
        this.title = title;
        this.content = content;
    }
}
@Getter
@NoArgsConstructor
@Entity
public class Posts {

    ...
    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }
}
@RequiredArgsConstructor
@Service
public class PostsService {

   ...
   @Transactional
    public Long update(Long id, PostsUpdateRequestDto requestDto) {
        Posts posts = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다. id=" + id));

        posts.update(requestDto.getTitle(), requestDto.getContent());

        return id;
    }

    @Transactional(readOnly = true)
    public PostsResponseDto findById(Long id) {
        Posts entity = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다. id=" + id));

        return new PostsResponseDto(entity);
    }
}

여기서 신기한 것이 있습니다. update 기능에서 데이터베이스 쿼리를 날리는 부분이 없습니다. 이게 가능한 이유는 JPA의 영속성 컨텍스트 때문입니다. 

영속성 컨텍스트란, 엔티티를 영구 저장하는 환경입니다. JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈립니다.

JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태입니다. 

이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영합니다. 즉, Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없는 것입니다. 이 개념을 더티 체킹이라고 합니다. (더티 체킹에 대한 자세한 내용 - https://jojoldu.tistory.com/415) 

 

그럼 테스트 코드를 작성하여 업데이트한 코드가 정상작동 하는지 테스트해보겠습니다.

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

    ...  

    @Test
    public void Posts_수정된다() throws Exception {
        //given
        Posts savedPosts = postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());

        Long updateId = savedPosts.getId();
        String expectedTitle = "title2";
        String expectedContent = "content2";

        PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
                .title(expectedTitle)
                .content(expectedContent)
                .build();

        String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;
        HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);

        //when
        ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class);

        //then
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isGreaterThan(0L);
        
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
        assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
    }
}

 

돌려보면 정상 작동하는 것을 확인할수 있습니다.

 

JPA와 테스트 코드에 대해 진행해보았으니, 조회 기능은 실제로 톰캣을 실행해서 확인해보겠습니다.

직접 접근하려면 웹 콘솔을 사용해야만 합니다. application.properties에 아래의 코드를 추가해줍시다.

spring.h2.console.enabled=true

추가 한 뒤 Application 클래스의 main 메소드를 실행합니다. 정상적으로 실행됐다면 톰캣이 8080 포트로 실행됐습니다. http://localhost:8080/h2-console로 접속하면 아래와 같이 웹 콘솔 화면이 등장합니다.

여기서 JDBC URL의 값이 jdbc:h2:mem:testdb로 되어있지 않다면 작성해줍니다. 그 후 connect 버튼을 클릭하면 현재 프로젝트의 H2를 관리할 수 있는 관리 페이지로 이동합니다. POSTS 테이블이 있어야 정상적으로 동작한 것 입니다.

간단한 쿼리를 실행해봅시다.

SELECT * FROM POSTS

위의 POSTS 테이블을 조회하면 아래와 같이 빈 테이블이 뜹니다.

테이블에 값을 추가한 뒤, 실행해 봅시다.

insert into posts (author, content, title) values ('author', 'content', 'title');

정상적으로 값이 잘 들어가는 것을 볼 수 있습니다.

 

등록된 데이터를 확인 후 API를 요청해 보겠습니다. http://localhosts:8080/api/v1/posts/1 을 입력해 API 조회 기능을 테스트해봅니다.

방금 입력한 데이터가 정상적으로 보이는 것을 볼 수 있습니다. 기본적인 등록/수정/조회 기능을 모두 만들고 테스트해 보았습니다. 

 

 

 

JPA Auditing으로 생성시간/수정시간 자동화하기

보통 엔티티에는 해당 데이터의 생성시간과 수정시간을 포함합니다. 언제 만들어졌는지, 언제 수정되었는지 등은 차후 유지보수에 있어서 굉장히 중요한 정보이기 때문입니다. 그렇다 보니 매번 DB에 삽입하기 전, 갱신하기 전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어가게 됩니다.

이런 단순하고 반복적인 코드가 모든 테이블과 서비스 메소드에 포함되어야 한다고 생각하면 어마어마하게 귀찮고 코드가 지저분해집니다. 그래서 이 문제를 해결하고자 JPA Auditing을 사용하겠습니다.

 

 

LocalDate 사용

Java8 부터 LocalDate와 LocalDateTime이 등장했습니다. 그간 Java의 기본 날짜 타입인 Date의 문제점을 제대로 고친 타입이라 Java8일 경우 무조건 써야 한다고 생각하면 됩니다.

 

domain 패키지에 BaseTimeEntity를 생성합니다. 코드는 아래와 같습니다.

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    @CreatedDate
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime modifiedDate;

}
  • @MappedSuperclass
    • JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들도 칼럼으로 인식하도록 합니다.
  • @EntityListeners(AuditingEntityListener.class)
    • BaseTimeEntity 클래스에 Auditing 기능을 포함시킵니다.
  • @CreatedDate
    • Entity가 생성되어 저장될 때 시간이 자동 저장됩니다.
  • @LastModifiedDate
    • 조회한 Entity의 값을 변경할 때 시간이 자동 저장됩니다.

BaseTimeEntity 클래스는 모든 Entity의 상위 클래스가 되어 Entity들의 createdDate, modifiedDate를 자동으로 관리하는 역할입니다.

 

그다음 Posts 클래스가 BaseTimeEntity를 상속받도록 변경합니다.

public class Posts extends BaseTimeEntity {
    ...
}

 

마지막으로 JPA Auditing 어노테이션들을 모두 활성화할 수 있도록 Application 클래스에 활성화 어노테이션 하나를 추가해줍니다.

@EnableJpaAuditing          // JPA Auditing 활성화
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

 

 

JPA Auditing 테스트 코드 작성하기

PostsRepositoryTest 클래스에 테스트 메소드를 하나 더 추가해줍니다.

    @Test
    public void BaseTimeEntity_등록() {
        //given
        LocalDateTime now = LocalDateTime.of(2019, 6, 4, 0, 0, 0);
        postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());
        //when
        List<Posts> postsList = postsRepository.findAll();

        //then
        Posts posts = postsList.get(0);

        System.out.println(">>>>>>>>> createDate=" + posts.getCreatedDate() + ", modifiedDate=" + posts.getModifiedDate());

        assertThat(posts.getCreatedDate()).isAfter(now);
        assertThat(posts.getModifiedDate()).isAfter(now);
    }

테스트 코드를 수행해보면 실제 시간이 잘 저장된 것을 확인할 수 있습니다.

 

 

 

 

 

출처

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

 

책과 김영한님의 inflern에서의 강의 모두 테스트 코드의 작성을 강조합니다. 모든 프로젝트를 작성할 때 테스트 코드는 선택이 아닌 필수인 것 같습니다. 저는 스프링 공부를 해보며 Junit을 통해 테스트 코드를 작성해봤는데, 실제 무거운 프로그램을 돌리며 테스트를 하는 시간을 줄일 수 있어서 편했던 기억이 있습니다. 

 

이번 시간에는 앞으로 진행할 프로젝트에서 가장 중요한 테스트 코드 작성의 기본을 배워보고자 합니다.

 

먼저 한가지 짚고 갈 것은 TDD와 단위 테스트는 다른 이야기 입니다. TDD는 테스트가 주도하는 개발을 이야기하고, 테스트 코드는 먼저 작성하는 것 부터 시작합니다.

 

TDD

레드 그린 사이클

  • 항상 실패하는 테스트를 먼저 작성하고 (Red)
  • 테스트가 통과하는 프로덕션 코드를 작성하고 (Green)
  • 테스트가 통과하면 프로덕션 코드를 리팩토링합니다. (Refactor)

반면, 단위 테스트는 TDD의 첫 번째 단계인 기능 단위의 테스트 코드를 작성하는 것을 의미합니다. 즉, 순수하게 테스트 코드 작성하는 것을 이야기 합니다. 제가 배울 것은 TDD가 아닌 단위 테스트 코드를 배웁니다. 이번 기회를 통해 테스트 코드를 먼저 배우고 따로 TDD를 공부해야봐야할 것 같습니다.

 

그렇다면 테스트 코드는 왜 작성해야 할까요? 

 

위키피디아에서는 아래와 같이 이야기합니다.

  • 단위 테스트는 개발단계 초기에 문제를 발견하게 도와줍니다.
  • 단위 테스트는 개발자가 나중에 코드를 리팩토링하거나 라이브러리 업그레이드 등에서 기존 기능이 올바르게 작동하는지 확인할 수 있습니다. (예, 회귀 테스트)
  • 단위 테스트는 기능에 대한 불확실성을 감소시킬 수 있습니다.
  • 단위 테스트는 시스템에 대한 실제 문서를 제공합니다. 즉, 단위 테스트 자체가 문서로 사용할 수 있습니다.

여기에 책을 쓰신 이동욱님은 추가적으로 가장 먼저 빠른 피드백이 있다고 말하였습니다. 단위 테스트를 배우기 전에 진행한 개발 방식은 아래와 같습니다.

  1. 코드 작성
  2. 프로그램(Tomcat) 실행
  3. Postman과 같은 API 테스트 도구로 HTTP 요청
  4. 요청 결과를 System.out.println()으로 눈으로 검증
  5. 결과가 다르면 다시 프로그램(Tomcat)을 중지한 뒤 코드 수정

여기서 2~5는 매번 코드를 수정할 때마다 반복해야되는 부분입니다. 톰캣을 재시작하는 시간은 1분 이상까지 소요되기도하며, 수십번씩 수정해야하는 상황에서 아무런 코드 작업 없이 1시간 이상 소요되기도 했다고 합니다.

 

테스트 코드를 작성하면 좋은 첫번째는 위의 문제가 해결됩니다.

두번째는 System.out.println()으로 직접 보지않고, 자동검증이 가능합니다. (Junit을 통해 테스트가 정상적으로 완료되면 프로그램 테스트는 성공적으로 완료됩니다.)

세번째는 개발자가 만든 기능을 안전하게 보호해줍니다. 예를들어, B라는 기능이 추가되어 테스트를 한다고 가정해봅시다. B 기능이 잘 되어 오픈했더니 기존에 잘되던 A 기능에 문제가 생긴 것을 발견합니다. 이런 문제는 규모가 큰 서비스에서는 빈번하게 발생하는 일이라고 합니다. 하나의 기능을 추가할 때마다 너무나 많은 자원이 들기 때문에 서비스의 모든 기능을 테스트할 수 는 없습니다.

 이렇게 새로운 기능이 추가될 때, 기존 기능이 잘 작동되는 것을 보장해주는 것이 테스트 코드입니다. A라는 기존 기능에 기본 기능을 비롯해 여러 경우를 모두 테스트 코드로 구현해 놓았다면 테스트 코드를 수행만하면 문제를 조기에 찾을 수 있습니다.

 

서비스 기업에서는 특히나 강조되고 있어 이동욱님은 100% 익혀야 할 기술이자 습관이라고 합니다.

 

대표적인 테스트 프레임워크 xUnit

  • JUnit - JAVA
  • DBUnit - DB
  • CppUnit - C++
  • NUnit - .net

이 중에서 저는 JUnit을 앞으로 사용할 것 입니다. 또한, 많은 회사에서 사용중인 JUnit4로 작성하도록 하겠습니다.

 

 

 

Hello Controller 테스트 코드 작성하기

 

일반적으로 패키지명은 웹 사이트 주소의 역순으로 한다고 합니다. 저는 com.qazyj.book.springboot라고 패키지명을 생성(main, test)해준 뒤, application이라는 클래스를 패키지안에 생성해주었습니다. 클래스 안에 psvm(public static void main)을 쳐서 자동완성 해준 뒤, @SpringBootApplication 어노테이션을 추가해주었습니다. 코드는 아래와 같습니다.

package com.qazyj.book.springboot;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
  • @SpringBootApplication : 스프링 부트의 자동 설정, 스프링 Bean 읽기와 생성을 모두 자동으로 설정됩니다. 특히, 해당 어노테이션이 있는 위치부터 설정을 읽어가기 때문에 해당 어노테이션으로 선언된 클래스는 항상 프로젝트 최상단에 위치해야 합니다.

해당 psvm 메소드안에 실행하는 SpringApplication.run으로 인해 내장 WAS (Web Application Server, 웹 애플리케이션 서버)를 실행합니다. 내장 WAS란 별도의 외부 WAS를 두지 않고 애플리케이션을 실행할 때 내부에서 WAS를 실행하는 것을 말합니다. 이렇게 되면 항상 서버에 톰캣을 설치할 필요가 없게 되고, 스프링 부트로 만들어진 Jar 파일로 실행하면 됩니다. 스프링 부트는 내장 WAS를 사용하는 것을 권장하고 있습니다. 이유는 "언제 어디서나 같은 환경에서 스프링 부트를 배포"할 수 있기 때문입니다.

 

이제 테스트를 위한 Controller를 만들어 보겠습니다. 

com.qazyj.book.springboot에 하위 패키지로 web(컨트롤러와 관련된 클래스들을 넣을 패키지)을 생성한 뒤, HelloController라는 클래스를 해당 패키지에 생성해주었습니다. 클래스안에는 간단한 API를 만들어 보겠습니다. 코드는 아래와 같습니다.

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";
    }
}
  • @RestContoller : 컨트롤러를 JSON으로 반환하는 컨트롤러로 만들어 줍니다.
  • @GetMapping : HTTP Method인 Get의 요청을 받을 수 있는 API를 만들어 줍니다.

작성한 코드를 테스트하기 위해 src/test/java에 생성한 com.qazyj.book.springboot 패키지 안에 똑같이 web패키지와 HelloControllerTest라는 클래스를 생성해 줍니다. 참고로 테스트 클래스 명은 보통 대상 클래스 이름에 Test를 붙입니다. 클래스 안에 아래와 같이 코드를 작성합니다.

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@RunWith(SpringRunner.class)
@WebMvcTest(controllers = HelloController.class)
public class HelloControllerTest {

    @Autowired
    private MockMvc mvc;

    @Test
    public void hello가_리턴된다() throws Exception {
        String hello = "hello";

        mvc.perform(get("/hello"))
                .andExpect(status().isOk())
                .andExpect(content().string(hello));
    }
}
  • @RunWith(SpringRunner.class)
    • 테스트를 진행할 때 JUnit에 내장된 실행자 외에 다른 실행자를 실행시킵니다.
    • 여기서는 SpringRunner라는 스프링 실행자를 사용합니다.
    • 즉, 스프링 부트 테스트와 JUnit 사이에 연결자 역할을 합니다.
  • @WebMvcTest
    • 여러 스프링 테스트 어노테이션 중, Web(Spring MVC)에 집중할 수 있는 어노테이션 입니다.
    • 선언할 경우 @Conroller, @ControllerAdvice 등을 사용할 수 있습니다.
    • 단, @Service, @Component, @Repository 등은 사용할 수 없습니다.
    • 여기서는 컨트롤러만 사용하기 때문에 선언했습니다.
  • @Autowired
    • 스프링이 관리하는 빈(Bean)을 주입받습니다.
  • private MockMvc mvc
    • 웹 API를 테스트할 때 사용합니다.
    • 스프링 MVC 테스트의 시작점 입니다.
    • 해당 클래스를 통해 HTTP GET, POST 등에 대한 API를 테스트할 수 있습니다.
  • mvc.perform(get("/hello"))
    • MockMvc를 통해 /hello 주소로 HTTP GET 요청을 합니다.
  • .andExpect(status().isOK())
    • mvc.perform의 결과를 검증합니다.
    • HTTP Header의 Status를 검증합니다.
    • 흔히 아는 200, 404, 500 등의 상태를 검증하는 것이라고 보면됩니다.
    • 여기선 200(OK)을 확인합니다.
  • .andExpect(content().string(hello))
    • mvc.perform의 결과를 검증합니다.
    • 응답 본문의 내용을 검증합니다.
    • Controller에서 "hello"를 리턴하기 때문에 이 값이 맞는지 검증합니다.

테스트 메소드 왼쪽의 화살표를 클릭하면 테스트를 실행할 수 있습니다. 정상적으로 초록색 체크가 뜨면 테스트 상 문제가 없는 것 입니다.

 

실제 프로젝트를 실행해보고 싶으면 main의 psvm 메서드를 실행한 뒤, 아래와 같이 웹 사이트에서 localhost:8080/hello를 입력하면 아래와 같이 hello가 출력되는 것을 확인할 수 있습니다. (port 번호는 8080이 기본이고, 8080 port번호를 사용해서 에러가 뜨는 분들은 포트 번호를 바꾸시면 됩니다.)

 

 

롬복 소개 및 설치하기

 

다음으로 해볼 것은 자바 개발자들의 필수 라이브러리 롬복입니다. 

 

롬복은 자바 개발할 때 자주 사용하는 코드인 Getter, Setter, 기본생성자, toString 등을 어노테이션으로 자동생성해줍니다.

build.gradle에 dependencies에 아래 코드를 추가해줍시다.

    implementation('org.projectlombok:lombok')

추가한 뒤, 코끼리 버튼을 눌러야 적용됩니다.

 

라이브러리를 다 받았다면 롬복 플러그인을 설치합시다. 윈도우 [컨트롤+시프트+A], 맥[커맨드+시프트+A]를 누른뒤, plugins action을 선택하여 플러그인 설치 팝업이 나오는데 marketplace탭으로 이동하여 Lombok을 검색한 뒤, install 버튼을 눌러 설치해줍시다.

 

 

 

 

HelloController 코드를 롬복으로 전환하기

 

기존 코드를 롬복으로 변경해보겠습니다. 우리는 테스트 코드가 우리의 코드를 지켜주기 때문에 쉽게 변경할 수 있습니다. 롬복으로 변경한 뒤, 문제가 생기는지 테스트 코드만 돌려보면 되기 때문입니다. 이처럼 테스트 코드는 중요합니다.

 

먼저, web 패키지에 dto 패키지를 추가한 뒤, HelloResponseDTO를 생성합니다. 코드는 아래와 같습니다.

@Getter
@RequiredArgsConstructor
public class HelloResponseDTO {
    private final String name;
    private final int amount;

}
  • @Getter
    • 선언된 모든 필드의 get 메소드를 생성해줍니다.
    • 지금 보이지는 않지만, 필드네이밍에 따라 getName, getAmount라는 메소드가 생성된 것 처럼 사용할 수 있습니다.
  • @RequiredArgsConstructor
    • 선언된 모든 final 필드가 포함된 생성자를 생성해줍니다. (final로 선언한 필드만)

 

dto에 적용된 롬복이 잘 작동하는지 간단한 테스트 코드를 작성해주겠습니다. 위와 같은 패키지를 test에도 추가한뒤 클래스명 뒤에 Test를 붙여 클래스를 생성해줍니다. 코드는 아래와 같습니다.

public class HelloResponseDtoTest {

    @Test
    public void 롬복_기능_테스트() {
        //given
        String name = "test";
        int amount = 1000;

        //when
        HelloResponseDto dto = new HelloResponseDto(name, amount);

        //then
        assertThat(dto.getName()).isEqualTo(name);
        assertThat(dto.getAmount()).isEqualTo(amount);
    }
}
  • @assertThat
    • assertj라는 테스트 검증 라이브러리의 검증 메소드입니다.
    • 검증하고 싶은 대상을 메소드 인자로 받습니다.
    • 메소드 체이닝이 지원되어 isEqualTo와 같이 메소드를 이어서 사용할 수 있습니다.
    • 현재는 두 파라미터가 같은지 확인합니다. 같다면 정상종료, 다르다면 에러가 발생합니다.
  • given, when, then
    • given : 어떤 자료형이 주어졌을 때,
    • when : 어떠한 작업을 하면
    • then : 이러한 결과가 있을 것이다.

 

여기서 Junit의 기본 assertThat이 아닌 assertj의 assertThat을 사용했습니다. assertj 역시 Junit에서 자동으로 라이브러리 등록을 해줍니다.

 

Junit과 비교하여 assertj의 장점은 아래와 같습니다.

  • CoreMatchers와 달리 추가적으로 라이브러리가 필요하지 않습니다.
    • JUnit의 assertThat을 쓰게 되면 is()와 같이 CoreMatchers 라이브러리가 필요합니다.
  • 자동완성이 좀 더 확실하게 지원됩니다.
    • IDE에서는 CoreMatchers와 같은 Matcher 라이브러리의 자동완성 지원이 약합니다.

 

돌려봤을 때, 오류가 발생하는 분들은 아래와 같이 build.gradle을 수정해주시면 됩니다. (gradle 버전에 따라 lombok 추가해주는 방식이 다른 것 같습니다.)

//기존 
implementation('org.projectlombok:lombok')
//추가 
testImplementation "org.projectlombok:lombok"
annotationProcessor('org.projectlombok:lombok')
testAnnotationProcessor('org.projectlombok:lombok')

 위와 같이 추가하면 프로그램이 test코드가 정상적으로 돌아가는 걸 확인할 수 있습니다.

 

그렇다면, HeeloController에도 새로만든 ResponseDto를 적용해보겠습니다. 코드는 아래와 같습니다.

    @GetMapping("/hello/dto")
    public HelloResponseDto helloDto(@RequestParam("name") String name,
                                     @RequestParam("amount") int amount) {
        return new HelloResponseDto(name, amount);
    }
  • @RequestParam
    • 외부에서 API로 넘긴 파라미터를 가져오는 어노테이션 입니다.
    • 여기서는 외부에서 name(@RequestParam("name"))이란 이름으로 넘긴 파라미터를 name(String name)에 저장하게 됩니다.

 

name과 amount는 API를 호출하는 곳에서 넘겨준 값들입니다. 추가된 API를 테스트 하는 코드를 HelloControllerTest에 추가합니다. 추가된 코드는 아래와 같습니다.

    @Test
    public void helloDto가_리턴된다() throws Exception {
        String name = "hello";
        int amount = 1000;

        mvc.perform(
                        get("/hello/dto")
                                .param("name", name)
                                .param("amount", String.valueOf(amount)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.name", is(name)))
                .andExpect(jsonPath("$.amount", is(amount)));
    }
  • param
    • API를 테스트할 때 사용될 요청 파라미터를 설정합니다.
    • 단, 값은 String만 허용됩니다.
    • 그래서 숫자/날짜 등의 데이터도 등록할 때는 문자열로 변경해야만 가능합니다.
  • jsonPath
    • JSON 응답값을 필드별로 검증할 수 있는 메소드입니다.
    • $를 기준으로 필드명을 명시합니다.
    • 여기서는 name과 amount를 검증하기 때문에 $.name, $.amount로 검증합니다.

 

테스트를 작동시키면 정상적으로 테스트가 통과하는 것을 볼 수 있습니다.

 

 

 

 

출처

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

 

해당 프로젝트는 이동욱 저자의 스프링 부트와 AWS로 혼자 구현하는 웹 서비스를 보고 따라하며, 스프링의 경험을 쌓기위한 프로젝트입니다.

 

프로젝트에 시작하기에 앞서 저는 IntelliJ와 JDK가 모두 설치되어 있기 때문에 설치가 안되어있으신 분들은 IntelliJ 및 JDK를 설치한다음 따라하시면 좋을 것 같습니다.

 

 

책에서의 개발 환경은 아래와 같습니다.

  • Java 8(JDK 1.8)
  • Gradle 4.8 ~ Gradle 4.10.2

저의 개발 환경은 아래와 같습니다.

  • Java 11 (JDK 11.0.12)
  • Gradle 7.1

프로젝트를 하면서 책에서와 다른 버전이라 안되는 부분들은 찾으며 해결해 나가려고 합니다.

 

github에서 책의 프로젝트 완성된 코드의 링크는 아래에 추가했습니다.

 

GitHub - jojoldu/freelec-springboot2-webservice

Contribute to jojoldu/freelec-springboot2-webservice development by creating an account on GitHub.

github.com

 

 

프로젝트 생성 버전

 

보통 스프링 부트 프로젝트는 start.spring.io 혹은 IntelliJ에서 Spring Initializr로 프로젝트를 생성하는데 해당 책에서는 필요한 기능별로 gradle에 추가하면서 진행할 예정이라고 합니다. 이렇게 하는 이유는 build.gradle의 코드가 무슨 역할을 하는지, 의존성 추가가 필요할 때 어떻게 해야하는지 알기 위함입니다.

 

 

먼저 build.gradle 맨 위에 위치할 코드입니다.

buildscript {
    ext {
        springBootVersion = '2.1.9.RELEASE'
    }
    repositories {
        mavenCentral()
        jcenter()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

위의 코드를 간략하게 설명하면

  • ext : build.gradle에서 사용하는 전역 변수를 설정하겠다는 의미입니다. 위의 코드는 springBootVersion 전역 변수를 생성하고 그 값을 2.1.7.REALEASE로 하겠다는 의미입니다. 즉, spring-boot-gradle-plugin라는 스프링 부트 그레이들 플러그인의 2.1.7.RELEASE를 의존성으로 받겠다는 의미입니다.

 

다음은 앞서 선언한 플러그인 의존성들을 적용할 것인지를 적용할 코드입니다. 위의 코드 바로 아래에 추가해주시면 됩니다.

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'	// 스프링 부트의 의존성들을 관리해주는 플러그인이기 때문에 꼭 추가

 

앞 4개의 플러그인은 자바와 스프링 부트를 사용하기 위해서는 필수 플러그인들이니 항상 추가하면 됩니다. 나머지 코드들은 아래와 같습니다.

 

repositories {
    mavenCentral()
    jcenter()
}

dependencies {
    implementation('org.springframework.boot:spring-boot-starter-web')
    testImplementation('org.springframework.boot:spring-boot-starter-test')
}

github의 링크엔 dependencies에서 compile을 사용하여 의존성을 선언하지만, 에러가 뜰 것 입니다. copile->inplementation으로 고쳐주시면 됩니다. (2019년 애플리케이션 gradle 작업할 때도 compile이 아닌 implementation을 사용했는데 gradle 측면에서 바뀐 것 같습니다.)

 

 repositories는 각종 의존성(라이브러리)들을 어떤 원격 저장소에서 받을지를 정합니다. 기본적으로 mavenCentral을 많이 사용하지만, 최근에는 라이브러리 업로드 난이도 때문에 jcenter도 많이 사용한다고 합니다.

 

 mavenCentral은 이전부터 많이 사용하는 저장소지만, 본인이 만든 라이브러리를 업로드하기 위해서는 정말 많은 과정과 설정이 필요하다고 합니다. 이러한 문제점을 해결하기 위해 jcenter가 나왔고 라이브러리 업로드를 간단하게 할 수 있게 하였습니다.

 dependencies는 프로젝트 개발에 필요한 의존성들을 선언하는 곳 입니다.

 

build.gradle

buildscript {
    ext {
        springBootVersion = '2.1.9.RELEASE'
    }
    repositories {
        mavenCentral()
        jcenter()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'         // 스프링 부트의 의존성들을 관리해주는 플러그인이기 때문에 꼭 추가해야만 합니다.

group 'org.example'
version '1.0-SNAPSHOT'

repositories {
    mavenCentral()
    jcenter()
}

dependencies {
    implementation('org.springframework.boot:spring-boot-starter-web')
    testImplementation('org.springframework.boot:spring-boot-starter-test')
}

저의 build.gradle 입니다. 코드 작성이 완료되었다면, 코끼리 버튼을 눌러 바뀐 gradle을 프로젝트에 적용해주면 됩니다.

 

저는 프로젝트를 github에 올리며, sourcetree로 업로드하는데 책과는 달라서 본인의 방식대로 Github에 올려 관리하시면 좋을 것 같습니다. github를 사용해보지 않으신 분들은 github를 공부하여서 프로젝트를 관리하시면 좋을 것 같습니다. 

 

 

 

 

 

 

출처

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

 

빈 스코프란?

지금까지 스프링 빈이 스프링 컨테이너의 시작과 함께 생성되어서 스프링 컨테이너가 종료될 때까지 유지된다고 알고 있습니다. 이것은 스프링 빈이 기본적으로 싱글톤 스코프로 생성되기 때문입니다. 스코프는 번역 그대로 빈이 존재할 수 있는 범위를 뜻합니다.

 

스프링은 다음과 같은 다양한 스코프를 지원합니다.

  • 싱글톤 : 기본 스코프, 스프링 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프입니다.
  • 프로토타입 : 스프링 컨테이너는 프로토타입 빈의 생성과 의존관계 주입까지만 관여하고 더는 관여하지 않는 매우 짧은 범위의 스코프입니다.
  • 웹 관련 스코프
    • request : 웹 요청이 들어오고 나갈때까지 유지되는 스코프입니다.
    • session : 웹 세션이 새성되고 종료될 떄 까지 유지되는 스코프입니다.
    • application : 웹의 서블릿 컨텍스트와 같은 범위로 유지되는 스코프입니다.

 

빈 스코프는 아래와 같이 지정할 수 있습니다.

컨포넌트 스캔 자동 등록

@Scope("prototype")
@Component
public class HelloBean {}

수동 등록

@Scope("prototype")
@Bean
PrototypeBean HelloBean() {
    return new HelloBean();
}

 

지금까지 싱글톤 스ㅡ코프를 계쏙 사용해보았으니, 프로토타입 스코프부터 확인해봅시다.

 

 

 

프로토타입 스코프

싱글톤 스코프의 빈을 조회하면 스프링 컨테이너는 항상 같은 인스턴스의 스프링 빈을 반환합니다. 반면에 프로토타입 스코프를 스프링 컨테이너에 조회하면 스프링 컨테이너는 항상 새로운 인스턴스를 생성해서 반환합니다.

 

싱글톤 빈 요청

클라이언트 마다 같은 요청이면 같은 객체 인스턴스의 스프링을 반환합니다.

 

프로토타입 빈 요청1

클라이언트의 요청에 따라 프로토타입 빈을 생성하고 필요한 의존관계를 주입합니다.

 

프로토타입 빈 요청2

스프링 컨테이너는 생성한 프로토타입 빈을 클라이언트에 반환하며, 이후에 같은 요청이 오면 항상 새로운 프로토타입 빈을 생성하고 반환합니다.

 

 

정리

여기서 핵심은 스프링 컨테이너는 프로토타입 빈을 생성하고, 의존관계 주입, 초기화까지만 처리한다는 것 입니다. 클라이언트에 빈을 반환하면 스프링 컨테이너는 생성된 프로토타입 빈을 관리하지 않습니다. 따라서 프로토타입 빈을 관리할 책임은 클라이언트에 있습니다. 그렇기 떄문에 @PreDestroy 같은 종료 메서드가 호출되지 않습니다.

 

코드로 확인해봅시다.

싱글톤 스코프 빈 테스트

public class SingletonTest {
    @Test
    public void singletonBeanFind() {
        AnnotationConfigApplicationContext ac = new
                AnnotationConfigApplicationContext(SingletonBean.class);
        SingletonBean singletonBean1 = ac.getBean(SingletonBean.class);
        SingletonBean singletonBean2 = ac.getBean(SingletonBean.class);
        System.out.println("singletonBean1 = " + singletonBean1);
        System.out.println("singletonBean2 = " + singletonBean2);
        assertThat(singletonBean1).isSameAs(singletonBean2);
        ac.close(); //종료 
    }

    @Scope("singleton")
    static class SingletonBean {
        
        @PostConstruct
        public void init() {
            System.out.println("SingletonBean.init");
        }
            
        @PreDestroy 
        public void destroy() {
            System.out.println("SingletonBean.destroy");
        }
    }
}

결과

SingletonBean.init
singletonBean1 = hello.core.scope.PrototypeTest$SingletonBean@54504ecd		// 아래와 같음
singletonBean2 = hello.core.scope.PrototypeTest$SingletonBean@54504ecd		// 위와 같음
org.springframework.context.annotation.AnnotationConfigApplicationContext -
Closing SingletonBean.destroy	//Destroy 호출

빈 초기화 메서드를 실행하고, 같은 인스턴스의 빈을 조회한 뒤, 종료메서드까지 정상 호출 된것을 확인할 수 있습니다.

 

 

프로토타입 스코프 빈 테스트

public class SingletonTest {
    @Test
    public void singletonBeanFind() {
        AnnotationConfigApplicationContext ac = new
                AnnotationConfigApplicationContext(PrototypeBean.class);
        System.out.println("find prototypeBean1");
        PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);

        System.out.println("find prototypeBean2");
        PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);
        
        System.out.println("prototypeBean1 = " + prototypeBean1);
        System.out.println("prototypeBean2 = " + prototypeBean2);
        assertThat(prototypeBean1).isNotSameAs(prototypeBean2);
        ac.close(); //종료

    }

    //@Scope("singleton") prototype
    @Scope("prototype")
    static class PrototypeBean {

        @PostConstruct
        public void init() {
            System.out.println("SingletonBean.init");
        }

        @PreDestroy
        public void destroy() {
            System.out.println("SingletonBean.destroy");
        }
    }
}

결과

find prototypeBean1
PrototypeBean.init
find prototypeBean2
PrototypeBean.init
prototypeBean1 = hello.core.scope.PrototypeTest$PrototypeBean@13d4992d	// 아래와 다름
prototypeBean2 = hello.core.scope.PrototypeTest$PrototypeBean@302f7971	// 위와 다름
org.springframework.context.annotation.AnnotationConfigApplicationContext -
Closing	//destroy 호출 안됌

프로토타입 빈은 서로 다른 빈을 받은 것을 볼 수 있습니다. 또한 프로토타입 빈은 스프링 컨테이너가 생성, 의존관계 주입, 초기화까지만 관여하고, 더는 관리하지 않는 것을 볼 수 있습니다. (@PreDestory 같은 종료 메서드가 실행되지 않는 모습을 볼 수 있습니다.)

 

 

프로토타입 빈 정리

  • 스프링 컨테이너에 요청마다 새로 생성됩니다.
  • 스프링 컨테이너는 생성, 의존관계 주입, 초기화까지만 관여합니다. (따라서, 종료메서드가 호출되지 않습니다.)
  • 따라서, 종료 메서드에 대한 호출도 클라이언트가 직접 해야합니다.

 

 

 

프로토타입 스코프 - 싱글톤 빈과 함께 사용시 문제

프로토타입 스포크의 빈과 싱글톤 빈을 함께 사용하면 의도한대로 잘 동작하지 않는다고 합니다. 왜 그럴까요??

 

먼저 스프링 컨테이너에 프로토타입 빈을 직접 요청하느 예제를 봅시다.

 

프로토타입 빈 직접 요청

 

스프링 컨테이너에 프로토타입 빈 직접 요청1

  1. 클라이언트 A가 프로토타입 빈을 요청 한 뒤, 받습니다.
  2. 해당 빈(x01)은 count라는 필드가 있고, 해당 필드의 값을 0 입니다.
  3. 클라이언트는 조회한 프로토타입 빈에 addCount()를 호출하여 count필드를 +1 합니다.
  4. 이렇게되면, 결과적으로 프로토타입 빈(x01)의 count는 1이됩니다.

스프링 컨테이너에 프로토타입 빈 직접 요청2

  1. 클라이언트 B가 프로토타입 빈을 요청 한 뒤, 받습니다.
  2. 마찬가지로 해당 빈(x02)의 count의 필드 값은 0 입니다.
  3. 클라이언트는 조회한 프로토타입 빈에 addCount()를 호출하여 count필드를 +1 합니다.
  4. 결과적으로 프로토타입 빈(x02)의 count는 1이됩니다.

이를 코드로 확인해봅시다.

코드

public class SingletonWithPrototypeTest1 {

    @Test
    void prototypeFind() {
        AnnotationConfigApplicationContext ac = new
                AnnotationConfigApplicationContext(PrototypeBean.class);
        PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);
        prototypeBean1.addCount();
        assertThat(prototypeBean1.getCount()).isEqualTo(1);
        PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);
        prototypeBean2.addCount();

        assertThat(prototypeBean2.getCount()).isEqualTo(1);
    }

    @Scope("prototype")
    static class PrototypeBean {

        private int count = 0;
        
        public void addCount() {
            count++;
        }

        public int getCount() {
            return count;
        }

        @PostConstruct
        public void init() {
            System.out.println("PrototypeBean.init " + this);
        }

        @PreDestroy
        public void destroy() {
            System.out.println("PrototypeBean.destroy");
        }
    }
}

 

 

싱글톤 빈에서 프로토타입 빈 사용

이번에는 clientBean이라는 싱글톤 빈이 의존관계 주입을 통해서 프로토타입 빈을 주입받아서 사용하는 예를 들어봅시다.

 

싱글톤에서 프로토타입 빈 사용1

clientBean은 싱글톤이므로, 보통 스프링 컨테이너 생성 시점에 생성되고, 의존관계 주입도 발생합니다.

1. clientBean은 의존관계 자동 주입을 사용합니다. 주입 시점에 스프링 컨테이너에 프로토타입 빈을 요청합니다.

2. 스프링 컨테이너는 프로토타입 빈을 생성해서 clientBean에 반환합니다. 프로토타입 빈의 count 필드 값은 0입니다.

이제 clientBean은 프로토타입 빈을 내부 필드에 보관합니다.

 

싱글톤에서 프로토타입 빈 사용2

클라이언트 A는 clientBean을 스프링 컨테이너에 요청해서 받습니다. 싱글톤이므로 항상 같은 clientBean이 반환됩니다.

3. 클라이언트 A는 clientBean.logic()을 호출합니다.

4. clientBean은 prototypeBean의 addCount()를 호출해서 프로토타입 빈의 count를 증가합니다. count는 현재 1입니다.

 

싱글톤에서 프로토타입 빈 사용3

클라이언트 B는 clientBean을 스프링 컨테이너에 요청해서 받습니다. 싱글톤이므로 항상 같은 clientBean이 반환됩니다.

여기서 중요한 점은, clientBean이 내부에 갖고있는 프로토타입 빈은 이미 과거에 주입이 끝난 빈 입니다. 주입 시점에만 새로 생성되지, 사용할떄마다 새로 생성되지 않습니다.

5. 클라이언트 B는 clientBean.logic()을 호출합니다.

6. clientBean은 prototypeBean의 addCount()를 호출해서 프로토타입 빈의 count를 증가합니다. 값은 2가 됩니다.

 

테스트 코드

public class SingletonWithPrototypeTest1 {
    @Test
    void singletonClientUsePrototype() {
        AnnotationConfigApplicationContext ac = new
                AnnotationConfigApplicationContext(ClientBean.class, PrototypeBean.class);
        ClientBean clientBean1 = ac.getBean(ClientBean.class);
        
        int count1 = clientBean1.logic();
        assertThat(count1).isEqualTo(1);
        ClientBean clientBean2 = ac.getBean(ClientBean.class);

        int count2 = clientBean2.logic();
        assertThat(count2).isEqualTo(2);
    }

    static class ClientBean {
        private final PrototypeBean prototypeBean;

        @Autowired
        public ClientBean(PrototypeBean prototypeBean) {
            this.prototypeBean = prototypeBean;
        }

        public int logic() {
            prototypeBean.addCount();
            int count = prototypeBean.getCount();
            return count;
        }
    }

    @Scope("prototype")
    static class PrototypeBean {
        private int count = 0;

        public void addCount() {
            count++;
        }

        public int getCount() {
            return count;
        }

        @PostConstruct
        public void init() {
            System.out.println("PrototypeBean.init " + this);
        }

        @PreDestroy
        public void destroy() {
            System.out.println("PrototypeBean.destroy");
        }
    }

스프링은 일반적으로 싱글톤 빈을 사용하므로, 싱글톤 빈이 프로토타입 빈을 사용하게 됩니다. 그런데 싱글톤 빈은 생성 시점에만 의존관계 주입을 받기 떄문에, 프로토타입 빈이 새로 생성되기는 하지만, 싱글톤 빈과 함께 계속 유지되는 것이 문제입니다.

 

 

 

프로토타입 스코프 - 싱글톤 빈과 함께 사용시 Provider로 문제 해결

싱글톤 빈과 프로토타입 빈을 함게 사용할 때, 어떻게하면 사용할 때마다 새로운 프로토타입 빈을 생성할 수 있을까요??

 

스프링 컨테이너에 요청

 

가장 간단한 방법은 싱글톤 빈이 프로토타입을 사용할 떄마다 스프링 컨테이너에 새로 요청하는 것 입니다.

코드

public class SingletonWithPrototypeTest1 {

    @Test
    void providerTest() {
        AnnotationConfigApplicationContext ac = new
                AnnotationConfigApplicationContext(ClientBean.class, PrototypeBean.class);
        ClientBean clientBean1 = ac.getBean(ClientBean.class);

        int count1 = clientBean1.logic();
        assertThat(count1).isEqualTo(1);
        ClientBean clientBean2 = ac.getBean(ClientBean.class);

        int count2 = clientBean2.logic();
        assertThat(count2).isEqualTo(1);
    }
    
    static class ClientBean {

        // 핵심코드
        @Autowired
        private ApplicationContext ac;
        public int logic() {
            PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class);
            prototypeBean.addCount();
            int count = prototypeBean.getCount();
            return count;
        }
    }

    @Scope("prototype")
    static class PrototypeBean {
        private int count = 0;

        public void addCount() {
            count++;
        }

        public int getCount() {
            return count;
        }

        @PostConstruct
        public void init() {
            System.out.println("PrototypeBean.init " + this);
        }

        @PreDestroy
        public void destroy() {
            System.out.println("PrototypeBean.destroy");
        }
    }
}
  • 실행해보면 ac.getBean()을 통해서 항상 새로운 프로토타입 빈이 생성되는 것을 확인할 수 있습니다.
  • 의존관계를 외부에서 주입(DI) 받는게 아니라 직접 필요한 의존관계를 찾는 것을 Dependency Lookup(DL) 의존관계 조회(탐색) 이라합니다.
  • 그런데 이렇게 주입받게 되면, 스프링 컨테이너에 종속적인 코드가 되고, 단위 테스트도 어려워집니다.
  • 지금 필요한 기능은 지정한 프로토타입 빈을 컨테이너에서 대신 찾아주는 기능만 제공하는 무언가가 있으면됩니다.

역시 스프링에는 이미 모든게 준비되어 있다고합니다.

 

 

 

ObjectFactory, ObjectProvider

지정한 빈을 컨테이너에서 대신 찾아주는 DL 서비스를 제공하는 것이 바로 ObjectProvideer 입니다. 과거에는 ObjectFactory가 있었는데, ObjectFactory에 편의 기능을 추가해서 ObjectProvider가 만들어졌다고 합니다.

 

코드

        @Autowired
        private ObjectProvider<PrototypeBean> prototypeBeanProvider;
        public int logic() {
            PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
            prototypeBean.addCount();
            int count = prototypeBean.getCount();
            return count;
        }
  • 실행해보면 prototypeBeanProvider.getObject()를 통해서 항상 새로운 프로토타입 빈이 생성됩니다.
  • ObjectProvider의 getObject()를 호출하면 내부에서는 스프링 컨테이너에서 해당 빈을 찾아서 반환합니다.(DL)
  • 스피링이 제공하는 기능을 사용하지만, 기능이 단순하므로 단위테스트를 만들거나 mock 코드를 만들기는 훨씬 쉬워집니다.
  • ObjectProvider는 지금 딱 필요한 DL정도의 기능만 제공합니다.

 

특징

  • ObjectFactory : 기능이 단순, 별도의 라이브러리 필요 없음, 스프링에 의존
  • ObjectProvider : ObjectFactory 상속, 옵션, 스트림 처리등 편의 기능이 많고, 별도의 라이브러리 필요 없음, 스프링에 의존

 

 

JSR-330 Provider

마지막 방법은 javax.inject.Provider라는 JSR-330 자바 표준을 사용하는 방법입니다.

이 방법을 사용하려면 javax.inject:javax.inject:1 라이브러리를 gradle에 추가해야 합니다.

 

javax.inject.Provider 참고용 코드

package javax.inject;

public interface Provider<T> {
    T get(); 
}
//implementation 'javax.inject:javax.inject:1' gradle 추가 필수 
@Autowired
private Provider<PrototypeBean> provider;

public int logic() {
    PrototypeBean prototypeBean = provider.get();
    prototypeBean.addCount();
    int count = prototypeBean.getCount();
    return count;
}
  • 실행해보면 provider.get()을 통해서 항상 새로운 프로토타입 빈이 생성되는 것을 확인할 수 있습니다.
  • provider의 get()을 호출하면 내부에서는 스프링 컨테이너를 통해 해당 빈을 찾아서 반환합니다. (DL)
  • 자바 표준이고, 기능이 단순하므로 단위테스트를 만들거나 mock 코드를 만들기는 훨씬 쉬워집니다.
  • Provider는 지금 딱 필요한 DL 정도의 기능만 제공합니다.

 

특징

  • get() 메서드 하나로 기능이 매우 단순합니다.
  • 별도의 라이브러리가 필요합니다.
  • 자바 표준이므로 스프링이 아닌 다른 컨테이너에서도 사용할 수 있습니다. (자바 표준이라 메뉴얼이 잘 되어있습니다. 직접 코드를 보면 주석 처리로 언제 사용하면 좋은지 적혀있습니다.)

 

정리

  • 그렇다면 프로토타입 빈을 언제 사용할까요?? 매번 사용할 때 마다 의존관계 주입이 완료된 새로운 객체가 필요하면 사용하면 됩니다. 그런데 실무에서 웹 애플리케이션을 개발해보면, 싱글톤 빈으로 대부분의 문제를 해결할 수 있기 때문에 프로토타입 빈을 직접적으로 사용하는 일은 매우 드뭅니다.
  • ObjectProvider, JSR330 Provider 등은 프로토타입 뿐만 아니라 DL이 필요한 경우는 언제든지 사용할 수 있습니다.

 

참고: 스프링이 제공하는 메서드에 @Lookup 어노테이션을 사용하는 방법도 있지만, 이전 방법들로 충분하고 고려해야할 내용도 많아서 생략했다고 합니다.

 

참고: 실무에서 자바 표준인 JSR-330 Provider를 사용할 것인지, 아니면 스프링이 제공하는 ObjectProvider를 사용할 것인지 고민이 될 것 입니다. ObjectProviderDL을 위한 편의 기능을 많이 제공해주고 스프링 외에 별도의 의존관계 추가가 필요 없기 때문에 편리합니다. 만약(정말 그럴일은 거의 없겠지만) 코드를 스프링이 아닌 다른 컨테이너에서도 사용할 수 있어야 한다면 JSR-330 Provider를 사용해야합니다.

스프링을 사용하다 보면 이 기능 뿐만 아니라 다른 기능들도 자바 표준과 스프링이 제공하는 기능이 겹칠때가 많이 있습니다. 대부분 스프링이 더 다양하고 편리한 기능을 제공해주기 때문에, 특별히 다른 컨테이너를 사용할 일이 없다면, 스프링이 제공하는 기능을 사용하면 됩니다.

 

 

 

웹 스코프

 

웹 스코프 특징

  • 웹 스코프는 웹 환경에서만 동작합니다.
  • 웹 스코프는 프로토타입과 다르게 스프링이 해당 스코프의 종료시점까지 관리합니다. 따라서 종료 메서드가 호출됩니다.

 

웹 스코프 종류

  • request : HTTP 요청 하나가 들어오고 나갈 때까지 유지되는 스코프, 각각의 HTTP 요청마다 별도의 빈 인스턴스가 생성되고 관리됩니다.
  • session : HTTP Session과 동일한 생명주기를 가지는 스코프입니다.
  • application : 서블릿 컨텍스트(ServletContext)와 동일한 생명주기를 가지는 스코프입니다.
  • websocket : 웹 소켓과 동일한 생명주기를 가지는 스코프입니다.

사실 세션, 서블릿 컨텍스트, 웹 소켓 같은 용어를 잘 모르는 분들이 많이 있을 것 입니다. (저도 잘 모릅니다..) 여기서는 request 스코프를 예제로 보겠습니다. 나머지도 범위만 다르지 동작 방식은 비슷합니다.

 

HTTP request 요청 당 각각 할당되는 request 스코프

 

 

request 스코프 예제 만들기

 

웹 환경 추가

웹 스코프는 웹 환경에서만 동작하므로 web 환경이 동작하도록 라이브러리를 추가해줘야 합니다.

 

build.gradle에 추가

// web 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-web'

 이제 heelo.core.CoreApplication의 main 메서드를 실행하면 웹 애플리케이션이 실행되는 것을 확인할 수 있습니다.

Tomcat started on port(s): 8080 (http) with context path ''
Started CoreApplication in 0.914 seconds (JVM running for 1.528)

 

참고 : spring-boot-starter-web 라이브러리를 추가하면 스프링 부트는 내장 톰켓 서버를 활용해서 웹 서버와 스프링을 함께 실행합니다.

 

참고 : 스프링 부트는 웹 라이브러리가 없으면 우리가 지금까지 학습한 AnnotationConfigApplicationContext를 기반으로 애플리케이션을 구동합니다. 웹 라이브러리가 추가되면 웹과 관련된 추가 설정과 환경들이 필요하므로 AnnotationConfigServletWebServerApplicationContext를 기반으로 애플리케이션을 구동합니다.

 

만약 기본 포트인 8080 포트를 다른 곳에서 사용중이어서 오류가 발생한다면 포트를 변경하면 됩니다. main/resources/application.properties에서 아래와 같이 변경하면됩니다. 예를들어 9090포트로 변경한 모습입니다.

server.port = 9090

 

 

request 스코프 예제 개발

동시에 여러 HTTP 요청이 오면 여러 스레디가 막 오기때문에 정확히 어떤 요청이 남긴 로그인지 구분하기 어렵습니다. 이럴 때 사용하기 딱 좋은 것이 바로 request 스코프입니다.

 

아래와 같이 로그가 남도록 request 스코프를 활용해서 추가 기능을 개발해보겠습니다.

[d06b992f...] request scope bean create
[d06b992f...][http://localhost:8080/log-demo] controller test
[d06b992f...][http://localhost:8080/log-demo] service id = testId
[d06b992f...] request scope bean close
  • 기대하는 공통 포멧 : [UUID][requestURL]{message}
  • UUID를 사용해서 HTTP 요청을 구분합시다.
  • requestURL 정보도 추가로 넣어서 어떤 URL을 요청해서 남은 로그인지 확인해봅시다.

먼저 코드로 확인해봅시다.

코드

@Component
@Scope(value = "request")
public class MyLogger {
    private String uuid;
    private String requestURL;

    public void setRequestURL(String requestURL) {
        this.requestURL = requestURL;
    }

    public void log(String message) {
        System.out.println("[" + uuid + "]" + "[" + requestURL + "] " +
                message);
    }

    @PostConstruct
    public void init() {
        uuid = UUID.randomUUID().toString();
        System.out.println("[" + uuid + "] request scope bean create:" + this);
    }

    @PreDestroy
    public void close() {
        System.out.println("[" + uuid + "] request scope bean close:" + this);
    }
}
  • 로그를 출력하기 위한 클래스입니다.
  • @Scope(value = "request")를 사용해서 request 스코프로 지정했습니다. 이제 해당 빈은 HTTP 요청 당 하나씩 생성되고, HTTP 요청이 끝나는 시점에 소멸됩니다.
  • @PostConstruct 초기화 메서드를 사용해서 uuid를 생성하여 저장해둡니다. 해당 빈은 HTTP 요청 당 하나씩 생성되므로, uuid를 저장해두명 다른 HTTP 요청과 구분할 수 있습니다.
  • @PreDestory를 사용해서 종료 메시지를 남깁니다.
  • requestURL은 빈이 생성되는 시점에는 알 수 없으므로, 외부에서 setter로 입력 받습니다.

 

테스트 컨트롤러

public class LogDemoController {

    @Controller
    @RequiredArgsConstructor
    public class LogDemoController {
        private final LogDemoService logDemoService;
        private final MyLogger myLogger;
        
        @RequestMapping("log-demo")
        @ResponseBody
        public String logDemo(HttpServletRequest request) {
            String requestURL = request.getRequestURL().toString();
            myLogger.setRequestURL(requestURL);
            myLogger.log("controller test");
            logDemoService.logic("testId");
            return "OK";
        } 
    }
}
  • MyLogger가 잘 작동하는지 확인하는 테스트용 컨트롤러입니다.
  • 여기서 HttpServletRequest를 통해 요청 URL을 받았습니다.
    • requestURL 값 http://localhost:8080/log-demo
  • 이렇게 받은 requestURL 값을 myLogger에 저장해둡니다. myLogger는 HTTP 요청 당 각각 구분되므로 다른 HTTP 요청 때문에 값이 섞이는 걱정은 하지 않아도 됩니다.
  • 컨트롤러에서 controller test라는 로그를 남깁니다.

 

참고: requestURLMyLogger에 저장하는 부분은 컨트롤러 보다는 공통 처리가 가능한 스프링 인터셉터나 서블릿 필터 같은 곳을 활용하는 것이 좋습니다. 여기서는 예제를 단순화하고, 아직 스프링 인터셉터를 학습하지 않은 분들을 위해서 컨트롤러를 사용했습니다. 스프링 웹에 익숙하다면 인터셉터를 사용해서 구현해봅시다.

 

LogDemoService 추가

@Service
@RequiredArgsConstructor
public class LogDemoService {
    private final MyLogger myLogger;
    public void logic(String id) {
        myLogger.log("service id = " + id);
    }
}
  • 비즈니스 로직이 있는 서비스 계층에서도 로그를 출력해보겠습니다.
  • 여기서 중요한 점은 request scope를 사용하지 않고 파라미터로 모든 정보를 서비스 계층에 넘긴다면, 파라미터가 많아서 지저분해집니다. 더 문제는 requestURL 같은 웹과 관련된 정보가 웹과 관련없는 서비스 계층까지 넘어가게 됩니다. 웹과 관련된 부분은 컨트롤러까지만 사용해야 합니다. 서비스 계층은 웹 기술에 종속되지 않고, 가급적 순수하게 유지하는 것이 유지보수 관점에서 좋습니다.
  • request socpe의 MyLogger 덕분에 이런 부분을 파라미터로 넘기지 않고, MyLogger의 멤버 변수에 저장해서 코드와 계층을 깔끔하게 유지할 수 있습니다.

 

기대하는 출력

[d06b992f...] request scope bean create
[d06b992f...][http://localhost:8080/log-demo] controller test
[d06b992f...][http://localhost:8080/log-demo] service id = testId
[d06b992f...] request scope bean close

실제는 기대와 다르게 애플리케이션 실행 시점에 오류 발생

Error creating bean with name 'myLogger': Scope 'request' is not active for the
current thread; consider defining a scoped proxy for this bean if you intend to
refer to it from a singleton;

스프링 애플리케이션을 실행하는 시점에 싱글톤 빈은 생성해서 주입이 가능하지만, requset 스코프 빈은 아직 생성되지 않습니다. 이 빈은 실제 고객의 요청이 와야 생성할 수 있습니다. 즉, 실제 고객이 오지 않았기 때문에 빈이 생성되지 않아서 오류가 나는게 정상적입니다.

 

 

 

스코프와 Provider

첫번째 해결방안은 앞서 배운 Provider를 사용하는 것 입니다.

@Controller
@RequiredArgsConstructor
public class LogDemoController {
    private final LogDemoService logDemoService;
    private final ObjectProvider<MyLogger> myLoggerProvider;    // ObjectProvider

    @RequestMapping("log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) {
        String requestURL = request.getRequestURL().toString();
        MyLogger myLogger = myLoggerProvider.getObject();
        myLogger.setRequestURL(requestURL);

        myLogger.log("controller test");
        logDemoService.logic("testId");
        return "OK";
    }
}
@Service
@RequiredArgsConstructor
public class LogDemoService {
    private final ObjectProvider<MyLogger> myLoggerProvider;

    public void logic(String id) {
        MyLogger myLogger = myLoggerProvider.getObject();
        myLogger.log("service id = " + id);
    }
}

실행해보면 정상적으로 동작하는 것을 볼 수 있습니다.

 

웹 브라우저에 http://localhost:8080/log-demo를 입력해봅시다. 아래와 같이 뜨면서 정상적으로 잘 동작하는 것을 확인할 수 있습니다.

[15103e1d-28e6-4995-b6a2-705a7e61f397] request scope bean create:hello.core.common.MyLogger@6951a9ce
[15103e1d-28e6-4995-b6a2-705a7e61f397][http://localhost:8080/log-demo] controller test
[15103e1d-28e6-4995-b6a2-705a7e61f397][http://localhost:8080/log-demo] service id = testId
[15103e1d-28e6-4995-b6a2-705a7e61f397] request scope bean close:hello.core.common.MyLogger@6951a9ce
  • ObjectProvider 덕분에 ObjectProvider.getObject()를 호출하는 시점까지 request scope 빈의 생성을 지연할 수 있습니다.
  • ObjectProvider.getObject()를 호출하는 시점에는 HTTP 요청이 진행중이므로 Request scope 빈의 생성이 정상 처리됩니다.
  • ObjectProvider.getObject()를 LogDemoController, LogDemoService에서 각각 한번씩 따로 호출해도 같은 HTTP 요청이면 같은 스프링 빈이 반환됩니다. => 직접 이걸 구분하려면 엄청 힘들 것 같습니다..

이 정도에서 끝내도 될 것 같지만... 개발자들의 코드 몇자를 더 줄이려는 욕심은 끝이 없습니다.

 

 

 

스코프와 프록시

이번에는 프록시 방식을 사용해봅시다.

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {}
  • proxyMode = ScopedProxyMode.TARGET_CLASS를 추가해 주었습니다. (핵심)
    • 적용 대상이 인터페이스가 아닌 클래스면 TARGET_CLASS 선택
    • 적용 대상이 인터페이스이면 INTERFACES를 선택
  • 이렇게 하면 MyLogger의 가짜 프록시 클래스를 만들어두고 HTTP request와 상관 없이 가짜 프록시 클래스를 다른 빈에 미리 주입해 둘 수 있습니다.

위와 같이 proxyMode를 추가해준 뒤 이전의 오류나서 Provider로 고쳤던 코드를 다시 되돌린 후 실행해보면 잘 동작하는 것을 확인할 수 있습니다. 어떻게 된걸까요??

 

웹 스코프와 프록시 등작 원리

먼저 주입된 myLogger를 확인해봅시다.

코드

System.out.println("myLogger = " + myLogger.getClass());

출력

myLogger = class hello.core.common.MyLogger$$EnhancerBySpringCGLIB$$b68b726d

 

CGLIB라는 라이브러리로 내 클래스를 상속 받은 가짜 프록시 객체를 만들어서 주입합니다.

  • @Scope의 proxyMode를 설정하면 스프링 컨테이너는 CGLIB라는 바이트코드를 조작하는 라이브러리를 사용해서 MyLogger를 상속받은 가짜 프록시 객체를 생성합니다.
  • 결과를 확인해보면 순수한 MyLogger 클래스가 아닌 가짜 클래스로 만들어진 객체가 대신 등록된 것을 확인할 수 있습니다.
  • 그리고 스프링 컨테이너에 "myLogger"라는 이름으로 진짜 대신에 가짜 프록시 객체를 등록합니다.
  • ac.getBean("myLogger", MyLogger.class)로 조회해도 프록시 객체가 조회되는 것을 확인할 수 있습니다.
  • 그래서 의존관계 주입도 가짜 프록시 객체가 주입됩니다.

가짜 프록시 객체는 요청이 오면 그때 내부에서 진짜 빈을 요청하는 위임 로직이 들어있습니다.

  • 가짜 프록시 객체는 내부에 진짜 myLogger를 찾는 방법을 알고 있습니다.
  • 클라이언트가 myLogger.logic()을 호출하면 사실은 가짜 프록시 객체의 메서드를 호출한 것 입니다.
  • 가짜 프록시 객체는 request 스코프의 진짜 myLogger.logic()을 호출합니다.
  • 가짜 프록시 객체는 원본 클래스를 상속 받아서 만들어졌기 때문에 이 객체를 사용하는 클라이언트 입장에서는 사실 원본인지 아닌지도 모르게, 동일하게 사용할 수 있습니다. (다형성)

 

동작 정리

  • CGLIB라는 라이브러리로 내 클래스를 상속 받은 가짜 프록시 객체를 만들어서 주입합니다.
  • 가짜 프록시 객체는 실제 요청이 오면 그때 내부에서 실제 빈을 요청하는 위임 로직이 들어있습니다.
  • 가짜 프록시 객체는 실제 request scope과는 관계가 없습니다. 그냥 가짜이고 내부에 단순한 위임 로직만 있고 싱글톤 처럼 동작합니다.

 

특징 정리

  • 프록시 객체 덕분에 클라이언트는 마치 싱글톤 빈을 사용하듯이 편리하게 request scope를 사용할 수 있습니다.
  • 사실 Provider를 사용하든, 프록시를 사용하든 핵심 아이디어는 진짜 객체 조회를 꼭 필요한 시점까지 지연처리 한다는 점입니다.
  • 단지 어노테이션 설정 변경만으로 원본 객체를 프록시 객체로 대체할 수 있습니다. 이것이 다형성과 DI 컨테이너가 가진 큰 강점입니다.
  • 꼭 웹 스코프가 아니어도 프록시는 사용할 수 있습니다.

 

주의점

  • 마치 싱글톤을 사용하는 것 같지만 다르게 동작하기 때문에 결국 주의해서 사용해야 합니다.
  • 이런 특별한 scope는 꼭 필요한 곳에만 최소화해서 사용해야 합니다. 무분멸하게 사용하면 유지보수하기 어려워집니다.

 

 

 

출처

 

스프링 핵심 원리 - 기본편 - 인프런 | 강의

스프링 입문자가 예제를 만들어가면서 스프링의 핵심 원리를 이해하고, 스프링 기본기를 확실히 다질 수 있습니다., - 강의 소개 | 인프런...

www.inflearn.com

 

빈 생명주기 콜백 시작

데이터베이스 커넥션 풀이나, 네트워크 소켓처럼 애플리케이션 시작 시점에 필요한 연결을 미리 해두고 애플리케이션 종료 시점에 연결을 모두 종료하는 작업을 진행하려면 객체의 초기화와 종료 작업이 필요합니다. (연결을 미리 해두는 이유는 tcp heanshaking을 통해 연결하는데 오래 걸리기 떄문입니다. 스레드 풀과 비슷한 느낌인 것 같습니다.)

 

이번에는 스프링을 통해 이러한 초기화 작업과 종료 작업을 어떻게 진행하는지 예제로 알아보고자 합니다.

 

간단하게 외부 네트워크에 미리 연결하는 객체를 하나 생성한다고 가정해보겠습니다. 실제로 네트워크에 연결하는 것은 아니고, 단순히 문자만 출력 하겠습니다. 해당 NetworkClient는 애플리케이션 시작시점에 Connect()를 호출해서 연결을 맺어두어야하고, 종료되면 disConnect()를 호출해서 연결을 끊어야 합니다.

 

코드

public class NetworkClient {

    private String url;

    public NetworkClient() {
        System.out.println("생성자 호출, url = " + url);
        connect();
        call("초기화 연결 메시지");
    }

    public void setUrl(String url) {
        this.url = url;
    }

    //서비스 시작시 호출
    public void connect() {
        System.out.println("connect: " + url);
    }

    public void call(String message) {
        System.out.println("call: " + url + " message = " + message);
    }

    //서비스 종료시 호출
    public void disconnect() {
        System.out.println("close: " + url);
    }
}

테스트 코드

    @Test
    public void lifeCycleTest() {
        ConfigurableApplicationContext ac = new
                AnnotationConfigApplicationContext(LifeCycleConfig.class);
        NetworkClient client = ac.getBean(NetworkClient.class); ac.close(); //스프링 컨테이너를 종료, ConfigurableApplicationContext 필요
    }

    @Configuration
    static class LifeCycleConfig {
        @Bean
        public NetworkClient networkClient() {
            NetworkClient networkClient = new NetworkClient();
            networkClient.setUrl("http://hello-spring.dev");
            return networkClient;
        } 
    }

출력

생성자 호출, url = null 
connect: null
call: null message = 초기화 연결 메시지

당연히 생성자단계에서 출력하는데 setUrl을 하지않았기 때문에, url값은 null로 출력이 됩니다.

 

스프링 빈은 간단하게 다음과 같은 라이프사이클을 가집니다.

객체 생성 -> 의존관계 주입

 

스프링 빈은 객체를 생성하고, 의존관계 주입이 다 끝난 다음에야 필요한 데이터를 사용할 수 있는 준비가 완료됩니다. 따라서 초기화 작업은 의존관계 주입이 모두 완료되고 난 다음에 호출해야 합니다. 그런데 개발자가 의존관계 주입이 모두 완료된 시점을 어떻게 알 수 있을까요??

스프링은 의존관계 주입이 완료되면 스프링 빈에게 콜백 메서드를 통해서 초기화 시점을 알려주는 다양한 기능을 제공합니다. 또한 스프링은 스프링 컨테이너가 종료되기 직전에 소멸 콜백을 줍니다. 따라서 안전하게 종료 작업을 진행할 수 있습니다.

 

스프링 빈의 이벤트 라이프사이클은 아래와 같습니다.

스프링 컨테이너 생성 -> 스프링 빈 생성 -> 의존관계 주입 -> 초기화 콜백 -> 사용 -> 소멸전 콜백 -> 스프링 종료

 

참고 : 객체의 생성과 초기화를 분리합시다.

생성자는 필수 정보(파라미터)를 받고, 메모리를 할당해서 객체를 생성하는 책임을 가집니다. 바념ㄴ에 초기화는 이렇게 생성된 값들을 활용해서 외부 커넥션을 연결하는 등 무거운 동작을 수행합니다.

따라서, 생성자 안에서 무거운 초기화 작업을 함께 하는 것 보다는 객체를 생성하는 부분과 초기화 하는 부분을 명확하게 나눈 것이 유지보수 관점에서 좋습니다. 물론 초기화 작업이 내부 값들만 조금 변경하는 정도로 단순한 경우에는 생성자에서 한번에 다 처리하는게 더 좋을 수도 있습니다. 

 

참고 : 싱글톤 빈들은 스프링 컨테이너가 종료될 때 싱글톤 빈들도 함꼐 종료되기 떄문에 스프링 컨테이너가 종료되기 직전에 소멸전 콜백이 일어납니다. 

 

 

스프링은 크게 3가지 방법으로 빈 생명주기 콜백을 지원합니다.

  • 인터페이스(InitializingBean, DisposableBean)
  • 설정 정보에 초기화 메서드, 종료 메서드 지정
  • @PostConstruct, @PreDestroy 지원

하나씩 알아봅시다.

 

 

 

인터페이스 InitializingBean, DisposableBean

 

코드

public class NetworkClient implements InitializingBean, DisposableBean {

    private String url;

    public NetworkClient() {
        System.out.println("생성자 호출, url = " + url);
        connect();
        call("초기화 연결 메시지");
    }

    public void setUrl(String url) {
        this.url = url;
    }

    //서비스 시작시 호출
    public void connect() {
        System.out.println("connect: " + url);
    }

    public void call(String message) {
        System.out.println("call: " + url + " message = " + message);
    }

    //서비스 종료시 호출
    public void disConnect() {
        System.out.println("close: " + url);
    }

    // 의존관계 주입이 끝나면 호출
    @Override
    public void afterPropertiesSet() throws Exception {
        connect();
        call("초기화 연결 메시지");
    }

    @Override
    public void destroy() throws Exception {
        disConnect();;
    }
}
  • InitializingBean은 afterPropertiesSet() 메서드로 초기화를 지원합니다.
  • DisposableBean은 destroy() 메서드로 소멸을 지원합니다.

출력

생성자 호출, url = null
NetworkClient.afterPropertiesSet
connect: http://hello-spring.dev
call: http://hello-spring.dev message = 초기화 연결 메시지
13:24:49.043 [main] DEBUG org.springframework.context.annotation.AnnotationConfigApplicationContext - Closing NetworkClient.destroy
close + http://hello-spring.dev

출력 결과를 보면 초기화 메서드가 주입 완료 후에 적절하게 호출된 것을 확인할 수 있습니다. 그리고 스프링 컨테이너의 종료가 호출되자 소멸 메서드가 호출된 것도 확인할 수 있습니다.

 

초기화, 소멸 인터페이스 단점

  • 이 인터페이스는 스프링 전용 인터페이스입니다. 해당 코드가 스프링 전용 인터페이스에 의존합니다.
  • 초기화, 소멸 메서드의 이름을 변경할 수 없습니다.
  • 내가 코드를 고칠 수 없는 외부 라이브러리에 적용할 수 없습니다.

참고로 인터페이스를 사용하는 초기화, 종료 방법은 스프링 초창기에 나온 방법들이고, 지금은 다음의 더 나은 방법들이 있어서 거의 사용하지 않습니다.

 

 

 

빈 등록 초기화, 소멸 메서드 지정

설정 정보에 @Bean(initMethod = "init", detroyMethod = "close")처럼 초기화, 소멸 메서드를 지정할 수 있습니다. 메소드명을 적으면 됩니다.

 

설정 정보를 사용하도록 변경

public class NetworkClient {

    private String url;

    public NetworkClient() {
        System.out.println("생성자 호출, url = " + url);
    }

    public void setUrl(String url) {
        this.url = url;
    }

    //서비스 시작시 호출
    public void connect() {
        System.out.println("connect: " + url);
    }

    public void call(String message) {
        System.out.println("call: " + url + " message = " + message);

    }

    //서비스 종료시 호출
    public void disConnect() {
        System.out.println("close + " + url);
    }
    
    public void init() { System.out.println("NetworkClient.init"); connect();
        call("초기화 연결 메시지");
    }

    public void close() {
        System.out.println("NetworkClient.close");
        disConnect();
    }
}

설정 정보에 초기화 소멸 메서드 지정

@Configuration
static class LifeCycleConfig {

    @Bean(initMethod = "init", destroyMethod = "close")
    public NetworkClient networkClient() {
        NetworkClient networkClient = new NetworkClient();
        networkClient.setUrl("http://hello-spring.dev");
        return networkClient;
    }
}

결과

생성자 호출, url = null
NetworkClient.init
connect: http://hello-spring.dev
call: http://hello-spring.dev message = 초기화 연결 메시지
13:33:10.029 [main] DEBUG org.springframework.context.annotation.AnnotationConfigApplicationContext - Closing NetworkClient.close
close + http://hello-spring.dev

 

설정 정보 사용 특징

  • 메서드 이름을 자유롭게 줄 수 있습니다.
  • 스프링 빈이 스프링 코드에 의존하지 않습니다.
  • 코드가 아니라 설정 정보를 사용하기 때문에 코드를 고칠 수 없는 외부 라이브러리에도 초기화, 종료 메서드를 적용할 수 있습니다.

종료 메서드 추론

  • @Bean의 destroyMethod 속성에는 아주 특별한 기능이 있습니다.
  • 라이브러리는 대부분 close, shutdown 이라는 이름의 종료 메서드를 사용합니다.
  • @Bean의 destroyMethod는 기본값이 (inferred)(추론)으로 등록되어 있습니다.
  • 이 추론 기능은 close, shutdown 이라는 이름의 메서드를 자동으로 호출해줍니다. 이름 그대로 종료메서드를 추론해서 호출해줍니다.
  • 따라서 직접 스프링 빈으로 등록하면 종료 메서드는 따로 적어주지 않아도 잘 동작합니다.
  • 추론 기능을 사용하기 싫으면 destroyMethod=""처럼 빈 공백을 지정하면 됩니다.

 

 

 

어노테이션 @PostConstruct, @PreDestroy

결론부터 말하자면 해당 방법을 사용하면 된다고 합니다.

 

코드

public class NetworkClient {

    private String url;

    public NetworkClient() {
        System.out.println("생성자 호출, url = " + url);
    }

    public void setUrl(String url) {
        this.url = url;
    }

    //서비스 시작시 호출
    public void connect() {
        System.out.println("connect: " + url);
    }

    public void call(String message) {
        System.out.println("call: " + url + " message = " + message);
    }

    //서비스 종료시 호출
    public void disConnect() {
        System.out.println("close + " + url);
    }

    @PostConstruct
    public void init() {
        System.out.println("NetworkClient.init");
        connect();
        call("초기화 연결 메시지");
    }

    @PreDestroy
    public void close() {
        System.out.println("NetworkClient.close");
        disConnect();
    }
}
    @Configuration
    static class LifeCycleConfig {
        @Bean
        public NetworkClient networkClient() {
            NetworkClient networkClient = new NetworkClient();
            networkClient.setUrl("http://hello-spring.dev");
            return networkClient;
        }
    }

출력

생성자 호출, url = null
NetworkClient.init
connect: http://hello-spring.dev
call: http://hello-spring.dev message = 초기화 연결 메시지
19:40:50.269 [main] DEBUG org.springframework.context.annotation.AnnotationConfigApplicationContext - Closing NetworkClient.close
close + http://hello-spring.dev

@PostConstruct, @PreDestroy 두 어노테이션을 사용하면 가장 편리하게 초기화와 종료를 실행할 수 있습니다.

 

@PostConstruct, @PreDestroy 애노테이션 특징

  • 최신 스프링에서 가장 권장하는 방법입니다.
  • 애노테이션 하나만 붙이면 되므로 매우 편리합니다.
  • 패키지를 잘 보면 javax.annotation.PostConstruct 입니다. 스프링에 종속적인 기술이 아니라 JSR-250 라는 자바 표준이다. 따라서 스프링이 아닌 다른 컨테이너에서도 동작합니다. 추가적으로 javax로 시작하는 것은 자바진영 에서 공식적으로 지원하는 기능입니다.
  • 컴포넌트 스캔과 잘 어울립니다.
  • 유일한 단점은 외부 라이브러리에는 적용하지 못한다는 것입니다. 외부 라이브러리를 초기화, 종료 해야 하면 @Bean의 기능을 사용합시다.

 

정리

  • @PostConstruct, @PreDestroy 어노테이션을 사용합시다.
  • 코드를 고칠 수 없는 외부 라이브러리를 초기화, 종료해야 하면 @Bean 의 initMethod destroyMethod 를 사용합시다.

 

 

 

출처

 

스프링 핵심 원리 - 기본편 - 인프런 | 강의

스프링 입문자가 예제를 만들어가면서 스프링의 핵심 원리를 이해하고, 스프링 기본기를 확실히 다질 수 있습니다., - 강의 소개 | 인프런...

www.inflearn.com

 

다양한 의존관계 주입 방법

의존관계 주입은 크게 4가지 방법이 있습니다.

  • 생성자 주입
  • 수정자 주입 (setter 주입)
  • 필드 주입
  • 일반 메서드 주입

 

생성자 주입

  • 이름 그대로 생성자를 통해서 의존관계를 주입 받는 방법입니다. 지금까지 제가 진행했던 방법이 바로 생성자 주입입니다.
  • 특징
    • 생성자 호출시점에 딱 1번만 호출되는 것이 보장됩니다.
    • 불편, 필수 의존관계에 사용합니다.

 

스프링 빈에서는 생성자가 딱 1개만 있는 경우 @Autowired를 생략해도 자동 주입 됩니다. (@Component, @Configuration)

 

 

 

수정자 주입 (setter 주입)

  • setter라 불리는 필드의 값을 변경하는 수정자 메서드를 통해서 의존관계를 주입하는 방법입니다.
  • 특징
    • 선택, 변경 가능성이 있는 의존관계에 사용합니다.
    • 자바 빈 프로퍼티 규약의 수정자 메서드 방식을 사용하는 방법입니다.
@Component
public class OrderServiceImpl implements OrderService {
    private MemberRepository memberRepository;
    private DiscountPolicy discountPolicy;
    
    @Autowired
    public void setMemberRepository(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Autowired
    public void setDiscountPolicy(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

 

자바빈 프로퍼티 규약 예시

class Data {
      private int age;
      public void setAge(int age) {
        this.age = age;
      }
    
      public int getAge() {
        return age;
      } 
}

 

 

 

필드 주입

  • 이름 그대로 필드에 바로 주입하는 방법입니다.
  • 특징
    • 코드가 간결해서 많은 개발자들을 유혹하지만 외부에서 변경이 불가능해서 테스트하기 힘들다는 치명적인 단점이 있습니다.
    • DI 프레임워크가 없으면 아무것도 할 수 없습니다.
    • 사용하지 말자!
      • 애플리케이션의 실제 코드와 관계없는 테스트 코드
      •  스프링 설정을 목적으로 하는 @Configuration 같은 곳에서만 특별한 용도로 사용합니다.
@Component
public class OrderServiceImpl implements OrderService {
     
    @Autowired
    private MemberRepository memberRepository;
    
    @Autowired
    private DiscountPolicy discountPolicy;
}
@Bean
OrderService orderService(MemberRepository memberRepoisitory, DiscountPolicy
discountPolicy) {
    new OrderServiceImpl(memberRepository, discountPolicy)
}

 

 

 

일반 메서드 주입

  • 일반 메서드를 통해서 주입 받을 수 있습니다.
  • 특징
    • 한번에 여러 필드를 주입 받을 수 있습니다.
    • 일반적으로 잘 사용하지 않습니다.
@Component
public class OrderServiceImpl implements OrderService {
      
    private MemberRepository memberRepository;
    private DiscountPolicy discountPolicy;
    
    @Autowired
    public void init(MemberRepository memberRepository, DiscountPolicy
    discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
}

참고로 당연한 이야기지만 의존관계 자동 주입은 스프링 컨테이너가 관리하는 스프링 빈이어야 동작합니다. 스프링 빈이 안인 Member 같은 클래스에서 @Autowired 코드를 적용해도 아무 기능도 동작하지 않습니다.

 

 

 

 

옵션 처리

주입할 스프링 빈이 없어도 동작해야 할 떄가 있습니다. 그런데 @Autowired만 사용하면 required 옵션의 기본 값이 true로 되어 있어서 자동 주입 대상이 없으면 오류가 발생합니다.

 

자동 주입 대상을 옵션으로 처리하는 방법은 아래와 같습니다.

  • @Autowired(required=false)  : 자동 주입할 대상이 없으면 수정자 메서드 자체가 호출이 안됩니다.
  • org.springframework.lang.@Nullable : 자동 주입할 대상이 없으면 Null이 입력됩니다.
  • Optional<> : 자동 주입할 대상이 없으면 Optional.empty가 입력됩니다. (java 8부터 제공하는 문법)

 

예제로 확인해 봅시다.

@Test
void AutowiredOption(){
    ApplicationContext ac = new AnnotationConfigApplicationContext(TestBean.class);

}

// 스프링 빈에 없는 Member 사용
static class TestBean {
    //호출 안됨
    // required = false 안할 경우 터짐
    @Autowired(required = false)
    public void setNoBean1(Member member) {
        System.out.println("setNoBean1 = " + member);
    }

    //null 호출
    @Autowired
    public void setNoBean2(@Nullable Member member) {
        System.out.println("setNoBean2 = " + member);
    }

    //Optional.empty 호출
    @Autowired(required = false)
    public void setNoBean3(Optional<Member> member) {
        System.out.println("setNoBean3 = " + member);
    }
}

 

Member는 스프링 빈이 아닙니다.

setNoBean1()은 @Autowired(required=false)이므로 호출 자체가 안됩니다.

 

출력 결과

 setNoBean2 = null
 setNoBean3 = Optional.empty

 

 

 

생성자 주입을 선택해라!

과거에는 수정자 주입과 필드 주입을 많이 사용했지만, 최근에는 스프링을 포함한 DI 프레임워크 대부분이 생성자 주입을 권장합니다. 이유는 아래와 같습니다.

 

불변

  • 대부분의 의존관계 주입은 한번 일어나면 종료시점까지 의존관계를 변경할 일이 없습니다. 따라서 종료전까지 의존관계는 변하면 안됩니다.
  • 수정자 주입을 사용하게되면, setXxx 메서드를 public으로 열어두어야하기 때문에 수정이 가능합니다. 이렇게 되는 경우 누군가 실수로 변경할 수 있습니다. 결론적으로 변경하면 안되는 메서드를 public해놓는 것은 좋은 설계 방법이 아닙니다.
  • 생성자 주입은 객체를 생서할 때 딱 1번만 호추로디므로 이후에 호출되는 일이 없습니다. 따라서 불변하게 설계가 가능합니다.

 

 

누락

프레임워크 없이 순수한 자바 코드를 단위 테스트 하는 경우에 아래와 같이 수정자 의존관계인 경우 (setXxx)

public class OrderServiceImpl implements OrderService {

      private MemberRepository memberRepository;
      private DiscountPolicy discountPolicy;
      
      @Autowired
      public void setMemberRepository(MemberRepository memberRepository) {
          this.memberRepository = memberRepository;
      }
      
      @Autowired
      public void setDiscountPolicy(DiscountPolicy discountPolicy) {
          this.discountPolicy = discountPolicy;
      }
      //...
}

테스트 코드

@Test
void createOrder() {
    OrderServiceImpl orderService = new OrderServiceImpl();
    orderService.createOrder(1L, "itemA", 10000);
}

실행은 되지만, NPE(Null Point Exception)이 발생합니다. 그 이유는 memberRepository, discountPolicy의 의존관계 주입이 누락되었기 때문입니다.

 

하지만, 생성자 주입을 사용하면 파라미터인 주입데이터를 누락했을 때 컴파일 오류가 발생합니다. 이를 통해 IDE에서 어떤 값을 필수로 주입해야하는지 알 수 있습니다.

 

 

final 키워드

생정자 주입을 사용하면 필드에 final 키워드를 사용할 수 있습니다. 그래서 생성자에서 혹시라도 값이 설정되지 않는 오류를 컴파일 시점에서 막아 줍니다.

@Component
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;
    
    @Autowired
    public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy
    discountPolicy) {
        this.memberRepository = memberRepository;
    }
    //...
}

만약, 위와 같은 상황이라고 봅시다. 현재 discountPolicy의 설정 값이 빠져있습니다. 이때, 컴파일 시점에서 java : variable discountPolicy might not have been initialized라는 오류를 발생시킵니다. 실제로 코드를 돌려보지 않고, 컴파일에서 확인할 수 있도록 할 수 있습니다.

 

 

정리

  • 생성자 주입 방식을 선택하는 이유는 여러가지가 있지만, 프레임에 의존하지 않고, 순수한 자바 언어의 특징을 잘 살리는 방법이기 떄문입니다.
  • 기본으로 생성자 주입을 사용하고 필수 값이 아닌 경우에는 수정자 주입 방식을 옵션으로 부여하면 됩니다. 
  • 항상 생성자 주입을 선택하되, 가끔 옵션이 필요한 경우 수정자 주입을 선택해라! 또한, 필드 주입은 반드시 사용하지 말자!

 

 

 

롬복과 최신 트렌드

막상 개발을 해보면, 99%가 불변이라고 한비다. 그래서 생성자에 final 키워드를 사용하게 된다고하는데요. 그런데 생성자도 만들어야하고, 주입 받은 값을 대입하는 코드도 만들어야하고.. 귀찮은 것을 싫어하는 개발자들을 위해 편리하게 만들어진 라이브러리가 있다고 합니다.

 

아래의 기본 코드를 최적화해보겠습니다. 기본 코드는 이전에 생성자 주입 코드입니다.

기본 코드

@Component
public class OrderServiceImpl implements OrderService{
    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    @Autowired
    public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
}

 

롬복 라이브러리가 제공하는 @RequiredArgsConstructor 기능을 사용하면 final이 붙은 필드를 모아서 생성자를 자동으로 만들어 줍니다.

 

해당 기본 코드에 롬복을 적용해보겠습니다.

최종 결과 코드

@Component
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService{
    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

}

 

롬복이 자바의 어노테이션 프로세서라는 기능을 이용해서 컴파일 시점에 생성자 코드를 자동으로 생성해준다고합니다. 실제 Class를 열어보면 아래와 같은 코드가 추가되어 있는 것을 확인할 수 있다고 합니다.

public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
    this.memberRepository = memberRepository;
    this.discountPolicy = discountPolicy;
}

 

 

정리

최근에는 생성자를 딱 1개 두고, @Autowired를 생략하는 방법을 주로 사용합니다. 여기에 Lombok 라이브러리의 @RequiredArgsConstructor를 사용하면 기능은 다 제공하면서, 코드는 깔끔하게 사용할 수 있습니다.

 

 

롬복 라이브러리 적용방법

 

build.gradle

plugins {
	id 'org.springframework.boot' version '2.6.2'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id 'java'
}

group = 'hello'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

//lombok 설정 추가 시작
configurations {
compileOnly {
	extendsFrom annotationProcessor
}
}
//lombok 설정 추가 끝


repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter'

	//lombok 라이브러리 추가 시작
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'

	testCompileOnly 'org.projectlombok:lombok'
	testAnnotationProcessor 'org.projectlombok:lombok'
	//lombok 라이브러리 추가 끝

	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
	useJUnitPlatform()
}

 

롬복 라이브러리 테스트

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class HelloLombok {

    private String name;
    private int age;

    public static void main(String[] args) {
        HelloLombok helloLombok = new HelloLombok();
        helloLombok.setAge(27);
        helloLombok.setName("KYJ");
        
        // 27 KYJ 출력
        System.out.println(helloLombok.getAge()  + " " + helloLombok.getName());
    }
}

 이런식으로 임의의 클래스에 @Getter, @Setter만 추가하면 자동으로 카멜표기법으로 메서드 추가없이 get, set기능을 사용할 수 있는 것을 볼 수 있습니다.

 

 

 

조회 빈이 2개 이상 - 문제

 

@Autowired 는 타입(Type)으로 조회합니다. 그렇기 떄문에, 마치 ac.getBean(클래스명.class)와 유사하게 동작합니다.

 

DiscountPolicy의 하위 타입인 FixDiscountPolicy, RateDiscountPolicy를 스프링 빈으로 선언 한 뒤, 의존관계 자동 주입을 실행하면 NoUniqueBeanDefinitionException 오류가 발생합니다.

 NoUniqueBeanDefinitionException: No qualifying bean of type
  'hello.core.discount.DiscountPolicy' available: expected single matching bean
  but found 2: fixDiscountPolicy,rateDiscountPolicy

이때 , 하위 타입으로 지정할 수도 있지만 하위 타입으로 지정하는 것은 DIP를 위배하고 유연성이 떨어집니다. 그리고 이름만 다르고 완전히 똑같은 타입의 스프링 빈이 2개 있을 때 해결이 안됩니다.

 

 

@Autowired 필드 명, @Qualifire, @Primary

조회 대상 빈이 2개 이상일 때 해결 방법

  • @Autowired 필드명 매칭
  • @Qualifier -> @Qualifire끼리 매칭 -> 빈 이름 매칭
  • @Primary 사용

 

@Autowired 필드명 매칭

@Autowired는 타입 매칭을 시도하는데 여러 빈이 있으면 필드 이름, 파라미터 이름으로 빈 이름을 추가 매칭합니다.

 

기존코드

@Autowired
private DiscountPolicy discountPolicy

 

필드 명을 빈 이름으로 변경

@Autowired
private DiscountPolicy rateDiscountPolicy

필드 명이 rateDiscountPolicy이므로 정상 주입 됩니다. 

 

@Autowired 매칭 정리

  1. 타입 매칭
  2. 타입 매칭의 결과가 2개 이상일 때는 필드 명, 파라미터 명으로 매칭

 

 

@Qualifier 사용

@Qualifier는 추가 구분자를 붙여주는 방법입니다. 주입 시 추가적인 방법을 제공하는 것이지 빈 이름을 변경하는 것은 아닙니다.

 

빈 등록시 @Qualifier를 붙여줍니다.

  @Component
  @Qualifier("mainDiscountPolicy")
  public class RateDiscountPolicy implements DiscountPolicy {}
  @Component
  @Qualifier("fixDiscountPolicy")
  public class FixDiscountPolicy implements DiscountPolicy {}

 

주입 시에 @Qualifier를 붙여주고 등록한 이름을 적어줍니다.

 

생성자 자동 주입 예시

@Autowired
public OrderServiceImpl(MemberRepository memberRepository,
	@Qualifier("mainDiscountPolicy") DiscountPolicy
  discountPolicy) {
    this.memberRepository = memberRepository;
    this.discountPolicy = discountPolicy;
}

수정자 자동 주입 예시

@Autowired
public DiscountPolicy setDiscountPolicy(@Qualifier("mainDiscountPolicy")
  DiscountPolicy discountPolicy) {
    return discountPolicy;
}

 

@Qualifier 로 주입할 때 @Qualifier("mainDiscountPolicy") 를 못찾으면 어떻게 될까요? 이럴 땐 mainDiscountPolicy라는 이름의 스프링 빈을 추가로 찾습니다. 하지만 경험상 @Qualifier @Qualifier 를 찾는 용도로만 사용하는게 명확하고 좋습니다.

 

@Qualifier 정리

  1. @Qualfier끼리 매칭
  2. 빈 이름 캐칭
  3. NoSuchBeanDefinitionException 예외 발생

 

 

@Primary 사용

@Primary는 우선순위를 정하는 방법입니다. @Autowired 시에 여러 빈이 매칭되면 @Primary가 우선권을 가집니다.

 

rateDiscountPolicy가 우선권을 가지도록 해봅시다.

@Component
@Primary
public class RateDiscountPolicy implements DiscountPolicy {}
 
@Component
public class FixDiscountPolicy implements DiscountPolicy {}

 

사용 코드

//생성자
@Autowired
public OrderServiceImpl(MemberRepository memberRepository,
                          DiscountPolicy discountPolicy) {
      this.memberRepository = memberRepository;
      this.discountPolicy = discountPolicy;
}

//수정자
@Autowired
public DiscountPolicy setDiscountPolicy(DiscountPolicy discountPolicy) {
      return discountPolicy;
}

 코드를 실행해보면 문제없이 잘 동작하는 것을 확인할 수 있을 것 입니다.

 

그렇다면 @Primary와 @Qualifier 중 어떤 것을 사용하면 좋을지 고민이 됩니다. @Qualifier의 단점은 주입 받을 때 @Qualifier를 부텽주어야한다는 점 입니다. 반면에 @Primary를 사용하면 @Qualifier를 붙일 필요가 없습니다.

 

@Primary, @Qualifier 활용
코드에서 자주 사용하는 메인 데이터베이스의 커넥션을 획득하는 스프링 빈이 있고, 코드에서 특별한 기능으로 가끔 사용하는 서브 데이터베이스의 커넥션을 획득하는 스프링 빈이 있다고 생각해봅시다. 메인 데이터베이스의 커넥션을 획득하는 스프링 빈은 @Primary 를 적용해서 조회하는 곳에서 @Qualifier 지정 없이 편리하게 조회하고, 서브 데이터베이스 커넥션 빈을 획득할 때는 @Qualifier 를 지정해서 명시적으로 획득 하는 방식으로 사용하면 코드를 깔끔하게 유지할 수 있습니다. 물론 이때 메인 데이터베이스의 스프링 빈을 등록할 때 @Qualifier 를 지정해주는 것은 상관없습니다.

 

우선순위

@Primary 는 기본값 처럼 동작하는 것이고, @Qualifier 는 매우 상세하게 동작합니다. 이런 경우 어떤 것이 우선권을 가져갈까? 스프링은 자동보다는 수동이, 넒은 범위의 선택권 보다는 좁은 범위의 선택권이 우선 순위가 높습니다. 따라서 여기서도 @Qualifier 가 우선권이 높습니다

 

 

 

어노테이션 직접 만들기

@Qualifier("mainDiscountPolicy") 이렇게 문자를 적으면 컴파일시 타입체크가 안됩니다. 아래와 같은 어노테이션을 만들어서 문제를 해결할 수 있습니다.

@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER,
ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented       
@Qualifier("mainDiscountPolicy")
public @interface MainDiscountPolicy {
}
@Component
@MainDiscountPolicy
public class RateDiscountPolicy implements DiscountPolicy {}
//생성자 자동 주입
@Autowired
public OrderServiceImpl(MemberRepository memberRepository,
                          @MainDiscountPolicy DiscountPolicy discountPolicy) {
      this.memberRepository = memberRepository;
      this.discountPolicy = discountPolicy;
}
  
//수정자 자동 주입
@Autowired
public DiscountPolicy setDiscountPolicy(@MainDiscountPolicy DiscountPolicy discountPolicy) {
      return discountPolicy;
}

 어노테이션에는 상속이라는 개념이 없습니다. @Qualifier 뿐만 아니라 다른 어노테이션들도 함께 조합해서 사용할 수 있습니다. 단적으로 @Autowired도 재정의 할 수 있습니다. 물론, 스프링이 제공하는 기능을 뚜렷한 목적없이 무분별하게 재정의 하는 것은 유지보수에 혼란만 가중할 수 있습니다.

 

 

조회한 빈이 모두 필요할 때, List, Map

해당 타입의 스프링 빈이 다 필요한 경우도 있습니다. 예를들어, 할인 정책이 클라이언트의 선택에 따라(Rate, Fix)를 선택할 수 있다고 가정해봅시다. 이러한 경우 스프링을 사용하면 전략 패턴을 매우 간단하게 구현할 수 있다고 합니다.

 

코드

    @Test
    void findAllBean() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class, DiscountService.class);
        DiscountService discountService = ac.getBean(DiscountService.class);

        Member member = new Member(1L, "userA", Grade.VIP);

        int discountPrice = discountService.discount(member, 10000,"fixDiscountPolicy");
        assertThat(discountService).isInstanceOf(DiscountService.class);
        assertThat(discountPrice).isEqualTo(1000);
    }

    static class DiscountService {
        private final Map<String, DiscountPolicy> policyMap;
        private final List<DiscountPolicy> policies;

        @Autowired
        public DiscountService(Map<String, DiscountPolicy> policyMap, List<DiscountPolicy> policies) {
            this.policyMap = policyMap;
            this.policies = policies;
            System.out.println("policyMap = " + policyMap);
            System.out.println("policies = " + policies);
        }

        public int discount(Member member, int price, String discountCode) {
            DiscountPolicy discountPolicy = policyMap.get(discountCode);
            System.out.println("discountCode = " + discountCode);
            System.out.println("discountPolicy = " + discountPolicy);
            return discountPolicy.discount(member, price);
        }
    }

로직 분석

  • DiscountService는 Map으로 모든 DiscountPolicy 를 받습니다. 이때 fixDiscountPolicy , rateDiscountPolicy가 주입됩니다.
  • discount () 메서드는 discountCode로 "fixDiscountPolicy"가 넘어오면 map에서 fixDiscountPolicy 스프링 빈을 찾아서 실행합니다.물론  rateDiscountPolicy가 넘어오면 rateDiscountPolicy 스프링 빈을 찾아서 실행한다.

 

주입 분석

  • Map<String, DiscountPolicy> : map의 키에 스프링 빈의 이름을 넣어주고, 그 값으로 DiscountPolicy 타입으로 조회한 모든 스프링 빈을 담아줍니다.
  • List<DiscountPolicy> : DiscountPolicy 타입으로 조회한 모든 스프링 빈을 담아준다. 만약 해당하는 타입의 스프링 빈이 없으면, 빈 컬렉션이나 Map을 주입합니다.

 

 

자동, 수동의 올바른 실무 운영 기준

편리한 자동 기능을 기본으로 사용하자

스프링이 나오고 시간이 갈 수록 점점 자동을 선호하는 추세입니다. 스프링은 @Component 뿐만 아니라 @Controller, @Service, @Repository처럼 계층에 맞추어 일반적인 애플리케이션 로직을 자동으로 스캔할 수 있도록 지원합니다. 거기에 더해 최근 스프링 부트는 컴포넌트 스캔을 기본으로 사용하고, 스프링 부트의 다양한 스프링 빈들도 조건이 맞으면 자동으로 등록하도록 설계했습니다.

 

설정 정보를 기반으로 애플리케이션을 구성하는 부분과 실제 동작하는 부분을 명확하게 나누는 것이 이상적이지만, 개발자 입장에서 스프링 빈을 하나 등록할 떄 @Component만 넣어주면 끝나는 일을 @Configuration 설정 정보에가서 @Bean을 적고, 객체를 생성하고, 주입할 대상을 일일이 적어주는 과정은 상당히 번거롭습니다.

 

또 관리할 빈이 많아서 설정 정보가 커지면 설정 정보를 관리하는 것 자체가 부담이됩니다. 또한 결정적으로 자동 빈 등록을 사용해도 OCP, DIP를 지킬 수 없습니다.

 

 

그렇다면 수동 빈 등록은 언제 사용하면 좋을까요?

 

애플리케이션은 크게 업무 로직과 기술 지원 로직으로 나눌 수 있다고 합니다.

  • 업무 로직 빈 : 웹을 지원하는 컨트롤러, 핵심 비즈니스 로직이 있는 서비스, 데이터 계층의 로직(DAO와 비슷)을 처리하는 리포지토리등이 모두 업무 로직입니다. 보통 비즈니스 요구사항을 개발할 때 추가되거나 변경됩니다. 
  • 기술 지원 빈 : 기술적인 문제나 공통 관심사(AOP)를 처리할 떄 주로 사용됩니다. 데이터베이스 연결이나 공통 로그 처리 처럼 업무 로직을 지원하기 위한 하부 기술이나 공통 기술들입니다.
  • 업무 로직은 숫자도 매우 많고, 한번 개발하려면 컨트롤러, 서비스, 리포지토리 처럼 어느정도 유사한 패턴이 있습니다. 이런 경우 자동 기능을 적극 사용하는 것이 좋습니다. 보통 문제가 발생해도 어떤 곳에서 문제가 발생했는지 명확하게 파악하기 쉽습니다.
  • 기술 지원 로직은 업무 로직과 비교해서 그 수가 매우 적고 보통 애플리케이션 전반에 걸쳐서 광범위하게 영향을 미칩니다. 그리고 기술 지원 로직은 업무 로직에 비해 무넺가 발생했을 떄 파악하기 어려운 경우가 많습니다. 그래서 이런 기술 지원로직들은 가급적 수동 빈 등록을 사용해서 명확하게 들어내는 것이 좋습니다.

 

애플리케이션에 광범위하게 영향을 미치는 기술 지원 객체는 수동 빈으로 등록해서 딱! 설정 정보에 바로 나타나게 하는 것이 유지보수 하기 좋습니다.

 

비즈니스 로직 중에서 다형성을 적극 활용할 때

의존관계 자동 주입 - 조회한 빈이 모두 필요할 때, LIst, Map을 다시 봅시다.

DiscountService가 의존관계 자동 주입으로 Map<String, DiscountPolicy>에 주입을 받는 상황을 생각해봅시다. 여기에 어떤 빈들이 주입될 지, 각 빈들의 이름은 무엇일지 코드만 보고 한번에 쉽게 파악할 수 있을까요? 당연히 본인이 개발했으면 상관없겠지만, 다른 개발자가 개발을 했다면 어떨까요? 

자동 등록을 사용하고 있기 떄문에 파악하려면 여러 코드를 찾아봐야 합니다. 그렇기떄문에 수동 등록하는 것이 좋은 경우도 있습니다.

 

이런 경우 수동 빈으로 등록하거나 또는 자동으로하면 특정 패키지에 같이 묶어두는 것이 좋습니다. 여기서 핵심은 딱 보고 이해가 되어야 한다는 것 입니다.

 

이 부분을 별도의 설정 정보로 만들고 수동으로 등록하면 아래와 같습니다.

@Configuration
  public class DiscountPolicyConfig {
      @Bean
      public DiscountPolicy rateDiscountPolicy() {
          return new RateDiscountPolicy();
      }
      @Bean
      public DiscountPolicy fixDiscountPolicy() {
          return new FixDiscountPolicy();
      }
}

이 설정 정보만 봐도 한눈에 빈의 이름은 물론이고, 어떤 빈들이 주입될지 파악할 수 있다. 그래도 빈 자동 등록을 사용하고 싶으면 파악하기 좋게 DiscountPolicy 의 구현 빈들만 따로 모아서 특정 패키지에 모아두자.

 

참고로 스프링과 스프링 부트가 자동으로 등록하는 수 많은 빈들은 예외입니다. 이런 부분들은 스프링 자체를 잘 이해하고 스프링의 의도대로 잘 사용하는 것이 중요합니다. 스프링 부트의 경우 DataSource같은 데이터베이스 연결에 사용하는 기술 지원 로직까지 내부에서 자동으로 등록하는데, 이런 부분들은 메뉴얼을 잘 참고해서 스프링 부트가 의도한 대로 편리하게 사용하면 됩니다. 반면에 스프링 부트가 아니라 내가 직접 기술 지원 객체를 스프링 빈으로 등록한다면 수동으로 등록해서 명확하게 들어내는 것이 좋습니다.

 

 

 

정리

  • 편리한 자동 기능을 기본으로 사용합시다.
  • 직접 등록하는 기술 지원 객체는 수동으로 등록합시다.
  • 다형성을 적극 활용하는 비즈니스 로직은 수동 등록을 고민해봅시다.

 

 

 

 

 

출처

 

스프링 핵심 원리 - 기본편 - 인프런 | 강의

스프링 입문자가 예제를 만들어가면서 스프링의 핵심 원리를 이해하고, 스프링 기본기를 확실히 다질 수 있습니다., - 강의 소개 | 인프런...

www.inflearn.com

 

 

 

 

+ Recent posts