-
Notifications
You must be signed in to change notification settings - Fork 0
이호석 4주차 java was(3) 학습일지
- 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;
}
미션을 진행하면서 지금까지 만들어왔던 코드의 예외 처리에 대한 기준이 없다는걸 깨닫게 됐습니다.
산발적으로 흩어져 있고 서로 다른 예외 처리, 로깅 방식은 안정적인 서비스를 운영하는데 방해요소
가 될 수 있다고 판단했습니다.
- 예외가 발생했을때, 중복적으로 로깅 처리가 될 수 있음
- 로깅만 하고 끝내는 부분, 로깅 및 예외를 다시 던지는 부분이 뒤섞이면서 예상치 못했던 부작용이 생길 가능성 내포
따라서 이런 부분을 응집시켜 처리해야 함을 느꼈고, 하기와 같이 예외 처리를 종류에 따라 두 가지 방법으로 나누어서 처리했습니다.
- 외부 IO와 같이 처리할 수 없는 부분 → 해당 예외를 즉시 로깅 하고 별도의
UncheckedException
을 던지고 종료 (ex:IOException
,SQLException
등)- 다만 Response, Request를 처리하면서 발생하는 예외는 처리하기 어려우므로 로깅만하고 패싱함
- 사용자 요청을 처리하면서 발생하는 예외는 사용자의 잘못일 가능성이 크기 때문에 즉시 에러 페이지 응답
// 외부 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
이 발생하는 예외 또한 로깅을 신경써주어야 합니다.
- 사용자 요청 처리 부분에 대한 플로우가 모두
H2 DB를 도입하면서, 실제 DB 환경에 값을 저장하게 됩니다.
기존 RequestHandler들의 테스트는 메모리 DB를 통해 진행했기 때문에, 테스트 격리가 간편했지만 실제 DB를 사용하는 환경에선 조금 더 신경써주거나, 만들어줘야 하는 기능들이 있었습니다.
-
테스트 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 {...} ... }
-
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가 실행 된 듯한 환경을 제공해줍니다.
-
-
RequestHander
테스트 격리를 위해 실행하는 setUp, tearDown, clean() 등의 코드들이 산발적으로 흩어져있다고 느껴짐, 이를 좀 더 가독성 있게 보여주려면 어떻게 해야할지 고민중입니다. -
(게시글 + 댓글) * N
개의 게시글을 보여주는 로직이 상당히 복잡합니다. 템플릿 엔진이 아니라 코드 조각들을 조합해 Response를 만들고 있는데 이로 인해 홈 화면을 처리하는 핸들러의 로직이 상당히 가독성이 나쁘고, 복잡합니다. 코드 조각들을 조합하는 부분을 어떻게 분리하면 좋을지 고민중입니다. -
MultipartRequest
,, 그냥 고민이 계속됩니다..CSV DB
도 ㅠㅠ
HTTP Request
를 이루고 있는 Request Line
, Header
는 String
으로 저장해도 무관합니다.
오로지 message body
데이터만 multipart form data
로 들어왔을때의 처리가 필요합니다.
따라서 기존 BufferedReader
로 읽어오던 Request
정보들을 InputStream
을 감싼 BufferedInputStream
을 사용해 읽었습니다.
-
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; } }
-
Message Body
데이터는 바이트로 관리하기RequestINputStreamReader
는MessageBody
데이터를byte[]
로 가지고 있습니다.RequestMessageBody
는 해당 데이터와HTTP Request
의Content-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 + '\'' + '}'; } }