Profile-Server는 플랫폼 내 사용자 프로필의 생명주기를 전담 관리하는 마이크로서비스이다. 음악 커뮤니티 플랫폼에서 사용자의 장르, 악기 선호도 등 프로필 정보를 관리한다.
| 기능 | 설명 |
|---|---|
| 프로필 생성 | Kafka 이벤트 수신 시 자동 프로필 초기화 |
| 프로필 조회 | 단일/배치/검색 조회 지원 |
| 프로필 수정 | 닉네임, 소개, 장르, 악기 등 정보 변경 |
| 복합 검색 | 지역, 장르, 악기, 성별 기반 다차원 검색 |
| 속성 관리 | 장르/악기 다중 선택 관리 (최대 3개) |
| 이력 추적 | 프로필 변경 이력 자동 기록 |
| 닉네임 검증 | 유일성 및 형식 유효성 검증 |
| 구분 | 기술 |
|---|---|
| Framework | Spring Boot 3.5.5 |
| Language | Java 21 (Eclipse Temurin) |
| Database | MariaDB 10.6+ |
| Query | QueryDSL 5.0.0 |
| Message Broker | Apache Kafka |
| Cache | Redis (설정됨, 비활성) |
| Documentation | Swagger/OpenAPI 3.0 |
flowchart TB
subgraph Client
APP[Mobile App]
WEB[Web Client]
end
subgraph API_Gateway
GW[API Gateway]
end
subgraph Profile_Service
PROFILE[Profile Server]
MARIA[(MariaDB)]
REDIS[(Redis)]
end
subgraph Messaging
KAFKA[Kafka Cluster]
end
subgraph Upstream_Services
AUTH[Auth Server]
IMAGE[Image Server]
end
subgraph Downstream_Services
CHAT[Chat Server]
NOTI[Notification Server]
end
APP --> GW
WEB --> GW
GW --> PROFILE
PROFILE --> MARIA
PROFILE --> REDIS
PROFILE --> KAFKA
AUTH -->|user-created| KAFKA
IMAGE -->|profile-image-changed| KAFKA
KAFKA -->|user-nickname-changed| CHAT
KAFKA -->|user-nickname-changed| NOTI
flowchart TB
subgraph Presentation["Presentation Layer"]
SEARCH_CTRL[ProfileSearchController]
UPDATE_CTRL[ProfileUpdateController]
ENUM_CTRL[EnumsController]
VALID_CTRL[NicknameValidator]
end
subgraph Application["Application Layer (CQRS)"]
subgraph Command["Command Side"]
CREATE_SVC[UserInfoLifeCycleService]
UPDATE_SVC[ProfileUpdateService]
end
subgraph Query["Query Side"]
SEARCH_SVC[ProfileSearchService]
end
end
subgraph Domain["Domain Layer"]
USER[UserInfo - Aggregate Root]
HIST[History]
GENRE[UserGenres]
INST[UserInstruments]
end
subgraph Infrastructure["Infrastructure Layer"]
REPO[JPA Repositories]
DSL[QueryDSL Implementation]
KAFKA_PUB[EventPublisher]
KAFKA_CON[EventConsumer]
end
SEARCH_CTRL --> SEARCH_SVC
UPDATE_CTRL --> UPDATE_SVC
SEARCH_SVC --> DSL
UPDATE_SVC --> REPO
CREATE_SVC --> REPO
KAFKA_CON --> CREATE_SVC
UPDATE_SVC --> KAFKA_PUB
sequenceDiagram
participant AUTH as Auth Server
participant K as Kafka
participant PS as Profile Server
participant DB as MariaDB
AUTH ->> K: user-created 이벤트 발행
K ->> PS: 이벤트 수신
PS ->> PS: ProfileCreateRequest 파싱
PS ->> PS: 닉네임 자동 생성
PS ->> DB: UserInfo 저장
PS ->> DB: 기본 속성 초기화
PS -->> K: 프로필 생성 완료
sequenceDiagram
participant C as Client
participant GW as API Gateway
participant PS as Profile Server
participant DB as MariaDB
participant CACHE as In-Memory Cache
C ->> GW: GET /api/v1/profiles?city=SEOUL&genres=1,2
GW ->> PS: 검색 요청 전달
PS ->> CACHE: 참조 데이터 조회 (장르, 악기, 지역)
CACHE -->> PS: 캐시된 데이터 반환
PS ->> DB: QueryDSL 동적 쿼리 실행
DB -->> PS: 검색 결과 반환
PS ->> PS: UserResponse 변환
PS -->> GW: Slice<UserResponse>
GW -->> C: 검색 결과 (커서 기반 페이징)
flowchart TD
A[프로필 수정 요청] --> B{입력값 검증}
B -->|실패| Z1[400 Bad Request]
B -->|성공| C{사용자 존재 확인}
C -->|없음| Z2[404 Not Found]
C -->|존재| D{닉네임 변경?}
D -->|Yes| E{닉네임 중복 확인}
E -->|중복| Z3[409 Conflict]
E -->|유일| F[프로필 필드 업데이트]
D -->|No| F
F --> G[속성 업데이트 - 장르/악기]
G --> H[변경 이력 저장]
H --> I{닉네임 변경됨?}
I -->|Yes| J[Kafka 이벤트 발행]
I -->|No| K[수정 완료 응답]
J --> K
sequenceDiagram
participant C as Client
participant PS as Profile Server
participant DB as MariaDB
C ->> PS: POST /api/v1/profiles/batch
Note over C,PS: userIds: ["id1", "id2", "id3"]
PS ->> DB: IN 쿼리로 일괄 조회
DB -->> PS: List<UserInfo>
PS ->> DB: 배치 초기화 - 장르 컬렉션
PS ->> DB: 배치 초기화 - 악기 컬렉션
Note over PS: N+1 문제 방지
PS ->> PS: 응답 변환
alt detail=true
PS -->> C: List<UserResponse>
else detail=false
PS -->> C: List<BatchUserSummaryResponse>
end
erDiagram
UserInfo ||--o{ UserGenres : has
UserInfo ||--o{ UserInstruments : has
UserInfo ||--o{ History : tracks
UserGenres }o--|| GenreNameTable : references
UserInstruments }o--|| InstrumentNameTable : references
UserInfo }o--o| LocationNameTable : references
UserInfo {
string userId PK "Snowflake ID"
string nickname UK "2-15자"
string profileImageUrl
char sex "M/F/O"
string city FK
string introduction
boolean isPublic
boolean isChatable
int version "낙관적 락"
datetime createdAt
datetime updatedAt
}
UserGenres {
string userId PK_FK
int genreId PK_FK
int version
}
UserInstruments {
string userId PK_FK
int instrumentId PK_FK
int version
}
History {
string historyId PK
string userId FK
string fieldName
string oldVal
string newVal
datetime updatedAt
}
GenreNameTable {
int genreId PK
string genreName
int version
}
InstrumentNameTable {
int instrumentId PK
string instrumentName
int version
}
LocationNameTable {
string cityId PK
string cityName
}
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| user_id | VARCHAR(255) | Y | Snowflake ID (PK) |
| nickname | VARCHAR(50) | Y | 닉네임 (UNIQUE, 2-15자) |
| profile_image_url | VARCHAR(500) | N | 프로필 이미지 URL |
| sex | CHAR(1) | N | 성별 (M/F/O) |
| city | VARCHAR(50) | N | 지역 코드 |
| introduction | VARCHAR(500) | N | 자기 소개 |
| is_public | BOOLEAN | Y | 프로필 공개 여부 (기본: true) |
| is_chatable | BOOLEAN | Y | 채팅 가능 여부 (기본: true) |
| version | INT | Y | Optimistic Lock |
| created_at | DATETIME(6) | Y | 생성 시간 |
| last_updated_at | DATETIME(6) | N | 수정 시간 |
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| genre_id | INT | Y | 장르 ID (PK) |
| genre_name | VARCHAR(50) | Y | 장르명 |
| version | INT | N | 버전 |
초기 데이터 예시:
| ID | 장르명 |
|---|---|
| 1 | ROCK |
| 2 | JAZZ |
| 3 | CLASSICAL |
| 4 | POP |
| 5 | BLUES |
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| instrument_id | INT | Y | 악기 ID (PK) |
| instrument_name | VARCHAR(50) | Y | 악기명 |
| version | INT | N | 버전 |
초기 데이터 예시:
| ID | 악기명 |
|---|---|
| 1 | GUITAR |
| 2 | PIANO |
| 3 | DRUMS |
| 4 | BASS |
| 5 | VIOLIN |
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| user_id | VARCHAR(255) | Y | FK to UserInfo (복합 PK) |
| genre_id | INT | Y | FK to GenreNameTable (복합 PK) |
| version | INT | N | Optimistic Lock |
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| user_id | VARCHAR(255) | Y | FK to UserInfo (복합 PK) |
| instrument_id | INT | Y | FK to InstrumentNameTable (복합 PK) |
| version | INT | N | Optimistic Lock |
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| history_id | VARCHAR(255) | Y | Snowflake ID (PK) |
| user_id | VARCHAR(255) | Y | FK to UserInfo |
| field_name | VARCHAR(50) | Y | 변경된 필드명 |
| old_val | VARCHAR(500) | N | 변경 전 값 |
| new_val | VARCHAR(500) | Y | 변경 후 값 |
| updated_at | DATETIME(6) | Y | 변경 시각 |
GET /api/v1/profiles/{userId}
Response
{
"userId": "1234567890123456789",
"nickname": "musiclover",
"profileImageUrl": "https://cdn.example.com/profiles/user123.jpg",
"city": "SEOUL",
"sex": "M",
"introduction": "기타리스트, 재즈와 블루스를 좋아합니다",
"genres": [
{"id": 2, "name": "JAZZ"},
{"id": 5, "name": "BLUES"}
],
"instruments": [
{"id": 1, "name": "GUITAR"},
{"id": 2, "name": "PIANO"}
],
"isPublic": true,
"isChatable": true,
"createdAt": "2025-01-01T10:00:00Z",
"updatedAt": "2025-01-20T15:30:00Z"
}상태 코드
| 코드 | 설명 |
|---|---|
| 200 | 조회 성공 |
| 404 | 사용자를 찾을 수 없음 |
GET /api/v1/profiles
Query Parameters
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
| city | String | N | null | 지역 코드 필터 |
| nickName | String | N | null | 닉네임 검색 (부분 일치) |
| genres | List | N | null | 장르 ID 목록 |
| instruments | List | N | null | 악기 ID 목록 |
| sex | String | N | null | 성별 필터 (M/F/O) |
| cursor | String | N | null | 페이징 커서 (userId) |
| size | Integer | N | 10 | 페이지 크기 (최대 100) |
Response
{
"content": [
{
"userId": "1234567890123456789",
"nickname": "musiclover",
"profileImageUrl": "https://cdn.example.com/...",
"city": "SEOUL",
"genres": ["JAZZ", "BLUES"],
"instruments": ["GUITAR"]
}
],
"hasNext": true,
"nextCursor": "1234567890123456788"
}POST /api/v1/profiles/batch
Query Parameters
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
| detail | Boolean | N | false | 상세 정보 포함 여부 |
Request Body
["userId1", "userId2", "userId3"]Response (detail=true)
[
{
"userId": "userId1",
"nickname": "user1",
"profileImageUrl": "...",
"genres": [...],
"instruments": [...]
}
]Response (detail=false)
[
{
"userId": "userId1",
"nickname": "user1",
"profileImageUrl": "..."
}
]PUT /api/v1/profiles/{userId}
Request
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| nickname | String | N | 새 닉네임 (2-15자, 영문/한글/숫자/_) |
| introduction | String | N | 자기 소개 |
| city | String | N | 지역 코드 |
| sex | String | N | 성별 (M/F/O) |
| genres | List | N | 장르 ID 목록 (최대 3개) |
| instruments | List | N | 악기 ID 목록 (최대 3개) |
| isPublic | Boolean | N | 프로필 공개 여부 |
| isChatable | Boolean | N | 채팅 가능 여부 |
Request Example
{
"nickname": "newNickname",
"introduction": "안녕하세요, 음악을 사랑합니다",
"city": "BUSAN",
"genres": [1, 3, 5],
"instruments": [2, 4],
"isPublic": true,
"isChatable": false
}Response
{
"success": true
}상태 코드
| 코드 | 설명 |
|---|---|
| 200 | 수정 성공 |
| 400 | 유효성 검증 실패 |
| 404 | 사용자를 찾을 수 없음 |
| 409 | 닉네임 중복 |
GET /api/v1/profiles/genres
Response
{
"1": "ROCK",
"2": "JAZZ",
"3": "CLASSICAL",
"4": "POP",
"5": "BLUES"
}GET /api/v1/profiles/instruments
Response
{
"1": "GUITAR",
"2": "PIANO",
"3": "DRUMS",
"4": "BASS",
"5": "VIOLIN"
}GET /api/v1/profiles/locations
Response
{
"SEOUL": "서울",
"BUSAN": "부산",
"INCHEON": "인천",
"DAEGU": "대구"
}GET /api/v1/profiles/validate
Query Parameters
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| type | String | Y | "nickname" |
| value | String | Y | 검증할 닉네임 |
Response
{
"valid": true
}검증 규칙
- 길이: 2-15자
- 허용 문자: 영문, 한글, 숫자, 언더스코어(_)
- 정규식:
^[a-zA-Z0-9가-힣_]{2,15}$ - 중복 불가
GET /health
Response
OK
| Topic | Producer | Consumer | 설명 |
|---|---|---|---|
| user-created | Auth Server | Profile Server | 회원가입 완료, 프로필 생성 트리거 |
| profile-image-changed | Image Server | Profile Server | 프로필 이미지 변경 |
| user-deleted | Auth Server | Profile Server | 회원 탈퇴, 프로필 삭제 |
| user-nickname-changed | Profile Server | Chat, Notification | 닉네임 변경 브로드캐스트 |
- Group ID:
profile-consumer-group - Auto Offset Reset:
earliest
{
"eventId": "evt-uuid-1234",
"eventType": "USER_CREATED",
"timestamp": "2025-01-15T10:00:00Z",
"payload": {
"userId": "1234567890123456789",
"provider": "SYSTEM"
}
}{
"eventId": "evt-uuid-2345",
"eventType": "PROFILE_IMAGE_CHANGED",
"timestamp": "2025-01-15T11:00:00Z",
"payload": {
"userId": "1234567890123456789",
"imageUrl": "https://cdn.example.com/new-image.jpg"
}
}{
"eventId": "evt-uuid-3456",
"eventType": "USER_NICKNAME_CHANGED",
"timestamp": "2025-01-15T12:00:00Z",
"payload": {
"userId": "1234567890123456789",
"oldNickname": "oldName",
"newNickname": "newName"
}
}{
"eventId": "evt-uuid-4567",
"eventType": "USER_DELETED",
"timestamp": "2025-01-15T13:00:00Z",
"payload": {
"userId": "1234567890123456789"
}
}| 규칙 | 설명 |
|---|---|
| 길이 | 2자 이상 15자 이하 |
| 허용 문자 | 영문(대소문자), 한글, 숫자, 언더스코어(_) |
| 유일성 | 전체 시스템에서 유일해야 함 |
| 자동 생성 | 프로필 생성 시 임의 닉네임 자동 부여 |
| 규칙 | 장르 | 악기 |
|---|---|---|
| 최대 개수 | 3개 | 3개 |
| 최소 개수 | 0개 | 0개 |
| 중복 선택 | 불가 | 불가 |
| 유효성 | ID가 마스터 테이블에 존재해야 함 | ID가 마스터 테이블에 존재해야 함 |
| 설정 | 기본값 | 설명 |
|---|---|---|
| isPublic | true | false면 검색 결과에서 제외 |
| isChatable | true | false면 채팅 요청 불가 |
| 규칙 | 설명 |
|---|---|
| 트리거 | Auth Server의 user-created 이벤트 |
| 닉네임 | 자동 생성 (user_ + 랜덤) |
| 기본값 | isPublic=true, isChatable=true |
| 장르/악기 | 빈 목록으로 초기화 |
flowchart LR
subgraph Application
SEARCH[ProfileSearchService]
UPDATE[ProfileUpdateService]
INIT[InitTableMapper]
end
subgraph In_Memory_Cache
GENRE_CACHE[GenreCache]
INST_CACHE[InstrumentCache]
LOC_CACHE[LocationCache]
end
subgraph Database
DB[(MariaDB)]
end
INIT -->|@PostConstruct| DB
DB -->|Load| GENRE_CACHE
DB -->|Load| INST_CACHE
DB -->|Load| LOC_CACHE
SEARCH -->|Read| GENRE_CACHE
SEARCH -->|Read| INST_CACHE
UPDATE -->|Validate| GENRE_CACHE
UPDATE -->|Validate| INST_CACHE
| 캐시 | 초기화 시점 | 갱신 주기 |
|---|---|---|
| GenreCache | 애플리케이션 시작 | 매일 06:00 |
| InstrumentCache | 애플리케이션 시작 | 매일 06:00 |
| LocationCache | 애플리케이션 시작 | 매일 06:00 |
- 설정됨:
spring.data.redis.repositories.enabled: false - 호스트/포트: 환경변수로 설정 가능
- 용도: 향후 쿼리 결과 캐싱용으로 예약
-- 닉네임 유일성 및 검색
CREATE UNIQUE INDEX idx_user_nickname ON user_info (nickname);
-- 지역 기반 검색
CREATE INDEX idx_user_city ON user_info (city);
-- 공개 프로필 + 지역 복합 검색
CREATE INDEX idx_user_public_city ON user_info (is_public, city);
-- 생성일 기준 정렬
CREATE INDEX idx_user_created ON user_info (created_at DESC);-- 사용자별 장르 조회
CREATE INDEX idx_genres_user ON user_genres (user_id);
-- 복합 키 인덱스
CREATE INDEX idx_genres_composite ON user_genres (user_id, genre_id);-- 사용자별 악기 조회
CREATE INDEX idx_instruments_user ON user_instruments (user_id);
-- 복합 키 인덱스
CREATE INDEX idx_instruments_composite ON user_instruments (user_id, instrument_id);-- 사용자별 이력 조회 (최신순)
CREATE INDEX idx_history_user_date ON profile_update_history (user_id, updated_at DESC);| 코드 | HTTP Status | 설명 |
|---|---|---|
| PROFILE_001 | 409 | 닉네임이 이미 존재함 |
| PROFILE_002 | 500 | 이력 업데이트 실패 |
| PROFILE_003 | 400 | 장르 개수 초과 (최대 3개) |
| PROFILE_004 | 400 | 장르 ID와 이름 동시 지정 불가 |
| PROFILE_005 | 400 | 악기 개수 초과 (최대 3개) |
| PROFILE_006 | 400 | 악기 ID와 이름 동시 지정 불가 |
| PROFILE_007 | 404 | 사용자를 찾을 수 없음 |
| PROFILE_008 | 400 | 닉네임 형식 오류 |
| PROFILE_009 | 400 | 장르 ID 또는 이름이 유효하지 않음 |
| PROFILE_010 | 400 | 지역 ID와 이름 동시 지정 불가 |
{
"timestamp": "2025-01-15T10:30:00Z",
"status": 400,
"code": "PROFILE_003",
"message": "장르는 최대 3개까지 선택 가능합니다",
"path": "/api/v1/profiles/1234567890123456789"
}# Database
DATABASE_HOST=localhost
DATABASE_PORT=3306
DATABASE_NAME=profiles
DATABASE_USER_NAME=profile_user
DATABASE_PASSWORD=your_password
# Kafka
KAFKA_URL1=localhost:9092
KAFKA_URL2=localhost:9093
KAFKA_URL3=localhost:9094
# Redis (선택)
REDIS_HOST=localhost
REDIS_PORT=6379
# Spring Profile
SPRING_PROFILES_ACTIVE=devserver:
port: 8080
spring:
datasource:
url: jdbc:mariadb://${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}
username: ${DATABASE_USER_NAME}
password: ${DATABASE_PASSWORD}
driver-class-name: org.mariadb.jdbc.Driver
jpa:
hibernate:
ddl-auto: none
show-sql: true
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.MariaDBDialect
kafka:
bootstrap-servers:
- ${KAFKA_URL1}
- ${KAFKA_URL2}
- ${KAFKA_URL3}
producer:
retries: 3
batch-size: 16384
consumer:
group-id: profile-consumer-group
auto-offset-reset: earliest
listener:
ack-mode: record
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}
repositories:
enabled: false
sql:
init:
mode: always
validation:
nickname:
regex: "^[a-zA-Z0-9가-힣_]{2,15}$"
attribute:
genre:
max-size: 3
instrument:
max-size: 3FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY build/libs/*.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]version: '3.8'
services:
profile-server:
image: profile-server:latest
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod
- DATABASE_HOST=mariadb
- DATABASE_PORT=3306
- DATABASE_NAME=profiles
- DATABASE_USER_NAME=${DB_USER}
- DATABASE_PASSWORD=${DB_PASSWORD}
- KAFKA_URL1=kafka:9092
depends_on:
- mariadb
- kafka
mariadb:
image: mariadb:10.6
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: profiles
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- mariadb_data:/var/lib/mysql
volumes:
mariadb_data:flowchart LR
subgraph Before["Before - N+1 Problem"]
Q1[1. UserInfo 조회]
Q2[2. UserGenres 조회 x N]
Q3[3. UserInstruments 조회 x N]
end
subgraph After["After - Batch Initialization"]
B1[1. UserInfo 조회]
B2[2. UserGenres IN 쿼리]
B3[3. UserInstruments IN 쿼리]
end
Before --> |"N+1 쿼리"| SLOW[느림]
After --> |"3개 쿼리"| FAST[빠름]
| 방식 | Offset 기반 | Cursor 기반 |
|---|---|---|
| 성능 | O(n) - 데이터 증가시 느려짐 | O(1) - 일정한 성능 |
| 일관성 | 삽입/삭제 시 데이터 누락 가능 | 일관된 결과 보장 |
| 사용 | 작은 데이터셋 | 대용량 데이터셋 |
- 참조 데이터(장르, 악기, 지역)를 애플리케이션 시작 시 메모리에 로드
- 매일 06:00에 자동 갱신 (@Scheduled)
- DB 조회 없이 빠른 유효성 검증 가능
- 프로필 CRUD
- Kafka 이벤트 수신 (user-created)
- 닉네임 유효성 검증
- 장르/악기 속성 관리
- QueryDSL 기반 복합 검색
- 커서 기반 페이징
- 배치 조회 API
- 닉네임 변경 이벤트 발행
- 프로필 이미지 변경 수신
- Redis 쿼리 결과 캐싱
- 프로필 삭제 이벤트 처리
- 성능 모니터링
복합 검색 조건을 타입 안전하게 처리:
BooleanBuilder builder = new BooleanBuilder();
if (criteria.getCity() != null) {
builder.and(userInfo.city.eq(criteria.getCity()));
}
if (criteria.getGenres() != null && !criteria.getGenres().isEmpty()) {
builder.and(userInfo.userId.in(
JPAExpressions.select(userGenres.id.userId)
.from(userGenres)
.where(userGenres.id.genreId.in(criteria.getGenres()))
));
}동시 수정 충돌 방지:
@Version
private int version;
// 충돌 시 OptimisticLockException 발생
// 클라이언트는 재시도 필요N+1 문제 해결을 위한 별도 쿼리 실행:
private void batchInitializeCollections(List<UserInfo> users) {
Set<String> userIds = users.stream()
.map(UserInfo::getUserId)
.collect(Collectors.toSet());
// 장르 일괄 조회
List<UserGenres> genres = queryFactory
.selectFrom(userGenres)
.where(userGenres.id.userId.in(userIds))
.fetch();
// 악기 일괄 조회
List<UserInstruments> instruments = queryFactory
.selectFrom(userInstruments)
.where(userInstruments.id.userId.in(userIds))
.fetch();
// 매핑
Map<String, List<UserGenres>> genreMap = genres.stream()
.collect(Collectors.groupingBy(g -> g.getId().getUserId()));
// ...
}- 개발:
http://localhost:8080/swagger-ui.html
http://localhost:8080/v3/api-docs
버전: 1.0.0 최종 업데이트: 2025-01-25 팀: Teambind Backend Development Team