https://school.programmers.co.kr/learn/courses/30/lessons/42578

 

프로그래머스

SW개발자를 위한 평가, 교육의 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프

programmers.co.kr

 

 

문제 설명

clothes의 각 행이 [의상 이름, 의상 종류]로 주어질 때, 의상의 종류가 겹치지 않게 입을수 있는 경우의 수 구하기

단, 의상의 이름이 겹치는 경우는 없다.

 

내가 생각했던 풀이법

먼저 의상의 종류가 겹치지 않고, 입을수 있는 경우의 수만 구하는 것 이라면 의상의 이름은 중요치 않다.

 

이 문제의 분류가 해시로 되어있었으므로 이를 활용하여 의상종류별로 구별해서 개수를 세면 될 것 같다.

[종류 : 개수] 이러한 꼴로 분류를 하면 될 것 같다.

 

입출력 예 1번을 확인해보면

[headgear : 2] , [eyewear : 1] 로 해시맵을 작성 할 수 있다.

 

이후 내가 생각한 방식은

1. 모두 1번씩은 입을수 있다 -> 2 + 1 = 3

2. 중복되지 않은 옷끼리 한번씩 더 입을수 있다 -> 2 * 1 = 2

3. 이를 모두 합하면 3+2 = 5 가 되므로 정답은 5이다.

 

하지만 이 방식에는 오류가 있는데 만약 종류가 3종류 이상 늘어난다면 계산이 매우 복잡해진다는 문제가 있었다.

또한, 만약 예제 2번처럼 종류가 1개 밖에 없다면 어떻게 처리할 것인가에 대해서도 생각해야한다.

 

그렇다면 비트처럼 계산해보면 되지않을까 해서 생각을 조금 바꿔봤다.

첫번째 예제를 예로 들면 이러한 꼴이 된다.

 

headgear = [off,on,on]

eyewear = [off,on]

 

off는 모두 벗는것을 의미하고 on은 그것 하나만 입는것을 의미한다.

하지만 어느 한부위라도 무조건 입고 있어야 하므로 -1을 해줘야한다.

그래서 (n+1) * (m+1) -1 이라는 식이 나오게 된다.

 

이렇게 한다면 가능한 모든 경우의 수를 계산하고, 모두 벗은 상태를 제거함으로써 정답이 된다.

이것을 java로 구현해보았다.

import java.util.*;

class Solution {
    public int solution(String[][] clothes) {
        int answer = 0;
        String temp = "";
        HashMap<String,Integer> hash = new HashMap<>();
        
        // 1. 옷의 종류별로 분류한다.
        for(int i = 0; i < clothes.length;i++){
            temp = clothes[i][1];
            hash.put(temp,hash.getOrDefault(temp,0)+1);
        }
        
        // 2. 만약 의상 종류가 한개밖에 없다면 해당 의상의 개수를 반환한다.
        if (hash.size() == 1) {
            answer = clothes.length;
        } else {
        // 3. 두개 이상이면 반복문을 실행하여 value값을 가져온다.
            for(Integer value : hash.values()){
                if (answer == 0){
                	// 초기값 할당
                    answer = value + 1;
                }else{
                	// value값에 1을 더하고 곱한 값을 할당
                    answer *= (value + 1);
                }
            }
            // 최종 결과에서 -1
            answer -= 1;
        }
        return answer;
    }
}

 

이번에 미 스틱을 어쩌다가 받게 되었는데 비밀번호가 걸려있어서 사용하지를 못했다.

그래서 인터넷을 찾아보니 리모컨의 OK버튼과 뒤로가기 버튼을 누르면서 부팅하면 공장초기화가 가능하다고 하여서 시도해보았지만 번번히 실패하고 포기하려던 찰나 ADB를 활용해서 초기화 할 수 있음을 알게되었다.

그래서 그 기록을 공유해보자 한다.

 

물론 다른 방법들도 있겠지만 모두 실패하고 여기로 들어왔다는 가정하로 진행할 예정이다.

 

https://youtu.be/5renoEqjxqc

 

해당 유튜버의 영상을 참고하여서 진행하였지만 완벽히 똑같이 따라하지는 않았다.

 

1. ADB를 설치

1-1. ADB는 SDK 플랫폼 도구 출시 노트  |  Android Studio  |  Android Developers 에 들어가서 다운한다

1-2. 압축을 풀고나온 platform-tools의 내용들을 모두 복사하고 C드라이브에 ADB 폴더를 만들어서 붙여넣는다.

 

1-3. windows 키를 눌러서 '시스템 환경 변수 편집' 에 들어간다.

 

1-4. 환경변수 -> 시스템 변수 내의 Path 더블클릭

 

1-5. 아까 파일을 옮겨 놓았던 C:\adb 로 경로 지정해서 추가

 

2. 안드로이드 드라이버 다운로드

Google USB 드라이버 가져오기  |  Android Studio  |  Android Developers

 

다운받은 파일의 압축을 풀고 '장치 관리자'에 들어가서 드라이버 추가를 합니다.

장치 관리자 검색
드라이버 추가 버튼 클릭

 

아까 다운받은 위치를 지정하여 드라이버 설치

 

이 과정을 모두 마치면 미 스틱을 윈도우에서 정상적으로 인식하게 됩니다.

 

3. cmd를 관리자 권한으로 열어서 아래의 명령어를 입력

1. fastboot reboot-bootloader (장치를 연결하기전에 실행)
2. fastboot flashing unlock
3. fastboot reboot
4. fastboot reboot-bootloader (장치를 분리하고 실행 후 다시 연결)
5. fastboot devices
6. fastboot set_active b (또는 fastboot set_active a)
7. fastboot reboot

 

번호와 괄호의 내용은 제외하고 fastboot reboot-bootloader 부분만 붙여넣어서 실행하면 됩니다.

각 단계별로 주의사항을 괄호내에 적어놨으니 주의하여서 실행하면 됩니다.

 

모든 과정을 마치고 나면 미 스틱의 공장초기화가 완료됩니다.

문제 발견

오늘 갑자기 아버지한테 홈페이지가 작동하지 않는다는 연락을 받았다.

그래서 별일 아니겠지 하면서 접속해보았더니 아니나다를까 api 서버가 연결이 안되서 로딩이 안되고 있었다.

 

분명 어제까지도 잘들어가지는 홈페이지였는데 왜 고장났을까 원인을 생각하다가

번쩍 하고 든 생각은 아! certbot으로 설정해두었던 인증서가 만료되어서 접근이 안되는거구나

 

그래도 혹시 몰라 OCI랑 호스팅 사이트에 들어가서 아이피 주소를 대조해보고 상태도 확인해봤지만 역시나 별다른 문제는 없었다.

마지막으로 curl 명령어를 사용하여 HTTPS 인증서를 확인해 보았는데 역시나 인증서가 만료되었다.

curl -v https://api.dreamseekers.kr
curl: (60) SSL certificate problem: certificate has expired
More details here: https://curl.se/docs/sslcerts.html

 

해결

문제를 해결하기 위해 MobaXterm으로 인스턴스에 접속해서 docker에 올라간 cerbot 컨테이너를 재시작해주었다.

 

docker-compose run --rm certbot certbot certonly \
  --webroot --webroot-path=/var/www/html \
  --email [이메일] --agree-tos --no-eff-email \
  -d [도메인]

 

이후에 nginx 컨테이너를 재시작하여 인증서를 적용한다.

docker-compose restart nginx

 

이렇게 발급받은 인증서는 90일간 유효하다. 하지만 90일뒤에 또 이런일을 반복하기엔 귀찮기에 cron을 설정해주었다.

echo '0 0 * * * docker run --rm \
  -v /etc/letsencrypt:/etc/letsencrypt \
  -v /var/www/html:/var/www/html \
  certbot/certbot renew --quiet \
  --deploy-hook "docker exec [nginx컨테이너] nginx -s reload"' | crontab -

 

아마 한동안은 문제가 없겠지만 또 다시 문제가 생긴다면 그때 또 확인해봐야할 것 같다.

 

---- 26.01.06 추가 ----

이번에도 접속이 안되서 확인해봤더니 마찬가지로 인증서 문제였는데

1. 기존 명령어(--deploy-hook "docker exec...")는 Certbot 컨테이너 안에서 또 docker 명령어를 찾으려고 했기 때문에 실행되지 않았습니다.

2. 에러가 발생했을때 로그가 메일로 전송이 안되서 로그 자체가 날아감

 

변경한 cron

1. 호스트(Ubuntu)에서 인증서 갱신을 끝낸 뒤, 이어서 Nginx를 리로드하는 방식이라 확실하게 작동합니다.

2. 경로를 확실하게 지정

3. 뒤에 붙은 >> /home/ubuntu/cert_renew.log 2>&1 덕분에, 나중에 혹시라도 갱신이 안 되면 로그 파일만 열어보고도 무엇이 문제인지 바로 알 수 있습니다.

0 0 1 * * cd /home/ubuntu/Dreamseekers && /usr/bin/docker-compose run --rm certbot certbot renew --quiet && /usr/bin/docker-compose exec -T nginx nginx -s reload >> /home/ubuntu/cert_renew.log 2>&1

 

개요

리액트를 사용해보고 싶다. 새로운 기술을 배우고 싶다. 재미있는 게임을 만들어보고 싶다.

이 세가지의 생각이 합쳐져서 일단 만들어보자 라는 생각으로 시작하게 된 멀티 스네이크 게임 프로젝트.

기존 게임과의 차별점

기본적인 스네이크 게임은 모두 알다시피 사과를 먹을 때 마다 꼬리가 길어지는 뱀을 조종해서 최대한 많은 사과를 먹는 게임이다.

지금까지 내가 경험해본 스네이크 게임은 여러가지 방해물(벽) 이 있거나 무한루프(모서리 통과가능)같은 것 만 있었다.

하지만 여기에 나는 멀티플레이를 추가하여 최종적으로는 1vs1 매치가 되는 스네이크 게임을 만들고 싶어졌다.

 

로그인 없이 간단하게 접속하여 친구들과 1대1 매칭으로 스네이크 게임을 즐길수있다. 정말 멋지지 않은가

여기서 심심하지 않게 여러가지 아이템을 추가해서 더 재밌게 만들고 싶다.

 

예를 들어서

- 시계를 먹으면 나의 뱀의 속도가 느려짐

- 상대방에게 벽을 설치할 수 있음

- 폭탄으로 벽을 제거할 수 있음

- 상대방의 사과 위치를 재설정

 

이런식으로 방해요소 등을 넣으면 더 재밌을 것 같다.

추가로 모바일도 지원하면 더 좋고 방을 생성해서 빠르게 대결한다던지 리더보드를 만들어서 싱글플레이 기록을 기록한다던지...

그러한 느낌으로다가 심심할때 친구들과 할만한 즐거운 게임을 만들어 보고 싶다.

기술 스택

- 프론트 : React

- 백엔드 : node.js (사용예정)

- DB : mongoDB (사용예정)

 

위에 기술된 기술들은 전부 한번도 써본적이 없다. 하지만 그래서 더 즐거울 것 같다.

여기에 추가로 외부 파일들은 최대한 넣지 않고 다양한 라이브러리를 이용해서 화면을 구성하려고 한다.

예를 들어 효과음등은 Tone.js등을 이용해서 소리를 낸다던지.. 그런 방식으로 해보려고 한다.

 

배포에 대해서

배포는 최대한 돈이 안드는 방향으로 하려고 하는데 그 부분에 대해서는 조금 더 생각해봐야 할 것 같다.

최종적으로는 GitAction을 사용한 배포를 고려하고있다.

 

현재 진행상황

리액트를 사용해서 기본적인 게임 로직은 만들었다.

다음은 Tone.js를 이용해서 효과음을 만들고 최종적으로는 일단 혼자서 할수있는 게임을 만드는게 목표다.

그 다음부터 백엔드와 연결해서 멀티플레이 기능을 만드는것을 목표로 하고있다.

 

https://github.com/MSP-31/multiSnake

 

GitHub - MSP-31/multiSnake

Contribute to MSP-31/multiSnake development by creating an account on GitHub.

github.com

 

이번 프로젝트는 싱크라이프라는 회사의 과제로 나온 '스터디룸 예약 시스템'을 주제로 만들었다.

핵심 목표는 '동시성 속에서 무결성 지키기' 로써 중첩 예약 및 시간 겹침 방지를 중점으로 뒀다.

 

핵심 로직

- PostgreSQL의 tstzrange 타입과 EXCLUDE USING gist 제약을 활용을 활용하여 중첩 예약 및 시간 겹침 방지

- GlobalExceptionHandler를 통한 예외 처리

- Spring Security를 활용한 접근 권한 제어

 

1. 중첩 예약 및 시간 겹침 방지 구현

이번 프로젝트의 가장 핵심이 되는 부분으로써 예약 시스템에는 반드시 들어가야하는 조건이다.처음에는 이것을 자주 사용하던 Mysql이나 MariaDB등으로 해결하려고 했으나 그렇게 하면 로직이 쓸데없이 복잡해진다.

 

- 중첩 예약 방지를 위해 이미 예약된 방의 정보를 불러와 비교해야한다.- 시간 겹침을 방지하기 위해서 불러온 정보에서 또 다시 시작시간과 끝 시간을 분리하여 대조해야한다.└> 이러한 과정들은 성능을 저하시키고, 로직을 복잡하게 만들수 있다.

 

그래서 과제에 힌트로 주어진 가장 효율적인 방법으로 PostgreSQLtstzrange 타입과 EXCLUDE USING gist 제약을 활용하기로 하였다.


- tstzrange( timestamp with time zone )시간의 범위를 저장하는 PostgreSQL 타입으로써 시작시간과 끝시간 쌍의 형태로 시간의 범위를 지정한다.

["2025-09-21 06:40:57.954+09","2025-09-21 07:40:57.954+09")

 

- GiST Index( Generalized Search Tree )

모든 종류의 데이터와 검색 방법을 지원할 수 있도록 일반화된 구조의 인덱스로써 유연성과 확장성이 뛰어남

여기서 사용하는 이유는 특정 영역에 겹치는 (시간이 겹치는) 데이터만 분류하기 위해서 사용한다.

└> EXCLUDE USING gist: GiST 인덱스를 사용해서 겹침 금지 제약을 설정.


하지만 Spring JPA에서는 이 둘을 지원하지 않기에 조금 다른 방법을 사용하여 적용하였다.

먼저 엔티티에서는 @Column 어노테이션의 columnDefinition를 활용하여 tstzrange타입임을 명시해주었다.

// JPA에서는 tstzrange 타입을 지원하지 않기 때문에 String으로 처리
@Column(name = "time_range", columnDefinition = "tstzrange")
private String timeRange;

기본적으로 Spring에서는 String 타입으로 처리하되 실제 데이터 타입은 tstzrange가 된다.

 

이후 Repository에서 조회 및 저장을 할때는 @Query 어노테이션을 사용하여 직접 SQL문을 작성해주었다.

이때, nativeQuery = true 옵션을 붙여 실제 DB에서 사용되는 문법임을 명시했다.

/**
     * 예약 시간 범위가 겹치는 모든 예약을 조회합니다.
     * PostgreSQL의 '&&' 연산자를 사용하여 time_range 컬럼과 입력된 범위(tstzrange) 간의 겹침 여부를 검사합니다.
     *
     * @param start 시작 시간
     * @param end   종료 시간
     * @return      예약 리스트 반환
     */
    @Query(value = """
    SELECT * FROM reservation r
    WHERE r.time_range && tstzrange(:startDate, :endDate)""", nativeQuery = true)
    List<Reservation> findByTimeRange(@Param("startDate") ZonedDateTime start,
                                      @Param("endDate") ZonedDateTime end);
 /**
     * 예약 정보를 저장합니다.
     * PostgreSQL의 tstzrange 타입으로 시간 범위를 저장하며, 문자열로 전달된 범위를 CAST하여 삽입합니다.
     * 이후 RETUNING * 을 통하여 Reservation 객체를 반환함
     *
     * @param userId    유저 ID
     * @param roomId    회의실 ID
     * @param timeRange timeRange 형식의 시간 범위
     * @return          예약 정보 반환
     */
    @Query(value = """
    INSERT INTO reservation (user_id, rooms_id, time_range)
    VALUES (:userId, :reservationId, CAST(:timeRange AS tstzrange))
    RETURNING *
    """, nativeQuery = true)
    Reservation saveReservationWithRange(
            @Param("userId") Long userId,
            @Param("reservationId") Long roomId,
            @Param("timeRange") String timeRange
    );

 

여기서 가장 핵심인 중첩 예약 및 시간 겹침 방지 구현을 위하여 마찬가지로 테이블을 생성할 때, 조건을 붙이려고 했으나

JPA에서 해당 조건을 인식하지 못하여서 SQL 파일을 따로 분리하고, 그곳에 테이블 생성 및 제약조건 처리를 하였다.

-- GIST 인덱스 기능을 위해 확장 설치
CREATE EXTENSION IF NOT EXISTS btree_gist;

-- 테이블이 없으면 생성
CREATE TABLE IF NOT EXISTS reservation (
  id SERIAL PRIMARY KEY,
  user_id BIGINT NOT NULL,
  rooms_id BIGINT NOT NULL,
  time_range tstzrange NOT NULL
);

-- JPA에서는 해당 제약 조건을 처리하지 못하기 때문에 SQL문으로 따로 처리
ALTER TABLE reservation
ADD EXCLUDE USING gist (
  rooms_id WITH =,
  time_range WITH &&
);

- CREATE EXTENSION IF NOT EXISTS btree_gist;

PostgreSQL에서 데이터베이스에서 Gist 인덱스의 기능을 확장하기 위해 사용하였다.

Gist를 B-tree처럼 일반적인 데이터 타입(숫자, 문자열, 날짜 등)에도 사용할 수 있도록 기능을 추가한다.

여기서는 EXCLUDE 제약 조건을 정의하기 위해서 사용되었다.


해당 SQL 파일은 프로젝트가 실행되고 엔티티로 테이블이 생성되기 전에 실행된다.

하지만 reservation 테이블이 아직 생성되지 않았는데 제약 조건을 걸어버리는 문제가 생겨 여기서 테이블을 한번 더 정의하였다.

이후, EXCLUDE USING gist  제약조건을 걸어서  중첩 예약 및 시간 겹침을 방지하였다.


완성된 프로젝트는 아래에서 확인할 수 있다.

대부분의 코드에 주석을 달아놨기에 이해하는데는 큰 무리가 없을것이다.

 

GitHub - MSP-31/studyroom

Contribute to MSP-31/studyroom development by creating an account on GitHub.

github.com

 

조건

1. 유저의 정보는 최소한으로 받되, DB에 기록을 남기지 말아야함
2. 필요한 유저 정보는 아래와 같다
 - 전화번호
 - 이메일 주소
3. 유저는 자신이 보낸 문의가 언제 어떤내용으로 조회가 가능해야한다.
4. 반복적인 요청을 보내는 악성 유저를 차단해야한다.


방안
- 회원가입이 필요없이 필드 폼을 만든다 그리고 그 안에는 아래의 내용들이 순서대로 들어간다.
 - 회원의 이메일 주소
 - 회원이 연락받을 전화번호
 - 강의 요청하는 내용
 - (로봇확인)

 

워크플로우
-> 유저
  1. 유저는 강의문의 페이지에 접속하여 필드폼을 채워 제출을 누른다.
  2. 서버에서는 제출된 폼을 미리 정의된 이메일로 전송한다 -> 관리자에게로
  3. 유저가 입력한 이메일 주소로 유저가 전송한 문의 내역을 정리해서 다시금 보내준다.

-> 관리자
  1. 관리자는 유저가 전송한 문의 내역을 확인할 수 있다.
  2. 이후에 관리자는 해당 메일에 적힌 전화번호 또는 이메일로 회신하며 소통한다.

장점
- 사이트에서는 유저와 관리자간의 소통을 중개만 하기에 개인정보 유출등의 보안적인 측면에서 유리하다.
- 백엔드에 불필요한 계정 정보를 남기지 않기에 보안적으로 유리하다.

단점
- 유저는 문의내역을 확인하기 위해서 자신이 이용하는 메일 서비스를 통해 열람해야한다
 -> 오히려 한곳에서 모두 확인가능하고 이후의 문의는 사이트를 통해 접속하지 않아도 되기에 편리하다.

추가사항
- 플로팅 버튼에 해당 강의 문의 버튼을 집어넣어서 접근성을 향상시켜야한다.

스프링에서 검색기능을 구현하는데에는 여러가지 방법이 있지만 나는 그 중 2가지 방법을 소개하려고 한다.

 

1. @query

필터링할 항목이 적을때는 간단하게 @query 어노테이션을 사용해서 처리할 수 있다.

 

예를 들어서 어떤 팀에서 해당 유저가 리더인지 확인해야 한다면

/**
 * 해당 팀의 리더 여부 확인
 * @param teamNo 팀번호
 * @param userNo 유저번호
 * @return Boolean
 */
@Query("SELECT tu.isLeader " +
        "FROM TeamUser tu " +
        "JOIN User u ON tu.user.no = u.no " +
        "WHERE tu.team.no = :teamNo AND u.no = :userNo")
Boolean isLeader(@Param("teamNo") Long teamNo, @Param("userNo") Long userNo);

 

@Query로 sql 쿼리문을 작성해서 비교하고,

아래에는 해당 쿼리문에 들어갈 인자값을 @Param 으로 넘겨주고 결과를 반환한다.

 

여기 이 코드에서는 TeamUser테이블과 User 테이블을 유저 번호로 조인하고,

TeamUser에 있는 teamNo로 같은 팀을 찾고, userNo로 해당 유저의 정보를 조회한다.

그리고 그 테이블의 isLeader를 찾아 반환하는 코드이다.

 

이런식으로 조금더 상세하게 조회를 해야한다면 @Query 어노테이션을 사용하기도 한다.

 

하지만 조건이 더 복잡하고 인자값이 더 많다면 어떻게 될까?

 

/**
 * 모든 팀 정보 조회
 * @param userNo (필터) 유저번호
 * @param pageable 페이징 크기
 * @return TeamSearchDto
 */
@Query("SELECT new com.beyond.backend.domain.team.dto.TeamResponseDto" +
        "(t.no, t.teamName, t.teamIntroduce, t.projectStatus) " +
        "FROM Team t " +
        "JOIN TeamUser tu ON t.no = tu.team.no " +
        "WHERE (:userNo IS NULL OR tu.user.no = :userNo) " +
        "AND (:teamName IS NULL OR t.teamName LIKE %:teamName%) " +
        "AND (:teamIntroduce IS NULL OR t.teamIntroduce LIKE %:teamIntroduce%) " +
        "AND (:projectStatus IS NULL OR t.projectStatus = :projectStatus) " +
        "GROUP BY t.no")
Page<TeamResponseDto> findByUserNoForUserTeams(
        @Param("userNo") Long userNo,
        @Param("teamName") String teamName,
        @Param("teamIntroduce") String teamIntroduce,
        @Param("projectStatus") ProjectStatus projectStatus,
        Pageable pageable);

 

보기만 해도 복잡한 이 코드는 모든 팀의 정보를 조회하는 코드이다.

Page를 사용하여 페이징 처리를 하였고, 인자값을 받아오되 만약 값이 Null 이라면 (빈 값이라면)  해당 조건은 건너띈다.

 

동작하는데에는 전혀 문제가 없지만 가독성도 떨어지고 많이 복잡하다.

이렇게 많은 양의 조건을 붙여야하는 복잡한 동적 쿼리문을 처리하기엔 비효율적이다.

 

2. Querydsl

Querydsl은 타입 안전한(Type-Safe) 쿼리를 생성할 수 있도록 도와주는 오픈 소스 프레임워크로서

자바 코드를 이용해서 데이터베이스 쿼리 (SQL, JPQL 등)를 작성할 수 있게 해주는 "빌더" 역할을 한다.


쉽게 말해, 우리가 보통 SQL이나 JPQL 쿼리를 문자열로 작성하는데,

이 문자열 쿼리는 오타가 나거나 필드명이 바뀌어도 컴파일 시점에는 오류를 알 수 없고 런타임에 가서야 오류가 발생한다.

Querydsl은 이런 문제점을 해결하기 위해 나왔다.

 

하지만 초기에 설정해야 하는 부분들이 조금 까다로워서 그 부분에서 약간 애를 먹었다.

 

Querydsl의 주요 특징 및 장점

1. 타입 안전성 (Type-Safety)

  • 가장 큰 장점으로서 Querydsl은 컴파일 시점에 엔티티(Entity)를 기반으로 Q-Class라는 것을 자동으로 생성합니다. 이 Q-Class는 엔티티의 필드들을 정적인 타입으로 가지고 있어서, 쿼리를 작성할 때 오타나 잘못된 필드명을 사용하면 컴파일 에러가 발생합니다.
  • 이는 런타임 오류를 줄여주고 개발 생산성을 크게 향상시킬 수 있다.

2. 직관적인 쿼리 작성

  • 문자열 쿼리 대신 자바 코드로  쿼리를 작성하므로 IDE의 자동완성 기능을 활용 가능하다.
  • SQL이나 JPQL과 유사한 문법으로 메서드 체이닝 방식을 사용하여 쿼리를 구성하기에 가독성이 좋다.

3 .동적 쿼리 (Dynamic Query) 생성 용이

  • where() 절 등에 조건을 유연하게 추가하여 조건에 따라 쿼리 내용이 달라지는 동적 쿼리 작성이 매우 편리하다.
  • 예를 들어서, 검색 조건이 있을 때만 특정 AND 조건을 추가하는 등의 로직을 깔끔하게 구현 가능 하다.

4 . 다양한 백엔드 지원

  • JPA, JDBC, SQL, MongoDB 등 다양한 데이터 베이스 및 데이터 소스 지원

5. 프로젝션 (Projection) 기능

  • 쿼리 결과를 특정 DTO로 바로 매핑하여 가져올 수 있는 기능을 지원한다.
  • 이를 통해 엔티티 전체를 가져오지 않고 필요한 데이터만 선택적으로 가져올 수 있다.

Querydsl의 동작 방식 (JPA 기준)

1. Q-Class 생성

  • Querydsl을 사용하기 위해 빌드 도구 (Gradle, Maven)에 설정을 추가하면, 컴파일 시점에 엔티티 클래스(예: Member.java)를 분석하여 해당 엔티티에 대한 Q-Class (예: QMember.java)를 자동으로 생성합니다.
    이 Q-Class는 엔티티 필드에 해당하는 정적인 필드들을 가지고 있습니다. (예: QMember.member.name, QMember.member.age).

2. JPAQueryFactory를 이용한 쿼리 작성

  • JPAQueryFactory라는 객체를 생성하여 Querydsl 쿼리를 시작합니다.
    이 팩토리를 통해 Q-Class를 사용하여 select, from, where, join, orderBy 등 SQL과 유사한 메서드를 체인 방식으로 호출하여 쿼리를 구성합니다.

3. 쿼리 실행

  • fetch(), fetchOne(), fetchResults() (deprecated), fetchCount() (deprecated) 등의 메서드를 호출하여 구성된 쿼리를 실행하고 결과를 반환받습니다.

간단한 예시 코드

예를 들어, 사용자가 주문한 상품만 보여주는 페이지에 검색기능과 관리자 여부를 판단해야 하는 코드가 있다고 생각해보자

그 경우 Querydsl은 이렇게 코드를 작성할 수 있다.

 

먼저 조건문을 이렇게 작성할 수 있다.

...
import static com.beyond.homs.company.entity.QCompany.company;
import static com.beyond.homs.order.entity.QOrder.order;
import static com.beyond.homs.product.entity.QProduct.product;
import static com.beyond.homs.order.entity.QOrderItem.orderItem;
import static com.beyond.homs.user.entity.QUser.user;
import static com.beyond.homs.wms.entity.QDeliveryAddress.deliveryAddress;
import static com.beyond.homs.order.entity.QClaim.claim;
...

@Repository
@RequiredArgsConstructor
public class OrderItemRepositoryImpl implements OrderItemRepositoryCustom {
    // Querydsl 쿼리를 생성하는 핵심 클래스
    // 내부적으로 EntityManager를 사용하여 데이터베이스에 접근
    private final JPAQueryFactory queryFactory;

    // 별칭 충돌을 피하기 위해 새로운 QCompany 인스턴스 생성
    // deliveryAddress를 통한 company 조인에 사용할 별칭
    private final QCompany deliveryCompany = new QCompany("deliveryCompany"); // 새로운 별칭

    // 동적 검색 조건 메서드
    private BooleanExpression searchOptions(String keyword, OrderSearchOption option) {
        if (option == OrderSearchOption.ORDER_CODE){
            return order.orderCode.contains(keyword); // 코드 검색
        } else if (option == OrderSearchOption.COMPANY_NAME){
            return company.companyName.contains(keyword); // 회사 검색
        }
        return null; // 일치하는 옵션 없으면 null
    }

    // 사용자 ID 기반 필터링 조건 추가
    private BooleanExpression userEq(Long userId) {
        if (userId == null) {
            return null; // userId가 null이면 모든 주문 조회 (관리자 케이스)
        }
        // 주문을 생성한 user의 ID와 userId가 일치하는 경우
        return order.user.userId.eq(userId);
    }

    // 관리자 역할에 따른 추가 필터링 조건
    private BooleanExpression adminSpecificFilter(boolean isAdmin) {
        if (isAdmin) {
            // 관리자일 경우 deliveryName과 dueDate가 모두 null인 주문은 보지 못하게 필터링
            return order.deliveryAddress.deliveryName.isNotNull().or(order.dueDate.isNotNull());
        }
        return null; // 관리자가 아니면 이 조건은 적용하지 않음
}

 

위에서 작성된 조건문을 아래의 쿼리문에서 사용하여 편리하게 필터링 하고 원하는 값만 가져올 수 있다.

@Override
public Page<OrderResponseDto> findOrders(OrderSearchOption option, String keyword, Long userId, boolean isAdmin, Pageable pageable) {
    List<OrderResponseDto> content = queryFactory // JPAQueryFactory 사용
            // select문 시작
            .select(Projections.constructor(OrderResponseDto.class, // DTO 인트턴스 직접 생성
                    order.orderId,
                    order.orderCode,
                    company.companyName,
                    deliveryAddress.deliveryName,
                    order.orderDate,
                    order.dueDate,
                    order.approved,
                    order.parentOrder.orderId,
                    order.rejectReason,
                    order.orderStatus
            ))
            .from(order)
            .leftJoin(order.user,user)
            .leftJoin(user.company,company)
            .leftJoin(deliveryAddress.company,deliveryCompany)
            .where(
                    searchOptions(keyword, option),  // 동적 검색 조건
                    userEq(userId) ,                 // 사용자 필터링 조건 추가
                    adminSpecificFilter(isAdmin)     // 관리자 필터링 조건 추가
            )
            .orderBy(order.orderDate.desc()) // 정렬
            .offset(pageable.getOffset()) // 페이징 시작 오프셋
            .limit(pageable.getPageSize()) // 페이지 크기
            .fetch(); // 실제 쿼리 실행 및 결과 리스트 반환

    // 총 개수 쿼리
    JPAQuery<Long> totalCount = queryFactory
            .select(order.count()) // COUNT 쿼리
            .from(order)
            .leftJoin(order.user,user)
            .leftJoin(user.company,company)
            .leftJoin(deliveryAddress.company,deliveryCompany)
            .where(
                    searchOptions(keyword, option),
                    userEq(userId),
                    adminSpecificFilter(isAdmin)     // 관리자 필터링 조건 추가
            );

    // Spring Data JPA의 PageableExecutionUtils를 사용하여 Page 객체 생성
    return PageableExecutionUtils.getPage(content,pageable,totalCount::fetchOne);
}

 

이렇게 작성하면 페이징이나 다른 조건문들도 쉽게 추가 가능하다.

코드가 더 길어보이지만 가독성이나 유지보수 측면에서도 더 효율적이라고 생각한다.

 

QuryDsl을 사용하기 전 설정해야 하는 것

Querydsl을 사용하기 위해서는 먼저 여러가지 설정을 해줘야하는데, 여기서는 Gradle 기준으로 설명하겠다.

먼저 Q-Class를 생성해야하는데 이것을 생성하기 위해서는 build.gradle에 아래의 코드를 추가해야한다.

 

- HOMS 프로젝트에서 내가 쓴 방법

// ...

ext {
    querydslVersion = "5.0.0"
}

// ...

dependencies {
    // ...
    // --- Querydsl ---
    implementation "com.querydsl:querydsl-jpa:${querydslVersion}:jakarta"
    annotationProcessor "com.querydsl:querydsl-apt:${querydslVersion}:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
    // ...
}

 

- Gemini가 알려준 방법

plugins {
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10" // <-- 이 플러그인
}

// ...

querydsl { // <-- 이 블록 (플러그인에 의해 제공됨)
    jpa = true 
    querydslSourcesDir = "$buildDir/generated/querydsl" 
}

sourceSets {
    main {
        java {
            srcDirs += [ "$buildDir/generated/querydsl" ] // <-- Q-Class 경로 추가
        }
    }
}

 

가장 큰 차이점은 Q-Class 생성을 위한 Gradle 플러그인의 유무이고 핵심은 annotationProcessor 이다.

 

Gradle의 annotationProcessor 설정은 컴파일 시점에 특정 어노테이션을 처리하고 코드를 생성하는 역할을 합니다.

Querydsl의 querydsl-apt 모듈은 바로 이러한 어노테이션 프로세서입니다.

 

내가 사용한 build.gradle에서는 com.ewerk.gradle.plugins.querydsl 플러그인을 명시적으로 사용하지 않았지만,

다음과 같이 직접 annotationProcessor에 Querydsl의 APT 모듈을 추가했다.

annotationProcessor "com.querydsl:querydsl-apt:${querydslVersion}:jakarta"

 

이것만으로도 Q-Class는 정상적으로 생성된다. 그 이유는 아래와 같다고 한다.

  1. querydsl-apt 모듈의 역할: 이 모듈 자체가 엔티티 클래스에 있는 @Entity 등의 어노테이션을 감지하고, 해당 엔티티에 대한 Q-Class를 자동으로 생성하는 로직을 포함하고 있습니다.
  2. Gradle의 annotationProcessor 기본 동작: Gradle은 annotationProcessor 설정에 포함된 라이브러리들을 빌드 시점에 어노테이션 프로세서로 실행합니다. 따라서 querydsl-apt가 실행되어 Q-Class를 생성하게 됩니다.
  3. 기본 출력 경로querydsl-apt는 특정 Gradle 플러그인이 설정되어 있지 않으면, 기본적으로 Gradle의 빌드 경로(일반적으로 build/generated/sources/annotationProcessor/java/main 또는 build/generated/sources/annotationProcessor/java/main/<패키지 경로>) 아래에 Q-Class를 생성합니다.

그렇다면 플러그인을 사용하는 이유는 무엇일까

  1. 명시적인 Q-Class 경로 지정: querydslSourcesDir을 통해 Q-Class가 생성될 정확한 디렉토리를 개발자가 직접 지정할 수 있습니다. 이는 빌드 스크립트의 가독성을 높이고, Q-Class의 위치를 예측 가능하게 만듭니다. 
  2. sourceSets 자동 추가: Q-Class가 생성될 경로를 sourceSets.main.java.srcDirs에 자동으로 추가해줍니다. 이 플러그인을 사용하지 않으면 이 부분을 수동으로 추가해야 IDE에서 Q-Class를 소스 파일로 인식합니다. 
  3. clean 태스크 통합: clean 태스크에 Q-Class 생성 디렉토리를 포함시켜 빌드 부산물을 깔끔하게 제거할 수 있도록 도와줍니다.
  4. 명확한 의도: 빌드 스크립트만 봐도 "이 프로젝트는 Querydsl을 사용하고 Q-Class를 자동 생성한다"는 의도를 명확하게 알 수 있습니다.

앞으로는 나도 플러그인을 사용해서 설정을 하도록 해야겠다.

 

이렇게 설정을 하고나면 Q-Class가 자동으로 생성이되고 커스텀 리포지토리를 생성해서 사용하게 된다.

 

결론

간단한 검색 쿼리가 필요할때는 @Query 어노테이션을 사용하면 되고,

복잡한 검색 쿼리가 필요할때는 QueryDsl을 사용하는것을 고려해보는게 좋을것 같다.

최종 발표의 마무리.

6월 11일 최종프로젝트를 발표함과 동시에 교육 또한 끝났다.

이번 최종 프로젝트 때에는 9주동안 배운것을 모두 보여주는 자리였기에 정말 열심히 임했다.

 

우리팀은 주문관리 시스템을 주제로 B2B 주문관리시스템을 만들었었다

해당 주문관리시스템은 한화케미컬에서 사용한다고 가정하고 다른 기업들이 이곳에서 물품을 주문하는 시나리오로 구성했다.

최종 프로젝트때 만든 'HOMS' 로그인 화면

 

프로젝트를 진행하면서 가장 어렸웠던 점은 B2B 시스템에 대한 이해였다.

평소에는 B2C 즉, 기업과 소비자간의 거래만 접해봤기에 기업과 기업간의 거래에 대한 경험이 없어 이를 구축하는데 애를 먹었다.

 

다양한 사례와 예시를 찾아보고 여러 경험담들을 들어보며 B2B 주문관리를 짚어보았는데

처음에는 호수인줄 알았는데 그 깊이가 바다만큼 넓고 깊다는것을 알게되었다.

 

그 깊이와 넓이가 끝없는 주제였고 이를 제한하기 위해서 우리는 주문관리에만 집중하기로 했다.

예를 들어 그저 간단히 주문을 한다고 하면 먼저 거래사와의 계약/정산/발주/배송 등을 한번에 관리해야하는데,

 

여기서도 각 분야별로 크기가 크고 프로젝트 기간도 짧기 때문에

딱 주문에만 최대한 집중하고 나머지는 간단하게 이렇게 될것이다 하고 넘기는 식으로 비중을 작게 잡아서 프로젝트를 진행하였다.

 

그 덕에 처음 기획할때 많은 시간이 들었지만 이후에 개발할 때 조금 더 여유롭고 수월하게 진행이 가능했다.

 

피드백과 느낀점

최종적으로 발표에서의 심사평은 실제 기업에서 사용할만한 프로세스를 잘 구성하였지만,

관리자 측면에서 메인페이지의 대시보드 등을 봤을때 한눈에 중요 정보가 들어와야하는데 그런 부분이 미숙하다고 하셨고,

(차트 등에서 보여주는 정보의 중요도가 떨어진다)

이외에 주문하는 부분과 상품 부분을 결합했으면 좋지 않았을까 등의 피드백이 있었다.

 

비록 1등은 하지못했지만 B2B 거래에 대한 프로세스를 이해하는데 많은 도움이 되었고,

사용자의 측면 뿐만 아니라 관리자의 측면에서도 어떤 정보가 필요할지 다시 한번 고민해볼 수 있는 시간이 되었다.

 

또, 이번 프로젝트에서 나는 백엔드나 프론트에 구애받지 않고 모두 개발하며 가장 중요한 주문 부분을 개발하였다.

덕분에 백과 프론트에서 어떤 정보를 원하고 주고받는지에 대해 알수있었다.

 

그리고 각 요소들을 컴포넌트화 하는것에 대한 중요성, 코드의 재활용, 어떻게 하면 같은 동작을 하는 코드를 간결하고 범용성있게 만들수 있을지 등에 대해서 고민하고 적용해볼 수 있는 귀중한 시간이었다.

 

이전에 진행했던 프로젝트를 다시 둘러보면서 내가 이전에 사용했던 방법보다 더 나은 방법을 적용해보기도 하고,

AWS S3를 도입하면서 보안을 위해 S3에 바로 연결하는게 아닌 백엔드를 거쳐서 데이터를 주고받게 하게 한다던지,

정말 많이 배울수 있는 시간이였다.

 

팀원들이 내 코드를 읽고 피드백을 주면서 이 부분에서는 왜 이렇게 한거야? 이 부분에서는 이렇게 개선하는게 좋지 않을까? 하며 코드에 대한 이해도를 높이고, 개선 사항을 이야기 하며 수정하고 보완하면서 더 나은 코드를 작성할 수 있었다.

 

마지막으로 부트캠프 후기

6개월이라는 짧으면 짧고 길면 긴 시간동안 많은 일이 있었고, 정말 고봉밥 처럼 꽉꽉눌러 배울 수 있는 시간이였다.

여기서 나는 기초적인 지식에 더불어 웹 프로그래밍에 대한 전반적인 지식, 다양한 팀 프로젝트, 그리고 자신감을 얻을 수 있었다.

 

또한 항상 쓰던 순수 html을 버리고 vue를 써보는 등 더 다양한 도전도 해볼 수 있었고,

정말 기초부터 시작하므로써 이미 알고있던 내용은 더 확실하게, 이후에는 배웠던것을 활용해서 프로젝트를 해보고

최종적으로는 이전에 배웠던 내용들이 지금은 어떻게 개선이 되었는지 배우면서

 

왜 이걸 쓰는가? 왜 이러한 문법을 쓰는가 등에 대한 조금 더 상세한 지식을 얻을 수 있었다.

물론 6개월이라는 시간안에 더 깊고 상세하게 배울순 없었지만 강사님이 그 부분도 캐치하시고 관련한 수업을 따로 마련하시거나,

이러이러한 식으로 활용되고 이렇게 쓰기도 한다 하면서  키워드를 알려주셔서 이후에 따로 공부할 수 있게 도와주셨다.

 

프로젝트는 총 4번인가 5번정도 진행하는데 각 프로젝트는 이전까지 배운 내용들을 실습하는 느낌에 가까웠고,

최종 프로젝트는 여태까지 배운 모든 지식을 총 동원해서 진행하는 프로젝트였다.

 

중간중간 시험을 통해서 어느정도로 알고있는지 테스트하고 부족한 부분에 대해서는 인프런 강의를 지급해서 보충했다.

매번 해당하는 과목에 대해서는 그에 맞는 교과서를 줬지만 강사님이 잘 정리해줘서 책을 거의 보지는 않았다.

 

또 한달에 한번이상은 취업 특강이나 코테가 준비되어 있어서 정말 유익한 시간이였다고 생각한다.

 

6개월동안 정말 많이 배웠고 얻어가는것도 많았다고 생각한다.

이제 다음 단계는 취업이다. 지금까지 배우고 쌓은걸 잘 정리해서 멋지게 마무리하고 싶다.

프로젝트의 설계의 마무리 단계에 접어들다

저번주부터 계속 작업하던 요구사항 정의서나 기능명세서등을 정리하고 이제 화면설계와 ERD 설계에 들어갔다.

화면설계를 하면서 요구사항이나 기능명세등에 추가사항이 많이 생기기도 했고 그 과정에서 많은 토론을 했다.

 

아직 모두 완성된 것은 아니지만 대략적으로 틀은 점점 잡혀가고 다음주면 확실히 완성하여 본격적인 작업에 들어갈 예정이다.

개인적으로 항상 가장 어려운것은 요구사항 정의나 기능명세, 화면 설계등이 아니라 ERD 설계가 가장 어려운 것 같다.

 

다른 부분들은 작업을 하면서 유동적으로 추가하거나 지울수 있지만 ERD는 그 특성상 그렇게 하기 힘들기 때문에 처음부터 되도록이면 수정할 부분이 없도록 작성을 하기 때문에 더욱 생각할 부분이 많은 것 같다.

 

한가지 걱정되는 부분이 있다면, 프로젝트의 크기에 비해서 기간이 너무나 짧기 때문에 지금까지 정리한 기능들을

모두 구현하는것은 고사하고 프로젝트를 완성할 수 있을지에 대한 걱정이 있다.

 

그러니 더 열심히 참여해서 속도를 올리고 성공적으로 마무리할 수 있도록 해야겠다.

또한, 이번에 프로젝트 명이 HOMS로 정해졌다. 주제가 주문 관리 시스템이기 때문에 재치있는 이름이라고 생각한다.

다음주의 계획

이 다음주에는 황금연휴가 있기때문에 쉬는날이 많기도 하고  이 기간동안 잠시 본가에 내려갈 생각이다.

물론 황금 연휴라고 해서 작업을 안하는게 아니고, 장소만 바꿔서 계속 이어나가야 한다.

 

그래서 다음주 수요일 이전까지 빠르게 ERD와 화면설계서를 마무리하고 개발환경을 세팅한 다음, 집에서 작업을 할 생각이다

그렇게 5월 부터는 실제 개발에 들어가서 6월 이전까지 만족할만한 결과를 내놓는게 목표이다.

강의가 없는 한주의 시작

이번 주 부터는 계속 강의는 없고 대신 모든 시간동안 최종 프로젝트에 집중하고 있다.

이를 위해서 책상의 배치도 팀원들과 항상 회의할수 있도록 서로 마주보게 배치하였다.

 

오전 9시부터 오후 5시 50분까지의가 없는 한주의 시작

이번 주 부터는 계속 강의는 없고 대신 모든 시간동안 최종 프로젝트에 집중하고 있다.

이를 위해서 책상의 배치도 팀원들과 항상 회의할수 있도록 서로 마주보게 배치하였다.

 

최종 프로젝트의 틀을 잡다

일단 프로젝트의 대주제는 '주문관리 시스템' 으로 정하였다.

대신 여기서 일반적인 프로젝트와 다른점은 B2B 주문관리 시스템을 구축하는것으로

한화 솔루션 케미칼 부분에 대한 B2B 주문관리 시스템을 실제와 비슷하게 구축하는것을 목표로 하고있다.

 

물론 우리가 해당 시스템을 사용해본적도 없고 한번도 구축해본적도 없기에 그에 대한 공부를 먼저 하는 시간을 가졌고,

기본적인 주문관리 시스템을 베이스로 해당 소재를 사용하는것 뿐이라고 생각한다.

 

그래서 한 주 동안 여기에 대한 요구사항 정의서나 기능명세서등을 작성하고 추가할 기능등을 조금 더 생각해보았는데,

사실 큰 차별점은 없고 조금 더 편리하게 채팅 기능등을 넣기로 하였다.

 

이번 주 목요일에는 멘토님을 만나서 같이 해당 주제에 대해서 이야기를 했는데

여태까지 이런 주제로 프로젝트를 진행한 조는 우리가 처음이라고도 했고, 좋은 소재지만 좀 지루할수도 있겠다 라고 하셨다.

 

솔직히 내가 생각해도 지루한 주제이긴 하지만 이것만큼 좋은 경험은 또 없을 것이라 생각하기도 하고

다른 팀원들도 그렇게 생각하기에 이 주제 그대로 가져가기로 하였다.

 

회고를 마치며

하루에 대략 7~8시간 동안 계속 마주보고 회의를 하는데 쉽지가 않다.

코딩을 하는것 보다 이렇게 기초틀을 잡는게 더 어렵고 힘든일인 것 같다.

 

하지만 이렇게 기초를 탄탄하게 다져놔야 나중에 편하게 작업할수있기 때문에 열심히 참여하고 있다.

이번 프로젝트도 잘 마무리하고 끝냈으면 좋겠다.

+ Recent posts