어느 날 갑자기 회사가 글로벌 기업에 인수합병(M&A)되었다. 비즈니스 뉴스에서는 시너지 창출이니 글로벌 진출이니 하며 떠들썩했지만, 지하 개발실에서 레거시 시스템을 유지보수하던 10년 차 백엔드 개발자인 나에게 그 소식은 거대한 아키텍처 격변을 예고하는 재앙의 서막에 불과했다.
평화롭게 돌아가던 우리 시스템은 전형적인 한국형 공공/엔터프라이즈 레거시 스택을 자랑하고 있었다. 전자정부 프레임워크 3.8, Spring 4.3, Java 8, 그리고 오라클 DB에 평문으로(또는 기껏해야 SHA-256 단방향 암호화 정도로) 저장된 아이디와 비밀번호. 그리고 Spring Security의 기본 DaoAuthenticationProvider를 이용한 소박한 폼 로그인. 세션은 L4 로드밸런서 밑에 묶인 톰캣 두 대가 각자 메모리 세션을 들고 있는, 그야말로 10년 전 흔히 구축되던 평범한 구조였다.
그런데 인수합병 직후, 본사의 글로벌 IT 보안팀에서 한 통의 무시무시한 지시사항이 하달되었다. 사내망과 대외망을 막론하고 현재 운영 중인 모든 시스템의 인증 체계를 본사의 글로벌 보안 표준인 'Microsoft Entra ID (구 Azure AD)' 기반의 SSO(Single Sign-On)로 통폐합하라는 것이었다. 게다가 비밀번호 입력만으로는 안 되고, Microsoft Authenticator 앱을 통한 MFA(Multi-Factor Authentication, 다중 인증) 중에서도 가장 빡빡하다는 '숫자 일치(Number Matching)' 방식을 무조건 강제 적용하라는 못이 박혀 있었다.
이 글은 모던 클라우드 네이티브 인증 체계를 10년 넘게 묵은 Spring 4.3 기반의 모놀리식 레거시에 이식하면서 겪은 아키텍처의 딜레마, 인프라의 벽, 그리고 눈물겨운 우회 기법들을 기록한 처절한 삽질기다.
1. 도입 배경과 멘붕: 잘 돌아가던 시스템을 왜 뜯어고쳐야 하나
지시사항을 처음 읽었을 때 솔직히 "이게 무슨 소린가" 싶었다. 우리 시스템은 로컬 DB 기반으로 권한과 계정이 아주 타이트하게 물려 돌아가고 있었다. 그런데 갑자기 인증을 클라우드에 있는 AD(Active Directory)로 넘기라니.
본사 보안팀이 MFA 중에서도 굳이 '숫자 일치(Number Matching)'를 강제한 이유는 명확했다. 최근 공격자가 탈취한 아이디/비밀번호로 로그인을 시도한 뒤, 실제 사용자에게 무의미한 MFA 승인 푸시 알림을 무차별적으로 보내 사용자가 지쳐서 또는 실수로 '승인'을 누르게 만드는 'MFA 피로도 공격(MFA Fatigue Attack)'이 기승을 부리고 있기 때문이다. 숫자 일치 방식은 화면에 표시된 두 자리 무작위 숫자를 모바일 Authenticator 앱에 직접 타이핑해야만 인증이 통과되므로, 화면을 보고 있지 않은 공격자의 원격 접속 시도를 원천 차단할 수 있다.
보안의 중요성은 십분 이해하지만, 문제는 내 앞에 놓인 환경이었다. Spring Boot 2.x나 3.x 환경이었다면 spring-boot-starter-oauth2-client 의존성 하나 띡 추가하고 application.yml에 클라이언트 ID와 시크릿만 적어주면 프레임워크가 알아서 다 해줬을 것이다. 하지만 내가 다루는 것은 전자정부 프레임워크 3.8, 즉 Spring 4.3 환경이다. 최신 OAuth2 자동 구성 마법 따위는 존재하지 않는다. 구형 spring-security-oauth2 라이브러리를 가져와 길고 긴 XML이나 Java Config로 하나하나 빈(Bean)을 엮어주어야 하는 가시밭길이 열린 것이다.
2. 아키텍처의 딜레마: 인증(AuthN)과 인가(AuthZ)의 철저한 분리
Entra ID 연동을 위한 첫 번째 설계 회의에서 나는 거대한 아키텍처적 딜레마에 봉착했다. 글로벌 AD 정책과 우리 시스템의 계정 구조 간에 도저히 좁힐 수 없는 간극이 존재했기 때문이다.
AD의 "1인 1계정" 원칙 vs 레거시의 "다중 프로필" 구조
기존 시스템은 업무 특성상 한 명의 실무자가 여러 개의 권한 프로필을 동시에 가져야 했다. 예를 들어, '홍길동'이라는 직원은 '본사 유통관리자' 권한을 가진 계정과, 특정 지역의 'A농장 관리자' 권한을 가진 계정을 별도의 아이디로 발급받아 필요에 따라 로그인/로그아웃하며 스위칭해서 사용하고 있었다. 한국의 낡은 B2B 시스템에서 흔히 볼 수 있는 구조다.
반면, 글로벌 본사에서 통제하는 Entra ID는 철저하게 UPN(User Principal Name, 주로 회사 이메일 형식) 기반의 "1인 1계정" 원칙을 고수한다. 한 명의 물리적 인간에게는 단 하나의 AD 계정만 부여된다.
선택지는 두 가지였다.
- 본사에 예외 처리 구걸하기: "우리 시스템 구조가 구려서 그러니, 홍길동 씨한테 유통관리자용 AD 계정 하나, 농장관리자용 AD 계정 하나씩 총 두 개를 만들어 주세요." -> 보안팀에서 단칼에 거절당할 게 뻔했다.
- AD App Role에 우리 권한 욱여넣기: AD에 우리 시스템의 수백 개에 달하는 세부 권한 그룹을 만들어 달라고 한 뒤, AD 관리자가 일일이 매핑 쳐주기를 기다리는 방식이다. -> 권한 하나 추가할 때마다 글로벌 IT팀에 티켓을 끊고 영문으로 사유서를 써야 하는 최악의 관리 지옥이 예상되었다.
해결책: 하이브리드 인증 아키텍처 도입 (토큰 까보기와 매핑 치기)
결국 나는 AD에 우리 시스템의 복잡한 권한을 만들어달라고 구걸하지 않기로 했다. 아키텍처의 핵심을 '인증(Authentication)과 인가(Authorization)의 철저한 분리'로 잡았다.
'이 사람이 누구인가(AuthN)'를 증명하는 행위는 전적으로 글로벌 보안 기준을 충족하는 Entra ID에 넘긴다. 그리고 '이 사람이 우리 시스템에서 무엇을 할 수 있는가(AuthZ)'를 결정하는 권한 부여는 여전히 우리 로컬 DB의 권한 체계를 따르도록 하이브리드 방식을 채택했다.
동작 방식은 다음과 같다.
- 사용자가 메인 화면에 접속하면 Entra ID 로그인 페이지로 튕겨낸다.
- 사용자가 이메일과 비밀번호를 치고, 모바일로 MFA(숫자 일치)까지 완료하면 Entra ID는 우리 시스템으로 JWT(JSON Web Token) 형태의 id_token과 access_token을 던져준다.
- 우리 백엔드 서버는 이 토큰을 받아 Payload를 깐 뒤, 다른 건 다 무시하고 오직 UPN(이메일) 하나만 쏙 빼낸다.
- 빼낸 이메일 주소를 들고 우리 로컬 DB의 TB_USER_PROFILE_MAP 테이블을 뒤져서, 이 이메일과 연결된 기존 레거시 권한 목록을 쫙 긁어온다.
이 로직을 Spring Security 4.3에 이식하기 위해 OAuth2ClientAuthenticationProcessingFilter를 확장한 커스텀 필터를 만들었다.
// Spring Security 4.3 환경: Custom OAuth2 로그인 필터 구현의 흔적
public class LegacyEntraIdSsoFilter extends OAuth2ClientAuthenticationProcessingFilter {
private final LocalUserProfileRepository profileRepository;
public LegacyEntraIdSsoFilter(String defaultFilterProcessesUrl, LocalUserProfileRepository profileRepository) {
super(defaultFilterProcessesUrl);
this.profileRepository = profileRepository;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
// 1. 부모 클래스를 통해 Entra ID로 토큰 교환 요청 (Authorization Code Flow)
OAuth2Authentication auth = (OAuth2Authentication) super.attemptAuthentication(request, response);
// 2. 받아온 AccessToken 객체 내부에서 id_token 까보기
OAuth2AccessToken accessToken = restTemplate.getAccessToken();
String idToken = (String) accessToken.getAdditionalInformation().get("id_token");
// 3. JWT 디코딩하여 UPN(이메일) 추출 (로컬 유틸리티 사용)
String upn = JwtUtils.extractClaim(idToken, "upn");
if(upn == null) {
upn = JwtUtils.extractClaim(idToken, "preferred_username"); // fallback
}
// 4. 로컬 DB를 찔러서 UPN에 매핑된 다중 프로필(권한) 목록 조회
List<UserProfile> profiles = profileRepository.findByUpn(upn);
if(profiles.isEmpty()) {
throw new UsernameNotFoundException("AD 인증은 성공했으나, 로컬 시스템에 매핑된 권한이 없습니다.");
}
// 5. 로컬 세션에 담기 위한 Custom Authentication 객체 생성 및 리턴
return new CustomHybridAuthenticationToken(upn, profiles, auth);
}
}
프로필 선택 화면(Profile Selection)의 도입
인증 필터를 통과해서 UPN을 확보했다고 끝이 아니었다. 로컬 DB를 뒤져보니 홍길동(hong@global.com)이라는 UPN에 '유통관리자', 'A농장 관리자'라는 2개의 프로필이 매핑되어 있다고 치자. 시스템 입장에서는 홍길동이 지금 당장 어떤 권한으로 시스템을 이용하고 싶은지 알 길이 없다. 무턱대고 메인 화면으로 진입시킬 수는 없는 노릇이다.
이를 해결하기 위해 Spring Security의 AuthenticationSuccessHandler를 커스텀하여 중간에 가로채기를 시전했다.
원래 폼 로그인 환경에서 쓰던 SavedRequestAwareAuthenticationSuccessHandler는 로그인 성공 시 사용자가 원래 가고자 했던 URL(예: /dashboard)로 곧바로 리다이렉트 시키는 역할을 한다. 나는 이 클래스를 상속받아 동작을 비틀었다.
로컬 DB에서 조회한 권한 프로필 개수를 확인한 뒤,
- 프로필이 딱 1개라면? 고민할 필요 없이 기존처럼 원래 요청한 페이지로 즉시 리다이렉트 시킨다.
- 프로필이 2개 이상이라면? 세션에 프로필 목록만 임시로 담아둔 채, /profile-select 라는 뷰(View) URL로 강제 리다이렉트 시켰다.
사용자는 프로필 선택 화면에서 "오늘은 유통관리자로 업무를 볼래" 하고 버튼을 클릭한다. 이 선택 값이 서버로 넘어오면, 비로소 최종적으로 선택된 권한을 SimpleGrantedAuthority로 변환하여 Spring Security의 SecurityContextHolder에 확정적으로 꽂아 넣고 원래의 목적지(/dashboard)로 보내준다.
이 설계 덕분에 글로벌 AD의 '1인 1계정' 원칙과 레거시의 '다중 프로필' 구조가 부딪히지 않고 평화롭게 공존할 수 있었다. 코드를 짜면서도 내심 "설계 참 기가 막히게 뽑았다"며 스스로 뿌듯해했던 기억이 난다.
3. 인프라와 네트워크의 벽: 코딩보다 험난했던 서버 환경 뚫기
아키텍처 설계와 로컬 PC(Windows) 환경에서의 코딩, 그리고 테스트가 완벽하게 끝났다. 가벼운 발걸음으로 개발망에 있는 리눅스 서버에 war 파일을 배포했다. 브라우저를 열고 개발 서버 URL로 접속하니 Entra ID 로그인 창이 예쁘게 떴다. 아이디를 치고, 핸드폰으로 숫자 일치 MFA를 승인했다.
그런데 콜백 URL로 돌아와서 시스템 메인 화면이 떠야 할 타이밍에, 톰캣이 하얀 에러 페이지와 함께 HTTP 500 Internal Server Error를 뱉으며 장렬하게 뻗어버렸다.
로그를 까보니 Connection Refused - Socket Timeout 에러였다. 아차 싶었다. 서버 사이드에서 토큰 교환을 위해 Entra ID의 토큰 엔드포인트(https://login.microsoftonline.com)로 아웃바운드 API 호출을 날리는데, 사내망에 갇혀 있는 폐쇄적인 레거시 서버 환경 특성상 외부 인터넷으로 나가는 방화벽이 꽉 막혀 있었던 것이다.
방화벽 해제와 끝나지 않은 시련: curl (35) SSL connect error
재빨리 사내 네트워크 팀에 티켓을 올려 서버 IP에서 login.microsoftonline.com (TCP 443)으로 나가는 아웃바운드 방화벽 오픈을 구걸했다. 며칠 뒤 방화벽이 뚫렸다는 답변을 받고, 기쁜 마음에 서버 터미널에 SSH로 접속해 통신 테스트를 위해 curl 명령어를 날렸다. 당연히 HTTP 200 OK가 떨어질 줄 알았다.
$ curl -I -v https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration
* About to connect() to login.microsoftonline.com port 443 (#0)
* Trying 40.126.32.138... connected
* Connected to login.microsoftonline.com (40.126.32.138) port 443 (#0)
* Initializing NSS with certpath: sql:/etc/pki/nssdb
* CAfile: /etc/pki/tls/certs/ca-bundle.crt
* CApath: none
* NSS error -12188
* Closing connection #0
curl: (35) SSL connect error
눈을 비비고 다시 봤다. 방화벽은 분명히 뚫려서 IP 연결(Connected)까지는 됐는데, TLS 핸드셰이크 단계에서 (35) SSL connect error가 튀어나오며 커넥션이 끊겨버린 것이다. 등줄기에 식은땀이 흘렀다.
이 악명 높은 curl (35) 에러가 발생하는 원인은 엔터프라이즈 환경에서 크게 두 가지로 압축된다.
| 의심되는 원인 | 발생 메커니즘 | 확인 방법 |
| 사내 프록시 SSL 가로채기 (TLS Inspection) | 사내 보안 장비(Proxy/Firewall)가 외부 트래픽을 모니터링하기 위해 패킷을 뜯어보고 사설 인증서로 Re-signing 하는 경우. 리눅스 서버의 Trust Store에 해당 사설 루트 인증서가 없으면 UntrustedRoot 에러가 발생하며 끊김. | curl -v 로그의 Certificate Chain 검사. 루트 CA가 DigiCert 등이 아니라 사내 알 수 없는 CA로 찍히는지 확인. |
| 구형 OS 패키지의 TLS 버전 미지원 | 대상 서버(Microsoft)는 보안상 TLS 1.2 이상만 지원하는데, 내 서버의 암호화 모듈(NSS/OpenSSL)이 너무 구형이라 TLS 1.2 협상(Handshake)에 실패하거나 지원하는 Cipher Suite가 없는 경우. | curl --version으로 NSS 버전을 확인하고, curl --tlsv1.2 옵션을 줬을 때 동일하게 뻗는지 확인. |
로그를 찬찬히 뜯어보니 Certificate Chain에서 사설 인증서가 찍히는 문제는 아니었다. 에러 로그에 선명하게 박힌 NSS error -12188. 범인은 오래 방치된 레거시 OS 자체였다.
우리 개발 서버는 무려 CentOS 6 (RHEL 6 호환) 버전이었다. Microsoft Entra ID는 이미 오래전에 보안 취약점을 이유로 SSLv3나 TLS 1.0, 1.1 지원을 중단하고 오직 TLS 1.2 이상만을 허용하고 있었다. 하지만 CentOS 6에 기본으로 깔려 있는 구형 nss (Network Security Services) 모듈과 curl은 TLS 1.2를 제대로 처리하지 못하거나 적절한 암호화 스위트(Cipher Suite)를 제공하지 못해 핸드셰이크를 포기해버린 것이다.
레거시 OS 패키지 강제 업데이트 삽질
보통 운영 중인 레거시 리눅스 서버의 코어 패키지를 건드리는 것은 미친 짓으로 통한다. 의존성이 깨지면 서버 전체가 날아갈 수 있기 때문이다. 하지만 이대로라면 프로젝트는 실패다. 리눅스 엔지니어 모드로 빙의해서 OS 패키지를 수동으로 업데이트하기 시작했다.
문제는 CentOS 6가 이미 EOL(End of Life)을 맞이하여 기본 YUM 리포지토리가 닫혀있었다는 점이다. Vault 리포지토리 주소로 /etc/yum.repos.d/ 설정을 수정하는 삽질을 거친 후, 간신히 패키지 매니저를 살려냈다.
# 구버전 nss-3.12.x 버전을 TLS 1.2가 제대로 지원되는 nss-3.16.x 이상으로 업데이트
$ sudo yum update -y nss curl libcurl openssl
패키지를 업데이트한 뒤 curl --version을 쳐보니 버전업이 된 것을 확인할 수 있었다. 마지막으로 톰캣 구동 스크립트인 catalina.sh를 열고 JAVA_OPTS에 JVM이 외부 통신 시 TLS 1.2를 강제로 쓰도록 옵션을 명시적으로 박아주었다.
JAVA_OPTS="$JAVA_OPTS -Dhttps.protocols=TLSv1.2"
톰캣을 재기동하고 다시 로그인 버튼을 눌렀다. 리다이렉트가 춤을 추더니, 마침내 에러 없이 내가 만든 프로필 선택 화면이 모니터에 떠올랐다. 서버 터미널에서 HTTP 200 OK 응답을 확인했을 때 터져 나온 탄성은, 그동안의 야근을 모두 보상해주는 실무자만의 짜릿한 카타르시스였다.
4. 로드밸런서와 무한 로그인 루프: OAuth2 State 파라미터 방어전
인프라의 빗장을 힘겹게 열고 났으니 이제 순탄하게 QA(품질보증) 단계로 넘어갈 줄 알았다. 하지만 분산 환경은 항상 개발자의 뒤통수를 노린다. 이번에는 L4 로드밸런서(Load Balancer) 환경에서 기이한 버그가 나타났다.
운영 환경과 유사하게 세팅된 스테이징 서버에서 테스트를 진행하는데, 사용자가 SSO 로그인을 마치고 시스템으로 콜백 되어 돌아오면 정상적으로 화면이 뜨는 대신 다시 Microsoft 로그인 창으로 튕겨 나가는 무한 로그인 루프(Infinite Redirect Loop) 현상이 간헐적으로 발생하기 시작했다.
State 파라미터 불일치(Mismatch)의 진실을 파헤치다
원인을 추적하기 위해 브라우저 개발자 도구의 네트워크(Network) 탭을 열고 리다이렉트되는 HTTP 패킷의 흐름을 한 땀 한 땀 추적했다. 그리고 Spring Security의 디버그 로그를 켜보니, OAuth2ClientAuthenticationProcessingFilter에서 Possible CSRF detected - state parameter was required but no state could be found 또는 state 값이 일치하지 않는다는 에러를 뱉으며 인증을 거부하고 있었다.
OAuth2와 OIDC(OpenID Connect) 표준 프로토콜은 CSRF(Cross-Site Request Forgery) 공격을 방지하기 위해 state 파라미터를 사용한다. 사용자가 로그인 버튼을 누르면, 백엔드 서버(Spring Security)는 인증 요청을 Entra ID로 보내기 직전에 임의의 난수 문자열(예: state=xyz123)을 생성하여 자신의 로컬 세션(또는 인메모리)에 저장한다. 그리고 이 값을 URL 파라미터에 붙여서 MS로 리다이렉트 시킨다. 사용자가 MS에서 인증을 마치고 돌아올 때, MS는 아까 받은 state=xyz123 값을 그대로 돌려준다. 그러면 서버는 현재 접속한 사용자의 세션에서 아까 저장해둔 state 값을 꺼내어, 지금 콜백 URL로 들어온 state 값과 똑같은지 비교(Validation)한다. 다르면 해커의 공격으로 간주하고 인증을 폐기한다.
문제는 우리 레거시 시스템의 웹 서버 클러스터링 아키텍처에 있었다. 우리 시스템은 L4 로드밸런서 밑에 2대의 톰캣(WAS A, WAS B)이 물려 있었다. 그리고 요즘처럼 Redis 같은 멋진 인메모리 데이터베이스를 이용한 분산 세션(Spring Session) 클러스터링 따위는 되어 있지 않은, 각 WAS가 자기만의 메모리 세션을 들고 있는 고전적인 구조였다.
이 구조에서 어떤 비극이 일어나는지 시나리오를 그려보자.
- 사용자가 메인 페이지에 접속한다. L4 로드밸런서는 트래픽을 WAS A로 보낸다.
- WAS A의 Spring Security는 state=xyz123을 생성하여 자신의 로컬 메모리 세션에 예쁘게 저장하고, 브라우저를 Entra ID로 리다이렉트 시킨다.
- 사용자가 핸드폰으로 숫자 일치 MFA까지 무사히 마치고, 브라우저가 콜백 URL(/login/oauth2/code/entra)을 타고 우리 시스템으로 돌아온다.
- 이때 L4 로드밸런서가 라우팅을 찰지게 분배한답시고 이 콜백 트래픽을 하필 WAS B로 꽂아버린다.
- WAS B는 브라우저가 가져온 state=xyz123을 검증하려고 자신의 세션을 뒤져보지만, 당연히 아무것도 없다. (아까 생성한 건 WAS A니까).
- WAS B의 Spring Security는 "어라? state 값이 없네? CSRF 공격인가?" 하고 인증을 거부해 버린다. 그리고 미인증 상태이므로 다시 로그인 창으로 쫓아낸다 (무한 루프의 완성).
Sticky Session이 만들어낸 뜻밖의 구원
원인을 알았으니 해결책을 찾아야 했다. 정석대로라면 Redis를 새로 인프라에 도입하고 spring-session-data-redis를 설정해서 WAS A와 WAS B가 세션을 공유하게 만들거나, 아예 OAuth2AuthorizationRequestResolver와 AuthorizationRequestRepository 인터페이스를 직접 구현하여 state 값을 세션이 아닌 브라우저의 암호화된 쿠키(Stateless)에 담아 검증하도록 프레임워크의 동작을 마개조해야 했다.
하지만 오픈 일정은 코앞으로 다가왔고, 레거시 코드를 그렇게 깊게 파고들어 대수술을 벌이기엔 리스크가 너무 컸다. 머리를 쥐어뜯으며 인프라 담당자와 로드밸런서 세팅을 확인하다가, 기존 L4 스위치에 '스티키 세션(Sticky Session / Source IP Hash)' 설정이 걸려 있다는 사실을 발견했다.
스티키 세션은 클라이언트의 IP나 로드밸런서가 박아넣은 쿠키(Persistence Cookie)를 기반으로, "한 번 맺어진 클라이언트는 무조건 처음 만났던 WAS로만 계속 트래픽을 밀어주는" 기능이다.
"어? 스티키 세션이 켜져 있는데 왜 아까 WAS B로 라우팅이 튀었지?"
이유는 단순했다. SSO 콜백 처리가 브라우저의 외부 리다이렉트를 통해 핑퐁을 치는 과정에서, 사내 망의 프록시를 거치며 순간적으로 Source IP가 변조되거나, 구형 L4 장비가 리다이렉트 타이밍에 브라우저가 던지는 세션 쿠키를 빠르게 파싱하지 못해 스티키 룰을 일시적으로 놓쳐버린 것이다.
코드를 뜯어고치는 대신, 인프라의 힘을 빌리기로 했다. L4 장비 엔지니어와 협의하여 쿠키 기반의 L4 Persistence 유지 시간(Timeout)을 넉넉하게 늘리고, SSO 콜백 URL 패턴에 대해서는 스티키 룰이 절대 풀리지 않도록 로드밸런서의 라우팅 정책을 빡빡하게 보강했다. 놀랍게도 네트워크 단의 조치만으로 무한 루프 악몽은 거짓말처럼 사라졌다. "코드로 안 되면 인프라로 조진다"는 선배들의 명언이 가슴에 깊이 와닿는 순간이었다.
5. (보너스) 데스크톱 레거시의 역습: 파워빌더와 C#의 기묘한 동거
웹 기반 시스템의 Entra ID 도입과 L4 이슈까지 성공적으로 마무리하고 드디어 한숨을 돌리며 커피를 마시려던 찰나, IT 지원팀에서 다급하게 메신저가 왔다.
"저기요, 창고 PDA랑 연결해서 물류팀이 피씨에서 쓰고 있는 데스크톱 클라이언트 프로그램은 언제 패치되나요?"
순간 멍해졌다. 웹 시스템에만 집중하느라, 물류 현장에서 여전히 현역으로 굴러가고 있는 초창기 C/S(Client-Server) 형태의 데스크톱 프로그램을 까맣게 잊고 있었다. 게다가 그 프로그램은 자바도 아니고, 무려 파워빌더(PowerBuilder 2017 R3)로 개발된 진짜배기 '화석' 레거시였다. 본사 지시사항은 "모든 시스템"이었으므로 이 파워빌더 프로그램도 Entra ID 로그인 창을 띄우고 숫자 일치 MFA를 통과해야만 했다.
웹 브라우저가 아닌 파워빌더 데스크톱 앱에서 최신 OAuth2 웹 통신을 뚫는 것은 그야말로 '돌도끼로 우주선 수리하기'나 다름없었다. 구버전 파워빌더 내부에 내장된 웹 브라우저 컨트롤(WebBrowser Control)은 구형 IE(Internet Explorer) 기반의 엔진을 사용한다. 이 낡은 브라우저 컴포넌트 위에 최신 자바스크립트와 보안 모듈로 무장한 Entra ID 로그인 페이지를 띄우자, 온갖 스크립트 오류 창이 팝업되더니 이내 화면이 새하얗게 변하는 백화현상(White Screen of Death)이 나타났다.
파워스크립트(PowerScript)만으로는 이 난관을 돌파할 방법이 없었다. 레거시는 레거시로 감싸서 우회하는 수밖에.
C# MSAL.NET Wrapper DLL을 통한 우회 돌파 작전
Microsoft는 고맙게도 데스크톱 및 모바일 애플리케이션에서 Entra ID 인증을 쉽게 구현할 수 있도록 MSAL.NET(Microsoft Authentication Library for.NET)이라는 강력한 라이브러리를 제공한다. 나는 파워빌더가 스스로 할 수 없는 인증 프로세스를.NET 프레임워크의 힘을 빌려 위임하기로 결정했다.
Visual Studio를 열고 C#으로.NET Framework 4.8 기반의 클래스 라이브러리(Class Library) 프로젝트를 새로 팠다. 이 프로젝트의 역할은 단 하나, 파워빌더 대신 MSAL.NET을 호출하여 토큰을 훔쳐오는 '브로커(Broker)' 역할이다.
// C# MSAL.NET Wrapper 로직 일부 (ClassLibrary)
using System;
using System.Runtime.InteropServices;
using Microsoft.Identity.Client;
using System.Linq;
namespace PowerBuilderSSOWrapper
{
// COM(Component Object Model)에 노출시켜 파워빌더가 볼 수 있게 인터페이스 정의
[ComVisible(true)]
public interface IMsalAuthWrapper
{
string GetAccessToken(string clientId, string tenantId);
}
[ComVisible(true)]
public class MsalAuthWrapper : IMsalAuthWrapper
{
public string GetAccessToken(string clientId, string tenantId)
{
try
{
// MSAL.NET을 이용한 Public Client App 객체 생성
var app = PublicClientApplicationBuilder.Create(clientId)
.WithAuthority(AzureCloudInstance.AzurePublic, tenantId)
.WithDefaultRedirectUri() // 데스크톱 환경을 위한 로컬 루프백 URL 사용
.Build();
var scopes = new string { "User.Read" };
// 시스템에 내장된 모던 브라우저(Edge/Chrome 등)를 팝업시켜 대화형 로그인 수행
var result = app.AcquireTokenInteractive(scopes)
.WithUseEmbeddedWebView(false) // 파워빌더의 구형 내장 브라우저 대신 OS 기본 브라우저 강제 호출
.ExecuteAsync().Result; [44, 45]
return result.AccessToken; // 성공 시 토큰 반환
}
catch (Exception ex)
{
return "ERROR:" + ex.Message;
}
}
}
}
이 C# 프로젝트의 핵심은 [ComVisible(true)] 어트리뷰트다. 파워빌더는 윈도우의 OLE(Object Linking and Embedding) 및 COM(Component Object Model) 기술을 통해 운영체제 레지스트리에 등록된 외부 DLL 객체를 파워스크립트 내에서 직접 인스턴스화하고 함수를 호출할 수 있는 오래된 비기를 가지고 있다.
C# 코드를 빌드하여 DLL을 뽑아낸 뒤, 클라이언트 PC에서 regasm (Assembly Registration Tool) 명령어를 관리자 권한으로 실행해 이 DLL을 윈도우 레지스트리에 COM 객체로 콱 박아 넣었다.
# C# 래퍼 DLL을 COM 객체로 레지스트리에 등록
> regasm.exe /codebase PowerBuilderSSOWrapper.dll
이제 파워빌더 차례다. 파워빌더 소스를 열어 기존 로그인 창의 버튼 클릭 이벤트를 갈아엎고, 방금 만든 C# COM 객체를 호출하는 코드를 작성했다.
// 파워빌더(PowerBuilder) 측 로그인 버튼 Click 이벤트 소스 발췌
OLEObject ole_msal
String ls_token, ls_client_id, ls_tenant_id
Integer li_rtn
// OLE 객체 생성
ole_msal = CREATE OLEObject
// 레지스트리에 등록된 C# 클래스 네임스페이스로 연결 시도
li_rtn = ole_msal.ConnectToNewObject("PowerBuilderSSOWrapper.MsalAuthWrapper")
IF li_rtn < 0 THEN
MessageBox("시스템 오류", "SSO 연동 모듈(C# DLL) 로드에 실패했습니다. 관리자에게 문의하세요. (코드: " + String(li_rtn) + ")")
RETURN
END IF
// 본사에서 발급해준 데스크톱 App용 클라이언트 정보
ls_client_id = "1234abcd-xxxx-xxxx..."
ls_tenant_id = "5678efgh-xxxx-xxxx..."
// C# 래퍼의 함수 호출!
// 이 순간 파워빌더 앱 위에 사용자 PC의 기본 브라우저(Chrome/Edge)가 뜨면서 Entra ID 로그인이 진행된다.
ls_token = ole_msal.GetAccessToken(ls_client_id, ls_tenant_id)
IF Left(ls_token, 6) = "ERROR:" THEN
MessageBox("인증 실패", "로그인 중 오류가 발생했거나 취소되었습니다.~r~n" + ls_token)
ELSE
// 정상적으로 토큰을 받아왔다!
// 이제 이 JWT Access Token을 HTTP 헤더에 담아서 우리 백엔드 API 서버로 전송하면 인가(AuthZ) 처리가 된다.
SetGlobalToken(ls_token)
Open(w_main_dashboard) // 메인 화면 열기
END IF
// 메모리 누수를 막기 위해 객체 연결 해제
ole_msal.DisconnectObject()
DESTROY ole_msal
결과는 대성공이었다. 파워빌더 앱에서 사용자가 'SSO 로그인' 버튼을 누르면, 구질구질한 IE 렌더링 화면 대신 사용자의 PC에 기본으로 깔려 있는 엣지(Edge)나 크롬(Chrome) 브라우저가 새 탭으로 경쾌하게 팝업된다. 그곳에서 Entra ID의 모던 웹 인증과 빡빡한 Number Matching MFA를 완벽하고 안전하게 통과한 뒤, 브라우저가 닫히면서 뒤에 숨어있던 C# DLL을 거쳐 파워빌더 프로그램으로 따끈따끈한 Access Token이 안전하게 배달되었다.
레거시 클라이언트와 최신 모던 인증 체계를 C#이라는 중간 브로커를 통해 기워 붙인 이 우회 전술은, 낡은 C/S 시스템 마이그레이션 파트에서 가장 짜릿한 순간이었다.
6. 성공적인 레거시 개조가 남긴 교훈: 삽질의 미학
레거시 환경에서 본사가 요구하는 글로벌 보안 표준을 강제 적용하는 과정은, 마치 지도 없이 지뢰밭을 건너거나 운행 중인 자동차의 엔진을 교체하는 것과 같은 살얼음판의 연속이었다.
AD의 '1인 1계정' 원칙과 레거시의 '다중 권한 프로필' 간의 아키텍처 충돌을 극복하기 위해 Spring Security 필터단을 뜯어고쳐 하이브리드 인증/인가 매핑 구조를 구현해 냈고, 최신 TLS 1.2를 지원하지 못해 뻗어버린 구형 CentOS 6 서버의 낡은 의존성 패키지들을 밑바닥부터 강제 업데이트하며 방화벽의 장벽을 부쉈다. 분산 서버 환경에서 OAuth2 프로토콜의 특성 때문에 발생한 무한 리다이렉트 지옥은 L4 로드밸런서의 스티키 세션 튜닝으로 틀어막았으며, 마지막으로 브라우저조차 제대로 뜨지 않던 파워빌더라는 유물에는 C# COM Wrapper라는 새로운 생명줄을 달아 모던 OIDC 생태계로 무사히 편입시켰다.
돌이켜보면, 최신 Spring Boot와 Cloud Native 기술 스택으로 처음부터 시스템을 우아하게 다시 짜는(Rewrite) 것보다, 오랜 세월 누더기처럼 기워진 레거시 시스템을 멈추지 않고 계속 운영하면서 최신 보안 패러다임을 억지로 이식하는 작업이 실무 개발자에게는 훨씬 더 가혹한 도전을 요구한다.
하지만 에러 로그 하나하나를 돋보기로 뜯어보고(토큰 까보기), StackOverflow와 깃허브 이슈를 뒤지며 수백 번의 삽질을 반복하는 과정 속에서, 단순히 코드를 짜는 것을 넘어 OS의 암호화 모듈, L4 네트워크의 세션 유지 메커니즘, 그리고 윈도우 COM 통신에 이르기까지 풀스택을 관통하는 입체적인 아키텍처 이해도를 얻을 수 있었다.
이 더럽고도 치열했던 삽질의 기록이, 오늘도 어딘가에서 "본사 정책 변경에 따라 당장 다음 달까지 레거시 시스템에 최신 보안 솔루션을 붙이라"는 날벼락 같은 지시를 받고 식은땀을 흘리고 있을 수많은 엔터프라이즈 환경의 실무 개발자들에게 작은 위로와 유용한 이정표가 되기를 바란다. 버티면 어떻게든 길은 열리고, 레거시는 생각보다 질기다.
'IT' 카테고리의 다른 글
| 레거시 EAI 걷어내기: DB 연동에서 API/MQ로 넘어갈 때 백엔드 개발자가 살아남는 법 (0) | 2026.03.25 |
|---|---|
| MTU (Maximum Transmission Unit) 최적화 가이드 (0) | 2025.02.14 |
댓글