Skip to content

이호석 4주차 java was(3) 학습일지

이호석 edited this page Jul 22, 2024 · 1 revision

✅ 웹 서버 7단계 - 게시판 기능 구현

1. JDBC를 직접 사용하지 않고 JdbcTemplate를 구현해 재사용성 높임

  • JDBC를 그대로 사용하면 예외 처리나, Statement 세팅, 결과 resultSet매핑 해주는 작업이 필요합니다.
  • JdbcTemplate은 이런 set, map의 작업을 사용자가 할 수 있도록 @FunctionalInterface를 사용하여 외부에서 사용자가 직접 값을 세팅하거나, 받아오도록 주입 하여 편리하게 JDBC를 사용할 수 있는 공통 API를 제공합니다.
  • 추가로 ConnectionPool에서 커넥션을 받아오므로 불필요하게 새로운 커넥션 생성에 대한 리소스를 줄였습니다.
public class JdbcTemplate {

    private final Logger log = LoggerFactory.getLogger(JdbcTemplate.class);

    private final DataSourceManager dataSourceManager = new DataSourceManager();

    public void insert(final String query, final PreparedStatementSetter psSetter) {
        try (Connection conn = dataSourceManager.getConnection();
             PreparedStatement ps = conn.prepareStatement(query)) {

            psSetter.setValues(ps);

            ps.executeUpdate();
        } catch (SQLException e) {
            log.error(e.getMessage(), e);
            throw new InternalServerException();
        }
    }
		...
}
@FunctionalInterface
public interface PreparedStatementSetter {

    void setValues(PreparedStatement ps) throws SQLException;
}
@FunctionalInterface
public interface ResultSetMapper<T> {

    T map(ResultSet resultSet) throws SQLException;
}

2. 흩어져 있는 사용자 예외에 대한 로깅과, 예외 처리 부분을 응집시키기

미션을 진행하면서 지금까지 만들어왔던 코드의 예외 처리에 대한 기준이 없다는걸 깨닫게 됐습니다. 산발적으로 흩어져 있고 서로 다른 예외 처리, 로깅 방식은 안정적인 서비스를 운영하는데 방해요소가 될 수 있다고 판단했습니다.

  1. 예외가 발생했을때, 중복적으로 로깅 처리가 될 수 있음
  2. 로깅만 하고 끝내는 부분, 로깅 및 예외를 다시 던지는 부분이 뒤섞이면서 예상치 못했던 부작용이 생길 가능성 내포

따라서 이런 부분을 응집시켜 처리해야 함을 느꼈고, 하기와 같이 예외 처리를 종류에 따라 두 가지 방법으로 나누어서 처리했습니다.

  1. 외부 IO와 같이 처리할 수 없는 부분 → 해당 예외를 즉시 로깅 하고 별도의 UncheckedException을 던지고 종료 (ex: IOException, SQLException 등)
    • 다만 Response, Request를 처리하면서 발생하는 예외는 처리하기 어려우므로 로깅만하고 패싱함
  2. 사용자 요청을 처리하면서 발생하는 예외는 사용자의 잘못일 가능성이 크기 때문에 즉시 에러 페이지 응답
// 외부 IO같이 처리할 수 없는 부분은 로깅만 하고 패싱 -> 지속적인 요청을 받아야 하므로
public class ConnectionHandler implements Runnable {

    @Override
    public void run() {
        try (InputStream clientInput = clientSocket.getInputStream();
             OutputStream clientOutput = clientSocket.getOutputStream()
        ) {
            HttpRequest httpRequest = new HttpRequest(clientInput);
            log.debug("Http Request = {}", httpRequest);

            HttpResponse httpResponse = new HttpResponse(clientOutput, httpRequest.getHttpVersion());

            RequestHandler requestHandler = requestHandlerMapping.read(httpRequest.getRequestPath());
            validateHandler(httpRequest, httpResponse, requestHandler);

            requestHandler.process(httpRequest, httpResponse);
        } catch (IOException e) {
            log.error("요청을 처리할 수 없습니다.", e);
        }
    }
}

// JdbcTemplate에서 SQLException이 발생하면 즉시 예외를 로깅하고 UncheckedException을 던짐
public <T> T selectOne(final String query, final PreparedStatementSetter setter,
                       final ResultSetMapper<T> mapper) {
    try (Connection conn = dataSourceManager.getConnection()) {
        PreparedStatement ps = conn.prepareStatement(query);
        setter.setValues(ps);

        ResultSet resultSet = ps.executeQuery();

        if (resultSet.next()) {
            return mapper.map(resultSet);
        }
        return null;
    } catch (SQLException e) {
        log.error(e.getMessage(), e);
        throw new InternalServerException();
    }
}
// UncheckedException인 CommonException을 잡아서 에러 페이지를 응답합니다.
public abstract class AbstractRequestHandler implements RequestHandler {

    private static final Logger log = LoggerFactory.getLogger(AbstractRequestHandler.class);

    public void process(HttpRequest request, HttpResponse response) {
        HttpMethod requestMethod = request.getHttpMethod();

        Cookie cookie = findCookie(request, "sid");
        if (Objects.nonNull(cookie)) {
            ContextHolder.setContext(cookie.getValue());
        }

        try {
            if (requestMethod == HttpMethod.GET) {
                handleGet(request, response);
            }
            if (requestMethod == HttpMethod.POST) {
                handlePost(request, response);
            }
        } catch (IOException e) {
            log.error(e.getMessage(), e);
            responseInternalServerError(response);
        } catch (CommonException e) {
            responseErrorPage(response, e);
        }
        ContextHolder.clear();
    }
}
  • 주의사항 혹은 고민사항
    • 사용자 요청 처리 부분에 대한 플로우가 모두 CommonException을 상속받는 UncheckedException을 던져주도록 신경써줘야 함
    • CheckedException이 발생하는 예외 또한 로깅을 신경써주어야 합니다.

3. H2 DB 도입, RequestHandler 테스트 간 격리시키기

H2 DB를 도입하면서, 실제 DB 환경에 값을 저장하게 됩니다.

기존 RequestHandler들의 테스트는 메모리 DB를 통해 진행했기 때문에, 테스트 격리가 간편했지만 실제 DB를 사용하는 환경에선 조금 더 신경써주거나, 만들어줘야 하는 기능들이 있었습니다.

  1. 테스트 Fixture 사용하여 특정 조건에서만 접근 가능한 요청을 테스트 하기

    XxxRequestHandlerTest extends RequestHandlerTest {
    		...
    }

    RequestHandlerTest는 각 요청 핸들러의 구현체들이 상속받아 사용할 수 있는 Fixture 역할을 합니다. 이렇게 계층을 둔 이유는, 특정 조건에서만(로그인을 해야 게시글 작성이 가능, 로그인을 해야 조회가 가능 등) 허락되는 요청이 있기 때문입니다. 따라서 미리 조건들을 미리 세팅할 수 있는 메소드들을 제공 합니다.

    public class RequestHandlerTest {
    
        protected JdbcTemplate jdbcTemplate;
    
        protected UserRepository userRepository;
    
        protected PostRepository postRepository;
    
        protected CommentRepository commentRepository;
    
        protected DatabaseCleaner databaseCleaner;
    
        @BeforeEach
        void setUpTest() {
            jdbcTemplate = new JdbcTemplate();
            databaseCleaner = new DatabaseCleaner(jdbcTemplate);
            userRepository = new JdbcUserRepository(jdbcTemplate);
            postRepository = new JdbcPostRepository(jdbcTemplate);
            commentRepository = new JdbcCommentRepository(jdbcTemplate);
        }
    
        @AfterEach
        void cleanDatabase() {
            ContextHolder.clear();
            databaseCleaner.clean();
        }
    
        protected void 회원입을_한다() throws IOException {
            SignUpRequestHandler signUpRequestHandler = new SignUpRequestHandler(userRepository);
            String httpRequestValue =
                    "POST /user/create HTTP/1.1\\r\\n"
                            + "Host: localhost\\r\\n"
                            + "Connection: keep-alive\\r\\n"
                            + "Content-Type: application/x-www-form-urlencoded\\r\\n"
                            + "Content-Length: "
                            + "userId=test&nickname=nick&password=password&email=email%40email.com".getBytes().length
                            + "\\r\\n"
                            + "\\r\\n"
                            + "userId=test&nickname=nick&password=password&email=email%40email.com";
            InputStream clientInput = new ByteArrayInputStream(httpRequestValue.getBytes("UTF-8"));
            HttpRequest request = new HttpRequest(clientInput);
            HttpResponse response = new HttpResponse(OutputStream.nullOutputStream(), request.getHttpVersion());
    
            signUpRequestHandler.handlePost(request, response);
        }
    
        protected String 로그인을_한다() throws IOException {...}
    
        protected void 로그아웃을_한다(String sessionId) throws IOException {...}
    
        protected void 게시글을_작성한다() throws IOException {...}
    
        ...
    }
  2. DatabaseCleaner를 통한 DB 초기화

    public class DatabaseCleaner {
    
        private final JdbcTemplate jdbcTemplate;
    
        public DatabaseCleaner(final JdbcTemplate jdbcTemplate) {
            this.jdbcTemplate = jdbcTemplate;
        }
    
        public void clean() {
            jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY FALSE");
            jdbcTemplate.execute("TRUNCATE TABLE users");
            jdbcTemplate.execute("TRUNCATE TABLE post");
            jdbcTemplate.execute("TRUNCATE TABLE comment");
            jdbcTemplate.execute("ALTER TABLE users ALTER COLUMN id RESTART WITH 1");
            jdbcTemplate.execute("ALTER TABLE post ALTER COLUMN id RESTART WITH 1");
            jdbcTemplate.execute("ALTER TABLE comment ALTER COLUMN id RESTART WITH 1");
            jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY TRUE");
        }
    }
    • DatabaseCleaner는 위와 같이 기존 테이블의 데이터를 삭제하고, Auto Increment로 증가된 값들 또한 초기값으로 세팅하게 됩니다. 따라서 다른 테스트에서는 마치 처음 DB가 실행 된 듯한 환경을 제공해줍니다.

4. 고민 사항

  • RequestHander테스트 격리를 위해 실행하는 setUp, tearDown, clean() 등의 코드들이 산발적으로 흩어져있다고 느껴짐, 이를 좀 더 가독성 있게 보여주려면 어떻게 해야할지 고민중입니다.
  • (게시글 + 댓글) * N개의 게시글을 보여주는 로직이 상당히 복잡합니다. 템플릿 엔진이 아니라 코드 조각들을 조합해 Response를 만들고 있는데 이로 인해 홈 화면을 처리하는 핸들러의 로직이 상당히 가독성이 나쁘고, 복잡합니다. 코드 조각들을 조합하는 부분을 어떻게 분리하면 좋을지 고민중입니다.
  • MultipartRequest,, 그냥 고민이 계속됩니다.. CSV DB도 ㅠㅠ



✅ 웹 서버 8단계 - 이미지 업로드 및 표시 구현

1. BufferedReader 기반 request읽기에서 Byte기반 request읽기로 변경

HTTP Request를 이루고 있는 Request Line, HeaderString으로 저장해도 무관합니다.

오로지 message body 데이터만 multipart form data로 들어왔을때의 처리가 필요합니다.

따라서 기존 BufferedReader로 읽어오던 Request 정보들을 InputStream을 감싼 BufferedInputStream을 사용해 읽었습니다.

  1. RequestInputStreamReader를 만들기

    기존 HttpRequest 객체의 구조를 최대한 변경하지 않고 싶었습니다. 따라서 HTTP Request를 전용으로 읽는 reader를 만들어서 해당 객체가 Request Line, Header, Body를 파싱하는 책임을 주었습니다.

    public class RequestInputStreamReader {
    
        private static final byte CR = 13;
        private static final byte LF = 10;
        private static final int START_POSITION = 0;
    
        private final Logger log = LoggerFactory.getLogger(RequestInputStreamReader.class);
    
        private final BufferedInputStream requestInputStream;
        private final byte[] requestLineBytes;
        private final List<byte[]> headerBytes = new ArrayList<>();
    
        public RequestInputStreamReader(final BufferedInputStream bufferedInputStream) throws IOException {
            this.requestInputStream = new BufferedInputStream(bufferedInputStream);
    
            requestLineBytes = readBytesUntilSymbol(LF);
    
            byte[] currentHeaderBytes;
            do {
                skipByte(2);
                currentHeaderBytes = readBytesUntilSymbol(LF);
                headerBytes.add(currentHeaderBytes);
            } while (currentHeaderBytes.length > 1);
    
            skipByte(2);
        }
    
        private byte[] readBytesUntilSymbol(final byte symbol) throws IOException {
            byte[] readBytes;
            requestInputStream.mark(START_POSITION);
            int count = countLine(requestInputStream, symbol);
            requestInputStream.reset();
    
            readBytes = new byte[count - 1];
            requestInputStream.read(readBytes);
    
            return readBytes;
        }
    
        private int countLine(final BufferedInputStream bufferedInputStream, final byte specificByte) throws IOException {
            int count = 0;
            while ((bufferedInputStream.read()) != specificByte) {
                count++;
            }
            return count;
        }
    
        private void skipByte(final int count) throws IOException {
            requestInputStream.skip(count);
        }
    
        public String readRequestLine() throws UnsupportedEncodingException {
            return new String(requestLineBytes, UTF_8.getCharset());
        }
    
        public List<String> readHeaders() {
            return headerBytes.stream()
                    .map(header -> {
                        try {
                            return new String(header, UTF_8.getCharset());
                        } catch (UnsupportedEncodingException e) {
                            log.error(e.getMessage(), e);
                            throw new InternalServerException();
                        }
                    })
                    .toList();
        }
    
        public byte[] readBody(final int contentLength) throws IOException {
            byte[] bytes = new byte[contentLength];
            requestInputStream.read(bytes);
    
            return bytes;
        }
    }
  2. Message Body 데이터는 바이트로 관리하기

    RequestINputStreamReaderMessageBody 데이터를 byte[]로 가지고 있습니다. RequestMessageBody는 해당 데이터와 HTTP RequestContent-Type을 넘겨받아 적절하게 파싱할 수 있도록 합니다.

    이때 x-www-form-urlencoded 혹은 multipart/form-data MimeType을 여기서 구분하여 파싱하기 위한 기반 코드를 작성했습니다.

    public class RequestMessageBody {
    
        private final byte[] bodyData;
        private final MimeType mimeType;
        private final RequestParameters formParameters = new RequestParameters();
    
        public RequestMessageBody(final byte[] bodyData, final MimeType mimeType) throws UnsupportedEncodingException {
            this.bodyData = bodyData;
            this.mimeType = mimeType;
    
            if (mimeType == MimeType.APPLICATION_X_WWW_FORM_ENCODED) {
                formParameters.putParameters(new String(bodyData, UTF_8.getCharset()));
            }
            // 멀티파트 파싱 구현
        }
    
        public boolean containsParameter(final String key) {
            return formParameters.contains(key);
        }
    
        public byte[] getBodyData() {
            return bodyData;
        }
    
        public String getParameter(final String key) {
            return formParameters.get(key);
        }
    
        @Override
        public String toString() {
            return "MessageBody{" +
                    "bodyData='" + bodyData + '\'' +
                    '}';
        }
    }

👼 개인 활동을 기록합시다.

개인 활동 페이지

🧑‍🧑‍🧒‍🧒 그룹 활동을 기록합시다.

그룹 활동 페이지

🎤 미니 세미나

미니 세미나

🤔 기술 블로그 활동

기술 블로그 활동

📚 도서를 추천해주세요

추천 도서 목록

🎸 기타

기타 유용한 학습 링크

Clone this wiki locally