2017년 6월 29일 목요일

Gradle 배포 환경 별 설정파일 분리

대부분 웹 애플리케이션을 개발할 시 DB를 개발용과 운영용으로 구분하여 개발하는데 WAS에 DB커넥션풀을 생성한 뒤 JNDI로 설정해두고 Spring의 context.xml에서 데이터 소스로 가져와서 쓰게 하는 방법을 쓰지 않는 한…. 소스 안에 직접 DB접근정보들을 프로퍼티로 해서 넣게 된다.

이렇게 하면 운영환경에 배포할 때마다 일일히 이 프로퍼티 파일의 개발DB 정보를 운영DB 정보로 바꿔서 올려야 하는 수고를 하게 된다.

회사 플젝의 경우 DB커넥션풀은 대부분 JNDI로 설정해두고 쓸거고(…아닌가?) 개인적으로도 커넥션풀은 WAS에서 생성해두고 쓰는게 낫다고 생각하고 있는데… 개인 플젝은 헤로쿠(heroku.com)에 올릴 것인데 이게 JNDI 설정하기가 좀 까다로운 거 같아서(사실 아직 방법을 못찾았다) 어쩔 수 없이 DB접근정보를 웹 애플리케이션 내부 프로퍼티 파일에 넣어놓고 deploy 하게 되었다.

배포환경 별로 설정파일을 분리하는 방법은 여러가지가 있는데 그냥 properties 파일 안에 개발인지 운영인지 구분하는 프로퍼티를 설정하고 deploy 할 때마다 그 프로퍼티만 dev에서 live로 바꿔서 올리는 방법이 있고 WAS 기동시 argument를 넘겨서 Spring의 context.xml에서 인식하게 하여 구분하게 하는 방법, 그리고 maven이나 gradle 로 빌드할 때 프로퍼티를 줘서 build를 배포환경 별로 달리 수행되게 하는 방법이 있는데 내가 보기엔 마지막 방법이 가장 나아보인다. 왜냐면 첫번째나 두번째 방법은 운영에 배포할 때 개발환경의 프로퍼티 파일이 war에 포함되서 결국 더미 파일을 가지고 있을 수 밖에 없는데 maven이나 gradle를 활용하는 방법은 아예 배포환경에 필요한 파일들만 포함시켜서 deploy 할 수 있기 때문이다.

gradle을 사용하여 설정파일 분리하는 방법이다.

1. 우선 main 경로 밑에 기존의 resources 디렉터리 외 임의의 다른 디렉터리를 만들고, 그 밑에 기존의 프로퍼티 파일을 개발용 운영용으로 분리하여 집어넣는다.

resources-env라는 디렉터리를 만들고 그 밑에 개발용과 운영용을 분리하여 ‘dev’ 와 ‘live’ 디렉터리를 만들고 프로퍼티 파일들을 옮겨 놓았다.

2. build.gradle에서 빌드대상 소스파일들의 경로를 설정한 아래 부분을

1
2
3
4
sourceSets {
 main.java.srcDirs=['src/main/java']
 main.resources.srcDirs=['src/main/resources']
}
cs

아래와 같이 바꿔준다. 상단의 조건문은 ‘profile’이라는 프로퍼티가 없거나 profile의 값이 없을 때는 profile의 값을 기본 ‘dev’로 정하는 내용이고 전달되어오는 profile 프로퍼티의 값에 따라서…

resources-env/dev 혹은 resources-env/live 둘 중 한 곳만 war에 포함되어 빌드 될 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
sourceSets {
 if (!project.hasProperty('profile'|| !profile) {
     ext.profile = 'dev'
 }
 main {
     java {
         srcDirs "src/main/java"
     }
     resources {
         srcDirs "src/main/resources""src/main/resources-env/${profile}"
     }
 }
}
cs

3. gradle 명령어로 빌드할 때… 아래와 같이 프로퍼티 붙여서 수행하면 잘 된다.

  • 개발 : gradle build -Pprofile=dev
  • 운영 : gradle build -Pprofile=live

2017년 6월 27일 화요일

Spring MVC에서 페이스북 소셜로그인 구현

개인 프로젝트에서 페이스북 소셜로그인을 구현하려는데 해보니까 진짜 쉽지만 초반에는 하루 이틀정도 헤멨던 것 같다. Spring.io에서 예제들을 열심히 찾았지만 Spring boot 사용을 전제로 해서 내놓은 것이라서 예시에서는 자동적으로 되던 것이 당연히 내 프로젝트에서는 안됐고 적지않은 구글링을 했어야 했다.

내가 구현해놓은 방식이 완전히 잘했다고는 생각지는 않지만 까먹지 않기 위해 기록해둔다.

페이스북 로그인은 Spring 공식 프로젝트로 되어 있다.

스프링 소셜 페이스북 프로젝트 사이트로 들어가서 위와 같이 메이븐 혹은 gradle의 파일에 페이스북 라이브러리의 의존성을 넣어준다. 내 프로젝트는 gradle을 사용하고 있기 때문에 build.gradle 파일의 dependencies 항목에 위 내용을 복사해서 집어넣어줬다.

그리고 페이스북 개발자 사이트로 접속한다. 주소는 https://developers.facebook.com 이다.

페이스북 개발자 사이트에서 페이스북 계정으로 로그인 한 다음 새 앱을 만들고 앱의 대시보드에서 제품 추가를 하여 Facebook 로그인을 추가한다. 클라이언트 OAuth 설정에서 클라이언트와 웹 OAuth 로그인을 활성화하고 리디렉션 URI도 설정해준다. 대충 아래의 화면 처럼 될 것이다.

아직 로컬에서만 돌아가고 있어서 유효한 리디렉션 URI에는 그냥 로컬 주소로 써넣었는데 잘 돌아간다.

그리고 설정 > 기본 설정에서 앱 ID와 앱 시크릿코드를 메모해준다. 이 두 개의 코드는 페이스북 로그인 외 페이스북 관련된 API를 연동할 때에도 모두 사용되는 모양이다.

스프링 페이스북 라이브러리에는 요청을 받아서 페이스북 API에 전달해주는 컨트롤러 단과 실제로 통신하고 페이스북 데이터를 가져오는 부분까지 통째로 다 있다. 컨트롤러까지 다 들어있으므로 여기까지만 했으면 세가지 작업만 더 해주면 페이스북 로그인은 바로 구현된다.

  1. XML이나 JAVA Config에 앱 ID와 시크릿코드를 Argument로 넣어서 Bean 설정해 준다.
  2. component-scan의 base-package에 소셜 로그인 관련된 라이브러리 패키지 경로(org.springframework.social)를 넣어준다.
  3. 페이스북의 경우 페이스북 데이터에 접근하기 위한 URL은 /connect/facebook (POST)이며 페이스북에서 인증처리한 후 똑같은 경로로 GET호출을 통해 코드값 등을 넘겨주므로 로그인 요청할 facebookConnect.jsp와 데이터를 받아서 표시할 facebookConnected.jsp 두 개 파일을 생성한다. 경로는 view 단의 /connect 디렉터리 밑이다. 왜 페이스북의 경우라고 하냐면 이 것이 페이스북 뿐만 아니라 다른 서비스 소셜 로그인 구현 시에도 공통으로 적용되는 사항이기 때문이다. Google Plus 소셜 로그인 구현 시에는 요청 URL이 /connect/google이 되게 되어 있다. 아래 소스를 보시면 이해가 가실 것이다.

위 세 가지 작업만 다 하면 페이스북 로그인은 바로 사용할 수 있지만 대부분은 그대로 사용하지 않을 것이다. 왜냐면 우리는 페이스북 로그인 정보를 받아오는 것 뿐만이 아니라 그 정보들을 받아서 회원정보 Model 객체에 매핑하거나 아님 그대로 UsernamePasswordAuthenticationToken을 생성할 적에 매개변수로 그대로 집어넣거나 해서 페이스북이 아닌 내 웹사이트에 사용자 인증처리를 시켜야 하고 그리고 라이브러리에서 정해놓은 대로 /connect/facebookConnected.jsp 파일을 사용할 생각이 없기 때문이다.

실제로 대부분 구현 사례를 구글링 해본 결과 대부분 페이스북에 연결하고 데이터를 Facebook.class 객체로 받아오는 부분만 라이브러리를 사용하고, 사용자 요청받고 응답하고 Token 생성하는 부분은 직접 구현한 사례가 많았다.

그래서 나도 우선은 최대한 라이브러리를 활용하되 인증 처리하는 부분은 직접 구현을 했다. 사용자 요청받고 응답하는 컨트롤러 부분은 직접 구현하려니 너무 복잡하고 굳이 이 내용들을 일일히 까볼 필요가 없을 것 같아 가장 손쉬운 방법으로 처리했다.

라이브러리 안의 org.springframework.social.connect.web.ConnectController 클래스를 상속받는 클래스를 내가 만든 패키지 안에 생성한 다음에 POST로 페이스북 API에 연결하는 메서드(connect)와 페이스북 인증 후 GET으로 코드값 받아오는 메서드(oauth2Callback) 두 개만을 오버라이드하여 내 입맛대로 바꿨다.

1. src/main/resources 경로 밑에 application.properties 파일을 생성한다. 아래처럼 페이스북 앱 ID와 앱 시크릿코드를 붙여 넣는다.

1
2
spring.social.facebook.appId=233668646673605
spring.social.facebook.appSecret=33b17e044ee6a4fa383f46ec6e28ea1d
cs

참고로 위의 앱 ID와 앱 시크릿코드는 내 것이 아니라 Spring.io에 공개되어 있는 테스트용 코드다.

2. JAVA Config 혹은 context.xml에 bean 설정과 application.properties 파일을 읽어서 사용할 수 있도록 설정한다. 나는 아직 XML 기반의 설정을 사용해서 아래와 같이 context.xml 파일에 설정해놓았다. 나중에 JAVA Config로 바꿀 생각이지만.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- application.properties 설정 -->
<context:property-placeholder location="classpath:/application.properties" />
 
<beans:bean id="connectionFactoryLocator" class="org.springframework.social.connect.support.ConnectionFactoryRegistry">
  <beans:property name="connectionFactories">
    <beans:bean class="org.springframework.social.facebook.connect.FacebookConnectionFactory">
      <beans:constructor-arg value="${spring.social.facebook.appId}" />
      <beans:constructor-arg value="${spring.social.facebook.appSecret}" />
    </beans:bean>
  </beans:property>
</beans:bean>
 
<beans:bean id="inMemoryConnectionRepository" class="org.springframework.social.connect.mem.InMemoryConnectionRepository">
  <beans:constructor-arg ref="connectionFactoryLocator" />
</beans:bean>
cs

XML의 내용을 보면 connectionFactoryLocator와 inMemoryConnectionRepository 라는 이름 Bean 두 개를 생성한다. connectionFactoryLocator라는 Bean은 페이스북 앱 ID와 앱 시크릿코드를 Argument로 받아 생성하는 클래스 Bean을 connectionFactories라는 이름의 Property로 가지고 있다. 대충 이 놈이 페이스북 API에 연결해서 인증과 관련된 역할을 할 것이라는 느낌이 온다.(Property 이름이 복수로 되어 있는 걸 보면 알 수 있겠지만 실제로 List 객체 타입으로 되어있다. 나중에 추가 소셜로그인 구현을 하다보면 페이스북 이외에 다른 서비스들의 CoonnectionFactory Bean들을 추가로 add해서 쓴다.) 그리고 inMemoryConnectionRepository 라는 녀석은 Repository라는 단어에서 아마도 페이스북 인증 후 정보를 담는 역할을 할 것이라는 Feel이 전해져 올 것이다. 이 두 개의 Bean은 소셜로그인을 위해 org.springframework.social.connect.web.ConnectController 클래스를 상속해서 만든 컨트롤러 Class 생성자의 매개변수로 주입된다.

3. org.springframework.social.connect.web.ConnectController 클래스를 상속받은 커스터마이징 컨트롤러 클래스 생성.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import com.walter.config.authentication.SignInUserDetailsService;
import com.walter.model.MemberVO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.social.connect.Connection;
import org.springframework.social.connect.ConnectionFactoryLocator;
import org.springframework.social.connect.ConnectionRepository;
import org.springframework.social.connect.web.ConnectController;
import org.springframework.social.facebook.api.Facebook;
import org.springframework.social.facebook.api.User;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.servlet.view.RedirectView;
 
import javax.annotation.Resource;
import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;
 
@Controller
@RequestMapping("/connect")
public class FacebookTestController extends ConnectController {
    final Logger logger = LoggerFactory.getLogger(this.getClass());
    private String TARGET_URL = new String();
 
    @Resource(name="signInUserDetailsService")
    private SignInUserDetailsService signInUserDetailsService;
 
    @Resource(name="inMemoryConnectionRepository")
    private ConnectionRepository connectionRepository;
 
    @Inject
    public FacebookTestController(ConnectionFactoryLocator connectionFactoryLocator, ConnectionRepository connectionRepository) {
        super(connectionFactoryLocator, connectionRepository);
    }
 
    @RequestMapping(value="/{providerId}", method=RequestMethod.POST)
    public RedirectView connect(@PathVariable String providerId, NativeWebRequest request) {
        HttpServletRequest httpServletRequest = (HttpServletRequest)request.getNativeRequest();
        TARGET_URL = httpServletRequest.getHeader("REFERER");
        return super.connect(providerId, request);
    }
 
    @RequestMapping(value="/{providerId}", method= RequestMethod.GET, params="code")
    public RedirectView oauth2Callback(@PathVariable String providerId, NativeWebRequest request) {
        RedirectView redirectView = super.oauth2Callback(providerId, request);
 
        // 사용자 정보 가져오기
        Connection<Facebook> connection = connectionRepository.findPrimaryConnection(Facebook.class);
        Facebook facebook = connection.getApi();
        String [] fields = { "id""age_range""email""first_name""gender",
                "last_name""link""locale""name""third_party_id""verified" };
        User userProfile = facebook.fetchObject("me", User.class, fields);
 
        // 로그인 처리
        signInUserDetailsService.onAuthenticationBinding(new MemberVO(), userProfile);
        redirectView.setUrl(TARGET_URL);
        return redirectView;
    }
}
cs

프런트엔드에서 소셜로그인을 위해 최초로 요청을 받게 되는 메서드는 connect이다. 여기에는 요청을 보낸 페이지의 URL을 받아 TARGET_URL이라는 이름의 String 변수에 대입하는 부분만을 추가했는데 페이스북 페이지에 가서 인증을 하고 나서는 바로 처음으로 소셜로그인 요청을 했던 원래 페이지로 돌아오게 하기 위해서다. 예를 들면 http://a.com/abc 페이지에서 페이스북 로그인 요청을 해서 페이스북 사이트가 갔다 오면 다시 http://a.com/abc 로 돌아오게 하려고. 이를 처리하기 위해 페이스북 개발자 센터에서도 별도로 Callback 페이지 설정하는 부분도 있고 분명 더 좋은 방법이 있을 것인데 혹시 알고 계시는 분이 계시면 알려주기 바람. 라이브러리의 컨트롤러를 그대로 사용하자면 분명 Client Side에서 Callback 처리해줘야 할 거 같은데 나같은 경우는 백엔드 - 프런트엔드를 여러번 왔다갔다 하는 것을 꺼려해서 그냥 이렇게 처리해버렸다.

그리고 페이스북 페이지에서 로그인 한 뒤에는 페이스북 API에서 code 값을 파라메터로 담아 Callback 요청을 하게 되는데 이 요청을 받는 메서드가 마지막 oauth2Callback 메서드이다. 여기에서는 수퍼클래스의 메서드 내용을 수행한 뒤 connectionRepository에서 사용자 정보를 User 객체에 fetch하고 그 User 객체를 이용하여 로그인 처리를 한 다음 TARGET_URL로 리디렉션하도록 해놨다.

로그인 처리는 별도의 signInUserDetailsService 클래스의 onAuthenticationBinding 메서드에서 수행하는데 내용은 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void onAuthenticationBinding(MemberVO memberVO, User facebookUser) throws NullPointerException {
    memberVO.setUsername(facebookUser.getId());
    memberVO.setEmail(facebookUser.getEmail());
    memberVO.setFirst_name(facebookUser.getFirstName());
    memberVO.setKr_name(facebookUser.getName());
    memberVO.setLast_name(facebookUser.getLastName());
    memberVO.setAuthorities(ROLE.DEFAULT.getRoleList());
    memberVO.setAccountNonExpired(true);
    memberVO.setAccountNonLocked(true);
    memberVO.setCredentialsNonExpired(true);
    memberVO.setEnabled(true);
 
    // Token 생성하고 로그인 세션 생성
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
            memberVO, null, ROLE.DEFAULT.getRoleList()
    );
    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
cs

메서드 내용 상단의 memberVO.set~으로 시작되는 부분은 기존의 회원정보 Model 객체로 사용되던 MemberVO 클래스에 User 객체의 내용들을 바인딩하는 내용이고 하단은 사용자 정보를 담은 Model 객체와 자격증명(대개 비밀번호 암호화한 값을 넣는데 여기선 없으므로 null로 넣었다), 그리고 권한을 매개변수로 던져서 authenticationToken을 생성하게 되고 마지막으로 Spring Security에 태워보내는 내용이다.

4. 마지막으로 프런트엔드에서 페이스북 로그인 요청하는 부분을 만들어야 하는데, form에 /connect/facebook 이라는 주소로 POST 요청을 보내면 된다.

1
2
3
4
  <form action="/connect/facebook" method="post" id="facebook-form">
    <input type="hidden" name="scope" value="public_profile, email"/>
    <button type="submit">Sign In with Facebook</button>
  </form>
cs

파라메터로 scope 라는 항목이 있는데 이 값에 따라 페이스북 API로부터 가져오는 사용자 정보 항목이 달라진다. 자세한 내용은 https://developers.facebook.com/docs/facebook-login/permissions/ 여기를 참고하도록…

‘SIGN IN WITH FACEBOOK’ 클릭

(scope 파라메터에 ‘public_profile, email’ 라는 값을 담아서 /connect/facebook에 POST전송)

페이스북 로그인 화면으로 전환되고 로그인을 한다.

(앱에 대한 사용자 정보 사용 동의에 대한 화면이 나옴)

로그인 처리 후 다시 원래 화면으로 돌아온다(‘SIGN IN’이 ‘SIGN OUT’으로 바뀜)

잘 된다…

Kotlin, SpringBoot 3, GraalVM 환경에서 Native Image로 컴파일하여 애플리케이션 실행

Spring Boot 3부터, GraalVM Native Image를 공식 지원하여 애플리케이션의 시작 속도와 메모리 사용량을 크게 줄일 수 있다. Native Image란 기존의 JVM 기반 위에서 돌아가는 Java 애플리케이션과는 달리 JVM 없이...