반복영역 건너뛰기
주메뉴 바로가기
본문 바로가기
제품/서비스
EMS Solution
Features
클라우드 관리
서버관리
데이터베이스 관리
네트워크 관리
트래픽 관리
설비 IoT 관리
무선 AP 관리
교환기 관리
운영자동화
실시간 관리
백업 관리
스토리지 관리
예방 점검
APM Solution
애플리케이션 관리
URL 관리
브라우저 관리
ITSM Solution
서비스데스크
IT 서비스 관리
Big Data Solution
SIEM
AI 인공지능
Dashboard
대시보드
Consulting Service
컨설팅 서비스
고객
레퍼런스
고객FAQ
문의하기
가격
자료실
카탈로그
회사소개
비전·미션
연혁
2016~현재
2000~2015
인증서·수상
투자정보
재무정보
전자공고
IR자료
새소식
공고
보도자료
오시는 길
채용
피플
컬처
공고
FAQ
블로그
열기
메인 페이지로 이동
블로그
최신이야기
블로그
최신이야기
사람이야기
회사이야기
기술이야기
다양한이야기
JPA 도입을 위한 고민_ORM 기술을 써야 하나?
[행사] HAPPY NEW YEAR! 2023 신년회
최순정
2023.01.06
페이스북 공유하기
트위터 공유하기
링크드인 공유하기
블로그 공유하기
[행사] 친해지길 바라, '브행시'
2023년 신년회가 1월 5일 브레인즈컴퍼니 본사 8층 라운지에서 열렸습니다.
오랜만에 많은 브레인저들이 한 자리에 모였습니다.
각 부서장들이 2022년 사업실적 및 2023년 사업계획을 발표하며 행사가 시작됐습니다.
전략사업본부의 재걸님을 시작으로 연구개발본부 자환님, 경영지원실 현보님, 경영기획실 관진님 순으로 발표가 이어졌습니다.
이후 선근님(대표이사)이 지난 한 해를 돌아보고, 올해 계획에 대해 설명하는 시간을 가졌습니다.
특별히 지난 1년간 열심히 활동해 온 '행복한 회사 만들기 TF, YB(Young Brainz)'의 성과를 돌아보고,
YB가 만든 소통함에 들어온 브레인저들의 의견을 하나씩 답해주는 시간도 가졌습니다.
다음으로 각종 포상 및 승진 행사가 이어졌습니다.
먼저, 5/10/15/20년 장기근속자들에 대한 포상이 이뤄졌습니다.
5년 근속자는 황금열쇠 5돈, 10년 근속자는 현금 300만원과 휴가 3일을, 15년 근속자는 500만원과 휴가 5일을 지급합니다.
장기근속자 포상 중 가장 반응이 뜨거웠던 20년 근속자 포상은 무려 700만원입니다!
개발1그룹장인 상호님이 20년간 브레인즈에서 근무했는데요.
선근님은 오랜 시간 함께 해 온 상호님에게 고마운 마음을 담아 뜨거운 포옹을 했습니다.
다음으로 우수 직원 및 최우수 부서 포상, 협력지원 포상이 이뤄졌습니다.
포상이 끝난 후 승진 임명이 진행됐습니다.
대리부터 부사장까지 여러 직급에서 브레인저들이 승진을 했습니다.
이후 회식을 위해 근처 고깃집으로 이동했습니다.
코로나19로 몇 년만에 진행하는 단체 회식이다 보니, 브레인저들 모두 즐거운 시간을 가졌습니다.
이렇게 브레인저들은 기분좋게 2023년을 시작했습니다!
브레인즈컴퍼니 화이팅!
#브레인즈컴퍼니
#신년회
#포상
#승진
#행복한 회사 만들기
최순정
경영기획실(PR매니저)
브레인즈컴퍼니의 소식, 조직문화, 브레인저 이야기를 대내외에 전파하고 있습니다.
필진 글 더보기
목록으로
추천 콘텐츠
이전 슬라이드 보기
Java APM 기반 기술에 대한 간략한 설명
Java APM 기반 기술에 대한 간략한 설명
몇 년 전부터 미국 실리콘밸리에서 불어온 스타트업 광풍이 인플레이션과 경기 침체가 동시에 예상되는 최악의 전망 속에서 조금 사그러드는 모습입니다. 그러나 빠른 속도로 퍼지기 시작한 IT 관련 유행들은 아마 꽤 오랜 시간 우리들 근처에 남아 그 영향이 지속되지 않을까 예상해봅니다. 그 중 한 부분을 차지하는 것이 새로운 혹은 인기가 급상승한 Go, Python, R, Julia, Kotlin, Rust, Swift 등의 컴퓨터 언어들입니다. 이렇게 많은 언어들이 새로 등장해 번쩍번쩍하는 장점을 뽐내고 있는 와중에도, 아직 세상의 많은 부분, 특히 ‘엔터프라이즈 IT’라 불리는 영역에서 여전히 가장 많이 사용되는 것은 Java입니다. 절대적이지는 않지만 컴퓨터 언어의 인기 순위 차트인 TIOBE 인덱스에 따르면, 2022년 6월 현재도 Java의 인기는 Python, C의 뒤를 잇는 3위입니다. Java 역시 Java 9부터는 십 수년간 고수하던 백워드 컴패티빌리티 정책을 포기하고 여러가지 반짝거리는 장점을 받아들이면서 버전업을 계속해, 올해 9월에는 Java 19가 나올 예정입니다. 그러나 아직도 우리나라 ‘엔터프라이즈 IT’에서 가장 많이 쓰이는 버전, 그리고 작년까지는 세계에서 가장 많이 쓰이는 버전은 Java 8이었습니다. 이렇게 많은 Java 어플리케이션의 성능을 모니터링하고 관리할 수 있는 솔루션을 통상적으로 APM(Application Performance Management)이라고 합니다. 위에서 서술한 것처럼 다른 컴퓨터 언어들의 인기가 올라가고 사용되는 컴퓨터 언어가 다양해지면서 많은 APM 제품들이 Java외의 다른 컴퓨터 언어로 작성된 어플리케이션도 지원하는 경우가 늘어나고 있으나, 이 글에서는 APM을 Java 어플리케이션의 성능을 모니터링하고 관리할 수 있는 솔루션으로 한정하도록 하겠습니다. 어플리케이션의 성능을 보다 깊이 모니터링하는데 필수적인 것이 Trace[i]입니다. Trace는 어플리케이션이 실행되는 과정에 중요하다고 생각되는 부분에서 중요하다고 생각되는 어플리케이션의 상태를 기록으로 남긴 것입니다. 전통적인 어플리케이션에서는 실행 Thread를 따라가면서 순차적인 Trace가 남게 되고 유행에 맞는 MSA(Micro-Service Architecture) 어플리케이션에서는 서로 연관됐지만 직선적이지는 않은 형태의 Trace가 남게 됩니다. 이러한 Trace를 수집하고 추적하고 분석하는 것이 APM의 주요 기능 중 하나입니다. 그런데, 여기서 문제가 하나 생깁니다. Trace는 누가 남길 것인가 하는 문제입니다. 개발 리소스가 충분하고 여유가 있는 경우, 개발시 성능에 대한 부분에 신경을 써서 개발자들이 Trace를 남기며 이를 분석하고 최적화하는 것이 정례화, 프로세스화 돼있겠지만, 많은 경우 개발 리소스를 보다 중요한 목표 달성을 위해 투입하는 것도 모자랄 지경인 것이 현실입니다. 아무리 분석 툴인 APM이 좋아도, 분석할 거리가 되는 Trace가 없으면 무용지물이 돼 버립니다. 그래서 APM에는 미리 정해진 중요한 시점에 어플리케이션에서 아무 것도 하지 않더라도 자동으로 Trace를 남기도록 하는 기능이 필수적으로 필요합니다. Java 어플리케이션의 경우 이러한 기능은 Java Bytecode Instrumentation이라고 하는 기반 기술을 사용해 구현됩니다. 서론이 매우 길어졌지만, 이 글에서는 Java Bytecode Instrumentation에 대해 조금 상세히 살펴보도록 하겠습니다. Java Bytecode Instrumentation을 명확히 이해하려면, 먼저 Java가 아니라 C, C++, Rust등의 언어들로 작성된 프로그램이 어떤 과정을 거쳐서 실행되는가, 그리고 Java 프로그램은 어떤 과정을 거쳐서 실행되는가를 살펴보는 것이 도움이 됩니다. Java가 세상에 나오기 이전에는 ‘컴퓨터 학원’이나 고등학교 ‘기술’ 과목, 그리고 대학의 ‘컴퓨터 개론’ 등에 반드시 이런 내용이 포함돼 있었지만 요즘은 그렇지도 않은 것 같습니다. 컴퓨터에서 프로그램을 실행시키는 것은 CPU, 즉 Central Processing Unit입니다. 지금 이 글을 작성하고 있는 컴퓨터의 CPU는 Intel(R) Core(TM) i7-6700 CPU @ 3.40GHz입니다. CPU는 메모리의 프로그램이 있는 영역을 읽어 들여, 미리 정해진 값에 따라 정해진 동작을 수행하게 됩니다. 이때 어떤 값이 어떤 동작을 수행하는지 규정해 놓은 것을 Machine Language라고 합니다. Machine Language는 100% 숫자의 나열이므로 이를 좀더 사람이 읽기 쉬운 형태로 1:1 매핑 시킨 것이 Assembly Language입니다. (그렇다고 읽기가 많이 쉬워지지는 않습니다.) 이 글에서는 이 두 단어를 구분없이 혼동해 사용합니다. C, C++, 그리고 나온 지 벌써 10년이나 된 Go, 요즘 인기가 계속 상승하고 있는 Rust 등의 언어로 작성된 프로그램은, 이들 언어로 작성된 소스 코드를 Machine Language로 미리 변환해서[ii] 실행 파일을 만들고 이를 실행하게 됩니다. 이 변환을 수행하는 것을 Compile한다라고 하고 이 변환을 수행하는 프로그램을 Compiler라고 부릅니다. 한편, 소스 코드를 완전히 Machine Language로 변환시킨 실행 파일을 실행하는 것이 아니라 Interpreter라 불리우는 프로그램이 소스 코드를 읽으면서 그 의미에 맞게 동작을 수행시키는 언어들도 있습니다. ‘스크립트 언어’라 불리는 bash, Perl, PHP, Ruby, Python 등이 이에 해당되면, 요즘은 잘 쓰이지 않지만 그 옛날 Bill Gates가 직접 Interpreter를 만들기도 했던 BASIC 등이 이에 해당합니다. 본론으로 돌아가보겠습니다. 그렇다면, Java 프로그램은 어떤 방식으로 실행이 되는가? 기본적으로는 Interpreter 방식이라고 생각해도 이 글의 주제인 Java Bytecode Instrumentation을 이해하는 데는 무리가 없습니다.[iii] 여기에 더해 Java의 실행 방식에는 몇 가지 큰 특징이 있습니다. 첫째로, Java는 소스 파일을 직접 읽어 들이면서 실행하는 것이 아니라 소스 파일을 미리 변환시킨 Java Class File을 읽어 들이면서 실행합니다. 하나의 Java Class File에는 하나의 Java Class 내용이 모두 포함됩니다. 즉, Class의 이름, public/private/internal 여부, 부모 클래스, implement하는 interface 등의 Class에 대한 정보, Class의 각 필드들의 정보, Class의 각 메서드[iv]들의 정보, Class에서 참조하는 심볼과 상수들, 그리고 이 글에서 가장 중요한 Java로 작성된 각 메서드의 내용을 Java Bytecode 혹은 JVM Bytecode라고 하는 중간 형태의 수열로 변환시킨 결과 등이 Java Class File에 들어가게 됩니다. 이 Java Bytecode는 실제 실행 환경인 CPU 및 Machine 아키텍처에 무관합니다. 똑같은 Java 소스 코드를 Windows에서 Compile해 Java Class File로 만들건, Linux에서 Compile해 Java Class File로 만들건 그 내용은 100% 동일하게 되고 이 점은 C, C++, Rust 등 Compiler 방식의 언어와 큰 차이점입니다. Java의 가장 큰 마케팅 캐치프레이즈 “Write Once, Run Anywhere”는 이를 표현한 것입니다. 둘째, Java Bytecode는 일반적인 CPU의 Machine Language와 많은 유사점을 지닙니다.[v] 어찌 보면 Java Bytecode는 실제 존재하지는 않지만 동작하는 가상의 CPU의 Machine Language라고 볼 수 있는 것입니다. 이러한 이유에서 Java Class File을 읽어 들여 실행시키는 프로그램을 JVM이라고 (Java Virtual Machine) 부릅니다. Java 소스 파일을 Java Class File로 변환시키는 프로그램을 Java Compiler라고 부르며, 가장 많이 쓰는 Java Compiler는 JDK(Java Development Kit)에 포함된 javac라고 하는 프로그램입니다.[vi] JVM은 JDK에 포함된 java라고 하는 프로그램을 가장 많이 씁니다. 한편 사용 빈도는 그렇게 높지 않지만, Java Class File을 사람이 알아볼 수 있는 형태로 변환해서 그 내용을 보고 싶은 경우도 있습니다. 이런 일을 하는 프로그램을 Java Bytecode Disassembler[vii]라고 부르며, JDK에는 Java Bytecode Disassembler인 javap가 포함돼 있습니다. 혹은, Eclipse나 Intellij IDEA 같은 IDE에서 Java Class File을 로드하면 사람이 알아볼 수 있는 형태로 변환해 보여줍니다. Java Bytecode의 실제 예를 한번 살펴보도록 하겠습니다. 설명을 간단히 하기 위해, 클래스나 메서드 선언 등은 다 제외하고, 오직 메서드의 내용에만 집중하면, System.out.println(“Hello, World.”); 라는 Java 프로그램은 다음과 같은 Java Bytecode로 변환됩니다. (전통적으로 16진수로 표시합니다.) b2 00 0b 12 09 b6 00 0f b1 이를 javap를 사용해, 혹은 JVM Reference[viii]를 보고 좀더 사람이 보기 쉬운 형태로 표현하면 다음과 같습니다. 0: getstatic #11 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #9 // String Hello World 5: invokevirtual #15 // Method java/io/PrintStream.println: (Ljava/lang/String;)V 8: return JVM Reference의 Chapter 7을 참고하면, Java Bytecode를 javap의 결과에 어떻게 대응되는지를 알 수 있습니다. javap의 결과를 조금 더 살펴봅시다. 먼저 콜론 앞의 숫자는 인스트럭션의 offset으로서 Bytecode 시퀀스의 0번째, 3번째, 5번째, 8번째를 의미합니다. 0번째의 getstatic은 그 다음 숫자에 해당하는 필드를 스택의 맨 위에 저장하도록 합니다. 3번째의 ldc는 “Hello, World”라는 상수값을 스택의 맨 위에 저장하도록 합니다. 5번째의 invokevirtual은 println 메서드를 호출하고, 8번째의 return은 메서드에서 리턴해 호출한 곳으로 실행을 넘깁니다. Java 프로그램은 (정확히는 Java 소스 코드로 작성된 프로그램을 Compile한 결과) 통상적으로 많은 수의 Java Class File로 이뤄집니다. JVM은 이러한 Java Class File을 한꺼번에 읽어 들이는 것이 아니라 실행을 하다가 필요한 순간이 되면 그 때 읽어 들입니다. JVM은 이 로딩 과정에 사용자가 개입할 여지를 남겨 뒀는데, 이것이 Java Bytecode Instrumentation입니다. 이에 대한 개요는 https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html에 설명돼 있습니다. 요약해서 설명하면 다음과 같습니다. (1)사용자는 미리 정해진 규약대로 Java Agent라는 프로그램을 작성하고 이를 JVM 실행시에 옵션으로 명기합니다. (2)JVM은 Java Class File을 읽어 들여서 JVM이 처리하기 좋은 형태로 저장하기 전에, 그 파일 내용을 Java Agent의 ClassFileTransformer 클래스의 transform 메서드[ix]에 전달합니다. (3)JVM은 Java Class File의 원래 내용이 아니라 (2)의 메서드가 반환하는 결과를 저장하고 실행합니다. 이 과정을 Java Bytecode Instrumentation이라고 합니다. 사용자는 Java Bytecode Instrumentation을 구현해, 즉 Java Agent를 잘 작성헤 무엇이든 원하는 바를 달성할 수 있는 것입니다![x] 이러한 Java Bytecode Instrumentation은 APM, 그리고 Aspect-Oriented Programming의 기반 기술이 됩니다. 우리나라에서 Java로 프로그래밍을 한다고 하면 누구나 다 알고 있을 것 같은 Spring Core의 핵심 요소 중의 하나가 Aspect-Oriented Programming입니다. 예를 들어 Spring에서 @Transaction 이라고 annotation된 메서드가 있으면, Spring은 그 메서드의 맨 처음에 transaction을 시작하는 코드, 정상적으로 return하기 직전에는 transaction을 commit하는 코드, 그리고 익셉션에 의해 메서드를 빠져 나가기 직전에는 transaction을 rollback하는 코드를 삽입해 주게 되는데 이를 Java Bytecode Instrumentation을 이용해 구현하는 것입니다. 그럼, Java Agent에 거의 무조건적으로 필요한 기능은 무엇일까요? Java Agent는 Java Class File 내용을 그대로 전달받기 때문에 이를 해석할 수 있어야 무언가를 할 수 있습니다. 불행히도, java 스탠다드 라이브러리에는 Java Bytecode를 직접 다루는 기능은 없습니다.[xi] 그래서 de facto standard로 사용되는 것이 asm이라는 라이브러리입니다. 이 라이브러리는 수많은 java 라이브러리와 어플리케이션에 포함돼 있습니다. 그러나 asm이 훌륭한 라이브러리이긴 하지만, 이를 직접 사용하려면 각 상황에 맞게 코드를 삽입하는 프로그램을 작성해서 사용해야 하므로 자유도가 떨어집니다. 그래서 Zenius APM에서는 asm을 사용하되 삽입될 코드를 설정 파일에서 지정할 수 있는 suji(Simple Universal Java Instrumentor)[xii]라고 이름 붙인 라이브러리를 직접 만들어 사용하고 있습니다. suji를 사용하면 yaml 형식의 설정 파일에서, 어떤 클래스의 어떤 메서드의 어느 부분에 삽입할 것인지에 대한 조건과 삽입될 코드를 yaml의 list 형태로 지정하는 것만으로 (이는 Lisp와 비슷한 방식으로, 이렇게 하면 파싱 과정을 생략하면서 쉽게 코드를 넣을 수 있습니다.) Java Bytecode Instrumentation을 손쉽게 처리할 수 있습니다. 예를 들어, Zenius APM에서 JDBC getConnection을 처리하기 위해서 다음과 같은 부분이 설정 파일에 포함돼 있습니다. JDBC.DataSource.getConnection: IsEnabled: true ClassChecker: [ HasInterface, javax/sql/DataSource ] MethodName: getConnection IsStatic: false IsPublic: true IsDeclared: false ReturnType: Ljava/sql/Connection; Locals: [ Ljava/lang/Object;, Ljava/lang/Object; ] AtEntry: - [ INVOKE, dataSourceGetConnection, l1, [] ] AtExit: - [ INVOKE, poolGetConnectionEnd, l2, [ l1, ^r, true ] ] - [ LOAD, l2 ] - [ CAST, Ljava/sql/Connection; ] - [ STORE, ^r ] AtExceptionExit: - [ INVOKE, endByException, null, [ l1, ^e ] ] 간략하게 설명하면, Class가 만약 javax.sql.DataSource를 implement하고 메서드가 스태틱이 아니고 public이면서 java.sql.Connection을 리턴하는 getConnection이라는 이름을 가진 경우에 메서드 시작 시, 리턴 시, 그리고 익셉션에 의해 메서드를 나갈 때 위의 예제에 규정된 코드를 삽입하라는 의미입니다. 이상으로 Java Bytecode Instrumentation에 대한 간략한 설명을 마칩니다. 다음에는 실제로 APM이 중점적으로 추적하고 분석하는 것은 어떤 것들인가에 대해 설명하겠습니다. -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [i] Sridharan, Distributed Systems Observability, O’Reilly, 2018의 Chapter 4. The Three Pillars of Observability 참조. 번역본은 없는 듯합니다. [ii] 이 외에 여러가지 과정을 거치지만 이 글의 목적과는 무관하므로 과감하게, 자세한 설명은 생략합니다. [iii] 실제로는 Java 프로그램이 100% 이렇게 interpret되어 실행되는 것은 아닙니다. 특정 메쏘드 혹은 메쏘드의 일부분이 자주 실행돼 interpret하는 것보다 미리 컴퓨터(=CPU)가 바로 실행할 수 있는 형태(=Machine Language)로 변환(=compile)해 놓는 것이 더 낫다고 JVM이 판단하는 경우, 미리 이런 변환 과정을 한번 거쳐 그 결과를 기억해 놓고, 그 기억된 결과를 컴퓨터(=CPU)가 바로 실행합니다. 이렇게 변환하는 과정을 Just-In-Time Compile 혹은 JIT라고 합니다. 또 이 때문에 JVM을 단순한 interpreter로 부를 수는 없는 것입니다. [iv] 국립국어원은 메서드가 맞는 표기라고 합니다. [v] 물론 많은 차이점도 지닙니다. (1) JVM은 register가 존재하지 않고 오로지 stack에만 의존한다. (2) JVM은 Class, Method의 개념을 포함하고 있지만 일반적인 범용 CPU에는 그런 상위 개념은 없습니다. [vi] 보통 IDE를 써서 개발을 하기 때문에, javac를 직접 사용하거나 Java Class File을 직접 다룰 일은 잘 없고, jar 파일이 이 글을 읽는 여러분에게 훨씬 더 익숙할 지도 모릅니다. Jar 파일은 그냥 zip으로 압축된 파일이니 그 압축을 한번 풀어 보길 바란다. 확장자가 class인 수많은 파일을 찾을 수 있을 것입니다. [vii] Assembly는 Assemble의 명사형이며, Assemble의 반대말은 Disassemble입니다. [viii] JVM에 대한 모든 것은 The Java Virtual Machine Specification에 나와 있습니다. 이 중 'Chapter 6. The Java Virtual Machine Instruction Set'를 참고하면 각각의 instruction에 대해 상세히 알 수 있습니다. [ix] https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/ClassFileTransformer.html#transform-java.lang.ClassLoader-java.lang.String-java.lang.Class-java.security.ProtectionDomain-byte:A- [x] 쉽다고는 하지 않았습니다. 또 몇가지 제약 사항은 있습니다. [xi] 참고로 최근에는 asm을 대체할 수 있는 기능을 스탠다드 라이브러리에 넣을 계획이 진행되고 있습니다. https://openjdk.org/jeps/8280389 [xii] 명명이 아이돌 그룹 출신 모 여배우와 관계가 아주 없지는 않음을 조심스럽게 밝혀 둡니다.
2022.08.04
업계 1위 회사에서 개발 경험을 쌓고 싶다면?
업계 1위 회사에서 개발 경험을 쌓고 싶다면?
브레인즈컴퍼니는 IT 인프라 통합관리 소프트웨어 업계에서 20년 넘게 선두 자리를 지켜오고 있습니다. 20년 역사 중 절반인 10년 가량을 브레인즈에서 함께 성장해 온 개발자들이 있는데요. 업계 1위 제품을 개발하고 있다는 자부심으로 근무 중인 백엔드 개발자, 신호진님&프런트엔드 개발자 김범호님의 이야기를 들어보겠습니다. ----------------------------------------------------------------- Q. 안녕하세요, 자기소개 부탁드릴게요. 호진님: 안녕하세요. 2014년에 입사해 개발1그룹 인프라코어팀에서 근무 중인 신호진입니다. 첫 직장이 브레인즈컴퍼니라, 이제 8년차에 접어든 백엔드 개발자입니다. 범호님: 저는 2012년에 입사해서 10년이 흘렀네요. 개발2그룹 인프라웹팀에서 근무 중인 프런트엔드 개발자 김범호입니다. Q. 각자 맡고 있는 업무에 대해 설명해 주세요. 호진님: 브레인즈컴퍼니의 지능형 IT 인프라 통합관리 소프트웨어인 ZENIUS EMS(제니우스 이엠에스)의 통보 매니저, MRTG 매니저, 서버 Agent를 담당하고 있어요. 통보 매니저는 장애 발생 시 메일, 문자, App 등으로 통보해 사용자가 인지할 수 있도록 하고요. MRTG매니저는 다양한 IT 인프라에 대해 모니터링 분석 데이터를 제공해요. 서버 Agent는 장애 감시, OS 별 성능항목 초 단위 모니터링, 프로세스 모니터링을 제공합니다. 범호님: 호진님 팀에서 실시간 모니터링 작업을 통해 데이터를 수집하면, 그 수집된 데이터를 보고서나 차트, 오버뷰 등으로 사용자가 한눈에 볼 수 있도록 기획/설계/개발하는 업무를 하고 있습니다. Q. 이번 기회를 빌려 Zenius(제니우스)에 대해 홍보해 보자면? 범호님: 긴 말이 필요 없을 것 같아요. 관제 시스템으로서 갖출 수 있는 건 다 갖추고 있어요. 그러니까 업계 1위겠죠? 호진님: Zenius(제니우스)는 다양한 IT 인프라를 관리하는 제품이지만, 복잡하지 않고 사용자가 이용하기 쉽게 직관적으로 잘 만들어진 제품이에요. 국내에서 가장 인기있는 통합관제 솔루션입니다. Q. Zenius(제니우스) 제품을 개발할 때 주로 어떠한 언어를 사용하고 계시나요? 호진님: 주로 C, C++ 언어를 사용하고 있습니다. 범호님: 저는 주로 Java를 사용해요. 현재 팀 이전에는 ITSM팀에서 근무했는데, 그때는 Kotlin을 사용했어요. Q. 두 분은 프런트엔드/백엔드 커리어를 선택한 계기가 있나요? 호진님: 저는 컴퓨터공학을 전공했고, 프로젝트 때마다 담당하던 부분이 백엔드였어요. 그러다 보니 자연스럽게 백엔드 개발자가 됐어요. 그리고 C, C++ 언어를 배우면서 이 분야가 전망이 좋다는 점도 직무를 선택하는데 영향을 미친 것 같아요. 범호님: 저도 호진님과 비슷해요. 전공이기도 했고, 개발 업무가 성격에 잘 맞았어요. Q. 두 분 모두 개발 일을 하신 지 10년 정도가 흘렀네요. 개발 환경이 10년 전과 비교했을 땐 어떻게 달라졌나요? 호진님: 예전에는 개발자라 하면 야근도 많았고 연봉도 그렇게 높지 않았죠. 지금은 개발자 품귀 현상이 나타날 정도로 인기있는 직종이 되다 보니, 연봉도 높아지고 야근도 없는 편이에요. 얼마 전에 연봉이 천만원 인상되면서 매우 만족하며 다니고 있습니다. (웃음) 범호님: 10년 전만 해도 개발자는 3D 업종이라는 말이 있을 만큼 힘든 직업이었던 것 같아요. 예전에는 “적성에 맞는 일을 꼭 해야겠다”라는 인식이 있었다면, 요즘은 개발자가 좋은 이미지로 비치다 보니 사람들이 쉽게 접할 수 있게 되면서 적성보다는 “개발 일을 한 번 해 봐도 괜찮지 않을까”라는 인식으로 바뀐 것 같아요. Q. 이제 팀에 대해 이야기 나눠볼게요. 각자 팀 분위기는 어떤가요? 범호님: 저희는 자유로운 분위기인 것 같아요. 혼자 밥 먹고 싶으면 혼자만의 시간을 갖기도 하고, 다른 사람과 어울리고 싶으면 함께하기도 하고요. 각자 취향을 존중해 주고 있습니다. 호진님: 인프라코어팀은 그 어떤 팀보다 밝은 팀이에요. 저희 팀은 10명 가량으로 구성돼 있는데, 그중 절반 이상이 10~15년 이상의 근속자분들이자 베테랑 개발자예요. 모두 겸손하고 유머러스해서 입사 초부터 잘 해주시다 보니 적응하기도 쉬웠어요. 평소 서로 인사도 잘 하고 이야기도 자주 나누고요. 다른 팀들이 저희 팀을 무서워(?) 하는 것 같은데, 실상은 전혀 그렇지 않거든요. 특히 그룹장인 상호님은 겉으로는 차가워 보이지만, 속은 누구보다 따뜻해서 이야기 나눠보면 그 매력을 느낄 수 있을 거예요. (웃음) Q. 장기 근속자가 많다는 것이 배울 점이 많아 좋기도 하지만, 세대 차이가 발생할 수도 있을 것 같은데요. 그 간극을 좁히기 위해 어떤 노력을 하시나요? 호진님: 시니어 개발자들과 주니어 개발자들이 서로의 차이를 극복하기 위해 대화를 정말 많이 합니다. 그러다 보면, 서로 생각하지 못한 부분을 채워줄 수 있더라고요. 그리고 저희 팀은 함께 밥도 자주 먹고 강제성 없이 원하는 사람들끼리 술도 마시면서 동료애를 쌓아가고 있어요. 범호님: 코드 리뷰를 통해 서로 피드백을 주고받고 있어요. 연차가 낮은 동료가 먼저 신기술을 접하고 오면 제가 배우려고 할 때도 있고요. 또, 저희 팀도 대화를 많이 해요. 설득하는 과정이 필요할 때마다 대화를 통해 의사소통을 해 나가요. 서로 존중해주는 과정이라고 생각해요. 내 의견이 맞다고 생각하다가도 상대 의견이 타당한 내용이라면 믿어주고 서로 응원하면서 일하고 있습니다. Q. 동료들은 본인들을 어떤 사람이라고 이야기할 것 같은가요? 호진님: 아주 쑥스러운 질문이네요. (웃음) 음... 괜찮은 사람이라고 할 것 같습니다. (웃음) 앞으로 “같이 일하면 즐겁고, 어떤 일이든 믿고 맡길 수 있는 사람!”이라는 평가를 들을 수 있도록 더 노력해야죠. 범호님: 믿을 수 있는 사람. 그래서 의지할 수 있고 항상 같이 일하기 좋은 사람이고 싶습니다. (웃음) Q. 그럼 반대로 어떤 동료와 함께 일하고 싶은가요? 호진님: 예의 있고 끈기 있는 동료요. 예의는 직장 생활의 기본이라고 생각해요. 업무 관련해서는 개발과정에서 막히는 부분이 있을 때 오래 앉아있으면 해결책이 나오기 때문에 끈기가 정말 중요해요. 여기에 하나 더 덧붙이자면, 책임감 있는 동료들과 일하고 싶어요. 데드라인은 정말 중요하니까요. 범호님: 바보 같은 질문을 스스럼없이 하는 동료. 터무니없는 질문을 시작으로 되게 괜찮은 아이디어가 나오기도 하거든요. 그래서 때와 장소에 따라 질문을 하는 용기가 필요해요. 상대 이야기를 듣다가 모르는 부분에 대해서는 질문을 해야 업무를 하는데 문제가 없거든요. 보통 질문을 하지 않는 사람들은 이해를 하지 못했는데도 불구하고, 마치 다 알고 있는 것처럼 근엄하게 있다가 넘어가는 경우들이 많아요. 그렇다 보면 업무를 진행하는데 문제가 발생하죠. 그래서 아무 말없이 듣기만 하기보다는 질문할 수 있는 용기를 가진 동료가 좋습니다. Q. 차후에 합류하게 될 개발자들에게 브레인즈에 대해 꼭 알려주고 싶은 것은? 범호님: 저는 장기근속자다 보니, 그동안 회사가 바뀌는 과정을 봐왔는데요. 브레인즈컴퍼니는 지난 10년간 꾸준히 성장해오고 있어요. 그래서 새로운 개발자들이 합류한다면, 10년 후에는 더 큰 회사로 성장해 있을 것이라고 확신해요. 특히 브레인즈컴퍼니는 인재에 많이 투자를 하고 있습니다. 웬만한 교육은 지원을 해주고 있기 때문에, 이렇게 노력을 들이는 회사에서 본인 스스로 노력하고 발전하고자 하는 의지만 있다면 좋은 방향으로 성장할 수 있습니다. 호진님: 브레인즈컴퍼니는 직원을 많이 생각하는 회사예요. 복지가 좋고, 사람들도 좋아요. 이렇게 말하면 잘 와 닿지 않을 것 같은데, 입사하셔서 직접 느껴 보시기 바랍니다! Q. 마지막 질문입니다. 나에게 브레인즈컴퍼니란? 범호님: 동반자. 저는 그동안 운이 좋은 케이스였던 것 같아요. 프로젝트를 하기 위해 외부로 나갔다가 다시 돌아오기도 했고, 팀도 옮겨 봤고요. 그 와중에 회사가 리모델링도 하고 인력이 충원되면서 커졌고요. 또, 기존 제품을 아예 새로 만들어 보기도 했죠. 안주할 새도 없이 여러 변화를 겪으며 회사와 함께 성장해왔기 때문에 동반자 같은 존재가 됐습니다. 호진님: 성장할 수 있는 기회를 주는 곳. 또, 밥 굶지 않고 살아갈 수 있도록 아낌없이 지원해주는 곳이기도 하고요. (웃음)
2022.11.22
JPA 도입을 위한 고민_ORM 기술을 써야 하나?
JPA 도입을 위한 고민_ORM 기술을 써야 하나?
몇 해전에 새로운 버전의 ITSM을 개발하기 시작하면서 JPA 기술 도입을 두고 고민했던 내용을 이제는 한 번쯤 정리해야 할 시점이라고 생각했다. 비단 JPA뿐 아니라 Spring Boot, Thymeleaf, Kotlin과 같은 새로운 개발 기술이나 Git, Gradle, Slack, PR처리 등 새로운 업무 환경까지 상당한 변화를 시작한 프로젝트였기 때문에 고민되는 것이 한두 개가 아니었지만 가장 길고 심각하게 고민했던 부분이라 따로 기록을 남겨본다. 이 글은 기술적인 내용은 아니고 어떻게 보면 당연하고 일반적인 내용이지만 다음 기회에 새로운 기술, 환경, 프로세스에 대한 도입을 검토할 때 조금이나마 도움이 됐으면 하는 마음이다. 여기에선 기술적인 내용에 대한 설명을 덧붙이지 않는 것은 관련된 내용은 'JAVA', 'ORM', 'JPA' 등으로 검색만 해도 비슷한 글들이 넘쳐나는 상황에 하나 더 덧붙이는 건 별로 의미가 없어 보이기 때문이다. 1. ORM에 대한 갑을논박 ORM에 대한 검색을 해보면 정말 여기서 다시 얘기하고 싶지 않을 정도로 오랜 시간동안 많은 사람들의 많은 의견들이 쏟아져 나온다. 게다가 더욱 혼란스러운 점은 구구절절 옳은 말들이라는 점이다. 여기서 뭔가 딱 부러진 결론을 내는 것은 불가능하고 너무 많은 의견들을 접하면서 점점 혼란스러워졌다. 대표적으로 참고 삼아 [자바 ORM 표준 JPA 프로그래밍]을 쓰신 김영한님의 글로 추정되는 링크 하나 투척~ https://okky.kr/article/286812 2. 우리에게 중요한 것 2.1. 진입장벽 : 진입장벽… 이 높다한들 하늘 아래 뫼… 일까? 어떤 기술이든 진입장벽은 그 도입 여부를 결정하는 가장 중요한 요소이다. 개인적으로 스터디를 하거나 한번 써보고 싶은 마음에서라면 진입장벽이 높을수록 구미가 당기는 변태적인 성향이 있는 사람도 있겠지만 이게 업무적인 접근이고 다른 팀원들과 함께 해야 하는 것이라면 진입장벽이 높이에 따라서는 그 기술의 효과가 인정되어도 도입이 쉽지 않은 것이 사실이다. JPA는 많은 사람들이 진입장벽이 높은 편이라고 입을 모아 말한다. 검토를 위해 살짝 들여다 보았을때도 쉬워 보이진 않았다. 말 그대로 ORM을 잘 쓰기 위해서는 Object와 Model에 대한 깊이 있는 사전 지식과 그 둘을 Mapping하는 개념적인 체계가 머리 속에 있어야 충분히 활용할 수 있을 것 같았다. 진입장벽이란 것도 사실 상대적인데 당시에 판단으로 우리 팀에서 도입하기에 진입장벽은 중상(中上)이라고 생각했다. 잘 자리잡기 쉽지 않을 것이고 시간도 오래 걸리리라 생각이 들었다. 이러한 점을 만회할 장점이 있는지 고민이 필요했다. 2.2. 제품 특징 : 우리가 만드는 제품/프로젝트의 특징에 맞는가? 당시에 새롭게 시작되는 프로젝트에서 만드는 제품은 기존 Zenius ITSM 시스템의 새로운 버전이다. 업무적으로 여러가지 특징이 있지만 Model과 관련되어서는 상대적으로 복잡한 구조라 할 순 없었고 극단적인 성능과도 거리가 좀 있다. 상대적으로 깔끔하고 명확한 모델링이 훨씬 더 중요하다고 판단했고 이러한 면은 JPA도입에 대한 긍정적인 입장을 가지게 했다. 쿼리와 관련되어서 수많은 간단한 작업들을 효과적으로 할 수 있을거란 기대감… 만약 만들려고 하는 제품이 특정 RDBMS에 의존적이거나, 혹은 인수인계나 유지보수가 어려울 정도로 비즈니스부터가 복잡한 형태라서 JPA를 쓰면서도 많은 성능 튜닝과 Native Query를 사용해야 하는 상황이거나 한다면 상황은 약간 달라졌을 것이다. 제품의 특징과 더불어 현재 프로젝트의 특성도 같이 살펴봐야 한다. 레거시 시스템의 업그레이드인지, 이번 프로젝트처럼 완전히 새 판에서 시작하는 게 가능한 상황인지… 새로운 제품을 만드는 프로젝트가 납기일이 정해진 프로젝트보다 나은 점은 그나마 초기 학습과 관련된 투입을 감안하기가 좀 더 수월하다는 점이다. SI같은 성격의 프로젝트라면 내부 고객뿐 아니라 상대방 고객도 설득해야 하는 문제점이 더 크다. 그런 면에서 이번 프로젝트는 JPA를 도입하거나 적용하기엔 괜찮은 상황이라는 게 결론이었다. 2.3. 조직/인력 구조 : 바로 우리가 쓰는 기술이다. 기술도 중요하지만 우리도 중요하다. 제목처럼 아무리 좋은 기술이라도 우리에게 맞냐는 게 결정적이다. 아래와 같은 질문들을 던져 보았다. • 현재 구성원들의 사전 지식은 어느 정도인가? • 우리 회사나 우리 팀에서 향후 관련된 개발자를 계속 충원할 수 있는가? • 우리 팀은 새로운 기술을 공부하며 도입할 의지를 가졌는가? • 회사는 관련된 교육과 초기에 벌어질 삽질을 감내할 수 있는가? 결론적으로 반반이었다. 우리 팀은 JPA에 대해서 아는 바가 거의 없는 상태였다. 게다가 지금이야 JPA를 사용하는 사람들도 더 늘어난 것 같고 우리 회사의 위상도 달라졌지만 당시의 우리 회사의 규모나 채용 형태를 봤을 때 관련된 개발자를 충원하는 것도 쉽지는 않을 것 같았다. 반대로 새로운 기술 도입에 대해서 강한 의지까지는 아니라도 긍정적은 자세를 가진 팀원과 초기 삽질에 대해서 어느 정도 감내할 수 있는 회사라는 것이 당시의 생각이었다. 그래도 반이 어디냐…는 게 최종 결론이었다. 2.4. 재미 : 그래서 땡기냐? 이성적이고 객관적인 여러 사실들을 매트릭스화해서 평가를 하면서도 스스로에게 던지는 마지막 질문은… 그래서 땡기냐는 거다. 모든 수치가 부정적인데도 끝까지 미련을 버리지 못하고 하고 싶은 경우가 있고, 모든 결과가 긍정적인데도 뭔가 하기 싫은 경우가 많은데, 결국 그것들은 결과로 이어지더라. 리누스 토발즈가 커널을 업그레이드할 때 가장 중요한 점으로 “얼마나 재미”가 있냐는 점이라고 얘기 했다는데, 우리는 그 정도 레벨의 개발자는 아직(!) 아니지만 우리에게도 “재미”는 가장 중요한 결정요인 중 하나이다. 스스로에게 물어보자. 재미있어 보이나? 그리고, 당시에 나에게는 무척 설레었던 일이었음을 고백해야겠다. 3. 염려스러운 점 3.1. 회귀본능 아직 익숙하지 않은 상태에서 개발을 진행하다 보면 도무지 JPA에서 왜 이런 쿼리를 만들어내는지 이해하기 어려운 경우를 종종 만난다. 혹은 익숙한 SQL이 머리속에서 막 떠오르는데 JPA로 적용하기 위해서 이런저런 삽질을 하다 보면… 아… 그냥 쿼리를 직접 짤까? Native Query도 Mybatis도 지원한다던데… 분명 이런 순간이 올 것이라고 예상했다. 공부를 하는 것도 좋지만 회사에서 업무로 일정에 맞춰 무언가를 만들어내야 하는 압박감은 따로 누가 주지 않아도 가지고 있는 것이니… 침착하자. 익숙하지 않고 힘들다고 나도 모르게 무언가 자꾸 길을 벗어나고 있는 건 아닌지 계속 주의 깊게 들여다 봐야 한다. 결론적으로 지금에 와서 돌이켜보면 초반에는 의도대로 생성되지 않는 쿼리들에 당황하긴 했지만, 약간의 삽질 후에는 왜 그런 상황이 발생되는지 알기가 어렵지는 않았다. 언젠가는 복잡한 통계나 로직 때문에 Native Query를 쓰게 될 날이 오겠지만 아직은 아니다. 3.2. 학습곡선 도입하려는 기술에 따라, 혹은 구성원의 사전 지식에 따라 학습곡선은 상당히 다양한 형태로 나타나는데, 평균적으로 JPA의 학습곡선은 전반적으로 경사가 아주 완만하다고 판단했다. 즉 도입 검토 시점의 진입장벽은 그 자체로 염려스러운 점이었다. 그 얘기는 수준을 일정수준 이상으로 끌어올리기 위해서 많은 시간과 노력이 팀 차원에서 필요하다는 얘기였고 필요로 하는 사전지식도 꽤 있을 듯 했다. 게다가 여러 가지 이유로 개인별로 나타나는 학습곡선도 많이 다르리라 예상했다. 뭔가 기막힌 해결책이 있으면 좋겠지만, 책을 구매해서 읽고 유료 강의, 무료 강의들을 공유하고… 서로서로 도와가며 공부하는 클래식한 정공법을 택했다. (그만큼 사실 효과는 기대하기 힘들다는 것도 알지만…) 지금 생각해보면 어떤 기술이나 프로세스든 누군가 소수의 인원이 먼저 출발해서 끌어줄 수 있는 형태가 되는 것이 제일 나은 것 같다. 서로서로 도움을 주면서 같이 커가는 모양새가 될 수 있을 듯 한데 우리는 그렇지는 못했고 모두가 공평(?)하게 모르는 상태에서 스타뜨~ JPA의 도입에 대한 학습곡선은 최종적으로 도입을 결정하는데 마지막까지 고민을 하게 했던 점이었다. 3.3. Mapper는 누가? 자, 우리는 Object도 Model도 이제까지 다 개발자가 했다. Object야 당연히 개발자가 만들어야 하겠지만 큰 기업에서처럼 DBA가 있거나 화면을 퍼블리싱해주거나 하지 않는다. 우리는 우리가 화면, 미들웨어, DB까지 직접 만들고 컨트롤 해왔다. 그게 좋은 것이냐의 문제를 여기서 얘기하자는 게 아니라 현실이라는걸 얘기하는 거다. 우리 팀원 모두가 JPA 초보이다. Mybatis를 사용하고 Spring을 사용해봤다고 하지만 ORM이나 SQL Mapper에 대한 심도있는 고민은 부족한 상황. 앞으로 JPA에서 Object와 Model은 그렇다고 해도 Mapper역할은 또 필요하지 않을까? 그런 가이드는 또 누가 해야 하나… 모든 개발자에게 알아야 한다고 말할 수 있지만 모든 개발자에게 팀에서 잘하는 메인이 되라고 하기엔 좀 애매한 영역이란 게 항상 있다. 프로그램의 오브젝트와 DB의 모델을 연결하는 Mapper를 잘 구성할 경험이 많은 개발자가 없다는 점은 학습곡선과 더불어 JPA 도입을 망설이게 했던 주요 고민이었다. 결론적으로 선임 개발자를 중심으로 착실히 스터디를 잘 해주었고 제품의 특성상 그렇게 복잡한 관계를 매핑할 일이 많지 않아서인지 초반에 몇 번 팀원들이 같이 머리를 싸매고 논의했던 것 외에 문제는 없었다. 4. 결론(현재까지는…) 도입 결정 후 꽤 긴 시간 제품을 만들고, 이제는 고객사에 납품도 하면서 기능을 계속 추가하고 있는 이 시점에서 돌아보면, 어떤 부분은 팀원들이 너무 잘해주고 있고, 어떤 부분은 전혀 예상하지 않은 형태로 진행이 돼서 난감한 경우도 있지만 전체적으로는 아주 만족하고 있다. 정확하게 측정을 하진 못했지만 쿼리를 직접 짜면서 개발을 진행하는 것보다 생산성 측면에서 확실히 나아졌다고 느끼고 있고 그 효과는 초반에 투입된 시간에 비례해 앞으로 더욱 더 기대된다. 만족하고 있다고는 했지만 여기서 만족이라는 게 성과나 기술적인 완성도에 대한 절대적인 만족은 아니다. 다만 아직 우리 제품에 대한 아쉬움을 가지는 것이 JPA 때문은 아니라는 점은 확실하다. JPA가 유행에 따라 생긴 기술이라고 하기엔 너무 오래된 기술이지만 그래서인가 ORM 자체에 대한 흥미도 점점 더 해가고 있다. JPA도 ORM에 대한 가장 최근의 시도중 하나겠지만, 앞으로 어떤 식으로 발전해 나갈지, 그에 따른 개발 업무는 또 어떤 식으로 변화가 있을지도 궁금하고… 어쨌든, 지금으로서는 다시 돌아가진 않을 생각이다.
2023.01.03
Spring Security로 로그인부터 사용자 전환까지 구현하기
Spring Security로 로그인부터 사용자 전환까지 구현하기
ID/PW 로그인으로 시작한 인증 기능도 제품의 접점이 늘어나면 다양한 요구사항을 만나게 됩니다. 외부 시스템에는 API Key를 이용한 호출이 필요하고, 개발 과정에서는 사용자 ID만으로 권한별 화면을 확인해야 할 때가 있습니다. 운영 지원을 위해 관리자가 하위 사용자의 실행 환경으로 전환하는 기능도 필요합니다. 이러한 요구사항을 구현하려면 인증 정보를 검증하는 방식뿐 아니라 요청을 구분하고 인증 상태를 유지하는 구조도 함께 이해해야 합니다. 이 글에서는 먼저 Spring Security의 공식 구조를 바탕으로 로그인 처리 흐름을 살펴보겠습니다. 이어서 프로젝트에서 세 가지 인증 방식을 분리한 방법을 자세히 알아보고, 마지막으로 원래 로그인한 사용자를 보존하면서 실행 사용자를 전환하는 구현을 살펴보겠습니다. 1장: Servlet 필터 체인부터 인증 관리자·제공자·토큰·세션까지 2장: ID/PW, API Key, 개발용 ID 인증의 커스텀 구현 3장: 상위 사용자의 하위 사용자 전환과 원래 계정 복귀 ※ 프로젝트 코드는 설명에 필요한 부분만 발췌했으며, 암호화·복호화·비밀번호 검증의 세부 구현과 실제 인증값은 주석으로 생략했습니다. 1. 공식 문서로 이해하는 로그인 처리 구조 로그인을 구현할 때는 요청을 받는 코드와 사용자를 검증하는 코드의 역할을 먼저 구분해야 합니다. Spring Security는 이를 필터, 인증 관리자, 인증 제공자, 인증 결과 객체로 나누어 처리합니다. 요청이 이 구성 요소들을 거쳐 인증되고 저장되는 흐름을 이해하면, 이후에 살펴볼 여러 로그인 방식과 사용자 전환도 같은 구조 안에서 파악할 수 있습니다. ※설명 기준: 프로젝트는 Spring Boot 3.0.4를 사용하며, Spring Security 버전은 6.0.2입니다. 이 장은 Spring Security 6.x의 Servlet 구조를 설명하며, 그림과 설명은 공식 6.5 레퍼런스를 사용했습니다. 아래 설정은 학습용 예시로, 다음 장의 프로젝트 설정과는 구분됩니다. 1.1. Servlet 컨테이너의 FilterChain에 들어가는 보안 필터 먼저 요청이 들어오는 지점부터 살펴보겠습니다. Spring Security의 보안 필터가 연결되는 대상은 Apache Tomcat 같은 Servlet 컨테이너가 실행하는 Servlet Filter 체계입니다. 간혹 이를 “Apache 필터”라고 표현하기도 하지만, Apache HTTP Server의 모듈이나 필터 체계와는 구분해야 합니다. 그림 1. 컨테이너의 필터들과 Servlet. 요청은 여러 필터를 거쳐 Spring MVC의 DispatcherServlet에 도착합니다. 각 필터는 요청을 다음 단계로 넘기거나 직접 응답하고 처리를 종료할 수 있습니다. Spring Security도 이 구조를 이용해 인증이 필요한 요청을 컨트롤러에 도착하기 전에 차단할 수 있습니다. 이 과정에서 컨테이너의 필터와 Spring Bean을 연결하는 클래스가 DelegatingFilterProxy입니다. 이 프록시는 Spring에서 관리하는 보안 필터 Bean으로 처리를 위임합니다. 일반적으로 위임 대상인 springSecurityFilterChain Bean의 실제 구현은 FilterChainProxy이며, 이름에 Chain이 포함되어 있지만 Servlet 관점에서는 하나의 Filter로 동작합니다. 그림 2. Servlet 컨테이너와 Spring ApplicationContext를 연결하는 프록시. 이 구조를 요청의 이동 순서에 따라 살펴보겠습니다. 컨테이너의 필터 체인에서 보안 처리 지점에 도착한 요청은 DelegatingFilterProxy를 통해 FilterChainProxy로 전달됩니다. FilterChainProxy는 요청에 맞는 SecurityFilterChain을 선택하고, 그 안의 보안 필터들을 순서대로 실행합니다. 보안 처리를 통과한 요청은 컨테이너의 필터 체인으로 돌아가 Servlet까지 진행합니다. 공식 문서의 Architecture에서도 이러한 계층을 각각의 그림으로 설명합니다. 1.2. 필터 매니저와 패턴 매처: 어떤 보안 체인을 적용할까 그렇다면 여러 보안 체인이 등록되어 있을 때는 어떤 체인을 선택할까요? 앞서 살펴본 FilterChainProxy가 이 선택을 담당하며, 이 글에서 말하는 “필터 매니저” 역할에 해당합니다. 우선순위가 높은 체인부터 RequestMatcher로 요청을 검사하고, 처음 일치한 체인 하나만 실행합니다. 일치하는 체인을 모두 이어서 실행하는 방식은 아닙니다. 그림 3. 요청에 처음 일치한 SecurityFilterChain 선택. 이 선택 구조를 설정하려면 요청 매처의 역할을 구분해야 합니다. 먼저 securityMatcher와 requestMatchers가 어떤 단계에서 사용되는지 알아보겠습니다. 두 설정 모두 요청을 비교하지만, 체인을 선택하는 단계와 선택된 체인 안에서 접근 권한을 확인하는 단계로 역할이 나뉩니다. 로그인 필터에도 별도의 요청 매처가 있으며, 각 역할은 다음과 같습니다. 예를 들어 /api/** 전용 체인이 먼저 선택되면 뒤쪽 웹 체인의 formLogin 설정은 해당 요청에 적용되지 않습니다. 같은 이유로 /secured/**만 담당하는 체인에 기본 /login 처리를 설정하면 로그인 요청이 그 체인에 도달하지 못합니다. 또한 어느 체인에도 일치하지 않는 요청은 Spring Security의 보호를 받지 않습니다. 마지막에 전체 요청을 담당하는 체인을 두는 이유도 이러한 선택 구조 때문입니다. 1.3. 인증 관리자, 인증 제공자, 토큰의 역할 요청에 적용할 체인이 정해지면 그 안에서 실제 인증이 진행됩니다. 이때 흔히 “authenticator”라고 부르는 역할은 하나의 클래스에 모여 있지 않습니다. 인증 필터가 입력을 해석하고, AuthenticationManager가 인증 요청을 받으며, AuthenticationProvider가 해당 인증 방식을 검증합니다. AuthenticationManager의 대표 구현인 ProviderManager는 등록된 인증 제공자에게 검증을 위임합니다. 각 제공자는 supports를 통해 자신이 처리할 수 있는 Authentication 타입을 알립니다. 이는 해당 토큰의 처리 가능 여부를 나타내며, 로그인 성공을 의미하지는 않습니다. 이처럼 역할을 분리하면 요청에서 자격 증명을 추출하는 방식과 사용자 검증 정책을 각각 확장할 수 있습니다. 필터와 인증 제공자 사이에서 전달되는 입력과 결과는 Authentication 객체로 표현합니다. ID/PW 인증에서는 UsernamePasswordAuthenticationToken을 사용할 수 있습니다. 검증 전에는 ID와 비밀번호를 담고, 검증 후에는 사용자 객체와 부여된 권한을 가진 인증 결과를 받습니다. 여기서 Token은 Spring Security 내부의 인증 객체를 뜻하며, 이 객체를 사용한다고 해서 JWT가 발급되거나 브라우저에 문자열 토큰이 반환되는 것은 아닙니다. 이제 인증 요청과 결과가 코드에서 어떻게 연결되는지 살펴보겠습니다. 공식 인증 아키텍처 예제에서 인증 요청과 결과를 받는 부분만 축약했으며, HTTP 응답과 세션 저장 처리는 생략했습니다. 위 코드의 request는 아직 검증되지 않은 인증 요청입니다. authenticate 호출을 통해 제공자의 검증을 마친 뒤에야 인증에 성공한 결과를 사용할 수 있습니다. 저장된 사용자 정보로 인증하는 기본 구현인 DaoAuthenticationProvider도 이 흐름을 따릅니다. UserDetailsService로 사용자를 조회하고, PasswordEncoder로 입력한 비밀번호와 저장된 값을 검증합니다. 사용자 조회와 비밀번호 검증은 별개의 책임이므로, 사용자 정보를 조회했다는 사실만으로 로그인이 성공하지는 않습니다. 검증을 마치면 사용자와 권한이 담긴 Authentication을 반환합니다. 비밀번호 보관·검증에 사용하는 구체적인 알고리즘은 이 글의 범위에서 생략합니다. 1.4. 표준 ID/PW 로그인 한 번 따라가기 이제 앞에서 살펴본 구성 요소들이 표준 로그인에서 어떻게 연결되는지 예제를 통해 알아보겠습니다. 공식 Form Login 예제의 핵심 설정은 formLogin입니다. 아래 코드는 해당 예제에 접근 규칙과 반환문을 추가한 학습용 예시이며, 사용자 저장소와 인증 제공자는 별도로 준비되어 있다고 가정합니다. 이 설정의 기본 폼은 POST /login으로 username, password 파라미터를 보냅니다. CSRF 보호가 켜져 있다면 폼에 CSRF 토큰도 포함해야 합니다. JSON 요청 본문으로 로그인 정보를 전달하는 API는 기본 폼 파라미터 처리와 별도로 입력 처리를 구성해야 합니다. 로그인 요청이 들어온 이후에는 앞서 설명한 인증 구성 요소들이 다음 순서로 동작합니다. 1. UsernamePasswordAuthenticationFilter가 로그인 요청에서 ID/PW를 추출합니다. 2. 미인증 UsernamePasswordAuthenticationToken을 만들어 AuthenticationManager에 전달합니다. 3. 인증 제공자가 사용자와 자격 증명을 검증하고 인증 결과를 반환합니다. 4. 인증에 성공하면 세션 인증 전략을 실행하고 인증 결과를 SecurityContext에 설정합니다. 5. 성공 핸들러가 후속 응답을 생성합니다. 인증에 실패하면 실패 핸들러가 응답합니다. 이처럼 표준 로그인에서는 자격 증명 검증을 마친 뒤에도 세션 처리와 응답 생성이 이어집니다. 프로젝트의 커스텀 로그인 역시 이 후속 흐름까지 연결해야 합니다. 특히 로그인 상태를 다음 요청에서도 사용하려면 인증 결과를 어디에 저장하는지 확인해야 합니다. 1.5. 이번 요청의 인증을 다음 요청까지 유지하기 현재 사용자 정보는 SecurityContextHolder가 보관하는 SecurityContext의 Authentication으로 표현됩니다. 기본적인 Servlet 환경에서는 이 Holder가 요청 처리 스레드와 연결됩니다. 따라서 현재 요청에서 사용할 인증 정보를 설정하는 작업과, 다음 요청에서 다시 읽을 수 있도록 저장하는 작업은 구분해야 합니다. 요청 간 인증 상태를 연결하는 역할은 SecurityContextRepository가 담당합니다. 세션 기반 로그인에서는 브라우저가 다음 요청에 세션 쿠키를 보내면 서버가 저장된 컨텍스트를 복원합니다. Spring Security 6의 기본 SecurityContextHolderFilter는 컨텍스트를 읽는 역할을 담당하므로, 커스텀 인증 코드에서는 저장소에 명시적으로 저장해야 합니다. 기본 폼 로그인 필터가 처리하던 인증 흐름을 직접 구현한다면 이 저장 과정도 함께 연결해야 합니다. 인증 정보를 저장하는 것과 별개로, 새 인증에 어떤 세션 정책을 적용할지도 결정해야 합니다. 이를 담당하는 구성 요소가 SessionAuthenticationStrategy입니다. 동시 로그인 제한을 예로 들어 세션 정책을 어떻게 설정하는지 살펴보겠습니다. 아래는 공식 예제의 핵심 부분이며, 프로젝트가 같은 정책을 적용했다는 의미는 아닙니다. 이 설정은 기존 로그인을 유지하면서 두 번째 로그인을 거절합니다. maxSessionsPreventsLogin(true)를 생략하면 기본적으로 기존 세션을 만료시키는 방향으로 동작합니다. 세션 종료 이벤트를 추적하려면 HttpSessionEventPublisher도 연결해야 합니다. 또한 직접 만든 인증 필터에는 동시 세션 제어를 명시적으로 구성해야 합니다. 여기까지의 흐름은 요청에 맞는 체인 선택, 인증 객체 생성, 제공자의 검증, 인증 결과 저장으로 정리할 수 있습니다. 이 순서에 맞춰 코드를 읽으면 여러 인증 방식이 공통으로 사용하는 부분과 서로 다르게 구현한 부분을 구분하기 쉽습니다. 다음 장에서는 이 구조를 ID/PW, API Key, 개발용 ID 인증에 각각 적용한 방식을 살펴보겠습니다. 2. 세 가지 인증 요구사항을 Spring Security 확장 지점에 연결하기 프로젝트에는 인증이 필요한 세 가지 접점이 있습니다. 사용자는 화면에서 ID와 비밀번호로 로그인하고, 외부 연동 프로그램은 API Key로 API를 호출합니다. 개발자는 화면을 구현하면서 사용자 ID만으로 특정 사용자의 권한을 확인해야 합니다. 이러한 요구사항을 처리하기 위해 요청을 선택하는 SecurityFilterChain, 자격 증명을 해석하는 필터, 실제 검증을 수행하는 AuthenticationProvider의 역할을 나누었습니다. 여기서 ID는 DB의 숫자형 사용자 식별자가 아니라 사용자가 입력하는 loginName을 뜻합니다. ID/PW 인증과 개발용 인증에는 UsernamePasswordAuthenticationToken을 사용하고, API Key 인증에는 별도의 ZngApiKeyAuthenticationToken을 사용합니다. 인증 요청에 사용하는 Token은 다르지만, 인증 결과의 principal은 MyUserDetails로 통일했습니다. 따라서 이후 서비스는 동일한 사용자 모델과 메서드 권한 검사 체계를 사용할 수 있습니다. MyUserDetails는 Spring Security의 UserDetails 인터페이스를 구현한 프로젝트의 사용자 클래스입니다. Kotlin에서는 클래스 선언에 : UserDetails를 붙여 구현합니다. UserDetails는 사용자명, 권한 목록, 계정 잠금·활성 상태 등 인증에 필요한 정보를 제공하는 인터페이스입니다. 프로젝트에서는 여기에 사용자 ID와 계층, 소속 관리자, 접근 가능한 대상 그룹, 원래 로그인한 사용자 정보 등의 업무 속성을 추가했습니다. 인증 성공 후 MyUserDetails를 Authentication.principal에 넣으면 Spring Security의 권한 검사와 업무 코드의 현재 사용자 조회가 같은 객체를 기준으로 동작합니다. 다만 사용자 객체를 제공하는 것과 자격 증명을 검증하는 것은 별개의 역할입니다. UserDetails 구현만으로 비밀번호나 계정 상태가 자동 검증되지는 않으며, 이 프로젝트에서는 커스텀 Provider가 필요한 검사를 직접 호출합니다. getPassword()도 일반적인 저장 비밀번호 제공 경로로 사용하지 않습니다. 뒤에서 살펴볼 ID/PW Provider와 개발용 Provider 역시 목적에 따라 검증 범위가 다릅니다. 이러한 공통 사용자 모델을 바탕으로 각 인증 방식의 요청 구분과 결과 저장 범위를 다음과 같이 구성했습니다. 이제 요청 분류부터 시작해 각 인증 방식의 구현을 차례로 살펴보겠습니다. 이 장의 코드는 현재 저장소 구현에서 발췌했으며, 패키지와 import는 생략했습니다. 자격 증명 처리 부분은 주석으로 대체했고, 암호화·복호화·해시 알고리즘과 파라미터, 실제 인증값은 포함하지 않았습니다. 2.1 먼저 인증 방식이 충돌하지 않도록 요청을 분류합니다 세 가지 인증 방식을 함께 사용하려면 먼저 각 요청이 어느 체인으로 들어갈지 정해야 합니다. 앞 장에서 설명한 것처럼 여러 체인이 선언되어 있어도 처음 일치한 체인 하나만 요청을 처리합니다. 따라서 @Order와 securityMatcher의 조합은 단순한 실행 순서 설정을 넘어 인증 정책의 일부가 됩니다. 프로젝트에서는 세 가지 인증 진입점보다 앞에 인증 방식의 개수를 검사하는 체인을 두었습니다. 여기서 확인하는 항목은 로그인 요청 여부, 세션에 저장된 SecurityContext, Authorization 헤더입니다. 세션 쿠키의 존재만으로 판단하지 않고, HttpSessionSecurityContextRepository.containsContext(request)로 실제 저장된 컨텍스트를 확인합니다. 위 함수의 결과는 Order 40 체인의 matcher에서 사용합니다. 값이 1이 아닌 요청은 이 체인에서 선택해 401로 응답합니다. 예를 들어 세션으로 로그인한 요청에 API Key 헤더를 추가하거나, POST /api/login에 Authorization 헤더를 함께 보내면 두 방식이 겹칩니다. 이 경우 뒤쪽의 API Key 체인이나 세션 인증 체인으로 넘어가지 않습니다. 다만 이 단계에서는 Authorization 헤더의 형식이 아니라 존재 여부만 확인합니다. 이 검사의 적용 범위는 앞서 선택되는 체인까지 함께 보아야 이해할 수 있습니다. OPTIONS(Order 10), 정적 리소스(Order 20), 공개키 조회 GET /api/user/pk(Order 30), 비정상 로그아웃 처리(Order 35), 특정 보고서 생성 요청(Order 37)은 Order 40보다 먼저 선택될 수 있습니다. 이후에는 별도의 보고서용 인증(Order 50), 개발용 Basic(Order 60), API Key(Order 70), 세션 인증(Order 80)이 이어집니다. 따라서 모든 URL이 동일한 검사를 거치는 것은 아니며, @Order와 matcher를 기준으로 흐름을 확인해야 합니다. 각 인증 체인으로 분기할 때 사용하는 헤더 matcher는 대소문자를 무시하는 startsWith(authScheme, true)로 구현되어 있습니다. 이는 scheme에 따라 요청을 구분하는 역할이며, 엄격한 헤더 문법 검증까지 수행하는 구현은 아닙니다. 이렇게 요청 경로를 나눈 뒤, 각 필터와 Provider에서 인증 방식에 맞는 처리를 진행합니다. 2.2 ID/PW 로그인: 입력 형식과 사용자 정책을 분리합니다 먼저 일반 로그인은 POST /api/login 요청의 JSON body를 읽습니다. 이를 담당하는 ZngUsernamePasswordAuthenticationFilter는 AbstractAuthenticationProcessingFilter를 상속하며, 생성자에서 ProviderManager(ZngUsernamePasswordAuthenticationProvider(userService))를 전달합니다. 필터는 입력과 HTTP 요청을 처리하고 Provider는 사용자를 검증하므로, 역할 분담은 표준 로그인과 같습니다. 표준 폼 로그인과 달라지는 부분은 입력 형식입니다. 폼 파라미터 대신 JSON 본문을 읽어야 하므로 커스텀 필터를 사용했습니다. 필터는 입력을 준비한 뒤 다음과 같이 미인증 Token을 생성합니다. 이 Token은 검증할 요청을 전달하는 객체이며, 아직 인증 성공 상태는 아닙니다. 이때 details에 추가한 HTTP 요청 정보는 이후 IP 정책 검사에 사용합니다. Token을 전달받은 Provider는 서비스에 ID/PW 검증을 위임하고, 필요한 비밀번호 저장 형식 이전 처리를 수행한 뒤 사용자 상태를 추가로 확인합니다. 서비스에서는 ID 오류와 비밀번호 오류를 구별해 알리지 않고 동일한 인증 예외로 처리합니다. 자격 증명 검증 이후에는 어떤 항목을 추가로 확인하는지 코드로 살펴보겠습니다. 검증에 성공하면 principal은 문자열 ID에서 MyUserDetails로 바뀌고, authorities에는 사용자 권한이 포함되며, credentials는 null로 반환됩니다. 이 Provider의 supports는 전달된 Token이 UsernamePasswordAuthenticationToken 계열인지 확인합니다. ProviderManager는 이 지원 여부를 기준으로 검증을 담당할 Provider를 선택합니다. 인증 결과를 받은 다음에는 세션 정책을 적용하고 로그인 상태를 저장해야 합니다. 이를 위해 로그인 필터에 다음과 같은 세션 전략과 성공 핸들러를 지정했습니다. 먼저 세션 전략의 첫 번째 구성 요소는 설정값으로 최대 동시 세션 수를 확인합니다. 한도를 넘으면 신규 로그인을 예외로 거절하거나 기존 세션을 expireNow()로 만료 표시합니다. 두 번째 전략은 새 세션을 SessionRegistry에 등록합니다. 만료 표시된 세션으로 후속 요청이 들어오면 세션 체인에 배치한 ConcurrentSessionFilter가 처리합니다. 즉, 세션에 만료 여부를 기록하는 시점과 해당 세션의 다음 요청을 차단하는 시점은 구분됩니다. 다만 현재 동시 세션 전략에는 sortedBy의 반환값을 사용하지 않는 코드가 있어, 주석의 의도만으로 가장 오래 사용하지 않은 세션이 반드시 선택된다고 설명하기는 어렵습니다. 또한 위 Composite에 직접 포함된 전략은 동시 세션 검사와 등록 두 가지이므로, 이 설정만으로 세션 ID 변경 전략까지 적용된다고 단정할 수는 없습니다. 세션 전략을 통과하면 성공 핸들러가 SecurityContext를 생성하고 HttpSessionSecurityContextRepository.saveContext로 명시적으로 저장합니다. 다음 요청에서 세션에 저장된 인증 정보를 사용할 수 있는 이유입니다. 성공 응답은 리다이렉트 대신 MyResult JSON으로 반환하며, 로그인 실패 핸들러는 401을 반환합니다. 로그아웃 요청인 POST /api/logout에는 Spring Security의 로그아웃 처리와 감사 기록을 연결하고, 성공 JSON을 응답하도록 구성했습니다. 인증 결과를 통계와 감사 처리에 반영하는 흐름은 이벤트로 연결했습니다. ProviderManager에 AuthenticationEventPublisher를 설정하고, SecurityConfig의 이벤트 리스너에서 인증 성공 시 사용자 로그인 시도 횟수와 마지막 로그인 시각을 갱신합니다. 사용자 관련 실패와 API Key 자체의 실패는 서로 다른 이벤트로 매핑해 실패 횟수를 갱신할 대상도 구분합니다. 다만 이 이벤트는 Provider의 인증 결과를 알리는 것이므로, 이후 동시 세션 전략까지 성공했다는 의미는 아닙니다. 2.3 API key 인증: 사용자 인증에 호출 범위를 더합니다 ID/PW 로그인이 브라우저의 세션을 유지하기 위한 흐름이라면, 외부 연동에서는 Authorization: ZNG <API_KEY> 형식의 헤더로 인증 정보를 전달합니다. 실제 키는 예시에 포함하지 않았습니다. Order 70 체인이 이 헤더를 가진 요청을 선택하면, BasicAuthenticationFilter 위치에 배치한 ZngApiKeyAuthenticationFilter가 처리를 담당합니다. 이 필터는 OncePerRequestFilter를 상속합니다. 헤더에서 키를 읽고 형식을 확인한 뒤, 식별자와 검증용 비밀값을 미인증 ZngApiKeyAuthenticationToken에 담습니다. 여기에 요청 details를 추가해 API Key 전용 ProviderManager로 전달합니다. 인증 결과를 받은 뒤에는 컨텍스트를 설정하고 저장합니다. 이 과정이 ID/PW 로그인과 어떻게 다른지 다음 코드와 함께 살펴보겠습니다. 앞서 본 로그인 처리와 코드 형태는 비슷하지만, 여기서 사용하는 저장소는 RequestAttributeSecurityContextRepository입니다. API Key 필터는 HttpSession에 인증 결과를 직접 저장하지 않고 요청 속성 저장소를 사용하므로, 의도한 인증 유지 범위는 현재 요청입니다. 다만 설정에 SessionCreationPolicy.STATELESS가 명시된 것은 아니어서, 이 코드만으로 애플리케이션 전체에서 어떤 이유로도 세션이 생성되지 않는다고 볼 수는 없습니다. 이 인증 결과를 만드는 ZngApiKeyAuthenticationProvider는 먼저 식별자로 키를 조회하고, 비밀값 일치 여부와 활성·만료·잠금 상태를 확인합니다. 이어서 키에 연결된 사용자도 조회합니다. 키가 유효하더라도 사용자가 휴면·잠금·자격 증명 만료·비활성 상태이거나 IP 조건을 만족하지 못하면 인증하지 않습니다. 검증에 성공하면 ZngApiKeyAuthenticationToken.authenticated(user, null, user.authorities, apiKey)를 반환합니다. 전용 Token에 apiKey를 남기는 이유는 인증 이후에도 키별 호출 범위를 확인해야 하기 때문입니다. 다음 인가 코드에서 이 정보를 사용합니다. 위 코드는 인증과 인가의 역할을 구분해 보여줍니다. Provider가 “어떤 사용자와 키인가”를 검증했다면, ZngApiKeyAuthorizationManager는 “이 키로 이 요청을 호출해도 되는가”를 판단합니다. 요청은 키에 허용된 API 경로와 HTTP 메서드 matcher에 일치해야 하며, 사용자 전환·사용자 관리·API Key 관리 등 별도로 선언한 금지 matcher에도 해당하지 않아야 합니다. 이 제한은 선언된 matcher 범위에 적용되며, 모든 관리 기능을 자동으로 차단한다는 의미는 아닙니다. 컨트롤러와 서비스의 메서드 권한 검사도 별도로 작동합니다. 실패 처리도 API Key 인증 흐름에 맞추어 구성했습니다. 필터에서 AuthenticationException이 발생하면 감사 기록을 남기고 컨텍스트를 비운 뒤 403 진입점으로 응답합니다. 키 형식이 맞지 않으면 필터 자체에서 예외를 던지는 대신 다음 필터로 넘기지만, 인증된 API Key Token이 없으면 이후 인가를 통과할 수 없습니다. API Key로 들어오는 로그아웃 요청은 세션 로그아웃과 구분되므로, 앞쪽의 비정상 로그아웃 처리 체인에서 별도로 처리합니다. 2.4 개발용 ID 인증: 기본 HTTP Basic 필터에 Provider만 교체합니다 마지막으로 개발 중 사용자별 권한과 화면 동작을 확인하기 위한 인증 방식을 살펴보겠습니다. 이때는 지정한 사용자의 컨텍스트로 API를 호출할 수 있도록, 기본 HTTP Basic 처리에 ID만 조회하는 Provider를 연결했습니다. 요청을 해석하는 기본 필터는 사용하면서 사용자 검증 부분을 별도로 구성한 것입니다. 여기서 “ID만으로 로그인”한다는 것은 HTTP body에 숫자 ID 하나를 보내는 별도 로그인 API를 뜻하지 않습니다. 요청은 Basic 헤더 형식을 따르며, 기본 필터가 이를 UsernamePasswordAuthenticationToken으로 변환합니다. 이후 커스텀 Provider가 authentication.name, 즉 loginName으로 사용자를 조회합니다. 프런트엔드의 개발 로그인 설정도 입력받은 사용자명을 Basic 형식으로 준비해 후속 요청의 Authorization 헤더에 넣습니다. 실제 인증값을 만드는 표현식은 본문에서 생략했습니다. 위 Provider는 비밀번호를 비교하지 않으며, 일반 로그인 Provider에서 수행하는 사용자 상태·IP 검사도 호출하지 않습니다. 검증을 단순화한 개발 편의 기능이므로 활성화 범위를 함께 확인해야 합니다. 현재 조건은 **`prod` 프로필이 활성화되지 않은 환경(@Profile("!prod"))**입니다. 반면 API Key 체인에는 이 프로필 조건이 없습니다. 개발용 Basic 체인이 활성화된 환경에서도 ZNG 헤더 요청은 API Key 체인으로 들어가며, 두 인증 방식은 헤더에 따라 구분됩니다. 앞서 설명한 Order 40의 인증 방식 개수 검사도 개발용 요청에 적용됩니다. 따라서 세션 컨텍스트를 유지한 채 Basic 헤더를 추가하면 인증 방식이 충돌한 요청으로 처리됩니다. 지금까지 세 가지 인증 요구사항을 필터·Provider·Token·Context·세션 전략의 역할에 맞추어 연결한 방식을 살펴봤습니다. 다음 장의 사용자 전환은 이렇게 인증된 사용자가 다른 사용자의 컨텍스트로 이동하는 기능입니다. 다음 장에서는 새 자격 증명을 검증하는 로그인과 비교하면서, 전환 권한을 판단하고 세션 상태를 유지하는 방법을 알아보겠습니다. 3. 상위 사용자에서 하위 사용자로: 로그인 상태를 유지하며 실행 주체 바꾸기 운영 지원 과정에서는 관리자가 하위 사용자의 화면과 데이터 접근 범위를 확인해야 할 때가 있습니다. 매번 상대 사용자의 비밀번호를 받아 다시 로그인하는 방식으로는 이러한 요구를 처리하기 어렵습니다. 프로젝트에서는 이미 인증된 상위 사용자의 권한으로 전환 가능 여부를 판단하고, 이후 요청에서 사용할 Authentication을 대상 사용자의 것으로 교체했습니다. 이때는 두 사용자를 구분해야 합니다. 하나는 현재 기능을 실행하는 사용자이고, 다른 하나는 처음 로그인하여 전환 권한의 근거가 된 사용자입니다. 현재 사용자는 화면과 업무 권한의 기준이며, 최초 사용자는 다른 사용자로 이동하거나 원래 계정으로 돌아갈 수 있는 권한의 근거입니다. 이 구분을 유지하기 위해 먼저 원래 로그인한 사용자 정보를 보관합니다. 3.1. 원래 로그인한 사용자는 한 번만 보관합니다 MyUserDetails에는 원래 로그인한 사용자를 보관하는 orgLoginUser 필드가 있습니다. 값이 null이면 현재 객체가 원래 로그인한 사용자를 나타냅니다. 필드 주석은 ID/PW 로그인을 기준으로 작성되어 있습니다. API Key 인증으로 들어오는 전환 요청은 별도의 인가 매니저에서 차단하므로, 여기서는 일반적인 세션 로그인 이후의 전환 흐름을 중심으로 설명하겠습니다. 예를 들어 관리자 A가 사용자 B로 전환하면 B의 orgLoginUser가 A를 가리킵니다. B 상태에서 사용자 C로 다시 이동하더라도 최초 사용자는 A로 유지됩니다. 전환할 때마다 이전 사용자를 연결하는 이력 구조를 만드는 대신, 언제나 하나의 원본 계정을 기준으로 전환 권한을 판단합니다. Spring Security에도 사용자 전환용 SwitchUserFilter가 있으며, 공식 구현은 SwitchUserGrantedAuthority에 원래 Authentication을 보관해 복귀에 활용합니다. 프로젝트에서는 이 필터를 등록하지 않고, 컨트롤러에서 업무 계층을 검증한 뒤 MyUserDetails.orgLoginUser로 원본 사용자를 보관했습니다. 따라서 다음 코드는 SwitchUserFilter를 확장한 구현이 아니라, 프로젝트의 사용자 모델을 이용한 별도 구현입니다. 3.2. 전환 권한은 현재 사용자보다 최초 사용자를 기준으로 판단합니다 원본 사용자를 보관했다면 전환 요청에서 이 정보를 권한 판단에 사용합니다. 실제 진입점은 POST /api/user/change/{userId}입니다. 컨트롤러는 현재 사용자와 원본 사용자를 구분한 뒤, 원본 사용자의 계층을 기준으로 대상에게 전환할 수 있는지 확인합니다. 코드의 계층 검사는 MyUserDetails의 level을 기준으로 합니다. 1은 SUPER, 2는 ADMIN, 3은 일반 사용자이며, isAdmin()과 isSuper()도 이 값을 검사합니다. ADMIN은 대상의 adminId가 자신의 ID와 같은 경우에만 전환할 수 있습니다. adminId는 일반 사용자에게는 소속 관리자의 ID이고, ADMIN과 SUPER에게는 본인의 ID입니다. 따라서 관리자 A는 자신의 하위 사용자로 이동할 수 있으며, 본인으로 복귀할 때도 같은 조건을 통과합니다. 이 값은 조회 SQL의 case when u.z_mylevel = 3 then u.z_parid else u.z_myid end에서 생성됩니다. SUPER의 전환 요청에는 ADMIN과 같은 대상별 소속 검사 분기가 없습니다. 사용자 계층에 맞는 선택 목록은 제공하지만, 실제 전환 API가 SUPER의 대상을 반드시 그 목록 안으로 제한한다고 설명할 수는 없습니다. 이후에 살펴볼 화면의 전환 후보 목록과 서버의 전환 허용 조건은 구분해야 합니다. 최초 사용자를 기준으로 검사하기 때문에, 이미 일반 사용자로 전환한 상태에서도 org가 관리자를 가리키면 다른 하위 사용자로 이동하거나 관리자 본인으로 돌아갈 수 있습니다. 반면 처음부터 일반 사용자로 로그인했다면 org도 일반 사용자이므로 요청이 거절됩니다. 이 권한 검사는 메서드의 @PreAuthorize가 아니라 메서드 본문에서 수행합니다. 3.3. 인증된 토큰을 새로 만들고 세션에 저장합니다 전환 권한 검사를 통과하고 대상이 확정되면, 해당 사용자를 principal로 가진 새 토큰을 만듭니다. 비밀번호를 다시 검증하는 요청이 아니므로 AuthenticationManager나 ID/PW용 AuthenticationProvider는 호출하지 않습니다. 앞에서 수행한 전환 권한 검사가 이 토큰 생성의 근거가 됩니다. 이 코드에서 권한 적용의 기준은 toBe.authorities입니다. 원래 관리자의 권한을 합치지 않고 대상 사용자의 권한으로 새 Authentication을 구성하므로, 일반 업무 API는 전환된 사용자의 권한을 기준으로 동작합니다. 원래 관리자 정보는 전환과 추적을 위한 참조로 보관되며, 새 토큰에 관리자 권한이 자동으로 승계되는 것은 아닙니다. 권한을 구성한 다음에는 바뀐 인증 상태를 현재 요청과 후속 요청에 반영합니다. 새 SecurityContext를 만들어 SecurityContextHolder에 설정하는 작업은 현재 요청의 실행 주체를 바꿉니다. 이어지는 saveContext는 다음 요청에서도 전환 결과를 사용할 수 있도록 HTTP 세션에 저장합니다. 1장에서 설명한 컨텍스트 설정과 저장의 구분이 여기에도 적용됩니다. 공식 문서에서도 SecurityContextRepository를 후속 요청까지 인증 상태를 연결하는 지점으로 설명하며, HttpSessionSecurityContextRepository는 이를 HTTP 세션에 연결합니다. 이렇게 인증 객체를 교체하면 업무 코드에서도 같은 사용자 조회 방식을 유지할 수 있습니다. 현재 사용자를 가져오는 공통 함수를 통해 이 동작을 확인해 보겠습니다. 전환 후 getCurrent()는 새 principal을 반환합니다. 따라서 이 공통 조회 방식을 사용하는 개별 업무 서비스에 전환 대상의 ID를 별도로 전달할 필요가 없습니다. 현재 사용자로부터 그룹이나 권한을 조회하는 기존 코드도 전환된 사용자 정보를 기준으로 동작합니다. 3.4. 후보 목록과 복귀도 같은 원본 계정을 활용합니다 사용자 전환을 화면에서 제공하려면 이동할 수 있는 후보를 조회하고 원래 계정으로 돌아가는 흐름도 필요합니다. 후보 목록은 GET /api/user/change-list로 조회합니다. 여기에서도 원본 계정을 먼저 구한 다음, 현재 실행 중인 사용자만 목록에서 제외합니다. ADMIN에게는 본인과 직접 하위 사용자를, SUPER에게는 본인과 하위 관리자 및 그 사용자들을 제공합니다. getChildren의 SQL은 z_parid로 직접 하위를 조회하므로, SUPER의 목록은 하위 관리자와 그 사용자에 대한 두 번의 계층 조회로 구성됩니다. 임의 깊이의 트리를 재귀적으로 조회하는 구현은 아닙니다. 목록에 포함된 원본 계정으로 복귀할 때도 같은 전환 API를 사용하며, 별도의 복귀 전용 API는 두지 않았습니다. 원본 사용자 ID를 전달하면 getUserById가 DB에서 새 MyUserDetails를 조회합니다. 대상이 원본과 같을 때는 orgLoginUser를 별도로 설정하지 않으며, 이때 equals는 사용자 ID를 기준으로 비교합니다. 따라서 원본 계정으로 돌아오면 다시 orgLoginUser == null인 상태가 됩니다. 3.5. 실행 사용자와 실제 행위자를 함께 남길 수 있게 합니다 전환과 복귀가 가능해지면, 이후 작업을 누가 수행했는지도 구분할 필요가 있습니다. 사용자 B의 권한으로 실행했다는 사실만 남기면 B가 직접 수행했는지, 관리자 A가 B로 전환해 수행했는지 파악하기 어렵습니다. 이를 위해 감사 로그 모델은 현재 사용자와 원본 사용자를 함께 저장할 수 있도록 구성했습니다. 다음은 감사 로그 생성자 인자 중 관련 부분을 발췌한 코드입니다. 감사 로그 대상 메서드의 AOP는 UserService.getCurrent()로 기록할 사용자를 설정합니다. 따라서 전환 상태에서 기록되는 작업에는 현재 사용자와 원본 사용자를 구분해 담을 수 있습니다. 감사 기록과 함께 확인할 부분은 전환 시 다시 수행하는 인증·세션 처리의 범위입니다. 현재 구현은 컨트롤러에서 인증 객체를 직접 만들기 때문에, 최초 로그인에서 수행하는 계정 상태·허용 IP 검증이나 세션 인증 전략을 다시 호출하지 않습니다. SessionRegistry 재등록, 세션 ID 변경, 로그인 성공 이벤트 발행도 전환 메서드에는 없습니다. 따라서 기존 세션의 인증 주체를 교체하는 처리와 새로운 로그인에 적용하는 동시 세션 관리 정책을 같은 동작으로 보아서는 안 됩니다. 마지막으로 어떤 인증 경로에서 사용자 전환 기능에 접근할 수 있는지 확인해 보겠습니다. ZngApiKeyAuthorizationManager는 "/api/user/change/*" to POST를 금지 목록에 넣고, API Key 자체의 접근 범위와 이 목록을 함께 검사합니다. 따라서 API Key로는 사용자 전환을 할 수 없으며, 컨트롤러뿐 아니라 앞단의 필터 체인에서 수행하는 인가까지 확인해야 전체 제한을 파악할 수 있습니다. 이 글의 사례는 기존 세션 로그인 상태에서 제공하는 사용자 전환 흐름을 중심으로 설명했습니다. 이 구현을 통해서는 원본 계정으로 전환 권한을 판단하고, 대상 계정으로 업무 권한을 구성한 뒤, 바뀐 컨텍스트를 세션에 저장합니다. 이 세 역할을 구분함으로써 일반 업무 API의 현재 사용자 조회 방식을 유지하면서 사용자 전환과 원본 계정 복귀를 구현할 수 있었습니다.
2026.11.04
다음 슬라이드 보기