🔗 참고자료

  • 백준 문제 큐(10845번) => 링크

 

 

 

✍ 공부하게 된 계기

알고리즘 문제에서 큐에대한 개념이 나왔는데, 조건문을 사용하면 쉽게 해결할 수 있는 문제였습니다.

그런데 단순히 정답을 위해서 풀면 나중에 큐(Queue)관련 문제가 나올 때 제대로 풀 지 못할것 같았습니다.

그래서 좀 더 깊게 파보고 큐(Queue)를 직접 구현해보기로 했습니다.

자료구조를 공부하면서 정말 많은것을 배우고 있습니다.

  • 자바의 내부함수 구조를 직접 뜯어보고 어떻게 구현되어있는지 확인하는 계기
  • 단순히 자바에서 잘 구현되어있는 List를 계속 사용만 한다면 추상화되어있는 개념만 가질 수 있었을겁니다.
    그런데 '한번 구현해보면 어떨까?' 라는 생각으로 인해 자료구조에 더 친해지는 계기가 되었습니다.

 

 

 

 

 

❓ 큐(Queue)란

  • 큐는 한국어로 "대기줄" 을 뜻합니다.
    => 그래서 큐를 설명하는 강의를 보면 줄서서 기다리는 사람들을 예시로 많이 설명하는 것을 볼 수 있습니다.
  • 먼저 집어 넣은 데이터가 먼저 나오는 FIFO(First In First Out)구조로 저장하는 형식을 말합니다.
  • 일렬로 늘어선 사람들로 이루어진 줄을 말하기도 하며, 먼너 주을 선 사람이 먼저 나가는 것과 같습니다.

 

 

 

 

 

 

💻 구현한 소스코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.StringTokenizer;

public class Main {
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int n = Integer.parseInt(br.readLine());
        StringTokenizer st;
        StringBuilder sb = new StringBuilder();

        customQueue<Integer> customQueue = new customQueue<>();

        for (int i = 0; i < n; i++) {
            st = new StringTokenizer(br.readLine());
            String command = st.nextToken();

            switch (command) {
                case "push":
                    customQueue.push(Integer.parseInt(st.nextToken()));
                    break;

                case "pop":
                    sb.append(customQueue.pop()).append("\n");
                    break;

                case "size":
                    sb.append(customQueue.size()).append("\n");
                    break;

                case "empty":
                    sb.append(customQueue.empty()).append("\n");
                    break;

                case "front":
                    sb.append(customQueue.front()).append("\n");
                    break;

                case "back":
                    sb.append(customQueue.back()).append("\n");
                    break;
            }
        }
        System.out.println(sb);
    }
}

class customQueue<T> {

    transient  Node<T> first;
    transient  Node<T> last;
    int size = 0;


    public void push(T e) {
        final Node<T> l = last;
        final Node<T> newNode = new Node<>(l, e, null);
        last = newNode;
        if (l == null)
            first = newNode;
        else
            l.next = newNode;
        size++;
    }

    public String pop() {
        if (size ==0) return "-1";
        final Node<T> f = first;
        final T element = f.item;
        final Node<T> next = f.next;
        f.item = null;
        f.next = null;
        first = next;
        if (next == null)
            last = null;
        else
            next.prev = null;
        size--;
        return element.toString();
    }

    public int size() {
        return size;
    }

    public int empty() {
        if (size == 0) return 1;
        else return 0;
    }

    public T front() {
        if (first == null) return (T) "-1";
        return first.item;
    }

    public T back() {
        if (last == null) return (T) "-1";
        return last.item;
    }


    private class Node<E> {
        E item;
        Node<E> next;
        Node<E> prev;

        Node(Node<E> prev, E element, Node<E> next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }
}

 

반응형

 

🔗 참고자료

  • mybatis 공식문서<매퍼주입> => 링크
  • 블로그 <연어 좋아하는 개발자> => 링크
  • 블로그 <Eseak> => 링크
  • 블로그 <하진쓰의 사이버기술블로그> => 링크
  • 블로그 <당근당근이> => 링크

 

✍ 공부하게 된 계기

마이바티스를 세팅하면서 매퍼주입을 하지 않아서 어플리케이션의 시작이 중단되었습니다.

블로그를 보고 쉽게 해결은 했지만, MapperScan이 무엇인지 조금 더 살펴보기로 했습니다.

 

 

 

 

 

 

❗ 에러

이미지 클릭 시 확대

  • Mapper로 지정한 파일들을 찾지 못하고 그냥 어플리케이션이 종료된다.
  • Mapper Interface를 스프링 빈으로 주입받아야 DB에 접근이 가능한데 처리를 안해놔서 생긴 문제였다.

 

 

 

 

 

🔍 해결한 방법

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@MapperScan(value = {"com.example.bbakmemo.mapper"})
@SpringBootApplication
public class BbakmemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(BbakmemoApplication.class, args);
    }

}
  • value에 적혀있는 곳에 @Mapper 어노테이션이 붙은 인터페이스만 Mapper로 로드가 된다.
  • @MapperScan 어노테이션은 mapper.xml 파일들이 바라볼 기본 패키지 위치를 지정해 줍니다.
    => 모든 마이바티스 애플리케이션은 SqlSessionFactory 인스턴스를 사용합니다.
    이후 빈을 생성하여 sessionFactory에  Mapper의 위치를 지정해 줍니다.
반응형

🔗 참고자료

  • 책 [자바 웹 프로그래밍 Next Step] - 저자 박재성 => 링크

 

 

✍ 공부하게 된 계기

자바와 좀 더 친해지고 웹 개발에 전반적인 내용을 이해하고 싶었습니다.

스프링 프레임워크의 도움을 받는 게 아닌, 직접 자바단에서 개발을 해서 Request를 날려보고,

스프링이 해주고 있던 일들을 조금 더 자세하게 알고 싶었습니다.

위와 같은 내용을 공부하기 위해 [자바 웹 프로그래밍 Next Step]은 정말 좋은 책이라는 걸 느꼈습니다.

아직 3장까지 읽어보지는 않았지만 다양한 과제들이 있고 힌트들이 있습니다.

 

 

 

과제 구현한 깃허브 레포지토리 => 링크

 

 

 

1️⃣ 요구사항 1단계

 

과제: index.html 응답하기

  • http://localhost:8080/index.html로 접속했을 때 webapp 디렉토리의 index.html 파일을 읽어 클라이언트에 응답한다.

 

1차로 구현한 소스코드

// ...생략...
public class RequestHandler extends Thread {

// ...생략...
    public void run() {
        log.debug("New Client Connect! Connected IP : {}, Port : {}", connection.getInetAddress(),
                connection.getPort());

        try (InputStream in = connection.getInputStream(); OutputStream out = connection.getOutputStream()) {

            InputStream inputStream = new FileInputStream("webapp/index.html");

            BufferedReader br = new BufferedReader(new InputStreamReader(inputStream));

            StringBuffer sb = new StringBuffer();

            String tempStr;
            while ( (tempStr = br.readLine()) != null ) {
                sb.append(tempStr + "\r\n");
            }

            DataOutputStream dos = new DataOutputStream(out);
            response200Header(dos, sb.toString().getBytes().length);
            responseBody(dos, sb.toString().getBytes());
        } catch (IOException e) {
            log.error(e.getMessage());
        }
    }

// ...생략...
}

 

index.html은 아래의 캡처사진처럼 출력이 완료되었습니다.
하지만 index.html로 접속 했을 때 해당 index.html이 출력되도록은 아직 구현 안 했습니다.

 

 

 

처음에 CSS가 적용이 안돼서 뭔가 문제가 있는 줄 알았습니다.

하지만 크롬 개발자 도구함을 열어보니까 css파일(styles.css)은 정상적으로 불러와지고 있었습니다(아래 사진).

 

 

 

 

최종적으로 구현한 소스코드

    public void run() {
        log.debug("New Client Connect! Connected IP : {}, Port : {}", connection.getInetAddress(),
                connection.getPort());

        try (InputStream in = connection.getInputStream(); OutputStream out = connection.getOutputStream()) {

            InputStreamReader inputStreamReader = new InputStreamReader(in);
            BufferedReader bufferedReader = new BufferedReader(inputStreamReader);

            String line = bufferedReader.readLine();

            if (line == null) { return; }


            String[] tokens = line.split(" ");

            String url = tokens[1];

            log.debug("connect URL : {}", url );

            byte[] bytes = Files.readAllBytes(new File("./webapp" + url).toPath());

            DataOutputStream dos = new DataOutputStream(out);
            response200Header(dos, bytes.length);
            responseBody(dos, bytes);

        } catch (IOException e) {
            log.error(e.getMessage());
        }
    }

 

  • 비록 힌트를 보고 했지만, localhost:8080/index.html 접속할 때 해당 파일이 불러와지도록 구현했습니다.
  • InputStreamReader를 통해 소켓의 InputStream을 읽었습니다.
  • InputStreamReader로 읽은 것을 BufferedReader 클래스 객체 생성 시 생성자에 집어넣습니다.
  • bufferedReader.readLine()을 통해 HTTP 요청 첫 번째 라인을 읽어 String 자료형 line 변수에 할당한다.
    => 첫번째 라인에 HTTP method와 요청 url이 담겨있다.
    ex) GET /index.html HTTP/1.1
  • line 변수를 split 함수를 사용해서 공백을 기준으로 나눠 String 배열인 tokens 변수에 할당합니다.
  • tokens 변수의 [1] 번째 인덱스에 있는 요청 URL만 String 자료형 url 변수에 할당합니다.
  • 요청 URL를 한번 로그로 띄어봅니다. => 클라이언트에서 요청한 URL 모니터링용
  • Files와 File 클래스를 사용해서 ./webapp 경로에 있는 파일들을 byte[] 형식으로 전부 읽어옵니다.
  • 읽어온 bytes 값을 DataOutputStream 클래스를 사용해 write 합니다.

 

 

정리

클라이언트로부터 받은 HTTP 리퀘스트 메시지를 InputStream에 받고 InputStreamReader로 읽습니다.

BufferedReader를 사용해 HTTP 리퀘스트 메시지를 한줄씩 읽습니다.

받은 HTTP 리퀘스트 메시지 내용에 따라 적절한 처리를 실행하여 Outputstream과 DataOutputStream을 사용해서
응답 메시지를 만들고, wrtie를 통해 이것을 클라이언트에 반송합니다.

 

 

 

구현하면서 느낀 점

  • 소켓을 연결하고 클라이언트와 서버가 HTTP Request, Response를 주고받는 과정을 알게 되었습니다.
  • 다양한 입출력 클래스를 알게 되었고 아직 입출력에 대해 모르는 게 많다는 걸 느꼈습니다.
  • 힌트를 봤지만 제대로 이해하지 못했습니다.
    => 힌트를 읽고서도 그걸 코드로 구현하지 못함
    => 계속 고민하면서 구현하다가 신기하게도 조금 쉬고 다시 코드를 보니까 쉽게 해결이 되었습니다.

 

 

2️⃣ 요구사항 2단계

GET 방식으로 회원가입하기

  • "회원가입" 메뉴를 클릭하면 http://localhost:8080/user/form.html으로 이동하면서 회원가입할 수 있다.
  • 회원가입을 하면 다음과 같은 형태로 사용자가 입력한 값이 서버에 전달된다.
    /user/create?userId=javajigi&password=password&name=Gaemi&email=gaemi@daum.net
  • HTML과 URL을 비교해 보고 사용자가 입력한 값을 파싱(문자열을 원하는 형태로 분리하거나 조작하는 것을 의미)해
    model.user 클래스에 저장한다.

 

 

구현한 소스코드

 

RequestHandler 클래스 run() 메소드

public void run() {
        log.debug("New Client Connect! Connected IP : {}, Port : {}", connection.getInetAddress(),
                connection.getPort());

        try (InputStream in = connection.getInputStream(); OutputStream out = connection.getOutputStream()) {

            InputStreamReader inputStreamReader = new InputStreamReader(in, "UTF-8");
            BufferedReader bufferedReader = new BufferedReader(inputStreamReader);

            String line = bufferedReader.readLine();

            if (line == null) { return; }

            String[] tokens = line.split(" ");

            String uri = tokens[1];

            if (userSignupCheck(uri)) return;

            log.debug("connect URL : {}", uri);

            Path path = Paths.get("./webapp", uri);

            if (! Files.exists(path)) {
                log.debug("Path : {} not exists", path);
                return;
            }

            File file = path.toFile();
            path.isAbsolute();

            String fileExtension = FilenameUtils.getExtension(file.getName());
            byte[] bytes = Files.readAllBytes(file.toPath());

            DataOutputStream dos = new DataOutputStream(out);
            response200Header(dos, bytes.length, fileExtension);
            responseBody(dos, bytes);
        } catch (IOException e) {
            log.error(e.getMessage());
        }
    }
  • if (userSignupCheck(uri)) return;
    userSignupCheck() 메소드로 사용자가 입력한 값을 파싱 및 저장합니다.

 

 

RequestHandler 클래스 userSignupCheck() 메소드

 private static boolean userSignupCheck(String uri) {
        if (uri.contains("/create")) {

            String userInfoQueryString = uri.split("\\?")[1];

            UserService userService = new UserService();
            userService.signupUser(userInfoQueryString);

            return true;
        }
        return false;
    }
  • /user/create?userId=gaemi&password=1234&name=Gaemi&email=gaemi@daum.net
    사용자가 입력한 값은 위와 같이 서버에 전달된다.
  • uri에 /create가 포함되어있는지 확인한다.
    => 포함되어 있으면 split() 메소드를 사용해서 "?" 를 기준으로 나눕니다.
    => 나눈 값에서 유저 정보가 담긴 queryString 값만 String 자료형인 userInfoQueryString 변수에 저장합니다.
  • 저장된 값을 UserService 클래스의 signupUser() 메소드를 실행시킵니다.

 

 

 

UserService클래스

public class UserService {

    public boolean signupUser(String userInfoqueryString) {

        Map<String, String> userInfo = HttpRequestUtils.parseQueryString(userInfoqueryString);

        String userId = userInfo.getOrDefault("userId", "");
        String password = userInfo.getOrDefault("password", "");
        String name = userInfo.getOrDefault("name", "");
        String email = userInfo.getOrDefault("email", "");

        User user = new User(userId, password, name, email);

        DataBase.addUser(user);

        return true;
    }
}
  • RequestHandler 클래스에 회원가입 로직을 추가하는 것보다는 분리하는 게 좋겠다고 생각해서
    UserService를 만들어서 분리를 했습니다.
  • 저자인 자바지기님이 미리 만들어놓으신 HttpRequestUtils 클래스의 parseQueryString() 메소드를 사용해서,
    쿼리 스트링의 값을 분리 "&" 기준으로 분리를 해서 Map<String, String>을 리턴 받습니다.
  • 리턴 받은 값을 userInfo 객체에 할당하고 유저의 정보를 각 String 변수에 할당합니다.
    => 여기서 유저가 값을 입력하지 않으면 빈 문자열을 집어넣도록 했습니다.
    => 참고로 유저 정보 중에 빈 값 관련 처리를 아직 안 했습니다.
  • 입력받은 유저 정보를 바탕으로 User 객체를 생성합니다.
  • 저자인 자바지기님이 만들어놓은 DataBase 클래스 addUser() 메소드를 사용해서 저장합니다.

 

 

구현하면서 느낀 점

  •  예외 처리를 제대로 안 해놔서 조금 아쉬웠습니다.
    => 책에서 다음 과제가 GET 방식이 아닌 POST 방식으로 값을 받아온다. 그때는 더 견고하게 코드를 짤 예정입니다.
  • 과제를 진행하면 할수록 코드가 조금 더러워지고 있습니다.
    => 리팩터링은 틈틈이 계속 진행해야겠다고 생각했습니다.
  • 다른 사람들이 제출한 코드를 직접 보고 나의 코드를 보완해봐야겠다고 생각했습니다.

 

3️⃣ 4️⃣ 요구사항 3, 4

POST 방식으로 회원가입하기

  • http://localhost:8080/user/form.html 파일의 form 태그 method를 get에서 post로 수정한 후 
    회원가입이 정상적으로 동작하도록 구현한다.

 

더보기

구현한 소스코드

RequestHandler 클래스의 run() 메소드

    public void run() {
        log.debug("New Client Connect! Connected IP : {}, Port : {}", connection.getInetAddress(),
                connection.getPort());

        try (InputStream in = connection.getInputStream(); OutputStream out = connection.getOutputStream()) {

            InputStreamReader inputStreamReader = new InputStreamReader(in, "UTF-8");
            BufferedReader br = new BufferedReader(inputStreamReader);

            ArrayList<String> httpHeaderList = new ArrayList<>();

            String line;
            while ( !(line = br.readLine()).equals("") ) {
                httpHeaderList.add(line);
            }

            String firstHttpHeader = httpHeaderList.get(0);

            if (firstHttpHeader == null) { return; }

            String[] tokens = firstHttpHeader.split(" ");

            String httpMethod = tokens[0];
            uri = tokens[1];
            statusCode = "200";
            description = "OK";


            if (httpMethod.equals("POST")) {

                int contentLength = Integer.parseInt(httpHeaderList.get(3).split(" ")[1]);
                String userInfoQueryString = IOUtils.readData(br, contentLength);

                uri = userSignupCheck(userInfoQueryString);
                statusCode = "302";
                description = "Redirection";

            } else if (httpMethod.equals("GET")) {

                if (uri.contains("/create")) {
                    String userInfoQueryString = uri.split("\\?")[1];
                    uri = userSignupCheck(userInfoQueryString);
                }
            }



            log.debug("connect URL : {}", uri);

            Path path = Paths.get("./webapp", uri);

            // ToDo : 해당 파일이 없으면 에러 페이지로 이동되도록 구현 필요
            if (! Files.exists(path)) {
                log.debug("Path : {} not exists", path);
                return;
            }

            File file = path.toFile();
            path.isAbsolute();

            String fileExtension = FilenameUtils.getExtension(file.getName());
            byte[] bytes = Files.readAllBytes(file.toPath());

            DataOutputStream dos = new DataOutputStream(out);
            response200Header(dos, bytes.length, fileExtension);
            responseBody(dos, bytes);
        } catch (IOException e) {
            log.error(e.getMessage());
        }
    }

 

  • 클라이언트로부터 받은 HttpHeader의 값을 각 라인별로 ArrayList<String>에 저장했습니다.
  • ArrayList에서 0번째 인덱스의 값을 " " 을 기준으로 나눠 int 배열인 tokens[] 에 할당한다.
  • Http Header 첫 줄에서 uri와 HttpMethod 문자열을 저장합니다.
  • Response로 보낼 statuscode와 description에 값을 넣습니다.
  • 여기서 HttpMethod의 문자열이 POST인지 GET인지 확인하는 조건문을 넣어놨다.
    => POST이면 HttpHeader에 있는 ContentLength 값을 찾아서 int 변수에 할당한다.
    * Body의 값을 어떻게 가져올까 계속 고민했었는데 저자인 자바지기님이 구현한 함수를 사용하면 됐었다.
    IOUtils클래스의 readdata() 메소드를 사용해서 body의 값을 파싱한다.
  • 받아온 Body의 값을 userSignupCheck() 함수에 인자로 넣고 userService클래스의 signupUser() 메소드로 DB에 저장
  • 완료가 되면 redirect url인 index.html의 값을 받아와 uri 변수에 할당한다.
  • Response를 보낼 때 Http Header에 담을 status code와 description 값을 할당한다.
    => 여기서 redirect를 하기 위해서는 status code에 302를 넣어줘야 한다. => 위키
    => Description에 Redirection 문자열을 넣어준다.

 

 

RequestHandler 클래스의 response200Header() 메소드

    private void response200Header(DataOutputStream dos, int lengthOfBodyContent, String fileExtension) {
        try {
            dos.writeBytes("HTTP/1.1 " + statusCode + " " + description + "\r\n");
            dos.writeBytes("Content-Type: text/" + fileExtension + ";charset=utf-8\r\n");
            dos.writeBytes("Content-Length: " + lengthOfBodyContent + "\r\n");

            if (statusCode.equals("302")) {
                dos.writeBytes("Location: " + uri);
            }
            dos.writeBytes("\r\n");
        } catch (IOException e) {
            log.error(e.getMessage());
        }
    }
  • 위와 같이 statusCode가 302이면 Location 값을 추가했다.
    ex) redirect 할 uri가 추가된다 => index.html

 

 

RequestHandler 클래스의 userSignupCheck() 메소드

    private static String userSignupCheck(String userInfoQueryString) {
        UserService userService = new UserService();
        return userService.signupUser(userInfoQueryString);
    }
  • GET과 POST 메소드에도 사용하기 위해 userSignupCheck() 함수의 라인수가 줄어들었다.

 

 

구현하면서 느낀 점

  • 리팩터링을 제대로 안 하고 구현만하고 있어서 코드가 점점 더러워지고 있다.
    => 다음 과제를 하기 전에 리팩터링을 하는 시간을 한번 가지자.
  • HTTP 책을 제대로 읽었다고 생각했지만 모르는 게 너무 많았다.
    => HTTP Header에 들어있는 값이 무엇이고 어떤 의미를 가지는지 알 수 있도록 책을 한번 더 읽어보자.
  • response200Header에 302를 처리하는 로직이 들어가 있다.
    => 분리를 꼭 하자.
    => 함수를 분리를 제대로 하지 않아서 중복되는 코드가 꽤 많아졌다. 리팩터링을 꼭 하자.

 

 

리팩터링한 소스코드

*저자인 자바지기님이 작성한 코드를 참고해서 코드가 거의 비슷합니다.

 

ReuqestHandler 클래스의 run() 메소드

// ... 생략 ...
InputStreamReader inputStreamReader = new InputStreamReader(in, "UTF-8");
BufferedReader br = new BufferedReader(inputStreamReader);

String requestLine = br.readLine();

if (requestLine == null) return;

log.debug("request line : {}", requestLine);

String[] tokens = requestLine.split(" ");
String uri = getDefaultUrl(tokens[1]);

int contentLength = 0;
boolean logined = false;
while (!requestLine.equals("")) {
    requestLine = br.readLine();
    log.debug("header : {} ", requestLine);

    if (requestLine.contains("Content-Length")) {
        contentLength = getContentLength(requestLine);
    }

    if (requestLine.contains("Cookie")) {
        logined = isLogin(requestLine);
    }
}

if ("/user/create".equals(uri)) {
    String body = IOUtils.readData(br, contentLength);
    Map<String, String> userInfo = HttpRequestUtils.parseQueryString(body);
    User user = new User(userInfo.get("userId"), userInfo.get("password"),
            userInfo.get("name"), userInfo.get("email"));
    DataBase.addUser(user);

    DataOutputStream dos = new DataOutputStream(out);
    response302Header(dos);
    
 // ... 생략 ...
  • 클라이언트의 Request에 첫 줄만 BufferReader로 읽어온다.
    => 여기서 첫 줄이 null이면 run() 메서드를 종료한다.
  • Http Request 첫 라인에서 uri만 String 변수에 할당합니다.
  • while문을 사용해서 BufferdReader에 있는 request가 빈 문자열이 나올 때까지 읽습니다.
    => "Content-Length"가 포함된 문자열은 getContentLength() 메서드를 사용해 값을 int 자료형에 할당합니다.
    => "Cookie"가 포함된 문자열은 로그인 여부를 확인하는 isLogin() 메서드를 사용해서 로그인 여부를 확인합니다.
  • uri가 "/user/create"와 같은지 확인합니다.
    => 같으면 자바지기 저자가 만들어놓은 IOUtils.readData() 메서를 사용해서 body의 값을 읽어 String 변수에 할당
    => 자바지기 저자가 만들어놓은 HttpRequestUtils.parseQueryString()을 사용해서 body값에서 request에 담긴 유저정보를 Map<String, Strin> 자료형에 할당합니다.
    => userInfo에 있는 데이터를 가지고 User 객체를 만들고 저자가 만들어놓은 임시 DataBase에 추가합니다.(인메모리)
  • DataOutputStream의 객체를 만들어서 response302Header() 메서드의 인자에 넣습니다.

 

 

RequestHandler 클래스의 getContentLength() 메소드

private int getContentLength(String requestLine) {
    String[] contentLengthLine = requestLine.split(":");
    return Integer.parseInt(contentLengthLine[1].trim());
}

 

  • Content-Length 개체가 담긴 문자열을 ":" 기준으로 split 한다.
    ex) Content-Length: <length>
  • split해서 할당된 문자열의  1번째 인덱스에 있는 값을 trim 해주고 int형으로 파싱해서 리턴한다.

 

 

 

RequestHandler 클래스의 response302Header() 메소드

private void response302Header(DataOutputStream dos) {
    try {
        dos.writeBytes("HTTP/1.1 302 Redirect \r\n");
        dos.writeBytes("Location: /index.html \r\n");
        dos.writeBytes("\r\n");
    } catch (IOException e) {
        log.error(e.getMessage());
    }
}
  • Redirect를 하기 위한 헤더를 세팅한다.
  • Location에 Redirect를 할 uri를 추가한다.

 

 

 

리팩터링하면서 느낀점

  • 다른 사람이 구현한 코드를 보고 분석하는것도 엄청 중요하다는걸 한번 더 느꼈습니다.
  • 저자가 최대한 깔끔하게 작성한 코드이지만, 구조적으로 문제가 있다는 것을 느끼게 되었습니다.
    => run 메소드가 점점 방대해 질 수 밖에 없는 구조
    => 조건문이 계속 늘어나고 나중에 리팩터링 하거나 기능을 수정하기 너무 힘들어진다.
    => 하드코딩하는 부분이 늘어날 수 밖에 없다.
  • 남이 구현한 코드를 갔다가 사용했어도 해당 코드를 직접 뜯어보고 글로 작성해보자.

 

 

 

 

 

5️⃣ 요구사항 5단계

로그인하기

  • "로그인" 메뉴를 클릭하면 http://localhost:8080/user/login.html으로 이동해 로그인할 수 있다.
  • 로그인이 성공하면 /index.html로 이동하고, 로그인이 실패하면 /user/login_failed.html로 이동해야 한다.
  • 앞에서 회원가입한 사용자로 로그인할 수 있어야 한다.
  • 로그인이 성공 하면 쿠키를 활용해 로그인 상태를 유지할 수 있어야 한다.
  • 로그인이 성공할 경우 요청 헤더의 Cookie 헤더 값이 logined=true,
    로그인이 실패하면 Cookie 헤더 값이 logined=false로 전달되어야 한다.

 

 

구현한 소스코드

Requesthandler 클래스의 httpMethodCheck() 메소드

    private void httpMethodCheck(
            String httpMethod,
            ArrayList<String> httpHeaderList,
            BufferedReader br
    ) throws IOException {

        if (httpMethod.equals("POST")) {

            int contentLength = Integer.parseInt(httpHeaderList.get(3).split(" ")[1]);
            String userInfoQueryString = IOUtils.readData(br, contentLength);

            if (uri.contains("/user/login")) {

                if (userService.userLogin(userInfoQueryString)) {
                    uri = "/index.html";
                    cookie = "Set-cookie: logined=true";
                } else {
                    uri = "/user/login_failed.html";
                    cookie = "Set-cookie: logined=false";
                }
            } else {
                uri = userService.signupUser(userInfoQueryString);
            }
            statusCode = "302";
            description = "Redirection";

        } else if (httpMethod.equals("GET")) {
            statusCode = "200";
            description = "OK";

            if (uri.contains("/create")) {
                String userInfoQueryString = uri.split("\\?")[1];
                uri = userService.signupUser(userInfoQueryString);
            }
        }
    }

 

  • 클라이언트에서 request를 POST method로 서버로 보낼 때 /user/login URI이면 로그인 로직을 처리하도록 했습니다.
  • "Set-cookie: key=value" 문자열을 클라이언트에게 Response를 보낼 때 헤더에 담아서 보냅니다.
  • 로그인이 정상적으로 작동하면 index.html 페이지로 redirect 되도록 했습니다.
  • 로그인이 정상적으로 작동되지 않으면 /user/login_failed.html로 redirect 되도록 했습니다.

 

 

UserService 클래스의 userLogin() 메소드

    public boolean userLogin(String userInfoQueryString) {

        Map<String, String> userInfo = HttpRequestUtils.parseQueryString(userInfoQueryString);

        String userId = userInfo.getOrDefault("userId", "");

        User user = DataBase.findUserById(userId);

        if (user == null) {
            return false;
        }

        return true;
    }
  • 로그인을 처리하는 로직을 Service 레이어에 따로 분리해놨습니다.
    => 분리를 해서 RequestHandler의 코드가 좀 줄어들기는 했는데 여러가지 에로사항이 있었습니다.
    * 구현하면서 느낀점에 에로사항 정리

 

 

구현하면서 느낀 점

  • 스프링에서는 Service, Controller 분리가 어렵지 않았었습니다.
    이렇게 분리 처리를 하는 것에서 스프링 프레임워크에서 많은 것들을 대신 해준다는 것을 알게되었습니다.
  • 스프링과 같은 아키텍쳐로 분리를 해보려고 하고 있습니다.
    => 지금은 RequestHandler의 코드가 너무 길어지고 복잡해지고 있습니다.
    => 아직은 코드가 많이 더러운데 하나씩 리팩토링을 하면서 바꿔갈 예정입니다.
  • 조건문(if)을 너무 많이 사용하게 되고 가독성을 떨어트리는 것 같습니다.
    => 책에도 적혀있는 내용처럼 else()를 사용하지 않고 구현하는 방식으로 진행해볼 예정입니다.
  • 아직 많이 부족한게 많지만 그래도 구현이 되어서 신기했습니다.
    그리고 기존에는 이해가 안되었던 MVC의 아키텍쳐에 대해서 점점 알아가게 되었습니다.

 

 

 

6️⃣ 요구사항 7단계

사용자 목록 출력

  • 접근하고 있는 사용자가 "로그인" 상태일 경우(Cookie 값이 logined=true) http://localhost:8080/user/list로 접근 했을 때
    사용자 목록을 출력한다.
    => 만약 로그인 하지 않은 상태라면 로그인 패이지(login.html)로 이동한다.

 

 

구현한 소스코드

RequestHandler 클래스 httpMethodCheck 메소드

    private void httpMethodCheck(
            String httpMethod,
            Map<String, String> httpHeaderMap,
            BufferedReader br
    ) throws IOException {
    
    // ... 생략 ...
    
        } else if (httpMethod.equals("GET")) {
            statusCode = "200";
            description = "OK";

            if (uri.contains("/create")) {
                String userInfoQueryString = uri.split("\\?")[1];
                uri = userService.signupUser(userInfoQueryString);
            } else if (uri.contains("/list.html")) {
                Map<String, String> cookies= HttpRequestUtils.parseCookies(httpHeaderMap.get("Cookie"));
                boolean isLogin = Boolean.parseBoolean(cookies.get("logined"));

                if (!isLogin) {
                    statusCode = "302";
                    description = "Redirection";
                    uri = "/index.html";
                }

            }
        }
    }
  • httpHeader에서 Cookie의 값을 저자인 자바지기님이 만든 HttpRequestUtils 클래스의 parseCookies 메소드를 사용해 파싱한다.
  • 파싱한 값은 Map<String, String> 으로 리턴 받는다.
  • 리턴 받은 값에서 key "logined"의 value를 가져와 boolean 자료형으로 파싱한다.
  • 파싱한 boolean 값으로 로그인 여부를 확인한다.
  • 로그인이 되어있지 않으면 /index.html 로 리다이렉트 시킨다.

 

 

구현하면서 느낀 점

  • 구현을 하는데 시간이 꽤 적게 소요되었습니다.
  • 요구사항이 그렇게 어렵지는 않아서 쉽게 끝낼 수 있었습니다.
    => 저자인 자바지기님이 cookie 값을 파싱하기 쉽도록 미리 구현해 놓은게 있어서 더욱 쉬웠습니다.
  • httpHeader의 값을 Map 자료형으로 파싱하는 방식으로 리팩터링을 진행했습니다.
    => 아직 RequestHandler의 코드가 너무 복잡하고 결합도가 너무 높습니다.
    => 과제 완료 후 리팩터링을 우선으로 진행 예정

 

 

 

7️⃣ 요구사항 7단계

CSS 지원하기

  • 지금까지 구현한 소스코드는 CSS 파일을 지원하지 못하고 있다.
    CSS 파일을 지원하도록 구현한다.

 

 

구현한 소스코드

 

RequestHandler 클래스 Run() 메서드

public void run() {
    // ... 생략 ...
        } else if (uri.endsWith(".css")) {
            responseCssResource(out, uri);
        } else {
            responseResource(out, uri);
        }
    } catch (IOException e) {
        log.error(e.getMessage());
    }
}

    // ... 생략 ...
  • 요청 uri의 확장자가 .css 이면 responseCssResource() 함수가 실행된다.

 

ReqiestHandler 클래스 responseCssResource() 메서드

private void responseCssResource(OutputStream out, String url) throws IOException {
    DataOutputStream dos = new DataOutputStream(out);
    byte[] body = Files.readAllBytes(new File("./webapp" + url).toPath());
    response200CssHeader(dos, body.length);
    responseBody(dos, body);
}
  • webapp 폴더에 있는 css 파일의 바이트 배열로 읽어서 헤더와 바디에 넣어주고 클라이언트에게 응답을 보낸다.

 

 

RequestHandler클래스 response200CssHeader() 메서드

private void response200CssHeader(DataOutputStream dos, int lengthOfBodyContent) {
    try {
        dos.writeBytes("HTTP/1.1 200 OK \r\n");
        dos.writeBytes("Content-Type: text/css;charset=utf-8 \r\n");
        dos.writeBytes("Content-Length: " + lengthOfBodyContent + "\r\n");
        dos.writeBytes("\r\n");
    } catch (IOException e) {
        log.error(e.getMessage());
    }
}
  • 위와 같은 양식으로 header 내용을 write한다.

 

 

 

구현하면서 느낀 점

  • 생각보다 빨리 구현해서 놀랐습니다.
  • 사전에 네트워크 관련 공부를 한게 크게 도움이 되었습니다.
    => 책 [그림으로 배우는 HTTP & Network] => yes24링크
  • 2단계를 먼저 진행해야 하는데 CSS를 꼭 적용해보고 싶어서 7단계로 훌쩍 넘어와버렸습니다.
    앞에 구현하는 것과 크게 영향이 없어보여서 다행이였습니다.

 

 

 

8️⃣ 과제를 하면서 궁금한 것들

 

 

❓ Content-Length를 넣는 이유는

  • Content-Length는 수신자에게 보내지는, 바이트 단위를 가지는 개체 본문의 크기를 나타냅니다.

 

❓ DataOutputStream이란

 

 

반응형

🔗 참고자료

  • 블로그 <어제보다 더 나은 개발자> spring-boot init sql (schema.sql) 이란? => 링크

 

✍ 공부하게 된 계기

JPA를 사용했을 때는 ddl-auto로 model에 설계한 대로 자동으로 처리를 해줬다.

그런데 마이바티스를 사용하면서 schema.sql이라는 것으로 초기에 DB에 쿼리문을 날리도록 한다는걸 알게되었다.

마이바티스를 처음 사용할 때 data.sql 파일에다가 ddl을 작성하는 실수도 했었다.

그래서 schema.sql과 data.sql에 대해 좀 더 찾아보고 정리하게 되었다.

 

 

 

 

schema.sql

  • 어플리케이션을 로딩 시 쿼리문을 실행하고 싶을 경우 초기에 db에 쿼리문을 날릴 때 유용하다.
  • 스프링 이용할 때 schema.sql 파일을 사용해서 초기 스키마를 생성할 수 있다.
  • schema.sql은 ddl을 작성한다
  • 스키마 : 데이터베이스를 구성하는 데이터 개체(Entity), 속성(Attribute), 관계(Relationship) 및 데이터 조작 시 데이터 값들이 갖는 제약 조건등에 관해 전반적으로 정의한다.

 

 

 

data.sql

  •  스프링 부트 초기 DB 데이터를 삽입하는 쿼리문을 작성해놓는 곳이다.
  • data.sql은 dml을 작성한다. 

 

 

추가내용

  • Spring boot 2.4 이하 버전의 경우 **src/resource/** 하위에 schema.sql이나 data.sql파일이 있는 경우 자동 실행
    => 실행되지 않을 경우 spring.datasource.initialization-mode=always 를 세팅한다.
  • Spring boot 2.5 이상 버전의 경우 자동으로 실행되지 않아 spring.sql.init.mode=always 프로퍼티 값을 입력
  • JPA와 함께 사용하는 경우 Hibernate의 ddl-auto 기능과 함께 schema.sql을 실행하면 schema.sql이 먼저 실행되고
    Hibernate의 기능이 수행된다.
    => 이 순서를 바꾸고 싶으면 spring.jpa.defer-datasource-initialization=true 옵션을 입력한다.
    => schema.sql로만 설정하고 싶으면 spring.jpa.hibernate.ddl-auto=none 옵션을 입력한다.
반응형

🔗 참고자료

  • 블로그 <Wonit> String과 String Constant Pool => 링크
  • 블로그 <doohong's blog> 자바의 기본 개념 정리-4.String pool => 링크

 

✍ 공부하게 된 계기

'자바의신' 이라는 책을 읽고 String에 대한 챕터를 읽다가 위의 내용들이 나왔습니다.

이번에 자바를 좀 더 깊게 공부하기 위해 모르는 단어를 직접 찾아보고 정리하기로 했습니다.

단어에 대해서 찾다보면 부가적으로 배우는 것들도 많아서 꼭 해야할 것들이라고 생각합니다.

 

 

 

String은 immutable(불변) 타입

String 객체의 값은 변경할 수 없다.

String text = "my Text";
text = "my new Text";

위 예제와 같이 String 자료형인 text 변수를 최초로 "my Text"로 할당했다.

그 다음줄을 보면 text 변수에 "my new Text" 값을 변경 시킨 것처럼 보인다.

 

하지만 실제로는 메모리에 "my new Text" 를 다시 만들고 text를 다시 참조하게 된다.

 

  • 자바에서 String은 constant 형태로 한 번 생성이 된다면 update 라는 개념이 사라진다.
  • 값을 수정할려고 하면 새로운 객체를 생성하고 그 값을 재할당해야 한다.

 

 

 

자바에서 String을 immutable 하게 한 이유 3가지

  • 캐싱
    JVM이 String Constant Pool 이라는 영역을 만들고 문자열들을 Constant화 하여 다른 변수 혹은 객체들과 공유하게 되는데, 이 과정에서 데이터 캐싱이 일어나고 그 만큼 성능적 이득을 취할 수 있다.

  • 동기화
    데이터가 immutable 하다면 Multi-Thread 환경에서 동기화 문제가 발생하지 않기 때문에 더욱 safe한 결과를 낸다.

  • 보안(Security)
    여러 참조 변수가 같은 String 객체를 참조하고 있다고 하더라도 안전하다.
    String 객체를 누가 조작할 수 없기 때문이다.
    그래서 원본 String 객체가 저장된 주소 자체를 넘겨도 안전하다.
    전달받은 곳에서 원본 값을 직접 읽을수 있으나 조작할 수는 없기 때문이다.
    예를들어 데이터베이스로 유저의 id나 비밀번호를 전송할 때 String 변수가 제 3자(해커)가 변경하지 못하도록 한다.

 

 

❓ String pool이란?

  • String은 재사용을 고려해서 Heap 영역에 내에 문자열 상수의 Pool 을 유지한다.
  • 해당 Pool로 사용자가 정의한 변수가 가지고 있는 value 들을 담게 된다.
  • String Constant Pool 을 이용하기 위해서는 Literal을 이용해 생성해야 한다.
    new 연산자를 통해 String을 생성하면 Heap 영역에 존재하게 된다.
    리터럴을 이용할 경우 String constant pool 이라는 영역에 존재하게 된다.

 

 

 

String pool의 위치

  • java 6 버전까지는 Perm 영역에 있었다.
    - Perm 영역은 고정된 사이즈이며 Runtime에 사이즈가 확장되지 않는 문제 발생
  • intern 되는 String 값이 커지면 OutOfMemoryException을 발생시킬 수 있었고,
    그에 따라서 java7 버전은 Heap 영역으로 String pool의 위치를 변경하였다.
반응형

🔗 참고자료

  • 블로그 <Taes-k DevLog> Tomcat, Spring MVC의 동작 과정 => 링크
  • 블로그 <heejeong Kwon> [SpringMVC] Spring MVC Framework란 => 링크
  • 블로그 <진열사랑> [tomcat] 동작원리 => 링크
  • 블로그 <일단은 내 이야기> [Java]Apache? Tomcat?? 둘이 무슨 차이지? => 링크

 

✍ 공부하게 된 계기

Dispatcher Servlet을 직접 구현해보면서 Tomcat과 Spring MVC의 동작 과정을 알고 있어야 구현하는데 도움이 된다고 생각했습니다. 기본적으로 작동하는 원리를 모르고 구현을 하는건 제 기준에서는 거의 불가능하다고 생각했습니다.

설령 구현을 한다고 해도 나 자신에게 도움되는게 거의 없을 것 같다고도 느꼈습니다.

Spring Boot를 사용하면서 내장 Tomcat이 있어서 개발자가 직접 실행을 시키지 않아도 된다고만 알고있습니다.

단순히 위의 개념만 가지고는 너무 부족한 것 같아서 이렇게 공부하게 되었습니다.

 

 

 

❓ 톰캣(Tomcat)이란

  • 일반적으로 톰캣(Tomcat)은 'WAS(Web Application Server)' 의 대표적인 미들웨어 서비스로 알려져 있다.
    하지만 톰캣은 일반적으로 아파치 톰캣(Apache Tomcat)이라 불리며 회사명이자 웹서버의 대표적인 미들웨어인
    아파치(Apache)의 기능 일부분을 가져와 함께 사용되면서 웹서버(Web Server)의 기능과 웹 애플리케이션 서버(Web Application Server) 모두를 포함하고 있다고 생각해도 무방하다.

 

Tomcat의 동작원리

  1. Http Request를 Servlet Container에 전송
  2. Servlet Container는 HttpServletRequest, HttpServletResponse 두 객체를 생성
  3. 사용자가 요청한 URL을 분석해 어느 서블릿에 대한 요청인지 탐색
  4. 만약 해당 서블릿이 한번도 실행된 적 없거나, 현재 메모리에 생성된 인스턴스가 없다면
    인스턴스를 생성하고 init() 메소드를 실행하여 초기화한 뒤 스레드를 하나 생성한다.
    이미 인스턴스가 존재할 경우에는 스레드만 하나 생성한다.
    (각 서블릿 인스턴스는 서블릿 컨테이너 당 하나만 존재하기 때문이다.)
  5. 컨테이너는 서블릿 service() 메소드를 호출하며, POST, GET 여부에 따라 doGet()또는 doPost()를 호출
  6. 실행된 메소드는 동적인 페이즈를 생성한 후 HttpServletResponse 객체에 응답을 보낸다.
  7. 응답이 완료되면 HttpServletRequest, HttpServletResponse 두 객체를 소멸한다.

 

아파치(Apache)와 톰캣(Tomcat)의 차이

  • 아파치 서버란 클라이언트에서 요청하는 HTTP요청을 처리하는 웹서버를 의미한다.
    이는 정적타입(HTML, CSS, 이미지 등)의 데이터만을 처리한다.
  • 톰캣 WAS(Web Application Server)는 컨테이너 웹 컨테이너 서블릿 컨테이너로도 불린다.
    • 컨테이너 : 동적인 테이터들을 가공하여 정적인 파일로 만들어주는 모듈
    • 서블릿 : 클라이언트의 요청을 받고 요청을 처리하여 결과를 클라이언트에게 제공하는 자바 인터페이스.
      java.servlet.package에 정의된 인터페이스로서 서블릿의 라이프 사이클을 위한 세 가지 필수적인 메소드를 정의
      - init(), service(), destory()
    • 서블릿 컨테이너 : 서블릿들을 모아 관리하고 새로운 요청이 들어올 때마다 새로운 스레드를 생성한다.
      작업이 끝난 서블릿 스레드는 자동 제거된다.
    • WAS : DB처리, 로직 처리를 요구하는 동적타입을 제공하는 소프트웨어 프레임워크를 의미한다.
      기본적으로 사용되는 기능 3가지는 아래와 같다.
      - 프로그램 실행 환경과 데이터베이스 접속 기능을 제공한다.
      - 여러 개의 트랜잭션을 관리한다.
      - 업무를 처리하는 비즈니스 로직을 수행한다.

정리하면

아파치 서버는 정적인 파일을 처리해주는 웹서버(80포트)이고,

톰캣 DB처리와 같은 동적인 기능을 가공하여 HTML파일로 만들어 클라이언트에게 제공(8080포트)한다.

 

하지만 아파치 톰캣으로 불리는 경우가 있는 그 이유는 

기본적으로 아파치와 톰캣의 기능은 분리되어 있지만 톰캣 안에 있는 컨테이너를 통해 일부 아파치의 기능을 발휘하기 때문이다. 보통 아파치 톰캣으로 합쳐서 부르곤 한다.

 

 

❓ Spring MVC란

  • Spring MVC는 Spring에서 제공하는 웹 모듈로, Model, View, Controller 세가지 구성요소를 사용해
    사용자의 다양한 HTTP Request를 처리한다.
  • 단순한 텍스트 형식의 응답부터 REST 형식의 응답은 물론 View를 표시하는 html을 return하는 응답까지 다양한 응답을 할 수 있도록 하는 프레임워크이다.

 

Spring Framework가 제공하는 Class와 동작과정

  • DispatcherServlet
    Spring Framework가 제공하는 Servlet 클래스로 사용자의 요청을 최초로 받는 Front Controller이다.
    Dispatcher가 받은 요청은 HandlerMapping으로 넘어간다.
  • HandlerMapping
    사용자의 요청을 처리할 Controller를 찾는다.(Controller URL Mapping)
    요청 url에 해당하는  Controller 정보를 저장하는 table을 가진다.
    즉, 클래스에 @RequestMapping("/url") 어노테이션을 명시하면 해당 URL에 대한 요청이 들어왔을 때
    table에 저장된 정보에 따라 해당 클래스 또는 메서드에 Mapping한다.
  • ViewResolver
    Controller가 반환한 View Name(the logical names)에 prefix, suffix를 적용하여
    View Object(the physical view files)를 반환한다.
    예를 들어 view name:home, prefix: /WEB-INF/views/, suffix: .jsp는
    "/.WEB-INF/views/home.jsp"라는 위치의 View(JSP)에 Controller에게 받은 Model을 전달한다.
    이 후에 해당 View에서 이 Model data를 이용하여 적절한 페이지를 만들어 사용자에게 보여준다.

 

 

반응형

 

 

🔗 참고자료

 

✍ 공부하게 된 계기

면접을 준비하면서 자주 나왔던 내용입니다.

질문에 대한 답을 그냥 이론만 거의 외워서 대답은 했었습니다.

정확하게 어떻게 DispatcherSevlet이 구현되어 있는지 알지 못했습니다.

단순히 이론을 외우면 계속 까먹고 제대로 이해하기가 힘들다고 생각했습니다.

그래서 이렇게 직접 구현을 해보면서 좀 더 깊게 공부하기로 했습니다.

 

 

 

깃허브 링크

  • 구현중인 깃허브 레포지토리 => 링크
  • 우아한테크코스에서 구현한 레포지토리 => 링크

전부 혼자서 구현하기에는 힘들기 때문에 다양한 자료들을 바탕으로 만들예정입니다.

 

 

1. DispatcherSevlet이란?

  • Dispatch는 "보내다" 라는 뜻을 가지고 있다.
  • HTTP 프로토콜로 들어오는 모든 요청을 가장 먼저 받아 적합한 컨트롤러에 위임해주는 프론트 컨트롤러이다.
    * 프론트 컨트롤러(Front Contoller)
  • 클라이언틀로부터 어떠한 요청이 오면 톰캣(Tomcat)과 같은 서블릿 컨테이너가 요청을 받게 된다.
    그리고 이 모든 요청을 프론트 컨트롤러인 디스패처 서블릿이 가장 먼저 받게 된다.
    그 후 공통적인 작업을 먼저 처리한 후에 해당 요청을 처리해야 하는 컨트롤러를 찾아서 작업을 위임한다.
  • 프론트 컨트롤러는 MVC 구조에서 함께 사용되는 디자인 패턴이다.

 

 

 

2. DispatcherServlet의 동작 과정

  1. DispatcherServlet으로 클라이언트의 웹 요청(HttpServletRequest)가 들어온다.
  2. 웹 요청을 LocaleResolver, ThemeResolver, MultipartResolver 인터페이스 구현체에서 분석한다.
  3. 웹 요청을 HandlerMapping에 위임하여 해당 요청을 처리할 Handler(Controller)를 탐색한다.
  4. 찾은 Handler를 실행할 수 있는 HandlerAdapter를 탐색한다.
  5. ④, ⑤ 찾은 HandlerAdapter를 사용해서 Handler의 메소드를 싱행한다. 이때, Handler의 반환값은 ModelView이다.
  6. ⑥ View 이름을 ViewResolver에게 전달하고, ViewResolder는 해당하는 View 객체를 반환한다.
  7. ⑦ DispatcherServlet은 View에게 Model을 전달하고 화면 표시를 요청한다. 이때, Model이 null이면 View를 그대로 사용한다. 반면, 값이 있으면 View에 Model 데이터를 렌더링한다.
  8. ⑧ 최종적으로 DispatcherServlet은 View 결과(HttpServletResponse)를 클라이언트에게 반환한다.

=> @Controller를 기준으로 작성한 것이고 @RestContoller의 경우 ⑥, ⑦ 과정이 생략된다. 즉 ViewResolver를 타지 않고 반환값에 알맞는 MessageConverter를 찾아 응답 본문을 작성한다.

 

 

 

3. 구현해야할 것들

  • DispatcherServlet 클래스
  • Controller 인터페이스
  • HandlerMapping 클래스
  • ViewResolver 클래스

 

 

4. Dependencies(gradle)

dependencies {
	// jakarta servlet api 라이브러리
    implementation 'jakarta.servlet:jakarta.servlet-api:5.0.0'

	// 아파치 톰캣
    implementation 'org.apache.tomcat.embed:tomcat-embed-core:10.1.0-M16'
    implementation 'org.apache.tomcat.embed:tomcat-embed-jasper:10.1.0-M16'
}

 

 

 

 

5. 소스코드

 

 

DispatcherServlet 클래스

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

public class DispatcherServlet  extends HttpServlet {
    private HandlerMapping handlerMapping;
    private ViewResolver viewResolver;

    @Override
    public void init() throws ServletException {
        handlerMapping = new HandlerMapping();
        viewResolver = new ViewResolver();
        viewResolver.setPrefix("./");
        viewResolver.setSuffix(".jsp");
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        service(req, resp);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        service(req, resp);
    }

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String uri = req.getRequestURI();
        String path = uri.substring(uri.lastIndexOf("/"));

        Controller controller = handlerMapping.getController(path);

        String viewName = controller.handleRequest(req, resp);

        String view = null;
        if (!viewName.contains(".do")) {
            view = viewResolver.getView(viewName);
        } else {
            view = viewName;
        }
    }
}
  • 코드의 동작 과정
    1. 사용자의 요청을 최전선에서 DispatherServlet이 받는다.
    2. URI를 Parsing해서 Path를 HandlerMapping에게 넘긴다.
    3. HandlerMapping은 사용자 요청을 처리할 수 있는 올바른 Controller를 리턴한다.
    4. Controller는 내부의 HandlerRequest를 통해서 요청을 처리한다.
    5. 요청을 처리한 뒤 이동할 화면을 ViewResolver를 통해 JSP(혹은 View)파일의 이름과 경로를 리턴받는다.
    6. 사용자에게 올바른 뷰를 보여준다.

 

Controller 인터페이스

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

public interface Controller {
    String execute(HttpServletRequest req, HttpServletResponse res) throws Exception;
}
  • 클라이언트의 요청을 받은 DispatcherServlet은 HanlderMapping을 통해 Controller 객체를 검색하여 실행한다.
    이때 어떤 Controller 객체가 검색되더라도 같은 코드로 실행하려면, 모든 Controller의 최상위 인터페이스가 필요하다.

 

 

 

6. 용어 정리

 

❓ Handler란

  • Spring MVC에서는 자동차의 핸들과 마찬가지로 클라이언트의 요청을 처리하는 처리자를 Handler라고 한다.
  • Spring MVC에서 Handler는 Controller 클래스를 의미한다.
    Controller 클래스에 있는 '@GetMapping, @PostMapping' 같은 어노테이션이 붙어 있는 메서드들을
    핸들러 메소드라고 한다

.

❓ HandlerMapping이란

  • 클라이언트의 요청과 이 요청을 처리하는 Handler를 매핑해주는 역할을 하는 것이다.
  • @GetMapping("/login") 처럼 HTTP request Method(GET, POST등)와 Mapping URL을 기준으로 해당 Handler와 
    매핑이 되는데 Spring 에서는 여러가지 유형의 HandlerMapping 클래스를 제공하고 있다.

 

 HandlerAdapter란

  • 컨트롤러(핸들러)가 처리한 결과값을 받아서 ModelAndView 형태로 바꿔 DispatcherServlet에 보내준다.
    이런 기능 덕분에 어떤 형태의 객체로 결과값을 받던지간에 상관없이 웹요청을 처리할 수 있게 된다.
  • ex) 컨트롤러에서 String 값을 리턴해주어도 그 값을 ModelAndView로 변환해서 DispatcherServlet으로 보내준다.

 

 

❓ ViewResolver란

  • Resolver 는 무언가를 해석하고, 해결해주다라는 뜻이 있다.
  • ViewResolver는 DispatcherServlet에서 '이런 이름을 가진 View를 줘'라고 요청하면 DispatcherServlet에서 전달한
    View 이름을 해석한 뒤 적절한 View 객체를 리턴해주는 역할을 합니다.
반응형

 

 

🔗 참고자료

  • 유튜브 Amigoscode 채널 => 링크
  • [10분 테코톡] 작은곰의 Spring Security => 링크
  • <Spring Security 란?> 블로그 망나니개발자 => 링크
  • <JWT(Json Web Token)란?> 블로그 망나니개발자 => 링크
  • <JWT란 무엇인가?> 벨로그 hahan => 링크
  • JJWT 라이브러리 관련 공식문서 정리 및 변역된 블로그 => 링크
  • 블로그 <삽질중인 개발자> => 링크

 

✍ 공부하게 된 계기

프로젝트를 진행하면서 스프링 시큐리티는 대부분 이미 구현되어 있는 것을 가져다 사용하는 방식을 했습니다.

그래서 인증과 인가가 어떻게 이뤄지고, 어떤 필터를 타서 인증이 진행되는지 모르는 결과를 초래하게 되었습니다.

구현을 했는데 내부적으로 어떻게 돌아가는지 대략적으로도 모르고, 결국에 구현 후 나에게 남는 게 많이 없을 것 같다고 생각돼서 이렇게 조금이라도 깊게 공부하게 되었습니다.

그리고 공부를 하면서 나오는 다양한 개념(CSRF, JWT, Basic Auth 등등)들이 있어서, 개발자로서 간단한 보안에 대한 시각도 넓혀주고 도움이 될 것이라고 생각해서 이렇게 글을 작성하게 됐습니다.

 

 

 

 

 

1. 인증(Authentication)과 인가(Authorization)

  • 인증(Authentication) : 해당 사용자가 본인이 맞는지를 확인하는 절차
  • 인가(Authorization) : 인증된 사용자가 요청한 자원에 접근 가능한지를 결정하는 절차

스프링 시큐리티는 기본적으로 인증 절차를 거친 후에 인가 절차를 진행하게 되며,
인가 과정에서 해당 리소스에 대한 접근 권한이 있는지 확인을 하게 됩니다.

스프링 시큐리티에서는 이러한 인증과 인가를 위해

Principal을 아이디로,

Credential을 비밀번호로 사용하는

Credential 기반의 인증 방식을 사용한다.

 

  • Principal(접근 주체) : 보호받는 Resource에 접근하는 대상
  • Credential(비밀번호) : Resource에 접근하는 대상의 비밀번호

 

 

 

2. 스프링 시큐리티(Spring Security)란?

  • 스프링 기반의 애플리케이션 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크이다.
  • '인증'과 '권한'에 대한 부분을 Filter 흐름에 따라 처리하고 있다.
    • Filter는 Dispatcher Servlet으로 가기 전에 적용되므로 가장 먼저 URL 요청을 받지만,
      Interceptor는 Dispatcher와 Controller 사이에 위치한다는 점에서 적용 시기의 차이가 있다.
    • 보안과 관련해서 체계적으로 많은 옵션을 제공해주기 때문에 개발자 입장에서는 일일이
      보안관련 로직을 작성하지 않아도 된다는 장점이 있다.

 

 

 

3. JWT(Json Web Token)란?

  • Json 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web Token
  • 토큰 자체를 정보로 사용하는 Self-Contained 방식으로 정보를 안전하게 전달한다.

 

 

4. JWT의 구성요소

JWT는 헤더(Header), 페이로드(Payload), 서명(Signature) 세 파트로 나눠져 있다.

 

  • 헤더 (Header)
    어떠한 알고리즘으로 암호화 할 것인지, 어떠한 토큰을 사용할 것 인지에 대한 정보가 담겨있다.

  • 정보 (Payload)
    전달할려는 정보(사용자 id나 다른 데이터들, 이것들을 크렘이라고 부른다)가 들어있다.
    payload에 있는 내용은 수정이 가능하여 더 많은 정보를 추가할 수 있다.
    그러나 노출과 수정이 가능한 지점이기 때문에 인증이 필요한 최소한의 정보만을 담아야 한다.
    (아이디, 비밀번호 등 개인정보가 아닌 이 토큰을 가졌을 때 권한의 범위나 토큰의 발급일과 만료일자 등)
  • 서명 (Signature)
    가장 중요한 부븐으로 헤더와 정보를 합친 후 발급해준 서버가 지정한 secret key로 암호화 시켜 토큰을 변조하기
    어렵게 만들어준다.
    한가지 예를 들어보자면 토큰이 발급된 후 누군가가 payload의 정보를 수정하면 payload에는 다른 누군가가
    조작된 정보가 들어가 있지만 signature에는 수정되기 전의 payload 내용을 기반으로 이미 암호화 되어있는
    결과가 저장되어 있기 때문에 조작되어 있는 payload와는 다른 결과값이 나오게 된다.
    이러한 방식으로 비교하면 서버는 토큰이 조작되었는지 아닌지를 쉽게 알 수 있고,
    다른 누군가는 조작된 토큰을 악용하기가 어려워진다.

 

 

 

 

5. 일반 토큰 기반과 클레임 토큰 기반 차이

  • 일반 토큰 기반 인증
    • 검증할 때 필요한 관련 정보들을 서버에 저장해두고 있다.
      => 인증 시 DB에 접근하는 과정이 추가된다.
    • session 방식 또한 저장소에 저장해두었던 session ID를 찾아와 검증하는 절차를 가져야 한다.
  • 클레임 토큰 기반
    • 클레임 토큰 기반 중 하나인 JWT는 사용자 인증에 필요한 정보를 토큰 자체에 담고 있다.
      => 별도의 인증 저장소가 필요 없다.
    • 분산 마이크로 서비스 환경에서 중앙 집중식 인증 서버와 데이터베이스에 의존하지 않는다.
      => 일반 토큰 기반 인증에 비해 편리한 인증 절차

 

 

 

6. JWT 인증 절차

  1. [클라이언트] 자격 증명에 필요한 정보 전송
    - 로그인 시 유저의 아이디, 비밀번호와 같은 자격 증명에 필요한 정보 전송
  2. [서버] 전송받은 자격 증명 정보를 확인(검증)
  3. [서버] 검증 완료된 정보를 기반으로 토큰 생성 및 서명
  4. [서버] 생성된 토큰을 클라이언트에게 전송
  5. [클라이언트] 전송받은 토큰을 기반으로 리퀘스트를 보낼 때 마다 토큰을 전송

 

 

 

 

7. JWT 라이브러리

https://github.com/jwtk/jjwt

 

GitHub - jwtk/jjwt: Java JWT: JSON Web Token for Java and Android

Java JWT: JSON Web Token for Java and Android. Contribute to jwtk/jjwt development by creating an account on GitHub.

github.com

 

  • JJWT는 JVM과 안드로이드에서 쉽게 JWT를 생성하고 확인(검증) 할 수게 하는 라이브러리입니다.
  • JJWT는  Apache 2.0 라이선스에 따라 JWT, JWS, JWE, JWK, JWA RFC 명세 및 오픈소스를
    독점적으로 기반하는 순수 Java 구현이다.

 

Dependencies

 

<Maven>

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>

 

<Gradle>

dependencies {
    compile 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtime 'io.jsonwebtoken:jjwt-impl:0.11.5',
    // Uncomment the next line if you want to use RSASSA-PSS (PS256, PS384, PS512) algorithms:
    //'org.bouncycastle:bcprov-jdk15on:1.70',
    'io.jsonwebtoken:jjwt-jackson:0.11.5' // or 'io.jsonwebtoken:jjwt-gson:0.11.5' for gson
}

 

String secretKey = "securesecuresecuresecuresecuresecuresecuresecuresecure";

String token = Jwts.builder()
        .setSubject(authResult.getName())
        .claim("authorities", authResult.getAuthorities())
        .setIssuedAt(new Date())
        .setExpiration(java.sql.Date.valueOf(LocalDate.now().plusWeeks(1)))
        .signWith(Keys.hmacShaKeyFor(secretKey.getBytes()))
        .compact();
  • 위와 같이 간단하게 JWT 토큰을 만들 수 있다.
  • 대부분의 Header와 Payload에 대한 정보를 이미 JJWT 라이브러리에서 제공한다.
  • JWT 토큰의 Header에 들어가는 알고리즘에 대한 정보는 어떤 알고리즘을 사용하느냐에 따라서 JJWT가 알아서 헤더에 추가해준다.

 

 

8. Spring Security 모듈

 

SecurityContextHolder

  • 보안 주체의 세부 정보를 포함하여 응용프로그램의 현재 보안 컨텍스트에 대한 세부 정보가 저장된다.
  • SecurityContext를 SecurityContextHolder가 관리한다.
  • 기본적으로 ThreadLocal을 사용한다.
    - ThreadLocal : 한 쓰레드 내에서 사용하는 공용 저장소
    - ThreadLocal을 사용해서 'Authentication'을  한 쓰레드 내에서 공유가 가능하다.
    - 쓰레드가 달라지면 제대로 된 인증 정보를 가져올 수 없다.
  • 즉 SecurityContextHolder란 Authentication을 담고 있는 Holder라고 정의할 수 있다.
  • 'Authentication' 자체는 인증된 정보이기에 'SecurityContextHolder'가 가지고 있는 값을 통해 인증이 되었는지
    아닌지 확인 할 수 있다. => Authentication.isAuthenticated();

 

SecurityContext

  • Authentication을 보관하는 역할을 하며, SecurityContext를 통해 Authentication 객체를 꺼내올 수 있다.

 

Authentication

  • 현재 접근하는 주체의 정보와 권한을 담는 인터페이스입니다.
  • SecurityContext에 저장되며, SecurityContextHolder를 통해 SecurityContext에 접근하고,
    SecurityContext를 통해 Authentication에 접근할 수 있습니다.

 

 

 

 

 

 

9. 구현 소스코드

JwtUserNamePasswordAuthenticationFilter 코드

import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.FilterChain;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDate;
import java.util.Date;

@RequiredArgsConstructor
public class JwtUserNamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;

    @Value("${jwt.secretKey}")
    private String secretKey;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response
    ) throws AuthenticationException {

        try {
            UsernamePasswordAuthenticationRequest authenticationRequest = new ObjectMapper()
                    .readValue(request.getInputStream(), UsernamePasswordAuthenticationRequest.class);

            Authentication authentication = new UsernamePasswordAuthenticationToken(
                    authenticationRequest.getUsername(),
                    authenticationRequest.getPassword()
            );
            return authenticationManager.authenticate(authentication);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }


    // attemptAuthentication이 정상적으로 작동되면 아래 메서드가 작동되게 된다.
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult
    ) {
        String token = Jwts.builder()
                .setSubject(authResult.getName())
                .claim("authorities", authResult.getAuthorities())
                .setIssuedAt(new Date())
                .setExpiration(java.sql.Date.valueOf(LocalDate.now().plusWeeks(1)))
                .signWith(Keys.hmacShaKeyFor(secretKey.getBytes()))
                .compact();

        response.addHeader("Authorization",  "Bearer" + token);
    }
}

 

  • request안에서 username, password 파라미터를 가져와서 UsernamePasswordAuthenticationToken을 생성 후
    AuthenticationManager을 구현한 객체에 인증을 위임한다.
    => ObjectMapper()를 사용해서 JSON 컨텐츠를 Java 객체로 deserialization 한다.
    * ObjectMapper는 Java 객체를 deserialization 하거나 Java 객체를 JSON으로 serialization 할 때 사용하는
    Jasckson 라이브러리의 클래스이다. ObjectMapper는 생성 비용이 비싸기 때문에 bean/static으로 처리하는 것이 좋다

 

UsernamePasswordAuthenticationFilter 란?

  • 요청정보를 받아서 정보 추출을 하여 인증객체를 생성한다.
  • Form based Authentication 방식으로 인증을 진행할 때 아이디, 패스워드 데이터를 파싱하여 인증 요청을 위임하는 필터이다.
  • 유저가 로그인 창에서 Login을 시도할 때 보내지는 요청에서 아이디(username)와 패스워드(password) 데이터를
    가져온 후 인증을 위한 토큰을 생성 후 인증을 다른 쪽에 위임하는 역할을 하는 필터입니다.

 

UsernamePasswordAuthenticationFilter 로직을 순서

 

 

UsernamePasswordAuthenticationToken란?

  • Authentication을 구현한 AbstractAuthenticationToken의 하위 클래스로 usernamePrincipal의 역할을 하고,
    passwordCredential의 역할을 합니다.
  • 첫 번째 생성자는 인증 전의 객체를 생성하고, 두 번째 생성자는 인증이 완료된 객체를 생성해줍니다.

 

AuthenticationManager란?

  • 인증에 대한 부분은 AuthenticationManager를 통해서 처리하게 되는데, 실질적으로는 AuthenticationManager에 등록된 AuthenticationProvider에 의해 처리됩니다.
  • 인증이 성공하면 isAuthentication-true인 객체를 생성하여 SecurityContext에 저장합니다.
  • 인증 상태를 유지하기 위해 세션에 보관하며, 실패할 경우에는 AuthenticationException을 발생시킵니다.

 

 

JwtTokenVerifier 코드

import com.google.common.base.Strings;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

public class JwtTokenVerifier extends OncePerRequestFilter {

    @Value("${jwt.secretKey}")
    private String secretKey;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain
    ) throws ServletException, IOException {
        String authorizationHeader = request.getHeader("Authorization");

        if (Strings.isNullOrEmpty(authorizationHeader) || authorizationHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = authorizationHeader.replace("Bearer ", "");
        try {
            Jws<Claims> claimsJws = Jwts.parserBuilder()
                    .setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
                    .build()
                    .parseClaimsJws(token);

            Claims body = claimsJws.getBody();

            String username = body.getSubject();

            var authorities = (List<Map<String, String>>) body.get("authorities");

            Set<SimpleGrantedAuthority> simpleGrantedAuthorities = authorities.stream()
                    .map(m -> new SimpleGrantedAuthority(m.get("authority")))
                    .collect(Collectors.toSet());

            Authentication authentication = new UsernamePasswordAuthenticationToken(
                    username,
                    null,
                    simpleGrantedAuthorities
            );

            SecurityContextHolder.getContext().setAuthentication(authentication);

        } catch (JwtException e) {
            throw new IllegalStateException(String.format("Token %s cannot be trust", token) );
        }
    }

}

 

  • 클라이언트에게서 넘어온 JWT토큰을 한번만 인증하면 되기 때문에 OncePerRequestFilter를 사용한다.
  • jjwt 라이브러리를 사용해서 토큰을 쉽게 파싱합니다.
    => 파싱한 토큰 내의 데이터를 authentication 객체에 저장 후 SecurityContextHolder에 set 합니다.

 

OncePerRequestFilter란?

  • 모든 서블릿에 일관된 요청을 처리하기 위해 만들어진 필터
  • 한 요청당 반드시 한 번만 실행된다.

 

 

위 소스코드에서 FilterChain이란?

  • 톰캣은 등록된 필터들의 클래스를 모두 객체화해서 내부 저장합니다.
    필터가 초기화될 때 필요한 부분들 또한 마찬가지입니다.
    이 객체들은 설정된 대로 순서를 가지게 되는데,
    이 순서 정보를 가진 "FilterChain" 인터페이스 객체르 메소드의 파라미터로 넘겨줍니다.
  • 모든 필터 클래스는 "Filter" 인터페이스를 상속받아야 하고, 구현해야 하는 세 개의 메소드 중 실제 필터의 기능을 담당하는 doFilter 메소드를 구현할 때 FilterChain 객체를 넘겨주게 됩니다.

 

UsernamePasswordAuthenticationRequest 코드

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
public class UsernamePasswordAuthenticationRequest {
    private String username;
    private String password;
}

 

 

 

PrincipalDetails 코드

import com.example.bbakmemo.vo.UserVo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;


@Builder
@AllArgsConstructor
public class PrincipalDetails implements UserDetails {

    private final String accountId;
    private final String password;
    private final boolean enabled;
    private final List<? extends GrantedAuthority> getAuthorities;
    private final UserVo userVo;

    public UserVo getUserVo() {
        return userVo;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return getAuthorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return accountId;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return enabled;
    }
}

UserDetails란?

  • 인증에 성공하여 생성된 UserDetails 객체는 UsernamePasswordAuthenticationToken을 생성하기 위해 사용됩니다.
  • UserDetails 인터페이스의 경우 직접 개발한 User 엔티티나 UserDto에 UserDetails를 구현하여 처리할 수 있습니다.

 

 

PrincipalDetailsService 코드

import com.example.bbakmemo.mapper.UserMapper;
import com.example.bbakmemo.vo.UserVo;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final UserMapper userRepository;

    @Override
    public UserDetails loadUserByUsername(String accountId) throws UsernameNotFoundException {

        UserVo userVo =  userRepository.findUserByAccountId(accountId)
                .orElseThrow(
                        () -> new UsernameNotFoundException("유효하지 않은 로그인 정보입니다.")
                );

        return PrincipalDetails.builder()
                .accountId(userVo.getAccountId())
                .password(userVo.getPassword())
                .enabled(userVo.isEnabled())
                .build();
    }
}

UserDetailsService란?

  • DB에서 유저 정보를 불러오는 중요한 메소드가 있는 인터페이스이다.
    => loadUserByUsername() 메소드
  • DB에서 유저의 정보를 가져와서 리턴해준다.

 

 

WebSecurityConfig 코드

import com.example.bbakmemo.config.security.jwt.JwtTokenVerifier;
import com.example.bbakmemo.config.security.jwt.JwtUserNamePasswordAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;


/* WebSecurityConfigurerAdapter란?
    스프링 시큐리티의 웹 보안 기능 초기화 및 설정
 */
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(securedEnabled = true) // @Secured 어노테이션 활성화
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    // BCryptPasswordEncoder는 Spring Security에서 제공하는 비밀번호 암호화 객체
    // 비밀번호를 BCryptPasswordEncoder로 암호화 안하면 Spring Security 자체에서 경고를 하게 된다.
    @Bean
    public BCryptPasswordEncoder encodePassword() {
        return new BCryptPasswordEncoder();
    }


    // 정적 자원에 대해서는 Security 설정을 적용하지 않음.
    @Override
    public void configure(WebSecurity web) {
        // h2-console 사용에 대한 허용 (CSRF, FrameOptions 무시)
        web.ignoring()
                .antMatchers("/h2-console/**")
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }


    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http
                .headers().frameOptions().disable().and()
                .csrf().disable()
                .formLogin().disable()

                .sessionManagement()
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .addFilter(new JwtUserNamePasswordAuthenticationFilter(authenticationManager()))
                .addFilterAfter(new JwtTokenVerifier(), JwtUserNamePasswordAuthenticationFilter.class)
                .authorizeRequests()

                // 회원 관리 처리 API 전부를 login 없이 허용
                .antMatchers("/user/signup").permitAll()
                .anyRequest()
                .authenticated();
    }
}

 

 

 

❓ 세션기반 인증과 토큰기반 인증의 차이

  • 세션의 경우 Cookie 헤더에 세션 ID만 실어 보내면 되므로 트래픽을 적게 사용한다.
  • JWT는 사용자 인증 정보와 토큰의 발급시각, 만료시각, 토큰의 ID 등 담겨있는 정보가 세션 ID에 비해 비대하므로
    세션 방식보다 훨씬 더 많은 네트워크 트래픽을 사용한다.
  • 세션의 경우 모든 인증 정보를 서버에서 관리하기 때문에 보안 측면에서 조금 더 유리하다.
    => 설령 세션 ID가 해커에게 탈취된다고 하더라도, 서버측에서 해당 세션을 무효 처리하면 된다.
    => 토큰의 경우 서버가 트래킹하지 않고, 클라이언트가 모든 인증정보를 가지고 있다.
    따라서 토큰이 한번 해커에게 탈취되면 해당 토큰이 만료되지 전까지는 속수무책으로 피해를 입을 수 밖에 없다.
    * JWT 토큰의 보안 문제 때문에 Access Token과 Refresh Token을 사용해서 보안 대책을 세우는 방법도 있다.
    (리프레쉬 토큰 관련 참고링크로 이동)

 

 

JWT가 여러 문제가 있음에도 사용하는 이유

  • 확장성이 좋다.
    일반적으로 웹 애플리케이션의 서버 확장 방식은 수평 확장을 사용한다. 즉, 한대가 아닌 여러대의 서버가 요청을 처리하게 된다. 이때 별도의 작업을 해주지 않는다면, 세션 기반 인증 방식은 세션 불일치 문제를 겪게 된다.
    이를 해결하기 위해서 Sticky Session, Session Clustering, 세션 스토리지 외부 분리 등의 작업을 해주어야 한다.

    하지만, 토큰 기반 인증 방식의 경우 서버가 직접 인증 방식을 저장하지 않고, 클라이언트가 저장하는 방식을 취하기 때문에 이런 세션 불일치 문제로부터 자유롭다. 이런 특징으로 토큰 기반 인증 방식은 HTTP의 비상태성(Stateless)를 그대로 활용 할 수 있고, 따라서 높은 확장성을 가질 수 있다.

  • 서버의 부담이 줄어든다.
    세션 기반 인증은 서비스가 세션 데이터를 직접 저장하고 관리하지만 토큰 인증 방식은 클라이언트가 인증 데이터를 직접 가지고 있다. 따라서 유저의 수가 얼마나 되던 서버의 부담이 증가하지 않는다.

 

 

❓ Authorization header란

Authorization 헤더는 인증 토큰(JWT든, Bearer 토큰이든)을 서버로 보낼 때 사용하는 헤더입니다.

API 요청같은 것을 할 때 토큰이 없으면 거절당하기 때문에 이 때 Authorization을 사용하면 됩니다.

 

 

 

 

❓ 헤더에 Bearer을 적는 이유

JWT 혹은 OAuth에 대한 토큰을 인증 타입을 명시하는 것이다.(RFC 6750)

결국 인증 시스템 구축을 위한 약속된 틀입니다.

다른 키워드를 넣어서 개발 할 수는 있지만 약속된 틀을 깨는 것이기 때문에 차 후에 문제가 발생할 수 있습니다.

반응형

 

❓ 해당 내용이 궁금해진 이유?

현재 금융권 대기업에 파견을 나가있는데 파일을 전송할 때 Base64로 변환을 해서 전송을 하고 있었습니다.

여기서 왜 굳이 Base64로 변환해서 전송을 하는지에 대해서 궁금해졌습니다.

 

  • 인코딩, 디코딩 해야하는 불편함이 있는데 굳이 사용하는 이유는 뭘까?
  • Base64로 변환하면 용량이 30퍼센트 정도 늘어나 비효율적이지 않나?
  • multipart/form-data를 사용하지 않는 이유는 뭘까?

 

 

❓ 굳이 Base64로 인코딩 하는 이유는?

바이너리 데이터를 텍스트 기반의 규격으로 다룰 수 있기 때문이다.

이밎 파일등을 Web에서 필요로 할 때 Base64로 인코딩하면 UTF-8과 호환 가능한 문자열을 얻을 수 있다.

 

기존 ASCII 코드는 시스템간 데이터를 전달하기에 안전하지 않다. 모든 Binary 데이터가 ASCII 코드에 포함되지 않으므로 제대로 읽지 못한다. 반면 Base64는 ASCII 중 제어문자와 일부 특수문자를 제외한 53개의 안전한 출력 문자만 이용하므로 데이터 전달에 더 적합하다.

 

위 내용을 보면 용량이 30 ~ 33퍼센트 증가해도 안정성을 위해 Base64 인코딩을 사용한다는 것을 알 수 있게 되었다.

그런데 multipart/form-data를 사용하면 되지 않을까?

 

위 의문에 대해서는 정확한 의도는 모르지만 아래와 같은 이유가 있지 않을까 정의해봤습니다.

Base64로 인코딩된 텍스트에 Header와 Tailer 양식을 만들어 정보를 담아서 전송하기  위해서 사용한다.

그 정보를 바탕으로 해당 파일이 무엇인지 파악할 수 있게 해놓는 시스템이 구축되어 있기 때문이 아닐까 생각해봤습니다.

 

 

 

❓ Base64는 암호화이다?

Base64를 사용 하는 이유에서 암호화이기 때문이라는 얘기를 들었습니다.

여러 블로그를 확인해본결과 암호화라고 부르는 경우도 있고 단순히 인코딩이라고 하는 경우도 있었습니다.

좀 더 깊게 찾아보기 위해 스택오버플로우(Stack Overflow)와 쿼라(Quora)에 찾아보기로 했습니다.

 

그 결과 Base64는 바이너리 데이터를 출력 가능한 문자로 나타내기 위한 인코딩이라는 것을 알게되었습니다.

생각해보면 Base64가 나열된 문자는 디코딩을 하기 전까지는 읽기 힘들다는 것 밖에 없었습니다.

암호화가 되어 있었다고 하면 암호화 키와 복호화 키가 있을텐데 그런 것도 없었습니다.

 

단순히 해당 텍스트가 Base64 형식이라는 것을 알게되면 누구나 쉽게 디코딩이 가능하기 때문에 암호화는 아니라는 것입니다.

 

 

 

❗️ 위 내용을 작성하면서 내가 부족하다고 느낀 부분

  • multipart/form-data를 통해 파일을 전송하는 것을 사용했지만 해당 내용에 대해 거의 아무것도 모르고 있었다.
  • 어떤 방식으로 파일을 전송하는게 좀 더 효율적일지에 대해 고민하지 않고 그동안 코드를 작성한 점.
  • Base64 인코딩에 대해 너무 두루뭉실하게 알고 있었다.
  • 지금까지 구현한 것에 대해 궁금점을 가지지 않고 그냥 코드를 작성하고 있었다.
    => 깊이가 없는 상태

 


참고자료

  • [OKKY] Base64는 왜 사용하는 걸까요? => 링크
  • [Stack Overflow] Why do we use Base64? => 링크
  • [Stack Overflow] is Base64 Encryption? => 링크
  • [Quora] is Base64 encoding secure? => 링크
  • [블로그 코딩배우는 학생] 우리가 Base64를 사용하는 이유 => 링크
반응형

 

 

구현하게된 계기 및 이유

현재 회사에서 금융권에 파견을 가있는 상황인데 각 증권사로 보내는 PDF파일들을 Tar로 묶어서 전송하라는 요청이 왔습니다.

아쉽게도 Tar로 묶어서 전송을 하라는 이유는 정확히 듣지 못하고 요청만 들어온 상황이라 구현을 하게 되었습니다.

저는 리눅스 환경에서 파일을 압축해서 전송하기 위해서 Tar 압축 형식을 사용한 것이라 생각했습니다.

윈도우에서는 zip파일형식이 있다면 리눅스는 Tar파일형식이 있다는것을 알게 되었습니다.

 

리눅스에서 Tar 명령어를 사용해 압축이 가능하지만 서버쪽에서 자바 코드로 구현을 해야 했기 때문에 자바로 구현했습니다.

 

 

 

사용한 라이브러리

 

 

 

소스코드

import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
import org.apache.commons.io.IOUtils;

import java.io.*;

public class CompressToTar {

	// 타르 압축 함수를 실행 할 메인 메서드
    public static void main(String[] args) throws Exception {
        compressToTar();
    }


	
    private static void compressToTar() throws Exception {
		
        // 타르 파일 생성 경로를 입력해주세요.
        String tarOutputPath = "타르파일 생성 경로";

        try {
        	// 찹축할 파일이 있는 경로를 입력해주세요.
            File inputFilePath = new File("압축할 파일이 있는 경로");

			// 필터를 통해 원하는 파일 형식 및 파일들만 배열로 파일리스트로 가져옵니다.
            File[] pdfFileArray = inputFilePath.listFiles(new FilenameFilter() {
                @Override
                public boolean accept(File dir, String name) {
                    return name.endsWith("압축할 파일들의 파일명 패턴");
                }
            });


             if (pdfFileArray.length != 0) {
             
             	// 생설할 타르 파일명과 .tar 확장자를 꼭 붙여주세요.
                File tarOutputFile = new File(tarOutputPath, "test.tar");

                FileOutputStream fOut = new FileOutputStream(tarOutputFile);
                BufferedOutputStream bOut = new BufferedOutputStream(fOut);
                TarArchiveOutputStream tOut = new TarArchiveOutputStream(bOut);


                for (File file : pdfFileArray) {
                    TarArchiveEntry tarArchiveEntry = new TarArchiveEntry(file, file.getName());
                    tarArchiveEntry.setModTime(0);
                    tarArchiveEntry.setSize(file.length());

					// tar아웃풋 스트림에 tar아카이브 엔트리 put
                    tOut.putArchiveEntry(tarArchiveEntry);
                    
                    // tar아웃풋 스트림을 통해 해당 파일 생성(write)
                    // -> 이때 IOUtils를 사용해서 해당 파일을 ByteArray로 변환합니다.
                    tOut.write(IOUtils.toByteArray(new FileInputStream(file)));

                    tOut.closeArchiveEntry();
                }

                tOut.flush();
                tOut.finish();
                tOut.close();
                bOut.close();
                fOut.close();
            } else {
                System.out.println("해당 폴더에 압축할 파일이 없습니다.");
            }

        } catch (Exception e) {
            System.out.println("compress 에러 발생: " + e);
            throw new Exception(e);
        }

    }
}

* 위에 작성된 코드는 예시입니다. 참고만 하시고 더 견고한 코드를 작성하시는걸 권장드립니다.

 

 

 

구현하면서 느낀점

구현하는 것은 크게 문제가 되지 않는 기능이였습니다.

하지만 금융권 개발 환경이 생각보다 낮은 자바 버전을 사용해서, 호환이 되는 라이브러리를 찾는게 시간이 꽤 걸렸습니다.

이번에 구현하면서 이런 안좋은 개발환경에서도 차선책이 존재한다는 것을 느꼈습니다.

많은 사람들이 사용하는 검증된 라이브러리가 검색하면 엄청 많이 나오는 라이브러리를 사용하지 못했지만,

계속 검색해보면 해당 라이브러리와 비슷한 기능을 하는 것이 있다는 것을 체감했습니다.

 

 

 

반응형

+ Recent posts