Spring Security: SecurityContextHolder 의 Thread 공유 전략

다수 정보를 리스트로 조회하는 페이지에서 현재 로그인한 유저가 가진 권한에 따라 일부 정보를 보여주지 않도록하는 처리가 필요했습니다. 그래서 먼저 리스트를 API 로부터 가져온 뒤, 현재 Spring Security 로그인 세션에 저장되어있는 권한을 통해 일부 정보를 필터링하여 최종적으로 조회 페이지에 렌더링하도록 작업하였었습니다.

하지만 이상하게 리스트에 노출되는 Row 가 총 10개라면 2 ~ 3개 약 1/4 에 해당하는 Row 만 해당 ‘세션 권한 필터링’ 로직이 적용되었고 나머지 3/4 에 대해서는 적용되지 않는 버그를 발견하였습니다. 심지어 1/4 에 해당하는 2 ~ 3개는 변칙적으로 계속 변경되는것이었습니다. 예를 들면 새로고침 한번에 2번째 3번째 Row 에만 ‘세션 권한 필터링’ 이 적용되었다가, 새로고침을 한번 더 하면 5번째 6번째 Row 에 ‘세션 권한 필터링’이 적용되는것입니다. 마치 슈뢰딩거의 고양이처럼요…

구현은 다음과 같았습니다.

1
2
3
4
5
6
7
8
List<SomeInformation> list = someApi.retreive(condition);
list.parallelStream()
.forEach(each -> {
if (!SecurityHelper.hasRole("ROLE_CAN_SEE_SENSITIVE_NUMBERS")) {
each.setSensitiveNumber1(null);
each.setSensitiveNumber2(null);
}
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class SecurityHelper {

public static boolean hasRole(String role) {
SecurityContext context = SecurityContextHolder.getContext();
if (Objects.isNull(context))) {
return false;
}
Authentication authentication = context.getAuthentication();
if (Objects.isNull(auth))) {
return false;
}
for (GrantAuthority eachAuthority : authentication.getAuthorities()) {
if (role.equals(eachAuthority.getAuthority())) {
return true;
}
}
return false;
}
}

실제로는 테스트했던 로그인 계정에 ROLE_CAN_SEE_SENSITIVE_NUMBERS 권한이 있었기 때문에, 리스트의 모든 Row 들에 sensitiveNumber1, 2 모두 정상 노출되는것이 맞습니다. 하지만 1/4만 노출되는건 아무리 생각해도 이상하여 parallelStream.forEach 내부에 로그를 추가하였더니 아래와 같은 결과가 나왔습니다.

1
2
3
4
5
6
7
8
9
10
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-3] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-2] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-7] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-1] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [http-nio-80-exec-3] [TEST] hasRole: true
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-4] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-5] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-6] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-2] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [http-nio-80-exec-3] [TEST] hasRole: true

보아하니 ForkJoinPool 즉, ParallelStream 실행을 위해 할당된 하위 Thread(ForkJoinPool.commonPool-worker-1~7) 에서는 hasRole이 비정상적으로 false값을 반환하고, 메인 Thread(http-nio-80-exec-3) 에서는 hasRole이 정상적으로 true값을 반환하는걸 알 수 있습니다.

무언가 ParallelStream 과 SecurityContextHolder 혼용이 문제인것으로 보입니다.


SecurityContextHolder 의 Thread 간 공유 모드

ParallelStream 의 Thread 에서 hasRole = false 가 반환됐던 1차 원인은 SecurityContext context = SecurityContextHolder.getContext(); 호출시 null이 반환되고 있었습니다. 반면 메인 Thread 에서 SecurityContextHolder.getContext() 호출시에는 정상적으로 세션 데이터를 가져올 수 있었고, hasRole 에 알맞은 비교 로직까지 수행할 수 있었습니다. 알아보니 아래와 같은 사실을 발견했습니다.

SecurityContextHolderSecurityContext 로그인 세션 정보를 어떤 레벨의 Thread 까지 공유할지 모드를 지정하도록 되어있습니다. 기본값으로는 MODE_THREADLOCAL로써 SecurityContext 정보는 “메인 Thread” 에서만 볼 수 있습니다.

총 공유 모드는 3가지로 나뉘어져있습니다.

  1. MODE_THREADLOCAL: (Default) Local Thread 에서만 공유 가능
  2. MODE_INHERITABLETHREADLOCAL: Local Thread 에서 생성한 하위 Thread 에까지 공유 가능
  3. MODE_GLOCAL: 모든 Thread, 어플리케이션 전체에서 공유 가능

기본 모드MODE_THREADLOCAL 였기에, 아무런 설정도 하지 않았던 서버에서는 메인 Thread(http-nio-80-exec-3)에서만 SecurityContext 가 반환되었던고, 나머지 하위 Thread(ForkJoinPool.commonPool-worker-1~7)에서는 null 이 반환되었던것입니다.

Conclusion

SecurityContextHolder 의 기본 설정은 SecurityContext 정보를 Local Thread 만 공유하도록 되어있기 때문에 SecurityContextHolder 를 직접 하위 Thread 안에서 호출하여 사용하는것보다, 메인 Thread 에서 호출하여 해당 값을 하위 Thread 에서 참조하도록 하는것이, 성능적으로나 가시적으로도 더 깔끔한 코드가 될것입니다. ParallelStream 혹은 Async 관련된 기능을 사용 시 하위 Thread 에서 SecurityContextHolder 를 사용해야하는 경우가 있다면 SecurityContextHolder 의 공유 모드를 MODE_INHERITABLETHREADLOCAL 로 낮추는것을 고려해야합니다.


출처:

  1. Spring Security - SecurityContextHolder Strategy:
    http://ncucu.me/116

Wrapper Class Caching: Integer(Wrapper Class) == 사용시 이슈

얼마전부터 서버에서 Integer 객체를 == (항등 연산자)를 사용한 코드때문에 간간히 에러 로그가 남는것을 확인했습니다. 신기한건 해당 API 가 매우 자주사용되는데, 간헐적으로 발생한다는 것이었습니다. 간단하게 설명하면 업데이트하려는 리스트 개수와, 업데이트 이전 리스트 개수가 맞는지 검사하는 Validation 로직이었는데, 에러 로그를 확인해보면 업데이트 전 리스트 개수와 업데이트 후 리스트 개수가 324 != 324 로 다릅니다.라고 찍혀있는 것이었습니다. 단순히 팀원들과 **객체 비교는 == 를 사용하면 Reference 메모리 주소값을 비교하기 때문에 당연히 equals 를 사용해야합니다.**라고 공유했지만, 실제로 해당 로직이 이상해서 값을 하나씩 1씩 증가시키며 대입해본 끈기있는 개발자분에 의해 다음과 같은 사실이 밝혀졌습니다.

Integer 객체 비교는 == 를 사용했을때 127 까지는 ‘true(같음)’을 반환하는데, 그 이상 128 부터는 ‘false(다름)’으로 반환합니다.

본 글은 왜 그런지에 대한 이유에 대한 짧은 글입니다.


Java 뿐만 아니라 Javascript 를 처음배운다면 Class 를 접하실테고, Primitive Type, Reference Type 을 배우실겁니다. 컴퓨터공학/과학과에서 요즘엔 Python 을 배우지않을까 싶은데 C 를 배우게 된다면 변수에 값을 저장하면 메모리에 어떻게 적재되는지 배우게 됩니다. 간단하게 아래와 같이 나뉩니다.

Primitive Type

  • 변수에 값을 할당하면 그 값 그대로 메모리에 저장
  • 값이 그 값 자체로 사용가능한 타입
    • 정수형: byte, short, int, long
    • 실수형: float, double
    • 문자형: char
    • 논리형: boolean

Reference Type

  • 변수에 값을 가진 객체의 주소를 저장하고, 그 값은 주소가 가리키는 객체 공간에 저장되어있습니다.
  • 값(field)과 유용한 함수(method)들을 하나의 객체로 담은 타입
    • Wrapper Class: 그 중 Primitive Type 값과 유용한 함수들을 하나의 객체로 담은 타입
      • 정수형: Byte, Short, Int, Long
      • 실수형: Float, Double
      • 문자형: Character
      • 논리형: Boolean
    • 그 외: Array, Class 등

본 글에서는 Primitive Type그 값들을 감싼 Wrapper Class, 이 둘만을 다룹니다.


Boxing & Unboxing

이 두 타입이 Java 에서 혼용할 수 있기 때문에, Primitive Type 과 Wrapper Class 에 저장된 값을 사용하기 위해서 매번 연산자나 함수에서 사용하는 타입에 맞춰서 변환해줄 순 없습니다. 불필요한 코드의 양이 늘어나기에 이는 Java Compiler 가 바이트코드 생성 시 자동변환을 해주게 됩니다. 어떤 타입에서 어떤 타입으로 변환하는지에 따라 boxing, unboxing 으로 나뉘는데 Class 에서 값을 꺼낸다 = unboxing, Class 에 값을 담는다 = boxing 으로 직관적으로 이해 가능합니다.

Boxing

Primitive Type 값을 Wrapper Class 객체 내부에 감싸(box) 저장하여 Wrapper Class 주소를 반환합니다. Integer a = 10; 이런식으로 선언하면 좌측은 Integer(Wrapper Class) 우측은 10(Primitive Type)이기에 우측의 10 값을 new Integer(10) 의 형태로 객체로 자동으로 감싸 반환하게 됩니다. 이를 Auto-boxing 이라고 부릅니다. 이 덕분에 함수 파라미터가 다음과 같더라도 private void pleaseGiveMeReference(Integer a) 함수 호출시에 pleaseGiveMeReference(10)으로 호출 할 수 있는것입니다.

Unboxing

Primitive Type 값을 가진 Wrapper Class 객체를 int a;, Integer b = new Integer(10) 과 같은곳에 사용하려면 Primitive Type 으로 값을 꺼내어(unbox) int a = b 의 결과는 int a = 10 이 됩니다. 이를 Auto-unboxing 이라고 부릅니다. 이 또한 위에 Boxing 에서 살펴봤듯이, 이 덕분에 함수 파라미터가 다음과 같더라도 private void pleaseGiveMePrimitive(int a) 함수 호출시에 Integer wrapped = 10 객체를 다음 함수에 pleaseGiveMePrimitive(wrapped) 이렇게 호출 할 수 있는것입니다.


글의 맨 처음에 문제가 되었던 == 은 실제 값의 비교이기에 Primitive Type 비교할때만 우리의 직관대로 동작합니다 Wrapper Class 을 비교한다면 Integer a 변수에 저장된 객체에 대한 메모리 주소만을 비교하기에 아무리 같은 값을 갖고있는 두 객체를 비교하더라도 결과값은 ‘false(불일치)’일것입니다. 명심해야할 것은 == 연산자는 “절대로” Auto-boxing, Auto-unboxing 을 지원하지 않습니다. 심지어 Integer 처럼 Auto-boxing, Auto-unboxing 를 지원하더라도 말입니다.

그렇다면 왜 서버에서 Integer == Integer 는 127 까지는 제대로 동작하고 128 부터는 우리가 생각하는대로 동작하지 않는것일까요? == 연산자는 Auto-unboxing 이 안된다면서요. 설마 조건에 따라 되는걸까요?

아닙니다.


Wrapper Class Caching (Java 5+)

Java 5 에서는 메모리 효율을 위해 Wrapper Class Caching 을 도입했습니다. “일부” Wrapper Class(Byte, Short, Integer, Long, Character) 에 대해서 작은 값에 대해서 메모리에 캐싱하여, 작은 값에 대한 객체를 생성하면 캐싱해놓은 Wrapper Class 객체를 반환해주는 것입니다. Integer 의 예로 1, 2, 10 같은 값들은 사용 빈도수가 굉장히 크기때문에 일일히 이에 대한 Wrapper Class 객체를 생성해주면 메모리 입장에서 Integer a = 10;, Integer b = 10;100개를 정의한다면 100개에 대한 메모리를 다 할당해놓아야하는것입니다. 이에 따라 빈도수가 큰 객체는 미리 만들어두고 10 값에 대한 Wrapper Class 객체는 미리 만들어놓은 단 하나의 객체만을 사용하도록 하는것입니다. Integer a = 10;, Integer b = 10; … 모두 캐싱된 new Integer(10) 객체를 사용하기때문에 Integer a, Integer b 모두 같은 객체 주소값을 가지며, 메모리는 단 1개에 대해서만 할당하면 됩니다.

한 객체로 여러 변수들에 사용가능하도록 했기때문에 이를 Immutable Wrapper Object 라고도 부르는듯 합니다. Wrapper Class Caching 이란것이 “일부” Wrapper Class 에만 적용된다고 강조했던 이유는 Float 는 캐싱하지 않고, Character 는 음수값을 제외한 0 ~ 127 만 캐싱하는 등 타입별 지원되는 캐싱 스펙이 다르기 때문입니다. 상제한 스펙은 자바 공식 스펙 문서를 참조하시기 바랍니다.^1 아무래도 적은 수에 대해서만 캐싱한것은 빈도수가 적은수에 대해서만 집중함일것이고, 2^8(256)을 넘는다면 bit 개수에 따라 캐싱 메모리도 늘어나므로 어느 정도 합의점을 본것으로 느껴집니다.

Wrapper Class 중 빈도수가 높은 작은 값들에 대한 객체들을 미리 선언해놓고, 코드상에서 해당 값으로 Wrapper Class 객체를 생성하려하면 이미 저장된 객체를 반환합니다.

Integer 에 대한 Wrapper Class Caching 은 -128 ~ 127 값에 대한 객체를 캐싱해놓습니다.


Conclusion

Wrapper Class 의 동일 여부는 equals() 를 사용합시다.

그렇다면 이제 Integer == Integer 가 어떨때 동작하였고, 어떨때 동작하지 않는지 이유가 명확해졌습니다. Integer 는 -128 ~ 127 까지의 값에 대한 객체는 Java 의 Wrapper Class Caching에 의해 매번 정의할때마다 메모리에 생성하지 않고, 미리 캐싱되어있는 객체를 사용하게 됩니다. 그리하여 Integer a = 10, Integer b = 10 모두 같은 객체 주소값을 가지기때문에 a == b10 == 10 값이 같다는 이유가 아닌 9ab2e1 == 9ab2e1 주소가 같다는 이유로 ‘true(같음)’을 반환하는것이었습니다.

에러 발생 빈도수가 적었던것도 해당 로직 특성상 127 이상의 값이 나올일이 없었던것일테고, 테스트시 발견 못한것은 테스트 값을 상식적인 값 범주만 했을뿐 Integer 최대, 최소 경계값에 대한 테스트케이스는 놓쳤기 때문이라 생각합니다. 다시 한번 값 비교는 equals 를 사용해야한다는 것과, 항상 경계값에 대한 테스트케이스는 필수다라는 당연한 사실을 다시 깨닫고 갑니다.


Java 는 예나 지금이나 참 어려운 언어인것같습니다. 이런걸 접하다보면 예전에 1년간 맛보았던 Kotlin 으로 다시 돌아가고 싶은 마음이 듭니다(…). 그래도 이런 작은 부분들까지 메모해놓고 알아둔다면 앞으로의 지식에 큰 도움이 언젠간 되겠죠. JVM, Java Compiler 에서는 개발자 편의를 위해 지원해주는 기능이 몇가지가 있는데, 이번 캐싱 이슈뿐만 아니라 Java Generic 개념에서도 메모리 효율을 위해 컴파일 시 개발자가 개발한 Interface 구현체를 모두 Interface 로 자동 변환하여, 컴파일 타임에서 걸러지지 못한 에러가 런타임에서 에러로 발생하는 이슈도 있습니다. 이는 추후 포스팅으로 설명하도록 하겠습니다.


출처:

  1. Immutable Objects / Wrapper Class Caching:
    https://wiki.owasp.org/index.php/Java_gotchas#Immutable_Objects_.2F_Wrapper_Class_Caching

Spring MVC, Security 동작 원리와 처리 흐름

Web Server (static 페이지)

웹 초기에는 서버에 정적인 문서(html)를 저장해서 유저가 요청하면 해당 파일을 유저의 브라우저에서 다운받아 보여주는 방식이었습니다. 예를 들면 특정 서버 aaron.com 에 있는 hello.html 문서를 보고싶다면 브라우저에 aaron.com/hello.html 을 호출하면 되는것입니다. 아주 예전에 대학교에서 교수가 자신의 연구실 서버를 이용해서 강의자료를 배포할때 아래와 같은 페이지에 들어가서 다운받았던 기억이 납니다.

서버에 있는 페이지를 유저들에게 보여줍니다.

서버에서 제공하고자 하는 파일들을 실제 서버 내부에 일일히 적재를 해야했으며, 서버에 존재하지 않는 파일에 접근한다면 404 Not Found Error 오류를 보게 됩니다. 이렇게 유저에게 정적인 페이지를 제공하는 서버를 Web Server(웹 서버)라고 부르며 많이 접해봤을 Apache, Nginx 가 이에 해당합니다.

Nginx 의 요청/처리 흐름

요청 처리시

웹 서버의 예로 nginx 에서는 유저 요청을 아래의 과정으로 처리합니다.

요청 처리시

  • 유저는 웹 서버에게 특정 페이지(index.html)를 요청합니다.
  • 웹 서버index.html 검색 후, 있다면 유저에게 반환합니다.

Web Application (dynamic 페이지)

Javascript 의 등장으로 초기 웹 서버처럼 유저에게 단순한 문서를 공유하는 일방적인 서비스를 제공하는것에서 그치지 않고, 유저와의 인터렉션을 통해 회원가입도 가능하고, 글도 쓸 수 있고, 작성한 글들을 서로 볼 수 있는 등의 양방향의 서비스에 대한 요구사항이 생겨나게 되었습니다. 이를 위해서는 일반적인 어플리케이션처럼 데이터베이스와의 연결도 필요하고, 회원의 상태에 따른 동적 페이지 렌더링 등이 필요해졌습니다. 서버는 서버에 있는 자원만 반환하는것이 아니라 유저가 요청한 정보를 요청받은 시점에 알맞은 자원(페이지)를 만들어서 반환하게 됩니다.

서버에 없는 페이지를 유저들에게 매 요청때마다 동적으로 만들어서 보여줍니다.

웹으로 어플리케이션과 같은 요구사항을 처리하기 위해서는 웹 서버여러 언어로 개발된 프로그램을 연결하여 유저의 요청을 서버를 통해 프로그램으로 전달해야합니다. 이렇게 웹 서버와 프로그램 사이를 연결해주는 방식을 CGI(Common Gateway Interface)라고하며 여러 언어로 개발되어있습니다. 그 중 Java 에서는 Web Server 요청/반환과 Java Application 사이를 연결해주는 Servlet 객체가 등장합니다. Servlet 은 유저 요청 하나마다 하나씩 생성되기 때문에 여러 요청에 따른 Servlet 자원 관리가 필요합니다. 이 역할을 하는것이 Web Container 이며 Servlet 입장에선 Servlet Container 로 부르기도 합니다. 유저의 요청/반환을 관할하는 Web Server + 요청에 따른 적합한 Java Application 구동을 위한 Servlet 관리자 Web Container 이 둘을 합쳐 Web Application(웹 어플리케이션) 이라고 부릅니다.

Web Application = Web Server + Web Container(= Servlet Container)

Web Container 는 유저의 요청에 따라 Servlet 자원에 대한 생명주기를 관리합니다

  • 생성(init) -> 처리(service) -> 파기(destory)

Tomcat 의 요청/처리 흐름

요청 처리시

웹 어플리케이션의 예로 tomcat 에서는 유저 요청을 아래의 과정으로 처리합니다.

웹 서버 그림과 비교했을때 웹 서버 아래에 추가된것은 모두 웹 컨테이너에 관련된 것입니다. Web Container 를 시작으로 아래서 위로 역순으로 살펴보겠습니다. 옆에 회색으로 표시한 명칭은 실제 클래스/인터페이스명입니다.

ServletContext (Web Container)

‘Servlet 객체 주기 관리를 위한 웹 컨테이너’에 해당합니다. 관리라는 의미로 Context 를 사용했습니다.
모든 요청에 대한 Servlet 생명주기는 이 ServletContext가 모두 관리합니다.

ServletContextListener

‘Servlet 객체 주기 관리를 위한 웹 컨테이너’ ServletContext 최초 구동시(Listener) 수행할 작업을 정의합니다.

web.xml (Deployment Description)

Deployment Description 이라는 명칭에서 알 수 있듯이 웹 컨테이너 구동시, Servlet 을 위한 2가지 설정을 합니다.

  • B) ServletContextListener 인터페이스 구현체 (어떤것을 실행할지)
  • A) ‘어떤 요청’에 ‘어떤 타입’의 Servlet 객체를 생성할지

추가된 요소들을 살펴보았으니 위 웹 어플리케이션 그림의 유저 요청 처리 방식을 따라가보겠습니다.

최초 구동시

  • tomcat 웹 어플리케이션이 최초 구동시 가장 먼저 웹 컨테이너(ServletContext)를 구동합니다.
  • B) ServletContext 구동 시 web.xml 에 설정한 ServletContextListener 를 같이 수행합니다.

요청 처리시

  • 유저는 웹 서버에게 특정 페이지(index.html)를 요청합니다.
  • 웹 서버index.html 검색 후, 존재하지 않기 때문에 웹 컨테이너(ServletContext)에게 요청을 이관합니다.
  • A) ServletContext 는 web.xml 에서 index.html 요청에 맞는 타입의 Servlet 를 생성합니다.
  • 생성된 Servlet 은 유저가 요청한 페이지를 동적으로 생성하여 유저에게 반환 후 파기(destory)됩니다.

Spring MVC Framework

Java Servlet 을 활용한 웹 어플리케이션 개발이 활성화되면서 여러 디자인 패턴들을 적용하여 Java 웹 개발을 더 쉽게 도와주는 Spring Framework 가 등장하게됩니다. 초기 웹 어플리케이션이 페이지를 동적으로 렌더링하기 위해 각 요청마다 Servlet 을 할당하여 요청을 처리하였다면, Spring 은 각 요청마다 Servlet 보다 작은 단위인 Bean 을 할당하여 요청을 처리합니다.

요청을 처리하는 단위가 Servlet 이라면 Servlet 관리를 위한 Servlet Container
요청을 처리하는 단위가 Bean 이라면 Bean 관리를 위한 Bean Container 가 필요합니다.
이 Bean Container 를 Spring Container 로 부릅니다.

Servlet Container 는 각 URL 요청들을 Serlvet 을 단위로 처리하지만
Spring Container 는 각 URL 요청들을 Bean 을 단위로 처리합니다.

Spring 은 기본적으로 MVC 모델로 Model, View, Controller 세 그룹의 역할로 분리 개발을 돕는 프레임워크이기에 아무리 디자인 패턴에 대한 지식이 전무한 개발자일지라도 유지보수성, 재사용성이 뛰어난 웹 어플리케이션을 만들 수 있습니다. 또한 데이터베이스 접근을 위한 JPA, 트랜잭션, 보안 등 웹 어플리케이션에서 필요로하는 모든것을 Bean 설정으로 제공하기 때문에 어떤 초보자라도 탄탄한 이해만 바탕이 된다면 웹 어플리케이션을 손쉽게 만들 수 있습니다. 디자인 패턴이 실무적으로 어떻게 적용되었는지 공부하는데엔 Spring 만한것이 없는것같다는 어느 시니어의 말씀이 기억에 남습니다.


Spring + Web Application

Spring MVC 동작 과정을 쉽게 이해하기 위해서는 MVCFront Controller 패턴 (2-레벨 Controller) 만 알면 됩니다.

MVC

Model, View, Controller 로써 유저의 요청을 효율적으로 처리하기 위한 모델입니다. 유저가 어떤 페이지를 요청하면

  1. 요청에 적합한 Controller 가 요청을 받아
  2. 요청 페이지에 필요로 하는 정보인 Model 을 조회/생성하고
  3. 조회/생성한 Model 을 통해 최종 페이지인 View 를 생성하여 유저에게 반환하는 모델입니다.

Front Controller 패턴

요청을 받는 부분을 Controller 라고 하였는데 tomcat 은 요청을 Servlet 이라는 Controller 에서 처리하고, Spring 은 요청을 Bean 이라는 Controller 에서 처리합니다. 2-레벨 Controller 의 의미는 (1) 맨 앞의 tomcat 이 모든 요청을 단일 Servlet으로 먼저 받아, 요청 URL 이 무엇인지에 따라서 (2) Spring 의 Controller Bean 에 재할당해주게 됩니다. 가장 앞의 (1) tomcat 단일 Servlet 을 ‘요청을 가장 앞에서 먼저 받는다’는 의미에서 Front Controller 라 부르고, 그 뒤에 (2) Spring Controller Bean 을 실제 페이지 생성에 사용된다는 의미에서 Page Controller 라고 부릅니다.

Spring MVC 의 요청/처리 흐름

최초 구동시

Spring + tomcat 에서는 유저 요청을 어떻게 처리하는지 알아보기에 앞서, tomcat 과 Spring 이 처음 구동될때 어떤 객체들이 생성되어 준비되는지 먼저 알아보겠습니다. Web Container 아래에 Spring Container 가 새로 추가된것을 볼 수 있습니다.

위 그림과 같이 tomcat 에 Spring 을 연결하여 사용하려면 tomcat 설정파일인 web.xml 에 2 가지 설정이 필요합니다.

web.xml (Deployment Description)

  • B) ServletContextListener 인터페이스 구현체 - Root WebApplicationContext
    -> Spring 공용 Bean (@Service, @Repository, @Component…) 객체들을 미리 생성해놓기 위함
  • A) ‘모든 요청’은 Front Controller 에 해당하는 단일 Servlet 객체(DispatcherServlet)가 처리한다.

최초 구동시

  • tomcat 웹 어플리케이션이 최초 구동시 가장 먼저 웹 컨테이너(ServletContext)를 구동합니다.
  • B) ServletContext 구동 시 web.xml 에 설정한 Spring Root WebApplicationContext가 동시에 구동됩니다.

요청 처리시

위 최초 구동 후 tomcat 은 모든 요청을 단일 Servlet(명칭은 DispatcherServlet) 으로 받을 준비가 완료되었고, Spring 도 Controller Bean 이 결과를 반환하기 위해 필요로하는 모든 Bean 들이 Root WebApplicationContext 로 준비가 완료되었습니다. 이제 유저가 요청을 보내면 tomcat 과 Spring 이 어떻게 처리하여 결과를 반환하는지 아래 그림으로 살펴보겠습니다.

Spring 의 키워드는 IoC, DI 라고 할 수 있는데, 간단하게 설명하자면 기존에는 개발자가 new 를 통해 객체를 직접 생성하고, 직접 주입해줬다면 Spring 에서는 어떤 인터페이스, 클래스를 사용할것인지만 표기해놓으면 ApplicationContext(BeanFactory 상속) 라고 불리는 Spring Container 가 객체를 Bean 이라는 단위로 알아서 생성하고 알아서 주입해주는 개념입니다. 이렇게 Spring 에서는 Java 의 모든 객체를 Bean 으로 부르며 사용합니다.

Spring Container = ApplicationContext

Spring 에서 Bean 은 웹 어플리케이션 관점에서 크게 2 개의 타입으로 구분될 수 있습니다.
그에 따라 Bean 의 생명주기를 관리하는 Spring Container 도 2 개의 타입으로 나뉘어집니다.

  • 요청이 들어왔을때 적합한 처리를 위해 요청과 상관없이 모든 Servlet 들이 공유하는 공용 Bean
    • 예: @ComponentScan 으로 등록된 @Service, @Repository, @Component
    • 생명주기 관리: Spring Container 1 (Root WebApplicationContext)
  • 요청이 들어왔을때 할당되는 Servlet 처럼, 요청이 들어왔을때만 생성하면 되는 Bean
    • 예: @ComponentScan 으로 등록된 @Controller, @Interceptor
    • 생명주기 관리: Spring Container 2 (Servlet WebApplicationContext)

위 그림을 보면 최초 구동시에 생성된 Spring Container 1 아래에 또 하나의 Spring Container 2 가 생겨난걸 볼 수 있습니다. parent 와 child 라고 써져있는것은 두 컨테이너 간 계층이 있다는 의미이며, 단순히 child 인 Servlet WebApplicationContext 의 Bean 들은 부모인 Root WebApplicationContext 의 Bean 들을 참조할 수 있지만 그 반대로는 참조할 수 없음을 의미합니다. Root WebApplicationContext 이 모든 Servlet 들이 공유하는 Bean 생명주기를 관리하는것이라 생각하면 당연한것입니다.

요청 처리시

  • 유저는 웹 서버에게 특정 페이지(index.html)를 요청합니다.
  • 웹 서버index.html 검색 후, 존재하지 않기 때문에 웹 컨테이너(ServletContext)에게 요청을 이관합니다.
  • A) ServletContext 는 web.xml 에서 어떤 요청이든 / 단일 DispatcherServlet 을 생성합니다.
  • DispatcherServlet 은 유저가 요청한 페이지에 해당하는 Spring Controller가 있는지 HandlerMapping 을 탐색합니다.
    • Spring Controller 를 Handler 라고 부릅니다
  • DispatcherServlet 은 찾은 Spring Controller BeanHandlerAdapter를 통해 호출합니다.
  • HandlerAdapterHelloController Bean를 호출합니다.
  • HelloController는 Root WebApplicationContext 의 여러 Bean 들을 활용하여 결과를 DispatcherServlet에 반환합니다.
  • DispatcherServlet는 Controller 로부터 받은 결과로 ViewResolver, View에서 결과 페이지(index.html)를 생성합니다.
  • DispatcherServletViewResolver, View가 만든 결과 페이지(index.html)를 유저에게 반환합니다.

위 과정의 코드레벨에서의 흐름은 다음 블로그 링크^1에 잘 정리되어있어 참고하시면 상세히 알 수 있습니다.

이렇게 Spring MVC 에서 어떻게 유저의 요청을 받아서 처리하고 반환하는지를 그림으로 알아보았습니다. 요청 URL 에 따라 Controller Bean 이 할당된다는것은 알았지만, 이렇게 상세하게 알아보니 컨트롤러나 서비스에서 Exception 이 발생하였을때 로그에 남는 Stacktrace 의 메서드와 클래스들의(invoke, DispatcherServlet, preHandle, postHandle 등) 의미를 좀 더 알 수 있었습니다.


Spring Interceptor 와 Filter 의 차이점

Spring 을 활용하여 개발한 웹 어플리케이션들은 일부 혹은 모든 사용자에게 오픈되어 서비스를 제공하기때문에 보안이 필요합니다. Spring Security 는 기본적으로 로그인과 세션에 관련된 모듈 및 설정을 손쉽게 사용가능하도록 제공하지만, 웹 어플리케이션에 인입되는 모든 요청에 따로 개발한 인증 모듈을 적용하거나, 요청 URL 에 따라 다른 처리 등이 필요하다면, 개발자가 해당 로직들을 직접 만들어 유저 요청이 실제 Spring Controller 에게 전달되기 전에 수행되도록 해당 로직을 추가해야합니다. 이때 사용되는것이 InterceptorFilter 입니다.

우리는 앞서 Spring 을 사용한 웹 어플리케이션은 크게 Tomcat (Web Container)Spring (Spring Container) 의 2개로 구성된다는것을 배웠습니다. InterceptorFilter 도 Spring Controller 에 요청이 도달하기 이전에 원하는 중간 작업을 위해 사용된다는 목적에선 동일하지만, 관리주체 및 실행시간이 TomcatSpring 으로 나뉘어집니다.

Filter 는 Servlet 스펙의 일부이고 Servlet(Tomcat)에 의해 호출되지만
Interceptor 는 Spring 에 의해 호출됩니다.
(It’s perfectly fine as Filter’s are part of Servlet specification. Filters are called by your Server(tomcat). while Interceptors are called by Spring^2)

아래는 Interceptor 와 Filter 의 관리주체 및 실행시간을 이해하기 쉽게 표현한 그림입니다.

Filter (Tomcat)

  • Servlet (J2EE 7 표준)스펙에 정의
    • 웹 어플리케이션(tomcat) Deployment Descriptor(web.xml)에 설정
      • 이 부분에 대한것도 최신 Spring 에서 설정 가능
  • 1개의 함수로 DispatcherServlet 이전/이후에 호출
    • 함수명: doFilter()
      • 요청이 DispatcherServlet.service() 에 진입하기 직전(init() 후)에 호출
      • 결과를 DispatcherServlet.service() 가 반환하는 직후(destroy() 전)에 호출
  • doFilter 함수가 요청 진입시 & 결과 반환시, 2번 호출되기 때문에, 암/복호화같은 요청 전 & 반환 후 두 곳에 전역적으로 처리해야하는 로직에 적합합니다.

Interceptor (Spring)

  • Spring Framework 스펙에 정의
    • Spring WebApplicationContext에 설정
  • 3개의 함수로 Controller 이전/이후에 호출
    • 함수명: preHandle()
      • 요청이 Controller 에 진입하기 직전호출
    • 함수명: postHandle()
      • 결과를 Controller 가 반환하는 직후호출
    • 함수명: afterCompletion()
      • Controller 결과에 따라 View 를 생성한 직후호출
  • 컨트롤러 진입 혹은 결과 반환 시점에 디테일하게 처리해야하는 로직에 적합합니다. 예를 들어 특정 URL 로 진입되는 요청에 대해서는 컨트롤러 진입 직전에 해당 URL 에 특화된 정보들을 미리 세션에 설정하여 컨트롤러 내부 로직에서 활용할 수 있게 할 수 있습니다. 다른 URL 이라면 본 로직을 수행하지 않도록 조건을 추가할 수도 있습니다.

필터와 인터셉터는 관리주체가 다르기 때문에 다음과 같은 상황이 발생합니다.

  • 필터는 Spring Container 관리주체가 아니기 때문에 필터 로직 내부에서 Spring 의 Bean 을 사용하려면 @Autowired 같은 빈 주입이 아닌, 먼저 Spring WebApplicationContext 객체를 가져와서 그 안에 설정된 Bean 을 하드코딩을 통해 직접 가져와서 사용해야합니다.

다수 Interceptor 와 Filter 의 호출 순서

필터와 인터셉터는 상황에 따라 여러개를 지정하여 사용할 수 있습니다. 다수의 필터 혹은 인터셉터 사용시 각각의 호출 순서는 설정에 따라 바꿀 수 있는데, 필터도 사실은 DispatcherServlet 호출 전/후에 호출되는 Servlet 설정이기 때문에 tomcat 에서 관리하는것이라 하더라도 인터셉터와 마찬가지로 Spring 설정을 통해 설정할 수 있습니다.

2개의 필터2개의 인터셉터를 사용할때 어떻게 동작하는지 순서를 살펴보기 위해 DispatcherServlet 과 HandlerAdaptor 를 중점적으로 살펴보면 아래와 같습니다.

정확한 순서는 아래 간략하게 요약한 그림으로 알 수 있습니다.

  1. doFilter (F1)
  2. doFilter (F2)
  3. preHandler (I1)
  4. preHandler (I2)
  5. Controller 요청 처리
  6. postHandler (I2)
  7. postHandler (I1)
  8. View 렌더링
  9. afterCompletion (I2)
  10. afterCompletion (I1)
  11. doFilter (F2)
  12. doFilter (F1)

웹 서버에서 웹 어플리케이션, 웹 서버와 웹 어플리케이션을 연결하기 위한 CGI 의 예로 Servlet 그리고 Container 를 알아보고, Spring Container 와 Filter, Interceptor 의 차이 그리고 실행 순서에 대해 알아보았습니다. Spring 을 공부하시거나 사용하시는 다른 개발자 분들에게 본 글이 도움이 되었길 바랍니다. 참조한 글들도 좋은 글들이니 시간이 되시면 한번씩 훑어보시는걸 추천드립니다.


출처:

  1. Spring 동작 원리 #1:
    https://asfirstalways.tistory.com/334
  2. Spring 동작 원리 #2:
    https://devpad.tistory.com/24
  3. Spring 동작 원리 #3:
    https://taes-k.github.io/2020/02/16/servlet-container-spring-container/
  4. Tomcat 이 Spring 호출하는 방법:
    http://www.deroneriksson.com/tutorial-categories/java/spring/introduction-to-the-spring-framework
  5. Java Servlet:
    https://mangkyu.tistory.com/14
  6. Web Server, Web Application 차이:
    https://gmlwjd9405.github.io/2018/10/27/webserver-vs-was.html
  7. Spring DispatcherServlet 동작 원리 #1:
    https://jess-m.tistory.com/15
  8. Spring DispatcherServlet 동작 원리 #2:
    https://dynaticy.tistory.com/entry/Spring-MVC-Dispatcher-Servlet-%EB%82%B4%EB%B6%80-%EC%B2%98%EB%A6%AC-%EA%B3%BC%EC%A0%95-%EB%B6%84%EC%84%9D
  9. Spring web.xml 설명 #1:
    https://sphere-sryn.tistory.com/entry/%EC%8A%A4%ED%94%84%EB%A7%81%ED%94%84%EB%A0%88%EC%9E%84%EC%9B%8C%ED%81%AC-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%9D%98-%EA%B0%80%EC%9E%A5-%EA%B8%B0%EB%B3%B8%EC%84%A4%EC%A0%95-%EB%B6%80%EB%B6%84%EC%9D%B8-webxml%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90
  10. Spring web.xml 설명 #2:
    https://gmlwjd9405.github.io/2018/10/29/web-application-structure.html
  11. Spring 2개 타입의 ApplicationContext:
    https://jaehun2841.github.io/2018/10/21/2018-10-21-spring-context/#web-application-context
  12. Servlet Container & Spring Container:
    https://velog.io/@16616516/%EC%84%9C%EB%B8%94%EB%A6%BF-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88%EC%99%80-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88
  13. Spring MVC 코드 기반 동작 원리:
    https://galid1.tistory.com/526

신입 개발자였던 내게 해주고싶은 조언들

부족했던 나의 신입시절

개발자로 살아온지 3년반이 되었습니다. 물리학자의 삶을 꿈꿨었지만, 중학교때 게임을 만들자고 배웠던 C 언어와 고등학교때 hamachi 로 VPN 과 네트워크에 흥미를 가진것을 시작으로 학석사를 지난 뒤 시대 기술 흐름에 타고싶다는 욕심으로 첫 직장으로 쿠팡에 들어갔었습니다.

신입때는 너무 어렸었습니다. 튀는걸 너무 좋아해서 옷도 행실도 너무 자유분방했던것같고, 개발을 잘하면 모르겠는데 제대로 할줄아는것도 없으면서 열정만 높았어서 이것저것 손대는것들만 많았으니 팀적으로도 개인적으로도 남는것이 그리 많진 않았었습니다. 개발적으로는 Spring, Java 와 React 에 좀 더 집중했었으면 어떨까 아쉬움은 있지만, 나름 큰 회사에 비지니스에서 개발까지 풀스택에 가까운 다양한 경험들을 통해 배운것들은 많아서 큰 후회는 없습니다. 그래도 제가 과거의 제 신입시절로 돌아간다면 해주고 싶은 몇가지 조언들이 있습니다.


주변에서의 조언 요청

쉬는날엔 개발에 흥미를 가진 학생과 Spring 나 머신러닝 스터디를 진행하며 몇 조언도 주었었는데, 얼마전에 한명으로부터 자기 주변의 신입 개발자분들한테 해줄 조언에 대해 질문받았습니다. 짧게 답변해주려다가 내 부족했던 신입시절을 회고할겸, 블로그로 작성해놓으면 다른 스터디에서도 본 글을 추천해주면 되겠다 싶어서 이렇게 글을 작성하게 되었습니다. 워낙 다양한 스택의 신입 개발자분들이 많아서 각 분야에 맞게 학습하시는게 좋을것같아 따로 본 글에 기술적인것들은 제외하였습니다.

개발자, 한번 더 생각해보세요

최근 산업구조의 변화때문에 개발직군에 대한 수요가 폭증하기 시작했고, 현재의 취업난은 구직하는 분들에게 개발자가 매력적인 대안으로 다가오면서 직군을 개발자로 바꿔 이직하시거나 학생들은 전과를 하는 경우가 주변에서 종종 보이기 시작합니다. 유능한 개발자가 돈을 많이 벌기도하고, 회사의 대우도 좋은 편이고, 창의적 직군이다보니 흔히말하는 꼰대가 생존할 수 없는 분야라 개발자 문화가 선진화되어있어서 좋아보일 수 있습니다. 하지만 이는 나 자신이 유능한 개발자일때 한하여 얻을 수 있는 장점들입니다. 어떤 분야와도 마찬가지로 그 사이에는 무수히 많은 인고의 시간들이 있습니다.


대체 가능한 인력

개발자는 유능하지 않으면 사실 해당 인력을 대체하기가 굉장히 쉬운 직군이기도 합니다. 제 주변의 시니어 개발자분들은 모두 SI 업체에서 혹독한 개발경험을 거쳐 오셔서 관련 이야기를 많이 들어왔었는데, 개발자들의 실력 분포는 승자독식이라고 보면 됩니다. 상위 적은 % 의 유능한 개발자들만 주도적으로 프로젝트를 운영할 수 있고 그 외의 개발자들은 수동적으로 할당 받은 개발만 할 수 있습니다. 이 능동성과 수동성의 차이가 내가 있을 기업, 내가 받을 연봉, 주변에 능력있는 개발자들의 유무를 크게 가른다고 생각하시면 됩니다. 제 주변의 모든 존경스러운 시니어분들은 SI 에서 끊임없는 공부와 노력을 끝으로 올라오신 분들입니다. 이 말을 즉슨 내가 어디에 속하는지가 중요한것이 아니라 나의 태도가 중요하다는 것입니다.


유능한 개발자의 특성

유능한 개발자와 무능한 개발자의 분포가 굉장히 편파적인 이유는 유능해지기 그만큼이나 어렵다고 생각하셔야합니다. 경쟁은 상대적인데다가 노력하는 개발자들도 많기때문에 조금만 부족하더라도 장기적으로는 도태될 수 있습니다. 그렇다면 개발자는 어떤 사람들이 잘할 수 있을까요?

  1. 업무 외 시간에도 코딩 및 공부를 순수하게 즐길 줄 알아야합니다.

직장에서 업무를 마치고 집 돌아와서 혼자 쉬는시간에도, 직장에서 있었던 여러 이슈나 내가 주고받았던 코드리뷰들을 한번 더 복습하고 이유와 원인을 분석할 수 있어야합니다. 예를 들면, 오늘 처음으로 docker 를 이용한 배포 프로세스를 경험해봤다면 docker 에 대한 궁금한 점들을 공부하는식이죠. 개인 프로젝트가 있다면 좋습니다. 아니라면 다른 오픈소스를 하나, 둘 개선해보거나 잘 짜여진 오픈소스 코드를 뜯어보거나 디버깅을 통해 한라인씩 실행시켜보는것도 코드 역량을 올릴 수 있는 좋은 방법이기도 합니다.

  1. 평생 공부할 수 있어야합니다.

즐기는것도 필요하지만 개발자는 그만두기 이전까지 계속해서 공부해야하는 직군입니다. Spring 만하더라도 과거에는 .xml 기반으로 Bean 인젝션을 처리했었지만 이젠 @Annotaion 기반으로 처리합니다. React.js 도 과거에는 class 기반의 component 를 사용했지만 이제는 함수형 Hook 을 사용합니다. JVM 과 ES 표준은 항상 끊임없이 변화하고 매 개발 컨퍼런스에서는 자신들의 새 개념을 발표합니다. 세상을 빠르게 돌아가도록 하는건 기술이고 최근 그 기술의 선봉에 개발자들이 있습니다. 이 빠른 흐름에 뒤쳐지지 않기 위해서는 매 새로운 개념을 ‘과거의 탄탄한 개발지식을 바탕’으로 흡수하여 그 다음을 준비할 줄 아는 인재가 되어야합니다.

개발은 어떤 유저가 하나의 웹/앱에 수행한 클릭이 프론트 UI -> 네트워크 -> 보안 -> 백엔드 서버 -> 서버 인프라 -> DB 모든 기술 스택의 사이클을 한바퀴 돌아 원하는 결과를 볼 수 있게하는것입니다. 각 분야는 의사로 치면 전문의들이 있을 정도로 그 깊이가 매우 깊습니다. 내가 어떤 분야의 전문의가 되더라도 모든 사이클에 대한 적당한 깊이의 이해가 있어야지

  • 개발하는데 있어서 다양한 상황을 동시에 고려하여 신뢰성 높은 결과를 만들/조언할 수 있고
  • 문제가 생겼을때 빠른 원인 파악 및 대처를 할 수 있습니다.

개발자는 사실 분석가입니다.

개발자를 꿈꿀때 흔히 생각하는 모습은 키보드를 통해 코드를 끊임없이 쳐서 새로운걸 생산해내는것일 것입니다. 개발의 절반만 보안, 성능, 안정성, 확장성을 고려하여 새로운 코드를 생성하는것이고, 그조차도 내가 만들었던 혹은 타인이나 스택오버플로의 코드의 많은 부분을 참조하여 재생산하게됩니다. 나머지 절반은 테스팅과 디버깅입니다.

회사에서 업무를 수행하게 되면 하나의 프로젝트를 한 사람이 모든 역할과 책임을 갖고 만들지 않습니다. 만약 그렇다하더라도 해당 코드는 다음 세대에 새 개발자가 물려받게됩니다. 개발은 다수 개발자와의 협업입니다. 즉, 다수 개발자들의 코드더미에 내 작은 코드를 넣는 과정의 반복입니다. 비대면, 비동기적 협업인 셈입니다. 그렇기때문에 하나의 피쳐를 개발한다 하더라도 타 개발자가 개발한 코드를 온전히 이해할수 있어야지 원치않는 에러를 내지 않게됩니다. 테스트를 진행하다보면 분명히 의도적인 결과가 도출되지 않을때가 있는데, 내가 만든 코드뿐만 아니라 타 개발자의 코드도 같이 디버깅 할 줄 알아야합니다. 라인별로 의도를 파악하고, 코드의 흐름을 파악하는건 직접 개발능력보다 더 중요합니다. 아무리 좋은 침을 쓴다한들 한의사가 맥을 잘짚지 않으면 소용없는것과 마찬가집니다.

코드의 유지보수성은 한때 엄청 큰 이슈였고, 이를 위해 Spring 의 IoC, 책임분리 개념들이 이를 돕기위해 나왔었던것입니다. 남들이 어떤 문서없이도 코드를 이해할 수 있도록 만드는것이 가장 좋지만, 문서와 커멘터리는 개발자와 협업에 있어서 그 못지않게 중요합니다. 개발은 협업이란걸 잊으면 안됩니다.


좋은 시니어 개발자가 되기

생각했던것과 조금은 다른 개발자의 역량에 대해 알아봤습니다. 그럼에도 개발자가 되고싶은 마음이 크다면 이제는 신입때부터 어떻게해야 몇년뒤에 동료들이 같이 일하고싶어하는 시니어로 성장할 수 있을지에 대해 말해보겠습니다. 짧게 요약하자면 내가 작성한 코드에 대한 근거/이유를 어느 누구에게도 명료히 이야기할 수 있어야하고, 주력인 언어나 프레임워크에 대한 깊은 이해가 있어야합니다.


이슈와 리뷰는 꼭 메모/복습

개발중에 발생하는 이슈, 코드리뷰는 꼭 ‘왜’에 대해 꼼꼼히 메모해놓고 복습하는것이 필요합니다. 앞서 말씀드렸듯이 개발자는 평생 공부해야하는 직업일 정도로 익혀야할 정보가 많기 때문에 따로 메모/복습이 없다면 매번 똑같은 실수를 반복하게됩니다. 기억을 잘하기 위해서는 스터디같은것을 통해 누군가에게 가르치는게 좋은 방법입니다.


코드 작성 이유 설명

내가 어떤 작업을 왜 이렇게 했는지 누군가에게 제대로 설명할 수 있어야합니다. 신입이나 경력 개발자를 뽑을때 인터뷰로 유심히 보는 부분이기도 합니다. 누가 이렇게 하라던데요? 스택오버플로에서 이렇게 쓰더라구요.의 근거는 당연하지만 좋지 않습니다. 이유없이, 그냥 되니까 사용하는 코드는 거의 없어야합니다. 물론 시간의 한계 때문에 모든 이유를 다 알 순 없습니다만 적어도 내 주력 분야에 대해서는 설득할 수 있어야합니다.


언어/프레임워크 선택과 집중

신입때는 주력 언어, 프레임워크, DB 가 적고 깊을수록 좋습니다. 특히나 최신 기술을 사용하고 각 팀마다 개발스택이 다양한 (좋은) 회사를 처음 가게된다면 뷔페에 온듯양 두 눈이 휘둥그레져서 이것저것 다 먹어보고싶은 마음은 백번천번 이해합니다. 하지만 주니어라면 내 주력 기술이 단 하나라도 있어야 그를 통해 다른 기술들과의 차이점을 알고, 특징들을 더 잘 이해할 수 있습니다. 타 기술을 이해하는데에 뿐만 아니라 커리어적으로도 3년~5년의 긴 시간이 흘렀을때 그 주력 기술이 나의 강점이 되어 후배들을 이끌 수 있게됩니다.

모든것을 다 익히고싶은 마음은 당장은 안타깝지만 정보량이 정말 방대하기 때문에 하나라도 제대로할 줄 아는 선택과 집중의 지혜가 필요합니다. 풀스택은 내 분야에 대한 제대로 된 이해가 꼿꼿히 바로서야 그 위에 ㅡ 자를 그려서 T 자형 인재가 될 수 있는것입니다. ㅣ만 제대로 세운다면 그 이후 ㅡ 는 더 넓고 방대하게 뻗어나갈 수 있으니 당장의 욕심을 내려놓고 내 분야에 집중합시다.

언어/프레임워크 선택은 내가 가고싶어하는 분야에서 많이 선호되고 보편적으로 사용하는것을 선택해야합니다. 웹 서비스 개발이라면 Java, Spring 그리고 SQL 은 MySQL 만 제대로 공부해놓는다면 NoSQL 의 개념과 MVCC 등을 이해하는데 큰 도움이 됩니다. 게임 개발이라면 유니티를 위해 C# 을 배운다거나 머신러닝을 위해서는 tensorflow 를 위한 python 을 공부할 수 있을것입니다.


틈틈히 시간날때마다 공부

신입분들은 새 회사에서 크게 세가지를 배우실겁니다. 내외부 팀 구성 및 본인의 팀 내 팀원들, 비지니스, 개발 기술스택. 쿠팡처럼 비지니스가 방대한 회사를 가셨다면 당장 그것만 이해하는데 1여년을 다 사용해야할수도 있지만, 틈틈히 개인의 주력 언어, 프레임워크를 좀 더 끈질기게 공부하신다면 좋은 기회가 왔을때 더 잘 캐치하여 성과를 올릴 수 있으실겁니다.

혹자는 2~3년 세상에 난 없는 사람이라고 생각하면서 끈기있게 공부에 전력을 쏟으면 더 빠른 시간안에 시니어가 될 수 있다곤 하지만, 그렇게 추천드리긴 인간적으로 어렵다고 생각됩니다. 이러한 이유로 오타쿠 혹은 코딩/공부를 미친듯이 좋아하는 분들이 천재 개발자가 되는것이라 생각합니다. 결국엔 공부의 총량이 결정하는 셈입니다.


코드리뷰는 필수

코드리뷰가 매우 활성화되어있는 회사로 가는것을 추천드립니다. 적어도 한명의 사수가 꾸준히 코드리뷰를 해줄 수 있는 기업에서 일하는것이 좋습니다. 멘토-멘티 문화가 바로잡혀있다면 정말 최고입니다. 한줄의 코드에는 그렇게 작성한 수많은 이유들이 있습니다. 주니어 개발자들은 이걸 왜 이렇게 개발하였는지 알기위해서 몇달을 공부해야하지만, 대로 된 멘토가 있다면 일주일에 모든 이유를 배울 수 있습니다. 물론 그 지혜는 꼭 메모해놓는것이 필요합니다. 그런 문화가 없다면 동기들과 혹은 스터디를 통해 서로의 코드와 지식을 나누고 리뷰하는 모임을 만드는것이 좋습니다. 회사 동기들은 대외비인 회사 내부 코드들을 리뷰해줄수있다는 장점이 있고, 스터디를 통해서는 지정한 언어/프레임워크를 다양한 시각에서 토론해볼 수 있다는 장점이 있습니다. 비슷한 이유로 개발자 모임같은 대외 활동을 늘리는것도 시니어 분들에 의해 많이 추천됩니다.


커뮤니케이션 능력

사람들을 대하지 않는다는 비대면의 특성때문에 전 개발자를 선택하였었지만, 흔히 아싸의 이미지를 갖는 개발자의 이미지와는 달리 결국 개발자는 사람을 대하는 직업입니다. 실제로 매체에서 접하는 비사회적인 nerd 들은 본인의 역량은 굉장히 뛰어날지 모르겠지만, 회사는 팀별로 설정된 목표를 가지고 함께 나아가는 곳이기에 협업과 커뮤니케이션 능력이 없다면 활용불가능한 자원에 불가합니다. 신입 개발자로써 두 분야의 사람을 접하게 될텐데 비지니스 분야, 디자인 분야입니다. 이들과는 (1) 요구사항과 (2) 개발 한계에 대해 이야기할일이 많을것입니다. 또한 같은 분야의 개발자들과도 (1) 코드에 대한 리뷰/이유 설명과 (2) 정보 교환을 위해서도 커뮤니케이션은 필수입니다. 타인의 의견을 항상 경청, 존중해야할줄 알아야하며 내 근거에 대해서 조리있게 설명할 줄 알아야합니다.


사실 처음에는 풀스택 개발자가 되기위해서 알아야할 기본적인 프론트엔드, 백엔드, 보안, 네트워크, 인프라에 대한 기술적인 내용들을 이야기하고 싶었습니다. 하지만 워낙 양이 방대하기 때문에 이건 앞으로 블로그글이나 오프라인으로 스터디를 통해 전달해야할거같습니다.

벌써 3년 반이란 세월이 지났지만 아직도 항상 부족함을 느끼며 공부하고 있기 때문에 이런 조언글을 쓰는게 적합한가 싶기도합니다. 신입때 내게 아쉬웠던 점들을 풀어쓴 글이 이제 막 신입으로 들어오는 후배나 개발직을 준비하는 분들에게 도움이 될것이라 생각합니다. 짧은 시간에 작성한것이라 부족함이 많을 수 있음에도 끝까지 읽어주셔서 감사합니다. :) 모든 개발자 분들 화이팅하셨으면 좋겠고 언젠간 좋은 위치에서 만나뵈면 좋을것같습니다.

VSCode 에서 Hexo 디버깅 하는 방법

Hexo 테마 커스터마이징

hexo 에서 원하는 테마를 선택하더라도 수정하고 싶은 부분들이 있을 수 있습니다. 아무리 테마에서 yaml 기반의 config 를 제공한다고 하더라도, 더 세밀한 부분까지 원하는대로 바꾸고싶다면 테마 코드를 직접 수정해야합니다. Hexo 설치형 블로그를 시작하면서 제가 선택했던 Icarus 테마는 크게 두가지 타입의 코드로 나뉘어져있습니다.

  • .styl : bulma 를 기반으로한 CSS 설정들이 있습니다.
  • .jsx : .md 로 작성한 글들을 페이지에 어떻게 렌더링할지 정의되어있습니다.

.styl 커스터마이징

너비, 높이, 폰트사이즈, 색깔등의 설정은 .styl 코드에서 설정하면 되며, 브라우저에서 페이지 각 요소의 CSS 설정들을 분석하여 그에 해당하는 설정이 있다면 값을 수정하고, 없다면 bulma 의 설정을 그대로 사용하는것이므로 오버라이딩을 위해 원하는 설정을 추가해줍니다.

.jsx 커스터마이징

글이나 위젯을 페이지에 렌더링 하는 부분은 .jsx 코드에서 설정하면됩니다. 화면에 어떻게 렌더링되는지, 내가 수정한 코드가 제대로 동작하는지 알기위해 디버깅을 필요로합니다. hexo 블로그 운영은 VSCode 를 통해 하고있어서 VSCode 에서 hexo 를 디버깅하고 있습니다.

디버깅 설정

VSCode 의 디버깅 설정은 디버깅 창에서 RUN 우측 디버깅 리스트에서 Add Configuration... 를 통해 가능합니다.

Add Configuration... 선택하게되면 현재 프로젝트 디렉토리에서 .vscode 라는 디렉토리 한개와 그 안에 launch.json 파일 하나를 생성하고, 해당 파일로 이동하여 어떤 설정을 추가할지 아래와 같이 리스트를 보여줍니다.

리스트에서 Node.js: Launch Program을 선택하면 설정이 한개 추가되는데,

아래와 같이 수정 입력하면 됩니다.

launch.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Hexo Debugging",
"program": "${workspaceFolder}/node_modules/hexo-cli/bin/hexo",
"args": [
"server"
]
}
]
}

로컬에 살행한 hexo server 에 대한 디버깅이기 때문에 타켓 프로그램은 hexo-clibin/hexo 이며, argsserver 가 들어가는것을 보실 수 있습니다. 이제 디버깅을 하면서 즐겁게 나만의 커스텀 테마를 만드시면 됩니다. 더 나아가서 그렇게 만든 나만의 테마를 사람들과 공유할수도, 이미 있는 테마에 git contributor 로 확장된 기능을 추가할수도 있을것입니다.


  1. https://gary5496.github.io/2018/03/nodejs-debugging/
  2. https://stackoverflow.com/questions/57125171/how-to-debug-inspect-hexo-blog

Hexo, Icarus 새 버전으로 마이그레이션 및 커스터마이징

21년 첫 블로그 글 발행시 오류

업무를 하면서 정말 많은 것들을 경험하고, 배우지만 정작 시간을 내서 정리하여 어딘가에 작성해놓으지 않으면 기억이나지 않더군요. 사내 위키를 잘 활용했었는데, 아무래도 더 많은 분들과 정보 및 의견을 공유하고 싶어서 블로그에 다시 글을 올리려했습니다. 얼마전에 겪었던 이슈에 관련된 글을 hexo 로 작성하여 hexo g -d 를 통해 발행하려하니 갑자기 오류가 발생했습니다.

1
2
TypeError [ERR_INVALID_ARG_TYPE]: The "mode" argument must be integer.
Received an instance of Object at copyFile

구글링을 해보니 node 버전을 Downgrade 하여야하며, 현재 hexo 5.0.0 버전 이후로 픽스되었다는 스레드^1를 찾았습니다. 생각해보니 작년말에 개발 공부를 위해 라이브러리나 프레임워크 버전들을 일괄적으로 다 올렸었습니다. 그중에 node.js 가 15 버전으로 업데이트 되어있었습니다. node.js 최신 버전을 아무래도 글로벌로 업데이트하였어서 hexo 에서 npm 동작시 충돌이 일어나는것같습니다.

Hexo 최신 버전 마이그레이션

사용중이던 버전은 Hexo 3.8.0 / Icarus 2.3.0 인데 확인해본 최신 버전은 Hexo 5.3.0 / Icarus 4.1.1 이었습니다. Hexo 먼저 버전업을 위해서 package.json 에 기존 패키지들을 모두 지우고 hexo 버전을 5.3.0 버전으로 바꾸었습니다. npm install 수행 후 깨지는 패키지들을 일일히 넣어주기 귀찮아서, 새 디렉토리에서 hexo init 하여 자동 생성된 package.json 을 참조했습니다.

최신 Hexo 와 구 Icarus 충돌

hexo 버전 업데이트 후 hexo server 로 로컬에 페이지를 띄워보니 icarus 테마 파일에서 계속해서 특정 변수를 찾을 수 없음 등의 오류가 발생하였습니다. 구글링하니 이 또한 버전문제로 파악되어 기존의 icarus 테마의 커스터마이징 설정 _config.yml 만을 백업한 채 모두 지우고 icarus 를 최신버전으로 (1) npm install 이 아닌 (2) git submodule 을 통해 (커스터마이징을 위해) 설치하였습니다. 다시 실행하니 jsx 코드만 덩그러니 노출되는 문제가 발생하였는데 jsx 라서 react.js 인줄알았더니 inferno.js 로 개발되어있어서 해당 라이브러리를 설치하여 해결하었습니다.

Icarus JSX 커스터마이징

어떤 테마든 수정을 해야하는 깐깐한 성격탓에 예전 icarus 테마는 ejs 코드를 분석해서 커스터마이징을 따로 하였었습니다. 예전엔 ejs-based 였던 icarus 가 이젠 jsx-based 로 되어서, 이전 커스터마이징 코드를 그대로 사용할 순 없고, 또 다시 분석해서 수정해야했습니다. 프론트 개발을 쭉 react.js 로 해왔었고 icarus 개발 프레임워크인 inferno.js 도 react-like 를 표방하는만큼 디버깅만 할 줄 알면 크게 어렵진 않을것같았고, 실제로도 그랬습니다. 다만, icarus 프로젝트가 jsx 로 변환되면서 과거에 난잡했던 ejs 구조에서 컴포넌트 단위로 모듈화가 잘되어졌기 때문에 페이지마다 렌더링을 다르게해야하는 부분은 공통 컴포넌트에 예외 조건을 넣는 방식으로 처리해야했습니다. 아래 커스터마이징한 코드들을 보시면 이해가 되실겁니다.

VSCode 를 통한 Hexo 디버깅

테마를 수정하기 위해서는 가장 먼저 hexo, icarus jsx 들이 페이지로 어떻게 렌더링 되는지 알아야합니다. 로컬에서 hexo 테스트를 위해 실행하는 hexo server 명령어는 hexo-cli 에 정의되어있는 npm 스크립트를 실행한것입니다. 테마 설정을 바꾸거나 글을 수정하면 바로 로컬 페이지에 적용이되는데 이는 npm 를 통해 동적으로 렌더링하고 있기 때문입니다. 저는 icarus 테마가 어떻게 동작하는지 이해하고, 수정한 제 코드가 제대로 동작하는지 확인하기 위해 VSCode 를 통해 디버그를 진행하였습니다. VSCode 에서 디버깅하는 방법은 해당 글 VSCode 에서 Hexo 디버깅 하는 방법에서 잘 설명해놓았으니 참고하시면 됩니다.

네비게이션바 로고

제일 상단의 네비게이션바에 기본적으로는 로고 이미지를 올리도록 설정되어있지만, 해당 블로그를 표현할 수 있는 단어로 치환 및 폰트 사이즈를 설정하였습니다.

_config.icarus.yml
1
2
3
# Path or URL to the website's logo
logo:
text: Crucian Carp
include/style/navbar.styl
1
2
3
4
.navbar-logo
img
max-height: $logo-height
font-size: 1.4rem

좌측 위젯 - 프로필 재설정

본 블로그에서는 웹 검색에 이점을 버리더라도 글의 구성을 제일 심플하게 하고싶었기 때문에 Tag 를 모두 사용하지 않습니다. 좌측 위젯에 프로필에서 Post 개수Category 개수만 보여주도록 Tag 개수는 표기하지 않도록 코드를 삭제하였습니다.

layout/widget/profile.jsx (아래 코드 모두 삭제)
1
2
3
4
5
6
7
8
<div class="level-item has-text-centered is-marginless">
<div>
<p class="heading">{counter.tag.title}</p>
<a href={counter.tag.url}>
<p class="title">{counter.tag.count}</p>
</a>
</div>
</div>
layout/widget/profile.jsx (아래 코드 모두 삭제)
1
2
3
4
5
tag: {
count: tagCount,
title: _p('common.tag', tagCount),
url: url_for('/tags')
}

포스트 상단 시간포맷 변경

icarus 테마는 기본적으로 포스트 상단에 글 최초 작성 이후 얼마나 지났는지, 업데이트 후 얼마나 지났는지 표기해주는데, 개인적으로는 과거 네이버같은 WYSIWYG 블로그에서 표기해주는 포스트 최초 작성일을 날짜 형태로 보는것을 선호해서 이 또한 바꿔주었습니다.

layout/common/article.jsx (time 을 div 로 변경 및 Update Date 삭제)
1
2
3
4
5
6
7
<div class="level-left">
{/* Creation Date */}
{page.date && <span class="level-item" dangerouslySetInnerHTML={{
__html: _p(`<div>${date(page.date)}</div>`)
}}></span>}
{/* author */}
{page.author ? <span class="level-item"> {page.author} </span> : null}

폰트 변경

폰트는 hexo 블로그 처음 시작할때 글들을 모두 한글로 작성할것이라 자간 간격이 아주 조금은 벌어져있는것이 가독성이 좋다고 판단하여 hexo 사용 처음부터 사용해왔던 나눔고딕 폰트를 계속 사용하기로 했습니다.

include/style/base.styl ('Nanum Gothic' 폰트 추가 및 기존 미사용 폰트 삭제)
1
2
$family-sans-serif ?= 'Ubuntu', 'Nanum Gothic', sans-serif
$family-code ?= 'Source Code Pro', monospace, 'Microsoft YaHei'

위젯 & 포스트 너비 재설정

icarus 에서 제공하는 위젯은 (1) 우측, (2) 좌측이 있는데 이 둘을 모두 사용하면 중간에 포스트 너비가 짧아져 가독성을 떨어트릴수있다고 판단했습니다. 좌측 위젯만 사용했음에도 위젯 너비가 포스트 너비에 비해 길다고 생각하여 조율해주었습니다.

  • 4(좌측 위젯) + 8(글) = 12
  • 3(좌측 위젯) + 9(글) = 12

icarus 의 너비 분배는 bulma css 12 셀 규칙을 사용합니다. 기존엔 아래와 같았습니다.

  • 4(좌측 위젯) + 8(글) = 12
  • 8(글) + 4(우측 위젯) = 12
  • 3(좌측 위젯) + 6(글) + 3(우측 위젯) = 12

여기서 본 블로그는 위젯을 포스트의 가독성을 위해 좌측 하나만 사용할것이기 때문에

  • 3(좌측 위젯) + 9(글) = 12
  • 9(글) + 3(우측 위젯) = 12

포스트의 너비는 8에서 9으로 위젯은 4에서 3으로 재정의하였습니다.

layout/common/widgets.jsx (위젯 하나의 너비 4 -> 3)
1
2
3
4
5
6
7
8
9
function getColumnSizeClass(columnCount) {
switch (columnCount) {
case 2:
return 'is-3-tablet is-3-desktop is-3-widescreen';
case 3:
return 'is-3-tablet is-3-desktop is-2-widescreen';
}
return '';
}
layout/layout.js (포스트 너비 8 -> 9)
1
2
3
4
5
6
7
8
9
10
11
12
<div class="columns">
<div class={classname({
column: true,
'order-2': true,
'column-main': true,
'is-12': columnCount === 1,
'is-9-tablet is-9-desktop is-9-widescreen': columnCount === 2,
'is-9-tablet is-9-desktop is-8-widescreen': columnCount === 3
})} dangerouslySetInnerHTML={{ __html: body }}></div>
<Widgets site={site} config={config} helper={helper} page={page} position={'left'} />
<Widgets site={site} config={config} helper={helper} page={page} position={'right'} />
</div>

About 내 위젯, 플러그인 제거

상단 네비게이션바에서 About 을 클릭하시면 저에 대한 개략적인 정보를 아실수있습니다. 또한 개인 Resume 페이지를 따로 만들어서 굳이 Google docs 나 Linkedin 으로 접속하지 않아도 저의 이력을 한눈에 볼 수 있도록 Resume 페이지도 따로 마련해두었습니다.

구 icarus 의 ejs 시절에는 About, Resume 페이지 모두 각각 따로 ejs 페이지가 있었기때문에 해당 페이지만 수정하면 되었었지만, 새 icarus 의 jsx 에서는 포스트에 대한 컴포넌트가 about, resume 등 모든 페이지의 기본 컴포넌트로 사용되고있었습니다. 정적 리스트를 만들어서 특정 페이지에 대해서만 위젯과 플러그인을 표시하지 않도록 필터링 하는 로직을 넣었습니다.

  • 위젯도 위젯이지만 buy me a coffee 가 킬링포인트입니다.
  • About 페이지는 저를 표현하는것만으로 충분합니다.
layout/layout.jsx (About, Resume 여부 조건)
1
const isAboutPage = [ "about/index.html", "resume/index.html" ].includes(page.path);
layout/layout.jsx (좌측, 우측 위젯에 About, Resume 여부 조건 추가)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<Head site={site} config={config} helper={helper} page={page} />
<body class={`is-${columnCount}-column`}>
<Navbar config={config} helper={helper} page={page} />
<section class="section">
<div class="container">
<div class="columns">
<div class={classname({
column: true,
'order-2': true,
'column-main': true,
'is-12': columnCount === 1,
'is-9-tablet is-9-desktop is-9-widescreen': columnCount === 2,
'is-9-tablet is-9-desktop is-8-widescreen': columnCount === 3
})} dangerouslySetInnerHTML={{ __html: body }}></div>
{!isAboutPage && <Widgets site={site} config={config} helper={helper} page={page} position={'left'} />}
{!isAboutPage && <Widgets site={site} config={config} helper={helper} page={page} position={'right'} />}
layout/common/article.jsx (About, Resume 여부 조건)
1
const isAboutPage = [ "about/index.html", "resume/index.html" ].includes(page.path);
layout/common/article.jsx (포스트 하단 모든 플러그인에 About, Resume 여부 조건 추가)
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
{/* Licensing block */}
{!isAboutPage && !index && article && article.licenses && Object.keys(article.licenses)
? <ArticleLicensing.Cacheable page={page} config={config} helper={helper} /> : null}

{/* Tags */}
{!isAboutPage && !index && page.tags && page.tags.length ? <div class="article-tags is-size-7 mb-4">
<span class="mr-2">#</span>
{page.tags.map(tag => {
return <a class="link-muted mr-2" rel="tag" href={url_for(tag.path)}>{tag.name}</a>;
})}
</div> : null}

{/* "Read more" button */}
{!isAboutPage && index && page.excerpt ? <a class="article-more button is-small is-size-7" href={`${url_for(page.link || page.path)}#more`}>{__('article.more')}</a> : null}

{/* Share button */}
{!isAboutPage && !index ? <Share config={config} page={page} helper={helper} /> : null}

{/* Donate button */}
{!isAboutPage && !index ? <Donates config={config} helper={helper} /> : null}

{/* Post navigation */}
{!isAboutPage && !index && (page.prev || page.next) ? <nav class="post-navigation mt-4 level is-mobile">

{/* Comment */}
{!isAboutPage && !index ? <Comment config={config} page={page} helper={helper} /> : null}

지금 보고 계신 이 블로그와 본 포스트는 위 요소들을 모두 커스터마이징한 Icarus 테마로 구성된것입니다. 몇년전에 hexo 나 icarus 를 적용하셨었고, 마이그레이션을 앞두고 계시거나 커스텀하게 수정하는걸 원하시는 분께 본 글이 도움이 되셨으면 좋겠습니다. 이후로 CSS, JSX 에 자잘한 수정이 있을순 있으나 따로 다 업데이트하진 않으려합니다.

ParallelStream 과 HashMap 의 Rehashing 이슈

Single SQL Query -> MSA APIs 전환 시 성능 저하

최근 Monolithic Architecture 구조의 레가시 시스템을 MSA 구조로 바꾸는 리플랫폼을 진행하였었습니다. 기존 레가시 시스템은 여러 서브도메인에 해당하는 테이블들이 단일 쿼리에 수많은 Join 으로 연결되어있어서 “단 하나의 쿼리”를 통해 결과를 얻을 수 있어 성능은 매우 좋았지만, 재사용성 및 유지보수성에 있어서는 최악의 구조를 갖고있었습니다. 예약, 결제, 정산, 상품 등 각 서브도메인들을 서비스들로 나누어서 “다수의 API 호출”들로 요청을 처리하도록 변환하니 재사용성 및 유지보수성은 올라갔지만 SQL Join 을 사용하던 것을 API 로 바꾸다보니 수행의 파편화 및 네트워크 시간에 의해 성능이 저하되어 이 해결이 또 다른 리플랫폼의 챌린지 포인트였습니다.

Java Stream -> ParallelStream 을 통한 성능 개선

단일 쿼리에서는 Join 하나만으로 여러 테이블에 분산된 정보를 하나의 Dto 로 모아서 반환할 수 있습니다. 하지만 각 테이블들을 도메인 기반으로 예약 서비스, 계정 서비스 등으로 나눈다면 간단했던 Join 문은 각각 테이블에 해당하는 다수의 API들을 호출한 뒤, 하나의 Dto 로 Id 기반으로 합치는 작업을 필요로 하게됩니다. 이런 작업에서 Id 기반의 Join 을 프로그램으로 구현할때 저는 개인적으로 성능을 위해 Hash Join 전략과 유사하게 작성하게 되는데 이는 각 API 결과의 HashMap 을 필요로 함을 뜻합니다.

List -> HashMap 변환은 간단하지만, List 결과값이 매우 비대한 경우 각 도메인에 해당하는 테이블별로 HashMap 변환만 하더라도 몇초의 시간을 소비하기 때문에 이 시간을 줄이고자 Stream 에서 ParallelStream 로 변환하는 작업을 거쳤습니다. 이실직고하자면 빠르다는 사실 하나만으로 주니어였던 제겐 왜 안써? 싶은 존재였습니다. 성능은 굉장히 빨라졌고, 긴 시간동안 잘 동작하는 듯 했지만 예상치 못했던 몇 이슈로 다가오게 됩니다.

ParallelStream

ParallelStream 는 Java 8 에서 도입된 멀티스레드 프로그래밍을 매우 쉽게 활용할 수 있게 해주는 도구입니다. 학부때도 멀티스레드가 제일 복잡하고 힘들었었는데, 이걸 단 하나의 코드로 쉽게 사용하게 해준다니 스레드 관리가 불편했던 저에겐 굉장히 매력적으로 다가왔습니다. 또한 타 웹페이지에서 고전적 for-each, stream, parallelStream 성능 비교를 보면 당연하겠지만 말도 안되게 빠른 성능을 제공해주는걸 알 수 있습니다.

ForkJoinPool: ParallelStream 의 Thread 관리

스레드 관리가 쉬워진 이유는 기존에 Java 에서 사용하던 스레드 관리 방식을 확장한 ForkJoinPool 이라는 관리 방식을 사용하기 때문입니다. 이름과 같이 Fork + Join 을 통해 어떤 복잡한 작업도 작은 단위로 세분화하여 여러 스레드들이 나누어 작업한 뒤 완료된 결과를 하나의 결과로 합치게 되는데, 그것이 ParallelStream 의 방식이기도 합니다.

ExecutorService (기존)

  • 1개의 Queue (1: Main Queue)
  • Thread Pool 에서 쉬고있는 Thread 에게 Main Queue 의 작업(Job)을 할당

ForkJoinPool (신규, ‘Fork’ + ‘Join’)

  • 2개의 Queue (1: Main Queue, 2: ExecutorService Queue)
    • ForkJoinPool = Queue 가 추가된 ExecutorService 구현체
  • Thread Pool 에서 쉬고있는 Thread 에게 Main Queue 의 작업(Job)을 할당 후 추가 프로세스가 존재
    • Fork: 해당 Thread 는 할당받은 작업(Job)을 수행가능한 작은 단위의 작업들로 분할
    • Steal: 한 Thread 가 다수의 작업(Job) 부담을 갖게되므로 타 Thread 들이 작업을 나눠서 수행
    • Join: 세분화되어 여러 Thread 에서 수행완료된 작업 결과는 쪼개어졌던 Thread 에서 다시 합쳐 반환

ParallelStream 는 SpliteratorForkJoinPool 기반^1으로 Fork + Join 을 통해 작업을 작은 단위로 분할한 뒤 실시간으로 어느 하나의 스레드에 작업 부담(Workload)가 몰리지 않도록 여러 Thread 들이 작은 단위의 작업들을 서로 나누어서 효율적으로 자원을 사용하게됩니다. 결과적으로는 더 빠르게 결과를 반환하게되며, ParallelStream == 성능으로 인식되는 이유입니다.

HashMap & ParallelStream 사용시 무한루프 이슈

Rehashing

ParallelStream 을 통해 서비스 성능 개선을 이룬 뒤 많은 시간이 지나서 갑자기 해당 서버 인스턴스 CPU 가 75% 를 넘어서서 오랜시간동안 계속 내려오지 않는 온콜이 발생하였었습니다. 점유율이 오랜시간동안 75% 에서 내려오지 않자 무한 루프에 진입한것으로 보여 쓰레드 덤프를 분석해보니 parallelStream 에서 할당된 스레드에서 block 인채 멈춰있는걸 발견하였습니다.

문제의 로직은 ParallelStream 내부에서 HashMap 의 put 함수를 사용한 부분이었습니다.

1
2
3
4
Map<Integer, Boolean> result = new HashMap<>();
sampleList.parallelStream().forEach(each ->
result.put(each.getId(), isSample)
);

간단하게 생각하면 List 가 아닌 Map 이기때문에 주입되는 순서도 상관없고, 값이 잘 들어갈것처럼 보입니다. 하지만 HashMap 는 Rehashing 이 있다는 정말 기초적인것을 놓친 생각이었습니다. HashMap 은 Key-Value Pair 를 주입(put)할 때 아래의 과정을 거쳐 이뤄집니다.

  1. 새로 추가하는 Key 에 대한 Hash 를 생성하고
  2. Hash 테이블 인덱스에 for-loop 를 통해 존재여부 판단 후, 해당 Hash Key 에 포인터를 통해 Value 을 적재하게 됩니다.
  3. 특정 Hash Key 에 포인터로 연결된 Value 개수가 일정 수를 넘으면 Rehashing 을 통해 Hash 인덱스를 나누어 Value 들을 재적재하게 됩니다.

Rehashing: Race Condition

위 과정 중에 2. 새 Value 포인터로 연결3. Rehashing, 두 부분에서는 포인터를 변경하게 되는데 기본 HashMap 의 경우엔 이 포인터 변경 부분이 thread-safe 하지 않습니다. 따라서 다수의 스레드가 2번3번을 동시에 수행한다면 즉, 같은 Hash 인덱스의 포인터를 변경하려 하면 문제가 발생할 수 있습니다. 두 스레드가 같은 Hash Key 에 대한 포인터들을 재설정하는 과정에서 서로 꼬여 포인트간 사이클이 발생하게 됩니다. 2번, 3번 모두 put 실행시 수행되는 로직이고, 여기서 생긴 포인터 사이클에 Hash 테이블 인덱스에 대한 for-loop 존재여부 조회가 들어서면서 무한 루프에 빠진것입니다.

HashMap 과 ParallelStream 를 동시에 사용시 이러한 Race Condition 으로 인한 무한 루프 문제도 있지만, 실제로 정상 수행되더라도 HashMap 에는 몇개의 Key 가 유실되는 경우도 발생합니다. 이 또한 다수의 스레드가 Hash Key 에 포인터로 Value 를 동시에 주입하면서, 몇개만 포인터가 정상할당되고 나머지는 무시되는 문제에서 발생합니다. 이로 인해 새로운 Key 를 10000 개 put으로 주입하였는데, 실제 HashMap 에 저장된 Key 는 10000 개보다 적은 황당한 경우도 발생합니다.

Conclusion

Java 의 ParallelStream 내부에서 thread-safe 하지 못한 어떤 작업, 본 글에서는 HashMap 의 put, 을 수행하면 Race Condition 발생으로 인해 몇개의 thread 작업들이 타 thread 에 의해 무시되게 되어서 예상치 못한 결과를 얻게됩니다. HashMap 의 경우엔 아래의 이슈가 발생합니다.

  • Hash Key 에 연결된 Value 간 포인터 사이클이 발생 후, for-loop 존재여부 조회 시 무한루프
  • 10000 번 put 수행하더라도, 몇 Value 포인터 주입이 무시되어 결과 HashMap 사이즈가 10000 미만

당시까지 ParallelStream 로 말미암아 생긴 이슈들이 많았기에, 온콜 해결을 위해서 서비스 전체 로직에서 ParallelStream 이 사용되는 부분을 모두 걷어내었었습니다. 위 문제를 해결하기 위해서는 HashMap 을 ConcurrentHashMap 으로만 바꾸는것으로도 해결이 가능합니다. 물론 ParallelStream 동작 원리는 SpliteratorForkJoinPool 기반이기 때문에 Divide-and-Conquer 라는 기본 원칙인 분할(split) 과 합치는(merge) 작업에 메모리, CPU 자원 소요 비중이 커질 수 있습니다. 따라서 루프 횟수가 몇 십만, 몇 백만건까지의 유즈케이스가 존재한다면 꼭 스트레스 테스트가 필요할것 입니다.


참조

  1. https://hamait.tistory.com/612
  2. https://blog.naver.com/tmondev/220945933678
  3. http://www.h-online.com/developer/features/The-fork-join-framework-in-Java-7-1762357.html
  4. https://medium.com/@itugs/custom-forkjoinpool-in-java-8-parallel-stream-9090882472db
  5. https://java-8-tips.readthedocs.io/en/stable/parallelization.html#conclusion

Javascript 엔진 개요 및 실행 과정으로 살펴보는 Hoisting 과 Closure

자바스크립트

자바스크립트는 웹 페이지의 세 요소중 하나입니다.

  • HTML: 웹 페이지(문서) 포맷을 정의하는 마크업 언어
  • CSS: 웹 페이지(문서)의 디자인 요소에 대한 언어
  • Javascript: 웹 페이지(문서)와 사용자 사이의 interaction 이벤트에 대한 모든 처리

자바스크립트는 일반 프로그래밍 언어와 동일하게 함수 선언 및 호출를 통해 바로 동기적(Synchronous)으로 실행할수도 있고, 콜백을 통해 특정 이벤트 시점에 비동기적(Synchronous)으로 수행하게 만들수도 있습니다. 실행을 위해서는 개발자가 작성한 자바스크립트 언어를 실행 가능한 언어로 변형하여 실행, 실행 순서 및 메모리를 관리하는 엔진이 필요합니다.

하나의 브라우저HTML/CSS 엔진자바스크립트 엔진으로 구성되어있습니다.

흔히 알고 있는 Chrome, Internet Exploerer, Safari 등 다양한 웹 브라우저마다 각자 자신들만의 HTML/CSS/Javascript 엔진를 갖고 있습니다. 자바스크립트 엔진 중 유명한것이라면 Chrome 브라우저와 node.js 에서 사용되고있는 V8 가 있습니다. 앞으로 설명할 자바스크립트 엔진 및 런타임^9은 이 V8 기준으로 설명할것입니다. 잠깐 앞으로 계속 언급될 자바스크립트 엔진자바스크립트 런타임 용어를 확실히 짚고 넘어가겠습니다. 더 상세한 설명은 소제목 자바스크립트 엔진 및 런타임 에서 하겠습니다.

자바스크립트 런타임은 자바스크립트 동작을 위해 필요로 하는 자바스크립트 엔진을 포함한 API 및 기능의 집합입니다.
자바스크립트 엔진은 좁은 의미로 자바스크립트 인터프리팅 역할을 전담하는것으로 Java 의 JVM 으로 이해하면 됩니다.

예를 들면 V8 자바스크립트 엔진기반의 자바스크립트 런타임으로 우리가 사용하는 Chrome 이 동작되는것입니다.

자바스크립트 = 인터프리트 언어

자바스크립트는 스크립트 언어이자 엔진을 통해 처리되는 인터프리트 언어입니다. 다만, 컴파일 과정을 갖고 있습니다.

자바스크립트 엔진은 일반적인 쉘 스크립트가 한 라인씩 바로 실행되는 인터프리트 언어와는 조금 다른 실행 구조를 갖고있습니다. 먼저, 실행할 전체 함수를 실행 직전에 간단히 변수 및 함수 선언들만 스캔하는 (A) JIT 컴파일 과정을 거쳐, 그 후 (B) 수행 과정의 사이클로 실행^5됩니다. 여기서 (1) JIT 컴파일 과정은 실제 우리들이 흔히 알고있는 C++, Java 와 같은 컴파일 언어에서 중간코드를 만드는 AOT(Ahead-of-Time) 컴파일 과정과는 다릅니다.^7 자바스크립트를 인터프리트 언어라고 알고있었는데 좀 놀랍죠. 이렇게 자바스크립트 엔진에 단순히 컴파일 과정이 있다는 사실만으로 자바스크립트를 컴파일 언어로 언급하기도 합니다만 엄연히 기존 컴파일 언어의 정의와 다르고^8, 자바스크립트 엔진은 함수 실행 시점에 컴파일을 진행하므로 인터프리트 언어입니다.^7

자바스크립트 엔진은 (A) JIT 컴파일 과정(B) 수행 과정 이렇게 두 개로 나뉩니다.
결론적으로 자바스크립트는 컴파일 과정을 가진 인터프리트 언어로 요약할 수 있지 않을까합니다.

자바스크립트 엔진 및 런타임

자바스크립트 런타임은 크게 2 개의 구성요소로 나눠질 수 있고, 개별적으로는 5 개로 나누어 볼 수 있습니다.

  1. 자바스크립트 엔진 = (1) Heap, (2) Stack(Call stack)
  2. (3) Web APIs, (4) Callback queue, (5) Event loop

자바스크립트 엔진(1) Heap 그리고 (2) Stack 만을 의미하며 싱글 스레드로 모든 코드를 수행합니다.. 자바스크립트의 비동기를 학습할때 배우는 (3) Web APIs, (4) Callback queue, (5) Event loop들은 정확히는 자바스크립트 엔진의 구성요소가 아닙니다. 자바스크립트 엔진싱글 스레드로 모든 코드를 수행한다면 동기적 실행밖에 안될텐데 어떻게 비동기를 지원한다는 것일까요? 비동기 지원을 위해 바로 자바스크립트 런타임에서 (3), (4), (5) 세 요소를 추가한것입니다.

자바스크립트 엔진(2) Stack 은 일반 프로그램 언어들의 Stack 과는 다른데요. 타 프로그램 언어들은 함수 실행에 따라 Call stack 에 각 로컬 함수들의 변수 등의 Context 정보들을 다 같이 쌓습니다. 로컬 함수에만 국한된 정보들을 갖는다는 이유로 Context 를 Scope 라고도 부릅니다. 반면, 자바스크립트 엔진도 Call stack 에 함수 호출 순서를 적재합니다만, 변수 및 함수 선언과 할당 정보는 Heap 에 따로 저장히여 Call Stack 에는 본 Heap 에 대한 포인터만 갖고 있습니다. 구체적으로 정리하면 아래와 같습니다.

  1. 자바스크립트 엔진
  • (1) Heap: 각 함수 별 선언 및 할당되는 모든 변수 및 함수를 적재하는 메모리 영역
  • (2) Stack(Call Stack): 함수 실행 순서에 맞게 위 Heap 에 대한 포인터 적재 및 실행
  1. 비동기 지원
  • (3) Web APIs: 기본 자바스크립트에 없는 DOM, ajax, setTimeout 등의 라이브러리 함수들^10. 브라우저나 OS 등에서 C++ 처럼 다양한 언어로 구현되어 제공
  • (4) Callback Queue: 위 Web APIs 에서 발생한 콜백 함수들이 차곡차곡 여기에 적재
  • (5) Event Loop: 위 Callback Queue에 적재된 함수를 Stack 로 하나씩 옮겨서 실행되도록 하는 스레드

자바스크립트 엔진 실행 과정

자바스크립트 엔진은 (A) JIT 컴파일 과정(B) 수행 과정 이렇게 두 개로 나뉩니다.

(A) Compilation Phase

매 함수 실행 시 (자바스크립트 첫 실행 함수는 main() 입니다.) ASTs 생성 및 바이트코드로 변경하고 JIT 컴파일 기법(바이트코드 캐싱을 통해 불필요한 컴파일 시간을 줄이는것)을 위해 프로파일러로 함수 호출 횟수를 저장/추적합니다. 우리가 기억하면 될 것은 본 과정에서 변수의 ‘선언’(선언과 할당 중) 그리고 함수의 ‘선언’**을 **Heap 에 적재한다는것입니다.

자바스크립트 변수의 ‘선언’**은 **var a 입니다. (a = 5 는 ‘할당(Assignment)’입니다.)

자바스크립트 함수의 ‘선언’**은 **function a() 입니다.

Compilation Phase에선 변수 및 함수‘선언(Declaration)’**만 추출하여 **Heap 에 적재합니다.
변수와 함수의 선언을 자바스크립트 실행 이전에 컴파일로 저장하여 실제 실행 시 변수와 함수 선언 여부를 검색합니다.

예를 들어 아래 자바스크립트 파일을 처음 실행하게 되면 파일 전체에 컴파일 과정을 수행하게됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var a = 2;
b = 1;

function f(z) {
b = 3;
c = 4;
var d = 6;
e = 1;

function g() {
var e = 0;
d = 3*d;
return d;
}

return g();
var e;
}

f(1);
  1. 자바스크립트 첫 실행을 위한 main() 함수의 Global Scope (window) 영역을 Heap 에 생성합니다.
    1
    2
    3
    # Global Scope (window)
    -
    -
  2. 변수 선언 var a을 찾아서 Global Scope (window) 영역에 a 를 적재합니다.
  3. 변수 할당 b = 1은 할당이므로 본 영역에 b 를 적재하지 않습니다.
    1
    2
    3
    # Global Scope (window)
    - a =
    -
  4. 함수 선언 function f(z)**을 찾아서 **Global Scope (window) 영역에 f 를 적재합니다.
  5. 함수 적재시엔 f 함수의 바이트코드(blob)에 대한 포인터값을 함께 적재합니다.
    1
    2
    3
    # Global Scope (window)
    - a =
    - f = a pointer for f functions bytecode
    자바스크립트 코드를 첫번째 라인에서 20번째 라인까지 컴파일 과정을 마치면 Heap 구성은 마지막과 같습니다.

(B) Execution Phase

변수‘할당(Assignment)’**과 실제 **함수호출 및 실행합니다.

자바스크립트 변수의 ‘할당’**은 **a = 1 입니다.
a = 1 할당 시 이전 컴파일 과정에서 선언된 변수 a 가 있는지 확인합니다.
만약 존재하지 않는다면 a 변수 ‘선언’과 동시에 ‘할당’하여 적재합니다.

자바스크립트 함수의 ‘호출 및 실행’**은 **a() 입니다.
a() 실행 시 첫번째로, 이전 컴파일 과정에서 선언된 함수 a() 가 있는지 확인합니다.
a() 실행 시 두번째로, Heap 에는 새 함수를 위한 Local Execution Scope 영역을 생성하고, Call Stack 에는 생성된 Heap 에 대한 포인터를 갖는 함수 a() 정보를 적재합니다.
a() 실행 시 마지막으로, 컴파일을 수행하여 본 함수 내 변수 및 함수위 Local Execution Scope 영역에 적재합니다.

Execution Phase에선 변수‘할당(Assignment)’**값들을 **Heap 에 적재하고 함수호출 및 실행합니다.

매 함수 호출때마다 스택에 함수 내 변수 및 함수를 같이 적재하는 스택 베이스 언어과 달리 자바스크립트는 스택에는 함수 호출 순서와 실제 변수 및 함수 정보들은 Heap 에 대한 포인터를 갖습니다. Heap 에 함수 a() 를 위한 Local Execution Scope 는 a() 함수가 호출되기 이전에 Heap 에 존재했던 **Global Scope (window)**에 대한 포인터를 갖고있어서, 엔진 내에서 아래와 같은 처리가 가능합니다.

  • a() 함수 내에서 a = 1 변수 할당 시 먼저 Local Execution Scope 에 a 변수의 선언을 찾고,
    존재하지 않는다면 이전 Global Scope 로 돌아가 검색할 수 있습니다.
  • a() 함수 실행이 끝나게 되면 Call Stack 을 통해 현재 Heap 영역을 Global Scope 로 다시 되돌립니다.

위에서 예시로 살펴본 자바스크립트 파일에 컴파일 과정을 마친 뒤 수행 과정은 아래와 같이 진행됩니다.

  1. 컴파일 이후 아래의 Heap 을 갖고 다시 자바스크립트 파일 코드의 맨 첫번째 라인에서 실행이 시작됩니다.
    1
    2
    3
    # Global Scope (window)
    - a =
    - f = a pointer for f functions bytecode
  2. 변수 할당 a = 2을 찾아서 Global Scope (window) 영역에 변수 a 존재 여부를 확인합니다.
  3. 변수 a 가 존재하므로 해당 a2 를 할당합니다.
    1
    2
    3
    # Global Scope (window)
    - a = 2
    - f = a pointer for f functions bytecode
  4. 변수 할당 b = 1을 찾아서 Global Scope (window) 영역에 변수 b 존재 여부를 확인합니다.
  5. 변수 b 가 선언되어있지 않아 b 선언1 을 할당합니다.
    1
    2
    3
    4
    # Global Scope (window)
    - a = 2
    - f = a pointer for f functions bytecode
    - b = 1
  6. 함수 호출 f(1)**을 찾아서 **Global Scope (window)**영역에서 **f() 선언 여부를 확인합니다.
  7. 함수 f() blob 컴파일 및 수행을 위해 Heap 에 새 Local Execution Scope 영역을 생성합니다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    # Global Scope (window)
    - a = 2
    - f = a pointer for f functions bytecode
    - b = 1

    # Local Execution Scope for f()
    - (hidden) A pointer for previous scope (= Global Scope (window))
    -
    -

f(1) 함수 실행 시 새로이 생성된 Local Execution Scope에 다시 Compilation Phase 과정을 통해 변수와 함수를 적재하게 되고 Execution Phase 과정을 수행하게 됩니다. 또 f(1) 함수 내부에 또 다른 함수가 있다면 이 과정을 계속해서 재귀적으로 반복합니다.

  1. 함수 f() 의 Compilation Phase 과정을 마치면 아래와 같이 됩니다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # Global Scope (window)
    - a = 2
    - f = a pointer for f functions bytecode
    - b = 1

    # Local Execution Scope for function f()
    - (hidden) a pointer for previous scope (= Global Scope (window))
    - z =
    - d =
    - e =
  2. 함수 f() 의 Execution Phase 과정을 마치면 함수 f()변수 할당함수 g() 의 Scope 가 생성되게 됩니다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    # Global Scope (window)
    - a = 2
    - f = a pointer for f functions bytecode
    - b = 3

    # Local Execution Scope for function f()
    - (hidden) a pointer for previous scope (= Global Scope (window))
    - z = 1
    - d = 6
    - e = 1
    - c = 4

    # Local Execution Scope for function g()
    - (hidden) a pointer for previous scope (= Local Execution Scope for function f())
    - e =

    자바스크립트 엔진 특성

Function-level scope: var

자바스크립트 실행은 결국 함수에 따라 (A) 컴파일, (B) 수행이 재귀적으로 이뤄집니다. 처음 자바스크립트 실행 시 main() 함수에 대한 (A), (B) 처리를 시작으로 내부에 새로운 함수 호출이 일어나면 새 함수에 대한 (A), (B) 처리 그리고 또 내부 함수 호출이 있다면 그 함수에 대한 (A), (B) … 이런식으로 처리를 반복하게 됩니다.

특정 함수 내 변수 var 의 선언본 함수 (A) 컴파일에 정의되기 때문에 변수 var 의 scopefunction-level이 됩니다.

if, for 문과 같은 block-level({}) 단위 변수를 위해 ES6 스펙에선 Block-level scope: const, let이 새로 소개되었습니다.

Scope Chain

자바스크립트 엔진 실행 과정에서 살펴보았듯 특정 함수에 대한 (B) 수행 단계에서 변수 할당 시 본 함수의 Heap 영역에 변수 선언이 되어있는지 먼저 검사하게 됩니다. 만약 본 함수 내 변수가 선언되어있지 않았다면 해당 함수의 Heap 에서는 변수 선언을 찾을 수 없게됩니다. 이때 해당 함수가 호출되기 이전의 함수(hidden) A pointer for previous scope 를 통해 올라가면서 해당 함수 Heap Scope 에 변수가 선언되었는지 확인합니다. 어떠한 함수에서도 변수 선언이 되어있지 않다면 가장 처음에 호출된 main() 함수까지 올라가면서 검색하게 됩니다. 함수 호출 스택에 따라 가장 처음의 main() 함수까지 각 함수 Heap Scope 에 변수 선언 존재여부를 연쇄적으로 Chaining 하며 찾기때문에 이를 Scope Chain 이라고 부릅니다.

Variable Hoisting

(A) 컴파일 단계에서 변수를 선언을 먼저하고, 그 다음 (B) 수행 단계에서 변수를 할당하기 때문에 같은 function-level 이라면 아래와 같이 변수 선언과 할당을 나누어서 하더라도 자바스크립트 엔진에서는 변수 선언이 먼저 된 것으로 처리됩니다.

1
2
a = 10
var a;
1
2
# Global Scope (window)
- a = 10

위 예시처럼 var a 선언이 같은 function-level 내에서 최상단에 ‘말려올라간것’처럼 수행되기도 하지만, 만약 함수 내 변수가 선언되어있지 않았다면 Scope Chain 을 통해 main() 함수까지 올라가면서 변수 선언을 찾습니다. 최종적으로 main() 함수 Heap Scope 에도 선언되어있지 않다면 main() 함수 영역에 변수를 선언해주게 됩니다. main() 에서 호출한 어떤 함수이든 Scope Chain 을 통해 방금 선언해준 변수를 바라볼테니 이는 전역 변수인것입니다. (main() 의 Heap Scope 영역 명칭은 Global Scope (window)**이기도 합니다.) 특정 함수내에 변수를 할당하였지만 본 변수는 어느 함수에도 존재하지 않는 변수이기에 **main() 함수까지 ‘말려올라가서’ 전역 변수를 선언한것이 됩니다. 변수 선언이 ‘말려올라갔다’는 의미에서 이 모든 경우를 Variable Hoisting 이라고 표현합니다.

Variable Shadowing

특정 함수의 Heap Scope 에 변수 선언이 되어있다면 해당 변수에 대한 변수 할당은 현재 함수 Heap Scope 에 선언되어있는 변수에 대입됩니다. 만약에 해당 함수를 호출하는 이전 함수에 해당 변수와 똑같은 명칭의 변수가 선언되어있다고 할지라도 현재 함수 Heap Scope 에 이미 존재하기때문에 이전 함수의 Heap Scope 까지 Scope Chain 할 필요가 없습니다. 이전 함수에 같은 명칭의 변수가 있다고하더라도 현재 함수는 그 존재를 알 수도 알 필요도 없기 때문에 이를 Variable Shadowing 이라 부릅니다.

Garbage Collection

함수 직접 수행이 끝나면 Stack 에서 수행 완료된 함수의 정보를 없애면서 Heap 메모리 내 수행 완료된 함수의 Heap Scope 도 없애게 됩니다. 메모리 청소의 의미로 Garbage Collection 이라고 부릅니다. 전체 자바스크립트 파일 실행이 끝나게되면 가장 마지막으로 main() 함수의 Global Scope(Window) 도 사라지게 됩니다. Reference Count 를 통한 Garbage Collection 를 하는 스위프트 언어도 있지만 자바스크립트는 단순히 함수(포인터)의 Reachability 를 기반으로 Garbage Collection 를 수행합니다. 함수 직접 수행이 아닌 함수 수행을 변수에 할당한 경우엔 함수 수행이 끝났다고 하더라도 할당된 변수로 또 함수 수행이 가능하기 때문에 본 함수에 대한 Garbage Collection 를 안하는 경우가 존재하는데 바로 아래서 설명할 Closure 개념입니다.

Closure

자바스크립트 엔진 실행 설명시 다뤘던 예제에서 function f 를 바로 실행하지 않고 var myFunction 를 선언하여 그에 할당해보았습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var a = 2;
b = 1;

function f(z) {
b = 3;
c = 4;
var d = 6;
e = 1;

function g() {
var e = 0;
d = 3*d;
return d;
}

return g;
var e;
}

var myFunction = f(1); // 새로 추가된 코드
myFunction();

함수 호출을 변수에 할당하게 되면 함수의 호출은 일회성으로 호출이 끝나면 사라지는것이 아니라 myFunction 이란 변수를 통해서 계속해서 반복 호출이 가능하기 때문에 f 함수 호출을 위해 생성된 f 함수의 Heap Scope 는 지워질 수 없습니다. 조금 쉽게 생각하자면 f 함수 Heap Scope 에는 f 함수 수행을 위해 넘긴 파라미터 값 1 도 들고있기 때문에 Heap Scope 를 Garbage Collection 할 수 없는것입니다. 이처럼 함수 호출을 변수에 할당하게 되면 f 함수의 Heap Scopef 를 호출한 함수의 Heap Scope파라미터 1 을 기준으로 강하게 묶여있기 때문에 f 함수 실행이 끝났음에도 불구하고 f 함수의 Heap Scope 가 Garbage Collection 되지 않습니다.

Closure 는 함수의 Heap Scope 와 해당 함수를 호출하는 함수의 Heap Scope 를 연결하는것으로, 함수 호출이 끝나더라도 Scope 는 여전히 해당 함수를 호출한 함수의 Scope 에 ‘갇혀있는’ 개념입니다.


1: https://youtu.be/QyUFheng6J0
2: https://www.quora.com/Is-JavaScript-a-compiled-or-interpreted-programming-language
3: https://medium.com/@almog4130/javascript-is-it-compiled-or-interpreted-9779278468fc
4: https://blog.usejournal.com/is-javascript-an-interpreted-language-3300afbaf6b8
5: https://youtu.be/QyUFheng6J0?t=435
6: https://dev.to/genta/is-javascript-a-compiled-language-20mf
7: https://dev.to/deanchalk/comment/8h32
8: https://gist.github.com/kad3nce/9230211#compiler-theory
9: https://blog.sessionstack.com/how-does-javascript-actually-work-part-1-b0bacc073cf
10: https://developer.mozilla.org/ko/docs/Web/%EC%B0%B8%EC%A1%B0/API
11: https://medium.com/@antwan29/browser-and-web-apis-d48c3fd8739

Docker 간단하게 살펴보는 기본 개념

왜 Docker 를 사용하는가?

하나의 서버에 다양한 Application 들을 구동시키려면 여러 VM 들을 올려놓고 각 Application 마다 VM 을 할당해주는 방법도 있지만, Docker 는 각 Application 을 VM 보다 가벼운 Container 단위로 패키징 및 관리를 가능하게 합니다.

Container 는 무엇인가?

VM vs ‘Container’

VM 개념은 단일 Host OS 위에 다수의 Guest OS 를 갖고 각각 Application 을 단일 Guest OS 에 매핑한것인 반면

[ Host OS - [ VM: Guest OS - Libs - App ] ]

Container단일 Host OS 위에 다수의 Application 을 바로 구동할 수 있는 VM 보다 가벼운 단위입니다. Host OS 와 Container 사이 포트 포워딩이나 파일시스템(디렉토리) 연동 등은 후술할 Image 설정으로 가능합니다.

[ Host OS - [ Container: Libs - App ] ]

VM 은 Hypervisor 에 의해 물리적 자원 관리가 된다면 Container 는 Docker 에 의해 논리적으로 자원 분배가 됩니다.

  • VM 은 Hypervisor 에 의한 하드웨어 가상화
  • Container 는 Docker 에 의한 Host OS 가상화

과거 학부때 데모 실행을 위해 멀티 노드 하둡구성시 사용 경험이 있는 LXC(Linux Container) 개념이 Docker 의 초기 버전의 구현이었다고 합니다만 이후 Docker 는 자체 컨테이너를 사용한다고 합니다.

Image and ‘Container’

Docker 를 처음 접하며 명확히 구별하지 못했던 개념이 있습니다. ‘Image’와 ‘Container’입니다. Image 는 VM 에서의 개념과 동일하기에 쉽게 이해하실 수 있습니다.

  • Image 는 Container 구동을 위한 파일시스템과 구동에 필요한 설정들이 모여있는 정적 설정이며,
  • Container 는 위 Image 를 기반으로 실제 구동(Runtime)된 동적 인스턴스라고 보면 됩나다.

왜 Container 를 사용하는가?

Application 단위 관리

Application 단위로 패키징을 가능하게 함으로써 개발 시 역할/책임(R&R)을 분리할 수 있습니다. 웹 서비스를 개발하면 하나의 서버 인스턴스에 다양한 역할들이 들어있는데, 각각 독립된 Container 로 분리할 수 있습니다.

  • nginx: 정적 페이지 제공 및 SPA 프론트엔드
  • tomcat: 프론트엔드에 제공될 API 서버
  • logstash: nginx, tomcat 에서 발생하는 log 들을 log 적재 서버에 전송
  • 온콜(서비스 상태 추적): nginx, tomcat 에서 발생하는 오류 로그 및 CPU, memory 등 자원 상태를 상태 관리 서버에 전송
  • 성능 측정(예, pinpoint): tomcat 에서 타 서버들의 API 콜에 대한 횟수, 지연시간 등을 성능 관리 서버에 전송

즉, 위 예시와 같이 하나의 서버 인스턴스에 총 5개의 Container 가 작동될 수 있습니다.

만약 프론트엔드에 제공할 API 서버뿐만 아니라 외부에서 직접 호출할 수 있는 API 서버를 추가하고싶다면 tomcat 컨테이너를 하나 더 추가하여 총 2개의 tomcat 을 하나의 서버 인스턴스에 두고 사용할 수 있습니다. Java 기반 tomcat 을 Python 기반 django 로 교체할 수 도 있습니다. 프론트엔드를 제공하는 nginx 서버는 그대로 있으면서 API 서버만 교체된것이죠.

각 Application 을 레고 블럭처럼 관리하는건 배포에도 큰 이점이 있습니다. 단지 하나의 컨테이너 버전만 업데이트하고싶다면 해당 컨테이너의 이미지만 다시 받아서 재배포를 진행하면 됩니다. 각 컨테이너마다 버전 관리를 따로 할 수 있는것이죠.

레고 블럭처럼 Application 들을 관리할 수 있다는 장점은 VM 도 갖고있지만, 그보다 더 Container 를 선호하는 이유는 가상화의 레벨이 상위 레벨인 만큼 가볍고(Container = lightweight VM), 위에 설명했듯이 버전 및 배포관리가 이미지로 관리되므로 (1) 이미지 설정과 (2) 배포가 구분되어있어 과정의 자동화가 쉽기 때문입니다. 성능 측면에서도 Container 간 IO 및 네트워크 처리에 있어서 빠르기도 합니다.^1 가상화의 레벨이 로우 레벨인 VM 은 보안 측면에서의 캡슐화가 Container 보다 더 뛰어나다고 하지만, 현재 기술에서는 둘간 얼마나 큰 차이가 있을지 궁금하군요.

이처럼 Docker 로는 Application 이 구동될 환경구동할 이미지를 설정합니다. Application 각각의 자체 설정은 docker 와 별개로 프로젝트 내부에 설정해놓으면 됩니다. 책임 분리인 셈입니다.

Docker 용어(구성요소)

  • Registry = Images storage
    • Image 들을 저장헤놓는 중앙 저장소
    • 일반적으로 배포 파이프라인을 구성하면 최신 소스를 통해 Docker Engine 으로 생성한 tomcat/nginx 이미지를 Registry 에 올린뒤, 해당 이미지로 최종 서버 배포를 진행합니다.
    • 기본 Docker Hub 서버 혹은 회사/개인용 Docker Hub 서버를 만들어서 사용하거나
    • Amazon AWS 에서 제공하는 ECR(AWS EC2 Continaer Registry)를 사용할 수도 있습니다.
  • Image
    • 전에 설명했듯 Container 동작을 위한 파일시스템과 구동에 필요한 설정들이 모여있는 정적 설정입니다.
    • Image 는 RO(Read-Only) 파일시스템의 집합^2입니다. 좀 더 상세한 파일시스템 구조는 다음을 참조^3하세요.
  • Container
    • 위 Image 기반으로 실제 구동(Runtime)된 동적 인스턴스
  • Application/Service = Containers on One host
    • 이를 위해 Docker Compose 를 사용하여 하나의 호스트 머신에서 Containers 를 관리할 수 있습니다.
  • Orchestration = Containers on Multiple hosts(Systems, MSA)
    • 이를 위해 Docker Swarm 를 사용하여 다수의 호스트 머신에서 Containers 를 관리할 수 있습니다.

Docker Engine

(1) Image 생성(2) Container 구동 모두를 담당하는 엔진^4이며 구성은 아래와 같습니다.

  1. 컨테이너 및 이미지 생성을 위한 유저의 입력을 받는 Docker CLI
  2. 컨테이너 구동을 위한 Docker Daemon

Image 생성

Container 는 Image 기반으로 구동되기때문에 원하는 Container 구동에 앞서 원하는 Image 를 먼저 만들어야합니다. 이미지 생성에서 최종 컨테이너 구동까지는 세 절차로 이뤄집니다.

  1. Dockerfile - Dockerfile 작성

Dockerfile 로 원하는 Image 생성에 대한 설정(생성 규칙)을 여러 명령어로 작성합니다. 본 설정을 기반으로 이미지를 생성하고 생성된 이미지를 갖고 추후 컨테이너로 구동하게됩니다. 아래는 간단한 명령어 모음입니다.

FROM: 기본 베이스 이미지를 정의합니다. 가져올 해당 이미지 URL 을 적으면 됩니다.
ENV: 이미지 내 환경변수를 설정합니다. 리눅스 터미널에서 SET_VALUE=3 & echo $SET_VALUE 를 생각하면됩니다.

RUN: 실행할 Shell 명령어를 명시하면 이미지 빌드 시점에서 해당 명령어를 수행합니다.
CMD: 실행할 Shell 명령어를 명시하면 이미지 빌드 완료 뒤 컨테이너가 정상 실행되었을때 해당 명령어를 수행합니다.

EXPOSE: 외부에 열고싶은 Port 를 설정합니다. Container 포트실제 Host 에서 노출할 포트를 연결합니다.
WORKDIF, ENTRYPOINT: RUN/CMD 로 명시한 Shell 을 실행할 디렉토리 위치를 지정합니다.
ADD, COPY: 호스트의 디렉토리나 파일을 이미지에 커밋합니다.
VOLUME: 호스트의 디렉토리나 파일을 이미지에 커밋하지 않고 컨테이너 디렉토리에 연결합니다.

… 더 많은 명령어 및 상세 설명은 공식 Docker 문서를 참조하세요.

  1. Build (docker build) - 이미지 생성

docker build 명령어를 실행하면 가장 먼저, 작성되어있는 Dockerfile 를 Docker Daemon 에게 전달합니다. 그 후 Dockerfile 스크립트 내 매 명령어마다 실행하기 위한 컨테이너를 구동하고, 명령어가 성공적으로 수행된다면 해당 스냡샷으로 이미지를 생성합니다. 아래에서 예시로 살펴볼 docker build 수행 로그를 보면 Docker 는 Dockerfile 내 각 명령어가 실행되는 컨테이너의 ID실행이 끝난다면 실행완료된 컨테이너의 스냅샷으로 생성한 이미지 ID 이 둘을 반환하는걸 알 수 있습니다.

만약에 명령어 수행중에 실패하게 된다면 해당 명령어가 실행되는 컨테이너 ID에 쉘을 통해 접근하여 로그를 확인할 수 있습니다. 이처럼 중간에 반환되는 컨테이너 ID 를 통해 docker build 디버깅이 가능합니다. 그렇다면 Dockerfile 스크립트의 마지막 라인이 실행 완료된 컨테이너의 스냅샷이 최종적으로 우리가 생성할 이미지가 되는것입니다.

  • 2.1. 빌드의 시작은 Dockerfile 를 Docker Daemon 에 전달하면서 시작됩니다.

Docker Daemon 은 Dockerfile 에서 FROM 명령어에 명시된 새로 생성할 이미지의 기반이 될 베이스 이미지를 가져옵니다.

1
2
3
4
5
6
7
$ docker build .

Sending build context to Docker daemon 10240 bytes

Step 1/3 : FROM base-image:1.7.2
Pulling repository base-image:1.7.2
---> e9aa60c60128/1.000 MB (100%) endpoint: https://my-own.docker-registry.com/v1/

개인 Docker Registry 인 https://my-own.docker-registry.com/v1 에서 base-image:1.7.2 이미지를 가져왔습니다. 마지막 라인에 e9aa60c60128는 다운받은 베이스 이미지에 Docker 가 할당한 ID 입니다. 다음으로 수행될 명령어는 이 이미지 기반으로 중간 이미지를 만듭니다.

  • 2.2. 그 다음 명령어는 이전에 생성된 중간 이미지를 다시 컨테이너로 구동하여, 명령어들을 수행한 뒤 스냅샷을 이미지로 반환합니다.
1
2
3
4
Step 2/3 : WORKDIR /instance
---> Running in 9c9e81692ae9
Removing intermediate container 9c9e81692ae9
---> b35f4035db3f

바로 이전에 수행한 FROM 명령어의 결과로 e9aa60c60128 중간 이미지가 생성되었습니다. 본 이미지로 새 컨테이너 9c9e81692ae9 를 구동하였고, 그 내부에서 WORKDIR /instance 명령어를 수행한뒤, 수행 완료된 컨테이너를 내리고 그 스냅샷을 b35f4035db3f 이미지로 반환한것을 볼 수 있습니다.

  • 2.3. 2.2.와 동일합니다. 단, Dockerfile 내 모든 Step 을 마쳤으므로 마지막으로 생성한 스냅샷 이미지가 우리가 최종적으로 얻는 이미지가 됩니다.
1
2
3
4
5
6
Step 3/3 : CMD echo Hello world
---> Running in 02071fceb21b
Removing intermediate container 02071fceb21b
---> f52f38b7823e

Successfully built f52f38b7823e

우리가 얻는 최종 이미지명(ID)을 f52f38b7823e가 아닌 원하는 이름을 붙여주고 싶다면 tag 옵션을 통해 이름을 붙여줄 수 있습니다. 예를 들면 base-image:1.7.2 로 새 이미지를 만들었으니 custom-image:1.7.2 로 이름지어볼 수 있습니다.

  • 2.4. Push (docker push) - 이렇게 만든 이미지를 Docker Registry 에 저장합니다.

Container 구동

생성된 최종 Image 로 Docker Daemon 위에서 Container 구동합니다.

  • 1. Pull (docker pull) - 컨테이너를 구동하기 위해 저정된 이미지를 가져옵니다.
  • 2. Execute (docker run) - 가져온 이미지로 컨테이너를 구동합니다.

Docker 이미지 설정 예시

상품 정보를 저장/조회하는 서비스를 제공하기 위해 프론트엔드 서버nginx(react.js) 로 백엔드 서버tomcat(java) 으로 서비스를 제공하려고합니다. 두 Application 들을 각각 Container 로 총 두 개의 Container 를 하나의 AWS EC2 서버 인스턴스에서 구동하려합니다.

nginx

먼저 nginx image 설정을 보겠습니다. nginx 구동은 쉘 스크립트를 실행하게되는데 직접 만든 replace-hosts-and-run.sh 쉘을 이미지에 주입해서 알맞은 환경변수와 함께 수행하여 최종적으로 nginx 서버를 띄우는것을 목표로 하겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 1. 기본 베이스 이미지를 가져옵니다. 프론트엔드 서버용 nginx 기본 이미지를 받습니다.
FROM http://docker-hub.aaronryu.com/nginx:1.8.0

# 2. nginx 웹 서버에서 다국어 지원을 위한 gettext 를 설치합니다.
RUN apk --no-cache add gettext

# 3. 현재 프로젝트 디렉토리 중 files/, build/, 쉘 스크립트를 이미지 내 지정한 디렉토리에 추가/붙여넣습니다.
ADD files/ /instance/program/nginx/conf
ADD build/ /instance/service/webroot/ui
ADD replace-hosts-and-run.sh /instance/program/nginx/replace-hosts-and-run.sh

# 4. 위 쉘 스크립트(replace-hosts-and-run.sh)에서 사용할 호스트 명 환경변수를 설정합니다.
ENV NGINX_HOST aaronryu.frontend.com

# 5. 로깅 등을 위해 nginx 컨테이너 내 아래 디렉토리를 호스트의 디렉토리에 연결합니다.
# (Container 가 아래 디렉토리에 하는 작업은 실제 호스트의 디렉토리에 반영됩니다.)
VOLUME ["/instance/logs/nginx"]

# 6. '이미지 완료 뒤'에 아까 복사해둔 아래 쉘 스크립트를 위 환경변수와 함께 실행(CMD)합니다.
CMD /instance/program/nginx/replace-hosts-and-run.sh

tomcat

nginx 서버의 SPA 정적 페이지에서 조회 및 저장을 위해서는 그에 맞는 API 가 필요합니다. 이 API 들을 제공하기위한 tomcat 서버를 구동하겠습니다. Java 서버이기에 JVM 에 대한 설정을 추가하고, 외부에서 본 서버의 상태를 조회하기 위해 12345 포트를 열어두겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 1. 기본 베이스 이미지를 가져옵니다. 백엔드 서버용 tomcat 기본 이미지를 받습니다.
FROM http://docker-hub.aaronryu.com/tomcat:8.0.0-jdk8

# 2. tomcat 의 구현은 spring boot 로 되어있습니다. 구동 시 production 프로파일 옵션을 주겠습니다.
ENV SPRING_PROFILE production

# 3. tomcat 은 Java 기반 서버이기에 JVM 메모리 옵션을 추가합니다.
ENV JVM_MEMORY -Xms2g -Xmx2g -XX:PermSize=512m -XX:MAxPermSize=512m

# 4. 현재 프로젝트 디렉토리내 저장되어있는 setenv.sh 을 이미지 내 tomcat 실행 쉘 파일에 추가/붙여넣습니다.
ADD setenv.sh ${CATALINA_HOME}/bin/setenv.sh

# 5. 현재 프로젝트 빌드가 완료된 뒤 생성된 war 파일을 모두 tomcat 실행 webapps 에 추가/붙여넣습니다.
COPY build/libs/*.war "${CATALINA_HOME}" /webapps/ROOT.war

# 6. 설정한 tomcat 서버 포트 8080 을 호스트의 12345 포트에 연결하여 외부에 노출합니다.
EXPOSE 8080 12345

# 7. 로깅 등을 위해 tomcat 컨테이너 내 아래 디렉토리들을 호스트의 디렉토리에 연결합니다.
# (Container 가 아래 디렉토리에 하는 작업은 실제 호스트의 디렉토리에 반영됩니다.)
VOLUME ["/instance/logs/tomcat", "/instance/logs/tomcat/catalina_log", "/instance/logs/tomcat/gc"]

위 예시로 살펴본 각각의 Dockerfile 은 각각 nginx 와 tomcat 프로젝트 내에 위치하게 됩니다. 이 두 컨테이너를 하나의 인스턴스에 동시에 띄우기 위해서는 Docker Compose 설정으로(예, .yml) 설정으로 각 컨테이너의 이미지를 묶어서 명시하면 됩니다.


  1. https://medium.com/@darkrasid/docker%EC%99%80-vm-d95d60e56fdd
  2. https://docs.docker.com/storage/storagedriver/#images-and-layers
  3. https://rampart81.github.io/post/docker_image/
  4. https://www.quora.com/What-is-the-difference-between-the-Docker-Engine-and-Docker-Daemon
  5. https://www.joyfulbikeshedding.com/blog/2019-08-27-debugging-docker-builds.html

한 장으로 보는 정규표현식

중요성

개인적으로 좋아했던 구글 Tech Lead 유튜버가 개발자라면 당연히 알아야할 몇가지 스킬을 업로드^1한적이 있다.

  1. Regular expressions
  2. SQL
  3. Debugging Skills (problem solving).
  4. Tooling language
  5. Anti-Social skill

이 중 오늘의 주제는 가장 첫번째에 언급된 정규표현식이다. 중간에 5번이라는 스파이가 있는듯 한데 개발자는 사실상 코딩보단 말을 많이하는 직업이라 생각해서 그리 좋은 전략은 아닌듯하다. 정규표현식은 학사때도 나중에 공부해야지 하고 메모는 많이 해놓았는데 정작 제대로 외우진 않고 매번 필요할때마다 찾아 쓴듯하다. 최근에 정리하였는데 나름 문법처럼 분류해서 외우면 쉽다. 사실 Regex 는 Tech Lead 말대로 개발하는데 너무 널리 사용된다. 텍스트 검색, 정확히는 패턴 매칭에 사용되는데 검색이라면 아래같이 수많은 유즈케이스들이 있다.

  • grep 을 통한 로그/텍스트 분석
  • 개발하고 있는 코드/디렉토리 검색
  • commit 이전 코드 체킹
  • 웹 크롤링
  • URL 파싱
  • 값/포맷 validation

개요

Regex 는 처음보았을때나 공부하기 전까지는 암호내지 외계어처럼 보이긴 한다. 우리가 흔히 접하는 언어는 semantic 이 word 혹은 그 조합으로 표현되지만, semantic 들이 각각 하나의 charactor 에 매핑되어있는건 암호체계와 동일하기 때문이다. 이것도 syntax 로 분류하면 아래와 같이 나뉘어지는데, 정규표현식을 익히는데 많은 도움이 된다.

기본적으로 특정 단어를 검색하기 위해 정규표현식을 사용하는데, 단순히 찾고싶은 1. 특정 단어를 명시하는 방법도 있지만 2. 글자나 숫자 조합으로써 단어를 명시할수도 있다. 정규표현식은 이에 두 가지 방법을 제공한다.

기본

단어

간단하게 검색하고 싶은 특정 단어만 명시하면 된다.
만약 여러 단어를 한번에 검색하고 싶다면 () 를 사용하여 | 를 통해 다수의 단어를 넣으면 된다.

글자 - 타입

특정 글자를 명시하고 싶을땐 단어와 같은 방식으로 사용하면 되는데 []**를 통해 여러 글자를 찾을수도 있고, **[] 내부에서 확장 표현을 통해 A 부터 Z 까지(A-Z) 규칙을 추가하거나 특정 글자를 제외할 수도 있다.

숫자 글자를 검색하고 싶다면 위에서 배운대로 [0-9] 도 좋지만 ‘숫자’ 글자 타입을 명시하여 검색할수도 있다. 글자의 타입을 명시하기 위한것이 역슬래시(\)며 예를 들면 ‘숫자’ 글자 타입\d 로 표현할 수 있고 ‘숫자가 아닌’ 글자 타입\D 와 같이 대문자로 표기할 수 있다.


확장

앞/뒤

특정 단어 혹은 글자를 찾더라도 글의 가장 앞쪽에 혹은 가장 뒷쪽에 존재하는 것을 찾고 싶을때 사용한다.

횟수

특정 단어 혹은 글자가 몇번 반복된 것을 검색하고 싶은지 명시할 수 있다.

(abc){1} = abc
(abc){1,3} = abc, abcabc, abcabcabc
(abc)? = (공백), abc
(abc)+ = abc, abcabc …

캡쳐

앞부분에서 설명하였듯 패턴으로 검색할 단어를 집합으로 묶을때 사용하거나 검색한 결과물들을 활용하려고 할때 결과값을 저장하는 역할을 한다.


정규표현식은 한번 배워두면 어떤 개발 언어에서든 모든곳에서 범용적으로 사용가능하며, 개발에서 활용할 수 있는 경우의 수가 매우 많아 유용하다. 이렇게 정리함으로써 이젠 매번 검색할일 없이 잘 사용할 수 있을듯하다.