[토이 프로젝트] 순수 Java로 게시판 흉내 내기 (TDD, File, MVC)

BaekGwa·2024년 12월 3일
0

✔️ Java

목록 보기
8/12
post-thumbnail

Project Code : 링크

  • 현재 저는 프로그래머스 데브코스 과정을 진행 중인 학생입니다
  • 1주차 과제(?), 학습 목표가 선정되어서 해결한 기록들 입니다.
  • 간단하다고 생각한 과제이지만, 깊게 생각하면 생각할 수록 발전할 여지가 있는 과제라, 정리합니다.

요구사항

총 단계는 14단계 까지 있었지만, 단계별 요구사항은 상세히 적지는 않습니다.

  • 프로젝트) 명언 게시판
    • TDD 적용 (Junit) / (통합 테스트만 진행)
      • 테스트용 DB는 운영 단계의 DB와 분리할 것
    • CSR 구조 도입 (각 계층은, 역할에 맞게 사용될 것)
    • 게시판의 기능은 다음과 같다.
      • 명언 등록
      • 명언 삭제
      • 명언 수정
      • 명언 전체 빌드/조회(파일)
      • 명언 검색(queryParam 처럼 입력받고, like 검색이 가능해야함)
      • 명언 검색 페이징
    • DB는 별도로 두지 않고, File을 사용해서 저장한다.
      • EX) 1.json
    • 외부 라이브러리 사용 x
    • 입력/출력은 console 로 진행

중요 작업 기록

모든 요구 조건 사항이 처음부터 오픈된건 아니라, 실제 작업 순서에는 차이가 조금 있습니다.

  • 작업 중 몇가지 깊게 고민하고 작업한 부분을 소개하려고 합니다.
  • 일딴 제 작업의 중요 핵심은 확장성 고려, 범용성 입니다.

CSR 구조 도입

  • CSR 구조를 도입해야 합니다.
    • 구지? 라는 생각이 들긴 했는데, 코테 풀듯이 Main에 다 때려 박다보면 책임 분리가 절실하게 느껴집니다.
  • 처음 작업할 때는 Main에 다 때려박고 사용했었는데, 파일을 통한 영속성 부분 개발을 진행하다 보니, 가독성이 나락을 가버렸습니다.
//저장소 inMemory -> Json 파일 저장으로 변경 v2
public class MainV2 {

    private static long index;
    private static final String DELETE_PREFIX = "삭제?id=";
    private static final String MODIFY_PREFIX = "수정?id=";
    private static final String DB_ROOT = "db/wiseSaying/";
    private static final String LAST_ID_PATH = "db/lastId.txt";

    private static class WiseSaying {

        private final Long id;
        private final String content;
        private final String author;

        public WiseSaying(Long id, String content, String author) {
        }

        @Override
        public String toString() {
        }

        public String toJson() {
        }

        public static WiseSaying toObject(String json)
    }

    public static void main(String[] args) throws IOException {
    }

    private static void registerWiseSaying(BufferedReader br) throws IOException {
    }

    private static void saveWiseSaying(WiseSaying saying) throws IOException {
    }

    private static void printAllWiseSaying() throws IOException {
    }

    private static List<WiseSaying> loadAllWiseSaying() throws IOException {
    }

    private static void deleteWiseSaying(Long id) throws IOException {
    }

    private static void modifyWiseSaying(Long id, BufferedReader br) throws IOException {
    }

    private static Long loadLastId() throws IOException {
    }

    private static void saveLastId() throws IOException {
        }
    }
}
  • 약 200줄 정도가 나와버렸습니다. 😥😥
  • 따라서 요구사항대로, CSR 구조를 적극 도입해서 각 계층별 역할 분리를 진행 해주었습니다.
  • 계층별 작업은 controller -> service -> repository 순으로 요청의 흐름이 이뤄지게 하였습니다.

Controller

  • 컨트롤러는, 말 그대로 사용자에게 입력받은 요청을 처리하기 위해 서빙하고, 결과를 사용자에게 전달하는 역할을 가정하였습니다.

Service

  • 서비스 레이어는, 비지니스 로직이 주를 이루며, 데이터를 가공하여 응답을 생성하는 역할을 가정하였습니다.

Repository

  • 레포지토리 영역은, DB (File)에 접근하여 저장된 데이터를 가져오거나 저장하는 역할을 가정하였습니다.

명언 검색 기능

  • 명언 검색 기능은, 고민이 정말 많이 들어갔습니다.
  • 입력으로 이러한 데이터가 들어올 때 목록?keywordType=author&keyword=작자 like 검색을 때려야 했습니다.
  • 형태가 마치, http 통신의 queryParams처럼 느껴졌고, 처리할 방법을 생각하던 중, Servlet 처럼 받은 queryParams을 Map에 정리하여 controller 에 전달하는 아이디어가 생각났습니다.
public class ServletUtils {

    //EX) 목록?keyword=작자&keywordType=author
    //out = Map 형식, {key -> value} => {keyword -> 작자}, {keywordType -> author}
    public static Map<String, String> extractRequestParams(String commandString) {
        Map<String, String> requestParams = new HashMap<>();
        try {
            String subString = commandString.substring(commandString.indexOf('?') + 1);

            return Arrays.stream(subString.split("&"))
                    .map(pair -> pair.split("="))
                    .filter(keyValue -> keyValue.length == 2)
                    .collect(Collectors.toMap(keyValue -> keyValue[0], keyValue -> keyValue[1]));
        } catch (Exception e) {
            return requestParams;
        }
    }
}
  • 다음과 같은 Utils 클래스를 만들어서 main에서 사용하도록 하였습니다.
  • =를 기준으로 왼쪽을 key, 오른쪽을 value로 가정하여 map 에 넣도록 하였습니다.
  • 입력으로 오는 조건이 별로 없지만, 확장성을 생각해서 모든 queryParams 형식으로 오는걸 RequestParms Map 으로 처리해서 옮겨주었습니다.
  • 또한, 전달받은 requestParams를 controller에서 용도에 맞게 파싱하여 사용하도록 하였습니다.
public class ControllerUtils {

    /**
     * 입력받은 requestParams 로, Pageable 객체를 만들어 줍니다.
     * Handling 내용
     * "page"
     * "size"
     * @param requestParams
     * @return
     */
    public static Pageable createPageable(Map<String, String> requestParams) {
        int page = Integer.parseInt(requestParams.getOrDefault(PAGE, DEFAULT_PAGE.toString()));
        int size = Integer.parseInt(requestParams.getOrDefault(SIZE, DEFAULT_SIZE.toString()));

        return new Pageable(page, size);
    }

    /**
     * 입력받은 requestParams 로, Search 객체를 만들어 줍니다.
     * Handling 내용
     * "keywordType"
     * "keyword"
     */
    public static Search createSearch(Map<String, String> requestParams) {
        if(!requestParams.containsKey(KEYWORD_TYPE) || !requestParams.containsKey(KEYWORD)) {
            //둘중 하나라도 비어있다면, 정상적인 조회가 불가능.
            //exception or 전체 검색 되도록 설정.
            //현재는 전체 검색되도록 기능구현.
            return Search.empty();
        }
        String keywordType = requestParams.get(KEYWORD_TYPE);
        String keyword = requestParams.get(KEYWORD);
        return new Search(keywordType, keyword);
    }
}
  • 이후에 만약, request 종류가 다양해 지더라도, 일부분만 손보면 바로 재사용이 가능하도록 설정되었습니다.

Exception 공통 Handling 진행

  • Repository 에서는 File 입출력 관련된 코드를 다루다 보니, 예외가 거의 대부분 발생하였습니다.
  • 특히 IOException을 자주 handling 해주어야 했고, 이에 따라서 서비스 코드에 try-catch 문으로 처리하는 과정이 많이 들어갔습니다. (코드에 예외처리가 너무 침범)
  • 또한, 조건으로 출력과 관련된 부분은 모두 controller에서 처리하기 때문에 올바르지 않은 방법이었습니다.
  • 따라서, Spring 에서 사용하던 exception handler를 AOP 개념과 함께 도입하기로 하였습니다.
public class GlobalExceptionHandlingProxy implements InvocationHandler {

    private final Object target;

    public GlobalExceptionHandlingProxy(Object target) {
        this.target = target;
    }

    @SuppressWarnings("unchecked")
    public static <T> T createProxy(T target,
            Class<T> classType) {
        return (T) Proxy.newProxyInstance(
                target.getClass().getClassLoader(),
                new Class<?>[]{classType},
                new GlobalExceptionHandlingProxy(target)
        );
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        try {
            return method.invoke(this.target, args);
        } catch (InvocationTargetException e) {
            Throwable cause = e.getCause();
            if(cause instanceof IOException) {
                System.out.println("연결 중 오류가 발생하였습니다. message = " + cause.getMessage());
                System.exit(1);
            } else if(cause instanceof CustomException) {
                System.out.println(cause.getMessage());
            } else {
                System.out.println("기타 오류가 발생하였습니다. message = " + cause.getMessage());
                System.exit(1);
            }
        } catch (Exception e) {
            e.getCause();
            System.out.println("처리되지 않은 Exception 발생" + e.getMessage());
            System.exit(1);
        }
        return null;
    }
}
  • 다음과 같은 예외를 핸들링 할 수 있는 핸들러 객체를 생성해주고, Controller에 프록시를 적용해주었습니다.
public static void main(String[] args) throws IOException {
		~~~
        
        WiseSayingController wiseSayingControllerProxy
                = GlobalExceptionHandlingProxy.createProxy(wiseSayingController, WiseSayingController.class);

        ~~~
  • 즉, 다음과 같은 구조를 가지게 되었습니다.
    • main -> (proxy)controller -> controller -> service -> repository
  • 덕분에, 예외 처리 부분의 코드를 분리할 수 있게 되어, 중복 코드를 줄이고, 유지보수성이 조금더 높아지게 되었습니다.

후기

  • 진짜 솔직히, 코스 1주차라 엄청 만만하게 보고 대충 빨리 끝내야지! 라는 마인드로 접근했었습니다.
  • 하지만, 작업하면서 고려해야 될 부분이 점점 늘어나다 보니 꽤 시간을 잡아먹게 되었습니다.
  • 다행인건, 1년 전의 나와 비교해 보았을 때, 생각의 깊이가 조금은 더 깊어 졌다고 생각합니다.
    • 족욕탕에서 반신욕탕으로 변신!
  • 위에 소개드린 작업 외에도 java에 대한 다양한 생각이 담겨있는 코드니, 많이 보시고 누군가가 많이 리뷰해주면 좋겠습니다.

Thanks for!

😆😆😆 많은 리뷰 감사합니다!!!!!




profile
현재 블로그 이전 중입니다. https://blog.baekgwa.site/

0개의 댓글