Light Blue Pointer
본문 바로가기
TIL(CS)

궁극의 제티서버

by 개발바닥곰발바닥!!! 2025. 9. 3.

AsyncJettyServer

package server;

import dto.Dtos;
import dto.HttpException;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.eclipse.jetty.util.thread.QueuedThreadPool;
import service.CategoryService;
import service.RiskAnalysisService;
import util.HttpUtil;
import util.JsonUtil;
import util.ThreadUtil;

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;

public class AsyncJettyServer {
    public static void main(String[] args) throws Exception {
        int port = Integer.parseInt(System.getProperty("PORT", "8080"));

        int cores = Runtime.getRuntime().availableProcessors();
        int minThreads = cores;          // 최소 스레드는 코어 개수
        int maxThreads = cores * 4;      // CPU 바운드라면 ×2, I/O 바운드라면 ×4~8
        QueuedThreadPool pool = new QueuedThreadPool(maxThreads, minThreads);
        Server server = new Server(pool);

        ServerConnector http = new ServerConnector(server);
        http.setPort(port);
        http.setIdleTimeout(30_000);
        server.addConnector(http);

        ServletContextHandler ctx = new ServletContextHandler(ServletContextHandler.SESSIONS);
        ctx.setContextPath("/");

        ctx.addServlet(new ServletHolder(new ApiServlet()), "/*");

        server.setHandler(ctx);
        server.setStopAtShutdown(true);

        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            try {
                server.stop();
                ThreadUtil.shutdown(); // ThreadUtil 정리 추가
            } catch (Exception ignore) {}
        }));

        server.start();
        server.join();
    }

    public static class ApiServlet extends HttpServlet {

        private interface Handler { void handle(HttpServletRequest req, HttpServletResponse resp) throws IOException, HttpException; }

        private final Map<String, Handler> getRoutes  = new LinkedHashMap<String, Handler>();
        private final Map<String, Handler> postRoutes = new LinkedHashMap<String, Handler>();

        // 서비스들을 비동기로 초기화
        private final AtomicReference<CategoryService> categoryService = new AtomicReference<>();
        private final AtomicReference<RiskAnalysisService> riskService = new AtomicReference<>();
        private volatile boolean servicesInitialized = false;

        public ApiServlet() {
            initializeServicesAsync();
            setupRoutes();
        }

        /**
         * 서비스들을 비동기로 초기화 (ThreadUtil 사용)
         */
        private void initializeServicesAsync() {
            ThreadUtil.runAsync(() -> {
                try {
                    Path path = Paths.get("CATEGORIES.TXT").toAbsolutePath().normalize();
                    Path path2 = Paths.get("RISK_PATTERNS.TXT").toAbsolutePath().normalize();

                    // CategoryService와 RiskAnalysisService를 병렬로 초기화
                    var categoryFuture = ThreadUtil.supplyAsync(() -> {
                        try {
                            return new CategoryService(path.toString(), path2.toString());
                        } catch (Exception e) {
                            throw new RuntimeException("CategoryService 초기화 실패", e);
                        }
                    });

                    var riskFuture = ThreadUtil.supplyAsync(() -> {
                        try {
                            return new RiskAnalysisService();
                        } catch (Exception e) {
                            throw new RuntimeException("RiskAnalysisService 초기화 실패", e);
                        }
                    });

                    // 두 서비스 초기화 완료 대기
                    ThreadUtil.awaitAll(categoryFuture, riskFuture);

                    categoryService.set(categoryFuture.join());
                    riskService.set(riskFuture.join());
                    servicesInitialized = true;

                    System.out.println("모든 서비스가 성공적으로 초기화되었습니다.");

                } catch (Exception e) {
                    System.err.println("서비스 초기화 실패: " + e.getMessage());
                    e.printStackTrace();
                }
            });
        }

        /**
         * 서비스가 초기화될 때까지 대기
         */
        private void ensureServicesInitialized() throws HttpException {
            if (!servicesInitialized) {
                // 최대 5초 대기
                long startTime = System.currentTimeMillis();
                while (!servicesInitialized && System.currentTimeMillis() - startTime < 5000) {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        throw HttpException.internalServerError("서비스 초기화 대기 중 중단됨");
                    }
                }

                if (!servicesInitialized) {
                    throw HttpException.serviceUnavailable("서비스가 아직 초기화되지 않았습니다");
                }
            }
        }

        private void setupRoutes() {
            // GET 라우트 등록
            //getRoutes.put("/analyze", this::getAnalyze);

            // POST 라우트 등록
            //postRoutes.put("/echo", this::postEcho);
            //postRoutes.put("/performance", this::postPerformance);
            postRoutes.put("/register-transaction", this::registerTransaction);
            postRoutes.put("/analyze-session", this::analyzeSession);
        }

        @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
            dispatch(getRoutes, req, resp);
        }
        @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
            dispatch(postRoutes, req, resp);
        }

        private void dispatch(Map<String, Handler> routes, HttpServletRequest req, HttpServletResponse resp) throws IOException {
            String path = req.getPathInfo();
            if (path == null || path.isEmpty()) path = "/";
            Handler h = routes.get(path);
            if (h == null) {
                // 404 에러 직접 처리
                HttpException notFound = HttpException.notFound("경로를 찾을 수 없습니다: " + path);
                jsonResponse(resp, notFound.getStatusCode(), notFound.getResponseBody());
                return;
            }

            try {
                h.handle(req, resp);
            } catch (HttpException e) {
                // HttpException 처리 - 실제 HTTP 응답 상태 코드 설정
                HttpUtil.sendHttpExceptionResponse(resp, e);
            } catch (Exception e) {
                e.printStackTrace();
                HttpException serverError = HttpException.internalServerError("서버 내부 오류가 발생했습니다");
                jsonResponse(resp, serverError.getStatusCode(), serverError.getResponseBody());
            }
        }

        private void getAnalyze(HttpServletRequest req, HttpServletResponse resp) throws IOException, HttpException {
            //GET <http://127.0.0.1:8080/analyze?text=><분석할 텍스트>&level=<마스킹 레벨>
            //level은 basic과 strict가 있어요
            String text = req.getParameter("text");
            String levelStr = req.getParameter("level");

            if (text == null || text.isEmpty()) {
                json(resp, 400, "{\\"error\\":\\"missing text\\"}");
                return;
            }

            String risk_level = levelStr; // 위험도 결과

            json(resp, 200, "{\\"risk_level\\":" + risk_level + ",\\"result\\":\\"" + "\\"}");
        }

        private void postEcho(HttpServletRequest req, HttpServletResponse resp) throws IOException, HttpException {
            String body = readAll(req);
            json(resp, 201, body.isEmpty() ? "{}" : body);
        }

        private void postPerformance(HttpServletRequest req, HttpServletResponse resp) throws IOException, HttpException {
            try {
                String body = readAll(req);
                json(resp, 201, body.isEmpty() ? "{}" : body);
            }catch (Exception e){
                json(resp,400,"{}");
            }
        }

        //        curl -X POST <http://127.0.0.1:8080/register-transaction> \\
//                -H "Content-Type: application/json" \\
//                -d '{
//                "session_id": "SESS_001",
//                "transaction": "CARD_PAYMENT 150000 RESTAURANT 20240830"
//    }'
        private void registerTransaction(HttpServletRequest req, HttpServletResponse resp) throws IOException, HttpException {
            ensureServicesInitialized(); // 서비스 준비 확인

            String body = readAll(req);
            Dtos.Request request = JsonUtil.fromJson(body, Dtos.Request.class);

            // CategoryService의 비동기 메서드 사용 후 동기적으로 대기
            try {
                categoryService.get().saveFilteredCategoryAsync(request.sessionId, request.transaction).join();
            } catch (Exception e) {
                if (e.getCause() instanceof IOException) {
                    throw (IOException) e.getCause();
                }
                if (e.getCause() instanceof HttpException) {
                    throw (HttpException) e.getCause();
                }
                throw new IOException("거래 등록 실패", e);
            }

            Dtos.Response response = new Dtos.Response();
            response.status = "registered";
            json(resp, 200, JsonUtil.toJson(response));
        }

        private void analyzeSession(HttpServletRequest req, HttpServletResponse resp) throws IOException, HttpException {
            String body = readAll(req);
            Dtos.AnalyzeSessionRequest request = JsonUtil.fromJson(body, Dtos.AnalyzeSessionRequest.class);

            // 1초 내에 처리해야 함
            long startTime = System.currentTimeMillis();

            try {
                // 세션 파일에서 거래 데이터 비동기 읽기 (ThreadUtil로 동기 메서드를 비동기화)
                var transactionsFuture = ThreadUtil.supplyAsync(() -> {
                    try {
                        return categoryService.get().readSessionTransactions(request.sessionId);
                    } catch (IOException | HttpException e) {
                        throw new RuntimeException(e);
                    }
                });

                List<String> transactions;
                try {
                    transactions = transactionsFuture.join();
                } catch (Exception e) {
                    if (e.getCause() instanceof HttpException) {
                        throw (HttpException) e.getCause();
                    }
                    throw new IOException("세션 데이터 읽기 실패", e);
                }

                if (transactions.isEmpty()) {
                    Dtos.AnalyzeSessionResponse response = new Dtos.AnalyzeSessionResponse();
                    response.results = new String[0];
                    jsonResponse(resp, 200, response);
                    return;
                }

                // 외부 위험도 분석 서비스들을 병렬로 호출 (ThreadUtil로 동기 메서드를 비동기화)
                var riskAnalysisFuture = ThreadUtil.supplyAsync(() -> {
                    try {
                        return riskService.get().analyzeTransactionsParallel(transactions);
                    } catch (IOException | HttpException e) {
                        throw new RuntimeException(e);
                    }
                });

                List<String> riskLevels;
                try {
                    riskLevels = riskAnalysisFuture.join();
                } catch (Exception e) {
                    if (e.getCause() instanceof HttpException) {
                        throw (HttpException) e.getCause();
                    }
                    throw new IOException("위험도 분석 실패", e);
                }

                // 처리 시간 체크
                long elapsed = System.currentTimeMillis() - startTime;
                if (elapsed > 1000) {
                    throw HttpException.requestTimeout("분석 처리 시간이 1초를 초과했습니다");
                }

                // 응답
                Dtos.AnalyzeSessionResponse response = new Dtos.AnalyzeSessionResponse();
                response.results = riskLevels.toArray(new String[0]);

                jsonResponse(resp, 200, response);

            } catch (Exception e) {
                long elapsed = System.currentTimeMillis() - startTime;
                if (elapsed > 1000) {
                    throw HttpException.requestTimeout("분석 처리 시간이 1초를 초과했습니다");
                }
                e.printStackTrace(); // 콘솔에 에러 출력
                throw e; // 에러를 다시 던져서 dispatch에서 처리하도록
            }
        }

        private static String readAll(HttpServletRequest req) throws IOException {
            StringBuilder sb = new StringBuilder();
            BufferedReader br = req.getReader();
            try {
                String line;
                while ((line = br.readLine()) != null) sb.append(line);
            } finally {
                try { br.close(); } catch (IOException ignore) {}
            }
            return sb.toString();
        }

        private static void json(HttpServletResponse resp, int status, String json) throws IOException {
            resp.setStatus(status);
            resp.setContentType("application/json;charset=utf-8");
            PrintWriter w = resp.getWriter();
            w.write(json);
            w.flush();
        }

        private void jsonResponse(HttpServletResponse resp, int status, Object data) throws IOException {
            resp.setStatus(status);
            resp.setContentType("application/json;charset=utf-8");
            PrintWriter w = resp.getWriter();
            w.write(JsonUtil.gson().toJson(data));
            w.flush();
        }

        private static String opt(String v, String def) { return v == null ? def : v; }
        private static String safe(String s) { return s == null ? "" : s.replace("\\\\", "\\\\\\\\").replace("\\"", "\\\\\\""); }
    }
}

JettyServer

package server;

import dto.Dtos;
import dto.HttpException;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.eclipse.jetty.util.thread.QueuedThreadPool;
import service.CategoryService;
import service.RiskAnalysisService;
import util.HttpUtil;
import util.JsonUtil;

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class JettyServer {
    public static void main(String[] args) throws Exception {
        int port = Integer.parseInt(System.getProperty("PORT", "8080"));

        int cores = Runtime.getRuntime().availableProcessors();
        int minThreads = cores;          // 최소 스레드는 코어 개수
        int maxThreads = cores * 4;      // CPU 바운드라면 ×2, I/O 바운드라면 ×4~8
        QueuedThreadPool pool = new QueuedThreadPool(maxThreads, minThreads);
        Server server = new Server(pool);

        ServerConnector http = new ServerConnector(server);
        http.setPort(port);
        http.setIdleTimeout(30_000);
        server.addConnector(http);

        ServletContextHandler ctx = new ServletContextHandler(ServletContextHandler.SESSIONS);
        ctx.setContextPath("/");

        ctx.addServlet(new ServletHolder(new ApiServlet()), "/*");

        server.setHandler(ctx);
        server.setStopAtShutdown(true);

        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            try { server.stop(); } catch (Exception ignore) {}
        }));

        server.start();
        server.join();
    }

    public static class ApiServlet extends HttpServlet {

        private interface Handler { void handle(HttpServletRequest req, HttpServletResponse resp) throws IOException, HttpException; }

        private final Map<String, Handler> getRoutes  = new LinkedHashMap<String, Handler>();
        private final Map<String, Handler> postRoutes = new LinkedHashMap<String, Handler>();

        //서비스들...?
        Path path = Paths.get("CATEGORIES.TXT").toAbsolutePath().normalize();
        Path path2 = Paths.get("RISK_PATTERNS.TXT").toAbsolutePath().normalize();
        CategoryService categoryService = new CategoryService(path.toString(), path2.toString());

        public ApiServlet() {
            setupRoutes();
        }

        private void setupRoutes() {
            // GET 라우트 등록
            //getRoutes.put("/analyze", this::getAnalyze);

            // POST 라우트 등록
            //postRoutes.put("/echo", this::postEcho);
            //postRoutes.put("/performance", this::postPerformance);
            postRoutes.put("/register-transaction", this::registerTransaction);
            postRoutes.put("/analyze-session", this::analyzeSession);
        }

        @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
            dispatch(getRoutes, req, resp);
        }
        @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
            dispatch(postRoutes, req, resp);
        }

        private void dispatch(Map<String, Handler> routes, HttpServletRequest req, HttpServletResponse resp) throws IOException {
            String path = req.getPathInfo();
            if (path == null || path.isEmpty()) path = "/";
            Handler h = routes.get(path);
            if (h == null) {
//                json(resp, 404, "{\\"error\\":\\"not_found\\",\\"path\\":\\"" + safe(path) + "\\"}");
//                return;
                // Todo
                // 404 에러 직접 처리
                HttpException notFound = HttpException.notFound("경로를 찾을 수 없습니다: " + path);
                jsonResponse(resp, notFound.getStatusCode(), notFound.getResponseBody());
                return;
            }

//            try {
//                h.handle(req, resp);
//            } catch (Exception e) {
//                json(resp, 500, "{\\"error\\":\\"server_error\\",\\"message\\":\\"" + safe(e.getMessage()) + "\\"}");
//            }
            // Todo
            try {
                h.handle(req, resp);
            } catch (HttpException e) {
                // HttpException 처리 - 실제 HTTP 응답 상태 코드 설정
                HttpUtil.sendHttpExceptionResponse(resp, e);
            } catch (Exception e) {
                e.printStackTrace();
                HttpException serverError = HttpException.internalServerError("서버 내부 오류가 발생했습니다");
                jsonResponse(resp, serverError.getStatusCode(), serverError.getResponseBody());
            }
        }

        private void getAnalyze(HttpServletRequest req, HttpServletResponse resp) throws IOException, HttpException {
            //GET <http://127.0.0.1:8080/analyze?text=><분석할 텍스트>&level=<마스킹 레벨>
            //level은 basic과 strict가 있어요
            String text = req.getParameter("text");
            String levelStr = req.getParameter("level");

            if (text == null || text.isEmpty()) {
                json(resp, 400, "{\\"error\\":\\"missing text\\"}");
                return;
            }

            String risk_level = levelStr; // 위험도 결과

            json(resp, 200, "{\\"risk_level\\":" + risk_level + ",\\"result\\":\\"" + "\\"}");
        }

        private void postEcho(HttpServletRequest req, HttpServletResponse resp) throws IOException, HttpException {
            String body = readAll(req);
            json(resp, 201, body.isEmpty() ? "{}" : body);
        }

        private void postPerformance(HttpServletRequest req, HttpServletResponse resp) throws IOException, HttpException {
            try {
                String body = readAll(req);
                json(resp, 201, body.isEmpty() ? "{}" : body);
            }catch (Exception e){
                json(resp,400,"{}");
            }
        }

//        curl -X POST <http://127.0.0.1:8080/register-transaction> \\
//                -H "Content-Type: application/json" \\
//                -d '{
//                "session_id": "SESS_001",
//                "transaction": "CARD_PAYMENT 150000 RESTAURANT 20240830"
//    }'
        private void registerTransaction(HttpServletRequest req, HttpServletResponse resp) throws IOException, HttpException {
            String body = readAll(req);
            Dtos.Request request = JsonUtil.fromJson(body, Dtos.Request.class);
            categoryService.saveFilteredCategory(request.sessionId, request.transaction);
            Dtos.Response response = JsonUtil.fromJson(body, Dtos.Response.class);
            response.status = "registered";
            json(resp, 200, JsonUtil.toJson(response));
        }

        private void analyzeSession(HttpServletRequest req, HttpServletResponse resp) throws IOException, HttpException {
            String body = readAll(req);
            Dtos.AnalyzeSessionRequest request = JsonUtil.fromJson(body, Dtos.AnalyzeSessionRequest.class);

            // 1초 내에 처리해야 함
            long startTime = System.currentTimeMillis();

            try {
                // 세션 파일에서 거래 데이터 읽기 (락킹 적용, 1초 타임아웃)
                List<String> transactions = categoryService.readSessionTransactions(request.sessionId);

                if (transactions.isEmpty()) {
                    Dtos.AnalyzeSessionResponse response = new Dtos.AnalyzeSessionResponse();
                    response.results = new String[0];
                    jsonResponse(resp, 200, response);
                    return;
                }

                // 외부 위험도 분석 서비스들을 병렬로 호출
                RiskAnalysisService riskService = new RiskAnalysisService();
                List<String> riskLevels = riskService.analyzeTransactionsParallel(transactions);

                // 처리 시간 체크
                long elapsed = System.currentTimeMillis() - startTime;
                if (elapsed > 1000) {
                    throw HttpException.requestTimeout("분석 처리 시간이 1초를 초과했습니다");
                }

                // 응답
                Dtos.AnalyzeSessionResponse response = new Dtos.AnalyzeSessionResponse();
                response.results = riskLevels.toArray(new String[0]);

                jsonResponse(resp, 200, response);

            } catch (Exception e) {
                long elapsed = System.currentTimeMillis() - startTime;
                if (elapsed > 1000) {
                    throw HttpException.requestTimeout("분석 처리 시간이 1초를 초과했습니다");
                }
                e.printStackTrace(); // 콘솔에 에러 출력

            }}
                private static String readAll(HttpServletRequest req) throws IOException {
            StringBuilder sb = new StringBuilder();
            BufferedReader br = req.getReader();
            try {
                String line;
                while ((line = br.readLine()) != null) sb.append(line);
            } finally {
                try { br.close(); } catch (IOException ignore) {}
            }
            return sb.toString();
        }
        private static void json(HttpServletResponse resp, int status, String json) throws IOException {
            resp.setStatus(status);
            resp.setContentType("application/json;charset=utf-8");
            PrintWriter w = resp.getWriter();
            w.write(json);
            w.flush();
        }

        private void jsonResponse(HttpServletResponse resp, int status, Object data) throws IOException {
            resp.setStatus(status);
            resp.setContentType("application/json;charset=utf-8");
            PrintWriter w = resp.getWriter();
            w.write(JsonUtil.gson().toJson(data));
            w.flush();
        }

        private static String opt(String v, String def) { return v == null ? def : v; }
        private static String safe(String s) { return s == null ? "" : s.replace("\\\\", "\\\\\\\\").replace("\\"", "\\\\\\""); }
    }
}

Dtos

package dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import com.google.gson.annotations.SerializedName;
import lombok.NoArgsConstructor;

public class Dtos {
    @AllArgsConstructor
    public static class Request{
        @SerializedName("session_id")
        public String sessionId;
        public String transaction;
    }

    @AllArgsConstructor
    @NoArgsConstructor
    public static class Response{
        public String status;
    }

    @AllArgsConstructor
    @NoArgsConstructor
    public static class RegisterTransactionRequest {
        @SerializedName("session_id")
        public String sessionId;
        public String transaction;
    }

    @NoArgsConstructor
    public static class RegisterTransactionResponse {
        public String status;
    }

    @AllArgsConstructor
    @NoArgsConstructor
    public static class AnalyzeSessionRequest {
        @SerializedName("session_id")
        public String sessionId;
    }

    @NoArgsConstructor
    public static class AnalyzeSessionResponse {
        public String[] results;
    }

    // 외부 위험도 분석 서비스 요청/응답
    @AllArgsConstructor
    @NoArgsConstructor
    public static class RiskAnalysisRequest {
        public String query;
    }

    @NoArgsConstructor
    public static class RiskAnalysisResponse {
        @SerializedName("risk_code")
        public String riskCode;
    }

    // 서비스 목록 관련
    @NoArgsConstructor
    public static class ServicesConfig {
        public Service[] services;
    }

    @NoArgsConstructor
    public static class Service {
        @SerializedName("service_name")
        public String serviceName;
        public String url;
        @SerializedName("risk_mappings")
        public RiskMapping[] riskMappings;
    }

    @NoArgsConstructor
    public static class RiskMapping {
        public String code;
        public String level;
    }
}

HttpException

package dto;

public class HttpException extends Exception {
    private final int statusCode;
    private final Object responseBody;

    public HttpException(int statusCode, String message) {
        super(message);
        this.statusCode = statusCode;
        this.responseBody = new SimpleErrorResponse(message);
    }

    public HttpException(int statusCode, Object responseBody) {
        super(responseBody.toString());
        this.statusCode = statusCode;
        this.responseBody = responseBody;
    }

    public HttpException(int statusCode, String message, Object responseBody) {
        super(message);
        this.statusCode = statusCode;
        this.responseBody = responseBody;
    }

    public int getStatusCode() {
        return statusCode;
    }

    public Object getResponseBody() {
        return responseBody;
    }

    // 간단한 에러 응답 클래스
    public static class SimpleErrorResponse {
        public boolean success = false;
        public String error;
        public long timestamp;

        public SimpleErrorResponse(String error) {
            this.error = error;
            this.timestamp = System.currentTimeMillis();
        }
    }

    // 편의 메서드들 - 자주 사용하는 HTTP 상태 코드들
    public static HttpException badRequest(String message) {
        return new HttpException(400, message);
    }

    public static HttpException unauthorized(String message) {
        return new HttpException(401, message);
    }

    public static HttpException forbidden(String message) {
        return new HttpException(403, message);
    }

    public static HttpException notFound(String message) {
        return new HttpException(404, message);
    }

    public static HttpException methodNotAllowed(String message) {
        return new HttpException(405, message);
    }

    public static HttpException requestTimeout(String message) {
        return new HttpException(408, message);
    }

    public static HttpException conflict(String message) {
        return new HttpException(409, message);
    }

    public static HttpException tooManyRequests(String message) {
        return new HttpException(429, message);
    }

    public static HttpException internalServerError(String message) {
        return new HttpException(500, message);
    }

    public static HttpException notImplemented(String message) {
        return new HttpException(501, message);
    }

    public static HttpException badGateway(String message) {
        return new HttpException(502, message);
    }

    public static HttpException serviceUnavailable(String message) {
        return new HttpException(503, message);
    }

    public static HttpException gatewayTimeout(String message) {
        return new HttpException(504, message);
    }
}

FileLoader

package util;

import com.google.gson.JsonObject;
import com.google.gson.JsonParser;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;

public class FileLoader {
    /**
     * 파일을 한 줄씩 읽어서 List<String>으로 반환 (빈 줄 포함)
     */
    public static List<String> readFileLines(String filePath) {
        try {
            return Files.readAllLines(Paths.get(filePath), StandardCharsets.UTF_8);
        } catch (IOException e) {
            System.err.println("파일 읽기 실패: " + filePath + " - " + e.getMessage());
            return Collections.emptyList();
        }
    }

    /**
     * 파일을 한 줄씩 읽되, 빈 줄과 공백을 제거한 List<String> 반환
     */
    public static List<String> readFileLinesClean(String filePath) {
        return readFileLines(filePath).stream()
                .map(String::trim)
                .filter(line -> !line.isEmpty())
                .collect(Collectors.toList());
    }

    /**
     * 파일을 한 줄씩 읽되, 주석(#으로 시작)과 빈 줄 제거
     */
    public static List<String> readFileLinesNoComments(String filePath) {
        return readFileLines(filePath).stream()
                .map(String::trim)
                .filter(line -> !line.isEmpty() && !line.startsWith("#"))
                .collect(Collectors.toList());
    }

    /**
     * 파일을 읽어서 구분자로 분할된 Key-Value Map 반환
     */
    public static Map<String, String> parseKeyValueFile(String filePath, String delimiter) {
        Map<String, String> result = new HashMap<>();

        List<String> lines = readFileLinesNoComments(filePath);
        for (String line : lines) {
            if (line.contains(delimiter)) {
                String[] parts = line.split(delimiter, 2);
                if (parts.length == 2) {
                    result.put(parts[0].trim(), parts[1].trim());
                }
            }
        }

        return result;
    }

    /**
     * 파일을 읽어서 특정 타입의 Key-Value Map 반환 (제네릭)
     */
    public static <V> Map<String, V> parseTypedKeyValueFile(String filePath, String delimiter,
                                                            Function<String, V> valueParser) {
        Map<String, V> result = new HashMap<>();

        List<String> lines = readFileLinesNoComments(filePath);
        for (String line : lines) {
            if (line.contains(delimiter)) {
                String[] parts = line.split(delimiter, 2);
                if (parts.length == 2) {
                    try {
                        String key = parts[0].trim();
                        V value = valueParser.apply(parts[1].trim());
                        if (value != null) {
                            result.put(key, value);
                        }
                    } catch (Exception e) {
                        System.err.println("파싱 실패한 줄 무시: " + line + " (" + e.getMessage() + ")");
                    }
                }
            }
        }

        return result;
    }

    /**
     * JSON 파일을 제너릭하게 로드하는 메서드
     */
    public static <T> T parseJsonFile(String filePath, Class<T> clazz) {
        try {
            String json = readFileToString(filePath);
            return JsonUtil.gson().fromJson(json, clazz);
        } catch (IOException e) {
            System.err.println("JSON 파일 읽기 실패: " + filePath + " - " + e.getMessage());
            return null;
        }
    }

    /**
     * JSON 문자열을 제너릭하게 파싱하는 메서드
     */
    public static <T> T parseJson(String json, Class<T> clazz) {
        try {
            return JsonUtil.gson().fromJson(json, clazz);
        } catch (Exception e) {
            System.err.println("JSON 파싱 실패: " + e.getMessage());
            return null;
        }
    }

    /**
     * 이미지 파일을 로드하는 메서드
     */
    public static BufferedImage loadImage(String filePath) {
        try {
            File imageFile = new File(filePath);
            if (!imageFile.exists()) {
                System.err.println("이미지 파일 미존재: " + filePath);
                return null;
            }

            BufferedImage originalImage = ImageIO.read(imageFile);
            if (originalImage == null) {
                System.err.println("지원되지 않는 이미지 형식: " + filePath);
                return null;
            }

            System.out.println("이미지 로드 성공: " + filePath +
                    " (" + originalImage.getWidth() + "x" + originalImage.getHeight() + ")");
            return originalImage;

        } catch (IOException e) {
            System.err.println("이미지 로드 실패: " + filePath + " - " + e.getMessage());
            return null;
        }
    }

    /**
     * JSON 파일을 JsonObject로 로드하는 메서드
     */
    public static JsonObject loadJsonFile(String filePath) {
        try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) {
            String content = readFileToString(filePath);
            JsonParser parser = new JsonParser();
            return parser.parse(content).getAsJsonObject();
        } catch (IOException e) {
            System.err.println("JSON 파일 읽기 실패: " + e.getMessage());
        }
        return null;
    }

    /**
     * JSON 파일을 특정 클래스 타입으로 로드하는 메서드
     */
    public static <T> T loadFile(String filePath, Class<T> clazz) {
        try {
            String json = readFileToString(filePath);
            return JsonUtil.gson().fromJson(json, clazz);
        } catch (IOException e) {
            System.err.println("파일 읽기 실패: " + e.getMessage());
            return null;
        }
    }

    /**
     * 파일 내용을 문자열로 읽어오는 메서드
     */
    public static String readFileToString(String filePath) throws IOException {
        return new String(Files.readAllBytes(Paths.get(filePath)), StandardCharsets.UTF_8);
    }

    /**
     * CSV 파일을 읽어서 2차원 리스트로 반환하는 메서드
     */
    public static List<List<String>> readCsvFile(String filePath) {
        return readCsvFile(filePath, ",");
    }

    /**
     * 구분자를 지정하여 CSV 파일을 읽는 메서드
     */
    public static List<List<String>> readCsvFile(String filePath, String delimiter) {
        try {
            List<String> lines = Files.readAllLines(Paths.get(filePath), StandardCharsets.UTF_8);
            return lines.stream()
                    .map(line -> Arrays.asList(line.split(delimiter)))
                    .collect(Collectors.toList());
        } catch (IOException e) {
            System.err.println("CSV 파일 읽기 실패: " + e.getMessage());
            return Collections.emptyList();
        }
    }

    /**
     * JSON 문자열을 특정 클래스 타입으로 변환하는 메서드
     */
    public static <T> T fromJson(String json, Class<T> clazz) {
        return JsonUtil.gson().fromJson(json, clazz);
    }

    /**
     * 문장을 토큰(단어)으로 분할하는 메서드
     */
    public static List<String> tokenize(String sentence) {
        if (sentence == null || sentence.trim().isEmpty()) {
            return Collections.emptyList();
        }

        return Arrays.stream(sentence.trim().split("\\\\s+"))
                .filter(token -> !token.isEmpty())
                .collect(Collectors.toList());
    }

    /**
     * 파일 존재 여부를 확인하는 메서드
     */
    public static boolean fileExists(String filePath) {
        try {
            return Files.exists(Paths.get(filePath));
        } catch (Exception e) {
            System.err.println("파일 존재 확인 중 오류: " + filePath + " - " + e.getMessage());
            return false;
        }
    }

    /**
     * 파일의 확장자를 반환하는 메서드
     */
    public static String getFileExtension(String filePath) {
        if (filePath == null || filePath.isEmpty()) {
            return "";
        }

        int lastDotIndex = filePath.lastIndexOf('.');
        if (lastDotIndex == -1 || lastDotIndex == filePath.length() - 1) {
            return "";
        }

        return filePath.substring(lastDotIndex + 1).toLowerCase();
    }

    /**
     * 파일 크기를 반환하는 메서드 (바이트 단위)
     */
    public static long getFileSize(String filePath) {
        try {
            return Files.size(Paths.get(filePath));
        } catch (IOException e) {
            System.err.println("파일 크기 확인 중 오류: " + filePath + " - " + e.getMessage());
            return -1;
        }
    }

    /**
     * JSON 파일에서 객체를 읽어오는 제네릭 메서드 (보너스)
     * @param <T> 읽어올 객체의 타입
     * @param filePath 파일 경로
     * @param fileName 파일명
     * @param clazz 객체의 클래스
     * @return 읽어온 객체
     * @throws IOException 파일 읽기 실패 시
     */
    public static <T> T loadFromJsonFile(String filePath, String fileName, Class<T> clazz) throws IOException {
        if (!fileName.toLowerCase().endsWith(".json")) {
            fileName += ".json";
        }

        Path fullPath = Paths.get(filePath, fileName);
        String jsonContent = Files.readString(fullPath);
        return JsonUtil.gson().fromJson(jsonContent, clazz);
    }
}

FileSaver

package util;

import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;

public class FileSaver {

    // Gson 인스턴스 (재사용을 위해 static으로 선언)
    //private static final Gson gson = new Gson();

    /**
     * 객체를 JSON 파일로 저장하는 제네릭 메서드
     * @param <T> 저장할 객체의 타입
     * @param object 저장할 객체
     * @param filePath 저장할 경로
     * @param fileName 파일명 (확장자 포함/미포함 모두 가능)
     * @throws IOException 파일 쓰기 실패 시
     */
    public static <T> void saveToJsonFile(T object, String filePath, String fileName) throws IOException {
        // 파일명에 .json 확장자가 없으면 추가
        if (!fileName.toLowerCase().endsWith(".json")) {
            fileName += ".json";
        }

        // 전체 파일 경로 생성
        Path fullPath = Paths.get(filePath, fileName);

        // 디렉토리가 존재하지 않으면 생성
        Files.createDirectories(fullPath.getParent());

        // 객체를 JSON으로 변환하여 파일에 저장
        try (FileWriter writer = new FileWriter(fullPath.toFile())) {
            JsonUtil.gson().toJson(object, writer);
        }
    }

    /**
     텍스트파일로 스트링을 저장~
     */
    public static void saveToTextFile(String filePath, String fileName, String content) throws IOException {
        // 파일명에 .txt 확장자가 없으면 추가
        if (!fileName.toLowerCase().endsWith(".txt")) {
            fileName += ".txt";
        }

        // 전체 파일 경로 생성
        Path fullPath = Paths.get(filePath, fileName);

        // 디렉토리가 존재하지 않으면 생성
        Files.createDirectories(fullPath.getParent());

        // 문자열을 파일에 저장
        try (FileWriter writer = new FileWriter(fullPath.toFile())) {
            writer.write(content);
        }
    }

    /**
     텍스트파일로 스트링을 저장~ 자바 11 이상 버전
     */
    public static void saveToTextFileForJava11Plus(String content, String filePath, String fileName) throws IOException {
        if (!fileName.toLowerCase().endsWith(".txt")) {
            fileName += ".txt";
        }

        Path fullPath = Paths.get(filePath, fileName);
        Files.createDirectories(fullPath.getParent());

        // 한 줄로 간단하게!
        Files.writeString(fullPath, content);
    }

    /**
     * 객체를 JSON 파일로 저장하는 간편 메서드 (현재 디렉토리에 저장)
     * @param <T> 저장할 객체의 타입
     * @param object 저장할 객체
     * @param fileName 파일명
     * @throws IOException 파일 쓰기 실패 시
     */
    public static <T> void saveToJsonFile(T object, String fileName) throws IOException {
        saveToJsonFile(object, ".", fileName);
    }

    /**
     * 텍스트 파일에 문자열을 추가하는 메서드
     * @param filePath 파일 경로
     * @param fileName 파일명
     * @param content 추가할 내용
     * @throws IOException 파일 쓰기 실패 시
     */
    public static void appendToTextFile(String filePath, String fileName, String content) throws IOException {
        // 파일명에 .txt 확장자가 없으면 추가
        if (!fileName.toLowerCase().endsWith(".txt")) {
            fileName += ".txt";
        }

        // 전체 파일 경로 생성
        Path fullPath = Paths.get(filePath, fileName);

        // 디렉토리가 존재하지 않으면 생성
        Files.createDirectories(fullPath.getParent());

        // 파일에 내용 추가 (try-with-resources 사용)
        try (BufferedWriter writer = new BufferedWriter(new FileWriter(fullPath.toFile(), true))) {
            writer.write(content);
        }
    }

    /**
     * Java 11+ 버전용 텍스트 파일 추가 메서드
     * @param filePath 파일 경로
     * @param fileName 파일명
     * @param content 추가할 내용
     * @throws IOException 파일 쓰기 실패 시
     */
    public static void appendToTextFileForJava11Plus(String filePath, String fileName, String content) throws IOException {
        if (!fileName.toLowerCase().endsWith(".txt")) {
            fileName += ".txt";
        }

        Path fullPath = Paths.get(filePath, fileName);
        Files.createDirectories(fullPath.getParent());

        // Files.writeString을 APPEND 모드로 사용
        Files.writeString(fullPath, content,
                StandardOpenOption.CREATE,
                StandardOpenOption.APPEND);
    }
}

FileLockUtil

package util;

import dto.HttpException;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

/**
 * 간단한 파일명 기반 락킹 유틸리티
 */
public class FileLockUtil {

    // 파일별 락 관리를 위한 맵
    private static final ConcurrentHashMap<String, Object> fileLocks = new ConcurrentHashMap<>();

    /**
     * 파일에 대한 락을 획득하고 작업을 수행
     * @param fileName 락을 걸 파일명
     * @param operation 수행할 작업
     * @param <T> 반환 타입
     * @return 작업 결과
     */
    public static <T> T withFileLock(String fileName, LockableOperation<T> operation) throws IOException, HttpException {
        Path filePath = Paths.get(fileName);
        String absolutePath = filePath.toAbsolutePath().toString();

        // 파일별 동기화 객체 생성 (없으면 새로 생성)
        Object lockObject = fileLocks.computeIfAbsent(absolutePath, k -> new Object());

        synchronized (lockObject) {
            FileChannel channel = null;
            FileLock fileLock = null;

            try {
                // 파일 채널 생성
                channel = FileChannel.open(filePath,
                        StandardOpenOption.CREATE,
                        StandardOpenOption.READ,
                        StandardOpenOption.WRITE);

                // 논블로킹 락 시도 (즉시 실패하면 409 반환)
                fileLock = channel.tryLock();
                if (fileLock == null) {
                    throw HttpException.conflict("파일이 다른 프로세스에서 사용 중입니다: " + fileName);
                }

                // 락을 획득했으므로 작업 수행
                return operation.execute(filePath);

            } catch (IOException e) {
                throw new IOException("파일 락 처리 중 오류 발생: " + e.getMessage(), e);
            } finally {
                // 리소스 정리
                closeQuietly(fileLock, "FileLock");
                closeQuietly(channel, "FileChannel");
            }
        }
    }

    /**
     * 타임아웃과 함께 락 작업 수행
     */
    public static <T> T withFileLockTimeout(String fileName, LockableOperation<T> operation,
                                            long timeout, TimeUnit unit) throws IOException, HttpException {
        long startTime = System.currentTimeMillis();
        long timeoutMillis = unit.toMillis(timeout);

        while (System.currentTimeMillis() - startTime < timeoutMillis) {
            try {
                return withFileLock(fileName, operation);
            } catch (HttpException e) {
                if (e.getStatusCode() == 409) { // Conflict - 락 획득 실패
                    // 잠시 대기 후 재시도
                    try {
                        Thread.sleep(Math.min(50, timeoutMillis / 20));
                    } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                        throw HttpException.internalServerError("작업이 중단되었습니다");
                    }
                    continue;
                }
                throw e; // 다른 에러는 바로 던지기
            }
        }

        // 타임아웃 발생
        throw HttpException.requestTimeout("파일 락 획득 시간이 초과되었습니다 (" + timeout + " " + unit.name().toLowerCase() + ")");
    }

    /**
     * 리소스를 조용히 닫기
     */
    private static void closeQuietly(AutoCloseable resource, String resourceName) {
        if (resource != null) {
            try {
                resource.close();
            } catch (Exception e) {
                System.err.println(resourceName + " 닫기 실패: " + e.getMessage());
            }
        }
    }

    /**
     * 락 하에서 실행할 작업을 정의하는 함수형 인터페이스
     */
    @FunctionalInterface
    public interface LockableOperation<T> {
        T execute(Path filePath) throws IOException, HttpException;
    }
}

HttpUtil

package util;

import dto.HttpException;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;

public class HttpUtil {

    /**
     * HttpServletRequest에서 요청 본문을 문자열로 읽어오기
     */
    public static String readRequestBody(HttpServletRequest request) throws IOException {
        StringBuilder sb = new StringBuilder();
        BufferedReader br = request.getReader();

        try {
            String line;
            while ((line = br.readLine()) != null) {
                sb.append(line);
            }
        } finally {
            try {
                br.close();
            } catch (IOException ignore) {
                // 리소스 정리 실패는 무시
            }
        }

        return sb.toString();
    }

    /**
     * HttpServletRequest에서 요청 본문을 특정 클래스 타입으로 파싱
     */
    public static <T> T readRequestBodyAsJson(HttpServletRequest request, Class<T> clazz) throws IOException {
        String body = readRequestBody(request);
        return JsonUtil.fromJson(body, clazz);
    }

    /**
     * HttpServletRequest에서 요청 본문을 특정 클래스 타입으로 파싱 (예외 던지기)
     */
    public static <T> T readRequestBodyAsJsonThrows(HttpServletRequest request, Class<T> clazz)
            throws IOException, com.google.gson.JsonSyntaxException {
        String body = readRequestBody(request);
        return JsonUtil.fromJsonThrows(body, clazz);
    }

    /**
     * JSON 응답을 HTTP로 전송
     */
    public static void sendJsonResponse(HttpServletResponse response, int statusCode, Object data)
            throws IOException {
        response.setStatus(statusCode);
        response.setContentType("application/json;charset=utf-8");

        PrintWriter writer = response.getWriter();
        try {
            writer.write(JsonUtil.toJson(data));
            writer.flush();
        } finally {
            // PrintWriter는 자동으로 닫히지만 명시적으로 닫기
            writer.close();
        }
    }

    /**
     * 문자열 응답을 HTTP로 전송
     */
    public static void sendTextResponse(HttpServletResponse response, int statusCode, String text)
            throws IOException {
        response.setStatus(statusCode);
        response.setContentType("text/plain;charset=utf-8");

        PrintWriter writer = response.getWriter();
        try {
            writer.write(text);
            writer.flush();
        } finally {
            writer.close();
        }
    }

    /**
     * HTML 응답을 HTTP로 전송
     */
    public static void sendHtmlResponse(HttpServletResponse response, int statusCode, String html)
            throws IOException {
        response.setStatus(statusCode);
        response.setContentType("text/html;charset=utf-8");

        PrintWriter writer = response.getWriter();
        try {
            writer.write(html);
            writer.flush();
        } finally {
            writer.close();
        }
    }

    /**
     * 에러 응답을 JSON 형태로 전송
     */
    public static void sendErrorResponse(HttpServletResponse response, int statusCode, String message)
            throws IOException {
        ErrorResponse error = new ErrorResponse(message, System.currentTimeMillis());
        sendJsonResponse(response, statusCode, error);
    }

    /**
     * HttpException을 HTTP 응답으로 전송
     */
    public static void sendHttpExceptionResponse(HttpServletResponse response, HttpException exception)
            throws IOException {
        sendJsonResponse(response, exception.getStatusCode(), exception.getResponseBody());
    }

    /**
     * 요청 파라미터에서 값을 가져오되, 기본값 제공
     */
    public static String getParameterWithDefault(HttpServletRequest request, String paramName, String defaultValue) {
        String value = request.getParameter(paramName);
        return (value != null && !value.trim().isEmpty()) ? value.trim() : defaultValue;
    }

    /**
     * 요청 파라미터를 정수로 변환 (기본값 제공)
     */
    public static int getIntParameter(HttpServletRequest request, String paramName, int defaultValue) {
        String value = request.getParameter(paramName);
        if (value == null || value.trim().isEmpty()) {
            return defaultValue;
        }

        try {
            return Integer.parseInt(value.trim());
        } catch (NumberFormatException e) {
            return defaultValue;
        }
    }

    /**
     * 요청 파라미터를 boolean으로 변환 (기본값 제공)
     */
    public static boolean getBooleanParameter(HttpServletRequest request, String paramName, boolean defaultValue) {
        String value = request.getParameter(paramName);
        if (value == null || value.trim().isEmpty()) {
            return defaultValue;
        }

        return "true".equalsIgnoreCase(value.trim()) || "1".equals(value.trim());
    }

    /**
     * 요청 헤더에서 값을 가져오되, 기본값 제공
     */
    public static String getHeaderWithDefault(HttpServletRequest request, String headerName, String defaultValue) {
        String value = request.getHeader(headerName);
        return (value != null && !value.trim().isEmpty()) ? value.trim() : defaultValue;
    }

    /**
     * 클라이언트 IP 주소 가져오기 (프록시 고려)
     */
    public static String getClientIpAddress(HttpServletRequest request) {
        String xForwardedFor = request.getHeader("X-Forwarded-For");
        if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
            return xForwardedFor.split(",")[0].trim();
        }

        String xRealIp = request.getHeader("X-Real-IP");
        if (xRealIp != null && !xRealIp.isEmpty()) {
            return xRealIp.trim();
        }

        return request.getRemoteAddr();
    }

    /**
     * User-Agent 헤더 가져오기
     */
    public static String getUserAgent(HttpServletRequest request) {
        return getHeaderWithDefault(request, "User-Agent", "Unknown");
    }

    // 내부 클래스 - 에러 응답용
    public static class ErrorResponse {
        public boolean success = false;
        public String error;
        public long timestamp;

        public ErrorResponse(String error, long timestamp) {
            this.error = error;
            this.timestamp = timestamp;
        }
    }
}

JsonUtil

package util;

import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;

public class JsonUtil {
    // Gson 인스턴스 (재사용을 위해 static으로 선언)
    private static final Gson gson = new Gson(); // Gson은 thread-safe해서 멀티스레딩 환경에서도 문제없이 사용

    /**
     * Gson 인스턴스 반환
     */
    public static Gson gson() {
        return gson;
    }

    /**
     * 객체를 JSON 문자열로 변환
     */
    public static String toJson(Object obj) {
        if (obj == null) {
            return "null";
        }
        return gson.toJson(obj);
    }

    /**
     * JSON 문자열을 특정 클래스 타입으로 변환
     */
    public static <T> T fromJson(String json, Class<T> clazz) {
        if (json == null || json.trim().isEmpty()) {
            return null;
        }

        try {
            return gson.fromJson(json, clazz);
        } catch (JsonSyntaxException e) {
            System.err.println("JSON 파싱 실패: " + e.getMessage());
            return null;
        }
    }

    /**
     * JSON 문자열을 특정 클래스 타입으로 변환 (예외 던지기)
     */
    public static <T> T fromJsonThrows(String json, Class<T> clazz) throws JsonSyntaxException {
        if (json == null || json.trim().isEmpty()) {
            throw new JsonSyntaxException("JSON 문자열이 비어있습니다");
        }

        return gson.fromJson(json, clazz);
    }

    /**
     * 객체를 예쁘게 포맷된 JSON 문자열로 변환 (디버깅용)
     */
    public static String toPrettyJson(Object obj) {
        if (obj == null) {
            return "null";
        }
        return gson.newBuilder().setPrettyPrinting().create().toJson(obj);
    }

    /**
     * JSON 문자열이 유효한지 검증
     */
    public static boolean isValidJson(String json) {
        if (json == null || json.trim().isEmpty()) {
            return false;
        }

        try {
            gson.fromJson(json, Object.class);
            return true;
        } catch (JsonSyntaxException e) {
            return false;
        }
    }

    /**
     * JSON 문자열을 Object로 파싱 (타입 모를 때 사용)
     */
    public static Object parseJson(String json) {
        if (json == null || json.trim().isEmpty()) {
            return null;
        }

        try {
            return gson.fromJson(json, Object.class);
        } catch (JsonSyntaxException e) {
            System.err.println("JSON 파싱 실패: " + e.getMessage());
            return null;
        }
    }

    /**
     * JSON 문자열에서 특정 필드 값을 추출 (간단한 파싱)
     */
    public static String extractField(String json, String fieldName) {
        if (json == null || json.trim().isEmpty() || fieldName == null) {
            return null;
        }

        // 간단한 필드 추출: "fieldName":"value" 패턴
        String pattern = "\\"" + fieldName + "\\"\\\\s*:\\\\s*\\"([^\\"]+)\\"";
        java.util.regex.Pattern regex = java.util.regex.Pattern.compile(pattern);
        java.util.regex.Matcher matcher = regex.matcher(json);

        if (matcher.find()) {
            return matcher.group(1);
        }

        return null;
    }
}

ThreadUtil

package util;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.function.Supplier;

public class ThreadUtil {
    private static final ExecutorService EXECUTOR_SERVICE =
            Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

    //읽기 접근만 허용하면 더 안전할듯함...
    public static ExecutorService getExecutorService(){
        return EXECUTOR_SERVICE;
    }

    public static <T> Future<T> submit(Callable<T> task) {
        return EXECUTOR_SERVICE.submit(task);
    }

    public static Future<?> submit(Runnable task) {
        return EXECUTOR_SERVICE.submit(task);
    }

    /**
     * 비동기 작업을 CompletableFuture로 실행
     */
    public static <T> CompletableFuture<T> supplyAsync(Supplier<T> supplier) {
        return CompletableFuture.supplyAsync(supplier, EXECUTOR_SERVICE);
    }

    /**
     * 반환값이 없는 비동기 작업 실행
     */
    public static CompletableFuture<Void> runAsync(Runnable runnable) {
        return CompletableFuture.runAsync(runnable, EXECUTOR_SERVICE);
    }

    /**
     * 여러 CompletableFuture를 모두 완료될 때까지 대기하고 결과 수집
     */
    public static <T> List<T> awaitAllAndCollect(List<CompletableFuture<T>> futures) {
        // 한 번에 모든 결과를 기다리고 수집
        return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
                .thenApply(ignored -> {
                    List<T> results = new ArrayList<>(futures.size());
                    for (CompletableFuture<T> future : futures) {
                        results.add(future.getNow(null)); // 이미 완료되었으므로 즉시 반환
                    }
                    return results;
                }).join();
    }

    /**
     * 여러 CompletableFuture를 모두 완료될 때까지 대기만 (결과 수집 안함)
     */
    public static void awaitAll(CompletableFuture<?>... futures) {
        CompletableFuture.allOf(futures).join();
    }

    /**
     * 여러 CompletableFuture 중 하나라도 완료되면 반환
     */
    public static <T> T awaitAny(CompletableFuture<T>... futures) {
        return CompletableFuture.anyOf(futures).thenApply(result -> (T) result).join();
    }

    // 기존 shutdown 관련 코드
    public static void shutdown() {
        EXECUTOR_SERVICE.shutdown();
        try {
            if (!EXECUTOR_SERVICE.awaitTermination(10, TimeUnit.SECONDS)) {
                EXECUTOR_SERVICE.shutdownNow();
            }
        } catch (InterruptedException e) {
            EXECUTOR_SERVICE.shutdownNow();
        }
    }

    static {
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            if (!EXECUTOR_SERVICE.isShutdown()) {
                shutdown();
            }
        }));
    }

}

CategoryService

package service;

import dto.HttpException;
import util.FileLockUtil;
import util.ThreadUtil;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import static util.FileLoader.readFileLines;

public class CategoryService {

    public Map<String, String> categoryMap = new ConcurrentHashMap<>();
    public Map<String, String> riskMap = new ConcurrentHashMap<>();

    public CategoryService(String filePath) {
        loadCategoriesAsync(filePath).join(); // 동기적으로 초기화 완료 대기
    }

    public CategoryService(String filePath, String filePath2) {
        // 두 파일을 병렬로 로드
        CompletableFuture<Void> categoryFuture = loadCategoriesAsync(filePath);
        CompletableFuture<Void> riskFuture = loadRisksAsync(filePath2);

        // 모든 로딩 작업 완료 대기
        ThreadUtil.awaitAll(categoryFuture, riskFuture);
    }

    /**
     * 비동기로 위험 키워드 로드
     */
    private CompletableFuture<Void> loadRisksAsync(String filePath) {
        return ThreadUtil.supplyAsync(() -> readFileLines(filePath))
                .thenAccept(list -> {
                    for (String s : list) {
                        riskMap.put(s, "");
                    }
                });
    }

    /**
     * 비동기로 카테고리 매핑 로드
     */
    private CompletableFuture<Void> loadCategoriesAsync(String filePath) {
        return ThreadUtil.supplyAsync(() -> readFileLines(filePath))
                .thenAccept(list -> {
                    for (String s : list) {
                        String[] split = s.split("#");
                        if (split.length >= 2) {
                            categoryMap.put(split[0], split[1]);
                        }
                    }
                });
    }

    /**
     * 비동기로 카테고리 저장 (기본 결과 파일)
     */
    public CompletableFuture<Void> saveCategoryAsync(String input) {
        return ThreadUtil.supplyAsync(() -> categorize(input))
                .thenCompose(categorizedResult ->
                        ThreadUtil.supplyAsync(() -> {
                            try {
                                return FileLockUtil.withFileLock("RESULTS.TXT", (filePath) -> {
                                    String content = categorizedResult + "\\n";
                                    if (Files.exists(filePath)) {
                                        Files.write(filePath, content.getBytes(),
                                                java.nio.file.StandardOpenOption.APPEND);
                                    } else {
                                        Files.write(filePath, content.getBytes(),
                                                java.nio.file.StandardOpenOption.CREATE);
                                    }
                                    return null;
                                });
                            } catch (IOException | HttpException e) {
                                throw new RuntimeException(e);
                            }
                        })
                );
    }

    /**
     * 동기 버전 (기존 호환성 유지)
     */
    public void saveCategory(String input) throws IOException {
        try {
            saveCategoryAsync(input).join();
        } catch (RuntimeException e) {
            if (e.getCause() instanceof IOException) {
                throw (IOException) e.getCause();
            }
            throw e;
        }
    }

    /**
     * 비동기로 필터링된 카테고리 저장
     */
    public CompletableFuture<Void> saveFilteredCategoryAsync(String input) {
        return ThreadUtil.supplyAsync(() -> categorize(input))
                .thenCompose(categorizedResult ->
                        ThreadUtil.supplyAsync(() -> {
                            try {
                                return FileLockUtil.withFileLock("FILTERED_RESULTS.TXT", (filePath) -> {
                                    String content = categorizedResult + "\\n";
                                    if (Files.exists(filePath)) {
                                        Files.write(filePath, content.getBytes(),
                                                java.nio.file.StandardOpenOption.APPEND);
                                    } else {
                                        Files.write(filePath, content.getBytes(),
                                                java.nio.file.StandardOpenOption.CREATE);
                                    }
                                    return null;
                                });
                            } catch (IOException | HttpException e) {
                                throw new RuntimeException(e);
                            }
                        })
                );
    }

    /**
     * 동기 버전 (기존 호환성 유지)
     */
    public void saveFilteredCategory(String input) throws IOException {
        try {
            saveFilteredCategoryAsync(input).join();
        } catch (RuntimeException e) {
            if (e.getCause() instanceof IOException) {
                throw (IOException) e.getCause();
            }
            throw e;
        }
    }

    /**
     * 세션별 파일에 거래 데이터를 비동기로 저장 (파일명 기반 락킹)
     */
    public CompletableFuture<Void> saveFilteredCategoryAsync(String sessionId, String transaction) {
        return ThreadUtil.supplyAsync(() -> categorize(transaction))
                .thenCompose(processedTransaction ->
                        ThreadUtil.supplyAsync(() -> {
                            try {
                                String sessionFileName = "SESSION_" + sessionId + ".txt";
                                return FileLockUtil.withFileLock(sessionFileName, (filePath) -> {
                                    String content = processedTransaction.trim() + "\\n";

                                    if (Files.exists(filePath)) {
                                        Files.write(filePath, content.getBytes(),
                                                java.nio.file.StandardOpenOption.APPEND);
                                    } else {
                                        Files.write(filePath, content.getBytes(),
                                                java.nio.file.StandardOpenOption.CREATE);
                                    }
                                    return null;
                                });
                            } catch (IOException | HttpException e) {
                                throw new RuntimeException(e);
                            }
                        })
                );
    }

    /**
     * 세션별 파일에 거래 데이터를 저장 (동기 버전, 기존 호환성 유지)
     */
    public void saveFilteredCategory(String sessionId, String transaction) throws IOException, HttpException {
        try {
            saveFilteredCategoryAsync(sessionId, transaction).join();
        } catch (RuntimeException e) {
            if (e.getCause() instanceof IOException) {
                throw (IOException) e.getCause();
            }
            if (e.getCause() instanceof HttpException) {
                throw (HttpException) e.getCause();
            }
            throw e;
        }
    }

    /**
     * 세션 파일에서 모든 거래 데이터를 비동기로 읽어오기
     */
    public CompletableFuture<List<String>> readSessionTransactionsAsync(String sessionId) {
        return ThreadUtil.supplyAsync(() -> {
            try {
                String sessionFileName = "SESSION_" + sessionId + ".txt";

                return FileLockUtil.withFileLockTimeout(sessionFileName, (filePath) -> {
                    if (!Files.exists(filePath)) {
                        throw HttpException.notFound("세션 " + sessionId + "의 데이터가 없습니다");
                    }

                    List<String> lines = Files.readAllLines(filePath);
                    lines.removeIf(line -> line.trim().isEmpty());

                    return lines;
                }, 1, TimeUnit.SECONDS);
            } catch (IOException | HttpException e) {
                throw new RuntimeException(e);
            }
        });
    }

    /**
     * 세션 파일에서 모든 거래 데이터를 읽어오기 (동기 버전, 기존 호환성 유지)
     */
    public List<String> readSessionTransactions(String sessionId) throws IOException, HttpException {
        try {
            return readSessionTransactionsAsync(sessionId).join();
        } catch (RuntimeException e) {
            if (e.getCause() instanceof IOException) {
                throw (IOException) e.getCause();
            }
            if (e.getCause() instanceof HttpException) {
                throw (HttpException) e.getCause();
            }
            throw e;
        }
    }

    /**
     * 병렬 처리를 통한 향상된 categorize 메서드
     */
    public String categorize(String input) {
        String[] arr = input.split(" ");

        // 작은 입력에 대해서는 기존 방식 사용
        if (arr.length < 10) {
            return categorizeSync(arr);
        }

        // 큰 입력에 대해서는 병렬 처리
        return categorizeFE(arr);
    }

    /**
     * 동기 방식의 categorize (기존 로직)
     */
    private String categorizeSync(String[] arr) {
        PriorityQueue<String> pq = new PriorityQueue<>(String::compareTo);

        for (String word : arr) {
            if (riskMap.containsKey(word)) {
                continue; // 고위험 패턴 제거
            }
            String category = categoryMap.getOrDefault(word, "");
            if (!category.isEmpty()) {
                pq.add(category);
            }
        }

        StringBuilder sb = new StringBuilder();
        while (!pq.isEmpty()) {
            String category = pq.poll();
            sb.append(category).append(' ');
        }
        return sb.toString().trim();
    }

    /**
     * 병렬 처리 방식의 categorize
     */
    private String categorizeFE(String[] arr) {
        // 배열을 청크로 나누어 병렬 처리
        int chunkSize = Math.max(1, arr.length / ThreadUtil.getExecutorService().getClass().getDeclaredMethods().length);
        List<CompletableFuture<List<String>>> futures = new java.util.ArrayList<>();

        for (int i = 0; i < arr.length; i += chunkSize) {
            final int start = i;
            final int end = Math.min(i + chunkSize, arr.length);

            CompletableFuture<List<String>> future = ThreadUtil.supplyAsync(() -> {
                List<String> results = new java.util.ArrayList<>();
                for (int j = start; j < end; j++) {
                    String word = arr[j];
                    if (!riskMap.containsKey(word)) {
                        String category = categoryMap.getOrDefault(word, "");
                        if (!category.isEmpty()) {
                            results.add(category);
                        }
                    }
                }
                return results;
            });

            futures.add(future);
        }

        // 모든 결과를 수집하고 정렬
        List<List<String>> allResults = ThreadUtil.awaitAllAndCollect(futures);

        return allResults.stream()
                .flatMap(List::stream)
                .sorted()
                .collect(Collectors.joining(" "));
    }

    /**
     * 여러 입력을 배치로 병렬 처리
     */
    public CompletableFuture<List<String>> categorizeBatchAsync(List<String> inputs) {
        List<CompletableFuture<String>> futures = inputs.stream()
                .map(input -> ThreadUtil.supplyAsync(() -> categorize(input)))
                .collect(Collectors.toList());

        return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
                .thenApply(ignored -> ThreadUtil.awaitAllAndCollect(futures));
    }

    /**
     * 동기 배치 처리 (기존 호환성)
     */
    public List<String> categorizeBatch(List<String> inputs) {
        return categorizeBatchAsync(inputs).join();
    }
}

RiskAnalysisService

package service;

import dto.Dtos;
import dto.HttpException;
import util.FileLockUtil;
import util.JsonUtil;
import util.ThreadUtil;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

public class RiskAnalysisService {

    private final HttpClient httpClient;
    private final Map<String, String> riskCodeToLevelMap;
    private final String serviceUrl;

    // 설정 파일 캐시 (파일 변경 감지를 위한 타임스탬프 포함)
    private static final Map<String, CachedConfig> configCache = new ConcurrentHashMap<>();
    private static final long CONFIG_CACHE_TTL = 30000; // 30초 캐시

    private static class CachedConfig {
        final Dtos.ServicesConfig config;
        final long timestamp;

        CachedConfig(Dtos.ServicesConfig config, long timestamp) {
            this.config = config;
            this.timestamp = timestamp;
        }

        boolean isExpired() {
            return System.currentTimeMillis() - timestamp > CONFIG_CACHE_TTL;
        }
    }

    public RiskAnalysisService() throws IOException, HttpException {
        this.httpClient = HttpClient.newBuilder()
                .connectTimeout(Duration.ofMillis(500))
                .build();

        // 설정을 비동기로 로드
        CompletableFuture<Dtos.ServicesConfig> configFuture = loadServiceConfigAsync();

        this.riskCodeToLevelMap = new HashMap<>();

        // 설정 로드 완료 대기 및 초기화
        try {
            Dtos.ServicesConfig config = configFuture.get(2, TimeUnit.SECONDS);
            initializeFromConfig(config);
            this.serviceUrl = config.services[0].url;
        } catch (Exception e) {
            if (e.getCause() instanceof IOException) {
                throw (IOException) e.getCause();
            }
            if (e.getCause() instanceof HttpException) {
                throw (HttpException) e.getCause();
            }
            throw new IOException("서비스 초기화 실패", e);
        }
    }

    /**
     * 비동기로 서비스 설정 로드 (파일 락킹 및 캐싱 적용)
     */
    private CompletableFuture<Dtos.ServicesConfig> loadServiceConfigAsync() {
        return ThreadUtil.supplyAsync(() -> {
            try {
                String configFileName = "SERVICES.JSON";

                // 캐시된 설정 확인
                CachedConfig cached = configCache.get(configFileName);
                if (cached != null && !cached.isExpired()) {
                    return cached.config;
                }

                // 파일 락을 사용하여 안전하게 설정 로드
                return FileLockUtil.withFileLockTimeout(configFileName, (filePath) -> {
                    if (!Files.exists(filePath)) {
                        throw HttpException.notFound("SERVICES.JSON 파일을 찾을 수 없습니다");
                    }

                    String configJson = Files.readString(filePath);
                    Dtos.ServicesConfig config = JsonUtil.fromJson(configJson, Dtos.ServicesConfig.class);

                    if (config.services == null || config.services.length == 0) {
                        throw HttpException.internalServerError("위험도 분석 서비스 설정을 찾을 수 없습니다");
                    }

                    // 캐시에 저장
                    configCache.put(configFileName, new CachedConfig(config, System.currentTimeMillis()));

                    return config;
                }, 1, TimeUnit.SECONDS);

            } catch (IOException | HttpException e) {
                throw new RuntimeException(e);
            }
        });
    }

    /**
     * 설정으로부터 위험도 매핑 초기화
     */
    private void initializeFromConfig(Dtos.ServicesConfig config) {
        // 첫 번째 서비스 사용
        Dtos.Service service = config.services[0];

        // 위험도 코드 매핑 설정
        if (service.riskMappings != null) {
            for (Dtos.RiskMapping mapping : service.riskMappings) {
                riskCodeToLevelMap.put(mapping.code, mapping.level);
            }
        }
    }

    /**
     * 설정 재로드 (비동기)
     */
    public CompletableFuture<Void> reloadConfigAsync() {
        return ThreadUtil.runAsync(() -> {
            // 캐시 무효화
            configCache.clear();

            try {
                Dtos.ServicesConfig config = loadServiceConfigAsync().join();
                riskCodeToLevelMap.clear();
                initializeFromConfig(config);
            } catch (Exception e) {
                throw new RuntimeException("설정 재로드 실패", e);
            }
        });
    }

    /**
     * 여러 거래를 병렬로 분석 (ThreadUtil 활용)
     */
    public CompletableFuture<List<String>> analyzeTransactionsParallelAsync(List<String> transactions) {
        long startTime = System.currentTimeMillis();

        return ThreadUtil.supplyAsync(() -> {
            // 각 거래에 대해 비동기 분석 요청 생성
            List<CompletableFuture<String>> futures = transactions.stream()
                    .map(this::analyzeTransactionAsync)
                    .collect(Collectors.toList());

            try {
                // ThreadUtil의 awaitAllAndCollect 사용 (800ms 타임아웃은 각 작업에서 처리)
                List<String> results = ThreadUtil.awaitAllAndCollect(futures);

                long elapsed = System.currentTimeMillis() - startTime;
                if (elapsed > 1000) {
                    throw HttpException.requestTimeout("총 처리 시간이 1초를 초과했습니다");
                }

                return results;

            } catch (Exception e) {
                if (e instanceof HttpException) {
                    throw new RuntimeException(e);
                }
                throw new RuntimeException(HttpException.internalServerError("위험도 분석 처리 중 오류 발생: " + e.getMessage()));
            }
        });
    }

    /**
     * 동기 버전 (기존 호환성 유지)
     */
    public List<String> analyzeTransactionsParallel(List<String> transactions) throws IOException, HttpException {
        try {
            return analyzeTransactionsParallelAsync(transactions).join();
        } catch (RuntimeException e) {
            if (e.getCause() instanceof IOException) {
                throw (IOException) e.getCause();
            }
            if (e.getCause() instanceof HttpException) {
                throw (HttpException) e.getCause();
            }
            throw new IOException("위험도 분석 실패", e);
        }
    }

    /**
     * 단일 거래 비동기 분석
     */
    private CompletableFuture<String> analyzeTransactionAsync(String transaction) {
        return ThreadUtil.supplyAsync(() -> {
                    try {
                        return analyzeTransaction(transaction);
                    } catch (IOException | HttpException e) {
                        throw new RuntimeException(e);
                    }
                }).orTimeout(500, TimeUnit.MILLISECONDS) // 개별 요청 타임아웃
                .exceptionally(throwable -> {
                    if (throwable instanceof java.util.concurrent.TimeoutException) {
                        throw new RuntimeException(HttpException.requestTimeout("개별 거래 분석 시간 초과"));
                    }
                    throw new RuntimeException(throwable);
                });
    }

    /**
     * 단일 거래 분석 (동기 버전)
     */
    private String analyzeTransaction(String transaction) throws IOException, HttpException {
        // 외부 서비스 요청 생성
        Dtos.RiskAnalysisRequest request = new Dtos.RiskAnalysisRequest();
        request.query = transaction.trim();

        String requestJson = JsonUtil.toJson(request);

        HttpRequest httpRequest = HttpRequest.newBuilder()
                .uri(URI.create(serviceUrl))
                .header("Content-Type", "application/json")
                .timeout(Duration.ofMillis(500))
                .POST(HttpRequest.BodyPublishers.ofString(requestJson))
                .build();

        try {
            HttpResponse<String> response = httpClient.send(httpRequest,
                    HttpResponse.BodyHandlers.ofString());

            if (response.statusCode() != 200) {
                throw HttpException.internalServerError(
                        "외부 서비스 오류: " + response.statusCode());
            }

            // 응답 파싱
            Dtos.RiskAnalysisResponse analysisResponse = JsonUtil.fromJson(
                    response.body(), Dtos.RiskAnalysisResponse.class);

            // 위험도 코드를 위험도 레벨로 변환
            String riskLevel = riskCodeToLevelMap.getOrDefault(
                    analysisResponse.riskCode, "UNKNOWN");

            return riskLevel;

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw HttpException.internalServerError("외부 서비스 호출이 중단되었습니다");
        } catch (IOException e) {
            throw new IOException("외부 서비스 통신 오류: " + e.getMessage(), e);
        }
    }

    /**
     * 배치 크기를 조정하여 처리하는 고급 병렬 분석
     */
    public CompletableFuture<List<String>> analyzeTransactionsBatchAsync(List<String> transactions, int batchSize) {
        if (transactions.isEmpty()) {
            return CompletableFuture.completedFuture(List.of());
        }

        return ThreadUtil.supplyAsync(() -> {
            // 배치로 나누어 처리
            List<CompletableFuture<List<String>>> batchFutures = new java.util.ArrayList<>();

            for (int i = 0; i < transactions.size(); i += batchSize) {
                int end = Math.min(i + batchSize, transactions.size());
                List<String> batch = transactions.subList(i, end);

                CompletableFuture<List<String>> batchFuture = analyzeTransactionsParallelAsync(batch);
                batchFutures.add(batchFuture);
            }

            // 모든 배치 결과 수집
            List<List<String>> batchResults = ThreadUtil.awaitAllAndCollect(batchFutures);

            return batchResults.stream()
                    .flatMap(List::stream)
                    .collect(Collectors.toList());
        });
    }

    /**
     * 서비스 상태 확인 (비동기)
     */
    public CompletableFuture<Boolean> checkServiceHealthAsync() {
        return ThreadUtil.supplyAsync(() -> {
            try {
                HttpRequest request = HttpRequest.newBuilder()
                        .uri(URI.create(serviceUrl + "/health"))
                        .timeout(Duration.ofSeconds(1))
                        .GET()
                        .build();

                HttpResponse<String> response = httpClient.send(request,
                        HttpResponse.BodyHandlers.ofString());

                return response.statusCode() == 200;
            } catch (Exception e) {
                return false;
            }
        });
    }

    /**
     * 설정 파일 감시 및 자동 재로드 시작
     */
    public CompletableFuture<Void> startConfigWatcherAsync() {
        return ThreadUtil.runAsync(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    Thread.sleep(CONFIG_CACHE_TTL);

                    // 캐시된 설정이 만료되었는지 확인
                    CachedConfig cached = configCache.get("SERVICES.JSON");
                    if (cached != null && cached.isExpired()) {
                        reloadConfigAsync().join();
                        System.out.println("서비스 설정이 자동으로 재로드되었습니다.");
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                } catch (Exception e) {
                    System.err.println("설정 감시 중 오류 발생: " + e.getMessage());
                }
            }
        });
    }

    /**
     * 리소스 정리
     */
    public void shutdown() {
        configCache.clear();
        // HttpClient는 자동으로 정리됨
    }
}

 

FILE LOCK UTIL 완전 사용 가이드

FileLockUtil

package tutorial;

import dto.HttpException;
import util.FileLockUtil;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.ArrayList;

/**
 * FileLockUtil 완전 사용 가이드 - 초심자용
 * 파일 동시 접근 문제를 해결하는 방법들을 예제로 설명
 */
public class FileLockUtilTutorial {

    public static void main(String[] args) {
        System.out.println("=== FileLockUtil 사용법 강의 시작 ===\\n");
        
        // 1. 기본 파일 락킹 - 안전한 파일 쓰기
        lesson1_BasicFileLock();
        
        // 2. 타임아웃과 함께 사용하기
        lesson2_FileLockWithTimeout();
        
        // 3. 파일 읽기와 쓰기를 안전하게
        lesson3_SafeReadAndWrite();
        
        // 4. 동시 접근 문제 시나리오와 해결
        lesson4_ConcurrentAccessProblem();
        
        // 5. 실제 서비스에서 사용하는 패턴들
        lesson5_RealWorldExamples();
        
        System.out.println("\\n=== FileLockUtil 강의 완료 ===");
    }

    /**
     * 레슨 1: 기본 파일 락킹 - 안전한 파일 쓰기
     * withFileLock()의 기본 사용법
     */
    private static void lesson1_BasicFileLock() {
        System.out.println("--- 레슨 1: 기본 파일 락킹 ---");
        
        String fileName = "user_data.txt";
        
        try {
            // withFileLock(): 파일에 락을 걸고 안전하게 작업 수행
            String result = FileLockUtil.withFileLock(fileName, (filePath) -> {
                // 이 블록 안에서는 다른 프로세스가 같은 파일에 접근할 수 없음
                System.out.println("  [안전영역] 파일 락 획득 성공: " + fileName);
                
                try {
                    // 파일에 데이터 추가
                    String userData = "사용자ID: user123, 접속시간: " + System.currentTimeMillis() + "\\n";
                    
                    if (Files.exists(filePath)) {
                        // 파일이 있으면 내용 추가
                        Files.write(filePath, userData.getBytes(), StandardOpenOption.APPEND);
                        System.out.println("  [안전영역] 기존 파일에 데이터 추가");
                    } else {
                        // 파일이 없으면 새로 생성
                        Files.write(filePath, userData.getBytes(), StandardOpenOption.CREATE);
                        System.out.println("  [안전영역] 새 파일 생성 및 데이터 저장");
                    }
                    
                    return "데이터 저장 완료";
                    
                } catch (IOException e) {
                    throw new RuntimeException("파일 쓰기 실패", e);
                }
            });
            
            System.out.println("결과: " + result);
            System.out.println("파일 락 자동 해제됨\\n");
            
        } catch (IOException | HttpException e) {
            System.err.println("파일 작업 실패: " + e.getMessage());
        }
    }

    /**
     * 레슨 2: 타임아웃과 함께 사용하기
     * withFileLockTimeout()으로 무한 대기 방지
     */
    private static void lesson2_FileLockWithTimeout() {
        System.out.println("--- 레슨 2: 타임아웃과 함께 파일 락킹 ---");
        
        String fileName = "config.json";
        
        try {
            // withFileLockTimeout(): 지정된 시간 내에 락 획득 실패하면 에러 발생
            String config = FileLockUtil.withFileLockTimeout(fileName, (filePath) -> {
                System.out.println("  [타임아웃 락] 파일 락 획득: " + fileName);
                
                try {
                    if (!Files.exists(filePath)) {
                        // 기본 설정 파일 생성
                        String defaultConfig = "{\\n  \\"server_port\\": 8080,\\n  \\"debug_mode\\": false\\n}";
                        Files.write(filePath, defaultConfig.getBytes(), StandardOpenOption.CREATE);
                        System.out.println("  [타임아웃 락] 기본 설정 파일 생성");
                        return defaultConfig;
                    } else {
                        // 기존 설정 파일 읽기
                        String existingConfig = Files.readString(filePath);
                        System.out.println("  [타임아웃 락] 기존 설정 파일 읽기 완료");
                        return existingConfig;
                    }
                    
                } catch (IOException e) {
                    throw new RuntimeException("설정 파일 처리 실패", e);
                }
                
            }, 2, TimeUnit.SECONDS); // 2초 타임아웃
            
            System.out.println("설정 내용:\\n" + config);
            System.out.println();
            
        } catch (IOException | HttpException e) {
            if (e instanceof HttpException && ((HttpException) e).getStatusCode() == 408) {
                System.err.println("타임아웃 발생: 2초 내에 파일 락을 획득하지 못했습니다");
            } else {
                System.err.println("설정 파일 처리 실패: " + e.getMessage());
            }
        }
    }

    /**
     * 레슨 3: 파일 읽기와 쓰기를 안전하게
     * 읽기-수정-쓰기 패턴의 안전한 구현
     */
    private static void lesson3_SafeReadAndWrite() {
        System.out.println("--- 레슨 3: 안전한 읽기-수정-쓰기 ---");
        
        String counterFileName = "visit_counter.txt";
        
        try {
            // 방문자 수 증가 작업 (읽기 -> 증가 -> 쓰기)
            Integer newCount = FileLockUtil.withFileLock(counterFileName, (filePath) -> {
                System.out.println("  [카운터 락] 방문자 카운터 파일 락 획득");
                
                try {
                    int currentCount = 0;
                    
                    // 1단계: 현재 카운트 읽기
                    if (Files.exists(filePath)) {
                        String countStr = Files.readString(filePath).trim();
                        currentCount = countStr.isEmpty() ? 0 : Integer.parseInt(countStr);
                        System.out.println("  [카운터 락] 현재 방문자 수: " + currentCount);
                    } else {
                        System.out.println("  [카운터 락] 카운터 파일 없음, 새로 생성");
                    }
                    
                    // 2단계: 카운트 증가
                    int newCount = currentCount + 1;
                    
                    // 3단계: 새 카운트 저장
                    Files.write(filePath, String.valueOf(newCount).getBytes(), 
                               StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
                    
                    System.out.println("  [카운터 락] 새 방문자 수: " + newCount + " (저장 완료)");
                    return newCount;
                    
                } catch (IOException e) {
                    throw new RuntimeException("카운터 업데이트 실패", e);
                } catch (NumberFormatException e) {
                    throw new RuntimeException("카운터 파일 형식 오류", e);
                }
            });
            
            System.out.println("방문자 카운터 업데이트 완료: " + newCount + "번째 방문자\\n");
            
        } catch (IOException | HttpException e) {
            System.err.println("방문자 카운터 업데이트 실패: " + e.getMessage());
        }
    }

    /**
     * 레슨 4: 동시 접근 문제 시나리오와 해결
     * 여러 스레드가 동시에 같은 파일에 접근할 때의 문제와 해결책
     */
    private static void lesson4_ConcurrentAccessProblem() {
        System.out.println("--- 레슨 4: 동시 접근 문제 해결 ---");
        
        String logFileName = "application.log";
        
        // 시나리오: 여러 스레드가 동시에 로그 파일에 쓰기 시도
        System.out.println("5개 스레드가 동시에 로그 파일에 쓰기 시도...");
        
        ExecutorService executor = Executors.newFixedThreadPool(5);
        List<CompletableFuture<String>> tasks = new ArrayList<>();
        
        // 5개 스레드가 각각 로그를 남기려고 시도
        for (int i = 1; i <= 5; i++) {
            final int threadNum = i;
            
            CompletableFuture<String> task = CompletableFuture.supplyAsync(() -> {
                try {
                    // FileLockUtil을 사용해서 안전하게 로그 작성
                    return FileLockUtil.withFileLockTimeout(logFileName, (filePath) -> {
                        String threadName = "Thread-" + threadNum;
                        System.out.println("  [" + threadName + "] 로그 파일 락 획득");
                        
                        try {
                            // 현재 시간과 스레드 정보로 로그 메시지 생성
                            String logMessage = String.format("[%d] %s: 작업 수행 중...\\n", 
                                                             System.currentTimeMillis(), threadName);
                            
                            // 파일에 로그 추가 (기존 내용 유지)
                            if (Files.exists(filePath)) {
                                Files.write(filePath, logMessage.getBytes(), StandardOpenOption.APPEND);
                            } else {
                                Files.write(filePath, logMessage.getBytes(), StandardOpenOption.CREATE);
                            }
                            
                            System.out.println("  [" + threadName + "] 로그 작성 완료");
                            return threadName + " 성공";
                            
                        } catch (IOException e) {
                            throw new RuntimeException("로그 작성 실패", e);
                        }
                        
                    }, 1, TimeUnit.SECONDS); // 1초 타임아웃
                    
                } catch (IOException | HttpException e) {
                    return "Thread-" + threadNum + " 실패: " + e.getMessage();
                }
            }, executor);
            
            tasks.add(task);
        }
        
        // 모든 스레드 작업 완료 대기
        List<String> results = new ArrayList<>();
        for (CompletableFuture<String> task : tasks) {
            results.add(task.join());
        }
        
        System.out.println("동시 접근 테스트 결과:");
        results.forEach(result -> System.out.println("  " + result));
        
        executor.shutdown();
        System.out.println();
    }

    /**
     * 레슨 5: 실제 서비스에서 사용하는 패턴들
     */
    private static void lesson5_RealWorldExamples() {
        System.out.println("--- 레슨 5: 실제 서비스 사용 패턴 ---");
        
        // 패턴 1: 세션 데이터 안전 저장
        example1_SessionDataStorage();
        
        // 패턴 2: 설정 파일 안전 업데이트
        example2_ConfigFileUpdate();
        
        // 패턴 3: 임시 파일을 이용한 원자적 업데이트
        example3_AtomicFileUpdate();
        
        // 패턴 4: 여러 파일을 순서대로 안전하게 업데이트
        example4_MultipleFilesUpdate();
    }

    /**
     * 실제 예제 1: 세션 데이터 안전 저장
     * 웹 서비스에서 사용자 세션 데이터를 파일에 저장할 때
     */
    private static void example1_SessionDataStorage() {
        System.out.println("  [실제 예제 1] 세션 데이터 저장");
        
        String sessionId = "SESS_12345";
        String sessionFileName = "session_" + sessionId + ".txt";
        
        try {
            // 세션에 새로운 거래 데이터 추가
            String transactionData = "CARD_PAYMENT 50000 CAFE 2024-12-01";
            
            String result = FileLockUtil.withFileLock(sessionFileName, (filePath) -> {
                System.out.println("    [세션] 세션 파일 락 획득: " + sessionFileName);
                
                try {
                    // 기존 세션 데이터 읽기
                    List<String> existingData = new ArrayList<>();
                    if (Files.exists(filePath)) {
                        existingData = Files.readAllLines(filePath);
                        System.out.println("    [세션] 기존 데이터 " + existingData.size() + "건 로드");
                    }
                    
                    // 새 거래 데이터 추가
                    existingData.add(transactionData);
                    
                    // 전체 데이터 다시 저장
                    Files.write(filePath, existingData, 
                               StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
                    
                    System.out.println("    [세션] 새 거래 데이터 추가됨 (총 " + existingData.size() + "건)");
                    return "세션 업데이트 완료";
                    
                } catch (IOException e) {
                    throw new RuntimeException("세션 파일 처리 실패", e);
                }
            });
            
            System.out.println("    결과: " + result + "\\n");
            
        } catch (IOException | HttpException e) {
            System.err.println("    세션 데이터 저장 실패: " + e.getMessage());
        }
    }

    /**
     * 실제 예제 2: 설정 파일 안전 업데이트
     * 서버 운영 중에 설정을 동적으로 변경할 때
     */
    private static void example2_ConfigFileUpdate() {
        System.out.println("  [실제 예제 2] 설정 파일 업데이트");
        
        String configFileName = "server_config.properties";
        
        try {
            // 서버 포트 설정을 8080에서 8090으로 변경
            String result = FileLockUtil.withFileLockTimeout(configFileName, (filePath) -> {
                System.out.println("    [설정] 설정 파일 락 획득");
                
                try {
                    List<String> configLines = new ArrayList<>();
                    
                    if (Files.exists(filePath)) {
                        configLines = Files.readAllLines(filePath);
                        System.out.println("    [설정] 기존 설정 " + configLines.size() + "줄 로드");
                    } else {
                        System.out.println("    [설정] 새 설정 파일 생성");
                    }
                    
                    // 포트 설정 찾아서 업데이트
                    boolean portUpdated = false;
                    for (int i = 0; i < configLines.size(); i++) {
                        if (configLines.get(i).startsWith("server.port=")) {
                            configLines.set(i, "server.port=8090");
                            portUpdated = true;
                            System.out.println("    [설정] 포트 설정 업데이트: 8080 -> 8090");
                            break;
                        }
                    }
                    
                    // 포트 설정이 없으면 새로 추가
                    if (!portUpdated) {
                        configLines.add("server.port=8090");
                        System.out.println("    [설정] 새 포트 설정 추가: 8090");
                    }
                    
                    // 설정 파일 저장
                    Files.write(filePath, configLines, 
                               StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
                    
                    return "설정 업데이트 완료 (포트: 8090)";
                    
                } catch (IOException e) {
                    throw new RuntimeException("설정 파일 업데이트 실패", e);
                }
                
            }, 3, TimeUnit.SECONDS);
            
            System.out.println("    결과: " + result + "\\n");
            
        } catch (IOException | HttpException e) {
            System.err.println("    설정 파일 업데이트 실패: " + e.getMessage());
        }
    }

    /**
     * 실제 예제 3: 임시 파일을 이용한 원자적 업데이트
     * 중요한 데이터 파일을 안전하게 업데이트할 때 (중간에 실패해도 원본 보존)
     */
    private static void example3_AtomicFileUpdate() {
        System.out.println("  [실제 예제 3] 원자적 파일 업데이트");
        
        String dataFileName = "important_data.json";
        String tempFileName = dataFileName + ".tmp";
        
        try {
            String result = FileLockUtil.withFileLock(dataFileName, (filePath) -> {
                System.out.println("    [원자적] 데이터 파일 락 획득");
                
                try {
                    Path tempPath = filePath.getParent().resolve(tempFileName);
                    
                    // 1단계: 새 데이터를 임시 파일에 먼저 저장
                    String newData = "{\\n  \\"users\\": 150,\\n  \\"orders\\": 320,\\n  \\"updated\\": " + System.currentTimeMillis() + "\\n}";
                    Files.write(tempPath, newData.getBytes(), 
                               StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
                    System.out.println("    [원자적] 새 데이터를 임시 파일에 저장");
                    
                    // 2단계: 임시 파일이 제대로 생성되었는지 검증
                    if (!Files.exists(tempPath) || Files.size(tempPath) == 0) {
                        throw new RuntimeException("임시 파일 생성 실패");
                    }
                    
                    // 3단계: 기존 파일을 백업 (선택사항)
                    if (Files.exists(filePath)) {
                        Path backupPath = filePath.getParent().resolve(dataFileName + ".backup");
                        Files.copy(filePath, backupPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
                        System.out.println("    [원자적] 기존 파일 백업 생성");
                    }
                    
                    // 4단계: 임시 파일을 실제 파일로 이동 (원자적 연산)
                    Files.move(tempPath, filePath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
                    System.out.println("    [원자적] 임시 파일을 실제 파일로 이동 완료");
                    
                    return "데이터 파일 원자적 업데이트 완료";
                    
                } catch (IOException e) {
                    // 실패 시 임시 파일 정리
                    try {
                        Path tempPath = filePath.getParent().resolve(tempFileName);
                        if (Files.exists(tempPath)) {
                            Files.delete(tempPath);
                            System.out.println("    [원자적] 실패로 인한 임시 파일 정리");
                        }
                    } catch (IOException cleanupError) {
                        System.err.println("    [원자적] 임시 파일 정리 실패: " + cleanupError.getMessage());
                    }
                    throw new RuntimeException("원자적 업데이트 실패", e);
                }
            });
            
            System.out.println("    결과: " + result + "\\n");
            
        } catch (IOException | HttpException e) {
            System.err.println("    원자적 파일 업데이트 실패: " + e.getMessage());
        }
    }

    /**
     * 실제 예제 4: 여러 파일을 순서대로 안전하게 업데이트
     * 관련된 여러 파일을 일관성 있게 업데이트할 때
     */
    private static void example4_MultipleFilesUpdate() {
        System.out.println("  [실제 예제 4] 여러 파일 순서적 업데이트");
        
        String[] fileNames = {"users.txt", "orders.txt", "products.txt"};
        
        try {
            // 파일명 순서대로 락을 획득하여 데드락 방지
            for (String fileName : fileNames) {
                String result = FileLockUtil.withFileLockTimeout(fileName, (filePath) -> {
                    System.out.println("    [다중파일] " + fileName + " 락 획득");
                    
                    try {
                        // 각 파일에 업데이트 타임스탬프 추가
                        String updateInfo = "업데이트 시간: " + System.currentTimeMillis() + "\\n";
                        
                        if (Files.exists(filePath)) {
                            // 기존 파일에 추가
                            Files.write(filePath, updateInfo.getBytes(), StandardOpenOption.APPEND);
                            System.out.println("    [다중파일] " + fileName + " 기존 파일 업데이트");
                        } else {
                            // 새 파일 생성
                            Files.write(filePath, updateInfo.getBytes(), StandardOpenOption.CREATE);
                            System.out.println("    [다중파일] " + fileName + " 새 파일 생성");
                        }
                        
                        // 짧은 대기 시간 (실제 업데이트 작업 시뮬레이션)
                        Thread.sleep(100);
                        
                        return fileName + " 업데이트 완료";
                        
                    } catch (IOException e) {
                        throw new RuntimeException(fileName + " 업데이트 실패", e);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        throw new RuntimeException(fileName + " 업데이트 중단", e);
                    }
                    
                }, 2, TimeUnit.SECONDS);
                
                System.out.println("    결과: " + result);
            }
            
            System.out.println("    모든 파일 업데이트 완료!\\n");
            
        } catch (IOException | HttpException e) {
            System.err.println("    다중 파일 업데이트 실패: " + e.getMessage());
        }
    }
}

ThreadUtil완전사용가이드

ThreadUtil 완전 사용 가이드

📚 ThreadUtil 클래스 구조

public class ThreadUtil {
    private static final ExecutorService EXECUTOR_SERVICE =
            Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

    // 기본 메서드들
    public static ExecutorService getExecutorService()
    public static <T> Future<T> submit(Callable<T> task)
    public static Future<?> submit(Runnable task)

    // CompletableFuture 메서드들
    public static <T> CompletableFuture<T> supplyAsync(Supplier<T> supplier)
    public static CompletableFuture<Void> runAsync(Runnable runnable)

    // 대기 및 수집 메서드들
    public static <T> List<T> awaitAllAndCollect(List<CompletableFuture<T>> futures)
    public static void awaitAll(CompletableFuture<?>... futures)
    public static <T> T awaitAny(CompletableFuture<T>... futures)

    // 리소스 관리
    public static void shutdown()
}

🔧 주요 메서드 상세 설명

1. supplyAsync() - 값을 반환하는 비동기 작업

// 기본 사용법
CompletableFuture<String> future = ThreadUtil.supplyAsync(() -> {
    // 시간이 걸리는 작업
    return "결과";
});

// 실제 예시: 파일 읽기
CompletableFuture<List<String>> fileReadFuture = ThreadUtil.supplyAsync(() -> {
    try {
        return Files.readAllLines(Paths.get("data.txt"));
    } catch (IOException e) {
        throw new RuntimeException(e); // RuntimeException으로 래핑
    }
});

// 결과 사용
List<String> lines = fileReadFuture.join(); // 완료까지 대기하고 결과 반환

2. runAsync() - 값을 반환하지 않는 비동기 작업

// 기본 사용법
CompletableFuture<Void> future = ThreadUtil.runAsync(() -> {
    // 로깅, 파일 쓰기 등
    System.out.println("백그라운드 작업 완료");
});

// 실제 예시: 로그 저장
CompletableFuture<Void> logFuture = ThreadUtil.runAsync(() -> {
    try {
        Files.write(Paths.get("log.txt"), "로그 메시지\\n".getBytes(),
                   StandardOpenOption.CREATE, StandardOpenOption.APPEND);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
});

logFuture.join(); // 완료까지 대기

3. awaitAll() - 여러 작업 완료 대기

// 여러 작업을 병렬 실행하고 모두 완료까지 대기
CompletableFuture<String> task1 = ThreadUtil.supplyAsync(() -> "작업1");
CompletableFuture<String> task2 = ThreadUtil.supplyAsync(() -> "작업2");
CompletableFuture<String> task3 = ThreadUtil.supplyAsync(() -> "작업3");

// 모든 작업 완료까지 대기
ThreadUtil.awaitAll(task1, task2, task3);

// 이제 모든 결과에 안전하게 접근 가능
System.out.println(task1.join() + ", " + task2.join() + ", " + task3.join());

4. awaitAllAndCollect() - 여러 작업 완료 대기 및 결과 수집

// List로 Future들을 관리할 때 사용
List<CompletableFuture<String>> futures = Arrays.asList(
    ThreadUtil.supplyAsync(() -> "결과1"),
    ThreadUtil.supplyAsync(() -> "결과2"),
    ThreadUtil.supplyAsync(() -> "결과3")
);

// 모든 작업 완료 후 결과를 List로 수집
List<String> results = ThreadUtil.awaitAllAndCollect(futures);
// results = ["결과1", "결과2", "결과3"]

5. awaitAny() - 가장 먼저 완료되는 작업 대기

CompletableFuture<String> task1 = ThreadUtil.supplyAsync(() -> {
    // 빠른 작업
    return "빠른결과";
});

CompletableFuture<String> task2 = ThreadUtil.supplyAsync(() -> {
    // 느린 작업
    try { Thread.sleep(1000); } catch (InterruptedException e) {}
    return "느린결과";
});

// 둘 중 먼저 완료되는 것의 결과 반환
String firstResult = ThreadUtil.awaitAny(task1, task2);
// firstResult = "빠른결과"

🎯 실전 사용 패턴

패턴 1: 동기 메서드를 비동기로 변환

// 기존 동기 메서드
public String syncMethod() throws IOException {
    return Files.readString(Paths.get("file.txt"));
}

// ThreadUtil로 비동기화
CompletableFuture<String> asyncResult = ThreadUtil.supplyAsync(() -> {
    try {
        return syncMethod();
    } catch (IOException e) {
        throw new RuntimeException(e); // 예외 래핑
    }
});

// 사용
try {
    String result = asyncResult.join();
} catch (RuntimeException e) {
    if (e.getCause() instanceof IOException) {
        throw (IOException) e.getCause(); // 원본 예외 복원
    }
    throw e;
}

패턴 2: 여러 독립 작업 병렬 실행

// 서비스 초기화 예시
CompletableFuture<DatabaseService> dbFuture = ThreadUtil.supplyAsync(() ->
    new DatabaseService());

CompletableFuture<CacheService> cacheFuture = ThreadUtil.supplyAsync(() ->
    new CacheService());

CompletableFuture<LogService> logFuture = ThreadUtil.supplyAsync(() ->
    new LogService());

// 모든 서비스 초기화 완료 대기
ThreadUtil.awaitAll(dbFuture, cacheFuture, logFuture);

// 초기화된 서비스들 사용
DatabaseService db = dbFuture.join();
CacheService cache = cacheFuture.join();
LogService log = logFuture.join();

패턴 3: 순차적 비동기 체인

CompletableFuture<String> result = ThreadUtil.supplyAsync(() -> {
    // 1단계: 데이터 로드
    return loadData();
})
.thenCompose(data -> ThreadUtil.supplyAsync(() -> {
    // 2단계: 데이터 처리
    return processData(data);
}))
.thenCompose(processed -> ThreadUtil.supplyAsync(() -> {
    // 3단계: 결과 저장
    saveResult(processed);
    return "완료";
}));

String finalResult = result.join();

패턴 4: 배치 처리

// 대량 데이터를 청크로 나누어 병렬 처리
List<String> largeDataList = Arrays.asList(/* 큰 데이터 */);
int chunkSize = 100;

List<CompletableFuture<List<String>>> futures = new ArrayList<>();

for (int i = 0; i < largeDataList.size(); i += chunkSize) {
    final int start = i;
    final int end = Math.min(i + chunkSize, largeDataList.size());

    CompletableFuture<List<String>> chunkFuture = ThreadUtil.supplyAsync(() -> {
        List<String> chunk = largeDataList.subList(start, end);
        return processChunk(chunk); // 청크 처리
    });

    futures.add(chunkFuture);
}

// 모든 청크 처리 완료 후 결과 병합
List<List<String>> chunkResults = ThreadUtil.awaitAllAndCollect(futures);
List<String> finalResults = chunkResults.stream()
    .flatMap(List::stream)
    .collect(Collectors.toList());

패턴 5: 타임아웃 처리

CompletableFuture<String> result = ThreadUtil.supplyAsync(() -> {
    // 시간이 걸릴 수 있는 작업
    return longRunningTask();
});

try {
    // 5초 타임아웃
    String value = result.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
    System.out.println("작업 시간 초과");
    result.cancel(true); // 작업 취소
}

// 또는 orTimeout 사용 (Java 9+)
CompletableFuture<String> timeoutResult = ThreadUtil.supplyAsync(() -> {
    return longRunningTask();
}).orTimeout(5, TimeUnit.SECONDS);

⚠️ 주의사항 및 Best Practices

1. 예외 처리

// 올바른 예외 처리 패턴
CompletableFuture<String> future = ThreadUtil.supplyAsync(() -> {
    try {
        return riskyOperation();
    } catch (SpecificException e) {
        throw new RuntimeException(e); // RuntimeException으로 래핑
    }
});

// 사용할 때 원본 예외 복원
try {
    String result = future.join();
} catch (RuntimeException e) {
    if (e.getCause() instanceof SpecificException) {
        throw (SpecificException) e.getCause();
    }
    throw e;
}

2. 리소스 관리

// 애플리케이션 종료 시 반드시 호출
ThreadUtil.shutdown();

// Spring Boot라면 @PreDestroy 사용
@PreDestroy
public void cleanup() {
    ThreadUtil.shutdown();
}

3. 스레드 풀 크기 고려

// ThreadUtil은 CPU 코어 수만큼 스레드 풀 크기 설정
// I/O 집약적 작업이 많다면 별도 스레드 풀 고려 가능

// 너무 많은 동시 작업은 피하기
// CPU 코어 수 * 2 정도가 적절한 동시 작업 수

4. 데드락 방지

// 잘못된 예 - 데드락 위험
CompletableFuture<String> future1 = ThreadUtil.supplyAsync(() -> {
    CompletableFuture<String> nested = ThreadUtil.supplyAsync(() -> "nested");
    return nested.join(); // 데드락 위험
});

// 올바른 예 - 체이닝 사용
CompletableFuture<String> future2 = ThreadUtil.supplyAsync(() -> "first")
    .thenCompose(result -> ThreadUtil.supplyAsync(() -> "second"));

🚀 성능 최적화 팁

1. 적절한 작업 분할

  • 너무 작은 작업: 오버헤드 증가
  • 너무 큰 작업: 병렬성 저하
  • 적절한 크기: CPU 코어 수의 2-4배 정도의 작업 개수

2. I/O vs CPU 집약적 작업 구분

  • I/O 집약적: 더 많은 동시 실행 가능
  • CPU 집약적: CPU 코어 수 정도로 제한

3. 결과 캐싱

// 같은 작업 반복 시 캐싱 활용
private static final Map<String, CompletableFuture<String>> cache = new ConcurrentHashMap<>();

public CompletableFuture<String> getCachedResult(String key) {
    return cache.computeIfAbsent(key, k ->
        ThreadUtil.supplyAsync(() -> expensiveOperation(k))
    );
}

이 가이드를 참고해서 ThreadUtil을 효과적으로 활용하세요!

'TIL(CS)' 카테고리의 다른 글

lombok  (0) 2025.09.03
이미지 처리  (0) 2025.09.03
TypeToken  (0) 2025.09.03
웹소켓이란? (feat.Spring,STOMP,SockJS)  (1) 2024.10.26
OOP의 SOLID 원칙  (2) 2024.05.28