728x90

권장되지는 않지만 서버 운영 중에 SQL을 변경해야 할 수도 있다고 한다.

스프링답게 이 방법을 만들어보도록 하자.

 

DI와 기능의 확장

DI는 기술이 아니다.

DI 자체를 공부한다는 느낌보다는 최대한 이용하며 설계하는 방법을 배우고 있는 중이다.

단순히 Controller, Service, Repository를 사용하는 것이 아니라 DI를 통해 유연하게 의존관계를 지정하도록 설계해야 제대로 DI를 사용하는 것이다.

제대로 DI를 사용할 수 있도록 의식하면서 프로그래밍을 하는 습관이 필요하다.

하나의 커다란 오브젝트를 적절한 책임에 따라 분리해주는 습관을 들여야 한다.

물론 당장 사용하지는 않겠지만, DI를 미래에 대한 보험이라고 생각하면 될 것이다.

 

DI를 사용할 때에는 최대한 인터페이스를 사용해야 한다.

물론 클래스를 직접 주입해서 사용하는 것도 가능은 하다.

하지만 그러면 그 클래스에 직접적으로 의존하게 된다.

만약 구현코드가 변경된다면, 해당 클래스를 의존하고 있는 모든 오브젝트도 변경해야 한다.

 

이렇게 인터페이스를 사용하는 첫번째 이유는 다형성을 얻기 위해서다.

하나의 인터페이스로 여러개의 구현체를 바꿔가며 사용하도록 하는 것이 DI의 첫번째 목표이다.

그리고 또 인터페이스를 사용해야 하는 이유가 있다면 인터페이스 분리 원칙을 통해 클라이언트와 의존 오브젝트 사이의 관계를 명확하게 해줄 수 있기 때문이다.

인터페이스는 하나의 오브젝트가 여러 개를 구현할 수 있으므로, 하나의 오브젝트를 바라보는 관점이 여러가지일 수도 있다.

하나의 인터페이스를 하나의 관심사라고 한다면, 하나의 오브젝트가 다양한 관심사를 구현할 수도 있다는 것이다.

여기서 본인의 관심사에만 의존한다면 의존받는 오브젝트의 다른 부분이 변경되더라도, 의존하는 오브젝트는 관심이 없기 때문에 변경될 필요도 없다.

이런 부분을 잘 이용해서 관심사에 따라 오브젝트를 구현하도록 만드는 것도 DI를 잘 이용하는 방법이다.

 

오브젝트가 그 자체로 응집도가 높게 설계되었더라도, 목적과 관심이 각기 다른 클라이언트가 있다면 이를 적절하게 분리해주는 것이 좋다.

이를 객체지향의 5대 원칙 중 하나인 인터페이스 분리 원칙(Interface Segregation Principle)이라고 한다.

 

인터페이스 상속

인터페이스 분리 원칙이 주는 장점은 모든 클라이언트가 자신의 관심에 따른 접근 방식을 간섭없이 유지할 수 있다는 점이다.

어차피 클라이언트는 본인이 의존하고 있는 인터페이스의 메서드만 신경쓰기에, 다른 부분의 변경은 영향을 주지 않기 때문이다.

 

다음과 같은 구조에서

BaseSqlService는 어떤 클래스가 SqlReader를 구현했던, 신경쓰지 않고 그저 인터페이스만 사용해서 변경없이 코드를 유지할 수 있다.

 

그리고 또 하나의 장점은, 2개의 인터페이스를 구현해서 각각 다른 클라이언트가 사용할 수 있다는 점이다.

 

현재 SqlRegistry는

public interface SqlRegistry{
	void registerSql(String key, String sql);
    
    String findSql(String key) throws SqlNotFoundException;   
}

 

이렇게 2가지를 가지고 있다. 그리고 이 인터페이스를 MySqlRegistry가 다른 인터페이스를 추가로 구현하고 싶다고 한다.

당연히 현재 BaseSqlService는 이런 등록하는 과정이 필요가 없기 때문에 SqlRegistry 인터페이스에 그런 메서드를 추가하면 안된다.

 

이런 부분은 분리를 해야한다.

public interface UpdatableSqlRegistry extends SqlRegistry{
    
    public void updateSql(String key, String sql);
    
    public void updateSql(Map<String, String> sqlmap);
}

 

 

이렇게 SqlRegistry에 업데이트를 하는 기능을 추가한다.

 

이러면 현재 BaseSqlService는 저 위의 메서드들만 사용하고 있기에, 아래의 메서드들에는 영향을 전혀 받지 않는다.

 

이런 Sql의 Update를 담당하는 클래스를 SqlManager라고 하자.

이런 식으로 BaseSqlService는 결국 SimpleUpdatableSqlRegistry를 주입받지만, 그 중 SqlRegistry의 메서드만 사용하게 된다.

그리고 이런 업데이트들은 다시 UpdatableSqlRegistry의 메서드들을 통해서 일어나게 되며, 해당 인터페이스의 구현체 또한 SimpleUpdatableSqlRegistry 이기 때문에 이런 업데이트가 결국 BaseSqlService에도 반영이 되는 것이다.

 

이렇게 인터페이스를 추가하거나 상속을 통해 확장하는 방식을 잘 이용하면 기존의 인터페이스를 사용하는 클라이언트가 있더라도 유연한 확장이 가능해진다.

 

잘 적용된 DI는 결국 잘 설계된 오브젝트 의존관계에 달려있다.

인터페이스를 적절하게 분리하고 확장하는 방법을 항상 고민하고, 의존관계를 명확하게 해주는 방법을 항상 고민하며 개발하자.

728x90

JAVB외에도 XML과 클래스의 멤버에 데이터를 매핑하는 기술들이 있다고 한다.

그리고 XML을 더 자유롭게 읽어올 수도 있다고 한다.

알아보자.

 

OXM 서비스 추상화

OXM은 Object-XML Mapping으로 xml과 자바 오브젝트를 매핑해주는 기술이라고 한다.

JAXB는 자바 표준에 포함되어 있어서 사용했지만, 이 외에도 다음과 같은 XML 기술들이 있다.

Castor XML, JiBX, XmlBeans, Xstream...

 

이 모든 기술들의 목적은 XML에서 데이터를 가져오는 것이다.

그 중 어떤 기술을 사용할지 모르기에 추상화로 만들어두는 것도 좋은 방법이라고 생각된다.

자바 -> XML을 Marshaller라고 하고

XML -> 자바를 Unmarshaller라고 한다.

 

'우리는 XML에서 값을 불러와야 하기에 Unmarshaller를 사용해야 한다'가 목표였지만, xml에서 무언가 조작하기가 힘들어서 yml에서 가져와서 만드는 것으로 바꿨다...

 

기존에 작성했던 SqlMap으로 쿼리들을 불러와보자.

 

@Getter
@Setter
@ToString
@Configuration
@ConfigurationProperties(prefix = "sqlmap")
public class SqlMap {

    private List<SqlType> queries;
}

 

@Getter
@Setter
@ToString
public class SqlType {
    private String value;
    private String key;
}

 

이렇게 해두고 우선 테스트를 돌려보자.

 

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"/test-applicationContext.xml"})
public class OxmTest {

    @Test
    public void unmarshallSqlMap() throws Exception {
        ObjectMapper mapper = new ObjectMapper(new YAMLFactory());

        try (InputStream inputStream = getClass().getResourceAsStream("/sqlmap.yml")) {
            assert inputStream != null;

            SqlMap sqlMap = mapper.readValue(inputStream, SqlMap.class);
            System.out.println(sqlMap);
            
            
        }
    }
}

 

우선 이렇게 작성해두면

 

이렇게 제대로 출력되는 것을 볼 수 있다.

 

OXM 서비스 추상화 적용

yml을 읽어오는 기능을 바탕으로 SqlService를 만들어보자.

기존에 사용하던 SqlRegistry를 그대로 사용하면 될테니, Reader만 yml을 읽어오도록 추가해서 등록해주면 된다.

@RequiredArgsConstructor
public class YmlSqlService implements SqlService {

    private final SqlReader sqlReader;

    private final SqlRegistry sqlRegistry;

    @PostConstruct
    public void loadSql(){
        this.sqlReader.read(this.sqlRegistry);
    }

    public String getSql(String key){
        return this.sqlRegistry.findSql(key);
    }
}

 

이렇게 껍데기를 만들어두고, SqlReader를 구현해보자.

@RequiredArgsConstructor
public class XmlSqlReader implements SqlReader {

    private final String path;

    @Override
    public void read(SqlRegistry sqlRegistry) {
        ObjectMapper mapper = new ObjectMapper(new YAMLFactory());

        try (InputStream inputStream = getClass().getResourceAsStream(path)) {
            assert inputStream != null;

            SqlMap sqlMap = mapper.readValue(inputStream, SqlMap.class);

            for(SqlType sqlType: sqlMap.getQueries())
            {
                sqlRegistry.registerSql(sqlType.getKey(), sqlType.getValue());
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

 

이러면 저번에 만들었던 xml과는 동일하게 동작할 수 있을 것이다.

 

이렇게 서비스 자체를 추상화로 만들어두니 테스트도 성공했다.

728x90

어떤 관계들이 존재하는지에 대해서는 생략하고, 바로 OneToMany와 ManyToOne의 관계에 대해서 알아보자.

 

여기서는 사용자와 해당 사용자가 작성하는 보고서에 대한 관계로 설명을 해보려고 한다.

 

우선 OneToMany 관계이다.

한명의 사용자가 많은 양의 보고서를 작성할거기에 One이 사용자, Many가 보고서이다.

따라서 OneToMany는 User 쪽에 작성하는 데코레이션이다.

 

    @OneToMany(() => Report, (report) => report.users)
    reports: Report[];

 

@OneToMany로 2가지의 파라미터를 넘긴다.

우선 첫번째 파라미터는 Many 쪽의 타입이다.

Many로 가져올 타입을 명시하며, () => Report 처럼 함수형으로 반환하는 이유는 곧 보겠지만

Report 쪽에도 이렇게 Users 타입을 주게 되는데, 이 때 두 Entity 간에 순환 참조 에러가 발생하게 된다.

그렇기에 함수의 리턴타입으로 명시해주는 것이다.

 

그리고 2번째 파라미터는, Many 쪽에서 해당 Entity를 가지고 있는 property다.

지금은 하나밖에 없어서 그냥 users로 명시하고 있지만

 

만약 updateUser가 추가된다면

    @OneToMany(() => Report, (report) => report.updatedUser)
    updateReports: Report[];

이렇게 updateUser와 같은 속성을 명시해주는 것이다.

 

이런 이유로 OneToMany에도 2가지의 파라미터를 더 넘기게 된다.

 

이제 Many쪽으로 넘어가보자.

    @ManyToOne(() => Users, (Users) => Users.reports)
    users: Users

Many쪽도 다음과 같은 이유로 2개의 파라미터를 작성한다.

 

그리고 이렇게 작성을 하면 Many 쪽에는

다음과 같이 user쪽의 PK를 가지는 column이 추가된다.

 

근데 여기서 다음과 같은 문제가 발생한다.

우선 지금과 같이 작성해서는

    @Get("/check")
    @UseGuards(AuthGuard)
    async checkLogin(@Authentication() authentication: Users): Promise<Users>{
        console.log(authentication.reports)
        return authentication
    }

해당 코드에서 undefined가 출력되게 된다.

OneToMany 관계에서 Many를 불러오지 못하는 것이다.

 

불러오기 위해서는 다음과 같이 eager:true를 설정해줘야 한다.

@OneToMany(() => Report, (report) => report.users, {
        eager: true
    })
    reports: Report[];

이러면 출력은 된다.

 

하지만 또 문제가 발생한다.

이러면 로그인만 하더라도 모든 report까지 조회하게 된다.

로그인을 하면 user entity를 조회하게 될텐데, 그 때마다 report를 조회한다면 큰 오버헤드 일 것이다.

 

그렇기에 필요할 때만 가져오도록 만들어야 한다.

그러기 위해서는 해당 property를 다음과 같이 수정한다.

    @OneToMany(() => Report, (report) => report.users, {
        lazy: true
    })
    reports: Promise<Report[]>;

이렇게 타입을 Promise로 설정하고, lazy를 true로 하면 해당 property를 사용하려고 할 때 다시 쿼리를 날려서 report를 조회해온다.

 

테스트로 다음과 같이 코드를 작성하고 실행하면

    @Get("/check")
    @UseGuards(AuthGuard)
    async checkLogin(@Authentication() authentication: Users): Promise<Users>{
        console.log("before query")

        console.log(await authentication.reports)
        return authentication
    }

 

로그가 출력되고 그 다음에 다시 쿼리를 날리는 것을 볼 수 있다.

 

이 외에도 다양한 방법의 관계에서의 설정들이 있을텐데, 꾸준하게 nest를 공부하면서 알아보도록 하자.

728x90

기존에는 데이터베이스와 관련된 부분을 그냥 코드로 작성했었다.

 

이런 부분을 당연히 그대로 git에 올리면 안되기 때문에 환경변수로 만들어서 주입해야 한다.

 

우선 환경변수를 .env 파일로부터 가져오기 위해 다음 의존성들을 추가해준다.

 

그리고는 .env.{환경}으로 환경변수를 저장할 파일을 만들어준다.

 

당연히 올리면 안되기에 gitignore에 추가해준다.

 

그리고 데이터베이스는 같게 하더라도 스키마는 다르게 해주었다.

DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=1204
DB_DATABASE=usedCars_dev

.env.development

 

DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=1204
DB_DATABASE=usedCars_test

.env.test

 

테스트에서 다른 데이터베이스를 사용하기 위해 구별해주었다.

 

이제 app.module.ts로 가서 해당 환경변수들을 주입해줘야 한다.

@Module({
  imports: [
      ConfigModule.forRoot({
        isGlobal: true,
        envFilePath: `.env.${process.env.NODE_ENV}`
      })
   ]
})
export class AppModule {}

우선 ConfigModule.forRoot()로 환경변수를 읽어올 파일을 지정해준다.

 

여기에서 .env의 뒷 부분을 모듈 런타임의 환경변수로 지정해서 development, test를 구별해주었다.

 

그 다음에는 기존에 사용하던 TypeOrmModule을 수정해줘야 한다.

forRoot가 아니라 forRootAsync로 가져와야 한다.

forRoot는 정적이거나 process.env로 접근할 때 사용하며

forRootAsync는 동적으로 config 파일을 가져올 때 사용한다고 한다.

 

      TypeOrmModule.forRootAsync({
        inject: [ConfigService],
        useFactory: (config: ConfigService) => {
          return {
            type: "mysql",
            host: config.get<string>('DB_HOST'),
            port: config.get<number>('DB_PORT'),
            username: config.get<string>('DB_USERNAME'),
            password: config.get<string>('DB_PASSWORD'),
            database: config.get<string>('DB_DATABASE'),
            entities: [__dirname + '/**/*.entity{.ts,.js}'],
            synchronize: true
          }
        }
      }),

inject를 통해서 환경변수를 가져올 서비스를 지정해준다.

 

이제는 해당 환경에 대한 정보를 서버 시작 전에 넘겨줘야 한다.

이 쪽 부분에 해당하는 내용을 말이다.

 

현재 npm을 통해서 서버를 실행하고 있으니

package.json의 이쪽 부분을 수정해주면 된다.

 

우리는 런타임 환경을 부여하기 위해 cross-env 라이브러리를 설치했었다.

우리는 NODE_ENV를 줘야 하기에 NODE_ENV={원하는 값}을 주입해준다.

 

한 번 실행해서 원하는 데이터베이스에 값이 생기는지 확인해보자.

이렇게 설정하고 create-user.http를 실행하니 데이터베이스에 값이 원하는대로 들어가는 것을 볼 수 있었다.

 

728x90

자 이제 로그인을 하면 세션에 사용자 정보를 넣어두고, 다른 API를 요청하면 그 사용자 정보를 가져오도록 해보자.

 

우선 사용자가 로그인을 하면 세션에 사용자의 id를 넣어줘야 한다.

    @Post("/signIn")
    async signIn(@Body() loginUserDto: LoginUserDto, @Session() session: any): Promise<Users>{
        const user = await this.authService.signIn(loginUserDto)

        session.userId = user.id;

        return user;
    }

이렇게 user의 id를 먼저 넣어준다.

 

그리고 로그인을 해보면, 우선 다음과 같이 쿠키가 생긴 것을 볼 수 있다.

 

이제 이 정보를 다른 API에서 불러와야 한다.

 

일단 로그인 체크 API를 만들고 거기에서 사용자의 아이디를 콘솔로 출력해보자.

    @Get("/check")
    async checkLogin(@Session() session: any): Promise<string>{
        console.log(`userId: ${session.userId}`);
        return ""
    }

일단 사용자의 아이디가 출력되었다.

 

사용자 로그아웃도 구현해보자.

 

그냥 userId를 null로 바꿔주면 끝이다.

    @Post("/signOut")
    async signOut(@Session() session: any): Promise<void>{
        session.userId = null;
    }

로그아웃 API를 요청하고 다시 check 해보면 userId가 null로 출력되고 있었다.

 

이렇게 만들었지만, 항상 컨트롤러에서 세션을 통해 userId를 가져올 수는 없을 것이다.

 

로그인을 하지 않았다면 해당 경로의 접근을 막고, 데코레이터를 통해 사용자의 정보를 가져오도록 수정해보자.

@Authentication이라는 데코레이션으로 User의 정보를 가져올 수 있도록 만들어보자.

 

export const Authentication = createParamDecorator(
    (data: any, context: ExecutionContext) => {
        
    }
)

우선 다음과 같은 방법으로 데코레이터를 작성한다.

 

여기서 data는 데코레이터에 넘기로 넘기는 값, ExecutionContext는 NestJs에서 제공하는 Http요청이나 Websocket등 컨텍스트의 실행 정보를 담고 있다고 한다.

 

getType()을 통해 연결종류로 'http', 'rpc', 'ws' 중 하나를 반환하며, 우리는 이 중 http를 사용하기에 switchToHttp()를 통해서 http context를 반환한다.

 

그리고 해당 데코레이터에서 반환환 값은 

여기에서 받아서 사용한다.

 

        const httpRequest = context.switchToHttp().getRequest();

        const userId = httpRequest.session.userId;

이렇게 다음과 같은 방법으로 http로 변환하고 거기서 session을 가져와서 userId를 추출할 수 있다.

하지만 우리는 이 userId를 바탕으로 사용자 정보를 가져오고 싶다.

그렇기에 UserService가 필요하다.

하지만 이 UserService는 그냥 가져오는 것이 아니라, 의존성을 통해 주입받아야 한다.

그렇기에 여기서 우리는 의존성을 주입받을 수 있는 인터셉터를 사용해야 한다.

 

인터셉터를 통해 Session을 읽고 사용자를 조회한다.

그리고 그 조회한 사용자를 데코레이터를 통해 읽어오도록 만드는 것이다.

 

다시 인터셉터부터 만들어보자.

@Injectable()
export class ExtractUserInterceptor implements NestInterceptor {

    constructor(private readonly usersService: UsersService) {}

    async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {

        //HTTP Context를 가져온다
        const request = context.switchToHttp().getRequest();

        //session에서 userId를 가져온다.
        const {userId}: {userId: number} = request.session || {};

        //userId가 없으면 인증에러를 반환한다.
        if(!userId) {
            throw new UnauthorizedException();
        }

        //User 정보를 데코레이터에서 가져올 수 있도록 context에 넣기
        request.curUser = await this.usersService.findOne(userId);

        return next.handle()
    }
}

우선 이렇게 만들어두고, 의존성을 주입받을 수 있도록 모듈에 인터셉터를 추가해준다.

 

@Module({
  imports: [TypeOrmModule.forFeature([Users])],
  controllers: [UsersController],
  providers: [UsersService, AuthService, ExtractUserInterceptor],
})
export class UsersModule {}

 

이제 User를 추출할 데코레이터를 다음과 같이 수정하고

export const Authentication = createParamDecorator(
    (data: never, context: ExecutionContext) => {

        const httpRequest = context.switchToHttp().getRequest();

        return httpRequest.curUser;
    }
)

 

 

인터셉터를 달아준 후 테스트 해보도록 하자.

 

이렇게 잘 나오는 것을 볼 수 있다.

 

이거를 컨트롤러마다 설정하기 복잡하다면, 다음과 같은 방법으로 모듈에 글로벌하게 설정할 수 있다.

@Module({
  imports: [TypeOrmModule.forFeature([Users])],
  controllers: [UsersController],
  providers: [
      UsersService, AuthService,
    {
      provide: APP_INTERCEPTOR,
      useClass: ExtractUserInterceptor,
    }],
})
export class UsersModule {}

 

경로 중 인증정보가 없다면 401, 403 에러를 반환하고 싶을 때가 있다.

그럴 때를 위해 CanActivate를 위해 AuthGuard를 만들어주자.

export class AuthGuard implements CanActivate {
    canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
        const request = context.switchToHttp().getRequest();

        return request.session.userId;
    }
}

 

여기서는 userId가 존재하지 않으면 접근을 막는다.

 

다음과 같이 설정하고

 

로그아웃하고 API를 요청하면

이렇게 403으로 거부되는 것을 볼 수 있다.

 

 

728x90

이제 저번에 만들었던 sqlService를 더 제대로 사용해보자.

 

XML 파일매핑

저번에 sql을 분리해서 xml에 이렇게 넣기는 했지만

사실 여기에 sql이 있는 것도 좀 아쉽다.

차라리 다른 파일에서 sql만 불러오도록 만들고 싶어진다.

 

JAXB를 사용하면 xml에서 정보를 가져올 수 있다고 한다.

 

xml에서 sql들을 가져오는 방법은 과정만 작성하도록 하겠다.

    implementation("javax.xml.bind:jaxb-api:2.3.1")
    runtimeOnly("org.glassfish.jaxb:jaxb-runtime:2.3.3")
    implementation("javax.activation:activation:1.1.1")

 

이렇게 일단 xml을 읽을 수 있는 라이브러리를 가져오고

 

<?xml version="1.0" encoding="UTF-8" ?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
           targetNamespace="http://www.epril.com/sqlmap"
           xmlns:tns="http://www.epril.com/sqlmap"
           elementFormDefault="qualified">

    <xs:element name="sqlmap">
        <xs:complexType>
            <xs:sequence>
                <xs:element name="sql" type="tns:sqlType" maxOccurs="unbounded"/>
            </xs:sequence>
        </xs:complexType>
    </xs:element>

    <xs:complexType name="sqlType">
        <xs:simpleContent>
            <xs:extension base="xs:string">
                <xs:attribute name="key" type="xs:string" use="required"/>
            </xs:extension>
        </xs:simpleContent>
    </xs:complexType>

</xs:schema>

xsd와

 

<?xml version="1.0" encoding="UTF-8"?>
<sqlmap xmlns="http://www.epril.com/sqlmap">
    <sql key="findAll">SELECT * FROM users</sql>
    <sql key="findById">SELECT * FROM users WHERE id = ?</sql>
    <sql key="insertUser">INSERT INTO users (name, email) VALUES (?, ?)</sql>
</sqlmap>

xml을 작성해준다.

 

그리고 해당 데이터를 저장하고 있는 자바 클래스를 정의해준다.

@XmlType(name = "", propOrder = {"sql"})
@XmlRootElement(name = "sqlmap", namespace = "http://www.epril.com/sqlmap")
@XmlAccessorType(XmlAccessType.FIELD)
@Getter
@Setter
public class SqlMap {

    @XmlElement(namespace = "http://www.epril.com/sqlmap", required = true)
    private List<SqlType> sql;

    @Override
    public String toString() {
        return "SqlMap{" +
                "sql=" + sql +
                '}';
    }
}

 

@Setter
@Getter
@XmlAccessorType(XmlAccessType.FIELD)
@XmlType(name = "sqlType", namespace = "http://www.epril.com/sqlmap")
public class SqlType {

    @XmlValue
    private String value;

    @XmlAttribute(name = "key", required = true)
    private String key;

    @Override
    public String toString() {
        return "SqlType{" +
                "value='" + value + '\'' +
                ", key='" + key + '\'' +
                '}';
    }
}

 

이러고 테스트를 통해 출력해보면 다음과 같이 나오게 된다.

public class JaxbTest {

    @Test
    public void readSqlMap() throws JAXBException {
        JAXBContext context = JAXBContext.newInstance(SqlMap.class);

        Unmarshaller unmarshaller = context.createUnmarshaller();

        SqlMap sqlmap = (SqlMap) unmarshaller.unmarshal(
                getClass().getResourceAsStream("/sqlmap.xml")
        );

        System.out.println(sqlmap);
    }

}

 

 

이렇게 xml에서 데이터를 가져오는 방법을 언마샬링이라고 한다고 한다.

 

XML 파일을 이용하는 SQL 서비스

이 sqlmap.xml을 통해서 UserDao를 사용해보도록 하자.

우선 sql을 모두 작성해준다.

<?xml version="1.0" encoding="UTF-8"?>
<sqlmap xmlns="http://www.epril.com/sqlmap"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.epril.com/sqlmap
http://www.epril.com/sqlmap/sqlmap.xsd">
    <sql key="userAdd">INSERT INTO users(id, name, password, email, level, login, recommend) values(?, ?, ?, ?, ?, ?, ?)</sql>
    <sql key="userGet">SELECT * FROM users WHERE Id = ?</sql>
    <sql key="userGetAll">SELECT * FROM users ORDER BY ID</sql>
    <sql key="userDeleteAll">DELETE FROM users</sql>
    <sql key="userGetCount">SELECT COUNT(*) FROM users</sql>
    <sql key="userUpdate">UPDATE users SET name = ?, password = ?, email = ?, level = ?, login = ?, recommend = ? where id = ?</sql>
</sqlmap>

 

이 xml을 읽어서 sql을 전달해주는 SqlService를 작성해보자.

생성자를 통해 해당 Bean이 생성되는 순간에 sqlMap을 불러와서 Map으로 저장한다.

package seungkyu;

import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Unmarshaller;
import java.util.HashMap;
import java.util.Map;

public class XmlSqlService implements SqlService {

    private Map<String, String> sqlMap = new HashMap<>();

    public XmlSqlService() {
        String contextPath = SqlMap.class.getPackage().getName();
        try
        {
            JAXBContext jaxbContext = JAXBContext.newInstance(SqlMap.class);
            Unmarshaller unmarshaller = jaxbContext.createUnmarshaller();

            SqlMap sqlmap = (SqlMap) unmarshaller.unmarshal(
                    getClass().getResourceAsStream("/sqlmap.xml")
            );

            for(SqlType sqlType: sqlmap.getSql()) {
                sqlMap.put(sqlType.getKey(), sqlType.getValue());
            }

        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public String getSql(String key) {
        String sql = sqlMap.get(key);
        if(sql == null) {
            throw new RuntimeException("SQL Key " + key + " not found");
        }
        else 
            return sql;
    }
}

이 XmlSqlService를 sqlService로 등록해준다.

 

빈의 초기화 작업

일단 위와 같이 만들긴 했지만, 생성하는 과정에서 몇가지 수정할 부분이 있다고 한다.

우선 생성 중 예외가 발생할 수 있고, 가져오는 파일이 sqlmap.xml로 고정이 되어있다.

 

우선 간단하게 파일부터 동적으로 받을 수 있도록 수정해보자.

이렇게 그냥 생성자와 빈만 수정한다.

public XmlSqlService(String fileName) {
        try
        {
            JAXBContext jaxbContext = JAXBContext.newInstance(SqlMap.class);
            Unmarshaller unmarshaller = jaxbContext.createUnmarshaller();

            SqlMap sqlmap = (SqlMap) unmarshaller.unmarshal(
                    getClass().getResourceAsStream(fileName)
            );

            for(SqlType sqlType: sqlmap.getSql()) {
                sqlMap.put(sqlType.getKey(), sqlType.getValue());
            }

        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }
    }

 

그리고 이 생성로직을 loadSql()로 추출하려고 하는데

    public void loadSql(){
        try
        {
            JAXBContext jaxbContext = JAXBContext.newInstance(SqlMap.class);
            Unmarshaller unmarshaller = jaxbContext.createUnmarshaller();

            SqlMap sqlmap = (SqlMap) unmarshaller.unmarshal(
                    getClass().getResourceAsStream(fileName)
            );

            for(SqlType sqlType: sqlmap.getSql()) {
                sqlMap.put(sqlType.getKey(), sqlType.getValue());
            }

        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }
    }

문제는 이 클래스를 스프링이 빈으로 등록해주는 것이기 때문에, 우리가 이 메서드를 호출할 방법이 없다.

 

그렇기에 이 클래스에 @PostConstruct를 사용하겠다.

AOP에 대해 알아볼 때, 스프링의 빈 후처리기가 있다고 했었다.

빈 후처리기는 스프링 컨테이너가 빈을 생성한 뒤에 추가적인 작업을 해줄 수 있다고 했는데, 여기서 @PostConstruct는 스프링이 DI 작업을 마친 뒤 해당 메서드를 자동으로 실행하도록 해주는 어노테이션이다.

 

	@PostConstruct
    public void loadSql(){}

 

이렇게 @PostConstruct 어노테이션을 붙이고

 

<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:tx="http://www.springframework.org/schema/tx"
       xsi:schemaLocation="
           http://www.springframework.org/schema/beans
           http://www.springframework.org/schema/beans/spring-beans.xsd
           http://www.springframework.org/schema/aop
           http://www.springframework.org/schema/aop/spring-aop.xsd
           http://www.springframework.org/schema/context
           http://www.springframework.org/schema/context/spring-context.xsd
           http://www.springframework.org/schema/tx
           http://www.springframework.org/schema/tx/spring-tx-2.5.xsd">
           
           <context:annotation-config/>
           
</beans>

이렇게 어노테이션 설정을 붙여주면

 

 

이렇게 스프링을 실행하면 PostConstruct가 실행되는 것을 볼 수 있다.

 

스프링 컨테이너는 빈을 다음과 같은 순서로 생성한다.

 

변화를 위한 준비: 인터페이스 분리

솔직히 이정도면 끝났다고 생각은 했지만, 아직도 수정할 부분이 남아있다고 한다.

현재 XmlSqlService가 xml을 통해서 sql을 불러오고, 요청에 따른 sql을 반환하는 2가지의 임무를 수행하고 있다.

 

현재는 xml에서 map을 통해 sql을 주고 있지만, 이 방법이 엑셀로 그리고 list로 변경될 수도 있다는 것이다.

 

SqlService를 리펙토링하면 다음과 같은 구조로 만들어진다.

당연히 읽는 부분과 등록하는 부분은 인터페이스로 구현해야 할 것이다.

 

SqlService는 생성시 혹은 사용 전에 SqlReader를 통해 SqlRegistry에 sql을 등록해야 한다.

SqlService가 사실은 Sql을 이용하는 것은 아니기 때문에 Map<String, String> 타입으로 의존성이 생기게 넘겨줄 필요는 없고

SqlRegistry를 받아서 하나하나 등록해주는 방법이 더 좋다.

사실은 잘 모르겠고, 일단 만들고나서 확인해보자.

 

우선 각각 인터페이스부터 만들어보자.

우선 간단하게 registry부터

public interface SqlRegistry {

    void registerSql(String key, String sql);

    String findSql(String key);
}

 

그리고 Reader에서는 읽자마자 바로 넘겨주기에

public interface SqlReader {
    void read(SqlRegistry sqlRegistry);
}

이렇게 SqlRegistry를 넘겨주는 read 메서드 하나만 두도록 한다.

 

자기참조 빈으로 시작하기

이제 각각의 인터페이스를 구현해보자.

어렵게 되었지만, 결국 의존관계를 그려보면 다음과 같다.

 

하지만 너무 구조가 복잡하기에 XmlSqlService가 다른 3개의 인터페이스를 구현하도록 만드는 방법도 있다.

그럼 구조가 다음과 같아질것이다.

 

사실 자기참조 빈을 사용해서 만든다고는 하는데, 이해가 잘 되지 않는다.

바로 만들어보고 생각하자.

 

우선 내부에서 사용할 멤버 변수를 다음과 같이 만들어준다.

@RequiredArgsConstructor
public class XmlSqlService implements SqlService, SqlRegistry, SqlReader{

    @Setter
    private SqlReader sqlReader;

    @Setter
    private SqlRegistry sqlRegistry;

    private final String path;

    private final Map<String, String> sqlMap = new HashMap<>();
}

SqlService, SqlRegistry, SqlReader를 구현하지만 내부적으로 SqlReader와 SqlRegistry를 사용하도록 만들어준다.

 

    @Override
    public String findSql(String key) {
        return sqlMap.getOrDefault(key, "NOT FOUND");
    }

    @Override
    public void registerSql(String key, String sql) {
        sqlMap.put(key, sql);
    }

이제 인터퍼이스들을 구현해주는데, SqlRegistry 먼저 구현해준다.

 

내부적으로 가지고 있는 sqlMap을 통해 간단하게 등록하고 조회한다.

 

    @Override
    public void read(SqlRegistry sqlRegistry) {
        JAXBContext context;
        try {
            context = JAXBContext.newInstance(SqlMap.class);
        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }

        Unmarshaller unmarshaller;
        try {
            unmarshaller = context.createUnmarshaller();
        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }

        try {
            SqlMap sqlmap = (SqlMap) unmarshaller.unmarshal(
                    getClass().getResourceAsStream(path)
            );
            for(SqlType sqlType : sqlmap.getSql()) {
                sqlRegistry.registerSql(sqlType.getKey(), sqlType.getValue());
            }
        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }
    }

이제는 그냥 SqlRegistry를 넘겨서 해당 인터페이스의 registrySql을 사용해 sql을 등록하도록 만든다.

 

이렇게 만들면 타입을 정해 SqlReader에서 SqlRegistry를 등록해줄 필요가 없어진다.

 

이제 SqlService는 다음과 같이 @PostConstruct와 getSql() 메서드를 구현해서 만들어준다.

    @PostConstruct
    public void loadSql(){
        this.sqlReader.read(this.sqlRegistry);
    }

    public String getSql(String key){
        return this.findSql(key);
    }

 

@RequiredArgsConstructor
public class XmlSqlService implements SqlService, SqlRegistry, SqlReader{

    private final SqlReader sqlReader;

    private final SqlRegistry sqlRegistry;

    private final String path;

    private final Map<String, String> sqlMap = new HashMap<>();

    @PostConstruct
    public void loadSql(){
        this.sqlReader.read(this.sqlRegistry);
    }

    public String getSql(String key){
        return this.findSql(key);
    }

    @Override
    public String findSql(String key) {
        return sqlMap.getOrDefault(key, "NOT FOUND");
    }

    @Override
    public void registerSql(String key, String sql) {
        sqlMap.put(key, sql);
    }

    @Override
    public void read(SqlRegistry sqlRegistry) {
        JAXBContext context;
        try {
            context = JAXBContext.newInstance(SqlMap.class);
        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }

        Unmarshaller unmarshaller;
        try {
            unmarshaller = context.createUnmarshaller();
        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }

        try {
            SqlMap sqlmap = (SqlMap) unmarshaller.unmarshal(
                    getClass().getResourceAsStream(path)
            );
            for(SqlType sqlType : sqlmap.getSql()) {
                sqlRegistry.registerSql(sqlType.getKey(), sqlType.getValue());
            }
        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }
    }
}

XmlSqlService는 SqlService와 SqlRegistry라는 전략을 사용하도록 구성되었기에, 이 2가지를 최대한 활용하여 만들면 된다.

 

사실 이게 핵심은 아니고, 이거를 xml을 통해 빈으로 등록하는 과정이 핵심이다.

    <bean id="sqlService" class="seungkyu.XmlSqlService">
        <constructor-arg value="/sqlmap.xml"/>
        <property name="sqlReader" ref="sqlService"/>
        <property name="sqlRegistry" ref="sqlService"/>
    </bean>

순환참조가 발생하지 않도록 setter로 자기자신을 주입해준다.

이렇게 하면 된다고는 하지만, 나는 이렇게 만들면 이해하기가 어려울 거 같아 그냥 인터페이스들을 분리하려고 한다.

 

    <bean id="sqlReader" class="seungkyu.XmlSqlReader">
        <constructor-arg value="/sqlmap.xml"/>
    </bean>

    <bean id="sqlRegistry" class="seungkyu.MapSqlRegistry"/>

    <bean id="sqlService" class="seungkyu.XmlSqlService">
        <constructor-arg ref="sqlReader"/>
        <constructor-arg ref="sqlRegistry"/>
    </bean>

한동안 에러가 발생해서 수정했더니, 테스트를 모두 통과하는 것을 볼 수 있다.

 

728x90

사용자가 로그인 한 동안에는 사용자가 API 요청 할 때마다 사용자의 정보를 가져와서 처리해야 할 것이다.

 

보통 이런 경우에는 2가지 방법 중 하나를 사용하는데, 세션혹은 쿠키를 사용한다.

 

우선 세션을 먼저 알아보고, 해당 강의가 끝나면 JWT를 통한 쿠키 인증 방법을 알아보자.

 

우선 cookie-session을 사용할 것인데, 이 라이브러리는 기존의 세션과는 다르게 세션데이터를 클라이언트의 쿠키에 저장한다고 한다.

어쩌면 쿠키라고 말하는 것이 맞을 거 같기도 하다.

이런식으로 세션을 유지한다고 한다.

 

우선 cookie-session 라이브러리부터 가져오자.

 npm install cookie-session

 npm install @types/cookie-session

 

그 다음에 main.ts에 cookie-session을 설정해준다.

const cookieSession = require('cookie-session');

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
      })
  );
  app.use(cookieSession({
    keys:['seungkyu']
  }))
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

cookie-session은 ts가 되지 않아, 저렇게 require를 사용해야 한다고 한다.

 

app.use로 cookieSession을 설정해주고, key를 넣어야 한다.

이 key는 cookie에 평문 그대로 넘겨주지 않으니 약간의 암호화를 하는데, 그 때 사용하는 키다.

지금은 연습단계니 간단하게 이름으로 추가했다.

 

이제 간단하게 userController에 /test 핸들러를 추가해서 세션을 조작해보자.

 

    @Get("/test/:name")
    testWriteSession(@Param('name') name:string, @Session() session: any){
        session.name = name;
    }

    @Get("/test")
    testReadSession(@Session() session: any){
        return session.name;
    }

이렇게 데코레이터로 Session을 가져온다.

우선 간단하게 쓰고, 읽도록 만들어보았다.

 

이제 다음과 같은 http call을 날려보자.

 

그 다음 위의 http call을 날려보면

이렇게 Set-Cookie가 온 것을 볼 수 있다.

쿠키의 위조를 막기 위해 하나는 실제 쿠키, 하나는 서명값이 온다고 하고

seungkyu가 온 것이 아니라 암호화가 되어 온 것을 볼 수 있다.

 

그 다음 밑의 http call은

이렇게 정상적으로 다시 해독해서 온 것을 볼 수 있다.

 

그리고 위의 http call을 다시 날려보면

Set-Cookie가 없는 것을 볼 수 있는데, 쿠키가 같으면 굳이 nest에서 새롭게 쿠키를 굳이 업데이트 하지 않기 때문이다.

 

일단 간단하게 nest에서 session을 사용하는 방식을 알아보았다.

이제 이 session을 사용해 사용자의 로그인을 유지해보자.

728x90

저번시간에는 salt를 통한 단방향 암호화로 데이터베이스에 사용자의 비밀번호를 저장했다.

 

해당 비밀번호는 해독이 불가능하기에 해당 비밀번호를 풀어서는 비밀번호가 맞는지 확인할 수 없다.

 

사용자가 입력한 비밀번호를 암호화해서 비교하는 방식을 사용해야 한다.

 

우선 사용자에게 로그인 정보를 받는 컨트롤러를 만들어보자.

 

    @Post("/signIn")
    signIn(@Body() loginUserDto: LoginUserDto): Promise<Users>{
        return this.authService.signIn(loginUserDto)
    }

loginUserDto에는 당연히 email, password를 받고 있다.

 

우선 입력받은 email을 바탕으로 사용자를 데이터베이스에서 찾아온다.

해당 사용자가 존재하지 않으면 에러를 반환한다.

	const {email, password} = loginUserDto;

        const user = await this.usersService.findByEmail(email);
        
        if(!user)
            throw new UnauthorizedException();

 

이 user class 안에는 암호화된 비밀번호와 salt값이 결합되어 있을 것이다.

 

그거를 분리해서 salt와 hashedPassword로 가져온다.

const [salt, hashedPassword] = user.password.split('.');

 

그리고 사용자가 입력한 비밀번호를 salt로 암호화하고 데이터베이스의 비밀번호와 비교한다.

 

        const hashed = (await scrypt(password, salt, 32)) as Buffer;

        if(hashedPassword != hashed.toString('hex'))
            throw new UnauthorizedException();

        return user;

 

일치하면 성공, 불일치하면 에러이다.

 

한 번 테스트를 해보자.

 

우선 다음과 같이 사용자를 생성한다.

그럼 다음과 같이 데이터가 생성된다.

해당 암호화값으로는 비밀번호를 알 수 없기에 로그인 API를 호출해보자.

 

우선 잘못된 값을 넣어보았다.

 

그러면 당연히 비밀번호가 일치하지 않아 인증 오류가 발생한다.

 

이번에는 제대로된 데이터를 넣어보자.

 

이렇게 제대로 유저가 응답되는 것을 볼 수 있다.

 

아마 이게 가장 정석적인 암호화 방법이라고 생각된다.

salt를 추가한 방법으로 로그인들을 구현하도록 하자.

+ Recent posts