과제를 받은 오늘 아침, 프로젝트를 시작하기 전에 팀원들과 첫 소통을 했다. 이전에 엘리스 트랙에서 만났던 사람들은 대부분 프론트엔드를 선호했고, 백엔드는 누구도 하고 싶어하지 않는 포지션이었다.
하지만 여기서는 상황이 달랐다. 나는 지금까지는 백엔드를 적극적으로 담당하고, 잘 모르지만 매번 팀장을 맡으며 리딩해왔지만, 여기선 현 팀원들의 지식과 의견을 듣고 나의 부족함을 많이 느꼈다. 무엇을 설명하려고 해도, 정확한지 확신이 없었고, 백엔드에 열정을 쏟아온 팀원들의 시선이 내게 부끄러움을 주기도 했다. 더군다나 Git 컨벤션등을 지키려고 하는 모습에, 지난 프로젝트를 중구난방으로 커밋했던 내 자신이 그려졌다. 난 그리고 완성된 프로젝트로 기준으로 전부 NoSQL로, RDBMS에는 너무 약했다. 개발 이야기와는 번외로 내게 귀가 두 개가 있고 입이 하나인 이유를 다시 한번 새기면서, 항상 경청해야함을 다시 마음에 새기고 블로그에 포스팅하는 마음을 가진다.
서비스의 특성을 고려할 때, RDBMS(관계형 데이터베이스 관리 시스템)와 NoSQL 중 어떤 데이터베이스 시스템을 선택할지 결정할 수 있습니다. 아래는 각 시스템의 특징과 서비스 요구사항에 대한 분석을 통해 어떤 시스템이 더 적합할지 제안합니다:
최종적으로는 서비스의 요구사항과 팀의 기술 스택, 그리고 미래의 확장성을 고려하여 더 적합한 데이터베이스 시스템을 선택해야 합니다.
물론, GPT의 한계로 정확하지 않을 수 있지만, 해당 제안서를 보고 관계가 필요한지, 독립적으로 테이블이 구성되는지 파악할 수 없다는 것은 내게 큰 약점이라는 생각이 들었다. 일단 Nest가 내겐 사실상 처음인 프레임워크이다보니, 이번 업무에서도 유저 인증을 선택했다. 지겨울만도 하지만, 프레임워크가 달라지면 나름 신선하게 다가오기도 해서, 다만, 매번 사용자 인증 관련을 나혼자 개발해왔지만, 이번엔 팀원 한명과 함께 해야하기 때문에 소통하고 소통해야겠다고 다짐했다. 이젠 데이터모델링과 Validate하는 예시를 먼저 검색해보았다.
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
import { Validate, ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments } from 'class-validator';
@Schema()
export class User extends Document {
@Prop({ unique: true })
account: string; // Username managed as hashtag
@Validate(PasswordConstraint)
@Prop()
password: string;
@Prop({ unique: true })
email: string;
}
export const UserSchema = SchemaFactory.createForClass(User);
@ValidatorConstraint({ name: 'ValidPassword', async: false })
export class PasswordConstraint implements ValidatorConstraintInterface {
validate(password: string, args: ValidationArguments) {
if (password.length < 10) return false;
const commonPasswords = ['123456', 'password', '123456789'];
if (commonPasswords.includes(password)) return false;
if (/^\d+$/.test(password)) return false;
const types = [/\d/.test(password), /[a-zA-Z]/.test(password), /\W/.test(password)];
if (types.filter(Boolean).length < 2) return false;
if (/([a-zA-Z\d\s])\1{2,}/.test(password)) return false;
return true;
}
defaultMessage(args: ValidationArguments) {
return 'Password does not meet complexity requirements!';
}
}
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.index.Indexed
import org.springframework.data.mongodb.core.mapping.Document
@Document
data class User(
@Id val id: String? = null,
@Indexed(unique = true)
val account: String, // Username managed as hashtag
@ValidPassword
val password: String,
@Indexed(unique = true)
val email: String,
val oldPassword: String? = null
)
@Constraint(validatedBy = [PasswordValidator::class])
@Target(AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class ValidPassword(
val message: String = "Invalid password",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = []
)
class PasswordValidator : ConstraintValidator<ValidPassword, String> {
override fun isValid(value: String?, context: ConstraintValidatorContext?): Boolean {
if (value == null) return false
if (value.length < 10) return false
val commonPasswords = listOf("123456", "password", "123456789")
if (commonPasswords.contains(value)) return false
s
if (value.all { it.isDigit() }) return false
val types = listOf(value.any { it.isDigit() }, value.any { it.isLetter() }, value.any { !it.isLetterOrDigit() })
if (types.count { it } < 2) return false
if (value.contains(Regex("""(.)\1{2,}"""))) return false
return true
}
}
Express에서도 타입스크립트를 사용하면, 저 데코레이터를 쓸 수 있는 것으로 알고 있다. 내게 스프링에서 어노테이션과 Nest의 데코레이터를 봤을 때, 스프링과 NestJS의 철학을 공유하고 있지 않나, 라는 생각을 갖게 해줬다.
지난 프로젝트 에서는 스프링 시큐리티 도입을 할까 하다가, 간단한 인터셉터 방식으로 개발을 진행했다. Express와 FastAPI와는 다르게 미들웨어 개념을 쓰지 않아서 처음으로 회원가입을 구현하는데 애를 먹었던 기억이 났다.
@Configuration
class WebMvcConfig(
private val jwtTokenInterceptor: JwtTokenInterceptor,
) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(jwtTokenInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/swagger-ui/**",
"/v3/**",
"/api/user/nickname/guest",
"/api/follow/guest/followings",
"/api/follow/guest/followers",
"/api/oauth/kakao/**",
"/api/comment/guest/**",
"/api/trip/nickname/**",
"/api/trip/guest/**",
"/api/trip/like/user/**",
"/favicon.ico",
)
}
}
@Component
class JwtTokenInterceptor(
private val jwtService: JwtService,
@Value("\${admin.ids}") private val adminIdsConfig: String,
) : HandlerInterceptor {
private val adminIds: List<Long> by lazy {
adminIdsConfig.split(",").mapNotNull { it.trim().toLongOrNull() }
}
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
val authorization = request.getHeader("Authorization") ?: throw UnauthorizedException("Authorization 헤더가 없습니다.")
val token = authorization.removePrefix("Bearer ").trim()
if (!jwtService.validateToken(token)) {
throw UnauthorizedException("유효하지 않은 토큰입니다.")
}
val memberId: Long = jwtService.getMemberIdFromToken(token)
request.setAttribute("memberId", memberId)
if (request.requestURI.contains("/admin/")) {
if (!adminIds.contains(memberId)) {
throw ForbiddenException("관리자만 접근 가능합니다.")
}
}
return true
}
}
그래서, 미들웨어와는 다르게 오히려 로그인이 필요없는 엔드포인트를 모아 excludePathPatterns에 넣어서, 모든 신호에 토큰 여부를 확인하는 방식으로 구현했다.
Nest에서는 주로 Passport.js와 함께 JWT 인증을 구현을 하는 정보를 습득했다. 이 방식에서는 @nestjs/jwt 및 passport-jwt 패키지를 사용하여 JWT 인증을 처리하고, Passport의 전략을 따르는 것으로 많은 검색 결과가 나왔다.
@Module({
imports: [UserModule, PassportModule, JwtModule.register({
secret: 'secretKey',
signOptions: { expiresIn: '60s' },
}), MongooseModule.forFeature([{ name: "user", schema: UserSchema }])],
providers: [AuthService, UsersService, LocalStrategy],
controllers: [AuthController],
})
export class AuthModule { }
Nest의 Module이 내게는 크게 익숙하게 다가오지 않은데, 개발을 하면서 익숙해져야할 포인트처럼 느껴진다.