diff --git a/src/main/java/com/sb/web/auth/controller/AuthController.java b/src/main/java/com/sb/web/auth/controller/AuthController.java
index c0898fc..41f37b6 100644
--- a/src/main/java/com/sb/web/auth/controller/AuthController.java
+++ b/src/main/java/com/sb/web/auth/controller/AuthController.java
@@ -104,4 +104,12 @@ public class AuthController {
HttpServletRequest request) {
return authService.updateProfile(current.getId(), AuthInterceptor.resolveToken(request), req);
}
+
+ /** 프로필 사진(사용자 지정) 변경/해제 — image 가 비면 해제(구글 사진으로 폴백) */
+ @PutMapping("/profile-image")
+ public MemberResponse updateProfileImage(
+ @RequestBody com.sb.web.auth.dto.ProfileImageRequest req,
+ @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
+ return authService.updateProfileImage(current.getId(), req.getImage());
+ }
}
diff --git a/src/main/java/com/sb/web/auth/domain/Member.java b/src/main/java/com/sb/web/auth/domain/Member.java
index eec71c1..4f5d261 100644
--- a/src/main/java/com/sb/web/auth/domain/Member.java
+++ b/src/main/java/com/sb/web/auth/domain/Member.java
@@ -26,6 +26,8 @@ public class Member implements Serializable {
private String provider; // LOCAL / NAVER / GOOGLE
private String providerId; // 소셜 제공자 측 고유 ID
private String googleId; // 구글 sub (계정 연결용 — LOCAL 계정에 구글을 연결해도 provider 는 유지)
+ private String googlePicture; // 구글 아바타 URL (로그인 시 동기화)
+ private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜
private String role; // USER / ADMIN
private String plan; // FREE / PREMIUM (멤버십)
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
diff --git a/src/main/java/com/sb/web/auth/dto/MemberResponse.java b/src/main/java/com/sb/web/auth/dto/MemberResponse.java
index a19e3d9..ae5c480 100644
--- a/src/main/java/com/sb/web/auth/dto/MemberResponse.java
+++ b/src/main/java/com/sb/web/auth/dto/MemberResponse.java
@@ -18,6 +18,8 @@ public class MemberResponse {
private String provider;
private String role;
private String plan; // FREE / PREMIUM
+ private String googlePicture; // 구글 아바타 URL
+ private String profileImage; // 사용자 지정 프로필 이미지(data URL)
public static MemberResponse from(Member m) {
return MemberResponse.builder()
@@ -28,6 +30,8 @@ public class MemberResponse {
.provider(m.getProvider())
.role(m.getRole())
.plan(m.getPlan())
+ .googlePicture(m.getGooglePicture())
+ .profileImage(m.getProfileImage())
.build();
}
}
diff --git a/src/main/java/com/sb/web/auth/dto/ProfileImageRequest.java b/src/main/java/com/sb/web/auth/dto/ProfileImageRequest.java
new file mode 100644
index 0000000..c151f06
--- /dev/null
+++ b/src/main/java/com/sb/web/auth/dto/ProfileImageRequest.java
@@ -0,0 +1,13 @@
+package com.sb.web.auth.dto;
+
+import lombok.Data;
+
+/**
+ * 프로필 사진(사용자 지정) 변경 요청. image 가 null/빈 값이면 해제(구글 사진으로 폴백).
+ */
+@Data
+public class ProfileImageRequest {
+
+ /** data URL(base64 이미지). null 이면 사용자 지정 사진 해제. */
+ private String image;
+}
diff --git a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java
index e497ca8..74d3705 100644
--- a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java
+++ b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java
@@ -40,6 +40,12 @@ public interface MemberMapper {
/** 프로필(이름/이메일) 변경 */
int updateProfile(@Param("id") Long id, @Param("name") String name, @Param("email") String email);
+ /** 구글 로그인 시 구글 아바타 URL 동기화 */
+ int updateGooglePicture(@Param("id") Long id, @Param("googlePicture") String googlePicture);
+
+ /** 사용자 지정 프로필 이미지 설정/해제(null = 해제 → 구글 사진으로 폴백) */
+ int updateProfileImage(@Param("id") Long id, @Param("profileImage") String profileImage);
+
int updateRole(@Param("id") Long id, @Param("role") String role);
int updatePlan(@Param("id") Long id, @Param("plan") String plan);
diff --git a/src/main/java/com/sb/web/auth/service/AuthService.java b/src/main/java/com/sb/web/auth/service/AuthService.java
index 2bc7b6e..49ac6f0 100644
--- a/src/main/java/com/sb/web/auth/service/AuthService.java
+++ b/src/main/java/com/sb/web/auth/service/AuthService.java
@@ -186,6 +186,7 @@ public class AuthService {
boolean emailVerified = "true".equals(String.valueOf(info.get("email_verified")));
String name = info.get("name") != null ? String.valueOf(info.get("name"))
: (email != null ? email.split("@")[0] : "사용자");
+ String picture = info.get("picture") != null ? String.valueOf(info.get("picture")) : null;
// 1) 구글 sub 로 이미 연결/가입된 계정 조회
Member member = memberMapper.findByGoogleId(sub);
@@ -216,6 +217,7 @@ public class AuthService {
.provider("GOOGLE")
.providerId(sub)
.googleId(sub)
+ .googlePicture(picture)
.role("USER")
.status("ACTIVE")
.build();
@@ -225,6 +227,11 @@ public class AuthService {
if (!"ACTIVE".equals(member.getStatus())) {
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
}
+ // 구글 아바타가 바뀌었으면 동기화(신규 가입은 이미 반영됨 → 변경 없을 때 불필요한 UPDATE 생략)
+ if (picture != null && !picture.equals(member.getGooglePicture())) {
+ memberMapper.updateGooglePicture(member.getId(), picture);
+ member.setGooglePicture(picture);
+ }
return issueSession(member, rememberMe);
}
@@ -335,6 +342,31 @@ public class AuthService {
return MemberResponse.from(member);
}
+ /**
+ * 프로필 사진(사용자 지정) 설정/해제. null/빈 값이면 해제 → 구글 사진으로 폴백.
+ * data URL(base64 이미지)만 허용하고 과도한 크기는 거절한다.
+ */
+ @Transactional
+ public MemberResponse updateProfileImage(Long memberId, String image) {
+ Member member = memberMapper.findById(memberId);
+ if (member == null) {
+ throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
+ }
+ String value = (image == null || image.isBlank()) ? null : image.trim();
+ if (value != null) {
+ if (!value.startsWith("data:image/")) {
+ throw new ApiException(HttpStatus.BAD_REQUEST, "이미지 형식이 올바르지 않습니다.");
+ }
+ if (value.length() > 700_000) { // 약 500KB 이미지 상한 (base64 약 33% 팽창 고려)
+ throw new ApiException(HttpStatus.BAD_REQUEST, "이미지 용량이 너무 큽니다. 더 작은 사진을 사용하세요.");
+ }
+ }
+ memberMapper.updateProfileImage(memberId, value);
+ member.setProfileImage(value);
+ log.info("[profile] image {} for {}", value == null ? "cleared" : "updated", member.getLoginId());
+ return MemberResponse.from(member);
+ }
+
/** 프로필 이름 변경 시 활성 세션(Redis + DB 백업)의 표시 이름을 맞춘다. */
private void syncSessionName(String token, String name) {
if (token == null || token.isBlank()) {
diff --git a/src/main/java/com/sb/web/common/config/WebConfig.java b/src/main/java/com/sb/web/common/config/WebConfig.java
index efe5084..2d13145 100644
--- a/src/main/java/com/sb/web/common/config/WebConfig.java
+++ b/src/main/java/com/sb/web/common/config/WebConfig.java
@@ -26,7 +26,7 @@ public class WebConfig implements WebMvcConfigurer {
// 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행)
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/auth/password",
- "/api/auth/verify-password", "/api/auth/profile",
+ "/api/auth/verify-password", "/api/auth/profile", "/api/auth/profile-image",
"/api/board/**", "/api/admin/**", "/api/account/**");
// 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사)
registry.addInterceptor(adminInterceptor)
diff --git a/src/main/resources/db/member.sql b/src/main/resources/db/member.sql
index c2d1646..167b518 100644
--- a/src/main/resources/db/member.sql
+++ b/src/main/resources/db/member.sql
@@ -30,6 +30,11 @@ UPDATE member SET google_id = provider_id WHERE provider = 'GOOGLE' AND google_i
-- 멤버십 플랜 (무료/유료). 기존 회원은 FREE 로 시작 (멱등).
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'FREE' COMMENT 'FREE / PREMIUM' AFTER role;
+-- 프로필 사진. google_picture 는 구글 로그인 시 동기화되는 구글 아바타 URL,
+-- profile_image 는 사용자가 직접 올린 커스텀 이미지(data URL). 표시 우선순위: profile_image > google_picture > 이니셜.
+ALTER TABLE member ADD COLUMN IF NOT EXISTS google_picture VARCHAR(500) NULL COMMENT '구글 아바타 URL (로그인 시 동기화)' AFTER google_id;
+ALTER TABLE member ADD COLUMN IF NOT EXISTS profile_image MEDIUMTEXT NULL COMMENT '사용자 지정 프로필 이미지(data URL)' AFTER google_picture;
+
-- ============================================================
-- 앱 전역 설정 (단일 행, id=1). 관리자 화면에서 변경.
-- ============================================================
diff --git a/src/main/resources/mapper/MemberMapper.xml b/src/main/resources/mapper/MemberMapper.xml
index 3506bf8..0e95300 100644
--- a/src/main/resources/mapper/MemberMapper.xml
+++ b/src/main/resources/mapper/MemberMapper.xml
@@ -12,6 +12,8 @@
+
+
@@ -20,15 +22,17 @@
- id, login_id, password, name, email, provider, provider_id, google_id, role, plan, status, created_at, updated_at
+ id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, status, created_at, updated_at
+