Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ OAuth 2.0 가이드 문서에 JSON 토큰에 대한 설명도 나와 있습니
회원 프로필 테이블입니다. 회원 테이블과 역할이 조금 다릅니다. 회원 테이블이 회원의 권한, 로그인 정보 등의 데이터를 담는다면,
회원 프로필 테이블은 프로필 이미지, 자기소개, 연락처, 이름 등 회원 프로필 데이터를 가지고 있습니다.

- `member_id`
- `id`
- `member` 테이블을 참조하는 FK이자, `member_profile` 테이블의 PK입니다. `member_profile` 테이블과 `member` 테이블은 식별 관계입니다.

### `available_study_time`
Expand Down
4 changes: 2 additions & 2 deletions docs/extract-member-id/extract-member-id.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public class TestController {

```json
{
"member_id": 1,
"id": 1,
"created_at": "2025-05-04 20:24:28.000000",
"updated_at": "2025-05-04 20:24:32.000000",
"auto_matching": "F",
Expand Down Expand Up @@ -178,4 +178,4 @@ public class TestController {

## 주의사항

`SecurityContextHolder`, `OAuth2AuthenticationPrincipal` 등은 Spring Security 기술이고, **변할 수 있는 것**입니다. 이 때문에 **Service 객체에 Spring Security 기술을 노출시켜서는 안 됩니다**. 왜냐하면 Service 객체는 비즈니스 로직을 가지고 있는 Application 코드이고, 비즈니스 로직 코드는 요구사항이 변경될 경우를 제외하고 변경되어서는 안 됩니다. 다시 말해, 비즈니스 로직 코드는 외부 기술에 의존해서는 안 된다는 것입니다. Service 객체 안에서 `SecurityContextHolder`에 접근하여 `Authentication` 객체를 꺼내는 코드를 작성한다면 그 Service 객체는 Spring Security라는 기술에 종속됩니다. 웬만하면 계속 Spring Security를 사용하겠지만, 그래도 Spring Security가 아닌 다른 기술을 사용하게 되었다고 가정해 봅시다. 그러면 Spring Security에 종속된 Service 객체의 코드 변경 없이 Spring Security를 대체할 수 없게 됩니다. 저희 프로젝트 규모가 더 커져서 막 코드가 10만 줄이 넘어가는데, Application 코드 곳곳에 Spring Security에 대한 의존성이 발견된다고 합시다. 그러면 이 프로젝트는 최후의 순간까지 Spring Security를 사용해야 합니다. 더 개쩌는 기술이 나온다고 하더라도요.
`SecurityContextHolder`, `OAuth2AuthenticationPrincipal` 등은 Spring Security 기술이고, **변할 수 있는 것**입니다. 이 때문에 **Service 객체에 Spring Security 기술을 노출시켜서는 안 됩니다**. 왜냐하면 Service 객체는 비즈니스 로직을 가지고 있는 Application 코드이고, 비즈니스 로직 코드는 요구사항이 변경될 경우를 제외하고 변경되어서는 안 됩니다. 다시 말해, 비즈니스 로직 코드는 외부 기술에 의존해서는 안 된다는 것입니다. Service 객체 안에서 `SecurityContextHolder`에 접근하여 `Authentication` 객체를 꺼내는 코드를 작성한다면 그 Service 객체는 Spring Security라는 기술에 종속됩니다. 웬만하면 계속 Spring Security를 사용하겠지만, 그래도 Spring Security가 아닌 다른 기술을 사용하게 되었다고 가정해 봅시다. 그러면 Spring Security에 종속된 Service 객체의 코드 변경 없이 Spring Security를 대체할 수 없게 됩니다. 저희 프로젝트 규모가 더 커져서 막 코드가 10만 줄이 넘어가는데, Application 코드 곳곳에 Spring Security에 대한 의존성이 발견된다고 합시다. 그러면 이 프로젝트는 최후의 순간까지 Spring Security를 사용해야 합니다. 더 개쩌는 기술이 나온다고 하더라도요.
28 changes: 14 additions & 14 deletions init_data/ZTO_LOCAL_DDL.sql
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ SET FOREIGN_KEY_CHECKS = 1;


CREATE TABLE `member` (
`member_id` bigint PRIMARY KEY AUTO_INCREMENT COMMENT '회원 Identifier',
`id` bigint PRIMARY KEY AUTO_INCREMENT COMMENT '회원 Identifier',
`oidc_id` varchar(50) COMMENT 'Open ID Connect에 의한 ID',
`login_id` VARCHAR(50) COMMENT '로그인할 때 사용되는 ID (자체 로그인)',
`member_status` VARCHAR(8) NOT NULL DEFAULT 'ACTIVE' COMMENT '회원 상태',
Expand All @@ -29,18 +29,18 @@ CREATE TABLE `member` (
);

CREATE TABLE `role` (
`role_id` varchar(20) PRIMARY KEY COMMENT '권한 ID',
`id` varchar(20) PRIMARY KEY COMMENT '권한 ID',
`role_name` varchar(20) NOT NULL COMMENT '권한명'
);

CREATE TABLE `access_permission` (
`permission_id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '접근허가 ID',
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '접근허가 ID',
`endpoint` VARCHAR(80) NOT NULL COMMENT '엔드포인트',
`http_method` VARCHAR(8) NOT NULL COMMENT 'HTTP 메소드 (Upper Case)' /* CHAR로 변경 고려 */
);

CREATE TABLE `access_permission_role` (
`access_permission_role_id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '접근허가_롤 ID',
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '접근허가_롤 ID',
`permission_id` BIGINT NOT NULL COMMENT '접근허가 참조 FK',
`role_id` VARCHAR(20) NOT NULL COMMENT '권한 참조 FK'
);
Expand All @@ -59,15 +59,15 @@ CREATE TABLE `member_profile` (
);

CREATE TABLE `member_interest` (
member_interest_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '관심사 ID',
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '관심사 ID',
name VARCHAR(20) NOT NULL COMMENT '관심사 이름',
member_id BIGINT NOT NULL COMMENT '회원 프로필 참조 FK',
`created_at` timestamp NOT NULL COMMENT '생성시간',
`updated_at` timestamp NOT NULL COMMENT '수정시간'
);

CREATE TABLE `available_study_time` (
available_study_time_id BIGINT PRIMARY KEY COMMENT '스터디 가능 시간대 ID',
id BIGINT PRIMARY KEY COMMENT '스터디 가능 시간대 ID',
from_time TIME(6) COMMENT '스터디 가능 시작 시간 (날짜 빼고 시간만 사용)',
to_time TIME(6) COMMENT '스터디 가능 끝 시간 (날짜 빼고 시간만 사용)',
label VARCHAR(10) NOT NULL COMMENT '시간대 라벨 (오전, 오후, 저녁 등등)',
Expand All @@ -76,21 +76,21 @@ CREATE TABLE `available_study_time` (
);

CREATE TABLE `profile_avl_time` (
available_study_time_id BIGINT NOT NULL COMMENT '스터디 가능 시간대 참조 FK',
id BIGINT NOT NULL COMMENT '스터디 가능 시간대 참조 FK',
member_id BIGINT NOT NULL COMMENT '회원 프로필 참조 FK',
PRIMARY KEY (available_study_time_id, member_id)
);

CREATE TABLE `image` (
`image_id` bigint PRIMARY KEY AUTO_INCREMENT COMMENT '이미지 ID',
`id` bigint PRIMARY KEY AUTO_INCREMENT COMMENT '이미지 ID',
`location` VARCHAR(80) NOT NULL COMMENT '이미지가 저장된 장소 (도메인 URL)',
`created_at` timestamp DEFAULT NOW() COMMENT '생성시간',
`updated_at` timestamp DEFAULT NOW() COMMENT '수정시간',
`deleted_at` TIMESTAMP COMMENT '삭제시간 - NULL일 경우 삭제되지 않음'
);

CREATE TABLE `resized_image` (
`resized_image_id` bigint AUTO_INCREMENT COMMENT '리사이징 이미지 ID',
`id` bigint AUTO_INCREMENT COMMENT '리사이징 이미지 ID',
`image_id` bigint NOT NULL COMMENT '이미지 참조 FK',
-- ENUM('THUMB','SMALL','LARGE','ETC') -> VARCHAR(10)
`resized_image_url` varchar(255) NOT NULL COMMENT '리사이징 이미지 URL',
Expand All @@ -100,13 +100,13 @@ CREATE TABLE `resized_image` (
);

CREATE TABLE `social_media_type` (
`social_media_type_id` VARCHAR(20) PRIMARY KEY,
`id` VARCHAR(20) PRIMARY KEY,
`social_media_name` varchar(100) NOT NULL,
`icon_id` BIGINT
);

CREATE TABLE `social_media` (
`social_media_id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`member_id` bigint NOT NULL,
`social_media_type_id` VARCHAR(255) NOT NULL,
`url` varchar(255) NOT NULL,
Expand All @@ -115,7 +115,7 @@ CREATE TABLE `social_media` (
);

CREATE TABLE `tech_stack` (
tech_stack_id BIGINT AUTO_INCREMENT PRIMARY KEY,
id BIGINT AUTO_INCREMENT PRIMARY KEY,
code CHAR(3) NOT NULL UNIQUE,
tech_stack_name VARCHAR(100) NOT NULL,
parent_id BIGINT,
Expand All @@ -126,8 +126,8 @@ CREATE TABLE `tech_stack` (
);

CREATE TABLE `tech_stack_ref` (
`tech_stack_ref_id` bigint PRIMARY KEY AUTO_INCREMENT,
`id` bigint PRIMARY KEY AUTO_INCREMENT,
`tech_stack_id` BIGINT NOT NULL,
`member_id` bigint NOT NULL,
`type` VARCHAR(30) NOT NULL
);
);
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ public abstract class BaseEntity {
protected BaseEntity() {
}

public LocalDateTime getCreatedAt() {
// 변경 사유: 자신의 날짜는 여기저기서 꺼내 볼 수 있게 하는 것보다 스스로가 관리하는 것이 객체지향의 원칙에도, DDD의 지향점에도 부합한다고 생각됩니다.
protected LocalDateTime getCreatedAt() {
return createdAt;
}

public LocalDateTime getUpdatedAt() {
// 변경 사유: 자신의 날짜는 여기저기서 꺼내 볼 수 있게 하는 것보다 스스로가 관리하는 것이 객체지향의 원칙에도, DDD의 지향점에도 부합한다고 생각됩니다.
protected LocalDateTime getUpdatedAt() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋은 의견 감사합니다

return updatedAt;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.codezerotoone.mvp.domain.common;

import jakarta.persistence.*;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

import static com.codezerotoone.mvp.domain.common.EntityConstant.BOOLEAN_DEFAULT_FALSE;
import static jakarta.persistence.GenerationType.IDENTITY;

// 추가 사유: 일반적으로 공통되는 컬럼들은 엔티티가 추가될 때마다 일일이 설정하기보다는 공용 멤버로 묶는 게 나아 보입니다.
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseGeneralEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;

@Column(nullable = false, columnDefinition = BOOLEAN_DEFAULT_FALSE)
private boolean deleteYn;

@Column(name = "deleted_at")
private LocalDateTime deletedAt;
Comment on lines +19 to +23
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제 개인적 견해로는 deletedAt 하나만으로 엔티티 삭제 여부를 알 수 있기에 deleteYndeletedAt은 중복이라고 생각합니다. 삭제된 시간과 삭제 여부를 따로 관리할 필요가 있는 케이스가 있을까요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

삭제된 엔티티를 복구할 경우, 이전에 삭제된 이력을 기록하는 것도 하나의 좋은 컨벤션이 될 수 있겠군요. 좋은 코드 제시 감사합니다!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋은 의견 감사합니다. 제 개인적으로는 deletedAt에 deleteYn이 추가로 필요하다기보다, 오히려 deletedAt이 필수가 아니고 deletedYn으로 조회를 하는 게 좋다는 생각입니다.
언제 삭제되었는지에 대한 기록이므로 삭제 시간이 중요한 컬럼이라고 생각될 수 있지만, 대부분의 서비스 애플리케이션은 요청 및 응답 데이터를 MongoDB나 Elasticsearch같은 NoSQL이든 다른 곳이든 RDBMS가 아닌 곳에 따로 로깅을 합니다. 책임 소재 등의 문제가 발생하면 DB가 아닌 API 요청 로그를 봅니다. 따라서 deleted_at 컬럼의 필요성이 생각보다 높지 않고, modified_at 정도로도 충분하다는 생각입니다. 말씀하신 대로 삭제된 이력을 RDBMS에 기록할 때에도 모든 이력을 추적하기 위해 내부 컬럼보다는 별도의 히스토리 테이블을 사용하는 것이 일반적입니다. 따라서 효율과 직관성이 좋은 deleteYn만 사용하는 게 더 낫지 않나 하는 생각이 듭니다. 서비스가 커지면 DB에서 직접 튜플을 삭제할 일도 많아지기 때문에 직관적이지 않은 방식일수록 실수할 확률도 높아집니다. null은 값이 정해지지 않은 상태를 의미하므로, 복구 시 일부러 null을 삽입하는 것도 부자연스러운 작업이라고 생각합니다.


public Long getId() {
return id;
}

protected boolean isDeleted() {
return deleteYn;
}

protected void updateId(Long id) {
this.id = id;
}

protected void deleteEntity() {
deleteYn = true;
deletedAt = LocalDateTime.now();
}

protected void undeleteEntity() {
deleteYn = false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.codezerotoone.mvp.domain.common;

public class EntityConstant {
public static final String BOOLEAN_DEFAULT_FALSE = "boolean default false";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오라클처럼 Boolean 타입을 제공하지 않는 DB를 상정한다면, DB에서 BOOLEAN 타입을 사용하는 건 DB 선택의 폭이 좁아지지 않을까요? 아니면 DB 선택의 폭을 좁히더라도 boolean 타입을 사용함으로써 얻는 이점이 더 클까요?
효빈님 의견은 어떠신가요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예를 들어 오라클을 사용하기로 하는 경우에, 상수로 선언했으므로

public static final String BOOLEAN_DEFAULT_FALSE = "number(1) default 0";

로 한 내용물만 바꾸면 되기 때문에 거의 수정이 필요 없을 것으로 보입니다.

}
34 changes: 17 additions & 17 deletions src/main/java/com/codezerotoone/mvp/domain/image/entity/Image.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.codezerotoone.mvp.domain.image.entity;

import com.codezerotoone.mvp.domain.common.BaseEntity;
import com.codezerotoone.mvp.domain.common.BaseGeneralEntity;
import com.codezerotoone.mvp.domain.image.entity.dto.ResizedImageInfo;
import jakarta.persistence.*;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
Expand All @@ -16,18 +18,16 @@
@Table(name = "image")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Image extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long imageId;

@OneToMany(mappedBy = "image", cascade = { CascadeType.PERSIST, CascadeType.REMOVE }) // TODO: 고아객체 삭제?
public class Image extends BaseGeneralEntity {
// 변경 사유: 원본 이미지와 연결되지 않은 경우 의미없는 데이터이므로, DB에 튜플을 남기지 않는 편이 장기적으로 낫다고 보입니다.
@OneToMany(mappedBy = "image", cascade = {CascadeType.PERSIST}, orphanRemoval = true)
private List<ResizedImage> resizedImages = new ArrayList<>();

private String location;

private LocalDateTime deletedAt = null;
private Image(Long id) {
updateId(id);
}

public static Image create(String location, ResizedImageInfo... resizedImages) {
Image image = new Image();
Expand All @@ -47,17 +47,17 @@ public static Image create(String location, List<ResizedImageInfo> resizedImages
return image;
}

public static Image getReference(Long id) {
Image image = new Image();
image.imageId = id;
return image;
// 변경 사유: 메서드명과 역할 불일치
public static Image of(Long id) {
// 변경 사유: 생성 로직 간소화
return new Image(id);
}

public void delete() {
this.deletedAt = LocalDateTime.now();
deleteEntity();
}

public boolean isDeleted() {
return this.deletedAt != null;
return isDeleted();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@

import java.time.LocalDateTime;

import static com.codezerotoone.mvp.domain.common.EntityConstant.BOOLEAN_DEFAULT_FALSE;

@Entity
@Table(name = "resized_image")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ResizedImage {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long resizedImageId;
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "image_id")
Expand All @@ -28,7 +29,11 @@ public class ResizedImage {
@Column(name = "image_size_type", columnDefinition = "VARCHAR(10)")
private ImageSizeType imageSizeType;

public LocalDateTime deletedAt = null;
@Column(nullable = false, columnDefinition = BOOLEAN_DEFAULT_FALSE)
private boolean deleteYn;

@Column(name = "deleted_at")
private LocalDateTime deletedAt;

public ResizedImage(Image image, String resizedImageUrl, ImageSizeType imageSizeType) {
this.image = image;
Expand All @@ -37,15 +42,12 @@ public ResizedImage(Image image, String resizedImageUrl, ImageSizeType imageSize
}

public void delete() {
this.deletedAt = LocalDateTime.now();
deleteYn = true;
deletedAt = LocalDateTime.now();
}

public boolean isDeleted() {
return this.deletedAt != null;
}

public boolean isNotDeleted() {
return !isDeleted();
return deleteYn;
}

public String getFullResizedImageUrl() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ public interface ImageRepository extends JpaRepository<Image, Long> {
@Query("""
SELECT i
FROM Image i
WHERE i.deletedAt IS NULL
AND i.imageId = :imageId
WHERE i.deleteYn = false
AND i.id = :imageId
""")
Optional<Image> findById(@Param("imageId") Long imageId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ public class ImageService {
public Long saveImage(String location, List<ResizedImageInfo> imageInfos) {
Image newImage = Image.create(location, imageInfos);
newImage = this.imageRepository.save(newImage);
return newImage.getImageId();
return newImage.getId();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ public class AuthService {
private final TokenSupport tokenSupport;
private final MemberRepository memberRepository;

@Transactional
// 변경 사유: 리소스 서버로의 요청이 pending되는 경우 무한 대기를 하는 것보다 실패 피드백을 반환하는 편이 낫다고 생각됩니다.
@Transactional(timeout = 30)
public LoginResult loginByOAuth2(String code, String redirectUri, AuthVendor authVendor)
throws UnsupportedCodeException {
GrantedTokenInfo grantedTokenInfo = this.tokenSupport.grantToken(code, redirectUri, authVendor);
Expand All @@ -33,7 +34,7 @@ public LoginResult loginByOAuth2(String code, String redirectUri, AuthVendor aut
.newMember(false)
.accessToken(grantedTokenInfo.accessToken())
.refreshToken(grantedTokenInfo.refreshToken())
.memberId(member.getMemberId())
.memberId(member.getId())
.build();
}
OAuth2UserInfo userInfo = this.tokenSupport.retrieveUserInfo(grantedTokenInfo.accessToken());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@ public record MemberCreationResponseDto(
@Schema(description = "프로필 이미지를 업로드할 URL")
String uploadUrl
) {
public static MemberCreationResponseDto of(Long memberId) {
return new MemberCreationResponseDto(memberId, null);
}
}
Loading