앙앙이
3k
2019-04-16 18:42:07 작성 2019-04-16 22:51:59 수정됨
9
1122

람다 도입에 대한 평가를 부탁드립니다.


  몇일전 동영상을 보게 되었는데 람다의 장점에 대한 내용이 있었습니다.

jdbc 전형적인 코드를 반복하여 코드에 넣지 않는 깔끔한 방법이였습니다.

개인적으로 람다에 대해서 거부감(?)이 있지만

반복적인 코드를 넣을 필요 없고 자원 해제에 대한 사용자 실수를 차단할 필요가 있다고 생각하여

제 나름대로 람다를 적용하여 변경을 해 보았습니다.

람다 도입후 가독성등 평가 부탁드립니다.


>> 변경후
public MessageResultRes doWork(String dbcpName, PasswordSearchReadyReq passwordSearchReadyReq) throws Exception {
		// FIXME!
		log.info(passwordSearchReadyReq.toString());

		try {
			ValueChecker.checkValidEmail(passwordSearchReadyReq.getEmail());
		} catch (RuntimeException e) {
			String errorMessage = e.getMessage();
			throw new ServerServiceException(errorMessage);
		}

		try {
			ValueChecker.checkValidIP(passwordSearchReadyReq.getIp());
		} catch (RuntimeException e) {
			String errorMessage = e.getMessage();
			throw new ServerServiceException(errorMessage);
		}
		
		MessageResultRes messageResultRes = new MessageResultRes();		
		
		/**
		 * '아이디 혹은 비밀번호 찾기' 서비스는 오직 일반 회원에 한에서만 서비스가 제공됩니다. 회원 테이블에는 손님, 관리자 그리고 일반회원
		 * 이렇게 3종류가 존재하는데 이중 2개 '아이디 혹은 비밀번호 찾기'서비스 대상자에서 제외합니다.
		 * 손님은 내부용 처리를 위해 존재할뿐 회원이 아니기때문에 제외하며 관리자는 보안상 허용해서는 안되기때문에 제외합니다.
		 */
		
		ServerDBUtil.execute(dbcpName, (conn, create) -> {
			Record3<String, String, String> memberRecord = create
					.select(SB_MEMBER_TB.USER_ID, SB_MEMBER_TB.NICKNAME, SB_MEMBER_TB.EMAIL).from(SB_MEMBER_TB)
					.where(SB_MEMBER_TB.EMAIL.eq(passwordSearchReadyReq.getEmail()))
					.and(SB_MEMBER_TB.ROLE.eq(MemberRoleType.MEMBER.getValue())).forUpdate().fetchOne();

			if (null == memberRecord) {
				try {
					conn.rollback();
				} catch (Exception e1) {
					log.warn("fail to rollback");
				}

				String errorMessage = "입력한 이메일에 해당하는 일반 회원이 없습니다";

				throw new ServerServiceException(errorMessage);
			}

			String userID = memberRecord.get(SB_MEMBER_TB.USER_ID);
			String nickname = memberRecord.get(SB_MEMBER_TB.NICKNAME);
			String email = memberRecord.get(SB_MEMBER_TB.EMAIL);

			Record2<UByte, UByte> passwordSearchRequestRecord = create
					.select(SB_PWD_SERARCH_REQ_TB.FAIL_CNT, SB_PWD_SERARCH_REQ_TB.RETRY_CNT).from(SB_PWD_SERARCH_REQ_TB)
					.where(SB_PWD_SERARCH_REQ_TB.USER_ID.eq(userID)).fetchOne();

			if (null == passwordSearchRequestRecord) {
				byte[] secretAuthenticationValueBytes = new byte[8];
				SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
				random.nextBytes(secretAuthenticationValueBytes);

				String secretAuthenticationValue = CommonStaticUtil.Base64Encoder
						.encodeToString(secretAuthenticationValueBytes);

				Timestamp registeredDate = new java.sql.Timestamp(System.currentTimeMillis());

				create.insertInto(SB_PWD_SERARCH_REQ_TB).set(SB_PWD_SERARCH_REQ_TB.USER_ID, userID)
						.set(SB_PWD_SERARCH_REQ_TB.FAIL_CNT, UByte.valueOf(0))
						.set(SB_PWD_SERARCH_REQ_TB.RETRY_CNT, UByte.valueOf(1))
						.set(SB_PWD_SERARCH_REQ_TB.LAST_SECRET_AUTH_VALUE, secretAuthenticationValue)
						.set(SB_PWD_SERARCH_REQ_TB.LAST_REQ_DT, registeredDate).execute();

				EmilUtil.sendPasswordSearchEmail(userID, nickname, email, secretAuthenticationValue);

				conn.commit();

				ServerDBUtil.insertSiteLog(conn, create, log, userID, "아이디 혹은 비밀번호 찾기서비스 신청", registeredDate,
						passwordSearchReadyReq.getIp());

				conn.commit();
			} else {
				UByte failCount = passwordSearchRequestRecord.get(SB_PWD_SERARCH_REQ_TB.FAIL_CNT);
				UByte retryCount = passwordSearchRequestRecord.get(SB_PWD_SERARCH_REQ_TB.RETRY_CNT);

				if (ServerCommonStaticFinalVars.MAX_PASSWORD_WRONG_COUNT_OF_PASSWORD_SEARCH_SERVICE == failCount
						.shortValue()) {
					try {
						conn.rollback();
					} catch (Exception e1) {
						log.warn("fail to rollback");
					}

					String errorMessage = "아이디 혹은 비밀번호 찾기로 비밀번호 틀린 횟수가  최대 횟수 5회에 도달하여 더 이상 진행할 수 없습니다, 관리자에게 문의하여 주시기 바랍니다";

					throw new ServerServiceException(errorMessage);
				}

				if (ServerCommonStaticFinalVars.MAX_RETRY_COUNT_OF_PASSWORD_SEARCH_SERVICE == retryCount.shortValue()) {
					try {
						conn.rollback();
					} catch (Exception e1) {
						log.warn("fail to rollback");
					}

					String errorMessage = "아이디 혹은 비밀번호 찾기 재시도 횟수가 최대 횟수 5회에 도달하여 더 이상 진행할 수 없습니다, 관리자에게 문의하여 주시기 바랍니다";

					throw new ServerServiceException(errorMessage);
				}

				byte[] newSecretAuthenticationValueBytes = new byte[8];
				SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
				random.nextBytes(newSecretAuthenticationValueBytes);

				String secretAuthenticationValue = CommonStaticUtil.Base64Encoder
						.encodeToString(newSecretAuthenticationValueBytes);
				Timestamp registeredDate = new java.sql.Timestamp(System.currentTimeMillis());

				create.update(SB_PWD_SERARCH_REQ_TB)
						.set(SB_PWD_SERARCH_REQ_TB.RETRY_CNT, SB_PWD_SERARCH_REQ_TB.RETRY_CNT.add(1))
						.set(SB_PWD_SERARCH_REQ_TB.LAST_SECRET_AUTH_VALUE, secretAuthenticationValue)
						.set(SB_PWD_SERARCH_REQ_TB.LAST_REQ_DT, registeredDate)
						.where(SB_PWD_SERARCH_REQ_TB.USER_ID.eq(userID)).execute();

				EmilUtil.sendPasswordSearchEmail(userID, nickname, email, secretAuthenticationValue);

				conn.commit();

				String logText = new StringBuilder().append("아이디 혹은 비밀번호 찾기서비스 신청[재시도횟수=")
						.append(retryCount.shortValue() + 1).append("]").toString();

				ServerDBUtil.insertSiteLog(conn, create, log, userID, logText, registeredDate,
						passwordSearchReadyReq.getIp());

				conn.commit();
			}
		
			String successMessage = new StringBuilder().append("아이디 ").append(userID).append(" 이신 ").append(nickname)
					.append(" 님 비밀번호 찾기 준비 단계 처리가 완료되었습니다").toString();
			messageResultRes.setTaskMessageID(passwordSearchReadyReq.getMessageID());
			messageResultRes.setIsSuccess(true);
			messageResultRes.setResultMessage(successMessage);
		});
		
		return messageResultRes;
	}



>>> 변경전
public MessageResultRes doWork(String dbcpName, PasswordSearchReadyReq passwordSearchReadyReq) throws Exception {
		// FIXME!
		log.info(passwordSearchReadyReq.toString());

		try {
			ValueChecker.checkValidEmail(passwordSearchReadyReq.getEmail());
		} catch (RuntimeException e) {
			String errorMessage = e.getMessage();
			throw new ServerServiceException(errorMessage);
		}

		try {
			ValueChecker.checkValidIP(passwordSearchReadyReq.getIp());
		} catch (RuntimeException e) {
			String errorMessage = e.getMessage();
			throw new ServerServiceException(errorMessage);
		}

		String successMessage = null;
		
		

		DataSource dataSource = DBCPManager.getInstance().getBasicDataSource(dbcpName);

		Connection conn = null;
		try {
			conn = dataSource.getConnection();
			conn.setAutoCommit(false);

			DSLContext create = DSL.using(conn, SQLDialect.MYSQL, ServerDBUtil.getDBCPSettings(dbcpName));

			/**
			 * '아이디 혹은 비밀번호 찾기' 서비스는 오직 일반 회원에 한에서만 서비스가 제공됩니다. 회원 테이블에는 손님, 관리자 그리고 일반회원
			 * 이렇게 3종류가 존재하는데, 손님은 내부용 처리를 위해 존재할뿐 회원이 아니기때문에 손님은 '아이디 혹은 비밀번호 찾기'서비스 대상자에서
			 * 제외되며 관리자는 보안상 허용해서는 안됩니다.
			 */
			Record3<String, String, String> memberRecord = create
					.select(SB_MEMBER_TB.USER_ID, SB_MEMBER_TB.NICKNAME, SB_MEMBER_TB.EMAIL).from(SB_MEMBER_TB)
					.where(SB_MEMBER_TB.EMAIL.eq(passwordSearchReadyReq.getEmail()))
					.and(SB_MEMBER_TB.ROLE.eq(MemberRoleType.MEMBER.getValue())).forUpdate().fetchOne();

			if (null == memberRecord) {
				try {
					conn.rollback();
				} catch (Exception e1) {
					log.warn("fail to rollback");
				}

				String errorMessage = "입력한 이메일에 해당하는 일반 회원이 없습니다";

				throw new ServerServiceException(errorMessage);
			}

			String userID = memberRecord.get(SB_MEMBER_TB.USER_ID);
			String nickname = memberRecord.get(SB_MEMBER_TB.NICKNAME);
			String email = memberRecord.get(SB_MEMBER_TB.EMAIL);

			

			Record2<UByte, UByte> passwordSearchRequestRecord = create
					.select(SB_PWD_SERARCH_REQ_TB.FAIL_CNT, SB_PWD_SERARCH_REQ_TB.RETRY_CNT).from(SB_PWD_SERARCH_REQ_TB)
					.where(SB_PWD_SERARCH_REQ_TB.USER_ID.eq(userID)).fetchOne();

			if (null == passwordSearchRequestRecord) {
				byte[] secretAuthenticationValueBytes = new byte[8];
				SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
				random.nextBytes(secretAuthenticationValueBytes);

				String secretAuthenticationValue = CommonStaticUtil.Base64Encoder
						.encodeToString(secretAuthenticationValueBytes);

				Timestamp registeredDate = new java.sql.Timestamp(System.currentTimeMillis());

				create.insertInto(SB_PWD_SERARCH_REQ_TB).set(SB_PWD_SERARCH_REQ_TB.USER_ID, userID)
						.set(SB_PWD_SERARCH_REQ_TB.FAIL_CNT, UByte.valueOf(0))
						.set(SB_PWD_SERARCH_REQ_TB.RETRY_CNT, UByte.valueOf(1))
						.set(SB_PWD_SERARCH_REQ_TB.LAST_SECRET_AUTH_VALUE, secretAuthenticationValue)
						.set(SB_PWD_SERARCH_REQ_TB.LAST_REQ_DT, registeredDate).execute();

				EmilUtil.sendPasswordSearchEmail(userID, nickname, email, secretAuthenticationValue);

				conn.commit();

				ServerDBUtil.insertSiteLog(conn, create, log, userID, "아이디 혹은 비밀번호 찾기서비스 신청", registeredDate,
						passwordSearchReadyReq.getIp());

				conn.commit();
			} else {
				UByte failCount = passwordSearchRequestRecord.get(SB_PWD_SERARCH_REQ_TB.FAIL_CNT);
				UByte retryCount = passwordSearchRequestRecord.get(SB_PWD_SERARCH_REQ_TB.RETRY_CNT);

				if (ServerCommonStaticFinalVars.MAX_PASSWORD_WRONG_COUNT_OF_PASSWORD_SEARCH_SERVICE == failCount
						.shortValue()) {
					try {
						conn.rollback();
					} catch (Exception e1) {
						log.warn("fail to rollback");
					}

					String errorMessage = "아이디 혹은 비밀번호 찾기로 비밀번호 틀린 횟수가  최대 횟수 5회에 도달하여 더 이상 진행할 수 없습니다, 관리자에게 문의하여 주시기 바랍니다";

					throw new ServerServiceException(errorMessage);
				}

				if (ServerCommonStaticFinalVars.MAX_RETRY_COUNT_OF_PASSWORD_SEARCH_SERVICE == retryCount.shortValue()) {
					try {
						conn.rollback();
					} catch (Exception e1) {
						log.warn("fail to rollback");
					}

					String errorMessage = "아이디 혹은 비밀번호 찾기 재시도 횟수가 최대 횟수 5회에 도달하여 더 이상 진행할 수 없습니다, 관리자에게 문의하여 주시기 바랍니다";

					throw new ServerServiceException(errorMessage);
				}

				byte[] newSecretAuthenticationValueBytes = new byte[8];
				SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
				random.nextBytes(newSecretAuthenticationValueBytes);

				String secretAuthenticationValue = CommonStaticUtil.Base64Encoder
						.encodeToString(newSecretAuthenticationValueBytes);
				Timestamp registeredDate = new java.sql.Timestamp(System.currentTimeMillis());

				create.update(SB_PWD_SERARCH_REQ_TB)
						.set(SB_PWD_SERARCH_REQ_TB.RETRY_CNT, SB_PWD_SERARCH_REQ_TB.RETRY_CNT.add(1))
						.set(SB_PWD_SERARCH_REQ_TB.LAST_SECRET_AUTH_VALUE, secretAuthenticationValue)
						.set(SB_PWD_SERARCH_REQ_TB.LAST_REQ_DT, registeredDate)
						.where(SB_PWD_SERARCH_REQ_TB.USER_ID.eq(userID)).execute();

				EmilUtil.sendPasswordSearchEmail(userID, nickname, email, secretAuthenticationValue);

				conn.commit();

				String logText = new StringBuilder().append("아이디 혹은 비밀번호 찾기서비스 신청[재시도횟수=")
						.append(retryCount.shortValue() + 1).append("]").toString();

				ServerDBUtil.insertSiteLog(conn, create, log, userID, logText, registeredDate,
						passwordSearchReadyReq.getIp());

				conn.commit();
				
				successMessage = new StringBuilder().append("아이디 ").append(userID).append(" 이신 ").append(nickname)
						.append(" 님 비밀번호 찾기 준비 단계 처리가 완료되었습니다").toString();
			}

		} catch (ServerServiceException e) {
			throw e;
		} catch (Exception e) {
			if (null != conn) {
				try {
					conn.rollback();
				} catch (Exception e1) {
					log.warn("fail to rollback");
				}
			}

			throw e;
		} finally {
			if (null != conn) {
				try {
					conn.close();
				} catch (Exception e) {
					log.warn("fail to close the db connection", e);
				}
			}
		}
		
		MessageResultRes messageResultRes = new MessageResultRes();
		messageResultRes.setTaskMessageID(passwordSearchReadyReq.getMessageID());
		messageResultRes.setIsSuccess(true);
		messageResultRes.setResultMessage(successMessage);

		return messageResultRes;
	}


---------- 아래는 관련 인퍼페이스와 static 메소드입니다.


public interface DBExecutorIF {
	public void execute(Connection conn, DSLContext create) throws Exception;
}
public static void execute(String dbcpName, 
			DBExecutorIF dbExecutor) throws Exception {
		DataSource dataSource = DBCPManager.getInstance().getBasicDataSource(dbcpName);

		Connection conn = null;
		try {
			conn = dataSource.getConnection();
			conn.setAutoCommit(false);
			
			DSLContext create = DSL.using(conn, SQLDialect.MYSQL, ServerDBUtil.getDBCPSettings(dbcpName));
			
			dbExecutor.execute(conn, create); 
		} catch (ServerServiceException e) {
			throw e;
		} catch (Exception e) {
			if (null != conn) {
				try {
					conn.rollback();
				} catch (Exception e1) {
					InternalLogger log = InternalLoggerFactory.getInstance(ServerDBUtil.class);
					log.warn("fail to rollback");
				}
			}
			
			throw e;
		} finally {
			if (null != conn) {
				try {
					conn.close();
				} catch (Exception e) {
					InternalLogger log = InternalLoggerFactory.getInstance(ServerDBUtil.class);
					log.warn("fail to close the db connection", e);
				}
			}
		}
	}	



0
0
  • 댓글 9

  • 서버캣
    95
    2019-04-16 22:17:44

    디자인 패턴이라면 어떤 디자인 패턴을 쓰신건가요?

    0
  • 앙앙이
    3k
    2019-04-16 22:46:53 작성 2019-04-16 22:48:29 수정됨

    // 서버캣

    디자인 패턴이 아니고 디자인패턴 면에서 바꾸었다고 표현했는데요.

    람다 도입에 대한 평가라고 말했어야 하지 않았나 합니다.


    제목과 내용을 혼동 없도록 바꾸겠습니다.

    0
  • 박가사탕
    528
    2019-04-16 22:52:37

    람다쓰면 저런 느낌됩니다..

    그냥 받아들이세요 ㅎ


    주석을 좀 이쁘게 달아보세요~

    0
  • zepinos
    18k
    2019-04-17 09:47:42

    람다 이전에 조금 코드가 가독성이 떨어지는 것 같습니다.


    예를 들면,


            try {
                ValueChecker.checkValidEmail(passwordSearchReadyReq.getEmail());
            } catch (RuntimeException e) {
                String errorMessage = e.getMessage();
                throw new ServerServiceException(errorMessage);
            }

            try {
                ValueChecker.checkValidIP(passwordSearchReadyReq.getIp());
            } catch (RuntimeException e) {
                String errorMessage = e.getMessage();
                throw new ServerServiceException(errorMessage);
            }


    이런 코드는 좀 불필요한 형태 같습니다. 어짜피 method 에서 throw 을 시키고 있기 때문에 외부에서 합당한 처리를 해도 되고, 아니면 아래와 같이 하나로 합쳐도 문제 없습니다. 게다가 message 만 담아서 처리하기 때문에 stacktrace 을 제대로 파악하기 힘듭니다. ServerServiceException 이라는 것도 사용자 정의 Exception 이 아닌가 의심되는데, 이런 식으로 많이 사용하면 GC 때문에 성능이 떨어지는 단점도 있습니다. 최소한 log 로 catch 한 exception 에 대한 기록은 하는게 좋을 것 같습니다.

    그래서 이정도가 현재 코드와 동일한 상태에서 코드 가독성을 높일 것으로 생각됩니다.


            try {
                ValueChecker.checkValidEmail(passwordSearchReadyReq.getEmail());
                ValueChecker.checkValidIP(passwordSearchReadyReq.getIp());
            } catch (RuntimeException e) {
                e.printStackTrace();
                throw new ServerServiceException(e);
            }


    그리고 JOOQ 을 쓰고 있는 것으로 보이는데, Spring 에서는 보통 DB 연결 설정을 Config 에서 처리한 뒤 org.jooq.DSLContext 을 주입받아 사용하는 형태가 일반적입니다. Spring Boot 라면 properties 에서 설정하면 거의 건드릴 것도 없고, 특수한 설정이 필요하다면 Java Config 을 이용하면 훨씬 더 깔끔하죠.

    그래서 lambda 을 쓴 곳도 사실 필요없는 부분입니다. 행여나 Spring 같은 프래임워크를 쓰지 않는다면 보통 Singleton 으로 구현해서 Connection Pool 을 사용하지, 저렇게 요청 때마다 Connection 을 연결하지 않습니다.


    JOOQ 부분도 Record 객체를 이용해서 결과를 받아오는데, 이렇게 구현하면 ResultSet 으로 받아서 일일이 getString() 같은 걸 이용해서 컬럼 별로 일일이 객체에 넣는 작업을 하는 것과 다를 바가 없습니다. Object 에 Reflect 해주는 기능이 있기 때문에 그런 걸 이용한다면 훨씬 더 코드가 간결해질 것입니다.


    마지막으로, Transaction 역시 @Annotation 형태를 굳이 안쓸 필요가 없는 코드로 보여집니다. 이 전에 DSLContext 주입이라면 Exception 이 throw 될 때 rollback 이 되기 때문에 코드양이 엄청 줄어들 것이고, Spring 같은 프래임워크를 쓰지 않더라도 AOP 같은 것을 이용해서 method 앞뒤에서 begin~commit/rollback 처리 해도 될 것입니다.


    코드 리뷰를 더 구체적으로 하기에는 서로간의 신뢰가 쌓여있지 않기 때문에 딱 눈에 보이는 것만 말씀드렸습니다. 이런 코드 자체의 간결함 보다도 비즈니스 로직에 더 신경을 쓰고 계실 것이기 때문에...참고만 해주셨으면 좋겠습니다.

    1
  • 앙앙이
    3k
    2019-04-17 10:52:51

    // zepinos

    무엇보다 저는 스프링에 ㅅ자도 모릅니다.

    그리고 자체 프레임워크 개발중입니다.

    농담이지만 WEB-INF 밑에 jsp 떡칠한 전자정부랑 맞짱 뜨고 싶습니다.


    습관적으로 copy & paste 로 작업을 하게 되니 아래처럼 작업을 하게 되었네요.

    평소에도 가독성에 대해서 저두 난잡하다고 생각하고 있었기에 공감을 합니다.


    try {
                ValueChecker.checkValidEmail(passwordSearchReadyReq.getEmail());
            } catch (RuntimeException e) {
                String errorMessage = e.getMessage();
                throw new ServerServiceException(errorMessage);
            }
    
            try {
                ValueChecker.checkValidIP(passwordSearchReadyReq.getIp());
            } catch (RuntimeException e) {
                String errorMessage = e.getMessage();
                throw new ServerServiceException(errorMessage);
            }



    finally 절 conn 반환하는 구분을 공통으로 빼고 람다를 도입한 이유는 코드 반복뿐 아니라

    자체 프레임워크를 만들다 보니 개발자가 실수로 conn 을 닫지 않는 실수를 방지해야 하고

    지금 구현에서는 빠졌지만 conn 을 wrap 해서 commit, rollback 각각 몇번씩 했는지 추적하는 로그를 남겨 commit 생략등 개발자의 실수를 개발자가 빠르게 발견할 수 있도록 도와주기 위함입니다.


    하여 람다 도입은 지금까지의 작업을 전면적으로 다 수정해야 하는 큰 작업인지라

    혹여 살피지 못한것이 있는것은 아닌지 염려가 있습니다.


    바쁘신중에 그래도 조언을 해 주셔서 도움이 되었습니다. 수다를 풀었더니 조금 나아졌네요.

    좋은일 하셨으니 복받으실거에요 ^^

    좋안하루 보내세요.

    0
  • 박가사탕
    528
    2019-04-17 11:13:09

    전면적으로 도입하지 마세요. ㅎㅎ

    항상 새로운 건 곧 낡은게 됩니다.

    조금씩.. 꼭 필요한 부분을 수정해 가세요~

    0
  • 박가사탕
    528
    2019-04-17 11:14:16

    전체의 코드규칙을 딱 맞춰야 한다는 발상 자체가

    틀렸습니다. 비경제적이고 유연하지 못합니다.

    0
  • 앙앙이
    3k
    2019-04-17 13:20:47

    // 박가사탕

    개발자는 사람이고 실수를 하는 존재라 실수를 줄여줄 수 있는 방법론을 제시해 주고

    실수를 빠르게 발견할 수 있도록 하기 위해 도입하는 건데

    그걸 새롭다 낡다 따지십니까?


    conn 이라는 자원을 잘못 관리할 경우 어떤 일이 벌어지는지 알면서

    코드 규칙을 딱 맞추어야 한다는 발상이 틀렸다 하시나요?


     "100% 배끼고 변별력 제로" 가 아니된 예는 언제쯤 공개를 해 주실건가요.

    -------------

    https://okky.kr/article/565595?note=1660482


    앙앙이 
     3k
    2019-04-04 10:54:55 작성 ∙ 2019-04-04 10:55:34 수정됨

      "100% 배끼고 변별력 제로" 가 아니된 예를 가지고 계신분께서 먼저 이렇게 하는거다 라는것을 보여주세요. 뭘 그렇게 어렵게 하십니까?


    개발자끼리 걍 코드로 말하자구요.


    0
  • 박가사탕
    528
    2019-04-17 13:52:57

    남의 소스 보여달란 말을

    어찌 그리 당당히 말씀하시나요?

    공짜로요? ㅎㅎ

    0
  • 로그인을 하시면 댓글을 등록할 수 있습니다.