IT 서비스/단잠

[ 단잠 ] MSA에 Common 모듈 도입

agong이 2025. 4. 1. 20:39

🚨문제

단잠 개발하는 도중 모든 서비스에서 공통적으로 설정하고 사용하는 클래스들이 존재했습니다.

 ( 공통응답객체, BasteEntity, Code, GlobalException Handler, Security등 )

해당 클래스들은 global패키지안에 넣어 진행하고 있었습니다. 

 

그러던 도중 문제들이 터지기 시작했습니다.

바로 공통 클래스가 일관성이 유지되지 않는것이었습니다.


각 서비스가 수정사항이 생긴다면 복붙 해야합니다. 매우 번거롭기도 하고 복붙해야하는걸 잊으면 수정사항이 적용이 되지 않는 경우들이 생겼습니다.
또한 변경사항을 전달할때는 "어떤 서비스에서 global패키지 안에 어떤걸 넣어놨어요. 이러한 이유는~~~"
단순히 어떤 역할뿐아니라 어느 서비스에 global패키지 안에 추가되었다는걸 말해줘야했습니다.

이렇게 번거로우니 일관성이 유지되지 않을수 밖에없었습니다.

일관성이 깨짐

 

또한 기존에는 Gateway에서 jwt토큰을 검사한 뒤 Header에 user-id와user-role을 넘겨 각 서비스가 헤더의 값을 직접 사용해주었습니다.

이러한 이유는 각 서비스에서 별도의 security 설정을 할 필요없이 간단하게 사용할 수 있다는 장점이 있었습니다.

하지만 각 api마다 헤더에 있는 role값을 기준으로 서비스로직에서 권한을 체크해주어야 하였습니다.

기존에는 일관성을 유지하기 어렵고 번거롭기 때문에 별도의 Security 설정을 하지 않은채 Gateway에서 헤더로 전달받은 user정보와 role을 그대로 사용하였습니다.

 

이러한 문제를 해결하고 추후에 개발에 대한 편의성을 제공하기 위하여  멀티모듈을 도입하였습니다.

 

적용자체는 간단하였습니다. 

 

https://velog.io/@jthugg/spring-multi-module

 

[Spring] 스프링부트로 멀티 모듈 프로젝트 진행하기

스프링부트 프로젝트를 멀티 모듈 프로젝트로 만들어봅니다.

velog.io

 

사용자체는 여러글을 참고하여 구현하면 어렵지 않으니 주의깊게 고려한 부분을 말씀드리겠습니다.

 

1. Common 모듈 사용 규칙

Common모듈은 모든 서비스가 공통으로 사용하고 있습니다. 그렇기 때문에 함부로 추가하거나 수정하면 모든 서비스에 영향을 미치게 됩니다. 그러다보면 것잡을 수 없이 common모듈이 커지게되고 제 역할을 잃어버릴수있습니다.  

이러한 불상사를 막고자 다음과 같은규칙을 정하게 되었습니다.

 

1. 모든 서비스에 공통적으로 사용하는 클래스를 넣는다.

2. 변경이 적은 클래스만을 넣는다.

3. common 모듈의 변경은 최대한 적게 가져가도록 한다.

 

2. 시큐리티설정

기존에는 Gateway에서 jwt토큰을 검사한 뒤 Header에 user-id와user-role을 넘겨 각 서비스가 헤더의 값을 직접 사용해주었습니다.

 

여기에 common모듈에 security의존성을 추가한 뒤 Gateway에서 받은 헤더값을 SecurityContextHolder에 담는  별도의 커스텀 필터를 추가하여 동작하도록 하였습니다.

@Component
public class GlobalSecurityContextFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String role = request.getHeader("X-USER-ROLE");
        String userId = request.getHeader("X-USER-ID");

        if (role != null && userId != null) {
            try {
                Long id = Long.parseLong(userId);
                GlobalCustomUserDetails customUserDetails = new GlobalCustomUserDetails(role, id);
                Authentication authentication = new UsernamePasswordAuthenticationToken(customUserDetails, null,
                        customUserDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(authentication);
            } catch (NumberFormatException e) {
                // 로그만 남기고 계속 진행
            }
        }
        filterChain.doFilter(request, response);
    }
}

 

이렇게 설정을 해주게 되면 각 서비스에서는 모놀로틱 서비스처럼 동일하게 SecurityContextHolder에 담긴 CustomUserDetails정보를 가져와서 사용할 수있습니다. 또한 @PreAuthorize 메서드도 당연하게 사용가능합니다.

 @PreAuthorize("hasRole('AUTH_USER')")
    @GetMapping("/welcome")
    public String welcome(HttpServletRequest request, @AuthenticationPrincipal GlobalCustomUserDetails customUserDetails) {
        log.info("Server Port : {}", request.getServerPort());
        Long userId = customUserDetails.getUserId();
        String role = customUserDetails.getRole();
        String role2 = customUserDetails.getAuthorities().toString();
        return "Welcome to User Service : " + request.getServerPort() + "\nuserId : " +userId + "\nrole : "+ role + "\nrole2 : "+ role2;
    }

 

 

Common모듈 Security 관련 코드

더보기
@Getter
public class GlobalCustomUserDetails implements UserDetails {
    private final String role;
    private final Long userId;
    private final boolean enabled;

    public GlobalCustomUserDetails(String role, Long id) {
        this.role = role;
        this.userId = id;
        this.enabled = true;
    }

    // 필수 메서드 구현
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singletonList(
                new SimpleGrantedAuthority("ROLE_" + role)
        );
    }



    // 사용하지 않음
    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return userId.toString();
    }

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

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

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

    @Override
    public boolean isEnabled() {
        return enabled;
    }


}

 

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class GlobalSecurityConfig {

    private final GlobalSecurityContextFilter globalSecurityContextFilter;

    @Bean
    public SecurityFilterChain globalSecurityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .formLogin(formLogin -> formLogin.disable())  // 로그인 페이지 비활성화
                .httpBasic(httpBasic -> httpBasic.disable())  // HTTP 기본 인증 비활성화
                .logout(logout -> logout.disable())  // 로그아웃 기능 비활성화
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint((request, response, authException) ->
                                response.sendError(HttpServletResponse.SC_UNAUTHORIZED))  // 인증 실패 시 401 반환
                )
                .authorizeHttpRequests((auth) -> auth
                        .anyRequest().permitAll())
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(globalSecurityContextFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

 


@Component
public class GlobalSecurityContextFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String role = request.getHeader("X-USER-ROLE");
        String userId = request.getHeader("X-USER-ID");

        if (role != null && userId != null) {
            try {
                Long id = Long.parseLong(userId);
                GlobalCustomUserDetails customUserDetails = new GlobalCustomUserDetails(role, id);
                Authentication authentication = new UsernamePasswordAuthenticationToken(customUserDetails, null,
                        customUserDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(authentication);
            } catch (NumberFormatException e) {
                // 로그만 남기고 계속 진행
            }
        }
        filterChain.doFilter(request, response);
    }
}

 

정리📗

이번에는 MSA에서 공통의 common모듈을 적용한 이유에 대해 작성하였습니다.

미리 적용을 해두고 개발을 하면 일관성을 유지한 채 개발할 수 있는 좋은 방법인 것 같습니다. 

하지만 그만큼 모든 서비스에 common모듈이 적용되기 때문에 common모듈이 너무 많은 의존성을 가지는 것을 경계해야하고 꼭 필요한것만 넣어야할것 같습니다.

또한 common모듈을 역할에 따라 util모듈, security 모듈등으로 분리하는것도 서비스가 커지면 고려해봐야할 부분인것 같습니다.

 

그럼 끝!

 

 

참고 

- https://techblog.woowahan.com/2637/

 

멀티모듈 설계 이야기 with Spring, Gradle | 우아한형제들 기술블로그

멀티 모듈 설계 이야기 안녕하세요. 배달의민족 프론트 서버를 개발하고 있는 권용근입니다. 멀티 모듈의 개념을 처음알게 되었을 때부터 현재까지 겪었던 문제점들과 그것을 어떻게 해결해나

techblog.woowahan.com

- https://ksh-coding.tistory.com/136

 

[MSA] 개인 프로젝트 Monolithic to MSA 전환기 - (2) 멀티 모듈 구성하기

이제 본격적으로 모놀리식 아키텍쳐 프로젝트를 MSA 아키텍쳐로 전환해보고자 합니다! 이번 포스팅에서는 단일 모듈로 구성되어 있는 프로젝트를 멀티 모듈로 구성하는 과정을 담아보겠습니다.

ksh-coding.tistory.com