IntelliJ/IDEA 내 JDK 설정 확인

  • File > Project Structure > Project > Project SDK
    File > Project Structure > Modules > Module SDK
    File > Settings > Build, Execution, Deployment > Build Tools > Gradle > Gradle JVM
    모두 Java 17로 설정되어야 합니다.
728x90

'Spring Boot > IDEA' 카테고리의 다른 글

--add-opens java.base/java.lang=ALL-UNNAMED  (0) 2025.04.11
Spring Boot 테스트 Class 자동 생성 단축키  (0) 2025.04.10
Spring Boot Build 하기  (0) 2025.03.08
IntelliJ IDEA Auto Import 설정  (0) 2025.03.01
Build Gradle Update  (0) 2025.02.27
블로그 이미지

Link2Me

,

mariaDB 에서 PostgreSQL 로 변경하고 나서 데이터를 추가하니 이런 에러가 발생한다.

PK 시퀀스가 자동으로 증가된 값을 인지하지 못해서 생기는 현상이라고 한다.

 

1
2
3
4
5
6
2025-06-04T13:32:47.652+09:00  WARN 22624 --- [mall] [nio-8080-exec-6] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 0, SQLState: 23505
2025-06-04T13:32:47.652+09:00 ERROR 22624 --- [mall] [nio-8080-exec-6] o.h.engine.jdbc.spi.SqlExceptionHelper   : 오류: 중복된 키 값이 "menu_orgchart_pkey" 고유 제약 조건을 위반함
  Detail: (id)=(7) 키가 이미 있습니다.
2025-06-04T13:32:47.658+09:00 ERROR 22624 --- [mall] [nio-8080-exec-6] c.m.s.a.m.a.s.filter.JWTCheckFilter      : JWT Check Failed: Request processing failed: org.springframework.dao.DataIntegrityViolationException: could not execute statement [오류: 중복된 키 값이 "menu_orgchart_pkey" 고유 제약 조건을 위반함
  Detail: (id)=(7) 키가 이미 있습니다.] [insert into menu_orgchart (depth,name,parent_id,textvalues (?,?,?,?)]; SQL [insert into menu_orgchart (depth,name,parent_id,textvalues (?,?,?,?)]; constraint [null]
 

 

해결 방법은 아래와 같다.

PostgreSQL 에 접속한 다음에 해당 DB에 접속한다.

아래 명령어를 한번만 해주면 해결된다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
DO $$
DECLARE
    rec RECORD;
BEGIN
    -- SERIAL, BIGSERIAL 컬럼의 시퀀스와 테이블/컬럼 정보를 얻어서
    FOR rec IN
        SELECT
            s.relname AS sequence_name,
            t.relname AS table_name,
            a.attname AS column_name
        FROM
            pg_class s
        JOIN pg_depend d ON d.objid = s.oid
        JOIN pg_class t ON d.refobjid = t.oid
        JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
        WHERE
            s.relkind = 'S'
            AND d.deptype = 'a'
    LOOP
        -- setval을 수행
        EXECUTE format(
            'SELECT setval(''%I'', COALESCE((SELECT MAX(%I) FROM %I), 1))',
            rec.sequence_name,
            rec.column_name,
            rec.table_name
        );
    END LOOP;
END$$;
 

 

 

 

 

 

728x90
블로그 이미지

Link2Me

,

Spring Boot 에서 MariaDB를 연동하여 테스트하고 있는데, PostgreSQL 로 변동하려면 칼럼을 전부 소문자로 변경해야 하더라. JPA 에서의 변수를 userID 이렇게 사용하던 걸 userId 로 변경해야 DB 칼럼에는 user_id 로 연동되어 처리된다.

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#############################################
######### RockeyOS 9.5 ##########
#############################################
# ripgrep 설치
sudo dnf install ripgrep
 
# Spring Boot 코드 일괄 변경
# DB 테이블을 PostgreSQL 변경을 고려하여 칼럼명을 snake_case 로 변경하면서 
# members 테이블 칼럼을 일괄 변경하고 나서 관련된 Entity를 수정한다.
# 이후에 아래 코드로 관련된 함수 등을 일괄 변경 시도한다.
 
find . -type f -name "*.java" -exec sed -i \
    -'s/\buserID\b/userId/g' \
    -'s/\buserNM\b/userNm/g' \
    -'s/\btelNO\b/telNo/g' \
    -'s/\bmobileNO\b/mobileNo/g' \
    -'s/\bphoneSE\b/phoneSe/g' \
    -'s/\bcodeID\b/codeId/g' \
    -'s/\bregNO\b/regNo/g' \
    {} +
 
 
# 적용후 확인 명령어
rg 'userID|userNM|telNO|mobileNO|phoneSE|codeID|regNO' --glob '*.java'
 

 

 

728x90
블로그 이미지

Link2Me

,

DB 칼럼에 중요한 정보는 암호화 저장을 해야 한다.

처음에 평문으로 구성했던 칼럼을 암호화 업데이트 하기 위해서 아래와 같이 임시 적용한 코드를 적어둔다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public interface MemberRepository extends JpaRepository<Member, Long> {
 
    // 모든 회원 평문 mobile_no만 조회 (필요에 따라 select 절 수정)
    @Query("SELECT m FROM Member m WHERE m.mobileNo IS NOT NULL AND m.mobileNo <> ''")
    List<Member> findAllWithMobileNo();
}
 
 
@Service
@RequiredArgsConstructor
public class MemberMobileEncryptService {
    private final MemberRepository memberRepository;
    private final EncryptService encryptService;
 
    @Transactional
    public int encryptAllMobileNo() {
        String userName = SafeDBKeyConfig.userName;
        String tableName = SafeDBKeyConfig.tableName;
        String columnName = SafeDBKeyConfig.columnName_mobileNo;
 
        // 1. 평문 mobile_no가 있는 회원 모두 조회
        List<Member> memberList = memberRepository.findAllWithMobileNo();
 
        int updated = 0;
        for (Member member : memberList) {
            String plainMobile = member.getMobileNo();
            if (plainMobile == null || plainMobile.isBlank()) continue;
 
            // 2. 암호화
            String encrypted = encryptService.encrypt(userName, tableName, columnName, plainMobile);
 
            // 3. update (변경 감지, JPA 자동 저장)
            member.setMobileNo(encrypted);
            updated++;
        }
        // 트랜잭션 끝나면 JPA flush/commit
        return updated;
    }
}
 
@SpringBootTest
public class EncryptMobileNoTest {
 
    @Autowired
    private MemberMobileEncryptService mobileEncryptService;
 
    @Test
    public void testEncryptAllMobileNo() {
        int count = mobileEncryptService.encryptAllMobileNo();
        System.out.println("암호화 적용 회원 수: " + count);
    }
}
 

 

 

 

728x90
블로그 이미지

Link2Me

,

스프링 부트에서 이미지 처리시 주의사항이 되어버린 셈이다.

 

확장자 없는 요청을 아래와 같이 수정하고 정상동작함
// 확장자 없는 요청 처리 (jpg, png, gif 등 순회 탐색)
    @GetMapping("/{memberId:[0-9]+}")
    public ResponseEntity<Resource> getPhoto(@PathVariable String memberId)

// 확장자 포함 요청 처리 (예: /api/member/photo/10.png)
    @GetMapping("/{memberId}.{ext}")
    public ResponseEntity<Resource> getPhotoWithExt

 

728x90
블로그 이미지

Link2Me

,

fetch join은 Spring Boot + JPA (Hibernate) 환경에서 지연 로딩(LAZY) 관계의 데이터를 한 번의 쿼리로 함께 조회할 때 사용하는 방법

JPA에는 엔티티에 관계를 맵핑할 때 지연 로딩과 즉시 로딩 설정할 수 있다.
일반 Join : 연관 Entity에 Join을 걸어도 실제 쿼리에서 SELECT 하는 Entity는 오직 JPQL에서 조회하는 주체가 되는 Entity만 조회하여 영속화한다.
Fetch Join : 조회의 주체가 되는 Entity 이외에 Fetch Join이 걸린 연관 Entity도 함께 SELECT 하여 모두 영속화

Member Entity와 Team Entity가 @ManyToOne 일 때 Entity 클래스에 서로의 관계를 표시해 준다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private String username;
    private int age;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    @ToString.Exclude
    private Team team;
}


Fetch Join 을 사용해야 하는 상황
1. @ManyToOne, @OneToOne 연관 관계의 객체가 반복적으로 로딩될 때
   @Query("SELECT m FROM Member m JOIN FETCH m.team")
   List<Member> findAll();  // N+1 문제 해결

2. JPQL 또는 QueryDSL로 다대일 관계를 한 번에 조회하고자 할 때
   @ManyToOne, @OneToOne 관계는 fetch join으로 쉽게 한 쿼리에 묶을 수 있음

Fetch Join을 사용하면 안 되는 경우
1. 컬렉션(@OneToMany, @ManyToMany)에 페치 조인을 사용하면서 페이징이 필요한 경우
   JPA는 fetch join이 있는 경우 Pageable 정보를 무시하거나, 메모리에서 잘라내기 때문에 성능 이슈 발생
   @BatchSize 또는 별도 쿼리

2. 복수 개의 컬렉션을 동시에 Fetch Join 할 때
   @Query("SELECT d FROM Department d JOIN FETCH d.employees JOIN FETCH d.projects") 
   여러 컬렉션이 곱해져서 결과가 기하급수적으로 커질 수 있음
   해결 방법: 컬렉션 하나만 fetch join, 나머지는 batch fetch 사용

728x90
블로그 이미지

Link2Me

,

성능 최적화 요소까지 완벽한 고려는 아니지만 코드는 많이 정리된 거 같아서 적어둔다.

React 에서는 pk 값이 직접 노출되지 않도록 암호화처리하고, 대신에 no 를 넣어서 처리했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
@Repository
@Transactional
@RequiredArgsConstructor
@Log4j2
public class ProductSearchImpl implements ProductSearch {
 
    private final JPAQueryFactory queryFactory;
    private final CryptoUtil cryptoUtil;
 
    @Override
    public Page<ProductDTO> searchList(PageRequestDTO pageRequestDTO) {
        QProduct product = QProduct.product;
        QProductImage productImage = QProductImage.productImage;
 
        SearchConditionBuilder<Product> scb = new SearchConditionBuilder<>(Product.class"product");
 
        String where = pageRequestDTO.getFilter().getWhere();
        String keyword = pageRequestDTO.getFilter().getKeyword();
 
        OrderSpecifier<?> orderSpecifier = product.pno.desc(); // 기본값: pno 내림차순
 
        if (keyword != null && !keyword.isBlank()) {
            switch (where) {
                case "pname" -> scb.addLike("pname", keyword);
                case "desc" -> scb.addLike("pdesc", keyword);
                case "price" -> {
                    try {
                        int minPrice = Integer.parseInt(keyword);
                        scb.addGreaterThanEqual("price", minPrice);
                        orderSpecifier = product.price.asc(); // 가격 오름차순 정렬
                    } catch (NumberFormatException e) {
                        log.warn("가격 필터 숫자 변환 실패: {}", keyword);
                    }
                }
            }
        }
 
        BooleanBuilder builder = scb.build();
 
        Pageable pageable = PageRequest.of(
            pageRequestDTO.getPage() - 1,
            pageRequestDTO.getSize()
        );
 
        List<Product> productList = queryFactory
            .selectFrom(product)
            .leftJoin(product.imageList, productImage).fetchJoin()
            .where(product.delFlag.eq(false)
                .and(productImage.ord.eq(0))
                .and(builder))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .orderBy(orderSpecifier)
            .fetch();
 
        long totalCount = queryFactory
            .select(product.count())
            .from(product)
            .leftJoin(product.imageList, productImage)
            .where(product.delFlag.eq(false)
                .and(productImage.ord.eq(0))
                .and(builder))
            .fetchOne();
 
        List<ProductDTO> dtoList = IntStream.range(0, productList.size())
            .mapToObj(i -> {
                Product entity = productList.get(i);
                int no = (int) (totalCount - (pageable.getPageNumber() * pageable.getPageSize()) - i);
                return toDTO(entity, no);
            }).toList();
 
        return new PageImpl<>(dtoList, pageable, totalCount);
    }
 
    private ProductDTO toDTO(Product product, int no) {
        String encryptedPno = cryptoUtil.encryptAES(String.valueOf(product.getPno()));
 
        List<String> fileNames = product.getImageList().stream()
            .map(ProductImage::getFileName)
            .toList();
 
        return ProductDTO.builder()
            .no(no)
            .pno(encryptedPno)
            .pname(product.getPname())
            .price(product.getPrice())
            .pdesc(product.getPdesc())
            .uploadFileNames(fileNames)
            .build();
    }
}
 
728x90

'Spring Boot > Basic' 카테고리의 다른 글

Spring Boot 이미지 로딩 문제  (0) 2025.05.24
Spring Boot fetch join  (0) 2025.05.19
Spring Boot 3.4.5 QueryDSL 예제 개선  (0) 2025.05.04
Spring Boot 3.4.5 QueryDSL 예제  (0) 2025.05.04
Spring Boot 3.4.5 JOOQ TodoSearch 예제  (0) 2025.05.03
블로그 이미지

Link2Me

,

QueryDSL 이전 게시글의 최적화 버전을 chatGPT 도움으로 변경한 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.dsl.PathBuilder;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.data.domain.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
 
@Service
@Transactional
@Log4j2
@RequiredArgsConstructor // 생성자 자동 주입
public class AccessLogSearchImpl implements AccessLogSearch {
    private final JPAQueryFactory queryFactory;
    private final ErrorCodeRepository errorCodeRepository;
 
    @Override
    public Page<AccessLogDTO> search1(PageRequestDTO pageRequestDTO) {
        QAccessLog accessLog = QAccessLog.accessLog;
        BooleanBuilder builder = new BooleanBuilder();
 
        String where = pageRequestDTO.getFilter().getWhere();
        String keyword = pageRequestDTO.getFilter().getKeyword();
 
        log.info("검색 필터 where = {}, keyword = {}", where, keyword);
 
        if (keyword != null && !keyword.trim().isEmpty()) {
            buildSearchCondition(builder, accessLog, where.trim(), keyword.trim());
        }
 
        Pageable pageable = PageRequest.of(
                pageRequestDTO.getPage() - 1,
                pageRequestDTO.getSize(),
                Sort.by("uid").descending()
        );
 
        JPAQuery<AccessLog> query = queryFactory
                .selectFrom(accessLog)
                .where(builder)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .orderBy(accessLog.uid.desc());
 
        List<AccessLog> resultList = query.fetch();
 
        long totalCount = queryFactory
                .select(accessLog.count())
                .from(accessLog)
                .where(builder)
                .fetchOne();
 
        // errorCode 메시지 전체 Map으로 미리 조회 (N+1 제거)
        Map<Integer, String> errorMap = errorCodeRepository.findAll().stream()
                .collect(Collectors.toMap(ErrorCode::getCodeId, ErrorCode::getCodeNm));
 
        List<AccessLogDTO> dtoList = IntStream.range(0, resultList.size())
                .mapToObj(i -> {
                    AccessLog entity = resultList.get(i);
                    int no = (int) (totalCount - (pageable.getPageNumber() * pageable.getPageSize()) - i);
                    return toDTO(entity, no, errorMap);
                })
                .collect(Collectors.toList());
 
        return new PageImpl<>(dtoList, pageable, totalCount);
    }
 
    private void buildSearchCondition(BooleanBuilder builder, QAccessLog accessLog, String where, String keyword) {
        switch (where) {
            case "userID" -> builder.and(accessLog.userid.containsIgnoreCase(keyword));
            case "userNM" -> builder.and(accessLog.userNM.containsIgnoreCase(keyword));
            case "ipaddr" -> builder.and(accessLog.ipaddr.containsIgnoreCase(keyword));
            case "route" -> builder.and(accessLog.route.stringValue().containsIgnoreCase(keyword));
            case "errorCode" -> {
                List<Integer> codeIds = errorCodeRepository.findCodeIdsByCodeNmLike(keyword);
                if (!codeIds.isEmpty()) {
                    builder.and(accessLog.errCode.in(codeIds));
                } else {
                    builder.and(accessLog.errCode.eq(-9999)); // fallback
                }
            }
            case "accessDate" -> {
                String[] parts = keyword.split("/");
                if (parts.length == 2) {
                    String from = parts[0].trim();
                    String to = parts[1].trim();
                    if (from.length() == 8 && to.length() == 8) {
                        if (from.compareTo(to) > 0) {
                            String temp = from;
                            from = to;
                            to = temp;
                        }
                        builder.and(accessLog.date.between(from, to));
                    }
                } else {
                    builder.and(accessLog.date.startsWith(keyword));
                }
            }
            default -> {
                Set<String> allowedFields = Set.of("userid""userNM""ipaddr""browser""os""date");
                if (allowedFields.contains(where)) {
                    PathBuilder<AccessLog> pathBuilder = new PathBuilder<>(AccessLog.class"accessLog");
                    builder.and(pathBuilder.getString(where).containsIgnoreCase(keyword));
                } else {
                    log.warn(" 잘못된 where 필드명: '{}'. 검색 조건 무시", where);
                }
            }
        }
    }
 
    private AccessLogDTO toDTO(AccessLog entity, int no, Map<Integer, String> errorMap) {
        String errorMessage = errorCodeRepository
                .findMessageByCode(entity.getErrCode())
                .orElse(String.valueOf(entity.getErrCode()));
 
        return AccessLogDTO.builder()
                .no(no)  //  추가
                .uid(entity.getUid())
                .ipaddr(MaskingUtil.ipAddressMasking(entity.getIpaddr()))
                .date(InputSanitizer.displayDate(entity.getDate()))
                .time(entity.getTime())
                .OS(entity.getOs())
                .browser(entity.getBrowser())
                .userid(MaskingUtil.idMasking(entity.getUserid()))
                .userNM(MaskingUtil.letterMasking(entity.getUserNM()))
                .success(entity.getSuccess())
                .route(entity.getRoute())
                .errCode(entity.getErrCode())
                .errorMessage(errorMap.getOrDefault(entity.getErrCode(), String.valueOf(entity.getErrCode())))
                .build();
    }
 
}
 
 

 

 

728x90
블로그 이미지

Link2Me

,

접속로그를 동적 쿼리로 검색하는 걸 구현하는 예제를 적어둔다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
@Service
@Transactional
@Log4j2
@RequiredArgsConstructor // 생성자 자동 주입
public class AccessLogSearchImpl implements AccessLogSearch {
    private final JPAQueryFactory queryFactory;
    private final ErrorCodeRepository errorCodeRepository;
 
    @Override
    public Page<AccessLogDTO> search1(PageRequestDTO pageRequestDTO) {
        QAccessLog accessLog = QAccessLog.accessLog;
 
        BooleanBuilder builder = new BooleanBuilder();
 
        String where = pageRequestDTO.getFilter().getWhere();
        String keyword = pageRequestDTO.getFilter().getKeyword();
 
        log.info("검색 필터 where = {}, keyword = {}", where, keyword);
 
        if (keyword != null && !keyword.trim().isEmpty()) {
            switch (where) {
                case "userID":
                    builder.and(accessLog.userid.containsIgnoreCase(keyword));
                    break;
                case "userNM":
                    builder.and(accessLog.userNM.containsIgnoreCase(keyword));
                    break;
                case "ipaddr":
                    builder.and(accessLog.ipaddr.containsIgnoreCase(keyword));
                    break;
                case "route":
                    builder.and(accessLog.route.stringValue().containsIgnoreCase(keyword));
                    break;
                case "errorCode":
                    builder.and(accessLog.errCode.stringValue().containsIgnoreCase(keyword));
                    break;
                case "accessDate":
                    if (keyword.contains("/")) {
                        String[] parts = keyword.split("/");
                        if (parts.length == 2) {
                            String from = parts[0].trim();
                            String to = parts[1].trim();
 
                            log.info("accessDate 조건: from = {}, to = {}", from, to);
 
                            if (from.length() == 8 && to.length() == 8) {
                                // 날짜 순서 보정
                                if (from.compareTo(to) > 0) {
                                    String temp = from;
                                    from = to;
                                    to = temp;
                                }
                                log.info("accessDate 조건: from = {}, to = {}", from, to);
                                builder.and(accessLog.date.between(from, to));
                            }
                        }
                    } else {
                        builder.and(accessLog.date.startsWith(keyword));
                    }
                    break;
                default:
                    // where가 명시되지 않은 컬럼이라면, 문자열 컬럼으로 간주하고 LIKE 처리
                    // 존재하지 않는 필드면 builder에 아무 조건도 추가하지 않음 → 결과 없음
                    try {
                        PathBuilder<AccessLog> entityPath = new PathBuilder<>(AccessLog.class"accessLog");
                        // 유효한 필드인지 체크
                        Field field = AccessLog.class.getDeclaredField(where);
                        if (field.getType().equals(String.class)) {
                            builder.and(entityPath.getString(where).containsIgnoreCase(keyword));
                        } else {
                            log.warn(" '{}' 필드는 문자열(String)이 아닙니다. 검색 제외됨", where);
                        }
                    } catch (NoSuchFieldException e) {
                        log.warn(" 존재하지 않는 where 필드명: '{}'. 조건 제외 → 결과 없음 처리", where);
                        // builder에 조건 추가 안 함 → 결과 없음 유도
                    }
 
            }
        }
 
        Pageable pageable = PageRequest.of(
                pageRequestDTO.getPage() - 1,
                pageRequestDTO.getSize(),
                Sort.by("uid").descending()
        );
 
        JPAQuery<AccessLog> query = queryFactory
                .selectFrom(accessLog)
                .where(builder)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .orderBy(accessLog.uid.desc());
 
        List<AccessLog> resultList = query.fetch();
 
        long totalCount = queryFactory
                .select(accessLog.count())
                .from(accessLog)
                .where(builder)
                .fetchOne();
 
        List<AccessLogDTO> dtoList = IntStream.range(0, resultList.size())
                .mapToObj(i -> {
                    AccessLog entity = resultList.get(i);
                    int no = (int) (totalCount - ((pageable.getPageNumber()) * pageable.getPageSize()) - i);
                    return toDTO(entity, no);
                })
                .collect(Collectors.toList());
 
        return new PageImpl<>(dtoList, pageable, totalCount);
    }
 
    private AccessLogDTO toDTO(AccessLog entity, int no) {
        String errorMessage = errorCodeRepository
                .findMessageByCode(entity.getErrCode())
                .orElse(String.valueOf(entity.getErrCode()));
 
        return AccessLogDTO.builder()
                .no(no)  // 추가
                .uid(entity.getUid())
                .ipaddr(MaskingUtil.ipAddressMasking(entity.getIpaddr()))
                .date(InputSanitizer.displayDate(entity.getDate()))
                .time(entity.getTime())
                .OS(entity.getOs())
                .browser(entity.getBrowser())
                .userid(MaskingUtil.idMasking(entity.getUserid()))
                .userNM(MaskingUtil.letterMasking(entity.getUserNM()))
                .success(entity.getSuccess())
                .route(entity.getRoute())
                .errCode(entity.getErrCode())
                .errorMessage(errorMessage)
                .build();
    }
 
}
 

 

거의 동일한 코드를 최적화한 코드는 이 다음 게시글에 적어둘 것이다.

 

728x90
블로그 이미지

Link2Me

,

QueryDSL 방식의 코드를 JOOQ 코드로 변경하는 방법으로 테스트하고 적어둔다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@Service
@Transactional
@Log4j2
@RequiredArgsConstructor // 생성자 자동 주입
public class TodoSearchImpl implements TodoSearch {
 
    private final DSLContext dsl;
 
    @Override
    public Page<Todo> search1(PageRequestDTO pageRequestDTO) {
 
        String keyword = pageRequestDTO.getFilter().getKeyword();
        String where = pageRequestDTO.getFilter().getWhere();
 
        Condition condition = DSL.trueCondition();
 
        // 검색 조건 적용
        if (keyword != null && !keyword.trim().isEmpty()) {
            switch (where) {
                case "title":
                    condition = condition.and(TLB_TODO.TITLE.likeIgnoreCase("%" + keyword + "%"));
                    break;
                case "writer":
                    condition = condition.and(TLB_TODO.WRITER.likeIgnoreCase("%" + keyword + "%"));
                    break;
                default:
                    condition = condition.and(
                            TLB_TODO.TITLE.likeIgnoreCase("%" + keyword + "%")
                                    .or(TLB_TODO.WRITER.likeIgnoreCase("%" + keyword + "%"))
                    );
            }
        }
 
        Pageable pageable = PageRequest.of(
                pageRequestDTO.getPage() - 1,
                pageRequestDTO.getSize(),
                Sort.by("tno").descending()
        );
 
        // 실제 데이터 조회
        List<Todo> content = dsl.selectFrom(TLB_TODO)
                .where(condition)
                .orderBy(TLB_TODO.TNO.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch()
                .map(record -> Todo.builder()
                        .tno(record.getTno())
                        .title(record.getTitle())
                        .writer(record.getWriter())
                        .complete(record.getComplete())
                        .dueDate(record.getDueDate())
                        .build());
 
        long totalCount = dsl.selectCount()
                .from(TLB_TODO)
                .where(condition)
                .fetchOne(0, Long.class);
 
        return new PageImpl<>(content, pageable, totalCount);
    }
}
 

 

 

JOOQ Config

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import org.jooq.DSLContext;
import org.jooq.SQLDialect;
import org.jooq.impl.DSL;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
import javax.sql.DataSource;
 
@Configuration
public class JooqConfig {
 
    @Bean
    public DSLContext dslContext(DataSource dataSource) {
        return DSL.using(dataSource, SQLDialect.MARIADB); // DB에 맞게
    }
}
 

 

 

build.gradle

QueryDSL 과 JOOQ 혼용 테스트를 위해서 2가지 모두 처리할 수 있도록 구성

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.4'
    id 'io.spring.dependency-management' version '1.1.7'
    id 'nu.studer.jooq' version '8.2' // JOOQ용 플러그인
}
 
group = 'com.mansa.smartx.api'
version = '0.0.1-SNAPSHOT'
 
java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}
 
configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}
 
repositories {
    mavenCentral()
}
 
ext {
    querydslDir = "$buildDir/generated/querydsl"
    jooqDir = "$buildDir/generated-src/jooq"
}
 
sourceSets {
    main {
        java {
            srcDir querydslDir
            srcDir jooqDir
        }
    }
}
 
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
    annotationProcessor 'org.projectlombok:lombok'
 
    implementation 'org.modelmapper:modelmapper:3.2.2'
 
    //QueryDSL 추가
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
 
    // JOOQ
    implementation "org.jooq:jooq:3.19.7"
    jooqGenerator "org.jooq:jooq-codegen:3.19.7"
    jooqGenerator "org.mariadb.jdbc:mariadb-java-client" 
 
    implementation 'net.coobird:thumbnailator:0.4.20'
 
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'com.google.code.gson:gson:2.12.1'
 
    implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
    implementation 'io.jsonwebtoken:jjwt-impl:0.12.6'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.12.6'
 
    implementation 'org.springframework.boot:spring-boot-starter-validation'
 
    // P6Spy
//    implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'
    implementation 'org.hibernate.orm:hibernate-core:6.6.12.Final' // 최신 버전
 
    // Redis
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
 
    //test 롬복 사용
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    testCompileOnly 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'
    testImplementation 'jakarta.persistence:jakarta.persistence-api:3.1.0'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
 
}
 
tasks.named('compileJava', JavaCompile).configure {
    options.annotationProcessorGeneratedSourcesDirectory = file(querydslDir)
}
 
tasks.named('test') {
    useJUnitPlatform()
}
 
tasks.named('clean') {
    delete querydslDir
    delete jooqDir
}
 
jooq {
    version = '3.19.7'
    configurations {
        main {
            generationTool {
                jdbc {
                    driver = 'org.mariadb.jdbc.Driver' // 또는 PostgreSQL
                    url = 'jdbc:mariadb://localhost:3306/malldb'
                    user = 'testfox'
                    password = 'TestCodefox!!'
                }
                generator {
                    database {
                        name = 'org.jooq.meta.mariadb.MariaDBDatabase' // 또는 postgres
                        inputSchema = 'malldb' // 보통 'yourdb'
                        includes = '.*'
                    }
                    generate {
                        deprecated = false
                        records = true
                        immutablePojos = true
                        fluentSetters = true
                    }
                    target {
                        packageName = 'com.jpashop.api.jooq.generated'
                        directory = jooqDir
                    }
                }
            }
        }
    }
}
 
 

 

 

 

 

 

728x90
블로그 이미지

Link2Me

,

QueryDSL TodoSearch 에 대한 예제다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public interface TodoRepository extends JpaRepository<Todo, Long>, TodoSearch {
}
 
// TodoSearch는 JpaRepository에 붙이면 안 되는 인터페이스 => TodoSearch는 커스텀 구현용 인터페이스
public interface TodoSearch {
    Page<Todo> search1(PageRequestDTO pageRequestDTO);
}
 
@Service
@Transactional
@Log4j2
@RequiredArgsConstructor // 생성자 자동 주입
public class TodoSearchImpl implements TodoSearch {
    // TodoSearchImpl의 이름이 정확히 TodoSearch + Impl이면,
    // Spring Data JPA는 자동으로 이를 Repository 구현으로 인식한다.
 
    private final JPAQueryFactory queryFactory;
 
    @Override
    public Page<Todo> search1(PageRequestDTO pageRequestDTO) {
        QTodo qTodo = QTodo.todo;
 
        BooleanBuilder builder = new BooleanBuilder();
 
        String keyword = pageRequestDTO.getKeyword();
        String where = pageRequestDTO.getWhere();
 
        // 검색 조건 적용
        if (keyword != null && !keyword.trim().isEmpty()) {
            switch (where) {
                case "title":
                    builder.and(qTodo.title.containsIgnoreCase(keyword));
                    break;
                case "writer":
                    builder.and(qTodo.writer.containsIgnoreCase(keyword));
                    break;
                default:
                    builder.and(
                            qTodo.title.containsIgnoreCase(keyword)
                                    .or(qTodo.writer.containsIgnoreCase(keyword))
                    );
            }
        }
 
        Pageable pageable = PageRequest.of(
                pageRequestDTO.getPage() - 1,
                pageRequestDTO.getSize(),
                Sort.by("tno").descending()
        );
 
        JPAQuery<Todo> query = queryFactory
                .selectFrom(qTodo)
                .where(builder)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .orderBy(qTodo.tno.desc());
 
        List<Todo> resultList = query.fetch();
 
        long totalCount = queryFactory
                .select(qTodo.count())
                .from(qTodo)
                .where(builder)
                .fetchOne();
 
        return new PageImpl<>(resultList, pageable, totalCount);
    }
}
 

 

QueryDSL로 직접 selectFrom(qTodo)를 사용하는 경우, 기본 정렬이 적용되지 않으며, 
Sort.by("tno").descending()과 같은 JPA Pageable의 정렬 정보도 자동 적용되지 않는다.

 

JPAQuery<Todo> query = queryFactory
    .selectFrom(qTodo)
    .where(builder)
    .offset(pageable.getOffset())
    .limit(pageable.getPageSize())
    .orderBy(qTodo.tno.desc()); 

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
 
@Data
@SuperBuilder  // - **상속 관계가 있는 클래스**에서 부모 클래스의 빌더 패턴을 지원하기 위해 사용된다.
@AllArgsConstructor  // 모든 필드에 대해 값을 받아 전체 매개변수 생성자를 자동으로 생성한다.
@NoArgsConstructor
public class PageRequestDTO {
 
    @Builder.Default  // 값을 명시적으로 설정하지 않을 때만 기본값을 부여
    private int page = 1;
 
    @Builder.Default
    private int size = 10;
 
    private Integer blockSize;  // Integer로 변경 (null 체크 가능하게)
 
    private String where; // 검색 KEY 추가
    private String keyword; // 검색어
 
    public int getBlockSize() {
        // blockSize가 null이면 size와 동일하게 처리
        return (blockSize != null) ? blockSize : size;
    }
}
 

 

 

728x90
블로그 이미지

Link2Me

,

온라인 강의 시점은 Spring Boot 버전이 2.X 이고 현재 내가 사용하는 Spring Boot 버전은 3.4.5 이다보니 오류가 발생해서 개고생을 해서 적어둔다.

chatGPT 도 엉터리로 답변을 해주는 통에 몇번의 시행착오를 거쳤다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.5'
    id 'io.spring.dependency-management' version '1.1.7'
}
 
group = 'jpabook'
version = '0.0.1-SNAPSHOT'
 
java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}
 
configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}
 
repositories {
    mavenCentral()
}
 
ext {
    querydslDir = "$buildDir/generated/querydsl"
}
 
sourceSets.main.java.srcDir(querydslDir)
 
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
    annotationProcessor 'org.projectlombok:lombok'
 
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
    annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
 
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'jakarta.persistence:jakarta.persistence-api:3.1.0'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
 
tasks.named('compileJava', JavaCompile).configure {
    options.annotationProcessorGeneratedSourcesDirectory = file(querydslDir)
}
 
tasks.named('test') {
    useJUnitPlatform()
}
 
tasks.named('clean') {
    delete querydslDir
}

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
@Configuration
public class QueryDslConfig {
 
    @PersistenceContext
    private EntityManager entityManager;
 
    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}
 

 

QueryDSL을 사용할 때 반드시 JPAQueryFactory를 수동으로 Bean 등록해야 한다.
이 설정은 Spring이 애플리케이션 시작 시 JPAQueryFactory를 Bean으로 등록해준다.

728x90
블로그 이미지

Link2Me

,

Spring Boot 기능을 익히면서 회원 테이블을 생성하고 칼럼을 추가하면서 기능을 테스트하고 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
CREATE TABLE members (
  member_id bigint(20NOT NULL,
  userID varchar(60NOT NULL,
  userNM varchar(30NOT NULL,
  access int(2NOT NULL DEFAULT 1 COMMENT '접속상태',
  access_failed_count int(2NOT NULL DEFAULT 0 COMMENT '로그인실패횟수',
  access_date datetime DEFAULT NULL COMMENT '접속일자 및 시간',
  date date DEFAULT NULL COMMENT '최근접속일자',
  regNO int(5NOT NULL DEFAULT 0 COMMENT '팀서열',
  admin int(2NOT NULL DEFAULT 0 COMMENT '관리자유무',
  passwd varchar(120DEFAULT NULL,
  salt varchar(20DEFAULT NULL,
  email varchar(60DEFAULT NULL,
  org_id int(5NOT NULL DEFAULT 0 COMMENT '조직도ID',
  parent_id int(5NOT NULL DEFAULT 0 COMMENT '조직도 parent_id',
  codeID int(4NOT NULL DEFAULT 0 COMMENT '직위',
  telNO varchar(16DEFAULT NULL COMMENT '유선전화',
  mobileNO varchar(30DEFAULT NULL COMMENT '휴대폰번호',
  workrole varchar(200DEFAULT NULL COMMENT '담당업무',
  chosung varchar(10DEFAULT NULL COMMENT '초성',
  reg_date timestamp NULL DEFAULT current_timestamp() COMMENT '등록일자',
  phoneSE varchar(80DEFAULT NULL COMMENT 'deviceID',
  is_temp_password int(2DEFAULT 0 COMMENT '임시비번할당(1)',
  passwd_change_date datetime DEFAULT NULL COMMENT '비밀번호 변경일자',
  last_login_date datetime DEFAULT NULL COMMENT '마지막성공로그인시간',
  display int(2NOT NULL DEFAULT 1
ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
 
ALTER TABLE members
  ADD PRIMARY KEY (member_id),
  ADD UNIQUE KEY userID (userID) USING BTREE;
 
ALTER TABLE members
  MODIFY member_id bigint(20NOT NULL AUTO_INCREMENT;
COMMIT;
 

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
import jakarta.persistence.*;
import lombok.*;
 
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
 
@Entity
@Table(name = "members")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString(exclude = "roleList")
public class Member {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long memberId;
 
    @Column(name = "userID", nullable = false, unique = truelength = 60)
    private String userID;
 
    @Column(name = "userNM", nullable = falselength = 30)
    private String userNM;
 
    @Column(name = "regNO")
    private int regNO;
 
    @Column(name = "admin")
    private int admin;
 
    @Column(name = "passwd"length = 120)
    private String passwd;
 
    @Column(name = "salt"length = 20)
    private String salt;
 
    @Column(name = "email"length = 60)
    private String email;
 
    @Column(name = "org_id")
    private Integer orgId;
 
    @Column(name = "parent_id")
    private Integer parentId;
 
    @Column(name = "codeID")
    private Integer codeID;
 
    @Column(name = "telNO"length = 16)
    private String telNO;
 
    @Column(name = "mobileNO"length = 30)
    private String mobileNO;
 
    @Column(name = "workrole"length = 200)
    private String workrole;
 
    @Column(name = "access")
    private Integer access;
 
    @Column(name = "chosung"length = 10)
    private String chosung;
 
    @Column(name = "access_failed_count")
    private Integer accessFailedCount;
 
    @Column(name = "access_date")
    private LocalDateTime accessDate;
 
    @Column(name = "reg_date", columnDefinition = "timestamp default current_timestamp")
    private LocalDateTime regDate;
 
    @Column(name = "date")
    private LocalDate date;
 
    @Column(name = "phoneSE"length = 80)
    private String phoneSE;
 
    @Column(name = "is_temp_password")
    private Integer isTempPassword; // 관리자 임시 비밀번호 할당 여부
 
    @Column(name = "passwd_change_date")
    private LocalDateTime passwdChangeDate; // 비밀번호 변경일자
 
    @Column(name = "last_login_date")
    private LocalDateTime lastLoginDate;
 
    @Column(name = "display")
    private Integer display;
 
    @Builder.Default
    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<MemberRoleList> roleList = new ArrayList<>();
 
    public void addRole(MemberRoleList role) {
        this.roleList.add(role);
    }
 
    public void clearRoles() {
        this.roleList.clear();
    }
}
 

 

 

728x90
블로그 이미지

Link2Me

,

mariaDB 를 주로 사용하는데 PostgreSQL 를 설치 및 사용이 필요해서 설치하는 과정을 적은 스크립트를 적어둔다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
################################
##### PostgreSQL 15 버전 설치 #####
################################
# Rocky Linux 9.5에서 MariaDB와 PostgreSQL을 동시에 설치하고 운용하는 것은 완전히 가능
 
# 현재 설치된 PostgreSQL 버전 확인
dnf list installed | grep postgres
 
rpm -qa | grep postgres
 
# PostgreSQL 13 삭제
# sudo dnf remove 명령어로 하나씩 찾아서 삭제한다.
 
# PostgreSQL 13 데이터 디렉터리 제거 (선택 사항)
sudo rm -rf /var/lib/pgsql/13
 
# postgreSQL 15 저장소 추가
sudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm
 
#기존 내장된 PostgreSQL 저장소 비활성화
sudo dnf -qy module disable postgresql
 
# PostgreSQL 15 설치
sudo dnf -update
 
sudo dnf install -y postgresql15-server
 
# PostgreSQL 15 데이터베이스 구성 초기화
sudo postgresql-15-setup initdb
 
# PostgreSQL 15 실행 및 서비스 등록
sudo systemctl enable postgresql-15
sudo systemctl start postgresql-15
 
# PostgreSQL 상태 확인
sudo systemctl status postgresql-15
 
# PostgreSQL 15 확인 및 유지
/usr/pgsql-15/bin/psql --version
 
# PATH에 PostgreSQL 15 바이너리 추가 (권장)
echo 'export PATH=/usr/pgsql-15/bin:$PATH' >> ~/.bash_profile
source ~/.bash_profile
 
psql --version
 
#########################################################
# PostgreSQL 15 암호화 설정
1. postgres 사용자로 전환
sudo --u postgres
 
2. psql 실행
psql
 
3. 비밀번호 설정
\password
 
"postgres" 사용자의 새 암호:
 
# 특정 비밀번호로 직접 지정:
ALTER USER postgres WITH PASSWORD '새비밀번호';
ALTER USER postgres WITH PASSWORD 'Wonderfull!!';
 
4. 종료
\q
 
# 변경 후 PostgreSQL 재시작: ==> 반드시 root 권한으로 접속된 상태에서 실행 가능
sudo systemctl restart postgresql-15
 
#######################################################################
# pgAdmin 4 Web 모드 설치 (Rocky Linux 9.5)
#######################################################################
1. 필수 패키지 설치
sudo dnf install -y yum-utils
 
2. pgAdmin 4 저장소 추가
sudo rpm -i https://ftp.postgresql.org/pub/pgadmin/pgadmin4/yum/pgadmin4-redhat-repo-2-1.noarch.rpm
 
3. 시스템 패키지 목록 업데이트
sudo dnf update -y
 
4. pgAdmin 4 설치
sudo dnf install -y pgadmin4
 
5. Apache 웹서버 시작 및 활성화
sudo systemctl enable httpd
sudo systemctl start httpd
 
6. pgAdmin 4 웹 모드 설정
sudo /usr/pgadmin4/bin/setup-web.sh
 
→ 웹 접속용 이메일/비밀번호 입력
이 명령을 실행하면 관리자 이메일과 비밀번호를 설정하라는 메시지가 나타납니다. 
입력 후 Apache 웹서버가 자동으로 재시작됩니다.
 
7. 방화벽 설정 (HTTP 포트 80 허용)
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --reload
 
8. pgAdmin 4 접속
# 웹 브라우저에서 다음 주소로 접속하세요:
http://<서버 IP 또는 도메인>/
 
#######################################################################
####### 실제 적용 예제 ######
#######################################################################
sudo --u postgres
psql
 
-- DB 생성
create database malldb ENCODING 'UTF8';
\c malldb
 
-- 테이블 생성
CREATE TABLE members (
    member_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    userID VARCHAR(60NOT NULL UNIQUE,
    userNM VARCHAR(30NOT NULL,
    access INTEGER NOT NULL DEFAULT 1,
    access_failed_count INTEGER NOT NULL DEFAULT 0,
    access_date TIMESTAMP NULL,
    date DATE DEFAULT NULL,
    regNO INTEGER NOT NULL DEFAULT 0,
    admin INTEGER NOT NULL DEFAULT 0,
    passwd VARCHAR(120DEFAULT NULL,
    email VARCHAR(60DEFAULT NULL,
    orgId INTEGER NOT NULL DEFAULT 0,
    parent_id INTEGER NOT NULL DEFAULT 0,
    codeID INTEGER NOT NULL DEFAULT 0,
    telNO VARCHAR(16DEFAULT NULL,
    mobileNO VARCHAR(30DEFAULT NULL,
    workrole VARCHAR(200DEFAULT NULL,
    chosung VARCHAR(10DEFAULT NULL,
    reg_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    phoneSE VARCHAR(80DEFAULT NULL,
    display INTEGER NOT NULL DEFAULT 1,
    org_id INTEGER DEFAULT NULL
);
 
-- 주석 설정
COMMENT ON COLUMN members.access IS '접속상태';
COMMENT ON COLUMN members.access_failed_count IS '로그인실패횟수';
COMMENT ON COLUMN members.date IS '최근접속일자';
COMMENT ON COLUMN members.regNO IS '팀서열';
COMMENT ON COLUMN members.admin IS '관리자유무';
COMMENT ON COLUMN members.orgId IS '조직도ID';
COMMENT ON COLUMN members.parent_id IS '조직도 parent_id';
COMMENT ON COLUMN members.codeID IS '직위';
COMMENT ON COLUMN members.telNO IS '유선전화';
COMMENT ON COLUMN members.mobileNO IS '휴대폰번호';
COMMENT ON COLUMN members.workrole IS '담당업무';
COMMENT ON COLUMN members.chosung IS '초성';
COMMENT ON COLUMN members.reg_date IS '등록일자';
COMMENT ON COLUMN members.phoneSE IS 'deviceID';
 
-- 테이블 생성 확인
\dt
 
-- 특정 테이블 구조 보기
\d members
 
-- 해당 DB 접속
\c malldb
 
-- 인덱스 확인
\di members*
 
-- 인덱스 추가 (CREATE INDEX)
CREATE INDEX idx_userid ON members(userID);
 
-- 여러 칼럼 복합 인덱스 
CREATE INDEX idx_userid_orgid ON members(userID, orgId);
 
-- 유니크 인덱스
CREATE UNIQUE INDEX idx_userid_unique ON members(userID);
 
-- 인덱스 삭제 (DROP INDEX)
-- 기본 문법
-- DROP INDEX index_name;
DROP INDEX idx_userid;
 
############################################################################
# 사용자 권한 부여
sudo --u postgres
psql
 
-- 1. 사용자 생성
CREATE USER codefox WITH PASSWORD 'Wonderfull!!';
 
-- 2. 특정 데이터베이스에 대한 모든 권한 부여 (예: malldb)
GRANT ALL PRIVILEGES ON DATABASE malldb TO codefox;
 
-- 3. (선택) 해당 데이터베이스의 모든 테이블, 시퀀스, 함수에 대한 권한 부여
-- DB를 처음 생성한 경우에는 필요 없지만, 이후 객체 생성 시엔 아래도 고려해야 합니다
\c malldb  -- 해당 DB에 접속한 후 실행
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO codefox;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO codefox;
GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO codefox;
 
-- 4. (선택) 앞으로 생성되는 객체에도 권한 자동 부여
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO codefox;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO codefox;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO codefox;
 
-- 5. 빠져나오기
\q
exit
#######################################################################
# PATH 추가 (권장)
echo 'export PATH=/usr/pgsql-15/bin:$PATH' >> ~/.bash_profile
source ~/.bash_profile
 
# DB 백업
sudo --u postgres
pg_dump -U postgres -d malldb -F p -f malldb.sql
 
# 다운로드 받은 파일의 경로
cd /var/lib/pgsql/
 
#######################################################################

 

 

 

 

 

728x90
블로그 이미지

Link2Me

,

명령어를 찾을 수 없어서 개삽질을 한참했다.

build.gralde 에 아래와 같이 추가를 하고 컴파일을 하면 에러가 계속 발생한다.

// P6Spy
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'
implementation 'org.hibernate.orm:hibernate-core:6.2.12.Final'

 

그런데 이걸 Disable 시켜도 동일해서 아래와 같이 해결했다.

 

 

'Modify options' 버튼을 눌러 'Add VM options'를 선택

 

새벽에 잠도 못자고 뭐하는 개짓거리인지 ㅠㅠㅠ

해결 되었는데 잠이 오려나 모르겠네.

728x90

'Spring Boot > IDEA' 카테고리의 다른 글

IntelliJ/IDEA 내 JDK 설정 확인  (1) 2025.06.12
Spring Boot 테스트 Class 자동 생성 단축키  (0) 2025.04.10
Spring Boot Build 하기  (0) 2025.03.08
IntelliJ IDEA Auto Import 설정  (0) 2025.03.01
Build Gradle Update  (0) 2025.02.27
블로그 이미지

Link2Me

,

스프링부트에서 테스트 코드 Class 를 자동으로 생성하는 단축키

 

728x90

'Spring Boot > IDEA' 카테고리의 다른 글

IntelliJ/IDEA 내 JDK 설정 확인  (1) 2025.06.12
--add-opens java.base/java.lang=ALL-UNNAMED  (0) 2025.04.11
Spring Boot Build 하기  (0) 2025.03.08
IntelliJ IDEA Auto Import 설정  (0) 2025.03.01
Build Gradle Update  (0) 2025.02.27
블로그 이미지

Link2Me

,

전체 자료를 csv 파일로 받은 걸 업로드 목적으로 만든 테이블 구조이다.

기존 테이블과 동일한 구조인데 index 부분을 좀 더 추가했다.

총 자료 개수는 1100만개 정도되고, 운영 자료만 450만개 정도 되더라.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
DROP TABLE `LocaldataCSV`;
CREATE TABLE `LocaldataCSV` (
  `idx` int(11NOT NULL COMMENT '번호',
  `opnSvcNm` varchar(200DEFAULT NULL COMMENT '개방서비스명',
  `opnSvcId` char(10NOT NULL COMMENT '개방서비스ID',
  `opnSfTeamCode` char(7NOT NULL COMMENT '개방자치단체코드',
  `mgtNo` varchar(40NOT NULL COMMENT '관리번호',
  `apvPermYmd` varchar(12NOT NULL COMMENT '인허가일자',
  `apvCancelYmd` varchar(12DEFAULT NULL COMMENT '인허가취소일자',
  `trdStateGbn` varchar(5NOT NULL COMMENT '영업상태코드',
  `trdStateNm` varchar(100DEFAULT NULL COMMENT '영업상태명',
  `dtlStateGbn` varchar(4DEFAULT NULL COMMENT '상세영업상태코드',
  `dtlStateNm` varchar(255DEFAULT NULL COMMENT '상세영업상태명',
  `dcbYmd` varchar(12DEFAULT NULL COMMENT '폐업일자',
  `clgStdt` varchar(12DEFAULT NULL COMMENT '휴업시작일자',
  `clgEnddt` varchar(12DEFAULT NULL COMMENT '휴업종료일자',
  `ropnYmd` varchar(12DEFAULT NULL COMMENT '재개업일자',
  `siteTel` varchar(100DEFAULT NULL COMMENT '전화번호',
  `siteArea` varchar(18DEFAULT NULL COMMENT '소재지면적',
  `sitePostNo` varchar(7DEFAULT NULL COMMENT '지번우편번호',
  `siteWhlAddr` varchar(200DEFAULT NULL COMMENT '지번주소',
  `rdnWhlAddr` varchar(200NOT NULL COMMENT '도로명주소',
  `rdnPostNo` varchar(7DEFAULT NULL COMMENT '도로명우편번호',
  `bplcNm` varchar(100NOT NULL COMMENT '사업장명',
  `lastModTs` varchar(20DEFAULT NULL COMMENT '최종수정일자',
  `updateGbn` char(1DEFAULT NULL COMMENT '데이터갱신구분',
  `updateDt` date DEFAULT NULL COMMENT '데이터갱신일자',
  `uptaeNm` varchar(100DEFAULT NULL COMMENT '업태구분명',
  `x` char(20DEFAULT NULL COMMENT '좌표정보(X)',
  `y` char(20DEFAULT NULL COMMENT '좌표정보(Y)',
  `display` tinyint(2NOT NULL DEFAULT 1,
  `region` varchar(10DEFAULT NULL COMMENT '지역'
ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci COMMENT='Local Data Table';
 
ALTER TABLE `LocaldataCSV`
  ADD PRIMARY KEY (`idx`),
  ADD UNIQUE KEY `mgtNo` (`opnSfTeamCode`,`mgtNo`,`opnSvcId`) USING BTREE,
  ADD KEY `bplcNm_rdnWhlAddr` (`bplcNm`,`rdnWhlAddr`,`trdStateGbn`),
  ADD KEY `region` (`region`),
  ADD KEY `apvPermYmd` (`apvPermYmd`),
  ADD KEY `roadAddress` (`rdnWhlAddr`),
  ADD KEY `jiAddress` (`siteWhlAddr`),
  ADD KEY `uptaeNm` (`uptaeNm`);
 
ALTER TABLE `LocaldataCSV`
  MODIFY `idx` int(11NOT NULL AUTO_INCREMENT COMMENT '번호';
COMMIT;
 

 

테이블 구조 SQL 파일

LocaldataCSV.sql
0.00MB

 

이제 전체자료 csv 파일을 서버에 업로드하고 자료를 자동으로 읽어들이는 코드를 구현해야 한다.

파일 개수가 너무 많아서 메모리 걱정도 되고 그렇지만 시도해보려고 한다.

728x90
블로그 이미지

Link2Me

,

네이버 증권 뉴스를 엑셀로 저장하는 코드 예제이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import requests
from bs4 import BeautifulSoup
import pandas as pd
from datetime import datetime  # Importing datetime module
 
today = datetime.now().strftime("%Y-%m-%d")
data = []
for i in range(150):
    url = f"https://finance.naver.com/news/mainnews.naver?date={today}&page={i}"
    headers = {"User-Agent""Mozilla/5.0"}
    response = requests.get(url, headers=headers)
    response.raise_for_status()
    soup = BeautifulSoup(response.text, "html.parser")
 
    articles = soup.select(".block1")
    for article in articles:
        title = article.select_one(".articleSubject > a").text
        link = "https://finance.naver.com" + article.select_one(".articleSubject > a").get("href")
        content = article.select_one(".articleSummary").contents[0].strip()
        press = article.select_one(".press").text.strip()
        date = article.select_one(".wdate").text.strip()
        data.append([title, link, content, press, date])
 
    if soup.select_one(".pgRR"is None:
        break
 
df = pd.DataFrame(data, columns=['제목','링크','내용','언론사','날짜'])
df.to_csv("naver_finance_news.csv", index=False, encoding="utf-8-sig")  # UTF-8 인코딩 설정
 

 

 

 

 

 

728x90
블로그 이미지

Link2Me

,

 

 

개삽질을 해도 우측에 로그인/로그아웃 버튼이 보이도록 하는 것이 안된다.

npm install mdb-react-ui-kit@7.2.0

로 버전을 낮추고 나서야 해결(?)이 되었다.

 

현재 최신버전이 9.0.0 이다. 8.0.0 버전으로 변경만 해도 동일하게 로그인/로그아웃 버튼이 Home 메뉴 아래로 가버린다.

이걸 공개한 회사는 이런 현상이 발생한 것을 모르고 있는 걸까?

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
##################################################################################################
### 프로젝트 생성 및 모듈 설치
##################################################################################################
### 프로젝트 생성 : 대문자는 포함할 수 없다.
npx create-react-app mdb
 
cd mdb
 
# 모듈 설치 ==> mdb가 최신버전을 지원하지 않기 때문에 아래 버전으로 설치 필요
npm install react react-dom
npm install mdb-react-ui-kit@7.2.0
npm install react-router-dom
npm install @fortawesome/fontawesome-free
npm install bootstrap
npm install axios
 
############################################
# RSA 암호화
npm install jsencrypt
 
############################################
### React with Spring Boot API
############################################
# Redux
npm install @reduxjs/toolkit react-redux
npm install react-cookie
 
##################################################################################################
# 기존 모듈 삭제하고 다시 설치
rm -rf node_modules package-lock.json
npm install
 
# GitHub 에 자료 업로드할 때에 node_modules 폴더는 제외시키고 업로드한다.
 
# PowerShell에서는 rm 대신 Remove-Item 명령어를 사용
Remove-Item -Recurse -Force node_modules, package-lock.json
 
 
##################################################################################################
## index.js
##################################################################################################
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import 'mdb-react-ui-kit/dist/css/mdb.min.css';
import '@fortawesome/fontawesome-free/css/all.min.css'// 아이콘용
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.min.js';
 
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
    <App />
);
 

 

 

728x90

'React > React' 카테고리의 다른 글

React 코드 비교를 통한 의미 이해  (0) 2025.03.19
React 배포를 위한 nginx 설정  (0) 2025.03.13
React Naver Map API 사용 샘플  (2) 2022.10.25
React 카카오맵 API 사용 샘플  (0) 2022.10.24
React useMemo & useCallback  (0) 2022.10.24
블로그 이미지

Link2Me

,

localdata.go.kr 에서 제공하는 API 자료를 저장하기 위한 테이블 구조이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
DROP TABLE Localdata;
CREATE TABLE Localdata (
  idx int(11NOT NULL COMMENT '번호',
  opnSfTeamCode char(7NOT NULL COMMENT '개방자치단체코드',
  mgtNo varchar(40NOT NULL COMMENT '관리번호',
  opnSvcId char(10NOT NULL COMMENT '개방서비스ID',
  updateGbn char(1DEFAULT NULL COMMENT '데이터갱신구분',
  apvPermYmd varchar(12NOT NULL COMMENT '인허가일자',
  uptaeNm varchar(100DEFAULT NULL COMMENT '업태구분명',
  opnSvcNm varchar(200DEFAULT NULL COMMENT '개방서비스명',
  bplcNm varchar(100NOT NULL COMMENT '사업장명',
  siteTel varchar(100DEFAULT NULL COMMENT '전화번호',
  sitePostNo varchar(7DEFAULT NULL COMMENT '지번우편번호',
  siteWhlAddr varchar(200DEFAULT NULL COMMENT '지번주소',
  rdnPostNo varchar(7DEFAULT NULL COMMENT '도로명우편번호',
  rdnWhlAddr varchar(200NOT NULL COMMENT '도로명주소',
  siteArea varchar(18DEFAULT NULL COMMENT '소재지면적',
  apvCancelYmd varchar(12DEFAULT NULL COMMENT '인허가취소일자',
  dcbYmd varchar(12DEFAULT NULL COMMENT '폐업일자',
  clgStdt varchar(12DEFAULT NULL COMMENT '휴업시작일자',
  clgEnddt varchar(12DEFAULT NULL COMMENT '휴업종료일자',
  ropnYmd varchar(12DEFAULT NULL COMMENT '재개업일자',
  trdStateGbn varchar(5NOT NULL COMMENT '영업상태코드',
  trdStateNm varchar(100DEFAULT NULL COMMENT '영업상태명',
  dtlStateGbn varchar(4DEFAULT NULL COMMENT '상세영업상태코드',
  dtlStateNm varchar(255DEFAULT NULL COMMENT '상세영업상태명',
  x char(20DEFAULT NULL COMMENT '좌표정보(X)',
  y char(20DEFAULT NULL COMMENT '좌표정보(Y)',
  lastModTs varchar(20DEFAULT NULL COMMENT '최종수정일자',
  updateDt date DEFAULT NULL COMMENT '데이터갱신일자',
  display tinyint(2NOT NULL DEFAULT 1,
  region varchar(10DEFAULT NULL COMMENT '지역'
ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci COMMENT='Local Data Table';
 
ALTER TABLE Localdata
  ADD PRIMARY KEY (idx),
  ADD UNIQUE KEY mgtNo (opnSfTeamCode,mgtNo,opnSvcId) USING BTREE,
  ADD KEY bplcNm_rdnWhlAddr (bplcNm,rdnWhlAddr,trdStateGbn),
  ADD KEY region (region),
  ADD KEY apvPermYmd (apvPermYmd);
 
ALTER TABLE Localdata
  MODIFY idx int(11NOT NULL AUTO_INCREMENT COMMENT '번호'AUTO_INCREMENT=1;
COMMIT;
 

 

엔진은 InnoDB 로 변경해서 사용해도 된다.

 

위 테이블 구조 SQL 파일

Localdata.sql
0.00MB

 

 

https://www.localdata.go.kr/ 사이트 접속해서 확인할 수 있다.

 

 

 

 

테이블 구조에 대한 설명이 나와있고 KEY 구현에 대한 사항도 언급되어 있다.

728x90
블로그 이미지

Link2Me

,