핵심 기술 소개
이 온라인 사진 플랫폼은 Spring Boot, Vue.js, MyBatis를 포함한 최신 기술 스택을 기반으로 구축되었습니다. 각 기술은 시스템의 특정 측면을 담당하며, 효율적이고 확장 가능한 아키텍처를 제공합니다.
백엔드 프레임워크: Spring Boot
Spring Boot는 개발자가 독립적으로 실행 가능하며 프로덕션 수준의 표준을 충족하는 Spring 기반 애플리케이션을 신속하게 구축할 수 있도록 지원합니다. 이 프레임워크는 편리한 도구와 서비스를 제공하여 Spring 기반 애플리케이션 개발의 효율성과 품질을 크게 향상시킵니다. 대규모 프로젝트에서 일반적으로 사용되는 기본 구성을 제공함으로써, Spring Boot는 구성 파일 사용을 최소화하고 개발자가 Spring 애플리케이션을 빠르게 시작하고 실행할 수 있도록 돕습니다. '설정보다 관습' 원칙을 통해 기존 Spring 애플리케이션 개발 시의 번거로운 구성을 피하고, Tomcat, Jetty, Undertow와 같은 내장 서버의 자동 구성을 지원하여 웹 애플리케이션 배포 과정을 간소화합니다.
프론트엔드 프레임워크: Vue.js
Vue.js는 여러 장점을 가진 인기 있는 JavaScript 프레임워크입니다. 그 핵심 장점 중 하나는 가상 DOM(Virtual DOM) 기술입니다. 가상 DOM은 효율적인 DOM 조작을 가능하게 하는 메모리 내 데이터 구조입니다. Vue.js는 반응형 데이터 바인딩, 가상 DOM, 컴포넌트 기반 아키텍처와 같은 최신 기술을 채택하여 개발자에게 유연하고 효율적이며 유지보수가 쉬운 개발 방식을 제공합니다. 데이터가 변경되면 Vue.js는 UI를 자동으로 업데이트하므로, 개발자는 UI를 수동으로 업데이트할 필요 없이 데이터 처리에 더 집중할 수 있습니다.
영속성 프레임워크: MyBatis
MyBatis는 데이터베이스 작업의 작성 및 관리를 간소화하는 데 도움이 되는 오픈소스 영속성 프레임워크입니다. MyBatis의 핵심 사상은 SQL 문과 Java 코드를 분리하고, XML 또는 애너테이션 방식을 통해 데이터베이스 작업을 기술함으로써 데이터 접근 계층의 느슨한 결합과 유연성을 달성하는 것입니다. MyBatis의 주요 장점은 다음과 같습니다.
- 데이터베이스 작업 간소화: 강력한 SQL 매핑 기능을 제공하여 Java 객체와 데이터베이스 테이블을 매핑하므로, 개발자는 번거로운 SQL 문을 수동으로 작성할 필요 없이 데이터베이스 작업의 작성 및 유지보수를 크게 간소화할 수 있습니다.
- 유연한 SQL 제어: 동적 SQL을 지원하여 다양한 조건과 로직에 따라 SQL 문을 동적으로 생성할 수 있어 쿼리, 업데이트 등의 작업을 더욱 유연하고 제어 가능하게 만듭니다.
- 캐싱 지원: 1차 캐시와 2차 캐시를 지원하여 데이터베이스 접근 횟수를 효과적으로 줄이고 시스템 성능을 향상시킵니다.
- 뛰어난 확장성: 플러그인 메커니즘을 채택하여 기능을 쉽게 확장하고 사용자 정의할 수 있어 다양한 비즈니스 요구사항을 충족시킬 수 있습니다.
코드 참조
다음은 시스템의 인증 및 권한 부여 로직의 일부를 보여주는 코드 예시입니다.
인증 컨트롤러 및 토큰 관리 서비스
// ApiResponse.java (통합 응답을 위한 헬퍼 클래스)
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public ApiResponse(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(200, "성공", data);
}
public static ApiResponse<Void> fail(String message) {
return new ApiResponse<>(400, message, null);
}
public static ApiResponse<Void> fail(int code, String message) {
return new ApiResponse<>(code, message, null);
}
public int getCode() { return code; }
public String getMessage() { return message; }
public T getData() { return data; }
}
// SkipAuthCheck.java (권한 확인 건너뛰기를 위한 사용자 정의 애너테이션)
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface SkipAuthCheck {
}
// UserAccount.java (간소화된 사용자 계정 엔티티)
public class UserAccount {
private Long id;
private String username;
private String passwordHash; // 비밀번호 해시
private String role; // 사용자 역할
// Getters and Setters...
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
}
// AuthSession.java (인증 세션 엔티티)
import java.util.Date;
public class AuthSession {
private Long id;
private Long userId;
private String username;
private String entityType;
private String userRole;
private String sessionToken;
private Date expirationTime;
private Date createdAt;
public AuthSession() {}
public AuthSession(Long userId, String username, String entityType, String userRole, String sessionToken, Date expirationTime) {
this.userId = userId;
this.username = username;
this.entityType = entityType;
this.userRole = userRole;
this.sessionToken = sessionToken;
this.expirationTime = expirationTime;
this.createdAt = new Date();
}
// Getters and Setters... (생략)
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getEntityType() { return entityType; }
public void setEntityType(String entityType) { this.entityType = entityType; }
public String getUserRole() { return userRole; }
public void setUserRole(String userRole) { this.userRole = userRole; }
public String getSessionToken() { return sessionToken; }
public void setSessionToken(String sessionToken) { this.sessionToken = sessionToken; }
public Date getExpirationTime() { return expirationTime; }
public void setExpirationTime(Date expirationTime) { this.expirationTime = expirationTime; }
public Date getCreatedAt() { return createdAt; }
public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
}
// UserAuthenticationController.java
import org.springframework.web.bind.annotation.*;
import org.springframework.beans.factory.annotation.Autowired;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.HashMap;
// 권한 확인을 건너뛰는 커스텀 애너테이션
@SkipAuthCheck
@RestController
@RequestMapping("/api/auth")
public class UserAuthenticationController {
@Autowired
private UserAccountService userAccountService; // 사용자 계정 관리 서비스 (가정)
@Autowired
private AuthTokenManager authTokenManager; // 인증 토큰 관리 서비스
@PostMapping("/login")
public ApiResponse<Map<String, Object>> authenticate(@RequestParam String username,
@RequestParam String password,
@RequestParam(required = false) String captchaCode,
HttpServletRequest request) {
// 실제 애플리케이션에서는 캡차 검증 로직이 추가됩니다.
// 사용자 계정 조회 및 비밀번호 확인
UserAccount user = userAccountService.findUserByCredentials(username, password);
if (user == null) {
return ApiResponse.fail("사용자명 또는 비밀번호가 올바르지 않습니다.");
}
// 인증 토큰 생성
String sessionToken = authTokenManager.createSessionToken(user.getId(), user.getUsername(), "user_accounts", user.getRole());
Map<String, Object> responseData = new HashMap<>();
responseData.put("token", sessionToken);
// 필요에 따라 추가적인 사용자 정보 포함 가능
return ApiResponse.success(responseData);
}
}
// AuthTokenManager.java (토큰 관리 서비스)
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.Calendar;
import java.util.Date;
import java.util.UUID; // 랜덤 문자열 생성을 위해 UUID 사용
@Service
public class AuthTokenManager {
@Autowired
private AuthSessionRepository authSessionRepository; // AuthSession 데이터 접근 계층 (가정)
/**
* 사용자 인증 세션 토큰을 생성하거나 갱신합니다.
* @param userId 사용자 고유 ID
* @param username 사용자명
* @param entityType 토큰과 연관된 엔티티 타입 (예: "user_accounts", "admin_users")
* @param userRole 사용자 역할
* @return 생성 또는 갱신된 세션 토큰 문자열
*/
public String createSessionToken(Long userId, String username, String entityType, String userRole) {
// 기존 세션 토큰이 있는지 확인
AuthSession existingSession = authSessionRepository.findByUserIdAndRole(userId, userRole);
// 새로운 랜덤 토큰 문자열 생성
String newSessionToken = UUID.randomUUID().toString().replace("-", ""); // 하이픈 제거
// 토큰 만료 시간 설정: 현재 시간으로부터 1시간 후
Calendar cal = Calendar.getInstance();
cal.setTime(new Date());
cal.add(Calendar.HOUR_OF_DAY, 1);
Date expirationTime = cal.getTime();
if (existingSession != null) {
// 기존 세션이 있다면 토큰 정보 갱신
existingSession.setSessionToken(newSessionToken);
existingSession.setExpirationTime(expirationTime);
authSessionRepository.update(existingSession); // DB 업데이트 (가정)
} else {
// 기존 세션이 없다면 새로운 세션 생성
AuthSession newAuthSession = new AuthSession(userId, username, entityType, userRole, newSessionToken, expirationTime);
authSessionRepository.save(newAuthSession); // DB 저장 (가정)
}
return newSessionToken;
}
/**
* 토큰 문자열을 기반으로 인증 세션 상세 정보를 조회합니다.
* @param token 세션 토큰 문자열
* @return 인증 세션 객체 (AuthSession) 또는 null
*/
public AuthSession getSessionDetails(String token) {
if (token == null || token.trim().isEmpty()) {
return null;
}
return authSessionRepository.findBySessionToken(token); // DB에서 토큰으로 조회 (가정)
}
}
인증 인터셉터
// AuthVerificationInterceptor.java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import com.fasterxml.jackson.databind.ObjectMapper; // JSON 응답을 위해 추가
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import java.util.Date;
/**
* 인증(세션 토큰) 검증 인터셉터
*/
@Component
public class AuthVerificationInterceptor implements HandlerInterceptor {
public static final String AUTH_TOKEN_HEADER = "X-Auth-Token"; // 요청 헤더에서 토큰을 찾을 키
private static final ObjectMapper objectMapper = new ObjectMapper(); // JSON 직렬화 유틸리티
@Autowired
private AuthTokenManager authTokenManager; // 인증 토큰 관리 서비스
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// CORS(Cross-Origin Resource Sharing) 설정
response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "X-Auth-Token, Content-Type, Authorization, X-Requested-With, Origin, Accept");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Max-Age", "3600");
// OPTIONS 요청(Preflight request)에 대한 처리
if (request.getMethod().equalsIgnoreCase("OPTIONS")) {
response.setStatus(HttpStatus.OK.value());
return false;
}
// 핸들러 메서드에 @SkipAuthCheck 애너테이션이 있는지 확인
SkipAuthCheck skipAuthCheck = null;
if (handler instanceof HandlerMethod) {
skipAuthCheck = ((HandlerMethod) handler).getMethodAnnotation(SkipAuthCheck.class);
} else {
return true; // Controller가 아닌 경우 통과
}
// @SkipAuthCheck 애너테이션이 있는 경우 권한 검증 건너뛰기
if (skipAuthCheck != null) {
return true;
}
// 요청 헤더에서 인증 토큰 가져오기
String sessionToken = request.getHeader(AUTH_TOKEN_HEADER);
AuthSession sessionDetails = null;
if (sessionToken != null && !sessionToken.trim().isEmpty()) {
sessionDetails = authTokenManager.getSessionDetails(sessionToken);
}
// 토큰이 유효하고 만료되지 않았다면 요청 처리 계속
if (sessionDetails != null && sessionDetails.getExpirationTime().after(new Date())) {
// 사용자 정보를 세션에 저장 (컨트롤러에서 접근 가능하도록)
request.getSession().setAttribute("currentUserId", sessionDetails.getUserId());
request.getSession().setAttribute("currentUserRole", sessionDetails.getUserRole());
request.getSession().setAttribute("currentUserEntityType", sessionDetails.getEntityType());
request.getSession().setAttribute("currentUsername", sessionDetails.getUsername());
return true;
}
// 인증 실패 또는 토큰 만료 시 401 Unauthorized 응답
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
PrintWriter writer = null;
try {
writer = response.getWriter();
// ApiResponse 헬퍼 클래스를 사용하여 JSON 응답 생성
writer.print(objectMapper.writeValueAsString(ApiResponse.fail(HttpStatus.UNAUTHORIZED.value(), "인증 토큰이 유효하지 않거나 만료되었습니다. 다시 로그인하십시오.")));
} finally {
if (writer != null) {
writer.close();
}
}
return false;
}
}
데이터베이스 참조
시스템에 사용되는 주요 데이터베이스 스키마는 다음과 같습니다.
-- `user_addresses` 테이블 (사용자 주소 정보)
DROP TABLE IF EXISTS `user_addresses`;
CREATE TABLE `user_addresses` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '주 키',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시간',
`user_id` BIGINT(20) NOT NULL COMMENT '사용자 ID',
`full_address` VARCHAR(200) NOT NULL COMMENT '전체 주소',
`recipient_name` VARCHAR(200) NOT NULL COMMENT '수령인 이름',
`phone_number` VARCHAR(200) NOT NULL COMMENT '연락처',
`is_default` VARCHAR(200) NOT NULL COMMENT '기본 주소 여부 [예/아니오]',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='사용자 주소 정보';
-- `community_posts` 테이블 (커뮤니티 포럼 게시글)
DROP TABLE IF EXISTS `community_posts`;
CREATE TABLE `community_posts` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '주 키',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시간',
`post_title` VARCHAR(200) DEFAULT NULL COMMENT '게시글 제목',
`post_content` LONGTEXT NOT NULL COMMENT '게시글 내용',
`parent_post_id` BIGINT(20) DEFAULT NULL COMMENT '부모 게시글 ID (댓글의 경우)',
`author_id` BIGINT(20) NOT NULL COMMENT '작성자 ID',
`author_username` VARCHAR(200) DEFAULT NULL COMMENT '작성자 사용자명',
`author_avatar_url` LONGTEXT COMMENT '작성자 아바타 URL',
`status` VARCHAR(200) DEFAULT NULL COMMENT '상태 (예: "공개", "비공개")',
`is_pinned` INT(11) DEFAULT '0' COMMENT '상단 고정 여부 (0: 아니오, 1: 예)',
`pinned_at` DATETIME DEFAULT NULL COMMENT '상단 고정 시간',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='커뮤니티 포럼 게시글';
-- `user_accounts` 테이블 (사용자 계정 정보)
DROP TABLE IF EXISTS `user_accounts`;
CREATE TABLE `user_accounts` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '주 키',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시간',
`username` VARCHAR(200) NOT NULL COMMENT '사용자명 (계정)',
`password_hash` VARCHAR(200) NOT NULL COMMENT '비밀번호 해시',
`full_name` VARCHAR(200) NOT NULL COMMENT '실명',
`gender` VARCHAR(200) DEFAULT NULL COMMENT '성별',
`phone_number` VARCHAR(200) DEFAULT NULL COMMENT '휴대폰 번호',
`profile_image_url` LONGTEXT COMMENT '프로필 이미지 URL',
`points` DOUBLE DEFAULT '0' COMMENT '포인트',
`balance` DOUBLE DEFAULT '0' COMMENT '잔액',
PRIMARY KEY (`id`),
UNIQUE KEY `username_unique` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='사용자 계정 정보';
-- 데이터 삽입 예시: `user_accounts` 테이블
INSERT INTO `user_accounts` (`username`, `password_hash`, `full_name`, `gender`, `phone_number`, `profile_image_url`, `points`, `balance`)
VALUES ('photostar', 'e10adc3949ba59abbe56e057f20f883e', '김철수', '남', '01012345678', 'http://example.com/profiles/kim.jpg', 100.0, 50.0);
INSERT INTO `user_accounts` (`username`, `password_hash`, `full_name`, `gender`, `phone_number`, `profile_image_url`, `points`, `balance`)
VALUES ('snapshot_queen', 'e10adc3949ba59abbe56e057f20f883e', '이영희', '여', '01087654321', 'http://example.com/profiles/lee.jpg', 150.0, 75.0);
INSERT INTO `user_accounts` (`username`, `password_hash`, `full_name`)
VALUES ('guest_user', 'e10adc3949ba59abbe56e057f20f883e', '방문자');
시스템 테스트 사례
이 시스템은 주요 기능에 대한 광범위한 테스트를 거쳤습니다. 다음은 로그인 및 사용자 관리 기능에 대한 테스트 결과의 예시입니다.
로그인 기능 테스트 사례
| 입력 데이터 | 예상 결과 | 실제 결과 | 결과 분석 |
|---|---|---|---|
| 사용자명: admin, 비밀번호: 123456, 인증 코드: 올바르게 입력 | 시스템 로그인 성공 | 시스템에 성공적으로 로그인됨 | 결과 일치 |
| 사용자명: admin, 비밀번호: 111111, 인증 코드: 올바르게 입력 | 비밀번호 오류 메시지 | 비밀번호가 올바르지 않습니다. 다시 입력하십시오. | 결과 일치 |
| 사용자명: admin, 비밀번호: 123456, 인증 코드: 잘못 입력 | 인증 코드 오류 메시지 | 인증 코드 정보가 올바르지 않습니다. | 결과 일치 |
| 사용자명: (공백), 비밀번호: 123456, 인증 코드: 올바르게 입력 | 사용자명 필수 입력 메시지 | 사용자명을 입력하십시오. | 결과 일치 |
| 사용자명: admin, 비밀번호: (공백), 인증 코드: 올바르게 입력 | 비밀번호 오류 메시지 | 비밀번호가 올바르지 않습니다. 다시 입력하십시오. | 결과 일치 |
사용자 관리 기능 테스트 사례
본 시스템은 사용자 추가, 편집, 삭제, 검색 등 사용자 관리 기능에 대한 테스트도 수행했습니다. 다음은 각 기능에 대한 테스트 결과입니다.
1. 사용자 추가 기능 테스트 사례
| 입력 데이터 | 예상 결과 | 실제 결과 | 결과 분석 |
|---|---|---|---|
| 사용자명: testuser1, 비밀번호: pass123, 역할: 일반 사용자 | 추가 성공 및 사용자 목록에 표시 | 사용자 목록에 'testuser1' 추가됨 | 결과 일치 |
| 사용자명: testuser2, 비밀번호: pass456, 역할: 일반 사용자 | 추가 성공 및 사용자 목록에 표시 | 사용자 목록에 'testuser2' 추가됨 | 결과 일치 |
| 사용자명: testuser1, 비밀번호: pass789, 역할: 일반 사용자 | 추가 실패, '사용자명 이미 존재' 메시지 | 추가 실패: 사용자명이 이미 존재합니다. | 결과 일치 |
| 사용자명: (공백), 비밀번호: pass123, 역할: 일반 사용자 | 추가 실패, '사용자명 비어있음' 메시지 | 추가 실패: 사용자명은 비워둘 수 없습니다. | 결과 일치 |
2. 사용자 편집 기능 테스트 사례
| 입력 데이터 | 예상 결과 | 실제 결과 | 결과 분석 |
|---|---|---|---|
| 'testuser1' 선택, 비밀번호를 'newpass'로 변경 | 편집 성공, 비밀번호 변경 완료 | 'testuser1'의 비밀번호가 'newpass'로 변경됨 | 결과 일치 |
| 'testuser2' 선택, 역할을 '관리자'로 변경 | 편집 성공, 역할 변경 완료 | 'testuser2'의 역할이 '관리자'로 변경됨 | 결과 일치 |
| 'testuser1' 선택, 사용자명 필드 비우기 | 편집 실패, '사용자명 비어있음' 메시지 | 편집 실패: 사용자명은 비워둘 수 없습니다. | 결과 일치 |
3. 사용자 삭제 기능 테스트 사례
| 입력 데이터 | 예상 결과 | 실제 결과 | 결과 분석 |
|---|---|---|---|
| 'testuser1' 삭제 요청 | 시스템이 삭제 여부 확인, 확인 후 사용자 삭제 | 'testuser1'이 성공적으로 삭제됨 | 결과 일치 |
| 'testuser2' 삭제 요청, 취소 선택 | 시스템이 삭제 여부 확인, 취소 후 사용자 유지 | 'testuser2'가 삭제되지 않음 | 결과 일치 |
시스템 기능 테스트를 통해 시스템의 기능 완전성을 확보했으며, 요구사항 정의서에 따라 시스템이 정상적으로 작동함을 확인했습니다. 우리는 잠재적인 문제를 지속적으로 발견하고 수정하여 사용자에게 완벽한 기능 시스템 경험을 제공할 것입니다.