entity

package com.example.admin_project.userlog.entity;

import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Comment;

import java.time.LocalDateTime;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class UserLog {

    @Comment("식별자")
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Long id;

    @Comment("ip")
    @Column(name = "ip", nullable = false)
    private String ip;

    @Comment("uri")
    @Column(name = "uri", nullable = false)
    private String uri;

    @Comment("요청 방식")
    @Column(name = "http_method", nullable = false)
    private String httpMethod;

    @Comment("요청 시간")
    @Column(name = "log_time", nullable = false)
    private LocalDateTime logTime;
}

repository

package com.example.admin_project.userlog.repository;

import com.example.admin_project.userlog.entity.UserLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserLogRepository extends JpaRepository<UserLog, Long> {
}

service

package com.example.admin_project.userlog.service;

import com.example.admin_project.userlog.entity.UserLog;
import com.example.admin_project.userlog.repository.UserLogRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;

@Service
@RequiredArgsConstructor
@Slf4j
public class UserLogService {

    private final UserLogRepository userLogRepository;

    private static final String LOG_FILE_PATH = "C:/log/user-action.log";


    private long lastProcessedLine = 0;

    @Scheduled(fixedDelay = 60000) // 매 1분마다 실행
    public void processLogFile() {
        try {
            log.info(">>> [스케줄러 실행] 로그 파일 파싱 시작");
            Path path = Paths.get(LOG_FILE_PATH);
            if (!Files.exists(path)) {
                log.warn("로그 파일이 존재하지 않음: {}", LOG_FILE_PATH);
                return;
            }

            BufferedReader reader = Files.newBufferedReader(path);
            long currentLine = 0;
            String line;

            while((line = reader.readLine()) != null) {

                currentLine++;
                if (currentLine <= lastProcessedLine) continue;

                UserLog userLog = parseLine(line);

                if (userLog != null) {
                    userLogRepository.save(userLog);
                }

                lastProcessedLine = currentLine;
                log.info("로그 저장 완료 — 처리 라인 수: {}", currentLine);
            }
        } catch (IOException e) {
            log.error("로그 파일 처리 중 오류 발생: {}", e.getMessage(), e);
            throw new RuntimeException(e);
        }
    }

    private UserLog parseLine(String line) {
        try {
            // 예: 2025-06-10 20:17:34.404 [http...] INFO  USER_ACTION - URI=/menu, IP=..., httpMethod=..., currentTime=...
            if (!line.contains("USER_ACTION")) return null;

            String[] parts = line.split(" - ");
            if (parts.length < 2) return null;

            String[] tokens = parts[1].split(", "); // [ URI=/menu, IP=0:0:0:0:0:0:0:1, httpMethod=GET, currentTime=...]
            String uri = tokens[0].split("=")[1]; // /menu
            String ip = tokens[1].split("=")[1]; // 0:0:0:0:0:0:0:1
            String httpMethod = tokens[2].split("=")[1]; // GET
            String timeStr = tokens[3].split("=")[1]; // 2025-07-07 14:24:25

            return UserLog.builder()
                    .uri(uri)
                    .ip(ip)
                    .httpMethod(httpMethod)
                    .logTime(LocalDateTime.parse(timeStr))
                    .build();
        } catch (Exception e) {
            log.error("로그 파싱 실패: " + line);
            return null;
        }
    }
}

 

 

 

✅ 구현 목표

  • 특정 경로에 저장되는 user-action.log 파일을 주기적으로 읽는다.
  • 로그 내용 중 USER_ACTION이라는 태그가 포함된 라인만 필터링한다.
  • 라인에서 필요한 정보(URI, IP, HTTP 메소드, 시간)를 파싱한다.
  • 파싱한 데이터를 DB에 저장한다.
  • 매 1분마다 이 작업을 수행한다.

 

 

✅ 결과 확인

 

 

⚠️ 구현 시 주의할 점

 1. 로그 파일 확장자 확인

로그 파일 경로를 지정할 때 반드시 확장자명까지 포함해야 합니다.
예를 들어 C:/log/user-action.log처럼 .log를 명시하지 않으면 Java는 해당 파일을 찾지 못합니다

2. 스케줄링 기능 활성화 필수

Spring에서 @Scheduled 애노테이션을 사용하려면 메인 클래스 또는 설정 클래스에 @EnableScheduling을 반드시 선언해야 합니다.

 

 

📌 다음 계획

- yyyymmdd 컬럼 추가

  - 만약 로그 저장이 정상적이지 않을때 특정 날짜의 데이터를 삭제해야하는데 현재 구조로는 log_time밖에 없음

- 날짜를 입력받아 처리되도록 api 추가

  - 현재는 당일 날짜만 db저장되는 구조임

 

logback-spring.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds">

    <!-- 로그 파일 저장 경로 지정 -->
    <property name="LOG_PATH" value="C:\log" />

    <!-- 로그 패턴 정의 -->
    <property name="LOG_PATTERN"
              value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" />

    <!-- 콘솔 출력 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
        </encoder>
    </appender>

    <!-- USER_ACTION 로그 파일 -->
    <appender name="USER_ACTION_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_PATH}/user-action.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>${LOG_PATH}/user-action.%d{yyyy-MM-dd}.log</fileNamePattern>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
        </encoder>
    </appender>

    <!-- USER_ACTION 전용 로거 -->
    <logger name="USER_ACTION" level="INFO" additivity="false">
        <appender-ref ref="USER_ACTION_FILE" />
    </logger>

    <!-- 로그 레벨: 루트 로거는 INFO 이상만 기록 -->
    <root level="INFO">
        <appender-ref ref="CONSOLE" />
        <appender-ref ref="INFO_FILE" />
        <appender-ref ref="ERROR_FILE" />
    </root>

</configuration>

AOP

@Aspect
@Component
public class UserLoggingAspect {

    private static final Logger logger = LoggerFactory.getLogger("USER_ACTION");

    @Pointcut("execution(* com.example.admin_project..controller..*(..))")
    public void userLoggerPointCut() {}

    @Around("userLoggerPointCut()")
    public Object methodUserLogger(ProceedingJoinPoint joinPoint) throws Throwable {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String ip = request.getRemoteAddr();
        String uri = request.getRequestURI();
        String httpMethod = request.getMethod();
        LocalDateTime currentTime = LocalDateTime.now();

        logger.info("URI={}, IP={}, httpMethod={}, currentTime={}", uri, ip, httpMethod, currentTime);

        // 실제 메서드 실행
        return joinPoint.proceed();
    }
}

Logger 직접 생성  VS @Slf4j

1. 로그 분리 목적에 최적화된 명시적 로거 이름 부여

  • LoggerFactory.getLogger("USER_ACTION") 는 "USER_ACTION"이라는 명확한 로거 이름을 직접 지정하는 방식
  • 이를 통해 logback 설정에서 "USER_ACTION" 이름에 대한 별도 로그 파일과 로그 레벨 정책을 독립적으로 관리할 수 있음
  • 반면 @Slf4j는 기본적으로 클래스 이름 기반 로거를 생성하기 때문에
    특정 이름으로 로그 분리 및 별도 관리가 어렵거나 복잡해질 수밖에 없어서 Logger 직접 생성 선택

2. logback 설정에서 손쉬운 로그 경로 및 형식 관리

  • logback.xml에서 "USER_ACTION" 로거에 대해 다음처럼 설정
<logger name="USER_ACTION" level="INFO" additivity="false">
  <appender-ref ref="USER_ACTION_FILE" />
</logger>
  • 이를 통해 사용자 행동 로그를 전용 파일(user-action.log) 에 저장하며
    일반 정보 로그(info.log)와 분리하여 가독성과 관리 편의성을 극대화

결과물

entity

@Entity
@Getter
public class Menu {

    @Comment("식별자")
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Long id;

    @Comment("메뉴 이름")
    @Column(name = "menu_name", nullable = false)
    private String menuName;

    @Comment("메뉴 순서")
    @Column(name = "sort_order", nullable = false)
    private int sortOrder;

    @Comment("사용 여부")
    @Column(name = "is_use", nullable = false)
    private Boolean isUse;

    @Comment("부모 메뉴")
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_menu_id")
    private Menu parent;

    @Comment("자식 메뉴")
    @OneToMany(mappedBy = "parent")
    private List<Menu> children = new ArrayList<>();
}

 

DTO

@Getter
@Setter
public class MenuResponse {
    private Long id;
    private String menuName;
    private int sortOrder;
    private Boolean isUse;
    private Long parentId;
    private List<MenuResponse> children;
}

 

service

public List<MenuResponse> findMenuList() {
    List<Menu> menuEntity = menuRepository.findAll();
    return menuMapper.toDtoList(menuEntity);
}

mapper

package com.example.admin_project.menu.mapper;

import com.example.admin_project.menu.dto.MenuResponse;
import com.example.admin_project.menu.entity.Menu;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;

import java.util.List;

@Mapper(componentModel = "spring") // (1)
public interface MenuMapper {

    @Mapping(source = "parent.id", target = "parentId") // (2)
    @Mapping(target = "children", qualifiedByName = "mapChildren") // (3)
    MenuResponse toDto(Menu menu);

    List<MenuResponse> toDtoList(List<Menu> menuList);

    // 자식 메뉴를 재귀적으로 변환
    @Named("mapChildren") // (4)
    default List<MenuResponse> mapChildren(List<Menu> children) {
        return children != null ? toDtoList(children) : null;
    }
}

 

주석 설명
(1) Mapper 구현체를 Spring Bean으로 등록하기 위한 설정
 -> MapStruct은 컴파일 시 자동으로 MenuMapperImpl라는 구현 클래스를 생성
(2) Menu 엔티티 객체의 parent.id 값을 MenuResponse DTO의 parentId 필드로 매핑
(3) 자식 메뉴 필드를 mapChildren 메서드를 사용해 재귀적으로 매핑
-> qualifiedByName = "mapChildren"는 @Named("mapChildren")로 지정된 메서드를 사용하겠다는 의미입니다.
(4) MapStruct가 사용할 수 있도록 mapChildren 메서드를 이름으로 지정

 

@Mapping(source = "children", target = "children")
MenuResponse toDto(Menu menu);
  • source: 원본 객체(예: Menu 엔티티)의 필드 이름
  • target: 대상 객체(예: MenuResponse DTO)의 필드 이름

 

toDtoList()만 쓰는데 왜 toDto()가 필요??

 -> toDtoList()는 내부적으로 리스트 요소 하나하나에 toDto()를 호출하기 때문

 

(전체 흐름)

1. toDto(Menu) 호출

  • 상위 메뉴 하나를 변환 하기 위해 toDto(Menu)가 호출됨

2. MapStruct가 children 필드에 접근시

  • @Mapping(target = "children", qualifiedByName = "mapChildren") 설정 덕분에
  • Menu의 children 필드는 자동 매핑하지 않고, 위에 설정에 맞는 mapChildren() 메서드를 호출

3. mapChildren() 호출

@Named("mapChildren")
default List<MenuResponse> mapChildren(List<Menu> children) {
    return children != null ? toDtoList(children) : null;
}
  • children이 null이 아니면 toDtoList()로 변환

4. toDtoList()가 리스트 반복

  • 리스트 안의 각각의 Menu를 toDto()로 다시 변환함 (재귀 호출)
  • 이 안에서도 또 children이 있다면?
  • 다시 mapChildren() 호출됨

 

Spring Data JPA vs JPA vs Hibernate

기술명 역할
JPA ORM을 위한 표준 인터페이스 (기능 정의)
Hibernate JPA를 구현한 실제 라이브러리 (실행 담당)
Spring Data JPA JPA 사용을 더 편하게 도와주는 Spring 모듈

 

// JPA (Hibernate)
public List<User> findByName(String name) {
    String jpql = "SELECT u FROM User u WHERE u.name = :name";
    return em.createQuery(jpql, User.class)
             .setParameter("name", name)
             .getResultList();
}

// Spring Data JPA
List<User> findByName(String name);

 

[ 코드 ]
     ↓
Spring Data JPA (자동으로 리포지토리 구현)
     ↓
     JPA (인터페이스)
     ↓
Hibernate (JPA 구현체)
     ↓
Database

 

 

테이블 자동 생성

단계 주체 설명
@Entity 해석 JPA + Hibernate 어노테이션 읽고 해석
DDL(SQL) 생성 Hibernate 엔티티 보고 CREATE TABLE 만들기
DDL 실행 Hibernate + JDBC DB에 SQL 날려서 실행
테이블 생성 DBMS (예: H2, MySQL) SQL 받아서 진짜 테이블 생성

 

 

컨트롤러, 서비스 네이밍 규칙??

 

  • Controller는 클라이언트 요청을 처리하니까
    → 사용자 입장에서 이해하기 쉬운 getMenuList()처럼 요청 관점 이름
  • Service는 비즈니스 로직을 처리하니까
    → 내부 동작을 드러내는 findMenuList()처럼 동작 관점 이름

 

MapStruct

@Mapping(source = "children", target = "children")
MenuResponse toDto(Menu menu);

 

  • source: 원본 객체(예: Menu)의 필드 이름
  • target: 대상 객체(예: MenuResponse)의 필드 이름

 

 

@Transactional(readOnly = true) ???

N+1??

+ Recent posts