본문 바로가기
PJT

GPT랑 앱 만들기 6 - sandbox로 기능 잡기 + 기획 수정

by 정고정 2025. 4. 26.
반응형

 

일을 이렇게 하면 안 된다. 

회사에서 일을 이렇게 주먹구구식으로 한다고 생각하면 끔찍하다. 

욕은 뒤에서나 좀 먹고 말 거니까 상관 없는데 그냥... 그냥 이런 식으로 멍청하게 일을 진행하면 언젠가는 도태돼서 인사팀의 뒤안길로 사라질 거다. 

요즘은 백세시대니까 힘내야 한다.  

하지만 여긴 회사가 아니니까 괜찮지 않을까?

 

고백하자면 사용자 플로우를 세세하게 생각하기가 너무 싫었다. 

회사에서도 그러는데 집에서도? 문서화도 하고? 

그래서 admin 페이지에 sandbox 페이지를 만들었다. 

 

새하얗다. 

여기에 뭘 할 거냐면 사용자 ui를 짤 거다. 

앱으로 만들겠다면서 왜 여기다 짜냐 이런 생각이 들 수도 있는데, 지금 앱을 빌드하기 시작하면 안 되겠다 싶었다. 

 

1. api 스펙이 수시로 바뀜 

2. 천장 보고 생각해보니까 태그 삭제 기능 같은 게 필요할 것 같은데 자꾸 까먹음 '

3. 앱은 1도 모르겠지만 어드민성 api 연결하면서 리액트랑 shadcn/ui에 익숙해짐 

 

그래서 그냥 냅다 짜서 내가 써보면서 api 스펙을 확정지은 다음에, 서버를 다 마쳐놓고 앱으로 들어가기로 했다. 

단순 작업이나 모르는 부분은 하나라도 제대로 끝내고 들어가는 게 시간 낭비가 덜하니까. 

같은 맥락에서 기본적인 auth도 패스했다. 그건 어차피 다 거기서 거기라 시간만 들이면 다 된다.

데모페이지라고 생각해도 좋을 것 같다. 

 

 

서버

그래서 나는 

version: "3.8"
services:
  postgres:
    image: postgres:15
    container_name: local-tagypie-postgres
    environment:
      POSTGRES_USER: local
      POSTGRES_PASSWORD: local
      POSTGRES_DB: tagypie
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:

 

서버에 도커를 달았다. 더 이상 미룰 수 없다 테스트데이터 생성. 

h2로 버티기에는 그냥 내가 싫었다. 

	//flyway
	implementation("org.flywaydb:flyway-core")

 

flyway도 달아주고 

 

데이터도 넣어줬다. 물론 사용자는 id 0으로만 한 명 넣어줬다. 얘는 이제부터 사용자이자 어드민으로 쓰일 거임. 국룰임

 

 

클라이언트

shadcn/ui에도 data table이 있다. 

 

https://ui.shadcn.com/docs/components/data-table

 

Data Table

Powerful table and datagrids built using TanStack Table.

ui.shadcn.com

 

가장 기본적인 테이블을 인스트럭션대로 가져다 쓸 거다. 

그리고 여기는 badge가 예쁘니까 태그는 badge로 쓸 거다. 

 

shadcn/ui data table에서 신경 쓸 건 딱 두 개다. 

 

1. 넣어 줄 데이터 타입

2. 컬럼 cell 

 

진짜 끝이다. data table component도 하나 더 생성하는 것 같긴 한데 이건 거의 공통이라고 봐도 무방해 보였다.

일단 나는 공통으로 쓸 거다. 이거저거 알아보기 싫어서 일단 익숙해진 걸로 만들어 보는 거니까. 

 

import { ColumnDef } from "@tanstack/react-table"
import { Badge } from "@/components/ui/badge"

//data schema
export type MediaTaggingInfo = {
    id: number
    mediaHash: string
    mediaTitle: string
    mediaAuthor: string
    mediaThumnbail: string
    cpId: number
    tags: TagInfo[]
}
export type TagInfo = {
    id: number
    name: string
}

 

서버에서 받아오는 데이터는 미디어 - tagList 가 매핑된 구조다. 

데이터를 정의해 주고 컬럼 구조를 아래다가 달아 준다. 

// columns
export const columns: ColumnDef<MediaTaggingInfo>[] = [
    {
        accessorKey: "id",
        header: "ID"
    },
    {
        accessorKey: "mediaHash",
        header: "Media Hash"
    },
    {
        accessorKey: "mediaCpId",
        header: "CP"
    },
    {
        accessorKey: "tagList",
        header: "Tags",
        cell: ({ row }) => {
            const tags = row.getValue("tagList") as TagInfo[];
            return (
                <div className="flex flex-wrap gap-2">
                  {tags.map((tag) => (
                    <Badge key={tag.id} variant="secondary" className="text-xs">
                      {tag.name}
                    </Badge>
                  ))}
                </div>
              );
        }
    },
    {
        accessorKey: "mediaTitle",
        header: "Media Title"
    },
    {
        accessorKey: "mediaAuthor",
        header: "Media Author"
    },
    {
        accessorKey: "mediaThumbnail",
        header: "Media Thumbnail",
        cell: ({ row }) => {
            const thumbnailUrl = row.getValue("mediaThumbnail") as string;
            return (
                <img
                  src={thumbnailUrl}
                  alt="YouTube Thumbnail"
                  className="w-24 h-24 object-cover rounded-md"
                />
              );
          },
    },
]

 

tagList의 각 tag.name을 badge로 나타나게 했다. 이 정도 예쁨은 있어야 만질 맛이 날 것 같았다. 

그리고 thumbnail은 애초에 url을 db에 저장해 놓고 있었기 때문에 그냥 image로 뿌려주면 됐다. 

그런데 신기한 건 thumbnail url 포맷이 다 똑같아서 youtube video id 하나만 있으면 thumbnail도 따로 저장할 필요 없겠더라.

그래도 일단 가지고는 있기로 했다. 

 

그리고 이 간단한 작업을 하면서 수정할 게 생겼다. 

뭘 수정해야 할지 보려고 만드는 거였으니까 좋은 일이다. 

 

사용자가 조회 시 여러 태그를 이용할 경우, || 조건으로 쿼리를 쳤었는데 && 조건으로 쳐야겠다는 생각이 들었다. 

그리고 사용자가 아무런 태그도 선택하지 않을 경우 사용자가 태깅한 모든 미디어를 보여 주는 게 로지컬해 보인다. 

그래서 사실 서버도 슬쩍 바꿔 주고 리턴하는 필드 종류도 대폭 늘렸다. 

 

override fun findMediaByUserIdAndTag(userId: Long, tagList: List<Long>): List<MediaTaggingInfoDomainDto> {

    if(tagList.isEmpty()) {
        return buildBaseQuery(userId)
            .fetch()
            .map { result -> mapToDto(result) }
    } else {
        val mediaIdsWithAllTags = queryFactory
            .select(mediaTaggingInfo.mediaId)
            .from(mediaTaggingInfo)
            .where(
                mediaTaggingInfo.userId.eq(userId),
                mediaTaggingInfo.tagId.`in`(tagList)
            )
            .groupBy(mediaTaggingInfo.mediaId)
            .having(mediaTaggingInfo.tagId.countDistinct().eq(tagList.size.toLong()))
            .fetch()

        if (mediaIdsWithAllTags.isEmpty()) return emptyList()

        return buildBaseQuery(userId)
            .where(mediaTaggingInfo.mediaId.`in`(mediaIdsWithAllTags))
            .orderBy(mediaTaggingInfo.tagId.asc(), mediaTaggingInfo.sortOrder.asc())
            .fetch()
            .map(::mapToDto)
    }
}

    private fun buildBaseQuery(userId: Long) = queryFactory
    .select(
        tag.id,
        tag.name,
        media.id,
        media.title,
        media.author,
        media.thumbnail,
        media.mediaHash,
        media.cpId,
        mediaTaggingInfo.sortOrder,
        mediaTaggingInfo.userId,
        mediaTaggingInfo.createdAt,
        mediaTaggingInfo.updatedAt
    )
    .from(mediaTaggingInfo)
    .innerJoin(media).on(mediaTaggingInfo.mediaId.eq(media.id))
    .innerJoin(tag).on(mediaTaggingInfo.tagId.eq(tag.id))
    .where(mediaTaggingInfo.userId.eq(userId))

private fun mapToDto(tuple: com.querydsl.core.Tuple) = MediaTaggingInfoDomainDto(
    tagId = tuple.get(tag.id)!!,
    tagName = tuple.get(tag.name)!!,
    mediaId = tuple.get(media.id)!!,
    mediaTitle = tuple.get(media.title)!!,
    mediaAuthor = tuple.get(media.author)!!,
    mediaThumbnail = tuple.get(media.thumbnail)!!,
    mediaHash = tuple.get(media.mediaHash)!!,
    mediaCpId = tuple.get(media.cpId)!!,
    userId = tuple.get(mediaTaggingInfo.userId)!!,
    sortOrder = tuple.get(mediaTaggingInfo.sortOrder)!!,
    createdAt = tuple.get(mediaTaggingInfo.createdAt)!!,
    updatedAt = tuple.get(mediaTaggingInfo.updatedAt)!!
)

 

진짜 일 이렇게 해도 되나 싶지만 뭐... 괜찮을 거다. 

무지성으로 짠 거라 보기 더럽거나 성능이나 그런 건 나중에 생각하기로 했다. 일단은 사용할 수 있는 수준으로 만드는 게 먼저다.

mapToDto 저렇게 !!로 밀어도 되나... 고치는 건 일단 오늘의 목적에는 부합하지 않는다. 

어차피 api가 20개도 안 되니까 날 잡고 싹 갈아엎으면 된다. 

아 진짜 이런 마인드로 일하면 안 되는데 

 

하지만 이 레포는 나 혼자 쓰는 거고 데드라인도 없으니까 괜찮지 않을까 싶다 그리고 나는 뭔가 만들어내고 싶다는 생각이 들었을 때 빠르게 추진해야 한다. 

아 근데 이런 마인드 진짜 일 할 때 이런 사람 만나면 최악이긴 한데

자중해야겠다 정말로

 

어쨌든 점점 지피티 의존도는 낮아진다. 

머릿속에 있는 걸 얘한테 일일이 설명하는 게 너무 귀찮고 그 시간에 내가 몇 줄 짜는 게 더 빨라서 크로스체크만 조금 하면 된다. 

그런데 다른 게 늘었다. 

코파일럿이다.

 

 

진짜 미친놈 아니가

심지어 리스트를 줘도 지 혼자 알아서 뭘 해 보려고 하는 게 너무 기특하다.

InteliJ에서 서버 짤 때는 내 의도랑 좀 안 맞는 부분이 있어서 참고만 했는데 뭣도 모르는 리액트로 오니까 얘한테 정말 많은 걸 의존하게 된다. 설명을 안 해도 알아서 다 해 준다.  

솔직히 파일 만들고 처음에 몇 개 만들어 놓는 거 이외의 단순 노가다나 매핑 같은 건 다 얘한테 외주 주고 있다. 

코파일럿은 마음을 읽어 준다

초반에 이 정도는 아니었던 것 같은데 올해 뭐지 

심금을 울리는 무언가가 있음

그리고 VSCode에서는 무료임.

 

 

그러면 이렇게 나온다. 

무조건 userId 0인 사용자 대상으로 만든 데모페이지고, 해당 유저의 모든 태그를 선택해 미디어를 조회한 결과다. 

저 위에 Badge라는 이름으로 도로록 붙은 건 나중에 사용자의 모든 태그를 불러와 파싱하는 걸로 저녁에 추가로 작업해야 한다. 

그리고 태그 badge 누르면 해당 태그에 대응하는 미디어 조회하는 걸로 추가 예정임 기억해둬야 해 기억해ㅈ둬야 해 까먹으면 안 돼 

 

 

 

 

반응형

댓글