웹 서비스에서 사용자 인증을 구현할 때, 인증 정보를 요청 컨텍스트에 객체(보통 User)로 담아 컨트롤러 같은 api 핸들러에서 쉽게 꺼내쓸 수 있도록 구현하곤 했습니다. NestJS의 경우 @nestjs/passport 에서 AuthGuard와 PassportStrategy 함수를 제공해주어 개발 과정에서 검증 로직만 구현하면 됩니다. 이번 글에서는 Passport 기반 jwt 인증이 구현되는 원리를 상세히 알아보고자 합니다.

 

예제 소스코드는 링크에 있습니다.

1. 필요한 라이브러리 및 모듈 정리

passport

Node.js에서 전략 기반 인증(및 인가) 처리를 지원하는 Passport.js 는 npm 저장소 위클리 다운로드 수백만에 이르는 라이브러리입니다. 보통 유저의 인증은 아이디/패스워드뿐만 아닌 jwt, OAuth2 프레임워크 등 다양한 방식으로 인증하게 됩니다. 이같은 인증 방식들을 인증 전략(strategy)이라고 하며 passport는 하나 이상의 인증 전략 패키지와 함께 설치되어 동작합니다. 가장 대표적인 인증 전략은 아래와 같습니다.

전략명(npm package 명) 설명
passport-local userId, password 기반 인증
passport-jwt jwt 토큰 기반 인증
passport-oauth2 Oauth2 기반 인증

* jwt기반의 인증을 사용할 것이므로 passport-jwt 전략을 선택했습니다.

 

passport는 애플리케이션이 초기화될 때 인증 전략을 등록하고 이후 인가가 필요한 시점에 등록된 인증 전략으로 인증(authenticate())을 수행합니다. 호출 시점에 해당 전략을 찾지 못하면 에러를 발생시키고 프로세스가 종료됩니다.

 

passport/lib/middleware/authenticate.js

function attempt(i) {
      var layer = name[i]; 
      if (!layer) { return allFailed(); }
    
      var strategy, prototype;
      if (typeof layer.authenticate == 'function') {
        strategy = layer;
      } else {            
        prototype = passport._strategy(layer);
        //name 과 일치하는 (passport에서 사용중인) strategy가 없다면 에러발생        
        if (!prototype) { return next(new Error('Unknown authentication strategy "' + layer + '"')); }
        
        strategy = Object.create(prototype);
      }
      ...하략
  }

 

* passport는 express 에서 동작하는 미들웨어로 설계되어 NestJS에서 직접 사용될 일은 없습니다.

 

@nestjs/passport

express와 대비되는 특징으로 NestJS는 DI 컨테이너와 데코레이터 패턴을 기반으로하는 구조화된 시스템으로 설계되었습니다. 이 패키지는 express로 설계된 passport를 NestJS의 구조에서 쉽게 사용할 수 있도록합니다. 두가지 함수가 대표적입니다.

 

1. PassportStrategy

NestJS에서 Passport의 전략을 쉽게 등록할 수 있도록 도와줍니다. PassportStrategy 함수는 믹스인 패턴으로 동적 클래스(StrategyWithMixin)를 만들어 주는데, 이 클래스의 생성자에는 passport에서 인증 전략으로 등록하는 과정이 포함되어 있습니다. 말그대로 전략을 등록해줍니다.

 

@nestjs/passport/dist/passport/passport.strategy.js

function PassportStrategy(Strategy, name, callbackArity) {
    class StrategyWithMixin extends Strategy {
        constructor(...args) {
            ...중략
            //전략을 등록
            if (name) {
                passportInstance.use(name, this);
            }
            else {
                passportInstance.use(this);
            }
        }
        getPassportInstance() {
            return passport;
        }
    }
    return StrategyWithMixin;
}

2. AuthGuard

NestJS에는 인증&인가의 책임을 가진 Guards가 있습니다. passport 전략을 이 Guards와 통합해서 사용할 수 있도록 AuthGuard 를 제공합니다. 동적인 MixinAuthGuard 클래스를 생성하면서 canActive() 메서드를 구현하는데 메서드명에서 알 수 있듯 등록된 전략을 찾아 해당 전략으로 자격증명을 검증합니다.

 

passport-jwt(및 @types/passport-jwt)

PassportStrategy 함수에 인자로 넘겨주어 실제 등록될 인증 전략 인스턴스를 제공합니다. 그리고 http request로부터 jwt를 추출할 수 있도록 ExtractJwt 에서 여러가지 함수를 제공합니다.

 

passport-jwt/lib/extract_jwt.js

//http로부터 토큰을 추출하기 위한 함수 
extractors.fromAuthHeaderWithScheme = function (auth_scheme) {
    var auth_scheme_lower = auth_scheme.toLowerCase();
    return function (request) {

        var token = null;
        if (request.headers[AUTH_HEADER]) {
            var auth_params = auth_hdr.parse(request.headers[AUTH_HEADER]);
            if (auth_params && auth_scheme_lower === auth_params.scheme.toLowerCase()) {
                token = auth_params.value;
            }
        }
        return token;
    };
};

 

정리하면 이렇습니다.

 

@nestjs/jwt

위 소개된 패키지는 모두 유저 요청시 인증하는 것과 관련된 패키지입니다. 이 패키지는 로그인시 jwt 를 발행하기 위한 역할입니다. 따라서 jwt의 만료시간, 시크릿 등 설정을하고 토큰 생성(sign())메서드가 있습니다.

2. 인증 흐름

2-1. NestJS 앱 초기화

AppModule을 시작으로 모듈, 프로바이더 등 데코레이터 패턴으로 등록됩니다. 인증 예제 관련하여 주로 다음 3가지가 있습니다.

2-1-1. JwtStrategy 인스턴스 생성

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
...
}

이때 JwtStrategy가 상속받은 PassportStrategy 함수가 호출되어 전략을 등록(use())하게 됩니다. 앞서 살펴본 @nestjs/passport 의 코드블럭에 해당하는 부분입니다.

 

2-1-2.  JwtAuthGuard 인스턴스가 생성

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

canActivate() 는 AuthGuard에서 제공하는 것을 그대로 사용합니다.

 

2-1-3. @UseGuard()

@UseGuard() 데코레이터는 가변인자로 받은 Guard 들을 검증하고 Reflect로 이 메서드의 메타데이터에 AuthGuard를 등록합니다.

 

2-2. 사용자 로그인

@nestjs/jwt 에서 제공하는 JwtService 로 jwt 토큰을 발행합니다.

 

2-3. 사용자 인증이 필요한 API 호출

사용자가 Authorization 헤더에 JWT를 담아 /users/me GET 요청을 보냅니다. 요청은 ExpressAdapter가 받아 GuardsConsumer 를 통해 이 API에 등록된 가드들의 canActivate() 를 호출하게 됩니다.

 

@nestjs/core/guards/guards-consumer.js

class GuardsConsumer {
    async tryActivate(guards, args, instance, callback, type) {
        if (!guards || (0, shared_utils_1.isEmpty)(guards)) {
            return true;
        }
        const context = this.createContext(args, instance, callback);
        context.setType(type);
        for (const guard of guards) {
            const result = guard.canActivate(context);
            if (await this.pickResult(result)) {
                continue;
            }
            return false;
        }
        return true;
    }
    ...하략
}

 

3. JwtAuthGuard의 canActivate() 메서드 실행

AuthGuard의 canActivate() 를 그대로 호출합니다.

 

@nestjs/passport/dist/auth.guard.js

canActivate(context) {
    return __awaiter(this, void 0, void 0, function* () {
    	//passport 기본옵션과 사용자 옵션을 병합한 options
        const options = Object.assign(Object.assign(Object.assign({}, options_1.defaultOptions), this.options), (yield this.getAuthenticateOptions(context)));
        const [request, response] = [
            this.getRequest(context),
            this.getResponse(context)
        ];
        const passportFn = createPassportContext(request, response);
        const user = yield passportFn(type || this.options.defaultStrategy, options, (err, user, info, status) => this.handleRequest(err, user, info, context, status));
        request[options.property || options_1.defaultOptions.property] = user;
        return true;
    });
}

실행 컨텍스트로 부터 request, response를 가지고와 createPassportContext()를 통해 인증 함수를 생성합니다.

 

3-1. createPassportContext

제가 사용중인 @nestjs/passport:9.0.3 버전에서 createPassportContext 함수는 다음과 같습니다.😂

const createPassportContext = (request, response) => (type, options, callback) => new Promise((resolve, reject) => passport.authenticate(type, options, (err, user, info, status) => {
    try {
        request.authInfo = info;
        return resolve(callback(err, user, info, status));
    }
    catch (err) {
        reject(err);
    }
})(request, response, (err) => (err ? reject(err) : resolve())));

한눈에 들어오지 않아서 다음과 같이 변환했습니다.

function createPassportContext(request, response) {
    return function lambda(type, options, callback) {
        return new Promise((resolve, reject) => passport.authenticate(type, options, (err, user, info, status) => {
            try {
                request.authInfo = info;
                return resolve(callback(err, user, info, status));
            }
            catch (err) {
                reject(err);
            }
        })(request, response, (err) => (err ? reject(err) : resolve())));        
    };
}

createPassportContext 는 lambda 라는 함수를 리턴합니다. lambda는 제가 임의로 넣은 함수명입니다.

lambda 함수는 프로미스를 리턴합니다. 이 함수 호출시에는 yield 키워드로 resolve 이후 동작을 보장합니다.

프로미스 내부에서 passport.authenticate 를 호출하는데 이는 커링이 적용된 비동기 함수입니다. 호출시 passport.authenticate 내에서 next()로 호출할 callback 도 같이 넘겨줍니다.

 

3-1-1. passport.authenticate

옵셔널 인자 처리, name 여부를 체크하고 인증을 수행합니다.

 

passport/lib/middleware/authenticate.js

module.exports = function authenticate(passport, name, options, callback) {
  //인자 개수에 따라서 조정
  if (typeof options == 'function') {
    callback = options;
    options = {};
  }
  options = options || {};
  
  var multi = true;
  if (!Array.isArray(name)) {
    name = [ name ];
    multi = false;
  }
  
  return function authenticate(req, res, next) { //인증 수행... }
}

등록된 인증 전략을 찾아 인증을 수행합니다. 여기에서는 passport-jwt에 정의된 Strategy로 수행하며, 이 로직에는 JwtStrategy 의 validate() 함수를 호출하여 인증 성공을 판단하는 부분이 있습니다.

 

promise resolve 되면, lambda 함수를 호출할 때 넘겨준 callback이 호출됩니다. 이는 AuthGuard에서 제공하는 handleRequest 로 passport 결과가 err거나 user객체가 없을 때 UnauthorizedException을 발생시킵니다.

canActivate(context) {
...
const user = yield passportFn(type || this.options.defaultStrategy, options,
//resolve 되면 호출될 콜백
(err, user, info, status) => this.handleRequest(err, user, info, context, status));
...
}

handleRequest(err, user, info, context, status) {
    if (err || !user) {
        throw err || new common_1.UnauthorizedException();
    }
    return user;
}

getRequest(context) {
    return context.switchToHttp().getRequest();
}
...

*handleRequest() 를 JwtStrategy 클래스에서 재정의하여 에러 분기를 상세하게 할 필요가 있습니다. 예를들어 인증 전략인 jwt는 보통 refresh token 방식으로 구성하기 때문에 클라이언트에서는 인증 실패 에러가 토큰 만료에 의한 에러인지, 페이로드 조작 등 토큰자체의 검증 실패인지 구분해야할 필요가 있습니다.

*함께 정의된 getRequest() 도 마찬가지로, RESTAPI를 기본으로 하지만, GraphQL 등 타 프로토콜의 경우 재정의하여 사용하기도 합니다.

 

3-2. request 의 프로퍼티로 저장

passportFn()이 실행되어 user 객체를 가져오고, 최종적으로 request에 저장됩니다. 보통 User 이지만 실무에서 서비스상의 User와 혼동되는 경우 다르게도 사용했던 것 같습니다.

 

4. 컨트롤러에서 user 정보 가져오기

파라미터 데코레이터 @Request() 등으로 실행 컨텍스트의 request를 쉽게 가져올 수 있습니다. 이 안의 user로 비즈니스 로직을 수행합니다.

3. 회고

NestJS에서 Passport를 활용한 인증은 모듈화 및 DI 컨테이너를 활용한 확장성이 뛰어납니다. 기본적인 JWT 인증에서부터 세션, OAuth, RBAC까지 다양한 방식으로 확장할 수 있고, 내부적으로 Passport의 authenticate()를 NestJS의 Guard 시스템과 통합하여 효율적인 인증을 제공하는 세세한 과정을 보게되어 흥미로웠습니다.

 

또한 인증과 별개로 npm 패키지 소스를 분석하며 NestJS의 데코레이터 패턴을 사용하는 동작 방식과 그 구현의 핵심인 리플랙션, 함수형 프로그래밍, 프로토타입 기반의 상속 등 좋은 경험이 된 것 같습니다. 

 

AWS 공인 개발자 어소시에이트에 합격하여 간단한 후기를 남깁니다.
AWS 서비스에 대한 내용보다 시험 자체적인 정보 위주의 후기입니다.

 

0. 타 자격증 난이도 비교

보유 중인 자격증끼리 주관적인 난이도 비교

DVA-C02 >> 컴활 1급 > 정보처리기사 >> ADSP >> SQLD

1.  DVA-C02 공부 동기

지금까지 클라우드 서비스들을 다룰 땐 어떤 서비스를 어떻게 사용할 것인지 정해지고나서 구현을 담당했습니다. 최근 AWS 관리자 권한을 받게 되면서 그동안 비즈니스 요구사항만으로 알맞는 클라우드 서비스 도입하거나 구축하는 고민은 부족했던 것 같습니다. 이번 기회에 AWS 서비스들의 목적, 다양한 상황에서의 워크로드를 공부해 보며 자격증을 취득하고자 했습니다.

 

AWS 자격증 목록

어느정도 AWS 구축/운영을 경험해 본 개발자분들이 적당한 AWS 자격증을 준비할 때 SAA-C03(솔루션즈 아키텍트) 또는 DVA-C02(디벨로퍼) 중 하나를 추천드립니다. 둘은 비슷한 난이도에 상당 내용이 겹치지만 저는 아래와 같은 항목에서 차이를 고려하여 DVA-C02로 결정하였습니다. 다만 개발자분들도 SAA-C03를 더 많이 취득하시는 것 같습니다.

 

초점

  • SAA-C03은 전체적인 클라우드 아키텍처 설계와 최적화에 중점을 둡니다.
  • DVA-C02는 애플리케이션 개발과 AWS 서비스 통합에 중점을 둡니다.

대상 직무 

  • SAA-C03은 솔루션 아키텍트나 클라우드 엔지니어를 대상으로 합니다.
  • DVA-C02는 소프트웨어 개발자를 대상으로 합니다.

시험 내용 

  • SAA-C03은 아키텍처 설계와 시스템 최적화에 관한 시나리오 문제를 포함합니다.
  • DVA-C02는 코드 예제와 애플리케이션 개발 관련 문제를 포함합니다.

 

공부 기간은 6주로 계획했습니다. 실무에서 사용할 주요 서비스들은 시간을 가지고 실습하고 이해하면서 준비했습니다.

2. 공부 방식 및 준비 과정

이미 여러 블로그에 있는 것처럼 인강 & 덤프로 준비하였습니다.

인강

- 가장 유명한 유데미 강의 로 하였습니다. 상시 할인 중이니 만원대로 결제하면 좋을 것 같습니다. 이미 아는 서비스들은 제외하여 최종 70% 정도 수강하였으며 모든 챕터의 문제와 모의고사까지 3회씩 풀었습니다.

- 시험 범위 내 모든 서비스들의 이론+실습 구조로 실습하기 어려운 환경에서도 이해될 수 있게 잘 구성되어 있었습니다. 강의가 꽤 길지만 추후 언제든지 레퍼런스로 참고할 수 있어서 추천합니다.

 

덤프

- examtopics 80문제 정도는 무료지만 이후 덤프들은 결제를 해야 했습니다. 총 2회 풀고 2회 차 때 틀린 문제는 3번 봤습니다.

- 문제의 형태와 특유의 번역체에 익숙해지는데 도움이 되었습니다. 조금 비싼 감이 있지만 이 또한 강력 추천합니다. 문제를 반복해서 풀다 보니 문제에 특정 키워드로 정답의 핵심 키워드를 유추하게 되었습니다.

 

자격증 자체의 난이도와 더불어 시험환경과 같은 외부요인 때문에 더 난이도가 올라가는 것 같습니다.

 

시험 난이도

이미 알고 있던 AWS서비스들은 10개 내외였습니다. 웹 개발 위주의 EC2, lambda, S3, Cloudfront, Route53,... 등 입니다. 이 자격증에서는 이외에도 서버리스, 암호화, 메트릭 수집/로깅, 형상관리 CI/CD, IaC, 스트리밍, AI분석 서비스 등 총 30여 개 서비스에 대한 실습 수준의 지식이 필요합니다. 따라서 아직 관심이 크지 않은 서비스더라도 실습해보아야 했습니다. 게다가 이 익숙하지 않은 서비스들이 비슷한 기능을 가지고 있다면 더욱 헷갈리는데, 차이점과 목적으로 나누어 정리하며 구분했습니다. (대표적으로 SAM과 CDK 그리고 CloudFormation 🙃)

 

외부요인

클라우드 자격증시험이 처음이다 보니 환경 자체에도 어려움이 많았습니다.

1. 온라인으로 시험 보게 되었는데, 감독관과 어느 정도의 영어 의사소통을 해야 합니다. 시험 응시가 가능한 환경을 만들고 실시간으로 대화하며 요구사항에 응해야 합니다. 음질이 좋지는 않았지만 다행히 챗으로도 가능했습니다.

 

2. 부자연스러운 번역입니다. 문제 푸는 속도차이가 있어서 한국어로 신청했지만 번역이 모호했던 부분이 있었습니다. 문제는 원문이 같이 제공되어 중간중간 원문으로 풀었습니다.

 

3. 덤프의 답이 틀린 것이 종종 있었습니다. 제일 많은 유저들이 선택한 답이 정답인 경우가 많았으며 심지어 그 유저끼리도 갈리는 문제가 있었습니다. 문항 수는 수백 개인데 모두 문서 하나하나 찾아가며 검증할 수 없어서 유저끼리 갈리는 문제나 아예 풀지 못 한 문제 위주로만 문서를 찾아보았습니다.

 

4. 시험 일정이 까다롭습니다. 외국 주관 자격증이고 스케줄 된 시험일정에 응시자가 선택해서 예약합니다. 저는 운이 좋게도 이른 오전 시험이었는데, 다른 후기들을 보면 새벽 5시 시험이나 자정즈음 등 다양했습니다.

 

 

3. 회고

꽤 느슨한 기간을 가지고 준비했고 시험 자체의 긴장만큼 시험을 보는 환경에도 부담이 있었습니다. 그동안 국내 자격증 시험을 정말 편하게 봐왔구나 싶었습니다. 😂 다행히 대부분 익숙했던 문제들이었습니다. 생각보단 준수한 점수로 합격하게 되었습니다. (871/1000)

 

주위 동료분들께 AWS 자격증을 준비한다고 했을 때 유독 다른 주제보다 부정적인 반응이 많았습니다. 시험 자체의 비용 뿐만 아니라 준비 과정에서의 부대비용도 만만치 않다 보니 다양한 의견이 있는 것 같기도 합니다. 그러나 AWS 지식 필요성 자체를 부정하는 사람은 한 명도 없었습니다. 그래서 처음 취득 동기와 같이 서비스 공부를 목적으로 하고, 자격증은 따라온다는 생각으로 공부하였습니다. 자격증 이전 제 AWS 지식수준과 현재를 비교해 보면, 잘 도전했고 잘 취득했다고 생각합니다.

 

+ 저는 정가(20만 원...)에 시험 봤지만 때때로 있는 AWS 교육 이수 하면 50% 할인 쿠폰이 나온다고 합니다.

특정 타입 E의 컬렉션을 인자로 받아 최댓값을 반환하는 max 라는 제네릭 메서드를 만든다면, 선언부는 아래와 같이 설계할 수 있습니다.
public static <E extends Comparable<? super E>> E max(Collection<? extends E> c) { ... }
관련 내용을 정리해보았습니다.

 

Collection

객체나 값 여러개를 한 곳에 담아서 연산할 때가 많습니다. 자바에서는 연산의 특성에 따라 다양한 컬렉션 인터페이스(List, Queue, Set, Map 등)들을 제공합니다. 이 인터페이스들은 공통 적으로 Collection 인터페이스를 확장합니다. 다음은 일부입니다.

public interface Collection<E> extends Iterable<E> {
	int size();
	//... 생략
}

이 인터페이스의 구현체들은 크기를 재는 기능이 필수입니다. 전체 컬렉션을 순회하기 위해 몇번을 반복해야 할 지, 현재 컬렉션이 비어있는지 체크 등 크기를 아는 것 외에도 다양한 기능의 기본 기능으로 필요할 것 같습니다. 그래서 Collection의 구현체는 항상 size() 메서드를 구현해야 합니다. 마찬가지 이유로 Collection의 구현체들은 size() 말고도 다양한 기능들을 구현해야 합니다.

 

Collection<E> - 제네릭

Collection 인터페이스가 Collection<E> 와 같은 모양을 갖는 이유는 타입안정성 때문입니다.

Collection 에는 여러 객체를 담고 싶지만 의도한 타입의 객체가 들어오지 않을 수도 있습니다.

아래 예시는 정수 배열의 총합을 출력하는 addAll() 메서드 예시입니다.

private static int addAll() {
    List list = new ArrayList();
    list.add(1);
    list.add(2);
    list.add("3");

    int sum = 0;
    for (int i = 0; i < list.size(); i++) {
        int o = (int) list.get(i);
        sum += o;
    }
    return sum;
}

어떤 실수로 인해 "3"이라는 문자열이 리스트에 추가되었습니다. 코드는 정상적으로 컴파일되겠지만, int 캐스팅과정에서 런타임 에러가 날 것입니다.

컴파일 단계에서 이 오류를 잡기 위해 integer 타입만을 담는 IntegerList 같은 새로운 Collection 구현체를 만들어 해결이 가능합니다. 아마 IntegerList의 add() 메서드는 파라미터로 Integer 타입을 받을 것입니다.

하지만 이렇게 하면 리스트에 담길 타입별로 리스트를 각각 따로 만들어주어야 합니다. 문자열을 담는 StringList, 정수만 담는 IntegerList 등등입니다.

그래서 타입을 강제하면서 여러 타입에 대해 적용가능한 클래스와 메서드를 만들기 위해 Generics가 등장하였습니다.

 

Collection 인터페이스의 구현체는 선언할 때 Collection에 담길 요소들의 타입을 명시해 줍니다.

아래 List<Integer> 와 같이 사용합니다. 제네릭 타입에는 원시타입이 사용될 수 없기 때문에 int 대신 Integer 래퍼타입으로 명시하였습니다.

아래 코드는 Collection에 선언된 타입과 다른 요소가 추가되어 컴파일 에러가 발생합니다. 개발자는 자신의 실수를 최대한 빨리 알아챌 수 있습니다.

private static int addAll() {
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(2);
    list.add("3");//compile error 배포 전 수정 가능

    int sum = 0;
    for (int i = 0; i < list.size(); i++) {
        Integer i1 = list.get(i);
        sum += i1;
    }
    return sum;
}

 

Comparable

직역하면 '비교가능한'이란 인터페이스입니다. 이 인터페이스에는 compareTo라는 단 하나의 기능만이 정의되어 있습니다. 이 인터페이스의 compareTo 메서드를 통해 이 인스턴스와 T 타입의 인스턴스간 서로의 우위를 결정할 수 있는 로직을 구현합니다.

public interface Comparable<T> {
    int compareTo(T var1);
}

 

이 인터페이스를 구현하게 되면, 구현한 타입 인스턴스는 T타입의 인스턴스와 비교가능해야 합니다.

다음은 Integer 클래스에서 관련 부분입니다.

public final class Integer extends Number implements Comparable<Integer> {
    public int compareTo(Integer anotherInteger) {
        return compare(this.value, anotherInteger.value);
    }

    public static int compare(int x, int y) {
        return x < y ? -1 : (x == y ? 0 : 1);
    }
}

Integer 클래스는 Comparable<Integer> 를 구현했습니다. 즉 Integer 타입인스턴스 Integer 타입의 인스턴스와 비교가능합니다. 아래 예시와 같습니다.

Integer integer = 1;
integer.compareTo(2);

 

 

와일드카드 '?'

Generic을 활용하여 다양한 타입을 지원하고 한 번 정해진 타입을 강제할 수 있게 되었지만 아직 고민할 부분이 남아있습니다. 아래와 같은 경우입니다.

import java.util.ArrayList;
import java.util.List;

interface Animal {
    void makeFriends(List<Animal> animalList);
}

class Dog implements Animal {
    @Override
    public void makeFriends(List<Animal> animalList) {
    }
}

public class Main {
    public static void main(String[] args) {
        Animal animal = new Dog();
        List<Dog> dogList = new ArrayList<>();
        dogList.add(new Dog());
        animal.makeFriends(dogList);
    }
}

Animal 끼리 친구를 맺을 수 있는 makeFriends() 메서드가 있습니다. Animal은 같은 Animal 타입의 리스트를 받아 친구를 사귈 수 있습니다. 19라인의 animal.makeFriends(dogList); 는 언뜻 보면 문제가 없어 보입니다. Animal의 하위타입 Dog 리스트를 인자로 하여 makeFriends() 메서드를 호출했습니다.

그러나 이 코드는 List<Animal> 이 들어갈 자리에 List<Dog>가 들어왔다는 IDE 경고와 함께 컴파일 에러가 납니다.

간단히 말하면 Dog는 Animal의 하위타입이지만 List<Dog> 는 List<Animal>의 하위타입이 아닌 완전 별개의 타입이기 때문입니다.

주어진 요구사항은 Animal과 그 하위타입까지 받을 수 있는 List를 인자로 받고 싶습니다. 이를 위해 와일드카드 문자 '?'가 있습니다.

'?'는 상/하위 타입을 유연하게 처리하기 위한 특수한 매개변수화 타입으로, makeFriends() 메서드의 매개변수 부분을 다음과 같이 바꾸어 의도한 대로 동작하게 변경하였습니다.

interface Animal {
    void makeFriends(List<? extends Animal> animalList);
}

이로써 Animal 하위타입의 List를 인자로 받는 메서드로 변경되었고 잘 작동하게 되었습니다.

'? extends Animal'은 Animal 및 하위타입이란 뜻이고 '? super Animal'은 Animal 및 상위타입이라는 뜻입니다.

 

PECS

위 같은 상황을 피하기 위해서 파라미터를 유연하게 만들자는 취지의 PECS 원칙이 있습니다.

PECS(Producer-Extends, Consumer-Super)는 매개변수화 타입 T가 생산자면 extends를 쓰고 소비자면 super를 사용하는 원칙입니다.

 

외부로부터 컬렉션 매개변수를 받아 로직을 수행하는 메서드가 있을 때 이 매개변수를 생산자(producer)라고 합니다. 

생산자의 매개변수화 타입은 그냥 <E>가 아닌 <? extends E>와 같이 사용합니다.

 

반대로 이 클래스의 멤버에서 또는 새로 인스턴스를 생성해서 매개변수 컬렉션으로 소비할 때, 이 매개변수를 소비자(consumer)라고 합니다.

소비자의 매개변수화 타입은 그냥 <E>가 아닌 <? super E>와 같이 사용합니다.

아래 print()와 addDefaultValuesToList()가 각각의 예시 메서드입니다.

import java.util.List;

public class ListProcessor<E> {

    void print(Iterable<? extends E> producer) {
        producer.forEach(el-> System.out.println(el));
    }

    void addDefaultValuesToList(List<? super String> consumer) {
        consumer.addAll(List.of("init1","init2"));
    }
}

 

메서드에 선언된 매개변수화 타입

간략하게 짚고 넘어가겠습니다. 매개변수화 타입은 메서드에도 선언될 수 있습니다. 문법은 다음과 같습니다. 

import java.util.List;

public class MyClass {
    <V> V method(V v) { //접근제어자(생략) <매개변수화타입> 리턴타입 메서드명 (매개변수) {
        return v;
    }
}

 

max() 메서드 이해하기

드디어 이해를 위한 기반지식을 모두 갖추었습니다. 전체 max() 메서드는 아래와 같습니다.

구현부는 간단합니다. 비어있지 않은 <? extends E> 타입 컬렉션의 각 요소들에서 최우선 순위의 요소 E를 리턴합니다.

public static <E extends Comparable<? super E>> E max(Collection<? extends E> c) {
    if (c.isEmpty()) throw new IllegalArgumentException("empty collection");
    E result = null;
    for (E e : c) {
        if (result == null || e.compareTo(result) > 0) {
            result = Objects.requireNonNull(e);
        }
    }
    return result;
}

 

 

메서드에 선언된 매개변수화 타입부터 보겠습니다. <E extends Comparable<? super E>> 입니다.

하나씩 해석하기 위해 우선 PECS 원칙에 의한 와일드카드를 제거해 보겠습니다. 'E extends Comparable <E>'이 형태는 Comparable 단락에서 Integer 클래스에서 본 형태와 비슷합니다.

이 매개변수화 타입 E는 자기 자신 E타입의 Comparable을 구현한 타입, 즉 자기와 같은 타입의 다른 인스턴스를 비교할 수 있는 타입입니다. max라는 의미를 구현하기 위해서는 컬렉션의 각 요소끼리 비교가 가능해야 하므로 자기 자신의 Comparable 구현이 필수적입니다.

E 들어갈 수 있는 클래스는 아래와 같은 모습일 것입니다. EChild 클래스와 EParents 클래스 모두 E가 될 수 있습니다.

(참고 1) Comparable의 매개변수화 타입은 Comparable<? super E>를 나타내고자 Object로 하였습니다.

(참고 2) compareTo()의 구현부는 임시로 작성되었습니다. 이 부분에 들어가는 로직은 '최댓값'의 정의에 따라 달라집니다.

public class EParents implements Comparable<Object>{
    @Override
    public int compareTo(Object o) {
        return 0;
    }
}

class EChild extends EParents {}

 

이제 max() 메서드의 매개변수 부분을 보겠습니다. 

Collection<? extends E>입니다. PECS 원칙을 지켜 작성되어 있습니다. 위 Collection<E> 및 와일드카드 단락에서 본 내용과 같습니다. E 타입의 하위타입 컬렉션을 모두 매개변수로 받을 수 있습니다.

 

리턴타입은 E입니다. 결국 E 컬렉션을 받아 E 타입 요소를 하나만 리턴하면 해당 메서드는 모두 구현된 것이라고 할 수 있습니다.

적용 예시는 다음과 같습니다. Collection으로 사용된 List에 EChild, EParents 둘 다 적용이 가능하며 두 타입이 섞여있어도 가능합니다.

개발자는 compareTo()에 적당한 비교 로직을 담아 'max'라는 의미에 맞는 하나의 E를 리턴하도록 하면 됩니다.

public class Main {

    public static void main(String[] args) throws Exception {
        List<EParents> eParents = List.of(new EParents(), new EParents());
        List<EChild> eChildren = List.of(new EChild(), new EChild());
        EParents max = max(eParents);
        EChild max1 = max(eChildren);
    }
    
    public static <E extends Comparable<? super E>> E max(Collection<? extends E> c) {
        if (c.isEmpty()) throw new IllegalArgumentException("empty collection");
        E result = null;
        for (E e : c) {
            if (result == null || e.compareTo(result) > 0) {
                result = Objects.requireNonNull(e);
            }
        }
        return result;
    }
}

 

 

회고

public static <E extends Comparable<? super E>> E max(Collection<? extends E> c) { ... }

를 풀어서 정리하면 아래와 같습니다.

max 메서드의 매개변수화 타입은 E타입이다. E는 E 또는 그 상위타입의 Comparable을 구현했다. 그러므로 CompareTo() 메서드를 통해 서로 비교가 가능한 타입이다. max 메서드는 매개변수로 이 E 또는 E의 하위타입의 컬렉션을 매개변수로 받고 리턴으로 E타입을 반환한다.

PECS 원칙을 비롯한 매개변수화 타입은 자바 API 등에서 매우 자주 보이는 형태로 이해하고 문서를 읽는 습관을 갖겠습니다

시간복잡도  의 개념을 소개하고자 합니다. 이 글에서 추가되었습니다.
모든 예시는 현대 컴퓨터의 최적화된 알고리즘과는 다를 수 있지만 이해를 위한 개념적인 예시입니다.

 

기본 개념잡기

두 수 a, b의 합, 123 + 456 을 연산하는 과정을 생각해 보겠습니다.

각 자리 끼리 더하여 결과를 계산합니다.

각 자리를 더하는 과정은 컴퓨터의 계산이 필요한 부분으로 이를 연산이라고 합니다. 위 과정은 3번의 연산을 수행하게 됩니다. 이 3은 입력값의 자릿수와 같습니다.

만약 자릿수 n이 커지면 커질수록 답을 도출하는 연산도 많아집니다. 연산이 많아진다는 것은 결과 도출 시간도 오래 걸린다는 얘기입니다. 1자리 수 덧셈보다 20자리 수 덧셈이 더 오래 걸리는 것과 같습니다. 1자리 수 덧셈에는 1번의 연산, 2자리 수 덧셈에는 2번의 연산,..., n 자릿수 연산에는 n번의 연산이 필요합니다. 

일반화하면 'n자리 두 수의 각 자리를 더하여 결과를 도출하는 알고리즘은 n번의 연산이 필요하다.'라고 표현할 수 있습니다.

이를 간략히 이 알고리즘은 시간복잡도(Time complexity)가 O(n)라고 합니다. 시간복잡도가 왜 시간복잡도인지는 알겠는데 O(n)은 표현이 다소 어색합니다.

빅 오 표기법(big-O notation)

빅 오 표기법(big-O notation)이라고 불리는 이 표기법은 변화하는 n에 대해 n이 무한대로 커질 때의 근사한 연산 횟수를 표기하는 방법입니다. 위의 덧셈 예시에서 한 가지 슬쩍 넘어간 부분이 있는데 바로 자릿수 올림입니다. 123 + 456은 다행히도 각 자리의 덧셈에서 자릿수 올림되는 경우가 없었습니다. 하지만 123 + 999처럼 자릿수 올림이 발생하는 경우에는 올림에 대한 연산을 추가로 수행해주어야 합니다. 이 경우 기존 3번의 연산과 올림에 대한 연산 3번이 추가로 수행될 것입니다.

자릿수 올림 연산 횟수는 주어진 입력값에 따라 달라집니다. 올림 연산의 횟수를 a라고 했을 때, n자릿수 연산을 일반화하면 n+a (0 ≤ a ≤ n) 번의 연산이 필요하다고 할 수 있겠습니다.

여기서 극한의 개념이 등장합니다. n이 작은 경우에는 a번 연산들이 전체 연산 횟수에 유의미한 차이를 만들지만 n이 커질수록 n번의 연산이나 n + a번의 연산이나 연산 횟수에 큰차이가 없게됩니다. 결국 연산의 횟수는 n으로 근사하게 되고, 이 값을 표현하기 위해 빅 오 표기법을 사용합니다. 표기는 O(n)과 같습니다.

 

이해를 위해 간략한 예시를 몇가지 들어보겠습니다. 

1. O(1) - 선형 시간 복잡도

주어진 두 수의 두번째 자리까지의 덧셈 결과를 반환하는 함수가 있다고 가정해보겠습니다.  자리수 n이 아무리 커져도 항상 일정한 2 + a번연산 횟수를 가지게 됩니다. (= 연산에 걸리는 시간도 항상 일정합니다.) 이를 n과 관계없이 일정한 시간복잡도를 갖는다고 하여 O(1)이라고 표기합니다. 여기서 1은 1번이라는 뜻이 아닌, 일정하다는 뜻입니다.

 

2. O(n^2)

두 수의 곱셈에 대한 연산은 시간복잡도가 어떻게 될지 생각해보겠습니다. 사람들은 일반적으로 12 * 45 을 계산하기 위해서 다음과 같이 계산합니다.

 

 

선행 수의 자리수 각각에 후행 수의 곱셈 연산을 모두 수행하고 더해줍니다. 대략 n^2 + an + b 같은 꼴이 될 수 있습니다.

n이 커질수록 대략 n^2번의 연산이 필요함을 알 수 있습니다. 결국 최고차항만 반영되어 O(n^2)으로 표기할 수 있습니다.

 

정렬 알고리즘에서의 시간복잡도

개념적인 이해는 모두 끝났으니 데이터 정렬 알고리즘에서의 시간복잡도로 마무리하겠습니다.

1부터 10까지 수를 랜덤으로 나열합니다. 7 5 9 10 4 2 1 8 3 6 과 같습니다.

이를 정렬하는 방법은 여러가지가 있을텐데, 가장 직관적인 방법으로 다음과 같은 방식이 있습니다.

1. 배열의 첫번째 자리부터 열번째 자리까지 둘러보며 가장 작은 수(=1)를 찾고, 배열의 첫번째 자리의 수(=7)와 교환한다. 

2. 배열의 두번째 자리부터 열번째 자리까지 둘러보며 가장 작은 수(=2)를 찾고, 배열의 두번째 자리의 수(=5)와 교환한다.

3. 배열의 세번째 자리부터 열번째 자리까지 둘러보며 가장 작은 수(=3)를 찾고,  ...

...

10. 배열의 열번째 자리부터 열번째 자리까지 둘러보며 가장 작은 수(=10)를 찾고, 배열의 열번째 자리의 수(=10)와 교환한다.

1단계에서 나열된 모든 수를 둘러봐야 가장 작은 수인 1을 찾을 수 있습니다.

둘러본다는 것은 컴퓨터가 현재의 최소값과 배열의 n번째 요소의 크기를 비교연산한다는 뜻으로, 결과적으로 컴퓨터는 10번의 연산이 필요합니다. 따라서 위 정렬을 위한 총 연산은 아래 그림과 같습니다.

참고로 위 랜덤 숫자 정렬 예시는 선택정렬과 거의 유사한 방식입니다.

선택정렬은 위에 수행한 각 열번의 과정에서 i번째 연산에서의 i번째 값은 최소값을 담을 변수에 저장만 할 것이므로 (연산하지 않으므로) 선택정렬의 수행횟수 식은 ( N - 1 ) * N / 2 입니다. 따라서 선택정렬의 시간복잡도 역시 O(N^2) 입니.

동시성 제어를 위해 select ... left join ... for update를 사용하여 배타 락을 획득할 때 다음과 같은 에러를 만났습니다.
"FOR UPDATE cannot be applied to the nullable side of an outer join"

 

실행하려 했던 SQL은 대략 아래와 같습니다.

SELECT *
FROM   member
       LEFT JOIN team
              ON member.team_id = team.team_id
FOR UPDATE;

 

한 트랜잭션A에서 위 SQL이 실행될 때 team이 조인이 되지 않아서 null 상태로 읽었다고 가정해 보겠습니다.

결과 셋은 아래와 같습니다.

 

이 트랜잭션이 커밋되기 전에 다른 트랜잭션 B로부터 team_id : 1이 삽입되었다면, team_id 1은 락에 걸려야 할지 안걸려야 할 지 모호합니다. 이러한 일관성의 문제 등의 이유로 left join을 비롯한 outer join 은 for update 가 불가합니다.

 

해결방법은 2가지가 있습니다.

 

1. inner join

조인이 수행된 team 테이블의 레코드라도 잠그고 싶다면, inner join을 통해 해결할 수 있습니다.

SELECT *
FROM   member
       INNER JOIN team
              ON member.team_id = team.team_id
FOR UPDATE;

 

 

2. for update of 테이블명

outer join 된 결과 셋이 더 중요하다면 of 옵션으로 명시된 테이블의 락만 획득할 수 있습니다.

이 경우 team 레코드가 조인되었어도 잠그지 않습니다.

물론 postgres REAPEATABLE READ 이상의 격리 수준에서는 NON_REPEATABLE READ 나 팬텀 리드 현상이 없기 때문에 트랜잭션 내에서 team 관련 결과 셋이 바뀔 일은 없습니다.

SELECT *
FROM   member
       LEFT JOIN team
              ON member.team_id = team.team_id
FOR UPDATE OF member;

 

트랜잭션은 데이터 정합성을 보장하기 위한 기능으로 논리적인 작업 셋을 모두 완벽하게 적용하거나 원상태로 복구하기 위한 기능입니다.
개발 과정에서 2개 이상의 쿼리가 실행되어야 하는 비즈니스 로직에 트랜잭션 기능을 사용하여 원자성을 보장할 수 있습니다.
SQL 표준에서 정의하는 4단계의 트랜잭션 격리 수준도 같이 테스트해보았습니다.
이 글의 목적은 실제 격리 수준별 동작을 테스트해 보는 목적으로 작성되었습니다.

 

DB 환경 & 출처
mysql
- version. 8.0.35
- storage engine. innoDB
- autocommit disabled

postgres
- version 14.10

정보 출처 : mysql, postgres 공식문서 및 Real MySQL 8.0

트랜잭션 격리 수준(transaction isolation level)

트랜잭션 격리 수준이란 하나의 트랜잭션 내 또는 여러 트랜잭션 간 작업 내용 공유 수준을 뜻합니다.

어느 테이블의 2개의 커넥션에서 별도의 트랜잭션을 생성했다고 가정해 봅시다.

트랜잭션 1에서 이 테이블의 레코드를 수정했을 때 이 트랜잭션 1이 커밋(또는 롤백) 되기 전에 트랜잭션 2가 이 테이블의 수정된 내용을 읽을 수 있는지를 결정하는 수준이라고 할 수 있겠습니다.

격리 수준은 총 4단계로 아래와 같으며, 격리(고립) 정도가 아래로 갈수록 낮습니다. 

저는 오늘 이 4단계 수준에 대해 정의와 더불어 각 수준별로 알려진 현상들에 대해 경험해보려고 합니다.

  • SERIALIZABLE
  • REPEATABLE_READ
  • READ_COMMITTED
  • READ_COMMITTED

1. SERIALIZABLE

가장 단순하면서도 엄격한 관리 수준으로 특수한 비즈니스 요구사항에서 사용될 수 있습니다.

mysql innoDB에서는 모든 select 문에... for share를 적용합니다.(공유락을 획득) 그만큼 성능도 떨어지는 격리 수준입니다.

This level is like REPEATABLE READ, but InnoDB implicitly converts all plain SELECT statements to SELECT ... FOR SHARE if autocommit is disabled.

 

위 같은 이유로 아래처럼 동작합니다.

 

A. 선행 트랜잭션에서 어떤 행에 공유락을 걸었다면, 후행 트랜잭션에서 읽기밖에 못합니다.

B. select ... for update로 배타락을 걸었다면 후행 트랜잭션에서 조회조차 불가능하게 됩니다.

    물론 innoDB는 검색 조건에 사용된 인덱스 락이므로 조건에 해당하지 않는 레코드를 조회, 업데이트하는 것은 가능합니다.

C. 쓰기 작업을 위해 배타락을 획득하면, 후행 트랜잭션에서는 조회조차 할 수 없습니다.

D. mysql 8.0.22 이상부터는 순수한 select 구문은 락과 관계없이 조회가 가능합니다.

 

 

2. REPEATABLE_READ

mysql innoDB의 디폴트 격리 수준입니다. MVCC(*) 방식을 활용하여 작동합니다.

(*) MVCC(Multi Version Concurrency Control) - 잠금을 사용하지 않고 일관된 읽기를 제공하기 위한 목적으로, 트랜잭션 내에서 데이터가 수정되면 그 수정내역을 언두(Undo) 공간 등에 보관하여 격리 수준에 따라 수정내역에서 참조하여 일관된 데이터를 읽을 수 있게 합니다.

 

아래는 선행 트랜잭션이 이름을 변경하고 커밋까지 하였음에도, 후행 트랜잭션에서 여전히 변경 전 이름을 읽는 상황입니다.

후행 트랜잭션은 언두로그에서 읽으므로 여전히 kim으로 읽는다.

 

2-1. phantom read(팬텀 리드)

SQL 표준에 의하면 REPEATABLE_READ 수준부터는 팬텀 리드 현상이 발생할 수 있습니다. 수정내역을 기록하는 undo 영역에는 락을 걸 수 없어서 select for ...update 과 같이 쓰기 잠금을 획득해야 하는 쿼리는 최신화된 테이블로부터 직접 읽기 때문에 발생합니다. 

어떤 트랜잭션에서 2번의 조회 사이에 다른 트랜잭션에서 레코드 삽입 및 커밋을 하게 되면, 처음 조회와 다르게 나중 조회 결과 셋에는 다른 트랜잭션에서의 레코드가 포함되어 있는 현상입니다. 그러나 제가 테스트해 본 MySQL innoDB와 postgreSQL의 경우 각자의 방식으로 이 현상을 방지하여 REPEATABLE_READ 수준에서 팬텀리드를 방지하였습니다.

아래는 MySQL MyISAM 엔진에서 발생하는 팬텀리드 현상입니다.

#DDL
create table member_MyISAM 
(
    member_id bigint unsigned auto_increment
        primary key,
    name      varchar(255) not null,
    age       int          not null,
    team_id   int          not null,
    constraint member_id
        unique (member_id)
) engine=MyISAM;

 

 

3. READ_COMMITED

오라클, postgresql의 기본 격리 수준이라고 할 수 있습니다.

이름과 같이 커밋된 결과는 트랜잭션 중간에도 읽을 수 있기 때문에 여러 번의 읽기에서 결과 셋이 다르게 보일 수 있습니다.

이를 NON-REPEATEABLE READ 현상이라고 합니다. 아래 그림과 같이 connection2의 트랜잭션 커밋을 한 시점부터 읽을 수 있게 됩니다. 이 격리 수준은 [2.REPEATABLE_READ] 보다 더 느슨하므로 팬텀 리드 현상 역시 발생합니다.

 

4. READ_UNCOMMITED

다른 트랜잭션의 커밋되지 않은 내용까지 모두 반영하는 격리 수준입니다. 사실상 쓰이지 않는 격리 수준으로 다른 격리 수준과 비교용으로 접해보았습니다. 팬텀리드, NON_REPEATABLE READ 현상과 더불어 커밋되지 않은 결과 셋이 보이다 안 보이다 하는 DIRTY_READ 현상까지 발생할 수 있습니다. 아래는 그 예시입니다. connection 2에서 insert 이후 롤백을 하더라도, 그 사이에 읽기를 한 connection1 은 2개 레코드에 대하여 비즈니스 로직을 수행하게 됩니다. 문제가 발생할 여지가 많습니다.

 

 

회고.

어렴풋이 그렇다고 알고 있는 내용도 실제로 경험을 하게 되면 깨닫게 되는 점이 많은 것 같습니다. RDBMS는 쿼리튜닝과 실행계획을 공부해 보면서도 느꼈지만, 데이터베이스에서 최대한 빠르게 원하는 데이터를 가져오기 위해 RDBMS 기종별, 엔진별, 버전별 최적화가 각각 다른 것 같아 특히 더 그렇다고 생각합니다. 이번 격리 수준 정리는 정의 공부하는 것을 넘어 실제로 테스트를 해보고 그렇다는 것을 알게 된 좋은 경험이었습니다.

+ Recent posts