반응형

오류 처리는 더 견고한 서비스를 위해서 개발자에게 매우 중요합니다.

이번 시간에는 SpringBoot에서는 기본적으로 어떻게 오류 페이지를 처리하고 있는지, 어떻게 커스텀 하게 수정해서 오류 페이지를 관리하는지 알아보도록 하겠습니다.

 

1. SpringBoot에서는 기본적으로 BasicErrorController에서 오류를 관리하고 있습니다.

BasicErrorController.java

@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {

	private final ErrorProperties errorProperties;

	/**
	 * Create a new {@link BasicErrorController} instance.
	 * @param errorAttributes the error attributes
	 * @param errorProperties configuration properties
	 */
	public BasicErrorController(ErrorAttributes errorAttributes, ErrorProperties errorProperties) {
		this(errorAttributes, errorProperties, Collections.emptyList());
	}

	/**
	 * Create a new {@link BasicErrorController} instance.
	 * @param errorAttributes the error attributes
	 * @param errorProperties configuration properties
	 * @param errorViewResolvers error view resolvers
	 */
	public ₩(ErrorAttributes errorAttributes, ErrorProperties errorProperties,
			List<ErrorViewResolver> errorViewResolvers) {
		super(errorAttributes, errorViewResolvers);
		Assert.notNull(errorProperties, "ErrorProperties must not be null");
		this.errorProperties = errorProperties;
	}

	@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
	public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
		HttpStatus status = getStatus(request);
		Map<String, Object> model = Collections
				.unmodifiableMap(getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.TEXT_HTML)));
		response.setStatus(status.value());
		ModelAndView modelAndView = resolveErrorView(request, response, status, model);
		return (modelAndView != null) ? modelAndView : new ModelAndView("error", model);
	}

	@RequestMapping
	public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
		HttpStatus status = getStatus(request);
		if (status == HttpStatus.NO_CONTENT) {
			return new ResponseEntity<>(status);
		}
		Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
		return new ResponseEntity<>(body, status);
	}
   }

 - @RequestMapping("${server.error.path:${error.path:/error}}")

    -> 설정 파일에 error.path 값이 없으면 default로 /error를 사용합니다.

 - HTML로 응답을 주는 경우 errorHtml 메소드로 응답을 합니다.

 - HTML 외 응답은 error 메소드에서 처리합니다.

 

2. 에러 관련 설정 파일

application.yml

server:
  error:
    whitelabel:
      enabled: true           # 화이트 라벨 페이지 유무 (default : true) 
    include-stacktrace: never # 오류 응답에 stacktrace 내용을 포함할 지 여부 (default : always)
    path: /error              # 오류 응답을 처리할 핸들러(ErrorController) path (default : /error)

 

3. 에러 발생하면 에러 페이지 HTML을 노출시키도록 해보겠습니다.

개발 환경

  •  Spring 2.7.5
  •  Thymeleaf

 

기본적으로 오류 페이지를 노출시키는 방법은 간단합니다.

저는 Thymleaf를 사용하고 있기 때문에 resources > templates > error 디렉토리 아래에 에러 코드 이름으로

html 파일을 생성해주면 됩니다.

 * 400번대 에러코드를 모두 커버하고 싶다면 4xx.html로 생성하면 되겠습니다.

 

404.html

참 쉽죠? 이제 직접 ErrorController를 구현해서 에러 페이지에 데이터를 전달해 보도록 하겠습니다.

4. @ErrorController

WebErrorController.java

@Controller
public class WebErrorController implements ErrorController {

    @RequestMapping(value = "/error")
    public ModelAndView handleNoHandlerFoundException(HttpServletResponse response, HttpServletRequest request, Model model) {
        Object statusCode = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
        // int status = response.getStatus();

        ModelAndView modelAndView = new ModelAndView();

        if (statusCode != null) {
            Integer status = Integer.valueOf(statusCode.toString());
            if (status == HttpStatus.NOT_FOUND.value()) {
                modelAndView.addObject("errorCode", status);
                modelAndView.setViewName("/error/404");
            } else if (status == HttpStatus.INTERNAL_SERVER_ERROR.value()) {
                modelAndView.addObject("errorCode", status);
                modelAndView.setViewName("/error/500");
            } else if (status == HttpStatus.FORBIDDEN.value()) {
                modelAndView.setViewName("/error/403");
            } else modelAndView.setViewName("/error/common");
        }

        return modelAndView;
    }
}

 - ErrorController를 구현합니다.

 - request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE), response.getStatus()으로 에러 코드를 꺼냅니다.

 

 

404.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <h3>어이쿠 오류다!</h3>
    <h2 th:text="${errorCode}"></h2>
</body>
</html>

404.html

추가적으로

@ExceptionHandler를 사용해서 해당 애노테이션이 선언된 예외 및 하위 예외에 대해서 특정 메서드가 처리할 수 있도록 해줍니다.

상황에 맞추어서 리턴 형식을 반환할 수 있습니다.

    @ResponseStatus(HttpStatus.NOT_IMPLEMENTED)
    @ExceptionHandler(Exception.class)
    public Map<String, String> handle(Exception e) {
        Map<String, String> errorAttributes = new HashMap<>();
        errorAttributes.put("code", "NOT_IMPLEMENTED");
        errorAttributes.put("message", e.getMessage());
        return errorAttributes;
    }
728x90
반응형
반응형

어느 날 회사에서 시스템을 하나 더 운영하게 되었다!

인수인계도 없이... 

그러던 어느 날 서비스에 접속이 안된다는 연락을 받고 확인해 보니 서버가 죽진 않았는데 멈췄다.. 뭐지..

서버에 들어가 보자 윈도우 CMD 창으로 띄어진 서버..

일단 Tomcat 재실행으로 문제 해결 후 에러 로그를 확인하려는 찰나.. 로그가 없다.. 

산출물 확인해 보자! CMD 창이 선택 tomcat으로 되어 있다면 서버가 멈추니 CMD 창 클릭 후 엔터를 치라고 되어있다...

이 문제라면 CMD 창이 아니라 서버 구동 방식으로 윈도우 서비스 구동방식으로 변경해야겠다.

 

조치 1. 윈도우 서비스 구동방식 변경 순서

 

1. 서비스 이름 수정

  - 경로 : Tomcat > bin > service.bat

  - SERVICE_NAME, DISPLAYNAME 수정

 

2. service.bat install

 

3. 생성된 서비스에서 시작 유형 자동으로 수정 후 시작

 

그리고 한 달 뒤.. 같은 문제 재발생..

서비스 구동방식으로 변경 후 tomcat log가 추가적으로 더 생성되었다.

로그 파일에 increasing the maximum size of the cache 로그 확인

 

조치 2. Tomcat 설정 수정

Tomcat 캐시 메모리 설정 변경

 

1. tomcat 서버 > conf > context.xml 파일에 아래 문구 추가

 

 

<WatchedResource>WEB-INF/web.xml</WatchedResource>
<WatchedResource>${catalina.base}/conf/web.xml</WatchedResource>
<Resources cacheMaxSize="100000" cachingAllowed="true"/>

 

이후 멈춤 현상은 발생하지 않고 있습니다!

같은 증상 발생할 경우 다시 찾아오도록 하겠습니다.

다시 안 돌아오길..

728x90
반응형
반응형

아키텍처 흐름

  1. 어느 부분에서 Authentication이 저장되는가? 
  2. AuthenticationManager를 통해서 인증된다.(Authentication)
  3. 결과로 나온 Authentication을 다시 SecurityContextHolder에 저장은 어디서?
  4. Authentication은 UsernamePasswordAuthenticationFilter, SecurityContextPersisenceFilter 에서 저장되는데 그러면 이 필터들은 어디에서 등록되는가?
  5. WebSecurityConfigurerAdapter를 상속받은 SecurityConfig에 설정한 정보가 Filter에 등록되는데 어떻게 url로 요청하면 FilterChainProxy 들어왔는가?
  6. DelegatingFilterProxy를 통해서 FilterChainProxy에 들어온다.
  7. 권한 확인(인가)은 어디서 이루어지는가? AccessDecisionManager
  8. AccessDecisionManager는 어디서 사용하고 있는가? FilterSecurityInterceptor
  9. 인증과 인가처리에 발생한 에러가 어떻게 처리되는지? ExceptionTranslationFilter

SecurityContextHolder와 Authentication

SecurityContextHolder

  • SecurityContext 제공, 기본적으로 ThreadLocal를 사용
  • 한 Thread에 특화되어 있는 정보
  • application 어디에서나 꺼내서 사용할 수 있다.
  • SampleService.findSercurityContextHolder() 참고

SecurityContext

  • Authentication 제공.

Authentication

  • Principal과 GrantAuthority 제공.

Principal

  • "누구"에 해당하는 정보
  • UserDetailsService에서 리턴한 그 객체(AccountService.class 참고)
  • 객체는 Userdetails 타입

GrantAuthority

  • "ROLE_USER", "ROLE_ADMIN"등 Principal이 가지고 있는 "권한"을 나타낸다.
  • 인증 이후, 인가 및 권한 확인할때 이 정보를 참조한다.

UserDetails

  • 어플리케이션이 가지고 있는 유저 정보와 스프링 시큐리티가 사용하는 Authentication 객체 사이의 어댑처

UserDetailsService

  • 유저 정보를 UserDetails 타입으로 가져오는 DAO 인터페이스
  • 구현은 마음대로

AuthenticationManager(인증할 때 사용)와 Authentication

  • 스프링 시큐리티에서 인증(Authentication)은 AuthenticationManager가 한다.

Authentication과 SecurityContextHolder

  • UsernamePasswordAuthenticationFilter
    • 폼 인증을 처리하는 시큐리티 필터
    • 인증된 Authentication 객체를 SecurityContextHolder에 넣어주는 필터
    • SecurityContextHolder.getContext().setAuthentication(authentication)
  • SecurityContextPersisenceFilter
    • SecurityContext를 HTTP session에 캐시(기본 전략)하여 여러 요청에서 Authentication을 공유할 수 있는 공유 필터
    • SecurityContextRepository를 교체하여 세션을 HTTP session이 아닌 다른 곳에 저장하는 것도 가능하다.
    • 같은 session에서만 공유된다.

스프링 시큐리티 Filter와 FilterChainProxy

  • 스프링 시큐리티 필터는 FilterChainProxy가 호출한다.
  • 여기에 등록되는 필터들은 SecurityConfig에서 설정한 정보가 SecurityFilterChain을 만드는데 사용된다.(FilterChainProxy.getFilters에 SecurityFilterChain)
  • SecurityConfig 설정에 따라서 등록되는 Filter에 개수가 달라진다.

DelegatingFilterProxy와 FilterChainProxy

DelegatingFilterProxy

  • 일반적인 서블릿 필터(위에서 살펴본 다른 필터들과 같은 서블릿 필터지만 서블릿에 직접 등록되는 필터)
  • 서블릿 필터 처리를 스프링에 들어있는 빈으로 위이함고 싶을 때 사용하는 서블릿 필터
  • 타겟 빈 이름을 설정한다.
  • 스프링 부트 없이 스프링 시큐리티 설정할 때는 AbstractSecurityWebApplicationInitializer를 사용해서 등록
  • 스프링 부트를 사용할 때는 자동으로 등록된다. (SecurityFilterAutoConfiguration)

FilterChainProxy

  • 보통 "springSecurityFilterChain" 이라는 이름의 빈으로 등록된다.
  • DelegatingFilterProxy

AccessDecisionManager

Access Control 결정을 내리는 인터페이스로, 구현체 3가지를 기본적으로 제공

  • AffirmativeBased : 여러 Voter중에 한명이라도 허용하면 허용, 기본 전략
  • ConsensusBased : 다수결
  • UnanimousBased : 만장일치

AccessDecisionVoter

  • 해당 Authentication이 특정한 Object에 접근할 때 필요한 ConfigAttributes를 만족하는지 확인한다.
  • WebExpressionVoter : 웹 시큐리티에서 사용하는 기본 구현체, ROLE_Xxxx가 매치하는지 확인
  • RoleHierarchyVoter : 계층형 ROLE 지원, ADMIN > MANAGER > USER

FilterSecurityInterceptor

  • AccessDecisionManager를 사용하여 Access Control또는 예외 처리하는 필터.
  • 대부분의 경우 FilterChainProxy에 제일 마지막 필터로 들어있다.

ExceptionTranslationFilter

AuthenticationException

  • AuthenticationEntryPoint 실행
  • AbstractSecurityInterceptor 하위 클래스(예, FilterSecurityInterceptor)에서 발생하는 예외만 처리
  • 그렇다면 UsernamePasswordAuthenticationFilter에서 발생한 인증 에러는? UsernamePasswordAuthenticationFilter 자체에서 처리한다.

AccessDeniedException

  • 익명 사용자라면 AuthenticationEntryPoint 실행
  • 익명 사용자가 아니라면 AccessDeniedHandler에게 위임

정리

DeligatingFilterProxy -> FilterChaninProxy -> 시큐리티 필터 목록들(체인들은 어떻게 만들어지는가? WebSecurity, HttpSecurity를 이용해서 만들어진다. 참고 - WebSecurity 주석) -> 인증 관련된 객체(AuthenticationManager) -> 인가 관련된 객체(AccessDecisionManager) -> SecurityContextHolder -> SecurityContext -> Authentication -> Pricipal, GrantAuthority

 

WebSecurity.java

 

참조 - 스프링 시큐리티(inflearn 백기선님 강의)

728x90
반응형
반응형

토이 프로젝트로 혼자서 클라우드 서비스를 이용하여 웹 개발부터 배포까지 온 과정을 경험해 보았습니다.

이 과정을 단계별로 나누어서 정리해 보려고 합니다.!

많은 피드백은 감사합니다!

 

목차 

STEP 01) NCP 서버 

STEP 02) AWS RDS, S3

STEP 03) Web Application 개발

STEP 04) Jenkins pipeline 배포

STEP 05) Domain 등록

 


이번에는 AWS Route53 서비스를 이용해서 도메인을 등록해보겠습니다.

 

가비아에서 구매 한 도메인을 AWS Route53에 등록해보겠습니다.

Route53에서도 도메인 구매가 가능합니다

 

1. 호스팅 영역 > 호스팅 영역 생성

 

 

2. 도메인 정보 입력

  - 도메인 이름 : 구매한 도메인

  - EC2 퍼블릭 IP

  - 퍼블릭 호스팅 영역 : 인터넷에서 트래픽을 라우팅하고자 하는 방법을 지정하는 레코드를 포함합니다.

 

3. NameServer 등록

  - NS : 네임서버 레코드로 도메인에 대한 네임서버의 권한을 가지고 있는지 알려주는 레코드

  - SOA : 도메인의 정보를 가지고 있는 레코드

 

 

4. 가비아에 AWS 네임서버 등록

  - 호스팅업체의 네임서버를 사용해도 무방합니다.

 

가비아에 등록된 네임서버

5. 도메인과 EC2 인스턴스 IP 연결

  - 레코드 생성 클릭

 

 

레코드 이름 : 라우팅할 이름을 설정합니다.

레코드 유형 : ec2로 라우팅할 경우 ipv4로 라우팅합니다.

값 : ec2 인스턴스 퍼블릭 IP

TTL : DNS에 ip 주소를 저장하는 시간

 

 

레코드를 생성하면 시간이 조금 지나서 등록한 도메인으로 접속이 가능합니다!

Route53은 Free tier 사용하더라도 월별 호스팅 가격 및 쿼리(도메인을 통해서 AWS에 접속하는 횟수) 비용이 발생합니다.

  - Rout53 요금제

728x90
반응형
반응형

토이 프로젝트로 혼자서 클라우드 서비스를 이용하여 웹 개발부터 배포까지 온 과정을 경험해 보았습니다.

이 과정을 단계별로 나누어서 정리해 보려고 합니다.!

많은 피드백은 감사합니다!

 

목차 

STEP 01) NCP 서버 

STEP 02) AWS RDS, S3

STEP 03) Web Application 개발

STEP 04) Jenkins pipeline 배포

STEP 05) Domain 등록


이번에는 젠킨스 파이프라인을 구축해보도록 하겠습니다.

 

젠킨스 파이프라인 구성 순서

1. pipeline 구조 생성

2. github clone

3. gradle build

4. ssh를 이용해서 파일 전송 후 applicaton 기동

 

1. pipeline 구조 생성

젠킨스 파이프라인 구성에 앞서 구조부터 생성해보도록 하겠습니다.

 

1.1) pipeline 생성

Dashboard > 새로운 Item > name 작성하고 OK

 

 

1.2) 샘플을 실행 시켜봅시다. (Advanced Project Options 탭에서 우측상단의 Hello Wold 선택하고 저장)

 

1.3) 파이프라인 실행

  - 파이프라인 구조를 생성하고 샘플 Script를 실행해보았습니다. 이제 본격으로 파이프라인 구성을 해보도록 하겠습니다.

 

2. github clone

jenkins pipeline은 Pipeline Syntax를 이용해서 Script를 생성해서 구성합니다.

 

2.1) Pipeline Syntax > Sample Step(git: Git 선택 혹시 안보이신다면 jenkins관리에서 github plugin을 설치하시기 바랍니다.)

 

2.2) github repository url을 작성하시고 배포할 Branch를 선택합니다.

 

2.3) 첫 Jenkins 세팅이라면 Credentials 없으실텐데요. 아래 Add > Jenkins 클릭하시면 아래와 같은 창이 나옵니다.

    - Kind를 Username with password를 선택 후 Password에는 github token을 넣어줍니다. github token 발급은 바로 아래에서 설명드리겠습니다.

    - ID는 중복되지 않도록 작성하시면 됩니다.

 

2.4) github token 발급

github > Settings > 오르쪽 카테고리에서 Developer settings 선택

 

 

Note - token을 구분할 수 있도록 작성하고 Expiration으로 토큰의 기간을 정한다.

Select scopes - 필요한 권한을 체크한다.(저는 repo, admin:repo_hook 체크)

repo : repository 권한

admin:repo_hook : webhook에 필요한 hook 권한

 

Generate token을 클릭하면 token이 발급됩니다.

아래와같이 발급된 토큰을 복사해서 사용하면됩니다. 토큰 기간만료 또는 분실시 같은 방법으로 토큰을 발급받으면 됩니다.

 

 

다시 jenkins로 위에서 추가한 Credentials를 선택하고 Generate Pipeline Script 클릭하면 Script가 생성됩니다.

 

 

Syntax에서 생성한 Script를 pipeline으로 가져와서 그대로 붙여줍니다.

 

 

github clone에 성공하였습니다!

 

 

3. gradle build

build stage를 추가하도록 하겠습니다.

  - build는 clone으로 가져온 소스에 포함되어 있는 gradle wrapper를 이용합니다. 자신의 소스코드에 맞게 위치를 지정해서 gradle          build를 해주시면 됩니다.

 

 

build까지 성공하였습니다! 애플리케이션이 배포되기까지 거의 다 왔습니다!

 

 

4. ssh를 이용해서 파일 전송 후 applicaton 기동

빌드된 파일을 전달하기위해서는 jenkins에 publish over ssh plugin이 설치되어있어야 합니다.

 

플러그인이 설치되면 Dashboard > Jenkins 관리 > Configure System으로 이동해서 Publish over SSH에 서버 정보를 입력하면 됩니다.

 

저는 AWS EC2 서버를 사용하고있습니다.

EC2 접속에 필요한 pem 키를 Key에 붙여넣어줍니다.

Name : syntax에서 참조될 이름

Hostname : private ip

Username : ec2에서 사용되는 username

Remote Dircetory : 베이스 디렉토리(참고, 이 디렉터리 기준으로 파일이 전송되고, 스크립트가 실행된다.)

 

 

모두 작성하시고 Test Configuration를 클릭하시면 문제 없으면 Success가 표시됩니다.

 

Pipeline Syntax로 돌아가서 Step : sshPublisher: Send build artifacts over SSH 선택

Souce files : 빌드된 파일 위치입니다.

Remove prefix : 소스파일에서 원본 파일의 디렉토리를 어디까지 포함할 것인지 설정입니다.(여기서는 jar 파일 하나만 선택되도록 설정)

Remote directory : 위에서 선택된 jar 파일을 해당 디렉터리 아래에 위치시킵니다.

Exec command : 파일을 전송한 다음 실행할 shell

 

마지막으로 Pipeline에 stage를 추가해서 위 script를 붙여줍니다.

 

최종 pipeline script

pipeline {
    agent any

    stages {
        stage('github clone') {
            steps {
                git credentialsId: 'tutorial-jenkins-token', url: 'https://github.com/kgc0120/daily_special.git'
            }
        }
        
        stage('build'){
            steps{
                sh'''
                    echo build start
                    ./gradlew clean bootJar
                '''
            }
        }
        
        stage('publish over ssh'){
            steps{
                sshPublisher(publishers: [sshPublisherDesc(configName: 'aws-daily-special'
                , transfers: [sshTransfer(cleanRemote: false
                , excludes: ''
                , execCommand: 'sh /dailySpecial/app/nonstop/deploy.sh'
                , execTimeout: 120000, flatten: false, makeEmptyDirs: false
                , noDefaultExcludes: false
                , patternSeparator: '[, ]+'
                , remoteDirectory: '/app/nonstop/springboot-webservice/build/libs'
                , remoteDirectorySDF: false
                , removePrefix: 'build/libs', sourceFiles: 'build/libs/*.jar')]
                , usePromotionTimestamp: false
                , useWorkspaceInPromotion: false
                , verbose: false
                )])
            }
        }
    }
}

 

pipeline을 실행시켜보면 서버 배포까지 정상적으로 성공하였습니다!!

 

 

정말 시행착오도 많았고 길었던 Jenkins pipeline 구축이었습니다... ㅜ

다음번에는 github webhooke을 이용해서 소스코드를 push 하면 젠킨시가 자동으로 배포되도록 해보겠습니다.

728x90
반응형
반응형

Spring에서 @Value 애노테이션은 설정 파일(yml, properties)에 있는 정보를 가져오는데 주로 사용됩니다.

@Value로 설정 파일 값을 가져오는 변수가 null인 경우 점검해보아야 하는 부분을 정리하였습니다. 

 

1. 어느 @Value 애노테이션을 사용했는지 import 확인

lombok이 아닌 spring 애노테이션을 사용하자

import org.springframework.beans.factory.annotation.Value;

 

2. static 변수에는 값을 넣을 수 없다.

예시는 application.properties 파일에서 active.root 값을 Config class에서 path 변수에 저장해서 사용하고 있습니다.

path 변수를 클래스 변수(static 변수)로 지정해서 테스트 해보도록 하겠습니다.

 

Config.java

@Component
public class Config {

    @Value("${active.root}")
    private static String path;

    public String getPath() {
        return path;
    }
}

 

application.properties

active.root=home

 

Test Code

@SpringBootTest
class ConfigServiceTest {

    @Autowired
    Config config;

    @Test
    void findActiveRootTest() {
        String root = "home";
        Assertions.assertEquals(config.getPath(), root);
    }

}

 

Test Code 결과

org.opentest4j.AssertionFailedError: 
Expected :null
Actual   :home

 

null 이 출력되는 것을 확인할 수 있습니다.

Config class에서 static 변수를 일반 전역 변수로 수정하면 문제를 해결됩니다.

 

3. Spring Bean으로 생성된 객체가 아닌 경우

Spring은 애플리케이션 로딩 시점에 스프링 컨테이너 내부에서 모든 빈들을 등록하면서 @Value 애노테이션 안의 값들을 설정 파일에서 읽어들여 변수에 저장합니다.

그 결과 Spring에서 싱글톤으로 관리되는 빈이 아닌 새로운 객체로 생성하게 되면 @Value 애노테이션으로 설정 파일을 읽어들이는 변수는 null로 값이 저장되지 않습니다.

 

Test Code

  - @Autowired를 제거하고 new를 이용해서 Config 인스턴스를 생성하였습니다.

@SpringBootTest
class ConfigServiceTest {

    @Test
    void findActiveRootTest() {

        Config config = new Config();

        String root = "home";
        Assertions.assertEquals(config.getPath(), root);
    }

}

 

Test Code 결과

org.opentest4j.AssertionFailedError: 
Expected :null
Actual   :home

 

Test Code

 - 정상으로 값이 저장되는 경우

 - new 연산자를 이용해서 인스턴스를 생성해서 사용하고 있는 것은 아닌지 확인해 보자!

@SpringBootTest
class ConfigServiceTest {

    @Autowired
    Config config;

    @Test
    void findActiveRootTest() {
        String root = "home";
        Assertions.assertEquals(config.getPath(), root);
    }

}
728x90
반응형
반응형

이번 시간에는 Spring Framework에서 동시성 문제를 해결해 줄 ThreadLocal에 대해서 알아보겠습니다.

스프링은 빈을 싱글톤으로 등록한다. 애플리케이션에 딱 1개만 존재한다는 뜻이다.

이렇게 하나만 있는 인스턴스에 하나의 필드에 여러 쓰레드가 동시에 접근하여 수정하게 되면 A 사용자가 B 사용자의 데이터를 보게 되는 사고가 발생될 수 있다. 아래 목차와 같이 순서대로 동시성 원인부터 해결 방안까지 알아보겠습니다. 


<목차>

1. 동시성 문제 발생하는 예시

2. ThreadLocal을 이용해서 동시성 문제 해결 방안

3. ThreadLocal의 주의사항

 


1. 동시성 문제 발생하는 예시

FieldService.java

  1. nameStore을 맴버변수로 선언

  2. nameStore 변수에 값을 저장하고 1초 뒤에 저장한 nameStore 출력

 

@Slf4j
public class FieldService {

    private String nameStore;

    public String logic(String name) {
        log.info("저장 name={} -> nameStore={}", name, nameStore);
        nameStore = name;
        sleep(1000);
        log.info("조회 nameStore = {}", nameStore);
        return nameStore;
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

 

FieldServiceTest.java

  1. threadA.setName("thread-A");   -- Thread 이름 설정

  2. Thread A 시작하고 2초 잠시 멈추기 때문에 동시성 발생 안함.

  3. Thread B 시작하고 sleep(2000) 안하면 쓰레드 끝나기 전에 테스트 코드가 종료되서 모든 로그가 출력이 안되서 추가해야 한다.

 

@Slf4j
public class FieldServiceTest {

    private FieldService fieldService = new FieldService();

    @Test
    void field() {
        log.info("main start");

        Runnable userA = () -> {
            fieldService.logic("userA");
        };
        Runnable userB = () -> {
            fieldService.logic("userB");
        };

        Thread threadA = new Thread(userA);
        threadA.setName("thread-A"); // 1. thread 이름 설정
        Thread threadB = new Thread(userB);
        threadB.setName("thread-B");

        threadA.start();
        sleep(2000); // 2. 동시성 문제 발생x
        threadB.start();

        sleep(2000); // 3. 메인 쓰레드 종료 대기
        log.info("main exit");
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

 

TestCode 결과

  1. thead-A, thread-B 위에서 설정한 이름

  2. 정상적으로 thread-A에 userA thread-B에 userB 출력

 

 

동시성 문제 발생

FieldServiceTest.java

  1. ThreadA가 시작하고 0.1초만에 ThreadB 접근

 

@Slf4j
public class FieldServiceTest {

    private FieldService fieldService = new FieldService();

    @Test
    void field() {
        log.info("main start");

        Runnable userA = () -> {
            fieldService.logic("userA");
        };
        Runnable userB = () -> {
            fieldService.logic("userB");
        };

        Thread threadA = new Thread(userA);
        threadA.setName("thread-A");
        Thread threadB = new Thread(userB);
        threadB.setName("thread-B");

        threadA.start();
        sleep(100); // 1. 동시성 문제 발생
        threadB.start();

        sleep(2000);
        log.info("main exit");
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

 

TestCode 결과

  1. thread-A에 조회할 때 그 전에 thread-B가 nameStore을 수정해서 동시성 문제 발생 

 

 

 


2. ThreadLocal을 이용해서 동시성 문제 해결 방안

ThreadLocal은 해당 쓰레드만 접근할 수 있는 별도의 특별한 내부 저장소이다.

 

ThreadLocalService.java

  1. String nameStore -> ThreadLocal<String> nameStore 변경

  2. nameStore.set() - 값 저장

      nameStore.get() - 값 조회

      nameStore.remove() - 값 제거

 

@Slf4j
public class ThreadLocalService {

    private ThreadLocal<String> nameStore = new ThreadLocal<>();

    public String logic(String name) {
        log.info("저장 name={} -> nameStore={}", name, nameStore.get());
        nameStore.set(name);
        sleep(1000);
        log.info("조회 nameStore = {}", nameStore.get());
        return nameStore.get();
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

 

ThreadLocalServiceTest.java

  1. FieldService만 ThreadLocalService로 수정

 

@Slf4j
public class ThreadLocalServiceTest {

    private ThreadLocalService threadLocal = new ThreadLocalService();

    @Test
    void field() {
        log.info("main start");

        Runnable userA = () -> {
            threadLocal.logic("userA");
        };
        Runnable userB = () -> {
            threadLocal.logic("userB");
        };

        Thread threadA = new Thread(userA);
        threadA.setName("thread-A");
        Thread threadB = new Thread(userB);
        threadB.setName("thread-B");

        threadA.start();
        sleep(100); // 동시성 문제 발생x
        threadB.start();

        sleep(2000); // 메인 쓰레드 종료 대기
        log.info("main exit");
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

 

TestCode 결과

  1. ThreadLocal이라는 별도의 쓰레드 저장소를 사용함으로써 동시성 문제를 해결하였습니다.

 

 


3. ThreadLocal의 주의사항

ThreadLocal 값을 사용 후 제거하지 않고 그냥 두면 WAS에서 쓰레드 풀을 사용하는 경우 심각한 문제가 발생할 수 있다.

보통 was는 쓰레드 생성 비용이 비싸기 때문에 쓰레드를 사용하면 반납하고 재사용하게 된다. 

그러므로 thead-A를 사용하고 쓰레드 값을 제거하지 않을 상태에서 반납 후 다른 사용자 HTTP 요청이 thead-A에 할당되면 thead-A 보관소에 그대로 값이 있어서 다른 사용자에게 잘못된 정보가 노출될 수 있다.

이런 문제를 예방하기 위해서는 사용자 요청이 끝날 때 쓰레드 로컬의 값을 ThreadLocal.remove()를 통해서 꼭 제거해야 한다.

* Spring Framework 같은 경우 Filter나 Interceptor를 이용해서 깔끔하게 제거해 주는 것이 안전하다.

 

 

 

참조

  - inflearn 강의(스프링 핵심 원리 - 고급편, 김영한)

728x90
반응형
반응형

토이 프로젝트로 혼자서 클라우드 서비스를 이용하여 웹 개발부터 배포까지 온 과정을 경험해 보았습니다.

이 과정을 단계별로 나누어서 정리해 보려고 합니다.!

많은 피드백은 감사합니다!

 

목차 

STEP 01) NCP 서버 

STEP 02) AWS RDS, S3

STEP 03) Web Application 개발

STEP 04) Jenkins pipeline 배포

STEP 05) Domain 등록

 

 


 

* Web Application 개발은 앞에서 살펴보았던 AWS 서비스를 사용하는 방법만 소개하도록 하겠습니다.

 

개발 환경

 - SpringBoot 2.6.6

 - Mariadb 2.7.5

 - Gradle 7.4.1

 

1. RDS 연결(yml 설정)

  - 이전 RDS 설정 확인

spring:
  datasource:
    url: jdbc:mariadb://rds end point 주소 + prot + schema name
      예) jdbc:mariadb://rds.amazonaws.com:3306/rdstest
    driver-class-name: org.mariadb.jdbc.Driver
    username: admin
    password: 패스워드

 

2. S3 연결

  - 이전 S3 설정 확인

1.  spring cloud starter 의존성 추가

  - build.gradlew

implementation 'io.awspring.cloud:spring-cloud-starter-aws:2.3.1'

 

2. yml 설정

cloud:
  aws:
    credentials:
      access-key: access key
      secret-key: secret key
    s3:
      region: ap-northeast-2
      endpoint: s3-bucket

 

  - access key, secrey key 새 액세스 키 만들기로 생성

 

 

- region, endpoint 작성

  endpoint는 :::뒤에 복/붙

 

AwsS3Config.java

 

package com.bumblebee.dailyspecial.domain.aws;

import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author bumblebee
 */
@Configuration
public class AwsS3Config {

    @Value("${cloud.aws.credentials.access-key}")
    private String accessKey;

    @Value("${cloud.aws.credentials.secret-key}")
    private String secretKey;

    @Value("${cloud.aws.s3.region}")
    private String region;

    @Bean
    public AmazonS3Client amazonS3Client() {
        BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey);
        return (AmazonS3Client) AmazonS3ClientBuilder.standard()
                .withRegion(region)
                .withCredentials(new AWSStaticCredentialsProvider(awsCreds))
                .build();
    }

}

 

AwsS3Service.java

 

package com.bumblebee.dailyspecial.domain.aws;

import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.bumblebee.dailyspecial.domain.comutils.CommonUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.io.InputStream;

/**
 * @author bumblebee
 */
@Slf4j
@RequiredArgsConstructor
@Service
public class AwsS3Service {

    private final AmazonS3Client amazonS3Client;

    @Value("${cloud.aws.s3.endpoint}")
    private String bucketName;

    public String uploadFileV1(String category, MultipartFile multipartFile) {
        validateFileExists(multipartFile);

        String fileName = CommonUtils.buildFileName(category, multipartFile.getOriginalFilename());

        ObjectMetadata objectMetadata = new ObjectMetadata();
        objectMetadata.setContentType(multipartFile.getContentType());

        try (InputStream inputStream = multipartFile.getInputStream()) {
            amazonS3Client.putObject(new PutObjectRequest(bucketName, fileName, inputStream, objectMetadata)
                    .withCannedAcl(CannedAccessControlList.PublicRead));
        } catch (IOException e) {
//            throw new FileUploadFailedException();
        }

        return amazonS3Client.getUrl(bucketName, fileName).toString();
    }

    private void validateFileExists(MultipartFile multipartFile) {
        if (multipartFile.isEmpty()) {
//            throw new EmptyFileException();
        }
    }

}

 

AwsS3Config class에 amazonS3Client 메소드를 @Bean으로 등록합니다.

파일을 업로드가 필요한 로직에 AwsS3Service class에서 uploadFIleV1 메소드를 이용해서 S3에 업로드 합니다.

예)

awsS3Service.uploadFileV1("Img", multipartFile);

 

그 외 다운로드 및 다중 업로드 기능도 제공하고 있습니다.

 

참조

  - Springboot로 S3 파일 업로드하기

728x90
반응형

+ Recent posts