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로 설정되어야 합니다.

 

$env:JAVA_HOME="C:\Program Files\OpenLogic\jdk-17.0.14.7-hotspot"
$env:PATH="$env:JAVA_HOME\bin;" + $env:PATH
java -version

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

,

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

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

,

Nginx 설정으로 8080 포트 접근을 차단 설정하면 문제가 발생하더라.

Spring Boot 포트를 8080으로 설정해서 jar 파일을 구동시켰다.

1
2
nohup java -Xshare:off -jar /home/spring/SpringJWT/SpringJWT-0.0.1-SNAPSHOT.jar --server.port=8080 > /var/log/springjwt.log 2>&1 &
 



방화벽 설정을 아래와 같이 하면 https://spb.abc.com:8080/api/pub-key 와 같은 직접적인 접속을 허용하지 않는다.

오로지 Front-End 언어(ex, React)에서 Back-End 를 호출하는 axios 라이브러리를 통한 접속만 허용한다.

1
2
3
4
5
6
7
8
9
10
11
12
ㅇ 현재 방화벽 설정 확인
sudo firewall-cmd --list-all
 
ㅇ 8080 포트를 오직 로컬(Nginx)에서만 접근 가능하도록 설정
# Nginx 프록시 서버만 8080 포트 접근 허용 (예: 127.0.0.1 또는 내부 IP)
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="127.0.0.1" port port="8080" protocol="tcp" accept'
 
# 외부에서 8080 포트 접근 차단
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" port port="8080" protocol="tcp" drop'
 
# 방화벽 재적용
sudo firewall-cmd --reload

 

 

nginx.conf 설정 예제 파일

실제 클라우드 환경에서 동작 테스트한 스크립트이며 도메인만 다른 명칭으로 변경했다.

즉 abc 라고 된 부분만 실제 부분을 변경한 것이다.

rocky linux 9.5 가 openssl 버전을 TLS 1.3 버전을 지원해서 아래와 같이 수정했다.

만약 TLS 1.2 버전을 지원하면 ssl_ciphers 주석 부분을 살리고 이에 맞는 스크립트로 수정해야 한다.

 

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
worker_processes auto;
 
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
 
include /etc/nginx/modules-enabled/*.conf;
 
events {
    worker_connections 1024;
}
 
http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;
 
    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
 
    access_log /var/log/nginx/access.log main;
 
    sendfile on;
    keepalive_timeout 65;
 
    # 시스템 정보 노출 제한
    server_tokens off;
 
    # 디렉토리 검색 방지
    autoindex off;
 
    ssl_protocols TLSv1.3;
    ssl_ecdh_curve X25519:prime256v1:secp384r1;
    #ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;
 
    # Redirect all HTTP traffic to HTTPS for spb.abc.com
    server {
        listen 80;
        server_name spb.abc.com;
        return 301 https://spb.abc.com$request_uri;
    }
 
    # Reverse Proxy for spb.abc.com with React static files
    server {
        listen 443 ssl;
        server_name spb.abc.com;
 
        ssl_certificate /etc/letsencrypt/live/spb.abc.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/spb.abc.com/privkey.pem;
        ssl_trusted_certificate /etc/letsencrypt/live/spb.abc.com/chain.pem;
        include /etc/letsencrypt/options-ssl-nginx.conf;
 
        error_log /var/log/nginx/spb-error.log;
        access_log /var/log/nginx/spb-access.log main;
 
        root /home/react/react-auth/build;
        index index.html;
 
        location / {
            try_files $uri /index.html;
            limit_except GET POST {
                deny all;
            }
        }
 
        location /static/ {
            root /home/react/react-auth/build/static;
            expires 30d;
            add_header Cache-Control "public, max-age=2592000";
        }
 
        location ~* \.(?:js|css|json|ico|png|jpg|jpeg|svg|woff|woff2|ttf|otf|eot|mp4|webm|ogv|ogg|mp3|wav|webp)$ {
            expires max;
            access_log off;
            add_header Cache-Control "public";
        }
 
    location ^~ /api {
        proxy_pass https://spb.abc.com:8080/api;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
 
    location ^~ /login {
        proxy_pass https://spb.abc.com:8080/login;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
    }
 
    include /etc/nginx/conf.d/*.conf;
}
 

 

 

아래 내용은 chatGPT 도움을 받은 내용을 기록해 둔다.

 

location ^~ /api {
    proxy_pass https://spb.abc.com:8080/api;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

이 설정은 Nginx가 /api 경로로 들어오는 요청을 https://spb.abc.com:8080/api로 프록시(Proxy)하는 역할을 한다.
즉, Front-End 가 https://spb.abc.com/api로 요청을 보내면, 해당 요청이 https://spb.abc.com:8080/api로 전달된다.

location ^~ /api {
    /api로 시작하는 모든 요청을 처리한다.
    ^~ : 해당 경로가 요청 URL과 일치하면 더 이상 다른 location 블록을 검사하지 않음 (우선순위 높음).

proxy_pass https://spb.abc.com:8080/api;
    Nginx가 /api 요청을 https://spb.abc.com:8080/api로 전달한다.
    예를 들어:

    React 요청:  https://spb.abc.com/api/users
    Nginx가 전달하는 요청: https://spb.abc.com:8080/api/users

    proxy_pass는 백엔드 서버(8080번 포트에서 실행 중인 API 서버)로 요청을 전달하는 역할을 한다.

# proxy_set_header 설정
proxy_set_header Host $host;
    원본 요청의 Host 헤더를 유지한다.
    기본적으로 proxy_pass를 사용하면 Nginx는 원래 요청의 Host 헤더를 변경할 수 있다.
    Host 헤더를 유지하면 백엔드 서버에서 도메인 정보를 유지한 채 요청을 처리할 수 있음.


proxy_set_header X-Real-IP $remote_addr;
    클라이언트(사용자)의 실제 IP 주소를 백엔드 서버로 전달한다.
    예를 들어:

    클라이언트 IP: 123.45.67.89
    백엔드 서버에서 보는 IP: 123.45.67.89

    Nginx가 백엔드 서버를 대신 호출하기 때문에, 백엔드에서는 보통 클라이언트의 IP를 127.0.0.1로 인식할 수 있다.
    이 설정이 없으면 백엔드 서버는 모든 요청이 Nginx에서 온 것으로 인식할 수 있음.


proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    X-Forwarded-For 헤더는 클라이언트의 IP 주소를 보존하는 역할을 한다.
    예를 들어, 사용자가 여러 프록시를 거쳐 요청하는 경우:

    X-Forwarded-For: 123.45.67.89, 98.76.54.32, 192.168.1.10

        123.45.67.89 → 실제 클라이언트 IP
        98.76.54.32 → 중간 프록시
        192.168.1.10 → 마지막 프록시(Nginx)
    백엔드에서 로그를 분석할 때 실제 클라이언트의 IP를 확인할 수 있음.


proxy_set_header X-Forwarded-Proto $scheme;
    클라이언트가 요청할 때 사용한 프로토콜(HTTP 또는 HTTPS)을 백엔드 서버에 전달한다.
    예를 들어:

    클라이언트가 https://spb.abc.com/api 요청 → X-Forwarded-Proto: https
    클라이언트가 http://spb.abc.com/api 요청 → X-Forwarded-Proto: http

    백엔드에서 HTTPS 요청을 식별할 때 유용하다.

 

 

1
2
3
4
5
6
7
location /gis {
    proxy_pass http://192.168.123.91:18080;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}
 

 

location /gis {
    /gis로 시작하는 모든 요청을 처리한다.
    예를 들어:

    클라이언트 요청:  http://spb.abc.com/gis/map
    Nginx가 전달하는 요청: http://192.168.123.91:18080/map
    /gis는 프록시 서버에서 제거되므로, 백엔드 서버(192.168.123.91:18080)는 /map으로 요청을 받음.


proxy_pass http://192.168.123.91:18080;
    백엔드 서버(192.168.123.91:18080)로 요청을 전달한다.
    예제:
    클라이언트 요청: http://spb.abc.com/gis/data
    Nginx가 전달하는 요청: http://192.168.123.91:18080/data
    /gis 경로는 자동으로 제거되며, 백엔드 서버가 그대로 요청을 처리할 수 있도록 설정됨.


만약 /gis를 유지해야 한다면, 다음처럼 수정 필요
proxy_pass http://192.168.123.91:18080/gis;

이렇게 하면 백엔드 서버에서 /gis/data 형태로 요청을 받게 된다.


proxy_http_version 1.1;
    백엔드와의 통신에서 HTTP/1.1을 사용하도록 설정.
    기본적으로 Nginx는 HTTP/1.0을 사용할 수 있음.
    HTTP/1.1을 사용하면 WebSocket과 같은 기능을 지원할 수 있음.


proxy_set_header Host $host;
    원본 요청의 Host 헤더를 유지하여 백엔드 서버로 전달한다.
    예를 들어:
    클라이언트 요청 Host: spb.abc.com
    백엔드 서버에서 보는 Host: spb.abc.com

    백엔드가 요청을 받을 때 Nginx의 IP 대신 원본 도메인 정보를 유지할 수 있음.


proxy_set_header X-Real-IP $remote_addr;
    클라이언트의 실제 IP를 백엔드 서버에 전달한다.
    기본적으로 Nginx가 백엔드에 요청을 전달할 때 자신(Nginx)의 IP로 요청을 보내기 때문에, 
    백엔드 서버에서는 클라이언트의 원래 IP를 확인할 수 없음.
    이 설정을 추가하면 백엔드가 원래 클라이언트의 IP를 알 수 있음.

728x90
블로그 이미지

Link2Me

,

implementation 'org.springframework.boot:spring-boot-starter-web' 라이브러리는 Back-end 단에서 필요한 의존성입니다. React는 Front-end 라이브러리로 JavaScript 기반에서 동작하며, 서버와의 통신은 보통 HTTP 요청(REST API) 또는 WebSocket을 사용합니다. 이 의존성은 Spring Boot에서 HTTP 요청을 처리하거나 RESTful API를 제공하기 위해 필요한 설정과 구성 요소(Web 관련 모듈 포함)를 포함하고 있습니다.

의존성의 역할

  • spring-boot-starter-web 의존성을 추가하면 다음과 같은 기능을 사용할 수 있습니다:
    1. Spring MVC: 컨트롤러(endpoint)를 정의하기 위한 기능.
    2. 내장 톰캣 서버: 애플리케이션을 실행하기 위한 웹 서버.
    3. Jackson 라이브러리 포함: JSON 형식의 요청/응답 바인딩.
    4. 기타 Spring Web 관련 설정 및 모듈.

따라서 React가 Front-end로 쓰이고, Back-end를 Spring Boot로 구현한다면 API 서버를 구축하기 위해 반드시 필요합니다. React와 Spring Boot는 별개이지만, React에서 만들어진 요청을 Spring Boot가 처리하려면 spring-boot-starter-web 의존성은 필수입니다.

728x90
블로그 이미지

Link2Me

,

Rocky 9.5 리눅스 환경에서 스프링부트 실행을 위해 준비한 스크립트이다.

 

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
##############################################
######### RockeyOS 9.5 Spring Boot 설치 ######
##############################################
# 스크립트는 반드시 관리자 권한에서 실행해야 한다.
# openssl 버전 확인 => TLS 1.3 지원
openssl version
 
dnf -y install expat-devel
 
# 시스템 업데이트
sudo dnf -y update
 
sudo dnf -y install wget unzip mc git nmap curl
 
# SSL 관련 패키지 설치 확인
sudo dnf -y install openssl openssl-devel
 
####################################################################################
# 방화벽 데몬 시작
systemctl start firewalld
 
# 서버 부팅 시 firewalld 데몬 자동 시작 설정
systemctl enable firewalld
 
firewall-cmd --permanent --add-service=http 
firewall-cmd --permanent --add-service=https
firewall-cmd --permanent --add-service=mysql
firewall-cmd --permanent --zone=public --add-port=3306/tcp
 
firewall-cmd --permanent --zone=public --add-port=8080/tcp
firewall-cmd --permanent --zone=public --add-port=3000/tcp
firewall-cmd --reload
firewall-cmd --list-all
 
####################################################################################
# tomcat 배포 사전 조건
# 1. JAVA가 설치되어 있어야 한다. (톰캣 9는 자바 11 이상이 필요)
# jar 파일은 내부 톰캣이 있기에 따로 톰캣을 설치하지 않아도 된다. 
####################################################################################
# 1. 기본 패키지 업데이트
sudo dnf update -y
 
# 2. Java 17 버전 설치
sudo dnf install -y java-17-openjdk java-17-openjdk-devel
java -version
 
# 3. Java 환경 변수 설정
echo "export JAVA_HOME=/usr/lib/jvm/java-17-openjdk" | sudo tee -/etc/profile.d/java.sh
echo "export PATH=\$JAVA_HOME/bin:\$PATH" | sudo tee -/etc/profile.d/java.sh
source /etc/profile.d/java.sh
 
# 여러개 설치되어 있는 자바 버전 선택하기
sudo alternatives --config java
 
 
# 4. Spring Boot 실행
# 아래 명령어를 수행하여 실행되는 메시지를 직접 육안으로 확인한다.
java -jar <jar파일명>
java -Xshare:off -jar /home/spring/SpringJWT/SpringJWT-0.0.1-SNAPSHOT.jar
 
# 5. Spring Boot 애플리케이션 실행
# 애플리케이션 실행 (백그라운드)
# 노트북에서 빌드(build)한 파일을 리눅스 서버로 업로드하고 아래와 같이 실행한다.
 
nohup java -Xshare:off -jar /home/spring/SpringJWT/SpringJWT-0.0.1-SNAPSHOT.jar --server.port=8080 > /var/log/springjwt.log 2>&1 &
 
 
# 필요한 Apache 모듈 활성화
sudo systemctl restart httpd
 
####################################################################################
# 프로세스 확인
ps aux | grep SpringJWT
 
# 프로세스 종료 (필요 시)
kill $(ps aux | grep 'SpringJWT' | awk '{print $2}')
 
# 종료 프로세스 작성
vi /usr/local/bin/kill_8080.sh
 
#!/bin/bash
 
# 8080 포트 사용 중인 프로세스 확인
PID=$(lsof -i :8080 -t)
 
if [ -"$PID" ]; then
    echo "8080 포트에서 실행 중인 프로세스(PID: $PID) 종료 중..."
    kill $PID
    sleep 2
 
    # 프로세스가 종료되지 않았으면 강제 종료
    if ps -p $PID > /dev/null; then
        echo "⚠️ 정상 종료되지 않아 강제 종료 진행..."
        kill -9 $PID
    fi
 
    echo "8080 포트 프로세스 종료 완료."
else
    echo "ℹ️ 8080 포트에서 실행 중인 프로세스가 없습니다."
fi
 
# 저장(wq)하고 빠져나온다.
 
# 실행권한 부여
sudo chmod +/usr/local/bin/kill_8080.sh
 
# 스크립트 실행
sudo /usr/local/bin/kill_8080.sh
 
####################################################################################
# systemd 서비스 등록 (서버 부팅 후 자동 실행)
vi /etc/systemd/system/springjwt.service
 
[Unit]
Description=Spring Boot Application - SpringJWT
After=network.target
 
[Service]
User=apache
Group=apache
ExecStart=/usr/bin/java -Xshare:off -jar /home/spring/SpringJWT/SpringJWT-0.0.1-SNAPSHOT.jar
SuccessExitStatus=143
Restart=always
RestartSec=5
StandardOutput=file:/var/log/springjwt.log
StandardError=file:/var/log/springjwt.log
 
[Install]
WantedBy=multi-user.target
 
#저장(wq)하고 빠져나온다.
 
# systemd 데몬 리로드
sudo systemctl daemon-reload
 
# 서비스 등록 및 실행
sudo systemctl enable springjwt
sudo systemctl start springjwt
 
# 실행 상태 확인
sudo systemctl status springjwt
 
# 서비스 중지 (필요 시)
sudo systemctl stop springjwt
 
# 실행 로그 확인
cat /var/log/springjwt.log
 
####################################################################################
 

 

 

728x90
블로그 이미지

Link2Me

,

React 에서 axios 를 통해서 백엔드에서 키를 호출하는 방법에 대해 기록한다.

 

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
import React from "react";
import {useState, useEffect, useRef} from "react";
import {Link, useNavigate} from "react-router-dom";
import axios from "axios";
import { JSEncrypt } from "jsencrypt"// npm install jsencrypt
 
const Signup = () => {
    //const baseUrl = process.env.REACT_APP_API_BASE_URL || "http://localhost:8080";
    const baseUrl = "https://spb.abc.com:8080";
 
    const navigate = useNavigate();
    const initData = {
        userID: '',
        password: ''
    }
 
    const [publicKey, setPublicKey] = useState("");
    const [sessionId, setSessionId] = useState(""); // 세션 ID 저장
    const isFetched = useRef(false); // 실행 여부 체크
 
    useEffect(() => {
        if (isFetched.current) return
        isFetched.current = true
 
        console.log("🔹 공개키 요청 실행됨");
        // 백엔드에서 공개키 가져오기
        axios.get(baseUrl + "/api/public-key")
            .then(response => {
                console.log("🔹 공개키 응답:", response.data);
                setPublicKey(response.data.publicKey);
                setSessionId(response.data.sessionId); // 세션 ID 저장
            })
            .catch(error => console.error("공개키 가져오기 실패", error));
    }, []);
 
    const encrypt = new JSEncrypt(); // Start our encryptor.
    encrypt.setPublicKey(publicKey); // Assign our encryptor to utilize the public key.
 
    const [formData, setFormData] = useState(initData);
    const [users, setUsers] = useState([]);
    // 첫번째 원소 : 현재 상태, 두번재 원소 : 상태를 바꾸어 주는 함수
    const [error, setError] = useState(null);
 
    const onChangeInput = (e) => {
        setFormData({
            ...formData,
            [e.target.name]: e.target.value
        });
        // e.target.name 은 해당 input의 name을 가리킨다.
        //console.log(formData);
    }
 
    const submitForm = (e) => {
        e.preventDefault();
        
        if (!sessionId) {
            console.error("세션 ID가 없습니다. 공개키를 다시 요청하세요.");
            setError(true);
            return;
        }
 
        const params = {
            username: formData.userID,
            password: encrypt.encrypt(formData.password), // 비밀번호 암호화
            //password: formData.password, 
            sessionId: sessionId // 세션 ID 포함
        }
        console.log(params);
        axios.post(baseUrl + '/login', params)
            .then((res) => {
                console.log(res);
                if (res.status === 200) {
                    console.log(res.data);
                    localStorage.setItem("accessToken", res.data.token);
                    axios.defaults.headers.common["Authorization"= "Bearer " + res.data.token;
                    navigate('/');
                } else {
                    console.log(res.data.message);
                    setError(true);
                }
            })
            .catch(error => {
                console.log(error.response)
            });
    }
 
    return (
        <div className="container h-100 mt-5">
            <div className="row d-flex justify-content-center align-items-center h-100">
                <div className="col-12 col-md-9 col-lg-7 col-xl-6">
                    <div className="card">
                        <div className="card-body p-5">
                            <h2 className="text-uppercase text-center mb-5">로그인</h2>
                            <form onSubmit={submitForm}>
                                <div className="form-outline mb-4">
                                    <label className="form-label" htmlFor="userID">userID</label>
                                    <input type="text" name="userID" onChange={onChangeInput} id="userID"
                                            className="form-control form-control-lg" value={formData.userID}
                                            required/>
                                </div>
 
                                <div className="form-outline mb-4">
                                    <label className="form-label" htmlFor="password">Password</label>
                                    <input type="password" name="password" onChange={onChangeInput} id="password"
                                            className="form-control form-control-lg" value={formData.password}
                                            required/>
                                </div>
 
                                { error &&
                                <div className="alert alert-danger" role="alert">
                                    로그인 정보를 다시 한번 확인하세요!
                                </div>
                                }
 
                                <div className="d-flex justify-content-center">
                                    <button type="submit"
                                            className="btn btn-primary btn-block btn-lg gradient-custom-3 text-body">로그인
                                    </button>
                                </div>
 
                                <p className="text-center text-muted mt-5 mb-0">
                                    <Link to="/register" className="fw-bold text-body"><u>회원가입</u></Link>
                                </p>
 
                            </form>
 
                        </div>
                    </div>
                </div>
            </div>
        </div>
    )
}
 
export default Signup

 

 

백엔드 : 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
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
package com.example.springjwt.config;
 
 
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 
@Configuration
public class CorsMvcConfig implements WebMvcConfigurer {
 
    @Override
    public void addCorsMappings(CorsRegistry corsRegistry) {
        corsRegistry.addMapping("/**")
                .allowedOrigins(
                        "https://spb.abc.com",
                        "http://localhost:3000"
                ) // 정확한 출처 지정
                .allowedMethods("GET""POST""PUT""DELETE""OPTIONS"// 허용할 메서드 명확히 설정
                .allowCredentials(true// 인증 정보 포함 허용
                .allowedHeaders("Authorization""Content-Type""Cache-Control");
    }
}
 
/**
 * 이 설정을 유지하려면 SecurityConfig.java에서 .cors() 설정을 제거해야 한다.
 * (권장) WebMvcConfigurer를 삭제하고, SecurityConfig.java에서 CORS를 관리하는 것이 좋다.
 */
 
package com.example.springjwt.config;
 
import com.example.springjwt.jwt.CustomLogoutFilter;
import com.example.springjwt.jwt.JWTFilter;
import com.example.springjwt.jwt.JWTUtil;
import com.example.springjwt.jwt.LoginFilter;
import com.example.springjwt.repository.RefreshRepository;
import com.example.springjwt.service.RSAService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
 
import java.util.Arrays;
import java.util.Collections;
 
@Configuration
@EnableWebSecurity
public class SecurityConfig {
 
    // AuthenticationManager 가 인자로 받을 AuthenticationConfiguraion 객체 생성자 주입
    private final AuthenticationConfiguration authenticationConfiguration;
    private final JWTUtil jwtUtil;
    private final RefreshRepository refreshRepository;
    private final RSAService rsaService;
 
    public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JWTUtil jwtUtil, RefreshRepository refreshRepository, RSAService rsaService) {
 
        this.authenticationConfiguration = authenticationConfiguration;
        this.jwtUtil = jwtUtil;
        this.refreshRepository = refreshRepository;
        this.rsaService = rsaService;
    }
 
    //AuthenticationManager Bean 등록
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
 
        return configuration.getAuthenticationManager();
    }
 
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
 
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
 
        http
                .cors((corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
 
                    @Override
                    public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
 
                        CorsConfiguration configuration = new CorsConfiguration();
                        configuration.setAllowedOriginPatterns(Arrays.asList(
                                "https://spb.abc.com",
                                "http://localhost:3000"
                        )); // allowedOrigins("*")을 사용하지 않고, 구체적인 출처를 명시
                        configuration.setAllowCredentials(true);
 
                        // allowedMethods("*") 대신 GET, POST, PUT, DELETE, OPTIONS를 명확하게 설정
                        // configuration.setAllowedMethods(Collections.singletonList("*"));
                        configuration.setAllowedMethods(Arrays.asList("GET""POST""PUT""DELETE""OPTIONS"));
 
                        // allowedHeaders("*") 대신 Authorization, Content-Type, Cache-Control을 지정
                        // configuration.setAllowedHeaders(Collections.singletonList("*"));
                        configuration.setAllowedHeaders(Arrays.asList("Authorization""Content-Type""Cache-Control"));
 
                        configuration.setExposedHeaders(Collections.singletonList("Authorization"));
 
                        configuration.setMaxAge(3600L);
 
                        return configuration;
                    }
                })));
 
        //csrf disable
        http
                .csrf((auth) -> auth.disable());
 
        //From 로그인 방식 disable
        http
                .formLogin((auth) -> auth.disable());
 
        //http basic 인증 방식 disable
        http
                .httpBasic((auth) -> auth.disable());
 
 
        //경로별 인가 작업
        http
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/api/join""/login""/""/api/public-key""/api/pub-key").permitAll()
                        .requestMatchers("/admin").hasRole("ADMIN")
                        .requestMatchers("/reissue").permitAll()
                        .anyRequest().authenticated());
 
        http
                .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);
 
        http
                .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil, refreshRepository, rsaService), UsernamePasswordAuthenticationFilter.class);
 
        http
                .addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository), LogoutFilter.class);
        
        //세션 설정
        http
                .sessionManagement((session) -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS));
 
        http
                .csrf((auth) -> auth.disable());
 
        return http.build();
    }
}
 

 

 

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
package com.example.springjwt.controller;
 
import com.example.springjwt.dto.JoinDTO;
import com.example.springjwt.service.JoinService;
import com.example.springjwt.service.RSAService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
 
import java.util.Map;
import java.util.UUID;
 
@RestController
@RequestMapping("/api")
public class AuthController {
 
    private final RSAService rsaService;
    private final JoinService joinService;
 
    @Autowired
    public AuthController(RSAService rsaService, JoinService joinService) {
        this.rsaService = rsaService;
        this.joinService = joinService;
    }
 
    /**
     * 회원 가입용 공개키
     */
//    @GetMapping("/pub-key")
//    public Map<String, String> getPubKey() {
//        return Map.of("publicKey", rsaService.getPubKey());
//    }
 
    @GetMapping("/pub-key")
    public Map<StringString> getPubKey(HttpServletRequest request) {
        String clientIp = getClientIp(request);
        int clientPort = request.getRemotePort(); // ✅ 클라이언트 포트 가져오기
        System.out.println("📌 클라이언트 IP: " + clientIp + " | 포트: " + clientPort);
        return Map.of("publicKey", rsaService.getPubKey());
    }
 
    private String getClientIp(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }
 
 
    /**
     * 로그인 용 공개키
     */
    @GetMapping("/public-key")
    public Map<StringString> getPublicKey() {
        String sessionId = UUID.randomUUID().toString(); // 클라이언트마다 새로운 세션 ID 생성
        String publicKey = rsaService.generateNewPublicKey(sessionId);
        return Map.of("sessionId", sessionId, "publicKey", publicKey);
    }
 
 
    @PostMapping("/join")
    public Map<StringString> joinProcess(@RequestBody JoinDTO joinDTO) {
        joinService.joinProcess(joinDTO, rsaService.getPriKey());
        return Map.of("message""회원가입 성공");
    }
 
}
 
 

 

 

728x90
블로그 이미지

Link2Me

,

Windows 11 노트북에서 접속할 때는 인증서가 필요없이도 잘 접속이 되는데 도메인 URL을 통해 접속하는 리눅스 서버에서 443 포트로 접속하는 통신을 하려면 인증서가 필요하다.

 

# SSL 인증서 로컬 시스템에 등록 (Rocky 9)
PKCS12 (Public Key Cryptograhic Standards)의 줄임말로 패스워드로 보호된 형식으로써, 여러 인증서 및 키를 포함할 수 있다. Java뿐만 아니라 여러 플랫폼에서 사용 가능하다.
Letsencrypt 에서 얻는 키를 이용하여 생성하면 된다.

# PKC12생성하기 : Java KeyStore (keystore.p12)로 변환
SSL 인증서를 Spring Boot에 추가하여 HTTPS를 8080 포트에서 활성화하려면, 다음 단계를 따라 Java KeyStore (JKS) 또는 PKCS12 포맷으로 변환하고 설정해야 한다.


openssl pkcs12 -export -in cert1.pem -inkey privkey1.pem -out keystore.p12 -name tomcat -password pass:changeit

 


-export: PKCS12 파일을 생성
-in cert1.pem  → 공개 인증서 파일
-inkey privkey1.pem 개인 키 파일
-out keystore.p12 변환된 인증서 저장 위치
-name tomcat 별칭(alias) 설정
-password pass:changeit  비밀번호 설정 (Spring Boot 설정에서 동일하게 사용해야 함)

생성된 인증서를 위의 3번과 같이 Windows 11 환경의 IntelliJ IDEA 구동한 

spring boot 프로젝트의 resources 폴더에 넣고 build 해서 생성된 파일을 리눅스 시스템에 업로드하면 된다.

 

 

 

728x90
블로그 이미지

Link2Me

,